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,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
}
}