Updating HUD, Gold Config, and finishing off Play flow for 9player map.

This commit is contained in:
Matt F 2026-05-22 12:18:23 -07:00
parent a7be12fa9b
commit 3dcc0e7edd
28 changed files with 2272 additions and 9601 deletions

View file

@ -93,6 +93,12 @@ namespace TD.Gameplay
private readonly Queue<Vector2Int> bfsQueue = new Queue<Vector2Int>();
private readonly HashSet<Vector2Int> bfsVisited = new HashSet<Vector2Int>();
// Scratch set for "tiles that should be treated as blocked for this BFS run only"
// — populated by queue-time path-validity checks with the candidate tower's footprint
// tiles. Avoids the stamp-then-restore pattern (which fired walkability-change events
// on tiles whose net state didn't change, causing a cascade of enemy re-paths).
private readonly HashSet<Vector2Int> virtualBlockedScratch = new HashSet<Vector2Int>();
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
@ -278,23 +284,26 @@ namespace TD.Gameplay
// ------------------------------------------------------------------
// Check 6: Path validity (queue-time)
// Temporarily stamp the footprint non-walkable, run BFS per spawner
// in the placing player's zone, then un-stamp if any spawner loses
// its exit route. Importantly we do NOT stamp other queued (but not
// yet constructing) jobs as non-walkable — queued ghosts represent
// intent only and don't block enemies. The check is "could THIS
// tower be built right now if it were instantly complete?" — a
// coarse test that catches obvious blockers at queue-time. The
// construction-start re-check (in Builder.DriveHead_Queued) catches
// cases where the maze changed since queue-time.
// Virtually treat the footprint as non-walkable and run BFS per spawner
// in the placing player's zone. We do NOT modify the grid here — the
// BFS just consults a "virtually blocked" tile set in addition to
// IsWalkable. Importantly we do NOT block other queued (but not yet
// constructing) jobs — queued ghosts represent intent only and don't
// block enemies. The check is "could THIS tower be built right now if
// it were instantly complete?" — a coarse test that catches obvious
// blockers at queue-time. The construction-start re-check (in
// Builder.DriveHead_Queued) catches cases where the maze changed since
// queue-time.
//
// Why virtual instead of stamp-and-restore: every real walkability
// flip fires OnWalkabilityChanged which triggers all enemies to A*.
// Stamp-and-restore (no net change) would fire those events twice for
// no reason. The virtual approach has zero side-effects.
// ------------------------------------------------------------------
StampWalkable(loader, footprint, walkable: false);
virtualBlockedScratch.Clear();
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
bool pathValid = CheckPathValidity(loader, placingSlot);
// Restore walkability — the queue stage leaves tiles walkable.
// Occupancy is stamped below as part of the commit.
StampWalkable(loader, footprint, walkable: true);
bool pathValid = CheckPathValidity(loader, placingSlot, virtualBlockedScratch);
if (!pathValid)
{
@ -348,18 +357,22 @@ namespace TD.Gameplay
foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
footprint.Add(tile);
StampWalkable(loader, footprint, walkable: false);
// Virtual check first — no grid mutation, no walkability events fire while
// we're just asking "would this break the maze?". Only if the check passes
// do we stamp the footprint for real, which fires exactly one batched event.
virtualBlockedScratch.Clear();
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
bool ok = CheckPathValidity(loader, placingSlot);
if (!ok)
if (!CheckPathValidity(loader, placingSlot, virtualBlockedScratch))
{
// Roll back — the maze would break. Caller refunds and drops the job.
StampWalkable(loader, footprint, walkable: true);
// Maze would break. Caller refunds and drops the job. Grid untouched,
// no events fired.
return false;
}
// Footprint is now occupied (still) and non-walkable. Construction proceeds.
// Commit: stamp the footprint non-walkable. Single batched event fires
// OnWalkabilityChanged once for the whole footprint, regardless of size.
StampWalkable(loader, footprint, walkable: false);
return true;
}
@ -407,7 +420,8 @@ namespace TD.Gameplay
/// grid. Reuses <see cref="bfsQueue"/> and <see cref="bfsVisited"/> scratch
/// collections (cleared between BFS runs) to avoid GC allocation per call.
/// </remarks>
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot)
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot,
HashSet<Vector2Int> virtualBlocked = null)
{
var levelData = loader.LevelData;
@ -432,10 +446,12 @@ namespace TD.Gameplay
return true;
}
// BFS per spawner: each spawner's tile area is the BFS seed set.
// BFS per spawner: each spawner's tile area is the BFS seed set. The optional
// virtualBlocked set lets queue-time checks treat the candidate footprint as
// non-walkable WITHOUT modifying the grid (avoiding spurious walkability events).
foreach (var spawner in zoneData.Spawners)
{
if (!SpawnerCanReachExit(loader, spawner, exitTiles))
if (!SpawnerCanReachExit(loader, spawner, exitTiles, virtualBlocked))
return false;
}
@ -474,11 +490,20 @@ namespace TD.Gameplay
/// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
/// </summary>
private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
HashSet<Vector2Int> exitTiles)
HashSet<Vector2Int> exitTiles,
HashSet<Vector2Int> virtualBlocked = null)
{
bfsQueue.Clear();
bfsVisited.Clear();
// Local walkability check that honors the virtual-blocked override. Hot-path
// helper so we don't duplicate the conditional inside every neighbor test.
bool IsTileOpen(Vector2Int t)
{
if (virtualBlocked != null && virtualBlocked.Contains(t)) return false;
return loader.IsWalkable(t);
}
// Seed the BFS with the spawner's full tile area (not just its center tile),
// matching bake-time P5-4 exactly.
foreach (var tile in spawner.TileArea)
@ -503,13 +528,13 @@ namespace TD.Gameplay
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
{
if (bfsVisited.Contains(neighbor)) continue;
if (!loader.IsWalkable(neighbor)) continue;
if (!IsTileOpen(neighbor)) continue;
if (GridCoordinates.IsDiagonal(current, neighbor))
{
GridCoordinates.GetCornerShoulders(current, neighbor,
out var shoulderA, out var shoulderB);
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
if (!IsTileOpen(shoulderA) || !IsTileOpen(shoulderB))
continue;
}
@ -531,8 +556,11 @@ namespace TD.Gameplay
private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
bool walkable)
{
foreach (var tile in footprint)
loader.SetWalkable(tile, walkable);
// Batched: fires OnWalkabilityChanged at most once for the whole footprint,
// instead of once per tile. Without this, a 2×2 placement fires 4 enemy
// re-paths instead of 1; a 3×3 fires 9. The cascade was the dominant
// contributor to placement stutter on larger maps.
loader.SetWalkableBatch(footprint, walkable);
}
/// <summary>