// ───────────────────────────────────────────────────────────────────────────── // CombatCameraDirector.cs — 전투 연출 카메라: 킬캠(줌인+슬로모) · 보스 등장 캠(오빗) · 3타 카메라 리드 // // PD 지시 #813 · 발주서 WL-811def §1-1 · 설계안 WL-811a §C 811d 행 · 기준서 v1 §B 요소 6·7 · §F-4 (2026-09-09) // // ■ 무엇을 하나 (전부 코어 이벤트 **구독** · 원본 훅 0 · RealCamera 무수정) // Killed(Elite/Boss) → TimeScaleArbiter.Request(Cinematic · cinematicDefaultScale · killCamSlowMoSeconds) // + RealCamera 궤도 거리(minDistance/maxDistance[/DistanceUp]) × killCamZoomScale 램프 → 유지 → 복원 // 쿨 = 코어 killCamCooldownSeconds · 런당 killCamMaxPerRun · 하단 40% 가드(처치 위치 뷰포트 y) // Spawned(isBoss) → arr_cCamData[1] 보스 프리셋 덮어쓰기(옵션 · 원값 스냅샷 후 · 보스가 죽거나 사라지면 원복) · 추적 대상을 보스로 // (공개 필드 target 직접 대입 = list_tfCamactionTarget 무접촉) · Add_Rot 로 sin 오빗 · 거리 배율 · 끝나면 전부 복원 // ComboStage(isLast) → CameraMoveLead.LeadMultiplier = comboLeadMultiplier (comboLeadSeconds 뒤 1 로) // // ■ 왜 RealCamera 공개 필드를 매 프레임 쓰는가 (사후 가산 기각 근거 = CameraMoveLead.cs / WLHitFeelCameraShake.cs 주석) // RealCamera.Update_Cam 은 자기 출력을 다시 입력으로 읽는 저역통과 필터라 transform 에 오프셋을 더하면 13배 반대 방향으로 튄다. // 대신 그 필터의 **입력**(궤도 거리 파라미터)을 바꾼다: DistanceAway 는 매 LateUpdate 마다 [minDistance, maxDistance] 로 클램프되므로 // 두 값을 같이 줄이면 그 프레임부터 궤도가 줄고 Lerp(smooth 4)가 자연스러운 줌을 만든다. 드로우 +0 · 프리팹 무수정. // // ■ 안전장치 (TimeScaleArbiter 규약 승계) // · 복원은 "우리가 마지막에 쓴 값이 그대로 남아 있을 때만" — Init_Cam/Set_InsideBuilding 등 외부가 덮어썼으면 포기(덮어쓰지 않음) // · 인트로(CamStatus 0 = IsEnter) 중에는 줌·오빗을 걸지 않는다 · RealCamera 가 없으면(타이틀 등) 슬로모만 // · 보스 캠 중 킬캠 줌은 스킵(슬로모만) · 킬캠 중 보스 스폰은 킬캠을 즉시 복원하고 보스 캠 시작 // · 씬 전환 시 상태·런 카운터 초기화 · 러너 파괴/앱 종료 시 전부 복원 // · 시간은 전부 unscaled(슬로모 중에도 램프가 늘어지지 않는다) · 핸들러 GC 0(verboseLog 때만 문자열) // // ■ 리뷰 WL-811def FIX 3건 반영 (2026-09-09 · #813) // FIX-1 킬캠 시작을 **다음 프레임**으로 미루고 그때 base 궤도 값을 재취득 — 처치 스택 뒷부분(MonsterSimpleInfo.Off → Set_LockTarget(null) // → RealCamera.Init_Cam)이 min/max/DistanceUp 을 덮어써 보스 킬캠 줌이 항상 포기되던 문제(Phase.Pending · KillCamRebased 로 계측) // FIX-2 arr_cCamData[1](보스/락타겟 프리셋)을 덮어쓸 때 **원값 스냅샷** → 보스 처치·사라짐·씬 전환·SO off 에 복원 · 적용을 busy 스킵 검사 뒤로 // FIX-3 보스 리타겟을 Set_Target(→ list_tfCamactionTarget 누적) 대신 **공개 필드 target 직접 대입**으로 — 리스트 길이 불변 // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.SceneManagement; using WL.Combat.Core; namespace WL.CombatCamera { public static class CombatCameraDirector { enum Phase { None = 0, Pending = 1, ZoomIn = 2, Hold = 3, ZoomOut = 4 } // ── 킬캠 상태 static Phase s_phase; static float s_phaseStart, s_killStart; static float s_scale = 1f; static float s_baseMin, s_baseMax, s_baseUp; // 줌 시작 시점 스냅샷(FIX-1 = 처치 다음 프레임에 재취득) static float s_preMin, s_preMax, s_preUp; // 처치 이벤트 시점 값 — 외부 덮어쓰기 계측용(KillCamRebased) static float s_wMin, s_wMax, s_wUp; // 마지막으로 우리가 쓴 값(외부 덮어쓰기 감지) static bool s_scaleUp; static float s_lastKillCam = -999f; static int s_runCount; static int s_pendingFrame; // FIX-1: 킬캠을 예약한 프레임(이 프레임에는 시작하지 않는다) static RealCamera s_cam; // ── 보스 캠 상태 static bool s_bossActive; static float s_bossStart; static Transform s_bossTf, s_prevTarget; static bool s_bossRetargeted; static float s_bossYawApplied; static float s_bossBaseMin, s_bossBaseMax, s_bossWMin, s_bossWMax; // ── 보스 프리셋(arr_cCamData[1]) 상태 — FIX-2: 원값 스냅샷 → 보스 처치·사라짐·씬 전환·SO off 에 복원 static bool s_presetApplied; static cCamData s_presetData; // 우리가 값을 쓴 프리셋(참조 · 원본 [Serializable] 클래스라 fake-null 아님) static Actor s_presetBoss; // 프리셋을 건 보스(처치·풀 반납 감지) static float s_presetOMin, s_presetOMax, s_presetOHeight, s_presetOUp; // 원값 static float s_presetWMin, s_presetWMax, s_presetWHeight, s_presetWUp; // 우리가 쓴 값 // ── 3타 리드 상태 static float s_leadUntil = -1f; static CombatCameraRunner s_runner; // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int KillCamCount, KillCamSkippedRole, KillCamSkippedCooldown, KillCamSkippedRunCap, KillCamSkippedGuard, KillCamSkippedNoCam, KillCamSkippedBusy, KillCamAbandoned; public static int KillCamRebased; // FIX-1: 미룬 사이에 외부(Init_Cam 등)가 궤도 값을 바꿔 base 를 다시 잡은 횟수 public static int BossCamCount, BossCamSkipped, BossPresetApplied, BossPresetRestored, BossPresetAbandoned, ComboLeadCount; public static ArbiterResult LastSlowMoResult; public static float LastVictimViewportY = -1f; public static float LastKillCamMeasured; // 줌 시작 → 완전 복원까지(초 · unscaled) public static float LastBossCamMeasured; public static string LastInfo = ""; public static bool KillCamActive { get { return s_phase != Phase.None; } } public static bool KillCamPending { get { return s_phase == Phase.Pending; } } public static bool BossCamActive { get { return s_bossActive; } } public static bool BossPresetActive { get { return s_presetApplied; } } public static float CurrentZoomScale { get { return s_scale; } } public static int RunCount { get { return s_runCount; } } public static float LeadUntil { get { return s_leadUntil; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.Killed.Add(OnKilled); CombatEvents.Spawned.Add(OnSpawned); CombatEvents.ComboStage.Add(OnComboStage); SceneManager.activeSceneChanged += OnSceneChanged; Subscribed = true; } static WLCombatCameraSettings St { get { return WLCombatCameraSettings.Instance; } } static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } static void Log(string msg) { var st = St; if (st != null && st.verboseLog) Debug.Log("[CombatCameraDirector] " + msg); } static CombatCameraRunner Runner { get { if (s_runner == null) { var go = new GameObject("__WLCombatCamera"); Object.DontDestroyOnLoad(go); s_runner = go.AddComponent(); } return s_runner; } } static RealCamera ResolveCamera() { return MyValue.m_RealCamera; } // ───────────────────────────────────────── Killed → 킬캠 static void OnKilled(in KilledEvent e) { if (!WLCombatCameraSettings.Enabled) return; var st = St; // FIX-2: 프리셋을 건 보스가 죽었으면 즉시 원복(틱 폴백보다 한 프레임 빠르다). // Set_Die 는 이 뒤에 Set_LockTarget(null) → Init_Cam() 으로 인덱스 0 을 읽으므로 여기서 되돌려도 이번 프레임 연출에 영향 0. if (s_presetApplied && ReferenceEquals(e.victim, s_presetBoss)) RestoreBossPreset(); if (!st.killCamEnabled) return; bool roleOk = e.subRole == eSubRol.Boss ? st.killCamOnBoss : e.subRole == eSubRol.Elite ? st.killCamOnElite : st.killCamOnNormal; if (!roleOk || (st.killCamDirectHitOnly && !e.byDirectHit)) { KillCamSkippedRole++; return; } float now = Time.unscaledTime; var core = WLCombatCoreSettings.Instance; float cooldown = core != null ? core.killCamCooldownSeconds : 0f; if (now - s_lastKillCam < cooldown) { KillCamSkippedCooldown++; LastInfo = "cooldown"; if (Verbose) Log("킬캠 스킵(쿨) " + (now - s_lastKillCam).ToString("F2") + " < " + cooldown); return; } if (st.killCamMaxPerRun > 0 && s_runCount >= st.killCamMaxPerRun) { KillCamSkippedRunCap++; LastInfo = "runcap"; if (Verbose) Log("킬캠 스킵(런 상한 " + st.killCamMaxPerRun + ")"); return; } // 쿨·런 카운터는 여기서 소모한다 — 아래 스킵(카메라 없음·인트로·하단 가드)으로 슬로모만 나가도 "킬캠 1회를 썼다"로 친다. // 의도: 연출 밀도(런당 ≤6 · 최소 간격 8 s)는 슬로모까지 포함한 체감 기준이다(리뷰 Minor-6 확인 사항). s_lastKillCam = now; s_runCount++; KillCamCount++; // ① 슬로모 — 중재자 Cinematic 층(히트스톱은 이 동안 SkippedHigher · D-1) float slow = core != null ? core.cinematicDefaultScale : 1f; LastSlowMoResult = TimeScaleArbiter.Request(TimeLayer.Cinematic, slow, st.killCamSlowMoSeconds); // ② 줌 — RealCamera 가 있고 인트로가 아니고 보스 캠 중이 아닐 때 var cam = ResolveCamera(); if (cam == null) { KillCamSkippedNoCam++; LastInfo = "slowmo-only(no cam)"; if (Verbose) Log("킬캠 줌 스킵(RealCamera 없음) 슬로모=" + LastSlowMoResult); return; } if (cam.IsEnter() || s_bossActive) { KillCamSkippedBusy++; LastInfo = "slowmo-only(busy)"; if (Verbose) Log("킬캠 줌 스킵(인트로/보스 캠) 슬로모=" + LastSlowMoResult); return; } if (st.killCamBottomGuardViewportY > 0f) { var uc = Camera.main; if (uc != null) { var v = uc.WorldToViewportPoint(e.position); LastVictimViewportY = v.y; if (v.z > 0f && v.y < st.killCamBottomGuardViewportY) { KillCamSkippedGuard++; LastInfo = "slowmo-only(bottom guard)"; if (Verbose) Log("킬캠 줌 스킵(하단 가드 y=" + v.y.ToString("F2") + " < " + st.killCamBottomGuardViewportY + ") 슬로모=" + LastSlowMoResult); return; } } } if (s_phase != Phase.None) EndZoom(true); // (쿨 안이라 사실상 없음) 안전 // ── FIX-1(리뷰 Major-1): 여기서 바로 스냅샷·줌을 걸면 같은 호출 스택 뒷부분이 그 값을 덮어쓴다. // MobActor.Set_Die 는 RaiseKilled(:435) 다음에 MonsterSimpleInfo.DeadTarget(:444) → Off() → 보스면 Set_LockTarget(null) // → RealCamera.Init_Cam() 으로 minDistance/maxDistance/cameraHeight 와 Set_DistanceUp(false) 로 DistanceUp 을 프리셋 값으로 다시 쓴다. // → 다음 틱의 ZoomStillOurs() 가 false → KillCamAbandoned · 보스 킬캠은 슬로모만 남았다. // 그래서 **시작을 다음 프레임으로 미루고 그때 base 를 재취득**한다(엘리트/일반은 Set_LockTarget 경로가 없어 값이 같다 = 회귀 0 · 지연 1프레임). s_cam = cam; s_preMin = cam.minDistance; s_preMax = cam.maxDistance; s_preUp = cam.DistanceUp; s_scaleUp = st.killCamScaleDistanceUp; s_scale = 1f; s_phase = Phase.Pending; s_pendingFrame = Time.frameCount; s_phaseStart = s_killStart = now; Runner.enabled = true; LastInfo = "killcam"; if (Verbose) Log("킬캠 예약(다음 프레임 base 재취득) victim=" + (e.victim != null ? e.victim.name : "?") + " sub=" + e.subRole + " 슬로모=" + LastSlowMoResult + " pre=" + s_preMin.ToString("F2") + "/" + s_preMax.ToString("F2") + "/" + s_preUp.ToString("F2")); } /// FIX-1: 예약해 둔 킬캠을 다음 프레임에 시작한다 — 처치 프레임의 나머지 로직(Init_Cam 등)이 다 쓴 뒤의 값을 base 로 잡는다. static void BeginZoom(float now) { if (Time.frameCount <= s_pendingFrame) return; // 처치와 같은 프레임에는 시작하지 않는다 var cam = s_cam; if (cam.IsEnter() || s_bossActive) // 미룬 사이에 인트로/보스 캠이 시작됐으면 슬로모만 { KillCamSkippedBusy++; LastInfo = "slowmo-only(busy)"; s_phase = Phase.None; s_cam = null; LastKillCamMeasured = -1f; if (Verbose) Log("킬캠 줌 취소(예약 중 인트로/보스 캠)"); return; } s_baseMin = cam.minDistance; s_baseMax = cam.maxDistance; s_baseUp = cam.DistanceUp; if (!Mathf.Approximately(s_baseMin, s_preMin) || !Mathf.Approximately(s_baseMax, s_preMax) || !Mathf.Approximately(s_baseUp, s_preUp)) KillCamRebased++; s_phase = Phase.ZoomIn; s_phaseStart = now; WriteZoom(1f); if (Verbose) Log("킬캠 시작 base=" + s_baseMin.ToString("F2") + "/" + s_baseMax.ToString("F2") + "/" + s_baseUp.ToString("F2") + " rebased=" + KillCamRebased); } static void WriteZoom(float k) { var cam = s_cam; s_scale = k; cam.minDistance = s_wMin = s_baseMin * k; cam.maxDistance = s_wMax = s_baseMax * k; if (s_scaleUp) cam.DistanceUp = s_wUp = s_baseUp * k; } static bool ZoomStillOurs() { var cam = s_cam; if (cam == null) return false; if (!Mathf.Approximately(cam.minDistance, s_wMin) || !Mathf.Approximately(cam.maxDistance, s_wMax)) return false; if (s_scaleUp && !Mathf.Approximately(cam.DistanceUp, s_wUp)) return false; return true; } static void EndZoom(bool restore) { if (s_phase == Phase.None) return; if (s_phase == Phase.Pending) // 아직 아무것도 쓰지 않았다 — 복원할 것도, 포기로 셀 것도 없다 { s_phase = Phase.None; s_scale = 1f; s_cam = null; if (Verbose) Log("킬캠 예약 취소"); return; } if (restore && ZoomStillOurs()) { var cam = s_cam; cam.minDistance = s_baseMin; cam.maxDistance = s_baseMax; if (s_scaleUp) cam.DistanceUp = s_baseUp; LastKillCamMeasured = Time.unscaledTime - s_killStart; } else if (restore) { KillCamAbandoned++; LastKillCamMeasured = -1f; } s_phase = Phase.None; s_scale = 1f; s_cam = null; if (Verbose) Log("킬캠 종료 restore=" + restore + " measured=" + LastKillCamMeasured.ToString("F3")); } static void TickKillCam(float now) { if (s_phase == Phase.None) return; var st = St; if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndZoom(true); return; } if (s_phase == Phase.Pending) { BeginZoom(now); return; } // FIX-1: 예약 → 다음 프레임 시작(base 재취득) if (!ZoomStillOurs()) { KillCamAbandoned++; s_phase = Phase.None; s_scale = 1f; s_cam = null; LastKillCamMeasured = -1f; if (Verbose) Log("킬캠 포기(외부가 거리 필드를 바꿈)"); return; } float t = now - s_phaseStart; float zoom = st.killCamZoomScale; switch (s_phase) { case Phase.ZoomIn: if (t >= st.killCamZoomInSeconds) { WriteZoom(zoom); s_phase = Phase.Hold; s_phaseStart = now; } else WriteZoom(Mathf.Lerp(1f, zoom, Mathf.SmoothStep(0f, 1f, t / Mathf.Max(st.killCamZoomInSeconds, 0.0001f)))); break; case Phase.Hold: if (t >= st.killCamHoldSeconds) { s_phase = Phase.ZoomOut; s_phaseStart = now; } break; case Phase.ZoomOut: if (t >= st.killCamZoomOutSeconds) EndZoom(true); else WriteZoom(Mathf.Lerp(zoom, 1f, Mathf.SmoothStep(0f, 1f, t / Mathf.Max(st.killCamZoomOutSeconds, 0.0001f)))); break; } } // ───────────────────────────────────────── Spawned(isBoss) → 보스 캠 static void OnSpawned(in SpawnedEvent e) { if (!WLCombatCameraSettings.Enabled) return; var st = St; if (!st.bossCamEnabled || !e.isBoss || e.actor == null) return; var cam = ResolveCamera(); if (cam == null) { BossCamSkipped++; LastInfo = "bosscam(no cam)"; return; } // FIX-2: 프리셋 적용은 busy 스킵 검사 **뒤**로 — 보스 캠이 스킵되는데 프리셋만 써지는 일이 없게 한다. if (cam.IsEnter() || s_bossActive) { BossCamSkipped++; LastInfo = "bosscam(busy)"; return; } if (s_phase != Phase.None) EndZoom(true); ApplyBossPreset(cam, st, e.actor); float now = Time.unscaledTime; s_cam = cam; s_bossActive = true; s_bossStart = now; s_bossTf = e.actor.transform; // ── FIX-3(리뷰 Major-3): RealCamera.Set_Target 은 호출마다 list_tfCamactionTarget 에 항목을 쌓고(원본 :77-86), // 그 리스트는 인트로(CamStatus 0) 분기에서만 소비된다(:197·212) → 보스 캠 1회당 2건이 영구히 남아 다음 인트로/컷 경로가 // 우리 잔재를 첫 목표로 삼고 Start_Game() 이 지연된다. 그래서 **공개 필드 target 을 직접 대입**해 리스트를 건드리지 않는다. // · isTarget(private)은 target 이 이미 있을 때만 true 이므로 기존 대상이 없으면 아예 리타겟하지 않는다(카메라 정지 방지). // · m_Target(private Actor)은 이전 대상(PC)인 채로 남는다 = CameraMoveLead 의 "기준 속도" 출처만 PC 값 // (리드 자체는 target 의 이동 차분으로 계산 · speedReference > 0 이면 애초에 안 쓴다) · 오빗이 끝나면 다시 정합. // · 완전 정합이 필요하면 811n 에 Add_CamActionTarget 없는 Set_Target 오버로드를 요청한다(완료보고 기재). s_prevTarget = cam.target; s_bossRetargeted = false; if (st.bossCamRetarget && s_prevTarget != null && s_bossTf != null) { cam.target = s_bossTf; s_bossRetargeted = true; } s_bossBaseMin = cam.minDistance; s_bossBaseMax = cam.maxDistance; cam.minDistance = s_bossWMin = s_bossBaseMin * st.bossCamDistanceScale; cam.maxDistance = s_bossWMax = s_bossBaseMax * st.bossCamDistanceScale; s_bossYawApplied = 0f; BossCamCount++; Runner.enabled = true; LastInfo = "bosscam"; if (Verbose) Log("보스 캠 시작 boss=" + e.actor.name + " prevTarget=" + (s_prevTarget != null ? s_prevTarget.name : "null") + " retarget=" + s_bossRetargeted); } static void EndBossCam(bool restore) { if (!s_bossActive) return; var cam = s_cam; if (restore && cam != null) { if (Mathf.Abs(s_bossYawApplied) > 1e-4f) cam.Add_Rot(-s_bossYawApplied); if (Mathf.Approximately(cam.minDistance, s_bossWMin) && Mathf.Approximately(cam.maxDistance, s_bossWMax)) { cam.minDistance = s_bossBaseMin; cam.maxDistance = s_bossBaseMax; } // FIX-3: 복원도 공개 필드 대입(리스트 무접촉) · 외부가 대상을 바꿨으면 손대지 않음 if (s_bossRetargeted && s_prevTarget != null && ReferenceEquals(cam.target, s_bossTf)) cam.target = s_prevTarget; LastBossCamMeasured = Time.unscaledTime - s_bossStart; } s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f; s_cam = null; if (Verbose) Log("보스 캠 종료 restore=" + restore + " measured=" + LastBossCamMeasured.ToString("F3")); } // ───────────────────────────────────────── 보스 프리셋(arr_cCamData[1]) — FIX-2 /// 보스/락타겟 프리셋을 덮어쓰기 전에 원값을 스냅샷한다. 이미 걸려 있으면 스냅샷을 덮어쓰지 않는다(중첩 보스). static void ApplyBossPreset(RealCamera cam, WLCombatCameraSettings st, Actor boss) { if (!st.bossPresetApply || s_presetApplied) return; var arr = cam.arr_cCamData; if (arr == null || arr.Length <= 1 || arr[1] == null) return; var d = arr[1]; s_presetData = d; s_presetBoss = boss; s_presetOMin = d.minDistance; s_presetOMax = d.maxDistance; s_presetOHeight = d.cameraHeight; s_presetOUp = d.DistanceUp; d.minDistance = s_presetWMin = st.bossPresetDistance; d.maxDistance = s_presetWMax = st.bossPresetDistance; d.cameraHeight = s_presetWHeight = st.bossPresetCameraHeight; if (st.bossPresetDistanceUp >= 0f) d.DistanceUp = st.bossPresetDistanceUp; s_presetWUp = d.DistanceUp; s_presetApplied = true; BossPresetApplied++; if (Verbose) Log("보스 프리셋 적용 [1] " + s_presetOMin.ToString("F2") + "/" + s_presetOMax.ToString("F2") + "/" + s_presetOHeight.ToString("F0") + "° → " + s_presetWMin.ToString("F2") + "/" + s_presetWMax.ToString("F2") + "/" + s_presetWHeight.ToString("F0") + "°"); } /// 프리셋을 원값으로 되돌린다(보스 처치·사라짐·씬 전환·SO off·러너 파괴). 파일 규약대로 "우리가 쓴 값이 그대로 남아 있을 때만" 되돌린다. static void RestoreBossPreset() { if (!s_presetApplied) return; var d = s_presetData; s_presetApplied = false; s_presetData = null; s_presetBoss = null; if (d == null) return; if (!Mathf.Approximately(d.minDistance, s_presetWMin) || !Mathf.Approximately(d.maxDistance, s_presetWMax) || !Mathf.Approximately(d.cameraHeight, s_presetWHeight) || !Mathf.Approximately(d.DistanceUp, s_presetWUp)) { BossPresetAbandoned++; if (Verbose) Log("보스 프리셋 복원 포기(외부가 값을 바꿈)"); return; } d.minDistance = s_presetOMin; d.maxDistance = s_presetOMax; d.cameraHeight = s_presetOHeight; d.DistanceUp = s_presetOUp; BossPresetRestored++; if (Verbose) Log("보스 프리셋 복원 [1] " + s_presetOMin.ToString("F2") + "/" + s_presetOMax.ToString("F2") + "/" + s_presetOHeight.ToString("F0") + "°"); } /// 프리셋 수명 감시 — 보스가 죽거나(IsDead) 풀로 돌아가거나(비활성) 파괴되면, SO 가 꺼지면 원값으로 돌린다. static void TickBossPreset() { if (!s_presetApplied) return; if (!WLCombatCameraSettings.Enabled) { RestoreBossPreset(); return; } // C8 — 끄면 즉시 원본 값 var b = s_presetBoss; if (b == null || b.IsDead()) RestoreBossPreset(); // IsDead() = DeadStatus || !activeInHierarchy(풀 반납) } static void TickBossCam(float now) { if (!s_bossActive) return; var st = St; if (s_cam == null || st == null || !WLCombatCameraSettings.Enabled) { EndBossCam(true); return; } float dur = Mathf.Max(st.bossCamSeconds, 0.0001f); float t = (now - s_bossStart) / dur; if (t >= 1f) { EndBossCam(true); return; } float yaw = Mathf.Sin(t * Mathf.PI) * st.bossCamOrbitDegrees; s_cam.Add_Rot(yaw - s_bossYawApplied); s_bossYawApplied = yaw; } // ───────────────────────────────────────── ComboStage(isLast) → 3타 리드 static void OnComboStage(in ComboStageEvent e) { if (!WLCombatCameraSettings.Enabled) return; var st = St; if (!st.comboLeadEnabled || !e.isLast) return; CameraMoveLead.LeadMultiplier = st.comboLeadMultiplier; s_leadUntil = Time.unscaledTime + st.comboLeadSeconds; ComboLeadCount++; Runner.enabled = true; if (Verbose) Log("3타 리드 ×" + st.comboLeadMultiplier + " " + st.comboLeadSeconds + " s clip=" + e.clip); } static void TickLead(float now) { if (s_leadUntil >= 0f && now >= s_leadUntil) { CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; } } // ───────────────────────────────────────── 공용 internal static void Tick() { float now = Time.unscaledTime; TickKillCam(now); TickBossCam(now); TickBossPreset(); TickLead(now); } /// 활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리). public static void ReleaseAll() { EndZoom(true); EndBossCam(true); RestoreBossPreset(); CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; } static void OnSceneChanged(Scene from, Scene to) { // 카메라 오브젝트가 사라졌을 수 있다 — 복원 없이 상태만 버린다(복원은 ReleaseAll 이 러너 OnDisable 에서 시도) s_phase = Phase.None; s_scale = 1f; s_cam = null; s_bossActive = false; s_bossTf = null; s_prevTarget = null; s_bossYawApplied = 0f; RestoreBossPreset(); // FIX-2: 프리셋은 순수 데이터(cCamData)라 카메라가 파괴돼도 되돌리는 쪽이 안전하다(살아 있으면 원값 복귀) CameraMoveLead.LeadMultiplier = 1f; s_leadUntil = -1f; s_runCount = 0; s_lastKillCam = -999f; } /// 프로브용: 런 카운터·쿨 초기화(연출 상태는 유지). public static void ResetRun() { s_runCount = 0; s_lastKillCam = -999f; } /// 프로브·QA 용: 쿨만 초기화(런 카운터 유지 → 런 상한 검증). public static void ResetCooldown() { s_lastKillCam = -999f; } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { KillCamCount = KillCamSkippedRole = KillCamSkippedCooldown = KillCamSkippedRunCap = KillCamSkippedGuard = KillCamSkippedNoCam = KillCamSkippedBusy = KillCamAbandoned = KillCamRebased = 0; BossCamCount = BossCamSkipped = BossPresetApplied = BossPresetRestored = BossPresetAbandoned = ComboLeadCount = 0; LastKillCamMeasured = LastBossCamMeasured = 0f; LastVictimViewportY = -1f; LastInfo = ""; } } /// 연출 카메라 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad). 파괴·비활성·종료 시 카메라 값을 반드시 되돌린다. public sealed class CombatCameraRunner : MonoBehaviour { void Update() { CombatCameraDirector.Tick(); } void OnDisable() { CombatCameraDirector.ReleaseAll(); } void OnApplicationQuit() { CombatCameraDirector.ReleaseAll(); } } }