// ───────────────────────────────────────────────────────────────────────────── // WLBattlePadLayout.cs — 전투 패드(우하단 엄지 부채꼴) 배치 드라이버 (WL-813c · #813) // // 기준서 v1 §F-1 권고: 오른손 엄지 부채꼴 **4슬롯** + 물약 1 + 회피 1 · 좌우 미러 옵션. // §B 요소 3: 버튼 지름 132~150 px · 중심 간격 ≥16 px · 요소 7: Safe Area 안 · 한 손 도달. // // ■ 설계 // · 원본 BattleUI.prefab 의 **배열을 줄이지 않는다**(skillCards 6 그대로 · 원복 가능). // 활성 슬롯 수만 설정으로 정하고, 남는 슬롯은 물약/회피(813j/813r) 자리로 **비활성**으로 둔다. // · 값은 전부 WLHudLayoutSettings(에셋). 이 파일에 좌표·지름·각도 상수는 없다(C45). // · 배치는 런타임에만 한다([ExecuteAlways] 아님) — 프리팹 YAML 에 좌표를 굽지 않아 // NewGameUI.prefab diff 를 "노드/컴포넌트 추가"로만 유지한다(발주서 ⓓ). // · 이 컴포넌트는 **NewGameUI 안 IngameUIs/BattleUI 인스턴스에만** 붙인다. // 같은 BattleUI.prefab 을 쓰는 SkillUI/SkillEquipUI 인스턴스는 손대지 않는다(회귀 0). // // ■ Safe Area // 같은 오브젝트의 SafeAreaFitter(802b)가 루트를 안전 영역에 맞춘다. 이 스크립트는 루트 rect 를 // 건드리지 않고(피터가 주인) 자식만 **우하단(미러 시 좌하단) 모서리 앵커**로 배치한다. // 피터가 없으면(단독 테스트) 루트를 스트레치로 정규화한다. // // ■ 롤백(C8) // 설정 에셋이 없거나 battlePadEnabled=false 면 아무 것도 만지지 않는다 = 원본 배치 그대로. // // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using System.Text; using UnityEngine; using UnityEngine.UI; namespace WL.UI { [DisallowMultipleComponent] [RequireComponent(typeof(RectTransform))] public class WLBattlePadLayout : MonoBehaviour { [Tooltip("배치 대상 — 비우면 같은 오브젝트의 BattleUI 를 찾는다")] [SerializeField] private BattleUI target; [Tooltip("자동전투 토글 오브젝트 이름(BattleUI 자식) — 못 찾으면 건너뛴다")] [SerializeField] private string autoButtonName = "btn_auto"; [Tooltip("공격 버튼 오브젝트 이름(BattleUI 자식) — 못 찾으면 건너뛴다")] [SerializeField] private string attackButtonName = "Attack"; private RectTransform _rt; private Canvas _canvas; private bool _lastMirror; private Vector2Int _lastScreen; // 마지막 적용 결과 — 프로브(에디터)가 읽는 진단 값. 실측 외에는 채우지 않는다. public float LastUnitsPerPx { get; private set; } public float LastMaxReachPx { get; private set; } public float LastMinGapPx { get; private set; } public int LastActiveSlots { get; private set; } public int LastReserveSlots { get; private set; } /// 전투 액션 버튼(스킬 4 + 공격 + 물약)만의 도달 반경 — 자동전투 토글 제외(WL-813y). public float LastActionReachPx { get; private set; } /// 마지막 배치 때의 하단 메뉴 상태(true = 메뉴 보임 → 패드가 idle 자리로 올라감). public bool LastBottomMenuVisible { get; private set; } private void Awake() { _rt = GetComponent(); } private void OnEnable() { Apply(); } private void Update() { // 해상도 전환·설정 토글에만 다시 배치한다(매 프레임 계산 없음). var s = WLHudLayoutSettings.Instance; if (s == null) return; if (Screen.width != _lastScreen.x || Screen.height != _lastScreen.y || s.mirrorLeftHanded != _lastMirror) Apply(); } /// BattleUI.Set() 직후 훅 — 서버 프리셋이 슬롯을 다시 채운 뒤 배치를 되살린다. public static void NotifySet(BattleUI ui) { if (ui == null) return; var layout = ui.GetComponent(); if (layout != null && layout.isActiveAndEnabled) layout.Apply(); } /// 설정 값대로 슬롯·공격·자동 버튼을 재배치한다. 설정이 없으면 아무 것도 하지 않는다. public string Apply() { if (_rt == null) _rt = GetComponent(); if (target == null) target = GetComponent(); var s = WLHudLayoutSettings.Instance; if (s == null) return "WLHudLayoutSettings 에셋 없음 — 배치 건너뜀(원본 유지)"; if (!s.battlePadEnabled) return "battlePadEnabled=false — 배치 건너뜀(원본 유지)"; if (target == null) return "BattleUI 없음 — 배치 건너뜀"; _lastScreen = new Vector2Int(Screen.width, Screen.height); _lastMirror = s.mirrorLeftHanded; // Safe Area 는 SafeAreaFitter 가 주인 — 없을 때만 루트를 스트레치로 정규화한다. if (GetComponent() == null) { _rt.anchorMin = Vector2.zero; _rt.anchorMax = Vector2.one; _rt.offsetMin = Vector2.zero; _rt.offsetMax = Vector2.zero; } float u = ComputeUnitsPerPx(s); LastUnitsPerPx = u; var cards = target.skillCards; int total = cards != null ? cards.Length : 0; // 🔴 WL-816u ③ — PD 「우선 엑티브 스킬은 사용 Off 해줘」. 잠금이 걸려 있으면 활성 슬롯 0 // = 스킬 버튼 4칸이 전부 비활성(아래 SetActive(isSkill)). 되돌리기는 설정 값 하나. int want = WL.Combat.Auto.ActiveSkillLock.Locked ? 0 : s.activeSkillSlots; int active = Mathf.Clamp(want, 0, total); LastActiveSlots = active; LastReserveSlots = Mathf.Max(0, total - active); float maxReach = 0f, actionReach = 0f; var centers = new Vector2[total]; // WL-813y — 하단 메뉴가 보이는 동안만 패드를 위로 올린다(전투 중에는 모서리 원점). bool menuVisible = HudCombatVisibility.BottomMenuVisible; LastBottomMenuVisible = menuVisible; for (int i = 0; i < total; i++) { var card = cards[i]; if (card == null) continue; var crt = card.transform as RectTransform; if (crt == null) continue; bool isSkill = i < active; Vector2 px = isSkill ? s.FanSlotPx(i, menuVisible) : s.ReserveSlotPx(i - active, menuVisible); float dia = isSkill ? s.slotDiameterPx : s.reserveDiameterPx; PlaceFromCorner(crt, px, dia, u, s.mirrorLeftHanded); centers[i] = px; maxReach = Mathf.Max(maxReach, px.magnitude + dia * 0.5f); if (isSkill) actionReach = Mathf.Max(actionReach, px.magnitude + dia * 0.5f); // 4 활성 · 나머지는 물약/회피(813j/813r) 예약 자리 = 비활성 if (card.gameObject.activeSelf != isSkill) card.gameObject.SetActive(isSkill); } // 공격 버튼 · 자동전투 토글 var attack = FindChild(attackButtonName); if (attack != null) { Vector2 apx = s.AttackCenterPxFor(menuVisible); PlaceFromCorner(attack, apx, s.attackDiameterPx, u, s.mirrorLeftHanded); maxReach = Mathf.Max(maxReach, apx.magnitude + s.attackDiameterPx * 0.5f); actionReach = Mathf.Max(actionReach, apx.magnitude + s.attackDiameterPx * 0.5f); } var auto = FindChild(autoButtonName); if (auto != null) { PlaceFromCorner(auto, s.autoButtonCenterPx, s.autoButtonDiameterPx, u, s.mirrorLeftHanded); maxReach = Mathf.Max(maxReach, s.autoButtonCenterPx.magnitude + s.autoButtonDiameterPx * 0.5f); } LastMaxReachPx = maxReach; LastMinGapPx = MinGapPx(s, centers, active, total); // 813tj 물약 버튼 — 예약 슬롯 자리에 있으므로 미러·해상도가 바뀌면 같이 다시 앉힌다. var potion = GetComponentInChildren(true); if (potion != null) { potion.ApplyLayout(); Vector2 ppx = s.ReserveSlotPx(Mathf.Max(0, PotionSlotIndex()), menuVisible); actionReach = Mathf.Max(actionReach, ppx.magnitude + s.reserveDiameterPx * 0.5f); } LastActionReachPx = actionReach; return "applied slots=" + active + "/" + total + " unitsPerPx=" + u.ToString("F4") + " maxReachPx=" + maxReach.ToString("F1") + " actionReachPx=" + actionReach.ToString("F1") + "(스킬+공격+물약 · 자동토글 제외)" + " minGapPx=" + LastMinGapPx.ToString("F1") + " menuVisible=" + menuVisible + " fanOrigin=" + s.FanOriginPxFor(menuVisible) + " mirror=" + s.mirrorLeftHanded; } private static int PotionSlotIndex() { var su = WLSurvivalUiSettings.Instance; return su != null ? su.potionReserveSlotIndex : 0; } /// /// 모서리(우하단 · 미러면 좌하단) 기준 (dx, dy) px 로 배치한다. /// 813tj 물약 버튼(PotionButton)이 예약 슬롯 자리에 앉을 때 **같은 산식**을 쓰도록 public 이다. /// public static void PlaceFromCorner(RectTransform rt, Vector2 px, float diameterPx, float unitsPerPx, bool mirror) { Vector2 anchor = new Vector2(mirror ? 0f : 1f, 0f); rt.anchorMin = anchor; rt.anchorMax = anchor; rt.pivot = new Vector2(0.5f, 0.5f); float sx = mirror ? px.x : -px.x; rt.anchoredPosition = new Vector2(sx * unitsPerPx, px.y * unitsPerPx); rt.sizeDelta = new Vector2(diameterPx * unitsPerPx, diameterPx * unitsPerPx); rt.localScale = Vector3.one; } /// 이웃 슬롯 중심 거리 − 지름 = 실제 여백(px). 기준서 §B 요소3 ≥16 검증용. private static float MinGapPx(WLHudLayoutSettings s, Vector2[] centers, int active, int total) { float min = float.MaxValue; for (int i = 0; i < total; i++) { for (int j = i + 1; j < total; j++) { float ri = (i < active ? s.slotDiameterPx : s.reserveDiameterPx) * 0.5f; float rj = (j < active ? s.slotDiameterPx : s.reserveDiameterPx) * 0.5f; float gap = Vector2.Distance(centers[i], centers[j]) - ri - rj; if (gap < min) min = gap; } } return min == float.MaxValue ? 0f : min; } /// px → 캔버스 유닛 계수. 산식은 WLHudLayoutSettings 가 갖는다(보스 바와 같은 것을 쓴다). public float ComputeUnitsPerPx(WLHudLayoutSettings s) { if (_canvas == null) _canvas = GetComponentInParent(true); return s.UnitsPerPx(_canvas); } private RectTransform FindChild(string childName) { if (string.IsNullOrEmpty(childName)) return null; for (int i = 0; i < transform.childCount; i++) { var c = transform.GetChild(i); if (c.name == childName) return c as RectTransform; } return null; } // ── 검증·디버그 전용 ────────────────────────────────────────────────── /// 런타임 미러 토글(로드된 설정 인스턴스만 바꾼다 — .asset 파일은 건드리지 않는다). public string SetMirror(bool mirror) { var s = WLHudLayoutSettings.Instance; if (s == null) return "WLHudLayoutSettings 에셋 없음"; s.mirrorLeftHanded = mirror; return Apply(); } /// 현재 배치 덤프(슬롯별 앵커·좌표·크기 · px 환산). 발주서 ⓑ 증거용. public string Dump() { var sb = new StringBuilder(); var s = WLHudLayoutSettings.Instance; float u = LastUnitsPerPx > 0f ? LastUnitsPerPx : (s != null ? ComputeUnitsPerPx(s) : 1f); sb.AppendLine("[WLBattlePadLayout] unitsPerPx=" + u.ToString("F4") + " mirror=" + (s != null ? s.mirrorLeftHanded.ToString() : "?") + " activeSlots=" + LastActiveSlots + "/" + (LastActiveSlots + LastReserveSlots) + " maxReachPx=" + LastMaxReachPx.ToString("F1") + " (목표 " + (s != null ? s.thumbReachRadiusPx.ToString("F0") : "?") + ")" + " actionReachPx=" + LastActionReachPx.ToString("F1") + "(자동토글 제외)" + " menuVisible=" + LastBottomMenuVisible + " fanOrigin=" + (s != null ? s.FanOriginPxFor(LastBottomMenuVisible).ToString() : "?") + " attackCenter=" + (s != null ? s.AttackCenterPxFor(LastBottomMenuVisible).ToString() : "?") + " minGapPx=" + LastMinGapPx.ToString("F1")); sb.AppendLine(" root " + RectLine(_rt != null ? _rt : GetComponent()) + " safeAreaFitter=" + (GetComponent() != null)); var ui = target != null ? target : GetComponent(); if (ui != null && ui.skillCards != null) { for (int i = 0; i < ui.skillCards.Length; i++) { var c = ui.skillCards[i]; if (c == null) { sb.AppendLine(" slot" + i + " : null"); continue; } var crt = c.transform as RectTransform; sb.AppendLine(" slot" + i + " " + c.name + " active=" + c.gameObject.activeSelf + " " + RectLine(crt) + " " + PxLine(crt, u)); sb.AppendLine(" 쿨링 " + CoolRingLine(c)); } } foreach (var n in new[] { attackButtonName, autoButtonName, "Avoid", "Equip" }) { var t = FindChild(n); sb.AppendLine(" " + n + (t == null ? " : 없음" : " active=" + t.gameObject.activeSelf + " " + RectLine(t) + " " + PxLine(t, u))); } return sb.ToString(); } /// 쿨 링 실측 — SkillCard 원본의 i_cooltime(Filled/Radial360)이 배치 뒤에도 유효한지(발주서 ⓑ "쿨 링"). private static string CoolRingLine(SkillCard card) { var img = card != null ? card.sprite_cooltime : null; if (img == null) return "sprite_cooltime 없음"; var irt = img.rectTransform; return img.gameObject.name + " type=" + img.type + " fillMethod=" + img.fillMethod + " origin=" + img.fillOrigin + " cw=" + img.fillClockwise + " amount=" + img.fillAmount.ToString("F3") + " active=" + img.gameObject.activeInHierarchy + " aMin" + irt.anchorMin + " aMax" + irt.anchorMax + " size" + irt.sizeDelta + " · 구동 = PCActor.cs:91 Set_CoolTime → CardCoolTimeBase.Set_CoolTimeText"; } private static string RectLine(RectTransform rt) { if (rt == null) return "(rt 없음)"; return "aMin" + rt.anchorMin + " aMax" + rt.anchorMax + " pos" + rt.anchoredPosition + " size" + rt.sizeDelta + " piv" + rt.pivot + " scl" + (Vector2)(Vector3)rt.localScale; } private static string PxLine(RectTransform rt, float u) { if (rt == null || u <= 0f) return ""; return "→ px(dx=" + Mathf.Abs(rt.anchoredPosition.x / u).ToString("F1") + ", dy=" + (rt.anchoredPosition.y / u).ToString("F1") + ", d=" + (rt.sizeDelta.x / u).ToString("F1") + ")"; } } }