feat(ui): 添加UI路由基类和相关接口定义

- 实现UiRouterBase基类,提供页面栈管理和层级UI管理功能
- 定义IUiPageBehavior接口,规范UI页面生命周期方法和状态管理
- 定义IUiRouter接口,统一UI界面导航和切换操作规范
- 实现页面栈操作功能,包括Push、Pop、Replace、Clear等方法
- 实现层级UI管理功能,支持Overlay、Modal、Toast等浮层显示
- 集成UI过渡管道,支持UI切换动画和逻辑处理
- 添加暂停令牌管理,实现页面可见性驱动的暂停控制
- 实现UI动作捕获和分发机制,支持语义动作处理
This commit is contained in:
GeWuYou 2026-04-17 22:00:33 +08:00
parent 665b3e8396
commit 2e7fd1fc87
10 changed files with 459 additions and 6 deletions

View File

@ -0,0 +1,17 @@
namespace GFramework.Game.Abstractions.UI;
/// <summary>
/// 由页面视图实现,用于接收路由仲裁后的 UI 语义动作。
/// </summary>
public interface IUiActionHandler
{
/// <summary>
/// 处理一个 UI 语义动作。
/// </summary>
/// <param name="action">当前要处理的动作。</param>
/// <returns>
/// 如果页面已经完成处理则返回 <see langword="true" />
/// 返回 <see langword="false" /> 时,路由器仍会把声明捕获该动作视为已消费。
/// </returns>
bool TryHandleUiAction(UiInputAction action);
}

View File

@ -0,0 +1,16 @@
using GFramework.Game.Abstractions.Enums;
namespace GFramework.Game.Abstractions.UI;
/// <summary>
/// 由页面视图实现,用于按运行时状态动态提供交互语义配置。
/// </summary>
public interface IUiInteractionProfileProvider
{
/// <summary>
/// 获取页面当前应使用的交互配置。
/// </summary>
/// <param name="layer">页面绑定的默认 UI 层级。</param>
/// <returns>当前页面的交互配置。</returns>
UiInteractionProfile GetUiInteractionProfile(UiLayer layer);
}

View File

@ -66,6 +66,11 @@ public interface IUiPageBehavior : IRoute
/// </summary>
bool BlocksInput { get; }
/// <summary>
/// 获取页面当前的输入、阻断与暂停交互配置。
/// </summary>
UiInteractionProfile InteractionProfile { get; }
/// <summary>
/// 页面进入时调用的方法
/// </summary>
@ -96,4 +101,11 @@ public interface IUiPageBehavior : IRoute
/// 页面重新显示时调用的方法
/// </summary>
void OnShow();
}
/// <summary>
/// 尝试处理一个经过路由器仲裁后的 UI 语义动作。
/// </summary>
/// <param name="action">当前动作。</param>
/// <returns>如果页面已经显式处理该动作则返回 <see langword="true" />。</returns>
bool TryHandleUiAction(UiInputAction action);
}

View File

@ -186,5 +186,31 @@ public interface IUiRouter : ISystem
/// <param name="hideAll">是否隐藏所有匹配的UI实例默认为false。</param>
void HideByKey(string uiKey, UiLayer layer, bool destroy = false, bool hideAll = false);
/// <summary>
/// 查询当前对指定 UI 语义动作拥有最高优先级捕获权的页面。
/// </summary>
/// <param name="action">要查询的动作。</param>
/// <returns>动作所有者;如果当前没有页面声明捕获该动作则返回 <see langword="null" />。</returns>
IUiPageBehavior? GetUiActionOwner(UiInputAction action);
/// <summary>
/// 尝试把语义动作分发给当前拥有该动作的页面。
/// </summary>
/// <param name="action">当前动作。</param>
/// <returns>如果该动作已被某个页面捕获并消费,则返回 <see langword="true" />。</returns>
bool TryHandleUiAction(UiInputAction action);
/// <summary>
/// 判断当前可见 UI 是否阻断 World 指针输入。
/// </summary>
/// <returns>如果 World 指针输入应被阻断则返回 <see langword="true" />。</returns>
bool BlocksWorldPointerInput();
/// <summary>
/// 判断当前可见 UI 是否阻断 World 语义动作输入。
/// </summary>
/// <returns>如果 World 动作输入应被阻断则返回 <see langword="true" />。</returns>
bool BlocksWorldActionInput();
#endregion
}
}

