2.0_design_refactor #13

Merged
matt merged 2 commits from 2.0_design_refactor into main 2026-07-31 00:37:38 -07:00
64 changed files with 4023 additions and 344 deletions
Showing only changes of commit 4892d7253d - Show all commits

View file

@ -8,9 +8,22 @@ namespace TD.Core
/// </summary>
public enum EnemyAbilityKind : byte
{
/// <summary>No ability rolled. Never a real pool entry — only the sentinel value
/// <see cref="TD.Gameplay.EnemyAbility"/> reports before/absent a roll.</summary>
/// <summary>Sentinel for "no ability". Never a real pool entry.</summary>
None = 0,
/// <summary>Dies into several smaller, faster copies of itself.</summary>
SplitOnDeath = 1,
/// <summary>Grounded enemies take to the air, ignoring the maze entirely.</summary>
Flight = 2,
/// <summary>Teleports a short distance further along its path on a timer.</summary>
Blink = 3,
/// <summary>Grants no kill bounty.</summary>
NoBounty = 4,
/// <summary>Steals gold from the player whose zone it escaped, on top of the life cost.</summary>
GoldTheft = 5,
}
}

View file

@ -68,8 +68,12 @@ namespace TD.Core
/// </summary>
/// <remarks>
/// <c>None</c> is a sentinel value used in <c>OwnerGrid</c> 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.
///
/// <para><b>The enum runs to 9, but only <see cref="MatchRules.MaxPlayers"/> slots are ever
/// allocated.</b> Lobbies capped at 3 for the 2.0 design; the surplus values are kept because
/// they are baked into existing <c>LevelData</c> owner grids, and renumbering the enum would
/// force a re-bake of every map to no runtime benefit.</para>
/// </remarks>
/// <summary>
/// Global phase of a match, driven by <c>MatchState</c>.
@ -92,6 +96,28 @@ namespace TD.Core
Defeat = 4,
}
/// <summary>
/// Which step of the between-encounters sequence is currently running. Replicated by
/// <c>WaveManager</c> so every HUD can label the shared countdown correctly.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public enum InterWaveStage : byte
{
/// <summary>No inter-wave step running — a wave is spawning or being fought.</summary>
None = 0,
/// <summary>Players are picking their personal draft reward.</summary>
Draft = 1,
/// <summary>Players are voting on the buff for the wave they just cleared.</summary>
Vote = 2,
/// <summary>Build countdown before the next wave spawns.</summary>
Build = 3,
}
/// <summary>
/// Stable identifier per race. Values 1-16 reserve slots for the planned
/// 16-race grid in the lobby; only races with a corresponding

View file

