// WL813c_Apply.cs — #813 바퀴 2: 전투 패드 4슬롯(우하단 엄지 부채꼴) + 상단 보스 HP 바 (에디트 모드 · Play 불필요) // unity command run_script --file AgentScripts/WL813c_Apply.cs --entry WL813c_Apply.CreateSettings // unity command run_script --file AgentScripts/WL813c_Apply.cs --entry WL813c_Apply.Resave (무변경 재저장 — 재직렬화 diff 측정 · 802b 교훈) // unity command run_script --file AgentScripts/WL813c_Apply.cs --entry WL813c_Apply.Apply // unity command run_script --file AgentScripts/WL813c_Apply.cs --entry WL813c_Apply.Revert (추가한 컴포넌트·노드 제거 = 원상) // // 방식(발주서 ⓓ "NewGameUI.prefab 변경 최소 · 노드 추가만"): // ① IngameUIs/BattleUI (BattleUI.prefab 인스턴스) 에 **인스턴스 전용 추가 컴포넌트**로 // WL.UI.SafeAreaFitter(802b 방식 · 전용 루트 · 스트레치 · offset 0 · 재부모화 0) + WL.UI.WLBattlePadLayout 을 붙인다. // → BattleUI.prefab 원본은 손대지 않으므로 SkillUI/SkillEquipUI 의 다른 인스턴스는 회귀 0. // ② IngameUIs/WL_HUD (이미 SafeAreaFitter 부착 = Safe Area 레이어) 아래에 WL_BossHpBar 노드 1개를 추가한다. // ③ 슬롯 좌표는 프리팹에 굽지 않는다 — 런타임 WLBattlePadLayout 이 에셋 값으로 배치한다(코드 상수 0 · diff 최소). // 롤백: Revert() 또는 git checkout -- Assets/Res_Addr/MainUI/NewGameUI.prefab using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; public static class WL813c_Apply { public const string NewGameUIPath = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; public const string SettingsDir = "Assets/WL/UI/Settings/Resources/WL"; public const string SettingsPath = SettingsDir + "/WLHudLayoutSettings.asset"; public const string BossBarName = "WL_BossHpBar"; public const string PadPath = "IngameUIs/BattleUI"; public const string HudPath = "IngameUIs/WL_HUD"; const string TmpFontGuid = "a387e325271126742ad15569dba1ac57"; // SkillCard/t_cooltime 이 쓰는 폰트(실측) const int UILayer = 5; static Transform FindByPath(Transform root, string path) { var cur = root; foreach (var part in path.Split('/')) { Transform next = null; for (int i = 0; i < cur.childCount; i++) if (cur.GetChild(i).name == part) { next = cur.GetChild(i); break; } if (next == null) return null; cur = next; } return cur; } static void EnsureFolder(string dir) { var parts = dir.Split('/'); var cur = parts[0]; for (int i = 1; i < parts.Length; i++) { var next = cur + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]); cur = next; } } /// WLHudLayoutSettings.asset 생성(이미 있으면 값 유지 · 덮어쓰지 않는다). public static object CreateSettings() { var sb = new StringBuilder(); var existing = AssetDatabase.LoadAssetAtPath(SettingsPath); if (existing != null) { sb.AppendLine(SettingsPath + " : 이미 있음 — 값 유지"); } else { EnsureFolder(SettingsDir); var so = ScriptableObject.CreateInstance(); // 기본값 = 기준서 §B/§F-1 제안값 AssetDatabase.CreateAsset(so, SettingsPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); sb.AppendLine(SettingsPath + " : 생성"); } WL.UI.WLHudLayoutSettings.ClearCache(); var s = WL.UI.WLHudLayoutSettings.Instance; if (s == null) { sb.AppendLine("🔴 Resources 로드 실패 — Resources/WL 경로 확인"); return sb.ToString(); } sb.AppendLine("Resources 로드 OK · design=" + s.designWidthPx + "x" + s.designHeightPx + " activeSlots=" + s.activeSkillSlots + " slotDia=" + s.slotDiameterPx + " fanR=" + s.fanRadiusPx + " start=" + s.fanStartAngleDeg + " step=" + s.fanStepAngleDeg); // 기하 검증 — 모서리 기준 좌표 · 이웃 여백 · 최대 도달 반경 (기준서 §B 요소3/7) var pts = new List(); var dia = new List(); for (int i = 0; i < s.activeSkillSlots; i++) { pts.Add(s.FanSlotPx(i)); dia.Add(s.slotDiameterPx); } for (int i = 0; i < 6 - s.activeSkillSlots; i++) { pts.Add(s.ReserveSlotPx(i)); dia.Add(s.reserveDiameterPx); } pts.Add(s.attackCenterPx); dia.Add(s.attackDiameterPx); pts.Add(s.autoButtonCenterPx); dia.Add(s.autoButtonDiameterPx); float minGap = float.MaxValue, maxReach = 0f, minEdgeX = float.MaxValue, minEdgeY = float.MaxValue, maxTopPx = 0f; for (int i = 0; i < pts.Count; i++) { maxReach = Mathf.Max(maxReach, pts[i].magnitude + dia[i] * 0.5f); minEdgeX = Mathf.Min(minEdgeX, pts[i].x - dia[i] * 0.5f); minEdgeY = Mathf.Min(minEdgeY, pts[i].y - dia[i] * 0.5f); maxTopPx = Mathf.Max(maxTopPx, pts[i].y + dia[i] * 0.5f); for (int j = i + 1; j < pts.Count; j++) minGap = Mathf.Min(minGap, Vector2.Distance(pts[i], pts[j]) - (dia[i] + dia[j]) * 0.5f); sb.AppendLine(" pt" + i + " px(dx=" + pts[i].x.ToString("F1") + ", dy=" + pts[i].y.ToString("F1") + ", d=" + dia[i].ToString("F1") + ") reach=" + (pts[i].magnitude + dia[i] * 0.5f).ToString("F1")); } sb.AppendLine(" 검증 minGapPx=" + minGap.ToString("F1") + "(기준 ≥" + s.slotMinGapPx + ")" + " maxReachPx=" + maxReach.ToString("F1") + "(목표 ≤" + s.thumbReachRadiusPx + ")" + " 화면밖여유 dx=" + minEdgeX.ToString("F1") + " dy=" + minEdgeY.ToString("F1")); float bottom40 = s.designHeightPx * 0.4f; // 기준서 §F-1 "하단 40 %" — 설정 값에서 파생(상수 0) sb.AppendLine(" 하단40% 경계=" + bottom40.ToString("F0") + "px · 최상단 버튼 dy+반지름=" + maxTopPx.ToString("F1") + " → 안=" + (maxTopPx <= bottom40)); return sb.ToString(); } /// 무변경 재저장 — LoadPrefabContents→SaveAsPrefabAsset 이 diff 를 만드는지 먼저 잰다(802b 교훈). public static object Resave() { var root = PrefabUtility.LoadPrefabContents(NewGameUIPath); try { PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.Refresh(); return NewGameUIPath + " : 무변경 재저장 (git diff 로 재직렬화 여부 확인)"; } public static object Apply() { var sb = new StringBuilder(); var settings = AssetDatabase.LoadAssetAtPath(SettingsPath); if (settings == null) return "🔴 " + SettingsPath + " 없음 — CreateSettings 먼저"; TMPro.TMP_FontAsset font = null; var fontPath = AssetDatabase.GUIDToAssetPath(TmpFontGuid); if (!string.IsNullOrEmpty(fontPath)) font = AssetDatabase.LoadAssetAtPath(fontPath); sb.AppendLine("폰트: " + (font != null ? fontPath : "「미확인」 — 이름 라벨 폰트 미주입")); var root = PrefabUtility.LoadPrefabContents(NewGameUIPath); try { // ── ① 전투 패드 루트 = IngameUIs/BattleUI (인스턴스 전용 추가 컴포넌트) ── var pad = FindByPath(root.transform, PadPath); if (pad == null) { sb.AppendLine("🔴 " + PadPath + " 없음"); } else { var prt = (RectTransform)pad; if (pad.GetComponent() == null) { pad.gameObject.AddComponent(); sb.AppendLine(PadPath + " : SafeAreaFitter 추가"); } else sb.AppendLine(PadPath + " : SafeAreaFitter 이미 있음"); var layout = pad.GetComponent(); if (layout == null) { layout = pad.gameObject.AddComponent(); sb.AppendLine(PadPath + " : WLBattlePadLayout 추가"); } else sb.AppendLine(PadPath + " : WLBattlePadLayout 이미 있음"); var so = new SerializedObject(layout); so.FindProperty("target").objectReferenceValue = pad.GetComponent(); so.ApplyModifiedPropertiesWithoutUndo(); // Safe Area 패널 규격(802b 방식): 스트레치 · offset 0 · pivot 중앙 · 재부모화 0 sb.AppendLine(" before " + Describe(prt)); prt.anchorMin = Vector2.zero; prt.anchorMax = Vector2.one; prt.pivot = new Vector2(0.5f, 0.5f); prt.offsetMin = Vector2.zero; prt.offsetMax = Vector2.zero; prt.localScale = Vector3.one; prt.localRotation = Quaternion.identity; sb.AppendLine(" after " + Describe(prt)); } // ── ② 상단 보스 HP 바 = IngameUIs/WL_HUD/WL_BossHpBar (기존 Safe Area 레이어 아래) ── var hud = FindByPath(root.transform, HudPath); if (hud == null) { sb.AppendLine("🔴 " + HudPath + " 없음"); } else { var barT = hud.Find(BossBarName); if (barT == null) { var go = new GameObject(BossBarName, typeof(RectTransform)); go.layer = UILayer; barT = go.transform; ((RectTransform)barT).SetParent((RectTransform)hud, false); sb.AppendLine(HudPath + "/" + BossBarName + " : 노드 추가"); } else sb.AppendLine(HudPath + "/" + BossBarName + " : 이미 있음"); var bar = barT.GetComponent(); if (bar == null) { bar = barT.gameObject.AddComponent(); sb.AppendLine(" BossHpBar 추가"); } if (font != null) bar.SetNameFont(font); bool made = bar.BuildIfNeeded(); sb.AppendLine(" BuildIfNeeded made=" + made + " · " + bar.ApplyLayout()); // 저장 상태의 기본은 **미표시** — 보스 Spawned 이벤트가 와야 보인다(발주서 §1-2 "이벤트 미도착 = 미표시"). var cg = barT.GetComponent(); if (cg != null) { cg.alpha = 0f; cg.interactable = false; cg.blocksRaycasts = false; sb.AppendLine(" CanvasGroup alpha=0 (기본 미표시)"); } foreach (Transform c in barT) sb.AppendLine(" child " + c.name + " " + Describe(c as RectTransform)); } PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return sb.ToString(); } public static object Revert() { var sb = new StringBuilder(); var root = PrefabUtility.LoadPrefabContents(NewGameUIPath); try { var pad = FindByPath(root.transform, PadPath); if (pad != null) { var l = pad.GetComponent(); if (l != null) { Object.DestroyImmediate(l, true); sb.AppendLine(PadPath + " : WLBattlePadLayout 제거"); } var f = pad.GetComponent(); if (f != null) { Object.DestroyImmediate(f, true); sb.AppendLine(PadPath + " : SafeAreaFitter 제거"); } sb.AppendLine(" ⚠ 루트 RectTransform 값은 되돌리지 않는다 — git checkout -- " + NewGameUIPath + " 를 쓸 것"); } var hud = FindByPath(root.transform, HudPath); var bar = hud != null ? hud.Find(BossBarName) : null; if (bar != null) { Object.DestroyImmediate(bar.gameObject); sb.AppendLine(HudPath + "/" + BossBarName + " : 제거"); } PrefabUtility.SaveAsPrefabAsset(root, NewGameUIPath); } finally { PrefabUtility.UnloadPrefabContents(root); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return sb.ToString(); } static string Describe(RectTransform rt) { if (rt == null) return "(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; } }