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>
199 lines
8.6 KiB
C#
199 lines
8.6 KiB
C#
// Assets/_Project/Scripts/Gameplay/EnemyAbility.cs
|
|
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
using TD.Gameplay.EnemyAbilities;
|
|
|
|
namespace TD.Gameplay
|
|
{
|
|
/// <summary>
|
|
/// 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 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="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
|
|
{
|
|
// 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 readonly List<EnemyAbilityDefinition> pendingDefinitions = new List<EnemyAbilityDefinition>();
|
|
private bool hasPendingInit;
|
|
|
|
// ----- Public state -----------------------------------------------------
|
|
|
|
private readonly List<EnemyAbilityDefinition> definitions = new List<EnemyAbilityDefinition>();
|
|
|
|
/// <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="abilities"/> may be null or empty,
|
|
/// meaning this enemy carries no wave buffs.
|
|
/// </summary>
|
|
/// <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)
|
|
{
|
|
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 --------------------------------------------------
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
if (!IsServer || !hasPendingInit) return;
|
|
|
|
definitions.Clear();
|
|
definitions.AddRange(pendingDefinitions);
|
|
hasPendingInit = false;
|
|
|
|
timers = definitions.Count > 0
|
|
? new float[definitions.Count]
|
|
: System.Array.Empty<float>();
|
|
|
|
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 || 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);
|
|
}
|
|
}
|
|
}
|