// ───────────────────────────────────────────────────────────────────────────── // 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] 보스 프리셋 덮어쓰기(옵션) · Set_Target(보스) · 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 때만 문자열) // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.SceneManagement; using WL.Combat.Core; namespace WL.CombatCamera { public static class CombatCameraDirector { enum Phase { None = 0, ZoomIn = 1, Hold = 2, ZoomOut = 3 } // ── 킬캠 상태 static Phase s_phase; static float s_phaseStart, s_killStart; static float s_scale = 1f; static float s_baseMin, s_baseMax, s_baseUp; // 발동 시점 스냅샷 static float s_wMin, s_wMax, s_wUp; // 마지막으로 우리가 쓴 값(외부 덮어쓰기 감지) static bool s_scaleUp; static float s_lastKillCam = -999f; static int s_runCount; 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; // ── 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 BossCamCount, BossCamSkipped, BossPresetApplied, 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 BossCamActive { get { return s_bossActive; } } 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; 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; } 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); // (쿨 안이라 사실상 없음) 안전 s_cam = cam; s_baseMin = cam.minDistance; s_baseMax = cam.maxDistance; s_baseUp = cam.DistanceUp; s_scaleUp = st.killCamScaleDistanceUp; s_phase = Phase.ZoomIn; s_phaseStart = s_killStart = now; WriteZoom(1f); Runner.enabled = true; LastInfo = "killcam"; if (Verbose) Log("킬캠 발동 victim=" + (e.victim != null ? e.victim.name : "?") + " sub=" + e.subRole + " 슬로모=" + LastSlowMoResult + " base=" + s_baseMin.ToString("F2") + "/" + s_baseMax.ToString("F2") + "/" + s_baseUp.ToString("F2")); } 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 (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 (!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; } if (st.bossPresetApply && cam.arr_cCamData != null && cam.arr_cCamData.Length > 1 && cam.arr_cCamData[1] != null) { var d = cam.arr_cCamData[1]; d.minDistance = d.maxDistance = st.bossPresetDistance; d.cameraHeight = st.bossPresetCameraHeight; if (st.bossPresetDistanceUp >= 0f) d.DistanceUp = st.bossPresetDistanceUp; BossPresetApplied++; } if (cam.IsEnter() || s_bossActive) { BossCamSkipped++; LastInfo = "bosscam(busy)"; return; } if (s_phase != Phase.None) EndZoom(true); float now = Time.unscaledTime; s_cam = cam; s_bossActive = true; s_bossStart = now; s_bossTf = e.actor.transform; s_prevTarget = cam.target; s_bossRetargeted = false; if (st.bossCamRetarget) { cam.Set_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; } if (s_bossRetargeted && ReferenceEquals(cam.target, s_bossTf)) cam.Set_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")); } 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); TickLead(now); } /// 활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리). public static void ReleaseAll() { EndZoom(true); EndBossCam(true); 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; 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 = 0; BossCamCount = BossCamSkipped = BossPresetApplied = 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(); } } }