From 5c0b9fc904fb319268da7eb3e55918e0a759d079 Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 8 Sep 2026 23:37:10 +0900 Subject: [PATCH] =?UTF-8?q?[WL-802b]=20Safe=20Area=20=EC=A0=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B3=B5=ED=86=B5(TitleInfo=C2=B7SortOrder=5F5=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=A7=81=EC=86=8D=20WL=5FSafeAre?= =?UTF-8?q?a)=20+=20UI=20=EC=9C=A0=EB=8B=9B=20=EC=8A=A4=EC=BC=80=EC=9D=BC?= =?UTF-8?q?=20=EB=85=B8=ED=8A=B8=20(#802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TitleInfo.prefab · SortOrder_5.prefab 캔버스 루트 직속에 전용 루트 패널 WL_SafeArea 추가(스트레치·offset 0·Graphic 없음)하고 WL.UI.SafeAreaFitter 1개씩 부착. 기존 자식 재부모화 0 · diff 추가 전용(+51/파일 · 삭제 0). - NewGameUI.prefab 은 IngameUIs/WL_HUD 기존 부착 유지(파일당 1 · 중복 0) — 무수정. - Assets/WL/UI/UI_UnitScale_SafeArea_Note.md: 세 프리팹 참조 해상도(1080x1920 vs 1920x1080)·Expand·계수 1.0 vs 0.5625·1.7778배 환산표, Safe Area 적용 규칙, NGUI 잔재 3종 실측·대체 계획. - AgentScripts/WL802b_Apply.cs(Apply/Revert/Resave) · WL802b_Probe.cs(Dump) 신규. Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL802b_Apply.cs | 108 ++++++++++++++ AgentScripts/WL802b_Probe.cs | 139 ++++++++++++++++++ .../UIPrefabs/Title/SortOrder_5.prefab | 51 +++++++ .../ResWork/UIPrefabs/Title/TitleInfo.prefab | 51 +++++++ Assets/WL/UI/UI_UnitScale_SafeArea_Note.md | 121 +++++++++++++++ .../WL/UI/UI_UnitScale_SafeArea_Note.md.meta | 7 + 6 files changed, 477 insertions(+) create mode 100644 AgentScripts/WL802b_Apply.cs create mode 100644 AgentScripts/WL802b_Probe.cs create mode 100644 Assets/WL/UI/UI_UnitScale_SafeArea_Note.md create mode 100644 Assets/WL/UI/UI_UnitScale_SafeArea_Note.md.meta diff --git a/AgentScripts/WL802b_Apply.cs b/AgentScripts/WL802b_Apply.cs new file mode 100644 index 000000000..366dd67b1 --- /dev/null +++ b/AgentScripts/WL802b_Apply.cs @@ -0,0 +1,108 @@ +// WL802b_Apply.cs — #802 재단 2차: 세로 Safe Area 를 타이틀·로딩/팝업 프리팹까지 공통 적용 (에디트 모드 · 프리팹 컨텐츠 · Play 불필요) +// unity command run_script --file AgentScripts/WL802b_Apply.cs --entry WL802b_Apply.Resave (무변경 재저장 — 재직렬화 diff 측정용) +// unity command run_script --file AgentScripts/WL802b_Apply.cs --entry WL802b_Apply.Apply +// unity command run_script --file AgentScripts/WL802b_Apply.cs --entry WL802b_Apply.Revert (신규 패널 제거 = 2026-09-07 원상) +// 방식: 각 프리팹 캔버스 루트 직속에 **전용 루트 패널**(Graphic 없음 · 스트레치 · offset 0)을 1개 추가하고 WL.UI.SafeAreaFitter 를 붙인다. +// 기존 자식은 재부모화하지 않는다(#806 팝업 값 포함 레이아웃 회귀 0). 패널은 후속 UI 가 들어갈 안전 영역 레이어다. +// NewGameUI.prefab 은 WL_HUD 에 이미 SafeAreaFitter 가 있어 대상에서 제외한다(파일당 1개 · 중복 0). +// 롤백: 이 스크립트의 Revert() 또는 git checkout -- <프리팹 2종> +using System.Collections.Generic; +using System.Text; +using UnityEditor; +using UnityEngine; + +public static class WL802b_Apply +{ + // 이번 웨이브 UI 단독 소유 핫스팟 2종 (NewGameUI 는 기존 부착 유지 → 대상 아님) + static readonly string[] Targets = + { + "Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab", + "Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab", + }; + + public const string PanelName = "WL_SafeArea"; + const int UILayer = 5; + + static RectTransform FindPanel(GameObject root) + { + var t = root.transform.Find(PanelName); + return t as RectTransform; + } + + static string Describe(RectTransform rt) + { + return "aMin=" + rt.anchorMin + " aMax=" + rt.anchorMax + + " offMin=" + rt.offsetMin + " offMax=" + rt.offsetMax + + " size=" + rt.sizeDelta + " pos=" + rt.anchoredPosition + + " pivot=" + rt.pivot + " scale=" + rt.localScale; + } + + public static object Apply() + { + var sb = new StringBuilder(); + foreach (var path in Targets) + { + var root = PrefabUtility.LoadPrefabContents(path); + try + { + if (FindPanel(root) != null) { sb.AppendLine(path + " : 이미 " + PanelName + " 있음 — 건너뜀"); continue; } + + var go = new GameObject(PanelName, typeof(RectTransform)); + go.layer = UILayer; + var rt = (RectTransform)go.transform; + rt.SetParent(root.transform, false); // 마지막 자식 = 최상위 시블링(빈 패널이라 렌더 영향 0) + rt.localScale = Vector3.one; + rt.localRotation = Quaternion.identity; + rt.pivot = new Vector2(0.5f, 0.5f); + rt.anchorMin = Vector2.zero; // 스트레치 + rt.anchorMax = Vector2.one; + rt.offsetMin = Vector2.zero; // offset 0 (= sizeDelta 0 · anchoredPosition 0) + rt.offsetMax = Vector2.zero; + go.AddComponent(); // applyHorizontal/applyVertical 기본 true + + PrefabUtility.SaveAsPrefabAsset(root, path); + sb.AppendLine(path + " : " + PanelName + " 추가 · SafeAreaFitter 부착 · " + Describe(rt)); + } + finally { PrefabUtility.UnloadPrefabContents(root); } + } + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + return sb.ToString(); + } + + public static object Revert() + { + var sb = new StringBuilder(); + foreach (var path in Targets) + { + var root = PrefabUtility.LoadPrefabContents(path); + try + { + var panel = FindPanel(root); + if (panel == null) { sb.AppendLine(path + " : " + PanelName + " 없음 — 건너뜀"); continue; } + Object.DestroyImmediate(panel.gameObject); + PrefabUtility.SaveAsPrefabAsset(root, path); + sb.AppendLine(path + " : " + PanelName + " 제거"); + } + finally { PrefabUtility.UnloadPrefabContents(root); } + } + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + return sb.ToString(); + } + + /// 내용 변경 없이 LoadPrefabContents→SaveAsPrefabAsset 만 수행 — 재직렬화가 diff 를 만드는지 측정한다(완료 기준 ⓓ 사전 확인). + public static object Resave() + { + var sb = new StringBuilder(); + var only = new List(Targets); + foreach (var path in only) + { + var root = PrefabUtility.LoadPrefabContents(path); + try { PrefabUtility.SaveAsPrefabAsset(root, path); sb.AppendLine(path + " : 무변경 재저장"); } + finally { PrefabUtility.UnloadPrefabContents(root); } + } + AssetDatabase.Refresh(); + return sb.ToString(); + } +} diff --git a/AgentScripts/WL802b_Probe.cs b/AgentScripts/WL802b_Probe.cs new file mode 100644 index 000000000..ead9a3016 --- /dev/null +++ b/AgentScripts/WL802b_Probe.cs @@ -0,0 +1,139 @@ +// WL802b_Probe.cs — #802 재단 2차 검증 덤프: CanvasScaler 유닛 스케일 + SafeAreaFitter 부착/앵커 실측 (에디트 모드 · Play 불필요) +// unity command run_script --file AgentScripts/WL802b_Probe.cs --entry WL802b_Probe.Dump +// 산출: ⓑ 파일당 SafeAreaFitter 개수·경로 · ⓒ 부착 RectTransform 앵커/offset + safeArea=전체화면 시뮬레이션 · ⓓ #806 팝업 값 · ⓔ 환산표. +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.UI; + +public static class WL802b_Probe +{ + static readonly string[] Prefabs = + { + "Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab", + "Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab", + "Assets/Res_Addr/MainUI/NewGameUI.prefab", + }; + + // Lead 확정 QA 캡처 해상도 5종 (§3) + static readonly Vector2Int[] QaRes = + { + new Vector2Int(1080, 1920), new Vector2Int(1080, 2340), new Vector2Int(1080, 2400), + new Vector2Int(720, 1600), new Vector2Int(1440, 3200), + }; + + // CanvasScaler.ScreenMatchMode.Expand 의 실제 계산식 (UnityEngine.UI 소스와 동일) + static float ExpandScale(Vector2 refRes, Vector2Int screen) + { + return Mathf.Min(screen.x / refRes.x, screen.y / refRes.y); + } + + static string Path(Transform t, Transform stop) + { + var s = t.name; + for (var p = t.parent; p != null && p != stop; p = p.parent) s = p.name + "/" + s; + return s; + } + + static string Rt(RectTransform rt) + { + return "aMin=" + rt.anchorMin + " aMax=" + rt.anchorMax + " offMin=" + rt.offsetMin + " offMax=" + rt.offsetMax + + " size=" + rt.sizeDelta + " pos=" + rt.anchoredPosition + " pivot=" + rt.pivot; + } + + // SafeAreaFitter.Apply() 와 동일한 산식 — Play 없이 결과를 재현한다. + static string SimulateFitter(Rect safe, Vector2Int screen) + { + var min = new Vector2(safe.xMin / screen.x, safe.yMin / screen.y); + var max = new Vector2(safe.xMax / screen.x, safe.yMax / screen.y); + return "screen=" + screen.x + "x" + screen.y + " safeArea=" + safe + + " → anchorMin=" + min + " anchorMax=" + max + " offsetMin=(0,0) offsetMax=(0,0)"; + } + + static RectTransform FindByName(GameObject root, string name) + { + foreach (var rt in root.GetComponentsInChildren(true)) if (rt.name == name) return rt; + return null; + } + + public static object Dump() + { + var sb = new StringBuilder(); + sb.AppendLine("== WL-802b Probe =="); + + foreach (var path in Prefabs) + { + var root = PrefabUtility.LoadPrefabContents(path); + try + { + sb.AppendLine(); + sb.AppendLine("### " + path + " (root=" + root.name + ")"); + + // --- CanvasScaler --- + foreach (var cs in root.GetComponentsInChildren(true)) + { + sb.AppendLine(" CanvasScaler @" + Path(cs.transform, root.transform.parent) + + " mode=" + cs.uiScaleMode + " ref=" + cs.referenceResolution + + " match=" + cs.screenMatchMode + " matchWH=" + cs.matchWidthOrHeight + + " refPPU=" + cs.referencePixelsPerUnit); + foreach (var r in QaRes) + sb.AppendLine(" scaleFactor " + r.x + "x" + r.y + " = " + ExpandScale(cs.referenceResolution, r).ToString("0.#####") + + " (1 px = " + (1f / ExpandScale(cs.referenceResolution, r)).ToString("0.####") + " unit)"); + } + + // --- SafeAreaFitter --- + var fitters = root.GetComponentsInChildren(true); + sb.AppendLine(" SafeAreaFitter count = " + fitters.Length); + foreach (var f in fitters) + { + var so = new SerializedObject(f); + var rt = (RectTransform)f.transform; + bool activeChain = true; + for (var t = f.transform; t != null; t = t.parent) if (!t.gameObject.activeSelf) { activeChain = false; break; } + sb.AppendLine(" @" + Path(f.transform, root.transform.parent) + + " parent=" + (f.transform.parent != null ? f.transform.parent.name : "(none)") + + " canvasDirectChild=" + (f.transform.parent == root.transform) + + " activeInPrefab=" + activeChain + + " applyH=" + so.FindProperty("applyHorizontal").boolValue + + " applyV=" + so.FindProperty("applyVertical").boolValue); + sb.AppendLine(" RectTransform: " + Rt(rt)); + sb.AppendLine(" graphic=" + (rt.GetComponent() != null) + " childCount=" + rt.childCount); + } + + // --- #806 팝업 값 (레이아웃 회귀 확인) --- + if (path.EndsWith("SortOrder_5.prefab")) + { + foreach (var n in new[] { "PopupUI", "LoadingUI" }) + { + var p = FindByName(root, n); + if (p == null) { sb.AppendLine(" [" + n + "] 없음"); continue; } + sb.AppendLine(" [" + n + "] " + Rt(p)); + foreach (var c in p.GetComponentsInChildren(true)) + if (c != p && (c.name == "bg" || c.name == "msg" || c.name == "btn_ok")) + sb.AppendLine(" " + c.name + " " + Rt(c)); + } + } + } + finally { PrefabUtility.UnloadPrefabContents(root); } + } + + // --- ⓒ safeArea = 전체 화면일 때 회귀 0 --- + sb.AppendLine(); + sb.AppendLine("### SafeAreaFitter.Apply 시뮬레이션 (Play 없이 산식 재현)"); + foreach (var r in QaRes) + sb.AppendLine(" 미적용기기: " + SimulateFitter(new Rect(0, 0, r.x, r.y), r)); + sb.AppendLine(" 노치예시(추정): " + SimulateFitter(new Rect(0, 0, 1080, 2400 - 91), new Vector2Int(1080, 2400))); + + // --- ⓔ 환산표 --- + sb.AppendLine(); + sb.AppendLine("### 유닛 ↔ px 환산 (세로 1080 폭 기준 · Expand)"); + sb.AppendLine(" ref 1080x1920: scale 1.0 → 1 unit = 1 px"); + sb.AppendLine(" ref 1920x1080: scale 0.5625 → 1 px = 1.7778 unit"); + foreach (var px in new[] { 44f, 88f, 130f, 340f }) + sb.AppendLine(" " + px + " px = " + px + " unit(1080기준) = " + (px / 0.5625f).ToString("0.##") + " unit(1920기준)"); + + sb.AppendLine(); + sb.AppendLine("Screen(batch) = " + Screen.width + "x" + Screen.height + " safeArea=" + Screen.safeArea + " (배치모드 값 — 실기 아님)"); + return sb.ToString(); + } +} diff --git a/Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab b/Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab index 624fb4f9b..86021bf87 100644 --- a/Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab +++ b/Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab @@ -9127,6 +9127,7 @@ RectTransform: - {fileID: 5196368072863920478} - {fileID: 8045443696459751298} - {fileID: 5452868491221757929} + - {fileID: 425477525647692469} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -12904,6 +12905,56 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: m_localtextKey: 14 +--- !u!1 &8173757062782547902 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 425477525647692469} + - component: {fileID: 2442976163919323621} + m_Layer: 5 + m_Name: WL_SafeArea + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &425477525647692469 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8173757062782547902} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 2109408569755660315} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &2442976163919323621 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8173757062782547902} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4f4cdcc41055ee140ae3a9916e8563ce, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::WL.UI.SafeAreaFitter + applyHorizontal: 1 + applyVertical: 1 --- !u!1 &8185998291231727465 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab b/Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab index ce3b4b3f4..48ae6e72d 100644 --- a/Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab +++ b/Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab @@ -830,6 +830,56 @@ MonoBehaviour: m_FlexibleWidth: -1 m_FlexibleHeight: -1 m_LayoutPriority: 1 +--- !u!1 &2938214554690998105 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3273655931171191069} + - component: {fileID: 1369323462138204881} + m_Layer: 5 + m_Name: WL_SafeArea + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &3273655931171191069 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2938214554690998105} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 444483819741401241} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1369323462138204881 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2938214554690998105} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4f4cdcc41055ee140ae3a9916e8563ce, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::WL.UI.SafeAreaFitter + applyHorizontal: 1 + applyVertical: 1 --- !u!1 &3491913842085948827 GameObject: m_ObjectHideFlags: 0 @@ -867,6 +917,7 @@ RectTransform: - {fileID: 8826935861239906465} - {fileID: 1885993367526989969} - {fileID: 4554408050129889465} + - {fileID: 3273655931171191069} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} diff --git a/Assets/WL/UI/UI_UnitScale_SafeArea_Note.md b/Assets/WL/UI/UI_UnitScale_SafeArea_Note.md new file mode 100644 index 000000000..c9310abee --- /dev/null +++ b/Assets/WL/UI/UI_UnitScale_SafeArea_Note.md @@ -0,0 +1,121 @@ +# WL UI 기준 노트 — CanvasScaler 유닛 스케일 · Safe Area 공통 적용 + +> WL-802b (#802 재단 2차) · 2026-09-08 · 브랜치 `wl/ui/WL-802b-safearea-scale` (base `main` @ `abcd03865`) +> 대상 = 세로 1080×1920 모바일(#782 모바일 전제 · #803 세로 전용). **후속 UI 작업은 이 문서의 계수·규칙을 기준으로 한다.** +> 줄 번호는 **이 브랜치(WL-802b 적용 후)** 파일 기준. 실측 재현 = `unity command run_script --file AgentScripts/WL802b_Probe.cs --entry WL802b_Probe.Dump` + +--- + +## 1. 세 프리팹의 CanvasScaler (실측) + +| 프리팹 | 참조 해상도 | UI Scale Mode | Screen Match Mode | matchWidthOrHeight | 근거 | +|---|---|---|---|---|---| +| `Assets/ResWork/UIPrefabs/Title/TitleInfo.prefab` | **1080 × 1920** | ScaleWithScreenSize(`m_UiScaleMode: 1`) | **Expand**(`m_ScreenMatchMode: 1`) | 0 (Expand 에서 미사용) | `TitleInfo.prefab:983-988` | +| `Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab` | **1920 × 1080** | ScaleWithScreenSize | Expand | 0 | `SortOrder_5.prefab:9173-9178` | +| `Assets/Res_Addr/MainUI/NewGameUI.prefab` | **1920 × 1080** | ScaleWithScreenSize | Expand | 0 | `NewGameUI.prefab:131617-131622` | + +- 세 프리팹 모두 캔버스가 **프리팹 루트**다(`m_Father: {fileID: 0}`) · `m_ReferencePixelsPerUnit: 100` 동일. +- ⚠️ **matchWidthOrHeight 는 Expand 모드에서 아무 효과가 없다.** `m_MatchWidthOrHeight: 0` 은 직렬화된 잔여값일 뿐, MatchWidthOrHeight 모드로 바꾸기 전에는 무의미하다. 「match 0 이라서 폭 기준」이라는 해석은 틀렸다 — 폭 기준이 되는 이유는 아래 Expand 산식 때문이다. + +## 2. Expand 산식과 계수 + +``` +scaleFactor = min(screenW / refW, screenH / refH) // ScreenMatchMode.Expand +실화면 px = 프리팹 유닛 × scaleFactor +``` + +세로(폭이 좁은) 화면에서는 두 참조 해상도 모두 **폭 항이 최솟값**이 되므로 `scaleFactor = screenW / refW` 이고, +따라서 두 계열의 비는 화면 해상도와 무관하게 **항상 1920 / 1080 = 1.7778 배**로 고정된다. + +| 화면(QA 5종 · #802 §3) | ref 1080×1920 (`TitleInfo`) | ref 1920×1080 (`SortOrder_5`·`NewGameUI`) | 비 | +|---|---|---|---| +| 1080 × 1920 | **1.0** | **0.5625** | 1.7778 | +| 1080 × 2340 | 1.0 | 0.5625 | 1.7778 | +| 1080 × 2400 | 1.0 | 0.5625 | 1.7778 | +| 720 × 1600 | 0.66667 | 0.375 | 1.7778 | +| 1440 × 3200 | 1.33333 | 0.75 | 1.7778 | + +- 세로 화면에서 높이는 남는다(Expand = 잘림 없음). 1080×2400 이면 1920 기준 캔버스의 높이 유닛은 2400/0.5625 = 4266.7 유닛까지 늘어난다 → **화면 상·하단에 여백이 생기므로 세로 전체를 채우는 배경은 stretch 앵커로 잡아야 한다**(고정 sizeDelta 금지). + +## 3. 유닛 ↔ px 환산표 (세로 · 화면 폭 1080 기준) + +| 실화면 px | ref 1080 프리팹의 유닛 | ref 1920 프리팹의 유닛 (= px ÷ 0.5625 = px × 1.7778) | +|---|---|---| +| 1 px | 1 | **1.7778** | +| 44 px (최소 터치 하한 참고) | 44 | 78.2 | +| **88 px** | 88 | **156.4** | +| 130 px | 130 | 231.1 | +| 340 px | 340 | 604.4 | + +- 검증 예 (#806 후속 B · `SortOrder_5.prefab` 의 `PopupUI/child` 실측): + +| 오브젝트 | 유닛(1920 기준, 직렬화 값) | ÷ 1.7778 | 실화면 px (1080 폭) | +|---|---|---|---| +| `bg` | 1529 × 924 | | 860 × 520 | +| `msg` | 1387 × 498 @ (0, 124) | | 780 × 280 @ (0, 70) | +| `btn_ok` | 604 × 231 @ (0, −267) | | 340 × 130 @ (0, −150) | +| 폰트 | 48 | | 27 | + + 340 ÷ 0.5625 = 604.4, 130 ÷ 0.5625 = 231.1 → 반올림 일치. 산출 근거는 `AgentScripts/WL806_Popup.cs:18-20`. +- **작성 규칙**: `TitleInfo`(1080 기준)에 넣는 값은 세로 1080 화면에서 px 와 1:1 이다. `SortOrder_5`·`NewGameUI`(1920 기준)에 같은 실크기를 내려면 **반드시 ×1.7778** 한다. 두 계열의 수치를 그대로 복사하면 1.78배 어긋난다. + +## 4. 인계서 §1 표기 불일치 (문서 오기) + +인계서 §1 UI 행은 `NewGameUI.prefab` 을 **1080×1920** 으로 적고 있으나, 실제 직렬화 값은 **1920×1080** 이다(`NewGameUI.prefab:131620` `m_ReferenceResolution: {x: 1920, y: 1080}`). +→ 인계서 쪽이 오기로 **「추정」**. 이 노트의 실측값을 SOT 로 쓴다. 인계서 수정은 Lead 판단(조직 레포는 워커가 커밋하지 않는다). + +## 5. Safe Area 적용 규칙 + +### 컴포넌트 +`WL.UI.SafeAreaFitter` (`Assets/WL/UI/Scripts/SafeAreaFitter.cs` · guid `4f4cdcc41055ee140ae3a9916e8563ce`) +`Screen.safeArea` 를 화면 크기로 나눠 **anchorMin/anchorMax 에 넣고 offsetMin/offsetMax 를 0 으로 덮어쓴다**(`SafeAreaFitter.cs:40-48`). + +### 반드시 지킬 것 +1. **전용 루트 패널에만 붙인다.** 캔버스 직속에 Graphic 없는 빈 패널(`WL_SafeArea`)을 두고 거기에만 부착한다. 안전 영역을 따라야 하는 UI 는 이 패널의 자식으로 넣는다. +2. **이미 스트레치 + offset 을 쓰는 패널에는 붙이지 않는다.** `Apply()` 가 offsetMin/Max 를 무조건 0 으로 덮어쓰므로, 여백(offset)으로 레이아웃을 잡아둔 패널에 붙이면 그 여백이 소실된다. 같은 이유로 `LayoutGroup`/`ContentSizeFitter` 가 자기 RectTransform 을 제어하는 오브젝트에도 붙이지 않는다. +3. **프리팹당 1개.** `[DisallowMultipleComponent]` 는 같은 오브젝트의 중복만 막는다. 중첩 부착(부모·자식 양쪽)은 막지 못하고 안전 영역이 이중으로 축소되니 금지. +4. **성능**: `Update()` 가 매 프레임 `Screen.safeArea` 를 폴링한다(`SafeAreaFitter.cs:25-29`). 값이 바뀔 때만 `Apply()` 하므로 비용은 비교 3회뿐이지만, 모바일 예산상 **부착 수는 화면당 1개로 유지**한다. +5. **미적용 기기 회귀 0**: `Screen.safeArea` 가 전체 화면이면 min=(0,0)·max=(1,1)·offset 0 → 프리팹 저장값과 동일해 아무 변화가 없다(WL-802b ⓒ). + +### 현재 부착 현황 (WL-802b 적용 후) + +| 프리팹 | 경로 | 캔버스 직속 | 비고 | +|---|---|---|---| +| `TitleInfo.prefab` | `TitleInfo/WL_SafeArea` | O | WL-802b 신규 · 빈 패널(자식 0 · Graphic 없음) · `TitleInfo.prefab:833-882` | +| `SortOrder_5.prefab` | `SortOrder_5/WL_SafeArea` | O | WL-802b 신규 · 빈 패널(자식 0 · Graphic 없음) · `SortOrder_5.prefab:12908-12957` | +| `NewGameUI.prefab` | `NewGameUI/IngameUIs/WL_HUD` | **X** | 기존(#800 계열) · 이번 태스크 무수정 · Fitter 블록 `NewGameUI.prefab:107746-107759` | + +`WL802b_Probe.Dump` 실측(2026-09-08): 세 파일 모두 `SafeAreaFitter count = 1` · `applyH=True applyV=True` · 부착 RectTransform `aMin=(0,0) aMax=(1,1) offMin=(0,0) offMax=(0,0) pivot=(0.5,0.5)`. + +- 신규 패널은 **비어 있다**. 기존 자식을 재부모화하면 레이아웃 회귀(WL-802b ⓓ)가 나므로 이동시키지 않았다. 이 패널은 **후속 UI 가 들어갈 안전 영역 레이어**이고, 기존 자식을 옮기는 작업은 별도 발주(레이아웃 변경 승인 필요)로 분리한다. +- `NewGameUI` 의 기존 부착은 캔버스 직속이 아니라 `IngameUIs` 아래에 있다(`NewGameUI.prefab:107739` `m_Father: {fileID: 2647977513349308266}` = `IngameUIs`). `IngameUIs` 는 프리팹 저장 상태에서 **비활성**(`m_IsActive: 0`)이므로, 런타임에 활성화되기 전에는 Fitter 도 돌지 않는다 → **「미확인」**(런타임 활성화 시점 미검증). 파일당 1개(중복 0) 기준을 지키려 이번에는 옮기거나 추가하지 않았다. + +### ⚠️ 남은 공백 — `NewGameUI` 최상위 레이어 (후속 발주 후보) + +`NewGameUI` 캔버스 직속 자식은 12개다(실측 · 저장 상태 활성/비활성 표기): + +`New Image`(비활성) · `Camera` · `CamRot` · `Common` · `LobbyUIs` · **`IngameUIs`(비활성)** · `FarmUIs`(비활성) · `MessageInfo` · `Center` · **`PopupMgr`** · `TestMapUIMgr` + +기존 `SafeAreaFitter` 는 `IngameUIs/WL_HUD` **한 갈래만** 덮는다. 즉 **`PopupMgr`·`MessageInfo`·`Common`·`LobbyUIs`·`Center` 는 안전 영역 밖에 그대로 노출된다.** +WL-802b 는 완료 기준 ⓑ(파일당 1 · 중복 0)와 ⓓ(기존 오브젝트 무변경) 때문에 이 프리팹을 수정하지 않았다. +→ **후속 발주 제안**: 이 5개 레이어를 덮는 방식은 (가) 각 레이어를 새 `WL_SafeArea` 패널의 자식으로 재부모화(레이아웃 승인 필요) 또는 (나) 레이어별 부착(부착 수 증가 = 매 프레임 폴링 증가) 중 **PD·Lead 결정 사항**이다. 이 노트만으로 임의 채택하지 않는다. + +## 6. NGUI 잔재 3종 — 실측과 대체 계획 (W2 Systems 후보 · 이번 태스크 무수정) + +`Assets/Script/Util/**` 는 Systems 소유이자 WL-802b 금지 경로다. 아래는 **읽기 전용 실측**이다. + +| 파일 | 부착 수(프리팹·씬 grep) | 세로 전용(#803)에서의 실동작 | 대체 | +|---|---|---|---| +| `Assets/Script/Util/SafeArea.cs` | **0** | `switch(Screen.orientation)` 이 `LandscapeLeft`/`LandscapeRight` 만 처리(`:20-37`) → 세로에서는 **어떤 분기도 타지 않음**. 그런데 `while(true) { … yield return null; }`(`:18-38`) 은 계속 돈다 = **효과 0 · 매 프레임 코루틴 비용만 남음** | 삭제 (부착 0 → 무위험) | +| `Assets/Script/Util/UISafeAreaTopController.cs` | **0** | NGUI `UIRoot.manualHeight` 의존. `GetComponentInParent()` 가 null 이면 조기 return(`:30-31`) → uGUI 캔버스 아래에서는 no-op | 삭제 | +| `Assets/Script/Util/UISafeAreaRightController.cs` | **1** — `Assets/ResWork/UIPrefabs/Equip/EquipUI.prefab:52-64` (루트 `EquipUI` · `BasePosisLeft: 1` · `xMul: 1`) | `MyCoroutine.Set_Repeat_for1sec` 로 **1초마다** 실행(`:17`). `m_cUIRoot` 를 받아만 두고 쓰지 않아 null 이어도 조기 return 이 없다. `localPosition.x` 를 **`Screen.safeArea.y`**(= 세로 상단 인셋)만큼 민다(`:28-30`) — **가로 화면 전제의 축 오용**. `EquipUI.prefab` 루트는 `RectTransform`(`!u!224`)이 아니라 `Transform`(`!u!4`) = NGUI 시절 프리팹 | uGUI 재작성 후 `SafeAreaFitter` 로 교체 | + +- 세로 전용 전환 후 이 3종은 **동작하지 않거나(0·0건) 축이 틀린 상태(1건)**다. 세로에서 노치 대응은 전부 `WL.UI.SafeAreaFitter` 로 일원화한다. +- **W2 Systems 제안**: ① `SafeArea.cs`·`UISafeAreaTopController.cs` 삭제(부착 0 실측) ② `EquipUI.prefab` 을 uGUI 로 재작성할 때 `UISafeAreaRightController` 를 제거하고 상위 `WL_SafeArea` 패널의 자식으로 넣는다 ③ NGUI `UIRoot` 를 쓰는 실사용 프리팹은 `BoxOpenUI`·`GetItemUI`·`ItemDescUI` 3종(나머지 23건은 `Assets/ThirdParty/NGUI/Examples/**`) — 재작성 범위 산정에 사용. + +## 7. 미확인 · 리스크 + +- **「미확인」** `NewGameUI` 의 `IngameUIs` 가 런타임에 언제 활성화되는지 · 팝업/오버레이 레이어가 `WL_HUD` 밖에 따로 있는지(5.3 MB 프리팹 통째 Read 금지 · 부분 실측만 수행). +- **「미확인」** `Title.unity` 안 PopupUI 인스턴스와 `SortOrder_5.prefab` 의 관계(씬 수정·Play 금지 범위). +- **「미확인」** 실기 노치 인셋 값. QA 5종 캡처(#802 §3)는 시뮬레이터 프리셋이며 실제 기기 `Screen.safeArea` 는 **「추정」**. +- **「미확인」** 세로 전용 자동회전이 실기 빌드에서 적용되는지(빌드 미수행). diff --git a/Assets/WL/UI/UI_UnitScale_SafeArea_Note.md.meta b/Assets/WL/UI/UI_UnitScale_SafeArea_Note.md.meta new file mode 100644 index 000000000..78242d737 --- /dev/null +++ b/Assets/WL/UI/UI_UnitScale_SafeArea_Note.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2d09ff72d4e8bd14a8909d2a37ac571e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: