// ───────────────────────────────────────────────────────────────────────────── // 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 { /// Resources 경로 — 런타임 로드 키. public const string ResourcesPath = "WL/WLHudLayoutSettings"; private static WLHudLayoutSettings s_instance; private static bool s_tried; /// 에셋이 없으면 null 을 돌려준다(호출부는 null 이면 배치를 건너뛴다 = C8 롤백). public static WLHudLayoutSettings Instance { get { if (s_instance == null && !s_tried) { s_tried = true; s_instance = Resources.Load(ResourcesPath); } return s_instance; } } /// 에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다. 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); // ── 유틸 ────────────────────────────────────────────────────────────── /// /// px → 캔버스 유닛 환산 계수. /// /// 1순위 = CanvasScaler 산식 재현. 기준 해상도(designWidth/HeightPx)에서 이 캔버스가 갖는 폭(유닛)을 /// 계산한다. 세로에서는 폭이 해상도와 무관하게 고정이라(1080×1920·Expand·참조 1920×1080 → 1920 유닛) /// px 는 진짜 "설계 단위"가 되고, 에디트 모드(레이아웃 미실행)에서도 같은 값이 나온다. /// 2순위 = 실제 캔버스 rect 실측(스케일러가 없거나 고정 픽셀 모드일 때). /// 계수를 코드 상수로 박지 않는다 — 스케일러 설정이 바뀌면 따라간다(802b 노트: 세로에서 1.7778). /// public float UnitsPerPx(Canvas canvas) { if (designWidthPx <= 0f || designHeightPx <= 0f) return 1f; var scaler = canvas != null ? canvas.GetComponent() : 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; } /// /// 지금 써야 할 부채꼴 중심. 하단 메뉴가 **보이는 동안**은 메뉴를 피해 올린 자리(idleFanOriginPx), /// 전투 중(메뉴 숨김)은 모서리 원점(fanOriginPx) — WL-813y §1-6. /// public Vector2 FanOriginPxFor(bool bottomMenuVisible) { // C8 롤백: 기능을 끄면 하단 메뉴가 **늘 보이므로** 메뉴를 피한 자리(idle) 를 써야 한다 // = 813c + Lead 핫픽스 상태 그대로. (모서리 원점을 쓰면 메뉴와 다시 겹친다 · Q3 결함 D-5) if (!combatHideBottomMenu) return idleFanOriginPx; return bottomMenuVisible ? idleFanOriginPx : fanOriginPx; } /// 지금 써야 할 공격 버튼 중심(위와 같은 규칙). public Vector2 AttackCenterPxFor(bool bottomMenuVisible) { if (!combatHideBottomMenu) return idleAttackCenterPx; return bottomMenuVisible ? idleAttackCenterPx : attackCenterPx; } /// 부채꼴 각도 → 모서리 기준 (dx, dy) px. 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; } /// 예약(물약·회피) 슬롯 각도 → 모서리 기준 (dx, dy) px. 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; } } }