// 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 { /// /// Per-enemy ability set. Holds every its wave has /// accumulated and drives their server-only hooks. /// /// /// Initialization: Call on the server immediately after /// Instantiate and before NetworkObject.Spawn(), following the same pattern as /// EnemyHealth.InitializeServer / EnemyMovement.InitializeServer. /// /// A set, not a slot. This used to hold one ability rolled at random per enemy. Wave /// buffs stack across a phase's cycles, so it now holds a list and fans every hook out over /// all of them. replicates the set so clients can show players what /// they're facing. /// /// Optional component: Not required by EnemyHealth or EnemyMovement — /// WaveManager.SpawnEnemy only assigns abilities when this component is present on the /// prefab, so enemy prefabs without it simply never carry wave buffs. /// /// On-death hook is not event-driven: WaveManager.HandleEnemyKilled calls /// directly rather than this component subscribing to /// EnemyHealth.OnDied itself — that keeps a split-spawn's activeEnemyCount /// increment ordered before the triggering kill's decrement, regardless of NetworkBehaviour /// subscription order. /// [RequireComponent(typeof(NetworkObject))] public class EnemyAbility : NetworkBehaviour { // Replicated ability kinds carried by this enemy. Empty means no wave buffs. Exists so // clients can render "this wave splits and flies" in the enemy-info panel without // re-deriving it from run state. private NetworkList kinds; // ----- Pre-spawn init (server-local) ---------------------------------- private readonly List pendingDefinitions = new List(); private bool hasPendingInit; // ----- Public state ----------------------------------------------------- private readonly List definitions = new List(); /// The abilities this enemy carries. Server-side; empty on clients. public IReadOnlyList Definitions => definitions; /// True if this enemy carries no abilities at all. public bool IsPlain => definitions.Count == 0; /// /// The stats this enemy actually spawned with, after every ability's spawn modification. /// Server-side. /// /// /// Kept so on-death abilities can derive from what the enemy really was rather than from /// its . Split-on-death needs this: its minions are a /// percentage of "the enemy that died", which is not the authored asset once other cards /// on the same wave have modified it. /// public EnemySpawnContext SpawnContext { get; private set; } private void Awake() { kinds = new NetworkList(); } /// Replicated check for a specific ability. Safe on any peer. public bool HasKind(EnemyAbilityKind kind) { byte target = (byte)kind; for (int i = 0; i < kinds.Count; i++) if (kinds[i] == target) return true; return false; } // ----- Server-only pre-spawn init ------------------------------------- /// /// Called by WaveManager on the server after Instantiate and before /// NetworkObject.Spawn(). may be null or empty, /// meaning this enemy carries no wave buffs. /// /// /// Copies rather than retains the caller's list — WaveManager reuses one scratch /// buffer across every enemy in a wave, so holding the reference would alias every enemy /// to the same (later mutated) contents. /// public void InitializeServer(IReadOnlyList abilities, EnemySpawnContext spawnContext) { 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(); 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(); /// /// A per-enemy float owned by the ability at , returned by /// reference so it can be read and written in place. Typically a cooldown accumulator. /// public ref float TimerFor(int abilityIndex) { if (abilityIndex < 0 || abilityIndex >= timers.Length) { // Out-of-range means a caller passed an index that doesn't match its slot. Hand // back a scratch cell rather than throwing mid-tick; the ability just won't // accumulate, which is visible in play but not fatal. Debug.LogError($"[EnemyAbility] TimerFor({abilityIndex}) is out of range on " + $"{name}. The ability will not keep time."); return ref timerFallback; } return ref timers[abilityIndex]; } private float timerFallback; // ----- Server hook fan-out ---------------------------------------- /// Server-only: run every carried ability's on-death hook. public void ServerInvokeOnDeath(EnemyHealth health) { if (!IsServer) return; for (int i = 0; i < definitions.Count; i++) definitions[i].ServerOnDeath(this, health); } /// /// Server-only: chain every carried ability's kill-reward modifier. Result is clamped at /// zero so a stack of reward-suppressing cards can't hand out negative gold. /// public int ServerModifyKillReward(int reward) { if (!IsServer) return reward; for (int i = 0; i < definitions.Count; i++) reward = definitions[i].ServerModifyKillReward(this, reward); return Mathf.Max(0, reward); } /// Server-only: run every carried ability's reached-goal hook. public void ServerInvokeReachedGoal(PlayerSlot originZone) { if (!IsServer) return; for (int i = 0; i < definitions.Count; i++) definitions[i].ServerOnReachedGoal(this, originZone); } } }