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

@ -0,0 +1,102 @@
// Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
using System.Collections.Generic;
using UnityEngine;
namespace TD.Gameplay.Waves
{
/// <summary>
/// One phase of a run: the pool its cycle waves are drawn from, and the pool its boss is
/// drawn from. A phase runs the same drawn wave set for every one of its cycles, then ends
/// in a boss.
/// </summary>
/// <remarks>
/// <b>Groups, not waves.</b> Both pools are lists of <see cref="WaveGroup"/> assets rather
/// than flat wave lists, so re-balancing a run means dragging a group between phases instead
/// of re-authoring individual entries. Listing the same group in two phases is legal — the
/// draws are independent.
///
/// <para><b>Draw contract.</b> <c>RunState</c> draws <c>WavesPerCycle</c> waves from
/// <see cref="WaveGroups"/> with <b>distinct enemy types</b>, because enemy upgrades are keyed
/// to a wave slot and two slots sharing an enemy type would make "which wave did this buff
/// apply to" ambiguous. That makes the real authoring requirement stricter than the entry
/// count: a phase needs at least <c>WavesPerCycle</c> distinct enemy types across its groups,
/// not just that many waves. <see cref="CountDistinctEnemyTypes"/> exists so the draw can
/// fail loudly at match start rather than mid-run.</para>
/// </remarks>
[CreateAssetMenu(fileName = "PhaseDefinition", menuName = "TD/Run/Phase Definition", order = 11)]
public class PhaseDefinition : ScriptableObject
{
[Tooltip("Designer-facing name for this phase, shown in validation messages. " +
"Falls back to the asset name when empty.")]
public string DisplayName;
[Tooltip("Groups this phase's cycle waves are drawn from. All groups are pooled " +
"together for the draw; group membership is an authoring convenience, not a " +
"draw boundary.")]
public WaveGroup[] WaveGroups;
[Tooltip("Groups this phase's boss is drawn from. A boss is an ordinary WaveDefinition " +
"that happens to spawn a single powerful enemy.")]
public WaveGroup[] BossGroups;
[Tooltip("Enemy-buff cards votable during this phase. Gating is per PHASE, not per cycle " +
"— every card listed here can be voted on from this phase's very first wave. " +
"Cards must also appear in the scene's EnemyUpgradePool to be offerable.")]
public EnemyUpgrades.EnemyUpgradeGroup[] EnemyUpgradeGroups;
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
/// <summary>
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
/// <paramref name="into"/>. Zero-weight entries are filtered here (not at draw time) so
/// "weight 0 means it can never appear" is enforced in exactly one place.
/// </summary>
public static void CollectCandidates(WaveGroup[] groups, List<WavePoolEntry> into)
{
into.Clear();
if (groups == null) return;
for (int g = 0; g < groups.Length; g++)
{
var group = groups[g];
if (group?.Waves == null) continue;
for (int i = 0; i < group.Waves.Length; i++)
{
var entry = group.Waves[i];
if (entry?.Wave == null) continue;
if (entry.Weight <= 0f) continue;
into.Add(entry);
}
}
}
/// <summary>Collects this phase's cycle-wave candidates.</summary>
public void CollectWaveCandidates(List<WavePoolEntry> into)
=> CollectCandidates(WaveGroups, into);
/// <summary>Collects this phase's boss candidates.</summary>
public void CollectBossCandidates(List<WavePoolEntry> into)
=> CollectCandidates(BossGroups, into);
/// <summary>
/// How many distinct enemy types are reachable across this phase's wave groups. The
/// upper bound on how many wave slots this phase can fill, since the draw requires
/// distinct types.
/// </summary>
public int CountDistinctEnemyTypes()
{
var candidates = new List<WavePoolEntry>();
CollectWaveCandidates(candidates);
var seen = new HashSet<EnemyDefinition>();
foreach (var entry in candidates)
{
var type = entry.Wave.PrimaryEnemyType;
if (type != null) seen.Add(type);
}
return seen.Count;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f73fcf0261654d44a6d0939bdc9b88f

View file

@ -0,0 +1,209 @@
// Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace TD.Gameplay.Waves
{
/// <summary>
/// The whole run: how many waves make a cycle, how many cycles make a phase, and the
/// per-phase pools everything is drawn from. One asset assigned to <c>RunState</c> in the
/// match scene; replaces <c>WaveManager</c>'s old flat, hand-ordered wave array.
/// </summary>
/// <remarks>
/// <b>Stable wave ids.</b> Waves are replicated by index into a flat list this asset builds
/// by walking its phases, groups, and entries in declaration order (see
/// <see cref="RebuildIndex"/>). Because every peer loads the identical asset, the same walk
/// produces the same ids everywhere — the same trick the tower catalog and
/// <c>DraftPool</c> already use, and the reason the server can say "wave slot 2 is wave #7"
/// in a single int.
///
/// <para><b>The index is frozen for the match.</b> <c>RunState</c> builds it once on spawn
/// and never rebuilds, so editing this asset mid-play-session cannot renumber ids out from
/// under in-flight replication. Edit-time changes invalidate the cache normally.</para>
/// </remarks>
[CreateAssetMenu(fileName = "RunDefinition", menuName = "TD/Run/Run Definition", order = 12)]
public class RunDefinition : ScriptableObject
{
[Header("Structure")]
[Tooltip("Waves in one cycle. The phase draws this many distinct-enemy-type waves and " +
"replays them every cycle.")]
[Min(1)]
public int WavesPerCycle = 5;
[Tooltip("Cycles before the phase's boss appears. The same drawn waves repeat each " +
"cycle, carrying whatever upgrades players voted onto them.")]
[Min(1)]
public int CyclesPerPhase = 3;
[Tooltip("Phases in order. Beating the last phase's boss wins the run. The MVP ships " +
"one phase; the full design is three.")]
public PhaseDefinition[] Phases;
// ----- Flat wave index (network identity) --------------------------
// Built lazily and cached. Null means "not built yet". Rebuilt only on explicit request
// or on edit-time validation — never spontaneously during play, since ids in flight
// would be invalidated.
private List<WaveDefinition> waveIndex;
private Dictionary<WaveDefinition, int> waveIds;
/// <summary>Number of distinct waves reachable anywhere in this run.</summary>
public int WaveCount
{
get { EnsureIndex(); return waveIndex.Count; }
}
/// <summary>Resolves a replicated wave id back to its asset, or null if out of range.</summary>
public WaveDefinition GetWave(int waveId)
{
EnsureIndex();
return (waveId >= 0 && waveId < waveIndex.Count) ? waveIndex[waveId] : null;
}
/// <summary>Resolves a wave asset to its replicated id.</summary>
public bool TryGetWaveId(WaveDefinition wave, out int waveId)
{
EnsureIndex();
if (wave != null && waveIds.TryGetValue(wave, out waveId)) return true;
waveId = -1;
return false;
}
/// <summary>
/// Rebuilds the flat wave index from the current asset contents. Called once by
/// <c>RunState</c> on every peer at match start so ids agree, and by the editor when
/// the asset changes.
/// </summary>
public void RebuildIndex()
{
waveIndex ??= new List<WaveDefinition>();
waveIds ??= new Dictionary<WaveDefinition, int>();
waveIndex.Clear();
waveIds.Clear();
if (Phases == null) return;
// Declaration-order walk. Every peer runs this over the same asset, so the
// resulting ids match without any negotiation. Zero-weight entries are indexed
// too: they can't be drawn, but indexing them keeps ids stable if a designer
// re-weights between sessions.
foreach (var phase in Phases)
{
if (phase == null) continue;
IndexGroups(phase.WaveGroups);
IndexGroups(phase.BossGroups);
}
}
private void IndexGroups(WaveGroup[] groups)
{
if (groups == null) return;
foreach (var group in groups)
{
if (group?.Waves == null) continue;
foreach (var entry in group.Waves)
{
if (entry?.Wave == null) continue;
if (waveIds.ContainsKey(entry.Wave)) continue; // first occurrence wins
waveIds[entry.Wave] = waveIndex.Count;
waveIndex.Add(entry.Wave);
}
}
}
private void EnsureIndex()
{
if (waveIndex == null || waveIds == null) RebuildIndex();
}
// ----- Validation --------------------------------------------------
/// <summary>
/// Checks that this run can actually be played: at least one phase, every phase able to
/// fill <see cref="WavesPerCycle"/> slots with distinct enemy types, and every phase able
/// to produce a boss. Returns true when the run is playable;
/// <paramref name="report"/> carries the problems either way (it may hold warnings even
/// on success).
/// </summary>
/// <remarks>
/// The distinct-enemy-type requirement is the one that bites in practice: a phase can
/// hold twenty waves and still be unplayable if they all point at the same three enemies.
/// Checking it up front turns that into a startup error instead of a draw that silently
/// runs short a wave.
/// </remarks>
public bool Validate(out string report)
{
var sb = new StringBuilder();
bool ok = true;
if (Phases == null || Phases.Length == 0)
{
sb.AppendLine("• No phases assigned — the run has nothing to play.");
report = sb.ToString();
return false;
}
var candidates = new List<WavePoolEntry>();
for (int i = 0; i < Phases.Length; i++)
{
var phase = Phases[i];
string label = phase != null ? phase.Label : $"Phase {i + 1}";
if (phase == null)
{
sb.AppendLine($"• {label}: slot is empty.");
ok = false;
continue;
}
int distinctTypes = phase.CountDistinctEnemyTypes();
if (distinctTypes < WavesPerCycle)
{
sb.AppendLine($"• {label}: needs {WavesPerCycle} distinct enemy types to fill " +
$"a cycle but only {distinctTypes} are reachable. Add waves " +
$"using other enemies, or lower Waves Per Cycle.");
ok = false;
}
phase.CollectBossCandidates(candidates);
if (candidates.Count == 0)
{
sb.AppendLine($"• {label}: no boss candidates (every boss group is empty, " +
$"unassigned, or entirely zero-weight).");
ok = false;
}
// Warnings — these don't block a run, they just play oddly.
phase.CollectWaveCandidates(candidates);
foreach (var entry in candidates)
{
if (entry.Wave.PrimaryEnemyType == null)
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' has no usable " +
$"enemy entries and can never be drawn meaningfully.");
else if (!entry.Wave.HasSingleEnemyType)
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' mixes enemy " +
$"types. Upgrades and HUD labels will use " +
$"'{entry.Wave.PrimaryEnemyType.name}' only.");
}
}
report = sb.Length > 0 ? sb.ToString() : "Run structure OK.";
return ok;
}
#if UNITY_EDITOR
private void OnValidate()
{
// Only invalidate outside play mode. Clearing the cache mid-match would renumber
// wave ids while replicated slot references are still pointing at the old numbering.
if (!Application.isPlaying)
{
waveIndex = null;
waveIds = null;
}
}
#endif
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2b73bb9876b7fdb4480b13ff6bb8bd67

View file

@ -0,0 +1,468 @@
// 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</c> is indexed by, so its per-wave entries 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
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a293cf6dca91dd2478c9f4e799cf9e0d

View file

@ -0,0 +1,69 @@
// Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
using System;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.Waves
{
/// <summary>
/// One weighted candidate inside a <see cref="WaveGroup"/>: a wave that may be drawn,
/// and how likely it is to be drawn relative to the group's other entries.
/// </summary>
/// <remarks>
/// <b>Weight is RELATIVE, not an absolute probability.</b> The draw normalizes every
/// candidate's weight against the summed weight of the whole candidate set, so three
/// entries at 1.0 each are equally likely (33% apiece) and an entry at 0.5 is half as
/// likely to be drawn as one at 1.0. This is what makes the default sane: new entries
/// start at 1.0, so adding a wave to a group never silently re-weights the others.
///
/// <para>A class rather than a struct specifically so <see cref="Weight"/> can carry a
/// field initializer — Unity runs it when the inspector creates a fresh array element,
/// which is what gives new entries their equal-by-default weighting. Mirrors
/// <see cref="WaveGoldEntry"/>.</para>
/// </remarks>
[Serializable]
public class WavePoolEntry
{
[Tooltip("The wave that may be drawn from this group.")]
public WaveDefinition Wave;
[Tooltip("Relative draw weight within this group's pool. Entries at equal weight are " +
"equally likely; an entry at 0.5 is half as likely as one at 1.0. NOT an " +
"absolute percentage.")]
[PoolWeight("wave")]
public float Weight = 1f;
}
/// <summary>
/// A named, reusable bag of candidate waves. Groups are the unit designers move between
/// phases: dragging a group asset from Phase 1's pool to Phase 2's moves every wave in it
/// (and their weights) in one action.
/// </summary>
/// <remarks>
/// <b>Why weights live on the entry, not on <see cref="WaveDefinition"/>.</b> Weight is a
/// property of "how common is this wave <i>in this pool</i>", not of the wave itself. Keeping
/// it here means the same <see cref="WaveDefinition"/> asset can be a staple of one group and
/// a rarity in another, and a group carries its tuning with it when moved between phases.
///
/// <para><b>Boss groups use the same type.</b> A boss wave is just a
/// <see cref="WaveDefinition"/> whose entries spawn a single powerful enemy, so boss pools
/// are ordinary <see cref="WaveGroup"/>s referenced from
/// <see cref="PhaseDefinition.BossGroups"/>.</para>
/// </remarks>
[CreateAssetMenu(fileName = "WaveGroup", menuName = "TD/Run/Wave Group", order = 10)]
public class WaveGroup : ScriptableObject
{
[Tooltip("Designer-facing name for this group, shown in run-structure validation " +
"messages. Falls back to the asset name when empty.")]
public string DisplayName;
[Tooltip("Candidate waves in this group, each with a relative draw weight.")]
public WavePoolEntry[] Waves;
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
/// <summary>Number of entries, including any that are null or zero-weight.</summary>
public int Count => Waves?.Length ?? 0;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5625bd25f378bda429666820a4940cee