2.5 update
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Defense
|
||||
@@ -89,6 +90,25 @@ namespace BumpCombat.Defense
|
||||
return suppliedIds.Add(enemyId);
|
||||
}
|
||||
|
||||
public bool RegisterSuppliesAtomically(IReadOnlyList<int> enemyIds)
|
||||
{
|
||||
if (supplyEnded || enemyIds == null || enemyIds.Count == 0
|
||||
|| enemyIds.Count > expectedSupplyCount - SuppliedCount)
|
||||
return false;
|
||||
|
||||
HashSet<int> batchIds = new();
|
||||
for (int i = 0; i < enemyIds.Count; i++)
|
||||
{
|
||||
int enemyId = enemyIds[i];
|
||||
if (enemyId < 0 || suppliedIds.Contains(enemyId) || !batchIds.Add(enemyId))
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (int enemyId in batchIds)
|
||||
suppliedIds.Add(enemyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void MarkSupplyEnded()
|
||||
{
|
||||
supplyEnded = true;
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace BumpCombat.Defense
|
||||
private SpriteRenderer target;
|
||||
private Sprite[] frames;
|
||||
private string key;
|
||||
private float durationScale = 1f;
|
||||
private bool useUnscaledTime;
|
||||
private float frameRemaining;
|
||||
private int frame;
|
||||
private Sprite[] nextFrames;
|
||||
@@ -44,8 +46,12 @@ namespace BumpCombat.Defense
|
||||
bool destroyOnFinish, string followingKey = null,
|
||||
Sprite[] followingFrames = null, float startDelay = 0f)
|
||||
{
|
||||
enabled = true;
|
||||
completionCallback = null;
|
||||
finished = false;
|
||||
animationFinished = false;
|
||||
durationScale = 1f;
|
||||
useUnscaledTime = false;
|
||||
target = renderer;
|
||||
key = sequenceKey;
|
||||
frames = sequenceFrames;
|
||||
@@ -53,6 +59,14 @@ namespace BumpCombat.Defense
|
||||
nextKey = followingKey;
|
||||
nextFrames = followingFrames;
|
||||
delayRemaining = Mathf.Max(0f, startDelay);
|
||||
travelStartProvider = null;
|
||||
travelEndProvider = null;
|
||||
travelCanAdvance = null;
|
||||
travelArrival = null;
|
||||
travelDuration = 0f;
|
||||
travelElapsed = 0f;
|
||||
travelArrivalInvoked = false;
|
||||
hideRendererAtArrival = false;
|
||||
if (target == null || frames == null || frames.Length == 0)
|
||||
{
|
||||
FinishSequence();
|
||||
@@ -61,7 +75,7 @@ namespace BumpCombat.Defense
|
||||
frame = 0;
|
||||
target.sprite = frames[frame];
|
||||
target.enabled = delayRemaining <= 0f;
|
||||
frameRemaining = DefenseTowerArt.FrameSeconds(key, frame);
|
||||
frameRemaining = DefenseTowerArt.FrameSeconds(key, frame) * durationScale;
|
||||
UpdateVisualAlignment();
|
||||
}
|
||||
|
||||
@@ -92,6 +106,18 @@ namespace BumpCombat.Defense
|
||||
|
||||
public bool IsFinished => finished;
|
||||
|
||||
public void SetPlaybackTiming(float newDurationScale, bool useUnscaledTime)
|
||||
{
|
||||
if (float.IsNaN(newDurationScale) || float.IsInfinity(newDurationScale) || newDurationScale <= 0f)
|
||||
throw new ArgumentOutOfRangeException(nameof(newDurationScale), "Playback duration scale must be positive and finite.");
|
||||
|
||||
float previousScale = durationScale;
|
||||
durationScale = newDurationScale;
|
||||
this.useUnscaledTime = useUnscaledTime;
|
||||
if (!finished && frames != null && frames.Length > 0)
|
||||
frameRemaining *= durationScale / previousScale;
|
||||
}
|
||||
|
||||
public void SetCompletionCallback(System.Action callback)
|
||||
{
|
||||
if (callback == null) return;
|
||||
@@ -141,9 +167,10 @@ namespace BumpCombat.Defense
|
||||
return;
|
||||
}
|
||||
if (travelCanAdvance != null && !travelCanAdvance()) return;
|
||||
float deltaTime = useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
|
||||
if (delayRemaining > 0f)
|
||||
{
|
||||
delayRemaining -= Time.deltaTime;
|
||||
delayRemaining -= deltaTime;
|
||||
if (delayRemaining > 0f) return;
|
||||
if (travelDuration > 0f || travelStartProvider != null)
|
||||
ResolveTravelStart();
|
||||
@@ -151,7 +178,7 @@ namespace BumpCombat.Defense
|
||||
}
|
||||
if (travelDuration > 0f && travelElapsed < travelDuration)
|
||||
{
|
||||
travelElapsed += Time.deltaTime;
|
||||
travelElapsed += deltaTime;
|
||||
Vector2 currentEnd = travelEndProvider != null
|
||||
? travelEndProvider() : travelEnd;
|
||||
transform.position = Vector2.Lerp(
|
||||
@@ -169,7 +196,7 @@ namespace BumpCombat.Defense
|
||||
return;
|
||||
}
|
||||
|
||||
frameRemaining -= Time.deltaTime;
|
||||
frameRemaining -= deltaTime;
|
||||
while (frameRemaining <= 0f)
|
||||
{
|
||||
frame++;
|
||||
@@ -191,7 +218,7 @@ namespace BumpCombat.Defense
|
||||
}
|
||||
target.sprite = frames[frame];
|
||||
UpdateVisualAlignment();
|
||||
frameRemaining += DefenseTowerArt.FrameSeconds(key, frame);
|
||||
frameRemaining += DefenseTowerArt.FrameSeconds(key, frame) * durationScale;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user