// ─────────────────────────────────────────────────────────────────────────────
// 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