// ───────────────────────────────────────────────────────────────────────────── // 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; // 우리가 쓴 값(외부 덮어쓰기 감지) // ───────────────────────────────────────────────────────────────────── // 틱 // ───────────────────────────────────────────────────────────────────── /// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 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) return; if (!cfg.framingInLobby && info.Get_GameMode() == eGameMode.Lobby) { 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; Boundaries++; LastReason = pcChanged ? "pc" : ("map " + map); 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; } float h = Mathf.Clamp(cfg.cameraHeight, 50f, 85f); float dist = Mathf.Clamp(cfg.distance, 1f, 8f); float up = 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; } /// 프로브용: 러너 없이 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(); } } }