2.5 update

This commit is contained in:
2026-09-28 11:07:17 +09:00
parent abd2da2057
commit 7978d82196
976 changed files with 111965 additions and 313 deletions
@@ -14,7 +14,7 @@ namespace BumpCombat.Defense.Comparison
{
private Defense25DComparison world;
private CapsuleCollider sensor;
private readonly RaycastHit[] movementHits = new RaycastHit[64];
private RaycastHit[] movementHits = new RaycastHit[64];
private Vector2 knockbackVelocity;
private Vector2 knockbackStartPosition;
private float knockbackRemaining;
@@ -25,6 +25,7 @@ namespace BumpCombat.Defense.Comparison
private bool guardHasBlocked;
private bool knockbackCompletionPending;
private float enemyMaximumHealthOverride;
private float enemyMoveSpeedOverride;
private Func<Vector2, Vector2> closestPoint;
private Vector2 facing = Vector2.right;
private Vector2 unflippedBodyCenterOffset;
@@ -72,7 +73,8 @@ namespace BumpCombat.Defense.Comparison
public override float MoveSpeed => Evaluate(CharacterStat.MoveSpeed, Kind switch
{
ComparisonActorKind.Player => GameplayConstants.Current.Player.BaseMoveSpeed,
ComparisonActorKind.Enemy => world.Settings.Enemy.MoveSpeed,
ComparisonActorKind.Enemy => enemyMoveSpeedOverride > 0f
? enemyMoveSpeedOverride : world.Settings.Enemy.MoveSpeed,
_ => GameplayConstants.Current.Defense.SummonUnitMoveSpeed,
});
@@ -172,6 +174,37 @@ namespace BumpCombat.Defense.Comparison
enemyMaximumHealthOverride = Mathf.Max(0f, maximumHealth);
}
public void SetEnemyDefinition(EnemyDefinition definition)
{
if (Kind != ComparisonActorKind.Enemy || definition == null) return;
enemyMaximumHealthOverride = Mathf.Max(0f, definition.MaxHealth);
enemyMoveSpeedOverride = Mathf.Max(0f, definition.MoveSpeed);
}
public bool SetEnemyBodyRadius(float radius)
{
if (Kind != ComparisonActorKind.Enemy || world == null || BodyCollider == null || sensor == null ||
radius <= 0f || float.IsNaN(radius) || float.IsInfinity(radius))
return false;
Defense25DComparisonSettings settings = world.Settings;
float height = Mathf.Max(settings.ActorHeight, 2f * radius);
Camera camera = world.View != null ? world.View.Camera : null;
Vector2 centerPixels = new(0f, settings.EnemyBodyCenterCorrectionPixels);
Vector2 screenCenter = centerPixels * (2f * settings.CameraSize / Defense25DView.Height);
Vector3 cameraUp = BodyProjectionRotation(camera, settings.CameraPitch) * Vector3.up;
screenCenter.y += cameraUp.y * height * .5f;
Radius = radius;
unflippedBodyCenterOffset = ScreenCenterOffset(screenCenter, height * .5f, camera, settings.CameraPitch);
BodyCollider.radius = radius;
BodyCollider.height = height;
sensor.radius = radius + settings.ContactSensorMargin;
sensor.height = height + 2f * settings.ContactSensorMargin;
ApplyColliderCenters();
return true;
}
public void SetSummonVariantScale(float scale, float coreDiameterPixels, Vector2 bodyCenterFromFootPixels)
{
if (Kind != ComparisonActorKind.Summon) return;
@@ -238,6 +271,8 @@ namespace BumpCombat.Defense.Comparison
? GetComponent<Defense25DEnemy>() : null;
float incoming = CalculateIncomingDamage(rawDamage);
if (enemy != null) incoming = enemy.ApplyDamageTakenEffects(incoming, ordinaryBump);
if (enemy?.BossCombat != null)
incoming = enemy.BossCombat.FilterIncomingDamage(CurrentHealth, incoming);
float applied = DamageCalculator.FinalizeDamage(incoming);
if (applied <= 0f) return false;
if (Kind == ComparisonActorKind.Player && IsGuarding)
@@ -271,6 +306,8 @@ namespace BumpCombat.Defense.Comparison
CurrentHealth = Mathf.Max(0f, CurrentHealth - applied);
actualDamage = healthBefore - CurrentHealth;
DamageEvents++;
if (actualDamage > 0f)
enemy?.BossCombat?.NotifyDamageApplied(healthBefore, actualDamage);
if (Kind == ComparisonActorKind.Player)
{
invulnerableUntil = world.GameTime + GameplayConstants.Current.Player.InvulnerabilityDuration;
@@ -369,11 +406,17 @@ namespace BumpCombat.Defense.Comparison
float cylinder = Mathf.Max(0f, BodyCollider.height * .5f - Radius);
Vector3 center = OnGround(next + Offset + BodyCenterOffset,
world.Settings.GroundY + BodyCollider.center.y);
int count = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
center - Vector3.up * cylinder, Radius, direction, movementHits,
length + world.Settings.MovementSkin,
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
QueryTriggerInteraction.Ignore);
int count;
while (true)
{
count = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
center - Vector3.up * cylinder, Radius, direction, movementHits,
length + world.Settings.MovementSkin,
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
QueryTriggerInteraction.Ignore);
if (count < movementHits.Length) break;
Array.Resize(ref movementHits, movementHits.Length * 2);
}
float allowed = length;
Vector2 normal = Vector2.zero;
for (int i = 0; i < count; i++)
@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public enum Defense25DArtifactDropStatus
{
Scheduled,
Available,
Choosing,
Claimed,
}
public sealed class Defense25DArtifactDropState
{
private IReadOnlyList<ActiveArtifactDefinition> candidates = EmptyCandidates;
private static readonly IReadOnlyList<ActiveArtifactDefinition> EmptyCandidates =
Array.AsReadOnly(Array.Empty<ActiveArtifactDefinition>());
internal Defense25DArtifactDropState(int runGeneration, int dropId, Vector2 position)
{
RunGeneration = runGeneration;
DropId = dropId;
Position = position;
AvailableAt = float.PositiveInfinity;
Status = Defense25DArtifactDropStatus.Scheduled;
}
public int RunGeneration { get; }
public int DropId { get; }
public Vector2 Position { get; }
public float AvailableAt { get; private set; }
public Defense25DArtifactDropStatus Status { get; private set; }
public IReadOnlyList<ActiveArtifactDefinition> Candidates => candidates;
internal void SetAvailableAt(float availableAt) => AvailableAt = availableAt;
internal void SetStatus(Defense25DArtifactDropStatus status) => Status = status;
internal void SetCandidates(IEnumerable<ActiveArtifactDefinition> definitions) =>
candidates = definitions == null ? EmptyCandidates : Array.AsReadOnly(definitions.ToArray());
}
public sealed class Defense25DArtifactDropChoice
{
internal Defense25DArtifactDropChoice(int runGeneration, int dropId, int choiceToken,
IReadOnlyList<ActiveArtifactDefinition> candidates)
{
RunGeneration = runGeneration;
DropId = dropId;
ChoiceToken = choiceToken;
Candidates = candidates;
}
public int RunGeneration { get; }
public int DropId { get; }
public int ChoiceToken { get; }
public IReadOnlyList<ActiveArtifactDefinition> Candidates { get; }
}
/// <summary>Owns the three long-run active-artifact drops and their one-shot choices.</summary>
public sealed class Defense25DArtifactDrops
{
private const uint PositionRandomStream = 0x41524450u;
private const uint ChoiceRandomStream = 0x41524348u;
private static readonly IReadOnlyList<ActiveArtifactDefinition> NoCandidates =
Array.AsReadOnly(Array.Empty<ActiveArtifactDefinition>());
private readonly List<Defense25DArtifactDropState> dropStates = new(3);
private readonly ReadOnlyCollection<Defense25DArtifactDropState> readOnlyDropStates;
private readonly Collider[] colliderScratch = new Collider[32];
private Defense25DComparison world;
private Defense25DRunLayout layout;
private Defense25DArtifactDropConstants settings;
private int dropSeed;
private int generation;
private int activeChoiceDropId = -1;
private int choiceToken;
private float firstPortalOpenedAt = float.NaN;
private float lastStepTime = float.NegativeInfinity;
public Defense25DArtifactDrops()
{
readOnlyDropStates = dropStates.AsReadOnly();
}
public event Action<Defense25DArtifactDropState> DropSpawned;
public event Action<Defense25DArtifactDropChoice> ChoiceOpened;
public event Action<Defense25DArtifactDropState> DropClaimed;
public event Action<string, float, float> ReliefWindowRequested;
public IReadOnlyList<Defense25DArtifactDropState> DropStates => readOnlyDropStates;
public IReadOnlyList<ActiveArtifactDefinition> CurrentCandidates => activeChoiceDropId < 0
? NoCandidates : dropStates[activeChoiceDropId].Candidates;
public int CurrentChoiceToken => activeChoiceDropId < 0 ? 0 : choiceToken;
public int RunGeneration => generation;
public void Bind(Defense25DComparison world, Defense25DRunLayout layout,
Defense25DArtifactDropConstants settings, int dropSeed)
{
if (world == null) throw new ArgumentNullException(nameof(world));
if (layout == null) throw new ArgumentNullException(nameof(layout));
if (settings == null) throw new ArgumentNullException(nameof(settings));
if (!settings.Validate(out string error))
throw new ArgumentException(error, nameof(settings));
this.world = world;
this.layout = layout;
this.settings = settings;
this.dropSeed = dropSeed;
Reset(0);
}
public void NotifyPortalOpened(int portalId, float combatElapsed)
{
if (world == null || layout == null || portalId < 0 || portalId >= layout.PortalPositions.Count ||
!IsFinite(combatElapsed) || combatElapsed < 0f ||
!float.IsNaN(firstPortalOpenedAt))
return;
firstPortalOpenedAt = combatElapsed;
dropStates[0].SetAvailableAt(combatElapsed + settings.FirstDropDelaySeconds);
}
public void Step(float combatElapsed)
{
if (world == null || !IsFinite(combatElapsed) || combatElapsed < 0f || combatElapsed < lastStepTime)
return;
lastStepTime = combatElapsed;
List<Defense25DArtifactDropState> dueDrops = dropStates
.Where(state => state.Status == Defense25DArtifactDropStatus.Scheduled &&
combatElapsed >= state.AvailableAt)
.OrderBy(state => state.AvailableAt)
.ThenBy(state => state.DropId)
.ToList();
foreach (Defense25DArtifactDropState state in dueDrops)
{
state.SetStatus(Defense25DArtifactDropStatus.Available);
ReliefWindowRequested?.Invoke(ReliefToken(generation, state.DropId), state.AvailableAt,
settings.ReliefSeconds[state.DropId]);
DropSpawned?.Invoke(state);
}
}
public bool TryInteract(int dropId)
{
if (world == null || activeChoiceDropId >= 0 || dropId < 0 || dropId >= dropStates.Count ||
!CanOpenChoice())
return false;
Defense25DArtifactDropState state = dropStates[dropId];
if (state.Status != Defense25DArtifactDropStatus.Available || world.Player == null ||
Vector2.Distance(world.Player.Position, state.Position) > settings.InteractionRadius)
return false;
if (state.Candidates.Count == 0)
{
IReadOnlyList<ActiveArtifactDefinition> candidates = BuildCandidates(state.DropId);
if (candidates.Count != 2)
return false;
state.SetCandidates(candidates);
}
state.SetStatus(Defense25DArtifactDropStatus.Choosing);
activeChoiceDropId = dropId;
AdvanceChoiceToken();
ChoiceOpened?.Invoke(new Defense25DArtifactDropChoice(generation, dropId, choiceToken,
state.Candidates));
return true;
}
public bool TryChoose(int candidateIndex) =>
TryChoose(candidateIndex, generation, choiceToken);
public bool TryChoose(int candidateIndex, int expectedGeneration, int expectedChoiceToken)
{
if (world == null || activeChoiceDropId < 0 || expectedGeneration != generation ||
expectedChoiceToken != choiceToken || candidateIndex < 0 ||
candidateIndex >= CurrentCandidates.Count || world.Loadout == null ||
!world.IsRunning || !world.IsLongRun || !IsCombatPhase(world.RunPhase) ||
world.Player == null || !world.Player.IsAlive || world.RunCleared || world.RunFailed)
return false;
Defense25DArtifactDropState state = dropStates[activeChoiceDropId];
ActiveArtifactDefinition selected = CurrentCandidates[candidateIndex];
if (state.RunGeneration != generation || state.Status != Defense25DArtifactDropStatus.Choosing ||
selected == null || world.Loadout.OwnedArtifacts.Any(item =>
item == null || item.ArtifactColor == selected.ArtifactColor || item.Effect == selected.Effect) ||
!world.Loadout.TryAddArtifact(selected))
return false;
if (world.Loadout.OwnedArtifactCount == 1)
world.Loadout.Select(0);
state.SetStatus(Defense25DArtifactDropStatus.Claimed);
activeChoiceDropId = -1;
DropClaimed?.Invoke(state);
return true;
}
public void Reset(int generation)
{
this.generation = generation;
activeChoiceDropId = -1;
AdvanceChoiceToken();
firstPortalOpenedAt = float.NaN;
lastStepTime = float.NegativeInfinity;
dropStates.Clear();
if (world == null || layout == null || settings == null)
return;
Vector2[] positions = CreateDropPositions();
float[] scheduledTimes =
{
float.PositiveInfinity,
settings.LaterDropTimesSeconds[0],
settings.LaterDropTimesSeconds[1],
};
for (int i = 0; i < positions.Length; i++)
{
Defense25DArtifactDropState state = new(generation, i, positions[i]);
state.SetAvailableAt(scheduledTimes[i]);
dropStates.Add(state);
}
}
private bool CanOpenChoice()
{
return world.IsRunning && world.IsLongRun && IsCombatPhase(world.RunPhase) && !world.Paused &&
!world.RunCleared && !world.RunFailed && !world.HasPendingFieldRelicReward &&
world.Player != null && world.Player.IsAlive;
}
private static bool IsCombatPhase(Defense25DRunPhase phase) =>
phase is Defense25DRunPhase.Running or Defense25DRunPhase.BossActive;
private IReadOnlyList<ActiveArtifactDefinition> BuildCandidates(int dropId)
{
Defense25DLoadout loadout = world.Loadout;
if (loadout == null) return NoCandidates;
List<ActiveArtifactDefinition> catalog = loadout.Catalog
.Where(definition => definition != null).OrderBy(definition => definition.Effect).ToList();
HashSet<ArtifactColor> ownedColors = new(loadout.OwnedArtifacts
.Where(definition => definition != null).Select(definition => definition.ArtifactColor));
Defense25DDeterministicRandom random = new(unchecked(dropSeed + dropId),
ChoiceRandomStream + (uint)dropId);
List<ActiveArtifactDefinition> candidates = new(2);
if (ownedColors.Count == 0)
{
List<ActiveArtifactDefinition> available = catalog.ToList();
if (available.Count < 2) return NoCandidates;
int first = random.NextInt(available.Count);
candidates.Add(available[first]);
available.RemoveAt(first);
candidates.Add(available[random.NextInt(available.Count)]);
}
else
{
List<ArtifactColor> missingColors = Enum.GetValues(typeof(ArtifactColor)).Cast<ArtifactColor>()
.Where(color => !ownedColors.Contains(color)).OrderBy(color => color).ToList();
if (missingColors.Count == 1)
{
candidates.AddRange(catalog.Where(item => item.ArtifactColor == missingColors[0] &&
!loadout.OwnsArtifact(item.Effect)).Take(2));
}
else if (missingColors.Count >= 2)
{
foreach (ArtifactColor color in missingColors.Take(2))
{
List<ActiveArtifactDefinition> sameColor = catalog.Where(item =>
item.ArtifactColor == color && !loadout.OwnsArtifact(item.Effect)).ToList();
if (sameColor.Count == 0) return NoCandidates;
candidates.Add(sameColor[random.NextInt(sameColor.Count)]);
}
}
}
return candidates.Count == 2 ? Array.AsReadOnly(candidates.ToArray()) : NoCandidates;
}
private Vector2[] CreateDropPositions()
{
Vector2[] positions = new Vector2[3];
for (int dropId = 0; dropId < positions.Length; dropId++)
{
Vector2 range = dropId == 0 ? settings.NearDistanceRange : settings.FarDistanceRange;
Defense25DDeterministicRandom random = new(unchecked(dropSeed + dropId),
PositionRandomStream + (uint)dropId);
bool found = false;
for (int attempt = 0; attempt < settings.PositionSearchAttempts; attempt++)
{
float angle = random.Range(0f, 360f) * Mathf.Deg2Rad;
float distance = random.Range(range.x, range.y);
Vector2 candidate = layout.HomePosition + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * distance;
if (!IsValidPosition(candidate, range)) continue;
if (dropId == 2 && Vector2.Distance(candidate, positions[1]) < settings.FarDropSeparation)
continue;
found = true;
positions[dropId] = candidate;
break;
}
if (!found)
throw new InvalidOperationException($"No valid position found for artifact drop {dropId} in battlefield '{layout.BattlefieldId}'.");
}
return positions;
}
private bool IsValidPosition(Vector2 candidate, Vector2 homeDistanceRange)
{
float clearance = settings.DropFootprintRadius;
if (!layout.Bounds.Contains(candidate) ||
candidate.x - layout.Bounds.xMin < clearance || layout.Bounds.xMax - candidate.x < clearance ||
candidate.y - layout.Bounds.yMin < clearance || layout.Bounds.yMax - candidate.y < clearance)
return false;
float homeDistance = Vector2.Distance(candidate, layout.HomePosition);
if (homeDistance < homeDistanceRange.x || homeDistance > homeDistanceRange.y ||
homeDistance < settings.StructureClearance)
return false;
if (DistanceToRoutes(candidate, layout.Routes) <= settings.RouteClearance ||
IsNearAny(candidate, layout.PadPositions, settings.StructureClearance) ||
IsNearAny(candidate, layout.PortalPositions, settings.StructureClearance))
return false;
if (world.TowerManager != null && world.TowerManager.Towers.Any(tower => tower != null &&
Vector2.Distance(candidate, tower.Position) <=
settings.StructureClearance + settings.DropFootprintRadius))
return false;
Vector3 queryCenter = new(candidate.x, .85f, candidate.y);
int count = Physics.OverlapSphereNonAlloc(queryCenter, clearance, colliderScratch,
~Defense25DComparisonSettings.ActorMask, QueryTriggerInteraction.Ignore);
if (count >= colliderScratch.Length) return false;
for (int i = 0; i < count; i++)
{
Collider collider = colliderScratch[i];
if (collider == null || !collider.enabled || !collider.gameObject.activeInHierarchy ||
collider.GetComponentInParent<Defense25DActor>() != null ||
collider.GetComponentInParent<Defense25DEnemy>() != null ||
collider.GetComponentInParent<Defense25DSummon>() != null)
continue;
return false;
}
return true;
}
private static bool IsNearAny(Vector2 position, IReadOnlyList<Vector2> points, float clearance)
{
foreach (Vector2 point in points)
if (Vector2.Distance(position, point) <= clearance)
return true;
return false;
}
private static float DistanceToRoutes(Vector2 position, IReadOnlyList<IReadOnlyList<Vector2>> routes)
{
float nearest = float.PositiveInfinity;
foreach (IReadOnlyList<Vector2> route in routes)
for (int i = 0; i < route.Count - 1; i++)
nearest = Mathf.Min(nearest, DistanceToSegment(position, route[i], route[i + 1]));
return nearest;
}
private static float DistanceToSegment(Vector2 point, Vector2 start, Vector2 end)
{
Vector2 segment = end - start;
float t = segment.sqrMagnitude <= Mathf.Epsilon ? 0f :
Mathf.Clamp01(Vector2.Dot(point - start, segment) / segment.sqrMagnitude);
return Vector2.Distance(point, start + segment * t);
}
private static string ReliefToken(int runGeneration, int dropId) =>
$"artifact-drop-{runGeneration}-{dropId}";
private void AdvanceChoiceToken()
{
choiceToken++;
if (choiceToken == 0) choiceToken++;
}
private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c98b1a8717df4d118a4c18e6f6cab21d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -31,13 +31,14 @@ namespace BumpCombat.Defense.Comparison
public int Tick;
public int HitCount;
public bool MovementBlocked;
public bool IsReplay;
public float DamageMultiplier = 1f;
public readonly HashSet<Defense25DActor> Hit = new();
public readonly List<ChainStrike> Strikes = new();
public readonly List<LineRenderer> Lines = new();
public readonly List<SpriteRenderer> Sprites = new();
public readonly List<Sprite[]> Frames = new();
public SpriteRenderer CycloneBack;
public SpriteRenderer CycloneFront;
public Defense25DCycloneVisual CycloneVisual;
public SpriteRenderer PulseSprite;
public SpriteRenderer EffectSprite;
public float VisualAge;
@@ -63,7 +64,6 @@ namespace BumpCombat.Defense.Comparison
private const float DirectionEpsilon = 0.0001f;
private const float PulseEllipseAspect = 13f / 32f;
private const float ThunderCrashScale = 1.05f;
private const float CycloneFrameSeconds = .05f;
private const float ThunderCrashVisualSeconds = .16f;
private static readonly float[] PhoenixFrameSeconds = { .02f, .03f, .03f, .03f, .02f, .02f };
private static readonly float[] ThunderFrameSeconds = { .02f, .03f, .03f, .03f, .02f, .03f };
@@ -229,6 +229,58 @@ namespace BumpCombat.Defense.Comparison
return true;
}
/// <summary>Schedules one independent Cyclone replay and returns its cancellation identity.</summary>
public bool TryStartCycloneReplay(Defense25DArtifactUse use, Vector3 origin, Vector3 direction,
float damageMultiplier, out int startedCastIdentity)
{
startedCastIdentity = 0;
if (world == null || loadout == null || use.Definition == null
|| use.Effect != ActiveArtifactEffect.Cyclone || !world.IsRunning || world.Paused
|| world.Player == null || !world.Player.IsAlive || world.Player.IsHurt
|| damageMultiplier <= 0f || use.Damage <= 0f)
return false;
Vector2 replayDirection = new(direction.x, direction.z);
if (replayDirection.sqrMagnitude < DirectionEpsilon) replayDirection = world.Player.Facing;
if (replayDirection.sqrMagnitude < DirectionEpsilon) replayDirection = Vector2.right;
replayDirection.Normalize();
var cast = new Cast
{
Use = use,
Identity = castIdentity == int.MaxValue ? 1 : castIdentity + 1,
Origin = new Vector2(origin.x, origin.z),
Direction = replayDirection,
IsReplay = true,
DamageMultiplier = damageMultiplier,
};
castIdentity = cast.Identity;
StartCyclone(cast);
casts.Add(cast);
startedCastIdentity = cast.Identity;
return true;
}
/// <summary>Cancels only the cast with this executor-local identity.</summary>
public bool TryCancelCast(int identity)
{
for (int i = casts.Count - 1; i >= 0; i--)
{
Cast cast = casts[i];
if (cast.Identity != identity) continue;
if (ReferenceEquals(cyclone, cast)) cyclone = null;
if (cast.Use.Effect == ActiveArtifactEffect.Dash) isDashing = false;
if (cast.Use.Effect == ActiveArtifactEffect.ThunderCrash
&& cast.Elapsed < Mathf.Max(cast.Use.Duration, Time.fixedDeltaTime))
locksArtifactInput = false;
DestroyCast(cast);
casts.RemoveAt(i);
locksArtifactInput = cyclone != null || isDashing;
return true;
}
return false;
}
private bool CanBegin() => CanExecuteNow() && !loadout.IsCharging
&& loadout.CurrentArtifact != null
&& loadout.Gauge >= loadout.CurrentArtifact.NormalGaugeCost;
@@ -279,8 +331,7 @@ namespace BumpCombat.Defense.Comparison
float next = Mathf.Min(duration, cast.Elapsed);
Vector2 target = cast.Origin + cast.Direction * cast.Use.Range * Mathf.Clamp01(next / duration);
Vector2 before = world.Player.Position;
world.Player.Move(target - before, false);
Vector2 moved = world.Player.Position - before;
Vector2 moved = world.Player.Move(target - before, false);
if (moved.sqrMagnitude > 0f)
{
CreateDashAfterimage(cast, before);
@@ -322,7 +373,8 @@ namespace BumpCombat.Defense.Comparison
Vector2 request = cast.Origin + direction * minimum;
Vector2 previousPosition = target.Position;
Vector2 moved = target.Move(request - target.Position, false);
if ((target.Position - request).sqrMagnitude <= .04f)
Vector2 resultingPosition = previousPosition + moved;
if ((resultingPosition - request).sqrMagnitude <= .04f)
{
world.CombatFeedback?.NotifyPulseGather(enemy, previousPosition, previousPosition + moved);
enemy.ApplyStun(cast.Use.Charged
@@ -332,7 +384,7 @@ namespace BumpCombat.Defense.Comparison
cast.Use.Effect, cast.Use.Charged);
CombatEvents.RaiseValidHit(CombatHitResult.ForArtifactContact(
world.Player.gameObject, target.gameObject, cast.Use.Effect, cast.Use.Charged,
HitSide.Front, direction, target.Position, cast.Use.Definition.DamageTag,
HitSide.Front, direction, resultingPosition, cast.Use.Definition.DamageTag,
cast.Use.Definition.ArtifactId));
}
}
@@ -363,16 +415,23 @@ namespace BumpCombat.Defense.Comparison
private void StartCyclone(Cast cast)
{
cyclone = cast;
locksArtifactInput = true;
if (!cast.IsReplay)
{
cyclone = cast;
locksArtifactInput = true;
cast.Origin = world.Player.Position;
cast.Direction = GetUseDirection();
}
float duration = Mathf.Max(cast.Use.Duration, Time.fixedDeltaTime);
cast.NextTick = 0f;
cast.HitCount = ScheduledHitCount(duration, cast.Use.Definition.HitInterval);
cast.Origin = world.Player.Position;
cast.Direction = GetUseDirection();
cast.Strikes.Clear();
CreateEllipse(cast, world.Player.Position, CycloneRadii(cast), cast.Use.Definition.EffectColor);
CreateCycloneSprites(cast);
GameObject visualObject = new($"Cyclone cast visual {cast.Identity}");
visualObject.transform.SetParent(world.transform, false);
cast.CycloneVisual = visualObject.AddComponent<Defense25DCycloneVisual>();
cast.CycloneVisual.ConfigureForCast(world);
cast.CycloneVisual.SetCastState(true, cast.Elapsed, CycloneCenter(cast),
CycloneRadii(cast), cast.Use.Charged);
}
private void StepCyclone(Cast cast, float dt)
@@ -383,12 +442,12 @@ namespace BumpCombat.Defense.Comparison
while (cast.Tick < cast.HitCount && cast.Elapsed + .0001f >= cast.NextTick)
{
Vector2 center = CycloneCenter(cast);
HitEllipse(cast, center, CycloneRadii(cast), cast.Use.Damage,
HitEllipse(cast, center, CycloneRadii(cast), cast.Use.Damage * cast.DamageMultiplier,
cast.Tick == cast.HitCount - 1 ? cast.Use.Knockback : 0f, cast.Use.MaxTargets, true);
cast.Tick++;
cast.NextTick += hitInterval;
}
if (cast.Elapsed < duration && !cast.MovementBlocked)
if (!cast.IsReplay && cast.Elapsed < duration && !cast.MovementBlocked)
{
float moveDistance = cast.Use.Charged ? d.ChargedMoveDistance : d.NormalMoveDistance;
float previousProgress = Mathf.Clamp01((cast.Elapsed - dt) / duration);
@@ -403,14 +462,16 @@ namespace BumpCombat.Defense.Comparison
{
while (cast.Tick < cast.HitCount)
{
HitEllipse(cast, CycloneCenter(cast), CycloneRadii(cast), cast.Use.Damage,
HitEllipse(cast, CycloneCenter(cast), CycloneRadii(cast),
cast.Use.Damage * cast.DamageMultiplier,
cast.Tick == cast.HitCount - 1 ? cast.Use.Knockback : 0f, cast.Use.MaxTargets, true);
cast.Tick++;
}
ApplyTimedModifier(cast.Use.Definition.ArtifactId + CycloneMoveSpeedSuffix,
CharacterStat.MoveSpeed,
cast.Use.Charged ? cast.Use.Definition.ChargedMoveSpeedIncrease : cast.Use.Definition.NormalMoveSpeedIncrease,
cast.Use.Charged ? cast.Use.Definition.ChargedBuffDuration : cast.Use.Definition.NormalBuffDuration);
if (!cast.IsReplay)
ApplyTimedModifier(cast.Use.Definition.ArtifactId + CycloneMoveSpeedSuffix,
CharacterStat.MoveSpeed,
cast.Use.Charged ? cast.Use.Definition.ChargedMoveSpeedIncrease : cast.Use.Definition.NormalMoveSpeedIncrease,
cast.Use.Charged ? cast.Use.Definition.ChargedBuffDuration : cast.Use.Definition.NormalBuffDuration);
}
private void StartThunderCrash(Cast cast)
@@ -648,17 +709,22 @@ namespace BumpCombat.Defense.Comparison
world.Player.gameObject, target.gameObject, d.Effect == ActiveArtifactEffect.Dash,
HitSide.Front, incomingDamage, direction, knockback,
target.ClosestGroundPoint(world.Player.Center),
damageTag: d.DamageTag, sourceId: d.ArtifactId, usesPositionBonus: false,
damageTag: d.DamageTag,
sourceId: cast.IsReplay ? Defense25DRelicRuntime.EchoSourceId : d.ArtifactId,
usesPositionBonus: false,
showsHitFeedback: incomingDamage > 0f, isArtifactHit: true,
artifactEffect: d.Effect, isChargedArtifact: cast.Use.Charged,
baseBumpDamage: world.Player.CalculateDamage(
GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision)));
Defense25DRelicTarget relicTarget = world.CreateRelicTarget(enemy);
NotifyRelicHit?.Invoke(new Defense25DRelicHit(relicTarget,
Defense25DActor.OnGround(target.ClosestGroundPoint(world.Player.Center), world.Settings.GroundY),
Defense25DActor.OnGround(direction), incomingDamage,
world.Player.CalculateDamage(GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision),
ordinaryBump: false, artifactHit: true, sourceId: d.ArtifactId));
if (!cast.IsReplay)
{
Defense25DRelicTarget relicTarget = world.CreateRelicTarget(enemy);
NotifyRelicHit?.Invoke(new Defense25DRelicHit(relicTarget,
Defense25DActor.OnGround(target.ClosestGroundPoint(world.Player.Center), world.Settings.GroundY),
Defense25DActor.OnGround(direction), incomingDamage,
world.Player.CalculateDamage(GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision),
ordinaryBump: false, artifactHit: true, sourceId: d.ArtifactId));
}
}
target.Visual?.ShowHurt();
return true;
@@ -700,7 +766,7 @@ namespace BumpCombat.Defense.Comparison
return ThunderCrashDirections[(sector % 8 + 8) % 8];
}
private Vector2 CycloneCenter(Cast cast) => world.Player.Position
private Vector2 CycloneCenter(Cast cast) => (cast.IsReplay ? cast.Origin : world.Player.Position)
+ CycloneGeometry.CenterOffset(cast.Use.Range, cast.Use.Charged, world.Settings.PlayerVisualScale);
private static Vector2 CycloneRadii(Cast cast) => CycloneGeometry.Radii(cast.Use.Range, cast.Use.Charged);
@@ -786,36 +852,6 @@ namespace BumpCombat.Defense.Comparison
1f);
}
private void CreateCycloneSprites(Cast cast)
{
bool charged = cast.Use.Charged;
string key = "Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-" + (charged ? "Charged-" : "Normal-");
Sprite[] back = LoadFrames(key + "Back-v3", 8, 100, 100, 32f);
Sprite[] front = LoadFrames(key + "Front-v3", 8, 100, 100, 32f);
if (back == null || front == null)
{
DestroyFrames(back);
DestroyFrames(front);
return;
}
cast.Frames.Add(back);
cast.Frames.Add(front);
cast.CycloneBack = CreateSprite("Cyclone orbit back", back[0], -1);
cast.CycloneFront = CreateSprite("Cyclone orbit front", front[0], 1);
cast.Sprites.Add(cast.CycloneBack);
cast.Sprites.Add(cast.CycloneFront);
float baseRange = charged ? 1.25f : .85f;
float scale = cast.Use.Range / baseRange;
cast.CycloneBack.transform.localScale = cast.CycloneFront.transform.localScale = Vector3.one * scale;
SpriteRenderer playerRenderer = world.Player.Visual != null ? world.Player.Visual.Renderer : null;
if (playerRenderer != null)
{
cast.CycloneBack.sortingLayerID = cast.CycloneFront.sortingLayerID = playerRenderer.sortingLayerID;
cast.CycloneBack.sortingOrder = playerRenderer.sortingOrder - 1;
cast.CycloneFront.sortingOrder = playerRenderer.sortingOrder + 1;
}
}
private SpriteRenderer CreateEffectSprite(Cast cast, string path, int count, int frameWidth,
int frameHeight, Vector2 position, Vector2 direction, float pixelsPerUnit, float uniformScale,
int cropX = 0, int cropY = 0, int cropWidth = 0, int cropHeight = 0)
@@ -907,21 +943,9 @@ namespace BumpCombat.Defense.Comparison
cast.EffectSprite.sprite = FrameAt(cast.Frames[0], PhoenixFrameSeconds, cast.Elapsed);
break;
case ActiveArtifactEffect.Cyclone:
{
Vector2 center = CycloneCenter(cast);
int frame = Mathf.FloorToInt(cast.Elapsed / CycloneFrameSeconds) % 8;
if (cast.CycloneBack != null)
{
cast.CycloneBack.sprite = cast.Frames[0][frame];
cast.CycloneFront.sprite = cast.Frames[1][frame];
PositionBillboard(cast.CycloneBack, center + Vector2.down * (7.5f / 32f), Vector2.right);
PositionBillboard(cast.CycloneFront, center + Vector2.down * (7.5f / 32f), Vector2.right);
cast.CycloneBack.transform.position += Vector3.forward * .12f;
cast.CycloneFront.transform.position -= Vector3.forward * .12f;
}
if (cast.Lines.Count > 0) SetEllipse(cast.Lines[0], center, CycloneRadii(cast), world.Settings.GroundY + .035f);
cast.CycloneVisual?.SetCastState(true, cast.Elapsed, CycloneCenter(cast),
CycloneRadii(cast), cast.Use.Charged);
break;
}
case ActiveArtifactEffect.ThunderCrash when cast.EffectSprite != null:
cast.EffectSprite.sprite = FrameAt(cast.Frames[0], ThunderFrameSeconds, cast.VisualAge);
cast.VisualAge += deltaTime;
@@ -1003,6 +1027,12 @@ namespace BumpCombat.Defense.Comparison
private static void DestroyCast(Cast cast)
{
if (cast.CycloneVisual != null)
{
cast.CycloneVisual.SetVisible(false);
UnityEngine.Object.Destroy(cast.CycloneVisual.gameObject);
cast.CycloneVisual = null;
}
foreach (LineRenderer line in cast.Lines)
if (line != null) UnityEngine.Object.Destroy(line.gameObject);
cast.Lines.Clear();
@@ -26,6 +26,10 @@ namespace BumpCombat.Defense.Comparison
if (replayDirection.sqrMagnitude < DirectionEpsilon) replayDirection = Vector2.right;
replayDirection.Normalize();
if (use.Effect == ActiveArtifactEffect.Cyclone)
return TryStartCycloneReplay(use, origin,
new Vector3(replayDirection.x, 0f, replayDirection.y), damageMultiplier, out _);
var replay = new Cast
{
Use = use,
@@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
/// <summary>Run-clock owner for the existing Necromancer protection phase and summon gates.</summary>
public sealed class Defense25DBossCombat
{
private const int ProtectionSummonCount = 3;
private const int MultiAoePatternIndex = 2;
private Defense25DComparison world;
private Defense25DEnemy owner;
private EnemyDefinition definition;
private Func<Defense25DEnemy, IReadOnlyList<int>> requestPhaseSummons;
private Func<Defense25DEnemy, bool, IReadOnlyList<int>> requestAmbientSummons;
private Action<bool> setProtectionVisual;
private Action<bool> setGroggyVisual;
private Action<bool> summonTelegraphAndCancelAttack;
private readonly HashSet<int> phaseSummonSpawnIds = new();
private int generation;
private float combatElapsed;
private float nextGeneralSummonAt;
private float nextCrowdSummonAt;
private float nextMultiAoeAt;
private float summonLockUntil;
private float groggyRemaining;
private bool desperationPending;
private bool desperationStarted;
private bool phaseStarted;
private bool desperationCompleted;
private bool groggy;
public bool IsBound => world != null && owner != null && definition != null;
public bool IsDamageBlocked => desperationPending || phaseSummonSpawnIds.Count > 0;
public bool IsPhaseActive => phaseSummonSpawnIds.Count > 0;
public bool DesperationStarted => desperationStarted;
public bool DesperationPending => desperationPending;
public bool DesperationCompleted => desperationCompleted;
public bool IsGroggy => groggy;
public bool IsAttackLocked => desperationPending || combatElapsed < summonLockUntil;
public int PhaseSummonCount => phaseSummonSpawnIds.Count;
public int RunGeneration => generation;
public int OwnerSpawnId => owner != null ? owner.SpawnContext.SpawnId : -1;
public float CombatElapsed => combatElapsed;
public float GroggyTimeRemaining => Mathf.Max(0f, groggyRemaining);
public float MultiAoeCooldown => Mathf.Max(.5f, GameplayConstants.Current.Enemies.MultiAoeCooldown);
public bool Bind(
Defense25DComparison run,
Defense25DEnemy boss,
EnemyDefinition bossDefinition,
Func<Defense25DEnemy, IReadOnlyList<int>> requestPhaseSummons,
Action<bool> protectionVisual = null,
Func<Defense25DEnemy, bool, IReadOnlyList<int>> requestAmbientSummons = null,
Action<bool> groggyVisual = null,
Action<bool> summonTelegraphAndCancelAttack = null)
{
Cancel();
if (run == null || boss == null || boss.Actor == null || !boss.Actor.IsAlive
|| bossDefinition == null || requestPhaseSummons == null
|| !run.IsRunning || run.RunFailed || run.RunCleared
|| boss.SpawnContext.RunGeneration != run.RunGeneration
|| boss.SpawnContext.Source is not (Defense25DSpawnSource.GlobalElite
or Defense25DSpawnSource.GlobalMidBoss or Defense25DSpawnSource.FinalBoss))
return false;
world = run;
owner = boss;
definition = bossDefinition;
this.requestPhaseSummons = requestPhaseSummons;
this.requestAmbientSummons = requestAmbientSummons;
setProtectionVisual = protectionVisual;
setGroggyVisual = groggyVisual;
this.summonTelegraphAndCancelAttack = summonTelegraphAndCancelAttack;
generation = run.RunGeneration;
combatElapsed = run.CombatElapsed;
nextGeneralSummonAt = combatElapsed + Mathf.Max(0f,
GameplayConstants.Current.Enemies.GeneralSummonCooldown);
nextCrowdSummonAt = combatElapsed + Mathf.Max(0f,
GameplayConstants.Current.Enemies.CrowdSummonCooldown);
return true;
}
public void Step(float dt, bool paused = false)
{
if (!IsBound)
return;
if (generation != world.RunGeneration || owner.Actor == null || !owner.Actor.IsAlive
|| !world.IsRunning || world.RunFailed || world.RunCleared
|| world.RunPhase is Defense25DRunPhase.Clearing or Defense25DRunPhase.Victory
or Defense25DRunPhase.Defeat)
{
Cancel();
return;
}
if (paused || world.Paused || dt <= 0f || float.IsNaN(dt) || float.IsInfinity(dt))
return;
combatElapsed += dt;
if (groggy)
{
groggyRemaining = Mathf.Max(0f, groggyRemaining - dt);
if (groggyRemaining <= 0f)
EndGroggy();
}
if (definition.Kind != EnemyKind.Necromancer || desperationCompleted)
return;
if (desperationPending)
{
TryBeginProtectionPhase();
return;
}
if (groggy || owner.IsStunned || owner.Actor.HasKnockback || owner.IsAttacking || IsAttackLocked)
return;
if (combatElapsed >= nextGeneralSummonAt)
{
bool spawned = RequestAmbientSummons(crowd: false);
nextGeneralSummonAt = combatElapsed + Mathf.Max(0f,
GameplayConstants.Current.Enemies.GeneralSummonCooldown);
if (spawned)
{
PlaySummonTelegraph(interruptAttack: false);
return;
}
}
if (combatElapsed >= nextCrowdSummonAt)
{
bool spawned = RequestAmbientSummons(crowd: true);
nextCrowdSummonAt = combatElapsed + Mathf.Max(0f,
GameplayConstants.Current.Enemies.CrowdSummonCooldown);
if (spawned)
PlaySummonTelegraph(interruptAttack: false);
}
}
/// <summary>Called only after a real positive damage amount has been applied to the owner.</summary>
public void NotifyDamageApplied(float healthBefore, float actualDamage)
{
if (!IsBound || definition.Kind != EnemyKind.Necromancer || !groggy
|| desperationStarted || desperationCompleted || actualDamage <= 0f
|| float.IsNaN(actualDamage) || owner.Actor == null || !owner.Actor.IsAlive
|| world.Paused)
return;
float healthFraction = owner.Actor.CurrentHealth / Mathf.Max(.0001f, owner.Actor.MaxHealth);
float threshold = Mathf.Clamp01(GameplayConstants.Current.Enemies.DesperationHealthFraction);
if (healthFraction > threshold + .0001f)
return;
desperationStarted = true;
desperationPending = true;
EndGroggy();
TryBeginProtectionPhase();
}
public bool BeginGroggy(float duration)
{
if (!IsBound || owner.Actor == null || !owner.Actor.IsAlive || groggy
|| duration <= 0f || float.IsNaN(duration) || float.IsInfinity(duration))
return false;
groggy = true;
groggyRemaining = duration;
setGroggyVisual?.Invoke(true);
return true;
}
public bool NotifyOwnedSummonResolved(int runGeneration, int spawnId, int summonOwnerSpawnId)
{
if (!IsBound || runGeneration != generation || generation != world.RunGeneration
|| owner == null || summonOwnerSpawnId != owner.SpawnContext.SpawnId
|| !phaseSummonSpawnIds.Remove(spawnId))
return false;
if (phaseSummonSpawnIds.Count == 0 && phaseStarted && !desperationPending
&& !desperationCompleted)
{
phaseStarted = false;
setProtectionVisual?.Invoke(false);
desperationCompleted = true;
BeginGroggy(GameplayConstants.Current.Enemies.FinalBossGroggyDuration);
}
return true;
}
public bool CanUseAttackPattern(int patternIndex)
{
return patternIndex != MultiAoePatternIndex || !IsBound
|| combatElapsed + .0001f >= nextMultiAoeAt;
}
public void NotifyAttackPatternUsed(int patternIndex)
{
if (!IsBound || patternIndex != MultiAoePatternIndex || world.Paused
|| generation != world.RunGeneration)
return;
nextMultiAoeAt = combatElapsed + MultiAoeCooldown;
}
public float FilterIncomingDamage(float currentHealth, float requestedDamage)
{
if (!IsBound || definition.Kind != EnemyKind.Necromancer)
return Mathf.Max(0f, requestedDamage);
return FilterNecromancerDamage(currentHealth, owner.Actor.MaxHealth, requestedDamage,
groggy, desperationStarted, IsDamageBlocked,
GameplayConstants.Current.Enemies.DesperationHealthFraction);
}
public void Cancel()
{
setProtectionVisual?.Invoke(false);
setGroggyVisual?.Invoke(false);
phaseSummonSpawnIds.Clear();
world = null;
owner = null;
definition = null;
requestPhaseSummons = null;
requestAmbientSummons = null;
setProtectionVisual = null;
setGroggyVisual = null;
summonTelegraphAndCancelAttack = null;
generation = 0;
combatElapsed = nextGeneralSummonAt = nextCrowdSummonAt = nextMultiAoeAt = 0f;
summonLockUntil = groggyRemaining = 0f;
desperationPending = desperationStarted = phaseStarted = desperationCompleted = groggy = false;
}
private bool RequestAmbientSummons(bool crowd)
{
if (requestAmbientSummons == null)
return false;
IReadOnlyList<int> spawnedIds = requestAmbientSummons(owner, crowd);
int expectedCount = crowd ? 3 : 1;
if (spawnedIds == null || spawnedIds.Count != expectedCount)
return false;
HashSet<int> uniqueIds = new();
for (int i = 0; i < spawnedIds.Count; i++)
if (spawnedIds[i] <= 0 || !uniqueIds.Add(spawnedIds[i]))
return false;
return true;
}
private void TryBeginProtectionPhase()
{
if (!desperationPending || !IsBound || world.Paused || !world.IsRunning)
return;
IReadOnlyList<int> spawnedIds = requestPhaseSummons(owner);
if (spawnedIds == null || spawnedIds.Count != ProtectionSummonCount)
return;
HashSet<int> uniqueIds = new();
for (int i = 0; i < spawnedIds.Count; i++)
if (spawnedIds[i] <= 0 || !uniqueIds.Add(spawnedIds[i]))
return;
phaseSummonSpawnIds.Clear();
foreach (int spawnId in uniqueIds)
phaseSummonSpawnIds.Add(spawnId);
desperationPending = false;
phaseStarted = true;
setProtectionVisual?.Invoke(true);
PlaySummonTelegraph(interruptAttack: true);
}
private void PlaySummonTelegraph(bool interruptAttack)
{
float duration = Mathf.Max(0f, GameplayConstants.Current.Enemies.SummonVisualDuration);
summonLockUntil = combatElapsed + duration;
summonTelegraphAndCancelAttack?.Invoke(interruptAttack);
}
private void EndGroggy()
{
if (!groggy)
return;
groggy = false;
groggyRemaining = 0f;
setGroggyVisual?.Invoke(false);
}
public static float FilterNecromancerDamage(
float currentHealth,
float maximumHealth,
float requestedDamage,
bool isGroggy,
bool desperationStarted,
bool protectionActive,
float desperationHealthFraction)
{
if (requestedDamage <= 0f || float.IsNaN(requestedDamage))
return 0f;
if (protectionActive)
return 0f;
if (!isGroggy || desperationStarted)
return requestedDamage;
float threshold = Mathf.Max(.0001f, maximumHealth * Mathf.Clamp01(desperationHealthFraction));
return currentHealth > threshold
? Mathf.Min(requestedDamage, currentHealth - threshold)
: 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f514e3943f4048c4a876a187aa8b320b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -272,7 +272,8 @@ namespace BumpCombat.Defense.Comparison
{
if (!bound || world == null || !world.isActiveAndEnabled || definition == null) return;
string path = Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + Defense25DCombatFeedbackConstants.ChargeReleaseSuffix;
Sprite[] frames = LoadFrames(path);
Sprite[] frames = ArtifactChargeVisual.LoadFrames(
path, ArtifactChargeVisual.FrameCount);
if (frames.Length == ArtifactChargeVisual.FrameCount)
CreateImpact(groundOrigin, frames, Defense25DCombatFeedbackConstants.ChargeReleaseFrames, 1f, Color.white,
Vector2.zero, "Artifact Release", EffectKind.Release, false, 1);
@@ -659,7 +660,9 @@ namespace BumpCombat.Defense.Comparison
string suffix, bool loop, IReadOnlyList<float> durations, int sortingOffset, int expectedFrameCount = ArtifactChargeVisual.FrameCount)
{
if (slot != null) return;
Sprite[] frames = LoadFrames(Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + suffix);
Sprite[] frames = ArtifactChargeVisual.LoadFrames(
Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + suffix,
expectedFrameCount);
if (frames.Length != expectedFrameCount || playerRenderer == null) return;
GameObject root = new("Artifact Charge " + suffix);
root.transform.SetParent(playerRenderer.transform, false);
@@ -850,6 +853,7 @@ namespace BumpCombat.Defense.Comparison
foreach (SpriteEffect effect in effects)
if (effect != null && effect.Root != null) Destroy(effect.Root);
effects.Clear();
heavyEffects.Clear();
aura = readyAura = chargeFlash = switchPulse = null;
guardImpact = blockedBump = null;
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
@@ -8,15 +9,39 @@ using UnityEngine.InputSystem;
namespace BumpCombat.Defense.Comparison
{
public enum Defense25DRunPhase
{
Preparing,
Running,
BossActive,
Clearing,
Victory,
Defeat,
}
public enum Defense25DRunEndReason
{
None,
FinalBossDefeated,
FinalBossArrived,
BaseDestroyed,
}
// An independent Physics3D experiment. No RunManager, PlayerController or 2D proxy.
public sealed partial class Defense25DComparison : MonoBehaviour
{
private const int PortalLaneCount = 2;
[SerializeField] private Defense25DComparisonSettings settings;
[SerializeField] private Defense25DView view;
[SerializeField] private Defense25DRunSceneProfile runSceneProfile;
[SerializeField] private Defense25DRunHud runHud;
[SerializeField, Min(1)] private int longRunLayoutSeed = 1;
[SerializeField] private bool useFixedLongRunSeed;
private readonly List<Defense25DEnemy> enemies = new();
private readonly List<Defense25DSpawnRequest> pendingLongRunSpecialSpawns = new();
private readonly List<Defense25DSummon> summons = new();
private readonly Collider[] overlap = new Collider[128];
private readonly Defense25DPersonalMissions personalMissions = new();
private Collider[] overlap = new Collider[128];
private readonly HashSet<Defense25DActor> unique = new();
private readonly List<Defense25DActor> candidates = new();
private readonly Dictionary<Defense25DActor, float> nextBump = new();
@@ -27,15 +52,17 @@ namespace BumpCombat.Defense.Comparison
private readonly int[] suppliedPerLane = new int[PortalLaneCount];
private readonly bool[] portalSupplyEnded = new bool[PortalLaneCount];
private readonly GameObject[] portalRewards = new GameObject[PortalLaneCount];
private readonly Vector2[][] routes = new Vector2[PortalLaneCount][];
private Vector2[][] routes = new Vector2[PortalLaneCount][];
private readonly List<ActiveArtifactDefinition> startupArtifactOptions = new();
private Defense25DCycloneVisual cycloneVisual;
private Defense25DFacilityVisuals facilityVisuals;
private Defense25DCombatFeedback combatFeedback;
private Defense25DPlayerArt playerArt;
private float previousChargeProgress = -1f;
private MeshFilter adjustedSouthBoundary;
private Mesh originalSouthBoundaryMesh;
private Mesh adjustedSouthBoundaryMesh;
private GameObject runtimeRouteVisualRoot;
private Vector2 input;
private Vector2 aim = Vector2.right;
private Vector2 cycloneDirection;
@@ -47,10 +74,50 @@ namespace BumpCombat.Defense.Comparison
private int startupSelectionsRemaining, startupSelectionTotal, startupSelectionIndex;
private ArtifactColor startupArtifactColor;
private bool startupSelectionPending;
private Defense25DRunDefinition longRunDefinition;
private Defense25DRunLayout longRunLayout;
private Defense25DWaveDirector waveDirector;
private bool longRunMode;
private bool resolvingLongRunEnemy;
private int runGeneration;
private int nextLongRunSpawnId;
private int longRunSeed;
private Defense25DRunEndReason runEndReason;
private bool longRunArtifactButtonPressForwarded;
private float combatElapsed;
private float preparationRemaining;
private float fallbackGauge;
private const string CycloneBuff = "defense-25d-cyclone";
public Defense25DComparisonSettings Settings => settings;
public Defense25DRunDefinition RunDefinition => longRunDefinition;
public Defense25DRunLayout RunLayout => longRunLayout;
public Defense25DWaveDirector WaveDirector => waveDirector;
public Defense25DPersonalMissions PersonalMissions => longRunMode ? personalMissions : null;
public IReadOnlyList<Defense25DPersonalMissionState> PersonalMissionStates =>
PersonalMissions?.States ?? Array.Empty<Defense25DPersonalMissionState>();
public Defense25DRunPhase RunPhase { get; private set; } = Defense25DRunPhase.Running;
public Defense25DRunEndReason RunEndReason => runEndReason != Defense25DRunEndReason.None
? runEndReason
: longRunMode && RunCleared ? Defense25DRunEndReason.FinalBossDefeated
: Defense25DRunEndReason.None;
public bool IsLongRun => longRunMode;
public int RunGeneration => runGeneration;
public int CurrentRunSeed => longRunMode
? unchecked(longRunSeed + Mathf.Max(0, runGeneration - 1) * 7919)
: 0;
public int PortalCount => longRunMode && longRunLayout != null ? longRunLayout.RouteCount : PortalLaneCount;
public float CombatElapsed => combatElapsed;
public float PreparationRemaining => preparationRemaining;
public bool HasPendingStartupArtifactChoice => startupSelectionPending;
public IReadOnlyList<ActiveArtifactDefinition> StartupArtifactCandidates => startupArtifactOptions;
public int StartupArtifactChoiceIndex => startupSelectionIndex;
public int CurrentGlobalWaveId => waveDirector?.GetCurrentPlan()?.WaveId ?? 0;
public float CurrentThreatBudget => waveDirector?.GetCurrentPlan()?.ThreatBudget ?? 0f;
public IReadOnlyList<int> ActiveWavePortalIds =>
waveDirector?.GetCurrentPlan()?.ActivePortalIds ?? Array.Empty<int>();
public int RemainingEnemies => Ledger == null ? 0 : Mathf.Max(0, Ledger.SuppliedCount - Ledger.ResolvedCount);
public IReadOnlyList<Defense25DSpawnRequest> PendingLongRunSpecialSpawns => pendingLongRunSpecialSpawns;
public Vector2 ArenaHalfExtents { get; private set; }
public Defense25DView View => view;
public Defense25DActor Player { get; private set; }
@@ -96,7 +163,7 @@ namespace BumpCombat.Defense.Comparison
public Defense25DCombatFeedback CombatFeedback => combatFeedback;
public Defense25DPlayerArt PlayerArt => playerArt;
private DefenseConstants Tuning => GameplayConstants.Current.Defense;
public Vector2[] GetRouteForLane(int lane) => routes[Mathf.Clamp(lane, 0, PortalLaneCount - 1)];
public Vector2[] GetRouteForLane(int lane) => routes[Mathf.Clamp(lane, 0, routes.Length - 1)];
public bool IsTowerImmuneToEnemy(DefenseTowerKind kind, Defense25DEnemy enemy) =>
enemy == null || DefensePrototypeRules.IsTowerImmune(kind, Tuning.GetPortalImmuneTowerKind(enemy.Lane));
@@ -105,7 +172,29 @@ namespace BumpCombat.Defense.Comparison
if (initialized || settings == null) return;
// This scene has no RunManager to resume a paused project's clock.
Time.timeScale = 1f;
Initialize(settings, view);
if (runSceneProfile != null)
{
if (runSceneProfile.RunDefinition == null)
throw new InvalidOperationException("The Defense25DRunSceneProfile has no run definition.");
InitializeLongRun(settings, runSceneProfile.RunDefinition,
useFixedLongRunSeed ? longRunLayoutSeed : CreateRuntimeLongRunSeed(), view);
}
else Initialize(settings, view);
}
private int CreateRuntimeLongRunSeed()
{
int seed = unchecked(Environment.TickCount ^ GetInstanceID() ^ (int)DateTime.UtcNow.Ticks);
seed &= int.MaxValue;
return seed == 0 ? 1 : seed;
}
public void BootstrapLongRun(Defense25DComparisonSettings configuration,
Defense25DRunSceneProfile profile, int layoutSeed = 1, Defense25DView display = null)
{
if (profile == null || profile.RunDefinition == null)
throw new ArgumentException("A scene profile with a run definition is required.", nameof(profile));
InitializeLongRun(configuration, profile.RunDefinition, layoutSeed, display);
}
public void Initialize(Defense25DComparisonSettings configuration, Defense25DView display = null)
@@ -114,15 +203,27 @@ namespace BumpCombat.Defense.Comparison
settings = configuration;
view = display;
initialized = true;
ArenaHalfExtents = settings.MapHalfExtents;
ArenaHalfExtents = longRunMode && longRunLayout != null
? new Vector2(longRunLayout.Bounds.width, longRunLayout.Bounds.height) * .5f
: settings.MapHalfExtents;
ReduceSouthBoundaryRenderHeight();
Player = CreateActor(ComparisonActorKind.Player, "Player (Physics3D)", settings.PlayerSpawn);
Vector2 playerStart = longRunMode && longRunLayout != null ? longRunLayout.PlayerStart : settings.PlayerSpawn;
Player = CreateActor(ComparisonActorKind.Player, "Player (Physics3D)", playerStart);
combatFeedback = gameObject.AddComponent<Defense25DCombatFeedback>();
combatFeedback.Bind(this);
playerArt = gameObject.AddComponent<Defense25DPlayerArt>();
playerArt.Bind(this);
for (int lane = 0; lane < PortalLaneCount; lane++)
int routeCount = longRunMode && longRunLayout != null ? longRunLayout.RouteCount : PortalLaneCount;
routes = new Vector2[routeCount][];
for (int lane = 0; lane < routeCount; lane++)
{
if (longRunMode)
{
IReadOnlyList<Vector2> layoutRoute = longRunLayout.Routes[lane];
routes[lane] = new Vector2[layoutRoute.Count];
for (int i = 0; i < layoutRoute.Count; i++) routes[lane][i] = layoutRoute[i];
continue;
}
routes[lane] = new[]
{
Tuning.GetPortalPosition(lane),
@@ -144,6 +245,12 @@ namespace BumpCombat.Defense.Comparison
CreateRuntimeRouteVisuals();
TowerManager = gameObject.AddComponent<Defense25DTowerManager>();
TowerManager.Configure(this);
if (longRunMode)
{
facilityVisuals = GetComponent<Defense25DFacilityVisuals>();
if (facilityVisuals == null) facilityVisuals = gameObject.AddComponent<Defense25DFacilityVisuals>();
TowerManager.SetFacilityVisuals(facilityVisuals);
}
ArtifactConstants artifactTuning = GameplayConstants.Current.Artifacts;
Loadout = new Defense25DLoadout(artifactTuning.StartingGauge, artifactTuning.MaximumGauge,
artifactTuning.MaximumOwnedArtifacts, artifactTuning.MaximumArtifactsPerColor);
@@ -153,18 +260,41 @@ namespace BumpCombat.Defense.Comparison
ConfigureRelicRuntime();
ConfigureRelicEffectCallbacks();
ConfigureRelicReplayCallbacks();
CreatePortalRewards();
cycloneVisual = new GameObject("Cyclone comparison").AddComponent<Defense25DCycloneVisual>();
cycloneVisual.transform.SetParent(transform, false);
cycloneVisual.Configure(this);
InitializeLongRunArtifactDrops();
if (!longRunMode) CreatePortalRewards();
if (!longRunMode)
{
cycloneVisual = new GameObject("Cyclone comparison").AddComponent<Defense25DCycloneVisual>();
cycloneVisual.transform.SetParent(transform, false);
cycloneVisual.Configure(this);
}
ResetRun();
}
public void InitializeLongRun(Defense25DComparisonSettings configuration,
Defense25DRunDefinition definition, int layoutSeed = 1, Defense25DView display = null)
{
if (initialized) throw new InvalidOperationException("The comparison world is already initialized.");
if (configuration == null) throw new ArgumentNullException(nameof(configuration));
if (definition == null) throw new ArgumentNullException(nameof(definition));
if (!definition.Validate(out string error))
throw new ArgumentException(error, nameof(definition));
if (definition.BattlefieldDefinitions.Length == 0 ||
!definition.BattlefieldDefinitions[0].TryCreateLayout(layoutSeed, out longRunLayout, out error))
throw new ArgumentException(string.IsNullOrEmpty(error) ? "A valid battlefield is required." : error,
nameof(definition));
longRunMode = true;
longRunDefinition = definition;
longRunSeed = layoutSeed;
Initialize(configuration, display);
}
private void ReduceSouthBoundaryRenderHeight()
{
GameObject boundary = GameObject.Find("South boundary");
if (boundary == null) return;
ExpandArenaForSummonSlots(boundary);
if (!longRunMode) ExpandArenaForSummonSlots(boundary);
MeshFilter meshFilter = boundary != null ? boundary.GetComponent<MeshFilter>() : null;
if (meshFilter == null || meshFilter.sharedMesh == null) return;
originalSouthBoundaryMesh = meshFilter.sharedMesh;
@@ -240,6 +370,8 @@ namespace BumpCombat.Defense.Comparison
private void OnDestroy()
{
ClearLongRunDynamicEnemies();
CleanupLongRunArtifactDrops();
for (int i = 0; i < adjustedArenaTransforms.Count; i++)
{
Transform target = adjustedArenaTransforms[i];
@@ -297,13 +429,20 @@ namespace BumpCombat.Defense.Comparison
private void CreateRuntimeRouteVisuals()
{
if (runtimeRouteVisualRoot != null)
{
runtimeRouteVisualRoot.SetActive(false);
if (Application.isPlaying) Destroy(runtimeRouteVisualRoot);
else DestroyImmediate(runtimeRouteVisualRoot);
}
for (int segment = 1; segment <= 4; segment++)
GameObject.Find($"Route segment {segment}")?.SetActive(false);
GameObject.Find("Portal")?.SetActive(false);
GameObject.Find("Base - arrival marker")?.SetActive(false);
GameObject.Find("South obstacle")?.SetActive(false);
Transform root = new GameObject("Defense routes and build pads").transform;
runtimeRouteVisualRoot = new GameObject("Defense routes and build pads");
Transform root = runtimeRouteVisualRoot.transform;
root.SetParent(transform, false);
void Box(string label, Vector2 center, Vector3 size, Material material, float height)
{
@@ -316,7 +455,7 @@ namespace BumpCombat.Defense.Comparison
Destroy(item.GetComponent<Collider>());
}
for (int lane = 0; lane < PortalLaneCount; lane++)
for (int lane = 0; lane < routes.Length; lane++)
{
for (int i = 0; i < routes[lane].Length - 1; i++)
{
@@ -334,9 +473,12 @@ namespace BumpCombat.Defense.Comparison
Box($"Portal {lane + 1} marker", routes[lane][0], new Vector3(1f, .12f, 1f), settings.PathMaterial, .06f);
}
Box("Shared base marker", Tuning.GetBasePosition(0), new Vector3(1.25f, .4f, 1.25f), settings.BaseMaterial, .2f);
for (int pad = 0; pad < 6; pad++)
Box($"Tower build pad {pad + 1}", Tuning.GetBuildPadPosition(pad),
Vector2 home = longRunMode && longRunLayout != null
? longRunLayout.HomePosition : Tuning.GetBasePosition(0);
Box("Shared base marker", home, new Vector3(1.25f, .4f, 1.25f), settings.BaseMaterial, .2f);
int padCount = longRunMode && longRunLayout != null ? longRunLayout.PadCount : 6;
for (int pad = 0; pad < padCount; pad++)
Box($"Tower build pad {pad + 1}", longRunMode ? longRunLayout.PadPositions[pad] : Tuning.GetBuildPadPosition(pad),
new Vector3(.8f, .08f, .8f), settings.BaseMaterial, .035f);
}
@@ -350,12 +492,22 @@ namespace BumpCombat.Defense.Comparison
ArtifactExecutor.Cancel();
Loadout.ResetRun();
RelicRuntime?.BeginRun();
if (view != null) BeginStartupArtifactSelection();
if (view != null && !longRunMode) BeginStartupArtifactSelection();
else
{
startupSelectionPending = false;
startupSelectionsRemaining = 0;
startupArtifactOptions.Clear();
}
CancelCyclone();
GameTime = nextSpawn = reviveAt = buffUntil = 0f;
combatElapsed = 0f;
preparationRemaining = 0f;
BaseHealth = Tuning.BaseMaximumHealth;
fallbackGauge = GameplayConstants.Current.Artifacts.StartingGauge;
BumpEvents = CycloneTicks = CycloneDamageEvents = KillCurrency = 0;
runEndReason = Defense25DRunEndReason.None;
longRunArtifactButtonPressForwarded = false;
input = Vector2.zero;
aim = Vector2.right;
Paused = startupSelectionPending;
@@ -364,37 +516,125 @@ namespace BumpCombat.Defense.Comparison
CommandTarget = null;
nextBump.Clear();
nextSupplyLane = 0;
Ledger = new DefenseWaveLedger(enemies.Count);
runGeneration++;
if (longRunMode)
{
ClearLongRunDynamicEnemies();
nextLongRunSpawnId = 0;
int layoutSeed = CurrentRunSeed;
Defense25DBattlefieldDefinition battlefield = longRunDefinition.BattlefieldDefinitions[0];
if (!battlefield.TryCreateLayout(layoutSeed, out Defense25DRunLayout nextLayout, out string error))
throw new InvalidOperationException($"The next run layout could not be created: {error}");
ApplyLongRunLayout(nextLayout);
CreateRuntimeRouteVisuals();
RunPhase = Defense25DRunPhase.Preparing;
preparationRemaining = longRunDefinition.PreparationSeconds;
pendingLongRunSpecialSpawns.Clear();
waveDirector = new Defense25DWaveDirector(longRunDefinition, runGeneration,
CurrentRunSeed);
}
else
{
RunPhase = Defense25DRunPhase.Running;
waveDirector = null;
}
Ledger = new DefenseWaveLedger(longRunMode ? int.MaxValue : enemies.Count);
for (int lane = 0; lane < PortalLaneCount; lane++)
{
portalLedgers[lane] = new DefenseWaveLedger(Tuning.PortalEnemyCount);
portalLedgers[lane] = longRunMode ? null : new DefenseWaveLedger(Tuning.PortalEnemyCount);
suppliedPerLane[lane] = 0;
portalSupplyEnded[lane] = false;
portalRewards[lane]?.SetActive(false);
}
TowerManager.ResetForRun();
Player.ResetAt(settings.PlayerSpawn);
Player.ResetAt(longRunMode ? longRunLayout.PlayerStart : settings.PlayerSpawn);
if (longRunMode)
{
BindLongRunProgression();
BindAndResetPersonalMissions();
}
foreach (Defense25DEnemy enemy in enemies) enemy.ResetForRun();
foreach (Defense25DSummon summon in summons) summon.ResetForRun();
Physics.SyncTransforms();
ResetLongRunArtifactDrops();
ResetLongRunFacilityVisuals();
BindLongRunPresentation();
}
private void ApplyLongRunLayout(Defense25DRunLayout layout)
{
longRunLayout = layout ?? throw new ArgumentNullException(nameof(layout));
ArenaHalfExtents = new Vector2(layout.Bounds.width, layout.Bounds.height) * .5f;
routes = new Vector2[layout.RouteCount][];
for (int routeId = 0; routeId < routes.Length; routeId++)
{
IReadOnlyList<Vector2> source = layout.Routes[routeId];
routes[routeId] = new Vector2[source.Count];
for (int point = 0; point < source.Count; point++)
routes[routeId][point] = source[point];
}
}
private void BindLongRunPresentation()
{
if (!longRunMode) return;
runHud ??= FindAnyObjectByType<Defense25DRunHud>();
runHud?.Bind(this, Progression, view);
if (view != null && Player != null)
view.SetFollowTarget(Player.transform, ArenaHalfExtents);
}
private void ResetLongRunFacilityVisuals()
{
if (!longRunMode) return;
facilityVisuals ??= GetComponent<Defense25DFacilityVisuals>();
if (facilityVisuals == null) facilityVisuals = gameObject.AddComponent<Defense25DFacilityVisuals>();
facilityVisuals.Clear();
facilityVisuals.Bind(this, longRunDefinition.FacilityVisualConstants, runGeneration);
TowerManager.SetFacilityVisuals(facilityVisuals);
Vector3 home = Defense25DActor.OnGround(longRunLayout.HomePosition, settings.GroundY);
facilityVisuals.PlayHomeSpawn(home);
for (int padId = 0; padId < longRunLayout.PadCount; padId++)
facilityVisuals.PlayPadSpawn(padId,
Defense25DActor.OnGround(longRunLayout.PadPositions[padId], settings.GroundY));
}
internal void PlayLongRunTerminalFacilityEffects(bool baseDestroyed)
{
if (!longRunMode || facilityVisuals == null) return;
if (baseDestroyed)
facilityVisuals.PlayHomeBurst(Defense25DActor.OnGround(longRunLayout.HomePosition, settings.GroundY));
foreach (int portalId in GetOpenLongRunPortalIds())
facilityVisuals.PlayPortalBurst(portalId,
Defense25DActor.OnGround(longRunLayout.PortalPositions[portalId], settings.GroundY));
}
private void Update()
{
if (!initialized || !ReadInput || Keyboard.current == null) return;
Keyboard k = Keyboard.current;
if (k.rKey.wasPressedThisFrame) ResetRun();
if (k.pKey.wasPressedThisFrame && !startupSelectionPending
&& !HasPendingFieldRelicReward && !showingArtifactSelection)
if (longRunMode && k.aKey.wasReleasedThisFrame && longRunArtifactButtonPressForwarded)
{
if (CanForwardLongRunGameplayInput()) ArtifactExecutor.OnArtifactButtonReleased();
else ArtifactExecutor.Cancel();
longRunArtifactButtonPressForwarded = false;
}
if (!longRunMode && k.rKey.wasPressedThisFrame) ResetRun();
if ((!longRunMode || runHud == null) && (k.pKey.wasPressedThisFrame || longRunMode && k.escapeKey.wasPressedThisFrame)
&& CanToggleRunPause)
SetPaused(!Paused);
if (k.escapeKey.wasPressedThisFrame) CancelCyclone();
if (!longRunMode && k.escapeKey.wasPressedThisFrame) CancelCyclone();
if (k.bKey.wasPressedThisFrame) ShowBoundaries = !ShowBoundaries;
if (k.cKey.wasPressedThisFrame) ScreenRelativeMovement = !ScreenRelativeMovement;
if (k.tabKey.wasPressedThisFrame) cycloneVisual.Ribbon = !cycloneVisual.Ribbon;
if (k.mKey.wasPressedThisFrame) cycloneVisual.Transparent = !cycloneVisual.Transparent;
if (k.hKey.wasPressedThisFrame) cycloneVisual.Height = cycloneVisual.Height < .4f ? .65f : .15f;
if (k.vKey.wasPressedThisFrame) cycloneVisual.DecorationSpeed = cycloneVisual.DecorationSpeed < 2f ? 4f : 1f;
if (k.nKey.wasPressedThisFrame) cycloneVisual.RadiusScale = cycloneVisual.RadiusScale < 1.1f ? 1.35f : 1f;
if (cycloneVisual != null)
{
if (k.tabKey.wasPressedThisFrame) cycloneVisual.Ribbon = !cycloneVisual.Ribbon;
if (k.mKey.wasPressedThisFrame) cycloneVisual.Transparent = !cycloneVisual.Transparent;
if (k.hKey.wasPressedThisFrame) cycloneVisual.Height = cycloneVisual.Height < .4f ? .65f : .15f;
if (k.vKey.wasPressedThisFrame) cycloneVisual.DecorationSpeed = cycloneVisual.DecorationSpeed < 2f ? 4f : 1f;
if (k.nKey.wasPressedThisFrame) cycloneVisual.RadiusScale = cycloneVisual.RadiusScale < 1.1f ? 1.35f : 1f;
}
if (k.gKey.wasPressedThisFrame && IsRunning && !Paused) DebugFillGauge();
if (startupSelectionPending)
{
@@ -406,15 +646,27 @@ namespace BumpCombat.Defense.Comparison
if (k.spaceKey.wasPressedThisFrame) ChooseStartupArtifact(startupSelectionIndex);
return;
}
if (longRunMode && HasPendingArtifactDropChoice)
{
if (k.digit1Key.wasPressedThisFrame || k.numpad1Key.wasPressedThisFrame)
TryChooseArtifactDrop(0);
else if (k.digit2Key.wasPressedThisFrame || k.numpad2Key.wasPressedThisFrame)
TryChooseArtifactDrop(1);
input = Vector2.zero;
return;
}
if (IsTowerShowcaseSimulationActive)
{
input = Vector2.zero;
return;
}
if (!IsRunning || Paused || !Player.IsAlive) { input = Vector2.zero; return; }
if (longRunMode && k.fKey.wasPressedThisFrame)
TryInteractWithNearestArtifactDrop();
Mouse mouse = Mouse.current;
bool pointerOverUI = mouse != null && IsPointerOverDefenseUI(mouse.position.ReadValue());
if (HasPendingFieldRelicReward || showingArtifactSelection
if (HasPendingFieldRelicReward || showingArtifactSelection || HasPendingArtifactDropChoice
|| longRunMode && runHud != null && runHud.IsGameplayInputBlocked
|| (pointerOverUI && mouse != null
&& (mouse.leftButton.wasPressedThisFrame || mouse.leftButton.wasReleasedThisFrame)))
{
@@ -434,8 +686,11 @@ namespace BumpCombat.Defense.Comparison
playerArt?.Bind(this);
}
}
if (k.aKey.wasPressedThisFrame) ArtifactExecutor.OnArtifactButtonPressed();
if (k.aKey.wasReleasedThisFrame) ArtifactExecutor.OnArtifactButtonReleased();
if (k.aKey.wasPressedThisFrame)
{
bool forwarded = ArtifactExecutor.OnArtifactButtonPressed();
if (longRunMode) longRunArtifactButtonPressForwarded = forwarded;
}
if (TowersOperational)
{
if (k.digit1Key.wasPressedThisFrame || k.numpad1Key.wasPressedThisFrame)
@@ -452,15 +707,15 @@ namespace BumpCombat.Defense.Comparison
TryBuildNearestTower(DefenseTowerKind.Empowering);
else if (k.uKey.wasPressedThisFrame)
TryUpgradeNearestTower(DefenseTowerBranch.None);
else if (k.jKey.wasPressedThisFrame)
else if (!longRunMode && k.jKey.wasPressedThisFrame)
TryUpgradeNearestTower(DefenseTowerBranch.First);
else if (k.kKey.wasPressedThisFrame)
else if (!longRunMode && k.kKey.wasPressedThisFrame)
TryUpgradeNearestTower(DefenseTowerBranch.Second);
}
// D is the original guard binding; movement remains on the original arrow-key layout.
Vector2 keys = new((k.rightArrowKey.isPressed ? 1 : 0)
- (k.leftArrowKey.isPressed ? 1 : 0),
(k.wKey.isPressed || k.upArrowKey.isPressed ? 1 : 0)
((!longRunMode && k.wKey.isPressed) || k.upArrowKey.isPressed ? 1 : 0)
- (k.downArrowKey.isPressed ? 1 : 0));
input = view != null && ScreenRelativeMovement ? view.KeyboardToGround(keys) : Vector2.ClampMagnitude(keys, 1f);
if (view != null && mouse != null && view.TryScreenToGround(mouse.position.ReadValue(), settings.GroundY, out Vector2 point))
@@ -474,10 +729,10 @@ namespace BumpCombat.Defense.Comparison
{
int nearestPad = -1;
float nearestDistance = Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius;
for (int pad = 0; pad < 6; pad++)
for (int pad = 0; pad < TowerManager.PadCount; pad++)
{
if (TowerManager.GetTowerAtPad(pad) != null) continue;
float distance = (Player.Position - Tuning.GetBuildPadPosition(pad)).sqrMagnitude;
float distance = (Player.Position - TowerManager.GetPadPosition(pad)).sqrMagnitude;
if (distance > nearestDistance) continue;
nearestDistance = distance;
nearestPad = pad;
@@ -548,12 +803,88 @@ namespace BumpCombat.Defense.Comparison
}
public void SetMovementInput(Vector2 direction) => input = Vector2.ClampMagnitude(direction, 1f);
public bool TryChooseStartupArtifact(int candidateIndex) =>
startupSelectionPending && !longRunMode && ChooseStartupArtifact(candidateIndex);
public bool CanStartPersonalMission(int missionId, out string reason)
{
if (!CanIssueLongRunInteraction(allowBossActive: true))
{
reason = "The run is paused, ending, or another selection currently owns input.";
return false;
}
if (!longRunMode || personalMissions == null)
{
reason = "Personal missions are available only in the long run.";
return false;
}
return personalMissions.CanStart(missionId, out reason);
}
public bool TryStartPersonalMission(int missionId) =>
CanStartPersonalMission(missionId, out _) && personalMissions.TryStart(missionId);
public bool TryCallNextWaveEarly(out EarlyCallResult result)
{
result = default;
if (!CanIssueLongRunInteraction(allowBossActive: true) || waveDirector == null ||
waveDirector.IsInsideReliefWindow(combatElapsed))
return false;
if (!waveDirector.TryCallNextWaveEarly(combatElapsed, out result))
return false;
if (result.RewardCurrency > 0)
TowerManager?.AddCurrency(result.RewardCurrency);
return true;
}
public void DebugFillGauge()
{
if (Loadout != null) Loadout.AddGauge(Loadout.MaximumGauge);
else fallbackGauge = GameplayConstants.Current.Artifacts.MaximumGauge;
}
public void SetPaused(bool value) { Paused = value; input = Vector2.zero; }
private bool CanIssueLongRunInteraction(bool allowBossActive)
{
bool activePhase = RunPhase == Defense25DRunPhase.Running ||
(allowBossActive && RunPhase == Defense25DRunPhase.BossActive);
return longRunMode && IsRunning && !Paused && !RunCleared && !RunFailed && activePhase &&
Player != null && Player.IsAlive && !HasPendingFieldRelicReward &&
!HasPendingArtifactDropChoice && !showingArtifactSelection && !startupSelectionPending &&
!HasPendingLongRunRewardChoice && !IsValidationShowcaseActive;
}
private void BindAndResetPersonalMissions()
{
personalMissions.Bind(longRunDefinition.PersonalMissionRequiredPortalCounts,
() => CanIssueLongRunInteraction(allowBossActive: true),
GetOpenLongRunPortalIds,
SpawnLongRunPersonalMissionTargets,
token => longRunProgression != null && longRunProgression.QueueReward(
Defense25DRewardSource.PersonalMissionRelic, token));
personalMissions.Reset(runGeneration, unchecked(longRunSeed + runGeneration * 15485863));
}
public bool CanToggleRunPause => !startupSelectionPending && !HasPendingFieldRelicReward &&
!showingArtifactSelection && (!longRunMode || IsRunning && !RunCleared && !RunFailed &&
!HasPendingArtifactDropChoice && !HasPendingLongRunRewardChoice && !IsValidationShowcaseActive &&
(runHud == null || !runHud.IsGameplayInputBlocked));
private bool CanForwardLongRunGameplayInput() => longRunMode && IsRunning && !Paused &&
!RunCleared && !RunFailed && Player != null && Player.IsAlive && !startupSelectionPending &&
!HasPendingFieldRelicReward && !HasPendingArtifactDropChoice && !showingArtifactSelection &&
!HasPendingLongRunRewardChoice && !IsValidationShowcaseActive &&
(runHud == null || !runHud.IsGameplayInputBlocked);
public void SetPaused(bool value)
{
if (longRunMode && value && !CanToggleRunPause) return;
Paused = value;
input = Vector2.zero;
if (longRunMode && value && longRunArtifactButtonPressForwarded)
{
ArtifactExecutor?.Cancel();
longRunArtifactButtonPressForwarded = false;
}
}
private void FixedUpdate()
{
@@ -565,7 +896,7 @@ namespace BumpCombat.Defense.Comparison
TowerManager.Step(dt);
return;
}
GameTime += dt;
AdvanceRunClock(dt);
RelicRuntime?.Advance(GameTime, Player.CurrentHealth);
if (buffUntil > 0f && GameTime >= buffUntil)
{
@@ -574,17 +905,17 @@ namespace BumpCombat.Defense.Comparison
}
if (!Player.IsAlive && GameTime >= reviveAt)
{
Player.ResetAt(settings.PlayerSpawn);
Player.ResetAt(longRunMode ? longRunLayout.PlayerStart : settings.PlayerSpawn);
Player.GrantInvulnerability(Tuning.PlayerReviveInvulnerabilitySeconds);
}
if (AutoSpawn && GameTime >= nextSpawn && Ledger.SuppliedCount < enemies.Count)
if (!longRunMode && AutoSpawn && GameTime >= nextSpawn && Ledger.SuppliedCount < enemies.Count)
{
SpawnNextEnemy();
nextSpawn = GameTime + Tuning.PortalSpawnInterval;
}
if (Player.IsAlive)
{
if (CycloneActive) StepCyclone(dt);
if (!longRunMode && CycloneActive) StepCyclone(dt);
else if (Player.HasKnockback || Player.IsHurt) Player.Move(Player.ConsumeKnockback(dt));
else
{
@@ -613,13 +944,63 @@ namespace BumpCombat.Defense.Comparison
previousChargeProgress = chargeProgress;
StepRelicHealingOrbs();
TowerManager.Step(dt);
foreach (Defense25DEnemy enemy in enemies) enemy.Step(dt);
int enemyCountAtStepStart = enemies.Count;
for (int i = 0; i < enemyCountAtStepStart; i++)
if (enemies[i] != null) enemies[i].Step(dt);
foreach (Defense25DSummon summon in summons) summon.Step(dt);
OfferPortalRewards();
TryCollectPortalRewards();
TryFinishRunClear();
if (!longRunMode)
{
OfferPortalRewards();
TryCollectPortalRewards();
TryFinishRunClear();
}
}
public void AdvanceRunClock(float dt)
{
if (float.IsNaN(dt) || float.IsInfinity(dt) || dt < 0f)
throw new ArgumentOutOfRangeException(nameof(dt), "Run clock steps must be finite and non-negative.");
GameTime += dt;
if (!longRunMode) return;
float combatDelta = dt;
if (RunPhase == Defense25DRunPhase.Preparing)
{
float preparationStep = Mathf.Min(preparationRemaining, combatDelta);
preparationRemaining = Mathf.Max(0f, preparationRemaining - preparationStep);
combatDelta -= preparationStep;
if (preparationRemaining <= 0f)
{
RunPhase = Defense25DRunPhase.Running;
combatElapsed = 0f;
AdvanceLongRunTimeline(combatElapsed);
}
else return;
}
if (combatDelta > 0f &&
(RunPhase == Defense25DRunPhase.Running || RunPhase == Defense25DRunPhase.BossActive))
{
AdvanceLongRunTimeline(combatElapsed + combatDelta);
}
}
public void SpawnScheduledEnemy(Defense25DSpawnRequest request)
{
if (!CanAcceptLongRunSpawn(request))
return;
if (request.PortalId < 0 || request.PortalId >= routes.Length)
throw new InvalidOperationException("The scheduled spawn has no valid portal route.");
if (!TrySpawnScheduledPoolEnemy(request) && CanAcceptLongRunSpawn(request))
throw new InvalidOperationException($"Scheduled enemy '{request.EnemyDefinitionId}' was rejected by the run spawn gate.");
}
public bool CanAcceptLongRunSpawn(Defense25DSpawnRequest request) =>
longRunMode && request != null && request.RunGeneration == runGeneration
&& !RunFailed && !RunCleared
&& RunPhase is (Defense25DRunPhase.Running or Defense25DRunPhase.BossActive)
&& waveDirector != null && !waveDirector.IsSupplyStopped;
public Defense25DEnemy SpawnNextEnemy()
{
if (Ledger.SuppliedCount >= enemies.Count) return null;
@@ -720,9 +1101,15 @@ namespace BumpCombat.Defense.Comparison
unique.Clear();
Vector3 low = Defense25DActor.OnGround(center, settings.GroundY + .05f);
Vector3 high = low + Vector3.up * settings.ActorHeight;
int count = Physics.OverlapBoxNonAlloc((low + high) * .5f,
new Vector3(radii.x + 1f, settings.ActorHeight, radii.y + 1f), overlap,
Quaternion.identity, Defense25DComparisonSettings.ActorMask, QueryTriggerInteraction.Ignore);
int count;
while (true)
{
count = Physics.OverlapBoxNonAlloc((low + high) * .5f,
new Vector3(radii.x + 1f, settings.ActorHeight, radii.y + 1f), overlap,
Quaternion.identity, Defense25DComparisonSettings.ActorMask, QueryTriggerInteraction.Ignore);
if (count < overlap.Length) break;
Array.Resize(ref overlap, checked(overlap.Length * 2));
}
for (int i = 0; i < count; i++)
{
Defense25DActor actor = overlap[i].GetComponentInParent<Defense25DActor>();
@@ -787,7 +1174,7 @@ namespace BumpCombat.Defense.Comparison
public bool TryUseCyclone(bool charged, Vector2 direction)
{
if (!IsRunning || Paused || !Player.IsAlive || Player.IsHurt || CycloneActive) return false;
if (longRunMode || !IsRunning || Paused || !Player.IsAlive || Player.IsHurt || CycloneActive) return false;
var definition = settings.Cyclone;
float cost = charged ? definition.ChargedGaugeCost : definition.NormalGaugeCost;
if (Gauge < cost) return false;
@@ -816,6 +1203,7 @@ namespace BumpCombat.Defense.Comparison
private void StepCyclone(float dt)
{
if (longRunMode || !CycloneActive) return;
while (nextCycloneTick < scheduledCycloneTicks && CycloneElapsed + .0001f >= nextCycloneTick * cycloneInterval)
{
DealCycloneTick(nextCycloneTick == scheduledCycloneTicks - 1);
@@ -898,6 +1286,65 @@ namespace BumpCombat.Defense.Comparison
public void ResolveEnemy(Defense25DEnemy enemy, DefenseEnemyResolution resolution)
{
if (longRunMode)
{
if (enemy == null || RunFailed || RunCleared || RunPhase == Defense25DRunPhase.Victory ||
RunPhase == Defense25DRunPhase.Defeat || enemy.SpawnContext.RunGeneration != runGeneration
|| enemy.SpawnContext.SpawnId != enemy.Id || !Ledger.TryResolve(enemy.Id, resolution)) return;
Defense25DEnemySpawnContext spawnContext = enemy.SpawnContext;
bool wasResolvingLongRunEnemy = resolvingLongRunEnemy;
resolvingLongRunEnemy = true;
try
{
if (resolution == DefenseEnemyResolution.Death)
RelicRuntime?.NotifyEnemyDeath(CreateRelicTarget(enemy), Player.CurrentHealth);
if (resolution == DefenseEnemyResolution.Death)
{
KillCurrency += Tuning.EnemyKillCurrency;
TowerManager.AddCurrency(Tuning.EnemyKillCurrency);
longRunProgression?.GrantKillExperience(enemy.LongRunDefinition != null
? enemy.LongRunDefinition.ExperienceValue : 0);
QueueLongRunBossDeathReward(spawnContext);
enemy.RetireForDeathVisual();
}
else
{
BaseHealth = Mathf.Max(0f, BaseHealth - Tuning.ArrivalBaseDamage);
enemy.Retire();
}
bool finalBossArrival = spawnContext.Source == Defense25DSpawnSource.FinalBoss &&
resolution == DefenseEnemyResolution.Arrival;
bool runFailedByResolution = finalBossArrival || DefensePrototypeRules.IsBaseDestroyed(BaseHealth);
if (spawnContext.Source == Defense25DSpawnSource.PersonalMission)
{
personalMissions.ResolveTarget(spawnContext.RunGeneration, spawnContext.SpawnId,
spawnContext.Source, spawnContext.PersonalMissionId, spawnContext.MissionInstanceId, resolution);
}
NotifyLongRunSummonOwnerResolved(spawnContext);
if (runFailedByResolution)
{
runEndReason = finalBossArrival
? Defense25DRunEndReason.FinalBossArrived
: Defense25DRunEndReason.BaseDestroyed;
RunFailed = true;
RunPhase = Defense25DRunPhase.Defeat;
waveDirector?.StopAllSupply();
pendingLongRunSpecialSpawns.Clear();
EndRun();
PlayLongRunTerminalFacilityEffects(DefensePrototypeRules.IsBaseDestroyed(BaseHealth));
}
if (spawnContext.Source == Defense25DSpawnSource.FinalBoss &&
resolution == DefenseEnemyResolution.Death && !RunFailed)
BeginLongRunClearing();
}
finally
{
resolvingLongRunEnemy = wasResolvingLongRunEnemy;
}
if (!wasResolvingLongRunEnemy) HandleLongRunProgressionChanged();
return;
}
if (enemy == null || !Ledger.TryResolve(enemy.Id, resolution)) return;
if (resolution == DefenseEnemyResolution.Death)
RelicRuntime?.NotifyEnemyDeath(CreateRelicTarget(enemy), Player.CurrentHealth);
@@ -909,14 +1356,42 @@ namespace BumpCombat.Defense.Comparison
else enemy.Retire();
if (DefensePrototypeRules.IsBaseDestroyed(BaseHealth))
{
runEndReason = Defense25DRunEndReason.BaseDestroyed;
RunFailed = true;
EndRun();
}
}
private void QueueLongRunBossDeathReward(Defense25DEnemySpawnContext spawnContext)
{
if (longRunProgression == null) return;
Defense25DRewardSource? rewardSource = spawnContext.Source switch
{
Defense25DSpawnSource.GlobalElite => Defense25DRewardSource.GlobalElite,
Defense25DSpawnSource.GlobalMidBoss => Defense25DRewardSource.GlobalMidBoss,
_ => null,
};
if (rewardSource.HasValue)
longRunProgression.QueueReward(rewardSource.Value,
$"run:{spawnContext.RunGeneration}:boss:{spawnContext.SpawnId}");
}
private void NotifyLongRunSummonOwnerResolved(Defense25DEnemySpawnContext spawnContext)
{
if (spawnContext.SummonOwnerSpawnId <= 0) return;
Defense25DEnemy owner = enemies.Find(candidate => candidate != null &&
candidate.SpawnContext.RunGeneration == spawnContext.RunGeneration &&
candidate.SpawnContext.SpawnId == spawnContext.SummonOwnerSpawnId);
owner?.BossCombat?.NotifyOwnedSummonResolved(spawnContext.RunGeneration,
spawnContext.SpawnId, spawnContext.SummonOwnerSpawnId);
}
public void EndRun()
{
IsRunning = false;
if (longRunMode) waveDirector?.StopAllSupply();
ArtifactExecutor?.Cancel();
RelicRuntime?.EndRun();
CancelCyclone();
@@ -929,8 +1404,29 @@ namespace BumpCombat.Defense.Comparison
foreach (Defense25DSummon summon in summons) { summon.SuspendAttack(); summon.Actor.StopBody(); }
}
public bool NotifyLongRunBossActivated()
{
if (!longRunMode || RunPhase != Defense25DRunPhase.Running || RunCleared || RunFailed)
return false;
RunPhase = Defense25DRunPhase.BossActive;
return true;
}
public bool BeginLongRunClearing()
{
if (!longRunMode || RunPhase != Defense25DRunPhase.BossActive || RunCleared || RunFailed)
return false;
waveDirector?.StopAllSupply();
pendingLongRunSpecialSpawns.Clear();
RunPhase = Defense25DRunPhase.Clearing;
return true;
}
private void OnDisable()
{
facilityVisuals?.Clear();
runHud?.Unbind();
view?.ClearFollowTarget();
CleanupPresentationDamagePopups();
combatFeedback?.ResetFeedback();
if (combatFeedback != null) combatFeedback.enabled = false;
@@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Progression;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DComparison
{
private static readonly Rect ArtifactDropChoiceRect = new(112, 96, 416, 168);
private readonly GameObject[] artifactDropVisuals = new GameObject[3];
private readonly HashSet<Defense25DEnemy> longRunDynamicEnemies = new();
private Defense25DArtifactDrops artifactDrops;
private bool[] longRunPortalsNotifiedOpen;
private MaterialPropertyBlock artifactDropMaterialProperties;
private Defense25DProgression longRunProgression;
public Defense25DProgression Progression => longRunMode ? longRunProgression : null;
public event Action<Defense25DArtifactDropState> ArtifactDropSpawned;
public event Action<Defense25DSpawnRequest> LongRunSpawnRequested;
public IReadOnlyList<Defense25DArtifactDropState> ArtifactDropStates => artifactDrops?.DropStates
?? Array.Empty<Defense25DArtifactDropState>();
public IReadOnlyList<ActiveArtifactDefinition> CurrentArtifactDropCandidates =>
artifactDrops?.CurrentCandidates ?? Array.Empty<ActiveArtifactDefinition>();
public bool HasPendingArtifactDropChoice => CurrentArtifactDropCandidates.Count > 0;
public bool CanOpenArtifactDropInteraction => longRunMode && IsRunning && !Paused &&
RunPhase is (Defense25DRunPhase.Running or Defense25DRunPhase.BossActive) && !RunCleared && !RunFailed &&
Player != null && Player.IsAlive && !HasPendingFieldRelicReward &&
!HasPendingArtifactDropChoice && !showingArtifactSelection && !startupSelectionPending &&
!IsValidationShowcaseActive && !HasPendingLongRunRewardChoice;
private bool HasPendingLongRunRewardChoice => longRunProgression != null &&
(longRunProgression.HasPendingLevelChoice ||
longRunProgression.RewardChoices != null &&
longRunProgression.RewardChoices.PendingRewardCount > 0);
private void ClearLongRunDynamicEnemies()
{
foreach (Defense25DEnemy enemy in longRunDynamicEnemies.ToArray())
{
if (enemy == null) continue;
enemies.Remove(enemy);
if (TryReturnLongRunEnemyToPool(enemy)) continue;
enemy.gameObject.SetActive(false);
if (Application.isPlaying) Destroy(enemy.gameObject);
else DestroyImmediate(enemy.gameObject);
}
longRunDynamicEnemies.Clear();
}
private void InitializeLongRunArtifactDrops()
{
if (!longRunMode) return;
artifactDrops = new Defense25DArtifactDrops();
artifactDrops.DropSpawned += HandleLongRunArtifactDropSpawned;
artifactDrops.DropClaimed += HandleLongRunArtifactDropClaimed;
artifactDrops.ReliefWindowRequested += RegisterLongRunReliefWindow;
longRunPortalsNotifiedOpen = new bool[longRunDefinition.PortalDefinitions.Length];
}
private void ResetLongRunArtifactDrops()
{
if (!longRunMode || artifactDrops == null) return;
Array.Clear(longRunPortalsNotifiedOpen, 0, longRunPortalsNotifiedOpen.Length);
for (int i = 0; i < artifactDropVisuals.Length; i++)
if (artifactDropVisuals[i] != null) artifactDropVisuals[i].SetActive(false);
Defense25DArtifactDropConstants constants = longRunDefinition.ArtifactDropConstants;
int seed = unchecked(longRunSeed + runGeneration * 104729);
artifactDrops.Bind(this, longRunLayout, constants, seed);
artifactDrops.Reset(runGeneration);
}
private void CleanupLongRunArtifactDrops()
{
if (artifactDrops == null) return;
artifactDrops.DropSpawned -= HandleLongRunArtifactDropSpawned;
artifactDrops.DropClaimed -= HandleLongRunArtifactDropClaimed;
artifactDrops.ReliefWindowRequested -= RegisterLongRunReliefWindow;
artifactDrops = null;
}
private void AdvanceLongRunTimeline(float targetCombatElapsed)
{
if (artifactDrops == null || waveDirector == null)
throw new InvalidOperationException("Long-run timeline owners must be initialized before advancing combat time.");
if (targetCombatElapsed < combatElapsed)
throw new ArgumentOutOfRangeException(nameof(targetCombatElapsed), "Combat time must be monotonic.");
int processedEvents = 0;
while (TryGetNextLongRunTimelineEvent(targetCombatElapsed, out float eventTime))
{
if (++processedEvents > longRunPortalsNotifiedOpen.Length + artifactDropVisuals.Length + 1)
throw new InvalidOperationException("Long-run timeline failed to consume a portal-open or artifact-drop event.");
waveDirector.AdvanceBefore(eventTime, SpawnScheduledEnemy);
combatElapsed = eventTime;
NotifyPortalsOpeningAt(eventTime);
artifactDrops.Step(eventTime);
waveDirector.AdvanceTo(eventTime, SpawnScheduledEnemy);
}
if (targetCombatElapsed > combatElapsed)
{
combatElapsed = targetCombatElapsed;
artifactDrops.Step(targetCombatElapsed);
waveDirector.AdvanceTo(targetCombatElapsed, SpawnScheduledEnemy);
}
else if (processedEvents == 0)
{
artifactDrops.Step(targetCombatElapsed);
waveDirector.AdvanceTo(targetCombatElapsed, SpawnScheduledEnemy);
}
}
private bool TryGetNextLongRunTimelineEvent(float targetCombatElapsed, out float eventTime)
{
eventTime = float.PositiveInfinity;
for (int i = 0; i < longRunDefinition.PortalDefinitions.Length; i++)
{
Defense25DPortalDefinition portal = longRunDefinition.PortalDefinitions[i];
if (longRunPortalsNotifiedOpen[i] || portal.OpenAt < combatElapsed ||
portal.OpenAt > targetCombatElapsed || waveDirector.IsSupplyStopped)
continue;
eventTime = Mathf.Min(eventTime, portal.OpenAt);
}
foreach (Defense25DArtifactDropState drop in artifactDrops.DropStates)
{
if (drop.Status != Defense25DArtifactDropStatus.Scheduled ||
float.IsNaN(drop.AvailableAt) || float.IsInfinity(drop.AvailableAt) ||
drop.AvailableAt < combatElapsed || drop.AvailableAt > targetCombatElapsed)
continue;
eventTime = Mathf.Min(eventTime, drop.AvailableAt);
}
return !float.IsInfinity(eventTime);
}
private void NotifyPortalsOpeningAt(float eventTime)
{
for (int i = 0; i < longRunDefinition.PortalDefinitions.Length; i++)
{
Defense25DPortalDefinition portal = longRunDefinition.PortalDefinitions[i];
if (longRunPortalsNotifiedOpen[i] || !Mathf.Approximately(portal.OpenAt, eventTime)) continue;
longRunPortalsNotifiedOpen[i] = true;
artifactDrops.NotifyPortalOpened(portal.PortalId, portal.OpenAt);
facilityVisuals?.PlayPortalSpawn(portal.PortalId,
Defense25DActor.OnGround(longRunLayout.PortalPositions[portal.PortalId], settings.GroundY));
}
}
private void RegisterLongRunReliefWindow(string token, float startAt, float durationSeconds)
{
waveDirector?.RegisterReliefWindow(token, startAt, durationSeconds);
}
private void HandleLongRunArtifactDropSpawned(Defense25DArtifactDropState drop)
{
if (drop == null || drop.RunGeneration != runGeneration) return;
GameObject visual = artifactDropVisuals[drop.DropId];
if (visual == null)
{
visual = GameObject.CreatePrimitive(PrimitiveType.Sphere);
visual.name = $"Artifact cache {drop.DropId + 1}";
visual.transform.SetParent(transform, false);
visual.transform.localScale = Vector3.one * .48f;
Collider collider = visual.GetComponent<Collider>();
if (collider != null) Destroy(collider);
artifactDropVisuals[drop.DropId] = visual;
}
visual.transform.position = Defense25DActor.OnGround(drop.Position, settings.GroundY + .28f);
Renderer renderer = visual.GetComponent<Renderer>();
if (renderer != null)
{
artifactDropMaterialProperties ??= new MaterialPropertyBlock();
artifactDropMaterialProperties.Clear();
artifactDropMaterialProperties.SetColor("_BaseColor", new Color32(104, 222, 239, 255));
artifactDropMaterialProperties.SetColor("_Color", new Color32(104, 222, 239, 255));
renderer.SetPropertyBlock(artifactDropMaterialProperties);
}
visual.SetActive(true);
ArtifactDropSpawned?.Invoke(drop);
}
private void HandleLongRunArtifactDropClaimed(Defense25DArtifactDropState drop)
{
if (drop == null || drop.RunGeneration != runGeneration) return;
if (drop.DropId >= 0 && drop.DropId < artifactDropVisuals.Length && artifactDropVisuals[drop.DropId] != null)
artifactDropVisuals[drop.DropId].SetActive(false);
}
public bool TryInteractWithArtifactDrop(int dropId)
{
if (!CanOpenArtifactDropInteraction || artifactDrops == null) return false;
showingArtifactSelection = false;
return artifactDrops.TryInteract(dropId);
}
public bool TryInteractWithNearestArtifactDrop()
{
if (!CanOpenArtifactDropInteraction || Player == null) return false;
Defense25DArtifactDropState nearest = null;
float nearestDistance = float.PositiveInfinity;
foreach (Defense25DArtifactDropState drop in ArtifactDropStates)
{
if (drop.Status != Defense25DArtifactDropStatus.Available) continue;
float distance = Vector2.Distance(Player.Position, drop.Position);
if (distance < nearestDistance)
{
nearestDistance = distance;
nearest = drop;
}
}
return nearest != null && TryInteractWithArtifactDrop(nearest.DropId);
}
public bool TryChooseArtifactDrop(int candidateIndex)
{
if (artifactDrops == null || !HasPendingArtifactDropChoice || !IsRunning || Paused ||
RunPhase is not (Defense25DRunPhase.Running or Defense25DRunPhase.BossActive) ||
RunCleared || RunFailed || Player == null || !Player.IsAlive || HasPendingFieldRelicReward ||
showingArtifactSelection || startupSelectionPending || HasPendingLongRunRewardChoice)
return false;
return artifactDrops.TryChoose(candidateIndex, runGeneration, artifactDrops.CurrentChoiceToken);
}
public bool ToggleOwnedArtifactPicker()
{
if (showingArtifactSelection)
{
showingArtifactSelection = false;
return true;
}
if (!CanOpenArtifactDropInteraction) return false;
showingArtifactSelection = true;
return true;
}
private void DrawLongRunArtifactDropChoiceGUI()
{
if (!HasPendingArtifactDropChoice) return;
GUI.Box(ArtifactDropChoiceRect, "Artifact cache — choose one");
for (int i = 0; i < CurrentArtifactDropCandidates.Count; i++)
{
ActiveArtifactDefinition candidate = CurrentArtifactDropCandidates[i];
Rect button = new(132, 132 + i * 40, 376, 34);
if (GUI.Button(button, candidate.DisplayName)) TryChooseArtifactDrop(i);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4c46190e2ef74241aafe5ad9b3ac7c54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,548 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Enemies;
using BumpCombat.Progression;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DComparison
{
private readonly List<Defense25DEnemy> pooledLongRunEnemies = new();
internal bool TryReturnLongRunEnemyToPool(Defense25DEnemy enemy)
{
string definitionId = enemy != null ? enemy.PoolReuseDefinitionId : null;
if (enemy == null || enemy.Actor == null || enemy.Actor.Kind != ComparisonActorKind.Enemy ||
string.IsNullOrWhiteSpace(definitionId) || longRunDefinition?.MonsterPoolDefinition == null ||
!longRunDefinition.MonsterPoolDefinition.TryGetEntry(definitionId,
out Defense25DMonsterPoolEntry entry) || entry == null ||
enemy.LongRunDefinition != entry.Definition || enemy.Actor.BodyCollider == null)
return false;
enemy.ResetForRun();
if (enemy.gameObject.activeInHierarchy || enemy.IsAvailable ||
enemy.Actor.BodyCollider.gameObject.activeInHierarchy)
return false;
enemies.Remove(enemy);
longRunDynamicEnemies.Remove(enemy);
if (!pooledLongRunEnemies.Contains(enemy))
{
pooledLongRunEnemies.Add(enemy);
pooledLongRunEnemies.Sort((left, right) => left.Id.CompareTo(right.Id));
}
return true;
}
private IReadOnlyList<int> GetOpenLongRunPortalIds()
{
if (waveDirector == null || longRunDefinition == null)
return Array.Empty<int>();
List<int> open = new();
foreach (Defense25DPortalDefinition portal in longRunDefinition.PortalDefinitions)
if (waveDirector.IsPortalOpen(portal.PortalId)) open.Add(portal.PortalId);
return open.AsReadOnly();
}
private bool SpawnLongRunPersonalMissionTargets(
IReadOnlyList<Defense25DPersonalMissionSpawnRequest> requests,
out IReadOnlyList<int> spawnIds)
{
spawnIds = Array.Empty<int>();
if (requests == null || requests.Count == 0 || longRunDefinition == null ||
longRunDefinition.MonsterPoolDefinition == null || longRunDefinition.MonsterSourceMotionCatalog == null)
return false;
Defense25DPersonalMissionSpawnRequest first = requests[0];
if (first == null || first.RunGeneration != runGeneration || first.MissionId is < 1 or > 3)
return false;
EnemyDefinition targetDefinition = first.MissionId == 3
? longRunDefinition.GlobalMidBossDefinition : longRunDefinition.GlobalEliteDefinition;
if (!TryFindPoolEntry(targetDefinition, out Defense25DMonsterPoolEntry poolEntry) ||
!longRunDefinition.MonsterSourceMotionCatalog.TryResolve(poolEntry,
out Defense25DMonsterSourceMotion sourceMotion, out _))
return false;
int[] portalIds = new int[requests.Count];
HashSet<int> uniquePortals = new();
for (int i = 0; i < requests.Count; i++)
{
Defense25DPersonalMissionSpawnRequest request = requests[i];
if (request == null || request.RunGeneration != runGeneration ||
request.MissionId != first.MissionId || request.MissionInstanceId != first.MissionInstanceId ||
request.PortalId < 0 || request.PortalId >= routes.Length ||
!waveDirector.IsPortalOpen(request.PortalId) || !uniquePortals.Add(request.PortalId))
return false;
portalIds[i] = request.PortalId;
}
if (!TrySpawnLongRunPoolBatch(poolEntry, sourceMotion, portalIds, 0,
Defense25DSpawnSource.PersonalMission, first.MissionId, first.MissionInstanceId,
out int[] createdIds))
return false;
spawnIds = Array.AsReadOnly(createdIds);
return true;
}
private bool TrySpawnScheduledPoolEnemy(Defense25DSpawnRequest request)
{
if (request == null || !CanAcceptLongRunSpawn(request)) return false;
if (request.Source == Defense25DSpawnSource.FinalBoss)
{
if (RunPhase != Defense25DRunPhase.Running ||
enemies.Exists(enemy => enemy != null && enemy.SpawnContext.RunGeneration == runGeneration &&
enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss))
return false;
return TrySpawnLongRunPoolBatch(null, null, new[] { request.PortalId }, request.GlobalWaveId,
request.Source, 0, 0, out _, request,
longRunDefinition.FinalBossDefinition, longRunDefinition.FinalBossVisualPrefab);
}
EnemyDefinition definition = request.Source switch
{
Defense25DSpawnSource.Normal or Defense25DSpawnSource.BossSummon => null,
Defense25DSpawnSource.GlobalElite => longRunDefinition.GlobalEliteDefinition,
Defense25DSpawnSource.GlobalMidBoss => longRunDefinition.GlobalMidBossDefinition,
_ => throw new InvalidOperationException($"Unsupported scheduled source {request.Source}.")
};
if (definition == null && request.Source is Defense25DSpawnSource.GlobalElite or Defense25DSpawnSource.GlobalMidBoss)
throw new InvalidOperationException($"RunDefinition has no typed definition for {request.Source}.");
if (longRunDefinition?.MonsterPoolDefinition == null)
throw new InvalidOperationException($"A typed monster pool is required for {request.Source} spawns.");
Defense25DMonsterPoolEntry entry;
if (definition != null)
{
if (!TryFindPoolEntry(definition, out entry))
throw new InvalidOperationException($"No exact monster-pool entry references the {request.Source} definition '{definition.name}'.");
}
else if (!longRunDefinition.MonsterPoolDefinition.TryGetEntry(request.EnemyDefinitionId, out entry))
{
throw new InvalidOperationException($"No exact monster-pool entry exists for '{request.EnemyDefinitionId}'.");
}
if (longRunDefinition.MonsterSourceMotionCatalog == null)
throw new InvalidOperationException($"A typed source-motion catalog is required for {request.Source} spawns.");
if (!longRunDefinition.MonsterSourceMotionCatalog.TryResolve(entry,
out Defense25DMonsterSourceMotion motion, out string motionError))
throw new InvalidOperationException($"Cannot resolve exact enemy motion for '{entry.EnemyDefinitionId}': {motionError}");
return TrySpawnLongRunPoolBatch(entry, motion, new[] { request.PortalId }, request.GlobalWaveId,
request.Source, 0, 0, out _, request);
}
private bool TrySpawnLongRunPoolBatch(Defense25DMonsterPoolEntry entry,
Defense25DMonsterSourceMotion motion, IReadOnlyList<int> portalIds, int globalWaveId,
Defense25DSpawnSource source, int missionId, int missionInstanceId, out int[] spawnIds,
Defense25DSpawnRequest eventTemplate = null, EnemyDefinition specialDefinition = null,
GameObject specialVisualPrefab = null, int summonOwnerSpawnId = 0)
{
spawnIds = Array.Empty<int>();
EnemyDefinition definition = entry != null ? entry.Definition : specialDefinition;
if (definition == null || (entry != null && motion == null) || (entry == null && motion != null) ||
portalIds == null || portalIds.Count == 0 ||
nextLongRunSpawnId < 0 || portalIds.Count > int.MaxValue - nextLongRunSpawnId)
return false;
if (entry == null && (source != Defense25DSpawnSource.FinalBoss || specialVisualPrefab == null))
return false;
for (int i = 0; i < portalIds.Count; i++)
{
if (portalIds[i] < 0 || portalIds[i] >= routes.Length) return false;
var gateRequest = new Defense25DSpawnRequest(runGeneration, globalWaveId, portalIds[i],
source, entry != null ? entry.EnemyDefinitionId : eventTemplate?.EnemyDefinitionId ?? definition.name,
entry != null ? entry.ThreatCost : eventTemplate?.ThreatCost ?? 0f, 0f, combatElapsed, combatElapsed,
combatElapsed, i);
if (!CanAcceptLongRunSpawn(gateRequest)) return false;
}
List<Defense25DEnemy> staged = new(portalIds.Count);
HashSet<Defense25DEnemy> reused = new();
int firstId = nextLongRunSpawnId;
try
{
for (int i = 0; i < portalIds.Count; i++)
{
int spawnId = firstId + i;
int portalId = portalIds[i];
Vector2 spawnPosition = routes[portalId][0];
Defense25DEnemy enemy = entry != null ? FindReadyLongRunPoolEnemy(entry) : null;
Defense25DActor actor;
if (enemy != null)
{
enemy.ResetForRun();
reused.Add(enemy);
actor = enemy.Actor;
}
else
{
actor = CreateActor(ComparisonActorKind.Enemy,
$"Long Run {source} {spawnId} ({entry?.EnemyDefinitionId ?? definition.name})", spawnPosition);
}
if (entry != null && !actor.SetEnemyBodyRadius(entry.CombatProfile.BodyRadius))
throw new InvalidOperationException($"Could not apply '{entry.EnemyDefinitionId}' body radius.");
if (enemy == null) enemy = actor.gameObject.AddComponent<Defense25DEnemy>();
if (entry != null) enemy.ConfigureLongRun(this, actor, spawnId, portalId, entry);
else enemy.ConfigureLongRunSpecial(this, actor, spawnId, portalId, definition);
enemy.SetSpawnContext(new Defense25DEnemySpawnContext(runGeneration, spawnId, portalId,
globalWaveId, source, missionId, missionInstanceId,
source == Defense25DSpawnSource.PersonalMission, summonOwnerSpawnId));
AttachEnemyStatusVisual(enemy);
enemy.Spawn(spawnPosition);
staged.Add(enemy);
if (entry != null)
{
if (!enemy.BindLongRunMonsterCombat(motion, out string bindError))
throw new InvalidOperationException($"Could not bind '{entry.EnemyDefinitionId}' combat: {bindError}");
}
else if (!enemy.BindLongRunFinalBossVisual(specialVisualPrefab, out string visualError))
{
throw new InvalidOperationException($"Could not bind the final-boss visual: {visualError}");
}
if (source is Defense25DSpawnSource.GlobalElite or Defense25DSpawnSource.GlobalMidBoss or Defense25DSpawnSource.FinalBoss)
{
Defense25DBossCombat bossCombat = new();
if (!bossCombat.Bind(this, enemy, definition, SpawnLongRunBossPhaseSummons,
enemy.SetLongRunBossProtectionVisual, SpawnLongRunBossAmbientSummons,
enemy.SetLongRunBossGroggyVisual, enemy.HandleLongRunBossSummonTelegraph))
throw new InvalidOperationException($"Could not bind {source} role combat.");
if (!enemy.BindBossCombat(bossCombat))
{
bossCombat.Cancel();
throw new InvalidOperationException($"Could not attach {source} role combat to spawn {spawnId}.");
}
}
}
}
catch
{
DestroyStagedLongRunEnemies(staged, reused);
throw;
}
int[] stagedIds = new int[staged.Count];
for (int i = 0; i < stagedIds.Length; i++) stagedIds[i] = firstId + i;
if (!Ledger.RegisterSuppliesAtomically(stagedIds))
{
DestroyStagedLongRunEnemies(staged, reused);
return false;
}
for (int i = 0; i < staged.Count; i++)
{
pooledLongRunEnemies.Remove(staged[i]);
if (!enemies.Contains(staged[i])) enemies.Add(staged[i]);
longRunDynamicEnemies.Add(staged[i]);
}
nextLongRunSpawnId += staged.Count;
spawnIds = stagedIds;
if (source == Defense25DSpawnSource.FinalBoss && RunPhase == Defense25DRunPhase.Running &&
!NotifyLongRunBossActivated())
Debug.LogError("A committed FinalBoss could not activate the long-run boss phase.", this);
for (int i = 0; i < staged.Count; i++)
{
Defense25DEnemy enemy = staged[i];
Defense25DSpawnRequest published = eventTemplate != null && staged.Count == 1
? eventTemplate
: new Defense25DSpawnRequest(runGeneration, globalWaveId, enemy.Lane, source,
entry != null ? entry.EnemyDefinitionId : definition.name,
entry != null ? entry.ThreatCost : 0f, 0f, combatElapsed, combatElapsed,
combatElapsed, i);
PublishLongRunSpawnRequest(published);
}
return true;
}
private IReadOnlyList<int> SpawnLongRunBossPhaseSummons(Defense25DEnemy owner)
{
if (longRunDefinition == null ||
!TryFindPoolEntry(longRunDefinition.GlobalEliteDefinition, out Defense25DMonsterPoolEntry eliteEntry) ||
!TryFindPoolEntry(longRunDefinition.GlobalMidBossDefinition, out Defense25DMonsterPoolEntry midBossEntry))
return Array.Empty<int>();
return TrySpawnLongRunBossSummonBatch(owner,
new[] { eliteEntry, eliteEntry, midBossEntry });
}
private IReadOnlyList<int> SpawnLongRunBossAmbientSummons(Defense25DEnemy owner, bool crowd)
{
if (longRunDefinition?.MonsterPoolDefinition == null || owner == null ||
!Defense25DMonsterPoolDefinition.TryGetTierForNominalStart(combatElapsed, out int tierId))
return Array.Empty<int>();
Defense25DMonsterTierPool tier = null;
foreach (Defense25DMonsterTierPool candidate in longRunDefinition.MonsterPoolDefinition.Tiers)
if (candidate.TierId == tierId) { tier = candidate; break; }
if (tier == null || tier.Entries.Count == 0)
return Array.Empty<int>();
int summonCount = crowd ? 3 : 1;
List<Defense25DMonsterPoolEntry> candidates = new(tier.Entries);
int seed = unchecked(longRunSeed ^ (owner.SpawnContext.SpawnId * 397) ^
Mathf.RoundToInt(owner.BossCombat.CombatElapsed * 1000f) ^ (crowd ? 0x43524F57 : 0x47454E));
Defense25DDeterministicRandom random = new(seed, 0x424F5353u);
List<Defense25DMonsterPoolEntry> selected = new(summonCount);
for (int i = 0; i < summonCount; i++)
{
int index = random.NextInt(candidates.Count);
selected.Add(candidates[index]);
candidates.RemoveAt(index);
if (candidates.Count == 0 && i + 1 < summonCount)
candidates.AddRange(tier.Entries);
}
return TrySpawnLongRunBossSummonBatch(owner, selected);
}
private IReadOnlyList<int> TrySpawnLongRunBossSummonBatch(Defense25DEnemy owner,
IReadOnlyList<Defense25DMonsterPoolEntry> entries)
{
if (owner == null || owner.SpawnContext.RunGeneration != runGeneration || owner.BossCombat == null ||
!owner.BossCombat.IsBound || entries == null || entries.Count == 0 ||
owner.Lane < 0 || owner.Lane >= routes.Length || nextLongRunSpawnId < 0 ||
entries.Count > int.MaxValue - nextLongRunSpawnId)
return Array.Empty<int>();
Defense25DMonsterSourceMotion[] motions = new Defense25DMonsterSourceMotion[entries.Count];
for (int i = 0; i < entries.Count; i++)
{
Defense25DMonsterPoolEntry entry = entries[i];
if (!TryResolveLongRunMotion(entry, out motions[i]))
return Array.Empty<int>();
Defense25DSpawnRequest gateRequest = new(runGeneration, owner.SpawnContext.GlobalWaveId,
owner.Lane, Defense25DSpawnSource.BossSummon, entry.EnemyDefinitionId,
entry.ThreatCost, 0f, combatElapsed, combatElapsed, combatElapsed, i);
if (!CanAcceptLongRunSpawn(gateRequest))
return Array.Empty<int>();
}
int firstId = nextLongRunSpawnId;
List<Defense25DEnemy> staged = new(entries.Count);
HashSet<Defense25DEnemy> reused = new();
try
{
for (int i = 0; i < entries.Count; i++)
{
Defense25DMonsterPoolEntry entry = entries[i];
int spawnId = firstId + i;
Vector2 position = routes[owner.Lane][0];
Defense25DEnemy summon = FindReadyLongRunPoolEnemy(entry);
Defense25DActor actor;
if (summon != null)
{
summon.ResetForRun();
reused.Add(summon);
actor = summon.Actor;
}
else
{
actor = CreateActor(ComparisonActorKind.Enemy,
$"Boss summon {spawnId} ({entry.EnemyDefinitionId})", position);
}
if (!actor.SetEnemyBodyRadius(entry.CombatProfile.BodyRadius))
throw new InvalidOperationException($"Could not apply '{entry.EnemyDefinitionId}' body radius.");
if (summon == null) summon = actor.gameObject.AddComponent<Defense25DEnemy>();
summon.ConfigureLongRun(this, actor, spawnId, owner.Lane, entry);
summon.SetSpawnContext(new Defense25DEnemySpawnContext(runGeneration, spawnId,
owner.Lane, owner.SpawnContext.GlobalWaveId, Defense25DSpawnSource.BossSummon,
summonOwnerSpawnId: owner.SpawnContext.SpawnId));
AttachEnemyStatusVisual(summon);
summon.Spawn(position);
staged.Add(summon);
if (!summon.BindLongRunMonsterCombat(motions[i], out string bindError))
throw new InvalidOperationException($"Could not bind boss summon combat: {bindError}");
}
}
catch
{
DestroyStagedLongRunEnemies(staged, reused);
throw;
}
int[] ids = new int[entries.Count];
for (int i = 0; i < ids.Length; i++) ids[i] = firstId + i;
if (!Ledger.RegisterSuppliesAtomically(ids))
{
DestroyStagedLongRunEnemies(staged, reused);
return Array.Empty<int>();
}
foreach (Defense25DEnemy summon in staged)
{
pooledLongRunEnemies.Remove(summon);
if (!enemies.Contains(summon)) enemies.Add(summon);
longRunDynamicEnemies.Add(summon);
}
nextLongRunSpawnId += staged.Count;
for (int i = 0; i < staged.Count; i++)
{
Defense25DMonsterPoolEntry entry = entries[i];
PublishLongRunSpawnRequest(new Defense25DSpawnRequest(runGeneration,
owner.SpawnContext.GlobalWaveId, owner.Lane, Defense25DSpawnSource.BossSummon,
entry.EnemyDefinitionId, entry.ThreatCost, 0f, combatElapsed, combatElapsed,
combatElapsed, i));
}
return Array.AsReadOnly(ids);
}
private bool TryResolveLongRunMotion(Defense25DMonsterPoolEntry entry,
out Defense25DMonsterSourceMotion motion)
{
motion = null;
return entry != null && longRunDefinition?.MonsterSourceMotionCatalog != null &&
longRunDefinition.MonsterSourceMotionCatalog.TryResolve(entry, out motion, out _);
}
private void DestroyStagedLongRunEnemies(List<Defense25DEnemy> staged,
HashSet<Defense25DEnemy> reused = null)
{
foreach (Defense25DEnemy enemy in staged)
{
if (enemy == null) continue;
if (reused != null && reused.Contains(enemy))
{
enemy.ResetForRun();
continue;
}
enemies.Remove(enemy);
longRunDynamicEnemies.Remove(enemy);
enemy.gameObject.SetActive(false);
if (Application.isPlaying) Destroy(enemy.gameObject);
else DestroyImmediate(enemy.gameObject);
}
}
private Defense25DEnemy FindReadyLongRunPoolEnemy(Defense25DMonsterPoolEntry entry)
{
if (entry == null) return null;
foreach (Defense25DEnemy candidate in pooledLongRunEnemies)
{
if (candidate != null && candidate.IsReadyForPoolReuse &&
string.Equals(candidate.PoolReuseDefinitionId, entry.EnemyDefinitionId,
StringComparison.Ordinal))
return candidate;
}
foreach (Defense25DEnemy candidate in enemies)
{
if (candidate != null && candidate.IsReadyForPoolReuse &&
string.Equals(candidate.PoolReuseDefinitionId, entry.EnemyDefinitionId,
StringComparison.Ordinal))
return candidate;
}
return null;
}
private void PublishLongRunSpawnRequest(Defense25DSpawnRequest request)
{
Action<Defense25DSpawnRequest> listeners = LongRunSpawnRequested;
if (listeners == null) return;
foreach (Action<Defense25DSpawnRequest> listener in listeners.GetInvocationList())
{
try { listener(request); }
catch (Exception exception) { Debug.LogException(exception, this); }
}
}
private bool TryFindPoolEntry(EnemyDefinition definition, out Defense25DMonsterPoolEntry result)
{
result = null;
if (definition == null || longRunDefinition?.MonsterPoolDefinition == null) return false;
foreach (Defense25DMonsterTierPool tier in longRunDefinition.MonsterPoolDefinition.Tiers)
foreach (Defense25DMonsterPoolEntry candidate in tier.Entries)
{
if (candidate != null && candidate.Definition == definition)
{
result = candidate;
return true;
}
}
return false;
}
private void BindLongRunProgression()
{
if (longRunDefinition?.RewardConstants == null)
throw new InvalidOperationException("A typed long-run RewardConstants asset is required.");
longRunProgression ??= new Defense25DProgression();
longRunProgression.Changed -= HandleLongRunProgressionChanged;
Defense25DTowerModifiers towerModifiers = new(longRunDefinition.RewardConstants);
TowerManager.SetRunModifiers(towerModifiers);
longRunProgression.Bind(Player, longRunDefinition.RewardConstants,
GameplayConstants.Current.LevelUp.ModifierDefinitions, towerModifiers,
HasTowerFamily, amount => TowerManager.AddCurrency(amount),
GetMissionRelicCandidates, ApplyMissionRelic);
longRunProgression.Changed += HandleLongRunProgressionChanged;
}
private bool HasTowerFamily(DefenseTowerKind family)
{
if (TowerManager == null) return false;
foreach (Defense25DTower tower in TowerManager.TowersInConstructionOrder)
if (tower != null && tower.Kind == family) return true;
return false;
}
private IReadOnlyList<Defense25DRewardChoice> GetMissionRelicCandidates(string token)
{
if (Loadout == null) return Array.Empty<Defense25DRewardChoice>();
List<Defense25DRewardChoice> choices = new();
foreach (PassiveRelicId relic in Loadout.GetAvailableRelics())
{
string id = relic.ToString();
string display = ArtifactRewardController.GetPassiveRelicName(relic);
int descriptionStart = display.IndexOf('\n');
string title = descriptionStart >= 0 ? display.Substring(0, descriptionStart) : display;
string description = descriptionStart >= 0 ? display.Substring(descriptionStart + 1) : string.Empty;
choices.Add(Defense25DRewardChoice.CreatePersonalMissionRelic(id, "PassiveRelic",
new Defense25DRewardDisplayData(title, description), relic));
}
return choices.AsReadOnly();
}
private bool ApplyMissionRelic(Defense25DRewardChoice choice)
{
return choice?.Payload is PassiveRelicId relic && Loadout != null && Loadout.TryAcquireRelic(relic);
}
private void HandleLongRunProgressionChanged()
{
if (resolvingLongRunEnemy) return;
if (personalMissions != null && longRunProgression?.RewardChoices != null)
{
foreach (Defense25DPersonalMissionState state in personalMissions.States)
{
if (state.Status == Defense25DPersonalMissionStatus.RewardPending && state.RewardQueued &&
!longRunProgression.RewardChoices.HasPendingRewardToken(state.RewardToken))
personalMissions.TryCompleteReward(runGeneration, state.MissionInstanceId);
}
}
EvaluateLongRunEnd();
}
private void EvaluateLongRunEnd()
{
if (!longRunMode || RunPhase != Defense25DRunPhase.Clearing || RunCleared || RunFailed ||
RemainingEnemies > 0 || HasPendingFieldRelicReward || HasPendingArtifactDropChoice ||
HasPendingLongRunRewardChoice)
return;
foreach (Defense25DPersonalMissionState state in personalMissions.States)
if (state.Status == Defense25DPersonalMissionStatus.RewardPending) return;
RunCleared = true;
RunPhase = Defense25DRunPhase.Victory;
EndRun();
PlayLongRunTerminalFacilityEffects(baseDestroyed: false);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b6dcce73f75423bac66097882751f54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -102,7 +102,8 @@ namespace BumpCombat.Defense.Comparison
float x = 8f;
if (view != null && view.Camera != null)
{
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(selectedBuildPad);
Vector2 pad = longRunMode ? TowerManager.GetPadPosition(selectedBuildPad)
: GameplayConstants.Current.Defense.GetBuildPadPosition(selectedBuildPad);
Vector3 projected = view.Camera.WorldToViewportPoint(
new Vector3(pad.x, settings.GroundY, pad.y));
if (projected.x < .5f) x = 422f;
@@ -258,6 +259,7 @@ namespace BumpCombat.Defense.Comparison
if (!initialized) return;
InitializePresentationDamagePopups();
RestoreRuntimeFeedbackAfterEnable();
BindLongRunPresentation();
}
internal void SetCombatDamagePopupEventsSubscribed(bool subscribed)
@@ -288,7 +290,8 @@ namespace BumpCombat.Defense.Comparison
|| BottomControlsRect.Contains(gui)) return true;
Rect details = GetSelectedPadDetailsRect();
return details.width > 0f && details.Contains(gui)
|| showingArtifactSelection && ArtifactPickerRect.Contains(gui);
|| showingArtifactSelection && ArtifactPickerRect.Contains(gui)
|| HasPendingArtifactDropChoice && ArtifactDropChoiceRect.Contains(gui);
}
public void BindValidationShowcase(Func<bool> enter, Action exit)
@@ -427,6 +430,7 @@ namespace BumpCombat.Defense.Comparison
DrawTowerCatalog();
DrawPadAndUpgradePanel();
DrawFieldRelicReward();
DrawLongRunArtifactDropChoiceGUI();
DrawStartupArtifactPicker();
if (hasPresentationMessage && GameTime < presentationMessageUntil)
GUI.Label(new Rect(245, 317, 150, 20), presentationMessage, CenteredStyle());
@@ -520,7 +524,11 @@ namespace BumpCombat.Defense.Comparison
GUI.Label(new Rect(347, 29, 113, 23), Loadout.IsCharging
? $"Charge {Loadout.ChargeProgress:P0}" : "Ready");
}
if (GUI.Button(new Rect(466, 29, 76, 23), "Artifacts")) showingArtifactSelection = !showingArtifactSelection;
if (GUI.Button(new Rect(466, 29, 76, 23), "Artifacts"))
{
if (longRunMode) ToggleOwnedArtifactPicker();
else showingArtifactSelection = !showingArtifactSelection;
}
if (HasPendingFieldRelicReward) GUI.Label(new Rect(548, 29, 77, 23), "RELIC REWARD");
else if (GUI.Button(new Rect(548, 29, 77, 23), IsValidationShowcaseActive ? "General run" : "Showcase"))
{
@@ -558,7 +566,7 @@ namespace BumpCombat.Defense.Comparison
private void DrawPadAndUpgradePanel()
{
for (int pad = 0; pad < 6; pad++)
for (int pad = 0; pad < TowerManager.PadCount; pad++)
{
Defense25DTower tower = TowerManager.GetTowerAtPad(pad);
string label = tower == null ? $"P{pad + 1}" : $"P{pad + 1} T{tower.Tier}";
@@ -1,25 +1,47 @@
using System;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
[DefaultExecutionOrder(1100)]
public sealed class Defense25DCycloneVisual : MonoBehaviour
{
private Defense25DComparison world;
private readonly Sprite[][] back = new Sprite[2][];
private readonly Sprite[][] front = new Sprite[2][];
private SpriteRenderer backRenderer, frontRenderer;
private MeshRenderer ribbonRenderer;
private Mesh ribbonMesh;
private Material ribbonMaterial;
private MeshRenderer orbitRenderer;
private Mesh orbitMesh;
private Material orbitMaterial;
private LineRenderer boundary;
private readonly Vector3[] vertices = new Vector3[130];
private readonly Vector2[] uv = new Vector2[130];
private Texture2D orbitAtlas;
private readonly Vector3[] orbitVertices = new Vector3[8 * 4];
private readonly Vector2[] orbitUvs = new Vector2[8 * 4];
private readonly Color[] orbitColors = new Color[8 * 4];
private readonly int[] orbitTriangles = new int[8 * 6];
private static readonly int BodyMaskEnabledId = Shader.PropertyToID("_BodyMaskEnabled");
private static readonly int BodyTexId = Shader.PropertyToID("_BodyTex");
private static readonly int BodyWorldToLocalId = Shader.PropertyToID("_BodyWorldToLocal");
private static readonly int BodyRectId = Shader.PropertyToID("_BodyRect");
private static readonly int BodyPixelsId = Shader.PropertyToID("_BodyPixels");
private static readonly int BodyPpuId = Shader.PropertyToID("_BodyPPU");
private static readonly int BodyFlipId = Shader.PropertyToID("_BodyFlip");
private static readonly int BodyMinYId = Shader.PropertyToID("_BodyMinY");
private static readonly int BodyOpacityId = Shader.PropertyToID("_BodyOpacity");
private static readonly int BodyAlphaCutoffId = Shader.PropertyToID("_BodyAlphaCutoff");
private bool visible;
public bool Ribbon { get; set; }
private bool castDriven;
private bool castActive;
private bool castCharged;
private float castElapsed;
private Vector2 castCenter;
private Vector2 castRadii;
public bool Ribbon { get; set; } = true;
public bool Transparent { get; set; }
public bool ProtectBody { get; set; } = true;
public float Height { get; set; } = .15f;
public float DecorationSpeed { get; set; } = 1f;
public float RadiusScale { get; set; } = 1f;
@@ -28,6 +50,8 @@ namespace BumpCombat.Defense.Comparison
public void Configure(Defense25DComparison owner)
{
world = owner;
castDriven = false;
castActive = false;
for (int i = 0; i < 2; i++)
{
string kind = i == 0 ? "Normal" : "Charged";
@@ -37,27 +61,57 @@ namespace BumpCombat.Defense.Comparison
}
backRenderer = CreateSprite("Existing Back frames");
frontRenderer = CreateSprite("Existing Front frames");
GameObject ribbon = new("Existing texture on XZ ribbon");
ribbon.transform.SetParent(transform, false);
ribbonMesh = new Mesh { name = "Cyclone visual ribbon (no hitbox)" };
ribbonMesh.MarkDynamic();
ribbon.AddComponent<MeshFilter>().sharedMesh = ribbonMesh;
ribbonRenderer = ribbon.AddComponent<MeshRenderer>();
ribbonMaterial = new Material(world.Settings.CutoutMaterial);
ribbonRenderer.sharedMaterial = ribbonMaterial;
orbitAtlas = Resources.Load<Texture2D>("Artifacts/ThreeColor-v1/CycloneOrbit25D-v1/OrbitParts");
if (orbitAtlas == null || orbitAtlas.width != 192 || orbitAtlas.height != 64)
throw new InvalidOperationException("Cyclone OrbitParts atlas must be 192x64 (three 64x64 cells).");
GameObject orbit = new("Cyclone orbit textured quads");
orbit.transform.SetParent(transform, false);
orbitMesh = new Mesh { name = "Cyclone orbit textured quads (visual only)" };
orbitMesh.MarkDynamic();
orbit.AddComponent<MeshFilter>().sharedMesh = orbitMesh;
orbitRenderer = orbit.AddComponent<MeshRenderer>();
orbitMaterial = new Material(world.Settings.CutoutMaterial);
Shader bodyMaskShader = Resources.Load<Shader>(
"Artifacts/ThreeColor-v1/CycloneOrbit25D-v1/CycloneBodyMask");
if (bodyMaskShader == null)
throw new InvalidOperationException("Cyclone body mask shader is missing from Resources.");
orbitMaterial.shader = bodyMaskShader;
orbitRenderer.sharedMaterial = orbitMaterial;
boundary = Defense25DSprite.CreateLine("Actual damage ellipse", world.Settings.HitBoundaryMaterial, transform, .025f);
int[] triangles = new int[64 * 6];
for (int i = 0; i < 64; i++)
for (int i = 0; i < 8; i++)
{
int v = i * 2, t = i * 6;
triangles[t] = v; triangles[t + 1] = v + 1; triangles[t + 2] = v + 2;
triangles[t + 3] = v + 1; triangles[t + 4] = v + 3; triangles[t + 5] = v + 2;
int v = i * 4, t = i * 6;
orbitTriangles[t] = v; orbitTriangles[t + 1] = v + 1; orbitTriangles[t + 2] = v + 2;
orbitTriangles[t + 3] = v + 1; orbitTriangles[t + 4] = v + 3; orbitTriangles[t + 5] = v + 2;
}
ribbonMesh.vertices = vertices;
ribbonMesh.triangles = triangles;
orbitMesh.vertices = orbitVertices;
orbitMesh.uv = orbitUvs;
for (int i = 0; i < orbitColors.Length; i++) orbitColors[i] = Color.white;
orbitMesh.colors = orbitColors;
orbitMesh.triangles = orbitTriangles;
SetVisible(false);
}
/// <summary>Initializes this instance for executor-owned, per-cast visual snapshots.</summary>
public void ConfigureForCast(Defense25DComparison owner)
{
Configure(owner);
castDriven = true;
}
/// <summary>Supplies the visual state for this cast without reading legacy Cyclone timing or geometry.</summary>
public void SetCastState(bool active, float elapsed, Vector2 center, Vector2 radii, bool charged)
{
if (!castDriven)
throw new InvalidOperationException("ConfigureForCast must be called before SetCastState.");
castActive = active;
castElapsed = Mathf.Max(0f, elapsed);
castCenter = center;
castRadii = radii;
castCharged = charged;
SetVisible(active);
}
private static Sprite[] Load(string key)
{
Texture2D texture = Resources.Load<Texture2D>(key);
@@ -78,16 +132,22 @@ namespace BumpCombat.Defense.Comparison
visible = value;
if (backRenderer == null) return;
backRenderer.enabled = frontRenderer.enabled = value && !Ribbon;
ribbonRenderer.enabled = value && Ribbon;
orbitRenderer.enabled = value && Ribbon;
boundary.enabled = value && world.ShowBoundaries;
}
private void LateUpdate()
{
if (!visible || !world.CycloneActive) return;
if (world == null || !visible) return;
bool active = castDriven ? castActive : world.CycloneActive;
if (!active) return;
bool charged = castDriven ? castCharged : world.IsCycloneCharged;
float elapsed = castDriven ? castElapsed : world.CycloneElapsed;
Vector2 center = castDriven ? castCenter : world.CycloneCenter;
Vector2 radii = castDriven ? castRadii : world.CycloneRadii;
SetVisible(true);
int kind = world.IsCycloneCharged ? 1 : 0;
int frame = Mathf.FloorToInt(world.CycloneElapsed / CycloneGeometry.FrameSeconds);
int kind = charged ? 1 : 0;
int frame = Mathf.FloorToInt(elapsed / CycloneGeometry.FrameSeconds);
if (front[kind].Length == 0 || back[kind].Length == 0) return;
Sprite frontFrame = front[kind][frame % front[kind].Length];
Sprite backFrame = back[kind][frame % back[kind].Length];
@@ -95,16 +155,26 @@ namespace BumpCombat.Defense.Comparison
backRenderer.sharedMaterial = frontRenderer.sharedMaterial = material;
backRenderer.sprite = backFrame;
frontRenderer.sprite = frontFrame;
Vector2 center = world.CycloneCenter;
Vector2 radii = world.CycloneRadii;
Quaternion rotation = world.View != null ? world.View.transform.rotation : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
Vector3 position = Defense25DActor.OnGround(center, world.Settings.GroundY);
float scale = CycloneGeometry.VisualScale(world.IsCycloneCharged ? world.Settings.Cyclone.ChargedRange : world.Settings.Cyclone.NormalRange,
world.IsCycloneCharged) * RadiusScale;
float visualScale;
if (castDriven)
{
float unitRadius = CycloneGeometry.Radii(1f, charged).x;
float snapshotRange = unitRadius > 0f ? radii.x / unitRadius : 0f;
visualScale = unitRadius > 0f ? CycloneGeometry.VisualScale(snapshotRange, charged) : 0f;
}
else
{
float configuredRange = charged
? world.Settings.Cyclone.ChargedRange : world.Settings.Cyclone.NormalRange;
visualScale = CycloneGeometry.VisualScale(configuredRange, charged);
}
visualScale *= RadiusScale;
foreach (SpriteRenderer renderer in new[] { backRenderer, frontRenderer })
{
renderer.transform.rotation = rotation;
renderer.transform.localScale = Vector3.one * scale;
renderer.transform.localScale = Vector3.one * visualScale;
renderer.transform.position = position + Vector3.up * Height;
}
// Real depth, not ZTest Always or a high global sorting layer.
@@ -112,24 +182,99 @@ namespace BumpCombat.Defense.Comparison
frontRenderer.transform.position -= Vector3.forward * .12f;
Defense25DSprite.SetEllipse(boundary, center, radii, world.Settings.GroundY + .035f);
if (!Ribbon) return;
ribbonMaterial.CopyPropertiesFromMaterial(material);
ribbonMaterial.mainTexture = frontFrame.texture;
Rect rect = frontFrame.textureRect;
Vector2 texel = frontFrame.texture.texelSize;
for (int i = 0; i <= 64; i++)
// Copy only shared render state; keep this shader's body-mask property layout.
orbitMaterial.SetColor("_BaseColor", material.GetColor("_BaseColor"));
orbitMaterial.SetFloat("_Cutoff", material.GetFloat("_Cutoff"));
orbitMaterial.SetFloat("_SrcBlend", material.GetFloat("_SrcBlend"));
orbitMaterial.SetFloat("_DstBlend", material.GetFloat("_DstBlend"));
orbitMaterial.SetFloat("_ZWrite", material.GetFloat("_ZWrite"));
orbitMaterial.renderQueue = material.renderQueue;
orbitMaterial.mainTexture = orbitAtlas;
SetBodyMaskProperties();
float[] widths = charged
? Defense25DComparisonSettings.CycloneChargedOrbitWidthsPixels
: Defense25DComparisonSettings.CycloneNormalOrbitWidthsPixels;
int ringCount = charged ? 7 : 3;
int orbitCount = ringCount + 1;
float yaw = Mathf.Floor(elapsed * DecorationSpeed / CycloneGeometry.FrameSeconds)
* (Mathf.PI * 2f / 8f);
float yawStep = charged ? -Mathf.PI * 2f / ringCount : Mathf.PI * 2f / ringCount;
Vector2 fixedCenter;
if (castDriven)
{
float t = i / 64f;
float angle = t * Mathf.PI * 2f + world.CycloneElapsed * DecorationSpeed * Mathf.PI * 2f;
Vector2 ground = center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y) * RadiusScale;
float height = world.Settings.GroundY + Height + .075f * Mathf.Sin(angle * 3f);
vertices[2 * i] = Defense25DActor.OnGround(ground, height - .08f);
vertices[2 * i + 1] = Defense25DActor.OnGround(ground, height + .08f);
uv[2 * i] = new Vector2((rect.x + rect.width * t) * texel.x, rect.y * texel.y);
uv[2 * i + 1] = new Vector2((rect.x + rect.width * t) * texel.x, rect.yMax * texel.y);
fixedCenter = center;
}
ribbonMesh.vertices = vertices;
ribbonMesh.uv = uv;
ribbonMesh.RecalculateBounds();
else
{
Vector3 footPosition = world.Player.Visual.FootPosition;
fixedCenter = new Vector2(footPosition.x, footPosition.z);
}
for (int i = 0; i < orbitCount; i++)
{
int cell = i == 0 ? 2 : (i - 1) % 2;
float degrees = i == 0 ? 0f : yaw + (i - 1) * yawStep;
float halfX = i == 0 ? Defense25DComparisonSettings.CycloneFixedRingWidthPixels / 64f
: widths[i - 1] / widths[ringCount - 1];
float halfZ = i == 0 ? Defense25DComparisonSettings.CycloneFixedRingDepthPixels / 64f : halfX;
Vector2[] corners = { new(-halfX, -halfZ), new(-halfX, halfZ), new(halfX, -halfZ), new(halfX, halfZ) };
float cos = Mathf.Cos(degrees), sin = Mathf.Sin(degrees);
int vertex = i * 4;
for (int corner = 0; corner < 4; corner++)
{
Vector2 p = corners[corner];
// Rotate on a circular local footprint first, then stretch to the gameplay ellipse.
float x = p.x * cos - p.y * sin;
float z = p.x * sin + p.y * cos;
Vector2 anchor = i == 0 ? fixedCenter : center;
Vector2 extent = i == 0
? new Vector2(visualScale, visualScale)
: new Vector2(radii.x, radii.y) * RadiusScale;
Vector2 ground = anchor + new Vector2(x * extent.x, z * extent.y);
float y = world.Settings.GroundY + (i == 0
? Defense25DComparisonSettings.CycloneFixedRingHeight
: Height + i * .001f);
orbitVertices[vertex + corner] = Defense25DActor.OnGround(ground, y);
float u = (cell * 64f + ((corner & 2) == 0 ? .5f : 63.5f)) / 192f;
float v = (corner & 1) == 0 ? .5f / 64f : 63.5f / 64f;
orbitUvs[vertex + corner] = new Vector2(u, v);
}
}
orbitMesh.vertices = orbitVertices;
orbitMesh.uv = orbitUvs;
int[] activeTriangles = new int[orbitCount * 6];
Array.Copy(orbitTriangles, activeTriangles, activeTriangles.Length);
orbitMesh.triangles = activeTriangles;
orbitMesh.RecalculateBounds();
}
private void SetBodyMaskProperties()
{
SpriteRenderer body = world.Player != null && world.Player.Visual != null
? world.Player.Visual.Renderer : null;
Sprite sprite = body != null ? body.sprite : null;
bool enabled = ProtectBody && body != null && body.enabled && sprite != null;
orbitMaterial.SetFloat(BodyMaskEnabledId, enabled ? 1f : 0f);
if (body == null || sprite == null) return;
Rect rect = sprite.textureRect;
Vector2 offset = sprite.textureRectOffset;
Vector2 pivot = sprite.pivot;
Texture texture = sprite.texture;
orbitMaterial.SetTexture(BodyTexId, texture);
orbitMaterial.SetMatrix(BodyWorldToLocalId, body.transform.worldToLocalMatrix);
orbitMaterial.SetVector(BodyRectId, new Vector4(rect.x / texture.width, rect.y / texture.height,
rect.width / texture.width, rect.height / texture.height));
orbitMaterial.SetVector(BodyPixelsId,
new Vector4(pivot.x - offset.x, pivot.y - offset.y, rect.width, rect.height));
orbitMaterial.SetFloat(BodyPpuId, sprite.pixelsPerUnit);
orbitMaterial.SetVector(BodyFlipId, new Vector4(body.flipX ? -1f : 1f, body.flipY ? -1f : 1f, 0f, 0f));
orbitMaterial.SetFloat(BodyMinYId, body.transform.InverseTransformPoint(
world.Player.Visual.FootPosition).y + Defense25DComparisonSettings.CycloneBodyMaskFootClearancePixels
/ sprite.pixelsPerUnit);
orbitMaterial.SetFloat(BodyOpacityId, body.color.a);
orbitMaterial.SetFloat(BodyAlphaCutoffId,
body.sharedMaterial != null && body.sharedMaterial.HasProperty("_Cutoff")
? body.sharedMaterial.GetFloat("_Cutoff") : .4f);
}
private void OnDisable() => SetVisible(false);
@@ -139,8 +284,8 @@ namespace BumpCombat.Defense.Comparison
if (frames != null) foreach (Sprite frame in frames) Destroy(frame);
foreach (Sprite[] frames in front)
if (frames != null) foreach (Sprite frame in frames) Destroy(frame);
if (ribbonMesh != null) Destroy(ribbonMesh);
if (ribbonMaterial != null) Destroy(ribbonMaterial);
if (orbitMesh != null) Destroy(orbitMesh);
if (orbitMaterial != null) Destroy(orbitMaterial);
}
}
}
@@ -5,6 +5,34 @@ using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public readonly struct Defense25DEnemySpawnContext
{
public int RunGeneration { get; }
public int SpawnId { get; }
public int PortalId { get; }
public int GlobalWaveId { get; }
public Defense25DSpawnSource Source { get; }
public int PersonalMissionId { get; }
public int MissionInstanceId { get; }
public bool IsMissionTarget { get; }
public int SummonOwnerSpawnId { get; }
public Defense25DEnemySpawnContext(int runGeneration, int spawnId, int portalId, int globalWaveId,
Defense25DSpawnSource source, int personalMissionId = 0, int missionInstanceId = 0,
bool isMissionTarget = false, int summonOwnerSpawnId = 0)
{
RunGeneration = runGeneration;
SpawnId = spawnId;
PortalId = portalId;
GlobalWaveId = globalWaveId;
Source = source;
PersonalMissionId = personalMissionId;
MissionInstanceId = missionInstanceId;
IsMissionTarget = isMissionTarget;
SummonOwnerSpawnId = summonOwnerSpawnId;
}
}
public sealed partial class Defense25DEnemy : MonoBehaviour
{
private Defense25DComparison world;
@@ -13,15 +41,41 @@ namespace BumpCombat.Defense.Comparison
private Vector2 attackDirection;
private float attackStart = -1f, progressUntil;
private float deathVisualRemovalAt = -1f;
private Color authoredBossBaseColor = Color.white;
private float slowUntil, slowFraction, stunUntil;
private int stunVisualRevision;
private float igniteRemaining, igniteInterval, igniteNextTick, igniteDamage;
private float shockRemaining, shockIncrease, vulnerabilityRemaining, vulnerabilityIncrease;
private float bossTelegraphUntil;
private int nextLongRunBossPatternIndex;
private int activeLongRunBossPatternIndex;
private bool bossProtectionVisualActive;
private bool bossGroggyVisualActive;
private bool dealtDamage, supplied, retired;
private bool showcaseDummy;
private Defense25DBossCombat bossCombat;
private Defense25DMonsterCombatAdapter monsterCombat;
private Defense25DMonsterPoolEntry monsterPoolEntry;
private EnemyDefinition longRunSpecialDefinition;
private Defense25DMonsterSourceMotion monsterSourceMotion;
private LineRenderer monsterWarning;
private GameObject monsterProjectile;
private GameObject authoredBossVisual;
private Animator authoredBossAnimator;
private bool monsterCombatInterrupted;
private string poolReuseDefinitionId;
private bool poolReuseReady;
public Defense25DActor Actor { get; private set; }
public int Id { get; private set; }
public int Lane { get; private set; }
public Defense25DEnemySpawnContext SpawnContext { get; private set; }
public Defense25DBossCombat BossCombat => bossCombat;
public Animator LongRunBossAnimator => authoredBossAnimator;
public Defense25DMonsterCombatAdapter MonsterCombat => monsterCombat;
internal string PoolReuseDefinitionId => poolReuseDefinitionId;
internal bool IsReadyForPoolReuse => poolReuseReady && !gameObject.activeSelf;
public EnemyDefinition LongRunDefinition => monsterPoolEntry != null
? monsterPoolEntry.Definition : longRunSpecialDefinition;
public bool CanSpawn => !supplied;
public int WaypointIndex { get; private set; }
public bool IsAvailable => isActiveAndEnabled && supplied && !retired && Actor.IsAlive;
@@ -39,35 +93,164 @@ namespace BumpCombat.Defense.Comparison
public void Configure(Defense25DComparison owner, Defense25DActor actor, int id, int lane = 0)
{
showcaseDummy = false;
poolReuseDefinitionId = null;
poolReuseReady = false;
world = owner; Actor = actor; Id = id; Lane = lane;
SpawnContext = default;
pattern = world.Settings.Enemy.GetAttackPattern(0);
ConfigureSkeletonCombat(world.Settings.Enemy, world.Settings.EnemyVisualScale,
world.Settings.EnemyAttackClip);
}
public void ResetForRun()
public void ConfigureLongRun(Defense25DComparison owner, Defense25DActor actor, int id, int lane,
Defense25DMonsterPoolEntry poolEntry)
{
showcaseDummy = false;
world = owner;
Actor = actor;
Id = id;
Lane = lane;
SpawnContext = default;
monsterPoolEntry = poolEntry;
poolReuseDefinitionId = poolEntry != null ? poolEntry.EnemyDefinitionId : null;
poolReuseReady = false;
longRunSpecialDefinition = null;
monsterSourceMotion = null;
monsterCombat = null;
monsterCombatInterrupted = false;
nextLongRunBossPatternIndex = activeLongRunBossPatternIndex = 0;
bossTelegraphUntil = 0f;
bossProtectionVisualActive = bossGroggyVisualActive = false;
pattern = poolEntry?.Definition != null ? poolEntry.Definition.GetAttackPattern(0) : default;
ConfigureSkeletonCombat(null, 1f, null);
if (poolEntry?.Definition != null) Actor.SetEnemyDefinition(poolEntry.Definition);
}
public void ConfigureLongRunSpecial(Defense25DComparison owner, Defense25DActor actor, int id,
int lane, EnemyDefinition definition)
{
showcaseDummy = false;
world = owner;
Actor = actor;
Id = id;
Lane = lane;
SpawnContext = default;
monsterPoolEntry = null;
poolReuseDefinitionId = null;
poolReuseReady = false;
longRunSpecialDefinition = definition;
monsterSourceMotion = null;
monsterCombat = null;
monsterCombatInterrupted = false;
nextLongRunBossPatternIndex = activeLongRunBossPatternIndex = 0;
bossTelegraphUntil = 0f;
bossProtectionVisualActive = bossGroggyVisualActive = false;
pattern = definition != null ? definition.GetAttackPattern(0) : default;
ConfigureSkeletonCombat(null, 1f, null);
if (definition != null) Actor.SetEnemyDefinition(definition);
}
public bool BindLongRunFinalBossVisual(GameObject visualSourcePrefab, out string error)
{
SpriteRenderer authoredRenderer = visualSourcePrefab != null
? visualSourcePrefab.GetComponent<SpriteRenderer>() : null;
if (Actor == null || Actor.Visual == null || authoredRenderer == null || authoredRenderer.sprite == null ||
world?.Settings.CutoutMaterial == null)
{
error = "FinalBoss requires an authored sprite source and the run's depth-tested material.";
return false;
}
GameObject visualObject = new("FinalBoss authored visual only");
visualObject.transform.SetParent(Actor.Visual.Renderer.transform, false);
SpriteRenderer visualRenderer = visualObject.AddComponent<SpriteRenderer>();
visualRenderer.sprite = authoredRenderer.sprite;
visualRenderer.flipX = authoredRenderer.flipX;
visualRenderer.flipY = authoredRenderer.flipY;
visualRenderer.color = authoredRenderer.color;
visualRenderer.sortingLayerID = authoredRenderer.sortingLayerID;
visualRenderer.sortingOrder = authoredRenderer.sortingOrder;
visualRenderer.maskInteraction = authoredRenderer.maskInteraction;
visualRenderer.sharedMaterial = world.Settings.CutoutMaterial;
Animator authoredAnimator = visualSourcePrefab.GetComponent<Animator>();
if (authoredAnimator != null && authoredAnimator.runtimeAnimatorController != null)
{
authoredBossAnimator = visualObject.AddComponent<Animator>();
authoredBossAnimator.runtimeAnimatorController = authoredAnimator.runtimeAnimatorController;
authoredBossAnimator.applyRootMotion = false;
authoredBossAnimator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
}
authoredBossVisual = visualObject;
authoredBossBaseColor = authoredRenderer.color;
Actor.Visual.Hide();
error = null;
return true;
}
public bool BindLongRunMonsterCombat(Defense25DMonsterSourceMotion sourceMotion, out string error)
{
monsterSourceMotion = sourceMotion;
if (monsterPoolEntry == null || sourceMotion == null)
{
error = "A pool entry and its exact source motion are required.";
return false;
}
monsterCombat = new Defense25DMonsterCombatAdapter();
if (!monsterCombat.Bind(monsterPoolEntry, Actor, sourceMotion, ResolveMonsterCombatTarget,
out error, Actor.Visual.SetExternalSourceSprite, RenderMonsterWarning, RenderMonsterProjectile))
{
monsterCombat = null;
Actor.Visual.EndExternalSourceMotion();
return false;
}
monsterCombatInterrupted = false;
return true;
}
public void ResetForRun()
{
bossCombat?.Cancel();
bossCombat = null;
bossProtectionVisualActive = bossGroggyVisualActive = false;
bossTelegraphUntil = 0f;
CancelMonsterCombat();
if (authoredBossVisual != null)
{
if (Application.isPlaying) Destroy(authoredBossVisual);
else DestroyImmediate(authoredBossVisual);
}
authoredBossVisual = null;
authoredBossAnimator = null;
authoredBossBaseColor = Color.white;
showcaseDummy = false;
SpawnContext = default;
supplied = retired = false;
attackStart = -1f;
deathVisualRemovalAt = -1f;
progressUntil = 0f;
slowUntil = slowFraction = stunUntil = 0f;
nextLongRunBossPatternIndex = activeLongRunBossPatternIndex = 0;
bossTelegraphUntil = 0f;
bossProtectionVisualActive = bossGroggyVisualActive = false;
ResetArtifactEffects();
LastDamageSource = EnemyDamageSource.None;
WaypointIndex = 1;
target = null;
skeletonAttackAnimationStarted = false;
SetSkeletonSlashVisible(false);
poolReuseReady = !string.IsNullOrEmpty(poolReuseDefinitionId);
gameObject.SetActive(false);
}
public void Spawn(Vector2 position)
{
poolReuseReady = false;
supplied = true;
retired = false;
attackStart = -1f;
deathVisualRemovalAt = -1f;
dealtDamage = false;
slowUntil = slowFraction = stunUntil = 0f;
ResetArtifactEffects();
LastDamageSource = EnemyDamageSource.None;
@@ -75,23 +258,45 @@ namespace BumpCombat.Defense.Comparison
Actor.ResetAt(position);
}
public void SetSpawnContext(Defense25DEnemySpawnContext context) => SpawnContext = context;
public bool BindBossCombat(Defense25DBossCombat combat)
{
if (combat == null || !combat.IsBound || combat.OwnerSpawnId != SpawnContext.SpawnId
|| combat.RunGeneration != SpawnContext.RunGeneration)
return false;
bossCombat?.Cancel();
bossCombat = combat;
return true;
}
public void ResetSpawnContext() => SpawnContext = default;
public void Retire()
{
bossCombat?.Cancel();
bossCombat = null;
CancelMonsterCombat();
retired = true;
deathVisualRemovalAt = -1f;
attackStart = -1f;
target = null;
Actor.StopBody();
gameObject.SetActive(false);
poolReuseReady = !string.IsNullOrEmpty(poolReuseDefinitionId);
}
public void RetireForDeathVisual()
{
bossCombat?.Cancel();
bossCombat = null;
CancelMonsterCombat();
retired = true;
attackStart = -1f;
target = null;
SetSkeletonSlashVisible(false);
Actor.StopBody();
poolReuseReady = false;
float deathDuration = GameplayConstants.Current.Enemies.DefaultDeathDuration;
float padding = GameplayConstants.Current.Enemies.DeathRemovalPadding;
deathVisualRemovalAt = world.GameTime + deathDuration + padding;
@@ -103,6 +308,7 @@ namespace BumpCombat.Defense.Comparison
{
deathVisualRemovalAt = -1f;
gameObject.SetActive(false);
poolReuseReady = !string.IsNullOrEmpty(poolReuseDefinitionId);
}
}
@@ -138,7 +344,22 @@ namespace BumpCombat.Defense.Comparison
{
if (!IsAvailable) return;
StepArtifactEffects(dt);
// Role behavior (boss phases, summons, and protection) owns run-timed state.
// Species combat owns authored locomotion/attack frames and must not skip it.
bossCombat?.Step(dt, world.Paused);
RefreshLongRunBossVisual();
if (monsterCombat != null)
{
StepLongRunMonster(dt);
return;
}
if (showcaseDummy) { Actor.StopBody(); return; }
if (bossCombat != null && bossCombat.IsGroggy)
{
if (IsAttacking) EndAttack();
Actor.StopBody();
return;
}
if (skeletonDefinition != null && StepSkeletonCombat(dt)) return;
if (IsStunned) { Actor.StopBody(); return; }
if (Actor.HasKnockback) { Actor.Move(Actor.ConsumeKnockback(dt)); return; }
@@ -154,7 +375,10 @@ namespace BumpCombat.Defense.Comparison
Actor.Move(attackDirection * (GameplayConstants.Current.Enemies.BodyContactSpeed * dt), false);
if (!dealtDamage && Actor.GapTo(target) <= world.Settings.ContactSensorMargin)
{
dealtDamage = target.TakeDamage(Actor.CalculateAttackDamage(world.Settings.Enemy.AttackDamage * pattern.DamageMultiplier),
float baseDamage = longRunSpecialDefinition != null
? longRunSpecialDefinition.AttackDamage
: world.Settings.Enemy.AttackDamage;
dealtDamage = target.TakeDamage(Actor.CalculateAttackDamage(baseDamage * pattern.DamageMultiplier),
attackDirection, target.IsMinion ? .3f : GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance);
}
}
@@ -163,6 +387,14 @@ namespace BumpCombat.Defense.Comparison
}
if (world.GameTime >= progressUntil)
{
if (bossCombat != null && bossCombat.IsBound)
{
if (bossCombat.IsAttackLocked || !TrySelectNextLongRunBossPattern())
{
Actor.StopBody();
return;
}
}
target = FindTarget();
if (target != null)
{
@@ -171,6 +403,14 @@ namespace BumpCombat.Defense.Comparison
attackDirection = (target.Position - Actor.Position).normalized;
Actor.Facing = attackDirection;
Actor.Visual.SetAttack(true);
if (bossCombat != null && bossCombat.IsBound)
{
bossCombat.NotifyAttackPatternUsed(activeLongRunBossPatternIndex);
nextLongRunBossPatternIndex = (activeLongRunBossPatternIndex + 1) %
Mathf.Max(1, longRunSpecialDefinition.AttackPatternCount);
if (authoredBossAnimator != null && !string.IsNullOrWhiteSpace(pattern.AnimationState))
authoredBossAnimator.Play(pattern.AnimationState, 0, 0f);
}
return;
}
}
@@ -198,6 +438,174 @@ namespace BumpCombat.Defense.Comparison
Actor.MoveSpeed * (1f - slow) * GameplayConstants.Current.Defense.RouteSpeedMultiplier * dt));
}
private void StepLongRunMonster(float dt)
{
if (!world.IsRunning || world.Paused)
{
Actor.StopBody();
monsterCombat.Step(dt, false, paused: true);
return;
}
if (bossCombat != null && (bossCombat.IsGroggy || bossCombat.IsAttackLocked))
{
Actor.StopBody();
monsterCombat.Step(dt, false, paused: true);
return;
}
if (IsStunned)
{
Actor.StopBody();
if (!monsterCombatInterrupted)
{
monsterCombat.Step(0f, false, interrupted: true);
BindLongRunMonsterCombat(monsterSourceMotion, out _);
monsterCombatInterrupted = true;
}
return;
}
monsterCombatInterrupted = false;
if (Actor.HasKnockback)
{
Actor.Move(Actor.ConsumeKnockback(dt));
monsterCombat.Step(0f, false, paused: true);
return;
}
bool moving = Actor.LastMove.sqrMagnitude > .0001f;
if (monsterCombat.Step(dt, moving)) return;
Vector2[] route = world.GetRouteForLane(Lane);
Vector2 delta = route[WaypointIndex] - Actor.Position;
if (delta.magnitude <= .2f)
{
if (WaypointIndex >= route.Length - 1)
{
world.ResolveEnemy(this, DefenseEnemyResolution.Arrival);
return;
}
WaypointIndex++;
delta = route[WaypointIndex] - Actor.Position;
}
Actor.Facing = delta.normalized;
Vector2 separation = Vector2.zero;
foreach (Defense25DEnemy other in world.Enemies)
{
if (other == this || !other.IsAvailable) continue;
Vector2 away = Actor.Position - other.Actor.Position;
float length = away.magnitude;
if (length > .001f && length < world.Settings.Enemy.SeparationRadius)
separation += away / length * (1f - length / world.Settings.Enemy.SeparationRadius);
}
Vector2 direction = (delta.normalized + separation * world.Settings.Enemy.SeparationStrength).normalized;
float slow = world.GameTime < slowUntil ? slowFraction : 0f;
Actor.Move(direction * Mathf.Min(delta.magnitude,
Actor.MoveSpeed * (1f - slow) * GameplayConstants.Current.Defense.RouteSpeedMultiplier * dt));
}
private Defense25DActor ResolveMonsterCombatTarget()
{
Defense25DActor best = null;
float bestDistance = float.PositiveInfinity;
void Consider(Defense25DActor candidate)
{
if (candidate == null || !candidate.IsAlive) return;
float distance = (candidate.Center - Actor.Center).sqrMagnitude;
if (distance >= bestDistance) return;
bestDistance = distance;
best = candidate;
}
Consider(world.Player);
if (world.TowersOperational)
foreach (Defense25DSummon summon in world.Summons)
if (summon != null) Consider(summon.Actor);
return best;
}
private void CancelMonsterCombat()
{
monsterCombat?.Cancel();
monsterCombat = null;
monsterSourceMotion = null;
monsterCombatInterrupted = false;
if (Actor != null && Actor.Visual != null) Actor.Visual.EndExternalSourceMotion();
if (monsterWarning != null) monsterWarning.enabled = false;
if (monsterProjectile != null) monsterProjectile.SetActive(false);
}
private void RenderMonsterWarning(Defense25DMonsterCombatWarning warning)
{
if (monsterWarning == null)
{
monsterWarning = Defense25DSprite.CreateLine("Species attack telegraph",
world.Settings.BoundaryMaterial, transform, .045f);
monsterWarning.positionCount = 1;
monsterWarning.loop = false;
}
if (!warning.Visible)
{
monsterWarning.enabled = false;
return;
}
monsterWarning.enabled = true;
Vector2 direction = warning.Direction.sqrMagnitude > .0001f
? warning.Direction.normalized : Vector2.right;
Vector2 side = new(-direction.y, direction.x);
Vector2 origin = warning.Origin;
Vector2 end = warning.TargetPoint;
if (warning.Shape == EnemyAttackShape.TargetCircle)
{
monsterWarning.loop = true;
monsterWarning.positionCount = 33;
for (int i = 0; i < monsterWarning.positionCount; i++)
{
float angle = i * Mathf.PI * 2f / (monsterWarning.positionCount - 1);
Vector2 point = end + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * warning.Radius;
monsterWarning.SetPosition(i, Defense25DActor.OnGround(point, world.Settings.GroundY + .04f));
}
return;
}
monsterWarning.loop = warning.Shape == EnemyAttackShape.Box;
if (warning.Shape == EnemyAttackShape.Box)
{
Vector2 far = origin + direction * warning.Length;
Vector2 halfWidth = side * (warning.Width * .5f);
Vector2[] points = { origin - halfWidth, origin + halfWidth, far + halfWidth, far - halfWidth };
monsterWarning.positionCount = points.Length;
for (int i = 0; i < points.Length; i++)
monsterWarning.SetPosition(i, Defense25DActor.OnGround(points[i], world.Settings.GroundY + .04f));
}
else
{
monsterWarning.positionCount = 2;
monsterWarning.SetPosition(0, Defense25DActor.OnGround(origin, world.Settings.GroundY + .04f));
monsterWarning.SetPosition(1, Defense25DActor.OnGround(end, world.Settings.GroundY + .04f));
}
}
private void RenderMonsterProjectile(Defense25DMonsterProjectileVisual projectile)
{
if (!projectile.Active)
{
if (monsterProjectile != null) monsterProjectile.SetActive(false);
return;
}
if (monsterProjectile == null)
{
monsterProjectile = new GameObject("Species projectile visual");
monsterProjectile.transform.SetParent(world.transform, false);
SpriteRenderer renderer = monsterProjectile.AddComponent<SpriteRenderer>();
renderer.sharedMaterial = world.Settings.CutoutMaterial;
}
SpriteRenderer sprite = monsterProjectile.GetComponent<SpriteRenderer>();
sprite.sprite = projectile.Sprite;
sprite.enabled = projectile.Sprite != null;
monsterProjectile.SetActive(projectile.Sprite != null);
monsterProjectile.transform.position = Defense25DActor.OnGround(projectile.Position, world.Settings.GroundY + .45f);
monsterProjectile.transform.localScale = Vector3.one * Mathf.Max(.08f, projectile.Radius * 2f);
if (world.View != null) monsterProjectile.transform.rotation = world.View.transform.rotation;
}
public bool ApplyIgnite(float duration, float interval, float tickDamage,
EnemyDamageSource source = EnemyDamageSource.Player)
{
@@ -306,5 +714,71 @@ namespace BumpCombat.Defense.Comparison
foreach (Defense25DSummon summon in world.Summons) Consider(summon.Actor);
return best;
}
private bool TrySelectNextLongRunBossPattern()
{
if (longRunSpecialDefinition == null || bossCombat == null || !bossCombat.IsBound)
return true;
int patternCount = Mathf.Max(1, longRunSpecialDefinition.AttackPatternCount);
for (int offset = 0; offset < patternCount; offset++)
{
int candidate = (nextLongRunBossPatternIndex + offset) % patternCount;
if (!bossCombat.CanUseAttackPattern(candidate)) continue;
activeLongRunBossPatternIndex = candidate;
pattern = longRunSpecialDefinition.GetAttackPattern(candidate);
return true;
}
return false;
}
internal void SetLongRunBossProtectionVisual(bool active)
{
bossProtectionVisualActive = active;
RefreshLongRunBossVisual();
}
internal void SetLongRunBossGroggyVisual(bool active)
{
bossGroggyVisualActive = active;
RefreshLongRunBossVisual();
}
internal void HandleLongRunBossSummonTelegraph(bool interruptAttack)
{
if (interruptAttack)
{
if (IsAttacking) EndAttack();
if (monsterCombat != null && monsterCombat.IsAttackInProgress)
{
Defense25DMonsterSourceMotion sourceMotion = monsterSourceMotion;
monsterCombat.Step(0f, false, interrupted: true);
BindLongRunMonsterCombat(sourceMotion, out _);
}
}
bossTelegraphUntil = world.GameTime + Mathf.Max(0f,
GameplayConstants.Current.Enemies.SummonVisualDuration);
RefreshLongRunBossVisual();
}
private void RefreshLongRunBossVisual()
{
if (Actor == null || Actor.Visual == null) return;
Color tint;
if (bossProtectionVisualActive)
tint = new Color32(138, 188, 255, 255);
else if (bossGroggyVisualActive)
tint = new Color32(255, 226, 142, 255);
else if (world != null && world.GameTime < bossTelegraphUntil)
tint = new Color32(255, 176, 104, 255);
else
tint = Color.white;
Actor.Visual.SetFeedbackTint(tint);
SpriteRenderer authoredRenderer = authoredBossVisual != null
? authoredBossVisual.GetComponent<SpriteRenderer>() : null;
if (authoredRenderer != null) authoredRenderer.color = authoredBossBaseColor * tint;
}
}
}
@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
[DisallowMultipleComponent]
public sealed class Defense25DFacilityVisuals : MonoBehaviour
{
private enum EffectKind
{
PortalSpawn,
PortalBurst,
HomeSpawn,
HomeBurst,
TowerPadSpawn,
}
private readonly struct EffectGate : IEquatable<EffectGate>
{
private readonly int generation;
private readonly EffectKind kind;
private readonly int facilityId;
public EffectGate(int generation, EffectKind kind, int facilityId)
{
this.generation = generation;
this.kind = kind;
this.facilityId = facilityId;
}
public bool Equals(EffectGate other) => generation == other.generation
&& kind == other.kind && facilityId == other.facilityId;
public override bool Equals(object obj) => obj is EffectGate other && Equals(other);
public override int GetHashCode() => ((generation * 397) ^ (int)kind) * 397 ^ facilityId;
}
private sealed class ActiveVisual
{
public GameObject Root;
public readonly List<DefenseSpriteSequence> Sequences = new();
public bool IsUpgrade;
public int PadId;
}
private readonly HashSet<EffectGate> playedEvents = new();
private readonly List<ActiveVisual> activeVisuals = new();
private readonly Dictionary<int, ActiveVisual> upgradeByPad = new();
private Defense25DComparison world;
private Defense25DFacilityVisualConstants settings;
private int runGeneration;
public bool Bind(Defense25DComparison comparison, Defense25DFacilityVisualConstants constants,
int generation, out string error)
{
Clear();
if (comparison == null || constants == null || comparison.Settings == null)
{
error = "A comparison world, its settings, and facility visual constants are required.";
return false;
}
if (comparison.Settings.CutoutMaterial == null)
{
error = "The comparison world's depth-tested CutoutMaterial is required.";
return false;
}
if (!constants.TryValidate(out error)) return false;
world = comparison;
settings = constants;
runGeneration = generation;
error = null;
return true;
}
public void Bind(Defense25DComparison comparison, Defense25DFacilityVisualConstants constants,
int generation)
{
if (!Bind(comparison, constants, generation, out string error))
Debug.LogError($"Could not bind facility visuals: {error}", this);
}
public void Clear()
{
playedEvents.Clear();
upgradeByPad.Clear();
for (int i = activeVisuals.Count - 1; i >= 0; i--)
DestroyVisual(activeVisuals[i]);
activeVisuals.Clear();
world = null;
settings = null;
}
public void PlayPortalSpawn(int portalId, Vector3 groundPosition)
{
PlayOnce(EffectKind.PortalSpawn, portalId, "Portal Spawn", groundPosition,
settings?.PortalSpawn, settings?.ImpactWaveResourceKey,
settings?.ImbuingResourceKey, useUnscaledTime: false);
}
public void PlayPortalBurst(int portalId, Vector3 groundPosition)
{
PlayOnce(EffectKind.PortalBurst, portalId, "Portal Burst", groundPosition,
settings?.PortalBurst, settings?.MeteorImpactResourceKey, null, useUnscaledTime: true);
}
public void PlayHomeSpawn(Vector3 groundPosition)
{
PlayOnce(EffectKind.HomeSpawn, -1, "Home Spawn", groundPosition,
settings?.HomeSpawn, settings?.ImpactWaveResourceKey,
settings?.ImbuingResourceKey, useUnscaledTime: false);
}
public void PlayHomeBurst(Vector3 groundPosition)
{
PlayOnce(EffectKind.HomeBurst, -1, "Home Burst", groundPosition,
settings?.HomeBurst, settings?.MeteorImpactResourceKey, null, useUnscaledTime: true);
}
public void PlayPadSpawn(int padId, Vector3 groundPosition)
{
PlayOnce(EffectKind.TowerPadSpawn, padId, "Tower Pad Spawn", groundPosition,
settings?.TowerPadSpawn, settings?.ImpactWaveResourceKey, null, useUnscaledTime: false);
}
public void PlayTowerUpgrade(int padId, int newTier, Vector3 groundPosition)
{
if (!IsBound) return;
if (newTier < 2 || newTier > 4)
{
Debug.LogError($"Tower upgrade visual received unsupported new tier {newTier}.", this);
return;
}
if (upgradeByPad.TryGetValue(padId, out ActiveVisual previous))
DestroyVisual(previous);
ActiveVisual current = CreateEffect($"Tower Upgrade Pad {padId} T{newTier}", groundPosition,
settings.TowerUpgrade, settings.ImpactWaveResourceKey, settings.ImbuingResourceKey,
useUnscaledTime: false);
if (current == null) return;
current.IsUpgrade = true;
current.PadId = padId;
upgradeByPad[padId] = current;
}
private bool IsBound => world != null && settings != null;
private void PlayOnce(EffectKind kind, int facilityId, string name, Vector3 position,
Defense25DFacilityEffectStyle style, string groundKey, string verticalKey, bool useUnscaledTime)
{
if (!IsBound) return;
EffectGate gate = new(runGeneration, kind, facilityId);
if (!playedEvents.Add(gate)) return;
CreateEffect(name + " " + facilityId, position, style, groundKey, verticalKey, useUnscaledTime);
}
private ActiveVisual CreateEffect(string name, Vector3 groundPosition,
Defense25DFacilityEffectStyle style, string groundKey, string verticalKey,
bool useUnscaledTime)
{
if (!IsBound || style == null) return null;
var root = new GameObject("Facility " + name);
root.transform.SetParent(transform, false);
root.transform.position = groundPosition;
var visual = new ActiveVisual { Root = root };
if (!string.IsNullOrEmpty(groundKey)
&& !AddLayer(visual, name + " Ground", groundKey, groundPosition,
style.GroundYOffset, style.GroundScale, style.GroundTint,
style.TargetDurationSeconds, settings.GroundSortingOrder, useUnscaledTime))
{
DestroyVisual(visual);
return null;
}
if (!string.IsNullOrEmpty(verticalKey)
&& !AddLayer(visual, name + " Vertical", verticalKey, groundPosition,
style.VerticalHeight, style.VerticalScale, style.VerticalTint,
style.TargetDurationSeconds, settings.VerticalSortingOrder, useUnscaledTime))
{
DestroyVisual(visual);
return null;
}
if (visual.Sequences.Count == 0)
{
DestroyVisual(visual);
return null;
}
activeVisuals.Add(visual);
return visual;
}
private bool AddLayer(ActiveVisual visual, string layerName, string key, Vector3 groundPosition,
float localHeight, float scale, Color tint, float targetDuration, int sortingOrder,
bool useUnscaledTime)
{
Sprite[] frames = DefenseTowerArt.Load(key);
if (!settings.TryGetPlaybackScale(key, targetDuration, out float durationScale, out string error))
{
Debug.LogError($"Facility layer '{layerName}' could not load: {error}", this);
return false;
}
var layer = new GameObject(layerName);
layer.transform.SetParent(visual.Root.transform, false);
layer.transform.localPosition = new Vector3(0f, localHeight, 0f);
layer.transform.localRotation = GetBillboardRotation();
layer.transform.localScale = new Vector3(scale, scale, 1f);
SpriteRenderer renderer = layer.AddComponent<SpriteRenderer>();
renderer.sharedMaterial = world.Settings.CutoutMaterial;
renderer.sortingOrder = sortingOrder;
renderer.color = tint;
DefenseSpriteSequence sequence = layer.AddComponent<DefenseSpriteSequence>();
sequence.Play(renderer, key, frames, destroyOnFinish: false);
sequence.SetPlaybackTiming(durationScale, useUnscaledTime);
visual.Sequences.Add(sequence);
return true;
}
private Quaternion GetBillboardRotation()
{
return world.View != null
? world.View.transform.rotation
: Quaternion.Euler(0f, 0f, world.Settings.CameraPitch);
}
private void Update()
{
for (int i = activeVisuals.Count - 1; i >= 0; i--)
{
ActiveVisual visual = activeVisuals[i];
if (visual.Root == null)
{
ForgetVisual(visual);
continue;
}
bool complete = true;
foreach (DefenseSpriteSequence sequence in visual.Sequences)
if (sequence != null && !sequence.IsFinished) { complete = false; break; }
if (complete) DestroyVisual(visual);
}
}
private void OnDisable() => Clear();
private void OnDestroy() => Clear();
private void DestroyVisual(ActiveVisual visual)
{
if (visual == null) return;
if (visual.Root != null)
{
visual.Root.SetActive(false);
if (Application.isPlaying) Destroy(visual.Root);
else DestroyImmediate(visual.Root);
}
ForgetVisual(visual);
}
private void ForgetVisual(ActiveVisual visual)
{
activeVisuals.Remove(visual);
if (visual != null && visual.IsUpgrade
&& upgradeByPad.TryGetValue(visual.PadId, out ActiveVisual current)
&& ReferenceEquals(current, visual))
upgradeByPad.Remove(visual.PadId);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 77b456abb7564085b7f09861c18a0581
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,633 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
/// <summary>
/// Original per-species sprite strips supplied by the run/factory integration.
/// Keys are authored action names from the source pack (for example Attack01).
/// </summary>
public sealed class Defense25DMonsterSourceMotion
{
private readonly Dictionary<string, Sprite[]> attackActions;
public string SourceCharacterId { get; }
public Sprite[] IdleFrames { get; }
public Sprite[] WalkFrames { get; }
public Sprite[] FlyingFrames { get; }
public Sprite[] ProjectileFrames { get; }
public float FrameSeconds { get; }
public Defense25DMonsterSourceMotion(
string sourceCharacterId,
Sprite[] idleFrames,
Sprite[] walkFrames,
IReadOnlyDictionary<string, Sprite[]> attackActions,
Sprite[] flyingFrames,
float frameSeconds,
Sprite[] projectileFrames = null)
{
SourceCharacterId = sourceCharacterId;
IdleFrames = Copy(idleFrames);
WalkFrames = Copy(walkFrames);
FlyingFrames = Copy(flyingFrames);
ProjectileFrames = Copy(projectileFrames);
FrameSeconds = frameSeconds;
this.attackActions = new Dictionary<string, Sprite[]>(StringComparer.Ordinal);
if (attackActions == null) return;
foreach (KeyValuePair<string, Sprite[]> pair in attackActions)
{
if (!string.IsNullOrWhiteSpace(pair.Key))
this.attackActions[pair.Key] = Copy(pair.Value);
}
}
public bool TryGetAttackFrames(string actionName, out Sprite[] frames)
{
if (actionName != null && attackActions.TryGetValue(actionName, out Sprite[] found)
&& found.Length > 0)
{
frames = found;
return true;
}
frames = Array.Empty<Sprite>();
return false;
}
private static Sprite[] Copy(Sprite[] source) =>
source == null ? Array.Empty<Sprite>() : (Sprite[])source.Clone();
}
public readonly struct Defense25DMonsterCombatWarning
{
public bool Visible { get; }
public Defense25DMonsterAttackArchetype Archetype { get; }
public EnemyAttackShape Shape { get; }
public Vector2 Origin { get; }
public Vector2 Direction { get; }
public Vector2 TargetPoint { get; }
public float Length { get; }
public float Width { get; }
public float Radius { get; }
internal Defense25DMonsterCombatWarning(
bool visible,
Defense25DMonsterAttackArchetype archetype,
EnemyAttackShape shape,
Vector2 origin,
Vector2 direction,
Vector2 targetPoint,
float length,
float width,
float radius)
{
Visible = visible;
Archetype = archetype;
Shape = shape;
Origin = origin;
Direction = direction;
TargetPoint = targetPoint;
Length = length;
Width = width;
Radius = radius;
}
}
public readonly struct Defense25DMonsterProjectileVisual
{
public bool Active { get; }
public Vector2 Position { get; }
public Vector2 Direction { get; }
public float Radius { get; }
public Sprite Sprite { get; }
internal Defense25DMonsterProjectileVisual(
bool active, Vector2 position, Vector2 direction, float radius, Sprite sprite)
{
Active = active;
Position = position;
Direction = direction;
Radius = radius;
Sprite = sprite;
}
}
/// <summary>
/// Pool-driven 2.5D monster attack state machine. It owns warning/recovery timing,
/// archetype hit geometry, charge movement, projectile travel, and damage dispatch.
/// The run owner supplies target selection and sinks for species source frames,
/// warning geometry, and projectile rendering.
/// </summary>
public sealed class Defense25DMonsterCombatAdapter
{
private Defense25DMonsterPoolEntry entry;
private Defense25DMonsterCombatProfile profile;
private EnemyDefinition definition;
private EnemyAttackPattern definitionPattern;
private Defense25DActor actor;
private Defense25DActor target;
private Defense25DMonsterSourceMotion sourceMotion;
private Sprite[] attackFrames;
private Func<Defense25DActor> targetResolver;
private Action<Sprite> sourceSpriteSink;
private Action<Defense25DMonsterCombatWarning> warningSink;
private Action<Defense25DMonsterProjectileVisual> projectileSink;
private float attackElapsed;
private float cooldownRemaining;
private float chargeDistance;
private bool activeResolved;
private bool attackDamageApplied;
private bool warningVisible;
private bool projectileActive;
private float projectileTravelled;
private Vector2 projectilePosition;
private Vector2 projectileDirection;
private Defense25DActor projectileTarget;
private Sprite lastSourceSprite;
public bool IsBound => entry != null && actor != null;
public bool IsAttackInProgress => target != null || projectileActive;
public bool IsFlying => profile != null && definition != null && (profile.IsFlying || definition.IsFlying);
public string EnemyDefinitionId => entry != null ? entry.EnemyDefinitionId : null;
public string SourceCharacterId => entry != null ? entry.SourceCharacterId : null;
public string AttackAction => profile != null ? profile.AttackAction : null;
public Defense25DMonsterAttackArchetype Archetype => profile != null
? profile.Archetype : Defense25DMonsterAttackArchetype.Contact;
public Sprite CurrentSourceSprite => lastSourceSprite;
/// <summary>
/// Binds one pool entry to its configured 2.5D actor. The caller passes the
/// exact original sprite strips for SourceCharacterId and the target resolver
/// used by the run. A missing authored action is an error; there is no species fallback.
/// </summary>
public bool Bind(
Defense25DMonsterPoolEntry poolEntry,
Defense25DActor monsterActor,
Defense25DMonsterSourceMotion motion,
Func<Defense25DActor> resolveTarget,
out string error,
Action<Sprite> setSourceSprite = null,
Action<Defense25DMonsterCombatWarning> setWarning = null,
Action<Defense25DMonsterProjectileVisual> setProjectile = null)
{
Cancel();
if (poolEntry == null)
return Fail("Pool entry is required.", out error);
if (poolEntry.Definition == null)
return Fail($"{poolEntry.EnemyDefinitionId} has no EnemyDefinition.", out error);
if (poolEntry.CombatProfile == null)
return Fail($"{poolEntry.EnemyDefinitionId} has no CombatProfile.", out error);
if (!poolEntry.CombatProfile.TryValidate(out string profileError))
return Fail($"{poolEntry.EnemyDefinitionId} has an invalid CombatProfile: {profileError}", out error);
if (monsterActor == null)
return Fail("Monster actor is required.", out error);
if (resolveTarget == null)
return Fail("Target resolver is required.", out error);
if (!TryResolveAttackFrames(poolEntry, motion, out Sprite[] resolvedFrames, out error))
return false;
entry = poolEntry;
profile = poolEntry.CombatProfile;
definition = poolEntry.Definition;
definitionPattern = definition.GetAttackPattern(0);
actor = monsterActor;
sourceMotion = motion;
attackFrames = resolvedFrames;
targetResolver = resolveTarget;
sourceSpriteSink = setSourceSprite;
warningSink = setWarning;
projectileSink = setProjectile;
cooldownRemaining = 0f;
UpdateSourceSprite(false, 0f);
error = null;
return true;
}
/// <summary>
/// Advances one run tick. Returns true when the caller should suppress ordinary
/// route movement for this tick (warning/attack/recovery, projectile flight, or pause).
/// Existing route movement remains with the run owner; only charge attacks move here.
/// </summary>
public bool Step(float dt, bool moving, bool paused = false, bool interrupted = false)
{
if (!IsBound) return false;
if (interrupted || !actor.IsAlive)
{
Cancel();
return true;
}
if (paused) return true;
if (dt <= 0f || float.IsNaN(dt) || float.IsInfinity(dt)) return IsAttackInProgress;
StepProjectile(dt);
cooldownRemaining = Mathf.Max(0f, cooldownRemaining - dt);
if (target != null)
{
StepAttack(dt);
return true;
}
if (cooldownRemaining > 0f || projectileActive)
{
UpdateSourceSprite(moving, 0f);
return projectileActive;
}
Defense25DActor candidate = targetResolver();
if (candidate == null || !candidate.IsAlive)
{
UpdateSourceSprite(moving, 0f);
return false;
}
Vector2 origin = actor.Center;
Vector2 targetPoint = candidate.Center;
float engagementRange = profile.AttackRange + candidate.Radius;
if (Vector2.Distance(origin, targetPoint) > engagementRange)
{
UpdateSourceSprite(moving, 0f);
return false;
}
BeginAttack(candidate, origin, targetPoint);
UpdateSourceSprite(false, 0f);
return true;
}
/// <summary>Interrupts attack, warning and in-flight projectile state.</summary>
public void Cancel()
{
if (warningVisible)
{
warningVisible = false;
warningSink?.Invoke(CreateWarning(false));
}
if (projectileActive)
{
projectileActive = false;
projectileSink?.Invoke(new Defense25DMonsterProjectileVisual(
false, projectilePosition, projectileDirection, profile != null ? profile.ProjectileRadius : 0f, null));
}
target = null;
projectileTarget = null;
attackElapsed = cooldownRemaining = chargeDistance = projectileTravelled = 0f;
activeResolved = false;
attackDamageApplied = false;
entry = null;
profile = null;
definition = null;
sourceMotion = null;
attackFrames = null;
actor = null;
targetResolver = null;
sourceSpriteSink = null;
warningSink = null;
projectileSink = null;
lastSourceSprite = null;
}
public static bool TryResolveAttackFrames(
Defense25DMonsterPoolEntry poolEntry,
Defense25DMonsterSourceMotion motion,
out Sprite[] frames,
out string error)
{
frames = Array.Empty<Sprite>();
if (poolEntry == null || poolEntry.CombatProfile == null)
return Fail("Pool entry and CombatProfile are required.", out error);
if (motion == null)
return Fail("Original source motion is required.", out error);
if (!Defense25DMonsterCombatProfile.IsFinite(motion.FrameSeconds) || motion.FrameSeconds <= 0f)
return Fail("Original source motion requires a finite positive FrameSeconds.", out error);
if (!string.Equals(poolEntry.SourceCharacterId, motion.SourceCharacterId, StringComparison.Ordinal))
return Fail($"SourceCharacterId mismatch: pool='{poolEntry.SourceCharacterId}', motion='{motion.SourceCharacterId}'.", out error);
if (motion.IdleFrames.Length == 0 && motion.FlyingFrames.Length == 0)
return Fail($"Source motion '{motion.SourceCharacterId}' has no Idle or Flying frames.", out error);
if (!motion.TryGetAttackFrames(poolEntry.CombatProfile.AttackAction, out Sprite[] sourceFrames))
return Fail($"Source motion '{motion.SourceCharacterId}' has no '{poolEntry.CombatProfile.AttackAction}' action frames.", out error);
for (int i = 0; i < sourceFrames.Length; i++)
{
if (sourceFrames[i] == null)
return Fail($"Source action '{poolEntry.CombatProfile.AttackAction}' contains a null frame.", out error);
}
frames = (Sprite[])sourceFrames.Clone();
error = null;
return true;
}
public static bool IntersectsAttack(
EnemyAttackShape shape,
Vector2 origin,
Vector2 direction,
Vector2 lockedTargetPoint,
Vector2 targetCenter,
float targetRadius,
float bodyRadius,
float attackLength,
float attackWidth,
float attackRadius)
{
direction = direction.sqrMagnitude > .0001f ? direction.normalized : Vector2.right;
Vector2 offset = targetCenter - origin;
switch (shape)
{
case EnemyAttackShape.Body:
return offset.sqrMagnitude <= Square(Mathf.Max(0f, bodyRadius) + Mathf.Max(0f, targetRadius));
case EnemyAttackShape.Box:
{
float along = Vector2.Dot(offset, direction);
float side = Mathf.Abs(Cross(direction, offset));
return along >= -targetRadius
&& along <= attackLength + targetRadius
&& side <= attackWidth * .5f + targetRadius;
}
case EnemyAttackShape.Cone:
{
float along = Vector2.Dot(offset, direction);
if (along < -targetRadius || along > attackLength + targetRadius) return false;
float reach = Mathf.Clamp(along, 0f, Mathf.Max(.0001f, attackLength));
float side = Mathf.Abs(Cross(direction, offset));
float halfWidthAtReach = attackLength > .0001f
? attackWidth * .5f * reach / attackLength : 0f;
return side <= halfWidthAtReach + targetRadius;
}
case EnemyAttackShape.TargetCircle:
return (targetCenter - lockedTargetPoint).sqrMagnitude
<= Square(Mathf.Max(0f, attackRadius) + Mathf.Max(0f, targetRadius));
default:
return false;
}
}
public static bool ProjectileSegmentHits(Vector2 from, Vector2 to, Vector2 targetCenter, float combinedRadius)
{
if (combinedRadius < 0f || float.IsNaN(combinedRadius) || float.IsInfinity(combinedRadius)) return false;
Vector2 segment = to - from;
float lengthSquared = segment.sqrMagnitude;
float t = lengthSquared <= .000001f ? 0f
: Mathf.Clamp01(Vector2.Dot(targetCenter - from, segment) / lengthSquared);
Vector2 nearest = from + segment * t;
return (targetCenter - nearest).sqrMagnitude <= combinedRadius * combinedRadius;
}
private void BeginAttack(Defense25DActor attackTarget, Vector2 origin, Vector2 targetPoint)
{
target = attackTarget;
attackElapsed = 0f;
chargeDistance = 0f;
activeResolved = false;
attackDamageApplied = false;
Vector2 toTarget = targetPoint - origin;
attackDirection = toTarget.sqrMagnitude > .0001f ? toTarget.normalized : actor.Facing.normalized;
if (attackDirection.sqrMagnitude <= .0001f) attackDirection = Vector2.right;
lockedTargetPoint = targetPoint;
actor.Facing = attackDirection;
warningVisible = profile.WarningSeconds > 0f;
if (warningVisible) warningSink?.Invoke(CreateWarning(true));
}
private Vector2 attackDirection;
private Vector2 lockedTargetPoint;
private void StepAttack(float dt)
{
if (target == null || !target.IsAlive)
{
FinishAttack();
return;
}
float previousElapsed = attackElapsed;
attackElapsed += dt;
float activeStart = profile.WarningSeconds;
float activeEnd = activeStart + profile.ActiveSeconds;
float activeStep = Mathf.Max(0f, attackElapsed - Mathf.Max(previousElapsed, activeStart));
bool enteringActive = !activeResolved && attackElapsed >= activeStart;
if (warningVisible && attackElapsed >= activeStart)
{
warningVisible = false;
warningSink?.Invoke(CreateWarning(false));
}
if (profile.Archetype == Defense25DMonsterAttackArchetype.Charge
&& attackElapsed >= activeStart && previousElapsed < activeEnd)
{
StepCharge(Mathf.Min(activeStep, Mathf.Max(0f, activeEnd - Mathf.Max(previousElapsed, activeStart))));
}
if (enteringActive)
{
activeResolved = true;
switch (profile.Archetype)
{
case Defense25DMonsterAttackArchetype.Projectile:
LaunchProjectile();
break;
case Defense25DMonsterAttackArchetype.Area:
TryApplyAreaDamage();
break;
case Defense25DMonsterAttackArchetype.Contact:
TryApplyContactDamage();
break;
case Defense25DMonsterAttackArchetype.Melee:
TryApplyMeleeDamage();
break;
case Defense25DMonsterAttackArchetype.Charge:
TryApplyContactDamage();
break;
}
}
UpdateSourceSprite(false, attackElapsed);
if (attackElapsed >= activeStart + profile.ActiveSeconds + profile.RecoverySeconds)
FinishAttack();
}
private void StepCharge(float dt)
{
if (dt <= 0f || chargeDistance >= profile.AttackLength || target == null || !target.IsAlive) return;
float requested = Mathf.Min(profile.MoveSpeed * dt, profile.AttackLength - chargeDistance);
Vector2 moved = actor.Move(attackDirection * requested);
chargeDistance += moved.magnitude;
TryApplyContactDamage();
}
private void TryApplyContactDamage()
{
if (target == null || !target.IsAlive) return;
float touchRange = profile.BodyRadius + target.Radius;
if (Vector2.Distance(actor.Center, target.Center) <= touchRange)
ApplyDamage(target, attackDirection);
}
private void TryApplyMeleeDamage()
{
if (target == null || !target.IsAlive) return;
if (IntersectsAttack(profile.AttackShape, actor.Center, attackDirection, lockedTargetPoint,
target.Center, target.Radius, profile.BodyRadius, profile.AttackLength,
profile.AttackWidth, profile.AttackRadius))
ApplyDamage(target, attackDirection);
}
private void TryApplyAreaDamage()
{
if (target == null || !target.IsAlive) return;
if ((target.Center - lockedTargetPoint).sqrMagnitude
<= Square(profile.AttackRadius + target.Radius))
ApplyDamage(target, attackDirection);
}
private void LaunchProjectile()
{
if (target == null || !target.IsAlive) return;
projectileTarget = target;
projectilePosition = actor.Center;
Vector2 offset = lockedTargetPoint - projectilePosition;
projectileDirection = offset.sqrMagnitude > .0001f ? offset.normalized : attackDirection;
projectileTravelled = 0f;
projectileActive = true;
projectileSink?.Invoke(new Defense25DMonsterProjectileVisual(
true, projectilePosition, projectileDirection, profile.ProjectileRadius, ProjectileSprite(0f)));
}
private void StepProjectile(float dt)
{
if (!projectileActive) return;
if (projectileTarget == null || !projectileTarget.IsAlive)
{
EndProjectile();
return;
}
Vector2 previous = projectilePosition;
float maxRange = profile.AttackRange;
float travel = Mathf.Min(profile.ProjectileSpeed * dt, Mathf.Max(0f, maxRange - projectileTravelled));
projectilePosition += projectileDirection * travel;
projectileTravelled += travel;
projectileSink?.Invoke(new Defense25DMonsterProjectileVisual(
true, projectilePosition, projectileDirection, profile.ProjectileRadius,
ProjectileSprite(projectileTravelled / profile.ProjectileSpeed)));
if (ProjectileSegmentHits(previous, projectilePosition, projectileTarget.Center,
profile.ProjectileRadius + projectileTarget.Radius))
{
ApplyDamage(projectileTarget, projectileDirection);
EndProjectile();
}
else if (projectileTravelled >= maxRange - .0001f)
{
EndProjectile();
}
}
private void ApplyDamage(Defense25DActor victim, Vector2 direction)
{
if (attackDamageApplied || victim == null || !victim.IsAlive) return;
attackDamageApplied = true;
float damage = actor.CalculateAttackDamage(profile.AttackDamage * definitionPattern.DamageMultiplier);
victim.TakeDamage(damage, direction, 0f, EnemyDamageSource.None);
}
private void FinishAttack()
{
if (warningVisible)
{
warningVisible = false;
warningSink?.Invoke(CreateWarning(false));
}
target = null;
attackElapsed = 0f;
// Recovery is already included in the attack state's total duration.
cooldownRemaining = 0f;
activeResolved = false;
UpdateSourceSprite(false, 0f);
}
private void EndProjectile()
{
if (!projectileActive) return;
projectileActive = false;
projectileSink?.Invoke(new Defense25DMonsterProjectileVisual(
false, projectilePosition, projectileDirection, profile.ProjectileRadius, null));
projectileTarget = null;
}
private Defense25DMonsterCombatWarning CreateWarning(bool visible) =>
new Defense25DMonsterCombatWarning(visible, profile.Archetype, profile.AttackShape,
actor != null ? actor.Center : Vector2.zero, attackDirection, lockedTargetPoint,
profile.AttackLength, profile.AttackWidth, profile.AttackRadius);
private Sprite ProjectileSprite(float elapsed)
{
Sprite[] frames = sourceMotion.ProjectileFrames.Length > 0
? sourceMotion.ProjectileFrames : attackFrames;
return FrameAt(frames, elapsed, sourceMotion.FrameSeconds);
}
private void UpdateSourceSprite(bool moving, float attackTime)
{
if (sourceMotion == null) return;
Sprite[] frames;
float elapsed;
if (target != null && attackTime + .0001f >= Mathf.Max(0f,
profile.WarningSeconds - definitionPattern.AnimationLeadTime))
{
frames = attackFrames;
elapsed = Mathf.Max(0f, attackTime - Mathf.Max(0f,
profile.WarningSeconds - definitionPattern.AnimationLeadTime));
}
else if (IsFlying && sourceMotion.FlyingFrames.Length > 0)
{
frames = sourceMotion.FlyingFrames;
elapsed = Time.time;
}
else if (moving && sourceMotion.WalkFrames.Length > 0)
{
frames = sourceMotion.WalkFrames;
elapsed = Time.time;
}
else if (sourceMotion.IdleFrames.Length > 0)
{
frames = sourceMotion.IdleFrames;
elapsed = Time.time;
}
else
{
frames = attackFrames;
elapsed = 0f;
}
Sprite sprite = FrameAt(frames, elapsed, sourceMotion.FrameSeconds);
if (sprite == null || sprite == lastSourceSprite) return;
lastSourceSprite = sprite;
sourceSpriteSink?.Invoke(sprite);
}
private static Sprite FrameAt(Sprite[] frames, float elapsed, float frameSeconds)
{
if (frames == null || frames.Length == 0) return null;
int frame = frameSeconds > 0f
? Mathf.FloorToInt(Mathf.Max(0f, elapsed) / frameSeconds)
: 0;
return frames[Mathf.Clamp(frame, 0, frames.Length - 1)];
}
private static float Cross(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
private static float Square(float value) => value * value;
private static bool Fail(string message, out string error)
{
error = message;
return false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dab681c66fee4e1ebac4e6ba7f723001
@@ -0,0 +1,486 @@
using System;
using System.Collections.Generic;
using BumpCombat.Defense;
namespace BumpCombat.Defense.Comparison
{
public enum Defense25DPersonalMissionStatus
{
Available,
Active,
RewardPending,
Completed,
Failed,
}
public sealed class Defense25DPersonalMissionSpawnRequest
{
public int RunGeneration { get; }
public int MissionId { get; }
public int MissionInstanceId { get; }
public int PortalId { get; }
public Defense25DSpawnSource Source => Defense25DSpawnSource.PersonalMission;
public bool IsMissionTarget => true;
internal Defense25DPersonalMissionSpawnRequest(
int runGeneration,
int missionId,
int missionInstanceId,
int portalId)
{
RunGeneration = runGeneration;
MissionId = missionId;
MissionInstanceId = missionInstanceId;
PortalId = portalId;
}
}
public sealed class Defense25DPersonalMissionState
{
private IReadOnlyList<int> targetPortalIds = Array.Empty<int>();
private IReadOnlyList<int> targetSpawnIds = Array.Empty<int>();
public int MissionId { get; }
public int RequiredTargetCount { get; }
public int MissionInstanceId { get; private set; }
public Defense25DPersonalMissionStatus Status { get; private set; }
public int RemainingTargetCount { get; private set; }
public bool RewardQueued { get; private set; }
public string RewardToken { get; private set; } = string.Empty;
public IReadOnlyList<int> TargetPortalIds => targetPortalIds;
public IReadOnlyList<int> TargetSpawnIds => targetSpawnIds;
internal Defense25DPersonalMissionState(int missionId, int targetCount)
{
MissionId = missionId;
RequiredTargetCount = targetCount;
Status = Defense25DPersonalMissionStatus.Available;
}
internal void Begin(int instanceId, IReadOnlyList<int> portalIds)
{
MissionInstanceId = instanceId;
targetPortalIds = Array.AsReadOnly(Copy(portalIds));
targetSpawnIds = Array.Empty<int>();
RemainingTargetCount = RequiredTargetCount;
RewardToken = string.Empty;
Status = Defense25DPersonalMissionStatus.Active;
}
internal void RegisterSpawnIds(IReadOnlyList<int> spawnIds)
{
targetSpawnIds = Array.AsReadOnly(Copy(spawnIds));
}
internal void ResolveOne()
{
if (RemainingTargetCount > 0)
RemainingTargetCount--;
}
internal void Fail()
{
Status = Defense25DPersonalMissionStatus.Failed;
}
internal void BeginRewardPending(string rewardToken)
{
RewardToken = rewardToken;
RewardQueued = false;
Status = Defense25DPersonalMissionStatus.RewardPending;
}
internal void MarkRewardQueued()
{
RewardQueued = true;
}
internal bool CompleteReward()
{
if (Status != Defense25DPersonalMissionStatus.RewardPending || !RewardQueued)
return false;
Status = Defense25DPersonalMissionStatus.Completed;
return true;
}
internal void Reset()
{
MissionInstanceId = 0;
Status = Defense25DPersonalMissionStatus.Available;
RemainingTargetCount = 0;
RewardQueued = false;
RewardToken = string.Empty;
targetPortalIds = Array.Empty<int>();
targetSpawnIds = Array.Empty<int>();
}
private static int[] Copy(IReadOnlyList<int> values)
{
var copy = new int[values.Count];
for (int i = 0; i < copy.Length; i++)
copy[i] = values[i];
return copy;
}
}
/// <summary>Owns personal mission target identity and completion independently of global wave timing.</summary>
public sealed class Defense25DPersonalMissions
{
public delegate bool SpawnTargetsHandler(
IReadOnlyList<Defense25DPersonalMissionSpawnRequest> requests,
out IReadOnlyList<int> spawnIds);
private readonly Dictionary<TargetKey, TargetRecord> targets = new();
private readonly HashSet<int> usedSpawnIds = new();
private Defense25DPersonalMissionState[] states = Array.Empty<Defense25DPersonalMissionState>();
private IReadOnlyList<Defense25DPersonalMissionState> readOnlyStates = Array.Empty<Defense25DPersonalMissionState>();
private int[] requiredTargetCounts = Array.Empty<int>();
private Func<bool> canAcceptMissions;
private Func<IReadOnlyList<int>> getOpenPortalIds;
private SpawnTargetsHandler spawnTargets;
private Func<string, bool> queueReward;
private Random portalRandom;
private int nextMissionInstanceId;
public int Generation { get; private set; }
public IReadOnlyList<Defense25DPersonalMissionState> States => readOnlyStates;
public Defense25DPersonalMissionState CurrentMissionState
{
get
{
for (int i = 0; i < states.Length; i++)
{
if (states[i].Status == Defense25DPersonalMissionStatus.Active
|| states[i].Status == Defense25DPersonalMissionStatus.RewardPending)
return states[i];
}
return null;
}
}
public int RemainingTargetCount => CurrentMissionState?.RemainingTargetCount ?? 0;
public void Bind(
IReadOnlyList<int> missionTargetCounts,
Func<bool> canAcceptNewMissions,
Func<IReadOnlyList<int>> openPortalIds,
SpawnTargetsHandler targetSpawner,
Func<string, bool> enqueueReward)
{
if (missionTargetCounts == null || missionTargetCounts.Count != 3)
throw new ArgumentException("Exactly three personal mission target counts are required.", nameof(missionTargetCounts));
if (canAcceptNewMissions == null)
throw new ArgumentNullException(nameof(canAcceptNewMissions));
if (openPortalIds == null)
throw new ArgumentNullException(nameof(openPortalIds));
if (targetSpawner == null)
throw new ArgumentNullException(nameof(targetSpawner));
if (enqueueReward == null)
throw new ArgumentNullException(nameof(enqueueReward));
requiredTargetCounts = new int[missionTargetCounts.Count];
states = new Defense25DPersonalMissionState[missionTargetCounts.Count];
for (int i = 0; i < missionTargetCounts.Count; i++)
{
int targetCount = missionTargetCounts[i];
if (targetCount <= 0)
throw new ArgumentOutOfRangeException(nameof(missionTargetCounts), "Mission target counts must be positive.");
requiredTargetCounts[i] = targetCount;
states[i] = new Defense25DPersonalMissionState(i + 1, targetCount);
}
readOnlyStates = Array.AsReadOnly(states);
canAcceptMissions = canAcceptNewMissions;
getOpenPortalIds = openPortalIds;
spawnTargets = targetSpawner;
queueReward = enqueueReward;
}
public bool CanStart(int missionId, out string reason)
{
if (!IsBound())
{
reason = "Personal missions are not bound to a run.";
return false;
}
if (missionId < 1 || missionId > states.Length)
{
reason = "Mission ID must be between 1 and 3.";
return false;
}
Defense25DPersonalMissionState state = states[missionId - 1];
if (state.Status != Defense25DPersonalMissionStatus.Available)
{
reason = "This mission has already started.";
return false;
}
if (CurrentMissionState != null)
{
reason = "Another personal mission is already active.";
return false;
}
if (!canAcceptMissions())
{
reason = "The run cannot accept a personal mission now.";
return false;
}
if (GetDistinctOpenPortalIds().Count < requiredTargetCounts[missionId - 1])
{
reason = $"Mission {missionId} requires {requiredTargetCounts[missionId - 1]} open portals.";
return false;
}
reason = string.Empty;
return true;
}
public bool TryStart(int missionId)
{
if (!CanStart(missionId, out _))
return false;
if (nextMissionInstanceId == int.MaxValue)
return false;
List<int> openPortals = GetDistinctOpenPortalIds();
int targetCount = requiredTargetCounts[missionId - 1];
if (openPortals.Count < targetCount)
return false;
Shuffle(openPortals);
int[] selectedPortals = openPortals.GetRange(0, targetCount).ToArray();
int instanceId = ++nextMissionInstanceId;
var requests = new Defense25DPersonalMissionSpawnRequest[targetCount];
for (int i = 0; i < targetCount; i++)
{
requests[i] = new Defense25DPersonalMissionSpawnRequest(
Generation,
missionId,
instanceId,
selectedPortals[i]);
}
Defense25DPersonalMissionState state = states[missionId - 1];
state.Begin(instanceId, selectedPortals);
// The world adapter must spawn this batch atomically through its shared enemy factory.
if (!spawnTargets(Array.AsReadOnly(requests), out IReadOnlyList<int> returnedIds)
|| !AreValidSpawnIds(returnedIds, targetCount))
{
state.Reset();
return false;
}
int[] spawnIds = Copy(returnedIds);
state.RegisterSpawnIds(spawnIds);
for (int i = 0; i < spawnIds.Length; i++)
{
usedSpawnIds.Add(spawnIds[i]);
targets.Add(new TargetKey(Generation, spawnIds[i]), new TargetRecord(state));
}
return true;
}
public bool ResolveTarget(int generation, int spawnId, DefenseEnemyResolution reason)
{
if (generation != Generation || !targets.TryGetValue(new TargetKey(generation, spawnId), out TargetRecord target))
return false;
if (target.Resolved || (reason != DefenseEnemyResolution.Death && reason != DefenseEnemyResolution.Arrival))
return false;
target.Resolved = true;
Defense25DPersonalMissionState state = target.Mission;
state.ResolveOne();
if (reason == DefenseEnemyResolution.Arrival)
{
if (state.Status == Defense25DPersonalMissionStatus.Active)
state.Fail();
return true;
}
if (state.Status == Defense25DPersonalMissionStatus.Active && state.RemainingTargetCount == 0)
{
string token = $"personal-mission:{generation}:{state.MissionInstanceId}";
state.BeginRewardPending(token);
TryQueuePendingReward(generation, state.MissionInstanceId);
}
return true;
}
public bool TryQueuePendingReward(int generation, int missionInstanceId)
{
Defense25DPersonalMissionState state = FindMissionInstance(generation, missionInstanceId);
if (state == null
|| state.Status != Defense25DPersonalMissionStatus.RewardPending
|| state.RewardQueued)
{
return false;
}
if (!queueReward(state.RewardToken))
return false;
state.MarkRewardQueued();
return true;
}
public bool TryCompleteReward(int generation, int missionInstanceId)
{
Defense25DPersonalMissionState state = FindMissionInstance(generation, missionInstanceId);
return state != null && state.CompleteReward();
}
public bool ResolveTarget(
int generation,
int spawnId,
Defense25DSpawnSource source,
int missionId,
int missionInstanceId,
DefenseEnemyResolution reason)
{
if (source != Defense25DSpawnSource.PersonalMission
|| !targets.TryGetValue(new TargetKey(generation, spawnId), out TargetRecord target)
|| target.Mission.MissionId != missionId
|| target.Mission.MissionInstanceId != missionInstanceId)
{
return false;
}
return ResolveTarget(generation, spawnId, reason);
}
public void Reset(int generation, int missionSeed)
{
Generation = generation;
nextMissionInstanceId = 0;
targets.Clear();
usedSpawnIds.Clear();
for (int i = 0; i < states.Length; i++)
states[i].Reset();
portalRandom = new Random(unchecked(missionSeed ^ (generation * 397)));
}
private bool IsBound()
{
return states.Length == 3
&& canAcceptMissions != null
&& getOpenPortalIds != null
&& spawnTargets != null
&& queueReward != null
&& portalRandom != null;
}
private Defense25DPersonalMissionState FindMissionInstance(int generation, int missionInstanceId)
{
if (generation != Generation || missionInstanceId <= 0)
return null;
for (int i = 0; i < states.Length; i++)
{
if (states[i].MissionInstanceId == missionInstanceId)
return states[i];
}
return null;
}
private List<int> GetDistinctOpenPortalIds()
{
var unique = new SortedSet<int>();
IReadOnlyList<int> portalIds = getOpenPortalIds?.Invoke();
if (portalIds == null)
return new List<int>();
for (int i = 0; i < portalIds.Count; i++)
{
if (portalIds[i] >= 0)
unique.Add(portalIds[i]);
}
return new List<int>(unique);
}
private bool AreValidSpawnIds(IReadOnlyList<int> spawnIds, int expectedCount)
{
if (spawnIds == null || spawnIds.Count != expectedCount)
return false;
var batchIds = new HashSet<int>();
for (int i = 0; i < spawnIds.Count; i++)
{
if (spawnIds[i] < 0 || usedSpawnIds.Contains(spawnIds[i]) || !batchIds.Add(spawnIds[i]))
return false;
}
return true;
}
private void Shuffle(List<int> portalIds)
{
for (int i = portalIds.Count - 1; i > 0; i--)
{
int swapIndex = portalRandom.Next(i + 1);
(portalIds[i], portalIds[swapIndex]) = (portalIds[swapIndex], portalIds[i]);
}
}
private static int[] Copy(IReadOnlyList<int> values)
{
var copy = new int[values.Count];
for (int i = 0; i < copy.Length; i++)
copy[i] = values[i];
return copy;
}
private readonly struct TargetKey : IEquatable<TargetKey>
{
private readonly int generation;
private readonly int spawnId;
public TargetKey(int generation, int spawnId)
{
this.generation = generation;
this.spawnId = spawnId;
}
public bool Equals(TargetKey other)
{
return generation == other.generation && spawnId == other.spawnId;
}
public override bool Equals(object obj)
{
return obj is TargetKey other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
return (generation * 397) ^ spawnId;
}
}
}
private sealed class TargetRecord
{
public Defense25DPersonalMissionState Mission { get; }
public bool Resolved { get; set; }
public TargetRecord(Defense25DPersonalMissionState mission)
{
Mission = mission;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79b3e2d5016a4c2f8d0b7a9631e5c4f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Progression;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
/// <summary>Owns long-run XP and routes all progression choices through the shared reward queue.</summary>
public sealed class Defense25DProgression
{
private readonly ExperienceProgress experience = new();
private Defense25DActor player;
private Defense25DRewardChoices rewards;
private Defense25DTowerModifiers towerModifiers;
private int runGeneration;
public event Action Changed;
public event Action<Defense25DRewardSource, string> ChoiceRequested;
public event Action<Defense25DRewardSource, string> RewardAutomaticallyResolved;
public int Level => experience.Level;
public int CurrentExperience => experience.CurrentExperience;
public int RequiredExperience => experience.RequiredExperience;
public bool HasPendingLevelChoice => rewards != null
&& rewards.GetPendingRewardCount(Defense25DRewardSource.LevelUp) > 0;
public bool HasPendingReward => rewards?.HasPendingReward == true;
public string CurrentRewardToken => rewards?.CurrentRewardToken ?? string.Empty;
public Defense25DRewardSource CurrentRewardSource => rewards != null
? rewards.CurrentSource
: default;
public bool CurrentRewardIsOpened => rewards?.CurrentRewardIsOpened == true;
public Defense25DTowerModifiers TowerModifiers => towerModifiers;
public Defense25DRewardChoices RewardChoices => rewards;
public IReadOnlyList<Defense25DRewardChoice> CurrentChoices => rewards != null
? rewards.CurrentChoices
: Array.Empty<Defense25DRewardChoice>();
public void Bind(
Defense25DActor playerActor,
Defense25DRewardConstants rewardConstants,
IReadOnlyList<ModifierDefinition> playerModifierCatalog,
Defense25DTowerModifiers towerModifiers,
Func<DefenseTowerKind, bool> hasTowerFamily,
Action<int> grantCurrency = null,
Func<string, IReadOnlyList<Defense25DRewardChoice>> missionRelicCandidates = null,
Func<Defense25DRewardChoice, bool> applyMissionRelic = null)
{
if (playerActor == null)
throw new ArgumentNullException(nameof(playerActor));
if (rewardConstants == null)
throw new ArgumentNullException(nameof(rewardConstants));
if (playerModifierCatalog == null)
throw new ArgumentNullException(nameof(playerModifierCatalog));
if (rewards != null)
{
rewards.Changed -= HandleChanged;
rewards.AutomaticallyResolved -= HandleRewardAutomaticallyResolved;
}
experience.ExperienceChanged -= HandleExperienceChanged;
experience.LevelGained -= HandleLevelGained;
player = playerActor;
this.towerModifiers = towerModifiers;
rewards = new Defense25DRewardChoices(
rewardConstants,
playerModifierCatalog,
player,
towerModifiers,
hasTowerFamily,
amount => player.Heal(amount),
grantCurrency,
missionRelicCandidates,
applyMissionRelic);
rewards.Changed += HandleChanged;
rewards.AutomaticallyResolved += HandleRewardAutomaticallyResolved;
experience.ExperienceChanged += HandleExperienceChanged;
experience.LevelGained += HandleLevelGained;
ResetProgression();
}
public void ResetProgression()
{
runGeneration++;
experience.Reset();
rewards?.Reset();
towerModifiers?.Reset();
Changed?.Invoke();
}
public void GrantKillExperience(int baseExperience)
{
if (player == null)
return;
float evaluatedExperience = player.Evaluate(
CharacterStat.ExperienceGain,
Mathf.Max(0, baseExperience));
experience.AddEvaluated(evaluatedExperience);
}
public bool QueueReward(Defense25DRewardSource source, string rewardToken)
{
return rewards != null && rewards.QueueReward(source, rewardToken);
}
public bool TryChooseModifier(int index)
{
if (rewards == null || rewards.PendingRewardCount == 0)
return false;
return rewards.TryChooseReward(index);
}
public bool TryChooseModifier(int index, string expectedRewardToken)
{
return TryChooseReward(index, expectedRewardToken);
}
public bool TryChooseReward(int index, string expectedRewardToken)
{
if (rewards == null || rewards.PendingRewardCount == 0)
return false;
return rewards.TryChooseReward(index, expectedRewardToken);
}
public void RefreshChoices()
{
rewards?.RefreshChoices();
}
public bool MarkCurrentRewardOpened()
{
return rewards != null && rewards.MarkCurrentRewardOpened();
}
private void HandleLevelGained(int level)
{
string token = $"run:{runGeneration}:level:{level}";
if (rewards != null
&& rewards.QueueReward(Defense25DRewardSource.LevelUp, token))
{
if (rewards.HasPendingRewardToken(token))
{
ChoiceRequested?.Invoke(Defense25DRewardSource.LevelUp, token);
}
}
}
private void HandleRewardAutomaticallyResolved(
Defense25DRewardSource source,
string rewardToken)
{
RewardAutomaticallyResolved?.Invoke(source, rewardToken);
}
private void HandleExperienceChanged(int current, int required, int level)
{
Changed?.Invoke();
}
private void HandleChanged()
{
Changed?.Invoke();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c9ea346ad1442618ae1a66e6fbc7f95
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 77f5e88267db4f21b14becfc6e1d056a
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7504a8f992d54ba3a6038b090a5f06f4
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed class Defense25DRunLayout
{
private readonly IReadOnlyList<IReadOnlyList<Vector2>> routes;
private readonly IReadOnlyList<Vector2> portalPositions;
private readonly IReadOnlyList<Vector2> pads;
public string BattlefieldId { get; }
public int LayoutSeed { get; }
public Vector2 HomePosition { get; }
public float RotationDegrees { get; }
public Vector2 PlayerStartOffset { get; }
public Vector2 PlayerStart { get; }
public Rect Bounds { get; }
public IReadOnlyList<IReadOnlyList<Vector2>> Routes => routes;
public IReadOnlyList<Vector2> PortalPositions => portalPositions;
public IReadOnlyList<Vector2> PadPositions => pads;
public int RouteCount => routes.Count;
public int PadCount => pads.Count;
private Defense25DRunLayout(string battlefieldId, int layoutSeed, Rect bounds,
Vector2 homePosition, float rotationDegrees, IReadOnlyList<IReadOnlyList<Vector2>> routes,
IReadOnlyList<Vector2> portalPositions, IReadOnlyList<Vector2> pads)
{
BattlefieldId = battlefieldId;
LayoutSeed = layoutSeed;
Bounds = bounds;
HomePosition = homePosition;
RotationDegrees = rotationDegrees;
PlayerStartOffset = Defense25DBattlefieldDefinition.Rotate(new Vector2(0f, -2f), rotationDegrees);
PlayerStart = homePosition + PlayerStartOffset;
this.routes = routes;
this.portalPositions = portalPositions;
this.pads = pads;
}
internal static Defense25DRunLayout Create(Defense25DBattlefieldDefinition definition, int seed,
Vector2 home, float rotation)
{
Defense25DLocalRoute[] localRoutes = (Defense25DLocalRoute[])definition.LocalRoutes.Clone();
Array.Sort(localRoutes, (left, right) => left.PortalId.CompareTo(right.PortalId));
List<IReadOnlyList<Vector2>> worldRoutes = new(localRoutes.Length);
Vector2[] worldPortals = new Vector2[localRoutes.Length];
bool[] portalAssigned = new bool[localRoutes.Length];
foreach (Defense25DLocalRoute route in localRoutes)
{
if (route.PortalId < 0 || route.PortalId >= localRoutes.Length || portalAssigned[route.PortalId])
throw new InvalidOperationException($"Battlefield '{definition.Id}' has an invalid portal route ID.");
Vector2[] points = new Vector2[route.Points.Length];
for (int i = 0; i < points.Length; i++)
points[i] = home + Defense25DBattlefieldDefinition.Rotate(route.Points[i], rotation);
portalAssigned[route.PortalId] = true;
worldPortals[route.PortalId] = points[0];
worldRoutes.Add(Array.AsReadOnly(points));
}
for (int i = 0; i < portalAssigned.Length; i++)
if (!portalAssigned[i])
throw new InvalidOperationException($"Battlefield '{definition.Id}' is missing route {i}.");
Vector2[] worldPads = new Vector2[definition.LocalPads.Length];
for (int i = 0; i < worldPads.Length; i++)
worldPads[i] = home + Defense25DBattlefieldDefinition.Rotate(definition.LocalPads[i], rotation);
return new Defense25DRunLayout(definition.Id, seed, definition.Bounds, home, rotation,
worldRoutes.AsReadOnly(), Array.AsReadOnly(worldPortals), Array.AsReadOnly(worldPads));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fc172d7738a243e59c305722e22477c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using BumpCombat.Constants;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
[DisallowMultipleComponent]
public sealed class Defense25DRunSceneProfile : MonoBehaviour
{
[SerializeField] private Defense25DRunDefinition runDefinition;
public Defense25DRunDefinition RunDefinition => runDefinition;
public void Bind(Defense25DRunDefinition definition)
{
runDefinition = definition;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bd2775ffd7934131b50ead6c0c6d97ea
MonoImporter
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -24,6 +24,8 @@ namespace BumpCombat.Defense.Comparison
private float scale = 1f, footOffset, hurtUntil, attackUntil, attackStart;
private bool attacking, warning, tower;
private bool hideAfterDeath;
private bool externalSourceMotion;
private Sprite externalSourceSprite;
private string activePlayerMotion;
private Color feedbackTint = Color.white;
private int forcedFrame;
@@ -196,6 +198,8 @@ namespace BumpCombat.Defense.Comparison
transientDuration = 0f;
hideAfterDeath = false;
activePlayerMotion = null;
externalSourceMotion = false;
externalSourceSprite = null;
ClearFeedbackTint();
attacking = warning = false;
VisualHeight = 0f;
@@ -215,6 +219,26 @@ namespace BumpCombat.Defense.Comparison
if (footprint != null) footprint.enabled = true;
}
public void BeginExternalSourceMotion(Sprite initialSprite)
{
externalSourceMotion = true;
externalSourceSprite = initialSprite;
if (sprite != null) sprite.sprite = initialSprite;
}
public void SetExternalSourceSprite(Sprite value)
{
externalSourceMotion = true;
externalSourceSprite = value;
if (sprite != null) sprite.sprite = value;
}
public void EndExternalSourceMotion()
{
externalSourceMotion = false;
externalSourceSprite = null;
}
public void ShowHurt()
{
if (actor != null && actor.Kind != ComparisonActorKind.Player)
@@ -364,19 +388,26 @@ namespace BumpCombat.Defense.Comparison
activePlayerMotion = null;
if (hideAfterDeath) { Hide(); return; }
}
Sprite[] sequence = transientFrames != null ? transientFrames
: world.GameTime < hurtUntil && frames.Hurt.Length > 0 ? frames.Hurt
: (attacking || world.GameTime < attackUntil) && frames.Attack.Length > 0 ? frames.Attack
: moving && frames.Walk.Length > 0 ? frames.Walk : frames.Idle;
int frame = transientFrames != null ? TimedFrame(transientDurations, world.GameTime - transientStart)
: forcedFrame >= 0 && attacking ? forcedFrame
: Mathf.FloorToInt((attacking ? world.GameTime - attackStart : world.GameTime)
/ (sequence == frames.Idle
? Defense25DPlayerAnimationConstants.IdleFrameSeconds
: Defense25DPlayerAnimationConstants.WalkFrameSeconds));
if (actor != null && actor.IsMinion && !attacking)
frame = FrameAt(moving ? summonWalkKey : summonIdleKey, world.GameTime, true);
if (sequence.Length > 0) sprite.sprite = sequence[Mathf.Max(0, frame) % sequence.Length];
if (externalSourceMotion)
{
sprite.sprite = externalSourceSprite;
}
else
{
Sprite[] sequence = transientFrames != null ? transientFrames
: world.GameTime < hurtUntil && frames.Hurt.Length > 0 ? frames.Hurt
: (attacking || world.GameTime < attackUntil) && frames.Attack.Length > 0 ? frames.Attack
: moving && frames.Walk.Length > 0 ? frames.Walk : frames.Idle;
int frame = transientFrames != null ? TimedFrame(transientDurations, world.GameTime - transientStart)
: forcedFrame >= 0 && attacking ? forcedFrame
: Mathf.FloorToInt((attacking ? world.GameTime - attackStart : world.GameTime)
/ (sequence == frames.Idle
? Defense25DPlayerAnimationConstants.IdleFrameSeconds
: Defense25DPlayerAnimationConstants.WalkFrameSeconds));
if (actor != null && actor.IsMinion && !attacking)
frame = FrameAt(moving ? summonWalkKey : summonIdleKey, world.GameTime, true);
if (sequence.Length > 0) sprite.sprite = sequence[Mathf.Max(0, frame) % sequence.Length];
}
if (actor != null) sprite.flipX = actor.Facing.x < 0f;
if (actor != null && actor.Kind == ComparisonActorKind.Player && world.View != null)
sprite.transform.position += PixelGridOffset(world.View.Camera, sprite.sprite, sprite.flipX,
@@ -110,7 +110,12 @@ namespace BumpCombat.Defense.Comparison
multiplier *= 1f + Tuning.SmotheringDamageBonus * efficiency;
}
multiplier *= GetBattleBatonDamageMultiplier();
bool hit = enemy.Actor.TakeDamageWithResult(damage * multiplier * SightEfficiency, direction,
float effectiveDamage = GetEffectiveStats(new Defense25DTowerStatInput(
Kind,
damage * multiplier,
0f,
sightEfficiency: SightEfficiency)).Damage;
bool hit = enemy.Actor.TakeDamageWithResult(effectiveDamage, direction,
0f, BumpCombat.Enemies.EnemyDamageSource.Tower, false, out float actualDamage);
if (actualDamage > 0f)
{
@@ -122,6 +127,42 @@ namespace BumpCombat.Defense.Comparison
return hit;
}
private Defense25DTowerEffectiveStats GetEffectiveStats(Defense25DTowerStatInput input)
{
if (manager != null && manager.RunModifiers != null)
return manager.RunModifiers.GetEffectiveStats(input);
float damageBeforeSight = Mathf.Max(0f, input.BaseDamage)
* Mathf.Max(0f, input.TierDamageMultiplier)
* Mathf.Max(0f, input.SupportDamageMultiplier)
* Mathf.Max(0f, input.RelicDamageMultiplier);
return new Defense25DTowerEffectiveStats(
damageBeforeSight,
damageBeforeSight * Mathf.Clamp01(input.SightEfficiency),
input.HasAreaEffect
? Mathf.Max(0f, input.BaseAreaRadius)
* Mathf.Max(0f, input.TierAreaMultiplier)
* Mathf.Max(0f, input.RelicAreaMultiplier)
: 0f,
Mathf.Max(0, input.BaseChainCount),
Mathf.Max(1, input.BaseProjectileCount),
Mathf.Max(1, input.BaseTargetCount),
false,
false,
0f,
0f);
}
private float GetModifiedAreaRadius(float baseRadius, bool projectileAttack = false)
{
return GetEffectiveStats(new Defense25DTowerStatInput(
Kind,
0f,
baseRadius,
hasAreaEffect: true,
isProjectileAttack: projectileAttack)).AreaRadius;
}
public void ResetSupport()
{
supportInterval = 1f;
@@ -23,6 +23,17 @@ namespace BumpCombat.Defense.Comparison
public bool TrackTarget;
public bool HideAtImpact;
public Func<Vector3> VisualStartProvider;
public Vector2 Origin;
public HashSet<Defense25DEnemy> HitTargets = new();
public HashSet<Defense25DEnemy> ReturnHitTargets = new();
public bool SplitOnFirstValidHit;
public int SplitGeneration;
public bool IsSplitProjectile;
public bool SplitHasHit;
public float SplitDamage;
public bool ReturnsOnceToOrigin;
public bool IsReturning;
public float ReturnDamage;
}
private sealed class CenteredImpactWave
@@ -152,37 +163,67 @@ namespace BumpCombat.Defense.Comparison
private bool FireParityDirect(Defense25DEnemy target)
{
float scale = GetParityDamageScale();
if (Tier == 4 && Branch == DefenseTowerBranch.First)
Defense25DTowerEffectiveStats stats = manager.RunModifiers != null
? manager.RunModifiers.GetEffectiveStats(new Defense25DTowerStatInput(
Kind,
Tuning.DirectTowerDamage * scale,
0f,
baseTargetCount: 1,
supportsSimultaneousTargets: true))
: new Defense25DTowerEffectiveStats(
Tuning.DirectTowerDamage * scale,
Tuning.DirectTowerDamage * scale,
0f, 0, 1, 1, false, false, 0f, 0f);
List<Defense25DEnemy> targets = new() { target };
if (stats.SimultaneousTargetCount > 1)
{
Vector2 destination = target.Actor.Position;
Vector2 start = destination + new Vector2(-0.81f, 0.81f);
CreateParityFlight("Effects/Fire/Meteor-Flight", start, destination,
0f, 0.32f, target, true,
impact =>
{
if (!CanParityImpact(impact)) return;
ApplyParityArea(impact.Actor.Position, 1.8f,
Tuning.DirectTowerDamage * scale * 1.6f);
CreateParityRadius(impact.Actor.Position, 1.8f, impact, .22f, .9f);
SpriteRenderer hit = CreateParitySprite("Effects/Fire/Meteor-Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
});
float range = GetParityRange();
foreach (Defense25DEnemy candidate in world.GetTowerTargets(Kind))
{
if (targets.Count >= stats.SimultaneousTargetCount) break;
if (candidate == target || !CanParityImpact(candidate)
|| IsParityTowerImmune(candidate)
|| (candidate.Actor.Position - Position).sqrMagnitude > range * range)
continue;
targets.Add(candidate);
}
}
else
foreach (Defense25DEnemy directTarget in targets)
{
Vector3 visualStart = ParityStaffTipVisualWorldPosition;
Vector2 start = Defense25DActor.Ground(visualStart);
string key = $"Effects/Fire/T{Tier}-Fireball";
CreateParityFlight(key, start, target.Actor.Position, 0f, 0.18f, target, true,
impact =>
{
if (!CanParityImpact(impact)) return;
ApplyDamage(impact, Tuning.DirectTowerDamage * scale);
SpriteRenderer hit = CreateParitySprite("Effects/Fire/Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
}, visualStart: visualStart);
if (Tier == 4 && Branch == DefenseTowerBranch.First)
{
Vector2 destination = directTarget.Actor.Position;
Vector2 start = destination + new Vector2(-0.81f, 0.81f);
float impactRadius = GetModifiedAreaRadius(1.8f);
CreateParityFlight("Effects/Fire/Meteor-Flight", start, destination,
0f, 0.32f, directTarget, true,
impact =>
{
if (!CanParityImpact(impact)) return;
ApplyParityArea(impact.Actor.Position, impactRadius,
Tuning.DirectTowerDamage * scale * 1.6f);
CreateParityRadius(impact.Actor.Position, impactRadius, impact, .22f, .9f);
SpriteRenderer hit = CreateParitySprite("Effects/Fire/Meteor-Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
});
}
else
{
Vector3 visualStart = ParityStaffTipVisualWorldPosition;
Vector2 start = Defense25DActor.Ground(visualStart);
string key = $"Effects/Fire/T{Tier}-Fireball";
CreateParityFlight(key, start, directTarget.Actor.Position, 0f, 0.18f, directTarget, true,
impact =>
{
if (!CanParityImpact(impact)) return;
ApplyDamage(impact, Tuning.DirectTowerDamage * scale);
SpriteRenderer hit = CreateParitySprite("Effects/Fire/Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
}, visualStart: visualStart);
}
}
return true;
}
@@ -194,10 +235,12 @@ namespace BumpCombat.Defense.Comparison
float radius = Tier == 4
? Branch == DefenseTowerBranch.First ? 3f : 2.4f
: Tuning.LightningRadius;
radius = GetModifiedAreaRadius(radius);
float damage = Tuning.LightningDamage * GetParityDamageScale()
* (Tier == 4 && Branch == DefenseTowerBranch.Second ? 1.4f : 1f);
int maxTargets = Tier == 4 && Branch == DefenseTowerBranch.First ? 3 : int.MaxValue;
if (!ApplyParityArea(center, radius, damage, maxTargets)) return false;
HashSet<Defense25DEnemy> hitTargets = new(parityUniqueTargets);
CreateParityRadius(visualCenter, radius, target, .22f, .9f);
string key = Tier == 4 && Branch == DefenseTowerBranch.Second
@@ -220,6 +263,26 @@ namespace BumpCombat.Defense.Comparison
SetParityHitSorting(bolt, target);
FitParityBeam(bolt, origin, visualCenter, 1f);
}
int chainCount = GetEffectiveStats(new Defense25DTowerStatInput(
Kind,
0f,
0f,
supportsChainTargets: true)).ChainCount;
Vector2 chainOrigin = center;
for (int i = 0; i < chainCount; i++)
{
Defense25DEnemy chained = FindNearestChainTarget(
chainOrigin,
Mathf.Max(Tuning.LightningRadius, GetParityRange()),
hitTargets);
if (chained == null || !ApplyDamage(chained, damage)) break;
hitTargets.Add(chained);
string chainKey = Tier == 4
? "Effects/Lightning/Arc"
: $"Effects/Lightning/T{Tier}-Bolt";
CreateParityLightningChainVisual(chainOrigin, chained, chainKey);
chainOrigin = chained.Actor.Position;
}
if (Tier == 4)
{
float waveScale = Mathf.Max(0.8f, radius / 1.4f);
@@ -239,26 +302,29 @@ namespace BumpCombat.Defense.Comparison
{
Vector2 center = target.Actor.Position;
Vector2 shadowCenter = GetParityShadowPosition(target);
CreateParityRadius(shadowCenter, 1.65f, target,
float blizzardRadius = GetModifiedAreaRadius(1.65f);
float radiusScale = blizzardRadius / 1.65f;
CreateParityRadius(shadowCenter, blizzardRadius, target,
DefenseTowerArtBounds.BlizzardLastLandingSeconds + .22f,
DefenseTowerArtBounds.BlizzardImpactRadiusAlpha);
for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++)
{
float launchDelay = i * DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds;
Vector2 landing = shadowCenter + DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f);
Vector2 landing = shadowCenter
+ DefenseTowerArtBounds.BlizzardLandingOffset(i, blizzardRadius);
float height = DefenseTowerArtBounds.BlizzardDropHeight
+ i % 4 * DefenseTowerArtBounds.BlizzardDropHeightStep;
float rotation = 90f + (i % 5 - 2) * 7f;
Vector2 shardStart = landing + Vector2.up * height;
SpriteRenderer wave = CreateParitySprite("Effects/Ice/Blizzard-Wave-v1", landing,
Vector2.one * DefenseTowerArtBounds.BlizzardWaveScale, 0f, 13,
Vector2.one * DefenseTowerArtBounds.BlizzardWaveScale * radiusScale, 0f, 13,
destroy: true, startDelay: launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds);
SetParityHitSorting(wave, target);
centeredImpactWaves.Add(new CenteredImpactWave
{
Renderer = wave,
Center = landing,
Scale = DefenseTowerArtBounds.BlizzardWaveScale,
Scale = DefenseTowerArtBounds.BlizzardWaveScale * radiusScale,
});
SpriteRenderer impact = CreateParitySprite("Effects/Ice/Impact", landing,
Vector2.one * DefenseTowerArtBounds.BlizzardImpactScale, 0f, 14,
@@ -266,7 +332,7 @@ namespace BumpCombat.Defense.Comparison
SetParityHitSorting(impact, target);
CreateParityFlight("Effects/Ice/Shard-Blizzard-v1", shardStart, landing,
launchDelay, DefenseTowerArtBounds.BlizzardLandingSeconds, target, false,
_ => ApplyParityBlizzardWave(landing, 1.65f,
_ => ApplyParityBlizzardWave(landing, blizzardRadius,
Tuning.ColdDamage * damageScale * 0.5f), rotation,
scale: Vector2.one * DefenseTowerArtBounds.BlizzardShardScale);
CreateParityFlight("Effects/Ice/Shard-Blizzard-v1",
@@ -280,26 +346,59 @@ namespace BumpCombat.Defense.Comparison
}
int projectileCount = Tier == 4 ? Tuning.FreezeboltProjectileCount : Tier;
Defense25DTowerEffectiveStats coldStats = GetEffectiveStats(
new Defense25DTowerStatInput(
Kind,
0f,
0f,
baseProjectileCount: projectileCount,
isProjectileAttack: true));
string projectileKey = Tier == 4 ? "Effects/Ice/Freezebolt" : "Effects/Ice/T1-Volley";
for (int i = 0; i < projectileCount; i++)
List<Defense25DEnemy> additionalTargets = FindAdditionalProjectileTargets(
target,
coldStats.ProjectileCount - projectileCount);
bool splits = GetEffectiveStats(new Defense25DTowerStatInput(
Kind, 0f, 0f, isProjectileAttack: true)).SplitsOnFirstValidHit;
bool returns = GetEffectiveStats(new Defense25DTowerStatInput(
Kind, 0f, 0f, isProjectileAttack: true)).ReturnsOnceToOrigin;
for (int i = 0; i < coldStats.ProjectileCount; i++)
{
float delay = i * Tuning.ColdProjectileBurstInterval;
CreateParityFlight(projectileKey, start, target.Actor.Position, delay, 0.18f,
target, true, impact =>
Defense25DEnemy projectileTarget = i < projectileCount
? target
: i - projectileCount < additionalTargets.Count
? additionalTargets[i - projectileCount]
: target;
float hitDamage = Tuning.ColdDamage * damageScale
* (Tier == 4 ? 1.8f : 1f);
ParityFlight flight = null;
flight = CreateParityFlight(projectileKey, start,
projectileTarget.Actor.Position, delay, 0.18f,
projectileTarget, true, impact =>
{
if (!CanParityImpact(impact)) return;
float sight = SightEfficiency;
impact.ApplySlow(Tuning.ColdSlowSeconds * (1f + .25f * (Tier - 1)),
Tuning.ColdSlowFraction * sight);
ApplyDamage(impact, Tuning.ColdDamage * damageScale
* (Tier == 4 ? 1.8f : 1f));
if (!flight.HitTargets.Add(impact)) return;
if (!ApplyDamage(impact, hitDamage))
{
flight.HitTargets.Remove(impact);
return;
}
if (Tier == 4) impact.ApplyStun(.3f * sight);
if (flight.SplitOnFirstValidHit && flight.SplitGeneration == 0)
SpawnColdSplitProjectiles(flight, impact, hitDamage, projectileKey);
SpriteRenderer hit = CreateParitySprite("Effects/Ice/Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
}, hideAtImpact: true, scale: Vector2.one,
visualStartProvider: () => ParityStaffTipVisualWorldPosition,
visualStart: visualStart);
flight.Origin = start;
flight.SplitOnFirstValidHit = splits;
flight.ReturnsOnceToOrigin = returns;
flight.ReturnDamage = hitDamage * .5f;
}
return true;
}
@@ -307,9 +406,10 @@ namespace BumpCombat.Defense.Comparison
private bool FireParitySeismic(Defense25DEnemy target)
{
Vector2 center = target.Actor.Position;
float radius = Tier == 4
float baseRadius = Tier == 4
? Branch == DefenseTowerBranch.Second ? 2.2f : 1.8f
: Tuning.SeismicRadius;
float radius = GetModifiedAreaRadius(baseRadius);
bool applied = Tier == 4 && Branch == DefenseTowerBranch.Second
? ApplyParityArea(center, radius, Tuning.SeismicDamage * GetParityDamageScale(),
slowSeconds: 2f, slowFraction: .5f)
@@ -323,7 +423,8 @@ namespace BumpCombat.Defense.Comparison
int visibleWidth = Tier == 4
? Branch == DefenseTowerBranch.First ? 40 : 45
: 32 + (Tier - 1) * 6;
float scale = GetParityGroundMarkerScale(target, key, visibleWidth);
float scale = GetParityGroundMarkerScale(target, key, visibleWidth)
* radius / baseRadius;
SpriteRenderer marker = CreateParitySprite(key, center, Vector2.one * scale, 0f, 13, destroy: true);
SetParityGroundSorting(marker, target);
return true;
@@ -469,16 +570,23 @@ namespace BumpCombat.Defense.Comparison
}
if (flight.Delay > 0f) continue;
}
flight.Elapsed += activeDelta;
float previousProgress = Mathf.Clamp01(flight.Elapsed / flight.Duration);
Vector2 end = flight.TrackTarget && CanParityImpact(flight.Target)
? flight.Target.Actor.Position : flight.End;
Vector2 previousPosition = Vector2.Lerp(flight.Start, end, previousProgress);
flight.Elapsed += activeDelta;
float progress = Mathf.Clamp01(flight.Elapsed / flight.Duration);
Vector2 currentPosition = Vector2.Lerp(flight.Start, end, progress);
if (flight.IsSplitProjectile)
ApplyColdSplitSegmentDamage(flight, previousPosition, currentPosition);
if (flight.IsReturning)
ApplyColdReturnSegmentDamage(flight, previousPosition, currentPosition);
if (flight.Root != null)
{
float height = world.Settings.GroundY + .04f
+ Mathf.Lerp(flight.StartHeightOffset, 0f, progress);
flight.Root.transform.position = Defense25DActor.OnGround(
Vector2.Lerp(flight.Start, end, progress), height);
currentPosition, height);
SpriteRenderer sprite = flight.Root.GetComponentInChildren<SpriteRenderer>();
if (sprite != null && flight.Delay <= 0f) sprite.enabled = true;
}
@@ -490,6 +598,20 @@ namespace BumpCombat.Defense.Comparison
if (sprite != null) sprite.enabled = false;
}
flight.Impact?.Invoke(flight.Target);
if (!flight.IsReturning && flight.ReturnsOnceToOrigin
&& flight.HitTargets.Count > 0)
{
flight.IsReturning = true;
flight.Start = end;
flight.End = flight.Origin;
flight.Target = null;
flight.TrackTarget = false;
flight.Impact = null;
flight.Elapsed = 0f;
flight.Duration = Mathf.Max(.18f, flight.Duration);
parityFlights.Add(flight);
continue;
}
if (flight.Root != null) Destroy(flight.Root);
}
for (int i = parityEffects.Count - 1; i >= 0; i--)
@@ -501,7 +623,7 @@ namespace BumpCombat.Defense.Comparison
}
}
private void CreateParityFlight(string key, Vector2 start, Vector2 end, float delay,
private ParityFlight CreateParityFlight(string key, Vector2 start, Vector2 end, float delay,
float duration, Defense25DEnemy target, bool trackTarget,
Action<Defense25DEnemy> impact, float rotation = 0f, bool hideAtImpact = false,
Vector2? scale = null, float alpha = 1f, string nextKey = null,
@@ -532,7 +654,7 @@ namespace BumpCombat.Defense.Comparison
"Each delayed cold salvo projectile must resample its staff-tip at launch.");
Vector3 initialVisualStart = visualStart ?? Defense25DActor.OnGround(
start, world.Settings.GroundY + .04f);
parityFlights.Add(new ParityFlight
ParityFlight flight = new()
{
Delay = delay,
Duration = Mathf.Max(.001f, duration),
@@ -546,9 +668,187 @@ namespace BumpCombat.Defense.Comparison
HideAtImpact = hideAtImpact,
StartHeightOffset = initialVisualStart.y - world.Settings.GroundY - .04f,
VisualStartProvider = visualStartProvider,
});
};
parityFlights.Add(flight);
root.transform.position = initialVisualStart;
renderer.transform.localPosition = Vector3.zero;
return flight;
}
private Defense25DEnemy FindNearestChainTarget(
Vector2 from,
float range,
HashSet<Defense25DEnemy> alreadyHit)
{
Defense25DEnemy nearest = null;
float best = range * range;
foreach (Defense25DEnemy candidate in world.GetTowerTargets(Kind))
{
if (!CanParityImpact(candidate) || IsParityTowerImmune(candidate)
|| alreadyHit.Contains(candidate))
continue;
float distance = (candidate.Actor.Position - from).sqrMagnitude;
if (distance > best) continue;
best = distance;
nearest = candidate;
}
return nearest;
}
private List<Defense25DEnemy> FindAdditionalProjectileTargets(
Defense25DEnemy primary,
int requestedCount)
{
List<Defense25DEnemy> selected = new();
float range = GetParityRange();
while (selected.Count < requestedCount)
{
Defense25DEnemy nearest = null;
float best = range * range;
foreach (Defense25DEnemy candidate in world.GetTowerTargets(Kind))
{
if (candidate == primary || selected.Contains(candidate)
|| !CanParityImpact(candidate) || IsParityTowerImmune(candidate))
continue;
float distance = (candidate.Actor.Position - Position).sqrMagnitude;
if (distance > best) continue;
best = distance;
nearest = candidate;
}
if (nearest == null) break;
selected.Add(nearest);
}
return selected;
}
private void CreateParityLightningChainVisual(
Vector2 origin,
Defense25DEnemy target,
string key)
{
Vector2 end = GetParityShadowPosition(target);
GameObject root = CreateParityRoot("Tower Attack Effect");
SpriteRenderer bolt = CreateParitySprite(root.transform, key,
Vector2.Lerp(origin, end, .5f), Vector2.one,
GroundAngle(origin, end), 14, destroy: true);
SetParityHitSorting(bolt, target);
FitParityBeam(bolt, origin, end, 1f);
}
private void SpawnColdSplitProjectiles(
ParityFlight source,
Defense25DEnemy impact,
float damage,
string key)
{
const float splitAngleDegrees = 25f;
const float splitRange = 1.65f;
Vector2 incoming = (impact.Actor.Position - source.Start).normalized;
if (incoming.sqrMagnitude <= Mathf.Epsilon)
incoming = Vector2.right;
Vector2 start = impact.Actor.Position;
for (int i = 0; i < 2; i++)
{
float angle = i == 0 ? -splitAngleDegrees : splitAngleDegrees;
Vector2 end = start + RotateDirection(incoming, angle) * splitRange;
ParityFlight child = CreateParityFlight(
key,
start,
end,
0f,
.14f,
null,
false,
null,
rotation: GroundAngle(start, end),
hideAtImpact: true,
scale: Vector2.one);
child.HitTargets = source.HitTargets;
child.SplitGeneration = source.SplitGeneration + 1;
child.IsSplitProjectile = true;
child.SplitDamage = damage * .5f;
child.ReturnsOnceToOrigin = false;
child.Origin = start;
}
}
private void ApplyColdSplitSegmentDamage(
ParityFlight flight,
Vector2 start,
Vector2 end)
{
if (flight.SplitHasHit) return;
Defense25DEnemy nearest = null;
float nearestProgress = float.MaxValue;
foreach (Defense25DEnemy candidate in world.GetTowerTargets(Kind))
{
if (!CanParityImpact(candidate) || IsParityTowerImmune(candidate)
|| flight.HitTargets.Contains(candidate))
continue;
Vector2 position = candidate.Actor.Position;
if (DistanceToSegment(position, start, end) > .24f) continue;
float progress = SegmentProgress(position, start, end);
if (progress >= nearestProgress) continue;
nearestProgress = progress;
nearest = candidate;
}
if (nearest == null || !ApplyDamage(nearest, flight.SplitDamage)) return;
flight.HitTargets.Add(nearest);
flight.SplitHasHit = true;
SpriteRenderer hit = CreateParitySprite(
"Effects/Ice/Impact",
nearest.Actor.Position,
Vector2.one,
0f,
13,
destroy: true);
SetParityHitSorting(hit, nearest);
}
private void ApplyColdReturnSegmentDamage(
ParityFlight flight,
Vector2 start,
Vector2 end)
{
foreach (Defense25DEnemy candidate in world.GetTowerTargets(Kind))
{
if (!CanParityImpact(candidate) || IsParityTowerImmune(candidate)
|| flight.ReturnHitTargets.Contains(candidate))
continue;
Vector2 position = candidate.Actor.Position;
if (DistanceToSegment(position, start, end) > .24f) continue;
if (ApplyDamage(candidate, flight.ReturnDamage))
flight.ReturnHitTargets.Add(candidate);
}
}
private static float SegmentProgress(Vector2 point, Vector2 start, Vector2 end)
{
Vector2 segment = end - start;
float lengthSquared = segment.sqrMagnitude;
return lengthSquared <= Mathf.Epsilon
? 0f
: Mathf.Clamp01(Vector2.Dot(point - start, segment) / lengthSquared);
}
private static float DistanceToSegment(Vector2 point, Vector2 start, Vector2 end)
{
Vector2 segment = end - start;
float lengthSquared = segment.sqrMagnitude;
if (lengthSquared <= Mathf.Epsilon)
return Vector2.Distance(point, start);
float t = SegmentProgress(point, start, end);
return Vector2.Distance(point, start + segment * t);
}
private static Vector2 RotateDirection(Vector2 direction, float angleDegrees)
{
float angle = angleDegrees * Mathf.Deg2Rad;
float cosine = Mathf.Cos(angle);
float sine = Mathf.Sin(angle);
return new Vector2(
direction.x * cosine - direction.y * sine,
direction.x * sine + direction.y * cosine).normalized;
}
private GameObject CreateParityRoot(string label)
@@ -603,8 +903,9 @@ namespace BumpCombat.Defense.Comparison
{
if (count++ >= maxTargets) break;
Defense25DEnemy enemy = actor.GetComponent<Defense25DEnemy>();
if (enemy == null || !CanParityImpact(enemy) || !parityUniqueTargets.Add(enemy)
|| (actor.Position - center).sqrMagnitude > radius * radius) continue;
if (enemy == null || !CanParityImpact(enemy)
|| (actor.Position - center).sqrMagnitude > radius * radius
|| !parityUniqueTargets.Add(enemy)) continue;
bool applied = ApplyDamage(enemy, damage);
float sight = SightEfficiency;
if (slowSeconds > 0f) applied |= enemy.ApplySlow(slowSeconds, slowFraction * sight);
@@ -616,8 +917,9 @@ namespace BumpCombat.Defense.Comparison
private void ApplyParityBlizzardWave(Vector2 center, float castRadius, float damage)
{
float radiusX = DefenseTowerArtBounds.BlizzardWaveRadiusX;
float radiusY = DefenseTowerArtBounds.BlizzardWaveRadiusY;
float radiusScale = castRadius / 1.65f;
float radiusX = DefenseTowerArtBounds.BlizzardWaveRadiusX * radiusScale;
float radiusY = DefenseTowerArtBounds.BlizzardWaveRadiusY * radiusScale;
float broadphase = castRadius + radiusX;
foreach (Defense25DActor actor in world.QueryEnemies(center, Vector2.one * broadphase))
{
@@ -7,16 +7,41 @@ namespace BumpCombat.Defense.Comparison
{
public sealed class Defense25DTowerManager : MonoBehaviour
{
private const int PadCount = 6;
private readonly Defense25DTower[] towers = new Defense25DTower[PadCount];
private readonly List<Defense25DTower> constructionOrder = new(PadCount);
private const int LegacyPadCount = 6;
private Defense25DTower[] towers = new Defense25DTower[LegacyPadCount];
private readonly List<Defense25DTower> constructionOrder = new(LegacyPadCount);
private Defense25DComparison world;
private Defense25DTowerModifiers runModifiers;
private Defense25DFacilityVisuals facilityVisuals;
private DefenseConstants Tuning => GameplayConstants.Current.Defense;
public int ConstructionCurrency { get; private set; }
public bool IsSuspended { get; private set; }
public int PadCount => world != null && world.RunLayout != null
? world.RunLayout.PadCount
: LegacyPadCount;
public IReadOnlyList<Defense25DTower> Towers => towers;
public IReadOnlyList<Defense25DTower> TowersInConstructionOrder => constructionOrder;
public Defense25DTowerModifiers RunModifiers => runModifiers;
public void SetRunModifiers(Defense25DTowerModifiers modifiers)
{
runModifiers = modifiers;
}
public void SetFacilityVisuals(Defense25DFacilityVisuals visuals)
{
facilityVisuals = visuals;
}
public Vector2 GetPadPosition(int padIndex)
{
if (padIndex < 0 || padIndex >= PadCount)
throw new System.ArgumentOutOfRangeException(nameof(padIndex));
return world != null && world.RunLayout != null
? world.RunLayout.PadPositions[padIndex]
: Tuning.GetBuildPadPosition(padIndex);
}
public void Configure(Defense25DComparison owner)
{
@@ -36,6 +61,8 @@ namespace BumpCombat.Defense.Comparison
towers[i] = null;
}
constructionOrder.Clear();
if (towers.Length != PadCount)
towers = new Defense25DTower[PadCount];
ConstructionCurrency = Tuning.InitialConstructionCurrency;
}
@@ -50,7 +77,7 @@ namespace BumpCombat.Defense.Comparison
int cost = GetBuildCost(kind);
if (cost < 0 || ConstructionCurrency < cost) return false;
Vector2 position = Tuning.GetBuildPadPosition(padIndex);
Vector2 position = GetPadPosition(padIndex);
GameObject instance = new($"2.5D {kind} Tower Pad {padIndex}");
instance.transform.SetParent(transform, false);
Defense25DTower tower = instance.AddComponent<Defense25DTower>();
@@ -77,6 +104,8 @@ namespace BumpCombat.Defense.Comparison
};
if (ConstructionCurrency < cost || !tower.TryUpgrade(branch)) return false;
ConstructionCurrency -= cost;
facilityVisuals?.PlayTowerUpgrade(padIndex, tower.Tier,
Defense25DActor.OnGround(GetPadPosition(padIndex), world.Settings.GroundY));
return true;
}
@@ -110,7 +139,7 @@ namespace BumpCombat.Defense.Comparison
{
if (world == null || !world.TowersOperational || world.Player == null
|| padIndex < 0 || padIndex >= towers.Length) return false;
Vector2 pad = Tuning.GetBuildPadPosition(padIndex);
Vector2 pad = GetPadPosition(padIndex);
return (world.Player.Position - pad).sqrMagnitude
<= Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius;
}
@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed class Defense25DTowerModifiers
{
private readonly Defense25DRewardConstants constants;
private readonly Dictionary<string, List<Defense25DTowerModifierDefinition>> applied =
new(StringComparer.Ordinal);
private int appliedChoiceCount;
public Defense25DTowerModifiers(Defense25DRewardConstants rewardConstants)
{
if (rewardConstants == null)
{
throw new ArgumentException(
"Reward constants are required.",
nameof(rewardConstants));
}
if (!rewardConstants.Validate(out string error))
{
throw new ArgumentException(error, nameof(rewardConstants));
}
constants = rewardConstants;
}
public event Action Changed;
public int AppliedChoiceCount => appliedChoiceCount;
public bool CanApply(Defense25DTowerModifierDefinition definition)
{
Defense25DTowerModifierDefinition catalogDefinition =
definition == null
? null
: FindCatalogDefinition(definition.DefinitionId);
if (catalogDefinition == null)
{
return false;
}
string key = Defense25DRewardConstants.StackKey(
catalogDefinition.Family,
catalogDefinition.Effect);
return GetStackCount(key) < catalogDefinition.MaximumStacks;
}
public bool Apply(Defense25DRewardChoice choice)
{
if (choice == null
|| choice.TargetKind != Defense25DRewardTargetKind.TowerModifier
|| !CanApply(choice.TowerModifierDefinition))
{
return false;
}
Defense25DTowerModifierDefinition definition =
FindCatalogDefinition(choice.DefinitionId);
string key = Defense25DRewardConstants.StackKey(
definition.Family,
definition.Effect);
if (!applied.TryGetValue(key, out List<Defense25DTowerModifierDefinition> stack))
{
stack = new List<Defense25DTowerModifierDefinition>();
applied.Add(key, stack);
}
stack.Add(definition);
appliedChoiceCount++;
Changed?.Invoke();
return true;
}
public int GetStackCount(
DefenseTowerKind family,
Defense25DTowerModifierEffect effect)
{
return GetStackCount(Defense25DRewardConstants.StackKey(family, effect));
}
public Defense25DTowerEffectiveStats GetEffectiveStats(
Defense25DTowerStatInput tower)
{
float attackDamageIncreased = 0f;
float areaRadiusIncreased = 0f;
int chainCount = 0;
int additionalProjectiles = 0;
int additionalTargets = 0;
bool splits = false;
bool returns = false;
foreach (KeyValuePair<string, List<Defense25DTowerModifierDefinition>> entry in applied)
{
foreach (Defense25DTowerModifierDefinition definition in entry.Value)
{
if (definition.Family != tower.Family)
{
continue;
}
switch (definition.Effect)
{
case Defense25DTowerModifierEffect.AttackDamageIncreased:
if (tower.Family != DefenseTowerKind.Empowering)
attackDamageIncreased += definition.Value;
break;
case Defense25DTowerModifierEffect.AreaRadiusIncreased:
if (tower.HasAreaEffect)
areaRadiusIncreased += definition.Value;
break;
case Defense25DTowerModifierEffect.ChainCount:
if (tower.SupportsChainTargets)
chainCount += Mathf.RoundToInt(definition.Value);
break;
case Defense25DTowerModifierEffect.AdditionalProjectile:
if (tower.Family == DefenseTowerKind.Cold
&& tower.IsProjectileAttack)
additionalProjectiles += Mathf.RoundToInt(definition.Value);
break;
case Defense25DTowerModifierEffect.AdditionalTarget:
if (tower.Family == DefenseTowerKind.Direct
&& tower.SupportsSimultaneousTargets)
additionalTargets += Mathf.RoundToInt(definition.Value);
break;
case Defense25DTowerModifierEffect.SplitProjectile:
splits |= tower.Family == DefenseTowerKind.Cold
&& tower.IsProjectileAttack;
break;
case Defense25DTowerModifierEffect.ReturnProjectile:
returns |= tower.Family == DefenseTowerKind.Cold
&& tower.IsProjectileAttack;
break;
}
}
}
float damageBeforeSight = Mathf.Max(0f, tower.BaseDamage)
* Mathf.Max(0f, tower.TierDamageMultiplier)
* Mathf.Max(0f, tower.SupportDamageMultiplier)
* Mathf.Max(0f, tower.RelicDamageMultiplier)
* (1f + attackDamageIncreased);
float damage = damageBeforeSight * Mathf.Clamp01(tower.SightEfficiency);
float areaRadius = tower.HasAreaEffect
? Mathf.Max(0f, tower.BaseAreaRadius)
* Mathf.Max(0f, tower.TierAreaMultiplier)
* Mathf.Max(0f, tower.RelicAreaMultiplier)
* (1f + areaRadiusIncreased)
: 0f;
return new Defense25DTowerEffectiveStats(
damageBeforeSight,
damage,
areaRadius,
Mathf.Max(0, tower.BaseChainCount + chainCount),
Mathf.Max(1, tower.BaseProjectileCount + additionalProjectiles),
Mathf.Max(1, tower.BaseTargetCount + additionalTargets),
splits,
returns,
attackDamageIncreased,
areaRadiusIncreased);
}
public void Reset()
{
if (appliedChoiceCount == 0)
{
return;
}
applied.Clear();
appliedChoiceCount = 0;
Changed?.Invoke();
}
private int GetStackCount(string key)
{
return applied.TryGetValue(key,
out List<Defense25DTowerModifierDefinition> stack)
? stack.Count
: 0;
}
private Defense25DTowerModifierDefinition FindCatalogDefinition(string definitionId)
{
if (string.IsNullOrWhiteSpace(definitionId)
|| constants.TowerModifierCatalog == null)
{
return null;
}
foreach (Defense25DTowerModifierDefinition definition
in constants.TowerModifierCatalog)
{
if (definition != null && definition.DefinitionId == definitionId)
{
return definition;
}
}
return null;
}
}
public readonly struct Defense25DTowerStatInput
{
public Defense25DTowerStatInput(
DefenseTowerKind family,
float baseDamage,
float baseAreaRadius,
int baseChainCount = 0,
int baseProjectileCount = 1,
int baseTargetCount = 1,
bool hasAreaEffect = false,
bool isProjectileAttack = false,
bool supportsChainTargets = false,
bool supportsSimultaneousTargets = false,
float tierDamageMultiplier = 1f,
float supportDamageMultiplier = 1f,
float relicDamageMultiplier = 1f,
float tierAreaMultiplier = 1f,
float relicAreaMultiplier = 1f,
float sightEfficiency = 1f)
{
Family = family;
BaseDamage = baseDamage;
BaseAreaRadius = baseAreaRadius;
BaseChainCount = baseChainCount;
BaseProjectileCount = baseProjectileCount;
BaseTargetCount = baseTargetCount;
HasAreaEffect = hasAreaEffect;
IsProjectileAttack = isProjectileAttack;
SupportsChainTargets = supportsChainTargets;
SupportsSimultaneousTargets = supportsSimultaneousTargets;
TierDamageMultiplier = tierDamageMultiplier;
SupportDamageMultiplier = supportDamageMultiplier;
RelicDamageMultiplier = relicDamageMultiplier;
TierAreaMultiplier = tierAreaMultiplier;
RelicAreaMultiplier = relicAreaMultiplier;
SightEfficiency = sightEfficiency;
}
public DefenseTowerKind Family { get; }
public float BaseDamage { get; }
public float BaseAreaRadius { get; }
public int BaseChainCount { get; }
public int BaseProjectileCount { get; }
public int BaseTargetCount { get; }
public bool HasAreaEffect { get; }
public bool IsProjectileAttack { get; }
public bool SupportsChainTargets { get; }
public bool SupportsSimultaneousTargets { get; }
public float TierDamageMultiplier { get; }
public float SupportDamageMultiplier { get; }
public float RelicDamageMultiplier { get; }
public float TierAreaMultiplier { get; }
public float RelicAreaMultiplier { get; }
public float SightEfficiency { get; }
}
public readonly struct Defense25DTowerEffectiveStats
{
public Defense25DTowerEffectiveStats(
float damageBeforeSight,
float damage,
float areaRadius,
int chainCount,
int projectileCount,
int simultaneousTargetCount,
bool splitsOnFirstValidHit,
bool returnsOnceToOrigin,
float runDamageIncreased,
float runAreaRadiusIncreased)
{
DamageBeforeSight = damageBeforeSight;
Damage = damage;
AreaRadius = areaRadius;
ChainCount = chainCount;
ProjectileCount = projectileCount;
SimultaneousTargetCount = simultaneousTargetCount;
SplitsOnFirstValidHit = splitsOnFirstValidHit;
ReturnsOnceToOrigin = returnsOnceToOrigin;
RunDamageIncreased = runDamageIncreased;
RunAreaRadiusIncreased = runAreaRadiusIncreased;
}
public float DamageBeforeSight { get; }
public float Damage { get; }
public float AreaRadius { get; }
public int ChainCount { get; }
public int ProjectileCount { get; }
public int SimultaneousTargetCount { get; }
public bool SplitsOnFirstValidHit { get; }
public bool ReturnsOnceToOrigin { get; }
public float RunDamageIncreased { get; }
public float RunAreaRadiusIncreased { get; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1394cefc3548400cb5bf1d90ad2d7705
@@ -9,6 +9,10 @@ namespace BumpCombat.Defense.Comparison
{
public const int Width = 640;
public const int Height = 360;
private static readonly Vector2[] ViewportCorners =
{
Vector2.zero, Vector2.right, Vector2.one, Vector2.up,
};
public Camera Camera { get; private set; }
public RenderTexture Texture { get; private set; }
private GameObject presentation;
@@ -16,6 +20,11 @@ namespace BumpCombat.Defense.Comparison
private int previousCullingMask;
private Vector2 feedbackCameraOffset;
private Vector3 lastAppliedFeedbackOffset;
private Transform followTarget;
private Vector2 followHalfExtents;
private Vector2 followBoundsCenter;
private float followGroundY;
private Vector3 followCameraOffset;
private void Awake()
{
@@ -94,7 +103,8 @@ namespace BumpCombat.Defense.Comparison
{
if (Camera != null)
{
Camera.transform.position -= lastAppliedFeedbackOffset;
RemoveAppliedFeedbackOffset();
ApplyFollowTarget();
float unitsPerReferencePixel = 2f * Camera.orthographicSize / Height;
lastAppliedFeedbackOffset = Camera.transform.right * (feedbackCameraOffset.x * unitsPerReferencePixel)
+ Camera.transform.up * (feedbackCameraOffset.y * unitsPerReferencePixel);
@@ -109,13 +119,126 @@ namespace BumpCombat.Defense.Comparison
public void ClearFeedbackCameraOffset()
{
feedbackCameraOffset = Vector2.zero;
if (Camera != null && lastAppliedFeedbackOffset != Vector3.zero)
{
Camera.transform.position -= lastAppliedFeedbackOffset;
lastAppliedFeedbackOffset = Vector3.zero;
}
RemoveAppliedFeedbackOffset();
}
public void SetFollowTarget(Transform target, Vector2 halfExtents)
{
if (target == null)
{
ClearFollowTarget();
return;
}
RemoveAppliedFeedbackOffset();
if (followTarget != target)
{
followTarget = target;
followGroundY = target.position.y;
followBoundsCenter = Vector2.zero;
Defense25DComparison comparison = FindAnyObjectByType<Defense25DComparison>();
if (comparison != null && comparison.Settings != null)
{
followGroundY = comparison.Settings.GroundY;
if (comparison.RunLayout != null)
followBoundsCenter = comparison.RunLayout.Bounds.center;
}
Vector3 groundTarget = new(target.position.x, followGroundY, target.position.z);
followCameraOffset = Camera != null
? Camera.transform.position - groundTarget : Vector3.zero;
}
followHalfExtents = new Vector2(
IsFinite(halfExtents.x) ? Mathf.Max(0f, halfExtents.x) : 0f,
IsFinite(halfExtents.y) ? Mathf.Max(0f, halfExtents.y) : 0f);
ApplyFollowTarget();
}
public void ClearFollowTarget()
{
RemoveAppliedFeedbackOffset();
followTarget = null;
followCameraOffset = Vector3.zero;
}
private void ApplyFollowTarget()
{
if (Camera == null || followTarget == null)
return;
Vector2 desiredCenter = Defense25DActor.Ground(followTarget.position);
Vector3 groundTarget = Defense25DActor.OnGround(desiredCenter, followGroundY);
Camera.transform.position = groundTarget + followCameraOffset;
if (!TryGetViewportGroundFootprint(Camera, followGroundY, desiredCenter,
out Vector2 minimumOffset, out Vector2 maximumOffset))
{
desiredCenter = followBoundsCenter;
}
else
{
desiredCenter = ClampViewportCenter(desiredCenter, followBoundsCenter,
followHalfExtents, minimumOffset, maximumOffset);
}
Camera.transform.position = Defense25DActor.OnGround(desiredCenter, followGroundY)
+ followCameraOffset;
}
public static bool TryGetViewportGroundFootprint(Camera camera, float groundY,
Vector2 groundCenter, out Vector2 minimumOffset, out Vector2 maximumOffset)
{
minimumOffset = new Vector2(float.PositiveInfinity, float.PositiveInfinity);
maximumOffset = new Vector2(float.NegativeInfinity, float.NegativeInfinity);
if (camera == null)
return false;
Plane ground = new(Vector3.up, new Vector3(0f, groundY, 0f));
foreach (Vector2 corner in ViewportCorners)
{
Ray ray = camera.ViewportPointToRay(new Vector3(corner.x, corner.y));
if (!ground.Raycast(ray, out float distance) || distance < 0f)
return false;
Vector2 point = Defense25DActor.Ground(ray.GetPoint(distance)) - groundCenter;
minimumOffset = Vector2.Min(minimumOffset, point);
maximumOffset = Vector2.Max(maximumOffset, point);
}
return true;
}
public static Vector2 ClampViewportCenter(Vector2 desiredCenter,
Vector2 boundsCenter, Vector2 boundsHalfExtents,
Vector2 minimumOffset, Vector2 maximumOffset)
{
Vector2 halfExtents = Vector2.Max(Vector2.zero, boundsHalfExtents);
Vector2 minimum = boundsCenter - halfExtents;
Vector2 maximum = boundsCenter + halfExtents;
return new Vector2(
ClampFollowAxis(desiredCenter.x,
minimum.x - minimumOffset.x, maximum.x - maximumOffset.x,
boundsCenter.x - (minimumOffset.x + maximumOffset.x) * .5f),
ClampFollowAxis(desiredCenter.y,
minimum.y - minimumOffset.y, maximum.y - maximumOffset.y,
boundsCenter.y - (minimumOffset.y + maximumOffset.y) * .5f));
}
private static float ClampFollowAxis(float value, float minimum, float maximum,
float centerWhenViewportIsLarger)
{
if (minimum > maximum)
return centerWhenViewportIsLarger;
return Mathf.Clamp(value, minimum, maximum);
}
private void RemoveAppliedFeedbackOffset()
{
if (Camera != null && lastAppliedFeedbackOffset != Vector3.zero)
Camera.transform.position -= lastAppliedFeedbackOffset;
lastAppliedFeedbackOffset = Vector3.zero;
}
private static bool IsFinite(float value) =>
!float.IsNaN(value) && !float.IsInfinity(value);
public static Rect GetViewport(int width, int height)
{
float fit = Mathf.Min(width / (float)Width, height / (float)Height);
@@ -146,6 +269,7 @@ namespace BumpCombat.Defense.Comparison
private void OnDestroy()
{
ClearFollowTarget();
if (presentation != null) { presentation.SetActive(false); Destroy(presentation); }
if (Camera != null) { Camera.targetTexture = null; Camera.cullingMask = previousCullingMask; }
if (Texture != null) { Texture.Release(); Destroy(Texture); }
@@ -0,0 +1,843 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BumpCombat.Constants;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public enum Defense25DSpawnSource
{
Normal = 0,
GlobalElite = 1,
GlobalMidBoss = 2,
FinalBoss = 3,
PersonalMission = 4,
BossSummon = 5,
}
public sealed class Defense25DWaveEnemyEntry
{
public string EnemyDefinitionId { get; }
public float ThreatCost { get; }
public float Weight { get; }
public Defense25DWaveEnemyEntry(string enemyDefinitionId, float threatCost, float weight = 1f)
{
EnemyDefinitionId = enemyDefinitionId;
ThreatCost = threatCost;
Weight = weight;
}
}
public sealed class Defense25DSpawnRequest
{
public int RunGeneration { get; }
public int GlobalWaveId { get; }
public int PortalId { get; }
public Defense25DSpawnSource Source { get; }
public string EnemyDefinitionId { get; }
public float ThreatCost { get; }
public float ThreatBudget { get; }
public float NominalStart { get; }
public float ActualStart { get; }
public float ScheduledAt { get; }
public int Ordinal { get; }
internal Defense25DSpawnRequest(int runGeneration, int globalWaveId, int portalId,
Defense25DSpawnSource source, string enemyDefinitionId, float threatCost, float threatBudget,
float nominalStart, float actualStart, float scheduledAt, int ordinal)
{
RunGeneration = runGeneration;
GlobalWaveId = globalWaveId;
PortalId = portalId;
Source = source;
EnemyDefinitionId = enemyDefinitionId;
ThreatCost = threatCost;
ThreatBudget = threatBudget;
NominalStart = nominalStart;
ActualStart = actualStart;
ScheduledAt = scheduledAt;
Ordinal = ordinal;
}
}
public sealed class Defense25DNodePlan
{
public int PortalId { get; }
public float AllocatedThreat { get; }
public IReadOnlyList<Defense25DWaveEnemyEntry> Entries { get; }
public IReadOnlyList<float> ScheduledAt { get; }
public float OffsetSeconds { get; }
internal Defense25DNodePlan(int portalId, float allocatedThreat,
IReadOnlyList<Defense25DWaveEnemyEntry> entries, IReadOnlyList<float> scheduledAt,
float offsetSeconds)
{
PortalId = portalId;
AllocatedThreat = allocatedThreat;
Entries = entries;
ScheduledAt = scheduledAt;
OffsetSeconds = offsetSeconds;
}
}
public sealed class Defense25DWavePlan
{
public int RunGeneration { get; }
public int WaveId { get; }
public int GlobalWaveId => WaveId;
public float NominalStart { get; }
public float ActualStart { get; }
public int TierId { get; }
public float ThreatBudget { get; }
public IReadOnlyList<int> ActivePortalIds { get; }
public int PrimaryPortalId { get; }
public IReadOnlyList<Defense25DNodePlan> NodePlans { get; }
public bool DiversityConstraintRelaxed { get; }
public Defense25DWaveComposition Composition { get; }
internal Defense25DWavePlan(int runGeneration, int waveId, float nominalStart, float actualStart,
int tierId, float threatBudget, IReadOnlyList<int> activePortalIds, int primaryPortalId,
IReadOnlyList<Defense25DNodePlan> nodePlans, bool diversityConstraintRelaxed,
Defense25DWaveComposition composition = null)
{
RunGeneration = runGeneration;
WaveId = waveId;
NominalStart = nominalStart;
ActualStart = actualStart;
TierId = tierId;
ThreatBudget = threatBudget;
ActivePortalIds = activePortalIds;
PrimaryPortalId = primaryPortalId;
NodePlans = nodePlans;
DiversityConstraintRelaxed = diversityConstraintRelaxed;
Composition = composition;
}
}
public readonly struct EarlyCallResult
{
public bool Succeeded { get; }
public int WaveId { get; }
public float NominalStart { get; }
public float PreviousActualStart { get; }
public float ActualStart { get; }
public float SkippedSeconds { get; }
public int RewardCurrency { get; }
internal EarlyCallResult(bool succeeded, int waveId, float nominalStart,
float previousActualStart, float actualStart, float skippedSeconds, int rewardCurrency)
{
Succeeded = succeeded;
WaveId = waveId;
NominalStart = nominalStart;
PreviousActualStart = previousActualStart;
ActualStart = actualStart;
SkippedSeconds = skippedSeconds;
RewardCurrency = rewardCurrency;
}
}
public sealed class Defense25DWaveDirector
{
private enum StartEventKind
{
Wave,
Elite,
MidBoss,
FinalBoss,
}
private sealed class ReliefWindow
{
public string Id;
public float Start;
public float End;
}
private sealed class MutableNode
{
public int PortalId;
public float TargetThreat;
public float AllocatedThreat;
public readonly List<Defense25DWaveEnemyEntry> Entries = new();
}
private readonly Defense25DWaveDirectorConstants constants;
private readonly Defense25DPortalDefinition[] portals;
private readonly Func<int, IReadOnlyList<Defense25DWaveEnemyEntry>> entriesForTier;
private readonly Func<int, float, float, int, Defense25DWaveComposition> buildWaveComposition;
private readonly int runGeneration;
private readonly float finalBossAtSeconds;
private readonly List<ReliefWindow> reliefWindows = new();
private readonly List<Defense25DSpawnRequest> pendingRequests = new();
private readonly List<int> recentPrimaryPortalIds = new(2);
private int waveSeed;
private int nextWaveId;
private float nextNominalStart;
private float nextActualStartOverride = float.NaN;
private float currentTime;
private float nextEliteNominal;
private int nextEliteIndex;
private int nextMidBossIndex;
private bool finalBossScheduled;
private bool supplyStopped;
private float supplyStopTime = float.NaN;
private Defense25DWavePlan currentPlan;
private Defense25DWavePlan previewPlan;
private float previewActualStart = float.NaN;
public bool IsSupplyStopped => supplyStopped;
public event Action<Defense25DWavePlan> WaveStarted;
public Defense25DWaveDirector(Defense25DWaveDirectorConstants constants,
Defense25DPortalDefinition[] portals, int runGeneration, int waveSeed,
Func<int, IReadOnlyList<Defense25DWaveEnemyEntry>> entriesForTier,
float finalBossAtSeconds = 720f)
: this(constants, portals, runGeneration, waveSeed, entriesForTier,
finalBossAtSeconds, null)
{
}
private Defense25DWaveDirector(Defense25DWaveDirectorConstants constants,
Defense25DPortalDefinition[] portals, int runGeneration, int waveSeed,
Func<int, IReadOnlyList<Defense25DWaveEnemyEntry>> entriesForTier,
float finalBossAtSeconds,
Func<int, float, float, int, Defense25DWaveComposition> buildWaveComposition)
{
this.constants = constants != null ? constants : throw new ArgumentNullException(nameof(constants));
this.portals = portals != null ? (Defense25DPortalDefinition[])portals.Clone() :
throw new ArgumentNullException(nameof(portals));
if (entriesForTier == null && buildWaveComposition == null)
throw new ArgumentNullException(nameof(entriesForTier));
this.entriesForTier = entriesForTier;
this.buildWaveComposition = buildWaveComposition;
this.runGeneration = runGeneration;
this.finalBossAtSeconds = finalBossAtSeconds;
if (!constants.Validate(out string constantsError))
throw new ArgumentException($"Wave Director constants are invalid: {constantsError}", nameof(constants));
if (float.IsNaN(finalBossAtSeconds) || float.IsInfinity(finalBossAtSeconds) || finalBossAtSeconds < 0f)
throw new ArgumentOutOfRangeException(nameof(finalBossAtSeconds));
if (!Defense25DRunDefinition.ValidatePortals(this.portals, finalBossAtSeconds,
this.portals.Length, null, out string portalError))
throw new ArgumentException($"Portal definitions are invalid: {portalError}", nameof(portals));
Reset(waveSeed);
}
public Defense25DWaveDirector(Defense25DRunDefinition definition, int runGeneration, int waveSeed,
Func<int, IReadOnlyList<Defense25DWaveEnemyEntry>> entriesForTier)
: this(RequireValidDefinition(definition).WaveDirectorConstants, definition.PortalDefinitions,
runGeneration, waveSeed, entriesForTier, definition.FinalBossAtSeconds)
{
}
public Defense25DWaveDirector(Defense25DRunDefinition definition, int runGeneration, int waveSeed)
: this(RequireValidDefinition(definition).WaveDirectorConstants, definition.PortalDefinitions,
runGeneration, waveSeed, null, definition.FinalBossAtSeconds,
definition.MonsterPoolDefinition.BuildWaveComposition)
{
}
public void Reset(int waveSeed)
{
this.waveSeed = waveSeed;
nextWaveId = 1;
nextNominalStart = 0f;
nextActualStartOverride = float.NaN;
currentTime = 0f;
nextEliteNominal = constants.EliteFirstAtSeconds;
nextEliteIndex = 0;
nextMidBossIndex = 0;
finalBossScheduled = false;
supplyStopped = false;
supplyStopTime = float.NaN;
currentPlan = null;
previewPlan = null;
previewActualStart = float.NaN;
pendingRequests.Clear();
recentPrimaryPortalIds.Clear();
reliefWindows.Clear();
}
public void AdvanceTo(float combatElapsed, Action<Defense25DSpawnRequest> emit)
{
AdvanceToBoundary(combatElapsed, emit, includeBoundary: true);
}
/// <summary>Processes scheduled events strictly earlier than the boundary.</summary>
public void AdvanceBefore(float combatElapsed, Action<Defense25DSpawnRequest> emit)
{
AdvanceToBoundary(combatElapsed, emit, includeBoundary: false);
}
private void AdvanceToBoundary(float combatElapsed, Action<Defense25DSpawnRequest> emit,
bool includeBoundary)
{
if (float.IsNaN(combatElapsed) || float.IsInfinity(combatElapsed) || combatElapsed < currentTime)
throw new ArgumentOutOfRangeException(nameof(combatElapsed), "Combat time must be finite and monotonic.");
if (emit == null) throw new ArgumentNullException(nameof(emit));
currentTime = combatElapsed;
if (supplyStopped)
{
pendingRequests.Clear();
return;
}
pendingRequests.Sort(CompareRequests);
while (!supplyStopped)
{
bool hasStartEvent = TryGetNextStartEvent(out StartEventKind kind, out float startAt);
bool startDue = hasStartEvent && IsDue(startAt, combatElapsed, includeBoundary);
bool requestDue = pendingRequests.Count > 0 &&
IsDue(pendingRequests[0].ScheduledAt, combatElapsed, includeBoundary);
if (!startDue && !requestDue) break;
if (startDue && (!requestDue || startAt <= pendingRequests[0].ScheduledAt))
{
StartNextEvent(kind);
pendingRequests.Sort(CompareRequests);
continue;
}
Defense25DSpawnRequest request = pendingRequests[0];
pendingRequests.RemoveAt(0);
emit(request);
if (supplyStopped) pendingRequests.Clear();
}
}
public bool TryCallNextWaveEarly(float combatElapsed, out EarlyCallResult result)
{
result = default;
if (supplyStopped || currentPlan == null || combatElapsed < currentTime ||
combatElapsed < currentPlan.ActualStart + constants.SpawnWindowSeconds ||
combatElapsed >= nextNominalStart || IsInsideRelief(combatElapsed))
return false;
Defense25DWavePlan nextPlan = GetNextPlanPreview();
if (nextPlan.ActivePortalIds.Any(portalId => !IsPortalOpenAt(portalId, combatElapsed)))
return false;
float previousActualStart = nextPlan.ActualStart;
float skippedSeconds = nextNominalStart - combatElapsed;
if (skippedSeconds <= 0f || combatElapsed >= previousActualStart)
return false;
nextActualStartOverride = combatElapsed;
previewPlan = RebasePlan(nextPlan, combatElapsed);
previewActualStart = combatElapsed;
float rewardUnit = Mathf.Max(0.001f, constants.EarlyCallRewardSecondsPerCurrency);
int reward = Mathf.Min(constants.EarlyCallRewardMaximum,
Mathf.FloorToInt(skippedSeconds / rewardUnit));
result = new EarlyCallResult(true, nextWaveId, nextNominalStart, previousActualStart,
combatElapsed, skippedSeconds, reward);
return true;
}
public void StopAllSupply()
{
if (!supplyStopped)
supplyStopTime = currentTime;
supplyStopped = true;
pendingRequests.Clear();
previewPlan = null;
previewActualStart = float.NaN;
}
public void RegisterReliefWindow(string reliefId, float startAt, float durationSeconds)
{
if (string.IsNullOrWhiteSpace(reliefId)) throw new ArgumentException("A relief ID is required.", nameof(reliefId));
if (float.IsNaN(startAt) || float.IsInfinity(startAt) || startAt < 0f ||
float.IsNaN(durationSeconds) || float.IsInfinity(durationSeconds) || durationSeconds <= 0f)
throw new ArgumentOutOfRangeException(nameof(durationSeconds), "Relief windows require finite positive time.");
if (reliefWindows.Any(window => window.Id == reliefId)) return;
reliefWindows.Add(new ReliefWindow { Id = reliefId, Start = startAt, End = startAt + durationSeconds });
reliefWindows.Sort((left, right) => left.Start.CompareTo(right.Start));
InvalidatePreview();
}
public bool IsPortalOpen(int id)
{
float portalStateTime = supplyStopped ? supplyStopTime : currentTime;
return IsPortalOpenAt(id, portalStateTime);
}
public bool IsInsideReliefWindow(float combatElapsed) =>
!float.IsNaN(combatElapsed) && !float.IsInfinity(combatElapsed) && IsInsideRelief(combatElapsed);
public Defense25DWavePlan GetCurrentPlan() => currentPlan;
public Defense25DWavePlan GetNextPlanPreview()
{
if (supplyStopped) return null;
float actualStart = GetUpcomingActualStart();
if (previewPlan == null || previewPlan.WaveId != nextWaveId ||
Mathf.Abs(previewActualStart - actualStart) > 0.0001f)
{
previewPlan = BuildPlan(nextWaveId, nextNominalStart, actualStart);
previewActualStart = actualStart;
}
return previewPlan;
}
public bool TryGetNextStrongEventPreview(out Defense25DSpawnSource source, out float actualStart)
{
source = default;
actualStart = float.PositiveInfinity;
if (supplyStopped) return false;
bool found = false;
if (constants.EliteIntervalSeconds > 0f)
{
source = Defense25DSpawnSource.GlobalElite;
actualStart = ResolveNonFinalStrongEventStart(nextEliteNominal);
found = true;
}
float[] midBossTimes = constants.MidBossTimes ?? Array.Empty<float>();
if (nextMidBossIndex < midBossTimes.Length)
{
float candidate = ResolveNonFinalStrongEventStart(midBossTimes[nextMidBossIndex]);
if (!found || candidate < actualStart)
{
source = Defense25DSpawnSource.GlobalMidBoss;
actualStart = candidate;
found = true;
}
}
if (!finalBossScheduled && (!found || finalBossAtSeconds < actualStart))
{
source = Defense25DSpawnSource.FinalBoss;
actualStart = finalBossAtSeconds;
found = true;
}
return found;
}
public float GetNextPortalOpenAt()
{
if (supplyStopped) return float.PositiveInfinity;
float next = float.PositiveInfinity;
foreach (Defense25DPortalDefinition portal in portals)
if (portal.OpenAt > currentTime && portal.OpenAt < next)
next = portal.OpenAt;
return next;
}
private bool TryGetNextStartEvent(out StartEventKind kind, out float startAt)
{
kind = StartEventKind.Wave;
startAt = GetUpcomingActualStart();
if (constants.EliteIntervalSeconds > 0f)
{
float candidate = ResolveNonFinalStrongEventStart(nextEliteNominal);
if (candidate < startAt)
{
kind = StartEventKind.Elite;
startAt = candidate;
}
}
float[] midBossTimes = constants.MidBossTimes ?? Array.Empty<float>();
if (nextMidBossIndex < midBossTimes.Length)
{
float candidate = ResolveNonFinalStrongEventStart(midBossTimes[nextMidBossIndex]);
if (candidate < startAt)
{
kind = StartEventKind.MidBoss;
startAt = candidate;
}
}
if (!finalBossScheduled && finalBossAtSeconds < startAt)
{
kind = StartEventKind.FinalBoss;
startAt = finalBossAtSeconds;
}
return !supplyStopped;
}
private void StartNextEvent(StartEventKind kind)
{
switch (kind)
{
case StartEventKind.Wave:
StartNextWave();
break;
case StartEventKind.Elite:
AddSingleEnemyRequest(Defense25DSpawnSource.GlobalElite,
ResolveNonFinalStrongEventStart(nextEliteNominal), "GlobalElite",
nextEliteNominal, nextEliteIndex);
nextEliteNominal += constants.EliteIntervalSeconds;
nextEliteIndex++;
break;
case StartEventKind.MidBoss:
float[] times = constants.MidBossTimes ?? Array.Empty<float>();
float nominal = times[nextMidBossIndex];
AddSingleEnemyRequest(Defense25DSpawnSource.GlobalMidBoss,
ResolveNonFinalStrongEventStart(nominal), "GlobalMidBoss", nominal, nextMidBossIndex);
nextMidBossIndex++;
break;
case StartEventKind.FinalBoss:
finalBossScheduled = true;
AddSingleEnemyRequest(Defense25DSpawnSource.FinalBoss, finalBossAtSeconds,
"FinalBoss", finalBossAtSeconds, 0);
break;
default:
throw new ArgumentOutOfRangeException(nameof(kind), kind, null);
}
}
private void StartNextWave()
{
float actualStart = GetUpcomingActualStart();
Defense25DWavePlan plan = previewPlan != null && previewPlan.WaveId == nextWaveId &&
Mathf.Abs(previewPlan.ActualStart - actualStart) <= 0.0001f
? previewPlan : BuildPlan(nextWaveId, nextNominalStart, actualStart);
currentPlan = plan;
WaveStarted?.Invoke(plan);
if (supplyStopped) return;
AddPlanRequests(plan);
recentPrimaryPortalIds.Add(plan.PrimaryPortalId);
if (recentPrimaryPortalIds.Count > 2) recentPrimaryPortalIds.RemoveAt(0);
int previousWaveId = nextWaveId;
nextWaveId++;
float breakSeconds = constants.StrategyBreakEvery > 0 &&
previousWaveId % constants.StrategyBreakEvery == 0
? constants.StrategyBreakSeconds : constants.MicroBreakSeconds;
nextNominalStart += constants.SpawnWindowSeconds + breakSeconds;
nextActualStartOverride = float.NaN;
InvalidatePreview();
}
private void AddSingleEnemyRequest(Defense25DSpawnSource source, float actualStart,
string enemyDefinitionId, float nominalStart, int eventIndex)
{
int[] openIds = portals.Where(portal => portal.OpenAt <= actualStart)
.Select(portal => portal.PortalId).OrderBy(id => id).ToArray();
if (openIds.Length == 0)
throw new InvalidOperationException($"No portal is open for {source} at {actualStart:0.###} seconds.");
uint stream = source switch
{
Defense25DSpawnSource.GlobalElite => 0x454C4954u,
Defense25DSpawnSource.GlobalMidBoss => 0x4D494442u,
_ => 0x46494E41u,
} ^ unchecked((uint)(eventIndex + 1) * 0x9E3779B9u);
Defense25DDeterministicRandom random = new(waveSeed, stream);
int portalId = openIds[random.NextInt(openIds.Length)];
pendingRequests.Add(new Defense25DSpawnRequest(runGeneration, 0, portalId, source,
enemyDefinitionId, 0f, 0f, nominalStart, actualStart, actualStart, eventIndex));
}
private void EmitDueRequests(float combatElapsed, Action<Defense25DSpawnRequest> emit,
bool includeBoundary)
{
pendingRequests.Sort(CompareRequests);
while (pendingRequests.Count > 0 && IsDue(pendingRequests[0].ScheduledAt, combatElapsed, includeBoundary))
{
Defense25DSpawnRequest request = pendingRequests[0];
pendingRequests.RemoveAt(0);
emit(request);
if (supplyStopped)
{
pendingRequests.Clear();
return;
}
}
}
private static int CompareRequests(Defense25DSpawnRequest left, Defense25DSpawnRequest right)
{
int time = left.ScheduledAt.CompareTo(right.ScheduledAt);
if (time != 0) return time;
int source = left.Source.CompareTo(right.Source);
if (source != 0) return source;
int wave = left.GlobalWaveId.CompareTo(right.GlobalWaveId);
return wave != 0 ? wave : left.Ordinal.CompareTo(right.Ordinal);
}
private float GetUpcomingActualStart()
{
float nominalOrEarly = float.IsNaN(nextActualStartOverride) ? nextNominalStart : nextActualStartOverride;
return ResolveReliefEnd(nominalOrEarly);
}
private float ResolveReliefEnd(float requestedStart)
{
float resolved = requestedStart;
bool changed;
do
{
changed = false;
foreach (ReliefWindow window in reliefWindows)
{
if (resolved >= window.Start && resolved < window.End)
{
resolved = window.End;
changed = true;
}
}
} while (changed);
return resolved;
}
private float ResolveNonFinalStrongEventStart(float nominalStart)
{
float resolved = ResolveReliefEnd(nominalStart);
float separation = constants.StrongEventMinimumIntervalSeconds;
if (Mathf.Abs(resolved - finalBossAtSeconds) < separation)
resolved = finalBossAtSeconds + separation;
return resolved;
}
private bool IsInsideRelief(float time)
{
return reliefWindows.Any(window => time >= window.Start && time < window.End);
}
private Defense25DWavePlan BuildPlan(int waveId, float nominalStart, float actualStart)
{
if (!constants.TryGetThreat(nominalStart, out float budget))
throw new InvalidOperationException($"No valid Threat budget curve point covers wave {waveId} at {nominalStart:0.###} seconds.");
if (!Defense25DMonsterPoolDefinition.TryGetTierForNominalStart(nominalStart, out int tierId))
throw new InvalidOperationException($"No valid monster tier curve point covers wave {waveId} at {nominalStart:0.###} seconds.");
if (!constants.TryGetActiveNodeRange(nominalStart, out int minimumNodes, out int maximumNodes))
throw new InvalidOperationException($"No valid active Node range covers wave {waveId}.");
IReadOnlyList<Defense25DWaveEnemyEntry> pool = buildWaveComposition == null
? entriesForTier(tierId)
: null;
if (buildWaveComposition == null) ValidatePool(pool, tierId);
int[] openPortalIds = portals.Where(portal => portal.OpenAt <= nominalStart)
.Select(portal => portal.PortalId).OrderBy(id => id).ToArray();
if (openPortalIds.Length == 0)
throw new InvalidOperationException($"No portal is open at wave {waveId} nominal start {nominalStart:0.###}.");
int maximum = Mathf.Min(maximumNodes, openPortalIds.Length);
int minimum = Mathf.Min(minimumNodes, maximum);
Defense25DDeterministicRandom nodeRandom = RandomFor(waveId, 0x4E4F4445u);
int nodeCount = nodeRandom.NextInt(maximum - minimum + 1) + minimum;
Shuffle(openPortalIds, nodeRandom);
int[] selectedPortalIds = openPortalIds.Take(nodeCount).ToArray();
int primaryPortalId = SelectPrimaryPortal(selectedPortalIds, openPortalIds, waveId);
Defense25DDeterministicRandom shareRandom = RandomFor(waveId, 0x53484152u);
float share = selectedPortalIds.Length == 1 ? 1f :
shareRandom.Range(Mathf.Clamp01(constants.PrimaryNodeShare.x),
Mathf.Clamp01(Mathf.Max(constants.PrimaryNodeShare.x, constants.PrimaryNodeShare.y)));
Dictionary<int, MutableNode> nodes = new();
foreach (int portalId in selectedPortalIds)
nodes.Add(portalId, new MutableNode { PortalId = portalId,
TargetThreat = selectedPortalIds.Length == 1 ? budget :
portalId == primaryPortalId ? budget * share : budget * (1f - share) / (selectedPortalIds.Length - 1) });
Defense25DWaveComposition sourceComposition = buildWaveComposition?.Invoke(
waveId, nominalStart, budget, waveSeed);
if (buildWaveComposition != null && sourceComposition == null)
throw new InvalidOperationException($"Monster pool returned no composition for wave {waveId}.");
List<Defense25DWaveEnemyEntry> composition;
bool diversityRelaxed;
if (sourceComposition != null)
{
if (sourceComposition.WaveId != waveId ||
Mathf.Abs(sourceComposition.NominalStart - nominalStart) > .0001f ||
Mathf.Abs(sourceComposition.ThreatBudget - budget) > .0001f ||
sourceComposition.TierId != tierId || sourceComposition.SelectedEntries == null ||
sourceComposition.SelectedEntries.Count == 0)
throw new InvalidOperationException($"Monster pool returned a mismatched composition for wave {waveId}.");
composition = sourceComposition.SelectedEntries.Select(entry =>
{
if (entry == null || string.IsNullOrWhiteSpace(entry.EnemyDefinitionId) ||
entry.ThreatCost <= 0f || float.IsNaN(entry.ThreatCost) || float.IsInfinity(entry.ThreatCost))
throw new InvalidOperationException($"Monster pool returned an invalid entry for wave {waveId}.");
return new Defense25DWaveEnemyEntry(entry.EnemyDefinitionId, entry.ThreatCost, entry.Weight);
}).ToList();
diversityRelaxed = sourceComposition.DiversityConstraintRelaxed;
}
else
{
composition = ComposeWave(pool, budget, waveId, out diversityRelaxed);
}
Defense25DDeterministicRandom assignmentRandom = RandomFor(waveId, 0x41535349u);
foreach (Defense25DWaveEnemyEntry entry in composition)
{
MutableNode destination = SelectNodeForEntry(nodes.Values.ToArray(), assignmentRandom);
destination.Entries.Add(entry);
destination.AllocatedThreat += entry.ThreatCost;
}
Defense25DDeterministicRandom scheduleRandom = RandomFor(waveId, 0x53434845u);
List<Defense25DNodePlan> nodePlans = new(selectedPortalIds.Length);
foreach (int portalId in selectedPortalIds)
{
MutableNode node = nodes[portalId];
float[] schedule = CreateSchedule(node.Entries.Count, scheduleRandom);
nodePlans.Add(new Defense25DNodePlan(portalId, node.AllocatedThreat,
Array.AsReadOnly(node.Entries.ToArray()), Array.AsReadOnly(schedule),
schedule.Length == 0 ? 0f : schedule[0] - actualStart));
}
return new Defense25DWavePlan(runGeneration, waveId, nominalStart, actualStart, tierId, budget,
Array.AsReadOnly(selectedPortalIds), primaryPortalId, nodePlans.AsReadOnly(), diversityRelaxed,
sourceComposition);
float[] CreateSchedule(int count, Defense25DDeterministicRandom random)
{
float[] times = new float[count];
if (count == 0) return times;
float interval = count == 1 ? 0f :
Mathf.Clamp(constants.SpawnWindowSeconds / (count - 1), 0.35f, 1.5f);
if (count > 1)
interval = Mathf.Min(interval, constants.SpawnWindowSeconds / (count - 1));
float remainingWindow = Mathf.Max(0f, constants.SpawnWindowSeconds - interval * (count - 1));
float offset = count == 1
? random.Range(0f, constants.SpawnWindowSeconds)
: random.Range(0f, Mathf.Min(1f, remainingWindow));
for (int i = 0; i < count; i++)
times[i] = actualStart + offset + interval * i;
if (times[count - 1] > actualStart + constants.SpawnWindowSeconds)
times[count - 1] = actualStart + constants.SpawnWindowSeconds;
return times;
}
}
private static bool IsDue(float scheduledAt, float boundary, bool includeBoundary) =>
includeBoundary ? scheduledAt <= boundary : scheduledAt < boundary;
private void AddPlanRequests(Defense25DWavePlan plan)
{
int ordinal = 0;
foreach (Defense25DNodePlan node in plan.NodePlans)
for (int i = 0; i < node.Entries.Count; i++)
{
Defense25DWaveEnemyEntry entry = node.Entries[i];
pendingRequests.Add(new Defense25DSpawnRequest(plan.RunGeneration, plan.WaveId, node.PortalId,
Defense25DSpawnSource.Normal, entry.EnemyDefinitionId, entry.ThreatCost,
plan.ThreatBudget, plan.NominalStart, plan.ActualStart, node.ScheduledAt[i], ordinal++));
}
}
private List<Defense25DWaveEnemyEntry> ComposeWave(IReadOnlyList<Defense25DWaveEnemyEntry> pool,
float budget, int waveId, out bool diversityRelaxed)
{
List<Defense25DWaveEnemyEntry> composition = new();
Dictionary<string, float> threatByEnemy = new(StringComparer.Ordinal);
float spent = 0f;
diversityRelaxed = false;
Defense25DDeterministicRandom random = RandomFor(waveId, 0x434F4D50u);
while (spent < budget - 0.0001f)
{
Defense25DWaveEnemyEntry[] eligible = pool.Where(entry =>
{
float current = threatByEnemy.TryGetValue(entry.EnemyDefinitionId, out float value) ? value : 0f;
return current + entry.ThreatCost <= budget * 0.60f + 0.0001f;
}).ToArray();
if (eligible.Length == 0)
{
eligible = pool.ToArray();
diversityRelaxed = true;
}
Defense25DWaveEnemyEntry selected = SelectWeighted(eligible, random);
composition.Add(selected);
spent += selected.ThreatCost;
threatByEnemy[selected.EnemyDefinitionId] =
(threatByEnemy.TryGetValue(selected.EnemyDefinitionId, out float currentThreat) ? currentThreat : 0f) + selected.ThreatCost;
}
return composition;
}
private static Defense25DWaveEnemyEntry SelectWeighted(IReadOnlyList<Defense25DWaveEnemyEntry> candidates,
Defense25DDeterministicRandom random)
{
float totalWeight = candidates.Sum(entry => entry.Weight);
float pick = random.Range(0f, totalWeight);
foreach (Defense25DWaveEnemyEntry candidate in candidates)
{
pick -= candidate.Weight;
if (pick < 0f) return candidate;
}
return candidates[candidates.Count - 1];
}
private static MutableNode SelectNodeForEntry(MutableNode[] nodes, Defense25DDeterministicRandom random)
{
float highestDeficit = nodes.Max(node => node.TargetThreat - node.AllocatedThreat);
MutableNode[] tied = nodes.Where(node => Mathf.Abs(node.TargetThreat - node.AllocatedThreat - highestDeficit) < 0.0001f).ToArray();
return tied[random.NextInt(tied.Length)];
}
private int SelectPrimaryPortal(int[] selectedPortalIds, int[] openPortalIds, int waveId)
{
int[] eligible = openPortalIds.Where(id => !recentPrimaryPortalIds.Contains(id)).ToArray();
if (eligible.Length == 0) eligible = openPortalIds;
Defense25DDeterministicRandom random = RandomFor(waveId, 0x5052494Du);
int primaryPortalId = eligible[random.NextInt(eligible.Length)];
if (!selectedPortalIds.Contains(primaryPortalId))
selectedPortalIds[random.NextInt(selectedPortalIds.Length)] = primaryPortalId;
return primaryPortalId;
}
private bool IsPortalOpenAt(int id, float time)
{
for (int i = 0; i < portals.Length; i++)
if (portals[i].PortalId == id)
return time >= portals[i].OpenAt;
return false;
}
private static Defense25DWavePlan RebasePlan(Defense25DWavePlan plan, float actualStart)
{
float shift = actualStart - plan.ActualStart;
List<Defense25DNodePlan> nodes = new(plan.NodePlans.Count);
foreach (Defense25DNodePlan node in plan.NodePlans)
{
float[] schedule = node.ScheduledAt.Select(time => time + shift).ToArray();
nodes.Add(new Defense25DNodePlan(node.PortalId, node.AllocatedThreat,
node.Entries, Array.AsReadOnly(schedule), node.OffsetSeconds));
}
return new Defense25DWavePlan(plan.RunGeneration, plan.WaveId, plan.NominalStart,
actualStart, plan.TierId, plan.ThreatBudget, plan.ActivePortalIds,
plan.PrimaryPortalId, nodes.AsReadOnly(), plan.DiversityConstraintRelaxed, plan.Composition);
}
private Defense25DDeterministicRandom RandomFor(int waveId, uint purpose)
{
return new Defense25DDeterministicRandom(waveSeed,
purpose ^ unchecked((uint)waveId * 0x9E3779B9u));
}
private static void Shuffle(int[] values, Defense25DDeterministicRandom random)
{
for (int i = values.Length - 1; i > 0; i--)
{
int swap = random.NextInt(i + 1);
(values[i], values[swap]) = (values[swap], values[i]);
}
}
private static void ValidatePool(IReadOnlyList<Defense25DWaveEnemyEntry> pool, int tierId)
{
if (pool == null || pool.Count == 0)
throw new InvalidOperationException($"Wave tier {tierId} has no configured enemy pool.");
foreach (Defense25DWaveEnemyEntry entry in pool)
if (entry == null || string.IsNullOrWhiteSpace(entry.EnemyDefinitionId) ||
entry.ThreatCost <= 0f || float.IsNaN(entry.ThreatCost) || float.IsInfinity(entry.ThreatCost) ||
entry.Weight <= 0f || float.IsNaN(entry.Weight) || float.IsInfinity(entry.Weight))
throw new InvalidOperationException($"Wave tier {tierId} has an invalid enemy or ThreatCost entry.");
}
private static Defense25DRunDefinition RequireValidDefinition(Defense25DRunDefinition definition)
{
if (definition == null) throw new ArgumentNullException(nameof(definition));
if (!definition.Validate(out string error))
throw new InvalidOperationException($"Long-defense profile is invalid: {error}");
return definition;
}
private void InvalidatePreview()
{
previewPlan = null;
previewActualStart = float.NaN;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6d898f7ca48e45119bcac26514e7e321
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: