// Assets/_Project/Scripts/Gameplay/WaveManager.cs using System.Collections; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Levels; using TD.UI; namespace TD.Gameplay { /// /// Server-authoritative wave controller. Spawns enemies across all player zones, /// tracks wave completion, awards kill gold, and manages the shared lives pool. /// /// /// Wave lifecycle: /// /// When is entered, /// advances /// immediately (so the HUD shows the wave number during prep), then waits /// before spawning. /// Each spawns Count enemies per player zone, /// one zone per frame-group, with SpawnInterval seconds between /// individual enemies in the group. /// After all entries are spawned, the wave is considered complete only when /// every active enemy is either killed or has reached the goal. /// All waves exhausted → . /// Lives drop to 0 → . /// /// /// Kill gold: When an enemy dies, names /// the tower's player. resolves the /// OwnerClientId, and awards /// the gold. /// /// Zone leak counts: is a NetworkList /// indexed by (int)PlayerSlot (indices 0–8). It is incremented when an enemy /// crosses from one player zone into another, giving the HUD a per-player leak score. /// Index 0 corresponds to and is unused. /// /// Inspector setup: /// /// Assign in order (Wave 1 at index 0). /// Set to match your level design intent. /// /// public class WaveManager : NetworkBehaviour { // ----- Singleton -------------------------------------------------- public static WaveManager Instance { get; private set; } // ----- Inspector -------------------------------------------------- [Tooltip("Wave definitions in order. Index 0 = Wave 1.")] [SerializeField] private WaveDefinition[] waveDefinitions; [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 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 zoneLeakCounts = new NetworkList(); // 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 waveLeakCounts = new NetworkList(); // 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 prepCountdown = new NetworkVariable( value: 0f, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); // ----- Server-local runtime state --------------------------------- private int remainingLives; private int activeEnemyCount; private bool spawningComplete; private int currentWaveIndex = -1; // -1 = not yet started private Coroutine activeWaveCoroutine; // ----- NGO lifecycle ---------------------------------------------- public override void OnNetworkSpawn() { if (Instance != null && Instance != this) { Debug.LogError("[WaveManager] Duplicate WaveManager detected. " + "Only one may exist per scene."); return; } Instance = this; if (!IsServer) return; // 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; // NGO's scene-object spawn sweep calls all OnNetworkSpawn methods // synchronously in a single call stack. Yielding one frame guarantees // every sibling NetworkBehaviour (including MatchState) has finished // its own OnNetworkSpawn before we try to read MatchState.Instance. StartCoroutine(InitAfterSpawn()); } private System.Collections.IEnumerator InitAfterSpawn() { yield return null; // wait one frame var ms = MatchState.Instance; if (ms == null) { Debug.LogWarning("[WaveManager] MatchState not found after spawn. " + "Waves will not start automatically."); yield break; } 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(); } public override void OnNetworkDespawn() { if (Instance == this) Instance = null; if (MatchState.Instance != null) MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged; } // ----- Public accessors ------------------------------------------- /// /// Total number of waves in this match. Same on every peer because /// is a serialized prefab field, identical /// on host and clients. Returns 0 if the array is unassigned. /// public int TotalWaves => waveDefinitions?.Length ?? 0; /// /// Number of times enemies have leaked out of the given player's zone over the /// entire match. Replicated — safe to call on any peer. /// public int GetZoneLeakCount(PlayerSlot slot) { int idx = (int)slot; return (idx >= 0 && idx < zoneLeakCounts.Count) ? zoneLeakCounts[idx] : 0; } /// /// The assigned to this wave manager, or null if unset. /// Exposed so other systems (HUD, future income panels) can read its values. /// public GoldConfig GoldConfig => goldConfig; /// /// 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. /// public float PrepCountdown => prepCountdown.Value; // ----- Phase handling --------------------------------------------- private void HandlePhaseChanged(MatchPhase previous, MatchPhase next) { if (!IsServer) return; if (next == MatchPhase.Playing && currentWaveIndex < 0) StartNextWave(); } // ----- Wave coroutine --------------------------------------------- private void StartNextWave(bool skipPrep = false) { currentWaveIndex++; if (waveDefinitions == null || currentWaveIndex >= waveDefinitions.Length) { Debug.Log("[WaveManager] All waves complete. Victory."); MatchState.Instance?.SetPhase(MatchPhase.Victory); return; } // Advance the replicated wave counter at the START of prep so the HUD // 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; activeWaveCoroutine = StartCoroutine( RunWave(waveDefinitions[currentWaveIndex], skipPrep)); } // ----- Dev / cheats ----------------------------------------------- /// /// Dev cheat: skip the rest of the current wave (despawn any remaining /// enemies, kill the prep timer) and start the next wave immediately. /// Server-only — silently no-ops on clients. Safe to call during prep, /// mid-spawn, or while enemies are alive. /// public void ForceAdvanceToNextWave() { if (!IsServer) return; // Stop the current wave's coroutine (cancels prep timer + remaining spawns). if (activeWaveCoroutine != null) { StopCoroutine(activeWaveCoroutine); activeWaveCoroutine = null; } // Despawn anything still running around. Iterate a snapshot since // EnemyHealth.HandleDeath will despawn the NetworkObject, mutating // the live collection. var spawnManager = NetworkManager.Singleton?.SpawnManager; if (spawnManager != null) { var snapshot = new System.Collections.Generic.List( spawnManager.SpawnedObjectsList); foreach (var no in snapshot) { if (no == null || !no.IsSpawned) continue; var movement = no.GetComponent(); if (movement == null) continue; // Unsubscribe so the despawn doesn't deduct lives or fire side effects. var h = no.GetComponent(); UnsubscribeEnemy(h); no.Despawn(); } } // Reset bookkeeping and start the next wave's RunWave coroutine, // skipping the prep timer so spawning starts immediately. activeEnemyCount = 0; spawningComplete = false; StartNextWave(skipPrep: true); } 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. 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) { 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) { foreach (var entry in def.Entries) { if (entry.EnemyType == null || entry.Count <= 0) continue; for (int i = 0; i < entry.Count; i++) { SpawnEnemyInAllZones(entry.EnemyType); if (entry.SpawnInterval > 0f) yield return new WaitForSeconds(entry.SpawnInterval); } } } spawningComplete = true; // If every spawned enemy was already resolved before this coroutine finished // (edge case: SpawnInterval = 0 and enemies die instantly), complete the wave now. CheckWaveComplete(); } // ----- Spawn helpers ---------------------------------------------- private void SpawnEnemyInAllZones(EnemyDefinition def) { var loader = LevelLoader.Instance; if (loader?.LevelData?.PlayerZones == null) return; foreach (var zone in loader.LevelData.PlayerZones) { if (zone.Spawners == null || zone.Spawners.Length == 0) continue; // Skip zones whose owning slot is empty. Until a lobby exists, // this means a 1-player test session only spawns enemies in // Player 1's zone; Player 2/3/... zones stay quiet until those // slots are actually filled. PlayerMatchState.GetForSlot returns // null for unoccupied slots (and for PlayerSlot.None). if (PlayerMatchState.GetForSlot(zone.Owner) == null) continue; // Use the first spawner in the zone. Future: round-robin through Spawners. // 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, PlayerSlot ownerSlot) { if (def.EnemyPrefab == null) { Debug.LogWarning($"[WaveManager] EnemyDefinition '{def.name}' has no EnemyPrefab assigned."); return; } var go = Instantiate( def.EnemyPrefab, GridCoordinates.GridToWorld(spawnerTile), Quaternion.identity); var health = go.GetComponent(); var movement = go.GetComponent(); if (health == null || movement == null) { Debug.LogError($"[WaveManager] Enemy prefab '{def.EnemyPrefab.name}' is missing " + $"EnemyHealth or EnemyMovement. Enemy will not be spawned."); Destroy(go); return; } health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying); movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot); health.OnDied += HandleEnemyKilled; movement.OnZoneLeaked += HandleZoneLeak; movement.OnReachedGoal += HandleEnemyReachedGoal; activeEnemyCount++; go.GetComponent().Spawn(); } // ----- Enemy event handlers (server-only) ------------------------- 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 && killReward > 0) { var pms = PlayerMatchState.GetForSlot(killerSlot); if (pms != null) PlayerGoldManager.GetForClient(pms.OwnerClientId) ?.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 (killReward > 0) ShowGoldRewardClientRpc(health.transform.position, killReward); UnsubscribeEnemy(health); DecrementAndCheckComplete(); } private void HandleZoneLeak(PlayerSlot leavingZone) { // 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) { // Capture the leak position BEFORE the enemy NetworkObject despawns // (HandleGoalReached on the enemy calls Despawn right after firing the // event we're handling here). Show the "-N" popup on every peer. Vector3 leakPos = movement.transform.position; if (livesCost > 0) ShowLifeLossClientRpc(leakPos, livesCost); UnsubscribeEnemy(movement.GetComponent()); remainingLives = Mathf.Max(0, remainingLives - livesCost); MatchState.Instance?.SetLives(remainingLives); if (remainingLives <= 0) { Debug.Log("[WaveManager] Lives depleted. Defeat."); MatchState.Instance?.SetPhase(MatchPhase.Defeat); return; } DecrementAndCheckComplete(); } // ----- Floating-text ClientRpcs ----------------------------------- // Fired on every peer (server + clients) so each one spawns its own local // FloatingText. The spawned GameObjects are not networked — purely visual. [ClientRpc] private void ShowGoldRewardClientRpc(Vector3 worldPos, int amount) { FloatingTextSpawner.Instance?.SpawnGoldReward(worldPos, amount); } [ClientRpc] private void ShowLifeLossClientRpc(Vector3 worldPos, int amount) { FloatingTextSpawner.Instance?.SpawnLifeLoss(worldPos, amount); OnLifeLost?.Invoke(amount); } // ----- Local-only notification events ----------------------------- /// /// Fired on every peer immediately after a life-loss popup spawns. /// HUD subscribes to flash a centered banner; gameplay code can also /// hook this for audio cues, screen-shake, etc. Argument is the number /// of lives lost (usually 1, but boss enemies with LivesCost > 1 /// fire a single event carrying the larger value). /// public static event System.Action OnLifeLost; // ----- Helpers ---------------------------------------------------- private void UnsubscribeEnemy(EnemyHealth health) { if (health == null) return; health.OnDied -= HandleEnemyKilled; var movement = health.GetComponent(); if (movement != null) { movement.OnZoneLeaked -= HandleZoneLeak; movement.OnReachedGoal -= HandleEnemyReachedGoal; } } private void DecrementAndCheckComplete() { activeEnemyCount--; CheckWaveComplete(); } private void CheckWaveComplete() { if (!spawningComplete) return; if (activeEnemyCount > 0) return; // Guard: don't start the next wave if the match is already decided. var ms = MatchState.Instance; 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); } } } } }