UnityTowerDefense/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
Matt F 16706a1ecf Scale difficulty by run position; wipe towers at phase end
Follow-up pass on the 2.0 refactor, closing the gap between "waves are drawn
at random" and "difficulty still lives in the enemy assets".

- Enemy health is no longer authored per enemy type. New EnemyScalingConfig
  scales a per-zone HP *budget* by encounter number; per-enemy health is that
  budget divided by the wave's enemy count. Scaling the total rather than the
  per-enemy value keeps enemy count a texture knob (few tanks vs many swarmers)
  instead of a second, uncontrolled difficulty axis. EnemyDefinition.MaxHp
  becomes HpMultiplier, a deviation around 1.0. Speed stays archetype-only and
  unscaled -- escalating it compounds with health and invalidates tower balance
  mid-run.
- GoldConfig entries no longer reference a WaveDefinition. Payout is a property
  of run position, not of which wave got drawn: WaveGoldEntry ->
  EncounterGoldEntry, Waves -> Encounters, keyed by global encounter number.
  Its inspector labels elements "Encounter N" and projects cumulative earnings.
- The wave's voted buffs now show as icon badges in the top bar, read from the
  same RunState slot the spawn path uses so the display can't drift from what
  the enemies actually carry. Hover names the buff; unillustrated cards fall
  back to a lettered badge rather than vanishing.
- Clearing a phase's boss destroys every built tower, unrefunded. Queued build
  jobs still refund -- those towers were never delivered. Runs with the field
  empty, so the walkability churn doesn't hit the re-path scheduler.
- Fixed an RPC codegen break: [ClientRpc] requires a ClientRpc suffix, unlike
  the newer [Rpc(SendTo...)] style this file doesn't use.
- Setup checklist reordered into dependency order; it previously asked for a
  RunDefinition two sections before creating one.

Also carries the editor-side asset reorganisation into Definitions/RunDefinitions
and the sprite move into Enemy/Player draft icon folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:32:33 -07:00

327 lines
14 KiB
C#

// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
using System.Collections;
using System.Collections.Generic;
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 pendingLivesCost = 1;
private bool pendingIsFlying;
private bool pendingIsHeld;
private bool pendingIsBoss;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
// Kill gold is no longer carried per-enemy — it comes from the GoldConfig entry for
// the current encounter (GoldConfig.GetEncounterEntry) at the moment the kill is
// registered. See WaveManager.HandleEnemyKilled.
/// <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);
private readonly NetworkVariable<bool> isHeld = new NetworkVariable<bool>(
false,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// Set for enemies spawned by a phase's boss encounter. Replicated because the HUD's boss
// bar is client-side and has to recognise a boss without asking the server.
private readonly NetworkVariable<bool> isBoss = 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;
public bool IsHeld => isHeld.Value;
/// <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;
/// <summary>True if this enemy was spawned by a phase's boss encounter. Replicated.</summary>
public bool IsBoss => isBoss.Value;
/// <summary>
/// Every live boss, on every peer. Maintained by spawn/despawn so the HUD can render a
/// boss bar without polling the scene.
/// </summary>
/// <remarks>
/// A list rather than a single reference because the boss encounter spawns one boss per
/// player zone — there are as many simultaneous bosses as there are players.
/// </remarks>
public static readonly List<EnemyHealth> ActiveBosses = new List<EnemyHealth>();
// ----- 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;
/// <summary>Fired on ALL peers the moment this enemy dies. Use for client-side visuals and audio.</summary>
public event System.Action OnDiedClient;
// ----- 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 livesCost, bool flying, bool held = false,
bool boss = false)
{
pendingMaxHp = maxHp;
pendingLivesCost = livesCost;
pendingIsFlying = flying;
pendingIsHeld = held;
pendingIsBoss = boss;
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;
isHeld.Value = pendingIsHeld;
isBoss.Value = pendingIsBoss;
hasPendingInit = false;
}
// Non-server clients resolve MaxHp from the replicated hp initial value.
if (!IsServer)
MaxHp = hp.Value;
if (isBoss.Value && !ActiveBosses.Contains(this))
ActiveBosses.Add(this);
// Apply initial held state on all peers and watch for future changes.
// isHeld.OnValueChanged doesn't fire for the initial replication, so we
// apply it explicitly here as well.
ApplyHeldState(isHeld.Value);
isHeld.OnValueChanged += (_, current) => ApplyHeldState(current);
}
public override void OnNetworkDespawn()
{
ActiveBosses.Remove(this);
// 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>
/// Sets whether this enemy is held. Held enemies are untargetable and do not
/// move. Server-only; the NetworkVariable replicates the change to all peers.
/// </summary>
public void SetHeld(bool held)
{
if (!IsServer) return;
isHeld.Value = held;
}
private void ApplyHeldState(bool held)
{
if (IsDead) return;
foreach (var col in GetComponentsInChildren<Collider>())
col.enabled = !held;
}
/// <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. NetworkAnimator on the prefab syncs the
// Die trigger to all clients automatically — no ClientRpc needed.
if (deathAnimator != null)
deathAnimator.SetTrigger(DieHash);
DiedClientRpc();
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;
[ClientRpc]
private void DiedClientRpc()
{
OnDiedClient?.Invoke();
}
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();
}
}
}