// Assets/_Project/Scripts/Gameplay/WaveManager.cs using System.Collections; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Gameplay.BuilderEffects; using TD.Gameplay.Draft; using TD.Gameplay.EnemyAbilities; using TD.Gameplay.EnemyUpgrades; using TD.Gameplay.Waves; using TD.Levels; using TD.UI; namespace TD.Gameplay { /// /// Server-authoritative encounter driver. Spawns enemies across all player zones, tracks /// completion, awards kill gold, and manages the shared lives pool. Which encounter to /// run comes from ; this class only runs it. /// /// /// Encounter lifecycle: /// /// When is entered, asks /// to draw phase 1 and then runs its first wave. /// Each encounter is: inter-wave step (draft, then the enemy-buff vote, then the /// build countdown) → spawn → mop up. See . /// Each spawns Count enemies per player zone. All /// spawn held, then release in 10% chunks on ReleaseInterval. /// The encounter is complete only when every spawned enemy is dead or has leaked. /// then decides what comes next. /// Final phase's boss dies → . /// Lives drop to 0 → . /// /// /// Why the wave list moved out. Waves used to be a hand-ordered array walked /// start-to-finish, so "which wave is next" was just an index++. Under the 2.0 cyclical /// design the same five waves repeat across three cycles while accumulating player-voted /// upgrades, and each phase draws a fresh five — progression that no longer fits in an index. /// owns it; this class asks it what to run. /// /// Kill gold: When an enemy dies, names /// the tower's player. resolves the /// OwnerClientId, and awards /// the gold. GoldConfig is indexed by /// — a run-long counter, not a per-cycle one, so /// per-wave payouts keep climbing as the cycles repeat instead of resetting. /// /// Zone leak counts: is a NetworkList /// indexed by (int)PlayerSlot. 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 a RunDefinition to the scene's . /// Set to match your level design intent. /// /// public class WaveManager : NetworkBehaviour { // ----- Singleton -------------------------------------------------- public static WaveManager Instance { get; private set; } // ----- Inspector -------------------------------------------------- [Tooltip("Shared lives pool at the start of a match.")] [SerializeField] private int startingLives = 20; [Tooltip("Seconds players get to make their personal draft pick. Ends early once every " + "player has picked. Be generous — this is reading time, not reaction time.")] [SerializeField] private float draftTimeSeconds = 30f; [Tooltip("Seconds players get to vote on the enemy buff. Ends early once every player " + "has voted. Generous enough that players can watch each other's votes land and " + "change their own in response.")] [SerializeField] private float voteTimeSeconds = 25f; [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 zoneLeakCounts = new NetworkList(); // 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 waveLeakCounts = new NetworkList(); // 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 prepCountdown = new NetworkVariable( value: 0f, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); // Which inter-wave stage the countdown above belongs to. One countdown serves all three // stages (they never overlap), so the HUD needs this to label it correctly. private readonly NetworkVariable interWaveStage = new NetworkVariable( value: InterWaveStage.None, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); // ----- Server-local runtime state --------------------------------- private int remainingLives; private int activeEnemyCount; private bool spawningComplete; private bool runStarted; private Coroutine activeWaveCoroutine; // Wave slot the NEXT inter-wave vote will buff — captured when an encounter clears, before // the run advances past it. -1 means "no vote due": the run's first encounter (nothing has // been beaten yet) or straight after a boss (the phase's slots are being wiped). private int pendingVoteSlot = -1; private readonly System.Collections.Generic.List heldEnemies = new System.Collections.Generic.List(); // ----- 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); } } // Seed each player's tower deck with the starter set. Runs here (after the // one-frame yield) so the TowerPlacementManager catalog is guaranteed spawned // and its starting-deck assets resolve to valid TypeIds. Clearing-and-setting // means Retry / return-to-lobby cycles reset the deck cleanly. var placement = TowerPlacementManager.Instance; if (placement != null) { var startingTypeIds = placement.GetStartingDeckTypeIds(); foreach (var pms in PlayerMatchState.AllPlayers) { var deck = PlayerTowerDeck.GetForClient(pms.OwnerClientId); if (deck != null) deck.ServerInitialize(startingTypeIds); // Upgrade nodes start empty — every one is earned through the draft. Cleared // rather than left alone so Retry / return-to-lobby doesn't carry a previous // run's unlocks into a new one. PlayerTowerUpgrades.GetForClient(pms.OwnerClientId)?.ServerInitialize(); } } else { Debug.LogWarning("[WaveManager] TowerPlacementManager not found at match start. " + "Player decks were not seeded."); } if (ms.Phase == MatchPhase.Playing) StartRun(); } public override void OnNetworkDespawn() { if (Instance == this) Instance = null; if (MatchState.Instance != null) MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged; } // ----- Public accessors ------------------------------------------- /// /// Number of times enemies have leaked out of the given player's zone over the /// entire match. 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; } /// /// The assigned to this wave manager, or null if unset. /// Exposed so other systems (HUD, future income panels) can read its values. /// public GoldConfig GoldConfig => goldConfig; /// /// 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. /// public float PrepCountdown => prepCountdown.Value; /// /// Which inter-wave step the current countdown belongs to, or /// while a wave is spawning or being fought. Replicated; /// safe on any peer. The HUD uses it to label the shared countdown. /// public InterWaveStage CurrentInterWaveStage => interWaveStage.Value; /// /// 1-based encounter number since the run began, or 0 before it starts. This is the key /// is indexed by. Replicated via , so it's /// safe on any peer. /// public int CurrentEncounterNumber => RunState.Instance != null && runStarted ? RunState.Instance.GlobalEncounterNumber : 0; // ----- Phase handling --------------------------------------------- private void HandlePhaseChanged(MatchPhase previous, MatchPhase next) { if (!IsServer) return; if (next == MatchPhase.Playing) StartRun(); } // ----- Run control ------------------------------------------------ /// /// Server-only: begin the run. Draws phase 1 and starts its first encounter. Idempotent — /// re-entering won't restart a run in progress. /// private void StartRun() { if (!IsServer || runStarted) return; var run = RunState.Instance; if (run == null) { Debug.LogError("[WaveManager] No RunState in the scene — no waves can run. " + "Add a RunState object and assign it a RunDefinition."); return; } if (!run.ServerBeginRun()) { // ServerBeginRun already logged the specific reason (missing asset, unplayable // pools). Bail rather than spawn an empty encounter that can never complete. Debug.LogError("[WaveManager] Run could not be started; see the RunState error above."); return; } runStarted = true; StartEncounter(skipInterWave: false); } /// /// Server-only: run whatever encounter is currently pointing at. /// /// Skip the draft/vote/build step and spawn immediately. /// Used by the dev force-advance cheat. private void StartEncounter(bool skipInterWave) { var run = RunState.Instance; var def = run?.CurrentWave; if (def == null) { Debug.LogError($"[WaveManager] No wave drawn for {run?.ProgressLabel ?? "?"} — " + $"the run cannot continue."); return; } // Publish the run-long encounter number so the HUD and the gold lookup agree on // which encounter's payout table applies. MatchState.Instance?.SetCurrentWave(run.GlobalEncounterNumber); // Reset per-wave bookkeeping for the encounter 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(RunEncounter(def, skipInterWave)); } /// /// Server-only: the encounter is over — ask what's next and either /// start it or end the match. /// private void AdvanceRun() { var run = RunState.Instance; if (run == null) return; switch (run.ServerAdvance()) { case RunAdvance.RunComplete: Debug.Log("[WaveManager] Final boss defeated. Victory."); MatchState.Instance?.SetPhase(MatchPhase.Victory); return; case RunAdvance.BossReady: Debug.Log($"[WaveManager] All cycles cleared — boss incoming " + $"({run.ProgressLabel})."); break; case RunAdvance.NextPhase: Debug.Log($"[WaveManager] Boss down. New phase drawn: {run.ProgressLabel}. " + $"Enemy upgrades wiped."); break; case RunAdvance.NextCycle: Debug.Log($"[WaveManager] Cycle complete — same waves return upgraded " + $"({run.ProgressLabel})."); break; } StartEncounter(skipInterWave: false); } // ----- Dev / cheats ----------------------------------------------- /// /// Dev cheat: skip the rest of the current encounter (despawn any remaining enemies, kill /// the timers) and start the next one immediately. Server-only — silently no-ops on /// clients. Safe to call during the inter-wave step, mid-spawn, or while enemies are alive. /// public void ForceAdvanceToNextWave() { if (!IsServer || !runStarted) return; ClearFieldAndStopEncounter(); AdvanceRun(); } /// /// Dev cheat: skip whole encounters until the run reaches this phase's boss. Useful for /// testing boss content without playing fifteen waves. Server-only. /// public void ForceAdvanceToBoss() { if (!IsServer || !runStarted) return; var run = RunState.Instance; if (run == null || run.IsBossStage) return; ClearFieldAndStopEncounter(); // Advance until the boss is up (or the run ends, which shouldn't happen from a // non-boss encounter but is guarded anyway). int guard = run.EncountersPerPhase + 1; while (!run.IsBossStage && guard-- > 0) { if (run.ServerAdvance() == RunAdvance.RunComplete) { MatchState.Instance?.SetPhase(MatchPhase.Victory); return; } } StartEncounter(skipInterWave: false); } // Shared teardown for the dev cheats: stop the running encounter, wipe the field, and // resolve anything the players had open so nothing is left dangling mid-skip. private void ClearFieldAndStopEncounter() { // Stop the current encounter's coroutine (cancels timers + 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(); } } activeEnemyCount = 0; spawningComplete = false; heldEnemies.Clear(); // Skipping ahead interrupts the inter-wave step, so resolve anything the players had // open before moving on. The draft auto-resolves (a free reward shouldn't vanish // because a dev pressed a key); the vote is closed WITHOUT resolving, since applying a // buff nobody finished voting on would silently rewrite the run. DraftService.Instance?.ServerAutoResolveAll(); WaveVote.Instance?.ServerClose(); interWaveStage.Value = InterWaveStage.None; prepCountdown.Value = 0f; } // ----- Encounter coroutine ---------------------------------------- private IEnumerator RunEncounter(WaveDefinition def, bool skipInterWave = false) { // Draft, enemy-buff vote, then the build countdown. Skipped entirely when a dev // cheat forces the encounter to start immediately. if (!skipInterWave) yield return RunInterWave(def); // Ensure the countdown reads zero entering the spawn phase, regardless of // whether the build step was skipped or just expired. prepCountdown.Value = 0f; // Resolve this wave slot's voted buffs once, here — the set is fixed for the whole // encounter, so doing it per enemy would repeat the same lookup hundreds of times. // Must run AFTER the inter-wave step, since the vote that just closed may have added // to a slot this very encounter re-runs on a later cycle. ResolveCurrentWaveAbilities(); // Spawn all enemies at once in a held (untargetable, immobile) state. 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, held: true); } } // Release in chunks of 10% of the total held count. int total = heldEnemies.Count; int chunkSize = Mathf.Max(1, Mathf.RoundToInt(total * 0.1f)); while (heldEnemies.Count > 0) { int burst = Mathf.Min(chunkSize, heldEnemies.Count); for (int i = 0; i < burst; i++) { var h = heldEnemies[0]; heldEnemies.RemoveAt(0); if (h != null && !h.IsDead) h.SetHeld(false); } if (heldEnemies.Count > 0) yield return new WaitForSeconds(def.ReleaseInterval); } 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(); } // ----- Inter-wave step --------------------------------------------- /// /// The step between encounters: players take their personal draft, then vote on the buff /// the wave they just cleared will carry into the next cycle, then build. /// /// /// The three stages run strictly in sequence and never overlap. The build countdown used /// to double as the draft window, which made "wait until everyone has chosen" impossible /// to express — players who wanted to build were paying for teammates who wanted to read /// their cards. Separating them costs a few seconds per wave and makes both steps mean /// what they say. /// /// Each stage ends early the moment every player has acted, so the generous timers /// are a ceiling for deliberation, not a floor everyone sits through. /// private IEnumerator RunInterWave(WaveDefinition def) { // ----- 1. Personal draft ----- // Offered to everyone at once. Anyone who hasn't picked when the timer expires has // their draft auto-resolved, so an idle player never wastes a free reward. interWaveStage.Value = InterWaveStage.Draft; DraftService.Instance?.ServerOfferToAll(); yield return TickCountdown(draftTimeSeconds, () => DraftService.AllPlayersPicked); DraftService.Instance?.ServerAutoResolveAll(); // ----- 2. Enemy-buff vote ----- // Votes on the wave that was just cleared, which is the slot captured before the run // advanced. Skipped on the run's first encounter (nothing has been defeated yet) and // after a boss (that phase's slots are being wiped anyway). if (pendingVoteSlot >= 0) { var vote = WaveVote.Instance; var run = RunState.Instance; // A vote-meta card may have armed this slot to skip its next vote and take // several buffs outright. That's the card's whole effect, so it pre-empts the // normal vote rather than running alongside it. int autoCount = run != null ? run.GetAutoApplyCount(pendingVoteSlot) : 0; if (autoCount > 0 && vote != null) { int applied = vote.ServerAutoApply(pendingVoteSlot, autoCount); run.ServerConsumeAutoApply(pendingVoteSlot); Debug.Log($"[WaveManager] Wave slot {pendingVoteSlot} auto-took {applied} " + $"buff(s); no vote held."); } else if (vote != null && vote.ServerOpenVote(pendingVoteSlot)) { interWaveStage.Value = InterWaveStage.Vote; yield return TickCountdown(voteTimeSeconds, () => vote.AllPlayersVoted); vote.ServerResolve(); } pendingVoteSlot = -1; } // ----- 3. Build ----- interWaveStage.Value = InterWaveStage.Build; yield return TickCountdown(def.PrepTime); interWaveStage.Value = InterWaveStage.None; } /// /// Ticks down from to zero, /// exiting early once returns true. /// /// /// Network sync is throttled to ~10 Hz. NetworkVariable replicates on every /// mutation, so ticking at frame rate would push ~600 deltas across a 10-second countdown /// purely to animate text that changes once a second. A tenth of a second still falls /// smoothly while keeping the traffic negligible. /// private IEnumerator TickCountdown(float seconds, System.Func finishedEarly = null) { const float NetworkSyncInterval = 0.1f; prepCountdown.Value = seconds; float remaining = seconds; float nextSync = seconds - NetworkSyncInterval; while (remaining > 0f) { if (finishedEarly != null && finishedEarly()) break; yield return null; remaining = Mathf.Max(0f, remaining - Time.deltaTime); if (remaining <= nextSync || remaining <= 0f) { prepCountdown.Value = remaining; nextSync = remaining - NetworkSyncInterval; } } prepCountdown.Value = 0f; } // ----- Wave ability resolution ------------------------------------- // Abilities carried by every enemy of the encounter currently spawning. Rebuilt once per // encounter (not per enemy) since a wave's buff set is fixed for its whole duration. private readonly System.Collections.Generic.List currentWaveAbilities = new System.Collections.Generic.List(); private readonly System.Collections.Generic.List upgradeIdScratch = new System.Collections.Generic.List(); /// /// Server-only: resolve the current wave slot's voted upgrades into the ability list every /// enemy of this encounter will carry. /// /// /// Called once at the top of an encounter's spawn phase. Cards that resolve to nothing /// (missing pool entry, non-ability card) are skipped silently here — the vote already /// validated them, and a warning per enemy would flood the log. /// private void ResolveCurrentWaveAbilities() { currentWaveAbilities.Clear(); var run = RunState.Instance; var pool = EnemyUpgradePool.Instance; if (run == null || pool == null) return; run.GetUpgradesForSlot(run.CurrentUpgradeSlot, upgradeIdScratch); foreach (int id in upgradeIdScratch) { if (pool.Get(id) is AbilityEnemyUpgradeOption card && card.Ability != null) currentWaveAbilities.Add(card.Ability); } if (currentWaveAbilities.Count > 0) Debug.Log($"[WaveManager] {run.ProgressLabel} enemies carry " + $"{currentWaveAbilities.Count} wave buff(s)."); } // ----- Spawn helpers ---------------------------------------------- private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false) { 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). var spawner = zone.Spawners[0]; (float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea); SpawnEnemy(def, spawner.TilePosition, zone.Owner, currentWaveAbilities, xHalfExtent: xHalf, zHalfExtent: zHalf, held: held, facing: spawner.Facing); } } // Computes half-extents in world units from a spawner's tile footprint. // X maps to world X; grid-Y maps to world Z (the grid lives on the XZ plane). private static (float x, float z) ComputeSpawnerHalfExtents(Vector2Int[] tileArea) { if (tileArea == null || tileArea.Length == 0) return (0f, 0f); int minX = int.MaxValue, maxX = int.MinValue; int minY = int.MaxValue, maxY = int.MinValue; foreach (var tile in tileArea) { if (tile.x < minX) minX = tile.x; if (tile.x > maxX) maxX = tile.x; if (tile.y < minY) minY = tile.y; if (tile.y > maxY) maxY = tile.y; } float xHalf = (maxX - minX + 1) * GridCoordinates.TILE_SIZE * 0.5f; float zHalf = (maxY - minY + 1) * GridCoordinates.TILE_SIZE * 0.5f; return (xHalf, zHalf); } /// /// Server-only: build and spawn one enemy. /// /// Wave buffs this enemy carries. Null/empty for a plain enemy. /// Pre-computed stats to spawn with, bypassing both the /// definition's values and ' spawn modifications. Used by /// split-on-death, whose minions are derived from the parent rather than the asset. private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot, System.Collections.Generic.IReadOnlyList abilities, EnemySpawnContext? contextOverride = null, float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false, Direction facing = Direction.South) { if (def.EnemyPrefab == null) { Debug.LogWarning($"[WaveManager] EnemyDefinition '{def.name}' has no EnemyPrefab assigned."); return; } // Resolve the stats this enemy will actually spawn with. Abilities get first say — // flight, health and size all have to be settled before the position is computed and // before EnemyHealth/EnemyMovement read them, since each is captured once. var context = contextOverride ?? EnemySpawnContext.FromDefinition(def); if (contextOverride == null && abilities != null) { for (int i = 0; i < abilities.Count; i++) abilities[i]?.ServerModifySpawn(ref context); } Vector3 spawnPos = GridCoordinates.GridToWorld(spawnerTile); if (xHalfExtent > 0f) spawnPos.x += Random.Range(-xHalfExtent, xHalfExtent); if (zHalfExtent > 0f) spawnPos.z += Random.Range(-zHalfExtent, zHalfExtent); // Flying enemies spawn elevated so they visually soar over towers. The // movement code preserves Y per-frame, so this height persists for the // enemy's whole flight. NetworkTransform replicates the raised position. if (context.IsFlying) spawnPos.y += context.FlightHeight; float yaw = facing switch { Direction.North => 0f, Direction.East => 90f, Direction.South => 180f, Direction.West => 270f, _ => 0f, }; var go = Instantiate( def.EnemyPrefab, spawnPos, Quaternion.Euler(0f, yaw, 0f)); // Scales the whole prefab hierarchy uniformly (split minions, size-changing buffs). // Relies on the enemy prefab's NetworkTransform syncing scale to clients — verify // that's enabled if a scaled spawn doesn't look smaller on remote peers. if (!Mathf.Approximately(context.VisualScale, 1f)) go.transform.localScale *= context.VisualScale; 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; } // Boss-ness comes from the run's position (this is the phase's boss encounter), not // from the wave asset — the same WaveDefinition could legally sit in both a cycle pool // and a boss pool, and it's only a boss when drawn as one. Split minions inherit it // via the context override path, which never sets the flag. bool isBoss = contextOverride == null && (RunState.Instance?.IsBossStage ?? false); health.InitializeServer(context.MaxHp, context.LivesCost, context.IsFlying, held, isBoss); movement.InitializeServer(context.MoveSpeed, spawnerTile, ownerSlot, context.IsFlying); // Optional — only prefabs with an EnemyAbility component can carry wave buffs. See // EnemyAbility's remarks for why on-death abilities aren't event-subscription driven. var ability = go.GetComponent(); if (ability != null) ability.InitializeServer(abilities, context); else if (abilities != null && abilities.Count > 0) Debug.LogWarning($"[WaveManager] '{def.EnemyPrefab.name}' has no EnemyAbility " + $"component, so this wave's {abilities.Count} buff(s) will not " + $"apply to it. Add the component to the prefab."); if (held) heldEnemies.Add(health); health.OnDied += HandleEnemyKilled; movement.OnZoneLeaked += HandleZoneLeak; movement.OnReachedGoal += HandleEnemyReachedGoal; activeEnemyCount++; go.GetComponent().Spawn(); } /// /// Server-only: spawns copies of at /// , continuing that tile's A* path onward rather than /// restarting from a wave spawner. Used by on-death abilities (e.g. /// SplitOnDeathAbilityDefinition) to spawn smaller enemies from a corpse's /// position. Reuses so split children get the same /// activeEnemyCount/event-wiring bookkeeping as any other spawn. /// /// /// Minions inherit nothing. They are spawned with an empty ability list, so a split /// can never chain into further splits or pick up any other buff the parent's wave carries. /// That is the whole termination argument for the recursion — one extra generation, always, /// however many cards the wave has collected. /// /// Their stats come entirely from , which the calling /// ability derives from the parent's own spawned values. Passing it as an override also /// bypasses spawn modification, which is correct: those modifiers already applied to the /// parent and are baked into what's being scaled here. /// public void ServerSpawnSplitEnemies(EnemyDefinition def, int count, Vector2Int atTile, PlayerSlot ownerSlot, float scatterRadius, EnemySpawnContext context) { if (!IsServer) return; for (int i = 0; i < count; i++) SpawnEnemy(def, atTile, ownerSlot, abilities: null, contextOverride: context, xHalfExtent: scatterRadius, zHalfExtent: scatterRadius); } // ----- 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(CurrentEncounterNumber); if (goldEntry != null) killReward = goldEntry.GoldPerEnemy; // Wave buffs get to alter the bounty before anything else sees it — this is what // reward-suppressing cards hook. Applied before the builder's gold-per-kill bonus so // a card that zeroes the bounty doesn't also cancel a player's own upgrade. var enemyAbility = health.GetComponent(); if (enemyAbility != null) killReward = enemyAbility.ServerModifyKillReward(killReward); // Award kill gold to the tower owner that landed the killing blow. The builder's // "gold per kill" effect (if granted) is queried live here rather than cached on // the killing tower — see BuilderUpgradeManager's "query, don't snapshot" note. PlayerSlot killerSlot = health.LastHitOwner; int totalReward = killReward; if (killerSlot != PlayerSlot.None && killReward > 0) { var pms = PlayerMatchState.GetForSlot(killerSlot); if (pms != null) { var gpk = BuilderUpgradeManager.GetForClient(pms.OwnerClientId) ?.GetEffectDefinition(BuilderEffectKind.GoldPerKill); totalReward = killReward + (gpk?.BonusGoldPerKill ?? 0); PlayerGoldManager.GetForClient(pms.OwnerClientId) ?.AwardGold(totalReward); } } // 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 (totalReward > 0) ShowGoldRewardClientRpc(health.transform.position, totalReward); // Resolve on-death abilities BEFORE unsubscribing/decrementing. A split-spawn's // activeEnemyCount++ (inside ServerSpawnSplitEnemies -> SpawnEnemy) must land before // this kill's activeEnemyCount-- below, or a split on a wave's last enemy could let // CheckWaveComplete see activeEnemyCount hit 0 and advance the wave prematurely. enemyAbility?.ServerInvokeOnDeath(health); 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); // Wave buffs that punish leaks beyond the life cost hook here, while the enemy is // still alive enough to be queried. Runs before the defeat check so a leak that ends // the match still applies its full penalty. movement.GetComponent()?.ServerInvokeReachedGoal(movement.OriginZone); 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); } [ClientRpc] private void ShowGoldLossClientRpc(Vector3 worldPos, int amount) { FloatingTextSpawner.Instance?.SpawnGoldLoss(worldPos, amount); } /// /// Server-only: show a "-N gold" popup on every peer. Exposed because enemy abilities run /// inside ScriptableObjects, which have no NetworkBehaviour of their own to send a /// ClientRpc from. /// public void ServerBroadcastGoldLoss(Vector3 worldPos, int amount) { if (!IsServer || amount <= 0) return; ShowGoldLossClientRpc(worldPos, 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; // 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(); // Capture which slot the upcoming vote will buff BEFORE advancing past it — the vote // is on "the wave you just beat", but by the time the inter-wave step runs the run has // already moved on. A boss clear is excluded: its phase's slots are about to be wiped, // so voting a buff onto them would be voting into a bin. var run = RunState.Instance; pendingVoteSlot = (run != null && !run.IsBossStage) ? run.CurrentUpgradeSlot : -1; Debug.Log($"[WaveManager] {run?.ProgressLabel ?? "Encounter"} complete."); AdvanceRun(); } // 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(CurrentEncounterNumber); 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); } } } } }