View File

@ -0,0 +1,23 @@
namespace GFramework.Game.Abstractions.UI;
/// <summary>
/// 定义框架级 UI 语义动作。
/// 这些动作由输入层映射后交给 UI 路由统一仲裁,避免页面直接依赖具体按键或设备事件。
/// </summary>
public enum UiInputAction
{
/// <summary>
/// 未指定动作。
/// </summary>
None = 0,
/// <summary>
/// 取消、返回或关闭当前 UI。
/// </summary>
Cancel = 1,
/// <summary>
/// 确认当前 UI 操作。
/// </summary>
Confirm = 2
}

View File

@ -0,0 +1,23 @@
namespace GFramework.Game.Abstractions.UI;
/// <summary>
/// 以位标记形式声明 UI 页面要捕获的语义动作集合。
/// </summary>
[Flags]
public enum UiInputActionMask
{
/// <summary>
/// 不捕获任何动作。
/// </summary>
None = 0,
/// <summary>
/// 捕获取消动作。
/// </summary>
Cancel = 1 << 0,
/// <summary>
/// 捕获确认动作。
/// </summary>
Confirm = 1 << 1
}

View File

@ -0,0 +1,90 @@
using GFramework.Core.Abstractions.Pause;
using GFramework.Game.Abstractions.Enums;
namespace GFramework.Game.Abstractions.UI;
/// <summary>
/// 描述一个 UI 页面在输入、World 阻断与暂停上的运行时语义。
/// </summary>
public sealed class UiInteractionProfile
{
/// <summary>
/// 获取默认值实例。
/// </summary>
public static UiInteractionProfile Default { get; } = new();
/// <summary>
/// 声明当前页面要捕获的语义动作集合。
/// </summary>
public UiInputActionMask CapturedActions { get; init; } = UiInputActionMask.None;
/// <summary>
/// 指示当前页面是否阻断 World 指针输入,例如地图点击或相机拖拽。
/// </summary>
public bool BlocksWorldPointerInput { get; init; }
/// <summary>
/// 指示当前页面是否阻断 World 语义动作输入,例如 gameplay 快捷键。
/// </summary>
public bool BlocksWorldActionInput { get; init; }
/// <summary>
/// 指示当前页面的可见性是否应驱动暂停栈。
/// </summary>
public UiPauseMode PauseMode { get; init; } = UiPauseMode.None;
/// <summary>
/// 当 <see cref="PauseMode" /> 生效时使用的暂停组。
/// </summary>
public PauseGroup PauseGroup { get; init; } = PauseGroup.Global;
/// <summary>
/// 当场景树暂停时,该页面是否仍需继续处理输入与动画。
/// </summary>
public bool ContinueProcessingWhenPaused { get; init; }
/// <summary>
/// 页面向暂停栈登记时使用的原因文本。
/// </summary>
public string PauseReason { get; init; } = string.Empty;
/// <summary>
/// 判断当前配置是否捕获了指定动作。
/// </summary>
/// <param name="action">要查询的语义动作。</param>
/// <returns>如果当前配置捕获该动作则返回 <see langword="true" />。</returns>
public bool Captures(UiInputAction action)
{
return action switch
{
UiInputAction.Cancel => CapturedActions.HasFlag(UiInputActionMask.Cancel),
UiInputAction.Confirm => CapturedActions.HasFlag(UiInputActionMask.Confirm),
_ => false
};
}
/// <summary>
/// 为指定层级生成默认交互配置。
/// </summary>
/// <param name="layer">UI 层级。</param>
/// <returns>该层级的默认交互语义。</returns>
public static UiInteractionProfile CreateDefault(UiLayer layer)
{
return layer switch
{
UiLayer.Modal => new UiInteractionProfile
{
CapturedActions = UiInputActionMask.Cancel,
BlocksWorldPointerInput = true,
BlocksWorldActionInput = true
},
UiLayer.Topmost => new UiInteractionProfile
{
CapturedActions = UiInputActionMask.Cancel,
BlocksWorldPointerInput = true,
BlocksWorldActionInput = true
},
_ => Default
};
}
}

