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:
parent
7e5c3a8279
commit
4892d7253d
64 changed files with 4023 additions and 344 deletions
|
|
@ -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 0–8). 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue