Project_WL/Assets/WL/Camera/CombatCameraDirector.cs

362 lines
19 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] 보스 프리셋 덮어쓰기(옵션) · 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<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;
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);
}
/// <summary>활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리).</summary>
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;
}
/// <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 = 0;
BossCamCount = BossCamSkipped = BossPresetApplied = 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(); }
}
}