diff --git a/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs
index 3bc7bd1..3356956 100644
--- a/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs
+++ b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs
@@ -8,9 +8,22 @@ namespace TD.Core
///
public enum EnemyAbilityKind : byte
{
- /// No ability rolled. Never a real pool entry — only the sentinel value
- /// reports before/absent a roll.
+ /// Sentinel for "no ability". Never a real pool entry.
None = 0,
+
+ /// Dies into several smaller, faster copies of itself.
SplitOnDeath = 1,
+
+ /// Grounded enemies take to the air, ignoring the maze entirely.
+ Flight = 2,
+
+ /// Teleports a short distance further along its path on a timer.
+ Blink = 3,
+
+ /// Grants no kill bounty.
+ NoBounty = 4,
+
+ /// Steals gold from the player whose zone it escaped, on top of the life cost.
+ GoldTheft = 5,
}
}
diff --git a/Assets/_Project/Scripts/Core/Enums.cs b/Assets/_Project/Scripts/Core/Enums.cs
index 95f979a..7d49400 100644
--- a/Assets/_Project/Scripts/Core/Enums.cs
+++ b/Assets/_Project/Scripts/Core/Enums.cs
@@ -68,8 +68,12 @@ namespace TD.Core
///
///
/// None is a sentinel value used in OwnerGrid to mark tiles not owned by any player zone.
- /// Player1..Player9 cover the maximum supported player count. Maps using fewer players use a
- /// contiguous prefix (e.g., a 3-player map uses Player1, Player2, Player3 only).
+ /// Maps use a contiguous prefix of the player values.
+ ///
+ /// The enum runs to 9, but only slots are ever
+ /// allocated. Lobbies capped at 3 for the 2.0 design; the surplus values are kept because
+ /// they are baked into existing LevelData owner grids, and renumbering the enum would
+ /// force a re-bake of every map to no runtime benefit.
///
///
/// Global phase of a match, driven by MatchState.
@@ -92,6 +96,28 @@ namespace TD.Core
Defeat = 4,
}
+ ///
+ /// Which step of the between-encounters sequence is currently running. Replicated by
+ /// WaveManager so every HUD can label the shared countdown correctly.
+ ///
+ ///
+ /// These run strictly in order and never overlap: players take their personal draft, then vote
+ /// on the buff for the wave they just cleared, then build. The build timer deliberately does
+ /// NOT run concurrently with the choices — "after all players have chosen" only means
+ /// something if it isn't racing a clock players also want to spend on their maze.
+ ///
+ public enum InterWaveStage : byte
+ {
+ /// No inter-wave step running — a wave is spawning or being fought.
+ None = 0,
+ /// Players are picking their personal draft reward.
+ Draft = 1,
+ /// Players are voting on the buff for the wave they just cleared.
+ Vote = 2,
+ /// Build countdown before the next wave spawns.
+ Build = 3,
+ }
+
///
/// Stable identifier per race. Values 1-16 reserve slots for the planned
/// 16-race grid in the lobby; only races with a corresponding
diff --git a/Assets/_Project/Scripts/Core/MatchRules.cs b/Assets/_Project/Scripts/Core/MatchRules.cs
new file mode 100644
index 0000000..d698547
--- /dev/null
+++ b/Assets/_Project/Scripts/Core/MatchRules.cs
@@ -0,0 +1,26 @@
+// Assets/_Project/Scripts/Core/MatchRules.cs
+namespace TD.Core
+{
+ ///
+ /// Match-wide constants that several unrelated systems have to agree on.
+ ///
+ public static class MatchRules
+ {
+ ///
+ /// Maximum players in one lobby.
+ ///
+ ///
+ /// Reduced from 9 to 3 for the 2.0 design as a deliberate scope cut: three players
+ /// is enough for the shared-lives and shared-vote mechanics to matter, and it shrinks map
+ /// authoring, balance surface, and network load all at once.
+ ///
+ /// Why still runs to 9. Shrinking the enum would
+ /// invalidate the owner grids baked into existing LevelData assets and force a
+ /// re-bake of every map, for no runtime benefit — the extra values are simply never
+ /// allocated. Slot allocation caps here instead, which is the only place that decides who
+ /// gets a slot at all. Per-slot arrays stay sized to the enum, so they hold a few unused
+ /// entries; that is intentional and costs nothing.
+ ///
+ public const int MaxPlayers = 3;
+ }
+}
diff --git a/Assets/_Project/Scripts/Core/MatchRules.cs.meta b/Assets/_Project/Scripts/Core/MatchRules.cs.meta
new file mode 100644
index 0000000..74bb050
--- /dev/null
+++ b/Assets/_Project/Scripts/Core/MatchRules.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ac169573e8a2fd046a3ebac7d2e03c8c
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
new file mode 100644
index 0000000..0bb29d0
--- /dev/null
+++ b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
@@ -0,0 +1,35 @@
+// Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
+using UnityEngine;
+
+namespace TD.Core
+{
+ ///
+ /// Marks a float field as a 0–1 relative draw weight inside an authored pool. The
+ /// inspector renders it as a slider and, when the value is zero, shows a warning directly
+ /// beneath explaining that the entry can never be drawn.
+ ///
+ ///
+ /// Relative, not absolute. Weights are normalized against the summed weight of the
+ /// candidate set at draw time, so entries at equal weight are equally likely and an entry at
+ /// 0.5 is half as likely as one at 1.0. This is why every weight field defaults to 1 —
+ /// adding an entry to a pool then never silently re-weights the entries already in it.
+ ///
+ /// The warning is advisory. Muting an entry while tuning is a legitimate
+ /// workflow, so the drawer never clamps the value or blocks the edit. It exists because a
+ /// zero-weight entry is otherwise indistinguishable, in play, from one that simply hasn't come
+ /// up yet — the failure is silent, so the authoring surface has to be loud.
+ ///
+ public class PoolWeightAttribute : PropertyAttribute
+ {
+ ///
+ /// Noun used in the zero-weight warning ("this wave can never be drawn"). Keep it
+ /// singular and lowercase.
+ ///
+ public readonly string Noun;
+
+ public PoolWeightAttribute(string noun = "entry")
+ {
+ Noun = string.IsNullOrWhiteSpace(noun) ? "entry" : noun;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta
new file mode 100644
index 0000000..2ce9e75
--- /dev/null
+++ b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e171856d7a8fe194f97091853672c19d
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Dev/DevWaveControls.cs b/Assets/_Project/Scripts/Dev/DevWaveControls.cs
index 2b941b1..9ffa05e 100644
--- a/Assets/_Project/Scripts/Dev/DevWaveControls.cs
+++ b/Assets/_Project/Scripts/Dev/DevWaveControls.cs
@@ -33,6 +33,10 @@ namespace TD.Dev
"verified now. Set to Key.None to use the OnGUI button only.")]
[SerializeField] private Key grantTowerHotkey = Key.F8;
+ [Tooltip("Keyboard shortcut to skip every remaining encounter in the current phase and " +
+ "go straight to its boss. Set to Key.None to use the OnGUI button only.")]
+ [SerializeField] private Key skipToBossHotkey = Key.F7;
+
private void Update()
{
var kb = Keyboard.current;
@@ -43,6 +47,9 @@ namespace TD.Dev
if (grantTowerHotkey != Key.None && kb[grantTowerHotkey].wasPressedThisFrame)
TryGrantNextTower();
+
+ if (skipToBossHotkey != Key.None && kb[skipToBossHotkey].wasPressedThisFrame)
+ TrySkipToBoss();
}
private void OnGUI()
@@ -59,11 +66,13 @@ namespace TD.Dev
// Anchored below the top HUD bar so it doesn't overlap gold/wave/lives.
const float topOffset = 90f;
- GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls");
+ GUI.Box(new Rect(10, topOffset, 180, 128), "Dev: Wave Controls");
if (GUI.Button(new Rect(20, topOffset + 25, 160, 28), "Force Next Wave"))
TryForceNextWave();
if (GUI.Button(new Rect(20, topOffset + 58, 160, 28), "Grant Next Tower"))
TryGrantNextTower();
+ if (GUI.Button(new Rect(20, topOffset + 91, 160, 28), "Skip To Boss"))
+ TrySkipToBoss();
}
// Dev stand-in for the draft system: grants the local player the first catalog
@@ -118,5 +127,27 @@ namespace TD.Dev
Debug.Log("[DevWaveControls] Forcing next wave.");
wm.ForceAdvanceToNextWave();
}
+
+ // Skips straight to the phase boss so boss content can be tested without playing
+ // every cycle first. Server-only, like the other cheats.
+ private void TrySkipToBoss()
+ {
+ if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer)
+ {
+ Debug.LogWarning("[DevWaveControls] Skip-to-boss requested off the server. Ignored.");
+ return;
+ }
+
+ var wm = WaveManager.Instance;
+ if (wm == null)
+ {
+ Debug.LogWarning("[DevWaveControls] WaveManager.Instance is null. " +
+ "Is the scene running with a WaveManager network-spawned?");
+ return;
+ }
+
+ Debug.Log("[DevWaveControls] Skipping to boss.");
+ wm.ForceAdvanceToBoss();
+ }
}
}
diff --git a/Assets/_Project/Scripts/Editor/Core.meta b/Assets/_Project/Scripts/Editor/Core.meta
new file mode 100644
index 0000000..7db3e99
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Core.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 75cc168bfd0b91b41a313a4389d31910
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
new file mode 100644
index 0000000..3321b16
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
@@ -0,0 +1,66 @@
+// Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
+using UnityEditor;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Editor.Core
+{
+ ///
+ /// Draws a field as a 0–1 slider, with an inline warning
+ /// rendered directly beneath it whenever the weight is zero.
+ ///
+ ///
+ /// Deliberately does not clamp, revert, or disable the field — a designer muting an entry
+ /// while tuning is normal. The drawer's only job is to make the consequence visible at the
+ /// point of editing, since a zero-weight entry fails silently at runtime.
+ ///
+ [CustomPropertyDrawer(typeof(PoolWeightAttribute))]
+ public class PoolWeightDrawer : PropertyDrawer
+ {
+ // Two lines of text alongside the icon column need more than one line of box height.
+ private const float HelpBoxLines = 2.4f;
+
+ public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
+ {
+ float h = EditorGUIUtility.singleLineHeight;
+
+ if (IsZero(property))
+ h += EditorGUIUtility.standardVerticalSpacing + HelpBoxHeight();
+
+ return h;
+ }
+
+ public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
+ {
+ if (property.propertyType != SerializedPropertyType.Float)
+ {
+ EditorGUI.LabelField(position, label.text, "[PoolWeight] only supports float fields.");
+ return;
+ }
+
+ var sliderRect = new Rect(position.x, position.y, position.width,
+ EditorGUIUtility.singleLineHeight);
+ EditorGUI.Slider(sliderRect, property, 0f, 1f, label);
+
+ if (!IsZero(property)) return;
+
+ var attr = (PoolWeightAttribute)attribute;
+ var boxRect = new Rect(
+ position.x,
+ position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing,
+ position.width,
+ HelpBoxHeight());
+
+ EditorGUI.HelpBox(
+ boxRect,
+ $"Weight is 0 — this {attr.Noun} can never be drawn and will not appear in game. " +
+ $"Raise it above 0 to put it back in the pool.",
+ MessageType.Warning);
+ }
+
+ private static bool IsZero(SerializedProperty property)
+ => property.propertyType == SerializedPropertyType.Float && property.floatValue <= 0f;
+
+ private static float HelpBoxHeight() => EditorGUIUtility.singleLineHeight * HelpBoxLines;
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta
new file mode 100644
index 0000000..3ec645e
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8ba57381fc7e6a3479e59d84e45720d9
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs
new file mode 100644
index 0000000..0c08333
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs
@@ -0,0 +1,69 @@
+// Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs
+using UnityEditor;
+using UnityEngine;
+using TD.Gameplay.Waves;
+
+namespace TD.Editor.Gameplay
+{
+ ///
+ /// Inspector for . Draws the normal fields, then a live
+ /// validation panel reporting whether the authored pools can actually produce a run.
+ ///
+ ///
+ /// The check that matters here is the distinct-enemy-type one: because enemy upgrades are
+ /// keyed to a wave slot, a phase must be able to fill its cycle with waves that use different
+ /// enemies. A pool can look healthy (plenty of entries) and still fail that. Reporting it in
+ /// the inspector turns a startup error into something a designer sees while authoring.
+ ///
+ [CustomEditor(typeof(RunDefinition))]
+ public class RunDefinitionEditor : UnityEditor.Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ DrawDefaultInspector();
+
+ var run = (RunDefinition)target;
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Validation", EditorStyles.boldLabel);
+
+ bool ok = run.Validate(out string report);
+ EditorGUILayout.HelpBox(report, ok ? MessageType.Info : MessageType.Error);
+
+ // Per-phase capacity readout — the fastest way to see how much headroom a pool has
+ // before the distinct-type requirement starts biting.
+ if (run.Phases != null)
+ {
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Phase capacity", EditorStyles.boldLabel);
+
+ for (int i = 0; i < run.Phases.Length; i++)
+ {
+ var phase = run.Phases[i];
+ if (phase == null)
+ {
+ EditorGUILayout.LabelField($"Phase {i + 1}", "— empty —");
+ continue;
+ }
+
+ int distinct = phase.CountDistinctEnemyTypes();
+ string value = $"{distinct} distinct enemy type(s) / {run.WavesPerCycle} needed";
+ var style = new GUIStyle(EditorStyles.label)
+ {
+ normal = { textColor = distinct >= run.WavesPerCycle
+ ? EditorStyles.label.normal.textColor
+ : new Color(0.9f, 0.4f, 0.3f) }
+ };
+ EditorGUILayout.LabelField(phase.Label, value, style);
+ }
+ }
+
+ EditorGUILayout.Space();
+ if (GUILayout.Button("Rebuild Wave Index"))
+ {
+ run.RebuildIndex();
+ Debug.Log($"[RunDefinition] Rebuilt wave index: {run.WaveCount} distinct wave(s).");
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta
new file mode 100644
index 0000000..2b46f6d
--- /dev/null
+++ b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5f5e8e0197efd154b92817bbb3028b35
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs b/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs
index 3b9ea5f..f95a423 100644
--- a/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs
+++ b/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs
@@ -82,7 +82,7 @@ namespace TD.Gameplay.Draft
draft.ServerOffer(resultScratch);
}
- /// Server-only: auto-resolve any unpicked drafts (called at prep end).
+ /// Server-only: auto-resolve any unpicked drafts (called when the timer expires).
public void ServerAutoResolveAll()
{
if (!IsServer) return;
@@ -90,6 +90,29 @@ namespace TD.Gameplay.Draft
PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve();
}
+ ///
+ /// True once every connected player has resolved their draft. The inter-wave step polls
+ /// this to end the draft timer early instead of making everyone wait out the clock.
+ ///
+ ///
+ /// Returns false when nobody is connected, so an empty lobby can't trip the barrier and
+ /// race the sequence forward.
+ ///
+ public static bool AllPlayersPicked
+ {
+ get
+ {
+ bool any = false;
+ foreach (var pms in PlayerMatchState.AllPlayers)
+ {
+ any = true;
+ var draft = PlayerDraft.GetForClient(pms.OwnerClientId);
+ if (draft != null && draft.HasActiveDraft) return false;
+ }
+ return any;
+ }
+ }
+
// ----- Generation -------------------------------------------------
// Weighted random draw WITHOUT replacement: picks `count` distinct option ids from
diff --git a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs
index 41a7c04..1ac355b 100644
--- a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs
+++ b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs
@@ -18,7 +18,10 @@ namespace TD.Gameplay.Draft
/// Authority. The server offers (), resolves
/// ( / ), and applies the
/// chosen option. The owning client only sends intent via
- /// and .
+ /// .
+ ///
+ /// The paid reroll is disabled for the 2.0 MVP — see the commented-out block at
+ /// the bottom of this file for why it was kept rather than removed.
///
/// Generation lives in ; this component is just
/// the replicated state + the pick/buy entry points.
@@ -143,25 +146,38 @@ namespace TD.Gameplay.Draft
ServerResolve(optionId);
}
- ///
- /// Owning client: buy a fresh roll of options for gold. Rejected if the player can't
- /// afford it or already has an unresolved draft (resolve the current one first so a
- /// free pick is never silently overwritten).
- ///
- [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
- public void RequestBuyRerollRpc()
- {
- if (HasActiveDraft) return; // resolve the pending draft before buying another
-
- var service = DraftService.Instance;
- if (service == null) return;
-
- var gold = PlayerGoldManager.GetForClient(OwnerClientId);
- int cost = service.RerollCost;
- if (gold == null || gold.CurrentGold < cost) return;
-
- gold.DeductGold(cost);
- service.ServerOfferTo(OwnerClientId);
- }
+ // ----- Extra-draft shop (DISABLED for the 2.0 MVP) --------------------
+ //
+ // Kept rather than deleted: the shop is expected back, but as a SHARED gold sink — the
+ // whole lobby pools money toward one collective extra draft for everyone — rather than
+ // the per-player reroll implemented here. When it returns, this method is the wrong shape
+ // (per-player deduction, per-player offer) but the surrounding plumbing is right, so it
+ // stays as the reference for what to rebuild against.
+ //
+ // Re-enabling this alone would also break the inter-wave barrier: DraftService.
+ // AllPlayersPicked ends the draft stage the moment everyone has resolved, so a player
+ // buying a roll after that point would be offered cards the sequence has already moved
+ // past.
+ //
+ // ///
+ // /// Owning client: buy a fresh roll of options for gold. Rejected if the player can't
+ // /// afford it or already has an unresolved draft (resolve the current one first so a
+ // /// free pick is never silently overwritten).
+ // ///
+ // [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
+ // public void RequestBuyRerollRpc()
+ // {
+ // if (HasActiveDraft) return; // resolve the pending draft before buying another
+ //
+ // var service = DraftService.Instance;
+ // if (service == null) return;
+ //
+ // var gold = PlayerGoldManager.GetForClient(OwnerClientId);
+ // int cost = service.RerollCost;
+ // if (gold == null || gold.CurrentGold < cost) return;
+ //
+ // gold.DeductGold(cost);
+ // service.ServerOfferTo(OwnerClientId);
+ // }
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs
index 7746e3e..841d627 100644
--- a/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs
+++ b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs
@@ -5,52 +5,62 @@ using TD.Towers;
namespace TD.Gameplay.Draft
{
///
- /// Draft choice — "upgrade a tower you already have". Replaces
- /// with in the player's : every
- /// tower of that type placed from now on is the upgraded version. Already-placed towers
- /// are unaffected.
+ /// Draft choice — "unlock an upgrade". Adds to the player's
+ /// set, letting them spend gold converting a placed
+ /// into it. The base tower stays buildable.
///
///
- /// Mirrors , but swaps rather than adds. Only offered
- /// while the player currently has unlocked — once taken,
- /// is removed from the deck, which automatically makes this option
- /// (and any sibling upgrade branching off the same base) invalid for future drafts.
+ /// Unlock, not swap. This option used to replace the base tower in the player's
+ /// deck, which made the draft pick itself the whole upgrade — free, instant, and retroactive to
+ /// every tower built afterward. The 2.0 model splits it: the draft grants access, gold
+ /// grants the tower. So the deck is untouched here and already-placed towers are
+ /// upgraded one at a time, each paid for.
+ ///
+ /// Prerequisites fall out of the tree. An upgrade is offerable only once its
+ /// parent is reachable — either the parent is a base tower in the player's deck, or it's an
+ /// upgrade node they've already unlocked. That single rule is what gates deep nodes behind
+ /// shallow ones without any explicit prerequisite list: authoring the tree in
+ /// is enough.
///
[CreateAssetMenu(fileName = "TowerUpgradeOption", menuName = "TD/Draft/Tower Upgrade Option")]
public class TowerUpgradeDraftOption : DraftOption
{
[Header("Payload")]
- [Tooltip("The tower this option upgrades from. Must be present in the player's deck " +
- "for this option to be offered.")]
+ [Tooltip("The tower this upgrade branches from — the tree node that must already be " +
+ "reachable (in the player's deck, or itself an unlocked upgrade) for this " +
+ "option to be offered.")]
public TowerDefinition BaseTower;
- [Tooltip("The tower this option upgrades to. Must also be present in the " +
- "TowerPlacementManager catalog (that's where its TowerTypeId comes from).")]
+ [Tooltip("The tower this option unlocks as an upgrade target. Must be in the " +
+ "TowerPlacementManager catalog (that's where its TowerTypeId comes from) and " +
+ "should be listed in BaseTower's UpgradePaths so the conversion is legal.")]
public TowerDefinition UpgradedTower;
public override bool IsValidFor(ulong clientId)
{
- var deck = PlayerTowerDeck.GetForClient(clientId);
- var pm = TowerPlacementManager.Instance;
- if (deck == null || pm == null || BaseTower == null || UpgradedTower == null) return false;
+ var deck = PlayerTowerDeck.GetForClient(clientId);
+ var upgrades = PlayerTowerUpgrades.GetForClient(clientId);
+ var pm = TowerPlacementManager.Instance;
- if (!pm.TryGetTypeId(BaseTower, out int baseTypeId)) return false;
+ if (deck == null || upgrades == null || pm == null) return false;
+ if (BaseTower == null || UpgradedTower == null) return false;
- return deck.Contains(baseTypeId);
+ if (!pm.TryGetTypeId(BaseTower, out int baseTypeId)) return false;
+ if (!pm.TryGetTypeId(UpgradedTower, out int upgradedTypeId)) return false;
+
+ // Already unlocked — offering it again would waste the pick.
+ if (upgrades.Contains(upgradedTypeId)) return false;
+
+ // The parent must be reachable: a base tower they can build, or an upgrade they've
+ // already taken. This is the whole prerequisite mechanism.
+ return deck.Contains(baseTypeId) || upgrades.Contains(baseTypeId);
}
public override bool ServerApply(ulong clientId)
{
- var deck = PlayerTowerDeck.GetForClient(clientId);
- var pm = TowerPlacementManager.Instance;
- if (deck == null || pm == null || BaseTower == null || UpgradedTower == null) return false;
-
- if (!pm.TryGetTypeId(BaseTower, out int baseTypeId))
- {
- Debug.LogError($"[TowerUpgradeDraftOption] '{BaseTower.name}' is not in the tower " +
- $"catalog; cannot apply. Add it to TowerPlacementManager.towerDefinitions.");
- return false;
- }
+ var upgrades = PlayerTowerUpgrades.GetForClient(clientId);
+ var pm = TowerPlacementManager.Instance;
+ if (upgrades == null || pm == null || UpgradedTower == null) return false;
if (!pm.TryGetTypeId(UpgradedTower, out int upgradedTypeId))
{
@@ -60,7 +70,7 @@ namespace TD.Gameplay.Draft
return false;
}
- return deck.ServerUpgradeTower(baseTypeId, upgradedTypeId);
+ return upgrades.ServerUnlock(upgradedTypeId);
}
/// Inherits the upgraded tower's icon unless this option assigns an override.
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
new file mode 100644
index 0000000..236c8b7
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
@@ -0,0 +1,60 @@
+// Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.EnemyAbilities
+{
+ ///
+ /// Every few seconds, the enemy teleports a short distance further along its path — skipping
+ /// tiles, and the towers covering them.
+ ///
+ ///
+ /// Blinks follow the path rather than cutting toward the goal, so the maze still shapes the
+ /// route; the buff shortens time-under-fire rather than bypassing walls. See
+ /// EnemyMovement.ServerBlinkForward.
+ ///
+ /// The cooldown lives in this enemy's own timer slot rather than on the asset. A field
+ /// here would be shared by every enemy carrying the buff, so the whole wave would blink in
+ /// perfect unison — which looks like a bug even when the timing is right.
+ ///
+ [CreateAssetMenu(fileName = "BlinkAbility", menuName = "TD/Enemy Abilities/Blink")]
+ public class BlinkAbilityDefinition : EnemyAbilityDefinition
+ {
+ public override EnemyAbilityKind Kind => EnemyAbilityKind.Blink;
+
+ [Header("Blink")]
+ [Tooltip("Seconds between blinks.")]
+ [Min(0.1f)]
+ public float IntervalSeconds = 4f;
+
+ [Tooltip("How many path waypoints to skip per blink. Note that path smoothing can make " +
+ "one waypoint span several tiles, so this is a coarser dial than it looks.")]
+ [Min(1)]
+ public int BlinkWaypoints = 2;
+
+ [Tooltip("Random spread (seconds) added to each enemy's first blink so a wave doesn't " +
+ "blink in one synchronized block. Applied once, at spawn.")]
+ [Min(0f)]
+ public float StartJitterSeconds = 1.5f;
+
+ public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex)
+ {
+ // Seed each enemy's cooldown with a different negative offset so the wave's first
+ // blink is staggered. Done once, here, rather than on the first tick — a tick-time
+ // check would have to distinguish "never started" from "just blinked", and both
+ // states read as a zero timer.
+ if (StartJitterSeconds > 0f)
+ instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds);
+ }
+
+ public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt)
+ {
+ ref float timer = ref instance.TimerFor(abilityIndex);
+ timer += dt;
+ if (timer < IntervalSeconds) return;
+
+ timer = 0f;
+ instance.GetComponent()?.ServerBlinkForward(BlinkWaypoints);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta
new file mode 100644
index 0000000..b7af88b
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4fbb09f55384ac44399ac4655b2b53ec
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs
index 349b0ee..d80b430 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs
@@ -5,21 +5,28 @@ using TD.Core;
namespace TD.Gameplay.EnemyAbilities
{
///
- /// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Rolled
- /// at random for each spawned enemy by and applied
- /// via .
+ /// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Abilities
+ /// are attached to a wave slot by the post-wave player vote, and every enemy that wave
+ /// spawns from then on carries the whole accumulated set.
///
///
- /// One asset per kind. is fixed per subclass, same as
- /// . uses
- /// it to build a fixed-size, enum-indexed lookup table.
+ /// Assignment is deterministic, not random. This system originally rolled an
+ /// ability per individual enemy against a no-ability weight. Under the 2.0 design the players
+ /// choose it, it applies to every enemy in the wave, and it persists for the rest of the phase
+ /// — so the roll is gone and WaveManager reads the slot's set instead. What survived is
+ /// the shape: one asset per kind, server-only hooks.
///
- /// Server-only hooks. All three hooks below only ever run on the server —
- /// only calls them when IsServer is true. and are no-ops for abilities that don't
- /// need spawn-time setup or a per-frame timer (e.g. Split on Death uses neither); they exist
- /// so a future cooldown-driven ability (disable towers, teleport) doesn't need a base-class
- /// change.
+ /// Abilities stack. A wave can collect several across a phase's cycles, so an
+ /// enemy holds a list and every hook below runs once per ability. Implementations must not
+ /// assume they are the only ability on the enemy.
+ ///
+ /// Server-only hooks. All hooks run on the server only —
+ /// gates every call on IsServer. They default to no-ops so a new card only overrides the
+ /// one or two it actually needs.
+ ///
+ /// No per-enemy state on the asset. A single asset is shared by every enemy
+ /// carrying the ability, across every wave and match. Anything per-instance belongs on the
+ /// component or the enemy itself.
///
public abstract class EnemyAbilityDefinition : ScriptableObject
{
@@ -27,29 +34,74 @@ namespace TD.Gameplay.EnemyAbilities
public abstract EnemyAbilityKind Kind { get; }
[Header("Presentation")]
- [Tooltip("Name shown in debug logs and future enemy-info UI.")]
+ [Tooltip("Name shown in debug logs and the enemy-info panel.")]
public string DisplayName;
- [Tooltip("Short description shown in future enemy-info UI.")]
+ [Tooltip("Short description shown in the enemy-info panel.")]
[TextArea(2, 4)]
public string Description;
- [Header("Selection")]
- [Tooltip("Relative weight of this ability being rolled, vs. the pool's other abilities " +
- "and its no-ability chance. Same semantics as DraftOption.Weight.")]
- [Min(0f)]
- public float Weight = 1f;
+ // ----- Spawn-time stat modification --------------------------------
- /// Server-only: called once, right after this ability is assigned (before the
+ ///
+ /// Server-only: alter what this enemy spawns as, before it is built. Runs for every enemy
+ /// of the buffed wave. Default no-op.
+ ///
+ ///
+ /// This is the hook for cards that change what an enemy fundamentally is — flight,
+ /// health, speed, size. It must run before the enemy exists, because spawn position
+ /// (flyers are raised) and EnemyHealth/EnemyMovement initialization all read
+ /// these values once and keep them.
+ ///
+ /// When several abilities stack, they see each other's edits in application order,
+ /// so multiplicative modifiers compose naturally. Prefer multiplying over assigning for
+ /// anything numeric, or the last card to run silently wins.
+ ///
+ public virtual void ServerModifySpawn(ref EnemySpawnContext context) { }
+
+ // ----- Lifetime hooks ----------------------------------------------
+
+ /// Server-only: called once, right after the ability set is assigned (before the
/// enemy's NetworkObject is spawned to clients). Default no-op.
- public virtual void ServerOnSpawn(EnemyAbility instance) { }
+ /// This ability's slot on — the
+ /// same index receives, so per-enemy timers can be seeded here.
+ public virtual void ServerOnSpawn(EnemyAbility instance, int abilityIndex) { }
- /// Server-only: called every frame this ability is active on a live enemy.
- /// Default no-op.
- public virtual void ServerTick(EnemyAbility instance, float dt) { }
+ ///
+ /// Server-only: called every frame this ability is active on a live enemy. Default no-op.
+ ///
+ /// This ability's slot on . Pass it
+ /// to to reach a per-enemy float this ability owns.
+ ///
+ /// The asset holds no per-enemy state. One
+ /// instance is shared by every enemy carrying the ability, so a cooldown stored in a field
+ /// here would be shared by the entire wave — every enemy would blink in lockstep, or worse,
+ /// race each other's writes. gives each enemy its own
+ /// slot by reference, which is enough for the timer-driven abilities and costs no
+ /// allocation.
+ ///
+ public virtual void ServerTick(EnemyAbility instance, int abilityIndex, float dt) { }
/// Server-only: called the instant the enemy's HP reaches zero, before the
/// death animation/despawn sequence plays. Default no-op.
public virtual void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { }
+
+ // ----- Economy hooks -----------------------------------------------
+
+ ///
+ /// Server-only: adjust the gold a player earns for killing this enemy. Returns the reward
+ /// to pass on; default is unchanged.
+ ///
+ ///
+ /// Chained across a stacked ability set, each ability receiving the previous one's result.
+ /// Callers clamp the final value at zero, so returning a negative is safe but pointless.
+ ///
+ public virtual int ServerModifyKillReward(EnemyAbility instance, int reward) => reward;
+
+ ///
+ /// Server-only: called when this enemy reaches the defense point, after lives have been
+ /// deducted. The hook for leak-punishing cards that cost more than lives. Default no-op.
+ ///
+ public virtual void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone) { }
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs
index 30fe26c..09a0880 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs
@@ -13,8 +13,12 @@ namespace TD.Gameplay.EnemyAbilities
///
///
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync.
- /// Only the server calls (at enemy spawn time, in
- /// WaveManager.SpawnEnemy).
+ ///
+ /// Lookup only — no rolling. This pool used to draw a random ability per spawned
+ /// enemy against a "no ability" weight. Under the 2.0 design abilities are chosen by the
+ /// players' post-wave vote and apply to every enemy in the wave, so the draw moved to
+ /// WaveVote (over cards, not abilities) and this is purely the kind→asset table
+ /// WaveManager resolves against at spawn time.
///
public class EnemyAbilityPool : MonoBehaviour
{
@@ -25,11 +29,6 @@ namespace TD.Gameplay.EnemyAbilities
"its slot.")]
[SerializeField] private EnemyAbilityDefinition[] abilities;
- [Tooltip("Relative weight of an enemy rolling no ability at all, vs. the combined " +
- "weight of every entry in 'abilities'. Higher = abilities are rarer.")]
- [Min(0f)]
- [SerializeField] private float noAbilityWeight = 100f;
-
// Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count,
// not authored count, so an out-of-range Kind is a compile-time impossibility rather
// than a bounds check we'd otherwise need on every Get().
@@ -68,35 +67,5 @@ namespace TD.Gameplay.EnemyAbilities
int i = (int)kind;
return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null;
}
-
- ///
- /// Server-only: weighted random draw across every authored ability plus the
- /// no-ability bucket. Returns null when "no ability" is drawn, or when the pool has
- /// nothing authored.
- ///
- public EnemyAbilityDefinition RollRandom()
- {
- if (abilities == null || abilities.Length == 0) return null;
-
- float total = noAbilityWeight;
- for (int i = 0; i < abilities.Length; i++)
- if (abilities[i] != null) total += abilities[i].Weight;
-
- if (total <= 0f) return null;
-
- float roll = UnityEngine.Random.Range(0f, total);
- if (roll < noAbilityWeight) return null;
- roll -= noAbilityWeight;
-
- for (int i = 0; i < abilities.Length; i++)
- {
- var def = abilities[i];
- if (def == null) continue;
- if (roll < def.Weight) return def;
- roll -= def.Weight;
- }
-
- return null;
- }
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs
new file mode 100644
index 0000000..d643fcf
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs
@@ -0,0 +1,42 @@
+// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs
+namespace TD.Gameplay.EnemyAbilities
+{
+ ///
+ /// Mutable per-spawn copy of an enemy's stats, handed to each of its abilities before the
+ /// enemy is built so they can alter what it spawns as.
+ ///
+ ///
+ /// Why a context rather than more parameters. Wave buffs like "grounded enemies become
+ /// flying" or "enemies spawn at half health" have to land before
+ /// EnemyHealth.InitializeServer and before the spawn position is computed (flyers are
+ /// raised by their flight height). Threading each new stat through SpawnEnemy as another
+ /// argument was already unwieldy at two; this keeps the signature flat and gives abilities one
+ /// obvious place to write.
+ ///
+ /// Seeded from the , never written back to it — the
+ /// definition is a shared asset and a buff that mutated it would leak across waves, matches,
+ /// and (in the editor) sessions.
+ ///
+ public struct EnemySpawnContext
+ {
+ public float MaxHp;
+ public float MoveSpeed;
+ public bool IsFlying;
+ public float FlightHeight;
+ public int LivesCost;
+
+ /// Uniform transform scale relative to the prefab's authored scale.
+ public float VisualScale;
+
+ /// Seeds a context from an enemy definition's authored stats.
+ public static EnemySpawnContext FromDefinition(EnemyDefinition def) => new EnemySpawnContext
+ {
+ MaxHp = def.MaxHp,
+ MoveSpeed = def.MoveSpeed,
+ IsFlying = def.IsFlying,
+ FlightHeight = def.FlightHeight,
+ LivesCost = def.LivesCost,
+ VisualScale = 1f,
+ };
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta
new file mode 100644
index 0000000..d812b43
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2f4b0f77b422a474cbe82f3967e6195b
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs
new file mode 100644
index 0000000..322b2cf
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs
@@ -0,0 +1,53 @@
+// Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.EnemyAbilities
+{
+ ///
+ /// Grounded enemies take to the air: they path over the baked terrain grid instead of through
+ /// the maze, and towers flagged ground-only can no longer touch them.
+ ///
+ ///
+ /// The single most punishing card in the set, because it doesn't weaken the maze — it
+ /// deletes it. Everything a player built to lengthen the route stops mattering for that wave,
+ /// and only their anti-air coverage counts. Weight it accordingly in the pools.
+ ///
+ /// Purely a spawn-time change: EnemyMovement reads the flying flag once at
+ /// initialization to pick which grid to path on and to opt out of re-path scheduling, and
+ /// TowerCombat reads the replicated flag on EnemyHealth when filtering targets.
+ /// Both are already wired for flight — this card only has to flip the bit before the enemy is
+ /// built.
+ ///
+ [CreateAssetMenu(fileName = "FlightAbility", menuName = "TD/Enemy Abilities/Flight")]
+ public class FlightAbilityDefinition : EnemyAbilityDefinition
+ {
+ public override EnemyAbilityKind Kind => EnemyAbilityKind.Flight;
+
+ [Header("Flight")]
+ [Tooltip("Height above the ground these enemies hover at. Keep modest — tower targeting " +
+ "uses 3D range, so altitude eats into the effective reach of anything that CAN " +
+ "shoot them.")]
+ [Min(0f)]
+ public float FlightHeight = 3f;
+
+ [Tooltip("Speed multiplier applied when the enemy takes flight. Flying enemies travel a " +
+ "much shorter route, so values below 1 are a reasonable counterweight.")]
+ [Range(0.1f, 2f)]
+ public float SpeedMultiplier = 1f;
+
+ public override void ServerModifySpawn(ref EnemySpawnContext context)
+ {
+ // Already-flying enemies keep their authored height rather than being overwritten by
+ // this card's — the wave was designed around it, and stacking Flight onto a flier
+ // shouldn't quietly relocate it.
+ if (!context.IsFlying)
+ {
+ context.IsFlying = true;
+ context.FlightHeight = FlightHeight;
+ }
+
+ context.MoveSpeed *= SpeedMultiplier;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta
new file mode 100644
index 0000000..82e8217
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: be77d353621f07e40bbdab2e9cc37f51
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs
new file mode 100644
index 0000000..974b59f
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs
@@ -0,0 +1,53 @@
+// Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.EnemyAbilities
+{
+ ///
+ /// An enemy that reaches the defense point doesn't just cost a life — it robs the player whose
+ /// maze it escaped.
+ ///
+ ///
+ /// Charged to the origin zone, not the whole team. Lives are a shared pool, so a leak
+ /// already punishes everyone equally; billing the gold to whoever let it through is the part
+ /// that makes this card bite differently from "enemies cost 2 lives". It also keeps the blame
+ /// legible — the player who leaked is the player who pays.
+ ///
+ /// Deducting more gold than a player has simply empties them; there is no debt. Going
+ /// negative would silently disable building until they clawed back to zero, which reads as a
+ /// broken HUD rather than a penalty.
+ ///
+ [CreateAssetMenu(fileName = "GoldTheftAbility", menuName = "TD/Enemy Abilities/Gold Theft")]
+ public class GoldTheftAbilityDefinition : EnemyAbilityDefinition
+ {
+ public override EnemyAbilityKind Kind => EnemyAbilityKind.GoldTheft;
+
+ [Header("Theft")]
+ [Tooltip("Flat gold taken from the leaking player, on top of the normal life cost.")]
+ [Min(0)]
+ public int GoldStolen = 15;
+
+ public override void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone)
+ {
+ if (GoldStolen <= 0 || originZone == PlayerSlot.None) return;
+
+ var pms = PlayerMatchState.GetForSlot(originZone);
+ if (pms == null) return;
+
+ var gold = PlayerGoldManager.GetForClient(pms.OwnerClientId);
+ if (gold == null) return;
+
+ // Clamp to what they actually hold — a leak shouldn't be able to put a player in debt.
+ int taken = Mathf.Min(GoldStolen, gold.CurrentGold);
+ if (taken <= 0) return;
+
+ gold.DeductGold(taken);
+
+ // Surface it in-world on every peer so the loss isn't just a number quietly ticking
+ // down in the corner. Routed through WaveManager because the popup has to reach
+ // clients and a ScriptableObject has no NetworkBehaviour to send from.
+ WaveManager.Instance?.ServerBroadcastGoldLoss(instance.transform.position, taken);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta
new file mode 100644
index 0000000..aa23919
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3b770ce042b78f44d9a3df537fe25db0
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs
new file mode 100644
index 0000000..42d2bab
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs
@@ -0,0 +1,34 @@
+// Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.EnemyAbilities
+{
+ ///
+ /// Killing these enemies pays little or nothing. The wave gets no harder to survive — it gets
+ /// harder to profit from.
+ ///
+ ///
+ /// Attacks the economy, not the maze. Every other card makes a wave more dangerous;
+ /// this one makes clearing it worthless, which compounds instead across cycles — a wave that
+ /// pays nothing on cycle 1 also pays nothing on cycles 2 and 3, so the players who voted it in
+ /// are choosing to be poorer for the rest of the phase. That slow squeeze is the point, and
+ /// it's why the card reads as mild and isn't.
+ ///
+ /// Wave-completion and no-leak bonuses are untouched — those are paid per player by
+ /// WaveManager, not per kill, so a wave carrying this still rewards clearing it.
+ ///
+ [CreateAssetMenu(fileName = "NoBountyAbility", menuName = "TD/Enemy Abilities/No Bounty")]
+ public class NoBountyAbilityDefinition : EnemyAbilityDefinition
+ {
+ public override EnemyAbilityKind Kind => EnemyAbilityKind.NoBounty;
+
+ [Header("Bounty")]
+ [Tooltip("Fraction of the normal kill bounty these enemies still pay. 0 = nothing at all.")]
+ [Range(0f, 1f)]
+ public float BountyMultiplier = 0f;
+
+ public override int ServerModifyKillReward(EnemyAbility instance, int reward)
+ => Mathf.FloorToInt(reward * BountyMultiplier);
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta
new file mode 100644
index 0000000..a2f48ff
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fd5b12948b24e9c4c8840ef3470b437e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs
index 48bf7c6..1d96391 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs
@@ -7,32 +7,60 @@ namespace TD.Gameplay.EnemyAbilities
///
/// On death, spawns smaller copies of the dying enemy's own type at
/// the corpse's position, continuing on toward the goal instead of restarting from the wave's
- /// spawner. There's no separate "minion" EnemyDefinition to author — the mini copies reuse
- /// whichever EnemyDefinition/prefab the dying enemy itself was spawned from, scaled down by
- /// /.
+ /// spawner.
///
+ ///
+ /// No separate minion asset. The copies reuse whichever
+ /// and prefab the dying enemy was spawned from; every stat is expressed here as a percentage
+ /// of what the parent actually spawned with, so one card covers any enemy it lands on.
+ ///
+ /// Percentages are of the PARENT, not the asset. If other cards on the same wave
+ /// have already modified the enemy, the minions scale off the modified values — a split of a
+ /// buffed enemy produces buffed minions, which is what players will expect from watching it.
+ ///
+ /// Minions inherit no abilities at all — not even this one. That is what makes the
+ /// recursion terminate: a minion of a splitting enemy is a plain enemy, so a wave carrying
+ /// Split produces exactly one extra generation regardless of how many other cards it has
+ /// collected. Enforced at the spawn site, not here.
+ ///
[CreateAssetMenu(fileName = "SplitOnDeathAbility", menuName = "TD/Enemy Abilities/Split On Death")]
public class SplitOnDeathAbilityDefinition : EnemyAbilityDefinition
{
public override EnemyAbilityKind Kind => EnemyAbilityKind.SplitOnDeath;
[Header("Split")]
- [Tooltip("How many mini copies spawn on death.")]
+ [Tooltip("How many copies spawn when the parent dies.")]
[Min(1)]
public int SplitCount = 2;
- [Tooltip("Each mini copy's MaxHp = the dying enemy's own MaxHp * this multiplier.")]
- [Range(0.01f, 1f)]
- public float HpMultiplier = 0.5f;
+ [Header("Minion stats (percent of the parent's spawned values)")]
+ [Tooltip("Minion max HP as a fraction of the parent's max HP.")]
+ [Range(0.01f, 2f)]
+ public float HpPercent = 0.5f;
- [Tooltip("Uniform transform scale applied to each mini copy, relative to the normal " +
- "prefab scale. Requires the enemy prefab's NetworkTransform to sync scale, " +
- "or the mini will render full-size on remote peers.")]
- [Range(0.1f, 1f)]
- public float ScaleMultiplier = 0.6f;
+ [Tooltip("Minion move speed as a fraction of the parent's speed. Above 1 makes the split " +
+ "faster than what died — the classic 'smaller and quicker' read.")]
+ [Range(0.01f, 3f)]
+ public float SpeedPercent = 1.25f;
- [Tooltip("Random scatter radius (world units) applied to each split spawn so they don't " +
- "spawn exactly on top of each other.")]
+ [Tooltip("Minion transform scale as a fraction of the parent's scale. Requires the enemy " +
+ "prefab's NetworkTransform to sync scale, or minions render full-size on remote peers.")]
+ [Range(0.1f, 2f)]
+ public float ScalePercent = 0.6f;
+
+ [Tooltip("Minion lives cost as a fraction of the parent's, rounded down but never below 1 " +
+ "— a leak always has to hurt, or splits become a way to farm free ground.")]
+ [Range(0f, 2f)]
+ public float LivesCostPercent = 1f;
+
+ [Tooltip("Minion flight height as a fraction of the parent's. Only meaningful when the " +
+ "parent is flying; ground splits ignore it.")]
+ [Range(0.1f, 2f)]
+ public float FlightHeightPercent = 1f;
+
+ [Header("Placement")]
+ [Tooltip("Random scatter radius (world units) applied to each spawn so minions don't " +
+ "stack exactly on top of each other.")]
[Min(0f)]
public float ScatterRadius = 0.5f;
@@ -41,12 +69,24 @@ namespace TD.Gameplay.EnemyAbilities
var def = health.Definition;
if (def == null) return;
- var movement = instance.GetComponent();
- var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
+ var movement = instance.GetComponent();
+ var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
var ownerSlot = movement != null ? movement.OriginZone : PlayerSlot.None;
+ // Derive the minion's stats from what the parent actually spawned as.
+ var parent = instance.SpawnContext;
+ var minion = new EnemySpawnContext
+ {
+ MaxHp = Mathf.Max(1f, parent.MaxHp * HpPercent),
+ MoveSpeed = Mathf.Max(0.01f, parent.MoveSpeed * SpeedPercent),
+ IsFlying = parent.IsFlying,
+ FlightHeight = parent.FlightHeight * FlightHeightPercent,
+ LivesCost = Mathf.Max(1, Mathf.FloorToInt(parent.LivesCost * LivesCostPercent)),
+ VisualScale = parent.VisualScale * ScalePercent,
+ };
+
WaveManager.Instance?.ServerSpawnSplitEnemies(
- def, SplitCount, atTile, ownerSlot, ScatterRadius, HpMultiplier, ScaleMultiplier);
+ def, SplitCount, atTile, ownerSlot, ScatterRadius, minion);
}
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs
index 93a577c..8913543 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs
@@ -1,4 +1,5 @@
// Assets/_Project/Scripts/Gameplay/EnemyAbility.cs
+using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
@@ -7,57 +8,101 @@ using TD.Gameplay.EnemyAbilities;
namespace TD.Gameplay
{
///
- /// Per-enemy ability slot. Holds the rolled for this
- /// instance (if any) and drives its server-only hooks.
+ /// Per-enemy ability set. Holds every its wave has
+ /// accumulated and drives their server-only hooks.
///
///
/// Initialization: Call on the server immediately after
/// Instantiate and before NetworkObject.Spawn(), following the same pattern as
/// EnemyHealth.InitializeServer / EnemyMovement.InitializeServer.
///
+ /// A set, not a slot. This used to hold one ability rolled at random per enemy. Wave
+ /// buffs stack across a phase's cycles, so it now holds a list and fans every hook out over
+ /// all of them. replicates the set so clients can show players what
+ /// they're facing.
+ ///
/// Optional component: Not required by EnemyHealth or EnemyMovement —
- /// WaveManager.SpawnEnemy only rolls and assigns an ability when this component is
- /// present on the prefab, so existing enemy prefabs are unaffected until it's added to them.
+ /// WaveManager.SpawnEnemy only assigns abilities when this component is present on the
+ /// prefab, so enemy prefabs without it simply never carry wave buffs.
///
/// On-death hook is not event-driven: WaveManager.HandleEnemyKilled calls
- /// directly (via )
- /// rather than this component subscribing to EnemyHealth.OnDied itself — that keeps a
- /// split-spawn's activeEnemyCount increment ordered before the triggering kill's
- /// decrement, regardless of NetworkBehaviour subscription order.
+ /// directly rather than this component subscribing to
+ /// EnemyHealth.OnDied itself — that keeps a split-spawn's activeEnemyCount
+ /// increment ordered before the triggering kill's decrement, regardless of NetworkBehaviour
+ /// subscription order.
///
[RequireComponent(typeof(NetworkObject))]
public class EnemyAbility : NetworkBehaviour
{
- private readonly NetworkVariable kind = new NetworkVariable(
- (byte)EnemyAbilityKind.None,
- NetworkVariableReadPermission.Everyone,
- NetworkVariableWritePermission.Server);
+ // Replicated ability kinds carried by this enemy. Empty means no wave buffs. Exists so
+ // clients can render "this wave splits and flies" in the enemy-info panel without
+ // re-deriving it from run state.
+ private NetworkList kinds;
// ----- Pre-spawn init (server-local) ----------------------------------
- private EnemyAbilityDefinition pendingDefinition;
- private bool hasPendingInit;
+ private readonly List pendingDefinitions = new List();
+ private bool hasPendingInit;
// ----- Public state -----------------------------------------------------
- /// The ability rolled for this enemy, or null if none was rolled.
- public EnemyAbilityDefinition Definition { get; private set; }
+ private readonly List definitions = new List();
- /// Replicated kind of the rolled ability. if
- /// none was rolled.
- public EnemyAbilityKind Kind => (EnemyAbilityKind)kind.Value;
+ /// The abilities this enemy carries. Server-side; empty on clients.
+ public IReadOnlyList Definitions => definitions;
+
+ /// True if this enemy carries no abilities at all.
+ public bool IsPlain => definitions.Count == 0;
+
+ ///
+ /// The stats this enemy actually spawned with, after every ability's spawn modification.
+ /// Server-side.
+ ///
+ ///
+ /// Kept so on-death abilities can derive from what the enemy really was rather than from
+ /// its . Split-on-death needs this: its minions are a
+ /// percentage of "the enemy that died", which is not the authored asset once other cards
+ /// on the same wave have modified it.
+ ///
+ public EnemySpawnContext SpawnContext { get; private set; }
+
+ private void Awake()
+ {
+ kinds = new NetworkList();
+ }
+
+ /// Replicated check for a specific ability. Safe on any peer.
+ public bool HasKind(EnemyAbilityKind kind)
+ {
+ byte target = (byte)kind;
+ for (int i = 0; i < kinds.Count; i++)
+ if (kinds[i] == target) return true;
+ return false;
+ }
// ----- Server-only pre-spawn init -------------------------------------
///
/// Called by WaveManager on the server after Instantiate and before
- /// NetworkObject.Spawn(). may be null, meaning no
- /// ability was rolled for this enemy.
+ /// NetworkObject.Spawn(). may be null or empty,
+ /// meaning this enemy carries no wave buffs.
///
- public void InitializeServer(EnemyAbilityDefinition definition)
+ ///
+ /// Copies rather than retains the caller's list — WaveManager reuses one scratch
+ /// buffer across every enemy in a wave, so holding the reference would alias every enemy
+ /// to the same (later mutated) contents.
+ ///
+ public void InitializeServer(IReadOnlyList abilities,
+ EnemySpawnContext spawnContext)
{
- pendingDefinition = definition;
- hasPendingInit = true;
+ pendingDefinitions.Clear();
+ if (abilities != null)
+ {
+ for (int i = 0; i < abilities.Count; i++)
+ if (abilities[i] != null) pendingDefinitions.Add(abilities[i]);
+ }
+ SpawnContext = spawnContext;
+ hasPendingInit = true;
}
// ----- NGO lifecycle --------------------------------------------------
@@ -66,22 +111,89 @@ namespace TD.Gameplay
{
if (!IsServer || !hasPendingInit) return;
- Definition = pendingDefinition;
- kind.Value = (byte)(Definition != null ? Definition.Kind : EnemyAbilityKind.None);
+ definitions.Clear();
+ definitions.AddRange(pendingDefinitions);
hasPendingInit = false;
- if (Definition != null)
- Debug.Log($"[EnemyAbility] {name} rolled {Definition.Kind}.");
+ timers = definitions.Count > 0
+ ? new float[definitions.Count]
+ : System.Array.Empty();
- Definition?.ServerOnSpawn(this);
+ kinds.Clear();
+ for (int i = 0; i < definitions.Count; i++)
+ kinds.Add((byte)definitions[i].Kind);
+
+ for (int i = 0; i < definitions.Count; i++)
+ definitions[i].ServerOnSpawn(this, i);
}
// ----- Server tick ------------------------------------------------
private void Update()
{
- if (!IsServer || Definition == null) return;
- Definition.ServerTick(this, Time.deltaTime);
+ if (!IsServer || definitions.Count == 0) return;
+
+ float dt = Time.deltaTime;
+ for (int i = 0; i < definitions.Count; i++)
+ definitions[i].ServerTick(this, i, dt);
+ }
+
+ // ----- Per-enemy ability scratch ----------------------------------
+
+ // One float per carried ability, owned by that ability. Exists because the definitions
+ // are shared ScriptableObjects: a cooldown stored on the asset would be shared by every
+ // enemy in the wave, making them all fire in lockstep. Allocated once at init, sized to
+ // the ability count — never resized, since the set is fixed at spawn.
+ private float[] timers = System.Array.Empty();
+
+ ///
+ /// A per-enemy float owned by the ability at , returned by
+ /// reference so it can be read and written in place. Typically a cooldown accumulator.
+ ///
+ public ref float TimerFor(int abilityIndex)
+ {
+ if (abilityIndex < 0 || abilityIndex >= timers.Length)
+ {
+ // Out-of-range means a caller passed an index that doesn't match its slot. Hand
+ // back a scratch cell rather than throwing mid-tick; the ability just won't
+ // accumulate, which is visible in play but not fatal.
+ Debug.LogError($"[EnemyAbility] TimerFor({abilityIndex}) is out of range on " +
+ $"{name}. The ability will not keep time.");
+ return ref timerFallback;
+ }
+ return ref timers[abilityIndex];
+ }
+
+ private float timerFallback;
+
+ // ----- Server hook fan-out ----------------------------------------
+
+ /// Server-only: run every carried ability's on-death hook.
+ public void ServerInvokeOnDeath(EnemyHealth health)
+ {
+ if (!IsServer) return;
+ for (int i = 0; i < definitions.Count; i++)
+ definitions[i].ServerOnDeath(this, health);
+ }
+
+ ///
+ /// Server-only: chain every carried ability's kill-reward modifier. Result is clamped at
+ /// zero so a stack of reward-suppressing cards can't hand out negative gold.
+ ///
+ public int ServerModifyKillReward(int reward)
+ {
+ if (!IsServer) return reward;
+ for (int i = 0; i < definitions.Count; i++)
+ reward = definitions[i].ServerModifyKillReward(this, reward);
+ return Mathf.Max(0, reward);
+ }
+
+ /// Server-only: run every carried ability's reached-goal hook.
+ public void ServerInvokeReachedGoal(PlayerSlot originZone)
+ {
+ if (!IsServer) return;
+ for (int i = 0; i < definitions.Count; i++)
+ definitions[i].ServerOnReachedGoal(this, originZone);
}
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
index 10256d2..ddd2e6a 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
@@ -1,5 +1,6 @@
// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
using System.Collections;
+using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
@@ -67,6 +68,7 @@ namespace TD.Gameplay
private int pendingLivesCost = 1;
private bool pendingIsFlying;
private bool pendingIsHeld;
+ private bool pendingIsBoss;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
@@ -103,6 +105,13 @@ namespace TD.Gameplay
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
+ // Set for enemies spawned by a phase's boss encounter. Replicated because the HUD's boss
+ // bar is client-side and has to recognise a boss without asking the server.
+ private readonly NetworkVariable isBoss = new NetworkVariable(
+ false,
+ NetworkVariableReadPermission.Everyone,
+ NetworkVariableWritePermission.Server);
+
// ----- Public state ---------------------------------------------------
public float CurrentHp => hp.Value;
@@ -117,6 +126,19 @@ namespace TD.Gameplay
///
public bool IsFlying => isFlying.Value;
+ /// True if this enemy was spawned by a phase's boss encounter. Replicated.
+ public bool IsBoss => isBoss.Value;
+
+ ///
+ /// Every live boss, on every peer. Maintained by spawn/despawn so the HUD can render a
+ /// boss bar without polling the scene.
+ ///
+ ///
+ /// A list rather than a single reference because the boss encounter spawns one boss per
+ /// player zone — there are as many simultaneous bosses as there are players.
+ ///
+ public static readonly List ActiveBosses = new List();
+
// ----- Events ---------------------------------------------------------
///
@@ -136,12 +158,14 @@ namespace TD.Gameplay
/// and before NetworkObject.Spawn(). Mirrors the
/// TowerInstance.InitializeServer pattern.
///
- public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false)
+ public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false,
+ bool boss = false)
{
pendingMaxHp = maxHp;
pendingLivesCost = livesCost;
pendingIsFlying = flying;
pendingIsHeld = held;
+ pendingIsBoss = boss;
hasPendingInit = true;
// Cache locally on the server immediately — clients resolve via NV.
@@ -158,6 +182,7 @@ namespace TD.Gameplay
hp.Value = pendingMaxHp;
isFlying.Value = pendingIsFlying;
isHeld.Value = pendingIsHeld;
+ isBoss.Value = pendingIsBoss;
hasPendingInit = false;
}
@@ -165,6 +190,9 @@ namespace TD.Gameplay
if (!IsServer)
MaxHp = hp.Value;
+ if (isBoss.Value && !ActiveBosses.Contains(this))
+ ActiveBosses.Add(this);
+
// Apply initial held state on all peers and watch for future changes.
// isHeld.OnValueChanged doesn't fire for the initial replication, so we
// apply it explicitly here as well.
@@ -174,6 +202,8 @@ namespace TD.Gameplay
public override void OnNetworkDespawn()
{
+ ActiveBosses.Remove(this);
+
// If this enemy was the locally-selected ISelectable, clear the
// selection so the HUD doesn't keep displaying a stale corpse.
// SelectionState is a local UI singleton, safe to query on any peer.
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
index 7eec0d1..147eaf9 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
@@ -282,6 +282,45 @@ namespace TD.Gameplay
NetworkObject.Despawn();
}
+ // ----- Ability-driven movement ----------------------------------------
+
+ ///
+ /// Server-only: teleport this enemy forward along its existing path by up to
+ /// waypoints. Used by the Blink wave buff.
+ ///
+ ///
+ /// Follows the path rather than the straight line to the goal. Jumping toward the
+ /// goal as the crow flies would let a grounded enemy blink through walls, which is the
+ /// flying buff's job, not this one. Skipping waypoints keeps the enemy inside the maze —
+ /// it cuts corners, it doesn't ignore them.
+ ///
+ /// Zone tracking still runs on the destination tile, so an enemy that blinks out of
+ /// its origin zone still credits its owner a leak. Skipping that would make the buff a way
+ /// to launder leaks past the no-leak bonus.
+ ///
+ /// Blinking onto the final waypoint resolves as a normal goal arrival, despawn and
+ /// life cost included.
+ ///
+ public void ServerBlinkForward(int tiles)
+ {
+ if (!IsServer || tiles <= 0) return;
+ if (hasReachedGoal || remainingPath.Count == 0) return;
+ if (health != null && health.IsHeld) return;
+
+ int skip = Mathf.Min(tiles, remainingPath.Count);
+ Vector2Int destination = remainingPath[skip - 1];
+ remainingPath.RemoveRange(0, skip);
+
+ // Preserve Y so flyers stay at altitude and grounded pivots don't sink.
+ Vector3 world = GridCoordinates.GridToWorld(destination);
+ transform.position = new Vector3(world.x, transform.position.y, world.z);
+
+ CheckZoneTransition(destination);
+
+ if (remainingPath.Count == 0)
+ HandleGoalReached();
+ }
+
// ----- Path invalidation ----------------------------------------------
// Called by PathfindingService's budgeted scheduler after a walkability change
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta
new file mode 100644
index 0000000..f5ba3d6
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 95ac4e8d1714a07489dad0c283970bf8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs
new file mode 100644
index 0000000..3576d6b
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs
@@ -0,0 +1,62 @@
+// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs
+using UnityEngine;
+using TD.Gameplay.EnemyAbilities;
+using TD.Gameplay.Waves;
+
+namespace TD.Gameplay.EnemyUpgrades
+{
+ ///
+ /// The workhorse enemy-buff card: grants an to a wave
+ /// slot. Every enemy spawned by that wave, in every later cycle of the phase, carries the
+ /// ability.
+ ///
+ ///
+ /// There is nothing to do at vote-resolution time — recording the option id on the slot IS the
+ /// effect, because the spawn path builds each enemy's ability set from the slot's recorded
+ /// options. is what that lookup resolves to.
+ ///
+ [CreateAssetMenu(fileName = "EnemyUpgrade_Ability",
+ menuName = "TD/Enemy Upgrades/Ability Card", order = 20)]
+ public class AbilityEnemyUpgradeOption : EnemyUpgradeOption
+ {
+ [Header("Payload")]
+ [Tooltip("The ability every enemy in this wave gains. Must also be present in the scene's " +
+ "EnemyAbilityPool so it can be resolved at spawn time.")]
+ public EnemyAbilityDefinition Ability;
+
+ public override bool IsValidForSlot(RunState run, int slot)
+ {
+ if (Ability == null) return false;
+
+ // Don't offer an ability the slot already has by another card's hand. Exact-id repeats
+ // are filtered by the caller, but two different cards can grant the same ability, and
+ // stacking one twice is a no-op the players would rightly feel cheated by.
+ return !SlotAlreadyHasAbility(run, slot);
+ }
+
+ // Shared scratch: validation runs on the server during vote generation, which is
+ // single-threaded, so one reused list beats allocating per candidate per slot.
+ private static readonly System.Collections.Generic.List s_idScratch
+ = new System.Collections.Generic.List();
+
+ private bool SlotAlreadyHasAbility(RunState run, int slot)
+ {
+ var pool = EnemyUpgradePool.Instance;
+ if (run == null || pool == null) return false;
+
+ var ids = s_idScratch;
+ run.GetUpgradesForSlot(slot, ids);
+
+ foreach (int id in ids)
+ {
+ if (pool.Get(id) is AbilityEnemyUpgradeOption other && other.Ability == Ability)
+ return true;
+ }
+ return false;
+ }
+
+ /// Inherits nothing automatically — carries no
+ /// icon, so the card's own is the only source.
+ public override Sprite ResolveIcon() => Icon;
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta
new file mode 100644
index 0000000..f619df4
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d075c66b381872a4781b3916296ccdc9
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
new file mode 100644
index 0000000..882dd8f
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
@@ -0,0 +1,48 @@
+// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
+using UnityEngine;
+using TD.Gameplay.Waves;
+
+namespace TD.Gameplay.EnemyUpgrades
+{
+ ///
+ /// Vote-meta card: the next time this wave is cleared, it takes several buffs automatically
+ /// and the players get no say in which.
+ ///
+ ///
+ /// Not an enemy ability. Nothing about the enemies changes when this is voted in — it
+ /// changes how the next vote on this slot resolves. That's why it derives from
+ /// directly instead of wrapping an
+ /// EnemyAbilityDefinition: the spawn path builds ability sets only from ability cards,
+ /// so this one is recorded on the slot and otherwise invisible to enemies.
+ ///
+ /// The trade is agency for information. Voting this in is choosing to make one
+ /// future wave worse in exchange for it not being the worst option — the auto-draw is weighted
+ /// and slot-filtered exactly like a real vote, so it's a gamble on the pool, not a punishment.
+ /// It also disarms itself after firing (ServerConsumeAutoApply), so a slot can only be
+ /// doubled once per card.
+ ///
+ /// Repeats are prevented by the normal offer filter: the slot records this card like any
+ /// other, so it can't be offered to the same slot twice in a phase.
+ ///
+ [CreateAssetMenu(fileName = "EnemyUpgrade_DoubleUp",
+ menuName = "TD/Enemy Upgrades/Double Up Card", order = 22)]
+ public class DoubleUpEnemyUpgradeOption : EnemyUpgradeOption
+ {
+ [Header("Payload")]
+ [Tooltip("How many buffs the wave takes automatically in place of its next vote.")]
+ [Min(2)]
+ public int BuffsToAutoApply = 2;
+
+ public override bool IsValidForSlot(RunState run, int slot)
+ {
+ // Pointless to offer while the slot is already armed — the player would be trading a
+ // vote away for nothing.
+ return run != null && run.GetAutoApplyCount(slot) <= 0;
+ }
+
+ public override void ServerOnApplied(RunState run, int slot)
+ {
+ run?.ServerSetAutoApply(slot, BuffsToAutoApply);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta
new file mode 100644
index 0000000..ca93bb2
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f5e90fe6b8e439e4f99769a6923c2262
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs
new file mode 100644
index 0000000..1e0f9f2
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs
@@ -0,0 +1,75 @@
+// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.EnemyUpgrades
+{
+ ///
+ /// One weighted candidate inside an .
+ ///
+ ///
+ /// Weight is relative and normalized at draw time — see .
+ /// Kept on the entry rather than on the option asset for the same reason wave weights are:
+ /// a card can be a staple of one phase's pool and a rarity in another's.
+ ///
+ [Serializable]
+ public class EnemyUpgradePoolEntry
+ {
+ [Tooltip("The enemy-buff card that may be offered from this group.")]
+ public EnemyUpgradeOption Option;
+
+ [Tooltip("Relative draw weight within the pool this group contributes to. Entries at " +
+ "equal weight are equally likely; 0.5 is half as likely as 1.0.")]
+ [PoolWeight("buff")]
+ public float Weight = 1f;
+ }
+
+ ///
+ /// A named, reusable bag of enemy-buff cards. Groups are the unit designers move between
+ /// phases: dragging a group into Phase 2's list makes every card in it votable from phase 2
+ /// onward, without touching the cards themselves.
+ ///
+ ///
+ /// Buffs are gated by phase, not by cycle — every card a phase allows is votable from
+ /// its very first wave. Gating by cycle was considered and rejected: the whole point of the
+ /// cyclical structure is that players choose how hard to make their own next lap, and holding
+ /// the interesting cards back until cycle 3 would flatten that decision into a formality.
+ ///
+ [CreateAssetMenu(fileName = "EnemyUpgradeGroup",
+ menuName = "TD/Enemy Upgrades/Upgrade Group", order = 21)]
+ public class EnemyUpgradeGroup : ScriptableObject
+ {
+ [Tooltip("Designer-facing name, shown in validation messages. Falls back to the asset name.")]
+ public string DisplayName;
+
+ [Tooltip("Cards in this group, each with a relative draw weight.")]
+ public EnemyUpgradePoolEntry[] Options;
+
+ /// Name used in validation and log messages. Falls back to the asset name.
+ public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
+
+ ///
+ /// Appends every non-null, non-zero-weight entry across into
+ /// . Zero-weight entries are filtered here so "weight 0 means it
+ /// can never appear" lives in exactly one place.
+ ///
+ public static void CollectCandidates(EnemyUpgradeGroup[] groups, List into)
+ {
+ into.Clear();
+ if (groups == null) return;
+
+ foreach (var group in groups)
+ {
+ if (group?.Options == null) continue;
+ foreach (var entry in group.Options)
+ {
+ if (entry?.Option == null) continue;
+ if (entry.Weight <= 0f) continue;
+ into.Add(entry);
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta
new file mode 100644
index 0000000..3410908
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4202a767d96e70f41b6a7e84eb08a832
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
new file mode 100644
index 0000000..ac43085
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
@@ -0,0 +1,66 @@
+// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
+using UnityEngine;
+using TD.Gameplay.Waves;
+
+namespace TD.Gameplay.EnemyUpgrades
+{
+ ///
+ /// Base class for one card in the post-wave enemy-buff vote. Players collectively choose one
+ /// of these to permanently attach to the wave they just cleared; it takes effect the next time
+ /// that wave comes round in the cycle.
+ ///
+ ///
+ /// Mirrors DraftOption deliberately. Same shape — abstract SO, one asset
+ /// per card, concrete subclasses carry the payload — so the two authoring surfaces read the
+ /// same way. The difference is who it targets: a draft option applies to a player, an enemy
+ /// upgrade applies to a wave slot.
+ ///
+ /// Identity. Options live in and cross the network
+ /// as their pool index, the same stable-within-a-match id pattern used by the tower catalog
+ /// and draft pool.
+ ///
+ /// Recording is not this type's job. WaveVote records the winning option id
+ /// onto the slot via RunState.ServerAddUpgrade; is only
+ /// for cards that need a side effect beyond being recorded (the vote-meta cards).
+ /// Ability cards need nothing here — the spawn path reads the slot's recorded set.
+ ///
+ public abstract class EnemyUpgradeOption : ScriptableObject
+ {
+ [Header("Presentation")]
+ [Tooltip("Name shown on the vote card.")]
+ public string DisplayName;
+
+ [Tooltip("Short description shown on the vote card. Say what it does to the enemies, in " +
+ "the players' terms — this is the only thing they get to judge the vote on.")]
+ [TextArea(2, 4)]
+ public string Description;
+
+ [Tooltip("OPTIONAL override for the vote card icon. Leave empty to inherit the icon of " +
+ "whatever this option grants.")]
+ public Sprite Icon;
+
+ ///
+ /// Icon actually shown on the vote card. Subclasses override to fall back to the icon of
+ /// the thing they grant; the base has nothing to inherit from, so it returns
+ /// (which may be null).
+ ///
+ public virtual Sprite ResolveIcon() => Icon;
+
+ ///
+ /// Server-only: may this option be OFFERED for right now? Default
+ /// true; override for cards with prerequisites or ones that would be a no-op.
+ ///
+ ///
+ /// Callers already skip options the slot has taken before, so implementations don't need
+ /// to re-check that.
+ ///
+ public virtual bool IsValidForSlot(RunState run, int slot) => true;
+
+ ///
+ /// Server-only: side effect to run after this option has been recorded onto
+ /// . Default no-op — most cards do their work at enemy-spawn time
+ /// by virtue of being on the slot, not at vote-resolution time.
+ ///
+ public virtual void ServerOnApplied(RunState run, int slot) { }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta
new file mode 100644
index 0000000..37a9369
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6b1e9770be72a694a96169d9a9032528
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
new file mode 100644
index 0000000..f6c51e8
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
@@ -0,0 +1,94 @@
+// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace TD.Gameplay.EnemyUpgrades
+{
+ ///
+ /// Scene singleton holding every that can appear anywhere in
+ /// this match. The array index is the option's EnemyUpgradeOptionId — the stable
+ /// identifier used to replicate vote offers, ballots, and the upgrades recorded on wave slots.
+ ///
+ ///
+ /// Why a flat pool and per-phase groups. The pool supplies network identity: one
+ /// array, one index per card, identical on every peer. PhaseDefinition.EnemyUpgradeGroups
+ /// supplies gating: which of those cards a given phase may offer. Folding gating into the pool
+ /// would make ids phase-relative and therefore unstable across a phase change, which is
+ /// exactly when replicated slot upgrades are being cleared and rewritten.
+ ///
+ /// Plain MonoBehaviour — identical assets on every peer, so there is nothing to sync.
+ /// The server reads it to generate and resolve votes; clients read it to render vote cards
+ /// from replicated ids.
+ ///
+ public class EnemyUpgradePool : MonoBehaviour
+ {
+ public static EnemyUpgradePool Instance { get; private set; }
+
+ [Tooltip("Every EnemyUpgradeOption asset that can appear this match. Array index is the " +
+ "id used over the network — reordering mid-development is fine, but not between " +
+ "a host and its clients.")]
+ [SerializeField] private EnemyUpgradeOption[] options;
+
+ private void Awake()
+ {
+ if (Instance != null && Instance != this)
+ {
+ Debug.LogError("[EnemyUpgradePool] Multiple instances detected. Only one per scene.");
+ return;
+ }
+ Instance = this;
+ }
+
+ private void OnDestroy()
+ {
+ if (Instance == this) Instance = null;
+ }
+
+ /// Number of options in the pool.
+ public int Count => options?.Length ?? 0;
+
+ /// Returns the option at , or null if out of range.
+ public EnemyUpgradeOption Get(int id)
+ => (options != null && id >= 0 && id < options.Length) ? options[id] : null;
+
+ /// Resolves an option asset back to its pool id, or -1 if it isn't in the pool.
+ public int GetId(EnemyUpgradeOption option)
+ {
+ if (options == null || option == null) return -1;
+ for (int i = 0; i < options.Length; i++)
+ if (options[i] == option) return i;
+ return -1;
+ }
+
+ ///
+ /// Collects the pool ids of every card the given phase groups allow. Cards missing from
+ /// the pool are reported once and skipped — an authored group referencing an unpooled
+ /// card is a wiring mistake that would otherwise silently shrink the vote.
+ ///
+ public void CollectAllowedIds(EnemyUpgradeGroup[] phaseGroups,
+ List entryScratch,
+ List intoIds,
+ List intoWeights)
+ {
+ intoIds.Clear();
+ intoWeights.Clear();
+
+ EnemyUpgradeGroup.CollectCandidates(phaseGroups, entryScratch);
+
+ foreach (var entry in entryScratch)
+ {
+ int id = GetId(entry.Option);
+ if (id < 0)
+ {
+ Debug.LogWarning($"[EnemyUpgradePool] '{entry.Option.name}' is referenced by a " +
+ $"phase's upgrade groups but is not in the pool — it can " +
+ $"never be offered. Add it to the pool's options array.");
+ continue;
+ }
+
+ intoIds.Add(id);
+ intoWeights.Add(entry.Weight);
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta
new file mode 100644
index 0000000..161d578
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c4d98a01d9b9a7b438a6f91b0b7f498f
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
new file mode 100644
index 0000000..4d8e872
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
@@ -0,0 +1,419 @@
+// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
+using System.Collections.Generic;
+using Unity.Netcode;
+using UnityEngine;
+using TD.Core;
+using TD.Gameplay.Waves;
+
+namespace TD.Gameplay.EnemyUpgrades
+{
+ ///
+ /// The shared, open-ballot vote held after each wave clears: players collectively pick
+ /// which permanent buff the wave they just beat will carry into the next cycle.
+ ///
+ ///
+ /// Ballots are public by design. replicates to everyone the
+ /// instant a vote is cast, and the HUD paints each voter's slot badge onto the card they chose.
+ /// That visibility is the point of the mechanic — seeing two teammates converge on "Flying" is
+ /// what lets the third decide whether to join them or split the vote. A private tally would
+ /// reduce this to a dice roll with extra steps.
+ ///
+ /// Votes stay changeable until the vote resolves, for the same reason. The
+ /// consequence is that the last player to vote ends the vote instantly (see
+ /// ), locking their choice in with no chance for anyone to
+ /// respond. If that reads badly in playtest, the fix is a short grace period after the final
+ /// ballot rather than closing the ballots.
+ ///
+ /// Scene singleton, server-authoritative. Unlike PlayerDraft (one per
+ /// player) this is one shared object, so votes arrive as plain server RPCs and the server maps
+ /// the sender to a itself rather than trusting an owner claim.
+ ///
+ public class WaveVote : NetworkBehaviour
+ {
+ // ----- Singleton ---------------------------------------------------
+
+ public static WaveVote Instance { get; private set; }
+
+ // ----- Inspector ---------------------------------------------------
+
+ [Tooltip("How many buff cards the vote offers. Standard is 3.")]
+ [SerializeField] private int optionsPerVote = 3;
+
+ // ----- Networked state ---------------------------------------------
+
+ // The EnemyUpgradeOptionIds on the table. Empty when no vote is open.
+ private NetworkList offeredOptionIds;
+
+ // Ballots indexed by (int)PlayerSlot, sized 10 (slots 0-9; index 0 = PlayerSlot.None and
+ // is never written). Value is the option id voted for, or NoVote. Public read permission
+ // is deliberate — see the class remarks.
+ private NetworkList ballots;
+
+ /// Sentinel stored in for a player who hasn't voted.
+ public const int NoVote = -1;
+
+ // Wave slot this vote will upgrade, or -1 when no vote is open.
+ private readonly NetworkVariable targetSlot = new NetworkVariable(
+ -1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
+
+ /// Fired on every peer when the offered set, any ballot, or the open/closed
+ /// state changes. The HUD subscribes to rebuild the vote panel and its voter badges.
+ public event System.Action OnVoteChanged;
+
+ // ----- Lifecycle ----------------------------------------------------
+
+ private void Awake()
+ {
+ offeredOptionIds = new NetworkList();
+ ballots = new NetworkList();
+ }
+
+ public override void OnNetworkSpawn()
+ {
+ if (Instance != null && Instance != this)
+ {
+ Debug.LogError("[WaveVote] Duplicate WaveVote detected. Only one may exist per scene.");
+ return;
+ }
+ Instance = this;
+
+ if (IsServer)
+ {
+ // One entry per PlayerSlot value (None + Player1..Player9).
+ for (int i = 0; i < 10; i++) ballots.Add(NoVote);
+ }
+
+ offeredOptionIds.OnListChanged += HandleListChanged;
+ ballots.OnListChanged += HandleListChanged;
+ targetSlot.OnValueChanged += HandleTargetChanged;
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ offeredOptionIds.OnListChanged -= HandleListChanged;
+ ballots.OnListChanged -= HandleListChanged;
+ targetSlot.OnValueChanged -= HandleTargetChanged;
+
+ if (Instance == this) Instance = null;
+ }
+
+ private void HandleListChanged(NetworkListEvent _) => OnVoteChanged?.Invoke();
+ private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke();
+
+ // ----- Read API ------------------------------------------------------
+
+ /// True while a vote is open for players to cast or change a ballot.
+ public bool IsOpen => targetSlot.Value >= 0;
+
+ /// Wave slot this vote will upgrade, or -1 when closed.
+ public int TargetSlot => targetSlot.Value;
+
+ public int OptionCount => offeredOptionIds.Count;
+
+ public int GetOptionId(int index)
+ => (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1;
+
+ /// The option voted for, or .
+ public int GetBallot(PlayerSlot slot)
+ {
+ int i = (int)slot;
+ return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote;
+ }
+
+ ///
+ /// Appends every player slot that voted for . Drives the voter
+ /// badges the HUD paints onto each card.
+ ///
+ public void GetVotersFor(int optionId, List into)
+ {
+ into.Clear();
+ if (optionId < 0) return;
+
+ foreach (var pms in PlayerMatchState.AllPlayers)
+ {
+ if (pms.Slot == PlayerSlot.None) continue;
+ if (GetBallot(pms.Slot) == optionId) into.Add(pms.Slot);
+ }
+ }
+
+ ///
+ /// True when every connected player with a real slot has cast a ballot. The inter-wave
+ /// step uses this to end the vote early instead of burning the whole timer.
+ ///
+ public bool AllPlayersVoted
+ {
+ get
+ {
+ if (!IsOpen) return false;
+
+ bool any = false;
+ foreach (var pms in PlayerMatchState.AllPlayers)
+ {
+ if (pms.Slot == PlayerSlot.None) continue;
+ any = true;
+ if (GetBallot(pms.Slot) == NoVote) return false;
+ }
+ return any;
+ }
+ }
+
+ // ----- Server: open / resolve ----------------------------------------
+
+ // Scratch reused across vote generation; server is single-threaded.
+ private readonly List entryScratch = new List();
+ private readonly List idScratch = new List();
+ private readonly List weightScratch = new List();
+ private readonly List tiedScratch = new List();
+
+ ///
+ /// Server-only: open a vote for , drawing cards from the
+ /// current phase's allowed pool. Returns false (and opens nothing) if there is nothing
+ /// legal left to offer — a wave that has already collected every card its phase allows
+ /// simply gets no vote rather than an empty panel.
+ ///
+ public bool ServerOpenVote(int waveSlot)
+ {
+ if (!IsServer) return false;
+
+ var run = RunState.Instance;
+ var pool = EnemyUpgradePool.Instance;
+ if (run == null || pool == null)
+ {
+ Debug.LogWarning("[WaveVote] No RunState or EnemyUpgradePool in scene — " +
+ "skipping the enemy-buff vote.");
+ return false;
+ }
+
+ if (!CollectCandidatesFor(waveSlot, run, pool))
+ {
+ Debug.Log($"[WaveVote] No enemy-buff cards left to offer for wave slot " +
+ $"{waveSlot}; skipping the vote.");
+ return false;
+ }
+
+ // Weighted draw without replacement.
+ offeredOptionIds.Clear();
+ int draws = Mathf.Min(optionsPerVote, idScratch.Count);
+ for (int n = 0; n < draws; n++)
+ {
+ int pick = DrawWeightedIndex(weightScratch);
+ if (pick < 0) break;
+
+ offeredOptionIds.Add(idScratch[pick]);
+ idScratch.RemoveAt(pick);
+ weightScratch.RemoveAt(pick);
+ }
+
+ ServerClearBallots();
+ targetSlot.Value = waveSlot;
+ return true;
+ }
+
+ ///
+ /// Server-only: tally the ballots, apply the winner to the target slot, and close the
+ /// vote. Returns the winning option id, or -1 if no vote was open.
+ ///
+ ///
+ /// Ties resolve randomly among the tied options. At three players and three cards a 1/1/1
+ /// split is common, and with open ballots the players can see it coming and break it
+ /// themselves — which is the intended pressure, not a flaw in the tie-break.
+ ///
+ /// If nobody voted at all (everyone idle), one of the offered cards is chosen at
+ /// random. Skipping the buff entirely would quietly make idling the optimal play.
+ ///
+ public int ServerResolve()
+ {
+ if (!IsServer || !IsOpen) return -1;
+
+ int slot = targetSlot.Value;
+ int winner = TallyWinner();
+
+ if (winner >= 0)
+ {
+ var run = RunState.Instance;
+ var pool = EnemyUpgradePool.Instance;
+
+ run?.ServerAddUpgrade(slot, winner);
+ pool?.Get(winner)?.ServerOnApplied(run, slot);
+
+ Debug.Log($"[WaveVote] Wave slot {slot} gains " +
+ $"'{pool?.Get(winner)?.DisplayName ?? winner.ToString()}'.");
+ }
+
+ ServerClose();
+ return winner;
+ }
+
+ ///
+ /// Server-only: apply buffs to outright,
+ /// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade
+ /// a future vote away for extra buffs.
+ ///
+ ///
+ /// Deliberately opens no ballot: the card's whole cost is that the players don't get
+ /// to choose this time. Drawing from the same weighted, slot-filtered candidate set the
+ /// vote would have used keeps the outcome fair rather than worst-case.
+ ///
+ public int ServerAutoApply(int slot, int count)
+ {
+ if (!IsServer || count <= 0) return 0;
+
+ var run = RunState.Instance;
+ var pool = EnemyUpgradePool.Instance;
+ if (run == null || pool == null) return 0;
+
+ var phase = run.CurrentPhase;
+ if (phase == null) return 0;
+
+ int applied = 0;
+ for (int n = 0; n < count; n++)
+ {
+ // Re-collect each time: applying one buff can invalidate another (a card the slot
+ // now has, or one whose prerequisite just changed).
+ if (!CollectCandidatesFor(slot, run, pool)) break;
+
+ int pick = DrawWeightedIndex(weightScratch);
+ if (pick < 0) break;
+
+ int id = idScratch[pick];
+ run.ServerAddUpgrade(slot, id);
+ pool.Get(id)?.ServerOnApplied(run, slot);
+ applied++;
+
+ Debug.Log($"[WaveVote] Auto-applied '{pool.Get(id)?.DisplayName ?? id.ToString()}' " +
+ $"to wave slot {slot}.");
+ }
+
+ return applied;
+ }
+
+ /// Server-only: close the vote without applying anything.
+ public void ServerClose()
+ {
+ if (!IsServer) return;
+ targetSlot.Value = -1;
+ offeredOptionIds.Clear();
+ ServerClearBallots();
+ }
+
+ private void ServerClearBallots()
+ {
+ for (int i = 0; i < ballots.Count; i++) ballots[i] = NoVote;
+ }
+
+ // Highest vote count wins; ties broken at random. No votes cast → random offered card.
+ private int TallyWinner()
+ {
+ if (offeredOptionIds.Count == 0) return -1;
+
+ int best = 0;
+ tiedScratch.Clear();
+
+ for (int i = 0; i < offeredOptionIds.Count; i++)
+ {
+ int id = offeredOptionIds[i];
+ int count = 0;
+
+ foreach (var pms in PlayerMatchState.AllPlayers)
+ {
+ if (pms.Slot == PlayerSlot.None) continue;
+ if (GetBallot(pms.Slot) == id) count++;
+ }
+
+ if (count > best)
+ {
+ best = count;
+ tiedScratch.Clear();
+ tiedScratch.Add(id);
+ }
+ else if (count == best && best > 0)
+ {
+ tiedScratch.Add(id);
+ }
+ }
+
+ if (tiedScratch.Count == 0)
+ {
+ // Nobody voted — pick one of the offered cards rather than letting the wave
+ // escape unbuffed, which would make going idle the strongest play.
+ return offeredOptionIds[Random.Range(0, offeredOptionIds.Count)];
+ }
+
+ return tiedScratch[Random.Range(0, tiedScratch.Count)];
+ }
+
+ ///
+ /// Fills / with the cards this phase
+ /// allows that can still meaningfully take. Returns false when
+ /// nothing qualifies.
+ ///
+ private bool CollectCandidatesFor(int slot, RunState run, EnemyUpgradePool pool)
+ {
+ var phase = run.CurrentPhase;
+ if (phase == null) return false;
+
+ pool.CollectAllowedIds(phase.EnemyUpgradeGroups, entryScratch, idScratch, weightScratch);
+
+ // Filter to what this slot can actually take: not already applied, and the card itself
+ // agrees it's a meaningful offer.
+ for (int i = idScratch.Count - 1; i >= 0; i--)
+ {
+ int id = idScratch[i];
+ var option = pool.Get(id);
+
+ bool valid = option != null
+ && !run.SlotHasUpgrade(slot, id)
+ && option.IsValidForSlot(run, slot);
+
+ if (!valid)
+ {
+ idScratch.RemoveAt(i);
+ weightScratch.RemoveAt(i);
+ }
+ }
+
+ return idScratch.Count > 0;
+ }
+
+ private static int DrawWeightedIndex(List weights)
+ {
+ if (weights == null || weights.Count == 0) return -1;
+
+ float total = 0f;
+ for (int i = 0; i < weights.Count; i++) total += Mathf.Max(0f, weights[i]);
+ if (total <= 0f) return -1;
+
+ float roll = Random.value * total;
+ for (int i = 0; i < weights.Count; i++)
+ {
+ roll -= Mathf.Max(0f, weights[i]);
+ if (roll <= 0f) return i;
+ }
+ return weights.Count - 1; // float drift fallback
+ }
+
+ // ----- Client → server ------------------------------------------------
+
+ ///
+ /// Cast or change this player's vote. Any client may send; the server resolves the sender
+ /// to a slot itself and ignores anything it can't attribute or that isn't on the table.
+ ///
+ [Rpc(SendTo.Server)]
+ public void RequestVoteRpc(int optionId, RpcParams rpcParams = default)
+ {
+ if (!IsOpen) return;
+
+ var senderSlot = PlayerMatchState.SlotForClient(rpcParams.Receive.SenderClientId);
+ if (senderSlot == PlayerSlot.None) return;
+
+ // Only options actually on the table are accepted — a client can't vote a card in.
+ bool offered = false;
+ for (int i = 0; i < offeredOptionIds.Count; i++)
+ if (offeredOptionIds[i] == optionId) { offered = true; break; }
+ if (!offered) return;
+
+ int index = (int)senderSlot;
+ if (index >= 0 && index < ballots.Count) ballots[index] = optionId;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta
new file mode 100644
index 0000000..20d621a
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a18321dc2d138034fbdb5f705f19f893
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/GoldConfig.cs b/Assets/_Project/Scripts/Gameplay/GoldConfig.cs
index 965ee7e..460223a 100644
--- a/Assets/_Project/Scripts/Gameplay/GoldConfig.cs
+++ b/Assets/_Project/Scripts/Gameplay/GoldConfig.cs
@@ -93,18 +93,26 @@ namespace TD.Gameplay
[Tooltip("Gold each player starts the match with. Same for every player.")]
public int StartingGold = 100;
- [Tooltip("Per-wave gold rules. Element 0 = Wave 1. Match the order and length of " +
- "WaveManager.waveDefinitions; extra entries are ignored, missing entries " +
- "fall back to zero gold for that wave.")]
+ [Tooltip("Per-encounter gold rules. Element 0 = the run's first encounter. Encounters " +
+ "are counted continuously across the whole run — cycles do NOT restart the " +
+ "count — so a phase of 5 waves × 3 cycles + a boss needs 16 entries. Missing " +
+ "entries fall back to zero gold for that encounter.")]
public WaveGoldEntry[] Waves;
///
- /// Returns the gold entry for the given 1-based wave number, or null if out of range.
+ /// Returns the gold entry for the given 1-based encounter number
+ /// (RunState.GlobalEncounterNumber), or null if out of range.
///
- public WaveGoldEntry GetWaveEntry(int waveNumber)
+ ///
+ /// Keyed by encounter, not by wave slot. Under the cyclical run structure the same
+ /// five waves come round three times per phase, so a slot-keyed table would pay the same
+ /// on cycle 3 as on cycle 1 while the enemies got steadily stronger. A run-long counter
+ /// lets payouts climb monotonically with difficulty.
+ ///
+ public WaveGoldEntry GetWaveEntry(int encounterNumber)
{
if (Waves == null) return null;
- int index = waveNumber - 1;
+ int index = encounterNumber - 1;
if (index < 0 || index >= Waves.Length) return null;
return Waves[index];
}
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
index 212dbd7..d572bd7 100644
--- a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
+++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
@@ -210,17 +210,21 @@ namespace TD.Gameplay
// --- Slot allocation (server-only) -------------------------------
+ // Caps at MatchRules.MaxPlayers rather than at the PlayerSlot enum's range — the enum
+ // still runs to 9 so baked LevelData owner grids stay valid, but only the first
+ // MaxPlayers slots are ever handed out. See MatchRules for why.
private static PlayerSlot AllocateNextSlot()
{
- for (int i = 1; i <= 9; i++)
+ for (int i = 1; i <= MatchRules.MaxPlayers; i++)
{
var candidate = (PlayerSlot)i;
if (!s_assignedSlots.Contains(candidate))
return candidate;
}
- Debug.LogError("[PlayerMatchState] No free PlayerSlot (already 9 players). " +
- "Returning None — this client will be unable to play.");
+ Debug.LogError($"[PlayerMatchState] No free PlayerSlot (lobby is full at " +
+ $"{MatchRules.MaxPlayers} players). Returning None — this client " +
+ $"will be unable to play.");
return PlayerSlot.None;
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
new file mode 100644
index 0000000..0b17d88
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
@@ -0,0 +1,116 @@
+// Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
+using System.Collections.Generic;
+using Unity.Netcode;
+using UnityEngine;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Per-player set of unlocked tower upgrade nodes — the tower types this player is
+ /// allowed to convert an existing tower into. Lives on the Player prefab alongside
+ /// .
+ ///
+ ///
+ /// Distinct from the deck, deliberately. is what you
+ /// can build from scratch; this is what you can upgrade into. They have to be
+ /// separate sets because the 2.0 model is two-step: a draft pick unlocks a node, and gold spent
+ /// on an already-placed tower converts it. Unlocking an upgrade must not make it directly
+ /// buildable, or the gold step — the entire cost of the upgrade — is bypassed.
+ ///
+ /// The tree lives in the data. Edges are TowerDefinition.UpgradePaths; this
+ /// component only records which nodes a player has earned the right to use. Whether a
+ /// particular tower can take a particular upgrade is a question for the tree (is it a child of
+ /// what's placed?) crossed with this set (has the player unlocked it?).
+ ///
+ /// Append-only within a match, mirroring the deck. Reset happens by re-initializing at
+ /// match start, so Retry / return-to-lobby cycles start clean.
+ ///
+ public class PlayerTowerUpgrades : NetworkBehaviour
+ {
+ // ----- Static registry (mirrors PlayerTowerDeck / PlayerGoldManager) -----
+
+ private static readonly Dictionary s_byClientId
+ = new Dictionary();
+
+ public static PlayerTowerUpgrades GetForClient(ulong clientId)
+ {
+ s_byClientId.TryGetValue(clientId, out var upgrades);
+ return upgrades;
+ }
+
+ public static PlayerTowerUpgrades Local
+ {
+ get
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null || !nm.IsClient) return null;
+ return GetForClient(nm.LocalClientId);
+ }
+ }
+
+ // ----- Networked state --------------------------------------------
+
+ // TowerTypeIds (catalog indices) this player may upgrade into.
+ private NetworkList unlockedNodes;
+
+ /// Fired on every peer when the unlocked set changes. The HUD subscribes so a
+ /// selected tower's upgrade buttons appear the moment a draft grants them.
+ public event System.Action OnUpgradesChanged;
+
+ private void Awake()
+ {
+ unlockedNodes = new NetworkList();
+ }
+
+ public override void OnNetworkSpawn()
+ {
+ s_byClientId[OwnerClientId] = this;
+ unlockedNodes.OnListChanged += HandleListChanged;
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ unlockedNodes.OnListChanged -= HandleListChanged;
+ if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
+ s_byClientId.Remove(OwnerClientId);
+ }
+
+ private void HandleListChanged(NetworkListEvent _) => OnUpgradesChanged?.Invoke();
+
+ // ----- Read API ---------------------------------------------------
+
+ public int Count => unlockedNodes.Count;
+
+ public int GetTypeIdAt(int index) => unlockedNodes[index];
+
+ /// True if this player has unlocked the given upgrade node.
+ public bool Contains(int towerTypeId)
+ {
+ for (int i = 0; i < unlockedNodes.Count; i++)
+ if (unlockedNodes[i] == towerTypeId) return true;
+ return false;
+ }
+
+ // ----- Server API -------------------------------------------------
+
+ /// Server-only: clear the unlocked set. Called at match start.
+ public void ServerInitialize()
+ {
+ if (!IsServer) return;
+ unlockedNodes.Clear();
+ }
+
+ ///
+ /// Server-only: unlock an upgrade node. Returns false if already unlocked (so a draft
+ /// option can report a wasted pick rather than silently succeeding).
+ ///
+ public bool ServerUnlock(int towerTypeId)
+ {
+ if (!IsServer) return false;
+ if (towerTypeId < 0 || Contains(towerTypeId)) return false;
+
+ unlockedNodes.Add(towerTypeId);
+ return true;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta
new file mode 100644
index 0000000..c0821f7
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f60f6928562351f4891142ad59ab6352
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
index dfd73a8..6128ab4 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
@@ -303,6 +303,10 @@ namespace TD.Gameplay
ApplyTint();
paintColor.OnValueChanged += HandlePaintColorChanged;
+ // An upgrade swaps the replicated TypeId out from under every peer; re-resolve so
+ // clients pick up the new stats and tint instead of holding the pre-upgrade asset.
+ definitionTypeId.OnValueChanged += HandleDefinitionTypeChanged;
+
// Register for minimap rendering.
MinimapEntityRegistry.Register(this);
@@ -332,7 +336,8 @@ namespace TD.Gameplay
public override void OnNetworkDespawn()
{
- paintColor.OnValueChanged -= HandlePaintColorChanged;
+ paintColor.OnValueChanged -= HandlePaintColorChanged;
+ definitionTypeId.OnValueChanged -= HandleDefinitionTypeChanged;
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
// so the tiles become walkable and buildable again.
@@ -412,6 +417,152 @@ namespace TD.Gameplay
upgradeCount.Value += 1;
}
+ // ----- Upgrading ------------------------------------------------------
+
+ ///
+ /// Fired on every peer when this tower's definition changes (i.e. it was upgraded).
+ /// The HUD subscribes to relabel a selected tower's action grid.
+ ///
+ public event System.Action OnDefinitionChanged;
+
+ ///
+ /// Gold cost to convert this tower into . The target's own
+ /// is the price — upgrade nodes are never placed
+ /// directly, so their cost field is free to mean "what this upgrade costs".
+ ///
+ public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0;
+
+ ///
+ /// True if is a legal upgrade of this tower's current type: a
+ /// direct child in the upgrade tree, with a matching footprint.
+ ///
+ ///
+ /// The footprint check is not cosmetic. A tower's occupied/unwalkable tiles are
+ /// stamped into the grid at its current size; converting to a differently-sized node would
+ /// leave the stamp describing a shape the tower no longer has, corrupting both pathfinding
+ /// and future placement checks. Growing a tower's footprint needs a stamp-swap (and a
+ /// re-validation that the new tiles are even free), which the tree doesn't currently need —
+ /// so it's rejected loudly rather than half-supported.
+ ///
+ public bool CanUpgradeTo(TowerDefinition target)
+ {
+ if (target == null || resolvedDefinition == null) return false;
+ if (resolvedDefinition.UpgradePaths == null) return false;
+
+ bool isChild = false;
+ foreach (var path in resolvedDefinition.UpgradePaths)
+ {
+ if (path == target) { isChild = true; break; }
+ }
+ if (!isChild) return false;
+
+ return target.FootprintSize == resolvedDefinition.FootprintSize;
+ }
+
+ ///
+ /// Collects the upgrades can currently apply to this tower:
+ /// direct children of its type that the player has unlocked. Does not filter on gold —
+ /// the HUD shows unaffordable upgrades disabled rather than hiding them, so players can
+ /// see what they're saving for.
+ ///
+ public void CollectAvailableUpgrades(ulong clientId, List<(TowerDefinition Def, int TypeId)> into)
+ {
+ into.Clear();
+
+ var unlocked = PlayerTowerUpgrades.GetForClient(clientId);
+ var pm = TowerPlacementManager.Instance;
+ if (unlocked == null || pm == null || resolvedDefinition?.UpgradePaths == null) return;
+
+ foreach (var path in resolvedDefinition.UpgradePaths)
+ {
+ if (path == null) continue;
+ if (!pm.TryGetTypeId(path, out int typeId)) continue;
+ if (!unlocked.Contains(typeId)) continue;
+ if (!CanUpgradeTo(path)) continue;
+
+ into.Add((path, typeId));
+ }
+ }
+
+ ///
+ /// Client → server request to convert this tower into .
+ /// Accepted only from the owner, only for a node they've unlocked, only along a real tree
+ /// edge, and only if they can pay.
+ ///
+ ///
+ /// Every check is repeated here even though the HUD already filters — the HUD is a
+ /// convenience, this is the authority. Same posture as placement, paint, and sell.
+ ///
+ [Rpc(SendTo.Server)]
+ public void RequestUpgradeServerRpc(int targetTypeId, RpcParams rpcParams = default)
+ {
+ if (!IsServer) return;
+ if (serverSold) return; // mid-sell; nothing to upgrade
+
+ ulong senderClientId = rpcParams.Receive.SenderClientId;
+ PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId);
+ if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value)
+ {
+ Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} " +
+ $"({senderSlot}) does not own tower owned by {ownerSlot.Value}.");
+ return;
+ }
+
+ var target = TowerPlacementManager.GetDefinition(targetTypeId);
+ if (target == null)
+ {
+ Debug.Log($"[TowerInstance] Upgrade rejected: TypeId {targetTypeId} is not in the catalog.");
+ return;
+ }
+
+ if (!CanUpgradeTo(target))
+ {
+ Debug.Log($"[TowerInstance] Upgrade rejected: '{target.name}' is not a " +
+ $"same-footprint child of '{resolvedDefinition?.name}'.");
+ return;
+ }
+
+ var unlocked = PlayerTowerUpgrades.GetForClient(senderClientId);
+ if (unlocked == null || !unlocked.Contains(targetTypeId))
+ {
+ Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} has not " +
+ $"unlocked '{target.name}'.");
+ return;
+ }
+
+ int cost = GetUpgradeCost(target);
+ var gold = PlayerGoldManager.GetForClient(senderClientId);
+ if (gold == null || gold.CurrentGold < cost)
+ {
+ Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} cannot " +
+ $"afford '{target.name}' ({cost}g).");
+ return;
+ }
+
+ if (cost > 0) gold.DeductGold(cost);
+
+ // Record the spend before switching type, so the refund reflects everything sunk in
+ // and the tower loses any full-refund-while-unupgraded status.
+ ServerAddUpgradeInvestment(cost);
+
+ // Switching the replicated TypeId is the upgrade: TowerCombat re-reads Definition
+ // every tick, so the new stats take effect on the next shot with nothing to notify.
+ definitionTypeId.Value = targetTypeId;
+ resolvedDefinition = target;
+
+ OnDefinitionChanged?.Invoke();
+ }
+
+ // Re-resolve and re-tint on clients when the type changes under them (an upgrade landed).
+ private void HandleDefinitionTypeChanged(int previous, int current)
+ {
+ if (previous == current) return;
+
+ resolvedDefinition = TowerPlacementManager.GetDefinition(current);
+ ApplyTint();
+ OnDefinitionChanged?.Invoke();
+ }
+
///
/// Client → server request to sell this tower. Accepted only from the tower's owner
/// (same ownership rule as placement and paint). The server refunds gold, broadcasts
diff --git a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
index 7398dd0..f23c5fa 100644
--- a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
+++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
@@ -41,5 +41,64 @@ namespace TD.Gameplay
[Tooltip("Enemy groups that make up this wave. Processed in order.")]
public WaveEntry[] Entries;
+
+ ///
+ /// The enemy type this wave is "about" — the first assigned entry's type. Null if the
+ /// wave has no usable entries.
+ ///
+ ///
+ /// The 2.0 design gives each wave a single enemy type, and enemy upgrades are keyed to a
+ /// wave slot, so this is the identity the phase draw uses to keep slots distinct and the
+ /// HUD uses to label an upcoming wave. still supports multiple
+ /// groups (useful for staggering counts of the same type), but mixing types within
+ /// one wave makes this ambiguous — see .
+ ///
+ public EnemyDefinition PrimaryEnemyType
+ {
+ get
+ {
+ if (Entries == null) return null;
+ foreach (var e in Entries)
+ {
+ if (e.EnemyType != null && e.Count > 0) return e.EnemyType;
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// True if every usable entry in this wave spawns the same enemy type. False means
+ /// is only telling part of the story — the run validator
+ /// warns on these rather than rejecting them, since a mixed wave still plays fine, it
+ /// just labels and upgrades oddly.
+ ///
+ public bool HasSingleEnemyType
+ {
+ get
+ {
+ var first = PrimaryEnemyType;
+ if (first == null) return false;
+ foreach (var e in Entries)
+ {
+ if (e.EnemyType != null && e.Count > 0 && e.EnemyType != first) return false;
+ }
+ return true;
+ }
+ }
+
+ /// Total enemies spawned per zone by this wave, across all entries.
+ public int TotalEnemyCount
+ {
+ get
+ {
+ if (Entries == null) return 0;
+ int total = 0;
+ foreach (var e in Entries)
+ {
+ if (e.EnemyType != null && e.Count > 0) total += e.Count;
+ }
+ return total;
+ }
+ }
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs
index ce61ce2..b393f9f 100644
--- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs
+++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs
@@ -6,44 +6,54 @@ using TD.Core;
using TD.Gameplay.BuilderEffects;
using TD.Gameplay.Draft;
using TD.Gameplay.EnemyAbilities;
+using TD.Gameplay.EnemyUpgrades;
+using TD.Gameplay.Waves;
using TD.Levels;
using TD.UI;
namespace TD.Gameplay
{
///
- /// Server-authoritative wave controller. Spawns enemies across all player zones,
- /// tracks wave completion, awards kill gold, and manages the shared lives pool.
+ /// Server-authoritative encounter driver. Spawns enemies across all player zones, tracks
+ /// completion, awards kill gold, and manages the shared lives pool. Which encounter to
+ /// run comes from ; this class only runs it.
///
///
- /// Wave lifecycle:
+ /// Encounter lifecycle:
///
- /// - When is entered,
- /// advances
- /// immediately (so the HUD shows the wave number during prep), then waits
- /// before spawning.
- /// - Each spawns Count enemies per player zone,
- /// one zone per frame-group, with SpawnInterval seconds between
- /// individual enemies in the group.
- /// - After all entries are spawned, the wave is considered complete only when
- /// every active enemy is either killed or has reached the goal.
- /// - All waves exhausted → .
+ /// - When is entered, asks
+ /// to draw phase 1 and then runs its first wave.
+ /// - Each encounter is: inter-wave step (draft, then the enemy-buff vote, then the
+ /// build countdown) → spawn → mop up. See .
+ /// - Each spawns Count enemies per player zone. All
+ /// spawn held, then release in 10% chunks on ReleaseInterval.
+ /// - The encounter is complete only when every spawned enemy is dead or has leaked.
+ /// then decides what comes next.
+ /// - Final phase's boss dies → .
/// - Lives drop to 0 → .
///
///
+ /// Why the wave list moved out. Waves used to be a hand-ordered array walked
+ /// start-to-finish, so "which wave is next" was just an index++. Under the 2.0 cyclical
+ /// design the same five waves repeat across three cycles while accumulating player-voted
+ /// upgrades, and each phase draws a fresh five — progression that no longer fits in an index.
+ /// owns it; this class asks it what to run.
+ ///
/// Kill gold: When an enemy dies, names
/// the tower's player. resolves the
/// OwnerClientId, and awards
- /// the gold.
+ /// the gold. GoldConfig is indexed by
+ /// — a run-long counter, not a per-cycle one, so
+ /// per-wave payouts keep climbing as the cycles repeat instead of resetting.
///
/// Zone leak counts: is a NetworkList
- /// indexed by (int)PlayerSlot (indices 0–8). It is incremented when an enemy
+ /// indexed by (int)PlayerSlot. It is incremented when an enemy
/// crosses from one player zone into another, giving the HUD a per-player leak score.
/// Index 0 corresponds to and is unused.
///
/// Inspector setup:
///
- /// - Assign in order (Wave 1 at index 0).
+ /// - Assign a RunDefinition to the scene's .
/// - Set to match your level design intent.
///
///
@@ -55,12 +65,18 @@ namespace TD.Gameplay
// ----- Inspector --------------------------------------------------
- [Tooltip("Wave definitions in order. Index 0 = Wave 1.")]
- [SerializeField] private WaveDefinition[] waveDefinitions;
-
[Tooltip("Shared lives pool at the start of a match.")]
[SerializeField] private int startingLives = 20;
+ [Tooltip("Seconds players get to make their personal draft pick. Ends early once every " +
+ "player has picked. Be generous — this is reading time, not reaction time.")]
+ [SerializeField] private float draftTimeSeconds = 30f;
+
+ [Tooltip("Seconds players get to vote on the enemy buff. Ends early once every player " +
+ "has voted. Generous enough that players can watch each other's votes land and " +
+ "change their own in response.")]
+ [SerializeField] private float voteTimeSeconds = 25f;
+
[Tooltip("Single source of truth for every gold tunable: starting gold, per-wave " +
"kill rewards, completion bonus, no-leak bonus. Required for a real match; " +
"if unset the game falls back to per-player startingGold defaults and grants " +
@@ -95,14 +111,27 @@ namespace TD.Gameplay
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
+ // Which inter-wave stage the countdown above belongs to. One countdown serves all three
+ // stages (they never overlap), so the HUD needs this to label it correctly.
+ private readonly NetworkVariable interWaveStage =
+ new NetworkVariable(
+ value: InterWaveStage.None,
+ readPerm: NetworkVariableReadPermission.Everyone,
+ writePerm: NetworkVariableWritePermission.Server);
+
// ----- Server-local runtime state ---------------------------------
private int remainingLives;
private int activeEnemyCount;
private bool spawningComplete;
- private int currentWaveIndex = -1; // -1 = not yet started
+ private bool runStarted;
private Coroutine activeWaveCoroutine;
+ // Wave slot the NEXT inter-wave vote will buff — captured when an encounter clears, before
+ // the run advances past it. -1 means "no vote due": the run's first encounter (nothing has
+ // been beaten yet) or straight after a boss (the phase's slots are being wiped).
+ private int pendingVoteSlot = -1;
+
private readonly System.Collections.Generic.List heldEnemies
= new System.Collections.Generic.List();
@@ -178,6 +207,11 @@ namespace TD.Gameplay
{
var deck = PlayerTowerDeck.GetForClient(pms.OwnerClientId);
if (deck != null) deck.ServerInitialize(startingTypeIds);
+
+ // Upgrade nodes start empty — every one is earned through the draft. Cleared
+ // rather than left alone so Retry / return-to-lobby doesn't carry a previous
+ // run's unlocks into a new one.
+ PlayerTowerUpgrades.GetForClient(pms.OwnerClientId)?.ServerInitialize();
}
}
else
@@ -187,7 +221,7 @@ namespace TD.Gameplay
}
if (ms.Phase == MatchPhase.Playing)
- StartNextWave();
+ StartRun();
}
public override void OnNetworkDespawn()
@@ -200,13 +234,6 @@ namespace TD.Gameplay
// ----- Public accessors -------------------------------------------
- ///
- /// Total number of waves in this match. Same on every peer because
- /// is a serialized prefab field, identical
- /// on host and clients. Returns 0 if the array is unassigned.
- ///
- public int TotalWaves => waveDefinitions?.Length ?? 0;
-
///
/// Number of times enemies have leaked out of the given player's zone over the
/// entire match. Replicated — safe to call on any peer.
@@ -230,34 +257,82 @@ namespace TD.Gameplay
///
public float PrepCountdown => prepCountdown.Value;
+ ///
+ /// Which inter-wave step the current countdown belongs to, or
+ /// while a wave is spawning or being fought. Replicated;
+ /// safe on any peer. The HUD uses it to label the shared countdown.
+ ///
+ public InterWaveStage CurrentInterWaveStage => interWaveStage.Value;
+
+
+ ///
+ /// 1-based encounter number since the run began, or 0 before it starts. This is the key
+ /// is indexed by. Replicated via , so it's
+ /// safe on any peer.
+ ///
+ public int CurrentEncounterNumber
+ => RunState.Instance != null && runStarted ? RunState.Instance.GlobalEncounterNumber : 0;
// ----- Phase handling ---------------------------------------------
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
{
if (!IsServer) return;
- if (next == MatchPhase.Playing && currentWaveIndex < 0)
- StartNextWave();
+ if (next == MatchPhase.Playing) StartRun();
}
- // ----- Wave coroutine ---------------------------------------------
+ // ----- Run control ------------------------------------------------
- private void StartNextWave(bool skipPrep = false)
+ ///
+ /// Server-only: begin the run. Draws phase 1 and starts its first encounter. Idempotent —
+ /// re-entering won't restart a run in progress.
+ ///
+ private void StartRun()
{
- currentWaveIndex++;
+ if (!IsServer || runStarted) return;
- if (waveDefinitions == null || currentWaveIndex >= waveDefinitions.Length)
+ var run = RunState.Instance;
+ if (run == null)
{
- Debug.Log("[WaveManager] All waves complete. Victory.");
- MatchState.Instance?.SetPhase(MatchPhase.Victory);
+ Debug.LogError("[WaveManager] No RunState in the scene — no waves can run. " +
+ "Add a RunState object and assign it a RunDefinition.");
return;
}
- // Advance the replicated wave counter at the START of prep so the HUD
- // shows the upcoming wave number during the countdown.
- MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
+ if (!run.ServerBeginRun())
+ {
+ // ServerBeginRun already logged the specific reason (missing asset, unplayable
+ // pools). Bail rather than spawn an empty encounter that can never complete.
+ Debug.LogError("[WaveManager] Run could not be started; see the RunState error above.");
+ return;
+ }
- // Reset per-wave bookkeeping for the wave that's about to begin:
+ runStarted = true;
+ StartEncounter(skipInterWave: false);
+ }
+
+ ///
+ /// Server-only: run whatever encounter is currently pointing at.
+ ///
+ /// Skip the draft/vote/build step and spawn immediately.
+ /// Used by the dev force-advance cheat.
+ private void StartEncounter(bool skipInterWave)
+ {
+ var run = RunState.Instance;
+ var def = run?.CurrentWave;
+
+ if (def == null)
+ {
+ Debug.LogError($"[WaveManager] No wave drawn for {run?.ProgressLabel ?? "?"} — " +
+ $"the run cannot continue.");
+ return;
+ }
+
+ // Publish the run-long encounter number so the HUD and the gold lookup agree on
+ // which encounter's payout table applies.
+ MatchState.Instance?.SetCurrentWave(run.GlobalEncounterNumber);
+
+ // Reset per-wave bookkeeping for the encounter that's about to begin:
// - waveLeakCounts: per-slot leaks this wave, used for the no-leak bonus.
// - PlayerGoldManager.goldEarnedThisWave: HUD top-bar resets so the
// "+N g/wave" counter starts at 0.
@@ -272,23 +347,91 @@ namespace TD.Gameplay
activeEnemyCount = 0;
spawningComplete = false;
- activeWaveCoroutine = StartCoroutine(
- RunWave(waveDefinitions[currentWaveIndex], skipPrep));
+ activeWaveCoroutine = StartCoroutine(RunEncounter(def, skipInterWave));
+ }
+
+ ///
+ /// Server-only: the encounter is over — ask what's next and either
+ /// start it or end the match.
+ ///
+ private void AdvanceRun()
+ {
+ var run = RunState.Instance;
+ if (run == null) return;
+
+ switch (run.ServerAdvance())
+ {
+ case RunAdvance.RunComplete:
+ Debug.Log("[WaveManager] Final boss defeated. Victory.");
+ MatchState.Instance?.SetPhase(MatchPhase.Victory);
+ return;
+
+ case RunAdvance.BossReady:
+ Debug.Log($"[WaveManager] All cycles cleared — boss incoming " +
+ $"({run.ProgressLabel}).");
+ break;
+
+ case RunAdvance.NextPhase:
+ Debug.Log($"[WaveManager] Boss down. New phase drawn: {run.ProgressLabel}. " +
+ $"Enemy upgrades wiped.");
+ break;
+
+ case RunAdvance.NextCycle:
+ Debug.Log($"[WaveManager] Cycle complete — same waves return upgraded " +
+ $"({run.ProgressLabel}).");
+ break;
+ }
+
+ StartEncounter(skipInterWave: false);
}
// ----- Dev / cheats -----------------------------------------------
///
- /// Dev cheat: skip the rest of the current wave (despawn any remaining
- /// enemies, kill the prep timer) and start the next wave immediately.
- /// Server-only — silently no-ops on clients. Safe to call during prep,
- /// mid-spawn, or while enemies are alive.
+ /// Dev cheat: skip the rest of the current encounter (despawn any remaining enemies, kill
+ /// the timers) and start the next one immediately. Server-only — silently no-ops on
+ /// clients. Safe to call during the inter-wave step, mid-spawn, or while enemies are alive.
///
public void ForceAdvanceToNextWave()
{
- if (!IsServer) return;
+ if (!IsServer || !runStarted) return;
+ ClearFieldAndStopEncounter();
+ AdvanceRun();
+ }
- // Stop the current wave's coroutine (cancels prep timer + remaining spawns).
+ ///
+ /// Dev cheat: skip whole encounters until the run reaches this phase's boss. Useful for
+ /// testing boss content without playing fifteen waves. Server-only.
+ ///
+ public void ForceAdvanceToBoss()
+ {
+ if (!IsServer || !runStarted) return;
+
+ var run = RunState.Instance;
+ if (run == null || run.IsBossStage) return;
+
+ ClearFieldAndStopEncounter();
+
+ // Advance until the boss is up (or the run ends, which shouldn't happen from a
+ // non-boss encounter but is guarded anyway).
+ int guard = run.EncountersPerPhase + 1;
+ while (!run.IsBossStage && guard-- > 0)
+ {
+ if (run.ServerAdvance() == RunAdvance.RunComplete)
+ {
+ MatchState.Instance?.SetPhase(MatchPhase.Victory);
+ return;
+ }
+ }
+
+ StartEncounter(skipInterWave: false);
+ }
+
+ // Shared teardown for the dev cheats: stop the running encounter, wipe the field, and
+ // resolve anything the players had open so nothing is left dangling mid-skip.
+ private void ClearFieldAndStopEncounter()
+ {
+ // Stop the current encounter's coroutine (cancels timers + remaining spawns).
if (activeWaveCoroutine != null)
{
StopCoroutine(activeWaveCoroutine);
@@ -316,59 +459,40 @@ namespace TD.Gameplay
}
}
- // Reset bookkeeping and start the next wave's RunWave coroutine,
- // skipping the prep timer so spawning starts immediately.
activeEnemyCount = 0;
spawningComplete = false;
heldEnemies.Clear();
- // Force-advancing interrupts the prep phase, so resolve any drafts the players
- // had open before skipping ahead.
+ // Skipping ahead interrupts the inter-wave step, so resolve anything the players had
+ // open before moving on. The draft auto-resolves (a free reward shouldn't vanish
+ // because a dev pressed a key); the vote is closed WITHOUT resolving, since applying a
+ // buff nobody finished voting on would silently rewrite the run.
DraftService.Instance?.ServerAutoResolveAll();
+ WaveVote.Instance?.ServerClose();
- StartNextWave(skipPrep: true);
+ interWaveStage.Value = InterWaveStage.None;
+ prepCountdown.Value = 0f;
}
- private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
+ // ----- Encounter coroutine ----------------------------------------
+
+ private IEnumerator RunEncounter(WaveDefinition def, bool skipInterWave = false)
{
- // Prep phase — players build while the countdown ticks. Skipped when
- // a dev cheat forces the wave to start immediately. We tick the
- // replicated prepCountdown each frame so every HUD can render the
- // remaining time consistently. Server is the only writer; clients
- // observe the value via NetworkVariable replication.
- if (!skipPrep)
- {
- // Open a draft for every player at the start of the build phase. They pick
- // during prep; any unpicked draft is auto-resolved when the timer expires.
- DraftService.Instance?.ServerOfferToAll();
+ // Draft, enemy-buff vote, then the build countdown. Skipped entirely when a dev
+ // cheat forces the encounter to start immediately.
+ if (!skipInterWave)
+ yield return RunInterWave(def);
- prepCountdown.Value = def.PrepTime;
- float remaining = def.PrepTime;
- // Throttle network sync to ~10 Hz. NetworkVariable replicates on every
- // mutation; at 60 fps we'd send ~600 deltas per 10s prep purely to
- // animate text that only changes once per second on the HUD. 0.1s
- // gives a smooth-enough fall while keeping bandwidth minimal.
- const float NetworkSyncInterval = 0.1f;
- float nextSync = def.PrepTime - NetworkSyncInterval;
- while (remaining > 0f)
- {
- yield return null;
- remaining = Mathf.Max(0f, remaining - Time.deltaTime);
- if (remaining <= nextSync || remaining <= 0f)
- {
- prepCountdown.Value = remaining;
- nextSync = remaining - NetworkSyncInterval;
- }
- }
-
- // Prep timer expired — auto-resolve any draft the player didn't pick so
- // the free reward isn't wasted.
- DraftService.Instance?.ServerAutoResolveAll();
- }
// Ensure the countdown reads zero entering the spawn phase, regardless of
- // whether prep was skipped or just expired.
+ // whether the build step was skipped or just expired.
prepCountdown.Value = 0f;
+ // Resolve this wave slot's voted buffs once, here — the set is fixed for the whole
+ // encounter, so doing it per enemy would repeat the same lookup hundreds of times.
+ // Must run AFTER the inter-wave step, since the vote that just closed may have added
+ // to a slot this very encounter re-runs on a later cycle.
+ ResolveCurrentWaveAbilities();
+
// Spawn all enemies at once in a held (untargetable, immobile) state.
if (def.Entries != null)
{
@@ -405,6 +529,145 @@ namespace TD.Gameplay
CheckWaveComplete();
}
+ // ----- Inter-wave step ---------------------------------------------
+
+ ///
+ /// The step between encounters: players take their personal draft, then vote on the buff
+ /// the wave they just cleared will carry into the next cycle, then build.
+ ///
+ ///
+ /// The three stages run strictly in sequence and never overlap. The build countdown used
+ /// to double as the draft window, which made "wait until everyone has chosen" impossible
+ /// to express — players who wanted to build were paying for teammates who wanted to read
+ /// their cards. Separating them costs a few seconds per wave and makes both steps mean
+ /// what they say.
+ ///
+ /// Each stage ends early the moment every player has acted, so the generous timers
+ /// are a ceiling for deliberation, not a floor everyone sits through.
+ ///
+ private IEnumerator RunInterWave(WaveDefinition def)
+ {
+ // ----- 1. Personal draft -----
+ // Offered to everyone at once. Anyone who hasn't picked when the timer expires has
+ // their draft auto-resolved, so an idle player never wastes a free reward.
+ interWaveStage.Value = InterWaveStage.Draft;
+ DraftService.Instance?.ServerOfferToAll();
+ yield return TickCountdown(draftTimeSeconds, () => DraftService.AllPlayersPicked);
+ DraftService.Instance?.ServerAutoResolveAll();
+
+ // ----- 2. Enemy-buff vote -----
+ // Votes on the wave that was just cleared, which is the slot captured before the run
+ // advanced. Skipped on the run's first encounter (nothing has been defeated yet) and
+ // after a boss (that phase's slots are being wiped anyway).
+ if (pendingVoteSlot >= 0)
+ {
+ var vote = WaveVote.Instance;
+ var run = RunState.Instance;
+
+ // A vote-meta card may have armed this slot to skip its next vote and take
+ // several buffs outright. That's the card's whole effect, so it pre-empts the
+ // normal vote rather than running alongside it.
+ int autoCount = run != null ? run.GetAutoApplyCount(pendingVoteSlot) : 0;
+
+ if (autoCount > 0 && vote != null)
+ {
+ int applied = vote.ServerAutoApply(pendingVoteSlot, autoCount);
+ run.ServerConsumeAutoApply(pendingVoteSlot);
+ Debug.Log($"[WaveManager] Wave slot {pendingVoteSlot} auto-took {applied} " +
+ $"buff(s); no vote held.");
+ }
+ else if (vote != null && vote.ServerOpenVote(pendingVoteSlot))
+ {
+ interWaveStage.Value = InterWaveStage.Vote;
+ yield return TickCountdown(voteTimeSeconds, () => vote.AllPlayersVoted);
+ vote.ServerResolve();
+ }
+
+ pendingVoteSlot = -1;
+ }
+
+ // ----- 3. Build -----
+ interWaveStage.Value = InterWaveStage.Build;
+ yield return TickCountdown(def.PrepTime);
+
+ interWaveStage.Value = InterWaveStage.None;
+ }
+
+ ///
+ /// Ticks down from to zero,
+ /// exiting early once returns true.
+ ///
+ ///
+ /// Network sync is throttled to ~10 Hz. NetworkVariable replicates on every
+ /// mutation, so ticking at frame rate would push ~600 deltas across a 10-second countdown
+ /// purely to animate text that changes once a second. A tenth of a second still falls
+ /// smoothly while keeping the traffic negligible.
+ ///
+ private IEnumerator TickCountdown(float seconds, System.Func finishedEarly = null)
+ {
+ const float NetworkSyncInterval = 0.1f;
+
+ prepCountdown.Value = seconds;
+ float remaining = seconds;
+ float nextSync = seconds - NetworkSyncInterval;
+
+ while (remaining > 0f)
+ {
+ if (finishedEarly != null && finishedEarly()) break;
+
+ yield return null;
+ remaining = Mathf.Max(0f, remaining - Time.deltaTime);
+
+ if (remaining <= nextSync || remaining <= 0f)
+ {
+ prepCountdown.Value = remaining;
+ nextSync = remaining - NetworkSyncInterval;
+ }
+ }
+
+ prepCountdown.Value = 0f;
+ }
+
+ // ----- Wave ability resolution -------------------------------------
+
+ // Abilities carried by every enemy of the encounter currently spawning. Rebuilt once per
+ // encounter (not per enemy) since a wave's buff set is fixed for its whole duration.
+ private readonly System.Collections.Generic.List currentWaveAbilities
+ = new System.Collections.Generic.List();
+
+ private readonly System.Collections.Generic.List upgradeIdScratch
+ = new System.Collections.Generic.List();
+
+ ///
+ /// Server-only: resolve the current wave slot's voted upgrades into the ability list every
+ /// enemy of this encounter will carry.
+ ///
+ ///
+ /// Called once at the top of an encounter's spawn phase. Cards that resolve to nothing
+ /// (missing pool entry, non-ability card) are skipped silently here — the vote already
+ /// validated them, and a warning per enemy would flood the log.
+ ///
+ private void ResolveCurrentWaveAbilities()
+ {
+ currentWaveAbilities.Clear();
+
+ var run = RunState.Instance;
+ var pool = EnemyUpgradePool.Instance;
+ if (run == null || pool == null) return;
+
+ run.GetUpgradesForSlot(run.CurrentUpgradeSlot, upgradeIdScratch);
+
+ foreach (int id in upgradeIdScratch)
+ {
+ if (pool.Get(id) is AbilityEnemyUpgradeOption card && card.Ability != null)
+ currentWaveAbilities.Add(card.Ability);
+ }
+
+ if (currentWaveAbilities.Count > 0)
+ Debug.Log($"[WaveManager] {run.ProgressLabel} enemies carry " +
+ $"{currentWaveAbilities.Count} wave buff(s).");
+ }
+
// ----- Spawn helpers ----------------------------------------------
private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false)
@@ -430,7 +693,9 @@ namespace TD.Gameplay
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
var spawner = zone.Spawners[0];
(float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea);
- SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held, spawner.Facing);
+ SpawnEnemy(def, spawner.TilePosition, zone.Owner, currentWaveAbilities,
+ xHalfExtent: xHalf, zHalfExtent: zHalf, held: held,
+ facing: spawner.Facing);
}
}
@@ -455,10 +720,18 @@ namespace TD.Gameplay
return (xHalf, zHalf);
}
+ ///
+ /// Server-only: build and spawn one enemy.
+ ///
+ /// Wave buffs this enemy carries. Null/empty for a plain enemy.
+ /// Pre-computed stats to spawn with, bypassing both the
+ /// definition's values and ' spawn modifications. Used by
+ /// split-on-death, whose minions are derived from the parent rather than the asset.
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot,
+ System.Collections.Generic.IReadOnlyList abilities,
+ EnemySpawnContext? contextOverride = null,
float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false,
- Direction facing = Direction.South, bool canRollAbility = true,
- float hpMultiplier = 1f, float visualScale = 1f)
+ Direction facing = Direction.South)
{
if (def.EnemyPrefab == null)
{
@@ -466,6 +739,16 @@ namespace TD.Gameplay
return;
}
+ // Resolve the stats this enemy will actually spawn with. Abilities get first say —
+ // flight, health and size all have to be settled before the position is computed and
+ // before EnemyHealth/EnemyMovement read them, since each is captured once.
+ var context = contextOverride ?? EnemySpawnContext.FromDefinition(def);
+ if (contextOverride == null && abilities != null)
+ {
+ for (int i = 0; i < abilities.Count; i++)
+ abilities[i]?.ServerModifySpawn(ref context);
+ }
+
Vector3 spawnPos = GridCoordinates.GridToWorld(spawnerTile);
if (xHalfExtent > 0f)
spawnPos.x += Random.Range(-xHalfExtent, xHalfExtent);
@@ -475,8 +758,8 @@ namespace TD.Gameplay
// Flying enemies spawn elevated so they visually soar over towers. The
// movement code preserves Y per-frame, so this height persists for the
// enemy's whole flight. NetworkTransform replicates the raised position.
- if (def.IsFlying)
- spawnPos.y += def.FlightHeight;
+ if (context.IsFlying)
+ spawnPos.y += context.FlightHeight;
float yaw = facing switch
{
@@ -492,11 +775,11 @@ namespace TD.Gameplay
spawnPos,
Quaternion.Euler(0f, yaw, 0f));
- // Scales the whole prefab hierarchy uniformly (used for split-on-death minions).
+ // Scales the whole prefab hierarchy uniformly (split minions, size-changing buffs).
// Relies on the enemy prefab's NetworkTransform syncing scale to clients — verify
// that's enabled if a scaled spawn doesn't look smaller on remote peers.
- if (visualScale != 1f)
- go.transform.localScale *= visualScale;
+ if (!Mathf.Approximately(context.VisualScale, 1f))
+ go.transform.localScale *= context.VisualScale;
var health = go.GetComponent();
var movement = go.GetComponent();
@@ -509,16 +792,24 @@ namespace TD.Gameplay
return;
}
- health.InitializeServer(def.MaxHp * hpMultiplier, def.LivesCost, def.IsFlying, held);
- movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying);
+ // Boss-ness comes from the run's position (this is the phase's boss encounter), not
+ // from the wave asset — the same WaveDefinition could legally sit in both a cycle pool
+ // and a boss pool, and it's only a boss when drawn as one. Split minions inherit it
+ // via the context override path, which never sets the flag.
+ bool isBoss = contextOverride == null && (RunState.Instance?.IsBossStage ?? false);
- // Optional — only prefabs with an EnemyAbility component roll an ability. See
+ health.InitializeServer(context.MaxHp, context.LivesCost, context.IsFlying, held, isBoss);
+ movement.InitializeServer(context.MoveSpeed, spawnerTile, ownerSlot, context.IsFlying);
+
+ // Optional — only prefabs with an EnemyAbility component can carry wave buffs. See
// EnemyAbility's remarks for why on-death abilities aren't event-subscription driven.
- // canRollAbility is false for split-spawned minions (see ServerSpawnSplitEnemies) so
- // they can never chain into further splits or pick up any other ability.
var ability = go.GetComponent();
if (ability != null)
- ability.InitializeServer(canRollAbility ? EnemyAbilityPool.Instance?.RollRandom() : null);
+ ability.InitializeServer(abilities, context);
+ else if (abilities != null && abilities.Count > 0)
+ Debug.LogWarning($"[WaveManager] '{def.EnemyPrefab.name}' has no EnemyAbility " +
+ $"component, so this wave's {abilities.Count} buff(s) will not " +
+ $"apply to it. Add the component to the prefab.");
if (held) heldEnemies.Add(health);
@@ -540,18 +831,26 @@ namespace TD.Gameplay
/// activeEnemyCount/event-wiring bookkeeping as any other spawn.
///
///
- /// Split-spawned minions never roll an ability of their own — they're always plain,
- /// so a split can't chain into further splits (or any other ability) regardless of
- /// which EnemyDefinition/prefab is used for .
+ /// Minions inherit nothing. They are spawned with an empty ability list, so a split
+ /// can never chain into further splits or pick up any other buff the parent's wave carries.
+ /// That is the whole termination argument for the recursion — one extra generation, always,
+ /// however many cards the wave has collected.
+ ///
+ /// Their stats come entirely from , which the calling
+ /// ability derives from the parent's own spawned values. Passing it as an override also
+ /// bypasses spawn modification, which is correct: those modifiers already applied to the
+ /// parent and are baked into what's being scaled here.
///
public void ServerSpawnSplitEnemies(EnemyDefinition def, int count, Vector2Int atTile,
PlayerSlot ownerSlot, float scatterRadius,
- float hpMultiplier = 1f, float visualScale = 1f)
+ EnemySpawnContext context)
{
if (!IsServer) return;
for (int i = 0; i < count; i++)
- SpawnEnemy(def, atTile, ownerSlot, scatterRadius, scatterRadius, canRollAbility: false,
- hpMultiplier: hpMultiplier, visualScale: visualScale);
+ SpawnEnemy(def, atTile, ownerSlot,
+ abilities: null,
+ contextOverride: context,
+ xHalfExtent: scatterRadius, zHalfExtent: scatterRadius);
}
// ----- Enemy event handlers (server-only) -------------------------
@@ -562,9 +861,16 @@ namespace TD.Gameplay
// every enemy in the wave regardless of EnemyDefinition type. Missing config
// or out-of-range wave → 0 reward (gold flow disabled, designer-error mode).
int killReward = 0;
- var goldEntry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
+ var goldEntry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
if (goldEntry != null) killReward = goldEntry.GoldPerEnemy;
+ // Wave buffs get to alter the bounty before anything else sees it — this is what
+ // reward-suppressing cards hook. Applied before the builder's gold-per-kill bonus so
+ // a card that zeroes the bounty doesn't also cancel a player's own upgrade.
+ var enemyAbility = health.GetComponent();
+ if (enemyAbility != null)
+ killReward = enemyAbility.ServerModifyKillReward(killReward);
+
// Award kill gold to the tower owner that landed the killing blow. The builder's
// "gold per kill" effect (if granted) is queried live here rather than cached on
// the killing tower — see BuilderUpgradeManager's "query, don't snapshot" note.
@@ -591,12 +897,11 @@ namespace TD.Gameplay
if (totalReward > 0)
ShowGoldRewardClientRpc(health.transform.position, totalReward);
- // Resolve any on-death ability BEFORE unsubscribing/decrementing. A split-spawn's
+ // Resolve on-death abilities BEFORE unsubscribing/decrementing. A split-spawn's
// activeEnemyCount++ (inside ServerSpawnSplitEnemies -> SpawnEnemy) must land before
// this kill's activeEnemyCount-- below, or a split on a wave's last enemy could let
// CheckWaveComplete see activeEnemyCount hit 0 and advance the wave prematurely.
- var ability = health.GetComponent();
- ability?.Definition?.ServerOnDeath(ability, health);
+ enemyAbility?.ServerInvokeOnDeath(health);
UnsubscribeEnemy(health);
DecrementAndCheckComplete();
@@ -624,6 +929,11 @@ namespace TD.Gameplay
if (livesCost > 0)
ShowLifeLossClientRpc(leakPos, livesCost);
+ // Wave buffs that punish leaks beyond the life cost hook here, while the enemy is
+ // still alive enough to be queried. Runs before the defeat check so a leak that ends
+ // the match still applies its full penalty.
+ movement.GetComponent()?.ServerInvokeReachedGoal(movement.OriginZone);
+
UnsubscribeEnemy(movement.GetComponent());
remainingLives = Mathf.Max(0, remainingLives - livesCost);
@@ -656,6 +966,23 @@ namespace TD.Gameplay
OnLifeLost?.Invoke(amount);
}
+ [ClientRpc]
+ private void ShowGoldLossClientRpc(Vector3 worldPos, int amount)
+ {
+ FloatingTextSpawner.Instance?.SpawnGoldLoss(worldPos, amount);
+ }
+
+ ///
+ /// Server-only: show a "-N gold" popup on every peer. Exposed because enemy abilities run
+ /// inside ScriptableObjects, which have no NetworkBehaviour of their own to send a
+ /// ClientRpc from.
+ ///
+ public void ServerBroadcastGoldLoss(Vector3 worldPos, int amount)
+ {
+ if (!IsServer || amount <= 0) return;
+ ShowGoldLossClientRpc(worldPos, amount);
+ }
+
// ----- Local-only notification events -----------------------------
///
@@ -704,8 +1031,15 @@ namespace TD.Gameplay
// no-leak bonus only if the player's waveLeakCounts entry is exactly 0.
AwardWaveCompletionBonuses();
- Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
- StartNextWave();
+ // Capture which slot the upcoming vote will buff BEFORE advancing past it — the vote
+ // is on "the wave you just beat", but by the time the inter-wave step runs the run has
+ // already moved on. A boss clear is excluded: its phase's slots are about to be wiped,
+ // so voting a buff onto them would be voting into a bin.
+ var run = RunState.Instance;
+ pendingVoteSlot = (run != null && !run.IsBossStage) ? run.CurrentUpgradeSlot : -1;
+
+ Debug.Log($"[WaveManager] {run?.ProgressLabel ?? "Encounter"} complete.");
+ AdvanceRun();
}
// Server-only. Iterates active players, awards CompletionBonus to each, plus
@@ -714,7 +1048,7 @@ namespace TD.Gameplay
// Skipped silently if no goldConfig or no entry for this wave.
private void AwardWaveCompletionBonuses()
{
- var entry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
+ var entry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
if (entry == null) return;
int completionBonus = entry.CompletionBonus;
diff --git a/Assets/_Project/Scripts/Gameplay/Waves.meta b/Assets/_Project/Scripts/Gameplay/Waves.meta
new file mode 100644
index 0000000..67bd139
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f65ba25aee224c24dad3648d9e5e953e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
new file mode 100644
index 0000000..2700d59
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
@@ -0,0 +1,102 @@
+// Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace TD.Gameplay.Waves
+{
+ ///
+ /// 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.
+ ///
+ ///
+ /// Groups, not waves. Both pools are lists of 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.
+ ///
+ /// Draw contract. RunState draws WavesPerCycle waves from
+ /// with distinct enemy types, 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 WavesPerCycle distinct enemy types across its groups,
+ /// not just that many waves. exists so the draw can
+ /// fail loudly at match start rather than mid-run.
+ ///
+ [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;
+
+ /// Name used in validation and log messages. Falls back to the asset name.
+ public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
+
+ ///
+ /// Appends every non-null, non-zero-weight entry across 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.
+ ///
+ public static void CollectCandidates(WaveGroup[] groups, List 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);
+ }
+ }
+ }
+
+ /// Collects this phase's cycle-wave candidates.
+ public void CollectWaveCandidates(List into)
+ => CollectCandidates(WaveGroups, into);
+
+ /// Collects this phase's boss candidates.
+ public void CollectBossCandidates(List into)
+ => CollectCandidates(BossGroups, into);
+
+ ///
+ /// 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.
+ ///
+ public int CountDistinctEnemyTypes()
+ {
+ var candidates = new List();
+ CollectWaveCandidates(candidates);
+
+ var seen = new HashSet();
+ foreach (var entry in candidates)
+ {
+ var type = entry.Wave.PrimaryEnemyType;
+ if (type != null) seen.Add(type);
+ }
+ return seen.Count;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta
new file mode 100644
index 0000000..c397855
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8f73fcf0261654d44a6d0939bdc9b88f
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
new file mode 100644
index 0000000..6b0feb3
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
@@ -0,0 +1,209 @@
+// 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
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta
new file mode 100644
index 0000000..6c2d0d3
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2b73bb9876b7fdb4480b13ff6bb8bd67
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
new file mode 100644
index 0000000..5f0cd92
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
@@ -0,0 +1,468 @@
+// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
+using System.Collections.Generic;
+using Unity.Netcode;
+using UnityEngine;
+
+namespace TD.Gameplay.Waves
+{
+ ///
+ /// Outcome of advancing the run by one encounter. Returned by
+ /// so WaveManager can react without
+ /// re-deriving the progression maths.
+ ///
+ public enum RunAdvance : byte
+ {
+ /// Moved to the next wave inside the current cycle.
+ NextWave = 0,
+ /// Moved to the first wave of a new cycle — the same waves come round again,
+ /// now carrying whatever upgrades players voted onto them.
+ NextCycle = 1,
+ /// Every cycle in the phase is done; the boss is up next.
+ BossReady = 2,
+ /// The boss died and a new phase was drawn. Upgrades were wiped.
+ NextPhase = 3,
+ /// The final phase's boss died — the run is won.
+ RunComplete = 4,
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Split of duties with WaveManager. This type is the run's state —
+ /// counters, draws, upgrade sets — and knows nothing about spawning. WaveManager is the
+ /// driver: it runs an encounter, and when the field is clear it calls
+ /// and acts on the returned . Keeping them
+ /// apart is what stops WaveManager (already a large class) from also owning pool draws and
+ /// upgrade bookkeeping.
+ ///
+ /// Linear encounter index. Rather than replicate cycle and wave separately and
+ /// keep them in sync, one counter runs
+ /// 0 .. CyclesPerPhase*WavesPerCycle inclusive, with the final value meaning "boss".
+ /// Cycle and wave slot are derived from it, so they cannot disagree.
+ ///
+ /// Upgrades are keyed by wave slot, not enemy type. 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.
+ ///
+ 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 phaseIndex = new NetworkVariable(
+ 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
+
+ // 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss".
+ private readonly NetworkVariable encounterInPhase = new NetworkVariable(
+ 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 drawnWaveIds;
+
+ private readonly NetworkVariable bossWaveId = new NetworkVariable(
+ -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 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 autoApplyCounts;
+
+ ///
+ /// Fired on every peer whenever run progression or the drawn/upgraded wave set changes.
+ /// The HUD subscribes to relabel without polling.
+ ///
+ public event System.Action OnRunChanged;
+
+ // ----- Lifecycle ---------------------------------------------------
+
+ private void Awake()
+ {
+ drawnWaveIds = new NetworkList();
+ waveUpgrades = new NetworkList();
+ autoApplyCounts = new NetworkList();
+ }
+
+ 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 _) => OnRunChanged?.Invoke();
+
+ // ----- Structure accessors -----------------------------------------
+
+ /// The run asset driving this match, or null if unassigned (designer error).
+ 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;
+
+ /// Encounters in one phase, counting its boss.
+ public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1;
+
+ // ----- Progression accessors ---------------------------------------
+
+ /// Zero-based phase index.
+ public int PhaseIndex => phaseIndex.Value;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ /// 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).
+ public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1);
+
+ /// Zero-based wave slot within the current cycle. Meaningless while
+ /// is true.
+ public int WaveSlot => encounterInPhase.Value % WavesPerCycle;
+
+ /// True when the next/current encounter is this phase's boss.
+ public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle;
+
+ ///
+ /// 1-based count of encounters since the run began, including bosses. This is the key
+ /// GoldConfig is indexed by, so its per-wave entries scale monotonically across the
+ /// whole run rather than resetting every cycle.
+ ///
+ public int GlobalEncounterNumber
+ => phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1;
+
+ /// Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5".
+ public string ProgressLabel
+ => IsBossStage
+ ? $"Phase {phaseIndex.Value + 1} · BOSS"
+ : $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " +
+ $"Wave {WaveSlot + 1}/{WavesPerCycle}";
+
+ // ----- Wave accessors ----------------------------------------------
+
+ /// The wave asset drawn for , or null if not drawn yet.
+ public WaveDefinition GetSlotWave(int slot)
+ {
+ if (runDefinition == null) return null;
+ if (slot < 0 || slot >= drawnWaveIds.Count) return null;
+ return runDefinition.GetWave(drawnWaveIds[slot]);
+ }
+
+ /// This phase's boss wave, or null if not drawn yet.
+ public WaveDefinition BossWave
+ => runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null;
+
+ /// The wave the run is currently pointing at — boss or cycle wave.
+ public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot);
+
+ ///
+ /// 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.
+ ///
+ public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot;
+
+ // ----- Upgrade accessors -------------------------------------------
+
+ ///
+ /// Collects the upgrade option ids voted onto , in the order they
+ /// were applied. Safe on any peer.
+ ///
+ public void GetUpgradesForSlot(int slot, List into)
+ {
+ into.Clear();
+ for (int i = 0; i < waveUpgrades.Count; i++)
+ {
+ int packed = waveUpgrades[i];
+ if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask);
+ }
+ }
+
+ /// How many upgrades are stacked on .
+ public int CountUpgradesForSlot(int slot)
+ {
+ int n = 0;
+ for (int i = 0; i < waveUpgrades.Count; i++)
+ if ((waveUpgrades[i] >> SlotShift) == slot) n++;
+ return n;
+ }
+
+ /// True if already carries .
+ /// Used to keep the vote from offering a buff a wave already has.
+ 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;
+ }
+
+ /// Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates.
+ 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) --------------------------------
+
+ ///
+ /// How many buffs will take automatically instead of holding its
+ /// next vote. Zero means a normal vote.
+ ///
+ public int GetAutoApplyCount(int slot)
+ => (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0;
+
+ ///
+ /// Server-only: arm to auto-take 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.
+ ///
+ 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);
+ }
+
+ /// Server-only: disarm after its auto-apply has fired.
+ 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 ------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the
+ /// boss falls. See for what the caller should do with each result.
+ ///
+ 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 candidateScratch = new List();
+
+ ///
+ /// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative
+ /// weight without replacement and with distinct enemy types; the boss is a plain
+ /// weighted draw.
+ ///
+ 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 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
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta
new file mode 100644
index 0000000..4905373
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a293cf6dca91dd2478c9f4e799cf9e0d
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
new file mode 100644
index 0000000..d870d94
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
@@ -0,0 +1,69 @@
+// Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
+using System;
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.Waves
+{
+ ///
+ /// One weighted candidate inside a : a wave that may be drawn,
+ /// and how likely it is to be drawn relative to the group's other entries.
+ ///
+ ///
+ /// Weight is RELATIVE, not an absolute probability. 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.
+ ///
+ /// A class rather than a struct specifically so 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
+ /// .
+ ///
+ [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;
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Why weights live on the entry, not on . Weight is a
+ /// property of "how common is this wave in this pool", not of the wave itself. Keeping
+ /// it here means the same 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.
+ ///
+ /// Boss groups use the same type. A boss wave is just a
+ /// whose entries spawn a single powerful enemy, so boss pools
+ /// are ordinary s referenced from
+ /// .
+ ///
+ [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;
+
+ /// Name used in validation and log messages. Falls back to the asset name.
+ public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
+
+ /// Number of entries, including any that are null or zero-weight.
+ public int Count => Waves?.Length ?? 0;
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta
new file mode 100644
index 0000000..817269e
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5625bd25f378bda429666820a4940cee
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs b/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs
index 392b1a2..49b4e66 100644
--- a/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs
+++ b/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs
@@ -74,6 +74,11 @@ namespace TD.UI
public void SpawnGoldReward(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"+{amount}", goldColor);
+ /// Spawns a gold-loss popup (e.g. "-15") at the given world position. Same colour
+ /// as a reward so it reads as gold; the sign carries the meaning.
+ public void SpawnGoldLoss(Vector3 worldPos, int amount)
+ => SpawnInternal(worldPos, $"-{amount}", goldColor);
+
/// Spawns a life-loss popup (e.g. "-1") at the given world position.
public void SpawnLifeLoss(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"-{amount}", livesColor);
diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs
index 488cea9..29e2b0f 100644
--- a/Assets/_Project/Scripts/UI/HUDController.cs
+++ b/Assets/_Project/Scripts/UI/HUDController.cs
@@ -10,6 +10,8 @@ using TD.Core;
using TD.Gameplay;
using TD.Gameplay.BuilderSpells;
using TD.Gameplay.Draft;
+using TD.Gameplay.EnemyUpgrades;
+using TD.Gameplay.Waves;
using TD.Towers;
using TD.UI.Minimap;
@@ -112,10 +114,34 @@ namespace TD.UI
// shown when no draft is pending so a paid roll can be bought any time.
private VisualElement draftPanel;
private VisualElement draftCardRow;
- private Button draftBuyButton;
private bool draftSubscribed;
private PlayerDraft subscribedDraft;
+ // Enemy-buff vote overlay. Shares the draft overlay's position and card styling, but the
+ // cards are shared rather than per-player and carry live voter badges.
+ private VisualElement votePanel;
+ private VisualElement voteCardRow;
+ private Label voteTitle;
+ private bool voteSubscribed;
+ private WaveVote subscribedVote;
+
+ // Boss health bar. Shown only while a boss is alive; polled per frame like lives/gold
+ // rather than event-driven, since HP changes continuously anyway.
+ private VisualElement bossBarPanel;
+ private VisualElement bossHealthFill;
+ private Label bossHealthText;
+ private Label bossNameLabel;
+
+ // Reused when painting voter badges so a rebuild-per-ballot doesn't allocate a list per
+ // card. The HUD is single-threaded, so one shared buffer is safe.
+ private readonly System.Collections.Generic.List voteVoterScratch
+ = new System.Collections.Generic.List();
+
+ // Reused when building a selected tower's upgrade buttons.
+ private readonly System.Collections.Generic.List<(TowerDefinition Def, int TypeId)>
+ upgradeOptionScratch
+ = new System.Collections.Generic.List<(TowerDefinition, int)>();
+
// Spell hotbar (bottom-ui Section 6). Frame is visibility:hidden (but still occupies
// its reserved width, so the centered command bar never shifts) while the local player
// has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown
@@ -201,6 +227,8 @@ namespace TD.UI
private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged
private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
+ private bool upgradesSubscribed; // true once we've hooked the local PlayerTowerUpgrades
+ private PlayerTowerUpgrades subscribedUpgrades;
private MinimapView minimapView;
private GameMenuView gameMenu; // gear button + modal menu overlay
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
@@ -330,6 +358,19 @@ namespace TD.UI
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
+ // Same idea for unlocked upgrade nodes: a draft pick can grant an upgrade while the
+ // player already has the target tower selected, and the new branch should appear in the
+ // action grid without needing a reselect.
+ private void TrySubscribeUpgrades()
+ {
+ var upgrades = PlayerTowerUpgrades.Local;
+ if (upgrades == null) return;
+ upgrades.OnUpgradesChanged += HandleDeckChanged;
+ subscribedUpgrades = upgrades;
+ upgradesSubscribed = true;
+ PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
+ }
+
// Hook the local player's draft so the overlay rebuilds when options are offered,
// picked, or rerolled. Retried each Update until the local PlayerDraft exists.
private void TrySubscribeDraft()
@@ -366,9 +407,9 @@ namespace TD.UI
// ----- Draft overlay ----------------------------------------------
- // Non-modal draft UI: a card row floats at top-center while a draft is active, and
- // a "Buy Roll" button shows when no draft is pending. The host panel ignores picks
- // so empty space clicks through to the world — the player keeps building during prep.
+ // Non-modal draft UI: a card row floats at top-center while the local player has an
+ // unresolved offer. The host panel ignores picks so empty space clicks through to the
+ // world — the player can still pan and inspect their maze while deciding.
private void BuildDraftOverlay(VisualElement root)
{
draftPanel = new VisualElement();
@@ -390,12 +431,8 @@ namespace TD.UI
draftCardRow.pickingMode = PickingMode.Ignore;
box.Add(draftCardRow);
- draftBuyButton = new Button(() => PlayerDraft.Local?.RequestBuyRerollRpc())
- {
- text = "Buy Roll"
- };
- draftBuyButton.style.marginTop = 6;
- box.Add(draftBuyButton);
+ // The "Buy Roll" button lived here. Removed with the extra-draft shop for the 2.0 MVP
+ // — see PlayerDraft's commented-out RequestBuyRerollRpc for why the backend was kept.
draftPanel.Add(box);
root.Add(draftPanel);
@@ -474,35 +511,318 @@ namespace TD.UI
return card;
}
- // Per-frame: toggles the card row vs. the buy button and keeps the buy button's
- // label/enabled state in sync with gold. Cheap (mirrors the gold label's per-frame
- // refresh); only allocates a string while the buy button is shown.
+ // Shows the draft row only while the local player has an unresolved offer. With the
+ // extra-draft shop gone there is nothing else in this panel, so it hides entirely once
+ // the player has picked — which also gives them a clear "I'm done, waiting on others"
+ // signal during the barrier.
private void UpdateDraftVisibility()
{
if (draftPanel == null) return;
- var draft = PlayerDraft.Local;
- var service = DraftService.Instance;
- var gold = PlayerGoldManager.Local;
-
- bool hasDraft = draft != null && draft.HasActiveDraft;
- bool canShowBuy = draft != null && service != null && !hasDraft;
+ var draft = PlayerDraft.Local;
+ bool hasDraft = draft != null && draft.HasActiveDraft;
if (draftCardRow != null)
draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
- if (draftBuyButton != null)
+ draftPanel.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
+ }
+
+ // ----- Enemy-buff vote overlay --------------------------------------
+
+ // Shared vote UI. Same anchor and card language as the draft row (they never show at the
+ // same time — the inter-wave stages are strictly sequential), but every peer sees the same
+ // three cards, and each card carries badges for the players currently voting for it.
+ private void BuildVoteOverlay(VisualElement root)
+ {
+ votePanel = new VisualElement();
+ votePanel.style.position = Position.Absolute;
+ votePanel.style.left = 0;
+ votePanel.style.right = 0;
+ votePanel.style.top = 70; // below the top bar, same as the draft row
+ votePanel.style.alignItems = Align.Center;
+ votePanel.pickingMode = PickingMode.Ignore;
+ votePanel.style.display = DisplayStyle.None;
+
+ var box = new VisualElement();
+ box.style.flexDirection = FlexDirection.Column;
+ box.style.alignItems = Align.Center;
+ box.pickingMode = PickingMode.Ignore;
+
+ voteTitle = new Label("Choose how this wave comes back");
+ voteTitle.style.fontSize = 15;
+ voteTitle.style.color = new Color(0.95f, 0.72f, 0.55f);
+ voteTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
+ voteTitle.style.marginBottom = 6;
+ box.Add(voteTitle);
+
+ voteCardRow = new VisualElement();
+ voteCardRow.style.flexDirection = FlexDirection.Row;
+ voteCardRow.pickingMode = PickingMode.Ignore;
+ box.Add(voteCardRow);
+
+ votePanel.Add(box);
+ root.Add(votePanel);
+
+ UpdateVoteVisibility();
+ }
+
+ // Hook the shared vote so the panel rebuilds whenever a ballot lands. Retried each Update
+ // until the scene's WaveVote has network-spawned.
+ private void TrySubscribeVote()
+ {
+ var vote = WaveVote.Instance;
+ if (vote == null) return;
+ vote.OnVoteChanged += HandleVoteChanged;
+ subscribedVote = vote;
+ voteSubscribed = true;
+ RebuildVoteCards();
+ }
+
+ private void HandleVoteChanged()
+ {
+ RebuildVoteCards();
+ }
+
+ // Rebuilt on every ballot change, not just when the offered set changes — the voter
+ // badges are the point of the panel, so they have to track votes as they land.
+ private void RebuildVoteCards()
+ {
+ if (voteCardRow == null) return;
+ voteCardRow.Clear();
+
+ var vote = WaveVote.Instance;
+ var pool = EnemyUpgradePool.Instance;
+
+ if (vote != null && pool != null && vote.IsOpen)
{
- draftBuyButton.style.display = canShowBuy ? DisplayStyle.Flex : DisplayStyle.None;
- if (canShowBuy)
+ for (int i = 0; i < vote.OptionCount; i++)
{
- int cost = service.RerollCost;
- draftBuyButton.text = $"Buy Roll ({cost}g)";
- draftBuyButton.SetEnabled(gold != null && gold.CurrentGold >= cost);
+ int id = vote.GetOptionId(i);
+ var option = pool.Get(id);
+ if (option == null) continue;
+ voteCardRow.Add(CreateVoteCard(option, id, vote));
}
}
- draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None;
+ UpdateVoteVisibility();
+ }
+
+ private VisualElement CreateVoteCard(EnemyUpgradeOption option, int optionId, WaveVote vote)
+ {
+ var localSlot = PlayerMatchState.Local != null
+ ? PlayerMatchState.Local.Slot
+ : PlayerSlot.None;
+ bool isLocalChoice = localSlot != PlayerSlot.None
+ && vote.GetBallot(localSlot) == optionId;
+
+ var card = new VisualElement();
+ card.style.width = 160;
+ card.style.marginLeft = card.style.marginRight = 4;
+ card.style.paddingTop = card.style.paddingBottom = 10;
+ card.style.paddingLeft = card.style.paddingRight = 10;
+ card.style.backgroundColor = new Color(0.13f, 0.09f, 0.09f, 0.96f);
+ card.style.borderTopWidth = card.style.borderBottomWidth =
+ card.style.borderLeftWidth = card.style.borderRightWidth = 2;
+
+ // The local player's current choice gets a bright border so they can tell at a glance
+ // which one is theirs among everyone else's badges.
+ var border = isLocalChoice
+ ? new Color(0.95f, 0.72f, 0.35f)
+ : new Color(0.45f, 0.30f, 0.30f);
+ card.style.borderTopColor = card.style.borderBottomColor =
+ card.style.borderLeftColor = card.style.borderRightColor = border;
+ card.style.alignItems = Align.Center;
+
+ var title = new Label(option.DisplayName);
+ title.style.fontSize = 14;
+ title.style.color = Color.white;
+ title.style.unityFontStyleAndWeight = FontStyle.Bold;
+ title.style.whiteSpace = WhiteSpace.Normal;
+ title.style.unityTextAlign = TextAnchor.MiddleCenter;
+ title.style.marginBottom = 6;
+ card.Add(title);
+
+ var iconSprite = option.ResolveIcon();
+ if (iconSprite != null)
+ {
+ var img = new Image { sprite = iconSprite };
+ img.style.width = 48;
+ img.style.height = 48;
+ img.style.marginBottom = 6;
+ card.Add(img);
+ }
+
+ var desc = new Label(option.Description ?? "");
+ desc.style.fontSize = 11;
+ desc.style.color = new Color(0.82f, 0.82f, 0.82f);
+ desc.style.whiteSpace = WhiteSpace.Normal;
+ desc.style.unityTextAlign = TextAnchor.MiddleCenter;
+ desc.style.marginBottom = 8;
+ card.Add(desc);
+
+ // Live voter badges — one per player currently voting for this card, in their own
+ // player color. This row is why the panel rebuilds on every ballot change.
+ var badgeRow = new VisualElement();
+ badgeRow.style.flexDirection = FlexDirection.Row;
+ badgeRow.style.justifyContent = Justify.Center;
+ badgeRow.style.minHeight = 24;
+ badgeRow.style.marginBottom = 6;
+
+ voteVoterScratch.Clear();
+ vote.GetVotersFor(optionId, voteVoterScratch);
+ foreach (var voter in voteVoterScratch)
+ badgeRow.Add(CreateVoterBadge(voter));
+
+ card.Add(badgeRow);
+
+ var pick = new Button(() => WaveVote.Instance?.RequestVoteRpc(optionId))
+ {
+ text = isLocalChoice ? "Voted" : "Vote"
+ };
+ card.Add(pick);
+
+ return card;
+ }
+
+ // Small filled circle carrying the player's slot number, in their canonical player color.
+ // Same visual vocabulary as the race-selection overlay's picker badges, so players read it
+ // as "who" without a legend.
+ private VisualElement CreateVoterBadge(PlayerSlot slot)
+ {
+ var badge = new Label(((int)slot).ToString());
+ badge.style.width = 20;
+ badge.style.height = 20;
+ badge.style.marginLeft = badge.style.marginRight = 2;
+ badge.style.backgroundColor = PlayerColors.Get(slot);
+ badge.style.color = Color.black;
+ badge.style.fontSize = 11;
+ badge.style.unityFontStyleAndWeight = FontStyle.Bold;
+ badge.style.unityTextAlign = TextAnchor.MiddleCenter;
+ badge.style.borderTopLeftRadius = badge.style.borderTopRightRadius =
+ badge.style.borderBottomLeftRadius = badge.style.borderBottomRightRadius = 10;
+ return badge;
+ }
+
+ private void UpdateVoteVisibility()
+ {
+ if (votePanel == null) return;
+
+ var vote = WaveVote.Instance;
+ bool open = vote != null && vote.IsOpen && vote.OptionCount > 0;
+ votePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
+ }
+
+ // ----- Boss bar -----------------------------------------------------
+
+ // Prominent health bar for the phase boss. Hidden whenever no boss is alive, so it costs
+ // nothing outside a boss encounter.
+ private void BuildBossBar(VisualElement root)
+ {
+ bossBarPanel = new VisualElement();
+ bossBarPanel.style.position = Position.Absolute;
+ bossBarPanel.style.left = 0;
+ bossBarPanel.style.right = 0;
+ bossBarPanel.style.top = 70;
+ bossBarPanel.style.alignItems = Align.Center;
+ bossBarPanel.pickingMode = PickingMode.Ignore;
+ bossBarPanel.style.display = DisplayStyle.None;
+
+ var box = new VisualElement();
+ box.style.width = 440;
+ box.style.alignItems = Align.Center;
+ box.pickingMode = PickingMode.Ignore;
+
+ bossNameLabel = new Label("BOSS");
+ bossNameLabel.style.fontSize = 18;
+ bossNameLabel.style.color = new Color(0.95f, 0.45f, 0.4f);
+ bossNameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
+ bossNameLabel.style.marginBottom = 3;
+ box.Add(bossNameLabel);
+
+ var track = new VisualElement();
+ track.style.width = 440;
+ track.style.height = 18;
+ track.style.backgroundColor = new Color(0.08f, 0.06f, 0.06f, 0.95f);
+ track.style.borderTopWidth = track.style.borderBottomWidth =
+ track.style.borderLeftWidth = track.style.borderRightWidth = 2;
+ var trackBorder = new Color(0.5f, 0.25f, 0.22f);
+ track.style.borderTopColor = track.style.borderBottomColor =
+ track.style.borderLeftColor = track.style.borderRightColor = trackBorder;
+ track.pickingMode = PickingMode.Ignore;
+
+ bossHealthFill = new VisualElement();
+ bossHealthFill.style.height = Length.Percent(100);
+ bossHealthFill.style.width = Length.Percent(100);
+ bossHealthFill.style.backgroundColor = new Color(0.75f, 0.18f, 0.16f);
+ bossHealthFill.pickingMode = PickingMode.Ignore;
+ track.Add(bossHealthFill);
+
+ bossHealthText = new Label("");
+ bossHealthText.style.position = Position.Absolute;
+ bossHealthText.style.left = 0;
+ bossHealthText.style.right = 0;
+ bossHealthText.style.top = 0;
+ bossHealthText.style.bottom = 0;
+ bossHealthText.style.fontSize = 12;
+ bossHealthText.style.color = Color.white;
+ bossHealthText.style.unityTextAlign = TextAnchor.MiddleCenter;
+ bossHealthText.pickingMode = PickingMode.Ignore;
+ track.Add(bossHealthText);
+
+ box.Add(track);
+ bossBarPanel.Add(box);
+ root.Add(bossBarPanel);
+ }
+
+ // Polled from the per-frame refresh, like lives and gold. Picks the boss in the local
+ // player's own zone when there is one — the boss encounter spawns one per zone, and the
+ // one actually walking through your maze is the one you need to watch.
+ private void RefreshBossBar()
+ {
+ if (bossBarPanel == null) return;
+
+ var boss = ResolveDisplayedBoss();
+ if (boss == null)
+ {
+ bossBarPanel.style.display = DisplayStyle.None;
+ return;
+ }
+
+ bossBarPanel.style.display = DisplayStyle.Flex;
+
+ float max = Mathf.Max(1f, boss.MaxHp);
+ float pct = Mathf.Clamp01(boss.CurrentHp / max);
+ bossHealthFill.style.width = Length.Percent(pct * 100f);
+ bossHealthText.text = $"{Mathf.CeilToInt(boss.CurrentHp)} / {Mathf.CeilToInt(max)}";
+ bossNameLabel.text = boss.DisplayName;
+ }
+
+ private EnemyHealth ResolveDisplayedBoss()
+ {
+ var bosses = EnemyHealth.ActiveBosses;
+ if (bosses.Count == 0) return null;
+
+ var localSlot = PlayerMatchState.Local != null
+ ? PlayerMatchState.Local.Slot
+ : PlayerSlot.None;
+
+ EnemyHealth fallback = null;
+ for (int i = 0; i < bosses.Count; i++)
+ {
+ var boss = bosses[i];
+ if (boss == null || boss.IsDead) continue;
+
+ fallback ??= boss;
+
+ if (localSlot != PlayerSlot.None)
+ {
+ var movement = boss.GetComponent();
+ if (movement != null && movement.OriginZone == localSlot) return boss;
+ }
+ }
+
+ return fallback;
}
// ----- Spell hotbar -------------------------------------------------
@@ -715,9 +1035,17 @@ namespace TD.UI
BuildMatchEndOverlay(root);
// Build the draft overlay (roguelike between-waves draft). Hidden until the
- // local player has an active draft (or to show the paid "Buy Roll" button).
+ // local player has an active draft.
BuildDraftOverlay(root);
+ // Build the shared enemy-buff vote overlay. Hidden until a vote is open; never shows
+ // at the same time as the draft row, since the inter-wave stages run in sequence.
+ BuildVoteOverlay(root);
+
+ // Boss health bar. Shares the top-centre anchor with the draft/vote rows, which is
+ // safe because a boss is only ever alive once the inter-wave stages have finished.
+ BuildBossBar(root);
+
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
// Player typing toggled with Enter; system messages (e.g. life lost) post via
// ChatService.PostLocalSystem on every peer.
@@ -777,6 +1105,13 @@ namespace TD.UI
deckSubscribed = false;
subscribedDeck = null;
+ if (upgradesSubscribed && subscribedUpgrades != null)
+ {
+ subscribedUpgrades.OnUpgradesChanged -= HandleDeckChanged;
+ }
+ upgradesSubscribed = false;
+ subscribedUpgrades = null;
+
if (draftSubscribed && subscribedDraft != null)
{
subscribedDraft.OnDraftChanged -= HandleDraftChanged;
@@ -784,6 +1119,13 @@ namespace TD.UI
draftSubscribed = false;
subscribedDraft = null;
+ if (voteSubscribed && subscribedVote != null)
+ {
+ subscribedVote.OnVoteChanged -= HandleVoteChanged;
+ }
+ voteSubscribed = false;
+ subscribedVote = null;
+
if (spellLoadoutSubscribed && subscribedSpellLoadout != null)
{
subscribedSpellLoadout.OnLoadoutChanged -= HandleSpellLoadoutChanged;
@@ -813,9 +1155,15 @@ namespace TD.UI
if (!deckSubscribed)
TrySubscribeDeck();
+ if (!upgradesSubscribed)
+ TrySubscribeUpgrades();
+
if (!draftSubscribed)
TrySubscribeDraft();
+ if (!voteSubscribed)
+ TrySubscribeVote();
+
if (!spellLoadoutSubscribed)
TrySubscribeSpellLoadout();
@@ -992,27 +1340,40 @@ namespace TD.UI
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
+ RefreshBossBar();
+
+ // Run progress reads "Phase 1 · Cycle 2 · Wave 3/5" (or "· BOSS"). RunState builds
+ // the string since it owns the phase/cycle/slot maths; the HUD just displays it.
if (waveLabel != null)
{
- int total = wm?.TotalWaves ?? 0;
- waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
- ? $"Wave {ms.CurrentWave} / {total}"
+ var run = RunState.Instance;
+ waveLabel.text = run != null && ms != null && ms.CurrentWave > 0
+ ? run.ProgressLabel
: "Wave --";
}
- // Next-wave countdown. Shows during prep ("next: 0:12") and clears the
- // moment the wave actually starts spawning. WaveManager.PrepCountdown
+ // Inter-wave countdown. One timer serves all three stages (they never overlap), so
+ // the label is prefixed with which stage is running — "draft: 0:12", "vote: 0:08",
+ // "build: 0:15". Clears the moment the wave starts spawning. WaveManager.PrepCountdown
// is networked so this reads the same value on every peer.
if (nextWaveLabel != null)
{
float t = wm != null ? wm.PrepCountdown : 0f;
if (t > 0f)
{
+ string stage = wm.CurrentInterWaveStage switch
+ {
+ InterWaveStage.Draft => "draft",
+ InterWaveStage.Vote => "vote",
+ InterWaveStage.Build => "build",
+ _ => "next",
+ };
+
// Ceiling so the user sees a full "0:01" tick before "0:00".
int seconds = Mathf.CeilToInt(t);
int mm = seconds / 60;
int ss = seconds % 60;
- nextWaveLabel.text = $"next: {mm}:{ss:00}";
+ nextWaveLabel.text = $"{stage}: {mm}:{ss:00}";
}
else
{
@@ -1276,8 +1637,27 @@ namespace TD.UI
}
else if (selection is TowerInstance tower)
{
- // WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
- cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
+ // WC3 layout convention: primary actions top-left (Q onward), sell bottom-right.
+ // One button per unlocked upgrade branch rather than a single generic "Upgrade" —
+ // a tree node can have several children, and a lone button couldn't express the
+ // choice between them without an extra submenu layer.
+ upgradeOptionScratch.Clear();
+ var localClientId = NetworkManager.Singleton != null
+ ? NetworkManager.Singleton.LocalClientId
+ : 0UL;
+ bool ownsTower = PlayerMatchState.Local != null
+ && tower.Owner == PlayerMatchState.Local.Slot;
+
+ if (ownsTower)
+ tower.CollectAvailableUpgrades(localClientId, upgradeOptionScratch);
+
+ int upgradeSlots = Mathf.Min(upgradeOptionScratch.Count, GRID_MAX - 1);
+ for (int i = 0; i < upgradeSlots; i++)
+ {
+ var (def, typeId) = upgradeOptionScratch[i];
+ cells[i] = CreateUpgradeButton(tower, def, typeId, HotkeyLayout[i]);
+ }
+
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
}
else if (selection is BuildSiteVisual bsv)
@@ -1439,19 +1819,35 @@ namespace TD.UI
return btn;
}
- // Upgrade and Sell — visuals + hotkeys wired; click is a no-op because the
- // upgrade/sell systems aren't built yet. Buttons are SetEnabled(false) so the
- // hotkey handler also skips them (it gates on enabledSelf).
- private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
+ // One button per unlocked upgrade branch on the selected tower. A tree node can have
+ // several children, so a single generic "Upgrade" button couldn't express the choice
+ // between them without an extra submenu layer — the grid already has the slots.
+ private VisualElement CreateUpgradeButton(TowerInstance tower, TowerDefinition target,
+ int targetTypeId, Key hotkey)
{
+ int cost = TowerInstance.GetUpgradeCost(target);
+ var gold = PlayerGoldManager.Local;
+ bool affordable = gold != null && gold.CurrentGold >= cost;
+
var btn = CreateActionButton(
- costText: "", // tier cost unknown until upgrade system lands
+ costText: cost > 0 ? $"{cost}g" : "",
hotkey: hotkey,
- onClick: () =>
- {
- /* TODO: upgrade flow */
- });
- btn.SetEnabled(false);
+ onClick: () => tower.RequestUpgradeServerRpc(targetTypeId));
+
+ if (target.Icon != null)
+ {
+ var img = new Image { sprite = target.Icon };
+ img.AddToClassList("cmd-icon");
+ img.pickingMode = PickingMode.Ignore;
+ btn.Insert(0, img);
+ }
+
+ btn.tooltip = $"Upgrade to {target.DisplayName}";
+
+ // Shown but disabled when unaffordable, rather than hidden — a player saving toward
+ // an upgrade should be able to see the price. Disabling also makes the hotkey handler
+ // skip it, since that gates on enabledSelf.
+ btn.SetEnabled(affordable);
return btn;
}
@@ -1659,8 +2055,7 @@ namespace TD.UI
// Bounty is per-wave now (GoldConfig.Waves[N].GoldPerEnemy) rather than
// per-enemy-type. Read the current wave's value so the tooltip is accurate.
var wm = WaveManager.Instance;
- int currentWave = MatchState.Instance != null ? MatchState.Instance.CurrentWave : 0;
- var goldEntry = wm?.GoldConfig?.GetWaveEntry(currentWave);
+ var goldEntry = wm?.GoldConfig?.GetWaveEntry(wm.CurrentEncounterNumber);
if (goldEntry != null)
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
// (Weaknesses/resistances will go here once the resistance system lands.)
diff --git a/Docs/2.0_Setup_Checklist.md b/Docs/2.0_Setup_Checklist.md
new file mode 100644
index 0000000..8ddc037
--- /dev/null
+++ b/Docs/2.0_Setup_Checklist.md
@@ -0,0 +1,129 @@
+# 2.0 Refactor — Editor Setup Checklist
+
+The 2.0 code landed on `2.0_design_refactor` and compiles clean, but the new systems are
+**data- and scene-driven**. Nothing runs until the assets below exist and the scene objects are
+wired. Work top to bottom — each section depends on the ones above it.
+
+---
+
+## 1. Scene objects (both `9Player` and `Main`)
+
+Three new scene objects are required. Two are plain MonoBehaviours; one needs a `NetworkObject`.
+
+| GameObject | Component | NetworkObject? | Notes |
+|---|---|---|---|
+| `RunState` | `RunState` | **Yes** | Assign the `RunDefinition` asset from §3. |
+| `WaveVote` | `WaveVote` | **Yes** | `Options Per Vote` defaults to 3. |
+| `EnemyUpgradePool` | `EnemyUpgradePool` | No | Fill `options` with every card asset from §2. |
+
+`WaveManager` **no longer has a `waveDefinitions` array** — that field is gone. Its two new
+serialized fields are `Draft Time Seconds` (30) and `Vote Time Seconds` (25).
+
+> Both new NetworkBehaviours are scene objects, so they must be present *before* the host starts.
+
+---
+
+## 2. Enemy-buff cards
+
+Two asset layers, because network identity and phase gating are separate concerns.
+
+**Abilities** (`TD/Enemy Abilities/…`) — what a buffed enemy *does*:
+
+- Split On Death — already exists; **re-check its fields**, they changed. It now has
+ `SplitCount`, `HpPercent`, `SpeedPercent`, `ScalePercent`, `LivesCostPercent`,
+ `FlightHeightPercent`, `ScatterRadius`.
+- Flight, Blink, No Bounty, Gold Theft — new, need assets creating.
+
+Add every ability asset to the scene's existing `EnemyAbilityPool`.
+Its `noAbilityWeight` field is **gone** (abilities are voted in now, not rolled).
+
+**Cards** (`TD/Enemy Upgrades/…`) — what players vote on:
+
+- One `Ability Card` per ability above, each pointing at its `EnemyAbilityDefinition`.
+- One `Double Up Card` (vote-meta; grants no ability).
+
+Then: add every card to `EnemyUpgradePool.options`, and group them into one or more
+`EnemyUpgradeGroup` assets for phase gating.
+
+---
+
+## 3. Run structure
+
+Build bottom-up:
+
+1. **`WaveGroup`** assets (`TD/Run/Wave Group`) — bags of weighted `WaveDefinition` entries.
+ Weights are **relative** 0–1 and default to 1 (equal odds). A zero weight warns inline and
+ means the wave can never be drawn.
+2. **`PhaseDefinition`** (`TD/Run/Phase Definition`) — `WaveGroups`, `BossGroups`,
+ `EnemyUpgradeGroups`.
+3. **`RunDefinition`** (`TD/Run/Run Definition`) — `WavesPerCycle` (5), `CyclesPerPhase` (3),
+ and the `Phases` array. **MVP needs one phase.**
+
+The `RunDefinition` inspector shows a live validation panel and a per-phase capacity readout.
+Get it green before pressing play.
+
+### The constraint that will bite
+
+A phase must supply **`WavesPerCycle` waves with DISTINCT enemy types**. Enemy buffs are keyed to
+a wave slot, so two slots sharing an enemy type would make "which wave did this buff apply to"
+ambiguous. Ten waves in a pool is *not* enough if they only use three enemies. The inspector
+reports distinct-type count per phase; the draw fails loudly at match start otherwise.
+
+Ten wave definitions and ten enemy definitions already exist, so the content floor is met — but
+check the type spread.
+
+---
+
+## 4. Gold config
+
+`GoldConfig.Waves` is now indexed by **global encounter number**, counted continuously across the
+whole run — cycles do **not** reset it. One phase of 5 waves × 3 cycles + a boss needs
+**16 entries**. Missing entries pay zero for that encounter.
+
+---
+
+## 5. Player prefab
+
+Add **`PlayerTowerUpgrades`** to `Player.prefab`, alongside `PlayerTowerDeck` / `PlayerDraft`.
+Without it, tower-upgrade draft options can't apply and the Upgrade buttons never appear.
+
+---
+
+## 6. Enemy prefabs
+
+Any enemy that should be able to carry wave buffs needs an **`EnemyAbility`** component on its
+root. `WaveManager` logs a warning naming the prefab if a buffed wave spawns an enemy without one.
+
+`SplitOnDeath` and any size-changing buff need the prefab's **`NetworkTransform` to sync scale**,
+or minions render full-size on remote peers.
+
+---
+
+## 7. Tower upgrade trees
+
+1. Author upgraded towers as ordinary `TowerDefinition` assets and add them to
+ `TowerPlacementManager.towerDefinitions` (**not** to `startingDeck`).
+2. Wire the tree by filling each parent's `UpgradePaths` array with its children.
+3. Each upgrade node's **`GoldCost` is its upgrade price** — upgrade nodes are never placed
+ directly, so the field is free to mean that.
+4. **Footprint must match the parent.** A differently-sized upgrade is rejected: the tower's
+ occupied/unwalkable tiles are stamped at its current size, and converting would leave the grid
+ describing a shape the tower no longer has.
+5. Create a `TD/Draft/Tower Upgrade Option` per node (`BaseTower` → `UpgradedTower`) and add it to
+ `DraftPool`. Prerequisites need no explicit list — a node is offered only once its parent is
+ reachable (in the deck, or itself unlocked).
+
+Existing `TowerUpgradeDraftOption` assets **changed meaning**: they used to swap the deck entry,
+they now unlock an upgrade node. Re-check any that exist.
+
+---
+
+## Known gaps
+
+- **Upgraded towers keep the parent's mesh.** Switching the definition changes stats immediately
+ (`TowerCombat` re-reads it every tick) but not the model — visual swap is an art task.
+- **No 3-player map yet.** `MatchRules.MaxPlayers` is 3 and slot allocation caps there, but
+ `9Player` is still the only authored level.
+- **Tower vulnerability is not built** — Savage, Mind Control, AOE EMP, Fart Miasma and Dummy all
+ need towers to be damageable/disableable, which doesn't exist. Deferred by agreement.
+- **Relics** untouched, as agreed.
diff --git a/Project_Context.md b/Project_Context.md
index fd56cfd..540f001 100644
--- a/Project_Context.md
+++ b/Project_Context.md
@@ -10,10 +10,11 @@ Last substantial update: 2026-07-14.
## Game overview
-A **co-op tower-defense / roguelike hybrid** for up to 9 players.
+A **co-op tower-defense / roguelike hybrid** for up to **3 players** (reduced from 9 in the 2.0 design as a scope cut — see `TD.Core.MatchRules.MaxPlayers`; the `PlayerSlot` enum still runs to 9 so baked `LevelData` owner grids stay valid).
- **Maze defense (Wintermaul-style):** players build towers to force enemies along a longer path through their own zone. Lives are a **shared pool**; gold is **per-player**.
-- **Roguelike layer (now core to the design):** every player starts with the **same three towers** and builds out a personal "deck"/"build" through a **3-option draft** presented at match start and after every wave. Choices span new towers, systemic upgrades, builder abilities, enemy debuffs, and relic quests. See [`Project_Roadmap.md`](Project_Roadmap.md) for the full design.
+- **Cyclical run structure (2.0):** 5 waves = a **cycle**, 3 cycles = a **phase**, each phase ends in a **boss**, 3 phases wins the run. A phase draws 5 waves with **distinct enemy types** and replays them every cycle. Beating the last phase's boss is Victory.
+- **Roguelike layer (core):** every player starts with the same three towers and grows a personal deck through a **3-option draft**. After each wave the sequence is: personal draft → shared **enemy-buff vote** (open ballots, visible per-player) → build countdown. The vote permanently buffs the wave just cleared, so it comes back harder next cycle; buffs are wiped when a new phase draws fresh waves. See [`Project_Roadmap.md`](Project_Roadmap.md) for the full design.
- **Target platform:** Steam (Windows / Linux / Steam Deck).
- **Visual direction (aspirational):** "painted tabletop miniature" look with Spider-Verse-style stepped (on-2s) enemy animation. Current visuals are **placeholder** (primitive meshes / cones, sourced creature models).
@@ -32,11 +33,13 @@ Unity **6.4 (6000.4.4f1)**, URP, IL2CPP, .NET Standard 2.1, Linear color space,
## Architecture & conventions
- **Server-authoritative gameplay; local-only UI/visual state.** Only gameplay-meaningful state is networked. Selection, placement ghosts, animation, and the paint cursor are client-local.
-- **Data-driven via ScriptableObjects:** `TowerDefinition`, `EnemyDefinition`, `WaveDefinition`, `RaceDefinition`, `GoldConfig`, `BuffDefinition`/`BuffCategory`, `DraftOption` (+ `NewTowerDraftOption`). Designers tune stats in assets, not code.
-- **Per-player state pattern:** `NetworkBehaviour`s on the **Player prefab** with a static `GetForClient(clientId)` / `Local` registry. Current set: `PlayerGoldManager`, `PlayerMatchState`, `PlayerBuffManager`, `PlayerTowerDeck`, `PlayerDraft`.
-- **Networked identifiers are catalog indices.** `TowerTypeId` indexes `TowerPlacementManager.towerDefinitions[]`; `DraftOptionId` indexes `DraftPool`. Stable **within a match**, not across sessions — cross-match persistence will need stable IDs (asset GUID or a serialized StableId).
-- **Namespaces:** `TD.Core` (enums, palettes, grid math), `TD.Gameplay` (builder, placement, match/wave/pathfinding state, economy, enemies, deck), `TD.Gameplay.Draft` (draft system), `TD.Combat` (TowerCombat/Projectile), `TD.Levels` (in-engine authoring + bake), `TD.UI`, `TD.Net`.
-- **Scenes:** `MainMenu` → `Lobby` → a Match level (`9Player`, `Main`).
+- **Data-driven via ScriptableObjects:** `TowerDefinition`, `EnemyDefinition`, `WaveDefinition`, `RaceDefinition`, `GoldConfig`, `DraftOption` (+ subclasses), `EnemyAbilityDefinition` (+ subclasses), `EnemyUpgradeOption` (+ subclasses), and the run structure — `WaveGroup` → `PhaseDefinition` → `RunDefinition`, plus `EnemyUpgradeGroup`. Designers tune stats in assets, not code.
+- **Pools are authored as draggable groups.** Wave and enemy-buff pools are lists of *group* assets, so rebalancing a phase means dragging a group between phases rather than re-authoring entries. Draw weights are **relative** 0–1 values on each entry (`[PoolWeight]`), normalized at draw time; a zero weight means "never drawn" and the inspector warns inline.
+- **Per-player state pattern:** `NetworkBehaviour`s on the **Player prefab** with a static `GetForClient(clientId)` / `Local` registry. Current set: `PlayerGoldManager`, `PlayerMatchState`, `PlayerTowerDeck`, `PlayerTowerUpgrades`, `PlayerDraft`, `PlayerSpellLoadout`, `BuilderUpgradeManager`.
+- **Networked identifiers are catalog indices.** `TowerTypeId` indexes `TowerPlacementManager.towerDefinitions[]`; `DraftOptionId` indexes `DraftPool`; `EnemyUpgradeOptionId` indexes `EnemyUpgradePool`; wave ids index a flat list `RunDefinition` builds by walking its phases/groups/entries in declaration order (identical on every peer). Stable **within a match**, not across sessions.
+- **Run progression lives in `RunState`, not `WaveManager`.** `RunState` owns phase/cycle position, the drawn wave slots, and the per-slot enemy-buff sets; `WaveManager` only runs the encounter it is pointed at and calls `ServerAdvance()` when the field clears.
+- **Namespaces:** `TD.Core` (enums, palettes, grid math, match rules), `TD.Gameplay` (builder, placement, match/pathfinding state, economy, enemies, deck), `TD.Gameplay.Waves` (run structure + progression), `TD.Gameplay.Draft` (per-player draft), `TD.Gameplay.EnemyAbilities` (what buffed enemies *do*), `TD.Gameplay.EnemyUpgrades` (the vote + its cards), `TD.Combat` (TowerCombat/Projectile), `TD.Levels` (in-engine authoring + bake), `TD.UI`, `TD.Net`.
+- **Scenes:** `MainMenu` → `Lobby` → a Match level (`9Player`, `Main`). `9Player` is **legacy** at 3-player scope — a new 3-player map is pending.
### Engineering principles (carried across sessions)