Projext/Assets/Scripts/Enemy/AI/ChargeMonster.cs
2026-02-27 09:44:52 +09:00

325 lines
21 KiB
C#

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System.Collections; // 코루틴을 사용할거에요 -> System.Collections를
public class ChargeMonster : MonsterClass // 클래스를 선언할거에요 -> 돌진 몬스터를
{
[Header("=== 돌진 설정 ===")] // 인스펙터 제목을 달거에요 -> 돌진 설정을
[SerializeField] private float chargeSpeed = 15f; // 변수를 선언할거에요 -> 돌진 속도를
[SerializeField] private float chargeRange = 10f; // 변수를 선언할거에요 -> 돌진 감지 거리를
[SerializeField] private float chargeDuration = 2f; // 변수를 선언할거에요 -> 돌진 지속 시간을
[SerializeField] private float chargeDelay = 3f; // 변수를 선언할거에요 -> 돌진 쿨타임을
[SerializeField] private float prepareTime = 0.5f; // 변수를 선언할거에요 -> 돌진 준비 시간을
[Tooltip("돌진 중 플레이어 추적 회전 속도 (낮을수록 둔하게 추적, 높을수록 예리하게 추적)")]
[SerializeField] private float chargeTrackingSpeed = 5f; // 변수를 선언할거에요 -> 돌진 중 실시간 추적 회전 속도를
[Header("애니메이션")] // 인스펙터 제목을 달거에요 -> 애니메이션 State 이름을
[SerializeField] private string chargeAnim = "Run-run"; // 변수를 선언할거에요 -> 돌진 State 이름을
[SerializeField] private string prepareAnim = "Run-wait"; // 변수를 선언할거에요 -> 준비 State 이름을
[SerializeField] private string walkAnim = "Run-walk"; // 변수를 선언할거에요 -> 걷기 State 이름을
private float lastChargeTime; // 변수를 선언할거에요 -> 마지막 돌진 시간을
private bool isCharging; // 변수를 선언할거에요 -> 돌진 중 여부를
private bool isPreparing; // 변수를 선언할거에요 -> 돌진 준비 중 여부를
private Rigidbody _rb; // 변수를 선언할거에요 -> 리지드바디를
private string _lastLoggedState = ""; // 변수를 선언할거에요 -> 직전 로그 State 이름을
// ─────────────────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────────────────
protected override void Awake() // 함수를 실행할거에요 -> 초기화 Awake를
{
base.Awake(); // 실행할거에요 -> 부모 Awake를
_rb = GetComponent<Rigidbody>(); // 가져올거에요 -> 리지드바디를
if (_rb == null) _rb = gameObject.AddComponent<Rigidbody>(); // 추가할거에요 -> 없으면 새로
_rb.isKinematic = true; // 끌거에요 -> 물리 연산을
}
protected override void Init() // 함수를 실행할거에요 -> 추가 초기화를
{
if (agent != null) agent.stoppingDistance = 1f; // 설정할거에요 -> 정지 거리를
isCharging = false; // 초기화할거에요 -> 돌진 상태를
isPreparing = false; // 초기화할거에요 -> 준비 상태를
}
// ─────────────────────────────────────────────────────────
// 디버그 State 감시
// ─────────────────────────────────────────────────────────
public override void OnManagedUpdate() // 함수를 정의할거에요 -> 매 프레임 상태 감시를
{
base.OnManagedUpdate(); // 실행할거에요 -> 부모 업데이트를
if (!showChargeDebugLog || animator == null) return; // 중단할거에요 -> 디버그 꺼져있으면
AnimatorStateInfo info = animator.GetCurrentAnimatorStateInfo(0); // 가져올거에요 -> 현재 State 정보를
string cur;
if (info.IsName(prepareAnim)) cur = prepareAnim;
else if (info.IsName(chargeAnim)) cur = chargeAnim;
else if (info.IsName(walkAnim)) cur = walkAnim;
else if (info.IsName(Monster_Idle)) cur = Monster_Idle;
else if (info.IsName(Monster_GetDamage)) cur = Monster_GetDamage;
else if (info.IsName(Monster_Die)) cur = Monster_Die;
else cur = $"UNKNOWN({info.fullPathHash})";
if (cur == _lastLoggedState) return; // 중단할거에요 -> 같은 State면 중복 출력 안 함
_lastLoggedState = cur; // 저장할거에요 -> 현재 State를
Debug.Log(
$"[ChargeMonster] 🎬 {cur}\n" +
$" isHit={isHit} | isPreparing={isPreparing} | isCharging={isCharging} | isAttacking={isAttacking} | isResting={isResting}\n" +
$" agent.enabled={agent?.enabled} | rb.isKinematic={_rb?.isKinematic}"
); // 출력할거에요 -> State + 상태 변수들을
}
// ─────────────────────────────────────────────────────────
// ⭐ 피격 처리 오버라이드
//
// 돌진 준비(isPreparing) 중 피격
// → StopChargingRoutine()으로 코루틴 종료 + 상태 정리
// → base.TakeDamage()로 체력 감소 + 히트 애니 처리
//
// 돌진 중(isCharging) 피격 (슈퍼아머)
// → ApplyDamageOnly()로 체력 감소 + UI만
// → 히트 애니/멈춤 없음
//
// 일반 상태 피격
// → base.TakeDamage() 그대로
// ─────────────────────────────────────────────────────────
public override void TakeDamage(float amount) // 함수를 정의할거에요 -> 피격 처리 오버라이드를
{
if (isDead) return; // 중단할거에요 -> 이미 죽었으면
// ── 돌진 중 슈퍼아머 ──────────────────────────────────
if (isCharging) // 조건이 맞으면 실행할거에요 -> 돌진 중이면
{
if (showChargeDebugLog)
Debug.Log($"[ChargeMonster] 🛡️ 슈퍼아머 — 데미지만 ({amount})"); // 출력할거에요
// ⭐ ApplyDamageOnly: 체력 감소 + UI만, StartHit() 호출 없음
ApplyDamageOnly(amount); // 실행할거에요 -> 체력 감소와 UI만 처리를
if (currentHP <= 0) // 조건이 맞으면 실행할거에요 -> 사망했으면
{
StopChargingRoutine(); // 실행할거에요 -> 돌진 정리를
Die(); // 실행할거에요 -> 사망 처리를
return;
}
// 피격 사운드/이펙트는 MonsterBehaviour가 OnHitEvent 구독해서 처리
InvokeHitEvent((transform.position - (playerTransform != null ? playerTransform.position : transform.position)).normalized); // 발생시킬거에요 -> 피격 이벤트를
return;
}
// ── 돌진 준비 중 피격 → 돌진 취소 + 일반 히트 ─────────
if (isPreparing) // 조건이 맞으면 실행할거에요 -> 준비 중이면
{
if (showChargeDebugLog)
Debug.Log($"[ChargeMonster] ⛔ 준비 중 피격 — 돌진 취소"); // 출력할거에요
StopChargingRoutine(); // 실행할거에요 -> 준비 코루틴 종료 + 상태 정리를
// 이후 base.TakeDamage()로 일반 피격 처리
}
// ── 일반 피격 ─────────────────────────────────────────
base.TakeDamage(amount); // 실행할거에요 -> 체력 감소 + StartHit() 호출을
}
// ─────────────────────────────────────────────────────────
// 돌진 코루틴만 종료 (StopAllCoroutines 사용 안 함)
// ─────────────────────────────────────────────────────────
private Coroutine _chargeCoroutine; // 변수를 선언할거에요 -> 돌진 코루틴 핸들을
[Header("=== 사운드 ===")] // 인스펙터 제목을 달거에요 -> 돌진 몬스터 전용 사운드를
[SerializeField] private AudioClip growlSound; // 변수를 선언할거에요 -> 돌진 준비 시 으르렁/포효 소리를
[SerializeField] private AudioClip chargeSound; // 변수를 선언할거에요 -> 돌진 시작 시 휙/쿵 소리를
[SerializeField] private AudioClip impactSound; // 변수를 선언할거에요 -> 플레이어 충돌 시 타격음을
[SerializeField] protected AudioClip[] chargeFootstepSounds; // 배열을 선언할거에요 -> 돌진 중 발소리를 (여러 개 넣으면 랜덤)
[SerializeField][Range(0f, 1f)] private float chargeFootstepVolume = 0.6f; // 변수를 선언할거에요 -> 돌진 발소리 볼륨을
[Header("=== 디버그 ===")] // 인스펙터 제목을 달거에요 -> 디버그 설정을
[SerializeField] private bool showChargeDebugLog = true; // 변수를 선언할거에요 -> 디버그 로그 on/off를
private void StopChargingRoutine() // 함수를 선언할거에요 -> 돌진 코루틴만 종료를
{
// ⭐ StopAllCoroutines() 사용 안 함
// HitRoutine 등 다른 코루틴은 건드리지 않음
if (_chargeCoroutine != null) // 조건이 맞으면 실행할거에요 -> 돌진 코루틴이 있으면
{
StopCoroutine(_chargeCoroutine); // 취소할거에요 -> 돌진 코루틴만
_chargeCoroutine = null; // 초기화할거에요 -> 핸들을
}
if (_rb != null) // 조건이 맞으면 실행할거에요 -> 리지드바디가 있으면
{
if (!_rb.isKinematic) _rb.velocity = Vector3.zero; // 멈출거에요 -> 돌진 중일 때만 속도를
_rb.isKinematic = true; // 끌거에요 -> 물리 연산을
}
if (agent != null && !agent.enabled) // 조건이 맞으면 실행할거에요 -> 에이전트가 꺼져있으면
agent.enabled = true; // 켤거에요 -> 에이전트를
isPreparing = false; // 해제할거에요 -> 준비 상태를
isCharging = false; // 해제할거에요 -> 돌진 상태를
isAttacking = false; // 해제할거에요 -> 공격 상태를
}
// ─────────────────────────────────────────────────────────
// AI 로직
// ─────────────────────────────────────────────────────────
protected override void ExecuteAILogic() // 함수를 실행할거에요 -> AI 로직을
{
if (isHit || isCharging || isPreparing || isAttacking || isResting) return; // 중단할거에요 -> 행동 불가 상태면
float dist = Vector3.Distance(transform.position, playerTransform.position); // 계산할거에요 -> 거리를
if (dist <= chargeRange && Time.time >= lastChargeTime + chargeDelay) // 조건이 맞으면 실행할거에요 -> 돌진 가능하면
{
_chargeCoroutine = StartCoroutine(ChargeRoutine()); // 실행할거에요 -> 돌진 코루틴을 (핸들 저장)
}
else // 조건이 틀리면 실행할거에요 -> 일반 추격
{
ChasePlayer(); // 실행할거에요 -> 추격을
}
}
private void ChasePlayer() // 함수를 선언할거에요 -> 추격 로직을
{
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> 에이전트가 정상이면
{
agent.isStopped = false; // 켤거에요 -> 이동을
agent.SetDestination(playerTransform.position); // 설정할거에요 -> 목적지를
}
if (!animator.GetCurrentAnimatorStateInfo(0).IsName(walkAnim)) // 조건이 맞으면 실행할거에요 -> 걷기 아니면
animator.Play(walkAnim, 0, 0f); // 재생할거에요 -> 걷기 애니를
}
// ─────────────────────────────────────────────────────────
// 돌진 코루틴
// ─────────────────────────────────────────────────────────
private IEnumerator ChargeRoutine() // 코루틴을 정의할거에요 -> 돌진 전체 과정을
{
// ── 준비 단계 ──────────────────────────────────────────
isPreparing = true; // 설정할거에요 -> 준비 중으로
isAttacking = true; // 설정할거에요 -> 공격 중으로
agent.isStopped = true; // 멈출거에요 -> 이동을
Vector3 dir = (playerTransform.position - transform.position).normalized; // 계산할거에요 -> 방향을
dir.y = 0f; // 무시할거에요 -> 수직 성분을
transform.rotation = Quaternion.LookRotation(dir); // 회전할거에요 -> 플레이어 방향으로
if (showChargeDebugLog) Debug.Log($"[ChargeMonster] ⏳ 돌진 준비 시작"); // 출력할거에요
animator.Play(prepareAnim, 0, 0f); // 재생할거에요 -> 준비 애니를
yield return new WaitForSeconds(prepareTime); // 기다릴거에요 -> 준비 시간만큼
// 준비 중 피격으로 isPreparing이 false가 됐으면 종료
// (TakeDamage → StopChargingRoutine에서 이미 상태 정리됨)
if (!isPreparing) // 조건이 맞으면 실행할거에요 -> 준비가 취소됐으면
{
if (showChargeDebugLog) Debug.Log($"[ChargeMonster] ⛔ 준비 취소 감지 — 코루틴 종료"); // 출력할거에요
_chargeCoroutine = null; // 초기화할거에요 -> 핸들을
yield break; // 중단할거에요
}
// ── 돌진 단계 ──────────────────────────────────────────
isPreparing = false; // 해제할거에요 -> 준비 상태를
isCharging = true; // 설정할거에요 -> 돌진 중으로
lastChargeTime = Time.time; // 기록할거에요 -> 마지막 돌진 시간을
agent.enabled = false; // 끌거에요 -> NavMesh를 (물리 이동 위해)
_rb.isKinematic = false; // 켤거에요 -> 물리 연산을
if (showChargeDebugLog) Debug.Log($"[ChargeMonster] ⚡ 돌진 시작"); // 출력할거에요
animator.Play(chargeAnim, 0, 0f); // 재생할거에요 -> 돌진 애니를
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (elapsed < chargeDuration) // 반복할거에요 -> 지속 시간 동안
{
// ⭐ 실시간 추적 — 매 프레임 플레이어 방향으로 재계산
if (playerTransform != null) // 조건이 맞으면 실행할거에요 -> 플레이어가 존재하면
{
Vector3 toPlayer = (playerTransform.position - transform.position); // 계산할거에요 -> 플레이어 방향 벡터를
toPlayer.y = 0f; // 무시할거에요 -> 수직 성분을
if (toPlayer.sqrMagnitude > 0.01f) // 조건이 맞으면 실행할거에요 -> 방향이 유효하면 (0벡터 방지)
{
// 방향 부드럽게 회전 (너무 급격한 방향 전환 방지)
Quaternion targetRot = Quaternion.LookRotation(toPlayer.normalized); // 계산할거에요 -> 목표 회전을
transform.rotation = Quaternion.Slerp( // 보간할거에요 -> 현재 회전에서 목표 회전으로 부드럽게
transform.rotation,
targetRot,
chargeTrackingSpeed * Time.deltaTime // 추적 속도 (Inspector에서 조절)
);
}
}
_rb.velocity = transform.forward * chargeSpeed; // 적용할거에요 -> 현재 방향으로 돌진 속도를
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
yield return null; // 대기할거에요 -> 다음 프레임까지
}
// ── 정상 종료 ──────────────────────────────────────────
if (!_rb.isKinematic) _rb.velocity = Vector3.zero; // 멈출거에요 -> 속도를
_rb.isKinematic = true; // 끌거에요 -> 물리 연산을
agent.enabled = true; // 켤거에요 -> NavMesh를
isCharging = false; // 해제할거에요 -> 돌진 상태를
_chargeCoroutine = null; // 초기화할거에요 -> 핸들을
if (showChargeDebugLog) Debug.Log($"[ChargeMonster] 🏁 돌진 정상 종료"); // 출력할거에요
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를 (isAttacking=false + 휴식)
}
// ─────────────────────────────────────────────────────────
// 충돌 판정
// ─────────────────────────────────────────────────────────
private void OnCollisionEnter(Collision col) // 함수를 실행할거에요 -> 충돌 시
{
if (!isCharging) return; // 중단할거에요 -> 돌진 중이 아니면
if (!col.gameObject.CompareTag("Player")) return; // 중단할거에요 -> 플레이어가 아니면
IDamageable t = col.gameObject.GetComponent<IDamageable>(); // 가져올거에요 -> 데미지 인터페이스를
if (t != null) t.TakeDamage(attackDamage); // 입힐거에요 -> 데미지를
// 충돌 타격음은 MonsterBehaviour가 OnHitEvent 구독해서 처리
// ⭐ 경직 적용 — 돌진 충돌 시 플레이어를 n초 동안 움직이지 못하게
PlayerMovement pm = col.gameObject.GetComponent<PlayerMovement>(); // 가져올거에요 -> 플레이어 이동 스크립트를
if (pm != null) pm.ApplyStagger(); // 적용할거에요 -> 경직을 (PlayerMovement.staggerDuration 값 사용)
}
// ─────────────────────────────────────────────────────────
// 디버그 기즈모
// ─────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────
// 애니메이션 이벤트
// ─────────────────────────────────────────────────────────
protected override void OnDrawGizmosSelected() // 함수를 선언할거에요 -> 기즈모 오버라이드를
{
base.OnDrawGizmosSelected(); // 실행할거에요 -> 부모 기즈모를 먼저
if (!showDebugGizmos) return; // 중단할거에요 -> 기즈모 꺼져있으면
Gizmos.color = new Color(1f, 0.5f, 0f, 0.12f); // 설정할거에요 -> 주황 반투명을
Gizmos.DrawSphere(transform.position, chargeRange); // 그릴거에요 -> 돌진 범위 채우기를
Gizmos.color = new Color(1f, 0.5f, 0f, 1f); // 설정할거에요 -> 주황 외곽선을
Gizmos.DrawWireSphere(transform.position, chargeRange); // 그릴거에요 -> 돌진 범위 외곽선을
#if UNITY_EDITOR
if (Application.isPlaying) // 조건이 맞으면 실행할거에요 -> 플레이 중이면
{
string label = isCharging ? "⚡ 돌진! (슈퍼아머)" : (isPreparing ? "⏳ 준비..." : ""); // 결정할거에요
if (!string.IsNullOrEmpty(label))
UnityEditor.Handles.Label(transform.position + Vector3.up * 0.5f, label); // 표시할거에요 -> 씬 뷰에
}
#endif
}
}