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

@ -33,13 +33,13 @@ namespace TD.Gameplay
"blocked by tower colliders (handled in EnemyMovement).")]
public bool IsFlying;
[Header("Rewards / Costs")]
[Tooltip("Gold awarded to the player whose tower lands the killing blow.")]
public int GoldReward = 10;
[Header("Costs")]
[Tooltip("Number of lives deducted from the shared pool when this enemy " +
"reaches the Goal. Boss enemies might cost 2 or more lives.")]
public int LivesCost = 1;
// GoldReward removed in favor of per-wave GoldPerEnemy in GoldConfig — every
// enemy in a given wave grants the same kill reward regardless of type. If
// per-type variation is needed later, add an optional override here.
[Header("Visuals")]
[Tooltip("Prefab spawned in the world for this enemy. Must have NetworkObject, " +

View file

@ -64,15 +64,15 @@ namespace TD.Gameplay
// ----- Pre-spawn init (server-local) ----------------------------------
private float pendingMaxHp = 100f;
private int pendingGoldReward;
private int pendingLivesCost = 1;
private bool pendingIsFlying;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
/// <summary>Gold awarded to the killing player when this enemy dies.</summary>
public int GoldReward { get; private set; }
// Kill gold is no longer carried per-enemy — it comes from
// GoldConfig.Waves[currentWaveIndex].GoldPerEnemy at the moment the kill
// is registered. See WaveManager.HandleEnemyKilled.
/// <summary>Lives deducted from the shared pool when this enemy reaches the goal.</summary>
public int LivesCost { get; private set; } = 1;
@ -126,18 +126,16 @@ namespace TD.Gameplay
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
/// <c>TowerInstance.InitializeServer</c> pattern.
/// </summary>
public void InitializeServer(float maxHp, int goldReward, int livesCost, bool flying)
public void InitializeServer(float maxHp, int livesCost, bool flying)
{
pendingMaxHp = maxHp;
pendingGoldReward = goldReward;
pendingLivesCost = livesCost;
pendingIsFlying = flying;
hasPendingInit = true;
// Cache locally on the server immediately — clients resolve via NV.
MaxHp = maxHp;
GoldReward = goldReward;
LivesCost = livesCost;
MaxHp = maxHp;
LivesCost = livesCost;
}
// ----- NGO lifecycle --------------------------------------------------

View file

@ -52,6 +52,7 @@ namespace TD.Gameplay
private float pendingMoveSpeed;
private Vector2Int pendingSpawnerTile;
private PlayerSlot pendingOwnerSlot;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
@ -59,6 +60,16 @@ namespace TD.Gameplay
private float moveSpeed;
private List<Vector2Int> remainingPath = new List<Vector2Int>();
private PlayerSlot currentZone = PlayerSlot.None;
// Zone the enemy was spawned in — i.e., which player "owns" this enemy as part
// of their wave. Used so OnZoneLeaked fires only when the enemy escapes that
// origin zone (not when it transits through any subsequent zone on its way to
// the goal). Without this, every zone crossing was counted as a leak; only
// the originating player should be credited a leak per the design.
private PlayerSlot originZone = PlayerSlot.None;
// Latches once the enemy has crossed its origin zone's leak volume, so we
// never double-count a leak if the enemy re-enters its origin (rare but
// possible if pathing is dynamic).
private bool hasLeakedOriginZone;
private EnemyStatus status;
private bool hasReachedGoal;
private bool wasStuck; // dedupes the "no path" warning
@ -66,10 +77,11 @@ namespace TD.Gameplay
// ----- Events ---------------------------------------------------------
/// <summary>
/// Fired on the server when this enemy crosses from one player zone into another
/// (or from a neutral zone into a player zone). The argument is the zone being
/// LEFT — the zone that should be debited a life.
/// <c>WaveManager</c> subscribes to deduct from the correct player's life pool.
/// Fired on the server EXACTLY ONCE per enemy, when it crosses out of its
/// origin zone (the zone it spawned in) into another zone. The argument is the
/// origin zone — the player who "owns" this enemy and should be credited a
/// leak. Transit through subsequent zones does NOT fire this event.
/// <c>WaveManager</c> subscribes to increment the per-player leak counter.
/// </summary>
public event System.Action<PlayerSlot> OnZoneLeaked;
@ -87,12 +99,18 @@ namespace TD.Gameplay
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and
/// before <c>NetworkObject.Spawn()</c>. <paramref name="speed"/> comes from
/// <see cref="EnemyDefinition.MoveSpeed"/>; <paramref name="spawnerTile"/> is
/// the tile the enemy spawns on (used as the A* start node).
/// the tile the enemy spawns on (used as the A* start node);
/// <paramref name="ownerSlot"/> identifies which player "owns" this enemy
/// for leak-attribution (the slot whose <c>PlayerZoneData</c> contained the
/// spawner). It is NOT derived from the spawner tile's owner because spawner
/// tiles sit inside <c>SpawnerVolume</c>, not <c>PlayerZoneVolume</c>, so
/// their owner-grid entry is <see cref="PlayerSlot.None"/>.
/// </summary>
public void InitializeServer(float speed, Vector2Int spawnerTile)
public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot)
{
pendingMoveSpeed = speed;
pendingSpawnerTile = spawnerTile;
pendingOwnerSlot = ownerSlot;
hasPendingInit = true;
}
@ -113,11 +131,21 @@ namespace TD.Gameplay
moveSpeed = pendingMoveSpeed;
// Resolve starting zone from the spawner tile.
// Resolve starting zone from the spawner tile. This is what the enemy
// observes as "the zone I am currently in." For SpawnerVolume tiles that
// sit outside any PlayerZoneVolume (the common case) this is None — the
// enemy will transition to its owner's zone on the first waypoint inside
// that PlayerZoneVolume.
var loader = LevelLoader.Instance;
if (loader != null)
currentZone = loader.GetOwner(pendingSpawnerTile);
// Origin zone is the player who "owns" this enemy for leak attribution.
// WaveManager passes it in (zone.Owner) because the spawner tile's owner-
// grid entry is unreliable (typically None — see InitializeServer remarks).
originZone = pendingOwnerSlot;
hasLeakedOriginZone = false;
// Compute the initial path from the spawn tile to the nearest goal.
ComputeAndStorePath(pendingSpawnerTile);
@ -194,9 +222,18 @@ namespace TD.Gameplay
PlayerSlot newZone = loader.GetOwner(tile);
if (newZone == currentZone) return;
// The enemy is leaving currentZone — debit that player's life pool.
if (currentZone != PlayerSlot.None)
OnZoneLeaked?.Invoke(currentZone);
// The enemy is crossing a zone boundary. Only fire OnZoneLeaked if it's
// the FIRST time this enemy escapes its ORIGIN zone — that's the
// "I failed to stop it in my own maze" event the leak counter tracks.
// Subsequent transit through other zones is just routing toward the goal
// and doesn't credit any player a leak.
if (!hasLeakedOriginZone
&& currentZone == originZone
&& currentZone != PlayerSlot.None)
{
hasLeakedOriginZone = true;
OnZoneLeaked?.Invoke(originZone);
}
currentZone = newZone;
}

View file

@ -0,0 +1,112 @@
// Assets/_Project/Scripts/Gameplay/GoldConfig.cs
using System;
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// Per-wave gold rules nested under <see cref="GoldConfig"/>. Each entry maps to one
/// wave by array index in <see cref="GoldConfig.Waves"/>.
/// </summary>
/// <remarks>
/// <b>Pairing with WaveDefinition.</b> The runtime pairing of gold entry to wave is by
/// array index — entry 0 applies to wave 1, etc. The <see cref="Wave"/> reference here
/// is OPTIONAL and exists purely so the inspector can compute and display the
/// <see cref="PreviewTotalGold"/> read-only value (which needs to know the wave's
/// total enemy count to multiply against <see cref="GoldPerEnemy"/>). Leaving it
/// unset just hides the kill-reward portion of the preview; runtime is unaffected.
/// </remarks>
[Serializable]
public class WaveGoldEntry
{
[Tooltip("Optional reference to the WaveDefinition this entry pairs with. " +
"Used ONLY for the inspector preview total — runtime pairing is by " +
"array index in GoldConfig.Waves. Drag the matching WaveDefinition " +
"here to see the live total computed under this entry.")]
public WaveDefinition Wave;
[Tooltip("Gold awarded to the killing player per enemy slain during this wave. " +
"Applies uniformly to every enemy type in the wave.")]
public int GoldPerEnemy = 10;
[Tooltip("Flat bonus gold awarded to every active player when this wave is fully " +
"cleared (all enemies dead or reached the goal).")]
public int CompletionBonus = 50;
[Tooltip("Extra bonus gold awarded to a player who cleared this wave without any " +
"enemy from their own zone escaping their leak volume. Stacks on top of " +
"CompletionBonus for the no-leak achievement.")]
public int NoLeaksBonus = 50;
/// <summary>
/// Read-only preview of the maximum gold a single player could earn in this wave:
/// <c>GoldPerEnemy × totalEnemyCount + CompletionBonus + NoLeaksBonus</c>.
/// </summary>
/// <remarks>
/// Returns just <c>CompletionBonus + NoLeaksBonus</c> if <see cref="Wave"/> is null
/// (no enemy count to multiply against). Pure computation — safe at edit time and
/// at runtime. The custom inspector under <see cref="GoldConfig"/> renders this as
/// a non-editable label under the entry's fields.
/// </remarks>
public int PreviewTotalGold
{
get
{
int total = CompletionBonus + NoLeaksBonus;
if (Wave != null && Wave.Entries != null)
{
int enemies = 0;
foreach (var e in Wave.Entries)
{
if (e.EnemyType != null && e.Count > 0)
enemies += e.Count;
}
total += GoldPerEnemy * enemies;
}
return total;
}
}
}
/// <summary>
/// Single source of truth for every gold-related tunable in the game.
/// </summary>
/// <remarks>
/// <b>Inspector layout.</b> StartingGold is at the top and applies to every player.
/// The Waves array follows, each element collapsible in the inspector with the
/// per-wave rules and a read-only preview total computed from the entry's data.
///
/// <b>Wiring.</b> Assign one GoldConfig asset to <c>WaveManager.goldConfig</c> in
/// the Match scene. WaveManager initializes per-player starting gold from
/// <see cref="StartingGold"/> at match start, uses <see cref="WaveGoldEntry.GoldPerEnemy"/>
/// to compute kill rewards, and awards <see cref="WaveGoldEntry.CompletionBonus"/> /
/// <see cref="WaveGoldEntry.NoLeaksBonus"/> when each wave clears.
///
/// <b>No per-enemy-type bounties.</b> Every enemy killed in wave N grants the same
/// <see cref="WaveGoldEntry.GoldPerEnemy"/> regardless of <see cref="EnemyDefinition"/>.
/// If per-type variation becomes a design need, extend WaveGoldEntry with a per-type
/// table; the current model intentionally favors simplicity.
/// </remarks>
[CreateAssetMenu(fileName = "GoldConfig", menuName = "TD/Gold Config", order = 2)]
public class GoldConfig : ScriptableObject
{
[Tooltip("Gold each player starts the match with. Same for every player.")]
public int StartingGold = 100;
[Tooltip("Per-wave gold rules. Element 0 = Wave 1. Match the order and length of " +
"WaveManager.waveDefinitions; extra entries are ignored, missing entries " +
"fall back to zero gold for that wave.")]
public WaveGoldEntry[] Waves;
/// <summary>
/// Returns the gold entry for the given 1-based wave number, or null if out of range.
/// </summary>
public WaveGoldEntry GetWaveEntry(int waveNumber)
{
if (Waves == null) return null;
int index = waveNumber - 1;
if (index < 0 || index >= Waves.Length) return null;
return Waves[index];
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dede7fe606207ab4a8b7624d0a710d9b

View file

@ -1,4 +1,5 @@
// Assets/_Project/Scripts/Gameplay/LevelLoader.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
using TD.Levels;
@ -379,6 +380,12 @@ namespace TD.Gameplay
/// <c>false</c>) and when a tower is sold/destroyed (pass <c>true</c>).
/// No-ops silently for out-of-bounds tiles.
/// </summary>
/// <remarks>
/// Fires <see cref="OnWalkabilityChanged"/> once if the tile actually changed.
/// For multi-tile stamps (a tower footprint), prefer
/// <see cref="SetWalkableBatch"/> to fire the event ONCE for the whole batch
/// instead of per-tile — every event triggers all enemy re-paths.
/// </remarks>
public void SetWalkable(Vector2Int tile, bool walkable)
{
if (!TryFlatIndex(tile, out int idx)) return;
@ -387,6 +394,28 @@ namespace TD.Gameplay
OnWalkabilityChanged?.Invoke();
}
/// <summary>
/// Batched variant of <see cref="SetWalkable"/>: updates every tile in
/// <paramref name="tiles"/> to <paramref name="walkable"/> and fires
/// <see cref="OnWalkabilityChanged"/> AT MOST ONCE for the entire batch
/// (only if at least one tile actually changed). Use this for tower footprint
/// stamps so a 2×2 placement triggers a single enemy re-path instead of four.
/// Out-of-bounds tiles in the list are skipped silently.
/// </summary>
public void SetWalkableBatch(IList<Vector2Int> tiles, bool walkable)
{
if (tiles == null) return;
bool anyChanged = false;
for (int i = 0; i < tiles.Count; i++)
{
if (!TryFlatIndex(tiles[i], out int idx)) continue;
if (runtimeWalkability[idx] == walkable) continue;
runtimeWalkability[idx] = walkable;
anyChanged = true;
}
if (anyChanged) OnWalkabilityChanged?.Invoke();
}
/// <summary>
/// Sets the runtime occupancy of <paramref name="tile"/>. Called alongside
/// <see cref="SetWalkable"/> — always update both grids together so they

View file

@ -109,6 +109,22 @@ namespace TD.Gameplay
return playerCount <= map.PlayerCount;
}
/// <summary>
/// True if any registered map's <see cref="LevelData.SceneName"/> matches the given
/// scene name. Used by components on the persistent Player Prefab
/// (e.g. <c>PlayerBuilderSpawner</c>) to recognize "we just entered a match scene"
/// without hardcoding individual scene names. Empty/null inputs return false.
/// </summary>
public bool ContainsScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return false;
for (int i = 0; i < validatedMaps.Count; i++)
{
if (validatedMaps[i].SceneName == sceneName) return true;
}
return false;
}
// ----- Lifecycle --------------------------------------------------
// Validated subset of the inspector array; built once in Awake and never mutated.

View file

@ -61,6 +61,17 @@ namespace TD.Gameplay
// Built once on Start from LevelData.Goals[].TileArea.
private HashSet<Vector2Int> goalTiles;
// Precomputed octile-distance-to-nearest-goal, indexed by [y * gridWidth + x] in
// grid-local space (subtract GridOriginTile to convert world-tile → array index).
// Computed ONCE on Start. Without this, the A* heuristic scanned every goal tile
// (~48 tiles for the 9-player map) on every node visit — making the heuristic
// O(node visits * goal count) per A* run. With this table, it's O(1) per node.
// Tiles outside the grid use Heuristic's fallback path (per-goal scan); they are
// rare so the table isn't extended to cover them.
private float[] heuristicTable;
private Vector2Int heuristicOrigin;
private Vector2Int heuristicSize;
// A* scratch collections — allocated once and cleared per run to avoid GC.
// PathfindingService is a singleton, so single-instance scratch is safe.
// gScore is float to support diagonal cost √2; the priority queue matches.
@ -85,6 +96,7 @@ namespace TD.Gameplay
private void Start()
{
BuildGoalTileSet();
BuildHeuristicTable();
var loader = LevelLoader.Instance;
if (loader != null)
@ -230,9 +242,24 @@ namespace TD.Gameplay
}
// Octile distance to the nearest goal tile. Admissible heuristic for an
// 8-connected uniform-cost grid (cardinal 1, diagonal √2).
// 8-connected uniform-cost grid (cardinal 1, diagonal √2). Hot path: served
// from heuristicTable (O(1)) when the tile is in the grid bounds, otherwise
// falls back to scanning every goal (O(goalCount)).
private float Heuristic(Vector2Int tile)
{
if (heuristicTable != null)
{
int x = tile.x - heuristicOrigin.x;
int y = tile.y - heuristicOrigin.y;
if (x >= 0 && x < heuristicSize.x && y >= 0 && y < heuristicSize.y)
{
return heuristicTable[y * heuristicSize.x + x];
}
}
// Out-of-grid fallback. A* shouldn't usually visit these (it expands only
// from walkable tiles, which are in-bounds), but we keep correctness for
// any callers that hand us a stray tile.
float best = float.MaxValue;
foreach (var goal in goalTiles)
{
@ -380,6 +407,53 @@ namespace TD.Gameplay
Debug.Log($"[PathfindingService] Goal tile set built: {goalTiles.Count} tiles.");
}
// Precomputes the octile-distance-to-nearest-goal for every tile in the grid.
// Runs once on Start (cheap: ~3000 tiles × ~50 goals = 150K octile evaluations on
// the 9-player map, well under a millisecond). The output is a flat float[] keyed
// by grid-local index, hot for the A* heuristic.
private void BuildHeuristicTable()
{
var loader = LevelLoader.Instance;
if (loader == null || loader.LevelData == null)
{
heuristicTable = null;
return;
}
var levelData = loader.LevelData;
heuristicOrigin = levelData.GridOriginTile;
heuristicSize = levelData.GridSize;
int total = heuristicSize.x * heuristicSize.y;
if (total <= 0 || goalTiles == null || goalTiles.Count == 0)
{
heuristicTable = null;
return;
}
heuristicTable = new float[total];
for (int y = 0; y < heuristicSize.y; y++)
{
for (int x = 0; x < heuristicSize.x; x++)
{
Vector2Int worldTile = new Vector2Int(
heuristicOrigin.x + x,
heuristicOrigin.y + y);
float best = float.MaxValue;
foreach (var goal in goalTiles)
{
float d = GridCoordinates.OctileDistance(worldTile, goal);
if (d < best) best = d;
}
heuristicTable[y * heuristicSize.x + x] = best;
}
}
Debug.Log($"[PathfindingService] Heuristic table built: {total} tiles, " +
$"origin={heuristicOrigin}, size={heuristicSize}.");
}
private void HandleWalkabilityChanged()
{
OnPathsInvalidated?.Invoke();

View file

@ -71,22 +71,22 @@ namespace TD.Gameplay
}
// Edge case: the player connected while a match is already in progress.
// The Match scene is already loaded, so OnLoadEventCompleted won't fire
// The match scene is already loaded, so OnLoadEventCompleted won't fire
// again until the next transition. Spawn now.
if (SceneManager.GetActiveScene().name == SceneNames.Match)
if (IsMatchScene(SceneManager.GetActiveScene().name))
TrySpawnBuilder();
}
// NGO fires this on the server once a scene load is acknowledged complete
// by every connected client (or timed out). We only act when the Match
// scene loads; Lobby / MainMenu loads are no-ops here.
// by every connected client (or timed out). We only act when a registered
// match scene loads; Lobby / MainMenu loads are no-ops here.
private void HandleSceneLoadCompleted(string sceneName,
LoadSceneMode loadSceneMode,
List<ulong> clientsCompleted,
List<ulong> clientsTimedOut)
{
if (!IsServer) return;
if (sceneName != SceneNames.Match) return;
if (!IsMatchScene(sceneName)) return;
TrySpawnBuilder();
}
@ -115,9 +115,9 @@ namespace TD.Gameplay
var pms = GetComponent<PlayerMatchState>();
if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
// Only spawn if we're in the Match scene. SlotReady can fire in MainMenu
// Only spawn if we're in a match scene. SlotReady can fire in MainMenu
// (during initial connection) — we don't want a builder there.
if (SceneManager.GetActiveScene().name == SceneNames.Match)
if (IsMatchScene(SceneManager.GetActiveScene().name))
SpawnBuilderForOwner(slot);
}
@ -185,6 +185,21 @@ namespace TD.Gameplay
// ----- Helpers ----------------------------------------------------
// True if the named scene is a registered match map. Source of truth is
// MapRegistry — any scene whose LevelData is in the registry counts. Falls back
// to comparing against the legacy hardcoded SceneNames.Match when the registry
// is missing (editor standalone-scene testing, etc.), so the original 2-player
// workflow keeps working.
private static bool IsMatchScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return false;
var registry = MapRegistry.Instance;
if (registry != null && registry.ContainsScene(sceneName)) return true;
// Fallback for editor testing without MainMenu (no MapRegistry persistent
// instance). Keeps the old behavior intact.
return sceneName == SceneNames.Match;
}
// Picks the builder prefab to spawn for this player. Race-specific takes
// priority when (a) RaceRegistry is in the scene, (b) the player picked
// a race, and (c) that race's RaceDefinition has a BuilderPrefab assigned.

