Adding the feature to sell towers with VFX and audio included
This commit is contained in:
parent
1794f65b19
commit
0b4cde4590
16 changed files with 5394 additions and 13 deletions
|
|
@ -21,8 +21,10 @@ namespace TD.Gameplay
|
|||
/// and stores the tile waypoint list.</item>
|
||||
/// <item>Each frame: moves toward the world center of <c>remainingPath[0]</c>.
|
||||
/// When within snap distance, pops the waypoint and checks for zone transitions.</item>
|
||||
/// <item>When <see cref="PathfindingService.OnPathsInvalidated"/> fires (tower placed /
|
||||
/// sold), <see cref="RecomputePath"/> reruns A* from the current tile.</item>
|
||||
/// <item>When the maze changes (tower placed / sold), <see cref="PathfindingService"/>'s
|
||||
/// budgeted scheduler calls <see cref="RecomputePath"/> (registered via
|
||||
/// <see cref="PathfindingService.RegisterMover"/>), rerunning A* from the current
|
||||
/// tile — spread across frames so a full wave never spikes.</item>
|
||||
/// <item>When <c>remainingPath</c> is empty after a pop, the enemy has reached the
|
||||
/// goal — <see cref="OnReachedGoal"/> fires and the enemy is despawned.</item>
|
||||
/// </list>
|
||||
|
|
@ -168,15 +170,18 @@ namespace TD.Gameplay
|
|||
|
||||
// Recompute when a tower is placed or sold — grounded enemies only.
|
||||
// Flyers path on the static baked grid, so tower changes can never affect
|
||||
// their route; subscribing would just trigger needless recomputes.
|
||||
// their route; registering would just trigger needless recomputes.
|
||||
// Registration (not a direct event subscription) lets PathfindingService
|
||||
// spread the recomputes across frames under a per-frame budget, so a maze
|
||||
// change with a full wave present never spikes a single frame.
|
||||
if (!isFlying && PathfindingService.Instance != null)
|
||||
PathfindingService.Instance.OnPathsInvalidated += RecomputePath;
|
||||
PathfindingService.Instance.RegisterMover(this);
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (PathfindingService.Instance != null)
|
||||
PathfindingService.Instance.OnPathsInvalidated -= RecomputePath;
|
||||
PathfindingService.Instance.UnregisterMover(this);
|
||||
}
|
||||
|
||||
// ----- Server update --------------------------------------------------
|
||||
|
|
@ -274,8 +279,9 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Path invalidation ----------------------------------------------
|
||||
|
||||
// Called on server when LevelLoader.OnWalkabilityChanged fires (tower placed/sold).
|
||||
private void RecomputePath()
|
||||
// Called by PathfindingService's budgeted scheduler after a walkability change
|
||||
// (tower placed/sold). Public so the scheduler can drive it across frames.
|
||||
public void RecomputePath()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,15 @@ namespace TD.VFX
|
|||
|
||||
if (coinBurstPrefab != null)
|
||||
{
|
||||
Instantiate(coinBurstPrefab, spawnPos, Quaternion.identity);
|
||||
var instance = Instantiate(coinBurstPrefab, spawnPos, Quaternion.identity);
|
||||
|
||||
// Force the burst to play. The spawner's whole job is to play this effect, so
|
||||
// it shouldn't depend on the prefab's ParticleSystem having "Play On Awake"
|
||||
// ticked — if that's off, the effect would silently never appear. Harmless when
|
||||
// Play On Awake is already on (a freshly instantiated one-shot just restarts at
|
||||
// t=0). VFX Graph effects play from their own settings, so a null PS is fine.
|
||||
var ps = instance.GetComponentInChildren<ParticleSystem>();
|
||||
if (ps != null) ps.Play(withChildren: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
30
Assets/_Project/Scripts/VFX/TransientEffect.cs
Normal file
30
Assets/_Project/Scripts/VFX/TransientEffect.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Assets/_Project/Scripts/VFX/TransientEffect.cs
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// Destroys its GameObject a fixed time after it spawns. Drop this onto any one-shot
|
||||
/// effect prefab — a particle burst, a VFX Graph effect, a sound-only object — so it
|
||||
/// cleans itself up without any bespoke code. Designed for prefabs spawned by
|
||||
/// <see cref="SellEffectSpawner"/> (and any future effect spawner) that fire once and
|
||||
/// should disappear.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Shuriken shortcut:</b> a plain <see cref="ParticleSystem"/> can instead self-destroy
|
||||
/// with no component at all — set <i>Main → Stop Action → Destroy</i> and make sure Looping
|
||||
/// is off. Use this component for <b>VFX Graph</b> effects (which have no Stop Action) or for
|
||||
/// prefabs that combine several systems and need one predictable lifetime.
|
||||
///
|
||||
/// <para>Set <see cref="lifetime"/> to comfortably exceed the effect's visible duration so it
|
||||
/// isn't cut off mid-play.</para>
|
||||
/// </remarks>
|
||||
public class TransientEffect : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Seconds after spawn before this GameObject destroys itself. Set it a little " +
|
||||
"longer than the effect's visible length so nothing is cut off.")]
|
||||
[SerializeField, Min(0f)] private float lifetime = 1.5f;
|
||||
|
||||
private void Start() => Destroy(gameObject, lifetime);
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/VFX/TransientEffect.cs.meta
Normal file
2
Assets/_Project/Scripts/VFX/TransientEffect.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 739f3145f66002d47850b603164d07b8
|
||||
Loading…
Add table
Add a link
Reference in a new issue