We've got enemies and movement!!
This commit is contained in:
parent
42ee0bf65d
commit
3287e8ea43
26 changed files with 1409 additions and 161 deletions
331
Assets/_Project/Scripts/Gameplay/WaveManager.cs
Normal file
331
Assets/_Project/Scripts/Gameplay/WaveManager.cs
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
// 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 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 <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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue