UI系统优化

This commit is contained in:
2026-05-25 11:54:37 +08:00
parent c7057db27d
commit 3c812cfb41
130 changed files with 4738 additions and 477 deletions

View File

@@ -5,16 +5,33 @@ using BaseGames.Core.Events;
namespace BaseGames.UI
{
[DefaultExecutionOrder(+50)]
public class UIManager : MonoBehaviour
/// <summary>
/// 面板 ID 枚举。新增面板时只需在此添加值并在 Inspector 的 _panels 数组中注册,
/// 无需修改 UIManager 的其他代码,满足开闭原则。
/// </summary>
public enum PanelId
{
[Header("Canvas Roots")]
Pause,
Settings,
Map,
Shop,
CharmPanel,
SpellSelect,
}
[DefaultExecutionOrder(+50)]
public class UIManager : MonoBehaviour, IUIManager
{
// ── 状态驱动根节点(不进入面板栈,仅根据 GameState 显示/隐藏)────────
[Header("状态驱动根节点(非面板栈)")]
[SerializeField] private GameObject _hudRoot;
[SerializeField] private GameObject _pauseMenuRoot;
[SerializeField] private GameObject _deathScreenRoot;
[SerializeField] private GameObject _settingsRoot;
[SerializeField] private GameObject _mapRoot;
[SerializeField] private GameObject _shopRoot;
// ── 面板栈注册表 ──────────────────────────────────────────────────────
[Header("面板栈注册表Inspector 配置,可运行时扩展)")]
[Tooltip("将 PanelId 与对应的根 GameObject 绑定。" +
"新增面板只需在此添加一行,无需修改 UIManager 代码。")]
[SerializeField] private PanelRegistration[] _panels;
[Header("Event Channels")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
@@ -22,27 +39,68 @@ namespace BaseGames.UI
[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();
private readonly CompositeDisposable _subs = new();
// ── 面板栈结构 ────────────────────────────────────────────────────────
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)
{
// GameStateId 是 struct用 if/else 而非 switch
bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
if (_hudRoot != null) _hudRoot.SetActive(showHud);
@@ -52,7 +110,6 @@ namespace BaseGames.UI
}
else
{
// 离开 Dead 状态时(复活/重生)隐藏死亡界面
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(false);
if (state == GameStates.Cutscene)
@@ -60,29 +117,101 @@ namespace BaseGames.UI
}
}
// ── 面板栈 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;
_panelStack.Pop().SetActive(false);
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true);
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 (_pauseMenuRoot != null && _pauseMenuRoot.activeSelf)
if (_panelRegistry.TryGetValue(PanelId.Pause, out var pausePanel)
&& _panelStack.Count > 0 && _panelStack.Peek() == pausePanel)
CloseTopPanel();
else
OpenPanel(_pauseMenuRoot);
OpenPanel(PanelId.Pause);
}
private void OpenShop(string _) => OpenPanel(_shopRoot);
private void OpenMap() => OpenPanel(_mapRoot);
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();
}
}