Project_WL/Assets/WL/Camera/CombatCameraDirector.cs

474 lines
29 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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<CombatCameraRunner>();
}
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"));
}
/// <summary>FIX-1: 예약해 둔 킬캠을 다음 프레임에 시작한다 — 처치 프레임의 나머지 로직(Init_Cam 등)이 다 쓴 뒤의 값을 base 로 잡는다.</summary>
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
/// <summary>보스/락타겟 프리셋을 덮어쓰기 전에 원값을 스냅샷한다. 이미 걸려 있으면 스냅샷을 덮어쓰지 않는다(중첩 보스).</summary>
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") + "°");
}
/// <summary>프리셋을 원값으로 되돌린다(보스 처치·사라짐·씬 전환·SO off·러너 파괴). 파일 규약대로 "우리가 쓴 값이 그대로 남아 있을 때만" 되돌린다.</summary>
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") + "°");
}
/// <summary>프리셋 수명 감시 — 보스가 죽거나(IsDead) 풀로 돌아가거나(비활성) 파괴되면, SO 가 꺼지면 원값으로 돌린다.</summary>
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);
}
/// <summary>활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리).</summary>
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;
}
/// <summary>프로브용: 런 카운터·쿨 초기화(연출 상태는 유지).</summary>
public static void ResetRun() { s_runCount = 0; s_lastKillCam = -999f; }
/// <summary>프로브·QA 용: 쿨만 초기화(런 카운터 유지 → 런 상한 검증).</summary>
public static void ResetCooldown() { s_lastKillCam = -999f; }
/// <summary>프로브용 카운터 초기화.</summary>
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 = "";
}
}
/// <summary>연출 카메라 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad). 파괴·비활성·종료 시 카메라 값을 반드시 되돌린다.</summary>
public sealed class CombatCameraRunner : MonoBehaviour
{
void Update() { CombatCameraDirector.Tick(); }
void OnDisable() { CombatCameraDirector.ReleaseAll(); }
void OnApplicationQuit() { CombatCameraDirector.ReleaseAll(); }
}
}