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() // 가져올거에요 -> 인터페이스를 ?? hit.GetComponentInParent(); // 또는 부모에서 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를 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() // 가져올거에요 -> 인터페이스를 ?? hit.GetComponentInParent(); // 또는 부모에서 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를 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() // 가져올거에요 -> 인터페이스를 ?? hit.GetComponentInParent(); // 또는 부모에서 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를 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() // 가져올거에요 -> 인터페이스를 ?? _target.GetComponentInParent(); // 또는 부모에서 if (d != null) d.TakeDamage(finalDmg); // 줄거에요 -> 대시 충돌 데미지를 Debug.Log($"[Boss] v9 대시 충돌! 데미지={finalDmg:F0} 거리={distToPlayer:F2}m"); // 로그를 찍을거에요 if (dashKnockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 설정 있으면 { PlayerMovement pm = _target.root.GetComponent(); // 가져올거에요 -> 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); // 갱신할거에요 -> 목적지를 플레이어 위치로 } } } }