// Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs using System.Collections.Generic; using System.Text; using UnityEngine; namespace TD.Gameplay.Waves { /// /// 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 RunState in the /// match scene; replaces WaveManager's old flat, hand-ordered wave array. /// /// /// Stable wave ids. Waves are replicated by index into a flat list this asset builds /// by walking its phases, groups, and entries in declaration order (see /// ). Because every peer loads the identical asset, the same walk /// produces the same ids everywhere — the same trick the tower catalog and /// DraftPool already use, and the reason the server can say "wave slot 2 is wave #7" /// in a single int. /// /// The index is frozen for the match. RunState 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. /// [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 waveIndex; private Dictionary waveIds; /// Number of distinct waves reachable anywhere in this run. public int WaveCount { get { EnsureIndex(); return waveIndex.Count; } } /// Resolves a replicated wave id back to its asset, or null if out of range. public WaveDefinition GetWave(int waveId) { EnsureIndex(); return (waveId >= 0 && waveId < waveIndex.Count) ? waveIndex[waveId] : null; } /// Resolves a wave asset to its replicated id. public bool TryGetWaveId(WaveDefinition wave, out int waveId) { EnsureIndex(); if (wave != null && waveIds.TryGetValue(wave, out waveId)) return true; waveId = -1; return false; } /// /// Rebuilds the flat wave index from the current asset contents. Called once by /// RunState on every peer at match start so ids agree, and by the editor when /// the asset changes. /// public void RebuildIndex() { waveIndex ??= new List(); waveIds ??= new Dictionary(); 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 -------------------------------------------------- /// /// Checks that this run can actually be played: at least one phase, every phase able to /// fill slots with distinct enemy types, and every phase able /// to produce a boss. Returns true when the run is playable; /// carries the problems either way (it may hold warnings even /// on success). /// /// /// 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. /// 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(); 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 } }