UnityTowerDefense/Assets/_Project/Scripts/Gameplay/WaveManager.cs

427 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
{
/// <summary>
/// Server-authoritative wave controller. Spawns enemies across all player zones,
/// tracks wave completion, awards kill gold, and manages the shared lives pool.
/// </summary>
/// <remarks>
/// <b>Wave lifecycle:</b>
/// <list type="bullet">
/// <item>When <see cref="MatchPhase.Playing"/> is entered,
/// <see cref="StartNextWave"/> advances <see cref="MatchState.CurrentWave"/>
/// immediately (so the HUD shows the wave number during prep), then waits
/// <see cref="WaveDefinition.PrepTime"/> before spawning.</item>
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone,
/// one zone per frame-group, with <c>SpawnInterval</c> seconds between
/// individual enemies in the group.</item>
/// <item>After all entries are spawned, the wave is considered complete only when
/// every active enemy is either killed or has reached the goal.</item>
/// <item>All waves exhausted → <see cref="MatchPhase.Victory"/>.</item>
/// <item>Lives drop to 0 → <see cref="MatchPhase.Defeat"/>.</item>
/// </list>
///
/// <b>Kill gold:</b> When an enemy dies, <see cref="EnemyHealth.LastHitOwner"/> names
/// the tower's player. <see cref="PlayerMatchState.GetForSlot"/> resolves the
/// <c>OwnerClientId</c>, and <see cref="PlayerGoldManager.GetForClient"/> awards
/// the gold.
///
/// <b>Zone leak counts:</b> <see cref="zoneLeakCounts"/> is a <c>NetworkList</c>
/// indexed by <c>(int)PlayerSlot</c> (indices 08). 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 <see cref="PlayerSlot.None"/> and is unused.
///
/// <b>Inspector setup:</b>
/// <list type="bullet">
/// <item>Assign <see cref="waveDefinitions"/> in order (Wave 1 at index 0).</item>
/// <item>Set <see cref="startingLives"/> to match your level design intent.</item>
/// </list>
/// </remarks>
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<int> zoneLeakCounts = new NetworkList<int>();
// ----- 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 -------------------------------------------
/// <summary>
/// Total number of waves in this match. Same on every peer because
/// <see cref="waveDefinitions"/> is a serialized prefab field, identical
/// on host and clients. Returns 0 if the array is unassigned.
/// </summary>
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.
/// </summary>
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 -----------------------------------------------
/// <summary>
/// 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.
/// </summary>
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<NetworkObject>(
spawnManager.SpawnedObjectsList);
foreach (var no in snapshot)
{
if (no == null || !no.IsSpawned) continue;
var movement = no.GetComponent<EnemyMovement>();
if (movement == null) continue;
// Unsubscribe so the despawn doesn't deduct lives or fire side effects.
var h = no.GetComponent<EnemyHealth>();
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<EnemyHealth>();
var movement = go.GetComponent<EnemyMovement>();
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<NetworkObject>().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<EnemyHealth>());
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);
}
// ----- Helpers ----------------------------------------------------
private void UnsubscribeEnemy(EnemyHealth health)
{
if (health == null) return;
health.OnDied -= HandleEnemyKilled;
var movement = health.GetComponent<EnemyMovement>();
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();
}
}
}