// ───────────────────────────────────────────────────────────────────────────── // LookModeHub.cs — 인게임 룩 모드 허브: 카메라 모드 순환 + 배경 룩 플래그 (WL-814d · #814) // // PD 제안(2026-09-10) 「토글 형태로 카메라를 바꿔서 볼 수 있게」 · 발주서 WL-814d §1-1·§1-2·§1-4. // // ■ 이 파일이 하는 것 — 원본 훅 0 · 새 Manager/Singleton 0(정적 + 러너 GameObject 1개) // ① 앱 세션 동안 「지금 어떤 룩인가」를 들고 있는다(CameraMode 0~N · EnvLookOn). // ② 모드가 바뀌면 값을 **두 기존 기능에 런타임으로 얹는다**(둘 다 이번에 추가한 API · 기존 경로 무변경): // · 811o `CameraFramingOverride.SetRuntimePreset(h, d, up)` / `ClearRuntimePreset()` // → 원본 `RealCamera.arr_cCamData[0]` 구도를 SO 값 대신 프리셋 값으로 (폴링 0.25 s 안에 반영 · // 킬캠/보스 캠이 도는 동안에는 그 뒤로 미뤄진다 = 811o 의 SkippedBusy 규칙 그대로) // · 814b `PixelCameraRig.SetRuntimeOverride(on, orthoSize, pixelHeight)` // → SO `enabled 0`(F-23) 이어도 on 이면 리그를 조립·유지, off 면 해체 // ③ 배경 룩(814c)은 **플래그 + 이벤트만** 여기서 든다 — 렌더러/에셋은 814c 소유라 이 태스크가 건드리지 않는다. // 814c `EnvLook` 이 `EnvLookChanged` 를 구독하면 그대로 이어진다(발주서 §1-4 · 완료보고 §연동 규약). // // ■ 왜 「값을 얹기」인가 // 814b 리그와 811o 구도는 각자 SO 를 갖고 각자 맵 진입 경계에서 값을 적용한다. 허브가 카메라를 직접 // 만지면 그 둘과 싸운다. 그래서 허브는 **두 기능의 입력값만 바꾸고** 적용 시점·복원은 원래 주인에게 맡긴다. // → 맵 재진입·런 재시작에서 리그 재조립·구도 재적용이 저절로 일어난다(각 기능의 PollBoundary). // // ■ 로비/타이틀 // `defaultInLobby` 가 켜져 있으면 로비에서는 **적용 모드만** 0 으로 내리고 고른 모드(CameraMode)는 그대로 둔다 // → 인게임에 다시 들어가면 저절로 복원된다. (814b 리그도 `assembleInLobby 0` 이라 로비에서 스스로 걷는다.) // // ■ GC 0 — 틱은 비교뿐이다(문자열·람다·클로저 0). 로그 문자열은 만들기 전에 verboseLog 로 막는다(811b FIX-3). // // ■ C8 롤백 — `WLLookModeSettings.enabled_ = 0` → 적용 모드 0 · 신규 API 호출 0 = 814b/811o 원래 동작 100 %. // // 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using WL.CameraFraming; using WL.PixelArt; namespace WL.Look.Toggle { /// 룩 모드(카메라 프리셋 + 배경 룩) 허브. 정적 · 상태는 앱 세션 동안 유지된다. public static class LookModeHub { static WLLookModeSettings St { get { return WLLookModeSettings.Instance; } } // ── 상태 ────────────────────────────────────────────────────────────── static int s_mode = -1; // PD 가 고른 모드(로비에 가도 유지) static int s_applied = 0; // 실제로 두 기능에 얹혀 있는 모드 static bool s_env; static bool s_booted; /// PD 가 고른 카메라 모드(0 = 기본 원근 · 1~ = 도트 프리셋). 로비에 가도 이 값은 그대로. public static int CameraMode { get { EnsureBoot(); return s_mode; } } /// 지금 실제로 카메라에 얹혀 있는 모드(로비에서는 0 으로 내려간다). public static int AppliedMode { get { return s_applied; } } /// 배경 룩(814c) on/off 플래그 — 이 태스크는 플래그와 이벤트만 든다. public static bool EnvLookOn { get { EnsureBoot(); return s_env; } } /// 지금 모드가 도트(직교 리그) 모드인가. public static bool IsPixelMode { get { var p = St != null ? St.Preset(CameraMode) : null; return p != null && p.pixelRig; } } /// 버튼에 찍을 지금 모드 이름. public static string CameraModeName { get { var s = St; return s != null ? s.ModeName(CameraMode) : "기본"; } } // ── 이벤트 (814c · UI 가 구독한다 · 인자 = 바뀐 값) ──────────────────── /// 카메라 모드가 바뀌었다(PD 선택 기준 · 로비 강등은 알리지 않는다). public static event System.Action CameraModeChanged; /// 배경 룩 플래그가 바뀌었다 — 🔴 814c `EnvLook` 이 여기에 붙는다(발주서 §1-4). public static event System.Action EnvLookChanged; /// UI(WLLookToggleUi)가 매 프레임 얻어가는 훅 — 러너를 두 개 만들지 않기 위한 배선. public static System.Action UiTick; // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── public static int Applies, ModeChanges, EnvChanges, LobbyDemotes, Restores; public static string LastLog = ""; // ───────────────────────────────────────────────────────────────────── // 공개 조작 // ───────────────────────────────────────────────────────────────────── /// 모드를 고른다(범위를 넘으면 순환). 같은 모드면 아무 것도 하지 않는다. public static string SetCameraMode(int mode) { EnsureBoot(); var s = St; if (s == null) return "WLLookModeSettings 에셋 없음"; int m = s.Wrap(mode); if (m == s_mode) return "모드 변화 없음(" + m + ")"; s_mode = m; ModeChanges++; if (s.envFollowsCameraMode) { var p = s.Preset(m); if (p != null) SetEnvLook(p.envLookOn); } var cb = CameraModeChanged; if (cb != null) cb(m); return Push(WantedMode(), "모드 선택"); } /// 다음 모드로 순환한다(버튼이 부르는 것 — 0 → 1 → 2 → 3 → 0). public static string NextCameraMode() { return SetCameraMode(CameraMode + 1); } /// 배경 룩 플래그를 바꾼다(814c 가 구독한다). 이 태스크에서 렌더러는 건드리지 않는다. public static string SetEnvLook(bool on) { EnsureBoot(); if (on == s_env) return "배경 룩 변화 없음(" + on + ")"; s_env = on; EnvChanges++; var cb = EnvLookChanged; if (cb != null) cb(on); return Log("배경 룩 " + (on ? "ON" : "OFF")); } /// 배경 룩을 뒤집는다(버튼이 부르는 것). public static string ToggleEnvLook() { return SetEnvLook(!EnvLookOn); } // ───────────────────────────────────────────────────────────────────── // 틱 · 적용 // ───────────────────────────────────────────────────────────────────── /// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). 비교뿐 = GC 0. public static void Tick() { var s = St; if (s == null || !WLLookModeSettings.Enabled) { if (s_applied != 0) Push(0, "off"); // C8 — 즉시 원래 동작으로 return; } EnsureBoot(); int want = WantedMode(); if (want != s_applied) Push(want, want == 0 ? "로비" : "인게임"); var ui = UiTick; if (ui != null) ui(); } /// 지금 얹혀야 하는 모드 — 로비/타이틀에서는 0(고른 모드는 그대로 둔다). static int WantedMode() { var s = St; if (s == null) return 0; if (!s.defaultInLobby) return s_mode; return InLobby ? 0 : s_mode; } /// 로비·타이틀인가. `InGameInfo.Ins` 는 순수 static 필드라 에디트 모드에서도 부작용 0(실측). public static bool InLobby { get { var info = InGameInfo.Ins; if (DSUtil.CheckNull(info)) return true; // 아직 인게임이 아니다 return info.Get_GameMode() == eGameMode.Lobby; } } /// 모드 값을 두 기존 기능에 얹는다(적용 시점·복원은 그 둘이 원래 하던 대로). static string Push(int mode, string why) { var s = St; if (s == null) return "WLLookModeSettings 에셋 없음"; bool on = WLLookModeSettings.Enabled; var p = on ? s.Preset(mode) : null; if (!on) mode = 0; if (p == null || p.useSoFraming) CameraFramingOverride.ClearRuntimePreset(); else CameraFramingOverride.SetRuntimePreset(p.cameraHeight, p.distance, p.distanceUp); string rig = p != null && p.pixelRig ? PixelCameraRig.SetRuntimeOverride(true, p.orthoSize, p.pixelHeight) : PixelCameraRig.SetRuntimeOverride(false, 0f, 0); if (mode == 0 && s_applied != 0) Restores++; if (mode == 0 && why == "로비") LobbyDemotes++; s_applied = mode; Applies++; if (s.verboseLog) return Log("모드 " + mode + "(" + s.ModeName(mode) + ") 적용 — " + why + " · 리그: " + rig); LastLog = why; return "모드 " + mode + " 적용(" + why + ")"; } static void EnsureBoot() { if (s_booted) return; s_booted = true; var s = St; s_mode = s != null ? s.Wrap(s.startMode) : 0; s_env = s != null && s.envLookOnAtStart; } static string Log(string msg) { LastLog = msg; var s = St; if (s != null && s.verboseLog) Debug.Log("[LookMode] " + msg); return msg; } // ───────────────────────────────────────────────────────────────────── // 프로브 · 러너 // ───────────────────────────────────────────────────────────────────── /// 프로브: 상태·카운터를 시작값으로 되돌리고 두 기능의 오버라이드도 해제한다. public static string ResetAll() { s_booted = false; EnsureBoot(); s_applied = -1; Applies = ModeChanges = EnvChanges = LobbyDemotes = Restores = 0; LastLog = ""; CameraFramingOverride.ClearRuntimePreset(); PixelCameraRig.SetRuntimeOverride(false, 0f, 0); s_applied = 0; return "리셋 — 모드 " + s_mode + " · 배경 " + s_env; } /// 프로브: 로비 판정을 건너뛰고 지금 고른 모드를 그대로 얹는다. public static string ForceApply() { EnsureBoot(); return Push(s_mode, "probe"); } public static string Dump() { var s = St; return "[LookMode] 고른모드=" + s_mode + "(" + (s != null ? s.ModeName(s_mode) : "-") + ")" + " 적용모드=" + s_applied + " 배경룩=" + s_env + " · 리그조립=" + PixelCameraRig.IsAssembled + " 리그오버라이드=" + PixelCameraRig.RuntimeOverrideOn + " · 구도적용=" + CameraFramingOverride.IsApplied + " 런타임프리셋=" + CameraFramingOverride.RuntimePresetOn + "(h " + CameraFramingOverride.AppliedHeight.ToString("0.##") + " · d " + CameraFramingOverride.AppliedDistance.ToString("0.##") + " · up " + CameraFramingOverride.AppliedUp.ToString("0.##") + ")" + " · 적용 " + Applies + "회 · 모드변경 " + ModeChanges + " · 배경변경 " + EnvChanges + " · 로비강등 " + LobbyDemotes; } static GameObject s_runner; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (s_runner != null) return; if (!WLLookModeSettings.Enabled) return; // C8 — 러너 자체가 뜨지 않는다 EnsureBoot(); s_runner = new GameObject("[WL814d] LookModeRunner"); s_runner.hideFlags = HideFlags.HideAndDontSave; s_runner.AddComponent(); Object.DontDestroyOnLoad(s_runner); } internal static void OnRunnerGone() { CameraFramingOverride.ClearRuntimePreset(); PixelCameraRig.SetRuntimeOverride(false, 0f, 0); s_applied = 0; } } /// 룩 토글의 시간 축(허브 + UI). GameObject 1개 · 코루틴 0. internal sealed class LookModeRunner : MonoBehaviour { void Update() { LookModeHub.Tick(); } void OnApplicationQuit() { LookModeHub.OnRunnerGone(); } void OnDestroy() { LookModeHub.OnRunnerGone(); } } }