301 lines
18 KiB
C#
301 lines
18 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// WLHudLayoutSettings.cs — 전투 HUD 배치 값의 단일 출처 (WL-813c · #813)
|
||
//
|
||
// 기준서 v1 §B 요소 3(슬롯 수·배치·지름·간격) · 요소 6(상단 보스 HP 바·페이즈) · 요소 7(한 손 도달성)
|
||
// + §F-1 권고(엄지 부채꼴 4슬롯 + 물약 1 + 회피 1 · 좌우 미러 옵션).
|
||
//
|
||
// ■ 왜 이 에셋인가 (C45)
|
||
// 거리·지름·각도·임계 같은 값은 코드 상수로 두지 않는다. 이 파일에는 "필드와 기본값"만 있고,
|
||
// 실제로 게임이 읽는 값은 Resources/WL/WLHudLayoutSettings.asset 이다(PD·기획이 인스펙터에서 바꾼다).
|
||
//
|
||
// ■ 좌표계 (px)
|
||
// 모든 px 값은 **세로 1080×1920 기준 화면 픽셀**이다(designWidth/HeightPx).
|
||
// 전투 패드는 **Safe Area 의 우하단 모서리**를 원점으로 삼고 dx = 왼쪽 방향, dy = 위쪽 방향으로 잰다.
|
||
// 좌우 미러(mirrorLeftHanded)를 켜면 원점이 좌하단으로 바뀌고 dx 는 오른쪽 방향이 된다.
|
||
// 각도 deg 는 0° = dx 방향(화면 안쪽 가로), 90° = dy 방향(위쪽) 인 반시계 회전이다.
|
||
//
|
||
// px → 캔버스 유닛 환산은 코드가 런타임에 캔버스 폭에서 구한다(802b 노트 §2 · 세로에서 1 px = 1.7778 유닛).
|
||
// 환산 계수를 필드로 박지 않는 이유 = 해상도가 바뀌어도 같은 px 규격이 유지되게 하기 위해서다.
|
||
//
|
||
// ■ 「미확인」 · 알려진 규격 충돌 (완료보고 WL-813c §3 에 산술 근거)
|
||
// §B 요소 7 "버튼 전부 우하단 모서리 반경 ≤ 320 px" 은 §B 요소 3 "버튼 지름 132~150 px · 6개" 와
|
||
// 기하학적으로 양립하지 않는다(반경 320 px 사분원 넓이 80,425 px² < 지름 140 px 원 6개 92,363 px²).
|
||
// → thumbReachRadiusPx 는 **목표값**으로 두고 실제 도달 반경은 813m 오버레이가 잰다.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
|
||
namespace WL.UI
|
||
{
|
||
[CreateAssetMenu(fileName = "WLHudLayoutSettings", menuName = "WL/HUD Layout Settings", order = 30)]
|
||
public sealed class WLHudLayoutSettings : ScriptableObject
|
||
{
|
||
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
|
||
public const string ResourcesPath = "WL/WLHudLayoutSettings";
|
||
|
||
private static WLHudLayoutSettings s_instance;
|
||
private static bool s_tried;
|
||
|
||
/// <summary>에셋이 없으면 null 을 돌려준다(호출부는 null 이면 배치를 건너뛴다 = C8 롤백).</summary>
|
||
public static WLHudLayoutSettings Instance
|
||
{
|
||
get
|
||
{
|
||
if (s_instance == null && !s_tried)
|
||
{
|
||
s_tried = true;
|
||
s_instance = Resources.Load<WLHudLayoutSettings>(ResourcesPath);
|
||
}
|
||
return s_instance;
|
||
}
|
||
}
|
||
|
||
/// <summary>에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다.</summary>
|
||
public static void ClearCache() { s_instance = null; s_tried = false; }
|
||
|
||
// ── 기준 해상도 (CLAUDE.md §9 · 세로 1080×1920) ────────────────────────
|
||
[Header("기준 해상도 (px)")]
|
||
[Tooltip("모든 px 값의 기준이 되는 화면 폭")]
|
||
public float designWidthPx = 1080f;
|
||
[Tooltip("모든 px 값의 기준이 되는 화면 높이")]
|
||
public float designHeightPx = 1920f;
|
||
|
||
// ── 전투 패드 공통 ────────────────────────────────────────────────────
|
||
[Header("전투 패드 — 공통")]
|
||
[Tooltip("끄면 WLBattlePadLayout 이 아무 것도 만지지 않는다(원본 배치 유지 · C8 롤백)")]
|
||
public bool battlePadEnabled = true;
|
||
[Tooltip("좌우 미러(왼손잡이) — 패드 원점이 좌하단으로 간다")]
|
||
public bool mirrorLeftHanded = false;
|
||
[Tooltip("활성 스킬 슬롯 수 — 기준서 §F-1 권고 4 (프리팹 배열은 6 그대로 · 나머지는 물약/회피 예약)")]
|
||
public int activeSkillSlots = 4;
|
||
[Tooltip("스킬 버튼 지름 — 기준서 §B 요소3 권고 132~150")]
|
||
public float slotDiameterPx = 140f;
|
||
[Tooltip("버튼 사이 최소 여백 — 기준서 §B 요소3 ≥16 (검증용 · 배치는 아래 각도/반경이 만든다)")]
|
||
public float slotMinGapPx = 16f;
|
||
[Tooltip("엄지 도달 반경 목표 — 기준서 §B 요소7 ≤320. 실제 도달 반경은 813m 오버레이가 잰다")]
|
||
public float thumbReachRadiusPx = 320f;
|
||
|
||
// ── 스킬 부채꼴 (우하단 모서리 원점) ──────────────────────────────────
|
||
[Header("전투 패드 — 스킬 부채꼴")]
|
||
[Tooltip("부채꼴 중심 (모서리 기준 dx, dy)")]
|
||
public Vector2 fanOriginPx = new Vector2(0f, 0f);
|
||
[Tooltip("부채꼴 중심 → 슬롯 중심 거리")]
|
||
public float fanRadiusPx = 420f;
|
||
[Tooltip("첫 슬롯 각도 (0° = 가로 안쪽 · 90° = 위)")]
|
||
public float fanStartAngleDeg = 12.5f;
|
||
[Tooltip("슬롯 사이 각도")]
|
||
public float fanStepAngleDeg = 22f;
|
||
|
||
// ── 예약 슬롯 (물약 · 회피 · 813j/813r) ───────────────────────────────
|
||
[Header("전투 패드 — 예약 슬롯(물약·회피 · 비활성)")]
|
||
[Tooltip("예약 슬롯 지름 — 스킬보다 작게 두어 부채꼴 안쪽에 자리를 만든다")]
|
||
public float reserveDiameterPx = 110f;
|
||
[Tooltip("예약 슬롯 반경 (부채꼴 중심 기준)")]
|
||
public float reserveRadiusPx = 270f;
|
||
[Tooltip("첫 예약 슬롯 각도")]
|
||
public float reserveStartAngleDeg = 25f;
|
||
[Tooltip("예약 슬롯 사이 각도")]
|
||
public float reserveStepAngleDeg = 40f;
|
||
|
||
// ── 공격 · 자동 버튼 ──────────────────────────────────────────────────
|
||
[Header("전투 패드 — 공격 · 자동")]
|
||
[Tooltip("공격 버튼 중심 (모서리 기준)")]
|
||
public Vector2 attackCenterPx = new Vector2(100f, 100f);
|
||
[Tooltip("공격 버튼 지름 — 원본 235 유닛 = 132 px")]
|
||
public float attackDiameterPx = 132f;
|
||
[Tooltip("자동전투 토글 중심 (모서리 기준)")]
|
||
public Vector2 autoButtonCenterPx = new Vector2(56f, 545f);
|
||
[Tooltip("자동전투 토글 지름 — 원본 61 유닛 = 34.3 px")]
|
||
public float autoButtonDiameterPx = 34.3f;
|
||
|
||
// ── 조이스틱 규격 (§B 요소7 · 값만 확정 · 배치는 별도 태스크) ─────────
|
||
[Header("조이스틱 규격 (값만 확정 · 813c 는 이동시키지 않는다)")]
|
||
[Tooltip("좌하단 모서리 기준 조이스틱 중심")]
|
||
public Vector2 joystickCenterPx = new Vector2(180f, 260f);
|
||
[Tooltip("조이스틱 반경")]
|
||
public float joystickRadiusPx = 90f;
|
||
|
||
// ── 상단 보스 HP 바 ───────────────────────────────────────────────────
|
||
[Header("보스 HP 바 (상단)")]
|
||
[Tooltip("끄면 바를 만들지도 켜지도 않는다")]
|
||
public bool bossBarEnabled = true;
|
||
[Tooltip("바 크기 (px)")]
|
||
public Vector2 bossBarSizePx = new Vector2(880f, 28f);
|
||
[Tooltip("Safe Area 상단에서 바 위쪽까지 여백 — 기존 WL_HUD/HUD_Currency(상단 −130.5~−176.1 px 실측) 아래에 둔다")]
|
||
public float bossBarTopMarginPx = 190f;
|
||
[Tooltip("이름 라벨 글자 크기")]
|
||
public float bossBarNameFontPx = 30f;
|
||
[Tooltip("이름 라벨과 바 사이 간격")]
|
||
public float bossBarNameGapPx = 8f;
|
||
[Tooltip("페이즈 눈금 굵기")]
|
||
public float bossBarTickWidthPx = 4f;
|
||
[Tooltip("페이즈 눈금 위치(HP 비율) — 기준서 §B 요소6 임계 100/70/40 중 전환점 2개. 전투 임계값 자체는 813h(Gameplay) 소유")]
|
||
public float[] bossPhaseTicks = new float[] { 0.7f, 0.4f };
|
||
[Tooltip("처치 후 바가 사라지기까지 시간(초)")]
|
||
public float bossBarHideDelaySec = 1.2f;
|
||
[Tooltip("바 채움 색")]
|
||
public Color bossBarFillColor = new Color(0.99f, 0.26f, 0.28f, 1f);
|
||
[Tooltip("바 배경 색")]
|
||
public Color bossBarBgColor = new Color(0.05f, 0.05f, 0.06f, 0.78f);
|
||
[Tooltip("페이즈 눈금 색")]
|
||
public Color bossBarTickColor = new Color(1f, 1f, 1f, 0.85f);
|
||
[Tooltip("이름 라벨 색")]
|
||
public Color bossBarNameColor = new Color(1f, 0.92f, 0.6f, 1f);
|
||
|
||
[Tooltip("바가 켜질 때 이미 살아 있는 보스가 있으면 즉시 물린다(813d 게이트 이전 스폰 = Q3 결함 D-3). 끄면 Spawned 이벤트만 본다.")]
|
||
public bool bossBarBindExisting = true;
|
||
|
||
// ── WL-813c2 (Q6 D-26) — 바가 실제 HP 를 안 따라간다 ───────────────────
|
||
// 이벤트(HitConfirmed)만 보면 ① 물릴 때 MaxHP 가 아직 0 이면(`MyValue.Get_Stat` 은 키가 없으면 0)
|
||
// _maxHp 가 폴백값에 고정돼 Clamp01(869/100)=1.000 = 영원히 만피 ② 리쉬 회복(BossArena.ResetBoss)·
|
||
// 리젠처럼 Actor.Get_Damage 를 안 타는 HP 변화가 바에 안 보인다. → 물고 있는 보스를 직접 폴링한다.
|
||
[Header("보스 HP 바 — 실제 HP 추종 (WL-813c2 · Q6 D-26)")]
|
||
[Tooltip("끄면 폴링 0 = 813q2 상태(이벤트 갱신만) 그대로 = C8 롤백.")]
|
||
public bool bossBarPollEnabled = true;
|
||
|
||
[Tooltip("초당 폴링 횟수. 0 이면 매 프레임. 발주서 §1-1 = 매 프레임 또는 0.1 s.")]
|
||
public float bossBarPollHz = 10f;
|
||
|
||
[Tooltip("폴링 때 813d BossArena.Boss(이번 아레나가 잡은 보스)가 물고 있는 것과 다르면 그쪽으로 갈아탄다 " +
|
||
"— 늦은 구독 씬 스캔이 엉뚱한 필드 보스를 물었을 때의 복구 경로.")]
|
||
public bool bossBarPollFollowsArena = true;
|
||
|
||
[Tooltip("물고 있는 보스가 죽으면 바를 0 으로 내리고 bossBarHideDelaySec 뒤 숨긴다(Killed 이벤트를 못 받아도).")]
|
||
public bool bossBarPollHidesOnDeath = true;
|
||
|
||
[Tooltip("「다음 런」 리셋 뒤 재바인드를 **이번 런의 보스가 실제로 등장했을 때만** 한다(Q6 D-23 잔여). " +
|
||
"🔴 BossArena.ResetGate() 는 s_boss 를 비우지 않아, 끄면 이전 런의 살아 있는 보스를 다시 물어 바가 남은 것처럼 보인다.")]
|
||
public bool bossBarRebindRequiresArenaSpawn = true;
|
||
|
||
|
||
[Header("전투 중 하단 메뉴 숨김 · 패드 원점 복귀 (WL-813y · Q3 결함 D-5)")]
|
||
[Tooltip("끄면 하단 메뉴를 건드리지 않고 패드도 항상 idle 원점(아래 값)을 쓴다 — 813c+Lead 핫픽스 상태로 복귀(C8 롤백).")]
|
||
public bool combatHideBottomMenu = true;
|
||
|
||
[Tooltip("숨길 하단 메뉴 노드의 루트 기준 경로(813y 실측 = IngameUIs/WL_HUD/HUD_BottomMenu).")]
|
||
public string bottomMenuPath = "IngameUIs/WL_HUD/HUD_BottomMenu";
|
||
|
||
[Tooltip("SetActive 대신 CanvasGroup 으로 숨긴다(하위 스크립트의 Update 가 계속 돌아야 하는 경우 · #796 선례).")]
|
||
public bool bottomMenuUseCanvasGroup = true;
|
||
|
||
[Tooltip("숨김/복귀 페이드 시간(초 · unscaled). 0 이면 즉시.")]
|
||
public float bottomMenuFadeSeconds = 0.18f;
|
||
|
||
[Tooltip("마지막 전투 신호(적 인지 · 피격 · 공격 · 스킬 · 처치) 뒤 이 시간이 지나면 메뉴가 돌아온다.")]
|
||
public float combatExitSeconds = 6f;
|
||
|
||
[Tooltip("전투 신호가 들어오고 이 시간이 지나야 숨긴다(0 = 즉시).")]
|
||
public float combatEnterDelaySeconds = 0f;
|
||
|
||
[Tooltip("현재 타깃이 살아 있으면 전투로 본다(적 인지). 끄면 공격/피격/스킬/처치 이벤트만 본다.")]
|
||
public bool combatUseTargetAsSignal = true;
|
||
|
||
[Tooltip("메뉴가 숨은 동안 되돌릴 버튼 1개를 만든다(런타임 생성 · 프리팹 diff 0).")]
|
||
public bool bottomMenuRestoreButton = true;
|
||
|
||
[Tooltip("복귀 버튼 중심 — x = 화면 가로 중앙 기준(양수 = 오른쪽) · y = 화면 아래 기준(px).")]
|
||
public Vector2 restoreButtonCenterPx = new Vector2(-380f, 62f);
|
||
|
||
[Tooltip("복귀 버튼 지름(px).")]
|
||
public float restoreButtonDiameterPx = 88f;
|
||
|
||
[Tooltip("복귀 버튼 글자(TMP · 아트 에셋 의존 0).")]
|
||
public string restoreButtonLabel = "≡";
|
||
|
||
[Tooltip("복귀 버튼 글자 크기(px).")]
|
||
public float restoreButtonFontPx = 46f;
|
||
|
||
[Tooltip("복귀 버튼 배경 색.")]
|
||
public Color restoreButtonBgColor = new Color(0.06f, 0.07f, 0.10f, 0.72f);
|
||
|
||
[Tooltip("복귀 버튼 글자 색.")]
|
||
public Color restoreButtonLabelColor = new Color(0.96f, 0.94f, 0.82f, 1f);
|
||
|
||
[Tooltip("복귀 버튼으로 메뉴를 되돌린 뒤, 이 시간이 지나면 다시 전투 판정을 따른다(초).")]
|
||
public float restoreHoldSeconds = 8f;
|
||
|
||
[Tooltip("하단 메뉴가 **보이는 동안** 쓰는 부채꼴 중심 — 메뉴(상단 y ≈ 163 px)를 피해 위로 올린 813c+Lead 핫픽스 값.")]
|
||
public Vector2 idleFanOriginPx = new Vector2(0f, 260f);
|
||
|
||
[Tooltip("하단 메뉴가 **보이는 동안** 쓰는 공격 버튼 중심.")]
|
||
public Vector2 idleAttackCenterPx = new Vector2(100f, 360f);
|
||
|
||
// ── 유틸 ──────────────────────────────────────────────────────────────
|
||
/// <summary>
|
||
/// px → 캔버스 유닛 환산 계수.
|
||
///
|
||
/// 1순위 = CanvasScaler 산식 재현. 기준 해상도(designWidth/HeightPx)에서 이 캔버스가 갖는 폭(유닛)을
|
||
/// 계산한다. 세로에서는 폭이 해상도와 무관하게 고정이라(1080×1920·Expand·참조 1920×1080 → 1920 유닛)
|
||
/// px 는 진짜 "설계 단위"가 되고, 에디트 모드(레이아웃 미실행)에서도 같은 값이 나온다.
|
||
/// 2순위 = 실제 캔버스 rect 실측(스케일러가 없거나 고정 픽셀 모드일 때).
|
||
/// 계수를 코드 상수로 박지 않는다 — 스케일러 설정이 바뀌면 따라간다(802b 노트: 세로에서 1.7778).
|
||
/// </summary>
|
||
public float UnitsPerPx(Canvas canvas)
|
||
{
|
||
if (designWidthPx <= 0f || designHeightPx <= 0f) return 1f;
|
||
|
||
var scaler = canvas != null ? canvas.GetComponent<CanvasScaler>() : null;
|
||
if (scaler != null && scaler.uiScaleMode == CanvasScaler.ScaleMode.ScaleWithScreenSize)
|
||
{
|
||
Vector2 r = scaler.referenceResolution;
|
||
if (r.x > 0f && r.y > 0f)
|
||
{
|
||
float w = designWidthPx, h = designHeightPx, scale;
|
||
switch (scaler.screenMatchMode)
|
||
{
|
||
case CanvasScaler.ScreenMatchMode.Expand: scale = Mathf.Min(w / r.x, h / r.y); break;
|
||
case CanvasScaler.ScreenMatchMode.Shrink: scale = Mathf.Max(w / r.x, h / r.y); break;
|
||
default: scale = Mathf.Pow(w / r.x, 1f - scaler.matchWidthOrHeight) *
|
||
Mathf.Pow(h / r.y, scaler.matchWidthOrHeight); break;
|
||
}
|
||
if (scale > 0f) return (w / scale) / designWidthPx;
|
||
}
|
||
}
|
||
|
||
var canvasRect = canvas != null ? canvas.transform as RectTransform : null;
|
||
if (canvasRect != null && canvasRect.rect.width > 0f) return canvasRect.rect.width / designWidthPx;
|
||
return 1f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 지금 써야 할 부채꼴 중심. 하단 메뉴가 **보이는 동안**은 메뉴를 피해 올린 자리(idleFanOriginPx),
|
||
/// 전투 중(메뉴 숨김)은 모서리 원점(fanOriginPx) — WL-813y §1-6.
|
||
/// </summary>
|
||
public Vector2 FanOriginPxFor(bool bottomMenuVisible)
|
||
{
|
||
// C8 롤백: 기능을 끄면 하단 메뉴가 **늘 보이므로** 메뉴를 피한 자리(idle) 를 써야 한다
|
||
// = 813c + Lead 핫픽스 상태 그대로. (모서리 원점을 쓰면 메뉴와 다시 겹친다 · Q3 결함 D-5)
|
||
if (!combatHideBottomMenu) return idleFanOriginPx;
|
||
return bottomMenuVisible ? idleFanOriginPx : fanOriginPx;
|
||
}
|
||
|
||
/// <summary>지금 써야 할 공격 버튼 중심(위와 같은 규칙).</summary>
|
||
public Vector2 AttackCenterPxFor(bool bottomMenuVisible)
|
||
{
|
||
if (!combatHideBottomMenu) return idleAttackCenterPx;
|
||
return bottomMenuVisible ? idleAttackCenterPx : attackCenterPx;
|
||
}
|
||
|
||
/// <summary>부채꼴 각도 → 모서리 기준 (dx, dy) px.</summary>
|
||
public Vector2 FanSlotPx(int index) { return FanSlotPx(index, HudCombatVisibility.BottomMenuVisible); }
|
||
|
||
public Vector2 FanSlotPx(int index, bool bottomMenuVisible)
|
||
{
|
||
float deg = fanStartAngleDeg + fanStepAngleDeg * index;
|
||
float rad = deg * Mathf.Deg2Rad;
|
||
return FanOriginPxFor(bottomMenuVisible) + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * fanRadiusPx;
|
||
}
|
||
|
||
/// <summary>예약(물약·회피) 슬롯 각도 → 모서리 기준 (dx, dy) px.</summary>
|
||
public Vector2 ReserveSlotPx(int index) { return ReserveSlotPx(index, HudCombatVisibility.BottomMenuVisible); }
|
||
|
||
public Vector2 ReserveSlotPx(int index, bool bottomMenuVisible)
|
||
{
|
||
float deg = reserveStartAngleDeg + reserveStepAngleDeg * index;
|
||
float rad = deg * Mathf.Deg2Rad;
|
||
return FanOriginPxFor(bottomMenuVisible) + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * reserveRadiusPx;
|
||
}
|
||
}
|
||
}
|