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

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

326 lines
22 KiB
C#

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System.Collections; // 코루틴 기능을 불러올거에요 -> IEnumerator를
// ============================================================
// NorcielBoss.FSM.cs — FSM 상태 핸들러 (partial class)
//
// [역할]
// ① RunFSM — 현재 상태에 따라 적절한 핸들러 호출
// ② FSM_Chase — NavMesh 추격 + 카이팅/도망 감지 → 대시
// ③ FSM_PreAttack — 사거리 안 쿨타임 대기 + 뒤잡기 감지 + 패턴 실행
// ④ FSM_Retrieving — 쇠공 회수 이동 + 줍기 트리거
// ⑤ SetState — FSM 상태 전환 + 로그
//
// [설계]
// partial class로 NorcielBoss의 private 필드에 직접 접근.
// Update()에서 RunFSM() 한 줄로 호출됩니다.
// ============================================================
public partial class NorcielBoss : MonsterClass // 부분 클래스를 선언할거에요 -> NorcielBoss의 FSM 부분으로
{
// ══════════════════════════════════════════════════════════
// FSM 메인 디스패처
// ══════════════════════════════════════════════════════════
/// <summary>현재 FSM 상태에 따라 적절한 핸들러를 호출</summary>
private void RunFSM() // 함수를 선언할거에요 -> 현재 FSM 상태에 따라 행동을 처리하는
{
if (!_isBattleStarted || _target == null) return; // 중단할거에요 -> 전투 전이거나 타겟 없으면
switch (_state) // 분기할거에요 -> 현재 상태에 따라
{
case BossState.Chase: FSM_Chase(); break; // 실행할거에요 -> 추격 로직을
case BossState.PreAttack: FSM_PreAttack(); break; // 실행할거에요 -> 공격 대기 로직을
case BossState.Resting: /* 아무것도 안 함 */ break; // 코루틴이 처리하므로 -> 휴식 중 아무것도 하지 않음
case BossState.Retrieving: FSM_Retrieving(); break; // 실행할거에요 -> 쇠공 회수 로직을
// Idle / Attacking / Recovering / Dead → 코루틴이 처리하므로 여기서 아무것도 안 함
}
}
// ══════════════════════════════════════════════════════════
// Chase: NavMesh로 플레이어 추격
//
// [우선순위]
// 1. 쇠공 착지 → 회수로 전환
// 2. 카이팅 감지 → 대시 갭클로저
// 3. 공격 사거리 진입 → PreAttack
// 4. 원거리 도망 감지 → 대시 추격
// 5. 일반 NavMesh 추격
// ══════════════════════════════════════════════════════════
private void FSM_Chase() // 함수를 선언할거에요 -> 추격 상태 처리를
{
if (_isResting) return; // 중단할거에요 -> 휴식 중이면 (휴식 코루틴이 처리)
// ── [v6.5] NavMesh 에이전트 안전 복구 ─────────────────
// ChargeMonster 방식: 에이전트가 꺼져있으면 강제로 켜기
// 대시 후 복구가 불완전했을 때를 대비한 안전장치
if (agent != null && !agent.enabled) // 조건이 맞으면 실행할거에요 -> 에이전트가 꺼져있으면
{
agent.enabled = true; // 켤거에요 -> NavMesh 에이전트를 강제로 (대시 후 미복구 대비)
Debug.LogWarning("[Boss] FSM_Chase: agent.enabled=false 감지 → 강제 복구!"); // 경고를 찍을거에요
}
// ── [v6.5] Rigidbody 안전 복구 ─────────────────
// Chase 상태에서는 반드시 Kinematic이어야 해요 (NavMesh 이동 모드)
// 대시 후 isKinematic=false가 남아있으면 미끄러짐 발생
if (_rb != null && !_rb.isKinematic) // 조건이 맞으면 실행할거에요 -> 물리 모드가 살아있으면
{
_rb.velocity = Vector3.zero; // 멈출거에요 -> 잔여 속도를
_rb.isKinematic = true; // 끌거에요 -> 물리 연산을 강제로
Debug.LogWarning("[Boss] FSM_Chase: isKinematic=false 감지 → 강제 복구!"); // 경고를 찍을거에요
}
if (!CheckNavMeshReady()) return; // 중단할거에요 -> NavMesh 비정상이면
float dist = GetDist(); // 가져올거에요 -> 거리를
// ── 1. 쇠공 착지 상태 → 회수 우선 ──────────────
if (ironBall != null && ironBall.IsLanded()) // 조건이 맞으면 실행할거에요 -> 쇠공 착지 상태면
{
StopAgent(); // 멈출거에요
SetState(BossState.Retrieving); // 전환할거에요 -> 회수로
return; // 중단할거에요
}
// ── 2. 공격 사거리 진입 → PreAttack ──────────────
if (dist <= throwRange) // 조건이 맞으면 실행할거에요 -> 사거리 안이면
{
StopAgent(); // 멈출거에요
SetState(BossState.PreAttack); // 전환할거에요 -> 공격 대기로
return; // 중단할거에요
}
// ══════════════════════════════════════════════════════
// 3. [v8] 거리 기반 즉시 대시 추격
//
// [v8 재설계] 기존 문제:
// - dashCooldownTime=4초 동안 NavMesh 걷기 → 보스가 이상한 곳 돌아다님
// - 대시 → Recover → PreAttack → Chase 돌아올 때 쿨다운 남아있음
// - 4초 걷기 + 대시 + 4초 걷기 → 지루한 반복
//
// v8 변경: 플레이어가 멀면 쿨다운 무시하고 즉시 대시!
// - dist >= dashChaseRange(12m) → 무조건 즉시 대시 (쿨다운 체크 안 함)
// - dist < dashChaseRange → 쿨다운 적용 (근거리에서는 대시 남발 방지)
// → 멀리 도망가면 보스가 즉시 달려옴 = 지루함 해소
// → 가까이서는 적절한 간격 유지 = 게임성 보존
// ══════════════════════════════════════════════════════
// ── [v8] 원거리: 즉시 대시 (쿨다운 무시!) ──────────────
// 플레이어가 dashChaseRange 이상 멀면 → 걷기 없이 바로 대시
// Recover 끝나고 돌아와도 즉시 다시 대시 → "이상한 곳 돌아다니기" 원천 차단
bool farEnough = dist >= dashChaseRange; // 판단할거에요 -> 대시 거리 이상인지를
bool cooldownReady = _dashCooldownTimer <= 0f; // 판단할거에요 -> 쿨다운 끝났는지를
bool shouldDash = farEnough || (dist >= meleeRange && cooldownReady); // 판단할거에요 -> 대시 발동 여부를 (멀면 무조건 / 중거리면 쿨다운 후)
if (shouldDash && dist >= meleeRange) // 조건이 맞으면 실행할거에요 -> 대시 발동이면
{
_dashCooldownTimer = dashCooldownTime; // 설정할거에요 -> 쿨다운을 (근거리 복귀 시 남발 방지용)
_kitingTimer = 0f; // 초기화할거에요 -> 카이팅 타이머를
Debug.Log($"[Boss] v8 대시 추격! dist={dist:F1}m 원거리즉시={farEnough} 쿨다운통과={cooldownReady}"); // 로그를 찍을거에요
// Phase/상황에 따라 DashSmash 또는 Dash 선택
float dsmashRoll = Random.value; // 굴릴거에요 -> 확률 주사위를
float dsmashThreshold = (phaseManager != null) // 계산할거에요 -> DashSmash 확률을
? phaseManager.GetDashSmashChance(dashSmashChance) // phaseManager에서 Phase별 확률
: dashSmashChance; // 기본값
if (dsmashRoll < Mathf.Clamp01(dsmashThreshold)) // 조건이 맞으면 실행할거에요 -> DashSmash 당첨이면
{
Debug.Log("[Boss] → DashSmash로 추격!"); // 로그를 찍을거에요
StartCoroutine(Pattern_DashSmash()); // 실행할거에요 -> 대시+내려찍기를
}
else // 아니면
{
Debug.Log("[Boss] → Dash로 추격!"); // 로그를 찍을거에요
StartCoroutine(Pattern_Dash()); // 실행할거에요 -> 대시 돌진을
}
return; // 중단할거에요 -> FSM_Chase를
}
// ── 4. 일반 NavMesh 추격 ────────────────────────────
// 근접 사거리 안이거나, 대시 조건 미충족 시 걸어서 추격
// ── [버그 수정] NavMesh 자동회전 비활성화 ─────────────
// 원인: agent.updateRotation=true + FaceTarget() 동시 실행
// → NavMesh 경로가 곡선일 때 agent가 옆/뒤를 바라보며 회전
// → FaceTarget()이 플레이어 방향으로 Slerp 충돌 → 빙빙 도는 현상
// 수정: updateRotation=false로 NavMesh 자동회전 차단 → FaceTarget()만 회전 담당
if (agent != null) agent.updateRotation = false; // 끌거에요 -> NavMesh 자동회전을 (FaceTarget이 회전 전담)
agent.isStopped = false; // 켤거에요 -> 이동을
agent.SetDestination(_target.position); // 설정할거에요 -> 플레이어 위치로
FaceTarget(); // 바라볼거에요 -> 플레이어를 (updateRotation=false이므로 이것만 회전 담당)
PlayMoveAnim(true); // 재생할거에요 -> 걷기 애니를
}
// ══════════════════════════════════════════════════════════
// PreAttack: 사거리 안, 쿨타임 대기 후 패턴 실행
//
// [우선순위]
// 1. 쇠공 착지 → 회수
// 2. 사거리 이탈 → 추격 복귀
// 3. 카이팅 긴급 대응 → 대시 갭클로저
// 4. 뒤잡기 감지 → 긴급 Sweep
// 5. 쿨타임 대기 중 접근 이동
// 6. 쿨타임 만료 → SelectAndFire
// ══════════════════════════════════════════════════════════
private void FSM_PreAttack() // 함수를 선언할거에요 -> 공격 대기 상태 처리를
{
if (_isResting) return; // 중단할거에요 -> 휴식 중이면 (휴식 코루틴이 처리)
float dist = GetDist(); // 가져올거에요 -> 거리를
// ── 1. 쇠공 착지 상태 → 회수 우선 ──────────────
if (ironBall != null && ironBall.IsLanded()) // 조건이 맞으면 실행할거에요 -> 쇠공 착지면
{
SetState(BossState.Retrieving); // 전환할거에요 -> 회수로
return; // 중단할거에요
}
// ── 2. 플레이어가 사거리 밖으로 나가면 → 다시 추격 ──
if (dist > throwRange) // 조건이 맞으면 실행할거에요 -> 사거리 밖이면
{
StartAgent(); // 켤거에요 -> NavMesh를
SetState(BossState.Chase); // 전환할거에요 -> 추격으로
return; // 중단할거에요
}
// ── 3. [카이팅 긴급 대응] ───────────────────────
// [v6 수정] 직접 대시 발동 → 쿨타임 빠르게 소모로 변경 (이슈5: 대시 빈도 과다)
// v5 문제: PreAttack에서 직접 Pattern_Dash/DashSmash 호출 → 대시 트리거가 5개나 되어 체감 빈도 과다
// v6 수정: 카이팅 감지 시 _attackTimer를 빠르게 단축 → SelectAndFire에서 자연스럽게 Dash 선택되게 유도
// → 대시 발동 경로 1개 제거 (5개→4개, Throw후 제거 포함하면 3개)
// → SelectAndFire의 가중치 시스템이 카이팅 상황에서 알아서 Dash 가중치를 높여줌
bool preAttackKiting = _kitingTimer >= kitingDetectTime && dist > meleeRange; // 판단할거에요 -> PreAttack 카이팅 발동 조건을 (v6: 대시쿨다운 체크 제거 — 직접 대시 안 하니까)
if (preAttackKiting) // 조건이 맞으면 실행할거에요 -> 카이팅 감지면
{
Debug.Log($"[Boss] PreAttack 카이팅 감지 → 쿨타임 단축! (dist={dist:F1}m, _attackTimer → 0.3s로 단축)"); // 로그를 찍을거에요
_attackTimer = Mathf.Min(_attackTimer, 0.3f); // 단축할거에요 -> 공격 쿨타임을 최대 0.3초로 (빠르게 SelectAndFire 도달)
_kitingTimer = 0f; // 초기화할거에요 -> 카이팅 타이머를 리셋 (중복 감지 방지)
// v6: 직접 대시 발동 안 함 → SelectAndFire가 카이팅 상황에서 Dash/DashSmash 가중치를 올려줌
}
// ── 4. [반응형] 뒤잡기 감지 → 긴급 Sweep ──────────
if (counterSystem != null && counterSystem.ShouldReactToBehind() && _state == BossState.PreAttack) // 조건이 맞으면 실행할거에요 -> 뒤잡기 반응 필요하면
{
counterSystem.ResetBehindTimer(); // 초기화할거에요 -> 뒤잡기 타이머를
Debug.Log("[Boss] 뒤잡기 감지 → 긴급 Sweep!"); // 로그를 찍을거에요
StartCoroutine(Pattern_Sweep()); // 시작할거에요 -> 긴급 휩쓸기를
return; // 중단할거에요 -> PreAttack을
}
FaceTarget(); // 바라볼거에요 -> 플레이어를
_attackTimer -= Time.deltaTime; // 줄일거에요 -> 쿨타임을
// ── 5. 쿨타임 대기 중 이동 판단 ─────────────────
// [v5 개선] 기본 정지 거리를 Smash 실제 타격 범위 기준으로 수정
//
// 기존 문제 (v3): midStopRange = 6.75m → Smash 범위(5.5m) 밖
// → 보스가 6.75m에서 멈추고 Smash/Sweep 가중치 0 → Dash/DashSmash만 선택 → 반복
// → "원거리에서 제자리 공격" 현상 발생
//
// 수정: 기본 정지 거리를 actualSmashRange(5.5m) 이내로 설정
// → 보스가 Smash 타격 범위 안에서 멈추므로 근접 패턴 선택 가능
// → 인스펙터에서 smashRadius/smashForwardOffset 조정 시 자동 반영
if (_attackTimer > 0f) // 조건이 맞으면 실행할거에요 -> 아직 쿨타임 중이면
{
// [v5] actualSmashRange 기반 정지 거리 — Smash가 닿는 거리에서 멈추게
float actualSmashRange = smashForwardOffset + smashRadius + 1f; // 계산할거에요 -> Smash 실제 타격 최대 거리를 (1.5+3+1=5.5m)
float midStopRange = Mathf.Min(actualSmashRange - 0.5f, (meleeRange + throwRange) * 0.5f); // 계산할거에요 -> 정지 거리를 (v5: Smash 범위 안으로, 기존 중간 거리보다 가까이)
// [v5] 플레이어가 정지 + 피격 OR 쿨타임 중 접근 필요 → 근접까지 접근
bool playerCamping = _playerStillTimer >= stillPunishTime && _recentHitCount >= 1; // 판단할거에요 -> 플레이어 캠핑 여부를 (정지 + 보스 피격 중)
float approachTarget = playerCamping ? meleeRange : midStopRange; // 결정할거에요 -> 접근 목표를 (캠핑이면 근접, 아니면 Smash 범위 내)
if (dist <= meleeRange) // 조건이 맞으면 실행할거에요 -> 이미 근접 사거리 안이면
{
StopAgent(); // 멈출거에요 -> NavMesh를
PlayMoveAnim(false); // 재생할거에요 -> 대기 애니를
}
else if (dist > approachTarget) // 조건이 맞으면 실행할거에요 -> 목표 거리보다 멀면
{
if (CheckNavMeshReady()) // 조건이 맞으면 실행할거에요 -> NavMesh 정상이면
{
// ── [버그 수정] PreAttack 이동 중 자동회전 차단 ──
// Chase와 동일한 원인: NavMesh 곡선 경로 → 자동회전 충돌 → 빙빙 돔
agent.updateRotation = false; // 끌거에요 -> NavMesh 자동회전을 (FaceTarget이 회전 전담)
agent.isStopped = false; // 켤거에요 -> 이동을
Vector3 dirToPlayer = (_target.position - transform.position).normalized; // 계산할거에요 -> 플레이어 방향을
Vector3 stopDest = _target.position - dirToPlayer * approachTarget; // 계산할거에요 -> 목표 지점을 (캠핑: 근접 / 일반: 중거리)
agent.SetDestination(stopDest); // 설정할거에요 -> 목표 지점으로 이동
}
PlayMoveAnim(true); // 재생할거에요 -> 걷기 애니를
}
else // 목표 거리 이내에 도착했으면
{
StopAgent(); // 멈출거에요 -> NavMesh를
PlayMoveAnim(false); // 재생할거에요 -> 대기 애니를
}
return; // 중단할거에요 -> 쿨타임 끝날 때까지
}
// ── 6. 쿨타임 만료 → 현재 dist 기반 패턴 실행 ──────
StopAgent(); // 멈출거에요 -> NavMesh를 (패턴 실행 전 정지)
PlayMoveAnim(false); // 재생할거에요 -> 대기 애니를
_attackTimer = patternInterval + Random.Range(-intervalVariance, intervalVariance); // 재설정할거에요 -> 쿨타임을 (±분산 적용)
SelectAndFire(dist); // 실행할거에요 -> 현재 dist 기반 패턴 선택 + 실행
}
// ══════════════════════════════════════════════════════════
// Retrieving: 쇠공 위치로 이동 후 줍기
// ══════════════════════════════════════════════════════════
private void FSM_Retrieving() // 함수를 선언할거에요 -> 쇠공 회수 상태 처리를
{
// [D-5 수정] Retrieving 진입 시 updateRotation 명시적 복원
// 이전 패턴(Dash/BossRest 등)에서 false로 설정된 채 남아있을 수 있음
// → true로 되돌려야 FaceTarget(ironBall)이 정상 회전을 가져감
if (agent != null) agent.updateRotation = true; // 복원할거에요 -> NavMesh 자동회전을 (이전 패턴에서 꺼진 채 남아있을 수 있음)
if (ironBall == null || !ironBall.IsLanded()) // 조건이 맞으면 실행할거에요 -> 쇠공 없거나 착지 안 됐으면
{
SetState(BossState.PreAttack); // 전환할거에요 -> 공격 대기로
return; // 중단할거에요
}
// ── 도달 불가능 높이 체크 ──────────────────────────
float ballHeightDiff = ironBall.transform.position.y - transform.position.y; // 계산할거에요 -> 높이 차이를
if (ballHeightDiff > 3f) // 조건이 맞으면 실행할거에요 -> 3m 이상 위면
{
Debug.LogWarning($"[Boss] 쇠공이 도달 불가능한 높이에요! (높이차={ballHeightDiff:F1}m) → 강제 회수!"); // 경고를 찍을거에요
ironBall.ForceReturn(handHolder); // 실행할거에요 -> 강제 회수를
SetState(BossState.PreAttack); // 전환할거에요 -> 공격 대기로
return; // 중단할거에요
}
if (!CheckNavMeshReady()) return; // 중단할거에요 -> NavMesh 비정상이면
agent.isStopped = false; // 켤거에요 -> 이동을
agent.SetDestination(ironBall.transform.position); // 이동할거에요 -> 쇠공 위치로
PlayMoveAnim(true); // 재생할거에요 -> 걷기 애니를
FaceTarget(ironBall.transform.position); // 바라볼거에요 -> 쇠공을
// 충분히 가까우면 줍기 코루틴 시작
float distToBall = Vector3.Distance(transform.position, ironBall.transform.position); // 계산할거에요 -> 쇠공까지 거리를
if (distToBall <= 2.0f) // 조건이 맞으면 실행할거에요 -> 2m 이내면
{
SetState(BossState.Attacking); // 잠글거에요 -> 중복 진입 방지
StartCoroutine(PickupBall()); // 시작할거에요 -> 줍기 코루틴을
}
}
// ══════════════════════════════════════════════════════════
// FSM 상태 변경 유틸리티
// ══════════════════════════════════════════════════════════
/// <summary>FSM 상태를 변경하고 디버그 로그를 출력</summary>
private void SetState(BossState s) // 함수를 선언할거에요 -> FSM 상태를 변경하는
{
if (_state == s) return; // 중단할거에요 -> 같은 상태면 중복 설정 방지
_state = s; // 설정할거에요 -> 새 상태로
Debug.Log($"[BossFSM] → {s}"); // 로그를 찍을거에요
}
}