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