Adding the feature to sell towers with VFX and audio included

This commit is contained in:
Matt F 2026-07-14 23:15:34 -07:00
parent 1794f65b19
commit 0b4cde4590
16 changed files with 5394 additions and 13 deletions

View file

@ -26,13 +26,15 @@ namespace TD.Gameplay
/// <b>Who calls this:</b>
/// <list type="bullet">
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
/// spawn and again whenever <see cref="OnPathsInvalidated"/> fires.</item>
/// spawn, then registers via <see cref="RegisterMover"/> to be re-pathed when
/// the maze changes.</item>
/// </list>
///
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event
/// and <see cref="OnPathsInvalidated"/> is relayed to all active enemies, which
/// each recompute their own path from their current tile.
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event; the
/// service then enqueues every registered grounded enemy and recomputes them under a
/// per-frame time budget (<c>recomputeBudgetMs</c>), draining across frames so a maze
/// change with a full wave present never spikes a single frame.
///
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
@ -52,7 +54,10 @@ namespace TD.Gameplay
/// <summary>
/// Fired on every peer when the walkability grid changes (tower placed/sold).
/// <see cref="EnemyMovement"/> subscribes per-instance to recompute its path.
/// Enemies no longer subscribe here — they register with the budgeted re-path
/// scheduler (<see cref="RegisterMover"/>) so recomputes spread across frames.
/// This event remains as an extension point for any non-enemy listener that wants
/// immediate notification of a maze change.
/// </summary>
public event System.Action OnPathsInvalidated;
@ -79,6 +84,28 @@ namespace TD.Gameplay
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
// ----- Deferred re-path scheduler ---------------------------------
//
// Grounded enemies register here (flyers never re-path — baked grid). On a
// walkability change we enqueue every registered enemy and recompute a
// TIME-BUDGETED number of them per frame, draining the backlog over subsequent
// frames. Recomputing them all synchronously on the change frame spiked to ~0.5s
// with a full wave on this large grid whenever a tower was sold/placed — this
// caps the per-frame cost so no single frame ever hitches. The maze can only be
// opened (sell) or narrowed with a guaranteed remaining route (placement, BFS-
// validated), so an enemy briefly following its slightly-stale path for a few
// frames until its turn comes up is safe and visually negligible.
[Tooltip("Max wall-clock milliseconds spent recomputing enemy paths per frame " +
"after a maze change. The backlog drains over following frames; at least " +
"one enemy is always processed per frame so it converges. Lower = smoother " +
"but slower to fully update; higher = faster to update but larger frame cost.")]
[SerializeField] private float recomputeBudgetMs = 1.5f;
private readonly HashSet<EnemyMovement> movers = new HashSet<EnemyMovement>();
private readonly Queue<EnemyMovement> recomputeQueue = new Queue<EnemyMovement>();
private readonly HashSet<EnemyMovement> queued = new HashSet<EnemyMovement>();
// ----- Lifecycle --------------------------------------------------
private void Awake()
@ -115,6 +142,48 @@ namespace TD.Gameplay
loader.OnWalkabilityChanged -= HandleWalkabilityChanged;
}
// Drains the deferred re-path backlog under a per-frame time budget. Runs on all
// peers, but the queue is only ever populated on the server (clients never register
// movers), so this is a no-op cost on clients. At least one enemy is processed per
// frame whenever the queue is non-empty, so it always converges.
private void Update()
{
if (recomputeQueue.Count == 0) return;
double startMs = Time.realtimeSinceStartupAsDouble * 1000.0;
do
{
var mover = recomputeQueue.Dequeue();
if (!queued.Remove(mover)) continue; // unregistered/cancelled after enqueue
if (mover != null) mover.RecomputePath(); // reads the CURRENT grid state
}
while (recomputeQueue.Count > 0
&& Time.realtimeSinceStartupAsDouble * 1000.0 - startMs < recomputeBudgetMs);
}
// ----- Deferred re-path scheduler API -----------------------------
/// <summary>
/// Registers a grounded enemy to be re-pathed (budgeted, over following frames)
/// whenever the maze changes. Flyers must NOT register — their baked-grid route
/// never changes. Called on the server from <see cref="EnemyMovement"/>.
/// </summary>
public void RegisterMover(EnemyMovement mover)
{
if (mover != null) movers.Add(mover);
}
/// <summary>
/// Removes an enemy from the scheduler (on despawn). Any stale entry still sitting
/// in the pending queue is skipped when dequeued (the <c>queued</c> membership check).
/// </summary>
public void UnregisterMover(EnemyMovement mover)
{
if (mover == null) return;
movers.Remove(mover);
queued.Remove(mover);
}
// ----- Public API -------------------------------------------------
/// <summary>
@ -471,7 +540,16 @@ namespace TD.Gameplay
private void HandleWalkabilityChanged()
{
// Notify any non-enemy listeners immediately (kept for API compatibility).
OnPathsInvalidated?.Invoke();
// Enqueue every registered grounded enemy for a budgeted, deferred recompute
// instead of recomputing them all on this frame. Already-queued enemies are
// deduped, so rapid successive maze changes just lengthen the drain rather
// than compounding into a spike.
foreach (var mover in movers)
if (queued.Add(mover))
recomputeQueue.Enqueue(mover);
}
}