598 lines
26 KiB
C#
598 lines
26 KiB
C#
// 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 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;
|
||
|
||
[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<int> zoneLeakCounts = new NetworkList<int>();
|
||
|
||
// 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<int> waveLeakCounts = new NetworkList<int>();
|
||
|
||
// 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<float> prepCountdown = new NetworkVariable<float>(
|
||
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 -------------------------------------------
|
||
|
||
/// <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 over the
|
||
/// entire match. 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The <see cref="GoldConfig"/> assigned to this wave manager, or null if unset.
|
||
/// Exposed so other systems (HUD, future income panels) can read its values.
|
||
/// </summary>
|
||
public GoldConfig GoldConfig => goldConfig;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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 -----------------------------------------------
|
||
|
||
/// <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. 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<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.LivesCost, def.IsFlying);
|
||
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot);
|
||
|
||
health.OnDied += HandleEnemyKilled;
|
||
movement.OnZoneLeaked += HandleZoneLeak;
|
||
movement.OnReachedGoal += HandleEnemyReachedGoal;
|
||
|
||
activeEnemyCount++;
|
||
|
||
go.GetComponent<NetworkObject>().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<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);
|
||
OnLifeLost?.Invoke(amount);
|
||
}
|
||
|
||
// ----- Local-only notification events -----------------------------
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
public static event System.Action<int> OnLifeLost;
|
||
|
||
// ----- 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;
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|