294 lines
17 KiB
C#
294 lines
17 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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" : "");
|
||
}
|
||
}
|
||
}
|