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>
209 lines
8.7 KiB
C#
209 lines
8.7 KiB
C#
// 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
|
|
}
|
|
}
|