View file

@ -63,7 +63,10 @@ namespace TD.Gameplay
// --- Tunables ----------------------------------------------------
[Tooltip("How much gold this player starts with when the match begins.")]
[Tooltip("Fallback starting gold used only when no GoldConfig is reachable at " +
"match start (e.g. editor testing in the Match scene without the lobby " +
"flow). Normally WaveManager overwrites this with GoldConfig.StartingGold " +
"during InitAfterSpawn.")]
[SerializeField] private int startingGold = 100;
// --- Networked state ---------------------------------------------
@ -77,7 +80,19 @@ namespace TD.Gameplay
writePerm: NetworkVariableWritePermission.Server
);
// Gold this player has accumulated since the start of the current wave. Reset to
// 0 at the start of each new wave by WaveManager. Includes kill rewards,
// completion bonus, and no-leak bonus — anything that flows through AwardGold.
// Spending does NOT decrement it; it's a "gold earned", not "gold held", counter.
// The HUD's top-bar "+N g/wave" reads this for the LOCAL player.
private readonly NetworkVariable<int> goldEarnedThisWave = new NetworkVariable<int>(
value: 0,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server
);
public int CurrentGold => currentGold.Value;
public int GoldEarnedThisWave => goldEarnedThisWave.Value;
// --- Lifecycle ---------------------------------------------------
@ -128,7 +143,9 @@ namespace TD.Gameplay
/// <summary>
/// Server-side entry point for awarding gold (wave clear, enemy kill).
/// Direct call — not Rpc-wrapped — because awards always originate
/// from server-authoritative game events.
/// from server-authoritative game events. Also increments
/// <see cref="GoldEarnedThisWave"/> so the HUD's per-wave counter reflects
/// it; spending does not decrement that counter (it tracks earnings, not balance).
/// </summary>
public void AwardGold(int amount)
{
@ -141,6 +158,39 @@ namespace TD.Gameplay
if (amount <= 0) return;
currentGold.Value += amount;
goldEarnedThisWave.Value += amount;
}
/// <summary>
/// Server-side: overwrites the player's current gold to the exact specified
/// amount. Used by <c>WaveManager</c> at match start to apply
/// <see cref="GoldConfig.StartingGold"/>, replacing the inspector-default value
/// set during initial spawn. Does NOT touch <see cref="GoldEarnedThisWave"/>.
/// </summary>
public void ServerSetGold(int amount)
{
if (!IsServer)
{
Debug.LogError("[PlayerGoldManager] ServerSetGold called on a client. " +
"Only server code should call this directly.");
return;
}
currentGold.Value = Mathf.Max(0, amount);
}
/// <summary>
/// Server-side: resets <see cref="GoldEarnedThisWave"/> back to 0. Called by
/// <c>WaveManager</c> at the start of each new wave so the HUD's top-bar
/// per-wave counter starts fresh.
/// </summary>
public void ServerResetWaveEarnings()
{
if (!IsServer)
{
Debug.LogError("[PlayerGoldManager] ServerResetWaveEarnings called on a client.");
return;
}
goldEarnedThisWave.Value = 0;
}
/// <summary>

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>

View file

@ -58,13 +58,40 @@ namespace TD.Gameplay
[Tooltip("Shared lives pool at the start of a match.")]
[SerializeField] private int startingLives = 20;
[Tooltip("Single source of truth for every gold tunable: starting gold, per-wave " +
"kill rewards, completion bonus, no-leak bonus. Required for a real match; " +
"if unset the game falls back to per-player startingGold defaults and grants " +
"no kill/wave rewards (designer-error indicator, not a supported runtime mode).")]
[SerializeField] private GoldConfig goldConfig;
// ----- Networked state --------------------------------------------
// Per-slot zone-leak counters. Index = (int)PlayerSlot; size = 10 (0-9).
// Index 0 (PlayerSlot.None) is allocated but never written.
// Replicated so the HUD can show per-player leak scores on all peers.
// Per-slot total leak counters across the whole match. Index = (int)PlayerSlot;
// size = 10 (0-9). Index 0 (PlayerSlot.None) is allocated but never written.
// Replicated so the HUD scoreboard can show total leaks per player.
//
// Semantics: zoneLeakCounts[P] is incremented exactly once per enemy that
// ORIGINATED in player P's spawn AND escaped P's zone (crossed P's leak
// volume). Transit through other zones (e.g. a P1 enemy passing through
// P4 on its way to the goal) does NOT increment any counter — this is the
// "enemies I failed to stop in my own maze" metric, not a transit count.
private readonly NetworkList<int> zoneLeakCounts = new NetworkList<int>();
// Per-slot leak counter for the CURRENT wave only. Same shape as zoneLeakCounts;
// server resets every entry to 0 at the start of each wave. Used to determine
// who earns the NoLeaksBonus on wave completion. Not currently surfaced in UI
// separately from zoneLeakCounts — could be exposed if a "this wave: 0 leaks"
// indicator becomes desirable.
private readonly NetworkList<int> waveLeakCounts = new NetworkList<int>();
// Networked prep-phase countdown. Counts down from WaveDefinition.PrepTime to
// zero during prep; 0 while the wave is active or being mopped up. Read by the
// HUD (next-wave-label) to render "next: 0:12". Server is the only writer.
private readonly NetworkVariable<float> prepCountdown = new NetworkVariable<float>(
value: 0f,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// ----- Server-local runtime state ---------------------------------
private int remainingLives;
@ -87,9 +114,12 @@ namespace TD.Gameplay
if (!IsServer) return;
// Populate the NetworkList with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
// Populate the NetworkLists with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
for (int i = 0; i < 10; i++)
{
zoneLeakCounts.Add(0);
waveLeakCounts.Add(0);
}
remainingLives = startingLives;
@ -115,6 +145,21 @@ namespace TD.Gameplay
ms.SetLives(remainingLives);
ms.OnPhaseChanged += HandlePhaseChanged;
// Apply StartingGold from the config to every connected player, overwriting
// whatever fallback the PlayerGoldManager set during its own OnNetworkSpawn.
// PlayerGoldManager spawns once per client connection (in MainMenu/Lobby),
// before the Match scene exists — so this is where the config-driven init
// actually lands. Skipped silently if no goldConfig is assigned; the per-
// player fallback startingGold stays.
if (goldConfig != null)
{
foreach (var pms in PlayerMatchState.AllPlayers)
{
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
if (gm != null) gm.ServerSetGold(goldConfig.StartingGold);
}
}
if (ms.Phase == MatchPhase.Playing)
StartNextWave();
}
@ -137,8 +182,8 @@ namespace TD.Gameplay
public int TotalWaves => waveDefinitions?.Length ?? 0;
/// <summary>
/// Number of times enemies have leaked out of the given player's zone.
/// Replicated — safe to call on any peer.
/// Number of times enemies have leaked out of the given player's zone over the
/// entire match. Replicated — safe to call on any peer.
/// </summary>
public int GetZoneLeakCount(PlayerSlot slot)
{
@ -146,6 +191,20 @@ namespace TD.Gameplay
return (idx >= 0 && idx < zoneLeakCounts.Count) ? zoneLeakCounts[idx] : 0;
}
/// <summary>
/// The <see cref="GoldConfig"/> assigned to this wave manager, or null if unset.
/// Exposed so other systems (HUD, future income panels) can read its values.
/// </summary>
public GoldConfig GoldConfig => goldConfig;
/// <summary>
/// Seconds remaining in the prep phase before the next wave starts spawning.
/// Zero outside of the prep phase. Replicated; safe to call on any peer.
/// HUD reads this each frame to render the countdown label.
/// </summary>
public float PrepCountdown => prepCountdown.Value;
// ----- Phase handling ---------------------------------------------
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
@ -172,6 +231,18 @@ namespace TD.Gameplay
// shows the upcoming wave number during the countdown.
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
// Reset per-wave bookkeeping for the wave that's about to begin:
// - waveLeakCounts: per-slot leaks this wave, used for the no-leak bonus.
// - PlayerGoldManager.goldEarnedThisWave: HUD top-bar resets so the
// "+N g/wave" counter starts at 0.
for (int i = 0; i < waveLeakCounts.Count; i++)
waveLeakCounts[i] = 0;
foreach (var pms in PlayerMatchState.AllPlayers)
{
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
if (gm != null) gm.ServerResetWaveEarnings();
}
activeEnemyCount = 0;
spawningComplete = false;
@ -229,9 +300,34 @@ namespace TD.Gameplay
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
{
// Prep phase — players build while the countdown ticks. Skipped when
// a dev cheat forces the wave to start immediately.
// a dev cheat forces the wave to start immediately. We tick the
// replicated prepCountdown each frame so every HUD can render the
// remaining time consistently. Server is the only writer; clients
// observe the value via NetworkVariable replication.
if (!skipPrep)
yield return new WaitForSeconds(def.PrepTime);
{
prepCountdown.Value = def.PrepTime;
float remaining = def.PrepTime;
// Throttle network sync to ~10 Hz. NetworkVariable replicates on every
// mutation; at 60 fps we'd send ~600 deltas per 10s prep purely to
// animate text that only changes once per second on the HUD. 0.1s
// gives a smooth-enough fall while keeping bandwidth minimal.
const float NetworkSyncInterval = 0.1f;
float nextSync = def.PrepTime - NetworkSyncInterval;
while (remaining > 0f)
{
yield return null;
remaining = Mathf.Max(0f, remaining - Time.deltaTime);
if (remaining <= nextSync || remaining <= 0f)
{
prepCountdown.Value = remaining;
nextSync = remaining - NetworkSyncInterval;
}
}
}
// Ensure the countdown reads zero entering the spawn phase, regardless of
// whether prep was skipped or just expired.
prepCountdown.Value = 0f;
// Spawn phase.
if (def.Entries != null)
@ -276,11 +372,15 @@ namespace TD.Gameplay
if (PlayerMatchState.GetForSlot(zone.Owner) == null) continue;
// Use the first spawner in the zone. Future: round-robin through Spawners.
SpawnEnemy(def, zone.Spawners[0].TilePosition);
// Pass zone.Owner explicitly so EnemyMovement knows which player owns
// this enemy for leak attribution — can't be derived from the spawner
// tile's owner-grid entry because SpawnerVolume sits outside
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
SpawnEnemy(def, zone.Spawners[0].TilePosition, zone.Owner);
}
}
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile)
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot)
{
if (def.EnemyPrefab == null)
{
@ -304,8 +404,8 @@ namespace TD.Gameplay
return;
}
health.InitializeServer(def.MaxHp, def.GoldReward, def.LivesCost, def.IsFlying);
movement.InitializeServer(def.MoveSpeed, spawnerTile);
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying);
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot);
health.OnDied += HandleEnemyKilled;
movement.OnZoneLeaked += HandleZoneLeak;
@ -320,22 +420,29 @@ namespace TD.Gameplay
private void HandleEnemyKilled(EnemyHealth health)
{
// Kill reward comes from GoldConfig for the current wave — same value for
// every enemy in the wave regardless of EnemyDefinition type. Missing config
// or out-of-range wave → 0 reward (gold flow disabled, designer-error mode).
int killReward = 0;
var goldEntry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
if (goldEntry != null) killReward = goldEntry.GoldPerEnemy;
// Award kill gold to the tower owner that landed the killing blow.
PlayerSlot killerSlot = health.LastHitOwner;
if (killerSlot != PlayerSlot.None)
if (killerSlot != PlayerSlot.None && killReward > 0)
{
var pms = PlayerMatchState.GetForSlot(killerSlot);
if (pms != null)
PlayerGoldManager.GetForClient(pms.OwnerClientId)
?.AwardGold(health.GoldReward);
?.AwardGold(killReward);
}
// Show a "+N" gold popup above the corpse on every peer. Capture the
// position here on the server — by the time the RPC fires on clients
// the death sequence will be moving the corpse, but the spawn point
// is good enough and we want the popup to anchor where the kill happened.
if (health.GoldReward > 0)
ShowGoldRewardClientRpc(health.transform.position, health.GoldReward);
if (killReward > 0)
ShowGoldRewardClientRpc(health.transform.position, killReward);
UnsubscribeEnemy(health);
DecrementAndCheckComplete();
@ -343,10 +450,15 @@ namespace TD.Gameplay
private void HandleZoneLeak(PlayerSlot leavingZone)
{
// Increment the per-slot leak counter for the zone the enemy is leaving.
// EnemyMovement fires this exactly once per enemy, when it escapes its
// origin zone. We increment both the match-total and the per-wave counter
// for the originating player. Per-wave count drives the no-leak bonus
// eligibility check at wave completion.
int idx = (int)leavingZone;
if (idx >= 0 && idx < zoneLeakCounts.Count)
zoneLeakCounts[idx]++;
if (idx >= 0 && idx < waveLeakCounts.Count)
waveLeakCounts[idx]++;
}
private void HandleEnemyReachedGoal(EnemyMovement movement, int livesCost)
@ -432,8 +544,55 @@ namespace TD.Gameplay
if (ms == null || ms.Phase == MatchPhase.Defeat || ms.Phase == MatchPhase.Victory)
return;
// Award per-wave bonuses BEFORE advancing the wave (so waveLeakCounts still
// reflects this wave's leaks, and goldEarnedThisWave still accumulates this
// wave's bonus on top of kill gold). Completion bonus is unconditional;
// no-leak bonus only if the player's waveLeakCounts entry is exactly 0.
AwardWaveCompletionBonuses();
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
StartNextWave();
}
// Server-only. Iterates active players, awards CompletionBonus to each, plus
// NoLeaksBonus to those whose per-wave leak counter is zero. Floating-text popups
// are spawned at each player's builder position so the reward is visible in-world.
// Skipped silently if no goldConfig or no entry for this wave.
private void AwardWaveCompletionBonuses()
{
var entry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
if (entry == null) return;
int completionBonus = entry.CompletionBonus;
int noLeaksBonus = entry.NoLeaksBonus;
if (completionBonus <= 0 && noLeaksBonus <= 0) return;
foreach (var pms in PlayerMatchState.AllPlayers)
{
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
if (gm == null) continue;
int award = 0;
if (completionBonus > 0) award += completionBonus;
int slotIdx = (int)pms.Slot;
bool zeroLeaks = slotIdx >= 0 && slotIdx < waveLeakCounts.Count
&& waveLeakCounts[slotIdx] == 0;
if (zeroLeaks && noLeaksBonus > 0) award += noLeaksBonus;
if (award > 0)
{
gm.AwardGold(award);
// Surface the bonus in-world so players see it land. Position the
// popup at the player's builder if we can find one; otherwise the
// origin (popups still spawn, just centered on world origin).
Vector3 popupPos = Vector3.zero;
var builder = Builder.GetForClient(pms.OwnerClientId);
if (builder != null) popupPos = builder.CurrentPosition;
ShowGoldRewardClientRpc(popupPos, award);
}
}
}
}
}