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
209
Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
Normal file
209
Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue