1346 lines
99 KiB
C#
1346 lines
99 KiB
C#
|
|
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); // 갱신할거에요 -> 목적지를 플레이어 위치로
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|