// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs using System.Collections; using Unity.Netcode; using UnityEngine; using TD.Core; namespace TD.Gameplay { /// /// Per-enemy HP component. Single point through which all damage flows so /// resistance lookups (Phase 1.5+) and kill attribution remain in one place. /// /// /// Initialization: Call on the server /// immediately after Instantiate and before NetworkObject.Spawn(), /// following the same pattern as TowerInstance.InitializeServer. /// /// Kill attribution: tracks the /// of the tower that most recently dealt direct damage. /// DoT ticks from EnemyStatus also carry an owner so the credit /// follows the source tower, not the DoT applicator. /// /// Death flow (server-only): clamps HP to 0 /// and fires immediately so wave bookkeeping and gold /// award happen the moment HP hits zero. The component then disables /// and all child Colliders, triggers the /// Die animator parameter (synced via NetworkAnimator), waits /// seconds, sinks the transform /// units over seconds, /// and finally calls NetworkObject.Despawn. /// [RequireComponent(typeof(NetworkObject))] public class EnemyHealth : NetworkBehaviour, ISelectable { // ----- Inspector ------------------------------------------------------ [Header("Identity")] [Tooltip("The EnemyDefinition this prefab represents. Drives the HUD info " + "panel (name, speed, gold bounty) when the enemy is selected. " + "Must be assigned on the prefab so it's available on every peer " + "without needing a registry lookup.")] [SerializeField] private EnemyDefinition definition; /// The static definition this enemy was spawned from. public EnemyDefinition Definition => definition; [Header("Death sequence")] [Tooltip("Animator that plays the death animation. Trigger parameter 'Die' " + "is set on death. Leave null to skip the death animation.")] [SerializeField] private Animator deathAnimator; [Tooltip("Seconds to hold after the Die trigger fires before the corpse starts sinking. " + "Should match the death animation clip's duration.")] [SerializeField] private float deathAnimationDuration = 2f; [Tooltip("Seconds spent sinking into the ground.")] [SerializeField] private float sinkDuration = 1.5f; [Tooltip("How far (world units) the corpse sinks before despawning.")] [SerializeField] private float sinkDepth = 1.5f; private static readonly int DieHash = Animator.StringToHash("Die"); // ----- Pre-spawn init (server-local) ---------------------------------- private float pendingMaxHp = 100f; private int pendingLivesCost = 1; private bool pendingIsFlying; private bool hasPendingInit; // ----- Server-local runtime state ------------------------------------- // Kill gold is no longer carried per-enemy — it comes from // GoldConfig.Waves[currentWaveIndex].GoldPerEnemy at the moment the kill // is registered. See WaveManager.HandleEnemyKilled. /// Lives deducted from the shared pool when this enemy reaches the goal. public int LivesCost { get; private set; } = 1; /// /// The of the tower that last dealt direct damage. /// Used by WaveManager to award kill gold to the correct player. /// Updated on every call, including DoT ticks whose /// source owner is tracked on . /// public PlayerSlot LastHitOwner { get; private set; } = PlayerSlot.None; // ----- Networked state ------------------------------------------------ private readonly NetworkVariable hp = new NetworkVariable( 0f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable isFlying = new NetworkVariable( false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // ----- Public state --------------------------------------------------- public float CurrentHp => hp.Value; public float MaxHp { get; private set; } = 100f; public bool IsDead => hp.Value <= 0f; /// /// True if this enemy flies over tower footprints. /// Replicated so client visuals can adjust altitude. /// Grounded towers with GroundedOnly=true will not target flying enemies. /// public bool IsFlying => isFlying.Value; // ----- Events --------------------------------------------------------- /// /// Fired on the server immediately before the enemy NetworkObject is despawned. /// WaveManager subscribes to credit kill gold and decrement wave count. /// Do not access the NetworkObject after this event returns. /// public event System.Action OnDied; // ----- Server-only pre-spawn init ------------------------------------- /// /// Called by WaveManager on the server after Instantiate /// and before NetworkObject.Spawn(). Mirrors the /// TowerInstance.InitializeServer pattern. /// public void InitializeServer(float maxHp, int livesCost, bool flying) { pendingMaxHp = maxHp; pendingLivesCost = livesCost; pendingIsFlying = flying; hasPendingInit = true; // Cache locally on the server immediately — clients resolve via NV. MaxHp = maxHp; LivesCost = livesCost; } // ----- NGO lifecycle -------------------------------------------------- public override void OnNetworkSpawn() { if (IsServer && hasPendingInit) { hp.Value = pendingMaxHp; isFlying.Value = pendingIsFlying; hasPendingInit = false; } // Non-server clients resolve MaxHp from the replicated hp initial value. if (!IsServer) MaxHp = hp.Value; } public override void OnNetworkDespawn() { // 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. var sel = SelectionState.Instance; if (sel != null && sel.IsSelected(this)) sel.Clear(); } // ----- Server API ----------------------------------------------------- /// /// Applies damage to this enemy. Server-only; silently no-ops on clients. /// is accepted for future resistance lookups (Phase 1.5+). /// identifies the tower owner for kill attribution. /// public void TakeDamage(float damage, DamageType type, PlayerSlot attackerSlot) { if (!IsServer) return; if (IsDead) return; // STUB — resistance table slot: // float modified = ResistanceTable.Apply(damage, type, this); float modified = damage; LastHitOwner = attackerSlot; hp.Value = Mathf.Max(0f, hp.Value - modified); if (hp.Value <= 0f) HandleDeath(); } // ----- Private -------------------------------------------------------- private void HandleDeath() { // Fire OnDied immediately so the wave count decrements and gold is // awarded the moment HP hits zero — the corpse animation and sink // play out asynchronously after that. OnDied?.Invoke(this); // Stop pathing and remove from tower targeting / selection. var movement = GetComponent(); if (movement != null) movement.enabled = false; foreach (var col in GetComponentsInChildren()) col.enabled = false; // Trigger the death animation. NetworkAnimator on the prefab syncs the // Die trigger to all clients automatically — no ClientRpc needed. if (deathAnimator != null) deathAnimator.SetTrigger(DieHash); StartCoroutine(DeathSequence()); } // ----- ISelectable ---------------------------------------------------- /// /// Enemy display name. Pulls from when assigned; /// falls back to "Enemy" so the HUD never shows an empty portrait label /// even if a prefab is missing its definition reference. /// public string DisplayName => definition != null && !string.IsNullOrEmpty(definition.DisplayName) ? definition.DisplayName : "Enemy"; public SelectableKind Kind => SelectableKind.Enemy; public Transform SelectionTransform => transform; // Used by SelectionVisualizer to size the selection ring. Tuned small // because enemy capsules are roughly 0.6-0.8 wide; if you change enemy // mesh sizes substantially, derive this from a collider bounds instead. public float SelectionRadius => 0.4f; private IEnumerator DeathSequence() { // Hold while the death animation plays. yield return new WaitForSeconds(deathAnimationDuration); // Sink phase. Server moves the transform; NetworkTransform replicates it. float t = 0f; Vector3 startPos = transform.position; Vector3 endPos = startPos + Vector3.down * sinkDepth; while (t < sinkDuration) { t += Time.deltaTime; transform.position = Vector3.Lerp(startPos, endPos, t / sinkDuration); yield return null; } if (NetworkObject != null && NetworkObject.IsSpawned) NetworkObject.Despawn(); } } }