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

368 lines
19 KiB
C#
Raw Normal View History

[WL-813y] UI 핫픽스 묶음 (#813) Q3 `qa/W2-3_바퀴2.md` 결함 4·6·7·8 + 813tj 연결점 6줄 + 813k LevelUp 페이로드 + 813m 도달 747 px 대응. 재부팅으로 끊긴 이전 워커의 미커밋 작업을 인수해 이어서 완성했다. 1. 813i 연결 6줄 — WLSurvivalUiBridge(신규): PotionButton Provider 4 + ReviveDialog 2. Gameplay 소유 813i API 를 UI 쪽에서 호출만 한다(Assets/WL/Combat/** 수정 0). 2. 스탯 팝업 — StatPopup 이 813k GrowthEvents.LevelUp 을 구독(811b 코어 축과 택일 = 이중 팝업 0). 스탯 변화 0 이면 "+5 포인트(보유 N)" 줄로 떨어진다(813k 보고 ①). 3. 토스트 가시성 — 지속 1.2 → 2.0 s · 글자 40 → 52 px · 줄 뒤 반투명 패널 · 등급 1 회색(#a6a6a7) 밝기 보정(#F2F5FA) · 중심 오프셋 +120 → -120 px(스탯 +180 · 연쇄 +520 대역 회피). 4. 텍스트 아웃라인 — uGUI Outline/Shadow 는 TMP 에 안 먹으므로 TMP 머티리얼 아웃라인 + 언더레이 그림자를 쓴다. (원본 머티리얼 + 값) 조합마다 1장 캐시 공유 = 라벨별 인스턴스 0. 5. 보스 HP 바·배너 늦은 구독 보정 — 켜질 때 BossArena.Boss → 없으면 MobActor 1회 스캔. 6. 전투 중 하단 메뉴 숨김 + 패드 원점 복귀 — HudCombatVisibility(신규 · 노드는 런타임 생성, 프리팹 diff 0) · fanOriginPx (0,260) → (0,0) · attackCenterPx (100,360) → (100,100). 엄지 도달 실측 746.8 → **490.0 px**(발주서 ≤ 490 PASS). 수정 중 실측으로 잡은 버그 2건: UnityEngine.Object 가짜 null 을 `??` 가 못 걸러 HUD_BottomMenu 에 CanvasGroup 이 붙지 않던 MissingComponentException · C8 롤백 (combatHideBottomMenu=false)이 모서리 원점을 돌려줘 메뉴와 다시 겹치던 문제. 값은 전부 SO(C8/C45) · 프리팹 0 · Gameplay/Systems 경로 0 · 핫스팟 0 · Play 0.
2026-09-08 22:58:46 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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;
/// <summary>하단 메뉴가 지금 보이는가 — 전투 패드 원점(WLHudLayoutSettings)이 이 값을 본다.</summary>
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);
}
/// <summary>전투 신호 1건(프로브도 이 경로를 쓴다).</summary>
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");
}
/// <summary>현재 타깃이 살아 있으면 전투로 본다(적 인지).</summary>
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<CanvasGroup>();
s_menuGroup = cg != null ? cg : s_menu.gameObject.AddComponent<CanvasGroup>();
}
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);
}
}
/// <summary>메뉴 표시 상태를 바꾸고 전투 패드를 다시 앉힌다.</summary>
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;
}
/// <summary>패드 재배치 — 원점이 메뉴 상태에 따라 바뀐다(WLHudLayoutSettings.FanOriginPxFor).</summary>
public static string ReapplyPad()
{
var pads = Object.FindObjectsByType<WLBattlePadLayout>(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();
}
// ── 노드 연결 · 복귀 버튼(런타임 생성) ────────────────────────────────
/// <summary>NewGameUI 루트에서 하단 메뉴를 찾고 복귀 버튼을 만든다. 이미 물려 있으면 그대로.</summary>
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<Image>();
s_restoreBg.raycastTarget = true;
var b = go.AddComponent<Button>();
b.targetGraphic = s_restoreBg;
b.onClick.AddListener(() => OnRestoreClicked()); // UnityAction = void() — 반환값 있는 메서드는 람다로 감싼다
s_restoreLabel = WLTextFxUtil.NewText(s_restore, "Label", null, TextAlignmentOptions.Center);
WLTextFxUtil.Stretch(s_restoreLabel.rectTransform);
}
float u = WLVignetteUtil.UnitsPerPx(parent.GetComponentInParent<Canvas>());
s_restore.anchorMin = s_restore.anchorMax = new Vector2(0.5f, 0f);
s_restore.pivot = new Vector2(0.5f, 0.5f);
s_restore.anchoredPosition = new Vector2(s.restoreButtonCenterPx.x * u, s.restoreButtonCenterPx.y * u);
s_restore.sizeDelta = new Vector2(s.restoreButtonDiameterPx * u, s.restoreButtonDiameterPx * u);
s_restore.localScale = Vector3.one;
s_restoreBg.color = s.restoreButtonBgColor;
s_restoreLabel.text = s.restoreButtonLabel;
s_restoreLabel.color = s.restoreButtonLabelColor;
s_restoreLabel.fontSize = s.restoreButtonFontPx * u;
return "복귀 버튼 pos" + s_restore.anchoredPosition + " size" + s_restore.sizeDelta +
" (px " + s.restoreButtonCenterPx + " 지름 " + s.restoreButtonDiameterPx + " u=" + u.ToString("F4") + ")";
}
private static void SetRestoreVisible(bool on)
{
var s = St;
if (s_restore == null) return;
bool want = on && s != null && s.bottomMenuRestoreButton;
if (s_restore.gameObject.activeSelf != want) s_restore.gameObject.SetActive(want);
}
/// <summary>복귀 버튼 클릭 — 메뉴를 되돌리고 잠시 전투 판정을 무시한다.</summary>
public static string OnRestoreClicked()
{
var s = St;
RestoreClickCount++;
float now = Time.unscaledTime;
s_restoreHeldUntil = now + (s != null ? Mathf.Max(0f, s.restoreHoldSeconds) : 0f);
return SetMenuVisible(true, now, false) + " · hold " + (s != null ? s.restoreHoldSeconds : 0f) + "s";
}
private static void DestroyRestore()
{
if (s_restore == null) return;
if (Application.isPlaying) Object.Destroy(s_restore.gameObject); else Object.DestroyImmediate(s_restore.gameObject);
s_restore = null; s_restoreLabel = null; s_restoreBg = null;
}
// ── 프로브 · 진단 ─────────────────────────────────────────────────────
/// <summary>상태를 초기값으로(에디트 모드 반복 실행 대비 · 노드는 남긴다).</summary>
public static string ResetState()
{
InCombat = false;
s_lastCombatAt = -9999f; s_firstSignalAt = -9999f; s_restoreHeldUntil = -9999f;
HideCount = ShowCount = RestoreClickCount = SignalCount = 0;
LastSignal = "";
s_menuVisible = true; s_alpha = 1f;
var s = St; if (s != null) PushAlpha(s);
SetRestoreVisible(false);
return "상태 초기화(메뉴 보임 · 패드 idle)";
}
/// <summary>연결·노드까지 되돌린다(프로브 종료용).</summary>
public static string Teardown()
{
Subscribe(false);
ResetState();
DestroyRestore();
s_menu = null; s_menuGroup = null; s_uiRoot = null;
return "teardown 완료";
}
public static string Dump()
{
var s = St;
var sb = new StringBuilder();
sb.AppendLine("[HudCombatVisibility] 구독=" + s_subscribed + " inCombat=" + InCombat +
" menuVisible=" + s_menuVisible + " alpha=" + s_alpha.ToString("F2") +
" hide=" + HideCount + " show=" + ShowCount + " restoreClick=" + RestoreClickCount +
" signals=" + SignalCount + "(last=" + LastSignal + ")");
sb.AppendLine(" 메뉴 = " + (s_menu == null ? "미연결" :
s_menu.name + " active=" + s_menu.gameObject.activeSelf + " 자식=" + s_menu.childCount +
" group=" + (s_menuGroup != null ? s_menuGroup.alpha.ToString("F2") : "-")));
sb.AppendLine(" 복귀버튼 = " + (s_restore == null ? "없음" :
"active=" + s_restore.gameObject.activeSelf + " pos" + s_restore.anchoredPosition +
" size" + s_restore.sizeDelta + " text=\"" + (s_restoreLabel != null ? s_restoreLabel.text : "") + "\""));
if (s != null)
sb.AppendLine(" 설정 enabled=" + s.combatHideBottomMenu + " exit=" + s.combatExitSeconds + "s enter=" +
s.combatEnterDelaySeconds + "s fade=" + s.bottomMenuFadeSeconds + "s canvasGroup=" +
s.bottomMenuUseCanvasGroup + " target신호=" + s.combatUseTargetAsSignal +
" · 원점 전투" + s.fanOriginPx + " idle" + s.idleFanOriginPx +
" 공격 전투" + s.attackCenterPx + " idle" + s.idleAttackCenterPx);
return sb.ToString();
}
}
/// <summary>매 프레임 <see cref="HudCombatVisibility.Tick"/> 을 부르는 런타임 전용 러너(씬에 굽지 않는다).</summary>
internal sealed class HudCombatVisibilityRunner : MonoBehaviour
{
private static HudCombatVisibilityRunner s_ins;
private NewGameUI _lastUi;
internal static void Ensure()
{
if (s_ins != null) return;
var go = new GameObject("WL_HudCombatVisibilityRunner");
go.hideFlags = HideFlags.HideAndDontSave;
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<HudCombatVisibilityRunner>();
}
private void Update()
{
var ui = NewGameUI.Ins;
if (ui != null && !ReferenceEquals(ui, _lastUi))
{
_lastUi = ui;
HudCombatVisibility.Bind(ui.transform);
}
HudCombatVisibility.Tick(Time.unscaledTime);
}
}
}