From 2e7fd1fc87e6bcbcd4485d440f05136be1e78132 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:00:33 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0UI=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=9F=BA=E7=B1=BB=E5=92=8C=E7=9B=B8=E5=85=B3=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现UiRouterBase基类,提供页面栈管理和层级UI管理功能 - 定义IUiPageBehavior接口,规范UI页面生命周期方法和状态管理 - 定义IUiRouter接口,统一UI界面导航和切换操作规范 - 实现页面栈操作功能,包括Push、Pop、Replace、Clear等方法 - 实现层级UI管理功能,支持Overlay、Modal、Toast等浮层显示 - 集成UI过渡管道,支持UI切换动画和逻辑处理 - 添加暂停令牌管理,实现页面可见性驱动的暂停控制 - 实现UI动作捕获和分发机制,支持语义动作处理 --- .../UI/IUiActionHandler.cs | 17 ++ .../UI/IUiInteractionProfileProvider.cs | 16 ++ .../UI/IUiPageBehavior.cs | 14 +- GFramework.Game.Abstractions/UI/IUiRouter.cs | 28 ++- .../UI/UiInputAction.cs | 23 +++ .../UI/UiInputActionMask.cs | 23 +++ .../UI/UiInteractionProfile.cs | 90 +++++++++ .../UI/UiPauseMode.cs | 17 ++ GFramework.Game/UI/UiRouterBase.cs | 188 +++++++++++++++++- .../UI/CanvasItemUiPageBehaviorBase.cs | 49 ++++- 10 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 GFramework.Game.Abstractions/UI/IUiActionHandler.cs create mode 100644 GFramework.Game.Abstractions/UI/IUiInteractionProfileProvider.cs create mode 100644 GFramework.Game.Abstractions/UI/UiInputAction.cs create mode 100644 GFramework.Game.Abstractions/UI/UiInputActionMask.cs create mode 100644 GFramework.Game.Abstractions/UI/UiInteractionProfile.cs create mode 100644 GFramework.Game.Abstractions/UI/UiPauseMode.cs 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 +} From 053fd4a37179393f0f25ad7905f5d50239fbee55 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:42:37 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0UI=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=9F=BA=E7=B1=BB=E5=92=8C=E6=8E=A5=E5=8F=A3=E5=AE=9A?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现UiRouterBase基类,提供页面栈管理和层级UI管理功能 - 定义IUiRouter接口,规范UI界面导航和切换操作 - 添加UI过渡管道机制,支持UI切换处理器注册和执行 - 实现页面栈操作方法,包括Push、Pop、Replace、Clear等 - 添加层级UI管理功能,支持Overlay、Modal、Toast等浮层显示 - 集成暂停管理功能,实现页面可见性驱动的暂停令牌管理 - 提供UI动作分发机制,支持语义动作捕获和分发 - 实现UI交互配置文件UiInteractionProfile,定义页面交互契约 --- GFramework.Game.Abstractions/UI/IUiRouter.cs | 13 +- .../UI/UiInteractionProfile.cs | 52 +-- .../UI/UiRouterInteractionTests.cs | 339 ++++++++++++++++++ GFramework.Game/UI/UiInteractionProfiles.cs | 59 +++ GFramework.Game/UI/UiRouterBase.cs | 37 +- .../UI/CanvasItemUiPageBehaviorBase.cs | 5 +- 6 files changed, 446 insertions(+), 59 deletions(-) create mode 100644 GFramework.Game.Tests/UI/UiRouterInteractionTests.cs create mode 100644 GFramework.Game/UI/UiInteractionProfiles.cs diff --git a/GFramework.Game.Abstractions/UI/IUiRouter.cs b/GFramework.Game.Abstractions/UI/IUiRouter.cs index 06510560..a84aff7b 100644 --- a/GFramework.Game.Abstractions/UI/IUiRouter.cs +++ b/GFramework.Game.Abstractions/UI/IUiRouter.cs @@ -194,10 +194,19 @@ public interface IUiRouter : ISystem IUiPageBehavior? GetUiActionOwner(UiInputAction action); /// - /// 尝试把语义动作分发给当前拥有该动作的页面。 + /// 尝试把语义动作分发给当前拥有该动作捕获权的页面。 /// /// 当前动作。 - /// 如果该动作已被某个页面捕获并消费,则返回 + /// 如果该动作已被某个页面捕获并完成分发,则返回 + bool TryDispatchUiAction(UiInputAction action); + + /// + /// 尝试把语义动作分发给当前拥有该动作捕获权的页面。 + /// + /// 当前动作。 + /// 如果该动作已被某个页面捕获并完成分发,则返回 + [Obsolete( + "Use TryDispatchUiAction(UiInputAction action) to emphasize dispatch semantics instead of handler success.")] bool TryHandleUiAction(UiInputAction action); /// diff --git a/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs b/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs index 32069d48..6f2cecb3 100644 --- a/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs +++ b/GFramework.Game.Abstractions/UI/UiInteractionProfile.cs @@ -1,18 +1,16 @@ using GFramework.Core.Abstractions.Pause; -using GFramework.Game.Abstractions.Enums; namespace GFramework.Game.Abstractions.UI; /// -/// 描述一个 UI 页面在输入、World 阻断与暂停上的运行时语义。 +/// 描述一个 UI 页面在输入、World 阻断与暂停上的交互契约数据。 /// +/// +/// 该类型仅承载抽象层需要共享的页面交互配置,不包含默认值工厂或动作判定等运行时策略。 +/// 运行时层可在不反向依赖 Abstractions 的前提下,通过专门的 helper 为该 DTO 提供默认值和语义判定。 +/// public sealed class UiInteractionProfile { - /// - /// 获取默认值实例。 - /// - public static UiInteractionProfile Default { get; } = new(); - /// /// 声明当前页面要捕获的语义动作集合。 /// @@ -47,44 +45,4 @@ public sealed class UiInteractionProfile /// 页面向暂停栈登记时使用的原因文本。 /// 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.Tests/UI/UiRouterInteractionTests.cs b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs new file mode 100644 index 00000000..205e1d6d --- /dev/null +++ b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs @@ -0,0 +1,339 @@ +// Copyright (c) 2026 GeWuYou +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Reflection; +using GFramework.Game.UI; + +namespace GFramework.Game.Tests.UI; + +/// +/// 验证 UI 路由输入语义、层级排序与显示恢复生命周期的回归测试。 +/// +[TestFixture] +public class UiRouterInteractionTests +{ + /// + /// 验证模态层和顶层共享同一套阻塞型默认交互配置。 + /// + [Test] + public void CreateDefault_ForModalAndTopmost_ReturnsBlockingCancelProfile() + { + // Arrange + var modal = UiInteractionProfiles.CreateDefault(UiLayer.Modal); + var topmost = UiInteractionProfiles.CreateDefault(UiLayer.Topmost); + + // Assert + Assert.Multiple(() => + { + Assert.That(modal.CapturedActions, Is.EqualTo(UiInputActionMask.Cancel)); + Assert.That(modal.BlocksWorldPointerInput, Is.True); + Assert.That(modal.BlocksWorldActionInput, Is.True); + Assert.That(topmost, Is.SameAs(modal)); + }); + } + + /// + /// 验证只要动作被页面捕获,路由分发就会返回成功,即使页面没有显式消费该动作。 + /// + [Test] + public void TryDispatchUiAction_WhenCapturedButUnhandled_ReturnsTrue() + { + // Arrange + var router = CreateRouter(); + var page = new TestUiPage("capturing-page", UiLayer.Topmost) + { + InteractionProfile = new UiInteractionProfile + { + CapturedActions = UiInputActionMask.Cancel + }, + TryHandleUiActionResult = false + }; + + router.Show(page, UiLayer.Topmost); + + // Act + var dispatched = router.TryDispatchUiAction(UiInputAction.Cancel); + + // Assert + Assert.Multiple(() => + { + Assert.That(dispatched, Is.True); + Assert.That(page.TryHandleUiActionCallCount, Is.EqualTo(1)); + }); + } + + /// + /// 验证层级页面排序使用实例自增序号,而不是依赖固定宽度的字符串顺序。 + /// + [Test] + public void GetUiActionOwner_WhenInstanceIdWidthOverflows_UsesNumericOrder() + { + // Arrange + var router = CreateRouter(); + SetInstanceCounter(router, 999998); + + var olderPage = new TestUiPage("older", UiLayer.Topmost) + { + InteractionProfile = new UiInteractionProfile + { + CapturedActions = UiInputActionMask.Cancel + } + }; + var newerPage = new TestUiPage("newer", UiLayer.Topmost) + { + InteractionProfile = new UiInteractionProfile + { + CapturedActions = UiInputActionMask.Cancel + } + }; + + router.Show(olderPage, UiLayer.Topmost); + router.Show(newerPage, UiLayer.Topmost); + + // Act + var owner = router.GetUiActionOwner(UiInputAction.Cancel); + + // Assert + Assert.That(owner, Is.SameAs(newerPage)); + } + + /// + /// 验证恢复挂起的层级页面时,不会再对依赖 OnShow 触发恢复的页面重复调用 OnResume。 + /// + [Test] + public void Resume_WhenPageResumesDuringShow_DoesNotCallResumeTwice() + { + // Arrange + var router = CreateRouter(); + var page = new TestUiPage("resumable-layer-page", UiLayer.Overlay) + { + ResumeFromShow = true + }; + + var handle = router.Show(page, UiLayer.Overlay); + router.Hide(handle, UiLayer.Overlay); + var resumeCountBeforeResume = page.OnResumeCallCount; + + // Act + router.Resume(handle, UiLayer.Overlay); + + // Assert + Assert.That(page.OnResumeCallCount, Is.EqualTo(resumeCountBeforeResume + 1)); + } + + /// + /// 验证弹出栈顶页面后,恢复下层页面时不会重复触发恢复逻辑。 + /// + [Test] + public async Task PopAsync_WhenPageResumesDuringShow_DoesNotCallResumeTwice() + { + // Arrange + var router = CreateRouter(); + var underlyingPage = new TestUiPage("underlying-page", UiLayer.Page) + { + ResumeFromShow = true + }; + var topPage = new TestUiPage("top-page", UiLayer.Page); + + await router.PushAsync(underlyingPage); + await router.PushAsync(topPage); + var resumeCountBeforePop = underlyingPage.OnResumeCallCount; + + // Act + await router.PopAsync(UiPopPolicy.Destroy); + + // Assert + Assert.That(underlyingPage.OnResumeCallCount, Is.EqualTo(resumeCountBeforePop + 1)); + } + + /// + /// 创建带有测试根节点的 UI 路由器。 + /// + /// 已绑定测试根节点的路由器实例。 + private static TestUiRouter CreateRouter() + { + var router = new TestUiRouter(); + router.BindRoot(new TestUiRoot()); + return router; + } + + /// + /// 把实例计数器调整到指定值,以便覆盖实例标识符宽度溢出的排序回归。 + /// + /// 目标路由器。 + /// 要写入的计数器值。 + private static void SetInstanceCounter(UiRouterBase router, int value) + { + var field = typeof(UiRouterBase).GetField("_instanceCounter", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null); + + field!.SetValue(router, value); + } + + /// + /// 测试用 UI 路由器实现。 + /// + private sealed class TestUiRouter : UiRouterBase + { + /// + /// 注册处理器。 + /// + protected override void RegisterHandlers() + { + } + } + + /// + /// 测试用 UI 根节点,占位记录添加/移除操作即可。 + /// + private sealed class TestUiRoot : IUiRoot + { + /// + /// 记录当前挂载的页面集合。 + /// + private readonly List _children = new(); + + /// + public void AddUiPage(IUiPageBehavior child) + { + _children.Add(child); + } + + /// + public void AddUiPage(IUiPageBehavior child, UiLayer layer, int orderInLayer = 0) + { + _children.Add(child); + } + + /// + public void RemoveUiPage(IUiPageBehavior child) + { + _children.Remove(child); + } + } + + /// + /// 可配置的测试页面,用于模拟路由器在不同交互语义下的可观察行为。 + /// + private sealed class TestUiPage : IUiPageBehavior + { + /// + /// 初始化测试页面实例。 + /// + /// 页面键。 + /// 页面层级。 + public TestUiPage(string key, UiLayer layer) + { + Key = key; + Layer = layer; + InteractionProfile = UiInteractionProfiles.Default; + IsAlive = true; + } + + /// + /// 获取或设置一个值,指示 是否要模拟 `CanvasItemUiPageBehaviorBase` 那样触发恢复逻辑。 + /// + public bool ResumeFromShow { get; init; } + + /// + /// 获取或设置页面处理动作时返回的结果。 + /// + public bool TryHandleUiActionResult { get; init; } = true; + + /// + /// 记录恢复回调触发次数。 + /// + public int OnResumeCallCount { get; private set; } + + /// + /// 记录动作处理方法调用次数。 + /// + public int TryHandleUiActionCallCount { get; private set; } + + /// + public UiHandle? Handle { get; set; } + + /// + public UiLayer Layer { get; } + + /// + public bool IsReentrant { get; init; } = true; + + /// + public object View => this; + + /// + public bool IsAlive { get; private set; } + + /// + public bool IsVisible { get; private set; } + + /// + public bool IsModal => Layer == UiLayer.Modal; + + /// + public bool BlocksInput { get; init; } + + /// + public UiInteractionProfile InteractionProfile { get; init; } + + /// + public string Key { get; } + + /// + public void OnEnter(IUiPageEnterParam? param) + { + } + + /// + public void OnExit() + { + IsAlive = false; + IsVisible = false; + } + + /// + public void OnPause() + { + } + + /// + public void OnResume() + { + OnResumeCallCount++; + } + + /// + public void OnHide() + { + IsVisible = false; + } + + /// + public void OnShow() + { + IsVisible = true; + + // The Godot page behavior resumes from OnShow(), so the router must not call OnResume() again on top. + if (ResumeFromShow) + OnResume(); + } + + /// + public bool TryHandleUiAction(UiInputAction action) + { + TryHandleUiActionCallCount++; + return TryHandleUiActionResult; + } + } +} diff --git a/GFramework.Game/UI/UiInteractionProfiles.cs b/GFramework.Game/UI/UiInteractionProfiles.cs new file mode 100644 index 00000000..224de6b6 --- /dev/null +++ b/GFramework.Game/UI/UiInteractionProfiles.cs @@ -0,0 +1,59 @@ +using GFramework.Game.Abstractions.Enums; +using GFramework.Game.Abstractions.UI; + +namespace GFramework.Game.UI; + +/// +/// 为 提供运行时默认值与语义判定。 +/// +/// +/// 该 helper 保留在运行时程序集内,避免把默认策略和输入判定逻辑放回 Abstractions。 +/// UI 页面和路由器都应通过这里共享同一套默认语义,避免层级默认值漂移。 +/// +public static class UiInteractionProfiles +{ + /// + /// 获取不捕获动作、也不阻断 World 输入的默认配置。 + /// + public static UiInteractionProfile Default { get; } = new(); + + /// + /// 获取会捕获取消动作并阻断 World 输入的阻塞型默认配置。 + /// + public static UiInteractionProfile BlockingCancel { get; } = new() + { + CapturedActions = UiInputActionMask.Cancel, + BlocksWorldPointerInput = true, + BlocksWorldActionInput = true + }; + + /// + /// 为指定层级生成默认交互配置。 + /// + /// UI 层级。 + /// 该层级的默认交互语义。 + public static UiInteractionProfile CreateDefault(UiLayer layer) + { + return layer switch + { + UiLayer.Modal or UiLayer.Topmost => BlockingCancel, + _ => Default + }; + } + + /// + /// 判断指定配置是否捕获了目标 UI 语义动作。 + /// + /// 目标配置。 + /// 要查询的动作。 + /// 如果配置声明捕获了该动作则返回 + public static bool Captures(UiInteractionProfile profile, UiInputAction action) + { + return action switch + { + UiInputAction.Cancel => (profile.CapturedActions & UiInputActionMask.Cancel) != 0, + UiInputAction.Confirm => (profile.CapturedActions & UiInputActionMask.Confirm) != 0, + _ => false + }; + } +} diff --git a/GFramework.Game/UI/UiRouterBase.cs b/GFramework.Game/UI/UiRouterBase.cs index 8bb376e9..34b64132 100644 --- a/GFramework.Game/UI/UiRouterBase.cs +++ b/GFramework.Game/UI/UiRouterBase.cs @@ -1,5 +1,3 @@ -using GFramework.Core.Abstractions.Logging; -using GFramework.Core.Abstractions.Pause; using GFramework.Core.Extensions; using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.UI; @@ -361,7 +359,6 @@ public abstract class UiRouterBase : RouterBase page.InteractionProfile.Captures(action)); + .FirstOrDefault(page => UiInteractionProfiles.Captures(page.InteractionProfile, action)); } /// @@ -475,7 +472,7 @@ public abstract class UiRouterBase : RouterBase /// 当前动作。 /// 如果已有页面捕获该动作则返回 - public bool TryHandleUiAction(UiInputAction action) + public bool TryDispatchUiAction(UiInputAction action) { var owner = GetUiActionOwner(action); if (owner is null) @@ -488,6 +485,18 @@ public abstract class UiRouterBase : RouterBase + /// 尝试将语义动作分发给当前拥有捕获权的页面。 + /// + /// 当前动作。 + /// 如果已有页面捕获该动作则返回 + [Obsolete( + "Use TryDispatchUiAction(UiInputAction action) to emphasize dispatch semantics instead of handler success.")] + public bool TryHandleUiAction(UiInputAction action) + { + return TryDispatchUiAction(action); + } + /// /// 判断当前可见 UI 是否阻断 World 指针输入。 /// @@ -735,7 +744,6 @@ public abstract class UiRouterBase : RouterBase 0) { var next = Stack.Peek(); - next.OnResume(); next.OnShow(); SyncPauseRequest(next, isVisible: true); } @@ -764,6 +772,7 @@ public abstract class UiRouterBase : RouterBase pair.Key, StringComparer.Ordinal) + // Use the numeric sequence encoded in the instance id so ordering stays correct after width overflow. + .OrderByDescending(static pair => ExtractInstanceSequence(pair.Key)) .Select(static pair => pair.Value) .Where(static page => page.IsAlive && page.IsVisible)) { @@ -849,5 +859,18 @@ public abstract class UiRouterBase : RouterBase + /// 从实例标识符中提取自增序号,供层内最近显示优先排序使用。 + /// + /// 实例标识符,预期格式为 ui_000001。 + /// 提取到的自增序号;若格式异常则返回 ,使异常值排在最后。 + private static int ExtractInstanceSequence(string instanceId) + { + return instanceId.Length > 3 && + int.TryParse(instanceId.AsSpan(3), NumberStyles.None, CultureInfo.InvariantCulture, out var sequence) + ? sequence + : int.MinValue; + } + #endregion } diff --git a/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs b/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs index b7bfafb4..565289e3 100644 --- a/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs +++ b/GFramework.Godot/UI/CanvasItemUiPageBehaviorBase.cs @@ -13,7 +13,7 @@ using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.UI; -using GFramework.Godot.Extensions; +using GFramework.Game.UI; namespace GFramework.Godot.UI; @@ -131,7 +131,7 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior /// 若页面未提供自定义配置,则回退到层级默认值。 /// public UiInteractionProfile InteractionProfile => _profileProvider?.GetUiInteractionProfile(Layer) - ?? UiInteractionProfile.CreateDefault(Layer); + ?? UiInteractionProfiles.CreateDefault(Layer); #endregion @@ -213,7 +213,6 @@ public abstract class CanvasItemUiPageBehaviorBase : IUiPageBehavior public virtual void OnShow() { _page?.OnShow(); - ApplyPauseAwareProcessingMode(); Owner.Show(); OnResume(); } From f011f3158fce5d4f731d498e04fbadf925354145 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:51:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?refactor(global):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=85=A8=E5=B1=80=E5=91=BD=E5=90=8D=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在GFramework.Game项目中添加暂停抽象接口的全局引用 - 在GFramework.Game项目中添加日志抽象接口的全局引用 - 从测试项目的UI路由交互测试文件中移除不必要的using声明 - 将测试项目的全局using声明统一到GlobalUsings.cs文件中 - 在GFramework.Godot项目中添加扩展方法的全局引用 - 优化全局命名空间引用的组织结构 --- GFramework.Game.Tests/GlobalUsings.cs | 6 +++++- GFramework.Game.Tests/UI/UiRouterInteractionTests.cs | 3 --- GFramework.Game/GlobalUsings.cs | 3 ++- GFramework.Game/UI/UiRouterBase.cs | 1 + GFramework.Godot/GlobalUsings.cs | 3 ++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/GFramework.Game.Tests/GlobalUsings.cs b/GFramework.Game.Tests/GlobalUsings.cs index 6b9d4d62..1ea2af31 100644 --- a/GFramework.Game.Tests/GlobalUsings.cs +++ b/GFramework.Game.Tests/GlobalUsings.cs @@ -16,4 +16,8 @@ global using Moq; global using System; global using System.Collections.Generic; global using System.Linq; -global using System.Threading.Tasks; \ No newline at end of file +global using System.Threading.Tasks; +global using System.Reflection; +global using GFramework.Game.Abstractions.Enums; +global using GFramework.Game.Abstractions.UI; +global using GFramework.Game.UI; diff --git a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs index 205e1d6d..160cb9ef 100644 --- a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs +++ b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs @@ -11,9 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Reflection; -using GFramework.Game.UI; - namespace GFramework.Game.Tests.UI; /// diff --git a/GFramework.Game/GlobalUsings.cs b/GFramework.Game/GlobalUsings.cs index b91413e9..687ce844 100644 --- a/GFramework.Game/GlobalUsings.cs +++ b/GFramework.Game/GlobalUsings.cs @@ -20,4 +20,5 @@ global using System.Threading.Tasks; global using System.Globalization; global using System.IO; global using System.Text.Json; -global using YamlDotNet.RepresentationModel; \ No newline at end of file +global using YamlDotNet.RepresentationModel; +global using GFramework.Core.Abstractions.Logging; diff --git a/GFramework.Game/UI/UiRouterBase.cs b/GFramework.Game/UI/UiRouterBase.cs index 34b64132..ede1b51b 100644 --- a/GFramework.Game/UI/UiRouterBase.cs +++ b/GFramework.Game/UI/UiRouterBase.cs @@ -1,3 +1,4 @@ +using GFramework.Core.Abstractions.Pause; using GFramework.Core.Extensions; using GFramework.Game.Abstractions.Enums; using GFramework.Game.Abstractions.UI; diff --git a/GFramework.Godot/GlobalUsings.cs b/GFramework.Godot/GlobalUsings.cs index 41d45db7..818d6b41 100644 --- a/GFramework.Godot/GlobalUsings.cs +++ b/GFramework.Godot/GlobalUsings.cs @@ -16,4 +16,5 @@ global using System.Collections.Generic; global using System.Linq; global using System.Threading; global using System.Threading.Tasks; -global using Godot; \ No newline at end of file +global using Godot; +global using GFramework.Godot.Extensions; From eec8dc841281ea2a39592caf4dec3842a8ee01cb Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:58:29 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test(ui):=20=E6=B7=BB=E5=8A=A0=20UI=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=99=A8=E4=BA=A4=E4=BA=92=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 验证模态层和顶层共享同一套阻塞型默认交互配置 - 验证页面捕获动作后路由分发返回成功的语义 - 验证层级页面排序使用实例自增序号而非字符串顺序 - 验证恢复挂起页面时避免重复调用 OnResume 的问题 - 验证弹出栈顶页面后恢复下层页面的重复触发问题 - 创建测试用路由器、根节点和可配置页面类 - 实现实例计数器设置功能以覆盖边界条件测试 --- .../UI/UiRouterInteractionTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs index 160cb9ef..618e289d 100644 --- a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs +++ b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs @@ -161,6 +161,7 @@ public class UiRouterInteractionTests { var router = new TestUiRouter(); router.BindRoot(new TestUiRoot()); + router.InitializeForTests(); return router; } @@ -182,6 +183,22 @@ public class UiRouterInteractionTests /// private sealed class TestUiRouter : UiRouterBase { + /// + /// 以测试专用的最小依赖集合执行路由器初始化。 + /// + public void InitializeForTests() + { + Initialize(); + } + + /// + /// 以测试最小依赖完成初始化,避免把测试绑定到完整的架构 Utility 配置上。 + /// + protected override void OnInit() + { + RegisterHandlers(); + } + /// /// 注册处理器。 /// From ef8530a379f6694a44f5e33d0896cee1bd8a72dd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:08:53 +0800 Subject: [PATCH 5/6] =?UTF-8?q?test(UiRouterInteractionTests):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0UI=E8=B7=AF=E7=94=B1=E4=BA=A4=E4=BA=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改modal验证逻辑为验证topmost元素的输入动作掩码、指针输入阻塞和操作输入阻塞属性 - 为_instanceCounter字段查找添加更详细的错误消息以提高调试可读性 - 验证_instanceCounter字段类型是否仍为int类型以确保测试稳定性 - 移除对field变量的null合并操作符调用以简化代码逻辑 --- GFramework.Game.Tests/UI/UiRouterInteractionTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs index 618e289d..dbed8ac1 100644 --- a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs +++ b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs @@ -35,7 +35,9 @@ public class UiRouterInteractionTests Assert.That(modal.CapturedActions, Is.EqualTo(UiInputActionMask.Cancel)); Assert.That(modal.BlocksWorldPointerInput, Is.True); Assert.That(modal.BlocksWorldActionInput, Is.True); - Assert.That(topmost, Is.SameAs(modal)); + Assert.That(topmost.CapturedActions, Is.EqualTo(UiInputActionMask.Cancel)); + Assert.That(topmost.BlocksWorldPointerInput, Is.True); + Assert.That(topmost.BlocksWorldActionInput, Is.True); }); } @@ -173,9 +175,10 @@ public class UiRouterInteractionTests private static void SetInstanceCounter(UiRouterBase router, int value) { var field = typeof(UiRouterBase).GetField("_instanceCounter", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.That(field, Is.Not.Null); + Assert.That(field, Is.Not.Null, "UiRouterBase._instanceCounter 字段未找到,可能发生了内部重构。"); + Assert.That(field!.FieldType, Is.EqualTo(typeof(int)), "_instanceCounter 字段类型已变化,请同步调整测试。"); - field!.SetValue(router, value); + field.SetValue(router, value); } /// From f7117254f82a2965c265416060d3dc0d96b030e4 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:16:04 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(tests):=20=E4=BF=AE=E5=A4=8DUI=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E4=BA=A4=E4=BA=92=E6=B5=8B=E8=AF=95=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=B3=A8=E9=87=8A=E5=92=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=9D=97=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为PopAsync测试方法添加了缺失的返回值XML文档注释 - 在Godot页面行为恢复逻辑中添加了正确的代码块括号 - 确保当ResumeFromShow为true时OnResume方法调用的条件判断正确 --- GFramework.Game.Tests/UI/UiRouterInteractionTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs index dbed8ac1..0920631d 100644 --- a/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs +++ b/GFramework.Game.Tests/UI/UiRouterInteractionTests.cs @@ -133,6 +133,7 @@ public class UiRouterInteractionTests /// /// 验证弹出栈顶页面后,恢复下层页面时不会重复触发恢复逻辑。 /// + /// 表示异步测试执行过程的任务。 [Test] public async Task PopAsync_WhenPageResumesDuringShow_DoesNotCallResumeTwice() { @@ -343,7 +344,9 @@ public class UiRouterInteractionTests // The Godot page behavior resumes from OnShow(), so the router must not call OnResume() again on top. if (ResumeFromShow) + { OnResume(); + } } ///