362 lines
19 KiB
C#
362 lines
19 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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(); }
|
||
}
|
||
}
|