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

331 lines
12 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;
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
// ----- 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>
/// 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()
{
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;
StartCoroutine(RunWave(waveDefinitions[currentWaveIndex]));
}
private IEnumerator RunWave(WaveDefinition def)
{
// Prep phase — players build while the countdown ticks.
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;
// 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);
}
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)
{
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();
}
// ----- 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();
}
}
}