Files
zeling_v2/Assets/_Game/Scripts/UI/UIManager.cs
2026-05-25 13:21:41 +08:00

340 lines
15 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 UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
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("Addressable 面板(按需异步加载,首次打开时实例化)")]
[Tooltip("配置 PanelId → AssetReferenceGameObject同 _panels 重复定义时,本表优先生效。" +
"未在此处也未在 _panels 中注册的 PanelId 调用 OpenPanel 将记录警告。")]
[SerializeField] private AddressablePanelRegistration[] _addressablePanels;
[Tooltip("Addressable 面板实例化后挂载的父节点;为空时挂在 UIManager.transform。")]
[SerializeField] private Transform _addressablePanelParent;
[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;
}
[System.Serializable]
private struct AddressablePanelRegistration
{
[Tooltip("面板标识符。")]
public PanelId id;
[Tooltip("Addressable 引用;首次 OpenPanel 时异步加载并实例化。")]
public AssetReferenceGameObject reference;
[Tooltip("关闭后保留实例(仅隐藏,不释放)。开启时常驻内存,避免反复加载抖动;适合频繁打开的面板。")]
public bool keepLoadedAfterClose;
}
/// <summary>记录 Addressable 加载状态,避免重复加载与正确释放。</summary>
private class AddressablePanelHandle
{
public AsyncOperationHandle<GameObject> Handle;
public GameObject Instance;
public bool IsLoading;
public bool KeepLoaded;
}
private readonly Dictionary<PanelId, AddressablePanelHandle> _addressableHandles = new();
private readonly Dictionary<PanelId, AssetReferenceGameObject> _addressableRefs = new();
private readonly Dictionary<PanelId, bool> _addressableKeep = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_panels != null)
foreach (var p in _panels)
if (p.root != null) _panelRegistry[p.id] = p.root;
if (_addressablePanels != null)
foreach (var p in _addressablePanels)
{
if (p.reference != null) _addressableRefs[p.id] = p.reference;
_addressableKeep[p.id] = p.keepLoadedAfterClose;
}
}
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();
ReleaseAllAddressablePanels();
}
/// <summary>释放所有由 Addressables 加载的面板(防止场景切换泄漏)。</summary>
private void ReleaseAllAddressablePanels()
{
foreach (var kv in _addressableHandles)
{
var h = kv.Value;
if (h == null) continue;
if (h.Instance != null && _addressableRefs.TryGetValue(kv.Key, out var aref))
{
aref.ReleaseInstance(h.Instance);
}
else if (h.Handle.IsValid())
{
Addressables.Release(h.Handle);
}
}
_addressableHandles.Clear();
}
// ── 面板注册(运行时动态扩展入口)────────────────────────────────────
/// <summary>运行时注册或覆盖面板绑定(如场景加载后动态添加的面板)。</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 打开面板:优先同步注册表,其次 Addressable 异步加载。</summary>
public void OpenPanel(PanelId id)
{
if (_panelRegistry.TryGetValue(id, out var panel))
{
OpenPanel(panel);
return;
}
if (_addressableRefs.ContainsKey(id))
{
OpenPanelAsync(id);
return;
}
Debug.LogWarning($"[UIManager] PanelId.{id} 未注册Inspector 中既无 _panels 也无 _addressablePanels。", this);
}
/// <summary>异步加载并打开 Addressable 面板。首次触发 InstantiateAsync后续复用。</summary>
private void OpenPanelAsync(PanelId id)
{
if (!_addressableRefs.TryGetValue(id, out var aref) || aref == null)
{
Debug.LogWarning($"[UIManager] Addressable 面板 {id} 未配置 AssetReference。", this);
return;
}
if (!_addressableHandles.TryGetValue(id, out var entry))
{
_addressableKeep.TryGetValue(id, out var keep);
entry = new AddressablePanelHandle { IsLoading = true, KeepLoaded = keep };
_addressableHandles[id] = entry;
}
// 已加载完成:直接复用实例。
if (entry.Instance != null)
{
_panelRegistry[id] = entry.Instance;
OpenPanel(entry.Instance);
return;
}
// 加载中:忽略重复请求。
if (entry.IsLoading && entry.Handle.IsValid()) return;
entry.IsLoading = true;
var parent = _addressablePanelParent != null ? _addressablePanelParent : transform;
entry.Handle = aref.InstantiateAsync(parent, instantiateInWorldSpace: false);
entry.Handle.Completed += op =>
{
entry.IsLoading = false;
if (op.Status != AsyncOperationStatus.Succeeded || op.Result == null)
{
Debug.LogError($"[UIManager] Addressable 面板 {id} 加载失败: {op.OperationException}", this);
return;
}
entry.Instance = op.Result;
entry.Instance.SetActive(false); // 与 PanelRegistration 同语义:默认隐藏
_panelRegistry[id] = entry.Instance;
OpenPanel(entry.Instance);
};
}
/// <summary>打开指定 GameObject 面板并压栈已在栈中则忽略O(1) 判断)。</summary>
public void OpenPanel(GameObject panel)
{
if (panel == null) return;
if (!_openPanelSet.Add(panel)) return;
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);
// ── 编辑器工具 ────────────────────────────────────────────────────────
[ContextMenu("验证面板注册表")]
private void EditorValidateRegistry()
{
if ((_panels == null || _panels.Length == 0) && (_addressablePanels == null || _addressablePanels.Length == 0))
{
Debug.LogWarning("[UIManager] 面板注册表为空!", this);
return;
}
var seen = new HashSet<PanelId>();
bool ok = true;
if (_panels != null)
{
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} 在 _panels 中重复!", this);
ok = false;
}
}
}
if (_addressablePanels != null)
{
var seenAddr = new HashSet<PanelId>();
foreach (var p in _addressablePanels)
{
if (p.reference == null)
{
Debug.LogWarning($"[UIManager] Addressable PanelId.{p.id} 的 reference 为 null", this);
ok = false;
}
if (!seenAddr.Add(p.id))
{
Debug.LogError($"[UIManager] PanelId.{p.id} 在 _addressablePanels 中重复!", this);
ok = false;
}
}
}
if (ok)
Debug.Log($"[UIManager] 验证通过 ✔ 同步 {_panels?.Length ?? 0} 个 / Addressable {_addressablePanels?.Length ?? 0} 个。", 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();
}
}