256 lines
11 KiB
C#
256 lines
11 KiB
C#
// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
|
|
using System.Collections;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
|
|
namespace TD.Gameplay
|
|
{
|
|
/// <summary>
|
|
/// Per-enemy HP component. Single point through which all damage flows so
|
|
/// resistance lookups (Phase 1.5+) and kill attribution remain in one place.
|
|
/// </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>TowerInstance.InitializeServer</c>.
|
|
///
|
|
/// <b>Kill attribution:</b> <see cref="LastHitOwner"/> tracks the
|
|
/// <see cref="PlayerSlot"/> of the tower that most recently dealt direct damage.
|
|
/// DoT ticks from <c>EnemyStatus</c> also carry an owner so the credit
|
|
/// follows the source tower, not the DoT applicator.
|
|
///
|
|
/// <b>Death flow (server-only):</b> <see cref="TakeDamage"/> clamps HP to 0
|
|
/// and fires <see cref="OnDied"/> immediately so wave bookkeeping and gold
|
|
/// award happen the moment HP hits zero. The component then disables
|
|
/// <see cref="EnemyMovement"/> and all child <c>Collider</c>s, triggers the
|
|
/// <c>Die</c> animator parameter (synced via <c>NetworkAnimator</c>), waits
|
|
/// <see cref="deathAnimationDuration"/> seconds, sinks the transform
|
|
/// <see cref="sinkDepth"/> units over <see cref="sinkDuration"/> seconds,
|
|
/// and finally calls <c>NetworkObject.Despawn</c>.
|
|
/// </remarks>
|
|
[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;
|
|
|
|
/// <summary>The static definition this enemy was spawned from.</summary>
|
|
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 pendingGoldReward;
|
|
private int pendingLivesCost = 1;
|
|
private bool pendingIsFlying;
|
|
private bool hasPendingInit;
|
|
|
|
// ----- Server-local runtime state -------------------------------------
|
|
|
|
/// <summary>Gold awarded to the killing player when this enemy dies.</summary>
|
|
public int GoldReward { get; private set; }
|
|
|
|
/// <summary>Lives deducted from the shared pool when this enemy reaches the goal.</summary>
|
|
public int LivesCost { get; private set; } = 1;
|
|
|
|
/// <summary>
|
|
/// The <see cref="PlayerSlot"/> of the tower that last dealt direct damage.
|
|
/// Used by <c>WaveManager</c> to award kill gold to the correct player.
|
|
/// Updated on every <see cref="TakeDamage"/> call, including DoT ticks whose
|
|
/// source owner is tracked on <see cref="EnemyStatus.StatusEffect"/>.
|
|
/// </summary>
|
|
public PlayerSlot LastHitOwner { get; private set; } = PlayerSlot.None;
|
|
|
|
// ----- Networked state ------------------------------------------------
|
|
|
|
private readonly NetworkVariable<float> hp = new NetworkVariable<float>(
|
|
0f,
|
|
NetworkVariableReadPermission.Everyone,
|
|
NetworkVariableWritePermission.Server);
|
|
|
|
private readonly NetworkVariable<bool> isFlying = new NetworkVariable<bool>(
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool IsFlying => isFlying.Value;
|
|
|
|
// ----- Events ---------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Fired on the server immediately before the enemy NetworkObject is despawned.
|
|
/// <c>WaveManager</c> subscribes to credit kill gold and decrement wave count.
|
|
/// Do not access the NetworkObject after this event returns.
|
|
/// </summary>
|
|
public event System.Action<EnemyHealth> OnDied;
|
|
|
|
// ----- Server-only pre-spawn init -------------------------------------
|
|
|
|
/// <summary>
|
|
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c>
|
|
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
|
|
/// <c>TowerInstance.InitializeServer</c> pattern.
|
|
/// </summary>
|
|
public void InitializeServer(float maxHp, int goldReward, int livesCost, bool flying)
|
|
{
|
|
pendingMaxHp = maxHp;
|
|
pendingGoldReward = goldReward;
|
|
pendingLivesCost = livesCost;
|
|
pendingIsFlying = flying;
|
|
hasPendingInit = true;
|
|
|
|
// Cache locally on the server immediately — clients resolve via NV.
|
|
MaxHp = maxHp;
|
|
GoldReward = goldReward;
|
|
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 -----------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Applies damage to this enemy. Server-only; silently no-ops on clients.
|
|
/// <paramref name="type"/> is accepted for future resistance lookups (Phase 1.5+).
|
|
/// <paramref name="attackerSlot"/> identifies the tower owner for kill attribution.
|
|
/// </summary>
|
|
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<EnemyMovement>();
|
|
if (movement != null) movement.enabled = false;
|
|
|
|
foreach (var col in GetComponentsInChildren<Collider>())
|
|
col.enabled = false;
|
|
|
|
// Trigger the death animation on all peers (NetworkAnimator syncs it).
|
|
if (deathAnimator != null)
|
|
deathAnimator.SetTrigger(DieHash);
|
|
|
|
StartCoroutine(DeathSequence());
|
|
}
|
|
|
|
// ----- ISelectable ----------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Enemy display name. Pulls from <see cref="definition"/> when assigned;
|
|
/// falls back to "Enemy" so the HUD never shows an empty portrait label
|
|
/// even if a prefab is missing its definition reference.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|