@ -0,0 +1,26 @@
// Assets/_Project/Scripts/Core/MatchRules.cs
namespace TD.Core
{
/// <summary>
/// Match-wide constants that several unrelated systems have to agree on.
/// </summary>
public static class MatchRules
{
/// <summary>
/// Maximum players in one lobby.
/// </summary>
/// <remarks>
/// <b>Reduced from 9 to 3 for the 2.0 design</b> 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.
///
/// <para><b>Why <see cref="PlayerSlot"/> still runs to 9.</b> Shrinking the enum would
/// invalidate the owner grids baked into existing <c>LevelData</c> 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.</para>
/// </remarks>
public const int MaxPlayers = 3;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac169573e8a2fd046a3ebac7d2e03c8c

View file

@ -0,0 +1,35 @@
// Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
using UnityEngine;
namespace TD.Core
{
/// <summary>
/// Marks a float field as a 01 <b>relative</b> 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.
/// </summary>
/// <remarks>
/// <b>Relative, not absolute.</b> 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.
///
/// <para><b>The warning is advisory.</b> 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.</para>
/// </remarks>
public class PoolWeightAttribute : PropertyAttribute
{
/// <summary>
/// Noun used in the zero-weight warning ("this <c>wave</c> can never be drawn"). Keep it
/// singular and lowercase.
/// </summary>
public readonly string Noun;
public PoolWeightAttribute(string noun = "entry")
{
Noun = string.IsNullOrWhiteSpace(noun) ? "entry" : noun;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e171856d7a8fe194f97091853672c19d

View file

@ -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();
}
}
}

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 75cc168bfd0b91b41a313a4389d31910
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,66 @@
// Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
using UnityEditor;
using UnityEngine;
using TD.Core;
namespace TD.Editor.Core
{
/// <summary>
/// Draws a <see cref="PoolWeightAttribute"/> field as a 01 slider, with an inline warning
/// rendered directly beneath it whenever the weight is zero.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8ba57381fc7e6a3479e59d84e45720d9

View file

@ -0,0 +1,69 @@
// Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs
using UnityEditor;
using UnityEngine;
using TD.Gameplay.Waves;
namespace TD.Editor.Gameplay
{
/// <summary>
/// Inspector for <see cref="RunDefinition"/>. Draws the normal fields, then a live
/// validation panel reporting whether the authored pools can actually produce a run.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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).");
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f5e8e0197efd154b92817bbb3028b35

View file

@ -82,7 +82,7 @@ namespace TD.Gameplay.Draft
draft.ServerOffer(resultScratch);
}
/// <summary>Server-only: auto-resolve any unpicked drafts (called at prep end).</summary>
/// <summary>Server-only: auto-resolve any unpicked drafts (called when the timer expires).</summary>
public void ServerAutoResolveAll()
{
if (!IsServer) return;
@ -90,6 +90,29 @@ namespace TD.Gameplay.Draft
PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve();
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Returns false when nobody is connected, so an empty lobby can't trip the barrier and
/// race the sequence forward.
/// </remarks>
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

View file

@ -18,7 +18,10 @@ namespace TD.Gameplay.Draft
/// <para><b>Authority.</b> The server offers (<see cref="ServerOffer"/>), resolves
/// (<see cref="ServerResolve"/> / <see cref="ServerAutoResolve"/>), and applies the
/// chosen option. The owning client only sends intent via
/// <see cref="RequestPickRpc"/> and <see cref="RequestBuyRerollRpc"/>.</para>
/// <see cref="RequestPickRpc"/>.</para>
///
/// <para><b>The paid reroll is disabled</b> for the 2.0 MVP — see the commented-out block at
/// the bottom of this file for why it was kept rather than removed.</para>
///
/// <para><b>Generation</b> lives in <see cref="DraftService"/>; this component is just
/// the replicated state + the pick/buy entry points.</para>
@ -143,25 +146,38 @@ namespace TD.Gameplay.Draft
ServerResolve(optionId);
}
/// <summary>
/// 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).
/// </summary>
[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.
//
// /// <summary>
// /// 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).
// /// </summary>
// [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);
// }
}
}

View file

@ -5,52 +5,62 @@ using TD.Towers;
namespace TD.Gameplay.Draft
{
/// <summary>
/// Draft choice — "upgrade a tower you already have". Replaces <see cref="BaseTower"/>
/// with <see cref="UpgradedTower"/> in the player's <see cref="PlayerTowerDeck"/>: every
/// tower of that type placed from now on is the upgraded version. Already-placed towers
/// are unaffected.
/// Draft choice — "unlock an upgrade". Adds <see cref="UpgradedTower"/> to the player's
/// <see cref="PlayerTowerUpgrades"/> set, letting them spend gold converting a placed
/// <see cref="BaseTower"/> into it. The base tower stays buildable.
/// </summary>
/// <remarks>
/// Mirrors <see cref="NewTowerDraftOption"/>, but swaps rather than adds. Only offered
/// while the player currently has <see cref="BaseTower"/> unlocked — once taken,
/// <see cref="BaseTower"/> is removed from the deck, which automatically makes this option
/// (and any sibling upgrade branching off the same base) invalid for future drafts.
/// <para><b>Unlock, not swap.</b> 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 <i>access</i>, gold
/// grants the <i>tower</i>. So the deck is untouched here and already-placed towers are
/// upgraded one at a time, each paid for.</para>
///
/// <para><b>Prerequisites fall out of the tree.</b> 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
/// <see cref="TowerDefinition.UpgradePaths"/> is enough.</para>
/// </remarks>
[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);
}
/// <summary>Inherits the upgraded tower's icon unless this option assigns an override.</summary>

View file

@ -0,0 +1,60 @@
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.EnemyAbilities
{
/// <summary>
/// Every few seconds, the enemy teleports a short distance further along its path — skipping
/// tiles, and the towers covering them.
/// </summary>
/// <remarks>
/// 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
/// <c>EnemyMovement.ServerBlinkForward</c>.
///
/// <para>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.</para>
/// </remarks>
[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<EnemyMovement>()?.ServerBlinkForward(BlinkWaypoints);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4fbb09f55384ac44399ac4655b2b53ec

View file

@ -5,21 +5,28 @@ using TD.Core;
namespace TD.Gameplay.EnemyAbilities
{
/// <summary>
/// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Rolled
/// at random for each spawned enemy by <see cref="EnemyAbilityPool.RollRandom"/> and applied
/// via <see cref="EnemyAbility.InitializeServer"/>.
/// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Abilities
/// are attached to a <b>wave slot</b> by the post-wave player vote, and every enemy that wave
/// spawns from then on carries the whole accumulated set.
/// </summary>
/// <remarks>
/// <para><b>One asset per kind.</b> <see cref="Kind"/> is fixed per subclass, same as
/// <see cref="BuilderSpells.BuilderSpellDefinition"/>. <see cref="EnemyAbilityPool"/> uses
/// it to build a fixed-size, enum-indexed lookup table.</para>
/// <para><b>Assignment is deterministic, not random.</b> 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 <c>WaveManager</c> reads the slot's set instead. What survived is
/// the shape: one asset per kind, server-only hooks.</para>
///
/// <para><b>Server-only hooks.</b> All three hooks below only ever run on the server —
/// <see cref="EnemyAbility"/> only calls them when <c>IsServer</c> is true. <see
/// cref="ServerOnSpawn"/> and <see cref="ServerTick"/> 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.</para>
/// <para><b>Abilities stack.</b> 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.</para>
///
/// <para><b>Server-only hooks.</b> All hooks run on the server only — <see cref="EnemyAbility"/>
/// gates every call on <c>IsServer</c>. They default to no-ops so a new card only overrides the
/// one or two it actually needs.</para>
///
/// <para><b>No per-enemy state on the asset.</b> A single asset is shared by every enemy
/// carrying the ability, across every wave and match. Anything per-instance belongs on the
/// <see cref="EnemyAbility"/> component or the enemy itself.</para>
/// </remarks>
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 --------------------------------
/// <summary>Server-only: called once, right after this ability is assigned (before the
/// <summary>
/// Server-only: alter what this enemy spawns as, before it is built. Runs for every enemy
/// of the buffed wave. Default no-op.
/// </summary>
/// <remarks>
/// This is the hook for cards that change what an enemy fundamentally <i>is</i> — flight,
/// health, speed, size. It must run before the enemy exists, because spawn position
/// (flyers are raised) and <c>EnemyHealth</c>/<c>EnemyMovement</c> initialization all read
/// these values once and keep them.
///
/// <para>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.</para>
/// </remarks>
public virtual void ServerModifySpawn(ref EnemySpawnContext context) { }
// ----- Lifetime hooks ----------------------------------------------
/// <summary>Server-only: called once, right after the ability set is assigned (before the
/// enemy's NetworkObject is spawned to clients). Default no-op.</summary>
public virtual void ServerOnSpawn(EnemyAbility instance) { }
/// <param name="abilityIndex">This ability's slot on <paramref name="instance"/> — the
/// same index <see cref="ServerTick"/> receives, so per-enemy timers can be seeded here.</param>
public virtual void ServerOnSpawn(EnemyAbility instance, int abilityIndex) { }
/// <summary>Server-only: called every frame this ability is active on a live enemy.
/// Default no-op.</summary>
public virtual void ServerTick(EnemyAbility instance, float dt) { }
/// <summary>
/// Server-only: called every frame this ability is active on a live enemy. Default no-op.
/// </summary>
/// <param name="abilityIndex">This ability's slot on <paramref name="instance"/>. Pass it
/// to <see cref="EnemyAbility.TimerFor"/> to reach a per-enemy float this ability owns.</param>
/// <remarks>
/// <b>The asset holds no per-enemy state.</b> One <see cref="EnemyAbilityDefinition"/>
/// 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. <see cref="EnemyAbility.TimerFor"/> gives each enemy its own
/// slot by reference, which is enough for the timer-driven abilities and costs no
/// allocation.
/// </remarks>
public virtual void ServerTick(EnemyAbility instance, int abilityIndex, float dt) { }
/// <summary>Server-only: called the instant the enemy's HP reaches zero, before the
/// death animation/despawn sequence plays. Default no-op.</summary>
public virtual void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { }
// ----- Economy hooks -----------------------------------------------
/// <summary>
/// Server-only: adjust the gold a player earns for killing this enemy. Returns the reward
/// to pass on; default is unchanged.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public virtual int ServerModifyKillReward(EnemyAbility instance, int reward) => reward;
/// <summary>
/// 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.
/// </summary>
public virtual void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone) { }
}
}

View file

@ -13,8 +13,12 @@ namespace TD.Gameplay.EnemyAbilities
/// </summary>
/// <remarks>
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync.
/// Only the server calls <see cref="RollRandom"/> (at enemy spawn time, in
/// <c>WaveManager.SpawnEnemy</c>).
///
/// <para><b>Lookup only — no rolling.</b> 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
/// <c>WaveVote</c> (over cards, not abilities) and this is purely the kind→asset table
/// <c>WaveManager</c> resolves against at spawn time.</para>
/// </remarks>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View file

@ -0,0 +1,42 @@
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs
namespace TD.Gameplay.EnemyAbilities
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <b>Why a context rather than more parameters.</b> Wave buffs like "grounded enemies become
/// flying" or "enemies spawn at half health" have to land <i>before</i>
/// <c>EnemyHealth.InitializeServer</c> and before the spawn position is computed (flyers are
/// raised by their flight height). Threading each new stat through <c>SpawnEnemy</c> as another
/// argument was already unwieldy at two; this keeps the signature flat and gives abilities one
/// obvious place to write.
///
/// <para>Seeded from the <see cref="EnemyDefinition"/>, 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.</para>
/// </remarks>
public struct EnemySpawnContext
{
public float MaxHp;
public float MoveSpeed;
public bool IsFlying;
public float FlightHeight;
public int LivesCost;
/// <summary>Uniform transform scale relative to the prefab's authored scale.</summary>
public float VisualScale;
/// <summary>Seeds a context from an enemy definition's authored stats.</summary>
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,
};
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2f4b0f77b422a474cbe82f3967e6195b

View file

@ -0,0 +1,53 @@
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.EnemyAbilities
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <b>The single most punishing card in the set</b>, 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.
///
/// <para>Purely a spawn-time change: <c>EnemyMovement</c> reads the flying flag once at
/// initialization to pick which grid to path on and to opt out of re-path scheduling, and
/// <c>TowerCombat</c> reads the replicated flag on <c>EnemyHealth</c> when filtering targets.
/// Both are already wired for flight — this card only has to flip the bit before the enemy is
/// built.</para>
/// </remarks>
[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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be77d353621f07e40bbdab2e9cc37f51

View file

@ -0,0 +1,53 @@
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.EnemyAbilities
{
/// <summary>
/// An enemy that reaches the defense point doesn't just cost a life — it robs the player whose
/// maze it escaped.
/// </summary>
/// <remarks>
/// <b>Charged to the origin zone, not the whole team.</b> 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.
///
/// <para>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.</para>
/// </remarks>
[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);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b770ce042b78f44d9a3df537fe25db0

View file

@ -0,0 +1,34 @@
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.EnemyAbilities
{
/// <summary>
/// Killing these enemies pays little or nothing. The wave gets no harder to survive — it gets
/// harder to profit from.
/// </summary>
/// <remarks>
/// <b>Attacks the economy, not the maze.</b> 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.
///
/// <para>Wave-completion and no-leak bonuses are untouched — those are paid per player by
/// <c>WaveManager</c>, not per kill, so a wave carrying this still rewards clearing it.</para>
/// </remarks>
[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);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fd5b12948b24e9c4c8840ef3470b437e

View file

@ -7,32 +7,60 @@ namespace TD.Gameplay.EnemyAbilities
/// <summary>
/// On death, spawns <see cref="SplitCount"/> 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
/// <see cref="HpMultiplier"/>/<see cref="ScaleMultiplier"/>.
/// spawner.
/// </summary>
/// <remarks>
/// <b>No separate minion asset.</b> The copies reuse whichever <see cref="EnemyDefinition"/>
/// 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.
///
/// <para><b>Percentages are of the PARENT, not the asset.</b> 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.</para>
///
/// <para><b>Minions inherit no abilities at all</b> — 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.</para>
/// </remarks>
[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<EnemyMovement>();
var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
var movement = instance.GetComponent<EnemyMovement>();
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);
}
}
}

View file

@ -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
{
/// <summary>
/// Per-enemy ability slot. Holds the <see cref="EnemyAbilityDefinition"/> rolled for this
/// instance (if any) and drives its server-only hooks.
/// Per-enemy ability set. Holds every <see cref="EnemyAbilityDefinition"/> its wave has
/// accumulated and drives their server-only hooks.
/// </summary>
/// <remarks>
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> on the server immediately after
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>, following the same pattern as
/// <c>EnemyHealth.InitializeServer</c> / <c>EnemyMovement.InitializeServer</c>.
///
/// <b>A set, not a slot.</b> 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. <see cref="kinds"/> replicates the set so clients can show players what
/// they're facing.
///
/// <b>Optional component:</b> Not required by <c>EnemyHealth</c> or <c>EnemyMovement</c> —
/// <c>WaveManager.SpawnEnemy</c> 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.
/// <c>WaveManager.SpawnEnemy</c> only assigns abilities when this component is present on the
/// prefab, so enemy prefabs without it simply never carry wave buffs.
///
/// <b>On-death hook is not event-driven:</b> <c>WaveManager.HandleEnemyKilled</c> calls
/// <see cref="EnemyAbilityDefinition.ServerOnDeath"/> directly (via <see cref="Definition"/>)
/// rather than this component subscribing to <c>EnemyHealth.OnDied</c> itself — that keeps a
/// split-spawn's <c>activeEnemyCount</c> increment ordered before the triggering kill's
/// decrement, regardless of NetworkBehaviour subscription order.
/// <see cref="ServerInvokeOnDeath"/> directly rather than this component subscribing to
/// <c>EnemyHealth.OnDied</c> itself — that keeps a split-spawn's <c>activeEnemyCount</c>
/// increment ordered before the triggering kill's decrement, regardless of NetworkBehaviour
/// subscription order.
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class EnemyAbility : NetworkBehaviour
{
private readonly NetworkVariable<byte> kind = new NetworkVariable<byte>(
(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<byte> kinds;
// ----- Pre-spawn init (server-local) ----------------------------------
private EnemyAbilityDefinition pendingDefinition;
private bool hasPendingInit;
private readonly List<EnemyAbilityDefinition> pendingDefinitions = new List<EnemyAbilityDefinition>();
private bool hasPendingInit;
// ----- Public state -----------------------------------------------------
/// <summary>The ability rolled for this enemy, or null if none was rolled.</summary>
public EnemyAbilityDefinition Definition { get; private set; }
private readonly List<EnemyAbilityDefinition> definitions = new List<EnemyAbilityDefinition>();
/// <summary>Replicated kind of the rolled ability. <see cref="EnemyAbilityKind.None"/> if
/// none was rolled.</summary>
public EnemyAbilityKind Kind => (EnemyAbilityKind)kind.Value;
/// <summary>The abilities this enemy carries. Server-side; empty on clients.</summary>
public IReadOnlyList<EnemyAbilityDefinition> Definitions => definitions;
/// <summary>True if this enemy carries no abilities at all.</summary>
public bool IsPlain => definitions.Count == 0;
/// <summary>
/// The stats this enemy actually spawned with, after every ability's spawn modification.
/// Server-side.
/// </summary>
/// <remarks>
/// Kept so on-death abilities can derive from what the enemy really was rather than from
/// its <see cref="EnemyDefinition"/>. 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.
/// </remarks>
public EnemySpawnContext SpawnContext { get; private set; }
private void Awake()
{
kinds = new NetworkList<byte>();
}
/// <summary>Replicated check for a specific ability. Safe on any peer.</summary>
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 -------------------------------------
/// <summary>
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and before
/// <c>NetworkObject.Spawn()</c>. <paramref name="definition"/> may be null, meaning no
/// ability was rolled for this enemy.
/// <c>NetworkObject.Spawn()</c>. <paramref name="abilities"/> may be null or empty,
/// meaning this enemy carries no wave buffs.
/// </summary>
public void InitializeServer(EnemyAbilityDefinition definition)
/// <remarks>
/// Copies rather than retains the caller's list — <c>WaveManager</c> reuses one scratch
/// buffer across every enemy in a wave, so holding the reference would alias every enemy
/// to the same (later mutated) contents.
/// </remarks>
public void InitializeServer(IReadOnlyList<EnemyAbilityDefinition> 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<float>();
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<float>();
/// <summary>
/// A per-enemy float owned by the ability at <paramref name="abilityIndex"/>, returned by
/// reference so it can be read and written in place. Typically a cooldown accumulator.
/// </summary>
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 ----------------------------------------
/// <summary>Server-only: run every carried ability's on-death hook.</summary>
public void ServerInvokeOnDeath(EnemyHealth health)
{
if (!IsServer) return;
for (int i = 0; i < definitions.Count; i++)
definitions[i].ServerOnDeath(this, health);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Server-only: run every carried ability's reached-goal hook.</summary>
public void ServerInvokeReachedGoal(PlayerSlot originZone)
{
if (!IsServer) return;
for (int i = 0; i < definitions.Count; i++)
definitions[i].ServerOnReachedGoal(this, originZone);
}
}
}

View file

@ -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<bool> isBoss = new NetworkVariable<bool>(
false,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// ----- Public state ---------------------------------------------------
public float CurrentHp => hp.Value;
@ -117,6 +126,19 @@ namespace TD.Gameplay
/// </summary>
public bool IsFlying => isFlying.Value;
/// <summary>True if this enemy was spawned by a phase's boss encounter. Replicated.</summary>
public bool IsBoss => isBoss.Value;
/// <summary>
/// Every live boss, on every peer. Maintained by spawn/despawn so the HUD can render a
/// boss bar without polling the scene.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static readonly List<EnemyHealth> ActiveBosses = new List<EnemyHealth>();
// ----- Events ---------------------------------------------------------
/// <summary>
@ -136,12 +158,14 @@ namespace TD.Gameplay
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
/// <c>TowerInstance.InitializeServer</c> pattern.
/// </summary>
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.

View file

@ -282,6 +282,45 @@ namespace TD.Gameplay
NetworkObject.Despawn();
}
// ----- Ability-driven movement ----------------------------------------
/// <summary>
/// Server-only: teleport this enemy forward along its existing path by up to
/// <paramref name="tiles"/> waypoints. Used by the Blink wave buff.
/// </summary>
/// <remarks>
/// <b>Follows the path rather than the straight line to the goal.</b> 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.
///
/// <para>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.</para>
///
/// <para>Blinking onto the final waypoint resolves as a normal goal arrival, despawn and
/// life cost included.</para>
/// </remarks>
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

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 95ac4e8d1714a07489dad0c283970bf8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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
{
/// <summary>
/// The workhorse enemy-buff card: grants an <see cref="EnemyAbilityDefinition"/> to a wave
/// slot. Every enemy spawned by that wave, in every later cycle of the phase, carries the
/// ability.
/// </summary>
/// <remarks>
/// 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. <see cref="Ability"/> is what that lookup resolves to.
/// </remarks>
[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<int> s_idScratch
= new System.Collections.Generic.List<int>();
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;
}
/// <summary>Inherits nothing automatically — <see cref="EnemyAbilityDefinition"/> carries no
/// icon, so the card's own <see cref="EnemyUpgradeOption.Icon"/> is the only source.</summary>
public override Sprite ResolveIcon() => Icon;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d075c66b381872a4781b3916296ccdc9

View file

@ -0,0 +1,48 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
using UnityEngine;
using TD.Gameplay.Waves;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// Vote-meta card: the next time this wave is cleared, it takes several buffs automatically
/// and the players get no say in which.
/// </summary>
/// <remarks>
/// <b>Not an enemy ability.</b> Nothing about the enemies changes when this is voted in — it
/// changes how the <i>next vote</i> on this slot resolves. That's why it derives from
/// <see cref="EnemyUpgradeOption"/> directly instead of wrapping an
/// <c>EnemyAbilityDefinition</c>: the spawn path builds ability sets only from ability cards,
/// so this one is recorded on the slot and otherwise invisible to enemies.
///
/// <para><b>The trade is agency for information.</b> 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 (<c>ServerConsumeAutoApply</c>), so a slot can only be
/// doubled once per card.</para>
///
/// <para>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.</para>
/// </remarks>
[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);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f5e90fe6b8e439e4f99769a6923c2262

View file

@ -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
{
/// <summary>
/// One weighted candidate inside an <see cref="EnemyUpgradeGroup"/>.
/// </summary>
/// <remarks>
/// Weight is relative and normalized at draw time — see <see cref="PoolWeightAttribute"/>.
/// 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.
/// </remarks>
[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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Buffs are gated by <b>phase</b>, 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.
/// </remarks>
[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;
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
/// <summary>
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
/// <paramref name="into"/>. Zero-weight entries are filtered here so "weight 0 means it
/// can never appear" lives in exactly one place.
/// </summary>
public static void CollectCandidates(EnemyUpgradeGroup[] groups, List<EnemyUpgradePoolEntry> 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);
}
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4202a767d96e70f41b6a7e84eb08a832

View file

@ -0,0 +1,66 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
using UnityEngine;
using TD.Gameplay.Waves;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para><b>Mirrors <c>DraftOption</c> deliberately.</b> 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 <i>wave slot</i>.</para>
///
/// <para><b>Identity.</b> Options live in <see cref="EnemyUpgradePool"/> and cross the network
/// as their pool index, the same stable-within-a-match id pattern used by the tower catalog
/// and draft pool.</para>
///
/// <para><b>Recording is not this type's job.</b> <c>WaveVote</c> records the winning option id
/// onto the slot via <c>RunState.ServerAddUpgrade</c>; <see cref="ServerOnApplied"/> is only
/// for cards that need a side effect <i>beyond</i> being recorded (the vote-meta cards).
/// Ability cards need nothing here — the spawn path reads the slot's recorded set.</para>
/// </remarks>
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;
/// <summary>
/// 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
/// <see cref="Icon"/> (which may be null).
/// </summary>
public virtual Sprite ResolveIcon() => Icon;
/// <summary>
/// Server-only: may this option be OFFERED for <paramref name="slot"/> right now? Default
/// true; override for cards with prerequisites or ones that would be a no-op.
/// </summary>
/// <remarks>
/// Callers already skip options the slot has taken before, so implementations don't need
/// to re-check that.
/// </remarks>
public virtual bool IsValidForSlot(RunState run, int slot) => true;
/// <summary>
/// Server-only: side effect to run after this option has been recorded onto
/// <paramref name="slot"/>. Default no-op — most cards do their work at enemy-spawn time
/// by virtue of being on the slot, not at vote-resolution time.
/// </summary>
public virtual void ServerOnApplied(RunState run, int slot) { }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b1e9770be72a694a96169d9a9032528

View file

@ -0,0 +1,94 @@
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
using System.Collections.Generic;
using UnityEngine;
namespace TD.Gameplay.EnemyUpgrades
{
/// <summary>
/// Scene singleton holding every <see cref="EnemyUpgradeOption"/> that can appear anywhere in
/// this match. The array index is the option's <c>EnemyUpgradeOptionId</c> — the stable
/// identifier used to replicate vote offers, ballots, and the upgrades recorded on wave slots.
/// </summary>
/// <remarks>
/// <b>Why a flat pool <i>and</i> per-phase groups.</b> The pool supplies network identity: one
/// array, one index per card, identical on every peer. <c>PhaseDefinition.EnemyUpgradeGroups</c>
/// 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.
///
/// <para>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.</para>
/// </remarks>
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;
}
/// <summary>Number of options in the pool.</summary>
public int Count => options?.Length ?? 0;
/// <summary>Returns the option at <paramref name="id"/>, or null if out of range.</summary>
public EnemyUpgradeOption Get(int id)
=> (options != null && id >= 0 && id < options.Length) ? options[id] : null;
/// <summary>Resolves an option asset back to its pool id, or -1 if it isn't in the pool.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public void CollectAllowedIds(EnemyUpgradeGroup[] phaseGroups,
List<EnemyUpgradePoolEntry> entryScratch,
List<int> intoIds,
List<float> 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);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4d98a01d9b9a7b438a6f91b0b7f498f

View file

@ -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
{
/// <summary>
/// The shared, <b>open-ballot</b> vote held after each wave clears: players collectively pick
/// which permanent buff the wave they just beat will carry into the next cycle.
/// </summary>
/// <remarks>
/// <para><b>Ballots are public by design.</b> <see cref="ballots"/> 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.</para>
///
/// <para><b>Votes stay changeable</b> until the vote resolves, for the same reason. The
/// consequence is that the last player to vote ends the vote instantly (see
/// <see cref="AllPlayersVoted"/>), 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.</para>
///
/// <para><b>Scene singleton, server-authoritative.</b> Unlike <c>PlayerDraft</c> (one per
/// player) this is one shared object, so votes arrive as plain server RPCs and the server maps
/// the sender to a <see cref="PlayerSlot"/> itself rather than trusting an owner claim.</para>
/// </remarks>
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<int> 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<int> ballots;
/// <summary>Sentinel stored in <see cref="ballots"/> for a player who hasn't voted.</summary>
public const int NoVote = -1;
// Wave slot this vote will upgrade, or -1 when no vote is open.
private readonly NetworkVariable<int> targetSlot = new NetworkVariable<int>(
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
/// <summary>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.</summary>
public event System.Action OnVoteChanged;
// ----- Lifecycle ----------------------------------------------------
private void Awake()
{
offeredOptionIds = new NetworkList<int>();
ballots = new NetworkList<int>();
}
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<int> _) => OnVoteChanged?.Invoke();
private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke();
// ----- Read API ------------------------------------------------------
/// <summary>True while a vote is open for players to cast or change a ballot.</summary>
public bool IsOpen => targetSlot.Value >= 0;
/// <summary>Wave slot this vote will upgrade, or -1 when closed.</summary>
public int TargetSlot => targetSlot.Value;
public int OptionCount => offeredOptionIds.Count;
public int GetOptionId(int index)
=> (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1;
/// <summary>The option <paramref name="slot"/> voted for, or <see cref="NoVote"/>.</summary>
public int GetBallot(PlayerSlot slot)
{
int i = (int)slot;
return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote;
}
/// <summary>
/// Appends every player slot that voted for <paramref name="optionId"/>. Drives the voter
/// badges the HUD paints onto each card.
/// </summary>
public void GetVotersFor(int optionId, List<PlayerSlot> 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);
}
}
/// <summary>
/// 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.
/// </summary>
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<EnemyUpgradePoolEntry> entryScratch = new List<EnemyUpgradePoolEntry>();
private readonly List<int> idScratch = new List<int>();
private readonly List<float> weightScratch = new List<float>();
private readonly List<int> tiedScratch = new List<int>();
/// <summary>
/// Server-only: open a vote for <paramref name="waveSlot"/>, 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
///
/// <para>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.</para>
/// </remarks>
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;
}
/// <summary>
/// Server-only: apply <paramref name="count"/> buffs to <paramref name="slot"/> outright,
/// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade
/// a future vote away for extra buffs.
/// </summary>
/// <remarks>
/// Deliberately opens no ballot: the card's whole cost is that the players <i>don't</i> 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.
/// </remarks>
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;
}
/// <summary>Server-only: close the vote without applying anything.</summary>
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)];
}
/// <summary>
/// Fills <see cref="idScratch"/>/<see cref="weightScratch"/> with the cards this phase
/// allows that <paramref name="slot"/> can still meaningfully take. Returns false when
/// nothing qualifies.
/// </summary>
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<float> 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 ------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a18321dc2d138034fbdb5f705f19f893

View file

@ -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;
/// <summary>
/// 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
/// (<c>RunState.GlobalEncounterNumber</c>), or null if out of range.
/// </summary>
public WaveGoldEntry GetWaveEntry(int waveNumber)
/// <remarks>
/// Keyed by <b>encounter</b>, 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.
/// </remarks>
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];
}

View file

@ -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;
}
}

