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

368 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
}
}