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