// 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;
// ----- 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.
private readonly NetworkList zoneLeakCounts = new NetworkList();
// ----- 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 NetworkList with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
for (int i = 0; i < 10; i++)
zoneLeakCounts.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;
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.
/// 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;
}
// ----- 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
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.
if (!skipPrep)
yield return new WaitForSeconds(def.PrepTime);
// 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.
SpawnEnemy(def, zone.Spawners[0].TilePosition);
}
}
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile)
{
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.GoldReward, def.LivesCost, def.IsFlying);
movement.InitializeServer(def.MoveSpeed, spawnerTile);
health.OnDied += HandleEnemyKilled;
movement.OnZoneLeaked += HandleZoneLeak;
movement.OnReachedGoal += HandleEnemyReachedGoal;
activeEnemyCount++;
go.GetComponent().Spawn();
}
// ----- Enemy event handlers (server-only) -------------------------
private void HandleEnemyKilled(EnemyHealth health)
{
// Award kill gold to the tower owner that landed the killing blow.
PlayerSlot killerSlot = health.LastHitOwner;
if (killerSlot != PlayerSlot.None)
{
var pms = PlayerMatchState.GetForSlot(killerSlot);
if (pms != null)
PlayerGoldManager.GetForClient(pms.OwnerClientId)
?.AwardGold(health.GoldReward);
}
// 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);
UnsubscribeEnemy(health);
DecrementAndCheckComplete();
}
private void HandleZoneLeak(PlayerSlot leavingZone)
{
// Increment the per-slot leak counter for the zone the enemy is leaving.
int idx = (int)leavingZone;
if (idx >= 0 && idx < zoneLeakCounts.Count)
zoneLeakCounts[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;
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
StartNextWave();
}
}
}