First pass at full refactor to 2.0 design

Restructures the game around the cyclical run loop from Game Design Doc V2:
5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss.

- New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the
  run as draggable weighted pools; RunState owns phase/cycle position, the drawn
  wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is
  gone -- it now only runs the encounter RunState points at.
- New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public
  live-replicated ballots so the HUD can show who voted for what.
- Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage
  ending early once every player has acted.
- Enemy abilities inverted from per-instance random rolls to deterministic,
  stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink,
  No Bounty, Gold Theft, Double Up.
- Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts
  an already-placed tower in place.
- Boss encounters flag their enemies and drive a boss HP bar.
- Player cap reduced to 3 via MatchRules.MaxPlayers.
- GoldConfig is now keyed by global encounter number rather than wave index.

Compiles clean; NOT yet verified in-engine. Editor wiring still required --
see Docs/2.0_Setup_Checklist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-30 17:45:11 -07:00
parent 7e5c3a8279
commit 4892d7253d
64 changed files with 4023 additions and 344 deletions

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