UnityTowerDefense/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
Matt F 16706a1ecf Scale difficulty by run position; wipe towers at phase end
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>
2026-07-31 00:32:33 -07:00

468 lines
21 KiB
C#

// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
namespace TD.Gameplay.Waves
{
/// <summary>
/// Outcome of advancing the run by one encounter. Returned by
/// <see cref="RunState.ServerAdvance"/> so <c>WaveManager</c> can react without
/// re-deriving the progression maths.
/// </summary>
public enum RunAdvance : byte
{
/// <summary>Moved to the next wave inside the current cycle.</summary>
NextWave = 0,
/// <summary>Moved to the first wave of a new cycle — the same waves come round again,
/// now carrying whatever upgrades players voted onto them.</summary>
NextCycle = 1,
/// <summary>Every cycle in the phase is done; the boss is up next.</summary>
BossReady = 2,
/// <summary>The boss died and a new phase was drawn. Upgrades were wiped.</summary>
NextPhase = 3,
/// <summary>The final phase's boss died — the run is won.</summary>
RunComplete = 4,
}
/// <summary>
/// Server-authoritative owner of run progression: which phase and cycle we're in, which waves
/// this phase drew, and which upgrades the players have voted onto each wave slot.
/// </summary>
/// <remarks>
/// <b>Split of duties with <c>WaveManager</c>.</b> This type is the run's <i>state</i> —
/// counters, draws, upgrade sets — and knows nothing about spawning. <c>WaveManager</c> is the
/// <i>driver</i>: it runs an encounter, and when the field is clear it calls
/// <see cref="ServerAdvance"/> and acts on the returned <see cref="RunAdvance"/>. Keeping them
/// apart is what stops WaveManager (already a large class) from also owning pool draws and
/// upgrade bookkeeping.
///
/// <para><b>Linear encounter index.</b> Rather than replicate cycle and wave separately and
/// keep them in sync, one <see cref="encounterInPhase"/> counter runs
/// <c>0 .. CyclesPerPhase*WavesPerCycle</c> inclusive, with the final value meaning "boss".
/// Cycle and wave slot are derived from it, so they cannot disagree.</para>
///
/// <para><b>Upgrades are keyed by wave slot, not enemy type.</b> The phase draw guarantees the
/// slots hold distinct enemy types, which makes slot and type interchangeable as a key — and
/// slot is the one that survives a phase change cleanly, since the next phase reassigns every
/// slot to a fresh enemy anyway.</para>
/// </remarks>
public class RunState : NetworkBehaviour
{
// ----- Singleton --------------------------------------------------
public static RunState Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("The run this match plays: phase pools, waves per cycle, cycles per phase. " +
"Required — without it no waves can be drawn.")]
[SerializeField] private RunDefinition runDefinition;
// ----- Networked state --------------------------------------------
private readonly NetworkVariable<int> phaseIndex = new NetworkVariable<int>(
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
// 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss".
private readonly NetworkVariable<int> encounterInPhase = new NetworkVariable<int>(
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
// Wave ids (indices into RunDefinition's flat index) drawn for this phase's slots.
// Exactly WavesPerCycle entries once a phase has been drawn.
private NetworkList<int> drawnWaveIds;
private readonly NetworkVariable<int> bossWaveId = new NetworkVariable<int>(
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
// Upgrades voted onto wave slots, packed as (slot << SlotShift) | optionId. A flat
// append-only list rather than a per-slot structure because NGO has no nested-collection
// support and the counts here are tiny (a handful per slot, per phase).
private NetworkList<int> waveUpgrades;
private const int SlotShift = 16;
private const int OptionMask = (1 << SlotShift) - 1;
// Per-slot count of buffs to apply automatically, skipping the next vote for that slot.
// Indexed by upgrade slot (0..WavesPerCycle, the last being the boss). Zero means a normal
// vote. Written by the vote-meta cards, consumed by the inter-wave step.
private NetworkList<int> autoApplyCounts;
/// <summary>
/// Fired on every peer whenever run progression or the drawn/upgraded wave set changes.
/// The HUD subscribes to relabel without polling.
/// </summary>
public event System.Action OnRunChanged;
// ----- Lifecycle ---------------------------------------------------
private void Awake()
{
drawnWaveIds = new NetworkList<int>();
waveUpgrades = new NetworkList<int>();
autoApplyCounts = new NetworkList<int>();
}
public override void OnNetworkSpawn()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[RunState] Duplicate RunState detected. Only one may exist per scene.");
return;
}
Instance = this;
// Freeze the wave index on EVERY peer (not just the server) before any id crosses
// the wire. Same asset, same declaration-order walk, same numbering.
runDefinition?.RebuildIndex();
// One auto-apply counter per upgrade slot, plus one for the boss slot.
if (IsServer)
{
for (int i = 0; i <= WavesPerCycle; i++) autoApplyCounts.Add(0);
}
phaseIndex.OnValueChanged += HandleIntChanged;
encounterInPhase.OnValueChanged += HandleIntChanged;
bossWaveId.OnValueChanged += HandleIntChanged;
drawnWaveIds.OnListChanged += HandleListChanged;
waveUpgrades.OnListChanged += HandleListChanged;
}
public override void OnNetworkDespawn()
{
phaseIndex.OnValueChanged -= HandleIntChanged;
encounterInPhase.OnValueChanged -= HandleIntChanged;
bossWaveId.OnValueChanged -= HandleIntChanged;
drawnWaveIds.OnListChanged -= HandleListChanged;
waveUpgrades.OnListChanged -= HandleListChanged;
if (Instance == this) Instance = null;
}
private void HandleIntChanged(int _, int __) => OnRunChanged?.Invoke();
private void HandleListChanged(NetworkListEvent<int> _) => OnRunChanged?.Invoke();
// ----- Structure accessors -----------------------------------------
/// <summary>The run asset driving this match, or null if unassigned (designer error).</summary>
public RunDefinition Definition => runDefinition;
public int WavesPerCycle => runDefinition != null ? Mathf.Max(1, runDefinition.WavesPerCycle) : 5;
public int CyclesPerPhase => runDefinition != null ? Mathf.Max(1, runDefinition.CyclesPerPhase) : 3;
public int PhaseCount => runDefinition?.Phases?.Length ?? 0;
/// <summary>Encounters in one phase, counting its boss.</summary>
public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1;
// ----- Progression accessors ---------------------------------------
/// <summary>Zero-based phase index.</summary>
public int PhaseIndex => phaseIndex.Value;
/// <summary>
/// The phase currently being played, or null if the run isn't set up. Read by the vote
/// generator to find which enemy-buff cards this phase allows.
/// </summary>
public PhaseDefinition CurrentPhase
{
get
{
var phases = runDefinition?.Phases;
if (phases == null) return null;
int i = phaseIndex.Value;
return (i >= 0 && i < phases.Length) ? phases[i] : null;
}
}
/// <summary>Zero-based cycle index within the current phase. Reads as the last cycle
/// while the boss is up (the boss belongs to the phase, not to a fourth cycle).</summary>
public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1);
/// <summary>Zero-based wave slot within the current cycle. Meaningless while
/// <see cref="IsBossStage"/> is true.</summary>
public int WaveSlot => encounterInPhase.Value % WavesPerCycle;
/// <summary>True when the next/current encounter is this phase's boss.</summary>
public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle;
/// <summary>
/// 1-based count of encounters since the run began, including bosses. This is the key
/// <c>GoldConfig.Encounters</c> is indexed by, so payouts scale monotonically across the
/// whole run rather than resetting every cycle.
/// </summary>
public int GlobalEncounterNumber
=> phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1;
/// <summary>Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5".</summary>
public string ProgressLabel
=> IsBossStage
? $"Phase {phaseIndex.Value + 1} · BOSS"
: $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " +
$"Wave {WaveSlot + 1}/{WavesPerCycle}";
// ----- Wave accessors ----------------------------------------------
/// <summary>The wave asset drawn for <paramref name="slot"/>, or null if not drawn yet.</summary>
public WaveDefinition GetSlotWave(int slot)
{
if (runDefinition == null) return null;
if (slot < 0 || slot >= drawnWaveIds.Count) return null;
return runDefinition.GetWave(drawnWaveIds[slot]);
}
/// <summary>This phase's boss wave, or null if not drawn yet.</summary>
public WaveDefinition BossWave
=> runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null;
/// <summary>The wave the run is currently pointing at — boss or cycle wave.</summary>
public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot);
/// <summary>
/// The slot key upgrades attach to for the current encounter. The boss occupies the slot
/// one past the last cycle wave, so boss upgrades (if ever voted) don't collide with it.
/// </summary>
public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot;
// ----- Upgrade accessors -------------------------------------------
/// <summary>
/// Collects the upgrade option ids voted onto <paramref name="slot"/>, in the order they
/// were applied. Safe on any peer.
/// </summary>
public void GetUpgradesForSlot(int slot, List<int> into)
{
into.Clear();
for (int i = 0; i < waveUpgrades.Count; i++)
{
int packed = waveUpgrades[i];
if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask);
}
}
/// <summary>How many upgrades are stacked on <paramref name="slot"/>.</summary>
public int CountUpgradesForSlot(int slot)
{
int n = 0;
for (int i = 0; i < waveUpgrades.Count; i++)
if ((waveUpgrades[i] >> SlotShift) == slot) n++;
return n;
}
/// <summary>True if <paramref name="slot"/> already carries <paramref name="optionId"/>.
/// Used to keep the vote from offering a buff a wave already has.</summary>
public bool SlotHasUpgrade(int slot, int optionId)
{
int packed = (slot << SlotShift) | (optionId & OptionMask);
for (int i = 0; i < waveUpgrades.Count; i++)
if (waveUpgrades[i] == packed) return true;
return false;
}
/// <summary>Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates.</summary>
public void ServerAddUpgrade(int slot, int optionId)
{
if (!IsServer) return;
if (slot < 0 || optionId < 0) return;
if (SlotHasUpgrade(slot, optionId)) return;
waveUpgrades.Add((slot << SlotShift) | (optionId & OptionMask));
}
// ----- Auto-apply (vote-meta cards) --------------------------------
/// <summary>
/// How many buffs <paramref name="slot"/> will take automatically instead of holding its
/// next vote. Zero means a normal vote.
/// </summary>
public int GetAutoApplyCount(int slot)
=> (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0;
/// <summary>
/// Server-only: arm <paramref name="slot"/> to auto-take <paramref name="count"/> buffs in
/// place of its next vote. Takes the larger value if already armed, so two vote-meta cards
/// on one slot don't cancel each other down.
/// </summary>
public void ServerSetAutoApply(int slot, int count)
{
if (!IsServer) return;
if (slot < 0 || slot >= autoApplyCounts.Count || count <= 0) return;
autoApplyCounts[slot] = Mathf.Max(autoApplyCounts[slot], count);
}
/// <summary>Server-only: disarm <paramref name="slot"/> after its auto-apply has fired.</summary>
public void ServerConsumeAutoApply(int slot)
{
if (!IsServer) return;
if (slot < 0 || slot >= autoApplyCounts.Count) return;
autoApplyCounts[slot] = 0;
}
private void ServerClearAutoApply()
{
for (int i = 0; i < autoApplyCounts.Count; i++) autoApplyCounts[i] = 0;
}
// ----- Server: run control ------------------------------------------
/// <summary>
/// Server-only: start the run at phase 0 and draw its waves. Returns false if the run
/// definition is missing or unplayable — the caller should treat that as fatal rather
/// than limping along with an empty schedule.
/// </summary>
public bool ServerBeginRun()
{
if (!IsServer) return false;
if (runDefinition == null)
{
Debug.LogError("[RunState] No RunDefinition assigned — cannot start a run.");
return false;
}
if (!runDefinition.Validate(out string report))
{
Debug.LogError($"[RunState] RunDefinition '{runDefinition.name}' is not playable:\n{report}");
return false;
}
phaseIndex.Value = 0;
encounterInPhase.Value = 0;
waveUpgrades.Clear();
ServerClearAutoApply();
return ServerDrawPhase(0);
}
/// <summary>
/// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the
/// boss falls. See <see cref="RunAdvance"/> for what the caller should do with each result.
/// </summary>
public RunAdvance ServerAdvance()
{
if (!IsServer) return RunAdvance.NextWave;
// Boss just died: the phase is over. Either draw the next one or win the run.
if (IsBossStage)
{
int next = phaseIndex.Value + 1;
if (next >= PhaseCount) return RunAdvance.RunComplete;
phaseIndex.Value = next;
encounterInPhase.Value = 0;
// A new phase means five brand-new waves, so every upgrade voted onto the old
// slots is meaningless — wiped by design, not by omission. Armed auto-applies go
// with them: they were promises made about waves that no longer exist.
waveUpgrades.Clear();
ServerClearAutoApply();
ServerDrawPhase(next);
return RunAdvance.NextPhase;
}
int previousCycle = CycleIndex;
encounterInPhase.Value++;
if (IsBossStage) return RunAdvance.BossReady;
if (CycleIndex != previousCycle) return RunAdvance.NextCycle;
return RunAdvance.NextWave;
}
// ----- Server: phase draw -------------------------------------------
// Scratch list reused across draws; server is single-threaded so sharing is safe.
private readonly List<WavePoolEntry> candidateScratch = new List<WavePoolEntry>();
/// <summary>
/// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative
/// weight <b>without replacement and with distinct enemy types</b>; the boss is a plain
/// weighted draw.
/// </summary>
private bool ServerDrawPhase(int index)
{
if (!IsServer) return false;
var phases = runDefinition?.Phases;
if (phases == null || index < 0 || index >= phases.Length || phases[index] == null)
{
Debug.LogError($"[RunState] Phase {index + 1} is missing — cannot draw waves.");
return false;
}
var phase = phases[index];
int needed = WavesPerCycle;
phase.CollectWaveCandidates(candidateScratch);
drawnWaveIds.Clear();
for (int drawn = 0; drawn < needed; drawn++)
{
var pick = DrawWeighted(candidateScratch);
if (pick == null)
{
Debug.LogError(
$"[RunState] {phase.Label}: ran out of candidates after {drawn} of " +
$"{needed} wave(s). The phase needs {needed} waves with DISTINCT enemy " +
$"types — check the RunDefinition inspector's phase-capacity readout.");
return false;
}
if (!runDefinition.TryGetWaveId(pick.Wave, out int waveId))
{
Debug.LogError($"[RunState] Wave '{pick.Wave.name}' is not in the run's wave " +
$"index. This should be impossible — the index is built from " +
$"the same pools. Skipping.");
candidateScratch.Remove(pick);
drawn--;
continue;
}
drawnWaveIds.Add(waveId);
// Retire every remaining candidate sharing this enemy type, so distinctness is
// enforced by shrinking the pool rather than by reject-and-retry.
var type = pick.Wave.PrimaryEnemyType;
candidateScratch.RemoveAll(
e => e.Wave == pick.Wave || (type != null && e.Wave.PrimaryEnemyType == type));
}
// Boss draw — independent pool, no distinctness constraint.
phase.CollectBossCandidates(candidateScratch);
var boss = DrawWeighted(candidateScratch);
if (boss == null || !runDefinition.TryGetWaveId(boss.Wave, out int bossId))
{
Debug.LogError($"[RunState] {phase.Label}: no boss could be drawn.");
bossWaveId.Value = -1;
return false;
}
bossWaveId.Value = bossId;
Debug.Log($"[RunState] Drew {phase.Label}: {needed} wave(s) + boss " +
$"'{boss.Wave.name}'.");
return true;
}
// Relative-weight draw. Weights are normalized against the candidate set's total, so a
// pool of three 1.0 entries is uniform and a 0.5 entry is half as likely as a 1.0 one.
// Zero-weight entries never reach here — PhaseDefinition filters them during collection.
private static WavePoolEntry DrawWeighted(List<WavePoolEntry> candidates)
{
if (candidates == null || candidates.Count == 0) return null;
float total = 0f;
for (int i = 0; i < candidates.Count; i++)
total += Mathf.Max(0f, candidates[i].Weight);
if (total <= 0f) return null;
float roll = Random.value * total;
for (int i = 0; i < candidates.Count; i++)
{
roll -= Mathf.Max(0f, candidates[i].Weight);
if (roll <= 0f) return candidates[i];
}
return candidates[candidates.Count - 1]; // float drift fallback
}
}
}