First pass at full refactor to 2.0 design

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>
This commit is contained in:
Matt F 2026-07-30 17:45:11 -07:00
parent 7e5c3a8279
commit 4892d7253d
64 changed files with 4023 additions and 344 deletions

View file

@ -6,44 +6,54 @@ 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 wave controller. Spawns enemies across all player zones,
/// tracks wave completion, awards kill gold, and manages the shared lives pool.
/// 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>Wave lifecycle:</b>
/// <b>Encounter lifecycle:</b>
/// <list type="bullet">
/// <item>When <see cref="MatchPhase.Playing"/> is entered,
/// <see cref="StartNextWave"/> advances <see cref="MatchState.CurrentWave"/>
/// immediately (so the HUD shows the wave number during prep), then waits
/// <see cref="WaveDefinition.PrepTime"/> before spawning.</item>
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone,
/// one zone per frame-group, with <c>SpawnInterval</c> seconds between
/// individual enemies in the group.</item>
/// <item>After all entries are spawned, the wave is considered complete only when
/// every active enemy is either killed or has reached the goal.</item>
/// <item>All waves exhausted → <see cref="MatchPhase.Victory"/>.</item>
/// <item>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.
/// 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> (indices 08). It is incremented when an enemy
/// 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 <see cref="waveDefinitions"/> in order (Wave 1 at index 0).</item>
/// <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>
@ -55,12 +65,18 @@ namespace TD.Gameplay
// ----- Inspector --------------------------------------------------
[Tooltip("Wave definitions in order. Index 0 = Wave 1.")]
[SerializeField] private WaveDefinition[] waveDefinitions;
[Tooltip("Shared lives pool at the start of a match.")]
[SerializeField] private int startingLives = 20;
[Tooltip("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 " +
@ -95,14 +111,27 @@ namespace TD.Gameplay
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 int currentWaveIndex = -1; // -1 = not yet started
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>();
@ -178,6 +207,11 @@ namespace TD.Gameplay
{
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
@ -187,7 +221,7 @@ namespace TD.Gameplay
}
if (ms.Phase == MatchPhase.Playing)
StartNextWave();
StartRun();
}
public override void OnNetworkDespawn()
@ -200,13 +234,6 @@ namespace TD.Gameplay
// ----- Public accessors -------------------------------------------
/// <summary>
/// Total number of waves in this match. Same on every peer because
/// <see cref="waveDefinitions"/> is a serialized prefab field, identical
/// on host and clients. Returns 0 if the array is unassigned.
/// </summary>
public int TotalWaves => waveDefinitions?.Length ?? 0;
/// <summary>
/// Number of times enemies have leaked out of the given player's zone over the
/// entire match. Replicated — safe to call on any peer.
@ -230,34 +257,82 @@ namespace TD.Gameplay
/// </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 && currentWaveIndex < 0)
StartNextWave();
if (next == MatchPhase.Playing) StartRun();
}
// ----- Wave coroutine ---------------------------------------------
// ----- Run control ------------------------------------------------
private void StartNextWave(bool skipPrep = false)
/// <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()
{
currentWaveIndex++;
if (!IsServer || runStarted) return;
if (waveDefinitions == null || currentWaveIndex >= waveDefinitions.Length)
var run = RunState.Instance;
if (run == null)
{
Debug.Log("[WaveManager] All waves complete. Victory.");
MatchState.Instance?.SetPhase(MatchPhase.Victory);
Debug.LogError("[WaveManager] No RunState in the scene — no waves can run. " +
"Add a RunState object and assign it a RunDefinition.");
return;
}
// Advance the replicated wave counter at the START of prep so the HUD
// shows the upcoming wave number during the countdown.
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
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;
}
// Reset per-wave bookkeeping for the wave that's about to begin:
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.
@ -272,23 +347,91 @@ namespace TD.Gameplay
activeEnemyCount = 0;
spawningComplete = false;
activeWaveCoroutine = StartCoroutine(
RunWave(waveDefinitions[currentWaveIndex], skipPrep));
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 wave (despawn any remaining
/// enemies, kill the prep timer) and start the next wave immediately.
/// Server-only — silently no-ops on clients. Safe to call during prep,
/// mid-spawn, or while enemies are alive.
/// 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) return;
if (!IsServer || !runStarted) return;
ClearFieldAndStopEncounter();
AdvanceRun();
}
// Stop the current wave's coroutine (cancels prep timer + remaining spawns).
/// <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);
@ -316,59 +459,40 @@ namespace TD.Gameplay
}
}
// Reset bookkeeping and start the next wave's RunWave coroutine,
// skipping the prep timer so spawning starts immediately.
activeEnemyCount = 0;
spawningComplete = false;
heldEnemies.Clear();
// Force-advancing interrupts the prep phase, so resolve any drafts the players
// had open before skipping ahead.
// 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();
StartNextWave(skipPrep: true);
interWaveStage.Value = InterWaveStage.None;
prepCountdown.Value = 0f;
}
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
// ----- Encounter coroutine ----------------------------------------
private IEnumerator RunEncounter(WaveDefinition def, bool skipInterWave = false)
{
// Prep phase — players build while the countdown ticks. Skipped when
// a dev cheat forces the wave to start immediately. We tick the
// replicated prepCountdown each frame so every HUD can render the
// remaining time consistently. Server is the only writer; clients
// observe the value via NetworkVariable replication.
if (!skipPrep)
{
// Open a draft for every player at the start of the build phase. They pick
// during prep; any unpicked draft is auto-resolved when the timer expires.
DraftService.Instance?.ServerOfferToAll();
// 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);
prepCountdown.Value = def.PrepTime;
float remaining = def.PrepTime;
// Throttle network sync to ~10 Hz. NetworkVariable replicates on every
// mutation; at 60 fps we'd send ~600 deltas per 10s prep purely to
// animate text that only changes once per second on the HUD. 0.1s
// gives a smooth-enough fall while keeping bandwidth minimal.
const float NetworkSyncInterval = 0.1f;
float nextSync = def.PrepTime - NetworkSyncInterval;
while (remaining > 0f)
{
yield return null;
remaining = Mathf.Max(0f, remaining - Time.deltaTime);
if (remaining <= nextSync || remaining <= 0f)
{
prepCountdown.Value = remaining;
nextSync = remaining - NetworkSyncInterval;
}
}
// Prep timer expired — auto-resolve any draft the player didn't pick so
// the free reward isn't wasted.
DraftService.Instance?.ServerAutoResolveAll();
}
// Ensure the countdown reads zero entering the spawn phase, regardless of
// whether prep was skipped or just expired.
// 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)
{
@ -405,6 +529,145 @@ namespace TD.Gameplay
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)
@ -430,7 +693,9 @@ namespace TD.Gameplay
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
var spawner = zone.Spawners[0];
(float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea);
SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held, spawner.Facing);
SpawnEnemy(def, spawner.TilePosition, zone.Owner, currentWaveAbilities,
xHalfExtent: xHalf, zHalfExtent: zHalf, held: held,
facing: spawner.Facing);
}
}
@ -455,10 +720,18 @@ namespace TD.Gameplay
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, bool canRollAbility = true,
float hpMultiplier = 1f, float visualScale = 1f)
Direction facing = Direction.South)
{
if (def.EnemyPrefab == null)
{
@ -466,6 +739,16 @@ namespace TD.Gameplay
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);
@ -475,8 +758,8 @@ namespace TD.Gameplay
// 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 (def.IsFlying)
spawnPos.y += def.FlightHeight;
if (context.IsFlying)
spawnPos.y += context.FlightHeight;
float yaw = facing switch
{
@ -492,11 +775,11 @@ namespace TD.Gameplay
spawnPos,
Quaternion.Euler(0f, yaw, 0f));
// Scales the whole prefab hierarchy uniformly (used for split-on-death minions).
// 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 (visualScale != 1f)
go.transform.localScale *= visualScale;
if (!Mathf.Approximately(context.VisualScale, 1f))
go.transform.localScale *= context.VisualScale;
var health = go.GetComponent<EnemyHealth>();
var movement = go.GetComponent<EnemyMovement>();
@ -509,16 +792,24 @@ namespace TD.Gameplay
return;
}
health.InitializeServer(def.MaxHp * hpMultiplier, def.LivesCost, def.IsFlying, held);
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying);
// 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);
// Optional — only prefabs with an EnemyAbility component roll an ability. See
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.
// canRollAbility is false for split-spawned minions (see ServerSpawnSplitEnemies) so
// they can never chain into further splits or pick up any other ability.
var ability = go.GetComponent<EnemyAbility>();
if (ability != null)
ability.InitializeServer(canRollAbility ? EnemyAbilityPool.Instance?.RollRandom() : 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);
@ -540,18 +831,26 @@ namespace TD.Gameplay
/// <c>activeEnemyCount</c>/event-wiring bookkeeping as any other spawn.
/// </summary>
/// <remarks>
/// Split-spawned minions never roll an ability of their own — they're always plain,
/// so a split can't chain into further splits (or any other ability) regardless of
/// which EnemyDefinition/prefab is used for <paramref name="def"/>.
/// <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,
float hpMultiplier = 1f, float visualScale = 1f)
EnemySpawnContext context)
{
if (!IsServer) return;
for (int i = 0; i < count; i++)
SpawnEnemy(def, atTile, ownerSlot, scatterRadius, scatterRadius, canRollAbility: false,
hpMultiplier: hpMultiplier, visualScale: visualScale);
SpawnEnemy(def, atTile, ownerSlot,
abilities: null,
contextOverride: context,
xHalfExtent: scatterRadius, zHalfExtent: scatterRadius);
}
// ----- Enemy event handlers (server-only) -------------------------
@ -562,9 +861,16 @@ namespace TD.Gameplay
// every enemy in the wave regardless of EnemyDefinition type. Missing config
// or out-of-range wave → 0 reward (gold flow disabled, designer-error mode).
int killReward = 0;
var goldEntry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
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.
@ -591,12 +897,11 @@ namespace TD.Gameplay
if (totalReward > 0)
ShowGoldRewardClientRpc(health.transform.position, totalReward);
// Resolve any on-death ability BEFORE unsubscribing/decrementing. A split-spawn's
// 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.
var ability = health.GetComponent<EnemyAbility>();
ability?.Definition?.ServerOnDeath(ability, health);
enemyAbility?.ServerInvokeOnDeath(health);
UnsubscribeEnemy(health);
DecrementAndCheckComplete();
@ -624,6 +929,11 @@ namespace TD.Gameplay
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);
@ -656,6 +966,23 @@ namespace TD.Gameplay
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>
@ -704,8 +1031,15 @@ namespace TD.Gameplay
// no-leak bonus only if the player's waveLeakCounts entry is exactly 0.
AwardWaveCompletionBonuses();
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
StartNextWave();
// 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
@ -714,7 +1048,7 @@ namespace TD.Gameplay
// Skipped silently if no goldConfig or no entry for this wave.
private void AwardWaveCompletionBonuses()
{
var entry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
var entry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
if (entry == null) return;
int completionBonus = entry.CompletionBonus;