442 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|