using System.Collections.Generic; using UnityEngine; using WL.Combat; namespace WL.Player { /// /// 적 유닛의 체력·피격 판정. 플레이어 자동 탐색·공격의 대상이 된다. /// 태그 "Enemy" + 이 컴포넌트 조합으로 식별한다. /// /// 체력 수치는 CombatSettings(ScriptableObject)에서 읽는다(C45). 에셋이 비어 있을 때만 /// 인스펙터의 fallbackMaxHp 를 쓴다. /// 사망 연출은 EnemyController 가 Died 이벤트를 받아 처리한다(구독자가 없으면 즉시 비활성화). /// public class Enemy : MonoBehaviour { /// 활성 상태인 모든 적. 매 프레임 FindObjectsByType 를 피하기 위한 등록부. public static readonly List All = new List(); [Header("수치 설정 (ScriptableObject)")] [SerializeField] private CombatSettings combatSettings; [Header("스탯 (에셋이 없을 때만 쓰는 대체값)")] [Tooltip("CombatSettings 가 비어 있을 때 쓰는 최대 체력")] [SerializeField] private float fallbackMaxHp = 10f; [Tooltip("피격 판정 중심의 발밑 기준 높이(m). 조준·거리 계산에 사용")] [SerializeField] private float bodyCenterHeight = 1.0f; [Tooltip("사망 연출을 처리할 구독자(EnemyController)가 없을 때 오브젝트를 비활성화한다")] [SerializeField] private bool deactivateOnDeath = true; [Tooltip("체력이 줄지 않고 죽지 않는다. 피격 플래시·데미지 숫자는 그대로 표시 — 이펙트 확인용 더미 봇 (PD #717 ④)")] [SerializeField] private bool invincible = false; [Header("상태 (읽기 전용)")] [SerializeField] private float currentHp; /// 체력이 0 이 된 순간 1회 발생. EnemyController 가 사망 연출을 맡는다. public event System.Action Died; /// 어떤 적이든 죽은 순간 1회 발생(전역). PlayerProgress 가 경험치 지급에 쓴다 (PD #727). public static event System.Action AnyDied; /// 피해를 받은 순간 발생(사망 타격 포함). 인자 = 이번 피해량. public event System.Action Damaged; private bool _dieFired; public CombatSettings Settings { get { return combatSettings; } } public float MaxHp { get { return combatSettings != null ? combatSettings.enemyHp : fallbackMaxHp; } } public float CurrentHp { get { return currentHp; } } public bool IsAlive { get { return currentHp > 0f; } } public Vector3 BodyCenter { get { return transform.position + Vector3.up * bodyCenterHeight; } } private void Awake() { currentHp = MaxHp; } private void OnEnable() { if (!All.Contains(this)) All.Add(this); } private void OnDisable() { All.Remove(this); } /// 무적(더미 봇)인가. 체력이 줄지 않고 죽지 않는다. public bool IsInvincible { get { return invincible; } } public void TakeDamage(float amount) { if (!IsAlive) return; // PD #717 ④ — 무적 더미는 체력을 깎지 않는다. 연출(플래시·데미지 숫자)은 그대로 태운다. if (!invincible) currentHp = Mathf.Max(0f, currentHp - amount); Debug.Log("[Enemy] " + name + " 피격 " + amount.ToString("F0") + " -> HP " + currentHp.ToString("F0") + "/" + MaxHp.ToString("F0") + (invincible ? " (무적 더미 — 체력 불변)" : ""), this); // 데미지 숫자 연출 (PD #702-①) — 스포너가 없으면 조용히 무시된다 WL.UI.DamagePopupSpawner.Spawn(BodyCenter, amount, WL.UI.DamagePopupSpawner.Kind.ToEnemy); // 피격 하이라이트(HitFlash)·행동 반응(EnemyController)이 이 이벤트를 구독한다 if (Damaged != null) Damaged(this, amount); if (currentHp <= 0f) Die(); } private void Die() { if (_dieFired) return; _dieFired = true; // 죽은 순간 탐색 대상에서 즉시 제외한다(연출이 끝날 때까지 남아 있으면 플레이어가 시체를 계속 때린다). All.Remove(this); Debug.Log("[Enemy] " + name + " 사망 — 연출 시작", this); if (AnyDied != null) AnyDied(this); if (Died != null) { Died(this); return; } if (deactivateOnDeath) gameObject.SetActive(false); } /// 지정 위치에서 range 안에 있는 가장 가까운 생존 적을 찾는다. public static Enemy FindNearestAlive(Vector3 from, float range) { Enemy best = null; float bestSqr = range * range; for (int i = 0; i < All.Count; i++) { var e = All[i]; if (e == null || !e.IsAlive) continue; float sqr = (e.transform.position - from).sqrMagnitude; if (sqr <= bestSqr) { bestSqr = sqr; best = e; } } return best; } private void OnDrawGizmosSelected() { Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.9f); Gizmos.DrawWireSphere(transform.position + Vector3.up * bodyCenterHeight, 0.5f); } } }