// ───────────────────────────────────────────────────────────────────────────── // WLLookToggleUi.cs — 인게임 룩 토글 HUD 버튼 2개 (WL-814d · #814) // // PD 제안(2026-09-10) 「토글 형태로 카메라를 바꿔서 볼 수 있게」 · 발주서 WL-814d §1-3. // // ■ 프리팹 diff 0 (813q RunHud · 813y 방식 그대로) // 노드를 프리팹에 굽지 않는다 — 런타임 생성(`HideFlags.DontSave`). // 부모는 `IngameUIs/WL_HUD`(802b/813y 실측 SafeAreaFitter 아래 = Safe Area 안 · 경로는 SO). // 루트에 **전용 Canvas(overrideSorting) + GraphicRaycaster** 를 달아 서브 캔버스로 띄운다 // (813q2 결과창 40 · 813z 부활 팝업 60 보다 낮은 sortingOrder → 팝업이 뜨면 그 아래로 가려진다). // // ■ 좌표 (px · 세로 1080×1920 · 전부 SO) // 상단 우측. 기본값은 813c 보스 HP 바(190~256) · 813q 런 HUD(272~394) · 813tj 보스 배너(300~450) // **아래**(topMarginPx 470)이고, 가로는 런 HUD 폭(560 = 중앙 ±280) 오른쪽으로 비켜 있다. // px → 캔버스 유닛 환산은 `WLHudLayoutSettings.UnitsPerPx(canvas)` 하나만 쓴다(산식 중복 0). // // ■ GC 0 (전환 외 프레임당 할당 0) // 라벨은 **모드/배경 값이 바뀐 프레임에만** 다시 쓴다(문자열 생성도 그때만). // Tick 은 int/bool 비교 2개뿐이다. // // ■ C8 롤백 — `WLLookModeSettings.enabled_ = 0` 또는 `showButtons = 0` → 노드 0 · 표시 0. // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using TMPro; using UnityEngine; using UnityEngine.UI; using WL.Look.Toggle; namespace WL.UI { /// 룩 토글 버튼 2개(카메라 모드 순환 · 배경 룩 on/off). 정적 · 노드는 런타임 생성. public static class WLLookToggleUi { // ── 노드 ────────────────────────────────────────────────────────────── private static Transform s_uiRoot; private static RectTransform s_root; private static Canvas s_canvas; private static GraphicRaycaster s_raycaster; private static Button s_camButton, s_envButton; private static Image s_camBg, s_envBg; private static TextMeshProUGUI s_camLabel, s_envLabel; private static TMP_FontAsset s_font; // ── 표시 캐시(변할 때만 쓴다 = GC 0) ────────────────────────────────── private static int s_shownMode = int.MinValue; private static bool s_shownEnv, s_shownEnvValid; // 🔴 Bind 시점의 `IngameUIs` 는 아직 비활성일 수 있다 → 그때 잰 px 계수는 틀린다(캔버스 rect 가 안 잡힌다). // 실제로 화면에 붙는 첫 프레임에 **한 번만** 다시 배치한다(그 뒤로는 bool 비교 1개). private static bool s_layoutDone; // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── public static int CameraClicks, EnvClicks, LabelWrites; public static bool Bound { get { return s_root != null; } } public static bool Visible { get { return s_root != null && s_root.gameObject.activeSelf; } } public static string CameraLabel { get { return s_camLabel != null ? s_camLabel.text : ""; } } public static string EnvLabel { get { return s_envLabel != null ? s_envLabel.text : ""; } } public static int SortingOrder { get { return s_canvas != null ? s_canvas.sortingOrder : -1; } } /// 🔴 중첩 캔버스로 인정됐는가. Unity 는 **부모 캔버스가 활성일 때만** overrideSorting 을 유지한다 /// (인게임 `IngameUIs` 는 프리팹 저장 상태가 비활성이라 조립 시점엔 false 일 수 있다 → Tick 이 다시 건다). public static bool OverrideSorting { get { return s_canvas != null && s_canvas.overrideSorting; } } /// 버튼 rect(px · Safe Area 좌상단 기준 · 실측 보고용). i = 0 카메라 · 1 배경. public static Rect ButtonRectPx(int i) { var s = St; if (s == null) return new Rect(); float w = s.buttonSizePx.x, h = s.buttonSizePx.y; float left = 1080f - s.rightMarginPx - w; // 기준 폭 1080 (813c 좌표계) float top = s.topMarginPx + (i == 1 ? h + s.buttonGapPx : 0f); return new Rect(left, top, w, h); } private static WLLookModeSettings St { get { return WLLookModeSettings.Instance; } } // ── 부팅 (노드 0 · 프리팹 diff 0) ───────────────────────────────────── [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Boot() { if (!WLLookModeSettings.Enabled) return; // C8 — 훅조차 걸지 않는다 LookModeHub.UiTick = OnUiTick; // 러너는 허브 것 하나만 쓴다 } /// 허브 러너가 매 프레임 부른다(바인딩 + 라벨 갱신). private static void OnUiTick() { var ui = NewGameUI.Ins; if (ui != null && !ReferenceEquals(ui.transform, s_uiRoot)) Bind(ui.transform); Tick(); } // ── 조립 (런타임 생성 · 프리팹 diff 0) ──────────────────────────────── /// UI 루트(NewGameUI)에서 부모를 찾아 버튼 노드를 만든다. 이미 물려 있으면 그대로. public static string Bind(Transform uiRoot) { var s = St; if (s == null) return "WLLookModeSettings 에셋 없음"; if (!WLLookModeSettings.Enabled || !s.showButtons) return "off — 조립 생략"; if (uiRoot == null) return "uiRoot=null"; if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name; s_uiRoot = uiRoot; var prt = WLVignetteUtil.FindUiPath(uiRoot, s.buttonParentPath) as RectTransform; if (prt == null) return "부모 없음 — 경로 \"" + s.buttonParentPath + "\""; if (s_font == null) s_font = RunHud.BorrowFont(uiRoot); if (s_root == null || s_root.parent != prt) { var go = new GameObject("WL_LookToggle", typeof(RectTransform)); go.layer = WLTextFxUtil.UILayer; go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다 s_root = (RectTransform)go.transform; s_root.SetParent(prt, false); s_camButton = NewButton(s_root, "btn_look_camera", out s_camBg, out s_camLabel, OnCameraClick); s_envButton = NewButton(s_root, "btn_look_env", out s_envBg, out s_envLabel, OnEnvClick); } EnsureCanvas(s); s_shownMode = int.MinValue; s_shownEnvValid = false; // 다음 Tick 이 라벨을 반드시 쓰게 한다 s_layoutDone = false; string layout = ApplyLayout(); Tick(); return "연결 " + s.buttonParentPath + " · " + layout + " · sortingOrder=" + SortingOrder; } /// 전용 Canvas(overrideSorting) + GraphicRaycaster — 813q2 결과창과 같은 방식. private static void EnsureCanvas(WLLookModeSettings s) { if (s == null || s_root == null || !s.ownCanvas) return; if (s_canvas == null) { s_canvas = s_root.GetComponent(); if (s_canvas == null) s_canvas = s_root.gameObject.AddComponent(); } if (!s_canvas.overrideSorting) s_canvas.overrideSorting = true; s_canvas.sortingOrder = s.sortingOrder; if (s_raycaster == null) { s_raycaster = s_root.GetComponent(); if (s_raycaster == null) s_raycaster = s_root.gameObject.AddComponent(); } } private static Button NewButton(RectTransform parent, string name, out Image bg, out TextMeshProUGUI label, UnityEngine.Events.UnityAction onClick) { var t = WLVignetteUtil.NewChild(parent, name); bg = t.GetComponent(); if (bg == null) bg = t.gameObject.AddComponent(); bg.raycastTarget = true; // 🔴 버튼이 터치를 먹는다(통과 0) var b = t.GetComponent