diff --git a/GFramework.Game.Abstractions/UI/IUiActionHandler.cs b/GFramework.Game.Abstractions/UI/IUiActionHandler.cs new file mode 100644 index 00000000..876b293b --- /dev/null +++ b/GFramework.Game.Abstractions/UI/IUiActionHandler.cs @@ -0,0 +1,17 @@ +namespace GFramework.Game.Abstractions.UI; + +/// +/// 由页面视图实现,用于接收路由仲裁后的 UI 语义动作。 +/// +public interface IUiActionHandler +{ + /// + /// 处理一个 UI 语义动作。 + /// + /// 当前要处理的动作。 + /// + /// 如果页面已经完成处理则返回 ; + /// 返回 时,路由器仍会把声明捕获该动作视为已消费。 + /// + bool TryHandleUiAction(UiInputAction action); +} diff --git a/GFramework.Game.Abstractions/UI/IUiInteractionProfileProvider.cs b/GFramework.Game.Abstractions/UI/IUiInteractionProfileProvider.cs new file mode 100644 index 00000000..138ddc27 --- /dev/null +++ b/GFramework.Game.Abstractions/UI/IUiInteractionProfileProvider.cs @@ -0,0 +1,16 @@ +using GFramework.Game.Abstractions.Enums; + +namespace GFramework.Game.Abstractions.UI; + +/// +/// 由页面视图实现,用于按运行时状态动态提供交互语义配置。 +/// +public interface IUiInteractionProfileProvider +{ + /// + /// 获取页面当前应使用的交互配置。 + /// + /// 页面绑定的默认 UI 层级。 + /// 当前页面的交互配置。 + UiInteractionProfile GetUiInteractionProfile(UiLayer layer); +} diff --git a/GFramework.Game.Abstractions/UI/IUiPageBehavior.cs b/GFramework.Game.Abstractions/UI/IUiPageBehavior.cs index 17a3e686..450a3000 100644 --- a/GFramework.Game.Abstractions/UI/IUiPageBehavior.cs +++ b/GFramework.Game.Abstractions/UI/IUiPageBehavior.cs @@ -66,6 +66,11 @@ public interface IUiPageBehavior : IRoute /// bool BlocksInput { get; } + /// + /// 获取页面当前的输入、阻断与暂停交互配置。 + /// + UiInteractionProfile InteractionProfile { get; } + /// /// 页面进入时调用的方法 /// @@ -96,4 +101,11 @@ public interface IUiPageBehavior : IRoute /// 页面重新显示时调用的方法 /// void OnShow(); -} \ No newline at end of file + + /// + /// 尝试处理一个经过路由器仲裁后的 UI 语义动作。 + /// + /// 当前动作。 + /// 如果页面已经显式处理该动作则返回 + bool TryHandleUiAction(UiInputAction action); +} diff --git a/GFramework.Game.Abstractions/UI/IUiRouter.cs b/GFramework.Game.Abstractions/UI/IUiRouter.cs index b6b18873..06510560 100644 --- a/GFramework.Game.Abstractions/UI/IUiRouter.cs +++ b/GFramework.Game.Abstractions/UI/IUiRouter.cs @@ -186,5 +186,31 @@ public interface IUiRouter : ISystem /// 是否隐藏所有匹配的UI实例,默认为false。 void HideByKey(string uiKey, UiLayer layer, bool destroy = false, bool hideAll = false); + /// + /// 查询当前对指定 UI 语义动作拥有最高优先级捕获权的页面。 + /// + /// 要查询的动作。 + /// 动作所有者;如果当前没有页面声明捕获该动作则返回 + IUiPageBehavior? GetUiActionOwner(UiInputAction action); + + /// + /// 尝试把语义动作分发给当前拥有该动作的页面。 + /// + /// 当前动作。 + /// 如果该动作已被某个页面捕获并消费,则返回 + bool TryHandleUiAction(UiInputAction action); + + /// + /// 判断当前可见 UI 是否阻断 World 指针输入。 + /// + /// 如果 World 指针输入应被阻断则返回 + bool BlocksWorldPointerInput(); + + /// + /// 判断当前可见 UI 是否阻断 World 语义动作输入。 + /// + /// 如果 World 动作输入应被阻断则返回 + bool BlocksWorldActionInput(); + #endregion -} \ No newline at end of file +} diff --git a/GFramework.Game.Abstractions/UI/UiInputAction.cs b/GFramework.Game.Abstractions/UI/UiInputAction.cs new file mode 100644 index 00000000..c0aee070 --- /dev/null +++ b/GFramework.Game.Abstractions/UI/UiInputAction.cs @@ -0,0 +1,23 @@ +namespace GFramework.Game.Abstractions.UI; + +/// +/// 定义框架级 UI 语义动作。 +/// 这些动作由输入层映射后交给 UI 路由统一仲裁,避免页面直接依赖具体按键或设备事件。 +/// +public enum UiInputAction +{ + /// + /// 未指定动作。 + /// + None = 0, + + /// + /// 取消、返回或关闭当前 UI。 + /// + Cancel = 1, + + /// + /// 确认当前 UI 操作。 + /// + Confirm = 2 +} diff --git a/GFramework.Game.Abstractions/UI/UiInputActionMask.cs b/GFramework.Game.Abstractions/UI/UiInputActionMask.cs new file mode 100644 index 00000000..09c7b54e --- /dev/null +++ b/GFramework.Game.Abstractions/UI/UiInputActionMask.cs @@ -0,0 +1,23 @@ +namespace GFramework.Game.Abstractions.UI; + +/// +/// 以位标记形式声明 UI 页面要捕获的语义动作集合。 +/// +[Flags] +public enum UiInputActionMask +{ + /// + /// 不捕获任何动作。 + /// + None = 0, + + /// + /// 捕获取消动作。 + /// + Cancel = 1 << 0, + + /// + /// 捕获确认动作。 + /// + Confirm = 1 << 1 +} diff --git a/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs b/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs new file mode 100644 index 00000000..32069d48 --- /dev/null +++ b/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs @@ -0,0 +1,90 @@ +using GFramework.Core.Abstractions.Pause; +using GFramework.Game.Abstractions.Enums; + +namespace GFramework.Game.Abstractions.UI; + +/// +/// 描述一个 UI 页面在输入、World 阻断与暂停上的运行时语义。 +/// +public sealed class UiInteractionProfile +{ + /// + /// 获取默认值实例。 + /// + public static UiInteractionProfile Default { get; } = new(); + + /// + /// 声明当前页面要捕获的语义动作集合。 + /// + public UiInputActionMask CapturedActions { get; init; } = UiInputActionMask.None; + + /// + /// 指示当前页面是否阻断 World 指针输入,例如地图点击或相机拖拽。 + /// + public bool BlocksWorldPointerInput { get; init; } + + /// + /// 指示当前页面是否阻断 World 语义动作输入,例如 gameplay 快捷键。 + /// + public bool BlocksWorldActionInput { get; init; } + + /// + /// 指示当前页面的可见性是否应驱动暂停栈。 + /// + public UiPauseMode PauseMode { get; init; } = UiPauseMode.None; + + /// + /// 当 生效时使用的暂停组。 + /// + public PauseGroup PauseGroup { get; init; } = PauseGroup.Global; + + /// + /// 当场景树暂停时,该页面是否仍需继续处理输入与动画。 + /// + public bool ContinueProcessingWhenPaused { get; init; } + + /// + /// 页面向暂停栈登记时使用的原因文本。 + /// + public string PauseReason { get; init; } = string.Empty; + + /// + /// 判断当前配置是否捕获了指定动作。 + /// + /// 要查询的语义动作。 + /// 如果当前配置捕获该动作则返回 + public bool Captures(UiInputAction action) + { + return action switch + { + UiInputAction.Cancel => CapturedActions.HasFlag(UiInputActionMask.Cancel), + UiInputAction.Confirm => CapturedActions.HasFlag(UiInputActionMask.Confirm), + _ => false + }; + } + + /// + /// 为指定层级生成默认交互配置。 + /// + /// UI 层级。 + /// 该层级的默认交互语义。 + 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 + }; + } +} diff --git a/GFramework.Game.Abstractions/UI/UiPauseMode.cs b/GFramework.Game.Abstractions/UI/UiPauseMode.cs new file mode 100644 index 00000000..5462c171 --- /dev/null +++ b/GFramework.Game.Abstractions/UI/UiPauseMode.cs @@ -0,0 +1,17 @@ +namespace GFramework.Game.Abstractions.UI; + +/// +/// 定义页面显示时与暂停系统的协作模式。 +/// +public enum UiPauseMode +{ + /// + /// 页面显示不会触发暂停请求。 + /// + None = 0, + + /// + /// 页面在可见期间持有一个暂停请求,隐藏或销毁时释放。 + /// + WhileVisible = 1 +} diff --git a/GFramework.Game/UI/UiRouterBase.cs b/GFramework.Game/UI/UiRouterBase.cs index f105cbb6..8bb376e9 100644 --- a/GFramework.Game/UI/UiRouterBase.cs +++ b/GFramework.Game/UI/UiRouterBase.cs @@ -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 private readonly Dictionary> _layers = new(); + /// + /// 记录当前由页面可见性驱动持有的暂停令牌。 + /// + private readonly Dictionary _pauseTokens = new(); + /// /// UI切换处理器管道,用于执行UI过渡动画和逻辑 /// @@ -36,6 +41,11 @@ public abstract class UiRouterBase : RouterBase private int _instanceCounter; + /// + /// 可选暂停栈管理器。 + /// + private IPauseStackManager? _pauseStackManager; + /// /// UI根节点引用,用于添加和移除UI页面 /// @@ -324,6 +334,7 @@ public abstract class UiRouterBase : RouterBase + /// 获取当前拥有指定 UI 语义动作捕获权的页面。 + /// + /// 要查询的动作。 + /// 动作所有者;若没有页面声明捕获该动作则返回 + public IUiPageBehavior? GetUiActionOwner(UiInputAction action) + { + return EnumerateVisiblePagesByPriority() + .FirstOrDefault(page => page.InteractionProfile.Captures(action)); + } + + /// + /// 尝试将语义动作分发给当前拥有捕获权的页面。 + /// + /// 当前动作。 + /// 如果已有页面捕获该动作则返回 + 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; + } + + /// + /// 判断当前可见 UI 是否阻断 World 指针输入。 + /// + /// 如果 World 指针输入应被阻断则返回 + public bool BlocksWorldPointerInput() + { + return EnumerateVisiblePagesByPriority() + .Any(page => page.InteractionProfile.BlocksWorldPointerInput); + } + + /// + /// 判断当前可见 UI 是否阻断 World 语义动作输入。 + /// + /// 如果 World 语义动作输入应被阻断则返回 + public bool BlocksWorldActionInput() + { + return EnumerateVisiblePagesByPriority() + .Any(page => page.InteractionProfile.BlocksWorldActionInput); + } + #endregion #region Initialization @@ -458,6 +520,7 @@ public abstract class UiRouterBase : RouterBase()!; + TryBindPauseStackManager(); // 输出调试日志,记录UI路由器基类已初始化及使用的工厂类型 Log.Debug("UiRouterBase initialized. Factory={0}", _factory.GetType().Name); @@ -472,6 +535,24 @@ public abstract class UiRouterBase : RouterBase protected override abstract void RegisterHandlers(); + /// + /// 路由销毁时释放所有由页面持有的暂停请求。 + /// + 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 @@ -646,11 +730,14 @@ public abstract class UiRouterBase : RouterBase 0) { var next = Stack.Peek(); next.OnResume(); next.OnShow(); + SyncPauseRequest(next, isVisible: true); } } @@ -665,5 +752,102 @@ public abstract class UiRouterBase : RouterBase + /// 尝试绑定暂停栈管理器。 + /// + private void TryBindPauseStackManager() + { + try + { + _pauseStackManager = this.GetUtility(); + } + catch (InvalidOperationException) + { + _pauseStackManager = null; + } + } + + /// + /// 根据页面可见性同步暂停请求。 + /// + /// 页面行为。 + /// 页面是否应视为可见。 + 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); + } + + /// + /// 释放页面此前登记的暂停请求。 + /// + /// 目标页面。 + private void ReleasePauseRequest(IUiPageBehavior page) + { + if (_pauseStackManager is null) + return; + + if (!_pauseTokens.Remove(page, out var token)) + return; + + _pauseStackManager.Pop(token); + } + + /// + /// 按输入优先级枚举当前所有可见页面。 + /// + /// 可见页面序列。 + private IEnumerable 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; + } + + /// + /// 枚举指定层级中的可见页面,层内按最近显示优先。 + /// + /// 目标层级。 + /// 该层级中的可见页面。 + private IEnumerable 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 -} \ No newline at end of file +} diff --git a/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs b/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs index 59dc320b..b7bfafb4 100644 --- a/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs +++ b/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs @@ -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 : IUiPageBehavior /// private readonly IUiPage? _page; + /// + /// 视图可选提供的交互配置提供者。 + /// + private readonly IUiInteractionProfileProvider? _profileProvider; + + /// + /// 视图可选提供的 UI 语义动作处理器。 + /// + private readonly IUiActionHandler? _uiActionHandler; + /// /// 视图节点的所有者实例。 /// @@ -51,6 +60,8 @@ public abstract class CanvasItemUiPageBehaviorBase : 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 : IUiPageBehavior /// public bool IsVisible => Owner.Visible; + /// + /// 获取页面当前的交互配置。 + /// 若页面未提供自定义配置,则回退到层级默认值。 + /// + public UiInteractionProfile InteractionProfile => _profileProvider?.GetUiInteractionProfile(Layer) + ?? UiInteractionProfile.CreateDefault(Layer); + #endregion #region 生命周期管理 @@ -153,6 +171,8 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior Owner.SetProcess(false); Owner.SetPhysicsProcess(false); Owner.SetProcessInput(false); + Owner.SetProcessUnhandledInput(false); + Owner.SetProcessUnhandledKeyInput(false); } /// @@ -166,10 +186,14 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior _page?.OnResume(); + ApplyPauseAwareProcessingMode(); + // 恢复处理 Owner.SetProcess(true); Owner.SetPhysicsProcess(true); Owner.SetProcessInput(true); + Owner.SetProcessUnhandledInput(true); + Owner.SetProcessUnhandledKeyInput(true); } /// @@ -189,9 +213,30 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior public virtual void OnShow() { _page?.OnShow(); + ApplyPauseAwareProcessingMode(); Owner.Show(); OnResume(); } + /// + /// 尝试处理一个路由仲裁后的 UI 语义动作。 + /// + /// 当前动作。 + /// 如果视图显式处理了该动作则返回 + public virtual bool TryHandleUiAction(UiInputAction action) + { + return _uiActionHandler?.TryHandleUiAction(action) ?? false; + } + + /// + /// 根据交互配置调整节点在暂停态下的处理模式。 + /// + private void ApplyPauseAwareProcessingMode() + { + Owner.ProcessMode = InteractionProfile.ContinueProcessingWhenPaused + ? Node.ProcessModeEnum.Always + : Node.ProcessModeEnum.Pausable; + } + #endregion -} \ No newline at end of file +}