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를 (GC 방지를 위해 캐싱) _col = GetComponent(); // 가져올거에요 -> 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 에서만 호출) // ══════════════════════════════════════════════════════════ /// /// 보스 손에 공을 붙여요. /// 보스 Init() 시작 시, 줍기 완료 시 호출. /// 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; // 초기화할거에요 -> 데미지 처리 여부를 (다음 발사 때 다시 데미지 줄 수 있게) } /// /// 줍기 모션과 동시에 공을 손에 부착하여 애니메이션과 함께 움직이게 해요. /// PickupBall() 코루틴에서 줍기 애니 재생 직후 즉시 호출. /// /// [동작] /// 1) 공을 handHolder의 자식으로 즉시 설정 (월드 위치 유지) /// 2) LateUpdate에서 매 프레임 localPosition → holdOffset으로 lerp /// 3) 목표 도달 시 State = Held (자동) /// 4) AttachToHand()는 폴백용으로만 사용 (애니 종료 후에도 못 잡은 경우) /// 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}"); // 로그를 찍을거에요 -> 줍기 시작 정보를 } /// /// 목표 위치로 포물선 발사. /// NorcielBoss.cs의 Pattern_Throw에서 발사 타이밍에 호출. /// targetTransform : 플레이어 Transform (실시간 위치 추적) /// aimHeight : 조준 높이 오프셋 (0=발, 0.7=복부, 1.0=가슴) /// damageMult : Phase2 등 데미지 배율 /// 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 전달) } /// /// 실제 포물선 발사 로직. /// WaitForFixedUpdate()로 isKinematic 해제가 물리 엔진에 완전히 반영된 뒤 velocity를 적용해요. /// 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×t²×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 정리) } /// /// 착지 상태인지 반환. 보스가 주우러 갈지 판단에 사용. /// PickingUp 상태는 이미 줍기가 진행 중이므로 false 반환해요. /// public bool IsLanded() => State == BallState.Landed; // 반환할거에요 -> 바닥에 착지 중(=주우러 가야 함)인지를 (PickingUp/Held/Flying은 false) /// /// 공이 도달 불가능한 위치에 있을 때 강제로 보스 손에 즉시 복귀시켜요. /// FSM_Retrieving에서 공의 Y좌표가 보스보다 너무 높으면 호출해요. /// Flying/Landed 어떤 상태든 즉시 Held로 전환돼요. /// 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 컴포넌트를 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 일 때 사용) // ══════════════════════════════════════════════════════════ /// /// 비행 중 isTrigger=true 상태에서 충돌을 감지해요. /// OnCollisionEnter 와 같은 로직이지만 Trigger 버전이에요. /// isTrigger=true 덕분에 충돌 힘이 velocity 를 바꾸지 못해 정확한 궤도 유지. /// 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를 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() // 가져올거에요 -> 자신에서 인터페이스를 ?? hit.GetComponentInParent(); // 또는 가져올거에요 -> 부모에서 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); // 그릴거에요 -> 윤곽선을 } }