Project_WL/Assets/WL/Scripts/Player/Enemy.cs

119 lines
5.5 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using WL.Combat;
namespace WL.Player
{
/// <summary>
/// 적 유닛의 체력·피격 판정. 플레이어 자동 탐색·공격의 대상이 된다.
/// 태그 "Enemy" + 이 컴포넌트 조합으로 식별한다.
///
/// 체력 수치는 CombatSettings(ScriptableObject)에서 읽는다(C45). 에셋이 비어 있을 때만
/// 인스펙터의 fallbackMaxHp 를 쓴다.
/// 사망 연출은 EnemyController 가 Died 이벤트를 받아 처리한다(구독자가 없으면 즉시 비활성화).
/// </summary>
public class Enemy : MonoBehaviour
{
/// <summary>활성 상태인 모든 적. 매 프레임 FindObjectsByType 를 피하기 위한 등록부.</summary>
public static readonly List<Enemy> All = new List<Enemy>();
[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;
/// <summary>체력이 0 이 된 순간 1회 발생. EnemyController 가 사망 연출을 맡는다.</summary>
public event System.Action<Enemy> Died;
/// <summary>어떤 적이든 죽은 순간 1회 발생(전역). PlayerProgress 가 경험치 지급에 쓴다 (PD #727).</summary>
public static event System.Action<Enemy> AnyDied;
/// <summary>피해를 받은 순간 발생(사망 타격 포함). 인자 = 이번 피해량.</summary>
public event System.Action<Enemy, float> 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);
}
/// <summary>무적(더미 봇)인가. 체력이 줄지 않고 죽지 않는다.</summary>
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);
}
/// <summary>지정 위치에서 range 안에 있는 가장 가까운 생존 적을 찾는다.</summary>
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);
}
}
}