Project_WL/Assets/WL/UI/Scripts/WLBattlePadLayout.cs

316 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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; }
/// <summary>전투 액션 버튼(스킬 4 + 공격 + 물약)만의 도달 반경 — 자동전투 토글 제외(WL-813y).</summary>
public float LastActionReachPx { get; private set; }
/// <summary>마지막 배치 때의 하단 메뉴 상태(true = 메뉴 보임 → 패드가 idle 자리로 올라감).</summary>
public bool LastBottomMenuVisible { get; private set; }
private void Awake() { _rt = GetComponent<RectTransform>(); }
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();
}
/// <summary>BattleUI.Set() 직후 훅 — 서버 프리셋이 슬롯을 다시 채운 뒤 배치를 되살린다.</summary>
public static void NotifySet(BattleUI ui)
{
if (ui == null) return;
var layout = ui.GetComponent<WLBattlePadLayout>();
if (layout != null && layout.isActiveAndEnabled) layout.Apply();
}
/// <summary>설정 값대로 슬롯·공격·자동 버튼을 재배치한다. 설정이 없으면 아무 것도 하지 않는다.</summary>
public string Apply()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
if (target == null) target = GetComponent<BattleUI>();
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<SafeAreaFitter>() == 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<PotionButton>(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;
}
/// <summary>
/// 모서리(우하단 · 미러면 좌하단) 기준 (dx, dy) px 로 배치한다.
/// 813tj 물약 버튼(PotionButton)이 예약 슬롯 자리에 앉을 때 **같은 산식**을 쓰도록 public 이다.
/// </summary>
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;
}
/// <summary>이웃 슬롯 중심 거리 지름 = 실제 여백(px). 기준서 §B 요소3 ≥16 검증용.</summary>
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;
}
/// <summary>px → 캔버스 유닛 계수. 산식은 WLHudLayoutSettings 가 갖는다(보스 바와 같은 것을 쓴다).</summary>
public float ComputeUnitsPerPx(WLHudLayoutSettings s)
{
if (_canvas == null) _canvas = GetComponentInParent<Canvas>(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;
}
// ── 검증·디버그 전용 ──────────────────────────────────────────────────
/// <summary>런타임 미러 토글(로드된 설정 인스턴스만 바꾼다 — .asset 파일은 건드리지 않는다).</summary>
public string SetMirror(bool mirror)
{
var s = WLHudLayoutSettings.Instance;
if (s == null) return "WLHudLayoutSettings 에셋 없음";
s.mirrorLeftHanded = mirror;
return Apply();
}
/// <summary>현재 배치 덤프(슬롯별 앵커·좌표·크기 · px 환산). 발주서 ⓑ 증거용.</summary>
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<RectTransform>()) +
" safeAreaFitter=" + (GetComponent<SafeAreaFitter>() != null));
var ui = target != null ? target : GetComponent<BattleUI>();
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();
}
/// <summary>쿨 링 실측 — SkillCard 원본의 i_cooltime(Filled/Radial360)이 배치 뒤에도 유효한지(발주서 ⓑ "쿨 링").</summary>
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") + ")";
}
}
}