Follow-up pass on the 2.0 refactor, closing the gap between "waves are drawn at random" and "difficulty still lives in the enemy assets". - Enemy health is no longer authored per enemy type. New EnemyScalingConfig scales a per-zone HP *budget* by encounter number; per-enemy health is that budget divided by the wave's enemy count. Scaling the total rather than the per-enemy value keeps enemy count a texture knob (few tanks vs many swarmers) instead of a second, uncontrolled difficulty axis. EnemyDefinition.MaxHp becomes HpMultiplier, a deviation around 1.0. Speed stays archetype-only and unscaled -- escalating it compounds with health and invalidates tower balance mid-run. - GoldConfig entries no longer reference a WaveDefinition. Payout is a property of run position, not of which wave got drawn: WaveGoldEntry -> EncounterGoldEntry, Waves -> Encounters, keyed by global encounter number. Its inspector labels elements "Encounter N" and projects cumulative earnings. - The wave's voted buffs now show as icon badges in the top bar, read from the same RunState slot the spawn path uses so the display can't drift from what the enemies actually carry. Hover names the buff; unillustrated cards fall back to a lettered badge rather than vanishing. - Clearing a phase's boss destroys every built tower, unrefunded. Queued build jobs still refund -- those towers were never delivered. Runs with the field empty, so the walkability churn doesn't hit the re-path scheduler. - Fixed an RPC codegen break: [ClientRpc] requires a ClientRpc suffix, unlike the newer [Rpc(SendTo...)] style this file doesn't use. - Setup checklist reordered into dependency order; it previously asked for a RunDefinition two sections before creating one. Also carries the editor-side asset reorganisation into Definitions/RunDefinitions and the sprite move into Enemy/Player draft icon folders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1170 lines
55 KiB
C#
1170 lines
55 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;
|
|
|
|
[Tooltip("How enemy health scales across the run. Required for a real match; if unset, " +
|
|
"every enemy spawns at a flat fallback health and difficulty never progresses " +
|
|
"(designer-error indicator, not a supported runtime mode).")]
|
|
[SerializeField] private EnemyScalingConfig enemyScalingConfig;
|
|
|
|
// ----- 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:
|
|
{
|
|
// A phase boundary resets the board as well as the enemies: the maze players
|
|
// spent the phase building is torn down, unrefunded, so the new phase starts
|
|
// from bare ground. Runs here rather than inside RunState.ServerAdvance
|
|
// because it touches player structures, which the run has no business owning.
|
|
int destroyed = TowerPlacementManager.Instance?.ServerDestroyAllTowers() ?? 0;
|
|
NotifyTowersWipedClientRpc(destroyed);
|
|
|
|
Debug.Log($"[WaveManager] Boss down. New phase drawn: {run.ProgressLabel}. " +
|
|
$"Enemy upgrades wiped; {destroyed} tower(s) destroyed.");
|
|
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();
|
|
ResolveCurrentEncounterHealth(def);
|
|
|
|
// 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).");
|
|
}
|
|
|
|
// ----- Encounter health resolution ---------------------------------
|
|
|
|
// Health one enemy of this encounter gets BEFORE its type's HpMultiplier is applied:
|
|
// the encounter's budget divided across the wave's bodies. Resolved once per encounter
|
|
// for the same reason the ability list is — it's fixed for the whole wave, and the
|
|
// divisor would otherwise be recomputed for every spawn.
|
|
private float currentEncounterHpShare = FallbackEnemyHp;
|
|
|
|
// Used only when no EnemyScalingConfig is assigned. Enemies stay killable so the match
|
|
// is still playable, but difficulty never progresses — see the field's tooltip.
|
|
private const float FallbackEnemyHp = 100f;
|
|
|
|
/// <summary>
|
|
/// Server-only: work out the per-enemy health share for the encounter about to spawn.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Splits the encounter's total budget across the wave's enemy count, so a swarm and an
|
|
/// elite wave in the same slot present the same threat. The type's
|
|
/// <see cref="EnemyDefinition.HpMultiplier"/> is applied later, per enemy, so a wave
|
|
/// mixing types skews each one independently off the shared share.
|
|
/// </remarks>
|
|
private void ResolveCurrentEncounterHealth(WaveDefinition def)
|
|
{
|
|
if (enemyScalingConfig == null)
|
|
{
|
|
currentEncounterHpShare = FallbackEnemyHp;
|
|
Debug.LogWarning("[WaveManager] No EnemyScalingConfig assigned — enemies spawn at " +
|
|
$"a flat {FallbackEnemyHp} HP and difficulty will not progress.");
|
|
return;
|
|
}
|
|
|
|
var run = RunState.Instance;
|
|
int encounter = CurrentEncounterNumber;
|
|
bool isBoss = run?.IsBossStage ?? false;
|
|
|
|
int bodies = def != null ? def.TotalEnemyCount : 0;
|
|
if (bodies <= 0)
|
|
{
|
|
// An empty wave can't be divided into. Nothing will spawn from it either, so this
|
|
// only guards against a divide-by-zero on a misauthored asset.
|
|
currentEncounterHpShare = FallbackEnemyHp;
|
|
return;
|
|
}
|
|
|
|
currentEncounterHpShare =
|
|
enemyScalingConfig.ResolvePerEnemyHp(encounter, isBoss, bodies, archetypeMultiplier: 1f);
|
|
|
|
Debug.Log($"[WaveManager] Encounter {encounter}{(isBoss ? " (BOSS)" : "")}: " +
|
|
$"{enemyScalingConfig.GetEncounterHpBudget(encounter, isBoss):0} HP budget " +
|
|
$"across {bodies} enemies = {currentEncounterHpShare:0} each before type modifiers.");
|
|
}
|
|
|
|
// ----- 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, currentEncounterHpShare * def.HpMultiplier);
|
|
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 encounter — same value for
|
|
// every enemy in it regardless of EnemyDefinition type. Missing config or an
|
|
// unauthored encounter → 0 reward (gold flow disabled, designer-error mode).
|
|
int killReward = 0;
|
|
var goldEntry = goldConfig?.GetEncounterEntry(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);
|
|
}
|
|
|
|
// Announces the end-of-phase tower wipe on every peer. Sent rather than derived locally
|
|
// because the despawns arrive as individual NetworkObject removals with nothing tying them
|
|
// together — without this a client just watches its maze evaporate for no stated reason.
|
|
[ClientRpc]
|
|
private void NotifyTowersWipedClientRpc(int destroyedCount)
|
|
{
|
|
OnTowersWiped?.Invoke(destroyedCount);
|
|
}
|
|
|
|
// ----- Local-only notification events -----------------------------
|
|
|
|
/// <summary>
|
|
/// Fired on every peer when a phase ends and every built tower is destroyed. Argument is
|
|
/// how many towers went down. HUD subscribes to explain what just happened; audio and
|
|
/// camera-shake hooks can use it too.
|
|
/// </summary>
|
|
public static event System.Action<int> OnTowersWiped;
|
|
|
|
/// <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 encounter.
|
|
private void AwardWaveCompletionBonuses()
|
|
{
|
|
var entry = goldConfig?.GetEncounterEntry(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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|