Projext/Assets/02_Scripts/Enemy/BossAI/BossIronBall.cs

792 lines
60 KiB
C#
Raw Normal View History

using UnityEngine; // 유니티 기능을 불러올거에요 -> UnityEngine을
using System.Collections; // 코루틴 기능을 불러올거에요 -> IEnumerator를 (LaunchRoutine에 필요)
// ============================================================
// BossIronBall.cs ─ 쇠공 전용 스크립트
//
// [이 스크립트가 하는 일]
// 보스 쇠공 오브젝트가 스스로 물리/데미지를 처리해요.
// NorcielBoss.cs는 ironBall.Launch(targetPos) 한 줄만 호출하면 돼요.
//
// [Unity 설정 체크리스트]
// □ 쇠공 오브젝트에 이 스크립트 부착
// □ Rigidbody → Mass=10 (묵직한 쇠공 느낌), Drag=0, Use Gravity ON, Is Kinematic ON (시작값)
// Physics Material → Bounciness=0 (탄성 없음), Dynamic Friction=0.6
// (Physics Material 없으면 공이 플레이어에 맞고 퉁 튀어오를 수 있어요)
// □ Collider → Sphere Collider, Is Trigger OFF
// □ Layer → BossWeapon
// □ Physics Matrix (Project Settings → Physics)
// BossWeapon ↔ Floor : ✅ 체크 (착지 감지)
// BossWeapon ↔ Player : ✅ 체크 (직접 충돌 데미지)
// BossWeapon ↔ BossBody: ❌ 해제 (보스 몸통 통과)
//
// [상태 흐름]
// Held ──(Launch 호출)──────────────▶ Flying ──(OnTriggerEnter 착지)──▶ Landed
// Landed ──(BeginPickup 호출)───────▶ PickingUp ──(목표 위치 도달)────▶ Held
// Landed ──(AttachToHand 폴백)──────▶ Held
// ============================================================
public class BossIronBall : MonoBehaviour // 클래스를 선언할거에요 -> MonoBehaviour를 상속받는 BossIronBall을
{
// ══════════════════════════════════════════════════════════
// 상태 열거형
// ══════════════════════════════════════════════════════════
public enum BallState // 열거형을 선언할거에요 -> 쇠공의 상태 종류를
{
Held, // 보스 손에 완전히 고정됨 (holdOffset 위치)
Flying, // 날아가는 중
Landed, // 바닥에 착지함
PickingUp // 줍기 모션 중: 손 로컬 좌표에서 holdOffset으로 부드럽게 이동 중
}
// ══════════════════════════════════════════════════════════
// Inspector 슬롯
// ══════════════════════════════════════════════════════════
[Header("=== 데미지 ===")]
[Tooltip("착지 / 직격 데미지")]
[SerializeField] private float damage = 20f; // 변수를 선언할거에요 -> 기본 데미지를
[Tooltip("착지 범위 판정 반경 (m) — 바닥에 닿을 때 이 반경 안 플레이어에게 데미지")]
[SerializeField] private float landRadius = 2.5f; // 변수를 선언할거에요 -> 착지 판정 반경을
[Header("=== 직선 투척 설정 ===")]
[Tooltip("투척 속도 (m/s)\n" +
"묵직하고 빠른 쇠공 느낌: 15~20 / 매우 빠름: 20~30\n" +
"중력 보정이 자동 계산되므로 어떤 속도에서도 플레이어에 정확히 맞아요")]
[SerializeField] private float launchSpeed = 18f; // 변수를 선언할거에요 -> 직선 투척 속도를 (m/s, 높을수록 빠르고 묵직한 느낌)
[Tooltip("속도 배율 — launchSpeed 에 곱해요\n" +
"1.0 = 기본 / 1.3 = 30% 빠름 / 1.5 = 50% 빠름\n" +
"착지 정확도는 자동 중력 보정으로 항상 유지돼요")]
[Range(1.0f, 2.5f)]
[SerializeField] private float throwPower = 1.0f; // 변수를 선언할거에요 -> 속도 배율을 (launchSpeed × throwPower = 최종 속도)
[Header("=== 넉백 설정 (인왕 쇠공 느낌) ===")]
[Tooltip("플레이어를 수평으로 날리는 힘 (m/s). 추천: 6~10\n0이면 넉백 없음")]
[SerializeField] private float knockbackForce = 8f; // 변수를 선언할거에요 -> 수평 넉백 세기를 (공에 맞고 뒤로 날아가는 거리)
[Tooltip("플레이어를 위로 살짝 튀기는 힘 (m/s). 추천: 1~4\n0이면 수평만")]
[SerializeField] private float knockbackUpForce = 2f; // 변수를 선언할거에요 -> 수직 넉백 세기를 (살짝 공중으로 뜨는 느낌)
[Tooltip("넉백+경직 지속 시간 (초). 추천: 0.4~0.7\n이 시간 동안 이동 입력 차단")]
[SerializeField] private float knockbackDuration = 0.5f; // 변수를 선언할거에요 -> 넉백 지속 시간을 (짧으면 살짝, 길면 그로기 느낌)
[Header("=== 손 부착 오프셋 ===")]
[Tooltip("공이 손 위치에 안 맞으면 Y값을 -(마이너스)로 조정\n예시: (0, -0.3, 0.1)")]
[SerializeField] public Vector3 holdOffset = Vector3.zero; // 변수를 선언할거에요 -> 손 부착 오프셋을 (public: 보스에서 읽기)
[Header("=== 줍기 모션 설정 ===")]
[Tooltip("줍기 애니 재생 시 공이 손으로 이동하는 보간 속도\n" +
"낮을수록 천천히 손에 빨려들어가는 느낌 (추천: 3~6)\n" +
"높을수록 즉시 손 위치로 이동 (추천: 8~12)")]
[Range(1f, 15f)]
[SerializeField] private float pickupLerpSpeed = 5f; // 변수를 선언할거에요 -> 공이 손으로 이동하는 lerp 속도를 (높을수록 빠르게 손에 붙음)
// ══════════════════════════════════════════════════════════
// 런타임 상태 (외부 읽기 전용)
// ══════════════════════════════════════════════════════════
public BallState State { get; private set; } = BallState.Held; // 프로퍼티를 선언할거에요 -> 현재 상태를 (외부는 읽기만)
// ══════════════════════════════════════════════════════════
// 내부 변수
// ══════════════════════════════════════════════════════════
private Rigidbody _rb; // 변수를 선언할거에요 -> Rigidbody 캐시를
private Collider _col; // 변수를 선언할거에요 -> Collider 캐시를 (비행 중 isTrigger 전환용)
private float _dmgMult = 1f; // 변수를 선언할거에요 -> 데미지 배율을 (Phase2용)
private bool _hasDealt = false; // 변수를 선언할거에요 -> 이미 데미지 처리했는지를 (중복 방지)
private Coroutine _launchRoutine; // 변수를 선언할거에요 -> 발사 코루틴 참조를 (중복 발사 방지용)
// ── Flying 상태 타임아웃 (공중 무한 방지) ──────────────────────
[Header("=== Flying 타임아웃 ===")]
[Tooltip("Flying 상태로 이 시간(초)이 지나면 강제 착지 처리\n" +
"공이 지형에 끼이거나 벽에 걸려 무한히 Flying 상태로 남는 것을 방지해요")]
[SerializeField] private float flyingTimeout = 5f; // 변수를 선언할거에요 -> Flying 타임아웃 시간을 (초, 이 시간 동안 착지 못 하면 강제 Landed)
private float _flyingTimer = 0f; // 변수를 선언할거에요 -> Flying 경과 시간을 (FixedUpdate에서 누적)
// ── 바닥 관통 방지 안전장치 ──────────────────────────────────────
[Header("=== 바닥 관통 방지 ===")]
[Tooltip("이 Y좌표 아래로 내려가면 공이 바닥을 뚫은 것으로 판단해요\n" +
"맵의 바닥 높이보다 약간 아래로 설정하세요 (예: 바닥이 0이면 -1)")]
[SerializeField] private float fallThroughMinY = -2f; // 변수를 선언할거에요 -> 관통 판정 Y 임계값을 (이 아래 = 관통)
[Tooltip("관통 감지 시 공을 복귀시킬 Y 좌표\n" +
"맵 바닥 높이 + 약간 위 (예: 0.5)")]
[SerializeField] private float safeGroundY = 0.5f; // 변수를 선언할거에요 -> 안전한 지면 Y좌표를 (관통 시 이 높이로 복귀)
private Vector3 _lastSafePosition; // 변수를 선언할거에요 -> 마지막으로 안전했던 위치를 (관통 감지 시 복귀 지점)
// ── 충돌 후 속도 복원 (공이 플레이어에 맞고 밀리지 않게) ─────────
// Unity 물리 엔진은 Rigidbody와 Collider가 충돌하면 자동으로 반발 속도를 적용해요.
// 플레이어는 CharacterController라 실제로 밀리지 않지만, 공(Rigidbody)은 반발력을 받아 방향이 꺾여요.
// FixedUpdate에서 충돌 직전 속도를 매 프레임 저장해뒀다가, 플레이어 히트 직후 다음 FixedUpdate에서
// 저장된 속도를 복원하면 → 공이 방향 꺾임 없이 원래 궤도로 계속 날아가요.
private Vector3 _preCollisionVelocity = Vector3.zero; // 변수를 선언할거에요 -> 충돌 직전 속도를 (매 FixedUpdate 갱신)
private bool _shouldRestoreVelocity = false; // 변수를 선언할거에요 -> 다음 FixedUpdate에서 속도 복원 여부를
private Transform _bossRoot; // 변수를 선언할거에요 -> 공을 던진 보스의 루트 Transform을 (발사 시 보스 몸체 충돌 무시용)
// ══════════════════════════════════════════════════════════
// 초기화
// ══════════════════════════════════════════════════════════
private void Awake() // 함수를 실행할거에요 -> 컴포넌트 캐싱을
{
_rb = GetComponent<Rigidbody>(); // 가져올거에요 -> Rigidbody를 (GC 방지를 위해 캐싱)
_col = GetComponent<Collider>(); // 가져올거에요 -> Collider를 (비행 중 isTrigger 전환용)
if (_rb == null) // 조건이 맞으면 실행할거에요 -> Rigidbody 없으면
{
Debug.LogError("[BossIronBall] Rigidbody가 없어요! 쇠공 오브젝트에 Rigidbody를 추가하세요."); // 에러를 찍을거에요
return; // 중단할거에요
}
// ── Continuous 충돌 감지 모드 설정 (바닥 관통 1차 방어) ────────
// Discrete 모드는 빠른 물체가 한 프레임 만에 콜라이더를 통과할 수 있어요.
// ContinuousDynamic은 프레임 사이 궤적을 연속으로 검사해서 관통을 크게 줄여요.
_rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; // 설정할거에요 -> 연속 충돌 감지 모드를 (관통 방지 1차 방어)
// ── 마지막 안전 위치 초기화 ─────────────────────────────────────
_lastSafePosition = transform.position; // 저장할거에요 -> 시작 위치를 첫 안전 위치로
// ⚠️ 진단: Freeze Position 제약이 걸려있으면 수평 이동이 막혀요!
// 공이 위로만 올라갔다 떨어지는 증상의 주요 원인이에요.
if (_rb.constraints != RigidbodyConstraints.None) // 조건이 맞으면 실행할거에요 -> 제약 있으면
Debug.LogWarning($"[BossIronBall] ⚠️ Rigidbody constraints 감지: {_rb.constraints}\n" +
"Freeze Position X 또는 Z 가 체크되어 있으면 공이 수직으로만 이동해요!\n" +
"Launch() 호출 시 자동으로 해제하지만, Inspector에서 None으로 설정해두세요."); // 경고를 찍을거에요
Freeze(); // 실행할거에요 -> 시작 시 물리 고정을 (손에 들린 상태이므로)
}
// ══════════════════════════════════════════════════════════
// 물리 업데이트 (충돌 전 속도 저장 + 복원)
// ══════════════════════════════════════════════════════════
private void FixedUpdate() // 함수를 실행할거에요 -> 물리 프레임마다
{
// ══════════════════════════════════════════════════════════════
// ★ 바닥 관통 안전장치 (모든 상태에서 항상 체크) ★
// 공이 어떤 상태든 fallThroughMinY 아래로 내려가면 관통으로 판정해요.
// 마지막 안전 위치로 강제 복귀시키고 Landed 상태로 전환해요.
// 이렇게 하면 보스가 공을 주우러 올 수 있어서 게임이 멈추지 않아요.
// ══════════════════════════════════════════════════════════════
if (transform.position.y < fallThroughMinY) // 조건이 맞으면 실행할거에요 -> 공이 관통 임계값 아래로 내려갔으면
{
Debug.LogWarning($"[BossIronBall] ⚠️ 바닥 관통 감지! Y={transform.position.y:F2} < {fallThroughMinY} " +
$"안전 위치로 강제 복귀해요 → ({_lastSafePosition.x:F1}, {safeGroundY:F1}, {_lastSafePosition.z:F1})"); // 경고를 찍을거에요
// ── 비행 중 코루틴 안전 종료 ─────────────────────────────
if (_launchRoutine != null) // 조건이 맞으면 실행할거에요 -> 발사 코루틴이 있으면
{
StopCoroutine(_launchRoutine); // 중단할거에요 -> 코루틴을
_launchRoutine = null; // 초기화할거에요 -> 참조를
}
// ── 물리 고정 + 위치 강제 복귀 ─────────────────────────────
Freeze(); // 실행할거에요 -> 물리를 완전히 고정 (isKinematic + FreezeAll)
Vector3 safePos = new Vector3( // 만들거에요 -> 안전한 복귀 위치를
_lastSafePosition.x, // X: 마지막 안전 위치의 X좌표
safeGroundY, // Y: 안전한 지면 높이 (인스펙터에서 조정 가능)
_lastSafePosition.z // Z: 마지막 안전 위치의 Z좌표
);
transform.position = safePos; // 설정할거에요 -> 공의 위치를 안전 위치로 강제 이동
State = BallState.Landed; // 전환할거에요 -> Landed로 (보스가 주우러 올 수 있게)
_hasDealt = false; // 초기화할거에요 -> 데미지 플래그를
_flyingTimer = 0f; // 초기화할거에요 -> 타이머를
return; // 중단할거에요 -> 나머지 로직 불필요
}
// ── 안전 위치 갱신 (Y가 정상 범위일 때만 저장) ────────────────────
// 관통 감지 시 이 위치로 복귀하므로, 정상 높이일 때만 갱신해요.
if (transform.position.y >= fallThroughMinY + 1f) // 조건이 맞으면 실행할거에요 -> Y가 임계값보다 충분히 위에 있으면
{
_lastSafePosition = transform.position; // 갱신할거에요 -> 마지막 안전 위치를
}
if (State != BallState.Flying) return; // 중단할거에요 -> 날아가는 중이 아니면 (불필요한 연산 방지)
// ── ⓪ Flying 타임아웃 체크 ─────────────────────────────────
// 공이 지형에 끼이거나, 벽 사이에 걸리거나, 바닥 충돌을 못 감지하면
// Flying 상태가 영원히 풀리지 않아요 → 보스가 공을 못 주워서 무한히 걸어다니는 버그!
// flyingTimeout 초가 지나면 현재 위치에서 강제 착지(Landed) 처리해요.
_flyingTimer += Time.fixedDeltaTime; // 누적할거에요 -> Flying 경과 시간을 (물리 프레임 단위)
if (_flyingTimer >= flyingTimeout) // 조건이 맞으면 실행할거에요 -> 타임아웃 초과 시
{
Debug.LogWarning($"[BossIronBall] ⚠️ Flying 타임아웃! ({flyingTimeout}초 경과) " +
$"현재 위치에서 강제 착지 처리해요. pos={transform.position:F2}"); // 경고를 찍을거에요
State = BallState.Landed; // 전환할거에요 -> 강제 Landed로 (보스가 주으러 올 수 있게)
Freeze(); // 실행할거에요 -> 물리 고정을 (더 이상 이동 안 함)
_flyingTimer = 0f; // 초기화할거에요 -> 타이머를
return; // 중단할거에요 -> 아래 속도 복원 로직 불필요
}
// ① 속도 복원: 플레이어와 충돌 직후 FixedUpdate에서 실행
// OnCollisionEnter에서 _shouldRestoreVelocity = true 로 설정됐으면
// 충돌 직전에 저장해뒀던 속도를 복원해서 공이 원래 궤도로 계속 날아가게 해요.
if (_shouldRestoreVelocity) // 조건이 맞으면 실행할거에요 -> 복원 플래그가 켜져 있으면
{
_rb.velocity = _preCollisionVelocity; // 복원할거에요 -> 충돌 직전 속도를 (물리 반발력 무효화)
_shouldRestoreVelocity = false; // 끌거에요 -> 복원 플래그를 (1회만 실행)
}
// ② 다음 프레임을 위해 현재 속도 저장 (충돌 직전 스냅샷)
// 순서 중요: 복원(①) 이후에 저장해야 복원된 속도가 다음 충돌 시에도 유지돼요.
_preCollisionVelocity = _rb.velocity; // 저장할거에요 -> 이번 FixedUpdate 속도를 (다음 충돌 시 복원 기준값)
}
// ══════════════════════════════════════════════════════════
// 줍기 모션 lerp (매 프레임 localPosition → holdOffset 이동)
// ══════════════════════════════════════════════════════════
private void LateUpdate() // 함수를 실행할거에요 -> 애니메이터 갱신 이후 매 프레임 (뼈대 위치 반영 후 실행)
{
if (State != BallState.PickingUp) return; // 중단할거에요 -> 줍기 모션 중이 아니면 (불필요한 연산 방지)
// ── 손 로컬 좌표에서 holdOffset을 향해 부드럽게 이동 ────────────────────────
//
// [동작 원리]
// BeginPickup() 호출 시 공을 handHolder의 자식으로 설정해요.
// 이 시점 localPosition = (공의 월드 위치)를 handHolder 기준으로 변환한 값
// = 바닥에 있는 공의 로컬 위치 (손에서 멀리 떨어진 값)
//
// 매 LateUpdate마다 이 localPosition을 holdOffset(손 중심)으로 lerp 해요.
// 동시에 Animator가 handHolder를 내려보내는 모션을 재생하므로:
// → 손이 공 쪽으로 내려옴 (애니메이션)
// → 공이 손 쪽으로 올라감 (lerp)
// = 두 움직임이 합쳐져 자연스러운 줍기처럼 보여요
//
// LateUpdate를 쓰는 이유:
// Animator가 Update에서 뼈대를 이동시키므로
// LateUpdate에서 처리해야 "이번 프레임의 손 위치"를 기준으로 정확히 보간할 수 있어요.
transform.localPosition = Vector3.Lerp( // 보간할거에요 -> 현재 로컬 위치를
transform.localPosition, // 시작값: 현재 위치 (이전 프레임의 결과)
holdOffset, // 목표값: 손 중심 오프셋
Time.deltaTime * pickupLerpSpeed // 비율: 프레임 속도에 무관하게 일정하게
);
transform.localRotation = Quaternion.Lerp( // 보간할거에요 -> 현재 로컬 회전을
transform.localRotation, // 시작값: 현재 회전
Quaternion.identity, // 목표값: 회전 없음 (손 방향 그대로)
Time.deltaTime * pickupLerpSpeed // 비율: 위와 동일
);
// ── 도착 판정 ──────────────────────────────────────────────────────────
// holdOffset과의 거리가 2cm 미만이면 완전히 붙은 것으로 간주해요.
if (Vector3.Distance(transform.localPosition, holdOffset) < 0.02f) // 조건이 맞으면 실행할거에요 -> 목표에 충분히 가까우면
{
transform.localPosition = holdOffset; // 설정할거에요 -> 정확히 holdOffset으로 (lerp 오차 제거)
transform.localRotation = Quaternion.identity; // 초기화할거에요 -> 회전을
_rb.constraints = RigidbodyConstraints.FreezeAll; // 잠글거에요 -> 모든 축을 (Held 상태 안전 고정)
State = BallState.Held; // 전환할거에요 -> Held로 (줍기 완료)
Debug.Log("[BossIronBall] ✅ 줍기 완료 → Held (lerp 도달)"); // 로그를 찍을거에요
}
}
// ══════════════════════════════════════════════════════════
// 외부 API (NorcielBoss.cs 에서만 호출)
// ══════════════════════════════════════════════════════════
/// <summary>
/// 보스 손에 공을 붙여요.
/// 보스 Init() 시작 시, 줍기 완료 시 호출.
/// </summary>
public void AttachToHand(Transform handHolder) // 함수를 선언할거에요 -> 손에 공을 부착하는
{
if (handHolder == null) // 조건이 맞으면 실행할거에요 -> handHolder 없으면
{
Debug.LogWarning("[BossIronBall] handHolder가 null이에요! Inspector에서 손 본(Transform)을 연결해주세요."); // 경고를 찍을거에요
return; // 중단할거에요
}
Freeze(); // 실행할거에요 -> 물리 고정을 (Freeze 내부에서 isTrigger=false 도 처리됨)
_bossRoot = handHolder.root; // 저장할거에요 -> 보스 루트 Transform을 (OnCollisionEnter에서 보스 몸체 충돌 무시에 사용)
transform.SetParent(handHolder); // 붙일거에요 -> 손의 자식으로 (자동으로 손 움직임 따라감)
transform.localPosition = holdOffset; // 설정할거에요 -> 오프셋 위치로 (Inspector에서 조정 가능)
transform.localRotation = Quaternion.identity; // 초기화할거에요 -> 로컬 회전을
State = BallState.Held; // 설정할거에요 -> Held 상태로
_hasDealt = false; // 초기화할거에요 -> 데미지 처리 여부를 (다음 발사 때 다시 데미지 줄 수 있게)
}
/// <summary>
/// 줍기 모션과 동시에 공을 손에 부착하여 애니메이션과 함께 움직이게 해요.
/// PickupBall() 코루틴에서 줍기 애니 재생 직후 즉시 호출.
///
/// [동작]
/// 1) 공을 handHolder의 자식으로 즉시 설정 (월드 위치 유지)
/// 2) LateUpdate에서 매 프레임 localPosition → holdOffset으로 lerp
/// 3) 목표 도달 시 State = Held (자동)
/// 4) AttachToHand()는 폴백용으로만 사용 (애니 종료 후에도 못 잡은 경우)
/// </summary>
public void BeginPickup(Transform handHolder) // 함수를 선언할거에요 -> 줍기 모션과 동시에 손에 부착을 시작하는
{
if (handHolder == null) // 조건이 맞으면 실행할거에요 -> handHolder 없으면
{
Debug.LogWarning("[BossIronBall] BeginPickup: handHolder가 null이에요!"); // 경고를 찍을거에요
return; // 중단할거에요
}
// ── 비행 중 코루틴 안전 종료 ─────────────────────────────
// 정상 흐름에서는 Landed 상태이므로 실행 안 되지만, 예외 방어용이에요.
if (_launchRoutine != null) // 조건이 맞으면 실행할거에요 -> 이전 발사 코루틴이 있으면
{
StopCoroutine(_launchRoutine); // 중단할거에요 -> 코루틴을
_launchRoutine = null; // 초기화할거에요 -> 참조를
}
// ── 물리 비활성화 ────────────────────────────────────────
// FreezeAll 없이 kinematic만 설정해요.
// LateUpdate에서 transform.localPosition을 직접 수정할 거라
// FreezeAll이 없어도 물리 힘은 차단되고 lerp 이동은 자유롭게 가능해요.
if (_col != null) _col.isTrigger = false; // 초기화할거에요 -> Trigger 모드를 (일반 콜리전으로)
_rb.isKinematic = true; // 설정할거에요 -> kinematic으로 (물리 힘 차단)
_rb.constraints = RigidbodyConstraints.None; // 해제할거에요 -> FreezeAll 제약을 (lerp 이동을 위해)
// ── 보스 루트 저장 ────────────────────────────────────────
_bossRoot = handHolder.root; // 저장할거에요 -> 보스 루트 Transform을
// ── 손 본에 부착 (현재 월드 위치 유지) ────────────────────
// SetParent 호출 전 월드 위치를 저장해두고, 부착 후 다시 설정해요.
// 이렇게 하면 공이 순간이동 없이 현재 위치(바닥)에서 시작해요.
// localPosition은 자동으로 "바닥 위치 - handHolder 위치"의 로컬 값이 됨.
Vector3 worldPos = transform.position; // 저장할거에요 -> 현재 월드 위치를 (바닥의 공 위치)
transform.SetParent(handHolder); // 설정할거에요 -> 부모를 handHolder로 (손의 자식이 됨)
transform.position = worldPos; // 복원할거에요 -> 월드 위치를 (localPosition이 바닥 기준 로컬값으로 자동 계산)
// ── 상태 전환 → PickingUp ──────────────────────────────────
// 이 시점부터 LateUpdate가 매 프레임 localPosition을 holdOffset으로 lerp 해요.
_hasDealt = false; // 초기화할거에요 -> 데미지 처리 여부를
State = BallState.PickingUp; // 전환할거에요 -> PickingUp 상태로
Debug.Log($"[BossIronBall] 🤲 줍기 시작! " +
$"로컬위치={transform.localPosition:F2} 목표={holdOffset:F2} " +
$"속도={pickupLerpSpeed}"); // 로그를 찍을거에요 -> 줍기 시작 정보를
}
/// <summary>
/// 목표 위치로 포물선 발사.
/// NorcielBoss.cs의 Pattern_Throw에서 발사 타이밍에 호출.
/// targetTransform : 플레이어 Transform (실시간 위치 추적)
/// aimHeight : 조준 높이 오프셋 (0=발, 0.7=복부, 1.0=가슴)
/// damageMult : Phase2 등 데미지 배율
/// </summary>
public void Launch(Transform targetTransform, float aimHeight = 0.7f, float damageMult = 1f) // 함수를 선언할거에요 -> 발사를 코루틴으로 시작하는 (Transform 전달로 실시간 위치 추적)
{
if (_rb == null) // 조건이 맞으면 실행할거에요 -> Rigidbody 없으면
{
Debug.LogError("[BossIronBall] Rigidbody 없어요! Launch 실패."); // 에러를 찍을거에요
return; // 중단할거에요
}
_dmgMult = damageMult; // 저장할거에요 -> 데미지 배율을 (Phase2에서 1.4 등)
_hasDealt = false; // 초기화할거에요 -> 중복 데미지 방지 플래그를
// ⚠️ 이전 발사 잔여 상태 초기화 (2번째 발사부터 빗나가는 버그 핵심 수정)
//
// [버그 시나리오]
// ① OnCollisionEnter(Player) → _shouldRestoreVelocity = true 설정
// ② OnCollisionEnter(Ground) → State = Landed / Freeze() 호출 (바로 이어서)
// ③ FixedUpdate → State != Flying → 조기 return
// → _shouldRestoreVelocity 가 true 인 채로 잔류!
// ④ 다음 Launch() / State = Flying 이 된 직후 FixedUpdate 실행
// → _shouldRestoreVelocity = true 감지
// → _rb.velocity 를 이전 발사 때의 충돌 전 속도로 덮어씌움 → 빗나감!
//
// [해결] Launch() 진입 시 이전 발사의 잔여 플래그/속도를 모두 0으로 초기화해요.
_shouldRestoreVelocity = false; // 초기화할거에요 -> 이전 발사의 속도 복원 플래그를 (오염 방지)
_preCollisionVelocity = Vector3.zero; // 초기화할거에요 -> 이전 발사의 충돌 전 속도를 (오염 방지)
_flyingTimer = 0f; // 초기화할거에요 -> Flying 타임아웃 타이머를 (새 발사이므로 리셋)
_lastSafePosition = transform.position; // 갱신할거에요 -> 발사 위치를 안전 위치로 (관통 시 복귀 기준점)
// 이전 발사 코루틴이 아직 실행 중이면 강제 중단 후 새로 시작
if (_launchRoutine != null) // 조건이 맞으면 실행할거에요 -> 이전 코루틴 있으면
StopCoroutine(_launchRoutine); // 중단할거에요 -> 이전 코루틴을
_launchRoutine = StartCoroutine(LaunchRoutine(targetTransform, aimHeight)); // 시작할거에요 -> 발사 코루틴을 (Transform 전달)
}
/// <summary>
/// 실제 포물선 발사 로직.
/// WaitForFixedUpdate()로 isKinematic 해제가 물리 엔진에 완전히 반영된 뒤 velocity를 적용해요.
/// </summary>
private IEnumerator LaunchRoutine(Transform targetTransform, float aimHeight) // 코루틴을 정의할거에요 -> 실제 발사 물리 처리를 (Transform으로 실시간 위치 추적)
{
// ─ 손에서 분리 ─────────────────────────────────────
Vector3 worldPos = transform.position; // 저장할거에요 -> 분리 전 월드 위치를 (분리 후 틀어짐 방지)
transform.SetParent(null); // 분리할거에요 -> 손에서
transform.position = worldPos; // 복원할거에요 -> 월드 위치를 (부모가 바뀌어도 같은 위치 유지)
// ─ isTrigger = true (비행 중 충돌 힘 차단) ──────────────
//
// ⚠️ 빗나가는 근본 원인:
// Rigidbody 물리 충돌은 비행 중 언제든 velocity 를 바꿀 수 있어요.
// - 보스 몸체 depenetration → 발사 위치 이동
// - 지형/장애물 경계면 마찰 → 속도 미세 변경
// - 매 FixedUpdate마다 누적되는 물리 오차
//
// [해결] 비행 동안 isTrigger = true 로 전환해요.
// isTrigger = true 상태에서는:
// → 충돌 힘(반발력, depenetration)이 완전히 차단됨 → velocity 절대 불변
// → 중력만 남음 → 계산한 포물선 궤도를 100% 정확히 따라감
// → OnTriggerEnter 로 충돌 감지는 그대로 유지
// 착지 시 isTrigger = false 로 되돌려 자연스러운 구르기 복원
if (_col != null) _col.isTrigger = true; // 설정할거에요 -> Trigger 모드로 (비행 중 충돌 힘 완전 차단)
// ─ 물리 활성화 ─────────────────────────────────────────
_rb.constraints = RigidbodyConstraints.None; // 해제할거에요 -> 모든 위치/회전 제약을
_rb.isKinematic = false; // 해제할거에요 -> 물리를 (isTrigger=true라 depenetration 없음)
_rb.WakeUp(); // 깨울거에요 -> Sleep 상태를
_rb.velocity = Vector3.zero; // 초기화할거에요 -> 이전 속도를
_rb.angularVelocity = Vector3.zero; // 초기화할거에요 -> 이전 회전 속도를
// ─ FixedUpdate 한 프레임 대기 ───────────────────────────
// isKinematic=false 직후 바로 velocity 를 설정하면 Unity가 다음 FixedUpdate에서
// 리셋하는 경우가 있어요. WaitForFixedUpdate 로 전환이 완전히 반영된 뒤 설정해요.
// isTrigger=true 이므로 이 대기 동안 depenetration/충돌 영향 없음.
yield return new WaitForFixedUpdate(); // 기다릴거에요 -> 물리 업데이트 1사이클을 (전환 안정화)
// ─ 발사 위치 읽기 ────────────────────────────────────────
Vector3 launchPos = transform.position; // 저장할거에요 -> 실제 발사 순간 공의 위치를 (isTrigger 덕분에 depenetration 없는 정확한 값)
// ─ 타겟 위치를 발사 직전 순간에 다시 계산 ─────────────
// ⚠️ 핵심 개선:
// 이전 방식: Pattern_Throw()에서 throwTarget(Vector3)을 미리 계산해서 전달
// → throwTiming 대기 + WaitForFixedUpdate 동안 플레이어가 이동하면 빗나감
// 새 방식: Transform 레퍼런스를 전달받아 발사 직전에 실시간 위치를 읽음
// → 항상 최신 플레이어 위치를 기준으로 탄도 계산 → 명중률 100%
Vector3 targetPos; // 선언할거에요 -> 최종 조준 위치를
if (targetTransform != null) // 조건이 맞으면 실행할거에요 -> 타겟이 살아있으면
{
targetPos = targetTransform.position + Vector3.up * aimHeight; // 계산할거에요 -> 발사 직전 플레이어 실시간 위치 + 조준 높이를
Debug.DrawLine(launchPos, targetPos, Color.red, 3f); // 그릴거에요 -> 보스→조준점 라인을 (씬 뷰 디버그)
Debug.DrawRay(targetPos, Vector3.up * 1.5f, Color.green, 3f); // 그릴거에요 -> 조준점 위치 표시를 (씬 뷰 디버그)
Debug.Log($"[BossIronBall] 발사 기준점: {launchPos:F2} 조준점: {targetPos:F2}"); // 로그를 찍을거에요 -> 발사/조준 위치를
}
else // 조건이 맞으면 실행할거에요 -> 타겟이 없으면 (사망 등)
{
targetPos = launchPos + transform.forward * 12f; // 계산할거에요 -> 전방 12m 지점을 폴백으로 (타겟 없을 때)
Debug.LogWarning("[BossIronBall] ⚠️ targetTransform이 null이에요! 전방으로 발사해요."); // 경고를 찍을거에요
}
// ─ 속도 계산 (중력 보상 보장 공식) ─────────────────────
//
// [이전 탄도 방정식 역산 방식의 문제]
// tanθ 2차방정식 풀이는 수평 거리(hDist) 기준으로 계산하기 때문에
// 보스 손 높이 ≠ 플레이어 높이인 상황에서 속도 벡터 방향에 미세 오차가 생겼어요.
//
// [새 방식: 중력 보상 보장 공식]
//
// 물리 방정식: position(t) = launchPos + vel×t 0.5×g××up
// 목표: position(tTravel) = targetPos
//
// 위 식을 vel 에 대해 풀면:
// vel = (targetPos launchPos) / tTravel + 0.5 × g × tTravel × up
// = toTarget / t + 중력 보상
//
// 이 공식은 tTravel 을 어떻게 정해도 position(tTravel) = targetPos 가
// 항상 수학적으로 성립해요. (명중 100% 보장)
// tTravel = 3D 거리 / finalSpeed 로 설정하면 실제 속도 ≈ finalSpeed 에요.
//
float finalSpeed = launchSpeed * throwPower; // 계산할거에요 -> 최종 투척 속도를 (m/s)
finalSpeed = Mathf.Max(finalSpeed, 1f); // 보호할거에요 -> 0 이하 방지를
float g = Mathf.Abs(Physics.gravity.y); // 가져올거에요 -> 중력 크기를 (기본 9.81)
Vector3 toTarget = targetPos - launchPos; // 계산할거에요 -> 발사 위치→타겟 3D 벡터를
float distance = Mathf.Max(toTarget.magnitude, 0.1f); // 계산할거에요 -> 3D 직선 거리를 (0 나누기 방지)
float tTravel = distance / finalSpeed; // 계산할거에요 -> 예상 비행 시간을 (거리 ÷ 속도)
// vel = toTarget/t + 0.5*g*t*up → position(tTravel) = targetPos 가 항상 성립
Vector3 vel = toTarget / tTravel // 계산할거에요 -> 타겟 방향 속도 성분을 (중력 없을 때 필요한 속도)
+ Vector3.up * (0.5f * g * tTravel); // 더할거에요 -> 중력 낙하 보상용 상향 속도를 (비행 중 중력 낙하량을 미리 올려서 상쇄)
// ─ 발사 속도 적용 ────────────────────────────────────────
// WaitForFixedUpdate 이후이므로 isKinematic→dynamic 전환이 완전히 반영된 상태에요.
// isTrigger=true 이므로 충돌 힘이 이 velocity 를 절대 바꾸지 못해요.
_rb.drag = 0f; // 설정할거에요 -> 공기저항을 0으로 (탄도 계산이 drag=0 기준)
_rb.angularDrag = 0.05f; // 설정할거에요 -> 회전 저항만 약간 (착지 후 구르기 제한용)
_rb.velocity = vel; // 적용할거에요 -> 계산된 정확한 발사 속도를 (isTrigger=true → 절대 덮어쓰기 안됨)
State = BallState.Flying; // 변경할거에요 -> Flying 상태로
// ─ 진단 로그 ────────────────────────────────────────
float hDist = new Vector2(toTarget.x, toTarget.z).magnitude; // 계산할거에요 -> 로그용 수평 거리를
Debug.Log($"[BossIronBall] ✅ 발사 완료! " +
$"vel=({vel.x:F1}, {vel.y:F1}, {vel.z:F1}) " +
$"3D거리={distance:F1}m 비행시간={tTravel:F2}s speed={vel.magnitude:F1}m/s " +
$"목표={targetPos:F1}"); // 로그를 찍을거에요 -> 발사 정보 전부를
// 수평 거리가 1m 미만 = throwTarget이 잘못 설정된 것 (경고)
if (hDist < 1f) // 조건이 맞으면 실행할거에요 -> 수평 거리가 너무 짧으면
Debug.LogWarning("[BossIronBall] ⚠️ 수평 거리가 1m 미만이에요!\n" +
"NorcielBoss의 _target(플레이어 참조)이 올바른지 확인하세요.\n" +
"또는 Inspector에서 Freeze Position X/Z 체크 여부를 확인하세요."); // 경고를 찍을거에요
_launchRoutine = null; // 초기화할거에요 -> 완료 후 참조를 (GC 정리)
}
/// <summary>
/// 착지 상태인지 반환. 보스가 주우러 갈지 판단에 사용.
/// PickingUp 상태는 이미 줍기가 진행 중이므로 false 반환해요.
/// </summary>
public bool IsLanded() => State == BallState.Landed; // 반환할거에요 -> 바닥에 착지 중(=주우러 가야 함)인지를 (PickingUp/Held/Flying은 false)
/// <summary>
/// 공이 도달 불가능한 위치에 있을 때 강제로 보스 손에 즉시 복귀시켜요.
/// FSM_Retrieving에서 공의 Y좌표가 보스보다 너무 높으면 호출해요.
/// Flying/Landed 어떤 상태든 즉시 Held로 전환돼요.
/// </summary>
public void ForceReturn(Transform handHolder) // 함수를 선언할거에요 -> 공을 강제로 손에 복귀시키는 (도달 불가능 시 사용)
{
if (handHolder == null) // 조건이 맞으면 실행할거에요 -> handHolder 없으면
{
Debug.LogWarning("[BossIronBall] ForceReturn: handHolder가 null이에요!"); // 경고를 찍을거에요
return; // 중단할거에요
}
// ── 비행 중 코루틴 안전 종료 ─────────────────────────────
if (_launchRoutine != null) // 조건이 맞으면 실행할거에요 -> 발사 코루틴이 있으면
{
StopCoroutine(_launchRoutine); // 중단할거에요 -> 코루틴을
_launchRoutine = null; // 초기화할거에요 -> 참조를
}
// ── 물리 고정 + 손에 부착 ────────────────────────────────
Freeze(); // 실행할거에요 -> 물리 고정을 (isKinematic + FreezeAll)
_bossRoot = handHolder.root; // 저장할거에요 -> 보스 루트를
transform.SetParent(handHolder); // 붙일거에요 -> 손의 자식으로
transform.localPosition = holdOffset; // 설정할거에요 -> 오프셋 위치로 (손에 딱 맞게)
transform.localRotation = Quaternion.identity; // 초기화할거에요 -> 회전을
State = BallState.Held; // 전환할거에요 -> Held로 (즉시 사용 가능)
_hasDealt = false; // 초기화할거에요 -> 데미지 플래그를
_flyingTimer = 0f; // 초기화할거에요 -> 타임아웃 타이머를
Debug.LogWarning("[BossIronBall] ⚡ 강제 회수! 공이 도달 불가능 → 보스 손에 즉시 복귀"); // 경고를 찍을거에요
}
// ══════════════════════════════════════════════════════════
// 착지 감지 → 데미지
// ══════════════════════════════════════════════════════════
private void OnCollisionEnter(Collision col) // 함수를 실행할거에요 -> 충돌이 일어난 순간에
{
if (State != BallState.Flying) return; // 중단할거에요 -> 날아가는 중이 아니면 (손에 있을 때 충돌 무시)
// ── 보스 몸체 충돌 무시 ───────────────────────────────────────
// ⚠️ 발사 직후 공이 보스 콜라이더와 겹쳐 있으면:
// isKinematic=false 되는 순간 OnCollisionEnter(보스) 가 즉시 발생해요.
// 보스는 "Player" 태그가 아니므로 아래 착지 처리로 빠져 Freeze() 호출 → 공중 정지 버그!
// [해결] _bossRoot 와 동일한 루트면 무조건 무시해요.
if (_bossRoot != null && col.transform.root == _bossRoot) return; // 무시할거에요 -> 보스 몸체와의 충돌을 (발사 직후 겹침 포함)
// ── 플레이어 또는 플레이어 자식 오브젝트 체크 ─────────────────
// ⚠️ col.gameObject.CompareTag("Player") 만으로는 부족해요!
// 플레이어 자식 오브젝트(Bow, Sword, HitBox 등)는 태그가 달라서
// "비-플레이어 = 착지" 로직에 잘못 걸려 공중에서 Freeze 됐어요.
// col.transform.root 로 루트 오브젝트까지 올라가서 태그를 확인해요.
bool isPlayerOrChild = col.transform.root.CompareTag("Player"); // 확인할거에요 -> 루트까지 올라가서 Player 태그인지를 (자식 오브젝트 포함)
if (isPlayerOrChild) // 조건이 맞으면 실행할거에요 -> 플레이어 본체 또는 자식 오브젝트(Bow, HitBox 등)에 맞으면
{
ApplyDamage(); // 실행할거에요 -> 데미지를 (직격)
// ── 공 궤도 유지 (플레이어에 맞아도 공이 밀리지 않게) ──
// Unity는 Collider 충돌 시 Rigidbody에 자동으로 반발력을 적용해요.
// 플레이어는 CharacterController라 밀리지 않지만 공은 방향이 꺾여요.
// 다음 FixedUpdate에서 _preCollisionVelocity 를 복원해서 무효화해요.
_shouldRestoreVelocity = true; // 설정할거에요 -> 속도 복원 플래그를 (FixedUpdate에서 궤도 복원)
// ── 넉백 적용 (인왕 쇠공 묵직한 느낌) ──────────────────
// 공→플레이어 방향으로 밀어냄 (수평만, 위는 upForce로 제어)
if (knockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 설정이 0보다 크면
{
Transform playerRoot = col.transform.root; // 가져올거에요 -> 플레이어 루트 Transform을
Vector3 knockDir = playerRoot.position - transform.position; // 계산할거에요 -> 공에서 플레이어 방향으로 벡터를
knockDir.y = 0f; // 제거할거에요 -> Y성분을 (수평 방향만 사용)
PlayerMovement pm = playerRoot.GetComponent<PlayerMovement>(); // 가져올거에요 -> PlayerMovement 컴포넌트를
if (pm != null) // 조건이 맞으면 실행할거에요 -> 컴포넌트가 있으면
pm.ApplyKnockback(knockDir, knockbackForce, knockbackUpForce, knockbackDuration); // 실행할거에요 -> 넉백을 (수평 날아감 + 살짝 위로 뜸)
}
return; // 중단할거에요 -> 착지 처리 안 함 (계속 날아가서 바닥에 꽂히게)
}
// ── 바닥 vs 벽 판별 (충돌 법선 방향으로 구분) ──────────────────────────
//
// 충돌 법선(normal)이 위쪽 (y > 0.5) → 수평 면(바닥) → 착지 처리
// 충돌 법선이 수평 (y ≤ 0.5) → 수직 면(벽) → 수평 속도만 제거 후 계속 낙하
//
// ※ 45° 기준: cos(45°) ≈ 0.707 이지만 약간 완만한 경사도 바닥으로 인정하려면 0.5
ContactPoint contact = col.GetContact(0); // 가져올거에요 -> 첫 번째 접촉점을 (충돌 법선 포함)
bool isFloor = contact.normal.y > 0.5f; // 판단할거에요 -> 바닥인지 벽인지를 (법선 y가 0.5 초과 = 위쪽 면 = 바닥)
if (!isFloor) // 조건이 맞으면 실행할거에요 -> 벽이면 (착지 아님)
{
// 벽 충돌: 수평 속도만 제거하고 수직 낙하 유지
_rb.constraints = RigidbodyConstraints.FreezeRotation; // 잠글거에요 -> 회전 축을 (굴러가지 않게)
Vector3 v = _rb.velocity; // 가져올거에요 -> 현재 속도를
_rb.velocity = new Vector3(0f, Mathf.Min(v.y, 0f), 0f); // 설정할거에요 -> 수평 제거 + 내려가는 속도 유지 (계속 낙하)
Debug.Log($"[BossIronBall] 벽 충돌 → 계속 낙하 ({col.gameObject.name} 법선y={contact.normal.y:F2})"); // 로그를 찍을거에요
return; // 중단할거에요 -> 착지 처리 건너뜀 (아직 바닥 아님)
}
// ── 바닥 → 착지 처리 ────────────────────────────────────────────────────
// 보스 몸통과의 충돌은 Physics Layer Matrix에서
// BossWeapon ↔ BossBody 체크 해제로 원천 차단돼요.
ApplyDamage(); // 실행할거에요 -> 착지 범위 데미지를 (OverlapSphere로 주변 플레이어에게)
State = BallState.Landed; // 변경할거에요 -> Landed 상태로 (보스가 주우러 옴)
Freeze(); // 실행할거에요 -> 물리 고정을 (착지 후 구르지 않게 / FreezeAll)
_lastSafePosition = transform.position; // 갱신할거에요 -> 착지 위치를 안전 위치로 (관통 방지 복귀 지점)
Debug.Log($"[BossIronBall] 착지 완료 → Landed ({col.gameObject.name} 법선y={contact.normal.y:F2})"); // 로그를 찍을거에요 -> 어디에 닿았는지를
}
// ══════════════════════════════════════════════════════════
// Trigger 충돌 감지 (비행 중 isTrigger=true 일 때 사용)
// ══════════════════════════════════════════════════════════
/// <summary>
/// 비행 중 isTrigger=true 상태에서 충돌을 감지해요.
/// OnCollisionEnter 와 같은 로직이지만 Trigger 버전이에요.
/// isTrigger=true 덕분에 충돌 힘이 velocity 를 바꾸지 못해 정확한 궤도 유지.
/// </summary>
private void OnTriggerEnter(Collider other) // 함수를 실행할거에요 -> Trigger 충돌 발생 순간에 (isTrigger=true 비행 중)
{
if (State != BallState.Flying) return; // 중단할거에요 -> 날아가는 중이 아니면
// ── 보스 몸체 무시 ──────────────────────────────────────
if (_bossRoot != null && other.transform.root == _bossRoot) return; // 무시할거에요 -> 보스 몸체를
// ── 플레이어 판정 ───────────────────────────────────────
bool isPlayerOrChild = other.transform.root.CompareTag("Player"); // 확인할거에요 -> 루트까지 올라가서 Player 태그인지를
if (isPlayerOrChild) // 조건이 맞으면 실행할거에요 -> 플레이어(또는 자식)에 맞으면
{
ApplyDamage(); // 실행할거에요 -> 데미지를
// ── 넉백 적용 ────────────────────────────────────────
if (knockbackForce > 0f) // 조건이 맞으면 실행할거에요 -> 넉백 설정 있으면
{
Transform playerRoot = other.transform.root; // 가져올거에요 -> 플레이어 루트를
Vector3 knockDir = playerRoot.position - transform.position; // 계산할거에요 -> 밀어내는 방향을
knockDir.y = 0f; // 제거할거에요 -> Y성분을 (수평 넉백만)
PlayerMovement pm = playerRoot.GetComponent<PlayerMovement>(); // 가져올거에요 -> PlayerMovement를
if (pm != null) // 조건이 맞으면 실행할거에요 -> 컴포넌트 있으면
pm.ApplyKnockback(knockDir, knockbackForce, knockbackUpForce, knockbackDuration); // 실행할거에요 -> 넉백을
}
// 플레이어 맞혀도 공은 계속 날아가 바닥에 착지해요 (return, 착지 처리 안 함)
return; // 중단할거에요 -> 착지 처리 안 함
}
// ── 벽/환경/장애물 → 낙하 모드 전환 ──────────────────────────────────
//
// [기존 문제]
// OnTriggerEnter에서 뭐에 닿든 즉시 Freeze() → 공이 공중에서 멈추는 버그
//
// [수정 방식]
// isTrigger = false 로 전환 + 수평 속도 제거 → 중력으로 수직 낙하
// 실제 바닥에 닿을 때 OnCollisionEnter에서 착지(Landed + Freeze) 처리해요.
// 회전 축을 미리 잠가서 낙하 중 / 착지 후에도 굴러가지 않게 해요.
if (_col != null) _col.isTrigger = false; // 전환할거에요 -> 일반 콜리전으로 (이제 OnCollisionEnter로 바닥 감지)
_rb.constraints = RigidbodyConstraints.FreezeRotation; // 잠글거에요 -> 회전 3축을 (낙하 중 + 착지 후 굴러가지 않게)
Vector3 curVel = _rb.velocity; // 가져올거에요 -> 현재 속도를
_rb.velocity = new Vector3(0f, Mathf.Min(curVel.y, 0f), 0f); // 설정할거에요 -> 수평 성분 제거 + 아래로 내려가는 속도만 유지 (위로 솟구치는 중이면 0으로 처리)
Debug.Log($"[BossIronBall] 벽/장애물 충돌 → 낙하 모드 ({other.gameObject.name}) 수직속도={_rb.velocity.y:F1}"); // 로그를 찍을거에요
// ✅ State = Flying 유지 → 바닥에 닿는 순간 OnCollisionEnter가 착지 처리해요
}
private void ApplyDamage() // 함수를 선언할거에요 -> 착지 위치 기준 범위 데미지를 주는
{
if (_hasDealt) return; // 중단할거에요 -> 이미 처리했으면 (중복 방지)
_hasDealt = true; // 설정할거에요 -> 처리 완료로
float finalDmg = damage * _dmgMult; // 계산할거에요 -> 최종 데미지를 (배율 적용)
// 착지 위치 기준 OverlapSphere로 범위 안 플레이어 탐색
Collider[] hits = Physics.OverlapSphere(transform.position, landRadius); // 검사할거에요 -> 착지 반경 안의 콜라이더를
foreach (Collider hit in hits) // 반복할거에요 -> 감지된 콜라이더마다
{
if (!hit.CompareTag("Player")) continue; // 건너뛸거에요 -> 플레이어가 아니면
// IDamageable 인터페이스 탐색 (자신 → 부모 순)
IDamageable d = hit.GetComponent<IDamageable>() // 가져올거에요 -> 자신에서 인터페이스를
?? hit.GetComponentInParent<IDamageable>(); // 또는 가져올거에요 -> 부모에서
if (d != null) // 조건이 맞으면 실행할거에요 -> 인터페이스 있으면
{
d.TakeDamage(finalDmg); // 줄거에요 -> 데미지를
Debug.Log($"[BossIronBall] 데미지 {finalDmg:F0} 적용!"); // 로그를 찍을거에요
}
}
}
// ══════════════════════════════════════════════════════════
// 내부 유틸
// ══════════════════════════════════════════════════════════
private void Freeze() // 함수를 선언할거에요 -> Rigidbody를 완전히 고정하는
{
if (_rb == null) return; // 중단할거에요 -> Rigidbody 없으면
// ⚠️ 순서 중요: isKinematic = true를 먼저 설정해야 해요.
// kinematic 상태에서 velocity/angularVelocity를 설정하면
// "Setting linear/angular velocity of a kinematic body is not supported" 에러가 나요.
// isKinematic = true 이후엔 물리 엔진이 velocity를 무시하므로 따로 초기화 불필요해요.
if (_col != null) _col.isTrigger = false; // 되돌릴거에요 -> isTrigger를 false로 (착지 후 일반 콜리전으로 복원)
_rb.isKinematic = true; // 먼저 고정할거에요 -> 물리를 (이 시점부터 velocity 설정 불필요)
_rb.constraints = RigidbodyConstraints.FreezeAll; // 잠글거에요 -> 모든 위치·회전 축을 (착지 후 구름 방지)
}
private Vector3 CalcParabolaVelocity(Vector3 from, Vector3 to, float h) // 함수를 선언할거에요 -> 포물선 초기 속도를 계산하는
{
// 중력 크기
float g = Mathf.Abs(Physics.gravity.y); // 가져올거에요 -> 중력 크기를 (기본 9.81)
// 수직 초기 속도: 최고 높이 h에 도달하는 속도 → v = √(2gh)
float vy = Mathf.Sqrt(2f * g * h); // 계산할거에요 -> 초기 수직 속도를
// 비행 시간 계산
float dy = to.y - from.y; // 계산할거에요 -> 출발점~목표 높이 차이를
float tUp = vy / g; // 계산할거에요 -> 최고점까지 상승 시간을
float tDown = Mathf.Sqrt(Mathf.Max(2f * (h - dy) / g, 0.01f)); // 계산할거에요 -> 최고점에서 목표까지 하강 시간을
float tTotal = Mathf.Max(tUp + tDown, 0.1f); // 계산할거에요 -> 총 비행 시간을 (0으로 나누기 방지)
// 수평 속도: 수평 거리 / 총 비행 시간
Vector3 hVec = to - from; // 계산할거에요 -> 수평 벡터를
hVec.y = 0f; // 제거할거에요 -> 수직 성분을 (수평만 남기기)
Vector3 hVel = hVec / tTotal; // 계산할거에요 -> 수평 속도를 (x,z 성분)
return new Vector3(hVel.x, vy, hVel.z); // 반환할거에요 -> 포물선 초기 속도를 (수평 + 수직)
}
// ══════════════════════════════════════════════════════════
// 씬 뷰 기즈모
// ══════════════════════════════════════════════════════════
private void OnDrawGizmosSelected() // 함수를 실행할거에요 -> 씬 뷰에서 선택 시 착지 범위 시각화를
{
Gizmos.color = new Color(1f, 0.2f, 0.2f, 0.3f); // 설정할거에요 -> 반투명 빨간색을
Gizmos.DrawSphere(transform.position, landRadius); // 그릴거에요 -> 채운 구를 (착지 판정 범위)
Gizmos.color = Color.red; // 설정할거에요 -> 윤곽선 색을
Gizmos.DrawWireSphere(transform.position, landRadius); // 그릴거에요 -> 윤곽선을
}
}