View File

@ -0,0 +1,17 @@
namespace GFramework.Game.Abstractions.UI;
/// <summary>
/// 定义页面显示时与暂停系统的协作模式。
/// </summary>
public enum UiPauseMode
{
/// <summary>
/// 页面显示不会触发暂停请求。
/// </summary>
None = 0,
/// <summary>
/// 页面在可见期间持有一个暂停请求,隐藏或销毁时释放。
/// </summary>
WhileVisible = 1
}

View File

@ -1,6 +1,6 @@
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Abstractions.Pause;
using GFramework.Core.Extensions;
using GFramework.Core.Logging;
using GFramework.Game.Abstractions.Enums;
using GFramework.Game.Abstractions.UI;
using GFramework.Game.Routing;
@ -21,6 +21,11 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
/// </summary>
private readonly Dictionary<UiLayer, Dictionary<string, IUiPageBehavior>> _layers = new();
/// <summary>
/// 记录当前由页面可见性驱动持有的暂停令牌。
/// </summary>
private readonly Dictionary<IUiPageBehavior, PauseToken> _pauseTokens = new();
/// <summary>
/// UI切换处理器管道用于执行UI过渡动画和逻辑
/// </summary>
@ -36,6 +41,11 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
/// </summary>
private int _instanceCounter;
/// <summary>
/// 可选暂停栈管理器。
/// </summary>
private IPauseStackManager? _pauseStackManager;
/// <summary>
/// UI根节点引用用于添加和移除UI页面
/// </summary>
@ -324,6 +334,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
if (destroy)
{
page.OnExit();
SyncPauseRequest(page, isVisible: false);
_uiRoot.RemoveUiPage(page);
layerDict.Remove(handle.InstanceId);
Log.Debug("Hide & Destroy UI: instanceId={0}, layer={1}", handle.InstanceId, layer);
@ -331,6 +342,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
else
{
page.OnHide();
SyncPauseRequest(page, isVisible: false);
Log.Debug("Hide UI (suspend): instanceId={0}, layer={1}", handle.InstanceId, layer);
}
}
@ -350,6 +362,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
page.OnShow();
page.OnResume();
SyncPauseRequest(page, isVisible: true);
Log.Debug("Resume UI: instanceId={0}, layer={1}", handle.InstanceId, layer);
}
@ -446,6 +459,55 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
Hide(handles[0], layer, destroy);
}
/// <summary>
/// 获取当前拥有指定 UI 语义动作捕获权的页面。
/// </summary>
/// <param name="action">要查询的动作。</param>
/// <returns>动作所有者;若没有页面声明捕获该动作则返回 <see langword="null" />。</returns>
public IUiPageBehavior? GetUiActionOwner(UiInputAction action)
{
return EnumerateVisiblePagesByPriority()
.FirstOrDefault(page => page.InteractionProfile.Captures(action));
}
/// <summary>
/// 尝试将语义动作分发给当前拥有捕获权的页面。
/// </summary>
/// <param name="action">当前动作。</param>
/// <returns>如果已有页面捕获该动作则返回 <see langword="true" />。</returns>
public bool TryHandleUiAction(UiInputAction action)
{
var owner = GetUiActionOwner(action);
if (owner is null)
return false;
var handled = owner.TryHandleUiAction(action);
if (!handled)
Log.Debug("UI action captured without explicit handler: key={0}, action={1}", owner.Key, action);
return true;
}
/// <summary>
/// 判断当前可见 UI 是否阻断 World 指针输入。
/// </summary>
/// <returns>如果 World 指针输入应被阻断则返回 <see langword="true" />。</returns>
public bool BlocksWorldPointerInput()
{
return EnumerateVisiblePagesByPriority()
.Any(page => page.InteractionProfile.BlocksWorldPointerInput);
}
/// <summary>
/// 判断当前可见 UI 是否阻断 World 语义动作输入。
/// </summary>
/// <returns>如果 World 语义动作输入应被阻断则返回 <see langword="true" />。</returns>
public bool BlocksWorldActionInput()
{
return EnumerateVisiblePagesByPriority()
.Any(page => page.InteractionProfile.BlocksWorldActionInput);
}
#endregion
#region Initialization
@ -458,6 +520,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
{
// 获取UI工厂实例并确保其不为null
_factory = this.GetUtility<IUiFactory>()!;
TryBindPauseStackManager();
// 输出调试日志记录UI路由器基类已初始化及使用的工厂类型
Log.Debug("UiRouterBase initialized. Factory={0}", _factory.GetType().Name);
@ -472,6 +535,24 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
/// </summary>
protected override abstract void RegisterHandlers();
/// <summary>
/// 路由销毁时释放所有由页面持有的暂停请求。
/// </summary>
protected override void OnDestroy()
{
base.OnDestroy();
if (_pauseStackManager is null)
return;
foreach (var token in _pauseTokens.Values.ToArray())
{
_pauseStackManager.Pop(token);
}
_pauseTokens.Clear();
}
#endregion
#region Internal Helpers
@ -525,6 +606,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
// 生命周期
page.OnEnter(param);
page.OnShow();
SyncPauseRequest(page, isVisible: true);
Log.Debug("Show UI: key={0}, instanceId={1}, layer={2}", page.Key, instanceId, layer);
return handle;
@ -610,6 +692,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
{
Log.Debug("Suspend current page (Exclusive): {0}", current.View.GetType().Name);
current.OnHide();
SyncPauseRequest(current, isVisible: false);
}
}
@ -621,6 +704,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
Log.Debug("Enter & Show page: {0}, stackAfter={1}", page.View.GetType().Name, Stack.Count);
page.OnEnter(param);
page.OnShow();
SyncPauseRequest(page, isVisible: true);
}
/// <summary>
@ -646,11 +730,14 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
top.OnHide();
}
SyncPauseRequest(top, isVisible: false);
if (Stack.Count > 0)
{
var next = Stack.Peek();
next.OnResume();
next.OnShow();
SyncPauseRequest(next, isVisible: true);
}
}
@ -665,5 +752,102 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
DoPopInternal(policy);
}
/// <summary>
/// 尝试绑定暂停栈管理器。
/// </summary>
private void TryBindPauseStackManager()
{
try
{
_pauseStackManager = this.GetUtility<IPauseStackManager>();
}
catch (InvalidOperationException)
{
_pauseStackManager = null;
}
}
/// <summary>
/// 根据页面可见性同步暂停请求。
/// </summary>
/// <param name="page">页面行为。</param>
/// <param name="isVisible">页面是否应视为可见。</param>
private void SyncPauseRequest(IUiPageBehavior page, bool isVisible)
{
if (_pauseStackManager is null)
return;
var profile = page.InteractionProfile;
if (!isVisible || profile.PauseMode == UiPauseMode.None)
{
ReleasePauseRequest(page);
return;
}
if (_pauseTokens.ContainsKey(page))
return;
var reason = string.IsNullOrWhiteSpace(profile.PauseReason)
? $"UI:{page.Key}"
: profile.PauseReason;
_pauseTokens[page] = _pauseStackManager.Push(reason, profile.PauseGroup);
}
/// <summary>
/// 释放页面此前登记的暂停请求。
/// </summary>
/// <param name="page">目标页面。</param>
private void ReleasePauseRequest(IUiPageBehavior page)
{
if (_pauseStackManager is null)
return;
if (!_pauseTokens.Remove(page, out var token))
return;
_pauseStackManager.Pop(token);
}
/// <summary>
/// 按输入优先级枚举当前所有可见页面。
/// </summary>
/// <returns>可见页面序列。</returns>
private IEnumerable<IUiPageBehavior> EnumerateVisiblePagesByPriority()
{
foreach (var page in EnumerateVisibleLayerPages(UiLayer.Topmost))
yield return page;
foreach (var page in EnumerateVisibleLayerPages(UiLayer.Modal))
yield return page;
foreach (var page in EnumerateVisibleLayerPages(UiLayer.Overlay))
yield return page;
foreach (var page in Stack.Where(static page => page.IsAlive && page.IsVisible))
yield return page;
foreach (var page in EnumerateVisibleLayerPages(UiLayer.Toast))
yield return page;
}
/// <summary>
/// 枚举指定层级中的可见页面,层内按最近显示优先。
/// </summary>
/// <param name="layer">目标层级。</param>
/// <returns>该层级中的可见页面。</returns>
private IEnumerable<IUiPageBehavior> EnumerateVisibleLayerPages(UiLayer layer)
{
if (!_layers.TryGetValue(layer, out var layerDict))
yield break;
foreach (var page in layerDict
.OrderByDescending(static pair => pair.Key, StringComparer.Ordinal)
.Select(static pair => pair.Value)
.Where(static page => page.IsAlive && page.IsVisible))
{
yield return page;
}
}
#endregion
}
}