View file

@ -0,0 +1,116 @@
// Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// Per-player set of unlocked tower <b>upgrade nodes</b> — the tower types this player is
/// allowed to convert an existing tower into. Lives on the Player prefab alongside
/// <see cref="PlayerTowerDeck"/>.
/// </summary>
/// <remarks>
/// <para><b>Distinct from the deck, deliberately.</b> <see cref="PlayerTowerDeck"/> is what you
/// can <i>build from scratch</i>; this is what you can <i>upgrade into</i>. 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.</para>
///
/// <para><b>The tree lives in the data.</b> Edges are <c>TowerDefinition.UpgradePaths</c>; 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?).</para>
///
/// <para>Append-only within a match, mirroring the deck. Reset happens by re-initializing at
/// match start, so Retry / return-to-lobby cycles start clean.</para>
/// </remarks>
public class PlayerTowerUpgrades : NetworkBehaviour
{
// ----- Static registry (mirrors PlayerTowerDeck / PlayerGoldManager) -----
private static readonly Dictionary<ulong, PlayerTowerUpgrades> s_byClientId
= new Dictionary<ulong, PlayerTowerUpgrades>();
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<int> unlockedNodes;
/// <summary>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.</summary>
public event System.Action OnUpgradesChanged;
private void Awake()
{
unlockedNodes = new NetworkList<int>();
}
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<int> _) => OnUpgradesChanged?.Invoke();
// ----- Read API ---------------------------------------------------
public int Count => unlockedNodes.Count;
public int GetTypeIdAt(int index) => unlockedNodes[index];
/// <summary>True if this player has unlocked the given upgrade node.</summary>
public bool Contains(int towerTypeId)
{
for (int i = 0; i < unlockedNodes.Count; i++)
if (unlockedNodes[i] == towerTypeId) return true;
return false;
}
// ----- Server API -------------------------------------------------
/// <summary>Server-only: clear the unlocked set. Called at match start.</summary>
public void ServerInitialize()
{
if (!IsServer) return;
unlockedNodes.Clear();
}
/// <summary>
/// Server-only: unlock an upgrade node. Returns false if already unlocked (so a draft
/// option can report a wasted pick rather than silently succeeding).
/// </summary>
public bool ServerUnlock(int towerTypeId)
{
if (!IsServer) return false;
if (towerTypeId < 0 || Contains(towerTypeId)) return false;
unlockedNodes.Add(towerTypeId);
return true;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f60f6928562351f4891142ad59ab6352

View file

@ -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 ------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public event System.Action OnDefinitionChanged;
/// <summary>
/// Gold cost to convert this tower into <paramref name="target"/>. The target's own
/// <see cref="TowerDefinition.GoldCost"/> is the price — upgrade nodes are never placed
/// directly, so their cost field is free to mean "what this upgrade costs".
/// </summary>
public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0;
/// <summary>
/// True if <paramref name="target"/> is a legal upgrade of this tower's current type: a
/// direct child in the upgrade tree, with a matching footprint.
/// </summary>
/// <remarks>
/// <b>The footprint check is not cosmetic.</b> 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.
/// </remarks>
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;
}
/// <summary>
/// Collects the upgrades <paramref name="clientId"/> 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.
/// </summary>
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));
}
}
/// <summary>
/// Client → server request to convert this tower into <paramref name="targetTypeId"/>.
/// Accepted only from the owner, only for a node they've unlocked, only along a real tree
/// edge, and only if they can pay.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <summary>
/// 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

View file

@ -41,5 +41,64 @@ namespace TD.Gameplay
[Tooltip("Enemy groups that make up this wave. Processed in order.")]
public WaveEntry[] Entries;
/// <summary>
/// The enemy type this wave is "about" — the first assigned entry's type. Null if the
/// wave has no usable entries.
/// </summary>
/// <remarks>
/// 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. <see cref="Entries"/> still supports multiple
/// groups (useful for staggering counts of the same type), but mixing <i>types</i> within
/// one wave makes this ambiguous — see <see cref="HasSingleEnemyType"/>.
/// </remarks>
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;
}
}
/// <summary>
/// True if every usable entry in this wave spawns the same enemy type. False means
/// <see cref="PrimaryEnemyType"/> 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.
/// </summary>
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;
}
}
/// <summary>Total enemies spawned per zone by this wave, across all entries.</summary>
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;
}
}
}
}

View file

@ -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
{
/// <summary>
/// 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. <b>Which</b> encounter to
/// run comes from <see cref="RunState"/>; this class only runs it.
/// </summary>
/// <remarks>
/// <b>Wave lifecycle:</b>
/// <b>Encounter lifecycle:</b>
/// <list type="bullet">
/// <item>When <see cref="MatchPhase.Playing"/> is entered,
/// <see cref="StartNextWave"/> advances <see cref="MatchState.CurrentWave"/>
/// immediately (so the HUD shows the wave number during prep), then waits
/// <see cref="WaveDefinition.PrepTime"/> before spawning.</item>
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone,
/// one zone per frame-group, with <c>SpawnInterval</c> seconds between
/// individual enemies in the group.</item>
/// <item>After all entries are spawned, the wave is considered complete only when
/// every active enemy is either killed or has reached the goal.</item>
/// <item>All waves exhausted → <see cref="MatchPhase.Victory"/>.</item>
/// <item>When <see cref="MatchPhase.Playing"/> is entered, <see cref="StartRun"/> asks
/// <see cref="RunState.ServerBeginRun"/> to draw phase 1 and then runs its first wave.</item>
/// <item>Each encounter is: inter-wave step (draft, then the enemy-buff vote, then the
/// build countdown) → spawn → mop up. See <see cref="RunInterWave"/>.</item>
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone. All
/// spawn held, then release in 10% chunks on <c>ReleaseInterval</c>.</item>
/// <item>The encounter is complete only when every spawned enemy is dead or has leaked.
/// <see cref="RunState.ServerAdvance"/> then decides what comes next.</item>
/// <item>Final phase's boss dies → <see cref="MatchPhase.Victory"/>.</item>
/// <item>Lives drop to 0 → <see cref="MatchPhase.Defeat"/>.</item>
/// </list>
///
/// <b>Why the wave list moved out.</b> 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.
/// <see cref="RunState"/> owns it; this class asks it what to run.
///
/// <b>Kill gold:</b> When an enemy dies, <see cref="EnemyHealth.LastHitOwner"/> names
/// the tower's player. <see cref="PlayerMatchState.GetForSlot"/> resolves the
/// <c>OwnerClientId</c>, and <see cref="PlayerGoldManager.GetForClient"/> awards
/// the gold.
/// the gold. <c>GoldConfig</c> is indexed by
/// <see cref="RunState.GlobalEncounterNumber"/> — a run-long counter, not a per-cycle one, so
/// per-wave payouts keep climbing as the cycles repeat instead of resetting.
///
/// <b>Zone leak counts:</b> <see cref="zoneLeakCounts"/> is a <c>NetworkList</c>
/// indexed by <c>(int)PlayerSlot</c> (indices 08). It is incremented when an enemy
/// indexed by <c>(int)PlayerSlot</c>. 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 <see cref="PlayerSlot.None"/> and is unused.
///
/// <b>Inspector setup:</b>
/// <list type="bullet">
/// <item>Assign <see cref="waveDefinitions"/> in order (Wave 1 at index 0).</item>
/// <item>Assign a <c>RunDefinition</c> to the scene's <see cref="RunState"/>.</item>
/// <item>Set <see cref="startingLives"/> to match your level design intent.</item>
/// </list>
/// </remarks>
@ -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> interWaveStage =
new NetworkVariable<InterWaveStage>(
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<EnemyHealth> heldEnemies
= new System.Collections.Generic.List<EnemyHealth>();
@ -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 -------------------------------------------
/// <summary>
/// Total number of waves in this match. Same on every peer because
/// <see cref="waveDefinitions"/> is a serialized prefab field, identical
/// on host and clients. Returns 0 if the array is unassigned.
/// </summary>
public int TotalWaves => waveDefinitions?.Length ?? 0;
/// <summary>
/// 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
/// </summary>
public float PrepCountdown => prepCountdown.Value;
/// <summary>
/// Which inter-wave step the current countdown belongs to, or
/// <see cref="InterWaveStage.None"/> while a wave is spawning or being fought. Replicated;
/// safe on any peer. The HUD uses it to label the shared countdown.
/// </summary>
public InterWaveStage CurrentInterWaveStage => interWaveStage.Value;
/// <summary>
/// 1-based encounter number since the run began, or 0 before it starts. This is the key
/// <see cref="GoldConfig"/> is indexed by. Replicated via <see cref="RunState"/>, so it's
/// safe on any peer.
/// </summary>
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)
/// <summary>
/// Server-only: begin the run. Draws phase 1 and starts its first encounter. Idempotent —
/// re-entering <see cref="MatchPhase.Playing"/> won't restart a run in progress.
/// </summary>
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);
}
/// <summary>
/// Server-only: run whatever encounter <see cref="RunState"/> is currently pointing at.
/// </summary>
/// <param name="skipInterWave">Skip the draft/vote/build step and spawn immediately.
/// Used by the dev force-advance cheat.</param>
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));
}
/// <summary>
/// Server-only: the encounter is over — ask <see cref="RunState"/> what's next and either
/// start it or end the match.
/// </summary>
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 -----------------------------------------------
/// <summary>
/// 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.
/// </summary>
public void ForceAdvanceToNextWave()
{
if (!IsServer) return;
if (!IsServer || !runStarted) return;
ClearFieldAndStopEncounter();
AdvanceRun();
}
// Stop the current wave's coroutine (cancels prep timer + remaining spawns).
/// <summary>
/// Dev cheat: skip whole encounters until the run reaches this phase's boss. Useful for
/// testing boss content without playing fifteen waves. Server-only.
/// </summary>
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 ---------------------------------------------
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
///
/// <para>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.</para>
/// </remarks>
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;
}
/// <summary>
/// Ticks <see cref="prepCountdown"/> down from <paramref name="seconds"/> to zero,
/// exiting early once <paramref name="finishedEarly"/> returns true.
/// </summary>
/// <remarks>
/// Network sync is throttled to ~10 Hz. <c>NetworkVariable</c> 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.
/// </remarks>
private IEnumerator TickCountdown(float seconds, System.Func<bool> 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<EnemyAbilityDefinition> currentWaveAbilities
= new System.Collections.Generic.List<EnemyAbilityDefinition>();
private readonly System.Collections.Generic.List<int> upgradeIdScratch
= new System.Collections.Generic.List<int>();
/// <summary>
/// Server-only: resolve the current wave slot's voted upgrades into the ability list every
/// enemy of this encounter will carry.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>
/// Server-only: build and spawn one enemy.
/// </summary>
/// <param name="abilities">Wave buffs this enemy carries. Null/empty for a plain enemy.</param>
/// <param name="contextOverride">Pre-computed stats to spawn with, bypassing both the
/// definition's values and <paramref name="abilities"/>' spawn modifications. Used by
/// split-on-death, whose minions are derived from the parent rather than the asset.</param>
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot,
System.Collections.Generic.IReadOnlyList<EnemyAbilityDefinition> 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<EnemyHealth>();
var movement = go.GetComponent<EnemyMovement>();
@ -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<EnemyAbility>();
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
/// <c>activeEnemyCount</c>/event-wiring bookkeeping as any other spawn.
/// </summary>
/// <remarks>
/// 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 <paramref name="def"/>.
/// <b>Minions inherit nothing.</b> 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.
///
/// <para>Their stats come entirely from <paramref name="context"/>, 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.</para>
/// </remarks>
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<EnemyAbility>();
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<EnemyAbility>();
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<EnemyAbility>()?.ServerInvokeReachedGoal(movement.OriginZone);
UnsubscribeEnemy(movement.GetComponent<EnemyHealth>());
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);
}
/// <summary>
/// Server-only: show a "-N gold" popup on every peer. Exposed because enemy abilities run
/// inside ScriptableObjects, which have no <c>NetworkBehaviour</c> of their own to send a
/// ClientRpc from.
/// </summary>
public void ServerBroadcastGoldLoss(Vector3 worldPos, int amount)
{
if (!IsServer || amount <= 0) return;
ShowGoldLossClientRpc(worldPos, amount);
}
// ----- Local-only notification events -----------------------------
/// <summary>
@ -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;

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f65ba25aee224c24dad3648d9e5e953e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,102 @@
// Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
using System.Collections.Generic;
using UnityEngine;
namespace TD.Gameplay.Waves
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <b>Groups, not waves.</b> Both pools are lists of <see cref="WaveGroup"/> 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.
///
/// <para><b>Draw contract.</b> <c>RunState</c> draws <c>WavesPerCycle</c> waves from
/// <see cref="WaveGroups"/> with <b>distinct enemy types</b>, 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 <c>WavesPerCycle</c> distinct enemy types across its groups,
/// not just that many waves. <see cref="CountDistinctEnemyTypes"/> exists so the draw can
/// fail loudly at match start rather than mid-run.</para>
/// </remarks>
[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;
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
/// <summary>
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
/// <paramref name="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.
/// </summary>
public static void CollectCandidates(WaveGroup[] groups, List<WavePoolEntry> 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);
}
}
}
/// <summary>Collects this phase's cycle-wave candidates.</summary>
public void CollectWaveCandidates(List<WavePoolEntry> into)
=> CollectCandidates(WaveGroups, into);
/// <summary>Collects this phase's boss candidates.</summary>
public void CollectBossCandidates(List<WavePoolEntry> into)
=> CollectCandidates(BossGroups, into);
/// <summary>
/// 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.
/// </summary>
public int CountDistinctEnemyTypes()
{
var candidates = new List<WavePoolEntry>();
CollectWaveCandidates(candidates);
var seen = new HashSet<EnemyDefinition>();
foreach (var entry in candidates)
{
var type = entry.Wave.PrimaryEnemyType;
if (type != null) seen.Add(type);
}
return seen.Count;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f73fcf0261654d44a6d0939bdc9b88f

View file

@ -0,0 +1,209 @@
// Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace TD.Gameplay.Waves
{
/// <summary>
/// The whole run: how many waves make a cycle, how many cycles make a phase, and the
/// per-phase pools everything is drawn from. One asset assigned to <c>RunState</c> in the
/// match scene; replaces <c>WaveManager</c>'s old flat, hand-ordered wave array.
/// </summary>
/// <remarks>
/// <b>Stable wave ids.</b> Waves are replicated by index into a flat list this asset builds
/// by walking its phases, groups, and entries in declaration order (see
/// <see cref="RebuildIndex"/>). Because every peer loads the identical asset, the same walk
/// produces the same ids everywhere — the same trick the tower catalog and
/// <c>DraftPool</c> already use, and the reason the server can say "wave slot 2 is wave #7"
/// in a single int.
///
/// <para><b>The index is frozen for the match.</b> <c>RunState</c> builds it once on spawn
/// and never rebuilds, so editing this asset mid-play-session cannot renumber ids out from
/// under in-flight replication. Edit-time changes invalidate the cache normally.</para>
/// </remarks>
[CreateAssetMenu(fileName = "RunDefinition", menuName = "TD/Run/Run Definition", order = 12)]
public class RunDefinition : ScriptableObject
{
[Header("Structure")]
[Tooltip("Waves in one cycle. The phase draws this many distinct-enemy-type waves and " +
"replays them every cycle.")]
[Min(1)]
public int WavesPerCycle = 5;
[Tooltip("Cycles before the phase's boss appears. The same drawn waves repeat each " +
"cycle, carrying whatever upgrades players voted onto them.")]
[Min(1)]
public int CyclesPerPhase = 3;
[Tooltip("Phases in order. Beating the last phase's boss wins the run. The MVP ships " +
"one phase; the full design is three.")]
public PhaseDefinition[] Phases;
// ----- Flat wave index (network identity) --------------------------
// Built lazily and cached. Null means "not built yet". Rebuilt only on explicit request
// or on edit-time validation — never spontaneously during play, since ids in flight
// would be invalidated.
private List<WaveDefinition> waveIndex;
private Dictionary<WaveDefinition, int> waveIds;
/// <summary>Number of distinct waves reachable anywhere in this run.</summary>
public int WaveCount
{
get { EnsureIndex(); return waveIndex.Count; }
}
/// <summary>Resolves a replicated wave id back to its asset, or null if out of range.</summary>
public WaveDefinition GetWave(int waveId)
{
EnsureIndex();
return (waveId >= 0 && waveId < waveIndex.Count) ? waveIndex[waveId] : null;
}
/// <summary>Resolves a wave asset to its replicated id.</summary>
public bool TryGetWaveId(WaveDefinition wave, out int waveId)
{
EnsureIndex();
if (wave != null && waveIds.TryGetValue(wave, out waveId)) return true;
waveId = -1;
return false;
}
/// <summary>
/// Rebuilds the flat wave index from the current asset contents. Called once by
/// <c>RunState</c> on every peer at match start so ids agree, and by the editor when
/// the asset changes.
/// </summary>
public void RebuildIndex()
{
waveIndex ??= new List<WaveDefinition>();
waveIds ??= new Dictionary<WaveDefinition, int>();
waveIndex.Clear();
waveIds.Clear();
if (Phases == null) return;
// Declaration-order walk. Every peer runs this over the same asset, so the
// resulting ids match without any negotiation. Zero-weight entries are indexed
// too: they can't be drawn, but indexing them keeps ids stable if a designer
// re-weights between sessions.
foreach (var phase in Phases)
{
if (phase == null) continue;
IndexGroups(phase.WaveGroups);
IndexGroups(phase.BossGroups);
}
}
private void IndexGroups(WaveGroup[] groups)
{
if (groups == null) return;
foreach (var group in groups)
{
if (group?.Waves == null) continue;
foreach (var entry in group.Waves)
{
if (entry?.Wave == null) continue;
if (waveIds.ContainsKey(entry.Wave)) continue; // first occurrence wins
waveIds[entry.Wave] = waveIndex.Count;
waveIndex.Add(entry.Wave);
}
}
}
private void EnsureIndex()
{
if (waveIndex == null || waveIds == null) RebuildIndex();
}
// ----- Validation --------------------------------------------------
/// <summary>
/// Checks that this run can actually be played: at least one phase, every phase able to
/// fill <see cref="WavesPerCycle"/> slots with distinct enemy types, and every phase able
/// to produce a boss. Returns true when the run is playable;
/// <paramref name="report"/> carries the problems either way (it may hold warnings even
/// on success).
/// </summary>
/// <remarks>
/// The distinct-enemy-type requirement is the one that bites in practice: a phase can
/// hold twenty waves and still be unplayable if they all point at the same three enemies.
/// Checking it up front turns that into a startup error instead of a draw that silently
/// runs short a wave.
/// </remarks>
public bool Validate(out string report)
{
var sb = new StringBuilder();
bool ok = true;
if (Phases == null || Phases.Length == 0)
{
sb.AppendLine("• No phases assigned — the run has nothing to play.");
report = sb.ToString();
return false;
}
var candidates = new List<WavePoolEntry>();
for (int i = 0; i < Phases.Length; i++)
{
var phase = Phases[i];
string label = phase != null ? phase.Label : $"Phase {i + 1}";
if (phase == null)
{
sb.AppendLine($"• {label}: slot is empty.");
ok = false;
continue;
}
int distinctTypes = phase.CountDistinctEnemyTypes();
if (distinctTypes < WavesPerCycle)
{
sb.AppendLine($"• {label}: needs {WavesPerCycle} distinct enemy types to fill " +
$"a cycle but only {distinctTypes} are reachable. Add waves " +
$"using other enemies, or lower Waves Per Cycle.");
ok = false;
}
phase.CollectBossCandidates(candidates);
if (candidates.Count == 0)
{
sb.AppendLine($"• {label}: no boss candidates (every boss group is empty, " +
$"unassigned, or entirely zero-weight).");
ok = false;
}
// Warnings — these don't block a run, they just play oddly.
phase.CollectWaveCandidates(candidates);
foreach (var entry in candidates)
{
if (entry.Wave.PrimaryEnemyType == null)
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' has no usable " +
$"enemy entries and can never be drawn meaningfully.");
else if (!entry.Wave.HasSingleEnemyType)
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' mixes enemy " +
$"types. Upgrades and HUD labels will use " +
$"'{entry.Wave.PrimaryEnemyType.name}' only.");
}
}
report = sb.Length > 0 ? sb.ToString() : "Run structure OK.";
return ok;
}
#if UNITY_EDITOR
private void OnValidate()
{
// Only invalidate outside play mode. Clearing the cache mid-match would renumber
// wave ids while replicated slot references are still pointing at the old numbering.
if (!Application.isPlaying)
{
waveIndex = null;
waveIds = null;
}
}
#endif
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2b73bb9876b7fdb4480b13ff6bb8bd67

View file

@ -0,0 +1,468 @@
// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
namespace TD.Gameplay.Waves
{
/// <summary>
/// Outcome of advancing the run by one encounter. Returned by
/// <see cref="RunState.ServerAdvance"/> so <c>WaveManager</c> can react without
/// re-deriving the progression maths.
/// </summary>
public enum RunAdvance : byte
{
/// <summary>Moved to the next wave inside the current cycle.</summary>
NextWave = 0,
/// <summary>Moved to the first wave of a new cycle — the same waves come round again,
/// now carrying whatever upgrades players voted onto them.</summary>
NextCycle = 1,
/// <summary>Every cycle in the phase is done; the boss is up next.</summary>
BossReady = 2,
/// <summary>The boss died and a new phase was drawn. Upgrades were wiped.</summary>
NextPhase = 3,
/// <summary>The final phase's boss died — the run is won.</summary>
RunComplete = 4,
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <b>Split of duties with <c>WaveManager</c>.</b> This type is the run's <i>state</i> —
/// counters, draws, upgrade sets — and knows nothing about spawning. <c>WaveManager</c> is the
/// <i>driver</i>: it runs an encounter, and when the field is clear it calls
/// <see cref="ServerAdvance"/> and acts on the returned <see cref="RunAdvance"/>. Keeping them
/// apart is what stops WaveManager (already a large class) from also owning pool draws and
/// upgrade bookkeeping.
///
/// <para><b>Linear encounter index.</b> Rather than replicate cycle and wave separately and
/// keep them in sync, one <see cref="encounterInPhase"/> counter runs
/// <c>0 .. CyclesPerPhase*WavesPerCycle</c> inclusive, with the final value meaning "boss".
/// Cycle and wave slot are derived from it, so they cannot disagree.</para>
///
/// <para><b>Upgrades are keyed by wave slot, not enemy type.</b> 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.</para>
/// </remarks>
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<int> phaseIndex = new NetworkVariable<int>(
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
// 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss".
private readonly NetworkVariable<int> encounterInPhase = new NetworkVariable<int>(
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<int> drawnWaveIds;
private readonly NetworkVariable<int> bossWaveId = new NetworkVariable<int>(
-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<int> 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<int> autoApplyCounts;
/// <summary>
/// Fired on every peer whenever run progression or the drawn/upgraded wave set changes.
/// The HUD subscribes to relabel without polling.
/// </summary>
public event System.Action OnRunChanged;
// ----- Lifecycle ---------------------------------------------------
private void Awake()
{
drawnWaveIds = new NetworkList<int>();
waveUpgrades = new NetworkList<int>();
autoApplyCounts = new NetworkList<int>();
}
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<int> _) => OnRunChanged?.Invoke();
// ----- Structure accessors -----------------------------------------
/// <summary>The run asset driving this match, or null if unassigned (designer error).</summary>
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;
/// <summary>Encounters in one phase, counting its boss.</summary>
public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1;
// ----- Progression accessors ---------------------------------------
/// <summary>Zero-based phase index.</summary>
public int PhaseIndex => phaseIndex.Value;
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>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).</summary>
public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1);
/// <summary>Zero-based wave slot within the current cycle. Meaningless while
/// <see cref="IsBossStage"/> is true.</summary>
public int WaveSlot => encounterInPhase.Value % WavesPerCycle;
/// <summary>True when the next/current encounter is this phase's boss.</summary>
public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle;
/// <summary>
/// 1-based count of encounters since the run began, including bosses. This is the key
/// <c>GoldConfig</c> is indexed by, so its per-wave entries scale monotonically across the
/// whole run rather than resetting every cycle.
/// </summary>
public int GlobalEncounterNumber
=> phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1;
/// <summary>Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5".</summary>
public string ProgressLabel
=> IsBossStage
? $"Phase {phaseIndex.Value + 1} · BOSS"
: $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " +
$"Wave {WaveSlot + 1}/{WavesPerCycle}";
// ----- Wave accessors ----------------------------------------------
/// <summary>The wave asset drawn for <paramref name="slot"/>, or null if not drawn yet.</summary>
public WaveDefinition GetSlotWave(int slot)
{
if (runDefinition == null) return null;
if (slot < 0 || slot >= drawnWaveIds.Count) return null;
return runDefinition.GetWave(drawnWaveIds[slot]);
}
/// <summary>This phase's boss wave, or null if not drawn yet.</summary>
public WaveDefinition BossWave
=> runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null;
/// <summary>The wave the run is currently pointing at — boss or cycle wave.</summary>
public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot);
/// <summary>
/// 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.
/// </summary>
public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot;
// ----- Upgrade accessors -------------------------------------------
/// <summary>
/// Collects the upgrade option ids voted onto <paramref name="slot"/>, in the order they
/// were applied. Safe on any peer.
/// </summary>
public void GetUpgradesForSlot(int slot, List<int> into)
{
into.Clear();
for (int i = 0; i < waveUpgrades.Count; i++)
{
int packed = waveUpgrades[i];
if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask);
}
}
/// <summary>How many upgrades are stacked on <paramref name="slot"/>.</summary>
public int CountUpgradesForSlot(int slot)
{
int n = 0;
for (int i = 0; i < waveUpgrades.Count; i++)
if ((waveUpgrades[i] >> SlotShift) == slot) n++;
return n;
}
/// <summary>True if <paramref name="slot"/> already carries <paramref name="optionId"/>.
/// Used to keep the vote from offering a buff a wave already has.</summary>
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;
}
/// <summary>Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates.</summary>
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) --------------------------------
/// <summary>
/// How many buffs <paramref name="slot"/> will take automatically instead of holding its
/// next vote. Zero means a normal vote.
/// </summary>
public int GetAutoApplyCount(int slot)
=> (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0;
/// <summary>
/// Server-only: arm <paramref name="slot"/> to auto-take <paramref name="count"/> 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.
/// </summary>
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);
}
/// <summary>Server-only: disarm <paramref name="slot"/> after its auto-apply has fired.</summary>
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 ------------------------------------------
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the
/// boss falls. See <see cref="RunAdvance"/> for what the caller should do with each result.
/// </summary>
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<WavePoolEntry> candidateScratch = new List<WavePoolEntry>();
/// <summary>
/// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative
/// weight <b>without replacement and with distinct enemy types</b>; the boss is a plain
/// weighted draw.
/// </summary>
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<WavePoolEntry> 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
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a293cf6dca91dd2478c9f4e799cf9e0d

