This commit is contained in:
2026-06-07 11:49:55 +08:00
parent ff0f3bde54
commit 1897658a00
98 changed files with 9903 additions and 13907 deletions

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0c910e685c9b35d4b90d187e2e20e614
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,14 @@
namespace BaseGames.UI
{
/// <summary>
/// 面板压栈方式:决定一个面板压栈时,其正下方的面板如何处理。
/// </summary>
public enum PushMode
{
/// <summary>替换:压栈时停用下方面板(整屏切换,下层不可见、不参与导航)。</summary>
Replace,
/// <summary>模态:压栈时下方面板保持可见,但屏蔽其交互与射线(对话框叠在其上)。</summary>
Modal,
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2dad1d08b7f80394493ca81439a7eb02
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b777118c6c3387b4e81cbf5bb56c8a23
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 87869bd9c1e862141bff2eff5fcbaf51
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4f0c943bfa478e4aa7b24aafb5192f3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: