// ───────────────────────────────────────────────────────────────────────────── // HudCombatVisibility.cs — 전투 중 하단 메뉴 숨김 + 전투 패드 원점 복귀 (WL-813y · #813) // // 근거 = Q3 `qa/W2-3_바퀴2.md` 결함 D-5: // Lead 핫픽스가 하단 메뉴(`IngameUIs/WL_HUD/HUD_BottomMenu`) 겹침을 피하려고 // `fanOriginPx.y 0 → 260` 을 넣어 **엄지 도달이 565 → 746.8 px 로 더 나빠졌다**(목표 320). // // ■ 해법 = 겹칠 상대를 전투 중에는 치운다 // · 전투 신호(적 인지 · 피격 · 공격 · 스킬 · 처치)가 오면 하단 메뉴를 접고(CanvasGroup α 0) // 전투 패드를 **모서리 원점**(`fanOriginPx` = 에셋 0,0 / `attackCenterPx` = 100,100)으로 되돌린다. // · 마지막 신호 뒤 `combatExitSeconds` 가 지나면 메뉴가 돌아오고 패드는 idle 자리로 올라간다. // · 숨은 동안 **복귀 버튼 1개**(런타임 생성)를 띄워 언제든 메뉴를 되돌릴 수 있다. // // ■ 프리팹 diff 0 // 노드를 굽지 않는다 — 러너도 복귀 버튼도 **런타임 생성**(`HideFlags.DontSave`). // `NewGameUI.prefab` 은 이 기능 때문에 1줄도 바뀌지 않는다(813c/813g/813tj 와 다른 점). // // ■ 값 = WLHudLayoutSettings.asset (C45 · 코드 상수 0) · C8 롤백 = `combatHideBottomMenu = 0` // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using System.Text; using TMPro; using UnityEngine; using UnityEngine.UI; using WL.Combat.Core; namespace WL.UI { public static class HudCombatVisibility { // ── 상태 ────────────────────────────────────────────────────────────── private static bool s_subscribed; private static float s_lastCombatAt = -9999f; private static float s_firstSignalAt = -9999f; private static float s_restoreHeldUntil = -9999f; private static bool s_menuVisible = true; // 초기값 = 원본(보임) private static float s_alpha = 1f; private static Transform s_menu; private static CanvasGroup s_menuGroup; private static Transform s_uiRoot; private static RectTransform s_restore; private static TextMeshProUGUI s_restoreLabel; private static Image s_restoreBg; /// 하단 메뉴가 지금 보이는가 — 전투 패드 원점(WLHudLayoutSettings)이 이 값을 본다. public static bool BottomMenuVisible { get { return s_menuVisible; } } public static bool InCombat { get; private set; } public static bool Subscribed { get { return s_subscribed; } } public static float LastCombatAt { get { return s_lastCombatAt; } } public static int HideCount, ShowCount, RestoreClickCount, SignalCount; public static string LastSignal = ""; public static bool Bound { get { return s_menu != null; } } public static float MenuAlpha { get { return s_alpha; } } public static bool RestoreButtonVisible { get { return s_restore != null && s_restore.gameObject.activeSelf; } } private static WLHudLayoutSettings St { get { return WLHudLayoutSettings.Instance; } } // ── 부팅 (노드 0 · 프리팹 diff 0) ───────────────────────────────────── [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Boot() { var s = St; if (s == null || !s.combatHideBottomMenu) return; Subscribe(true); HudCombatVisibilityRunner.Ensure(); } // ── 전투 신호 구독 ──────────────────────────────────────────────────── public static string Subscribe(bool on) { if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")"; if (on) { CombatEvents.AttackStarted.Add(OnAttack); CombatEvents.SkillCast.Add(OnSkill); CombatEvents.Damaged.Add(OnDamaged); CombatEvents.Killed.Add(OnKilled); } else { CombatEvents.AttackStarted.Remove(OnAttack); CombatEvents.SkillCast.Remove(OnSkill); CombatEvents.Damaged.Remove(OnDamaged); CombatEvents.Killed.Remove(OnKilled); } s_subscribed = on; return "구독=" + on + " (AttackStarted/SkillCast/Damaged/Killed)"; } private static void OnAttack(in AttackStartedEvent e) { if (e.isMainPC) Signal("attack"); } private static void OnSkill(in SkillCastEvent e) { if (IsMine(e.actor)) Signal("skill"); } private static void OnDamaged(in DamagedEvent e) { if (IsMine(e.victim) || IsMine(e.attacker)) Signal("damaged"); } private static void OnKilled(in KilledEvent e) { if (IsMine(e.killer)) Signal("killed"); } private static bool IsMine(Actor a) { if (a == null) return false; if (!MyValue.bMyPC) return false; return ReferenceEquals(a, MyValue.MyPC); } /// 전투 신호 1건(프로브도 이 경로를 쓴다). public static string Signal(string reason) { float now = Time.unscaledTime; if (s_lastCombatAt < 0f || now - s_lastCombatAt > 0.5f) s_firstSignalAt = now; s_lastCombatAt = now; SignalCount++; LastSignal = reason; return "signal=" + reason + " t=" + now.ToString("F2"); } /// 현재 타깃이 살아 있으면 전투로 본다(적 인지). private static bool HasLiveTarget() { var s = St; if (s == null || !s.combatUseTargetAsSignal) return false; if (!MyValue.bMyPC || MyValue.MyPC == null) return false; var t = MyValue.MyPC.Get_Target(); return t != null && !t.IsDead(); } // ── 매 프레임 판정 (러너가 부른다 · 프로브는 now 를 직접 준다) ──────── public static string Tick(float now) { var s = St; if (s == null) return "WLHudLayoutSettings 에셋 없음"; if (!s.combatHideBottomMenu) { if (!s_menuVisible) SetMenuVisible(true, now, true); return "combatHideBottomMenu=false — 무동작"; } if (HasLiveTarget()) { s_lastCombatAt = now; if (s_firstSignalAt < 0f) s_firstSignalAt = now; LastSignal = "target"; } float since = now - s_lastCombatAt; bool combat = since <= Mathf.Max(0f, s.combatExitSeconds) && (now - s_firstSignalAt) >= Mathf.Max(0f, s.combatEnterDelaySeconds); InCombat = combat; bool wantVisible = !combat; if (now < s_restoreHeldUntil) wantVisible = true; // 복귀 버튼을 누른 직후에는 전투여도 보인다 if (wantVisible != s_menuVisible) SetMenuVisible(wantVisible, now, false); StepFade(s, now); return "combat=" + combat + " since=" + since.ToString("F2") + "s menuVisible=" + s_menuVisible + " alpha=" + s_alpha.ToString("F2"); } private static float s_fadeFrom, s_fadeAt; private static void StepFade(WLHudLayoutSettings s, float now) { float want = s_menuVisible ? 1f : 0f; float dur = Mathf.Max(0f, s.bottomMenuFadeSeconds); float a = dur <= 0f ? want : Mathf.Lerp(s_fadeFrom, want, Mathf.Clamp01((now - s_fadeAt) / dur)); if (!Mathf.Approximately(a, s_alpha)) { s_alpha = a; PushAlpha(s); } } private static void PushAlpha(WLHudLayoutSettings s) { if (s_menu == null) return; if (s.bottomMenuUseCanvasGroup) { // 🔴 `??` 는 UnityEngine.Object 의 "가짜 null"(파괴됨 · 컴포넌트 없음)을 걸러 내지 못한다 — // GetComponent 결과는 반드시 오버로드된 `==` 로 검사한다(813y 프로브가 잡은 MissingComponentException). if (s_menuGroup == null) { var cg = s_menu.GetComponent(); s_menuGroup = cg != null ? cg : s_menu.gameObject.AddComponent(); } s_menuGroup.alpha = s_alpha; s_menuGroup.blocksRaycasts = s_alpha > 0.05f; s_menuGroup.interactable = s_alpha > 0.05f; } else { bool on = s_alpha > 0.05f; if (s_menu.gameObject.activeSelf != on) s_menu.gameObject.SetActive(on); } } /// 메뉴 표시 상태를 바꾸고 전투 패드를 다시 앉힌다. public static string SetMenuVisible(bool visible, float now, bool instant) { var s = St; s_fadeFrom = s_alpha; s_fadeAt = now; s_menuVisible = visible; if (visible) ShowCount++; else HideCount++; if (instant || s == null || s.bottomMenuFadeSeconds <= 0f) { s_alpha = visible ? 1f : 0f; if (s != null) PushAlpha(s); } SetRestoreVisible(!visible); string pad = ReapplyPad(); return "menuVisible=" + visible + " · " + pad; } /// 패드 재배치 — 원점이 메뉴 상태에 따라 바뀐다(WLHudLayoutSettings.FanOriginPxFor). public static string ReapplyPad() { var pads = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); if (pads == null || pads.Length == 0) return "WLBattlePadLayout 없음(패드 재배치 생략)"; var sb = new StringBuilder(); for (int i = 0; i < pads.Length; i++) sb.Append(i > 0 ? " | " : "").Append(pads[i].Apply()); return sb.ToString(); } // ── 노드 연결 · 복귀 버튼(런타임 생성) ──────────────────────────────── /// NewGameUI 루트에서 하단 메뉴를 찾고 복귀 버튼을 만든다. 이미 물려 있으면 그대로. public static string Bind(Transform uiRoot) { var s = St; if (s == null) return "WLHudLayoutSettings 에셋 없음"; if (uiRoot == null) return "uiRoot=null"; if (s_menu != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_menu.name; s_uiRoot = uiRoot; s_menu = WLVignetteUtil.FindUiPath(uiRoot, s.bottomMenuPath); s_menuGroup = null; if (s_menu == null) return "하단 메뉴 없음 — 경로 \"" + s.bottomMenuPath + "\""; s_alpha = s_menuVisible ? 1f : 0f; PushAlpha(s); string btn = BuildRestore(s); SetRestoreVisible(!s_menuVisible); return "연결 " + s.bottomMenuPath + " (자식 " + s_menu.childCount + ") · " + btn; } private static string BuildRestore(WLHudLayoutSettings s) { if (!s.bottomMenuRestoreButton) { DestroyRestore(); return "복귀 버튼 off"; } if (s_menu == null) return "복귀 버튼 — 부모 없음"; var parent = s_menu.parent as RectTransform; // WL_HUD (SafeAreaFitter 아래 = Safe Area 안) if (parent == null) return "복귀 버튼 — 부모가 RectTransform 이 아님"; if (s_restore == null) { var go = new GameObject("WL_BottomMenuRestore", typeof(RectTransform)); go.layer = WLTextFxUtil.UILayer; go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다 s_restore = (RectTransform)go.transform; s_restore.SetParent(parent, false); s_restoreBg = go.AddComponent(); s_restoreBg.raycastTarget = true; var b = go.AddComponent