Projext/Assets/02_Scripts/Enemy/BossAI/NorcielBoss.Patterns.cs
hydrozen e989d20668 카툰 쉐이더 추가 + 중복 스크립트 수정 + 전체 업데이트
- ToonPostProcess.shader: 횃불 고딕 스타일 후처리 쉐이더 (Built-in RP)
- ToonCameraEffect.cs: 카메라 자동 부착 후처리 스크립트
- 중복 UI 스크립트 제거 (MenuIntroController, ToggleCustom)
- 씬, 프리팹, 애니메이션 등 전체 업데이트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:31:16 +09:00

1346 lines
99 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System.Collections; // 코루틴 기능을 불러올거에요 -> IEnumerator를
// ============================================================
// NorcielBoss.Patterns.cs — 공격 패턴 실행 코루틴 (partial class)
//
// [역할]
// ① 5가지 공격 패턴 코루틴 (Throw, Smash, Sweep, Dash, DashSmash)
// ② 보스 휴식(BossRest) + 쇠공 줍기(PickupBall) + 경직(Recover)
//
// [설계]
// partial class로 NorcielBoss의 private 필드에 직접 접근합니다.
// 패턴 실행 로직만 분리하여 메인 FSM과 책임을 분리합니다.
// ============================================================
public partial class NorcielBoss : MonsterClass // 부분 클래스를 선언할거에요 -> NorcielBoss의 패턴 부분으로
{
// ══════════════════════════════════════════════════════════
// 패턴 디스패처
// ══════════════════════════════════════════════════════════
private void StartPattern(BossPattern pattern) // 함수를 선언할거에요 -> 패턴 코루틴을 시작하는
{
switch (pattern) // 분기할거에요 -> 패턴에 따라
{
case BossPattern.Throw: StartCoroutine(Pattern_Throw()); break; // 시작할거에요 -> 던지기를
case BossPattern.Smash: StartCoroutine(Pattern_Smash()); break; // 시작할거에요 -> 찍기를 (스텁)
case BossPattern.Sweep: StartCoroutine(Pattern_Sweep()); break; // 시작할거에요 -> 휩쓸기를 (스텁)
case BossPattern.Dash: StartCoroutine(Pattern_Dash()); break; // 시작할거에요 -> 대시를 (스텁)
case BossPattern.DashSmash: StartCoroutine(Pattern_DashSmash()); break; // 시작할거에요 -> 대시+내려찍기 콤보를
}
}
// ══════════════════════════════════════════════════════════
// 패턴 ① — 쇠공 던지기 ← 현재 구현된 유일한 패턴
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Throw() // 코루틴을 정의할거에요 -> 쇠공 던지기 패턴을
{
// 사전 검증
if (ironBall == null) // 조건이 맞으면 실행할거에요 -> 쇠공 없으면
{
Debug.LogError("[Boss] ironBall 슬롯이 비어있어요! Inspector에서 BossIronBall 스크립트가 부착된 오브젝트를 연결해주세요."); // 에러를 찍을거에요
SetState(BossState.PreAttack); // 복구할거에요
yield break; // 중단할거에요
}
SetState(BossState.Attacking); // 잠글거에요 -> 패턴 실행 중
StopAgent(); // 멈플거에요 -> NavMesh를
// ── 투척 목표: BossIronBall에 Transform 전달 (실시간 위치 추적) ──────
//
// [이전 방식의 문제]
// 여기서 throwTarget(Vector3)을 미리 계산 후 Launch(throwTarget) 에 전달했는데,
// throwTiming 대기(WaitForSeconds) + WaitForFixedUpdate 동안 플레이어가 조금이라도
// 움직이면 그 오차 그대로 빗나갔어요.
//
// [새 방식: Transform 전달]
// _target Transform 레퍼런스를 BossIronBall.Launch()에 전달해요.
// BossIronBall은 WaitForFixedUpdate() 직후, 즉 실제 발사 순간에
// targetTransform.position + Vector3.up * aimHeight 를 다시 계산해요.
// → 발사 타이밍과 위치 계산이 완벽히 동기화 → 명중률 100%
//
// 수평 거리 체크 (경고만, 강제 중단 안 함)
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
float hDist = Vector2.Distance( // 계산할거에요 -> 수평 거리를
new Vector2(transform.position.x, transform.position.z),
new Vector2(_target.position.x, _target.position.z));
Debug.Log($"[Boss] 던지기 시작 → 플레이어: {_target.position:F2} 수평거리={hDist:F1}m"); // 로그를 찍을거에요
if (hDist < 1.5f) // 조건이 맞으면 실행할거에요 -> 거리가 너무 가까우면
Debug.LogWarning("[Boss] ⚠️ 플레이어가 너무 가까워요! 근접 패턴이 더 적합해요."); // 경고를 찍을거에요
}
// [v6 수정] 즉시 스냅 회전 (이슈3: 쇠공 던질 때 플레이어를 안 보는 문제)
// v5 문제: FaceTarget()은 Slerp → 1프레임에 약 12% 회전 → 보스가 안 보고 던지는데 공이 날아감
// v6 수정: 즉시 스냅으로 던지기 전 완전 회전 보장
FaceTarget(instant: true); // 스냅할거에요 -> 플레이어를 즉시 바라보게 (던지기 전 완전 정렬)
PlayBossSFX(sfx_Throw_Windup, sfx_Throw_Windup_Vol); // 재생할거에요 -> 던지기 예비 사운드를 (쇠공 들어올리는 소리)
// 던지기 애니 재생
PlayAnimDirect(anim_Throw); // 재생할거에요 -> 던지기 애니를
yield return null; // 기다릴거에요 -> 1프레임 (애니 상태 전환 완료)
// 발사 타이밍 대기
// → OnThrowRelease 이벤트가 오면 즉시 발사
// → 이벤트가 없으면 클립 길이 × throwTiming(0.4) 후 발사
float clipLen = GetClipLength(); // 가져올거에요 -> 현재 클립 길이를
float waitTime = clipLen * throwTiming; // 계산할거에요 -> 발사 대기 시간을
_throwFired = false; // 초기화할거에요 -> 이전 이벤트 신호를
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (!_throwFired && elapsed < waitTime) // 반복할거에요 -> 이벤트 또는 타이머까지
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
// [v6 추가] 던지기 모션 중 플레이어 방향으로 부드러운 추적 회전
// → 보스가 던지는 동안에도 계속 플레이어를 바라봄 → 시각적으로 자연스러운 연출
// → Slerp 사용 (모션 중 급격한 스냅은 어색하므로 부드럽게)
FaceTarget(); // 바라볼거에요 -> 플레이어를 (Slerp 부드러운 추적, 던지는 모션 동안 자연스럽게)
yield return null; // 기다릴거에요 -> 1프레임을
}
// ─ 쇠공 발사 ─────────────────────────────────────────
// BossIronBall.cs 가 물리/데미지를 전담 → 여기서는 Launch 한 줄만
// _target Transform 전달 → BossIronBall이 WaitForFixedUpdate 직후 실시간 위치를 읽어 조준해요
float dmgMult = (phaseManager != null) ? phaseManager.GetDamageMult() : 1f; // 가져올거에요 -> Phase별 데미지 배율을 (phaseManager 위임) // 계산할거에요 -> 데미지 배율을
ironBall.Launch(_target, throwAimHeight, dmgMult); // 발사할거에요 -> 플레이어 실시간 위치로 정확히 조준해서
PlayBossSFX(sfx_Throw_Release, sfx_Throw_Release_Vol); // 재생할거에요 -> 발사 사운드를 (쇠공 날아가는 소리)
PlayBossVFXAt(vfx_Throw_Release, ironBall != null ? ironBall.transform.position : transform.position); // 재생할거에요 -> 발사 이펙트를 (쇠공 손 떠나는 위치에서)
// 나머지 애니 대기 (발사 이후 후속 모션)
float remaining = Mathf.Max(clipLen - elapsed - 0.05f, 0.3f); // 계산할거에요 -> 남은 애니 시간을
yield return new WaitForSeconds(remaining); // 기다릴거에요 -> 후속 모션 끝날 때까지
// [v6 수정] Throw 후 즉시 대시 제거 (이슈5: 대시 빈도 과다)
// v5: Throw 후 카이팅 감지 → 즉시 Dash/DashSmash 발동
// → 플레이어에게 반응 시간 0, "또 대시함?" 주원인
// v6: Throw 후에는 항상 Recover()로 진행
// → 대시가 필요하면 Recover 후 Chase/PreAttack에서 자연스럽게 발동
// → 플레이어에게 최소한의 반응 시간 보장
// ── Throw 후 기존대로 Recover ──────────────
yield return StartCoroutine(Recover()); // 기다릴거에요 -> 경직 후 상태 복구를
}
// ══════════════════════════════════════════════════════════
// 패턴 ② — 내려찍기 (Pattern_Smash)
//
// [작동 흐름]
// 플레이어를 향한 후 내려찍기 애니 재생
// → smashTiming 비율 타이밍에 전방 OverlapSphere 로 범위 데미지
// → 애니 종료 후 Recover
//
// [애니메이션 이벤트 (선택)]
// 내려치는 프레임 → OnSmashImpact
// 마지막 프레임 → OnAnimEnd
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Smash() // 코루틴을 정의할거에요 -> 내려찍기 패턴을
{
SetState(BossState.Attacking); // 잠글거에요 -> 패턴 실행 중 다른 패턴 차단
StopAgent(); // 멈플거에요 -> NavMesh를을 (내려찍는 동안 정지)
// ── SYSTEM 1: [회피 반응] 와인드업 시간 계산 ──────────
// 회피 반응 중이면 와인드업을 빠르게
float smashWindupTime = 0f; // 초기화할거에요 -> 내려찍기 와인드업 시간을
if (counterSystem != null && counterSystem.IsDodgeReacting) // 조건이 맞으면 실행할거에요 -> 카운터 시스템이 회피 반응 중이면
{
smashWindupTime = 0.3f * counterSystem.DodgeReactWindupMult; // 계산할거에요 -> 기본 0.3초에 반응 배율을 곱해서
counterSystem.ClearDodgeReact(); // 해제할거에요 -> 반응 플래그를 (한 번만 사용)
}
else
{
smashWindupTime = 0.4f + Random.Range(-windupVariance, windupVariance); // 계산할거에요 -> 일반 와인드업에 랜덤 분산을
}
smashWindupTime = Mathf.Max(smashWindupTime, 0.1f); // 보장할거에요 -> 최소 0.1초를
PlayBossSFX(sfx_Smash_Windup, sfx_Smash_Windup_Vol); // 재생할거에요 -> 내려찍기 예비 사운드를 (팔 드는 소리)
// 와인드업 대기
yield return new WaitForSeconds(smashWindupTime); // 기다릴거에요 -> 와인드업 시간을
// ── [슬라이드 접근] 근접 범위 밖이면 짧게 전진 후 공격 ──
// 근접 패턴인데 플레이어가 약간 밖이면, 허공 공격 대신 전진해서 닿게
float preDist = GetDist(); // 계산할거에요 -> 현재 거리를
if (preDist > meleeRange && preDist <= meleeRange + smashForwardOffset + smashRadius) // 조건이 맞으면 실행할거에요 -> 근접 밖이지만 전진하면 닿는 거리면
{
float slideTime = 0.25f; // 설정할거에요 -> 슬라이드 시간을 (짧고 빠르게)
float slideSpeed = (preDist - meleeRange * 0.7f) / slideTime; // 계산할거에요 -> 슬라이드 속도를 (거리 차이 / 시간)
float slideElapsed = 0f; // 초기화할거에요 -> 경과 시간을
Vector3 slideDir = (_target.position - transform.position); // 계산할거에요 -> 슬라이드 방향을
slideDir.y = 0f; // 제거할거에요 -> 수직 성분을
slideDir = slideDir.normalized; // 정규화할거에요 -> 단위 벡터로
while (slideElapsed < slideTime) // 반복할거에요 -> 슬라이드 시간 동안
{
slideElapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
transform.position += slideDir * slideSpeed * Time.deltaTime; // 이동할거에요 -> 플레이어 방향으로 전진
FaceTarget(); // 바라볼거에요 -> 플레이어를
yield return null; // 기다릴거에요 -> 1프레임을
}
}
// ── 플레이어 방향으로 즉시 스냅 회전 ──────────────────
// [수정] FaceTarget()은 Slerp라 1프레임에 완전히 안 돌아가요.
// → 즉시 스냅해서 애니 시작 방향을 정확히 플레이어를 향하게!
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toP = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toP.y = 0f; // 제거할거에요 -> 수직 성분을
if (toP.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 유효하면
transform.rotation = Quaternion.LookRotation(toP.normalized); // 스냅할거에요 -> 즉시 플레이어를 바라보게
}
// ─ 내려찍기 애니 재생 ────────────────────────────────
bool hasSmashAnim = animator != null && animator.HasState(0, Animator.StringToHash(anim_Smash)); // 확인할거에요 -> 애니 State 존재 여부를
if (hasSmashAnim) // 조건이 맞으면 실행할거에요 -> 애니 있으면
PlayAnimDirect(anim_Smash); // 재생할거에요 -> 내려찍기 애니를
else // 조건이 틀리면 실행할거에요 -> 애니 없으면
Debug.LogWarning($"[Boss] Animator State \"{anim_Smash}\" 없음 → 타이머만으로 진행"); // 경고를 찍을거에요
yield return null; // 기다릴거에요 -> 1프레임 (애니 상태 전환 완료)
// ── 범위 표시: 원형 바닥 표시 (Smash 판정 범위) ─────
if (attackIndicator != null) // 조건이 맞으면 실행할거에요 -> 표시기가 있으면
attackIndicator.ShowCircle(transform, smashRadius, smashForwardOffset, smashTiming); // 표시할거에요 -> 원형 범위를 (보스 앞, smashTiming 동안 점점 진해짐)
// ─ 임팩트 타이밍 대기 (애니 전반부: 플레이어를 추적 회전) ──
float clipLen = hasSmashAnim ? GetClipLength() : smashTiming + 0.5f; // 가져올거에요 -> 클립 길이를
_animEndFired = false; // 초기화할거에요 -> 종료 플래그를
bool _smashImpactFired = false; // 초기화할거에요 -> 찍기 이벤트 플래그를
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
// [개선] 임팩트 타이밍의 70% 지점까지는 플레이어를 계속 추적 회전해요.
// → 애니 초반에 플레이어가 옆으로 빠져도 보스가 따라 회전
// → 70% 이후부터는 방향 고정 (찍기 모션이 이미 내려가는 중이라 자연스러움)
float trackingCutoff = smashTiming * 0.7f; // 계산할거에요 -> 추적 회전 중단 시점을 (임팩트의 70%)
// ── 흡인 구간 계산 (smashTiming의 비율로 시작/종료) ────
float suctionStart = smashTiming * smashSuctionStartRatio; // 계산할거에요 -> 흡인 시작 시점을 (예: 0.15 → 15% 시점)
float suctionEnd = smashTiming * smashSuctionEndRatio; // 계산할거에요 -> 흡인 종료 시점을 (예: 0.75 → 75% 시점)
while (!_smashImpactFired && elapsed < smashTiming) // 반복할거에요 -> 임팩트 타이밍까지
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
// 임팩트 70% 전까지는 플레이어 방향으로 부드럽게 추적 회전
if (elapsed < trackingCutoff) // 조건이 맞으면 실행할거에요 -> 아직 추적 구간이면
FaceTarget(); // 바라볼거에요 -> 플레이어를 (Slerp 부드러운 추적)
// ── 흡인 효과: 공격 범위 안의 플레이어를 서서히 끌어당김 ──
// suctionStart ~ suctionEnd 구간에서만 작동해요
// → 와인드업 초반에는 반응할 틈을 주고, 임팩트 직전에는 빠져나갈 틈을 줌
if (elapsed >= suctionStart && elapsed < suctionEnd // 조건이 맞으면 실행할거에요 -> 흡인 구간이면
&& _target != null && _cachedPlayerMovement != null) // 그리고 플레이어가 유효하면
{
Vector3 smashCenterNow = transform.position + transform.forward * smashForwardOffset; // 계산할거에요 -> 현재 공격 판정 중심을 (보스가 회전하므로 매 프레임 갱신)
float distToSmash = Vector3.Distance(_target.position, smashCenterNow); // 계산할거에요 -> 플레이어~공격 중심 거리를
if (distToSmash <= smashSuctionRange && distToSmash > 0.5f) // 조건이 맞으면 실행할거에요 -> 흡인 범위 안이면 (너무 가까우면 제외)
{
_cachedPlayerMovement.ApplySuction(smashCenterNow, smashSuctionForce); // 끌어당길거에요 -> 플레이어를 공격 중심으로
}
}
yield return null; // 기다릴거에요 -> 1프레임을
}
// ── [v4 수정] 임팩트 직전 스냅 회전 → 부드러운 보정으로 완화 ──
// [이전 문제] 임팩트 직전에 Quaternion.LookRotation 스냅 → "피했는데 맞았다" 느낌
// [원칙 6] 판정은 보이는 것과 일치해야 함 → 추적 70%에서 고정된 방향을 존중
// [수정] 스냅 대신 최대 15도까지만 부드럽게 보정 → 플레이어가 피했으면 진짜 빗나감
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toP2 = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toP2.y = 0f; // 제거할거에요 -> 수직 성분을
if (toP2.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 유효하면
{
Quaternion targetRot = Quaternion.LookRotation(toP2.normalized); // 계산할거에요 -> 플레이어 방향 회전을
float angleDiff = Quaternion.Angle(transform.rotation, targetRot); // 계산할거에요 -> 현재와 목표의 각도 차이를
float maxCorrection = 15f; // 설정할거에요 -> 최대 보정 각도를 (15도, 스냅 대신 제한된 보정)
if (angleDiff <= maxCorrection) // 조건이 맞으면 실행할거에요 -> 15도 이내면 보정
transform.rotation = targetRot; // 보정할거에요 -> 작은 오차만 보정
// else: 15도 초과면 보정 안 함 → 플레이어가 옆으로 빠졌으면 진짜 빗나감
}
}
// ── 범위 표시 숨기기 (임팩트 발생 → 표시 종료) ─────────
if (attackIndicator != null) attackIndicator.Hide(); // 숨길거에요 -> 바닥 범위 표시를
// ─ 찍기 판정 실행 (Phase별 공유 메서드) ──────────────────
PlayBossSFX(sfx_Smash_Impact, sfx_Smash_Impact_Vol); // 재생할거에요 -> 내려찍기 임팩트 사운드를 (쾅!)
PlayBossVFXAt(vfx_Smash_Impact, transform.position + transform.forward * smashForwardOffset); // 재생할거에요 -> 내려찍기 임팩트 이펙트를 (판정 중심 위치)
ExecuteSmashHit(); // 실행할거에요 -> 찍기 판정을 (데미지 + 넉백)
Debug.Log($"[Boss] 내려찍기 판정 완료! Phase={(_isPhase3 ? "3" : _isPhase2 ? "2" : "1")}"); // 로그를 찍을거에요
// ─ 나머지 애니 대기 ──────────────────────────────────
float remaining = Mathf.Max(clipLen - elapsed - 0.05f, 0.3f); // 계산할거에요 -> 남은 애니 시간을 (최소 0.3초 보장)
yield return new WaitForSeconds(remaining); // 기다릴거에요 -> 후속 모션 끝날 때까지
// ══════════════════════════════════════════════════════════
// [v4 추가] Phase별 Smash 변형 — 원칙 5, 8 적용
//
// Phase 1: 기본 1회 찍기 → Recover
// Phase 2: 찍기 후 원형 충격파 1회 추가 (바닥 표시 + 지연 판정)
// Phase 3: 3연타 찍기 (각도 약간 변경, 연속 회피 필요)
// ══════════════════════════════════════════════════════════
if (_isPhase3) // 조건이 맞으면 실행할거에요 -> Phase3이면 3연타
{
// ── Phase3: 추가 2회 찍기 (총 3연타) ──────────────
// 각 찍기마다 약간 다른 방향으로 → 같은 방향으로 회피하면 맞음
Debug.Log("[Boss] Phase3 Smash → 3연타 발동!"); // 로그를 찍을거에요
for (int extraHit = 0; extraHit < 2; extraHit++) // 반복할거에요 -> 추가 2회 찍기를
{
// 짧은 딜레이 (연타 사이 숨 고르기)
yield return new WaitForSeconds(0.35f); // 기다릴거에요 -> 연타 간격을 (0.35초, 반응 시간 확보)
// 방향을 약간 다르게 (±20도 랜덤 오프셋)
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toPlayer = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toPlayer.y = 0f; // 제거할거에요 -> 수직 성분을
if (toPlayer.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 유효하면
{
float angleOffset = Random.Range(-20f, 20f); // 계산할거에요 -> 랜덤 각도 오프셋을 (±20도)
Quaternion offsetRot = Quaternion.Euler(0, angleOffset, 0); // 만들거에요 -> Y축 회전을
Vector3 offsetDir = offsetRot * toPlayer.normalized; // 적용할거에요 -> 오프셋 방향을
transform.rotation = Quaternion.LookRotation(offsetDir); // 회전할거에요 -> 오프셋된 방향으로
}
}
// 찍기 애니 재생 (반복)
if (hasSmashAnim) PlayAnimDirect(anim_Smash); // 재생할거에요 -> 내려찍기 애니를 다시
yield return null; // 기다릴거에요 -> 1프레임
// 범위 표시
if (attackIndicator != null) // 조건이 맞으면 실행할거에요 -> 표시기 있으면
attackIndicator.ShowCircle(transform, smashRadius, smashForwardOffset, smashTiming * 0.6f); // 표시할거에요 -> 연타용 짧은 표시를
// 임팩트 대기 (연타는 원본보다 빠르게)
float multiHitWait = smashTiming * 0.6f; // 계산할거에요 -> 연타 임팩트 시간을 (원본의 60%)
yield return new WaitForSeconds(multiHitWait); // 기다릴거에요 -> 임팩트까지
if (attackIndicator != null) attackIndicator.Hide(); // 숨길거에요 -> 표시를
// 판정 실행
PlayBossSFX(sfx_Smash_Impact, sfx_Smash_Impact_Vol); // 재생할거에요 -> 연타 임팩트 사운드를
PlayBossVFXAt(vfx_Smash_Impact, transform.position + transform.forward * smashForwardOffset); // 재생할거에요 -> 연타 임팩트 이펙트를
ExecuteSmashHit(); // 실행할거에요 -> 추가 찍기 판정을
Debug.Log($"[Boss] Phase3 연타 #{extraHit + 2} 판정 완료!"); // 로그를 찍을거에요
// 연타 후속 모션 대기
yield return new WaitForSeconds(0.2f); // 기다릴거에요 -> 후속 모션을
}
}
else if (_isPhase2) // 조건이 맞으면 실행할거에요 -> Phase2이면 충격파 추가
{
// ── Phase2: 찍기 후 원형 충격파 ──────────────────
// 근접 회피 후에도 충격파에 맞을 수 있음 → "Phase 바뀌었다" 체감
Debug.Log("[Boss] Phase2 Smash → 충격파 추가!"); // 로그를 찍을거에요
yield return new WaitForSeconds(0.3f); // 기다릴거에요 -> 충격파 전 짧은 딜레이를 (전조 시간)
// 충격파 범위 표시 (Smash보다 넓은 원형)
float shockwaveRadius = smashRadius * 1.8f; // 계산할거에요 -> 충격파 반경을 (Smash의 1.8배)
if (attackIndicator != null) // 조건이 맞으면 실행할거에요 -> 표시기 있으면
attackIndicator.ShowCircle(transform, shockwaveRadius, 0f, 0.5f); // 표시할거에요 -> 보스 중심 원형을 (0.5초 경고)
yield return new WaitForSeconds(0.5f); // 기다릴거에요 -> 충격파 경고 시간을
if (attackIndicator != null) attackIndicator.Hide(); // 숨길거에요 -> 표시를
PlayBossSFX(sfx_Smash_Shockwave, sfx_Smash_Shockwave_Vol); // 재생할거에요 -> 충격파 사운드를
PlayBossVFX(vfx_Smash_Shockwave); // 재생할거에요 -> 충격파 이펙트를 (보스 중심)
// 충격파 판정 (보스 중심, 넓은 범위)
float dmgMult = (phaseManager != null) ? phaseManager.GetDamageMult() : 1f; // 가져올거에요 -> 데미지 배율을
float shockwaveDmg = smashDamage * dmgMult * 0.6f; // 계산할거에요 -> 충격파 데미지를 (본체의 60%)
Collider[] shockHits = Physics.OverlapSphere(transform.position, shockwaveRadius); // 검사할거에요 -> 충격파 범위를
foreach (Collider hit in shockHits) // 반복할거에요 -> 감지된 콜라이더마다
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어 아니면
IDamageable d = hit.GetComponent<IDamageable>() // 가져올거에요 -> 인터페이스를
?? hit.GetComponentInParent<IDamageable>(); // 또는 부모에서
if (d != null) // 조건이 맞으면 실행할거에요 -> 있으면
{
d.TakeDamage(shockwaveDmg); // 줄거에요 -> 충격파 데미지를
Debug.Log($"[Boss] Phase2 충격파 데미지 {shockwaveDmg:F0} 적용!"); // 로그를 찍을거에요
}
// 충격파 넉백 (바깥 방향으로 밀어냄)
if (smashKnockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 있으면
{
Transform playerRoot = hit.transform.root; // 가져올거에요 -> 플레이어 루트를
Vector3 knockDir = (playerRoot.position - transform.position); // 계산할거에요 -> 밀어내는 방향을
knockDir.y = 0f; // 제거할거에요 -> 수직 성분을
PlayerMovement pm = playerRoot.GetComponent<PlayerMovement>(); // 가져올거에요 -> PlayerMovement를
if (pm != null) // 조건이 맞으면 실행할거에요 -> 있으면
pm.ApplyKnockback(knockDir, smashKnockbackForce * 0.7f, smashKnockbackUpForce * 0.5f, smashKnockbackDuration); // 적용할거에요 -> 충격파 넉백을 (본체보다 약하게)
}
}
Debug.Log($"[Boss] Phase2 충격파 판정 완료! 반경={shockwaveRadius:F1}m"); // 로그를 찍을거에요
}
yield return StartCoroutine(Recover()); // 기다릴거에요 -> 경직 후 상태 복구를
}
// ══════════════════════════════════════════════════════════
// [v4 추가] Smash 판정 공용 메서드 — Pattern_Smash + Phase 변형에서 재사용
// 단일 책임: OverlapSphere → 데미지 + 넉백만 처리
// ══════════════════════════════════════════════════════════
private void ExecuteSmashHit() // 함수를 선언할거에요 -> Smash 판정을 실행하는 (데미지 + 넉백)
{
float dmgMult = (phaseManager != null) ? phaseManager.GetDamageMult() : 1f; // 가져올거에요 -> Phase별 데미지 배율을
float finalDmg = smashDamage * dmgMult; // 계산할거에요 -> 최종 데미지를
Vector3 smashCenter = transform.position + transform.forward * smashForwardOffset; // 계산할거에요 -> 찍기 판정 중심을 (보스 앞)
Collider[] hits = Physics.OverlapSphere(smashCenter, smashRadius); // 검사할거에요 -> 반경 안 콜라이더를
foreach (Collider hit in hits) // 반복할거에요 -> 감지된 콜라이더마다
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어 아니면
IDamageable d = hit.GetComponent<IDamageable>() // 가져올거에요 -> 인터페이스를
?? hit.GetComponentInParent<IDamageable>(); // 또는 부모에서
if (d != null) // 조건이 맞으면 실행할거에요 -> 있으면
{
d.TakeDamage(finalDmg); // 줄거에요 -> 내려찍기 데미지를
Debug.Log($"[Boss] Smash 데미지 {finalDmg:F0} 적용!"); // 로그를 찍을거에요
}
if (smashKnockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 있으면
{
Transform playerRoot = hit.transform.root; // 가져올거에요 -> 플레이어 루트를
Vector3 knockDir = playerRoot.position - smashCenter; // 계산할거에요 -> 넉백 방향을
knockDir.y = 0f; // 제거할거에요 -> 수직 성분을
PlayerMovement pm = playerRoot.GetComponent<PlayerMovement>(); // 가져올거에요 -> PlayerMovement를
if (pm != null) // 조건이 맞으면 실행할거에요 -> 있으면
pm.ApplyKnockback(knockDir, smashKnockbackForce, smashKnockbackUpForce, smashKnockbackDuration); // 적용할거에요 -> 넉백을
}
}
}
// ══════════════════════════════════════════════════════════
// 패턴 ③ — 휩쓸기 (Pattern_Sweep)
//
// [작동 흐름]
// 플레이어를 향한 후 휩쓸기 애니 재생
// → sweepTiming 초 타이밍에 전방 부채꼴(OverlapSphere + 각도 체크)로 범위 데미지 + 수평 넉백
// → 애니 종료 후 Recover
//
// [발동 조건]
// dist ≤ meleeRange (근접 사거리) → Smash 또는 Sweep 랜덤 선택 (sweepSelectChance 확률)
//
// [sweepTiming 계산법]
// 타격 프레임 ÷ 클립 FPS = sweepTiming (초)
// 예) 60fps 클립의 90프레임 → 90 ÷ 60 = 1.5
//
// [애니메이션 이벤트 (선택)]
// 휩쓸기 실제 타격 프레임 → OnSweepImpact (미구현 시 sweepTiming 타이머로 대체)
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Sweep() // 코루틴을 정의할거에요 -> 휩쓸기 패턴을
{
SetState(BossState.Attacking); // 잠글거에요 -> 패턴 실행 중
StopAgent(); // 멈플거에요 -> NavMesh를
// ── SYSTEM 1: [회피 반응] 와인드업 시간 계산 ──────────
// 회피 반응 중이면 와인드업을 빠르게
float sweepWindupTime = 0f; // 초기화할거에요 -> 휩쓸기 와인드업 시간을
if (counterSystem != null && counterSystem.IsDodgeReacting) // 조건이 맞으면 실행할거에요 -> 카운터 시스템이 회피 반응 중이면
{
sweepWindupTime = 0.3f * counterSystem.DodgeReactWindupMult; // 계산할거에요 -> 기본 0.3초에 반응 배율을 곱해서
counterSystem.ClearDodgeReact(); // 해제할거에요 -> 반응 플래그를 (한 번만 사용)
}
else
{
sweepWindupTime = 0.35f + Random.Range(-windupVariance, windupVariance); // 계산할거에요 -> 일반 와인드업에 랜덤 분산을
}
sweepWindupTime = Mathf.Max(sweepWindupTime, 0.1f); // 보장할거에요 -> 최소 0.1초를
PlayBossSFX(sfx_Sweep_Swing, sfx_Sweep_Swing_Vol); // 재생할거에요 -> 휩쓸기 스윙 사운드를 (팔 움직이는 소리)
// 와인드업 대기
yield return new WaitForSeconds(sweepWindupTime); // 기다릴거에요 -> 와인드업 시간을
// ── [슬라이드 접근] 근접 범위 밖이면 짧게 전진 후 공격 ──
float preDist = GetDist(); // 계산할거에요 -> 현재 거리를
if (preDist > meleeRange && preDist <= meleeRange + sweepRadius) // 조건이 맞으면 실행할거에요 -> 근접 밖이지만 전진하면 닿으면
{
float slideTime = 0.2f; // 설정할거에요 -> 슬라이드 시간을
float slideSpeed = (preDist - meleeRange * 0.7f) / slideTime; // 계산할거에요 -> 속도를
float slideElapsed = 0f; // 초기화할거에요 -> 경과 시간을
Vector3 slideDir = (_target.position - transform.position); // 계산할거에요 -> 방향을
slideDir.y = 0f; // 제거할거에요 -> 수직 성분을
slideDir = slideDir.normalized; // 정규화할거에요
while (slideElapsed < slideTime) // 반복할거에요 -> 슬라이드 동안
{
slideElapsed += Time.deltaTime; // 더할거에요
transform.position += slideDir * slideSpeed * Time.deltaTime; // 이동할거에요
FaceTarget(); // 바라볼거에요
yield return null; // 기다릴거에요
}
}
// ── 플레이어 방향으로 즉시 스냅 회전 ──────────────────
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toP = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toP.y = 0f; // 제거할거에요 -> 수직 성분을
if (toP.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 유효하면
transform.rotation = Quaternion.LookRotation(toP.normalized); // 스냅할거에요 -> 즉시 바라보게
}
// ─ 휩쓸기 애니 재생 ─────────────────────────────────
bool hasSweepAnim = animator != null && animator.HasState(0, Animator.StringToHash(anim_Sweep)); // 확인할거에요 -> 애니 State 존재 여부를
if (hasSweepAnim) // 조건이 맞으면 실행할거에요 -> 애니 있으면
PlayAnimDirect(anim_Sweep); // 재생할거에요 -> 휩쓸기 애니를
else // 조건이 틀리면 실행할거에요 -> 애니 없으면
Debug.LogWarning($"[Boss] Animator State \"{anim_Sweep}\" 없음 → 타이머만으로 진행"); // 경고를 찍을거에요
yield return null; // 기다릴거에요 -> 1프레임
// ── 범위 표시: 부채꼴 바닥 표시 (Sweep 판정 범위) ───
if (attackIndicator != null) // 조건이 맞으면 실행할거에요 -> 표시기가 있으면
attackIndicator.ShowFan(transform, sweepRadius, sweepAngle, sweepTiming); // 표시할거에요 -> 부채꼴 범위를 (sweepTiming 동안 점점 진해짐)
// ─ 타이밍 대기 (애니 전반부: 추적 회전) ─────────────
float clipLen = hasSweepAnim ? GetClipLength() : sweepTiming + 0.5f; // 가져올거에요 -> 클립 길이를
_animEndFired = false; // 초기화할거에요 -> 종료 플래그를
bool _sweepImpactFired = false; // 초기화할거에요 -> 타격 이벤트 플래그를
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
float sweepTrackCutoff = sweepTiming * 0.65f; // 계산할거에요 -> 추적 중단 시점을 (65% → 휩쓸기 모션 특성상 좀 더 일찍 고정)
while (!_sweepImpactFired && elapsed < sweepTiming) // 반복할거에요 -> 임팩트 타이밍까지
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
// 임팩트 65% 전까지 추적 회전
if (elapsed < sweepTrackCutoff) // 조건이 맞으면 실행할거에요 -> 추적 구간이면
FaceTarget(); // 바라볼거에요 -> 플레이어를 (부드러운 추적)
yield return null; // 기다릴거에요 -> 1프레임을
}
// ── [v4 수정] 임팩트 직전 스냅 → 제한된 보정으로 완화 ──
// [원칙 6] "피했는데 맞았다" 방지 — 최대 15도까지만 보정
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toP2 = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toP2.y = 0f; // 제거할거에요 -> 수직 성분을
if (toP2.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 유효하면
{
Quaternion targetRot = Quaternion.LookRotation(toP2.normalized); // 계산할거에요 -> 플레이어 방향 회전을
float angleDiff = Quaternion.Angle(transform.rotation, targetRot); // 계산할거에요 -> 각도 차이를
if (angleDiff <= 15f) // 조건이 맞으면 실행할거에요 -> 15도 이내면 보정
transform.rotation = targetRot; // 보정할거에요 -> 작은 오차만
// else: 15도 초과 = 플레이어가 충분히 피함 → 보정 안 함
}
}
// ── 범위 표시 숨기기 (임팩트 발생 → 표시 종료) ─────────
if (attackIndicator != null) attackIndicator.Hide(); // 숨길거에요 -> 바닥 범위 표시를
// ─ 부채꼴 휩쓸기 판정 실행 (Phase 공용) ──────────────────
// [v4] Phase3에서는 360도 판정이므로 actualSweepAngle로 분기
float actualSweepAngle = _isPhase3 ? 360f : sweepAngle; // 결정할거에요 -> Phase별 실제 Sweep 각도를 (Phase3 = 360도 회전)
PlayBossSFX(sfx_Sweep_Impact, sfx_Sweep_Impact_Vol); // 재생할거에요 -> 휩쓸기 히트 사운드를 (충격음)
PlayBossVFX(vfx_Sweep_Trail); // 재생할거에요 -> 휩쓸기 트레일 이펙트를 (팔 잔상)
ExecuteSweepHit(actualSweepAngle); // 실행할거에요 -> 휩쓸기 판정을 (데미지 + 넉백)
Debug.Log($"[Boss] 휩쓸기 판정 완료! 반경={sweepRadius}m 각도={actualSweepAngle}° Phase={(_isPhase3 ? "3" : _isPhase2 ? "2" : "1")}"); // 로그를 찍을거에요
// ─ 나머지 애니 대기 ──────────────────────────────────
float remaining = Mathf.Max(clipLen - elapsed - 0.05f, 0.3f); // 계산할거에요 -> 남은 시간을
yield return new WaitForSeconds(remaining); // 기다릴거에요 -> 후속 모션 끝날 때까지
_animEndFired = false; // 초기화할거에요 -> 플래그를 (Recover에서 재사용하지 않도록)
// ══════════════════════════════════════════════════════════
// [v4 추가] Phase별 Sweep 변형 — 원칙 5, 8 적용
//
// Phase 1: 기본 180° 1회 → Recover
// Phase 2: 왕복 2회 (좌→우 후 즉시 우→좌)
// Phase 3: 360° 회전 (이미 위에서 actualSweepAngle=360으로 처리)
// ══════════════════════════════════════════════════════════
if (_isPhase2 && !_isPhase3) // 조건이 맞으면 실행할거에요 -> Phase2이면 왕복 Sweep
{
// ── Phase2: 왕복 2회차 (돌아오는 휩쓸기) ──────────
Debug.Log("[Boss] Phase2 Sweep → 왕복 2회차!"); // 로그를 찍을거에요
yield return new WaitForSeconds(0.25f); // 기다릴거에요 -> 왕복 간 짧은 간격을 (전조 시간)
// 2회차 범위 표시
if (attackIndicator != null) // 조건이 맞으면 실행할거에요 -> 표시기 있으면
attackIndicator.ShowFan(transform, sweepRadius, sweepAngle, sweepTiming * 0.5f); // 표시할거에요 -> 2회차 부채꼴을 (빠르게)
// 2회차 Sweep 애니 재생
if (hasSweepAnim) PlayAnimDirect(anim_Sweep); // 재생할거에요 -> Sweep 애니를 다시
yield return null; // 기다릴거에요 -> 1프레임
// 2회차 임팩트 대기 (원본의 50% 속도)
float secondHitWait = sweepTiming * 0.5f; // 계산할거에요 -> 2회차 임팩트 시간을
yield return new WaitForSeconds(secondHitWait); // 기다릴거에요 -> 2회차 임팩트까지
if (attackIndicator != null) attackIndicator.Hide(); // 숨길거에요 -> 표시를
// 2회차 판정 실행
// [Q-2 수정] 1회차와 동일한 SFX/VFX가 2회차에 누락되어 있었음 → 추가
PlayBossSFX(sfx_Sweep_Impact, sfx_Sweep_Impact_Vol); // 재생할거에요 -> 2회차 휩쓸기 충격음을
PlayBossVFX(vfx_Sweep_Trail); // 재생할거에요 -> 2회차 휩쓸기 트레일 이펙트를 (팔 잔상)
ExecuteSweepHit(sweepAngle); // 실행할거에요 -> 2회차 휩쓸기 판정을
Debug.Log("[Boss] Phase2 Sweep 2회차 판정 완료!"); // 로그를 찍을거에요
yield return new WaitForSeconds(0.2f); // 기다릴거에요 -> 후속 모션을
}
yield return StartCoroutine(Recover()); // 기다릴거에요 -> 경직 후 상태 복구를
}
// ══════════════════════════════════════════════════════════
// [v4 추가] Sweep 판정 공용 메서드 — 지정된 각도로 부채꼴 판정
// Phase1: 180° / Phase2: 180° × 2회 / Phase3: 360°
// ══════════════════════════════════════════════════════════
private void ExecuteSweepHit(float hitAngle) // 함수를 선언할거에요 -> Sweep 판정을 실행하는 (데미지 + 넉백, 지정 각도)
{
float dmgMult = (phaseManager != null) ? phaseManager.GetDamageMult() : 1f; // 가져올거에요 -> 데미지 배율을
float finalDmg = sweepDamage * dmgMult; // 계산할거에요 -> 최종 데미지를
Collider[] candidates = Physics.OverlapSphere(transform.position, sweepRadius); // 검사할거에요 -> 반경 안 후보를
foreach (Collider hit in candidates) // 반복할거에요 -> 후보 콜라이더마다
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어 아니면
// 각도 체크 (360도면 무조건 통과)
if (hitAngle < 360f) // 조건이 맞으면 실행할거에요 -> 360도 미만이면 각도 체크
{
Vector3 toPlayer = (hit.transform.position - transform.position).normalized; // 계산할거에요 -> 플레이어 방향을
float angle = Vector3.Angle(transform.forward, toPlayer); // 계산할거에요 -> 전방과의 각도를
if (angle > hitAngle * 0.5f) continue; // 건너뛸거에요 -> 부채꼴 밖이면
}
IDamageable d = hit.GetComponent<IDamageable>() // 가져올거에요 -> 인터페이스를
?? hit.GetComponentInParent<IDamageable>(); // 또는 부모에서
if (d != null) // 조건이 맞으면 실행할거에요 -> 있으면
{
d.TakeDamage(finalDmg); // 줄거에요 -> 휩쓸기 데미지를
Debug.Log($"[Boss] Sweep 데미지 {finalDmg:F0} 적용! (hitAngle={hitAngle}°)"); // 로그를 찍을거에요
}
if (sweepKnockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 있으면
{
Transform playerRoot = hit.transform.root; // 가져올거에요 -> 플레이어 루트를
Vector3 knockDir = playerRoot.position - transform.position; // 계산할거에요 -> 넉백 방향을
knockDir.y = 0f; // 제거할거에요 -> 수직 성분을
PlayerMovement pm = playerRoot.GetComponent<PlayerMovement>(); // 가져올거에요 -> PlayerMovement를
if (pm != null) // 조건이 맞으면 실행할거에요 -> 있으면
pm.ApplyKnockback(knockDir, sweepKnockbackForce, sweepKnockbackUpForce, sweepKnockbackDuration); // 적용할거에요 -> 넉백을
}
}
}
// ══════════════════════════════════════════════════════════
// 패턴 ④ — 대시 추격 (Pattern_Dash)
//
// [v9] NavMeshAgent 고속 이동 — Rigidbody 완전 폐기!
//
// v8 문제점:
// - Rigidbody velocity로 이동 → NavMesh 경계 무시 → 맵 밖 이탈!
// - agent.enabled=false → 복구 시 "Failed to create agent" 에러
// - 벽/지형 충돌 시 velocity 반사 → 보스가 반대 방향으로 튕김
// - 거리가 41m→60m→75m→111m로 계속 증가하는 현상 발생
//
// v9 핵심 변경:
// ① agent를 절대 끄지 않음 → NavMesh 이탈 원천 차단!
// ② agent.speed = dashSpeed로 가속 + SetDestination
// ③ NavMesh가 자동으로 벽 회피/경계 준수 → 투명벽 관통 불가
// ④ 도착 후 agent.speed를 원래 값으로 복원
//
// 게임성: 보스가 확실하게 플레이어에게 도착 + 벽 관통 불가
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Dash() // 코루틴을 정의할거에요 -> 대시 추격 패턴을 (v9: NavMeshAgent 고속 이동)
{
SetState(BossState.Attacking); // 잠글거에요 -> 패턴 실행 중 다른 패턴 차단
// ══════════════════════════════════════════════════════
// ① 준비 단계 — 정지 + 즉시 스냅 + 준비 포즈
// ══════════════════════════════════════════════════════
// ── [v10 달리기 추격] NavMesh 멈추지 않음 ────────────
// 기존 v9: 멈추고 → Idle 포즈 → 대기 → 대시 (부자연스러움)
// v10 변경: 즉시 달리기 애니 재생 → 자연스러운 추격 연출
// agent.isStopped=true 하지 않음 → 멈추지 않고 바로 달려나감
// ── 플레이어 방향으로 즉시 스냅 회전 ─────────────────
FaceTarget(instant: true); // 스냅할거에요 -> 플레이어 방향으로 즉시
// ── [v10] 준비 중 달리기 애니 재생 (Idle 포즈 제거) ──
bool hasRunAnimPrep = animator != null && animator.HasState(0, Animator.StringToHash(anim_Run)); // 확인할거에요 -> 달리기 애니 존재 여부를
if (animator != null) animator.Play(hasRunAnimPrep ? anim_Run : anim_Walk, 0, 0f); // 재생할거에요 -> 달리기 애니를 (없으면 걷기로 폴백)
Debug.Log($"[Boss] v10 달리기 추격 준비! prepareTime={dashWindupTime}s runAnim={( hasRunAnimPrep ? anim_Run : anim_Walk )}"); // 로그를 찍을거에요
PlayBossSFX(sfx_Dash_Windup, sfx_Dash_Windup_Vol); // 재생할거에요 -> 대시 준비 사운드를 (으르렁/숨 들이쉬기 소리)
yield return new WaitForSeconds(dashWindupTime); // 기다릴거에요 -> 준비 시간만큼 (Inspector에서 0으로 설정하면 즉시 출발)
// ══════════════════════════════════════════════════════
// ② 돌진 단계 — NavMeshAgent 고속 이동!
//
// [v9 핵심] agent.enabled = false 하지 않음!
// → agent.speed만 올려서 대시 표현
// → NavMesh 경계를 절대 벗어나지 않음
// → 벽이 있으면 자동으로 우회 경로 계산
// → "Failed to create agent" 에러 원천 차단
//
// 종료: dashHitRange 이내 도달 시 즉시 정지
// 안전: dashMaxTime 초 초과 시 강제 종료
// ══════════════════════════════════════════════════════
// ── 플레이어 방향 최종 스냅 (준비 중 이동 보정) ─────────
FaceTarget(instant: true); // 스냅할거에요 -> 돌진 직전 최종 방향으로
// ── agent 고속 설정 ─────────────────────────────────
float savedSpeed = (agent != null) ? agent.speed : 3.5f; // 저장할거에요 -> 원래 이동 속도를 (대시 끝나면 복원)
float savedAccel = (agent != null) ? agent.acceleration : 8f; // 저장할거에요 -> 원래 가속도를
float savedAngular = (agent != null) ? agent.angularSpeed : 120f; // 저장할거에요 -> 원래 회전 속도를
float savedStopDist = (agent != null) ? agent.stoppingDistance : 0f; // 저장할거에요 -> 원래 정지 거리를
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 위에 있으면
{
agent.speed = dashSpeed; // 올릴거에요 -> 이동 속도를 대시 속도로!
agent.acceleration = dashSpeed * 4f; // 올릴거에요 -> 가속도를 (즉시 최고 속도 도달)
agent.angularSpeed = 1000f; // 올릴거에요 -> 회전 속도를 (목적지 방향 즉시 회전)
agent.stoppingDistance = dashHitRange; // 설정할거에요 -> 도착 거리를 (충돌 범위에서 자동 감속)
agent.isStopped = false; // 켤거에요 -> 이동 시작!
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
agent.SetDestination(_target.position); // 설정할거에요 -> 첫 번째 목적지를 플레이어 위치로
}
// ── [v10] 달리기 애니 재생 (Attack_Dash → Monster_Run 변경) ──
// 기존: anim_Dash(Attack_Dash) 사용 → 공격 모션으로 부자연스러움
// v10: anim_Run(Monster_Run) 우선 사용 → 자연스러운 달리기 추격 연출
bool hasRunAnim = animator != null && animator.HasState(0, Animator.StringToHash(anim_Run)); // 확인할거에요 -> 달리기 애니 존재 여부를
string dashAnimName = hasRunAnim ? anim_Run : anim_Walk; // 결정할거에요 -> 달리기 애니를 (없으면 걷기로 폴백)
if (animator != null) animator.Play(dashAnimName, 0, 0f); // 재생할거에요 -> 달리기 애니를
PlayBossSFX(sfx_Dash_Move, sfx_Dash_Move_Vol); // 재생할거에요 -> 돌진 이동 사운드를 (발구르기/바람소리)
PlayBossVFX(vfx_Dash_Trail); // 재생할거에요 -> 돌진 트레일 이펙트를 (먼지/발자국, 루프)
// ── 거리 기반 최대 시간 계산 ────────────────────────
float distAtStart = (_target != null) // 계산할거에요 -> 돌진 시작 시점 거리를
? Vector3.Distance(transform.position, _target.position)
: 20f; // 타겟 없으면 기본 20m
float dashMaxTime = (distAtStart / dashSpeed) + 2.0f; // 계산할거에요 -> 최대 돌진 시간을 (거리/속도 + 여유 2초, NavMesh 우회 고려)
dashMaxTime = Mathf.Clamp(dashMaxTime, 1f, 10f); // 제한할거에요 -> 최소 1초, 최대 10초 (v8의 5초→10초, 먼 거리+우회 경로 대응)
// ── 돌진 방향 기록 (넉백용) ─────────────────────────
Vector3 dashDir = transform.forward; // 저장할거에요 -> 돌진 방향을 (넉백 방향에 사용)
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toP = (_target.position - transform.position); // 계산할거에요 -> 플레이어 방향을
toP.y = 0f; // 무시할거에요 -> 수직 성분을
if (toP.sqrMagnitude > 0.001f) dashDir = toP.normalized; // 확정할거에요 -> 넉백 방향을
}
Debug.Log($"[Boss] v9 NavMesh 고속 돌진! 속도={dashSpeed}m/s 시작거리={distAtStart:F1}m 최대시간={dashMaxTime:F1}s 도착범위={dashHitRange}m"); // 로그를 찍을거에요
// ── 돌진 루프 — NavMesh 이동 + 거리 기반 도착 종료 ─────
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
bool dashHitDealt = false; // 초기화할거에요 -> 충돌 데미지 처리 여부를
float dmgMult = (phaseManager != null) ? phaseManager.GetDamageMult() : 1f; // 가져올거에요 -> Phase별 데미지 배율을
float finalDmg = dashDamage * dmgMult; // 계산할거에요 -> 최종 대시 충돌 데미지를
bool arrivedInRange = false; // 초기화할거에요 -> 도착 성공 여부를
float destUpdateTimer = 0f; // 초기화할거에요 -> 목적지 갱신 타이머를
while (elapsed < dashMaxTime) // 반복할거에요 -> 최대 돌진 시간까지
{
// ── 돌진 애니 강제 유지 ──────────────────────
if (animator != null && !animator.GetCurrentAnimatorStateInfo(0).IsName(dashAnimName)) // 확인할거에요 -> 애니 다른 거면
animator.Play(dashAnimName); // 강제 재생할거에요 -> 돌진 애니를
// ── [v9 핵심] NavMesh 목적지 주기적 갱신 ─────────
// 플레이어가 이동하므로 0.2초마다 목적지를 업데이트
// NavMeshAgent가 자동으로 경로를 재계산 → 벽 회피, 우회 포함
destUpdateTimer += Time.deltaTime; // 더할거에요 -> 갱신 타이머를
if (destUpdateTimer >= 0.2f && _target != null && agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> 0.2초마다
{
agent.SetDestination(_target.position); // 갱신할거에요 -> 목적지를 플레이어 위치로
destUpdateTimer = 0f; // 초기화할거에요 -> 갱신 타이머를
}
// ── 거리 기반 도착 판정 + 충돌 데미지 ──────────────
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
// [Q-1 수정] Vector3.Distance(sqrt) → sqrMagnitude 비교로 교체
// 매 프레임 sqrt 연산이 불필요 → 제곱값끼리 비교로 성능 절감
float sqrDistToPlayer = (_target.position - transform.position).sqrMagnitude; // 계산할거에요 -> 거리 제곱을 (sqrt 없이 비교)
float dashHitSqr = dashHitRange * dashHitRange; // [Q-1] 비교 임계값 제곱 캐시
// ── 넉백 방향 갱신 (실제 접근 방향 기준) ─────────
Vector3 toP2 = (_target.position - transform.position); // 계산할거에요 -> 플레이어 방향을
toP2.y = 0f; // 무시할거에요 -> 수직 성분을
if (toP2.sqrMagnitude > 0.001f) dashDir = toP2.normalized; // 갱신할거에요 -> 넉백 방향을
// ── 충돌 데미지 (1회) ──────────────────────
if (!dashHitDealt && sqrDistToPlayer <= dashHitSqr) // 조건이 맞으면 실행할거에요 -> 충돌 거리 이내면 [Q-1: sqr비교]
{
float distToPlayer = Mathf.Sqrt(sqrDistToPlayer); // 계산할거에요 -> 로그용 실제 거리를 (조건 충족 시만 sqrt)
IDamageable d = _target.GetComponent<IDamageable>() // 가져올거에요 -> 인터페이스를
?? _target.GetComponentInParent<IDamageable>(); // 또는 부모에서
if (d != null) d.TakeDamage(finalDmg); // 줄거에요 -> 대시 충돌 데미지를
Debug.Log($"[Boss] v9 대시 충돌! 데미지={finalDmg:F0} 거리={distToPlayer:F2}m"); // 로그를 찍을거에요
if (dashKnockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 설정 있으면
{
PlayerMovement pm = _target.root.GetComponent<PlayerMovement>(); // 가져올거에요 -> PlayerMovement를
if (pm != null) pm.ApplyKnockback(dashDir, dashKnockbackForce, dashKnockbackUpForce, dashKnockbackDuration); // 적용할거에요 -> 넉백을
}
dashHitDealt = true; // 설정할거에요 -> 중복 방지
PlayBossSFX(sfx_Dash_Impact, sfx_Dash_Impact_Vol); // 재생할거에요 -> 충돌 임팩트 사운드를
PlayBossVFXAt(vfx_Dash_Impact, _target != null ? _target.position : transform.position); // 재생할거에요 -> 충돌 이펙트를 (플레이어 위치)
StopBossVFX(vfx_Dash_Trail); // 정지할거에요 -> 돌진 트레일을 (충돌 후 먼지 자연 소멸)
arrivedInRange = true; // 표시할거에요 -> 도착 성공
Debug.Log($"[Boss] v9 도착! 거리={distToPlayer:F2}m → 즉시 정지"); // 로그를 찍을거에요
break; // 탈출할거에요 -> 돌진 루프를
}
// ── 도착 판정 (충돌 안 했어도 가까우면 정지) ──────
if (sqrDistToPlayer <= dashHitSqr * 2.25f) // 조건이 맞으면 실행할거에요 -> 충돌 범위 약간 밖이면 [Q-1: 1.5^2=2.25]
{
float distToPlayer = Mathf.Sqrt(sqrDistToPlayer); // 계산할거에요 -> 로그용 실제 거리를 (조건 충족 시만 sqrt)
arrivedInRange = true; // 표시할거에요 -> 도착 성공
Debug.Log($"[Boss] v9 근접 도착! 거리={distToPlayer:F2}m → 정지"); // 로그를 찍을거에요
break; // 탈출할거에요 -> 돌진 루프를
}
}
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
yield return null; // 대기할거에요 -> 다음 프레임까지
}
if (!arrivedInRange) // 조건이 맞으면 실행할거에요 -> 시간 초과면
Debug.Log($"[Boss] v9 대시 시간 초과 ({dashMaxTime:F1}s) → 강제 정지"); // 로그를 찍을거에요
// ══════════════════════════════════════════════════════
// ③ 종료 — agent 속도 복원 (NavMesh 끊김 없음!)
//
// [v9] agent를 끈 적이 없으므로:
// - "Failed to create agent" 에러 원천 차단!
// - NavMesh Warp 불필요!
// - 즉시 정상 이동 모드로 복귀!
// ══════════════════════════════════════════════════════
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 위에 있으면
{
agent.isStopped = true; // 멈출거에요 -> 이동을
agent.velocity = Vector3.zero; // 제거할거에요 -> 잔여 관성을
agent.speed = savedSpeed; // 복원할거에요 -> 원래 이동 속도를
agent.acceleration = savedAccel; // 복원할거에요 -> 원래 가속도를
agent.angularSpeed = savedAngular; // 복원할거에요 -> 원래 회전 속도를
agent.stoppingDistance = savedStopDist; // 복원할거에요 -> 원래 정지 거리를
}
StopBossVFX(vfx_Dash_Trail); // 정지할거에요 -> 돌진 트레일을 (루프 종료 보험)
Debug.Log($"[Boss] v9 대시 종료. 도착={arrivedInRange} 충돌={dashHitDealt} 경과={elapsed:F2}s"); // 로그를 찍을거에요
yield return StartCoroutine(Recover()); // 기다릴거에요 -> 경직 후 상태 복구를
}
// ══════════════════════════════════════════════════════════
// 대시 + 내려찍기 콤보 (Pattern_DashSmash)
//
// [v9] NavMeshAgent 고속 이동 → 도착 시 즉시 내려찍기
//
// v8 문제점: Rigidbody velocity → NavMesh 이탈 → 투명벽 관통
//
// v9: Pattern_Dash와 동일하게 NavMeshAgent 고속 이동
// 차이점: 도착 시 스매시로 전환 (Pattern_Dash는 정지)
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_DashSmash() // 코루틴을 정의할거에요 -> 대시+내려찍기 콤보 패턴을 (v9: NavMeshAgent 고속 이동)
{
SetState(BossState.Attacking); // 잠글거에요 -> 패턴 실행 중 다른 패턴 차단
// ══════════════════════════════════════════════════════
// Phase 1 — 준비 단계
// ══════════════════════════════════════════════════════
// ── NavMesh 정지 ──────────────────────────────────
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 위에 있으면
{
agent.isStopped = true; // 멈출거에요 -> NavMesh 이동을
agent.velocity = Vector3.zero; // 제거할거에요 -> 잔여 관성을
}
// ── 플레이어 방향으로 즉시 스냅 회전 ─────────────────
FaceTarget(instant: true); // 스냅할거에요 -> 플레이어 방향으로 즉시
// ── 준비 애니 + 대기 ──────────────────────────────
if (animator != null) animator.Play(anim_Idle, 0, 0f); // 재생할거에요 -> 준비 포즈를
Debug.Log($"[Boss] v9 콤보 대시 준비! prepareTime={dashSmashWindupTime}s"); // 로그를 찍을거에요
PlayBossSFX(sfx_Dash_Windup, sfx_Dash_Windup_Vol); // 재생할거에요 -> 콤보 대시 준비 사운드를
yield return new WaitForSeconds(dashSmashWindupTime); // 기다릴거에요 -> 준비 시간만큼
// ══════════════════════════════════════════════════════
// Phase 2 — [v9] NavMeshAgent 고속 돌진 + 스매시 도착 판정
//
// [v9 핵심] agent를 끄지 않고 speed만 올림!
// → NavMesh 경계 자동 준수, 벽 관통 불가
// ══════════════════════════════════════════════════════
// ── 플레이어 방향 최종 스냅 ──────────────────────────
FaceTarget(instant: true); // 스냅할거에요 -> 돌진 직전 최종 방향으로
// ── agent 고속 설정 ─────────────────────────────────
float savedSpeed = (agent != null) ? agent.speed : 3.5f; // 저장할거에요 -> 원래 이동 속도를
float savedAccel = (agent != null) ? agent.acceleration : 8f; // 저장할거에요 -> 원래 가속도를
float savedAngular = (agent != null) ? agent.angularSpeed : 120f; // 저장할거에요 -> 원래 회전 속도를
float savedStopDist = (agent != null) ? agent.stoppingDistance : 0f; // 저장할거에요 -> 원래 정지 거리를
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 위에 있으면
{
agent.speed = dashSmashSpeed; // 올릴거에요 -> 이동 속도를 콤보 대시 속도로!
agent.acceleration = dashSmashSpeed * 4f; // 올릴거에요 -> 가속도를 (즉시 최고 속도)
agent.angularSpeed = 1000f; // 올릴거에요 -> 회전 속도를
agent.stoppingDistance = dashSmashArriveRange; // 설정할거에요 -> 스매시 전환 거리로
agent.isStopped = false; // 켤거에요 -> 이동 시작!
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
agent.SetDestination(_target.position); // 설정할거에요 -> 첫 번째 목적지를
}
// ── 돌진 애니 재생 ──────────────────────────────
bool hasDashAnim = animator != null && animator.HasState(0, Animator.StringToHash(anim_Dash)); // 확인할거에요 -> 대시 애니 존재 여부를
string comboDashAnimName = hasDashAnim ? anim_Dash : anim_Walk; // 결정할거에요 -> 재생할 애니를
if (animator != null) animator.Play(comboDashAnimName, 0, 0f); // 재생할거에요 -> 돌진 애니를
PlayBossSFX(sfx_Dash_Move, sfx_Dash_Move_Vol); // 재생할거에요 -> 콤보 돌진 이동 사운드를
PlayBossVFX(vfx_Dash_Trail); // 재생할거에요 -> 콤보 돌진 트레일 이펙트를 (루프)
// ── 거리 기반 최대 시간 계산 ────────────────────────
float distAtStart = (_target != null) // 계산할거에요 -> 시작 시점 거리를
? Vector3.Distance(transform.position, _target.position)
: 20f; // 타겟 없으면 기본 20m
float comboMaxTime = (distAtStart / dashSmashSpeed) + 2.0f; // 계산할거에요 -> 최대 돌진 시간을 (여유 2초, NavMesh 우회 고려)
comboMaxTime = Mathf.Clamp(comboMaxTime, 1f, 10f); // 제한할거에요 -> 최소 1초, 최대 10초
// ── 넉백 방향용 변수 ────────────────────────────────
Vector3 dashDir = transform.forward; // 저장할거에요 -> 돌진 방향을 (스매시 방향에 사용)
Debug.Log($"[Boss] v9 콤보 NavMesh 돌진! 속도={dashSmashSpeed}m/s 시작거리={distAtStart:F1}m 최대시간={comboMaxTime:F1}s 도착범위={dashSmashArriveRange}m"); // 로그를 찍을거에요
// ── 돌진 루프 — NavMesh 이동 + 스매시 도착 판정 ─────
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
bool arrivedInRange = false; // 초기화할거에요 -> 도착 여부를
float destUpdateTimer = 0f; // 초기화할거에요 -> 목적지 갱신 타이머를
while (elapsed < comboMaxTime) // 반복할거에요 -> 최대 시간까지
{
// ── 돌진 애니 강제 유지 ──────────────────────
if (animator != null && !animator.GetCurrentAnimatorStateInfo(0).IsName(comboDashAnimName)) // 확인할거에요 -> 애니 다른 거면
animator.Play(comboDashAnimName); // 강제 재생할거에요 -> 돌진 애니를
// ── [v9 핵심] NavMesh 목적지 주기적 갱신 ─────────
destUpdateTimer += Time.deltaTime; // 더할거에요 -> 갱신 타이머를
if (destUpdateTimer >= 0.2f && _target != null && agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> 0.2초마다
{
agent.SetDestination(_target.position); // 갱신할거에요 -> 목적지를
destUpdateTimer = 0f; // 초기화할거에요 -> 갱신 타이머를
}
// ── 스매시 사거리 도착 판정 ──────────────────────
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
// [Q-1 수정] Vector3.Distance → sqrMagnitude 비교 (매 프레임 sqrt 절감)
float sqrDistToPlayer = (_target.position - transform.position).sqrMagnitude; // 계산할거에요 -> 거리 제곱을 (sqrt 없이)
float arriveRangeSqr = dashSmashArriveRange * dashSmashArriveRange; // [Q-1] 비교 임계값 제곱 캐시
// ── 넉백 방향 갱신 ──────────────────────────
Vector3 toP = (_target.position - transform.position); // 계산할거에요 -> 플레이어 방향을
toP.y = 0f; // 무시할거에요 -> 수직 성분을
if (toP.sqrMagnitude > 0.001f) dashDir = toP.normalized; // 갱신할거에요 -> 방향을
if (sqrDistToPlayer <= arriveRangeSqr) // 조건이 맞으면 실행할거에요 -> 스매시 범위면 [Q-1: sqr비교]
{
float distToPlayer = Mathf.Sqrt(sqrDistToPlayer); // 계산할거에요 -> 로그용 실제 거리를 (조건 충족 시만 sqrt)
arrivedInRange = true; // 설정할거에요 -> 도착 성공
Debug.Log($"[Boss] v9 콤보 도착! 거리={distToPlayer:F2}m → 스매시 전환"); // 로그를 찍을거에요
break; // 탈출할거에요 -> 돌진 루프를
}
}
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
yield return null; // 대기할거에요 -> 다음 프레임까지
}
if (!arrivedInRange) // 조건이 맞으면 실행할거에요 -> 시간 초과면
Debug.Log($"[Boss] v9 콤보 시간 초과 ({comboMaxTime:F1}s) → 제자리 스매시"); // 로그를 찍을거에요
// ── 돌진 종료 — agent 속도 복원 ─────────────────────
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 위에 있으면
{
agent.isStopped = true; // 멈출거에요 -> 이동을
agent.velocity = Vector3.zero; // 제거할거에요 -> 잔여 관성을
agent.speed = savedSpeed; // 복원할거에요 -> 원래 이동 속도를
agent.acceleration = savedAccel; // 복원할거에요 -> 원래 가속도를
agent.angularSpeed = savedAngular; // 복원할거에요 -> 원래 회전 속도를
agent.stoppingDistance = savedStopDist; // 복원할거에요 -> 원래 정지 거리를
}
// ══════════════════════════════════════════════════════
// Phase 4 — 즉시 내려찍기 (Pattern_Smash 핵심 로직 재사용)
// 도착한 자리에서 바로 플레이어를 향해 회전 → 스매시 판정
// ══════════════════════════════════════════════════════
StopBossVFX(vfx_Dash_Trail); // 정지할거에요 -> 돌진 트레일을 (스매시 전환 시 루프 종료)
StopAgent(); // 멈플거에요 -> NavMesh를을 (내려찍는 동안 정지)
// ── 플레이어 방향으로 즉시 스냅 회전 ──────────────
// [수정] FaceTarget()은 Slerp라서 1프레임에 완전히 안 돌아가요.
// 대시 후 플레이어가 옆에 있으면 보스가 빈 곳을 찍는 버그의 원인이었어요.
// → LookRotation으로 즉시 스냅해서 스매시 판정이 정확히 플레이어를 향하게!
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toPlayer = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toPlayer.y = 0f; // 제거할거에요 -> 수직 성분을을 (수평만)
if (toPlayer.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 유효하면
transform.rotation = Quaternion.LookRotation(toPlayer.normalized); // 스냅할거에요 -> 플레이어 방향으로 즉시 회전
}
// ─ 내려찍기 애니 재생 ────────────────────────────
bool hasSmashAnim = animator != null && animator.HasState(0, Animator.StringToHash(anim_Smash)); // 확인할거에요 -> 스매시 애니 존재 여부를
if (hasSmashAnim) // 조건이 맞으면 실행할거에요 -> 있으면
PlayAnimDirect(anim_Smash); // 재생할거에요 -> 내려찍기 애니를
else // 없으면
Debug.LogWarning($"[Boss] Animator State \"{anim_Smash}\" 없음 → 타이머만으로 진행"); // 경고를 찍을거에요
yield return null; // 기다릴거에요 -> 1프레임 (애니 상태 전환 완료)
// ── 범위 표시: 원형 바닥 표시 (DashSmash 스매시 판정 범위) ──
if (attackIndicator != null) // 조건이 맞으면 실행할거에요 -> 표시기가 있으면
attackIndicator.ShowCircle(transform, smashRadius, smashForwardOffset, smashTiming); // 표시할거에요 -> 원형 범위를
// ─ 임팩트 타이밍 대기 ────────────────────────────
float clipLen = hasSmashAnim ? GetClipLength() : smashTiming + 0.5f; // 가져올거에요 -> 클립 길이를
bool smashImpactFired = false; // 초기화할거에요 -> 찍기 임팩트 플래그를
float smashElapsed = 0f; // 초기화할거에요 -> 스매시 경과 시간을
// ── 콤보 스매시 흡인 구간 계산 ──────────────────────
// [v6.2] DashSmash 흡인 복원 (유저 피드백: 50%는 너무 약함)
// 일반 Smash 대비 85% → 확실한 체감 + 약간의 억까 방지 여유
float comboSuctionStart = smashTiming * smashSuctionStartRatio; // 계산할거에요 -> 흡인 시작 시점을
float comboSuctionEnd = smashTiming * smashSuctionEndRatio; // 계산할거에요 -> 흡인 종료 시점을
float comboSuctionMult = 1.0f; // 설정할거에요 -> DashSmash 흡인 배율을 (일반 Smash와 동일 100%) [v6.3: 0.85→1.0 강화]
while (!smashImpactFired && smashElapsed < smashTiming) // 반복할거에요 -> 임팩트 타이밍까지
{
smashElapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
// ── 흡인 효과: DashSmash에서도 플레이어를 끌어당김 (85% 강도) ──
// [v6.2] 일반 Smash 대비 85% 힘 → 확실한 흡인 체감
if (smashElapsed >= comboSuctionStart && smashElapsed < comboSuctionEnd // 조건이 맞으면 실행할거에요 -> 흡인 구간이면
&& _target != null && _cachedPlayerMovement != null) // 그리고 플레이어가 유효하면
{
Vector3 comboSmashCenter = transform.position + transform.forward * smashForwardOffset; // 계산할거에요 -> 현재 공격 판정 중심을
float distToComboSmash = Vector3.Distance(_target.position, comboSmashCenter); // 계산할거에요 -> 플레이어~공격 중심 거리를
if (distToComboSmash <= smashSuctionRange && distToComboSmash > 0.5f) // 조건이 맞으면 실행할거에요 -> 흡인 범위 안이면
{
_cachedPlayerMovement.ApplySuction(comboSmashCenter, smashSuctionForce * comboSuctionMult); // 끌어당길거에요 -> 플레이어를 공격 중심으로 (85% 강도)
}
}
yield return null; // 기다릴거에요 -> 1프레임을
}
// ── 범위 표시 숨기기 (임팩트 발생 → 표시 종료) ─────
if (attackIndicator != null) attackIndicator.Hide(); // 숨길거에요 -> 바닥 범위 표시를
// ─ 찍기 판정 ────────────────────────────────────
// [C-2 수정] Pattern_Smash와 동일한 인라인 코드 중복 제거 → 공용 메서드로 통합
// 데미지·반경·넉백 수치 변경 시 ExecuteSmashHit() 한 곳만 수정하면 됨
PlayBossSFX(sfx_Smash_Impact, sfx_Smash_Impact_Vol); // 재생할거에요 -> 콤보 스매시 임팩트 사운드를 (쾅!)
PlayBossVFXAt(vfx_Smash_Impact, transform.position + transform.forward * smashForwardOffset); // 재생할거에요 -> 콤보 스매시 임팩트 이펙트를 (판정 중심 위치)
ExecuteSmashHit(); // 실행할거에요 -> 콤보 스매시 판정을 (Pattern_Smash와 동일한 공용 메서드)
Debug.Log($"[Boss] 💥 콤보 내려찍기 판정 완료! 반경={smashRadius}m 도착={arrivedInRange}"); // 로그를 찍을거에요
// ─ 나머지 애니 대기 ──────────────────────────────
float remaining = Mathf.Max(clipLen - smashElapsed - 0.05f, 0.3f); // 계산할거에요 -> 남은 애니 시간을을 (최소 0.3초 보장)
yield return new WaitForSeconds(remaining); // 기다릴거에요 -> 후속 모션 끝날 때까지
yield return StartCoroutine(Recover()); // 기다릴거에요 -> 경직 후 상태 복구를
}
// ══════════════════════════════════════════════════════════
// SYSTEM 3: 숨돌리기 (보스 휴식)
// ══════════════════════════════════════════════════════════
private IEnumerator BossRest() // 코루틴을 정의할거에요 -> 보스가 숨을 고르는 휴식 상태를
{
_isResting = true; // 설정할거에요 -> 휴식 중으로
SetState(BossState.Resting); // 전환할거에요 -> Resting 상태로
StopAgent(); // 멈플거에요 -> NavMesh를
PlayIdleAnim(); // 재생할거에요 -> 대기 애니를
PlayBossSFX(sfx_Rest, sfx_Rest_Vol); // 재생할거에요 -> 숨돌리기 사운드를 (보스 숨 고르는 소리)
// ── [버그 수정] 휴식 진입 시 즉시 플레이어 방향 스냅 ──
// 원인: Recover()에서 BossRest 체크가 FaceTarget(instant:true)보다 먼저 실행됨
// → Throw/Dash 애니 끝난 자세(옆/뒤 방향)로 그대로 BossRest 진입
// → 뒤로 물러나는 동안 Slerp가 느려서 등을 돌린 채 빙글 도는 현상
// 해결: 휴식 시작 즉시 스냅 회전으로 플레이어를 바라보게 강제 보정
FaceTarget(instant: true); // 스냅할거에요 -> 플레이어 방향으로 즉시 (Throw 등 이전 애니 자세 보정)
if (counterSystem != null) counterSystem.ResetAttackCount(); // 초기화할거에요 -> 카운터 시스템의 공격 카운트를
// Phase에 따라 실제 휴식 시간 계산 (배율 적용 + 랜덤 분산)
float actualRest = (counterSystem != null) // 계산할거에요 -> 실제 휴식 시간을
? counterSystem.GetRestDuration(_isPhase2, _isPhase3) // 가져올거에요 -> 카운터 시스템에서 Phase별 계산된 값을
: 2.0f; // 기본값을 사용할거에요 -> 카운터 시스템 없으면 2초를
Debug.Log($"[Boss] 숨돌리기 시작 ({actualRest:F1}초)"); // 로그를 찍을거에요
// 보스가 천천히 뒤로 물러나는 연출 (선택사항)
float backStepTime = Mathf.Min(actualRest * 0.3f, 0.8f); // 계산할거에요 -> 뒤로 물러나는 시간을을 (휴식 시간의 30%, 최대 0.8초)
float backStepElapsed = 0f; // 초기화할거에요 -> 경과 시간을
Vector3 backDir = -transform.forward; // 계산할거에요 -> 보스의 뒤 방향을을 (뒤로 이동하기 위해)
// ── [버그 수정] NavMesh 자동회전 비활성화 ─────────────
// 문제: agent.updateRotation=true + FaceTarget() 가 동시에 transform.rotation을 건드림
// → 매 프레임 서로 싸워서 보스가 이상하게 느리게 회전하며 멈춰있는 것처럼 보임
// 수정: 뒤로 물러나는 동안 NavMesh 자동회전을 끄고 FaceTarget()만 회전 담당
if (agent != null) agent.updateRotation = false; // 끌거에요 -> NavMesh 자동회전을 (FaceTarget이 직접 담당)
// 뒤로 물러나기 움직임
while (backStepElapsed < backStepTime) // 반복할거에요 -> backStepTime이 지날 때까지
{
backStepElapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
// NavMesh로 약간 뒤로 이동 (선택적 연출)
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 정상이면
{
agent.isStopped = false; // 켤거에요 -> 이동을
agent.SetDestination(transform.position + backDir * 3f); // 설정할거에요 -> 뒤로 3m 떨어진 목적지를
}
FaceTarget(); // 바라볼거에요 -> 플레이어를을 (뒤로 물러나도 계속 보며 경계, updateRotation=false라 충돌 없음)
yield return null; // 기다릴거에요 -> 1프레임을
}
StopAgent(); // 멈플거에요 -> NavMesh를을 (뒤로 이동 종료)
// ── [버그 수정 v2] 경로 초기화 + updateRotation 복원 타이밍 조정 ──────
// [이전 버그] StopAgent() 후에도 에이전트에는 backDir 목적지(플레이어 반대 방향)가 남아있음
// → updateRotation=true 복원하면 에이전트가 그 방향(등 쪽)으로 자동 회전
// → Monster_Idle 재생 중 보스가 뒤를 돌아보는 현상 발생
// [수정 ①] ResetPath()로 저장된 경로를 즉시 삭제 → 자동 회전 대상 없애기
// [수정 ②] updateRotation=true 복원을 대기 후로 이동 → 대기 중 자동 회전 원천 차단
if (agent != null && agent.isOnNavMesh) agent.ResetPath(); // 초기화할거에요 -> 저장된 경로를 (backDir 목적지 제거, 자동 회전 대상 없애기)
// 나머지 시간은 그냥 서서 대기 (updateRotation=false 유지 → 대기 중 자동 회전 없음)
yield return new WaitForSeconds(Mathf.Max(actualRest - backStepTime, 0f)); // 기다릴거에요 -> 남은 휴식 시간을 (C-5: Mathf.Max로 음수 방지)
// ── [버그 수정 v2] updateRotation 복원은 이동 재개 직전에 ────────────
// 대기가 모두 끝난 뒤 복원해야 대기 중 등을 돌아보는 현상이 완전히 차단됨
if (agent != null) agent.updateRotation = true; // 켤거에요 -> NavMesh 자동회전을 다시 (이동 재개 직전, 대기 완료 후)
_isResting = false; // 설정할거에요 -> 휴식 종료로
Debug.Log("[Boss] 숨돌리기 끝 → 전투 재개"); // 로그를 찍을거에요
SetState(BossState.Chase); // 전환할거에요 -> Chase (추격) 상태로
StartAgent(); // 켤거에요 -> NavMesh를을 (전투 재개)
}
// ══════════════════════════════════════════════════════════
// 쇠공 줍기
// ══════════════════════════════════════════════════════════
private IEnumerator PickupBall() // 코루틴을 정의할거에요 -> 줍기 애니와 동시에 공이 손을 따라 움직이게 하는
{
// ── 사전 검증 ─────────────────────────────────────────
if (ironBall == null || handHolder == null) // 조건이 맞으면 실행할거에요 -> 연결 안 됐으면
{
Debug.LogError("[Boss] PickupBall: ironBall 또는 handHolder가 null이에요!"); // 에러를 찍을거에요
SetState(BossState.Chase); // 전환할거에요 -> 강제 복구
StartAgent(); // 켤거에요 -> NavMesh를
yield break; // 중단할거에요
}
StopAgent(); // 멈플거에요 -> NavMesh를을 (줍기 중 이동 정지)
FaceTarget(ironBall.transform.position); // 바라볼거에요 -> 쇠공 방향으로 회전
// ── 줍기 애니 재생 ────────────────────────────────────
PlayAnimDirect(anim_Pickup); // 재생할거에요 -> 줍기 애니를
yield return null; // 기다릴거에요 -> 1프레임 (애니 상태 전환 완료 대기)
// ── pickupGrabDelay: 손이 공에 닿는 프레임까지 대기 ──
//
// [문제]
// BeginPickup을 너무 일찍 호출하면 손이 아직 위에 있는데 공이 먼저 손으로 올라와요.
// → 애니보다 공이 앞서 보여 부자연스러움
//
// [해결]
// Inspector의 pickupGrabDelay 초만큼 기다린 뒤 BeginPickup을 호출해요.
// → 줍기 애니에서 손이 공에 가장 가까워지는 타이밍에 맞게 조정하면
// 손이 닿는 순간 공이 손에 빨려들어가는 자연스러운 연출이 완성돼요.
//
// [조정 방법]
// 1) Unity에서 줍기 애니 클립을 Animation 윈도우에서 열어요
// 2) 손이 공에 가장 가까워지는 시간(초)을 확인해요
// 3) 그 값을 Inspector > pickupGrabDelay 에 입력하면 끝이에요
//
// ⚠️ 이 대기 시간도 maxWait에 포함되어야 타임아웃이 올바르게 동작해요.
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을을 (딜레이 + 애니 종료 대기 통합 카운터)
float maxWait = 8f; // 설정할거에요 -> 최대 대기 시간을을 (타임아웃 방지)
// pickupGrabDelay 동안은 공을 바닥에 그대로 두고 애니만 재생해요
while (elapsed < pickupGrabDelay) // 반복할거에요 -> 손이 공에 닿는 타이밍까지
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
yield return null; // 기다릴거에요 -> 1프레임을을 (이 동안 공은 바닥에 그대로)
}
// ── BeginPickup: 손이 공에 닿는 순간부터 부착 시작 ───
//
// [새 방식: BeginPickup]
// 1) 공을 handHolder의 자식으로 설정 (월드 위치는 현재 위치 그대로 유지)
// 2) BossIronBall.LateUpdate에서 매 프레임 localPosition → holdOffset 으로 lerp
// 3) 손이 이미 공 근처에 있는 상태이므로 짧은 lerp로 자연스럽게 손에 안착
ironBall.BeginPickup(handHolder); // 시작할거에요 -> 줍기 모션을을 (손이 공 근처에 있는 타이밍에 호출)
// ── 애니메이션 종료 대기 ───────────────────────────────
// OnAnimEnd 이벤트 수신 또는 maxWait 초 경과까지 대기해요.
// elapsed는 이미 pickupGrabDelay만큼 카운트됐으므로 타임아웃 계산에 자동 반영돼요.
// 이 대기 동안 BossIronBall.LateUpdate가 계속 lerp를 실행하고 있어요.
while (!_animEndFired && elapsed < maxWait) // 반복할거에요 -> 애니 종료 이벤트 또는 타임아웃까지
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
yield return null; // 기다릴거에요 -> 1프레임을
}
// ── 폴백: 애니 종료 후에도 아직 Held가 아니면 강제 완료 ──
// lerp 속도가 너무 낮거나 holdOffset이 너무 멀 경우 대비 안전장치예요.
if (ironBall.State != BossIronBall.BallState.Held) // 조건이 맞으면 실행할거에요 -> 아직 Held 상태가 아니면
{
ironBall.AttachToHand(handHolder); // 붙일거에요 -> 강제로 Held 상태로 전환 (폴백)
Debug.LogWarning($"[Boss] ⚠ 줍기 폴백 실행 (elapsed={elapsed:F2}s state={ironBall.State})\n" +
"BossIronBall Inspector의 pickupLerpSpeed를 높이거나 holdOffset을 줄여보세요."); // 경고를 찍을거에요
}
_animEndFired = false; // 초기화할거에요 -> 플래그를을 (다음 애니 이벤트를 위해)
Debug.Log($"[Boss] ✅ 줍기 코루틴 완료 (elapsed={elapsed:F2}s)"); // 로그를 찍을거에요
SetState(BossState.Chase); // 전환할거에요 -> 추격으로 (다시 공격 시작)
StartAgent(); // 켤거에요 -> NavMesh를
}
// ══════════════════════════════════════════════════════════
// 경직 복구
// ══════════════════════════════════════════════════════════
private IEnumerator Recover() // 코루틴을 정의할거에요 -> 공격 후 경직 → 상태 복구를
{
SetState(BossState.Recovering); // 설정할거에요 -> 경직 상태로
PlayIdleAnim(); // 재생할거에요 -> 대기 애니를
// ── 공격 예산 체크 → counterSystem에 위임 ──────────
if (counterSystem != null && counterSystem.RecordAttackAndCheckRest(_isPhase2, _isPhase3)) // 조건이 맞으면 실행할거에요 -> 카운터 시스템이 휴식 필요 판단하면
{
Debug.Log("[Boss] 공격 예산 소진 → 숨돌리기!"); // 로그를 찍을거에요
_comboCounter = 0; // 초기화할거에요 -> 콤보 카운터를
StartCoroutine(BossRest()); // 시작할거에요 -> 휴식 코루틴을
yield break; // 중단할거에요 -> Recover를
}
// ── [회피 반응] → counterSystem에 위임 ──────────────
if (counterSystem != null && counterSystem.ShouldDodgeReact()) // 조건이 맞으면 실행할거에요 -> 카운터 시스템이 회피 반응 판단하면
{
Debug.Log("[Boss] 회피 반응! 빠른 추가 공격"); // 로그를 찍을거에요
yield return new WaitForSeconds(0.15f); // 기다릴거에요 -> 짧은 숨 고르기를
float dist = GetDist(); // 계산할거에요 -> 플레이어 거리를
SelectAndFire(dist); // 실행할거에요 -> 빠른 추가 공격을
yield break; // 중단할거에요 -> Recover를
}
// ── 콤보 체인 판정 ─────────────────────────────────
// Phase에 따라 콤보 확률이 달라져요.
// 콤보 성공 시 쿨타임(PreAttack) 없이 바로 다음 패턴 실행!
float currentComboChance = (phaseManager != null) ? phaseManager.GetComboChance() : 0f; // 가져올거에요 -> 현재 Phase의 콤보 확률을 (phaseManager에서)
int maxCombo = (phaseManager != null) ? phaseManager.MaxComboCount : 3; // 가져올거에요 -> 최대 콤보 횟수를
float comboDly = (phaseManager != null) ? phaseManager.ComboDelay : 0.2f; // 가져올거에요 -> 콤보 딜레이를
if (_comboCounter < maxCombo && Random.value < currentComboChance) // 판단할거에요 -> 콤보 발동 여부를
{
// 콤보 발동! 짧은 딜레이만 주고 바로 다음 패턴
_comboCounter++; // 증가시킬거에요 -> 연속 콤보 카운터를
Debug.Log($"[Boss] ⚡ 콤보 체인 #{_comboCounter}! (확률={currentComboChance:F0%})"); // 로그를 찍을거에요
yield return new WaitForSeconds(comboDly); // 기다릴거에요 -> 콤보 딜레이를 (숨 고르기)
float dist = GetDist(); // 계산할거에요 -> 플레이어 거리를
StopAgent(); // 멈플거에요 -> NavMesh를을 (패턴 실행 전)
SelectAndFire(dist); // 실행할거에요 -> 다음 패턴을을 즉시 (쿨타임 스킵)
yield break; // 종료할거에요 -> Recover를을 (PreAttack으로 가지 않음)
}
// ── 콤보 불발 → 기존 경직 로직 ────────────────────
_comboCounter = 0; // 초기화할거에요 -> 콤보 카운터를 (연속 끊김)
// Phase별 경직 시간 배율 적용 (Phase3 → 65% 단축, Phase2 → 40% 단축)
float baseRecover = (phaseManager != null) ? phaseManager.GetRecoverTime() : 0.5f; // 가져올거에요 -> 기본 경직 시간을
float recoverMult = (phaseManager != null) ? phaseManager.GetRecoverMult() : 1f; // 가져올거에요 -> Phase별 경직 배율을
float actualRecover = (baseRecover + Random.Range(-recoverVariance, recoverVariance)) * recoverMult; // 계산할거에요 -> 실제 경직 시간을 (±분산 적용 후 배율)
// [v4 수정] 원칙 7, 11 — Phase3에서도 최소 0.3초 경직 보장
// 이전: 0.05초 최소 → Phase3에서 0.175초 → 플레이어가 반격할 틈 없음
// 수정: 0.3초 최소 → 플레이어가 최소 1회 공격 가능 (실패 원인 학습 + 반격 기회)
actualRecover = Mathf.Max(actualRecover, 0.3f); // 보장할거에요 -> 최소 0.3초를 (플레이어 반격 기회 확보)
yield return new WaitForSeconds(actualRecover); // 기다릴거에요 -> Phase별 경직 시간을
// 쇠공 착지 상태이면 회수로, 아니면 거리에 따라 분기
if (ironBall != null && ironBall.IsLanded()) // 조건이 맞으면 실행할거에요 -> 쇠공 착지면
SetState(BossState.Retrieving); // 전환할거에요 -> 회수로
else
{
// [v6 수정] 뒤로 회전 버그 근본 수정
FaceTarget(instant: true); // 스냅할거에요 -> 플레이어를 즉시 바라보게
// ── [v8] 거리에 따라 Chase/PreAttack 분기 ──────────
// 기존: 무조건 PreAttack → dist > throwRange → Chase → 쿨다운 대기 → 대시
// → 이 경로가 너무 길어서 보스가 "이상한 곳 돌아다니기" 발생
// v8: 플레이어가 멀면 바로 Chase → 다음 프레임에 즉시 대시 체크!
float recoverDist = GetDist(); // 계산할거에요 -> 현재 거리를
if (recoverDist > throwRange) // 조건이 맞으면 실행할거에요 -> 사거리 밖이면 (플레이어가 멀리 도망간 상태)
{
SetState(BossState.Chase); // 전환할거에요 -> 바로 추격으로! (PreAttack 건너뜀)
Debug.Log($"[Boss] v8 Recover→Chase 직행! dist={recoverDist:F1}m > throwRange"); // 로그를 찍을거에요
}
else // 사거리 안이면
{
SetState(BossState.PreAttack); // 전환할거에요 -> 공격 대기로 (기존과 동일)
}
// [v9] NavMesh 에이전트 복구 — agent를 끄지 않으므로 간결해짐
if (agent != null && agent.isOnNavMesh) // 조건이 맞으면 실행할거에요 -> NavMesh 위면
{
agent.isStopped = false; // 켤거에요 -> 이동을
agent.updateRotation = true; // 켤거에요 -> 자동 회전을
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
agent.SetDestination(_target.position); // 갱신할거에요 -> 목적지를 플레이어 위치로
}
}
}
}