Project_WL/Assets/WL/Scripts/Combat/EnemyController.cs

442 lines
18 KiB
C#

using System.Collections;
using UnityEngine;
using WL.Player;
namespace WL.Combat
{
/// <summary>
/// 몬스터 행동 제어 — 시야 안 플레이어 추적 → 사거리에서 공격, 시야 밖이면 제자리 Idle.
/// 배회·자동 이동은 하지 않는다(PD 지시).
///
/// 체력이 0 이 되면 Enemy.Died 를 받아 사망 연출을 수행한다:
/// Die 모션 → 깜빡임 → 알파 페이드아웃 → Destroy.
/// 모든 수치는 CombatSettings(ScriptableObject)에서 읽는다(C45).
/// </summary>
[RequireComponent(typeof(Enemy))]
[RequireComponent(typeof(CharacterController))]
public class EnemyController : MonoBehaviour
{
public enum State { Idle, Chase, Attack, Dead }
[Header("수치 설정 (ScriptableObject)")]
[SerializeField] private CombatSettings settings;
[Header("참조")]
[SerializeField] private Animator animator;
[Header("애니메이터 상태 이름 (Monster Pack Forest 2 클립명 기준)")]
[SerializeField] private string idleStateName = "Idle";
[SerializeField] private string moveStateName = "Move";
[SerializeField] private string attackStateName = "Attack";
[SerializeField] private string damageStateName = "Damage";
[SerializeField] private string dieStateName = "Die";
[SerializeField] private float stateBlendTime = 0.1f;
[Header("동작 옵션")]
[Tooltip("피격 시 Damage 모션을 짧게 재생한다(공격·사망 중에는 생략)")]
[SerializeField] private bool playDamageReaction = true;
[Tooltip("씬 뷰에서 시야(노랑)·공격(빨강) 범위를 그린다")]
[SerializeField] private bool drawRangeGizmos = true;
[Header("상태 (읽기 전용)")]
[SerializeField] private State state = State.Idle;
private Enemy _enemy;
private CharacterController _cc;
private Transform _player;
private PlayerController _playerController;
private float _cooldownTimer;
private float _attackTimer;
private float _hitDelayTimer;
private bool _hitPending;
private float _reactionTimer;
private float _verticalVelocity;
private float _retargetTimer;
private int _currentStateHash;
public State CurrentState { get { return state; } }
public bool IsDead { get { return state == State.Dead; } }
// ───────────────────────────────── 초기화
private void Awake()
{
_enemy = GetComponent<Enemy>();
_cc = GetComponent<CharacterController>();
if (animator == null) animator = GetComponentInChildren<Animator>();
if (settings == null && _enemy != null) settings = _enemy.Settings;
}
private void OnEnable()
{
_enemy.Died += OnDied;
_enemy.Damaged += OnDamaged;
}
private void OnDisable()
{
_enemy.Died -= OnDied;
_enemy.Damaged -= OnDamaged;
}
private void Start()
{
AcquirePlayer();
PlayState(idleStateName);
}
private void AcquirePlayer()
{
_playerController = Object.FindFirstObjectByType<PlayerController>();
_player = _playerController != null ? _playerController.transform : null;
}
/// <summary>
/// 추적 대상을 다시 잡는다 (PD #711 캐릭터 교체).
/// 교체된 캐릭터는 오브젝트가 사라지는 게 아니라 비활성화되므로 참조가 null 이 되지 않는다.
/// 그래서 주기 재탐색(_player == null 조건)에 걸리지 않고, 교체 측이 직접 불러 줘야 한다.
/// </summary>
public void RebindPlayer(PlayerController pc)
{
if (pc != null) { _playerController = pc; _player = pc.transform; }
else AcquirePlayer();
}
// ───────────────────────────────── 메인 루프
private void Update()
{
if (state == State.Dead) return;
float dt = Time.deltaTime;
// 플레이어 참조 유실 시 주기적으로 재탐색
_retargetTimer -= dt;
if (_player == null && _retargetTimer <= 0f) { _retargetTimer = 1f; AcquirePlayer(); }
if (_cooldownTimer > 0f) _cooldownTimer -= dt;
if (_reactionTimer > 0f) _reactionTimer -= dt;
// 공격 모션 중 피해 적용 타이밍
if (_hitPending)
{
_hitDelayTimer -= dt;
if (_hitDelayTimer <= 0f) { _hitPending = false; ApplyDamageToPlayer(); }
}
if (_attackTimer > 0f)
{
_attackTimer -= dt;
FacePlayer(dt);
ApplyGravityOnly(dt);
if (_attackTimer <= 0f) { state = State.Idle; PlayState(idleStateName); }
return;
}
if (_player == null) { SetIdle(dt); return; }
Vector3 to = _player.position - transform.position;
to.y = 0f;
float dist = to.magnitude;
float sight = settings != null ? settings.enemySight : 6f;
float lose = settings != null ? Mathf.Max(settings.enemyLoseSight, sight) : 8f;
float range = settings != null ? settings.enemyAttackRange : 1.5f;
// 추적 중에는 loseSight 까지 유지 — 시야 경계에서 상태가 깜빡이는 것을 막는다
float activeRange = (state == State.Chase || state == State.Attack) ? lose : sight;
if (dist > activeRange) { SetIdle(dt); return; }
if (dist <= range)
{
state = State.Attack;
FacePlayer(dt);
ApplyGravityOnly(dt);
if (_cooldownTimer <= 0f && _reactionTimer <= 0f) BeginAttack(dist);
else if (_reactionTimer <= 0f) PlayState(idleStateName);
return;
}
state = State.Chase;
MoveToward(to / Mathf.Max(dist, 0.0001f), dt);
}
private void SetIdle(float dt)
{
state = State.Idle;
ApplyGravityOnly(dt);
if (_reactionTimer <= 0f) PlayState(idleStateName);
}
// ───────────────────────────────── 이동 / 회전
private void MoveToward(Vector3 dir, float dt)
{
float speed = settings != null ? settings.enemyMoveSpeed : 1.6f;
FaceDirection(dir, dt);
ApplyGravityStep(dt);
Vector3 v = dir * speed;
v.y = _verticalVelocity;
_cc.Move(v * dt);
if (_reactionTimer <= 0f) PlayState(moveStateName);
}
private void FacePlayer(float dt)
{
if (_player == null) return;
Vector3 to = _player.position - transform.position;
to.y = 0f;
if (to.sqrMagnitude < 0.0001f) return;
FaceDirection(to.normalized, dt);
}
private void FaceDirection(Vector3 dir, float dt)
{
float turn = settings != null ? settings.enemyTurnSpeed : 360f;
float targetYaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg;
float yaw = Mathf.MoveTowardsAngle(transform.eulerAngles.y, targetYaw, turn * dt);
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
}
private void ApplyGravityOnly(float dt)
{
ApplyGravityStep(dt);
_cc.Move(new Vector3(0f, _verticalVelocity, 0f) * dt);
}
private void ApplyGravityStep(float dt)
{
float g = settings != null ? settings.enemyGravity : -19.62f;
if (_cc.isGrounded && _verticalVelocity < 0f) _verticalVelocity = -2f;
else _verticalVelocity = Mathf.Max(_verticalVelocity + g * dt, -50f);
}
// ───────────────────────────────── 공격
private void BeginAttack(float dist)
{
_cooldownTimer = settings != null ? settings.enemyCooldown : 1.5f;
_attackTimer = settings != null ? settings.enemyAttackFallbackDuration : 0.7f;
_hitDelayTimer = settings != null ? settings.enemyAttackHitDelay : 0.3f;
_hitPending = true;
PlayState(attackStateName, true);
if (animator != null && animator.runtimeAnimatorController != null)
{
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.IsName(attackStateName) && info.length > 0f && !float.IsInfinity(info.length))
_attackTimer = info.length;
}
// 공격 모션이 쿨타임보다 길면 쿨타임을 모션 길이에 맞춘다
if (_attackTimer > _cooldownTimer) _cooldownTimer = _attackTimer;
Debug.Log("[EnemyController] " + name + " ATTACK t=" + Time.timeSinceLevelLoad.ToString("F2")
+ "s dist=" + dist.ToString("F2") + "m 모션 " + _attackTimer.ToString("F2") + "s", this);
}
private void ApplyDamageToPlayer()
{
if (_playerController == null || _player == null) return;
float range = settings != null ? settings.enemyAttackRange : 1.5f;
Vector3 to = _player.position - transform.position;
to.y = 0f;
// 피해 시점에 사거리를 다시 확인한다(+여유 0.5m)
if (to.sqrMagnitude > (range + 0.5f) * (range + 0.5f)) return;
int dmg = settings != null ? settings.enemyDamage : 1;
_playerController.TakeDamage(dmg, transform);
}
private void OnDamaged(Enemy e, float amount)
{
if (state == State.Dead || !playDamageReaction) return;
if (_attackTimer > 0f) return; // 공격 모션 중에는 생략
float sec = settings != null ? settings.enemyDamageReactionSeconds : 0.35f;
if (sec <= 0f) return;
_reactionTimer = sec;
PlayState(damageStateName, true);
}
// ───────────────────────────────── 사망 연출
private void OnDied(Enemy e)
{
if (state == State.Dead) return;
StartCoroutine(DeathRoutine());
}
private IEnumerator DeathRoutine()
{
state = State.Dead;
_hitPending = false;
_attackTimer = 0f;
_reactionTimer = 0f;
if (_cc != null) _cc.enabled = false; // 이동·충돌 정지
foreach (var c in GetComponentsInChildren<Collider>(true)) c.enabled = false;
// 피격 하이라이트가 남아 있으면 사망 연출(깜빡임·페이드) 색이 오염된다 — 먼저 걷어낸다
var flash = GetComponent<HitFlash>();
if (flash != null) { flash.Clear(); flash.enabled = false; }
float dieLen = settings != null ? settings.deathDieFallbackDuration : 1.2f;
PlayState(dieStateName, true);
if (animator != null && animator.runtimeAnimatorController != null)
{
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.IsName(dieStateName) && info.length > 0f && !float.IsInfinity(info.length)) dieLen = info.length;
}
Debug.Log("[EnemyController] " + name + " DIE 모션 " + dieLen.ToString("F2") + "s", this);
yield return new WaitForSeconds(dieLen);
// Die 클립의 마지막 자세에서 정지시킨다(루프/기본 상태 복귀 방지)
if (animator != null) animator.speed = 0f;
float hold = settings != null ? settings.deathDieHoldSeconds : 0.15f;
if (hold > 0f) yield return new WaitForSeconds(hold);
var renderers = GetComponentsInChildren<Renderer>(true);
// ① 깜빡임 — 렌더러 on/off 토글
int blinks = settings != null ? Mathf.Max(settings.deathBlinkCount, 0) : 6;
float blinkTotal = settings != null ? settings.deathBlinkSeconds : 0.9f;
if (blinks > 0 && blinkTotal > 0f)
{
float half = blinkTotal / (blinks * 2f);
for (int i = 0; i < blinks; i++)
{
SetRenderersEnabled(renderers, false);
yield return new WaitForSeconds(half);
SetRenderersEnabled(renderers, true);
yield return new WaitForSeconds(half);
}
}
// ② 알파 페이드아웃
float fade = settings != null ? settings.deathFadeSeconds : 1.0f;
var fadeMats = BuildFadeMaterials(renderers);
if (fade > 0f && fadeMats.Length > 0)
{
float t = 0f;
while (t < fade)
{
t += Time.deltaTime;
float a = Mathf.Clamp01(1f - t / fade);
for (int i = 0; i < fadeMats.Length; i++) SetMaterialAlpha(fadeMats[i], a);
yield return null;
}
}
Debug.Log("[EnemyController] " + name + " 소멸(Destroy) t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
for (int i = 0; i < fadeMats.Length; i++) if (fadeMats[i] != null) Destroy(fadeMats[i]);
Destroy(gameObject);
}
private static void SetRenderersEnabled(Renderer[] rs, bool on)
{
for (int i = 0; i < rs.Length; i++) if (rs[i] != null) rs[i].enabled = on;
}
/// <summary>
/// 원본 셰이더(Toon/Toon)는 불투명이라 알파를 지원하지 않는다.
/// 페이드 구간에서만 URP/Lit Transparent 인스턴스로 교체하고 텍스처·색을 복사한다(대체 방식).
/// </summary>
private Material[] BuildFadeMaterials(Renderer[] renderers)
{
var lit = Shader.Find("Universal Render Pipeline/Lit");
if (lit == null) return new Material[0];
var created = new System.Collections.Generic.List<Material>();
for (int r = 0; r < renderers.Length; r++)
{
var rend = renderers[r];
if (rend == null) continue;
var src = rend.sharedMaterials;
var dst = new Material[src.Length];
for (int i = 0; i < src.Length; i++)
{
var m = new Material(lit);
m.SetFloat("_Surface", 1f); // 0 Opaque · 1 Transparent
m.SetFloat("_Blend", 0f); // Alpha
m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
m.SetFloat("_ZWrite", 0f);
m.SetFloat("_AlphaClip", 0f);
m.SetFloat("_Smoothness", 0f);
m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
m.DisableKeyword("_ALPHATEST_ON");
m.SetShaderPassEnabled("ShadowCaster", false);
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
Texture tex = null;
Color col = Color.white;
if (src[i] != null)
{
if (src[i].HasProperty("_BaseMap")) tex = src[i].GetTexture("_BaseMap");
if (tex == null && src[i].HasProperty("_MainTex")) tex = src[i].GetTexture("_MainTex");
if (src[i].HasProperty("_BaseColor")) col = src[i].GetColor("_BaseColor");
else if (src[i].HasProperty("_Color")) col = src[i].GetColor("_Color");
}
if (tex != null) { m.SetTexture("_BaseMap", tex); m.SetTexture("_MainTex", tex); }
col.a = 1f;
m.SetColor("_BaseColor", col);
m.SetColor("_Color", col);
dst[i] = m;
created.Add(m);
}
rend.materials = dst;
}
return created.ToArray();
}
private static void SetMaterialAlpha(Material m, float a)
{
if (m == null) return;
Color c = m.HasProperty("_BaseColor") ? m.GetColor("_BaseColor") : m.color;
c.a = a;
if (m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", c);
if (m.HasProperty("_Color")) m.SetColor("_Color", c);
}
// ───────────────────────────────── 애니메이터
private void PlayState(string stateName, bool force = false)
{
if (animator == null || animator.runtimeAnimatorController == null || string.IsNullOrEmpty(stateName)) return;
int hash = Animator.StringToHash(stateName);
if (!force && _currentStateHash == hash) return;
if (!animator.HasState(0, hash)) return;
_currentStateHash = hash;
animator.CrossFade(hash, stateBlendTime, 0, 0f);
}
// ───────────────────────────────── 기즈모
private void OnDrawGizmosSelected()
{
if (!drawRangeGizmos) return;
float sight = settings != null ? settings.enemySight : 6f;
float range = settings != null ? settings.enemyAttackRange : 1.5f;
Vector3 c = transform.position + Vector3.up * 0.05f;
Gizmos.color = new Color(1f, 0.92f, 0.16f, 1f);
DrawCircle(c, sight);
Gizmos.color = new Color(1f, 0.20f, 0.20f, 1f);
DrawCircle(c, range);
}
private static void DrawCircle(Vector3 center, float radius)
{
const int seg = 48;
Vector3 prev = center + new Vector3(radius, 0f, 0f);
for (int i = 1; i <= seg; i++)
{
float a = (i / (float)seg) * Mathf.PI * 2f;
Vector3 cur = center + new Vector3(Mathf.Cos(a) * radius, 0f, Mathf.Sin(a) * radius);
Gizmos.DrawLine(prev, cur);
prev = cur;
}
}
}
}