using System.Collections; using UnityEngine; using WL.Player; namespace WL.Combat { /// /// 몬스터 행동 제어 — 시야 안 플레이어 추적 → 사거리에서 공격, 시야 밖이면 제자리 Idle. /// 배회·자동 이동은 하지 않는다(PD 지시). /// /// 체력이 0 이 되면 Enemy.Died 를 받아 사망 연출을 수행한다: /// Die 모션 → 깜빡임 → 알파 페이드아웃 → Destroy. /// 모든 수치는 CombatSettings(ScriptableObject)에서 읽는다(C45). /// [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(); _cc = GetComponent(); if (animator == null) animator = GetComponentInChildren(); 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(); _player = _playerController != null ? _playerController.transform : null; } /// /// 추적 대상을 다시 잡는다 (PD #711 캐릭터 교체). /// 교체된 캐릭터는 오브젝트가 사라지는 게 아니라 비활성화되므로 참조가 null 이 되지 않는다. /// 그래서 주기 재탐색(_player == null 조건)에 걸리지 않고, 교체 측이 직접 불러 줘야 한다. /// 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(true)) c.enabled = false; // 피격 하이라이트가 남아 있으면 사망 연출(깜빡임·페이드) 색이 오염된다 — 먼저 걷어낸다 var flash = GetComponent(); 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(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; } /// /// 원본 셰이더(Toon/Toon)는 불투명이라 알파를 지원하지 않는다. /// 페이드 구간에서만 URP/Lit Transparent 인스턴스로 교체하고 텍스처·색을 복사한다(대체 방식). /// 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(); 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; } } } }