2026-09-08 15:20:16 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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)
2026-09-08 16:08:47 +00:00
// Spawned(isBoss) → arr_cCamData[1] 보스 프리셋 덮어쓰기(옵션 · 원값 스냅샷 후 · 보스가 죽거나 사라지면 원복) · 추적 대상을 보스로
// (공개 필드 target 직접 대입 = list_tfCamactionTarget 무접촉) · Add_Rot 로 sin 오빗 · 거리 배율 · 끝나면 전부 복원
2026-09-08 15:20:16 +00:00
// 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 때만 문자열)
2026-09-08 16:08:47 +00:00
/ /
// ■ 리뷰 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 직접 대입**으로 — 리스트 길이 불변
2026-09-08 15:20:16 +00:00
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine ;
using UnityEngine.SceneManagement ;
using WL.Combat.Core ;
namespace WL.CombatCamera
{
public static class CombatCameraDirector
{
2026-09-08 16:08:47 +00:00
enum Phase { None = 0 , Pending = 1 , ZoomIn = 2 , Hold = 3 , ZoomOut = 4 }
2026-09-08 15:20:16 +00:00
// ── 킬캠 상태
static Phase s_phase ;
static float s_phaseStart , s_killStart ;
static float s_scale = 1f ;
2026-09-08 16:08:47 +00:00
static float s_baseMin , s_baseMax , s_baseUp ; // 줌 시작 시점 스냅샷(FIX-1 = 처치 다음 프레임에 재취득)
static float s_preMin , s_preMax , s_preUp ; // 처치 이벤트 시점 값 — 외부 덮어쓰기 계측용(KillCamRebased)
2026-09-08 15:20:16 +00:00
static float s_wMin , s_wMax , s_wUp ; // 마지막으로 우리가 쓴 값(외부 덮어쓰기 감지)
static bool s_scaleUp ;
static float s_lastKillCam = - 999f ;
static int s_runCount ;
2026-09-08 16:08:47 +00:00
static int s_pendingFrame ; // FIX-1: 킬캠을 예약한 프레임(이 프레임에는 시작하지 않는다)
2026-09-08 15:20:16 +00:00
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 ;
2026-09-08 16:08:47 +00:00
// ── 보스 프리셋(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 ; // 우리가 쓴 값
2026-09-08 15:20:16 +00:00
// ── 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 ;
2026-09-08 16:08:47 +00:00
public static int KillCamRebased ; // FIX-1: 미룬 사이에 외부(Init_Cam 등)가 궤도 값을 바꿔 base 를 다시 잡은 횟수
public static int BossCamCount , BossCamSkipped , BossPresetApplied , BossPresetRestored , BossPresetAbandoned , ComboLeadCount ;
2026-09-08 15:20:16 +00:00
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 ; } }
2026-09-08 16:08:47 +00:00
public static bool KillCamPending { get { return s_phase = = Phase . Pending ; } }
2026-09-08 15:20:16 +00:00
public static bool BossCamActive { get { return s_bossActive ; } }
2026-09-08 16:08:47 +00:00
public static bool BossPresetActive { get { return s_presetApplied ; } }
2026-09-08 15:20:16 +00:00
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 ;
2026-09-08 16:08:47 +00:00
// FIX-2: 프리셋을 건 보스가 죽었으면 즉시 원복(틱 폴백보다 한 프레임 빠르다).
// Set_Die 는 이 뒤에 Set_LockTarget(null) → Init_Cam() 으로 인덱스 0 을 읽으므로 여기서 되돌려도 이번 프레임 연출에 영향 0.
if ( s_presetApplied & & ReferenceEquals ( e . victim , s_presetBoss ) ) RestoreBossPreset ( ) ;
2026-09-08 15:20:16 +00:00
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 ; }
2026-09-08 16:08:47 +00:00
// 쿨·런 카운터는 여기서 소모한다 — 아래 스킵(카메라 없음·인트로·하단 가드)으로 슬로모만 나가도 "킬캠 1회를 썼다"로 친다.
// 의도: 연출 밀도(런당 ≤6 · 최소 간격 8 s)는 슬로모까지 포함한 체감 기준이다(리뷰 Minor-6 확인 사항).
2026-09-08 15:20:16 +00:00
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 ) ; // (쿨 안이라 사실상 없음) 안전
2026-09-08 16:08:47 +00:00
// ── 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프레임).
2026-09-08 15:20:16 +00:00
s_cam = cam ;
2026-09-08 16:08:47 +00:00
s_preMin = cam . minDistance ; s_preMax = cam . maxDistance ; s_preUp = cam . DistanceUp ;
2026-09-08 15:20:16 +00:00
s_scaleUp = st . killCamScaleDistanceUp ;
2026-09-08 16:08:47 +00:00
s_scale = 1f ;
s_phase = Phase . Pending ; s_pendingFrame = Time . frameCount ; s_phaseStart = s_killStart = now ;
2026-09-08 15:20:16 +00:00
Runner . enabled = true ;
LastInfo = "killcam" ;
2026-09-08 16:08:47 +00:00
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 ) ;
2026-09-08 15:20:16 +00:00
}
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 ;
2026-09-08 16:08:47 +00:00
if ( s_phase = = Phase . Pending ) // 아직 아무것도 쓰지 않았다 — 복원할 것도, 포기로 셀 것도 없다
{
s_phase = Phase . None ; s_scale = 1f ; s_cam = null ;
if ( Verbose ) Log ( "킬캠 예약 취소" ) ;
return ;
}
2026-09-08 15:20:16 +00:00
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 ; }
2026-09-08 16:08:47 +00:00
if ( s_phase = = Phase . Pending ) { BeginZoom ( now ) ; return ; } // FIX-1: 예약 → 다음 프레임 시작(base 재취득)
2026-09-08 15:20:16 +00:00
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 ; }
2026-09-08 16:08:47 +00:00
// FIX-2: 프리셋 적용은 busy 스킵 검사 **뒤**로 — 보스 캠이 스킵되는데 프리셋만 써지는 일이 없게 한다.
2026-09-08 15:20:16 +00:00
if ( cam . IsEnter ( ) | | s_bossActive ) { BossCamSkipped + + ; LastInfo = "bosscam(busy)" ; return ; }
if ( s_phase ! = Phase . None ) EndZoom ( true ) ;
2026-09-08 16:08:47 +00:00
ApplyBossPreset ( cam , st , e . actor ) ;
2026-09-08 15:20:16 +00:00
float now = Time . unscaledTime ;
s_cam = cam ;
s_bossActive = true ; s_bossStart = now ;
s_bossTf = e . actor . transform ;
2026-09-08 16:08:47 +00:00
// ── 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 오버로드를 요청한다(완료보고 기재).
2026-09-08 15:20:16 +00:00
s_prevTarget = cam . target ;
s_bossRetargeted = false ;
2026-09-08 16:08:47 +00:00
if ( st . bossCamRetarget & & s_prevTarget ! = null & & s_bossTf ! = null ) { cam . target = s_bossTf ; s_bossRetargeted = true ; }
2026-09-08 15:20:16 +00:00
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 ;
}
2026-09-08 16:08:47 +00:00
// FIX-3: 복원도 공개 필드 대입(리스트 무접촉) · 외부가 대상을 바꿨으면 손대지 않음
if ( s_bossRetargeted & & s_prevTarget ! = null & & ReferenceEquals ( cam . target , s_bossTf ) ) cam . target = s_prevTarget ;
2026-09-08 15:20:16 +00:00
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" ) ) ;
}
2026-09-08 16:08:47 +00:00
// ───────────────────────────────────────── 보스 프리셋(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(풀 반납)
}
2026-09-08 15:20:16 +00:00
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 ) ;
2026-09-08 16:08:47 +00:00
TickBossPreset ( ) ;
2026-09-08 15:20:16 +00:00
TickLead ( now ) ;
}
/// <summary>활성 연출을 전부 끝내고 카메라 값을 복원한다(러너 파괴 · 앱 종료 · 프로브 정리).</summary>
public static void ReleaseAll ( )
{
EndZoom ( true ) ;
EndBossCam ( true ) ;
2026-09-08 16:08:47 +00:00
RestoreBossPreset ( ) ;
2026-09-08 15:20:16 +00:00
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 ;
2026-09-08 16:08:47 +00:00
RestoreBossPreset ( ) ; // FIX-2: 프리셋은 순수 데이터(cCamData)라 카메라가 파괴돼도 되돌리는 쪽이 안전하다(살아 있으면 원값 복귀)
2026-09-08 15:20:16 +00:00
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 ( )
{
2026-09-08 16:08:47 +00:00
KillCamCount = KillCamSkippedRole = KillCamSkippedCooldown = KillCamSkippedRunCap = KillCamSkippedGuard = KillCamSkippedNoCam = KillCamSkippedBusy = KillCamAbandoned = KillCamRebased = 0 ;
BossCamCount = BossCamSkipped = BossPresetApplied = BossPresetRestored = BossPresetAbandoned = ComboLeadCount = 0 ;
2026-09-08 15:20:16 +00:00
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 ( ) ; }
}
}