Restructures the game around the cyclical run loop from Game Design Doc V2: 5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss. - New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the run as draggable weighted pools; RunState owns phase/cycle position, the drawn wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is gone -- it now only runs the encounter RunState points at. - New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public live-replicated ballots so the HUD can show who voted for what. - Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage ending early once every player has acted. - Enemy abilities inverted from per-instance random rolls to deterministic, stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink, No Bounty, Gold Theft, Double Up. - Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts an already-placed tower in place. - Boss encounters flag their enemies and drive a boss HP bar. - Player cap reduced to 3 via MatchRules.MaxPlayers. - GoldConfig is now keyed by global encounter number rather than wave index. Compiles clean; NOT yet verified in-engine. Editor wiring still required -- see Docs/2.0_Setup_Checklist.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1086 lines
50 KiB
C#
1086 lines
50 KiB
C#
// 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
|
|
{
|
|
/// <summary>
|
|
/// Server-authoritative encounter driver. Spawns enemies across all player zones, tracks
|
|
/// completion, awards kill gold, and manages the shared lives pool. <b>Which</b> encounter to
|
|
/// run comes from <see cref="RunState"/>; this class only runs it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Encounter lifecycle:</b>
|
|
/// <list type="bullet">
|
|
/// <item>When <see cref="MatchPhase.Playing"/> is entered, <see cref="StartRun"/> asks
|
|
/// <see cref="RunState.ServerBeginRun"/> to draw phase 1 and then runs its first wave.</item>
|
|
/// <item>Each encounter is: inter-wave step (draft, then the enemy-buff vote, then the
|
|
/// build countdown) → spawn → mop up. See <see cref="RunInterWave"/>.</item>
|
|
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone. All
|
|
/// spawn held, then release in 10% chunks on <c>ReleaseInterval</c>.</item>
|
|
/// <item>The encounter is complete only when every spawned enemy is dead or has leaked.
|
|
/// <see cref="RunState.ServerAdvance"/> then decides what comes next.</item>
|
|
/// <item>Final phase's boss dies → <see cref="MatchPhase.Victory"/>.</item>
|
|
/// <item>Lives drop to 0 → <see cref="MatchPhase.Defeat"/>.</item>
|
|
/// </list>
|
|
///
|
|
/// <b>Why the wave list moved out.</b> 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.
|
|
/// <see cref="RunState"/> owns it; this class asks it what to run.
|
|
///
|
|
/// <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. <c>GoldConfig</c> is indexed by
|
|
/// <see cref="RunState.GlobalEncounterNumber"/> — a run-long counter, not a per-cycle one, so
|
|
/// per-wave payouts keep climbing as the cycles repeat instead of resetting.
|
|
///
|
|
/// <b>Zone leak counts:</b> <see cref="zoneLeakCounts"/> is a <c>NetworkList</c>
|
|
/// indexed by <c>(int)PlayerSlot</c>. 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 a <c>RunDefinition</c> to the scene's <see cref="RunState"/>.</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("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<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);
|
|
|
|
// 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> interWaveStage =
|
|
new NetworkVariable<InterWaveStage>(
|
|
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<EnemyHealth> heldEnemies
|
|
= new System.Collections.Generic.List<EnemyHealth>();
|
|
|
|
// ----- 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 -------------------------------------------
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// Which inter-wave step the current countdown belongs to, or
|
|
/// <see cref="InterWaveStage.None"/> while a wave is spawning or being fought. Replicated;
|
|
/// safe on any peer. The HUD uses it to label the shared countdown.
|
|
/// </summary>
|
|
public InterWaveStage CurrentInterWaveStage => interWaveStage.Value;
|
|
|
|
|
|
/// <summary>
|
|
/// 1-based encounter number since the run began, or 0 before it starts. This is the key
|
|
/// <see cref="GoldConfig"/> is indexed by. Replicated via <see cref="RunState"/>, so it's
|
|
/// safe on any peer.
|
|
/// </summary>
|
|
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 ------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Server-only: begin the run. Draws phase 1 and starts its first encounter. Idempotent —
|
|
/// re-entering <see cref="MatchPhase.Playing"/> won't restart a run in progress.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: run whatever encounter <see cref="RunState"/> is currently pointing at.
|
|
/// </summary>
|
|
/// <param name="skipInterWave">Skip the draft/vote/build step and spawn immediately.
|
|
/// Used by the dev force-advance cheat.</param>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: the encounter is over — ask <see cref="RunState"/> what's next and either
|
|
/// start it or end the match.
|
|
/// </summary>
|
|
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 -----------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void ForceAdvanceToNextWave()
|
|
{
|
|
if (!IsServer || !runStarted) return;
|
|
ClearFieldAndStopEncounter();
|
|
AdvanceRun();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dev cheat: skip whole encounters until the run reaches this phase's boss. Useful for
|
|
/// testing boss content without playing fifteen waves. Server-only.
|
|
/// </summary>
|
|
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<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();
|
|
}
|
|
}
|
|
|
|
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 ---------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
///
|
|
/// <para>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.</para>
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ticks <see cref="prepCountdown"/> down from <paramref name="seconds"/> to zero,
|
|
/// exiting early once <paramref name="finishedEarly"/> returns true.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Network sync is throttled to ~10 Hz. <c>NetworkVariable</c> 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.
|
|
/// </remarks>
|
|
private IEnumerator TickCountdown(float seconds, System.Func<bool> 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<EnemyAbilityDefinition> currentWaveAbilities
|
|
= new System.Collections.Generic.List<EnemyAbilityDefinition>();
|
|
|
|
private readonly System.Collections.Generic.List<int> upgradeIdScratch
|
|
= new System.Collections.Generic.List<int>();
|
|
|
|
/// <summary>
|
|
/// Server-only: resolve the current wave slot's voted upgrades into the ability list every
|
|
/// enemy of this encounter will carry.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: build and spawn one enemy.
|
|
/// </summary>
|
|
/// <param name="abilities">Wave buffs this enemy carries. Null/empty for a plain enemy.</param>
|
|
/// <param name="contextOverride">Pre-computed stats to spawn with, bypassing both the
|
|
/// definition's values and <paramref name="abilities"/>' spawn modifications. Used by
|
|
/// split-on-death, whose minions are derived from the parent rather than the asset.</param>
|
|
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot,
|
|
System.Collections.Generic.IReadOnlyList<EnemyAbilityDefinition> 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<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;
|
|
}
|
|
|
|
// 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<EnemyAbility>();
|
|
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<NetworkObject>().Spawn();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: spawns <paramref name="count"/> copies of <paramref name="def"/> at
|
|
/// <paramref name="atTile"/>, continuing that tile's A* path onward rather than
|
|
/// restarting from a wave spawner. Used by on-death abilities (e.g.
|
|
/// <c>SplitOnDeathAbilityDefinition</c>) to spawn smaller enemies from a corpse's
|
|
/// position. Reuses <see cref="SpawnEnemy"/> so split children get the same
|
|
/// <c>activeEnemyCount</c>/event-wiring bookkeeping as any other spawn.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Minions inherit nothing.</b> 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.
|
|
///
|
|
/// <para>Their stats come entirely from <paramref name="context"/>, 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.</para>
|
|
/// </remarks>
|
|
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<EnemyAbility>();
|
|
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<GoldPerKillEffectDefinition>(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<EnemyAbility>()?.ServerInvokeReachedGoal(movement.OriginZone);
|
|
|
|
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);
|
|
}
|
|
|
|
[ClientRpc]
|
|
private void ShowGoldLossClientRpc(Vector3 worldPos, int amount)
|
|
{
|
|
FloatingTextSpawner.Instance?.SpawnGoldLoss(worldPos, amount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-only: show a "-N gold" popup on every peer. Exposed because enemy abilities run
|
|
/// inside ScriptableObjects, which have no <c>NetworkBehaviour</c> of their own to send a
|
|
/// ClientRpc from.
|
|
/// </summary>
|
|
public void ServerBroadcastGoldLoss(Vector3 worldPos, int amount)
|
|
{
|
|
if (!IsServer || amount <= 0) return;
|
|
ShowGoldLossClientRpc(worldPos, 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();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|