Projext/Assets/02_Scripts/Enemy/BossAI/BossMonster.cs
2026-03-01 12:22:29 +09:00

1177 lines
75 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System.Collections; // 코루틴을 사용할거에요 -> System.Collections를
// ============================================================
// NorcielBoss — 버그 수정 + 고도화 버전
//
// ✅ 수정된 버그:
// ① 공이 안 날아감 → SetParent 후 월드 좌표 명시 복원 + 포물선 물리
// ② 대시가 안 됨 → OnDashStart 이벤트 의존 제거 → 타이머 방식
// ③ 데미지 없음 → OverlapSphere 높이 보정 + GetComponentInParent 추가
//
// ✅ 고도화:
// - 페이즈 시스템 HP 50% 이하 → Phase2 (속도↑ 간격↓ 데미지↑)
// - 연속 스매쉬 Phase2 전용: 찍기 2회 연속
// - 돌진+찍기 콤보 Phase2 전용: 돌진 후 즉시 찍기 연계
//
// 애니메이션 이벤트 등록 목록:
// Attack_Throw → 공 손 떠나는 프레임 : OnThrowRelease
// Attack_Throw → 마지막 프레임 : OnAnimEnd
// Skill_Smash_Impact → 쇠공 바닥 닿는 프레임 : OnSmashHit
// Skill_Smash_Impact → 마지막 프레임 : OnAnimEnd
// Skill_Sweep → 쓸리는 프레임 : OnSweepHit
// Skill_Sweep → 마지막 프레임 : OnAnimEnd
// Skill_Pickup → 마지막 프레임 : OnAnimEnd
// Roar → 마지막 프레임 : OnAnimEnd
// ※ Skill_Dash_Ready → OnDashStart 이벤트 더 이상 불필요
// ============================================================
public class NorcielBoss : MonsterClass // 클래스를 선언할거에요 -> MonsterClass를 상속받는 NorcielBoss를
{
// ──────────────────────────────────────────────────────────
// 패턴 열거형
// ──────────────────────────────────────────────────────────
private enum BossPattern // 열거형을 선언할거에요 -> 보스 공격 패턴 종류를
{
Throw, // 쇠공 던지기
Smash, // 내려찍기
Sweep, // 휩쓸기
Dash, // 돌진
ComboSmash, // 연속 스매쉬 2회 (Phase2 전용)
DashSmash, // 돌진 + 찍기 콤보 (Phase2 전용)
}
// ──────────────────────────────────────────────────────────
// Inspector — 두뇌 / 기본
// ──────────────────────────────────────────────────────────
[Header("=== 두뇌 연결 ===")]
[SerializeField] private BossCounterSystem counterSystem; // 변수를 선언할거에요 -> 패턴 결정 카운터 시스템을
[Header("=== 기본 패턴 설정 ===")]
[Tooltip("공격과 다음 공격 사이 대기 시간 (초)")]
[SerializeField] private float patternInterval = 3f; // 변수를 선언할거에요 -> 공격 간격을
[Tooltip("이 거리 이하 = 근접 범위 (찍기/휩쓸기 우선)")]
[SerializeField] private float meleeRange = 3.5f; // 변수를 선언할거에요 -> 근접 공격 사거리를
[Tooltip("이 거리 이상 = 원거리 범위 (던지기 우선)")]
[SerializeField] private float throwRange = 8f; // 변수를 선언할거에요 -> 원거리 던지기 사거리를
[Tooltip("이 거리 이상 지속 = 보스가 대시로 추격 (도망 감지)")]
[SerializeField] private float dashChaseRange = 10f; // 변수를 선언할거에요 -> 대시 추격 발동 거리를
[Tooltip("대시 추격 발동까지 플레이어가 멀리 있어야 하는 시간 (초)")]
[SerializeField] private float dashChaseDelay = 2f; // 변수를 선언할거에요 -> 도망 감지 누적 시간을
[Tooltip("Phase2 진입 HP 비율 (0.5 = 50%)")]
[SerializeField][Range(0f, 1f)] private float phase2HpRatio = 0.5f; // 변수를 선언할거에요 -> 페이즈2 전환 체력 비율을
[Header("=== 쇠공 설정 ===")]
[SerializeField] private GameObject ironBall; // 변수를 선언할거에요 -> 던질 쇠공 오브젝트를
[SerializeField] private Transform handHolder; // 변수를 선언할거에요 -> 쇠공 쥐는 손 위치를
[Tooltip("handHolder 기준 쇠공 위치 오프셋 (손 본 위치가 맞지 않을 때 조정)")]
[SerializeField] private Vector3 ballHoldOffset = Vector3.zero; // 변수를 선언할거에요 -> 쇠공 부착 위치 오프셋을
[Tooltip("포물선 최고 높이 (클수록 더 높이 올라감)")]
[SerializeField] private float throwArcHeight = 4f; // 변수를 선언할거에요 -> 포물선 높이를
[Tooltip("쇠공 착지 범위 판정 반경")]
[SerializeField] private float ballLandRadius = 2f; // 변수를 선언할거에요 -> 착지 판정 반경을
[Tooltip("쇠공 착지 데미지")]
[SerializeField] private float ballLandDamage = 20f; // 변수를 선언할거에요 -> 착지 데미지를
[Header("=== 찍기 설정 ===")]
[SerializeField] private float smashRadius = 2.5f; // 변수를 선언할거에요 -> 찍기 판정 반경을
[SerializeField] private float smashDamage = 25f; // 변수를 선언할거에요 -> 찍기 데미지를
[Tooltip("OverlapSphere 중심 높이 보정 (발 기준 → 허리 높이로)")]
[SerializeField] private float smashHeightOffset = 0.5f; // 변수를 선언할거에요 -> 찍기 판정 높이 오프셋을 ← 버그③ 수정
[Header("=== 휩쓸기 설정 ===")]
[SerializeField] private float sweepRadius = 3.5f; // 변수를 선언할거에요 -> 휩쓸기 판정 반경을
[SerializeField] private float sweepDamage = 18f; // 변수를 선언할거에요 -> 휩쓸기 데미지를
[SerializeField] private float sweepHeightOffset = 0.5f; // 변수를 선언할거에요 -> 휩쓸기 판정 높이 오프셋을
[Header("=== 돌진 설정 ===")]
[Tooltip("돌진 준비 자세 유지 시간 (초) — 이벤트 대신 타이머 사용")]
[SerializeField] private float dashReadyDuration = 0.8f; // 변수를 선언할거에요 -> 돌진 준비 시간을 ← 버그② 핵심
[SerializeField] private float dashSpeed = 18f; // 변수를 선언할거에요 -> 돌진 속도를
[SerializeField] private float dashDuration = 0.5f; // 변수를 선언할거에요 -> 돌진 지속 시간을
[SerializeField] private float dashDamage = 30f; // 변수를 선언할거에요 -> 돌진 충돌 데미지를
[Header("=== Phase2 강화 수치 ===")]
[Tooltip("Phase2 공격 간격 배율 (0.65 = 35% 단축)")]
[SerializeField] private float phase2IntervalMult = 0.65f; // 변수를 선언할거에요 -> 페이즈2 공격 간격 배율을
[Tooltip("Phase2 데미지 배율")]
[SerializeField] private float phase2DamageMult = 1.4f; // 변수를 선언할거에요 -> 페이즈2 데미지 배율을
[Tooltip("Phase2 이동 속도 배율")]
[SerializeField] private float phase2SpeedMult = 1.3f; // 변수를 선언할거에요 -> 페이즈2 이동 속도 배율을
[Header("=== UI ===")]
[SerializeField] private GameObject bossHealthBar; // 변수를 선언할거에요 -> 보스 체력바 UI를
[Header("=== 애니메이션 State 이름 ===")]
[SerializeField] private string anim_Idle = "Monster_Idle"; // 변수를 선언할거에요 -> 대기 애니 이름을
[SerializeField] private string anim_Walk = "Monster_Walk"; // 변수를 선언할거에요 -> 걷기 애니 이름을
[SerializeField] private string anim_Roar = "Roar"; // 변수를 선언할거에요 -> 포효 애니 이름을
[SerializeField] private string anim_Throw = "Attack_Throw"; // 변수를 선언할거에요 -> 던지기 애니 이름을
[SerializeField] private string anim_Pickup = "Skill_Pickup"; // 변수를 선언할거에요 -> 줍기 애니 이름을
[SerializeField] private string anim_DashReady = "Skill_Dash_Ready"; // 변수를 선언할거에요 -> 돌진 준비 애니 이름을
[SerializeField] private string anim_DashGo = "Skill_Dash_Go"; // 변수를 선언할거에요 -> 돌진 실행 애니 이름을
[SerializeField] private string anim_SmashImpact = "Skill_Smash_Impact"; // 변수를 선언할거에요 -> 찍기 애니 이름을
[SerializeField] private string anim_Sweep = "Skill_Sweep"; // 변수를 선언할거에요 -> 휩쓸기 애니 이름을
// ──────────────────────────────────────────────────────────
// 내부 상태 변수
// ──────────────────────────────────────────────────────────
private Rigidbody _rb; // 변수를 선언할거에요 -> 보스 Rigidbody를
private Transform _target; // 변수를 선언할거에요 -> 공격 대상 Transform을
private bool _isBattleStarted = false; // 변수를 선언할거에요 -> 전투 시작 여부를
private bool _isWeaponless = false; // 변수를 선언할거에요 -> 쇠공 없는 상태 여부를
private bool _isPerformingAction = false; // 변수를 선언할거에요 -> 패턴 실행 중 여부를
private bool _isDashing = false; // 변수를 선언할거에요 -> 돌진 중 여부를 (충돌 판정용)
private bool _isPhase2 = false; // 변수를 선언할거에요 -> 페이즈2 여부를
private bool _phase2Triggered = false; // 변수를 선언할거에요 -> 페이즈2 이미 발동됐는지를
private float _attackTimer = 0f; // 변수를 선언할거에요 -> 공격 쿨타임 타이머를
private float _farFromBossTimer = 0f; // 변수를 선언할거에요 -> 플레이어가 멀리 있던 누적 시간을 (도망 감지용)
// 애니메이션 이벤트 수신 플래그
private bool _animEndFired = false; // 변수를 선언할거에요 -> OnAnimEnd 수신 여부를
private bool _throwFired = false; // 변수를 선언할거에요 -> OnThrowRelease 수신 여부를
private bool _smashHitFired = false; // 변수를 선언할거에요 -> OnSmashHit 수신 여부를
// ──────────────────────────────────────────────────────────
// 초기화
// ──────────────────────────────────────────────────────────
protected override void Awake() // 함수를 실행할거에요 -> 컴포넌트 초기화를
{
base.Awake(); // 실행할거에요 -> 부모 Awake를
_rb = GetComponent<Rigidbody>(); // 가져올거에요 -> 보스 Rigidbody를
}
protected override void Init() // 함수를 실행할거에요 -> 스탯·상태 초기화를
{
base.Init(); // 실행할거에요 -> 부모 Init을
_attackTimer = 0f; // 초기화할거에요 -> 타이머를
_isBattleStarted = _isWeaponless = _isPerformingAction = _isDashing = false; // 초기화할거에요 -> 모든 상태를
_isPhase2 = _phase2Triggered = false; // 초기화할거에요 -> 페이즈 상태를
IsAggroed = true; // 설정할거에요 -> 항상 어그로 상태로
mobRenderer = null; // 초기화할거에요 -> 보스는 최적화 안 함
optimizationDistance = 9999f; // 설정할거에요 -> 최적화 거리를 무한대로
// 플레이어 탐색
GameObject playerObj = GameObject.FindWithTag("Player"); // 찾을거에요 -> Player 태그로
if (playerObj != null)
_target = playerTransform = playerObj.transform; // 설정할거에요 -> 타겟을
else
{
PlayerMovement pm = FindObjectOfType<PlayerMovement>(); // 찾을거에요 -> 컴포넌트로
if (pm != null) _target = playerTransform = pm.transform; // 설정할거에요 -> 타겟을
}
if (_target == null) Debug.LogError("[Boss] 플레이어를 찾지 못했어요!"); // 에러를 찍을거에요
currentHP = maxHP; // 채울거에요 -> 체력을 최대로
if (bossHealthBar != null) bossHealthBar.SetActive(false); // 끌거에요 -> 체력바를
if (animator != null) animator.Play(anim_Idle); // 재생할거에요 -> 대기 애니를
// ── 쇠공 초기 셋업 ──────────────────────────────────
// 씬에 별도 배치된 오브젝트든, handHolder 자식이든 상관없이
// 시작 시 handHolder에 붙이고 Rigidbody를 고정함
if (ironBall != null) // 조건이 맞으면 실행할거에요 -> 쇠공 있으면
{
Rigidbody initRb = ironBall.GetComponent<Rigidbody>(); // 가져올거에요 -> Rigidbody를
if (initRb != null) // 조건이 맞으면 실행할거에요 -> Rigidbody 있으면
{
initRb.isKinematic = true; // 고정할거에요 -> 물리를 (손에 들고 있는 상태)
initRb.velocity = Vector3.zero; // 초기화할거에요 -> 속도를
initRb.angularVelocity = Vector3.zero; // 초기화할거에요 -> 회전 속도를
}
// 보스 자신의 콜라이더와 쇠공 콜라이더 충돌 무시 (밀려남 방지)
Collider ballCol = ironBall.GetComponent<Collider>(); // 가져올거에요 -> 쇠공 콜라이더를
Collider bossCol = GetComponent<Collider>(); // 가져올거에요 -> 보스 콜라이더를
if (ballCol != null && bossCol != null) // 조건이 맞으면 실행할거에요 -> 둘 다 있으면
{
Physics.IgnoreCollision(ballCol, bossCol, true); // 무시할거에요 -> 보스↔쇠공 충돌을
Debug.Log("[Boss] 쇠공↔보스 콜라이더 충돌 무시 설정 완료"); // 로그를 찍을거에요
}
if (handHolder != null) // 조건이 맞으면 실행할거에요 -> 손 위치가 지정돼있으면
{
AttachBallToHand(); // 실행할거에요 -> 손에 부착을
}
else // 조건이 틀리면 실행할거에요 -> handHolder 없으면
{
Debug.LogWarning("[Boss] handHolder 슬롯이 비어있어요! Inspector에서 손 본(Bone) Transform 연결 필요"); // 경고를 찍을거에요
}
}
_isWeaponless = false; // 초기화할거에요 -> 무기 있는 상태로
StartCoroutine(SafeInitNavMesh()); // 시작할거에요 -> NavMesh 안전 초기화를
}
private IEnumerator SafeInitNavMesh() // 코루틴을 정의할거에요 -> NavMesh 초기화를
{
yield return null; // 기다릴거에요 -> 1프레임을
if (agent == null) yield break; // 중단할거에요 -> 에이전트 없으면
int retry = 0; // 초기화할거에요 -> 재시도 횟수를
while (!agent.isOnNavMesh && retry < 5) { retry++; yield return new WaitForSeconds(0.1f); } // 기다릴거에요 -> NavMesh 위에 올 때까지
if (agent.isOnNavMesh) agent.isStopped = true; // 멈출거에요 -> 올라오면
agent.enabled = false; // 끌거에요 -> 전투 시작 전까지
}
private void Start() // 함수를 실행할거에요 -> 시작 시 전투 개시를
{
StartBossBattle(); // 실행할거에요 -> 보스전 시작을
}
protected override void Update() // 함수를 실행할거에요 -> 매 프레임을
{
base.Update(); // 실행할거에요 -> 부모 Update를
if (!isDead)
{
CheckPhase2(); // 확인할거에요 -> 페이즈2 전환 조건을
ExecuteAILogic(); // 실행할거에요 -> AI 로직을
PollTestButtons(); // 확인할거에요 -> Inspector 테스트 버튼 입력을
}
}
// ──────────────────────────────────────────────────────────
// 페이즈2 체크
// ──────────────────────────────────────────────────────────
private void CheckPhase2() // 함수를 선언할거에요 -> HP 비율로 페이즈2 전환을 감지하는
{
if (_phase2Triggered || maxHP <= 0) return; // 중단할거에요 -> 이미 전환됐거나 maxHP 0이면
if (currentHP / maxHP <= phase2HpRatio) // 조건이 맞으면 실행할거에요 -> HP가 50% 이하가 됐으면
{
_phase2Triggered = true; // 설정할거에요 -> 이미 발동됨으로
StartCoroutine(EnterPhase2()); // 시작할거에요 -> 페이즈2 진입 코루틴을
}
}
private IEnumerator EnterPhase2() // 코루틴을 정의할거에요 -> 페이즈2 진입 연출을
{
while (_isPerformingAction) yield return null; // 기다릴거에요 -> 현재 패턴 끝날 때까지
_isPerformingAction = true; // 잠글거에요 -> 추격/공격 차단
Debug.Log("[Boss] ⚡ Phase2 진입!"); // 로그를 찍을거에요
yield return StartCoroutine(PlayAndWait(anim_Roar, 8f)); // 기다릴거에요 -> 분노 포효 애니 종료를
_isPhase2 = true; // 설정할거에요 -> 페이즈2 상태로
if (agent != null) agent.speed *= phase2SpeedMult; // 높일거에요 -> 이동 속도를
patternInterval *= phase2IntervalMult; // 줄일거에요 -> 공격 간격을
_isPerformingAction = false; // 해제할거에요 -> 행동 차단을
Debug.Log($"[Boss] Phase2 완료 — 간격 {patternInterval:F1}s / 속도 ×{phase2SpeedMult}"); // 로그를 찍을거에요
}
// ──────────────────────────────────────────────────────────
// 애니메이션 이벤트 수신 함수
// FBX → Animations 탭 → 클립 → Events에 함수명으로 등록
// ──────────────────────────────────────────────────────────
public void OnAnimEnd() // 함수를 선언할거에요 -> 애니 끝 신호를 받는
{
_animEndFired = true; // 설정할거에요 -> 종료 플래그를
Debug.Log("[BossEvent] OnAnimEnd"); // 로그를 찍을거에요
}
public void OnThrowRelease() // 함수를 선언할거에요 -> 공 손 떠나는 순간 신호를 받는
{
_throwFired = true; // 설정할거에요 -> 투척 플래그를
Debug.Log("[BossEvent] OnThrowRelease"); // 로그를 찍을거에요
}
public void OnSmashHit() // 함수를 선언할거에요 -> 찍기 타격 순간 신호를 받는
{
_smashHitFired = true; // 설정할거에요 -> 찍기 판정 플래그를
Debug.Log("[BossEvent] OnSmashHit"); // 로그를 찍을거에요
}
public void OnSweepHit() // 함수를 선언할거에요 -> 휩쓸기 타격 순간 신호를 받는 (즉시 판정)
{
float dmg = sweepDamage * (_isPhase2 ? phase2DamageMult : 1f); // 계산할거에요 -> 페이즈 보정 데미지를
ApplyAreaDamage(sweepRadius, dmg, sweepHeightOffset, "휩쓸기"); // 실행할거에요 -> 즉시 범위 데미지를
Debug.Log("[BossEvent] OnSweepHit"); // 로그를 찍을거에요
}
// ──────────────────────────────────────────────────────────
// AI 로직 (매 프레임)
// ──────────────────────────────────────────────────────────
protected override void ExecuteAILogic() // 함수를 정의할거에요 -> 매 프레임 AI 행동을
{
if (_isPerformingAction || !_isBattleStarted || _target == null) return; // 중단할거에요 -> 행동중/시작전/타겟없으면
if (isAttacking || isHit || isResting) return; // 중단할거에요 -> 전투 중간 상태면
if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; // 중단할거에요 -> NavMesh 비정상이면
if (_isWeaponless) { RetrieveWeaponLogic(); return; } // 실행할거에요 -> 무기 없으면 회수 우선
float dist = Vector3.Distance(transform.position, _target.position); // 계산할거에요 -> 플레이어 거리를
// ── 도망 감지: dashChaseRange 이상 거리가 dashChaseDelay초 지속 → 대시 추격
if (dist >= dashChaseRange) // 조건이 맞으면 실행할거에요 -> 플레이어가 충분히 멀면
{
_farFromBossTimer += Time.deltaTime; // 더할거에요 -> 누적 시간을
if (_farFromBossTimer >= dashChaseDelay) // 조건이 맞으면 실행할거에요 -> 일정 시간 이상 멀었으면
{
_farFromBossTimer = 0f; // 초기화할거에요 -> 누적 시간을
_attackTimer = patternInterval; // 재설정할거에요 -> 쿨타임을
Debug.Log("[Boss] 도망 감지 → 대시 추격!"); // 로그를 찍을거에요
StartCoroutine(Pattern_Dash()); // 시작할거에요 -> 대시 추격을
return; // 중단할거에요 -> 이번 프레임 추가 처리 중단
}
}
else // 조건이 틀리면 실행할거에요 -> 가까우면
{
_farFromBossTimer = 0f; // 초기화할거에요 -> 도망 누적 시간을
}
// ── 근접 범위 안: 멈추고 공격 쿨타임 카운트
if (dist <= meleeRange) // 조건이 맞으면 실행할거에요 -> 근접 범위 안이면
{
agent.isStopped = true; // 멈출거에요 -> 이동을
agent.velocity = Vector3.zero; // 제거할거에요 -> 관성을
LookAt(_target.position); // 바라볼거에요 -> 플레이어를
if (animator.GetCurrentAnimatorStateInfo(0).IsName(anim_Walk)) // 조건이 맞으면 실행할거에요 -> 걷는 중이면
animator.Play(anim_Idle); // 재생할거에요 -> 대기 애니로
_attackTimer -= Time.deltaTime; // 줄일거에요 -> 쿨타임을
if (_attackTimer <= 0f) // 조건이 맞으면 실행할거에요 -> 쿨타임 끝났으면
{
_attackTimer = patternInterval; // 재설정할거에요 -> 쿨타임을
SelectAndExecutePattern(dist); // 실행할거에요 -> 거리 기반 패턴 선택을
}
}
// ── 원거리 범위: 쫓아가다가 throwRange 이하로 가까우면 던지기
else if (dist <= throwRange) // 조건이 맞으면 실행할거에요 -> 던지기 사거리 안이면
{
agent.isStopped = true; // 멈출거에요 -> 이동 멈추고
agent.velocity = Vector3.zero; // 제거할거에요 -> 관성을
LookAt(_target.position); // 바라볼거에요 -> 플레이어를
if (animator.GetCurrentAnimatorStateInfo(0).IsName(anim_Walk)) // 조건이 맞으면 실행할거에요 -> 걷는 중이면
animator.Play(anim_Idle); // 재생할거에요 -> 대기 애니로
_attackTimer -= Time.deltaTime; // 줄일거에요 -> 쿨타임을
if (_attackTimer <= 0f) // 조건이 맞으면 실행할거에요 -> 쿨타임 끝났으면
{
_attackTimer = patternInterval; // 재설정할거에요 -> 쿨타임을
SelectAndExecutePattern(dist); // 실행할거에요 -> 거리 기반 패턴 선택을
}
}
else // 조건이 틀리면 실행할거에요 -> 던지기 사거리 밖이면 추격
{
agent.isStopped = false; // 켤거에요 -> 이동을
agent.SetDestination(_target.position); // 설정할거에요 -> 목적지를
LookAt(_target.position); // 바라볼거에요 -> 플레이어를
UpdateMoveAnim(); // 갱신할거에요 -> 이동 애니를
}
}
// ──────────────────────────────────────────────────────────
// 패턴 선택
// ──────────────────────────────────────────────────────────
private void SelectAndExecutePattern(float dist) // 함수를 선언할거에요 -> 거리에 따라 패턴을 결정하고 실행하는
{
LookAt(_target.position); // 바라볼거에요 -> 공격 전 방향 정렬을
BossPattern pattern; // 변수를 선언할거에요 -> 선택된 패턴을
bool isNearby = dist <= meleeRange; // 판단할거에요 -> 근접 범위 안인지를
bool isFar = dist > meleeRange; // 판단할거에요 -> 원거리 범위인지를
if (_isPhase2) // 조건이 맞으면 실행할거에요 -> Phase2이면
{
if (isNearby) // 조건이 맞으면 실행할거에요 -> 근접 범위이면
{
// 근접: 찍기/휩쓸기/콤보 위주
int roll = Random.Range(0, 10); // 뽑을거에요 -> 랜덤 값을
if (roll < 3) pattern = BossPattern.ComboSmash; // 30% — 연속 스매쉬
else if (roll < 6) pattern = BossPattern.DashSmash; // 30% — 돌진+찍기
else if (roll < 8) pattern = BossPattern.Smash; // 20% — 찍기
else pattern = BossPattern.Sweep; // 20% — 휩쓸기
}
else // 조건이 틀리면 실행할거에요 -> 원거리이면
{
// 원거리: 던지기/대시 위주
int roll = Random.Range(0, 10); // 뽑을거에요 -> 랜덤 값을
if (roll < 5) pattern = BossPattern.Throw; // 50% — 던지기
else pattern = BossPattern.Dash; // 50% — 돌진 추격
}
}
else // 조건이 틀리면 실행할거에요 -> Phase1이면
{
if (isFar) // 조건이 맞으면 실행할거에요 -> 원거리이면 무조건 던지기
{
pattern = BossPattern.Throw; // 설정할거에요 -> 던지기를
}
else // 조건이 틀리면 실행할거에요 -> 근접이면 카운터 시스템 활용
{
string result = counterSystem != null ? counterSystem.SelectBossPattern() : "Normal"; // 선택할거에요 -> 카운터 패턴을
switch (result) // 분기할거에요 -> 결과에 따라
{
case "DashAttack": pattern = BossPattern.Dash; break; // 설정할거에요 -> 돌진을
case "Smash": pattern = BossPattern.Smash; break; // 설정할거에요 -> 찍기를
case "ShieldWall": pattern = BossPattern.Sweep; break; // 설정할거에요 -> 휩쓸기를
default: pattern = BossPattern.Smash; break; // 설정할거에요 -> 기본 근접: 찍기를
}
}
}
Debug.Log($"[Boss] 패턴={pattern} dist={dist:F1}m Phase2={_isPhase2}"); // 로그를 찍을거에요
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.ComboSmash: StartCoroutine(Pattern_ComboSmash()); break;
case BossPattern.DashSmash: StartCoroutine(Pattern_DashSmash()); break;
default: StartCoroutine(Pattern_Throw()); break;
}
}
// ──────────────────────────────────────────────────────────
// 전투 시작
// ──────────────────────────────────────────────────────────
public void StartBossBattle() // 함수를 선언할거에요 -> 보스전을 시작하는
{
if (_isBattleStarted) return; // 중단할거에요 -> 이미 시작됐으면
StartCoroutine(BattleStartRoutine()); // 시작할거에요 -> 전투 시작 코루틴을
}
private IEnumerator BattleStartRoutine() // 코루틴을 정의할거에요 -> 전투 시작 연출을
{
_isPerformingAction = true; // 잠글거에요 -> 행동을
if (bossHealthBar != null) bossHealthBar.SetActive(true); // 켤거에요 -> 체력바를
if (counterSystem != null) counterSystem.InitializeBattle(); // 초기화할거에요 -> 카운터 시스템을
yield return StartCoroutine(PlayAndWait(anim_Roar, 10f)); // 기다릴거에요 -> 포효 종료를
animator.Play(anim_Idle, 0, 0f); // 재생할거에요 -> 대기 애니를
_isPerformingAction = false; // 해제할거에요 -> 행동 잠금을
_isBattleStarted = true; // 설정할거에요 -> 전투 시작 상태로
if (agent != null) // 조건이 맞으면 실행할거에요 -> 에이전트 있으면
{
agent.enabled = true; // 켤거에요 -> 에이전트를
int retry = 0; // 초기화할거에요 -> 재시도 횟수를
while (!agent.isOnNavMesh && retry < 10) { retry++; yield return new WaitForSeconds(0.1f); } // 기다릴거에요 -> NavMesh 위에 올 때까지
if (agent.isOnNavMesh) agent.isStopped = false; // 켤거에요 -> 이동을
else Debug.LogError("[Boss] NavMesh 연결 실패!"); // 에러를 찍을거에요
}
}
// ══════════════════════════════════════════════════════════
// 패턴 ① — 쇠공 던지기
//
// ✅ 버그① 수정 포인트:
// SetParent(null) 직전 월드 좌표 저장 → 분리 후 위치 복원
// → 포물선 초기속도 계산으로 정확히 날아감
// → 착지 후 velocity < 0.5 감지 → 범위 데미지
//
// 이벤트: Attack_Throw → OnThrowRelease (손 떠나는 프레임)
// Attack_Throw → OnAnimEnd (마지막 프레임)
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Throw() // 코루틴을 정의할거에요 -> 쇠공 던지기 패턴을
{
OnAttackStart(); // 실행할거에요 -> 공격 시작 처리를
if (ironBall == null) // 조건이 맞으면 실행할거에요 -> 쇠공 없으면
{
Debug.LogError("[Boss] Iron Ball 슬롯이 비어있어요! Inspector에서 연결 필요"); // 에러를 찍을거에요
OnAttackEnd(); // 실행할거에요 -> 패턴 종료를
yield break; // 중단할거에요
}
// 발사 목표 미리 고정 (애니 재생 전에 저장 → 도중에 플레이어가 움직여도 OK)
Vector3 throwTarget = _target != null
? _target.position + Vector3.up * 0.8f // 저장할거에요 -> 플레이어 허리 높이를
: transform.position + transform.forward * 10f; // 저장할거에요 -> 타겟 없으면 전방을
// 던지기 애니 재생
if (animator != null && animator.HasState(0, Animator.StringToHash(anim_Throw))) // 조건이 맞으면 실행할거에요 -> State 있으면
animator.Play(anim_Throw, 0, 0f); // 재생할거에요 -> 던지기 애니를
yield return null; // 기다릴거에요 -> 1프레임 (상태 전환)
// 던지기 애니 길이의 40% 시점에 공 발사 (이벤트 없어도 동작)
float clipLen = GetCurrentClipLength(); // 가져올거에요 -> 현재 클립 길이를
float throwTime = Mathf.Max(clipLen * 0.4f, 0.3f); // 계산할거에요 -> 발사 타이밍을
// OnThrowRelease 이벤트 OR 타이머 중 먼저 오는 것으로 발사
_throwFired = false; // 초기화할거에요 -> 이전 신호를
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (!_throwFired && elapsed < throwTime) // 기다릴거에요 -> 이벤트 또는 타이머
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
yield return null; // 기다릴거에요 -> 1프레임을
}
// ── 공 발사 ──
yield return StartCoroutine(DoLaunchBall(throwTarget)); // 실행할거에요 -> 공 발사 코루틴을
_isWeaponless = true; // 설정할거에요 -> 무기 없음 상태로
// 나머지 애니 재생 대기 (이미 지난 시간 빼고)
float remaining = Mathf.Max(clipLen - elapsed - 0.1f, 0.2f); // 계산할거에요 -> 남은 시간을
yield return new WaitForSeconds(remaining); // 기다릴거에요 -> 애니 끝날 때까지
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를
}
private IEnumerator DoLaunchBall(Vector3 targetPos) // 코루틴을 정의할거에요 -> 쇠공을 실제로 발사하는
{
// ── 발사 위치 확정 ──
Vector3 launchPos = ironBall.transform.position; // 저장할거에요 -> 발사 월드 위치를
// ── 손에서 분리 ──
ironBall.transform.SetParent(null); // 분리할거에요 -> 손에서
ironBall.transform.position = launchPos; // 복원할거에요 -> SetParent로 틀어진 위치를
// ── Rigidbody 가져오기 (발사 시점에 직접 → null 캐싱 문제 없음) ──
Rigidbody rb = ironBall.GetComponent<Rigidbody>(); // 가져올거에요 -> Rigidbody를 발사 시점에
if (rb != null) // 조건이 맞으면 실행할거에요 -> Rigidbody 있으면 물리 발사
{
rb.isKinematic = false; // 해제할거에요 -> 물리를
rb.velocity = Vector3.zero; // 초기화할거에요 -> 이전 속도를
rb.angularVelocity = Vector3.zero; // 초기화할거에요 -> 회전 속도를
yield return new WaitForFixedUpdate(); // 기다릴거에요 -> FixedUpdate 1회 (물리 활성화 대기)
yield return new WaitForFixedUpdate(); // 기다릴거에요 -> 한 번 더 (안전 마진)
Vector3 vel = CalcParabolaVelocity(launchPos, targetPos, throwArcHeight); // 계산할거에요 -> 포물선 초기 속도를
rb.velocity = vel; // 설정할거에요 -> 속도를 직접
Debug.Log($"[Boss] ✅ 쇠공 물리 발사! vel={vel} 목표={targetPos}"); // 로그를 찍을거에요
StartCoroutine(BallLandingDamage(rb)); // 시작할거에요 -> 착지 판정을
}
else // 조건이 틀리면 실행할거에요 -> Rigidbody 없으면 코드로 직접 이동
{
Debug.LogWarning("[Boss] 쇠공에 Rigidbody 없음 → 코드 이동 방식 사용"); // 경고를 찍을거에요
StartCoroutine(MoveBallManually(launchPos, targetPos)); // 시작할거에요 -> 수동 이동 코루틴을
}
}
private IEnumerator MoveBallManually(Vector3 from, Vector3 to) // 코루틴을 정의할거에요 -> Rigidbody 없이 쇠공을 포물선으로 직접 이동하는
{
float duration = 1.2f; // 설정할거에요 -> 비행 시간을
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (elapsed < duration && ironBall != null) // 반복할거에요 -> 비행 시간 동안
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
float t = elapsed / duration; // 계산할거에요 -> 진행 비율을 (0~1)
// 포물선 보간: 수평은 Lerp, 수직은 포물선 커브
Vector3 pos = Vector3.Lerp(from, to, t); // 계산할거에요 -> 수평 위치를
pos.y += throwArcHeight * 4f * t * (1f - t); // 더할거에요 -> 포물선 높이를 (t=0.5에서 최고)
ironBall.transform.position = pos; // 이동할거에요 -> 쇠공을
yield return null; // 기다릴거에요 -> 1프레임을
}
if (ironBall != null) ironBall.transform.position = to; // 이동할거에요 -> 최종 목표 위치로
// 착지 범위 데미지
float dmg = ballLandDamage * (_isPhase2 ? phase2DamageMult : 1f); // 계산할거에요 -> 데미지를
Collider[] hits = Physics.OverlapSphere(to, ballLandRadius); // 검사할거에요 -> 착지 범위를
foreach (Collider hit in hits) // 반복할거에요 -> 감지된 콜라이더를
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어 아니면
IDamageable d = hit.GetComponent<IDamageable>() ?? hit.GetComponentInParent<IDamageable>(); // 가져올거에요 -> 데미지 인터페이스를
if (d != null) d.TakeDamage(dmg); // 줄거에요 -> 데미지를
}
Debug.Log($"[Boss] 쇠공 착지 (수동 이동) 위치={to}"); // 로그를 찍을거에요
}
private Vector3 CalcParabolaVelocity(Vector3 from, Vector3 to, float arcH) // 함수를 선언할거에요 -> 포물선 초기 속도를 계산하는
{
float g = Mathf.Abs(Physics.gravity.y); // 가져올거에요 -> 중력 크기를
float dx = Vector3.Distance(
new Vector3(from.x, 0, from.z),
new Vector3(to.x, 0, to.z)); // 계산할거에요 -> 수평 거리를
float dy = to.y - from.y; // 계산할거에요 -> 수직 거리를
float tUp = Mathf.Sqrt(2f * arcH / g); // 계산할거에요 -> 상승 시간을
float tDown = Mathf.Sqrt(2f * Mathf.Max(arcH - dy, 0.1f) / g); // 계산할거에요 -> 하강 시간을
float tTotal = Mathf.Max(tUp + tDown, 0.1f); // 계산할거에요 -> 총 비행 시간을
Vector3 hDir = (to - from); // 계산할거에요 -> 수평 방향을
hDir.y = 0f; // 제거할거에요 -> 수직 성분을
hDir = hDir.normalized * (dx / tTotal); // 계산할거에요 -> 수평 속도를
float vy = arcH / tUp; // 계산할거에요 -> 초기 수직 속도를
return new Vector3(hDir.x, vy, hDir.z); // 반환할거에요 -> 포물선 초기 속도를
}
private IEnumerator BallLandingDamage(Rigidbody rb) // 코루틴을 정의할거에요 -> 쇠공 착지 후 범위 데미지를
{
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (elapsed < 6f) // 반복할거에요 -> 최대 6초 동안 착지 감지
{
elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
// 속도가 충분히 줄어들면 착지로 판정
if (rb != null && elapsed > 0.4f && rb.velocity.magnitude < 1f) break; // 중단할거에요 -> 착지 감지되면
yield return null; // 기다릴거에요 -> 1프레임을
}
if (ironBall == null) yield break; // 중단할거에요 -> 쇠공 없으면
float dmg = ballLandDamage * (_isPhase2 ? phase2DamageMult : 1f); // 계산할거에요 -> 보정 데미지를
Collider[] hits = Physics.OverlapSphere(ironBall.transform.position, ballLandRadius); // 검사할거에요 -> 착지 위치 기준으로
foreach (Collider hit in hits) // 반복할거에요 -> 감지된 콜라이더를
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어 아니면
IDamageable d = hit.GetComponent<IDamageable>() ?? hit.GetComponentInParent<IDamageable>(); // 가져올거에요 -> 데미지 인터페이스를
if (d != null) { d.TakeDamage(dmg); Debug.Log($"[Boss] 쇠공 착지 {dmg:F0} 데미지"); } // 줄거에요 -> 데미지를
}
}
// ══════════════════════════════════════════════════════════
// 패턴 ② — 내려찍기
//
// ✅ 버그③ 수정: smashHeightOffset으로 판정 중심 높이 보정
//
// 이벤트: Skill_Smash_Impact → OnSmashHit (바닥 닿는 프레임)
// Skill_Smash_Impact → OnAnimEnd (마지막 프레임)
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Smash() // 코루틴을 정의할거에요 -> 내려찍기 패턴을
{
OnAttackStart(); // 실행할거에요 -> 공격 시작 처리를
_smashHitFired = false; // 초기화할거에요 -> 찍기 플래그를
_animEndFired = false; // 초기화할거에요 -> 종료 플래그를
yield return StartCoroutine(PlayAnimSafe(anim_SmashImpact)); // 재생할거에요 -> 찍기 애니를
yield return StartCoroutine(WaitSmashHit(8f, "Smash")); // 기다릴거에요 -> 찍기 신호를
float dmg = smashDamage * (_isPhase2 ? phase2DamageMult : 1f); // 계산할거에요 -> 보정 데미지를
ApplyAreaDamage(smashRadius, dmg, smashHeightOffset, "내려찍기"); // 실행할거에요 -> 높이 보정 범위 데미지를 ← 버그③ 수정
yield return StartCoroutine(WaitAnimEnd(8f, "Smash")); // 기다릴거에요 -> 종료 신호를
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를
}
// ══════════════════════════════════════════════════════════
// 패턴 ③ — 휩쓸기
// 데미지: OnSweepHit 애니메이션 이벤트에서 즉시 처리
//
// 이벤트: Skill_Sweep → OnSweepHit (쓸리는 프레임)
// Skill_Sweep → OnAnimEnd (마지막 프레임)
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Sweep() // 코루틴을 정의할거에요 -> 휩쓸기 패턴을
{
OnAttackStart(); // 실행할거에요 -> 공격 시작 처리를
yield return StartCoroutine(PlayAndWait(anim_Sweep, 8f)); // 기다릴거에요 -> 휩쓸기 애니 종료를
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를
}
// ══════════════════════════════════════════════════════════
// 패턴 ④ — 돌진
//
// ✅ 버그② 수정: OnDashStart 이벤트 의존 완전 제거
// dashReadyDuration 타이머로 준비 시간 대기 후 즉시 물리 이동
// rb.isKinematic = false 명시 → velocity 적용 보장
//
// 이벤트: 없음 (이벤트 불필요, 타이머 방식)
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_Dash() // 코루틴을 정의할거에요 -> 돌진 패턴을
{
OnAttackStart(); // 실행할거에요 -> 공격 시작 처리를
if (agent != null) agent.enabled = false; // 끌거에요 -> NavMesh를 (물리 이동 위해)
// ① 준비 애니 재생
if (animator != null) animator.Play(anim_DashReady, 0, 0f); // 재생할거에요 -> 준비 애니를
// ② 준비 시간 동안 플레이어 방향으로 빠르게 회전 (방향 계속 갱신)
float readyElapsed = 0f; // 초기화할거에요 -> 준비 경과 시간을
while (readyElapsed < dashReadyDuration) // 반복할거에요 -> 준비 시간 동안
{
readyElapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toTarget = _target.position - transform.position; // 계산할거에요 -> 플레이어 방향을
toTarget.y = 0f; // 제거할거에요 -> 높이 차이를
if (toTarget.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 있으면
transform.rotation = Quaternion.Slerp(
transform.rotation,
Quaternion.LookRotation(toTarget),
Time.deltaTime * 15f); // 회전할거에요 -> 빠르게 플레이어 방향으로 (일반 LookAt보다 3배 빠름)
}
yield return null; // 기다릴거에요 -> 1프레임을
}
// ③ 돌진 직전 방향 최종 확정 + 즉시 스냅 (Slerp 아닌 즉시 적용)
Vector3 dashDir = _target != null
? (new Vector3(_target.position.x, transform.position.y, _target.position.z) - transform.position).normalized // 계산할거에요 -> 최신 플레이어 위치 기준 방향을
: transform.forward; // 사용할거에요 -> 타겟 없으면 전방을
if (dashDir.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향 있으면
transform.rotation = Quaternion.LookRotation(dashDir); // 설정할거에요 -> 즉시 방향 확정
// ④ 물리 이동 시작
if (_rb != null) // 조건이 맞으면 실행할거에요 -> Rigidbody 있으면
{
_rb.isKinematic = false; // 켤거에요 -> 물리를
_rb.velocity = Vector3.zero; // 초기화할거에요 -> 이전 속도를
_rb.velocity = dashDir * dashSpeed; // 설정할거에요 -> 정확한 방향으로 돌진을
}
else // 조건이 틀리면 실행할거에요 -> Rigidbody 없으면 NavMesh 이동으로 대체
{
Debug.LogWarning("[Boss] 보스에 Rigidbody 없음 → NavMesh 대시 이동으로 대체"); // 경고를 찍을거에요
if (agent != null)
{
agent.enabled = true; // 켤거에요 -> NavMesh를
yield return new WaitForSeconds(0.05f); // 기다릴거에요 -> 활성화 대기를
if (agent.isOnNavMesh)
{
agent.speed = dashSpeed; // 설정할거에요 -> 대시 속도를
agent.isStopped = false; // 켤거에요 -> 이동을
agent.SetDestination(_target != null ? _target.position : transform.position + transform.forward * 5f); // 설정할거에요 -> 목적지를
}
}
}
_isDashing = true; // 설정할거에요 -> 돌진 중으로 (충돌 데미지 활성화)
// ⑤ 돌진 애니 재생
if (animator != null) animator.Play(anim_DashGo, 0, 0f); // 재생할거에요 -> 돌진 애니를
// ⑥ 돌진 지속 시간 대기
yield return new WaitForSeconds(dashDuration); // 기다릴거에요 -> 돌진 시간만큼
// ⑦ 돌진 종료
_isDashing = false; // 해제할거에요 -> 돌진 상태를
if (_rb != null)
{
_rb.velocity = Vector3.zero; // 멈출거에요 -> 속도를
_rb.isKinematic = true; // 고정할거에요 -> 물리를
}
if (agent != null && _isBattleStarted)
{
agent.enabled = true; // 켤거에요 -> NavMesh를
if (agent.isOnNavMesh) agent.speed = moveSpeed; // 복구할거에요 -> 원래 이동 속도로
yield return new WaitForSeconds(0.05f); // 기다릴거에요 -> 재활성화 안정 대기
}
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를
}
// ══════════════════════════════════════════════════════════
// 패턴 ⑤ — 연속 스매쉬 (Phase2 전용)
// 찍기를 0.3초 간격으로 2회 연속
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_ComboSmash() // 코루틴을 정의할거에요 -> 연속 찍기 패턴을 (Phase2 전용)
{
OnAttackStart(); // 실행할거에요 -> 공격 시작 처리를
for (int i = 0; i < 2; i++) // 반복할거에요 -> 2회를
{
_smashHitFired = false; // 초기화할거에요 -> 찍기 플래그를
_animEndFired = false; // 초기화할거에요 -> 종료 플래그를
if (animator != null) animator.Play(anim_SmashImpact, 0, 0f); // 재생할거에요 -> 찍기 애니를
yield return null; // 기다릴거에요 -> 1프레임 (상태 전환 대기)
yield return StartCoroutine(WaitSmashHit(6f, $"ComboSmash({i + 1})")); // 기다릴거에요 -> 찍기 신호를
float dmg = smashDamage * phase2DamageMult; // 계산할거에요 -> Phase2 강화 데미지를
ApplyAreaDamage(smashRadius * 1.2f, dmg, smashHeightOffset, $"연속찍기 {i + 1}번"); // 실행할거에요 -> 범위 데미지를 (범위 20% 확대)
yield return StartCoroutine(WaitAnimEnd(6f, $"ComboSmash({i + 1})")); // 기다릴거에요 -> 종료 신호를
if (i == 0) yield return new WaitForSeconds(0.3f); // 기다릴거에요 -> 2번째 찍기 전 짧은 딜레이
}
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를
}
// ══════════════════════════════════════════════════════════
// 패턴 ⑥ — 돌진 + 찍기 콤보 (Phase2 전용)
// 빠른 돌진으로 플레이어 위치까지 이동 → 즉시 찍기 연계
// ══════════════════════════════════════════════════════════
private IEnumerator Pattern_DashSmash() // 코루틴을 정의할거에요 -> 돌진+찍기 콤보 패턴을 (Phase2 전용)
{
OnAttackStart(); // 실행할거에요 -> 공격 시작 처리를
if (agent != null) agent.enabled = false; // 끌거에요 -> NavMesh를
// ── 돌진 파트 ──────────────────────────────────────
if (animator != null) animator.Play(anim_DashReady, 0, 0f); // 재생할거에요 -> 준비 애니를
// 준비 시간(70%)동안 플레이어 방향으로 실시간 회전
float dsReady = dashReadyDuration * 0.7f; // 계산할거에요 -> 콤보 준비 시간을
float dsElapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (dsElapsed < dsReady) // 반복할거에요 -> 준비 시간 동안
{
dsElapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을
if (_target != null) // 조건이 맞으면 실행할거에요 -> 타겟 있으면
{
Vector3 toT = _target.position - transform.position; // 계산할거에요 -> 방향을
toT.y = 0f; // 제거할거에요 -> 높이를
if (toT.sqrMagnitude > 0.001f)
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(toT), Time.deltaTime * 15f); // 회전할거에요 -> 빠르게
}
yield return null; // 기다릴거에요 -> 1프레임을
}
// 발사 직전 방향 즉시 확정
Vector3 dashDir = _target != null
? (new Vector3(_target.position.x, transform.position.y, _target.position.z) - transform.position).normalized // 계산할거에요 -> 최신 방향을
: transform.forward; // 사용할거에요 -> 기본 방향을
if (dashDir.sqrMagnitude > 0.001f)
transform.rotation = Quaternion.LookRotation(dashDir); // 설정할거에요 -> 즉시 스냅
if (_rb != null)
{
_rb.isKinematic = false; // 켤거에요 -> 물리를
_rb.velocity = dashDir * dashSpeed * 1.2f; // 설정할거에요 -> 가속 돌진을
}
_isDashing = true; // 설정할거에요 -> 돌진 중으로
if (animator != null) animator.Play(anim_DashGo, 0, 0f); // 재생할거에요 -> 돌진 애니를
yield return new WaitForSeconds(dashDuration * 0.8f); // 기다릴거에요 -> 돌진 시간 80%만 이동 후 찍기 연계
// 돌진 중단
_isDashing = false; // 해제할거에요 -> 돌진 상태를
if (_rb != null) { _rb.velocity = Vector3.zero; _rb.isKinematic = true; } // 멈출거에요 -> 속도를
// ── 찍기 파트 ──────────────────────────────────────
LookAt(_target != null ? _target.position : transform.position + transform.forward); // 바라볼거에요 -> 플레이어를
_smashHitFired = false; // 초기화할거에요 -> 찍기 플래그를
_animEndFired = false; // 초기화할거에요 -> 종료 플래그를
if (animator != null) animator.Play(anim_SmashImpact, 0, 0f); // 재생할거에요 -> 찍기 애니를
yield return null; // 기다릴거에요 -> 1프레임을
yield return StartCoroutine(WaitSmashHit(6f, "DashSmash")); // 기다릴거에요 -> 찍기 신호를
float dmg = smashDamage * phase2DamageMult * 1.3f; // 계산할거에요 -> 콤보 보너스 데미지를
ApplyAreaDamage(smashRadius * 1.3f, dmg, smashHeightOffset, "돌진+찍기 콤보"); // 실행할거에요 -> 범위 데미지를 (범위 30% 확대)
yield return StartCoroutine(WaitAnimEnd(6f, "DashSmash")); // 기다릴거에요 -> 종료 신호를
if (agent != null && _isBattleStarted) { agent.enabled = true; yield return new WaitForSeconds(0.05f); } // 켤거에요 -> NavMesh를
OnAttackEnd(); // 실행할거에요 -> 공격 종료 처리를
}
// ──────────────────────────────────────────────────────────
// 범위 데미지 공통 함수
// ✅ 버그③ 수정: heightOffset으로 판정 중심 높이 보정
// GetComponentInParent 추가로 계층 구조 대응
// ──────────────────────────────────────────────────────────
private void ApplyAreaDamage(float radius, float damage, float heightOffset, string attackName) // 함수를 선언할거에요 -> 범위 안 플레이어에게 데미지를 주는
{
Vector3 center = transform.position + Vector3.up * heightOffset; // 계산할거에요 -> 보정된 판정 중심을 ← 핵심 수정
Collider[] hits = Physics.OverlapSphere(center, radius); // 검사할거에요 -> 반경 안의 콜라이더를
foreach (Collider hit in hits) // 반복할거에요 -> 감지된 콜라이더를
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어 아니면
// ✅ 자신 + 부모까지 탐색 (계층 구조 대응)
IDamageable dmg = hit.GetComponent<IDamageable>()
?? hit.GetComponentInParent<IDamageable>(); // 가져올거에요 -> 데미지 인터페이스를
if (dmg != null)
{
dmg.TakeDamage(damage); // 줄거에요 -> 데미지를
Debug.Log($"[Boss] {attackName} 적중! {damage:F0} 데미지"); // 로그를 찍을거에요
}
else Debug.LogWarning($"[Boss] {attackName}: Player에 IDamageable 없음!"); // 경고를 찍을거에요
}
}
// ──────────────────────────────────────────────────────────
// 돌진 충돌 판정
// ──────────────────────────────────────────────────────────
private void OnCollisionEnter(Collision col) // 함수를 실행할거에요 -> 물리 충돌 감지 시
{
if (!_isDashing) return; // 중단할거에요 -> 돌진 중 아니면
if (!col.gameObject.CompareTag("Player")) return; // 중단할거에요 -> 플레이어 아니면
IDamageable dmg = col.gameObject.GetComponent<IDamageable>()
?? col.gameObject.GetComponentInParent<IDamageable>(); // 가져올거에요 -> 데미지 인터페이스를
if (dmg != null)
{
float finalDmg = dashDamage * (_isPhase2 ? phase2DamageMult : 1f); // 계산할거에요 -> 보정 데미지를
dmg.TakeDamage(finalDmg); // 줄거에요 -> 돌진 충돌 데미지를
Debug.Log($"[Boss] 돌진 충돌! {finalDmg:F0} 데미지"); // 로그를 찍을거에요
}
}
// ──────────────────────────────────────────────────────────
// 쇠공 회수
// ──────────────────────────────────────────────────────────
private void RetrieveWeaponLogic() // 함수를 선언할거에요 -> 쇠공 위치로 이동 후 줍는
{
if (ironBall == null || agent == null || !agent.enabled || !agent.isOnNavMesh) return; // 중단할거에요 -> 조건 비정상이면
agent.SetDestination(ironBall.transform.position); // 이동할거에요 -> 쇠공 위치로
agent.isStopped = false; // 켤거에요 -> 이동을
UpdateMoveAnim(); // 갱신할거에요 -> 이동 애니를
float dist = Vector3.Distance(transform.position, ironBall.transform.position); // 계산할거에요 -> 쇠공 거리를
if (dist <= 2.5f && !isAttacking) StartCoroutine(PickUpRoutine()); // 실행할거에요 -> 줍기 코루틴을
}
private IEnumerator PickUpRoutine() // 코루틴을 정의할거에요 -> 쇠공 줍기를
{
OnAttackStart(); // 실행할거에요 -> 행동 잠금을
if (agent != null && agent.isOnNavMesh) { agent.isStopped = true; agent.velocity = Vector3.zero; } // 멈출거에요 -> 에이전트를
yield return StartCoroutine(PlayAndWait(anim_Pickup, 8f)); // 기다릴거에요 -> 줍기 애니 종료를
AttachBallToHand(); // 실행할거에요 -> 쇠공을 손에 부착 (오프셋 적용 + 물리 고정)
_isWeaponless = false; // 해제할거에요 -> 무기 없음 상태를
OnAttackEnd(); // 실행할거에요 -> 행동 해제를
if (agent != null && agent.isOnNavMesh) agent.isStopped = false; // 재개할거에요 -> 이동을
}
// ──────────────────────────────────────────────────────────
// 유틸 코루틴
// ──────────────────────────────────────────────────────────
private float GetCurrentClipLength() // 함수를 선언할거에요 -> 현재 재생 중인 클립 길이를 반환하는
{
if (animator == null) return 1f; // 반환할거에요 -> 기본값을
AnimatorClipInfo[] clips = animator.GetCurrentAnimatorClipInfo(0); // 가져올거에요 -> 현재 클립 정보를
if (clips.Length > 0) return clips[0].clip.length; // 반환할거에요 -> 클립 길이를
return 1f; // 반환할거에요 -> 클립 없으면 기본값을
}
/// <summary> 애니 재생 후 OnAnimEnd 대기 </summary>
private IEnumerator PlayAndWait(string animName, float maxWait = 10f) // 코루틴을 정의할거에요 -> 애니 재생 후 종료를 기다리는
{
yield return StartCoroutine(PlayAnimSafe(animName)); // 재생할거에요 -> 안전하게 애니를
yield return StartCoroutine(WaitAnimEnd(maxWait, animName)); // 기다릴거에요 -> 종료 신호를
}
/// <summary> Animator State 존재 확인 후 재생 </summary>
private IEnumerator PlayAnimSafe(string animName) // 코루틴을 정의할거에요 -> 안전하게 애니메이션을 재생하는
{
if (animator == null) yield break; // 중단할거에요 -> Animator 없으면
if (!animator.HasState(0, Animator.StringToHash(animName))) // 조건이 맞으면 실행할거에요 -> State 없으면
{
Debug.LogError($"[Boss] Animator State \"{animName}\" 없음! Inspector 이름 확인 필요"); // 에러를 찍을거에요
yield break; // 중단할거에요
}
_animEndFired = false; // 초기화할거에요 -> 이전 종료 신호를
animator.Play(animName, 0, 0f); // 재생할거에요 -> 해당 애니를
yield return null; // 기다릴거에요 -> 1프레임 (상태 전환 대기)
}
// ──────────────────────────────────────────────────────────
// WaitFlag 3종 — ref 대신 각 플래그 전용 코루틴으로 분리
// (C# 이터레이터는 ref 파라미터 불가 → 컴파일 에러 CS1623 수정)
// ──────────────────────────────────────────────────────────
private IEnumerator WaitAnimEnd(float maxWait, string label) // 코루틴을 정의할거에요 -> _animEndFired 플래그 대기를
{
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (!_animEndFired && elapsed < maxWait) { elapsed += Time.deltaTime; yield return null; } // 기다릴거에요 -> 플래그 올 때까지
if (!_animEndFired) Debug.LogWarning($"[Boss] {label} OnAnimEnd 미수신 — 강제 진행"); // 경고를 찍을거에요
_animEndFired = false; // 초기화할거에요 -> 플래그를
}
private IEnumerator WaitThrowFired(float maxWait) // 코루틴을 정의할거에요 -> _throwFired 플래그 대기를
{
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (!_throwFired && elapsed < maxWait) { elapsed += Time.deltaTime; yield return null; } // 기다릴거에요 -> 플래그 올 때까지
if (!_throwFired) Debug.LogWarning("[Boss] OnThrowRelease 미수신 — 강제 진행"); // 경고를 찍을거에요
_throwFired = false; // 초기화할거에요 -> 플래그를
}
private IEnumerator WaitSmashHit(float maxWait, string label) // 코루틴을 정의할거에요 -> _smashHitFired 플래그 대기를
{
float elapsed = 0f; // 초기화할거에요 -> 경과 시간을
while (!_smashHitFired && elapsed < maxWait) { elapsed += Time.deltaTime; yield return null; } // 기다릴거에요 -> 플래그 올 때까지
if (!_smashHitFired) Debug.LogWarning($"[Boss] {label} OnSmashHit 미수신 — 강제 진행"); // 경고를 찍을거에요
_smashHitFired = false; // 초기화할거에요 -> 플래그를
}
// ──────────────────────────────────────────────────────────
// 공격 시작 / 종료 / 회복
// ──────────────────────────────────────────────────────────
public override void OnAttackStart() // 함수를 정의할거에요 -> 공격 시작 공통 처리를
{
isAttacking = _isPerformingAction = true; // 설정할거에요 -> 공격/행동 중으로
if (agent != null && agent.isOnNavMesh) agent.isStopped = true; // 멈출거에요 -> 이동을
}
public override void OnAttackEnd() // 함수를 정의할거에요 -> 공격 종료 공통 처리를
{
StartCoroutine(RecoverRoutine()); // 시작할거에요 -> 회복 코루틴을
}
private IEnumerator RecoverRoutine() // 코루틴을 정의할거에요 -> 공격 후 휴식을
{
isAttacking = false; // 해제할거에요 -> 공격 상태를
isResting = true; // 설정할거에요 -> 휴식 상태로
if (animator != null) animator.Play(anim_Idle); // 재생할거에요 -> 대기 애니를
yield return new WaitForSeconds(patternInterval * 0.4f); // 기다릴거에요 -> 짧은 휴식 (간격의 40%)
isResting = false; // 해제할거에요 -> 휴식 상태를
_isPerformingAction = false; // 해제할거에요 -> 행동 상태를
}
// ──────────────────────────────────────────────────────────
// 이동 / 회전 유틸
// ──────────────────────────────────────────────────────────
private void AttachBallToHand() // 함수를 선언할거에요 -> 쇠공을 handHolder에 오프셋 적용해서 붙이는
{
if (ironBall == null || handHolder == null) return; // 중단할거에요 -> 없으면
Rigidbody rb = ironBall.GetComponent<Rigidbody>(); // 가져올거에요 -> Rigidbody를
if (rb != null) // 조건이 맞으면 실행할거에요 -> Rigidbody 있으면
{
rb.isKinematic = true; // 고정할거에요 -> 물리를
rb.velocity = Vector3.zero; // 초기화할거에요 -> 속도를
rb.angularVelocity = Vector3.zero; // 초기화할거에요 -> 회전 속도를
}
ironBall.transform.SetParent(handHolder); // 붙일거에요 -> 손에
ironBall.transform.localPosition = ballHoldOffset; // 설정할거에요 -> 오프셋 위치로 (Inspector에서 조정)
ironBall.transform.localRotation = Quaternion.identity; // 초기화할거에요 -> 로컬 회전을
}
private void LookAt(Vector3 targetPos) // 함수를 선언할거에요 -> 목표 방향으로 회전하는
{
Vector3 dir = targetPos - transform.position; // 계산할거에요 -> 방향 벡터를
dir.y = 0f; // 무시할거에요 -> 높이 차이를
if (dir.sqrMagnitude > 0.001f) // 조건이 맞으면 실행할거에요 -> 방향이 있으면
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), Time.deltaTime * 8f); // 회전할거에요 -> 부드럽게
}
private void UpdateMoveAnim() // 함수를 선언할거에요 -> 이동 애니메이션을 갱신하는
{
if (animator == null || _isPerformingAction || isAttacking || isHit || isDead) return; // 중단할거에요 -> 행동 불가 상태면
bool moving = agent != null && agent.velocity.magnitude > 0.1f; // 판단할거에요 -> 이동 중인지를
var state = animator.GetCurrentAnimatorStateInfo(0); // 가져올거에요 -> 현재 애니 상태를
if (moving && !state.IsName(anim_Walk)) animator.Play(anim_Walk); // 재생할거에요 -> 걷기 애니를
if (!moving && !state.IsName(anim_Idle) && !state.IsName(anim_Roar)) animator.Play(anim_Idle); // 재생할거에요 -> 대기 애니를
}
// ──────────────────────────────────────────────────────────
// 사망 / 피격 오버라이드
// ──────────────────────────────────────────────────────────
protected override void OnStartHit() { } // 비워둘거에요 -> 보스 슈퍼아머 (피격에 행동 안 끊김)
protected override void OnDie() // 함수를 정의할거에요 -> 사망 처리를
{
_isBattleStarted = _isPerformingAction = _isDashing = false; // 해제할거에요 -> 모든 상태를
if (bossHealthBar != null) bossHealthBar.SetActive(false); // 끌거에요 -> 체력바를
if (ObsessionSystem.instance != null) ObsessionSystem.instance.AddRunXP(100); // 지급할거에요 -> 경험치를
else Debug.LogError("[Boss] ObsessionSystem 인스턴스 없음!"); // 에러를 찍을거에요
base.OnDie(); // 실행할거에요 -> 부모 사망 처리를
}
// ══════════════════════════════════════════════════════════
// Inspector 패턴 테스트 버튼
// 플레이 중 Inspector에서 bool을 true로 체크 → 즉시 해당 패턴 실행
// 실행 후 자동으로 false로 리셋됩니다
// ══════════════════════════════════════════════════════════
[Header("=== 패턴 테스트 (플레이 중 체크) ===")]
[Tooltip("체크 시 쇠공 던지기 패턴 즉시 실행")]
[SerializeField] private bool _testThrow = false; // 변수를 선언할거에요 -> 던지기 테스트 트리거를
[Tooltip("체크 시 내려찍기 패턴 즉시 실행")]
[SerializeField] private bool _testSmash = false; // 변수를 선언할거에요 -> 찍기 테스트 트리거를
[Tooltip("체크 시 휩쓸기 패턴 즉시 실행")]
[SerializeField] private bool _testSweep = false; // 변수를 선언할거에요 -> 휩쓸기 테스트 트리거를
[Tooltip("체크 시 돌진 패턴 즉시 실행")]
[SerializeField] private bool _testDash = false; // 변수를 선언할거에요 -> 돌진 테스트 트리거를
[Tooltip("체크 시 연속 스매쉬 패턴 즉시 실행 (Phase2 패턴)")]
[SerializeField] private bool _testComboSmash = false; // 변수를 선언할거에요 -> 연속찍기 테스트 트리거를
[Tooltip("체크 시 돌진+찍기 콤보 즉시 실행 (Phase2 패턴)")]
[SerializeField] private bool _testDashSmash = false; // 변수를 선언할거에요 -> 돌진찍기 테스트 트리거를
[Tooltip("체크 시 Phase2 즉시 진입 (포효 연출 포함)")]
[SerializeField] private bool _testPhase2 = false; // 변수를 선언할거에요 -> Phase2 강제 진입 트리거를
private void PollTestButtons() // 함수를 선언할거에요 -> 매 프레임 Inspector 테스트 버튼을 감지하는
{
if (_isPerformingAction) return; // 중단할거에요 -> 패턴 실행 중이면 무시
if (_testThrow) { _testThrow = false; ForcePattern(BossPattern.Throw); return; } // 실행할거에요 -> 던지기 테스트를
if (_testSmash) { _testSmash = false; ForcePattern(BossPattern.Smash); return; } // 실행할거에요 -> 찍기 테스트를
if (_testSweep) { _testSweep = false; ForcePattern(BossPattern.Sweep); return; } // 실행할거에요 -> 휩쓸기 테스트를
if (_testDash) { _testDash = false; ForcePattern(BossPattern.Dash); return; } // 실행할거에요 -> 돌진 테스트를
if (_testComboSmash) { _testComboSmash = false; ForcePattern(BossPattern.ComboSmash); return; } // 실행할거에요 -> 연속찍기 테스트를
if (_testDashSmash) { _testDashSmash = false; ForcePattern(BossPattern.DashSmash); return; } // 실행할거에요 -> 돌진찍기 테스트를
if (_testPhase2) // 조건이 맞으면 실행할거에요 -> Phase2 테스트이면
{
_testPhase2 = false; // 초기화할거에요 -> 트리거를
_phase2Triggered = false; // 초기화할거에요 -> 이미 발동됨 플래그를 (재발동 허용)
StartCoroutine(EnterPhase2()); // 시작할거에요 -> Phase2 진입 코루틴을
}
}
private void ForcePattern(BossPattern pattern) // 함수를 선언할거에요 -> 패턴을 즉시 강제 실행하는
{
// 전투 미시작 상태에서도 테스트 가능하도록 타겟 자동 탐색
if (_target == null) // 조건이 맞으면 실행할거에요 -> 타겟 없으면
{
GameObject p = GameObject.FindWithTag("Player"); // 찾을거에요 -> 플레이어를
if (p != null) _target = playerTransform = p.transform; // 설정할거에요 -> 타겟을
}
Debug.Log($"[BossTest] 강제 패턴 실행: {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.ComboSmash: StartCoroutine(Pattern_ComboSmash()); break;
case BossPattern.DashSmash: StartCoroutine(Pattern_DashSmash()); break;
}
}
// ──────────────────────────────────────────────────────────
// 디버그 기즈모
// ──────────────────────────────────────────────────────────
private void OnDrawGizmosSelected() // 함수를 실행할거에요 -> 씬 뷰 선택 시 판정 범위 시각화를
{
Vector3 smashCenter = transform.position + Vector3.up * smashHeightOffset; // 계산할거에요 -> 찍기 판정 중심을
Vector3 sweepCenter = transform.position + Vector3.up * sweepHeightOffset; // 계산할거에요 -> 휩쓸기 판정 중심을
Gizmos.color = Color.red; Gizmos.DrawWireSphere(smashCenter, smashRadius); // 그릴거에요 -> 찍기 범위를
Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(sweepCenter, sweepRadius); // 그릴거에요 -> 휩쓸기 범위를
Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(transform.position, meleeRange); // 그릴거에요 -> 근접 사거리를
if (ironBall != null) { Gizmos.color = Color.magenta; Gizmos.DrawWireSphere(ironBall.transform.position, ballLandRadius); } // 그릴거에요 -> 쇠공 착지 범위를
}
}