View file

@ -0,0 +1,69 @@
// Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
using System;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.Waves
{
/// <summary>
/// One weighted candidate inside a <see cref="WaveGroup"/>: a wave that may be drawn,
/// and how likely it is to be drawn relative to the group's other entries.
/// </summary>
/// <remarks>
/// <b>Weight is RELATIVE, not an absolute probability.</b> 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.
///
/// <para>A class rather than a struct specifically so <see cref="Weight"/> 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
/// <see cref="WaveGoldEntry"/>.</para>
/// </remarks>
[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;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <b>Why weights live on the entry, not on <see cref="WaveDefinition"/>.</b> Weight is a
/// property of "how common is this wave <i>in this pool</i>", not of the wave itself. Keeping
/// it here means the same <see cref="WaveDefinition"/> 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.
///
/// <para><b>Boss groups use the same type.</b> A boss wave is just a
/// <see cref="WaveDefinition"/> whose entries spawn a single powerful enemy, so boss pools
/// are ordinary <see cref="WaveGroup"/>s referenced from
/// <see cref="PhaseDefinition.BossGroups"/>.</para>
/// </remarks>
[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;
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
/// <summary>Number of entries, including any that are null or zero-weight.</summary>
public int Count => Waves?.Length ?? 0;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5625bd25f378bda429666820a4940cee

View file

@ -74,6 +74,11 @@ namespace TD.UI
public void SpawnGoldReward(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"+{amount}", goldColor);
/// <summary>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.</summary>
public void SpawnGoldLoss(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"-{amount}", goldColor);
/// <summary>Spawns a life-loss popup (e.g. "-1") at the given world position.</summary>
public void SpawnLifeLoss(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"-{amount}", livesColor);

View file

@ -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<PlayerSlot> voteVoterScratch
= new System.Collections.Generic.List<PlayerSlot>();
// 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<EnemyMovement>();
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.)

129
Docs/2.0_Setup_Checklist.md Normal file
View file

@ -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** 01 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.

View file

@ -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** 01 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)