Project_WL/Assets/WL/UI/Scripts/WLLookToggleUi.cs

294 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>룩 토글 버튼 2개(카메라 모드 순환 · 배경 룩 on/off). 정적 · 노드는 런타임 생성.</summary>
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; } }
/// <summary>🔴 중첩 캔버스로 인정됐는가. Unity 는 **부모 캔버스가 활성일 때만** overrideSorting 을 유지한다
/// (인게임 `IngameUIs` 는 프리팹 저장 상태가 비활성이라 조립 시점엔 false 일 수 있다 → Tick 이 다시 건다).</summary>
public static bool OverrideSorting { get { return s_canvas != null && s_canvas.overrideSorting; } }
/// <summary>버튼 rect(px · Safe Area 좌상단 기준 · 실측 보고용). i = 0 카메라 · 1 배경.</summary>
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; // 러너는 허브 것 하나만 쓴다
}
/// <summary>허브 러너가 매 프레임 부른다(바인딩 + 라벨 갱신).</summary>
private static void OnUiTick()
{
var ui = NewGameUI.Ins;
if (ui != null && !ReferenceEquals(ui.transform, s_uiRoot)) Bind(ui.transform);
Tick();
}
// ── 조립 (런타임 생성 · 프리팹 diff 0) ────────────────────────────────
/// <summary>UI 루트(NewGameUI)에서 부모를 찾아 버튼 노드를 만든다. 이미 물려 있으면 그대로.</summary>
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;
}
/// <summary>전용 Canvas(overrideSorting) + GraphicRaycaster — 813q2 결과창과 같은 방식.</summary>
private static void EnsureCanvas(WLLookModeSettings s)
{
if (s == null || s_root == null || !s.ownCanvas) return;
if (s_canvas == null)
{
s_canvas = s_root.GetComponent<Canvas>();
if (s_canvas == null) s_canvas = s_root.gameObject.AddComponent<Canvas>();
}
if (!s_canvas.overrideSorting) s_canvas.overrideSorting = true;
s_canvas.sortingOrder = s.sortingOrder;
if (s_raycaster == null)
{
s_raycaster = s_root.GetComponent<GraphicRaycaster>();
if (s_raycaster == null) s_raycaster = s_root.gameObject.AddComponent<GraphicRaycaster>();
}
}
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<Image>();
if (bg == null) bg = t.gameObject.AddComponent<Image>();
bg.raycastTarget = true; // 🔴 버튼이 터치를 먹는다(통과 0)
var b = t.GetComponent<Button>();
if (b == null) b = t.gameObject.AddComponent<Button>();
b.targetGraphic = bg;
b.onClick.RemoveListener(onClick);
b.onClick.AddListener(onClick);
label = WLTextFxUtil.NewText(t, "Label", s_font, TextAlignmentOptions.Center);
WLTextFxUtil.Stretch(label.rectTransform);
return b;
}
/// <summary>SO 값으로 배치한다(px → 유닛 환산은 813c 산식 하나만 쓴다).</summary>
public static string ApplyLayout()
{
var s = St;
if (s == null) return "WLLookModeSettings 에셋 없음 — 배치 건너뜀";
if (s_root == null) return "노드 없음 — Bind 먼저";
// 🔴 우리 노드에는 전용 서브 캔버스가 붙어 있다 → `GetComponentInParent<Canvas>` 는 **자기 자신**을 준다.
// px 계수는 NewGameUI 루트 캔버스(CanvasScaler 보유)에서 와야 하므로 최상위 캔버스를 직접 찾는다.
float u = WLVignetteUtil.UnitsPerPx(RootCanvasOf(s_root));
float w = s.buttonSizePx.x * u, h = s.buttonSizePx.y * u, gap = s.buttonGapPx * u;
// 루트 = Safe Area 우상단 기준(anchor/pivot 1,1) → 노치·둥근 모서리 안쪽에서 시작한다.
s_root.anchorMin = s_root.anchorMax = new Vector2(1f, 1f);
s_root.pivot = new Vector2(1f, 1f);
s_root.anchoredPosition = new Vector2(-s.rightMarginPx * u, -s.topMarginPx * u);
s_root.sizeDelta = new Vector2(w, h * 2f + gap);
s_root.localScale = Vector3.one;
LayoutButton(s_camButton, s_camLabel, 0f, w, h, u, s);
LayoutButton(s_envButton, s_envLabel, -(h + gap), w, h, u, s);
return "pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta +
" 버튼 " + s.buttonSizePx.x + "×" + s.buttonSizePx.y + "px unitsPerPx=" + u.ToString("F4");
}
/// <summary>이 노드가 속한 **최상위** 캔버스(자기 서브 캔버스가 아니라 NewGameUI 루트 캔버스 · 비활성 계층에서도 맞다).</summary>
private static Canvas RootCanvasOf(Transform t)
{
Canvas top = null;
for (var p = t != null ? t.parent : null; p != null; p = p.parent)
{
var c = p.GetComponent<Canvas>();
if (c != null) top = c;
}
return top;
}
private static void LayoutButton(Button b, TextMeshProUGUI label, float y, float w, float h, float u, WLLookModeSettings s)
{
if (b == null) return;
var rt = (RectTransform)b.transform;
rt.anchorMin = rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(1f, 1f);
rt.anchoredPosition = new Vector2(0f, y);
rt.sizeDelta = new Vector2(w, h);
rt.localScale = Vector3.one;
if (label != null)
{
label.fontSize = s.buttonFontPx * u;
label.color = s.buttonTextColor;
label.alignment = TextAlignmentOptions.Center;
WLTextFxUtil.ApplyEdge(label, s.buttonEdge);
}
}
// ── 클릭 ──────────────────────────────────────────────────────────────
private static void OnCameraClick() { CameraClicks++; LookModeHub.NextCameraMode(); Tick(); }
private static void OnEnvClick() { EnvClicks++; LookModeHub.ToggleEnvLook(); Tick(); }
/// <summary>프로브·QA: 버튼을 코드로 누른다(실제 onClick 경로 그대로).</summary>
public static string ClickCamera() { if (s_camButton == null) return "버튼 없음"; s_camButton.onClick.Invoke(); return CameraLabel; }
public static string ClickEnv() { if (s_envButton == null) return "버튼 없음"; s_envButton.onClick.Invoke(); return EnvLabel; }
// ── 매 프레임 — 값이 바뀐 프레임에만 라벨을 쓴다 ──────────────────────
public static void Tick()
{
var s = St;
if (s == null || s_root == null) return;
bool show = WLLookModeSettings.Enabled && s.showButtons;
if (s_root.gameObject.activeSelf != show) s_root.gameObject.SetActive(show);
if (!show) return;
// 🔴 부모 캔버스(IngameUIs)가 켜지는 순간까지 Unity 가 overrideSorting 을 되돌린다 → 켜질 때까지 다시 건다.
if (s_canvas != null && s.ownCanvas && !s_canvas.overrideSorting)
{ s_canvas.overrideSorting = true; s_canvas.sortingOrder = s.sortingOrder; }
// 화면에 실제로 붙는 첫 프레임에 한 번만 다시 배치한다(Bind 때는 캔버스 rect 가 없어 px 계수가 틀릴 수 있다).
if (!s_layoutDone && s_root.gameObject.activeInHierarchy) { s_layoutDone = true; ApplyLayout(); }
int mode = LookModeHub.CameraMode;
if (mode != s_shownMode)
{
s_shownMode = mode;
var p = s.Preset(mode);
if (s_camLabel != null) s_camLabel.SetText(string.Format(s.cameraLabelFormat, s.ModeName(mode)));
if (s_camBg != null) s_camBg.color = (p != null && p.pixelRig) ? s.buttonBgOnColor : s.buttonBgColor;
LabelWrites++;
}
bool env = LookModeHub.EnvLookOn;
if (!s_shownEnvValid || env != s_shownEnv)
{
s_shownEnv = env; s_shownEnvValid = true;
if (s_envLabel != null) s_envLabel.SetText(string.Format(s.envLabelFormat, env ? s.envOnText : s.envOffText));
if (s_envBg != null) s_envBg.color = env ? s.buttonBgOnColor : s.buttonBgColor;
LabelWrites++;
}
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
public static string ResetState()
{
CameraClicks = EnvClicks = LabelWrites = 0;
s_shownMode = int.MinValue; s_shownEnvValid = false; s_layoutDone = false;
return "상태 초기화";
}
/// <summary>노드까지 되돌린다(프로브 종료용).</summary>
public static string Teardown()
{
if (s_root != null)
{
if (Application.isPlaying) Object.Destroy(s_root.gameObject);
else Object.DestroyImmediate(s_root.gameObject);
}
s_root = null; s_canvas = null; s_raycaster = null;
s_camButton = s_envButton = null; s_camBg = s_envBg = null;
s_camLabel = s_envLabel = null; s_uiRoot = null;
ResetState();
return "teardown 완료";
}
public static string Dump()
{
var s = St;
var r0 = ButtonRectPx(0); var r1 = ButtonRectPx(1);
return "[LookToggleUi] bound=" + Bound + " visible=" + Visible + " sortingOrder=" + SortingOrder +
" · 카메라 \"" + CameraLabel + "\" 배경 \"" + EnvLabel + "\"" +
" · 클릭 " + CameraClicks + "/" + EnvClicks + " 라벨쓰기 " + LabelWrites +
" · rect0 " + r0 + " rect1 " + r1 +
(s_root != null ? " · 노드 pos" + s_root.anchoredPosition + " size" + s_root.sizeDelta +
" SafeArea=" + (s_root.GetComponentInParent<SafeAreaFitter>(true) != null) : "") +
(s != null ? " · showButtons=" + s.showButtons + " top=" + s.topMarginPx + "px right=" + s.rightMarginPx + "px" : "");
}
}
}