// ─────────────────────────────────────────────────────────────────────────────
// CameraFramingOverride.cs — 맵 진입 시 카메라 구도(내려다보는 각 + 거리) 1회 오버라이드
//
// PD 지시 #813 · 발주서 WL-811o §1-1 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3
//
// ■ 관측된 결함 (QA 실측 · 캡처 Screenshots_WL/Q4b/01_enter_hud.png)
// 존1 진입 직후 큰 덤불/바위가 PC 를 화면의 40 % 가까이 덮는다. 카메라가 거의 눕다시피 해서
// 카메라~PC 선분이 지면 가까이 깔리기 때문이다(아래 실측).
//
// ■ 원인 (코드·프리팹 실측)
// Main Camera.prefab arr_cCamData[0] = minDistance 3 · maxDistance 3 · DistanceUp 2 · **cameraHeight 65**
// (원본 커밋 d5df1b2c9 「카메라 수정」 에서 78 → 65 · 1.5/1.6/1 → 3/3/2 로 바뀌었다.)
// RealCamera 궤도 수식으로 환산하면 **내림각 25.6°** — 카메라~주시점 선분이 PC 로부터 2.5 m 지점에서
// 지면 위 2.26 m 밖에 안 된다. 맵의 바위·덤불이 그보다 높으면 그대로 PC 를 덮는다.
//
// ■ 이 파일이 하는 것 — 원본 훅 0 · RealCamera 무수정 · 폴링만(AutoCombatOnEnter 선례)
// 맵(런) 진입 경계에서 **1회**, RealCamera 의 공개 데이터 arr_cCamData[0](일반 프리셋)에
// SO 값(cameraHeight · distance · distanceUp)을 쓰고, 살아 있는 공개 필드에도 같은 값을 반영한다.
// · arr_cCamData[0] 을 쓰는 이유 = 원본이 나중에 Init_Cam()(private · Set_Target(t, rot) 경유)으로
// 프리셋을 **다시 읽어** 라이브 필드를 덮어쓰기 때문. 배열까지 바꿔야 값이 유지된다.
// (같은 이유로 811d 는 arr_cCamData[1] 보스 프리셋을 덮어쓴다 — 이 파일은 [0] 만 건드린다.)
// · 라이브 필드 반영 = minDistance/maxDistance/cameraHeight 직접 대입 + Set_DistanceUp(false)(공개 API).
// Init_Cam() 의 나머지 한 줄(private VerticalAxis)은 DistanceAway 를 즉시 스냅시키는 용도인데,
// min=max 로 두면 다음 Update_Cam 의 Clamp 가 같은 일을 하므로 필요 없다.
// · 원값은 적용 직전에 스냅샷 → SO off · 씬 전환 · 러너 파괴 때 그대로 되돌린다(C8).
// · 킬캠/보스 캠(811d)이 궤도 거리를 쓰고 있는 동안에는 적용을 미룬다(다음 폴링에서 재시도) —
// 진행 중인 램프의 base 를 오염시키지 않기 위해서다.
//
// ■ 왜 매 프레임 쓰지 않는가
// 811d CombatCameraDirector 가 킬캠·보스 캠에서 같은 필드를 램프한다. 매 프레임 쓰면 두 기능이 싸운다.
// 구도는 「진입 시 1회」로 충분하다(발주서 §1-1).
//
// ■ 값(C45)은 전부 WLCameraFramingSettings. framingEnabled = false 면 아무 것도 쓰지 않는다.
//
// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.CombatCamera;
namespace WL.CameraFraming
{
/// 맵 진입 시 카메라 구도 1회 오버라이드. 정적 · 새 Manager/Singleton 0.
public static class CameraFramingOverride
{
static WLCameraFramingSettings Cfg { get { return WLCameraFramingSettings.Instance; } }
/// 기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 구도 100 %.
public static bool Active
{
get { var c = Cfg; return c != null && c.enabled && c.framingEnabled && !WLCameraFramingSettings.RuntimeDisabled; }
}
// ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다)
public static int Boundaries, Applied, Restored, SkippedLobby, SkippedBusy, SkippedNoCam;
public static string LastReason = "", LastLog = "";
/// 지금 우리 값이 걸려 있는가.
public static bool IsApplied { get { return s_applied; } }
/// 적용 중인 값(진단) — 궤도 파라미터 · 거리 · 궤도 중심 높이.
public static float AppliedHeight, AppliedDistance, AppliedUp;
/// 원값 스냅샷(진단) — 복원 대상.
public static float OriginHeight, OriginMin, OriginMax, OriginUp;
// ───────────────────────────────────────── 내부 상태
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
static float s_nextPoll;
static bool s_faulted;
static bool s_applied;
static cCamData s_data; // 우리가 값을 쓴 프리셋(원본 [Serializable] 클래스 = fake-null 아님)
static RealCamera s_cam;
static float s_wHeight, s_wMin, s_wMax, s_wUp; // 우리가 쓴 값(외부 덮어쓰기 감지)
// ── WL-814d 런타임 프리셋 — 룩 토글이 SO(cameraHeight/distance/distanceUp) 대신 얹는 값.
// 적용 시점은 그대로다(다음 폴링 · 킬캠/보스 캠이 돌면 그 뒤). 아무도 부르지 않으면 기존 동작과 같다.
static bool s_rtOn, s_rtDirty; static float s_rtH, s_rtD, s_rtUp;
/// 룩 토글이 지금 구도 값을 얹고 있는가(진단).
public static bool RuntimePresetOn { get { return s_rtOn; } }
/// WL-814d — SO 값 대신 이 구도 값을 쓴다(다음 폴링에 반영).
public static void SetRuntimePreset(float cameraHeight, float distance, float distanceUp)
{
if (s_rtOn && cameraHeight == s_rtH && distance == s_rtD && distanceUp == s_rtUp) return;
s_rtOn = true; s_rtH = cameraHeight; s_rtD = distance; s_rtUp = distanceUp;
s_rtDirty = true; s_nextPoll = 0f;
}
/// WL-814d — 런타임 프리셋을 걷고 SO 값으로 돌아간다.
public static void ClearRuntimePreset()
{
if (!s_rtOn) return;
s_rtOn = false; s_rtDirty = true; s_nextPoll = 0f;
}
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 직접 부를 수 있다).
internal static void Tick()
{
if (s_faulted) return;
// C8 — 기능이 꺼지면 즉시 원값으로 되돌린다.
if (!Active) { if (s_applied) Restore("off"); return; }
var cfg = Cfg;
float now = Time.unscaledTime;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, cfg.framingPollSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1).
try { PollBoundary(cfg); }
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
}
static void PollBoundary(WLCameraFramingSettings cfg)
{
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; }
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return; // 아직 인게임 정보가 없다 — 경계를 소비하지 않는다
int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue;
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
bool mapChanged = map != s_lastMapId;
if (!pcChanged && !mapChanged && !s_rtDirty) return; // WL-814d — 런타임 프리셋이 바뀌면 경계가 아니어도 다시 쓴다
if (!cfg.framingInLobby && info.Get_GameMode() == eGameMode.Lobby)
{
s_rtDirty = false;
s_lastPc = pc; s_lastMapId = map;
Boundaries++; SkippedLobby++;
LastReason = "lobby";
Log("로비 — 건너뜀");
return;
}
var cam = MyValue.m_RealCamera;
if (DSUtil.CheckNull(cam) || cam.arr_cCamData == null || cam.arr_cCamData.Length == 0 || cam.arr_cCamData[0] == null)
{
SkippedNoCam++; // 경계를 소비하지 않는다 — 카메라가 뜨면 다음 폴링에서 다시 본다
return;
}
// 811d 킬캠·보스 캠이 궤도 거리를 램프하는 중이면 미룬다(그 base 를 오염시키지 않는다).
if (CombatCameraDirector.KillCamActive || CombatCameraDirector.BossCamActive || CombatCameraDirector.BossPresetActive)
{
SkippedBusy++;
return;
}
s_lastPc = pc; s_lastMapId = map;
if (pcChanged || mapChanged) Boundaries++; // WL-814d — 프리셋 변경만으로는 경계를 세지 않는다
LastReason = pcChanged ? "pc" : (mapChanged ? "map " + map : "preset");
Apply(cfg, cam);
}
// ─────────────────────────────────────────────────────────────────────
// 적용 · 복원
// ─────────────────────────────────────────────────────────────────────
static void Apply(WLCameraFramingSettings cfg, RealCamera cam)
{
var d = cam.arr_cCamData[0];
// 이미 우리 값이 걸려 있고 같은 프리셋이면 원값 스냅샷을 다시 잡지 않는다(자기 값을 원값으로 착각 방지).
if (!s_applied || !ReferenceEquals(d, s_data))
{
s_data = d;
OriginHeight = d.cameraHeight; OriginMin = d.minDistance; OriginMax = d.maxDistance; OriginUp = d.DistanceUp;
}
// WL-814d — 런타임 프리셋이 걸려 있으면 SO 대신 그 값(안전 범위는 넓게 · 도트 프리셋은 45° 까지 내려간다).
s_rtDirty = false;
float h = s_rtOn ? Mathf.Clamp(s_rtH, 10f, 89f) : Mathf.Clamp(cfg.cameraHeight, 50f, 85f);
float dist = s_rtOn ? Mathf.Clamp(s_rtD, 0.5f, 20f) : Mathf.Clamp(cfg.distance, 1f, 8f);
float up = s_rtOn ? s_rtUp : (cfg.distanceUp >= 0f ? cfg.distanceUp : OriginUp);
d.cameraHeight = h;
d.minDistance = dist;
d.maxDistance = dist;
d.DistanceUp = up;
// 라이브 필드 — Init_Cam() 이 하는 일과 같은 효과(공개 API 만 사용)
cam.cameraHeight = h;
cam.minDistance = dist;
cam.maxDistance = dist;
cam.Set_DistanceUp(false); // DistanceUp = arr_cCamData[islockTarget ? 1 : 0].DistanceUp
s_wHeight = h; s_wMin = dist; s_wMax = dist; s_wUp = up;
s_cam = cam;
s_applied = true;
Applied++;
AppliedHeight = h; AppliedDistance = dist; AppliedUp = up;
Log("구도 적용 #" + Applied + " (" + LastReason + ") h " + OriginHeight + "→" + h +
" · dist " + OriginMax + "→" + dist + " · up " + OriginUp + "→" + up +
" · 내림각 " + DownAngleDeg(h, dist, up).ToString("0.0") + "°");
}
/// 원값으로 되돌린다(외부가 그 사이에 값을 바꿨으면 건드리지 않는다).
static void Restore(string why)
{
if (!s_applied) return;
s_applied = false;
var d = s_data;
if (d != null && d.cameraHeight == s_wHeight && d.minDistance == s_wMin && d.maxDistance == s_wMax && d.DistanceUp == s_wUp)
{
d.cameraHeight = OriginHeight; d.minDistance = OriginMin; d.maxDistance = OriginMax; d.DistanceUp = OriginUp;
var cam = s_cam;
if (!DSUtil.CheckNull(cam))
{
cam.cameraHeight = OriginHeight;
cam.minDistance = OriginMin;
cam.maxDistance = OriginMax;
cam.Set_DistanceUp(false);
}
Restored++;
Log("구도 복원(" + why + ") h→" + OriginHeight + " · dist→" + OriginMax);
}
else Log("구도 복원 포기(" + why + ") — 외부가 값을 바꿨다");
s_data = null; s_cam = null;
}
/// 씬 전환·러너 파괴·앱 종료에서 부른다.
public static void ReleaseAll() { Restore("release"); s_lastPc = null; s_lastMapId = int.MinValue; }
// ─────────────────────────────────────────────────────────────────────
// 궤도 환산 (진단·프로브 공용 · RealCamera.Update_Cam 수식 그대로)
// ─────────────────────────────────────────────────────────────────────
/// 궤도 파라미터 h 에서 카메라가 타겟 발밑보다 얼마나 높은지(m).
public static float CamHeightAbove(float h, float distance, float distanceUp)
{
float r = h * Mathf.Deg2Rad;
return distanceUp + distance * (Mathf.Sin(r) - Mathf.Cos(r));
}
/// 궤도 파라미터 h 에서 카메라~타겟 수평 거리(m).
public static float CamHorizontal(float h, float distance)
{
float r = h * Mathf.Deg2Rad;
float s = Mathf.Sin(r) + Mathf.Cos(r);
return distance * Mathf.Sqrt(1f + s * s);
}
/// 주시점(발밑 + lookAtY)까지의 내림각(도). lookAtY 기본 1.06 = RealCamera.m_LookAt.y 실측.
public static float DownAngleDeg(float h, float distance, float distanceUp, float lookAtY = 1.06f)
{
float dy = CamHeightAbove(h, distance, distanceUp) - lookAtY;
float dx = CamHorizontal(h, distance);
if (dx <= 0.0001f) return 90f;
return Mathf.Atan2(dy, dx) * Mathf.Rad2Deg;
}
/// 궤도 파라미터 h 의 카메라 위치(월드) — 프로브가 가림률을 계산할 때 쓴다.
public static Vector3 CameraPosition(Vector3 targetFoot, float yawDeg, float h, float distance, float distanceUp)
{
Vector3 rotateVector = Quaternion.Euler(h, yawDeg, 0f) * Vector3.one;
return targetFoot + Vector3.up * distanceUp - rotateVector * distance;
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 러너
// ─────────────────────────────────────────────────────────────────────
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[CameraFraming] " + msg);
}
/// 프로브용: 카운터·경계 상태 초기화(적용된 값은 유지).
public static void ResetDiagnostics()
{
Boundaries = Applied = Restored = SkippedLobby = SkippedBusy = SkippedNoCam = 0;
LastReason = LastLog = "";
s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f; s_faulted = false; s_rtOn = s_rtDirty = false;
}
/// 프로브용: 러너 없이 1회 폴링(강제 틱).
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
///
/// 프로브용: 경계 판정(PC·InGameInfo)을 건너뛰고 이 카메라에 값을 적용한다.
/// 🔴 에디트 모드에서 InGameInfo.Ins / ServerInfo.Ins 를 건드리면 싱글턴 GameObject 가 생겨 씬이 더러워진다(813x §0-12) —
/// 그래서 프로브는 이 진입점을 쓴다. 런타임 경로(PollBoundary)와 Apply 는 같은 함수를 부른다.
///
public static bool ProbeApply(RealCamera cam)
{
var cfg = Cfg;
if (cfg == null || !Active) return false;
if (DSUtil.CheckNull(cam) || cam.arr_cCamData == null || cam.arr_cCamData.Length == 0 || cam.arr_cCamData[0] == null) return false;
LastReason = "probe";
Boundaries++;
Apply(cfg, cam);
return true;
}
/// 프로브용: 즉시 원값 복원.
public static void ProbeRestore() { Restore("probe"); }
static CameraFramingRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL811o] CameraFramingRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
var c = Cfg;
if (c != null && c.enabled && (c.framingEnabled || c.occluderFadeEnabled)) EnsureRunner(); // 둘 다 꺼져 있으면 오브젝트 0(C8)
}
}
/// 구도 오버라이드 + 가림 소품 숨김의 시간 축. 숨김 GameObject 1개 · 코루틴 0.
internal sealed class CameraFramingRunner : MonoBehaviour
{
void Update()
{
CameraFramingOverride.Tick();
OccluderFade.Tick();
}
void OnDisable()
{
CameraFramingOverride.ReleaseAll();
OccluderFade.ReleaseAll();
}
void OnApplicationQuit()
{
CameraFramingOverride.ReleaseAll();
OccluderFade.ReleaseAll();
}
}
}