UI系统
This commit is contained in:
47
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs
Normal file
47
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 统一 UI 导航栈服务。所有面板(主菜单子面板 + 游戏内面板)经此压栈/出栈,
|
||||
/// 由它统一保证:只有栈顶可交互、下层被屏蔽出导航图、ESC 逐层回退、出栈恢复焦点。
|
||||
///
|
||||
/// <para>设计要点:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>单一取消入口:导航器是 EVT_UICancelPressed 的唯一消费者,ESC 只关栈顶一层。</item>
|
||||
/// <item>场景作用域:面板可能位于会被卸载的关卡 / 主菜单场景,导航器订阅 sceneUnloaded
|
||||
/// 清理随场景销毁的栈层,每次操作对已销毁面板兜底。</item>
|
||||
/// <item>非面板的"底层上下文"(如主菜单按钮组、游戏内 HUD)不入栈,由各自上下文
|
||||
/// 订阅 <see cref="StackChanged"/> / 读 <see cref="Depth"/> 自行屏蔽。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IUINavigator
|
||||
{
|
||||
/// <summary>当前栈顶面板;空栈为 null。</summary>
|
||||
UIPanelBase Top { get; }
|
||||
|
||||
/// <summary>栈深度(已打开的面板层数)。</summary>
|
||||
int Depth { get; }
|
||||
|
||||
/// <summary>栈结构变化(任何 Push / Pop / 场景清理)后触发,供底层上下文屏蔽自身。</summary>
|
||||
event Action StackChanged;
|
||||
|
||||
/// <summary>压栈打开面板。<paramref name="mode"/> 为空时用面板自身 <see cref="UIPanelBase.DefaultMode"/>。</summary>
|
||||
void Push(UIPanelBase panel, PushMode? mode = null);
|
||||
|
||||
/// <summary>关闭栈顶并恢复下层(若有)。空栈无操作。</summary>
|
||||
void Pop();
|
||||
|
||||
/// <summary>清空整个栈(逐层关闭)。</summary>
|
||||
void PopToRoot();
|
||||
|
||||
/// <summary>
|
||||
/// 压栈打开结果面板并等待玩家给出结果(确认 / 选择)。
|
||||
/// 面板被出栈(含 ESC 取消)、被销毁或 <paramref name="ct"/> 取消时,
|
||||
/// 以面板的取消默认值兜底完成,绝不悬挂 await。
|
||||
/// </summary>
|
||||
Task<T> PushForResultAsync<T>(UIResultPanel<T> panel, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/IUINavigator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0c910e685c9b35d4b90d187e2e20e614
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
14
Assets/_Game/Scripts/UI/Navigation/PushMode.cs
Normal file
14
Assets/_Game/Scripts/UI/Navigation/PushMode.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 面板压栈方式:决定一个面板压栈时,其正下方的面板如何处理。
|
||||
/// </summary>
|
||||
public enum PushMode
|
||||
{
|
||||
/// <summary>替换:压栈时停用下方面板(整屏切换,下层不可见、不参与导航)。</summary>
|
||||
Replace,
|
||||
|
||||
/// <summary>模态:压栈时下方面板保持可见,但屏蔽其交互与射线(对话框叠在其上)。</summary>
|
||||
Modal,
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/PushMode.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/PushMode.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2dad1d08b7f80394493ca81439a7eb02
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
182
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs
Normal file
182
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.SceneManagement;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="IUINavigator"/> 的实现:统一 UI 导航栈。常驻 Persistent 场景,
|
||||
/// 经 ServiceLocator 暴露,主菜单与游戏内共用同一套栈语义。
|
||||
///
|
||||
/// <para>压栈:记录压栈前焦点;据新面板 <see cref="PushMode"/> 处理下方面板
|
||||
/// (Replace→停用;Modal→保留可见但 <see cref="UIPanelBase.SetInteractableLayer"/>(false)
|
||||
/// 使其退出导航图);激活新面板并延后一帧聚焦其首项。</para>
|
||||
/// <para>出栈:关闭栈顶,按其压栈时的 mode 还原下方面板,恢复焦点到压栈前的选中项。</para>
|
||||
/// <para>取消:本类是 EVT_UICancelPressed 的唯一消费者,仅在栈顶 <see cref="UIPanelBase.CanCancel"/>
|
||||
/// 时出栈一层(逐层回退)。</para>
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(+40)] // 早于 UIManager(+50),确保委托方解析得到本服务
|
||||
public class UINavigator : MonoBehaviour, IUINavigator
|
||||
{
|
||||
[Tooltip("UI 取消操作(ESC / 手柄 B·Circle)。本导航器为唯一订阅者,按下时关闭栈顶一层。对应 EVT_UICancelPressed。")]
|
||||
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
|
||||
|
||||
private readonly Stack<UIStackEntry> _stack = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private Coroutine _focusRoutine;
|
||||
|
||||
public UIPanelBase Top => _stack.Count > 0 ? _stack.Peek().Panel : null;
|
||||
public int Depth => _stack.Count;
|
||||
|
||||
public event Action StackChanged;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
private void OnEnable()
|
||||
{
|
||||
ServiceLocator.Register<IUINavigator>(this);
|
||||
_onUICancelPressed?.Subscribe(HandleCancel).AddTo(_subs);
|
||||
SceneManager.sceneUnloaded += OnSceneUnloaded;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
SceneManager.sceneUnloaded -= OnSceneUnloaded;
|
||||
_subs.Clear();
|
||||
ServiceLocator.Unregister<IUINavigator>(this);
|
||||
}
|
||||
|
||||
// ── 压栈 ──────────────────────────────────────────────────────────────
|
||||
public void Push(UIPanelBase panel, PushMode? mode = null)
|
||||
{
|
||||
if (panel == null) return;
|
||||
PurgeDead();
|
||||
|
||||
PushMode m = mode ?? panel.DefaultMode;
|
||||
|
||||
// 处理下方面板:Replace 停用、Modal 屏蔽交互(退出导航图,杜绝上下键穿透)。
|
||||
if (_stack.Count > 0)
|
||||
{
|
||||
var below = _stack.Peek().Panel;
|
||||
if (below != null)
|
||||
{
|
||||
below.OnFocusLost();
|
||||
if (m == PushMode.Replace) below.gameObject.SetActive(false);
|
||||
else below.SetInteractableLayer(false);
|
||||
}
|
||||
}
|
||||
|
||||
var entry = new UIStackEntry
|
||||
{
|
||||
Panel = panel,
|
||||
Mode = m,
|
||||
FocusToRestore = EventSystem.current != null ? EventSystem.current.currentSelectedGameObject : null,
|
||||
OwningScene = panel.gameObject.scene,
|
||||
};
|
||||
_stack.Push(entry);
|
||||
|
||||
panel.gameObject.SetActive(true);
|
||||
panel.SetInteractableLayer(true);
|
||||
FocusNextFrame(panel.FirstSelectableGO);
|
||||
|
||||
StackChanged?.Invoke();
|
||||
}
|
||||
|
||||
// ── 出栈 ──────────────────────────────────────────────────────────────
|
||||
public void Pop()
|
||||
{
|
||||
PurgeDead();
|
||||
if (_stack.Count == 0) return;
|
||||
|
||||
var top = _stack.Pop();
|
||||
if (top.Panel != null) top.Panel.gameObject.SetActive(false);
|
||||
|
||||
UIPanelBase below = _stack.Count > 0 ? _stack.Peek().Panel : null;
|
||||
if (below != null)
|
||||
{
|
||||
// 按 top 压栈时的 mode 还原下方面板。
|
||||
if (top.Mode == PushMode.Replace) below.gameObject.SetActive(true);
|
||||
else below.SetInteractableLayer(true);
|
||||
below.OnFocusGained();
|
||||
}
|
||||
|
||||
// 恢复焦点到压栈前的选中项(失效则回落到下层首项)。
|
||||
GameObject restore = top.FocusToRestore != null && top.FocusToRestore.activeInHierarchy
|
||||
? top.FocusToRestore
|
||||
: below != null ? below.FirstSelectableGO : null;
|
||||
FocusNextFrame(restore);
|
||||
|
||||
StackChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void PopToRoot()
|
||||
{
|
||||
while (_stack.Count > 0) Pop();
|
||||
}
|
||||
|
||||
// ── 取消(ESC / 手柄 B)──────────────────────────────────────────────
|
||||
private void HandleCancel()
|
||||
{
|
||||
PurgeDead();
|
||||
if (_stack.Count == 0) return; // 栈空(如主菜单根):无操作
|
||||
if (Top != null && !Top.CanCancel) return;
|
||||
Pop(); // 仅关栈顶一层
|
||||
}
|
||||
|
||||
// ── 结果面板 ──────────────────────────────────────────────────────────
|
||||
public Task<T> PushForResultAsync<T>(UIResultPanel<T> panel, CancellationToken ct = default)
|
||||
{
|
||||
if (panel == null) return Task.FromResult<T>(default);
|
||||
Task<T> task = panel.BeginResult(ct);
|
||||
Push(panel, PushMode.Modal); // 结果对话框天然模态:下层保留可见但屏蔽交互
|
||||
return task;
|
||||
}
|
||||
|
||||
// ── 场景卸载清理 ──────────────────────────────────────────────────────
|
||||
private void OnSceneUnloaded(Scene s)
|
||||
{
|
||||
if (_stack.Count == 0) return;
|
||||
|
||||
// Stack 无法删中间项:过滤后按原序重建(保留非本场景且未销毁的层)。
|
||||
var kept = new List<UIStackEntry>();
|
||||
foreach (var e in _stack) // 枚举顺序:栈顶→栈底
|
||||
if (e.Panel != null && e.OwningScene != s) kept.Add(e);
|
||||
|
||||
if (kept.Count == _stack.Count) return; // 无变化
|
||||
|
||||
_stack.Clear();
|
||||
for (int i = kept.Count - 1; i >= 0; i--) _stack.Push(kept[i]);
|
||||
StackChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void PurgeDead()
|
||||
{
|
||||
bool changed = false;
|
||||
while (_stack.Count > 0 && _stack.Peek().Panel == null) { _stack.Pop(); changed = true; }
|
||||
if (changed) StackChanged?.Invoke();
|
||||
}
|
||||
|
||||
// ── 焦点(延后一帧,避开 OnEnable / UISelectionRestorer 同帧竞争)────────
|
||||
private void FocusNextFrame(GameObject target)
|
||||
{
|
||||
if (_focusRoutine != null) StopCoroutine(_focusRoutine);
|
||||
if (!isActiveAndEnabled) return;
|
||||
_focusRoutine = StartCoroutine(FocusRoutine(target));
|
||||
}
|
||||
|
||||
private IEnumerator FocusRoutine(GameObject target)
|
||||
{
|
||||
yield return null;
|
||||
_focusRoutine = null;
|
||||
if (target == null || !target.activeInHierarchy) yield break;
|
||||
if (EventSystem.current != null)
|
||||
EventSystem.current.SetSelectedGameObject(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/UINavigator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b777118c6c3387b4e81cbf5bb56c8a23
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
60
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs
Normal file
60
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回结果的模态面板基类(确认框、模式 / 难度选择等)。
|
||||
///
|
||||
/// <para>用法(调用方走线性 await,无回调嵌套):</para>
|
||||
/// <code>
|
||||
/// bool ok = await _confirmDialog.ShowAsync("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY", ct);
|
||||
/// if (!ok) return;
|
||||
/// </code>
|
||||
///
|
||||
/// <para>结果通道由 <see cref="TaskCompletionSource{T}"/> 承载,并对所有"提前结束"路径兜底
|
||||
/// (ESC 取消出栈、外部强关、所属场景卸载、CancellationToken 取消),保证 await 绝不悬挂。</para>
|
||||
/// </summary>
|
||||
public abstract class UIResultPanel<T> : UIPanelBase
|
||||
{
|
||||
private TaskCompletionSource<T> _tcs;
|
||||
private CancellationTokenRegistration _ctReg;
|
||||
|
||||
/// <summary>取消 / 默认结果:ESC、返回、销毁、场景卸载、ct 取消时以此完成。</summary>
|
||||
protected abstract T CancelResult { get; }
|
||||
|
||||
/// <summary>由导航器 <see cref="IUINavigator.PushForResultAsync{T}"/> 调用:开启一轮结果等待。</summary>
|
||||
internal Task<T> BeginResult(CancellationToken ct)
|
||||
{
|
||||
// 复用面板:若上一轮仍未决,先以默认值收口,避免句柄泄漏。
|
||||
ResolvePending();
|
||||
|
||||
_tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_ctReg = ct.CanBeCanceled ? ct.Register(ResolvePending) : default;
|
||||
return _tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>具体按钮回调:以 <paramref name="result"/> 完成并出栈自身。</summary>
|
||||
protected void Complete(T result)
|
||||
{
|
||||
var tcs = _tcs;
|
||||
if (tcs != null && tcs.TrySetResult(result))
|
||||
{
|
||||
_ctReg.Dispose();
|
||||
GetService<IUINavigator>()?.Pop(); // 弹出自己(栈顶)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>以取消默认值收口未决结果(幂等)。</summary>
|
||||
private void ResolvePending()
|
||||
{
|
||||
var tcs = _tcs;
|
||||
if (tcs != null && tcs.TrySetResult(CancelResult))
|
||||
_ctReg.Dispose();
|
||||
}
|
||||
|
||||
// 出栈(含 ESC 取消)会 SetActive(false) → OnDisable → OnPanelClose;
|
||||
// 场景卸载 / 销毁同样经 OnDisable。统一在此兜底,覆盖所有提前结束路径。
|
||||
protected override void OnPanelClose() => ResolvePending();
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/UIResultPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87869bd9c1e862141bff2eff5fcbaf51
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
17
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs
Normal file
17
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 导航栈中一层的记录。<see cref="FocusToRestore"/> 记录压栈前的选中项,
|
||||
/// 出栈时据此恢复键盘 / 手柄焦点;<see cref="OwningScene"/> 用于场景卸载时清理本层。
|
||||
/// </summary>
|
||||
internal sealed class UIStackEntry
|
||||
{
|
||||
public UIPanelBase Panel;
|
||||
public GameObject FocusToRestore;
|
||||
public PushMode Mode;
|
||||
public Scene OwningScene;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Navigation/UIStackEntry.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4f0c943bfa478e4aa7b24aafb5192f3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user