368 lines
19 KiB
C#
368 lines
19 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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);
|
||
}
|
||
}
|
||
}
|