View File

@ -14,7 +14,6 @@
using GFramework.Game.Abstractions.Enums;
using GFramework.Game.Abstractions.UI;
using GFramework.Godot.Extensions;
using Godot;
namespace GFramework.Godot.UI;
@ -36,6 +35,16 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
/// </summary>
private readonly IUiPage? _page;
/// <summary>
/// 视图可选提供的交互配置提供者。
/// </summary>
private readonly IUiInteractionProfileProvider? _profileProvider;
/// <summary>
/// 视图可选提供的 UI 语义动作处理器。
/// </summary>
private readonly IUiActionHandler? _uiActionHandler;
/// <summary>
/// 视图节点的所有者实例。
/// </summary>
@ -51,6 +60,8 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
Owner = owner;
_key = key;
_page = owner as IUiPage;
_profileProvider = owner as IUiInteractionProfileProvider;
_uiActionHandler = owner as IUiActionHandler;
}
#region -
@ -115,6 +126,13 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
/// </summary>
public bool IsVisible => Owner.Visible;
/// <summary>
/// 获取页面当前的交互配置。
/// 若页面未提供自定义配置,则回退到层级默认值。
/// </summary>
public UiInteractionProfile InteractionProfile => _profileProvider?.GetUiInteractionProfile(Layer)
?? UiInteractionProfile.CreateDefault(Layer);
#endregion
#region
@ -153,6 +171,8 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
Owner.SetProcess(false);
Owner.SetPhysicsProcess(false);
Owner.SetProcessInput(false);
Owner.SetProcessUnhandledInput(false);
Owner.SetProcessUnhandledKeyInput(false);
}
/// <summary>
@ -166,10 +186,14 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
_page?.OnResume();
ApplyPauseAwareProcessingMode();
// 恢复处理
Owner.SetProcess(true);
Owner.SetPhysicsProcess(true);
Owner.SetProcessInput(true);
Owner.SetProcessUnhandledInput(true);
Owner.SetProcessUnhandledKeyInput(true);
}
/// <summary>
@ -189,9 +213,30 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
public virtual void OnShow()
{
_page?.OnShow();
ApplyPauseAwareProcessingMode();
Owner.Show();
OnResume();
}
/// <summary>
/// 尝试处理一个路由仲裁后的 UI 语义动作。
/// </summary>
/// <param name="action">当前动作。</param>
/// <returns>如果视图显式处理了该动作则返回 <see langword="true" />。</returns>
public virtual bool TryHandleUiAction(UiInputAction action)
{
return _uiActionHandler?.TryHandleUiAction(action) ?? false;
}
/// <summary>
/// 根据交互配置调整节点在暂停态下的处理模式。
/// </summary>
private void ApplyPauseAwareProcessingMode()
{
Owner.ProcessMode = InteractionProfile.ContinueProcessingWhenPaused
? Node.ProcessModeEnum.Always
: Node.ProcessModeEnum.Pausable;
}
#endregion
}
}