Projext/Assets/02_Scripts/Enemy/BossAI/BossIronBall.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

792 lines
60 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; // 코루틴 기능을 불러올거에요 -> 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); // 그릴거에요 -> 윤곽선을
}
}