Files
zeling_v2/Assets/_Game/Scripts/UI/UIManager.cs
2026-05-25 11:54:37 +08:00

218 lines
9.7 KiB
C#
Raw 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.
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 面板 ID 枚举。新增面板时只需在此添加值并在 Inspector 的 _panels 数组中注册,
/// 无需修改 UIManager 的其他代码,满足开闭原则。
/// </summary>
public enum PanelId
{
Pause,
Settings,
Map,
Shop,
CharmPanel,
SpellSelect,
}
[DefaultExecutionOrder(+50)]
public class UIManager : MonoBehaviour, IUIManager
{
// ── 状态驱动根节点(不进入面板栈,仅根据 GameState 显示/隐藏)────────
[Header("状态驱动根节点(非面板栈)")]
[SerializeField] private GameObject _hudRoot;
[SerializeField] private GameObject _deathScreenRoot;
// ── 面板栈注册表 ──────────────────────────────────────────────────────
[Header("面板栈注册表Inspector 配置,可运行时扩展)")]
[Tooltip("将 PanelId 与对应的根 GameObject 绑定。" +
"新增面板只需在此添加一行,无需修改 UIManager 代码。")]
[SerializeField] private PanelRegistration[] _panels;
[Header("Event Channels")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
[SerializeField] private VoidEventChannelSO _onPauseRequested;
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
[SerializeField] private StringEventChannelSO _onShopOpen;
[SerializeField] private VoidEventChannelSO _onMapOpen;
[SerializeField] private VoidEventChannelSO _onCharmPanelOpen;
[SerializeField] private VoidEventChannelSO _onSpellSelectOpen;
// ── 面板栈结构 ────────────────────────────────────────────────────────
private readonly Stack<GameObject> _panelStack = new();
/// <summary>O(1) 成员判断,与 _panelStack 保持同步,替代 Stack.Contains O(n)。</summary>
private readonly HashSet<GameObject> _openPanelSet = new();
private readonly Dictionary<PanelId, GameObject> _panelRegistry = new();
private readonly CompositeDisposable _subs = new();
// ── 序列化辅助结构 ────────────────────────────────────────────────────
[System.Serializable]
private struct PanelRegistration
{
[Tooltip("面板标识符(与代码中的 PanelId 枚举对应)。")]
public PanelId id;
[Tooltip("该面板的根 GameObject通常是 Canvas 的直接子节点)。")]
public GameObject root;
}
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_panels != null)
foreach (var p in _panels)
if (p.root != null) _panelRegistry[p.id] = p.root;
}
private void OnEnable()
{
ServiceLocator.Register<IUIManager>(this);
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
_onPauseRequested?.Subscribe(TogglePause).AddTo(_subs);
_onFastTravelOpen?.Subscribe(OpenMap).AddTo(_subs);
_onShopOpen?.Subscribe(OpenShop).AddTo(_subs);
_onMapOpen?.Subscribe(OpenMap).AddTo(_subs);
_onCharmPanelOpen?.Subscribe(OpenCharmPanel).AddTo(_subs);
_onSpellSelectOpen?.Subscribe(OpenSpellSelect).AddTo(_subs);
}
private void OnDisable()
{
ServiceLocator.Unregister<IUIManager>(this);
_subs.Clear();
}
// ── 面板注册(运行时动态扩展入口)────────────────────────────────────
/// <summary>
/// 运行时注册或覆盖面板绑定(如场景加载后动态添加的面板)。
/// Inspector 中已配置的面板无需调用此方法。
/// </summary>
public void RegisterPanel(PanelId id, GameObject root)
{
if (root != null) _panelRegistry[id] = root;
}
// ── 状态响应 ──────────────────────────────────────────────────────────
private void HandleGameStateChanged(GameStateId state)
{
bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
if (_hudRoot != null) _hudRoot.SetActive(showHud);
if (state == GameStates.Dead)
{
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(true);
}
else
{
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(false);
if (state == GameStates.Cutscene)
if (_hudRoot != null) _hudRoot.SetActive(false);
}
}
// ── 面板栈 API ────────────────────────────────────────────────────────
/// <summary>通过 ID 打开已注册的面板。</summary>
public void OpenPanel(PanelId id)
{
if (_panelRegistry.TryGetValue(id, out var panel))
OpenPanel(panel);
}
/// <summary>打开指定 GameObject 面板并压栈已在栈中则忽略O(1) 判断)。</summary>
public void OpenPanel(GameObject panel)
{
if (panel == null) return;
if (!_openPanelSet.Add(panel)) return; // HashSet.Add 返回 false = 已存在
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
panel.SetActive(true);
_panelStack.Push(panel);
}
/// <summary>关闭栈顶面板并恢复上一层(如有);上一层若实现 IFocusable 则自动恢复焦点。</summary>
public void CloseTopPanel()
{
if (_panelStack.Count == 0) return;
var top = _panelStack.Pop();
_openPanelSet.Remove(top);
top.SetActive(false);
if (_panelStack.Count > 0)
{
var restored = _panelStack.Peek();
restored.SetActive(true);
restored.GetComponent<IFocusable>()?.OnFocusRestored();
}
}
// ── 快捷事件回调 ──────────────────────────────────────────────────────
private void TogglePause()
{
if (_panelRegistry.TryGetValue(PanelId.Pause, out var pausePanel)
&& _panelStack.Count > 0 && _panelStack.Peek() == pausePanel)
CloseTopPanel();
else
OpenPanel(PanelId.Pause);
}
private void OpenShop(string _) => OpenPanel(PanelId.Shop);
private void OpenMap() => OpenPanel(PanelId.Map);
private void OpenCharmPanel() => OpenPanel(PanelId.CharmPanel);
private void OpenSpellSelect() => OpenPanel(PanelId.SpellSelect);
// ── 编辑器工具 (不入构建) ──────────────────────────────────────────────────
/// <summary>验证面板注册表是否完整、无重复、无空引用。</summary>
[ContextMenu("验证面板注册表")]
private void EditorValidateRegistry()
{
if (_panels == null || _panels.Length == 0)
{
Debug.LogWarning("[UIManager] 面板注册表为空!", this);
return;
}
var seen = new System.Collections.Generic.HashSet<PanelId>();
bool ok = true;
foreach (var p in _panels)
{
if (p.root == null)
{
Debug.LogWarning($"[UIManager] PanelId.{p.id} 的 GameObject 引用为 null", this);
ok = false;
}
if (!seen.Add(p.id))
{
Debug.LogError($"[UIManager] PanelId.{p.id} 重复注册!", this);
ok = false;
}
}
if (ok)
Debug.Log($"[UIManager] 验证通过 ✔ 已注册 {_panels.Length} 个面板。", this);
}
#if UNITY_EDITOR
/// <summary>仅供 UIManagerEditor 实时可视化面板栈(由栈顶到栈底顺序)。</summary>
public GameObject[] EditorGetPanelSnapshot() => _panelStack.ToArray();
#endif
[ContextMenu("测试:打开 Pause 面板")]
private void EditorOpenPause() => OpenPanel(PanelId.Pause);
[ContextMenu("测试:打开 Map 面板")]
private void EditorOpenMap() => OpenPanel(PanelId.Map);
[ContextMenu("测试:打开 Shop 面板")]
private void EditorOpenShop() => OpenPanel(PanelId.Shop);
[ContextMenu("测试:关闭栈顶面板")]
private void EditorCloseTop() => CloseTopPanel();
}
}