mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
Merge pull request #249 from GeWuYou/feat/ui-router-base
This commit is contained in:
commit
b4459bf600
17
GFramework.Game.Abstractions/UI/IUiActionHandler.cs
Normal file
17
GFramework.Game.Abstractions/UI/IUiActionHandler.cs
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -186,5 +186,40 @@ 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 TryDispatchUiAction(UiInputAction action);
|
||||
|
||||
/// <summary>
|
||||
/// 尝试把语义动作分发给当前拥有该动作捕获权的页面。
|
||||
/// </summary>
|
||||
/// <param name="action">当前动作。</param>
|
||||
/// <returns>如果该动作已被某个页面捕获并完成分发,则返回 <see langword="true" />。</returns>
|
||||
[Obsolete(
|
||||
"Use TryDispatchUiAction(UiInputAction action) to emphasize dispatch semantics instead of handler success.")]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
23
GFramework.Game.Abstractions/UI/UiInputAction.cs
Normal file
23
GFramework.Game.Abstractions/UI/UiInputAction.cs
Normal 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
|
||||
}
|
||||
23
GFramework.Game.Abstractions/UI/UiInputActionMask.cs
Normal file
23
GFramework.Game.Abstractions/UI/UiInputActionMask.cs
Normal 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
|
||||
}
|
||||
48
GFramework.Game.Abstractions/UI/UiInteractionProfile.cs
Normal file
48
GFramework.Game.Abstractions/UI/UiInteractionProfile.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using GFramework.Core.Abstractions.Pause;
|
||||
|
||||
namespace GFramework.Game.Abstractions.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个 UI 页面在输入、World 阻断与暂停上的交互契约数据。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该类型仅承载抽象层需要共享的页面交互配置,不包含默认值工厂或动作判定等运行时策略。
|
||||
/// 运行时层可在不反向依赖 Abstractions 的前提下,通过专门的 helper 为该 DTO 提供默认值和语义判定。
|
||||
/// </remarks>
|
||||
public sealed class UiInteractionProfile
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
17
GFramework.Game.Abstractions/UI/UiPauseMode.cs
Normal file
17
GFramework.Game.Abstractions/UI/UiPauseMode.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace GFramework.Game.Abstractions.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 定义页面显示时与暂停系统的协作模式。
|
||||
/// </summary>
|
||||
public enum UiPauseMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 页面显示不会触发暂停请求。
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 页面在可见期间持有一个暂停请求,隐藏或销毁时释放。
|
||||
/// </summary>
|
||||
WhileVisible = 1
|
||||
}
|
||||
@ -16,4 +16,8 @@ global using Moq;
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading.Tasks;
|
||||
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;
|
||||
|
||||
359
GFramework.Game.Tests/UI/UiRouterInteractionTests.cs
Normal file
359
GFramework.Game.Tests/UI/UiRouterInteractionTests.cs
Normal file
@ -0,0 +1,359 @@
|
||||
// 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.
|
||||
|
||||
namespace GFramework.Game.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 UI 路由输入语义、层级排序与显示恢复生命周期的回归测试。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class UiRouterInteractionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证模态层和顶层共享同一套阻塞型默认交互配置。
|
||||
/// </summary>
|
||||
[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.CapturedActions, Is.EqualTo(UiInputActionMask.Cancel));
|
||||
Assert.That(topmost.BlocksWorldPointerInput, Is.True);
|
||||
Assert.That(topmost.BlocksWorldActionInput, Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证只要动作被页面捕获,路由分发就会返回成功,即使页面没有显式消费该动作。
|
||||
/// </summary>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证层级页面排序使用实例自增序号,而不是依赖固定宽度的字符串顺序。
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证恢复挂起的层级页面时,不会再对依赖 OnShow 触发恢复的页面重复调用 OnResume。
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证弹出栈顶页面后,恢复下层页面时不会重复触发恢复逻辑。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试执行过程的任务。</returns>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建带有测试根节点的 UI 路由器。
|
||||
/// </summary>
|
||||
/// <returns>已绑定测试根节点的路由器实例。</returns>
|
||||
private static TestUiRouter CreateRouter()
|
||||
{
|
||||
var router = new TestUiRouter();
|
||||
router.BindRoot(new TestUiRoot());
|
||||
router.InitializeForTests();
|
||||
return router;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把实例计数器调整到指定值,以便覆盖实例标识符宽度溢出的排序回归。
|
||||
/// </summary>
|
||||
/// <param name="router">目标路由器。</param>
|
||||
/// <param name="value">要写入的计数器值。</param>
|
||||
private static void SetInstanceCounter(UiRouterBase router, int value)
|
||||
{
|
||||
var field = typeof(UiRouterBase).GetField("_instanceCounter", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(field, Is.Not.Null, "UiRouterBase._instanceCounter 字段未找到,可能发生了内部重构。");
|
||||
Assert.That(field!.FieldType, Is.EqualTo(typeof(int)), "_instanceCounter 字段类型已变化,请同步调整测试。");
|
||||
|
||||
field.SetValue(router, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试用 UI 路由器实现。
|
||||
/// </summary>
|
||||
private sealed class TestUiRouter : UiRouterBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 以测试专用的最小依赖集合执行路由器初始化。
|
||||
/// </summary>
|
||||
public void InitializeForTests()
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以测试最小依赖完成初始化,避免把测试绑定到完整的架构 Utility 配置上。
|
||||
/// </summary>
|
||||
protected override void OnInit()
|
||||
{
|
||||
RegisterHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册处理器。
|
||||
/// </summary>
|
||||
protected override void RegisterHandlers()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试用 UI 根节点,占位记录添加/移除操作即可。
|
||||
/// </summary>
|
||||
private sealed class TestUiRoot : IUiRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录当前挂载的页面集合。
|
||||
/// </summary>
|
||||
private readonly List<IUiPageBehavior> _children = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddUiPage(IUiPageBehavior child)
|
||||
{
|
||||
_children.Add(child);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddUiPage(IUiPageBehavior child, UiLayer layer, int orderInLayer = 0)
|
||||
{
|
||||
_children.Add(child);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveUiPage(IUiPageBehavior child)
|
||||
{
|
||||
_children.Remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可配置的测试页面,用于模拟路由器在不同交互语义下的可观察行为。
|
||||
/// </summary>
|
||||
private sealed class TestUiPage : IUiPageBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化测试页面实例。
|
||||
/// </summary>
|
||||
/// <param name="key">页面键。</param>
|
||||
/// <param name="layer">页面层级。</param>
|
||||
public TestUiPage(string key, UiLayer layer)
|
||||
{
|
||||
Key = key;
|
||||
Layer = layer;
|
||||
InteractionProfile = UiInteractionProfiles.Default;
|
||||
IsAlive = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置一个值,指示 <see cref="OnShow" /> 是否要模拟 `CanvasItemUiPageBehaviorBase` 那样触发恢复逻辑。
|
||||
/// </summary>
|
||||
public bool ResumeFromShow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置页面处理动作时返回的结果。
|
||||
/// </summary>
|
||||
public bool TryHandleUiActionResult { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 记录恢复回调触发次数。
|
||||
/// </summary>
|
||||
public int OnResumeCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录动作处理方法调用次数。
|
||||
/// </summary>
|
||||
public int TryHandleUiActionCallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public UiHandle? Handle { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public UiLayer Layer { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsReentrant { get; init; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public object View => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAlive { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsVisible { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsModal => Layer == UiLayer.Modal;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool BlocksInput { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public UiInteractionProfile InteractionProfile { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnEnter(IUiPageEnterParam? param)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnExit()
|
||||
{
|
||||
IsAlive = false;
|
||||
IsVisible = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnPause()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnResume()
|
||||
{
|
||||
OnResumeCallCount++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnHide()
|
||||
{
|
||||
IsVisible = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryHandleUiAction(UiInputAction action)
|
||||
{
|
||||
TryHandleUiActionCallCount++;
|
||||
return TryHandleUiActionResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
global using YamlDotNet.RepresentationModel;
|
||||
global using GFramework.Core.Abstractions.Logging;
|
||||
|
||||
59
GFramework.Game/UI/UiInteractionProfiles.cs
Normal file
59
GFramework.Game/UI/UiInteractionProfiles.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using GFramework.Game.Abstractions.Enums;
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
|
||||
namespace GFramework.Game.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 为 <see cref="UiInteractionProfile" /> 提供运行时默认值与语义判定。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该 helper 保留在运行时程序集内,避免把默认策略和输入判定逻辑放回 Abstractions。
|
||||
/// UI 页面和路由器都应通过这里共享同一套默认语义,避免层级默认值漂移。
|
||||
/// </remarks>
|
||||
public static class UiInteractionProfiles
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取不捕获动作、也不阻断 World 输入的默认配置。
|
||||
/// </summary>
|
||||
public static UiInteractionProfile Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 获取会捕获取消动作并阻断 World 输入的阻塞型默认配置。
|
||||
/// </summary>
|
||||
public static UiInteractionProfile BlockingCancel { get; } = new()
|
||||
{
|
||||
CapturedActions = UiInputActionMask.Cancel,
|
||||
BlocksWorldPointerInput = true,
|
||||
BlocksWorldActionInput = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 为指定层级生成默认交互配置。
|
||||
/// </summary>
|
||||
/// <param name="layer">UI 层级。</param>
|
||||
/// <returns>该层级的默认交互语义。</returns>
|
||||
public static UiInteractionProfile CreateDefault(UiLayer layer)
|
||||
{
|
||||
return layer switch
|
||||
{
|
||||
UiLayer.Modal or UiLayer.Topmost => BlockingCancel,
|
||||
_ => Default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断指定配置是否捕获了目标 UI 语义动作。
|
||||
/// </summary>
|
||||
/// <param name="profile">目标配置。</param>
|
||||
/// <param name="action">要查询的动作。</param>
|
||||
/// <returns>如果配置声明捕获了该动作则返回 <see langword="true" />。</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
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 +20,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 +40,11 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
|
||||
/// </summary>
|
||||
private int _instanceCounter;
|
||||
|
||||
/// <summary>
|
||||
/// 可选暂停栈管理器。
|
||||
/// </summary>
|
||||
private IPauseStackManager? _pauseStackManager;
|
||||
|
||||
/// <summary>
|
||||
/// UI根节点引用,用于添加和移除UI页面
|
||||
/// </summary>
|
||||
@ -324,6 +333,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 +341,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);
|
||||
}
|
||||
}
|
||||
@ -349,7 +360,7 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
|
||||
return;
|
||||
|
||||
page.OnShow();
|
||||
page.OnResume();
|
||||
SyncPauseRequest(page, isVisible: true);
|
||||
Log.Debug("Resume UI: instanceId={0}, layer={1}", handle.InstanceId, layer);
|
||||
}
|
||||
|
||||
@ -446,6 +457,67 @@ 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 => UiInteractionProfiles.Captures(page.InteractionProfile, action));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试将语义动作分发给当前拥有捕获权的页面。
|
||||
/// </summary>
|
||||
/// <param name="action">当前动作。</param>
|
||||
/// <returns>如果已有页面捕获该动作则返回 <see langword="true" />。</returns>
|
||||
public bool TryDispatchUiAction(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>
|
||||
/// 尝试将语义动作分发给当前拥有捕获权的页面。
|
||||
/// </summary>
|
||||
/// <param name="action">当前动作。</param>
|
||||
/// <returns>如果已有页面捕获该动作则返回 <see langword="true" />。</returns>
|
||||
[Obsolete(
|
||||
"Use TryDispatchUiAction(UiInputAction action) to emphasize dispatch semantics instead of handler success.")]
|
||||
public bool TryHandleUiAction(UiInputAction action)
|
||||
{
|
||||
return TryDispatchUiAction(action);
|
||||
}
|
||||
|
||||
/// <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 +530,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 +545,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 +616,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 +702,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 +714,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 +740,13 @@ 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 +761,117 @@ public abstract class UiRouterBase : RouterBase<IUiPageBehavior, IUiPageEnterPar
|
||||
DoPopInternal(policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试绑定暂停栈管理器。
|
||||
/// </summary>
|
||||
private void TryBindPauseStackManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pauseStackManager = this.GetUtility<IPauseStackManager>();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
_pauseStackManager = null;
|
||||
Log.Debug("PauseStackManager not available. Pause integration is disabled for the UI router.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
// 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))
|
||||
{
|
||||
yield return page;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从实例标识符中提取自增序号,供层内最近显示优先排序使用。
|
||||
/// </summary>
|
||||
/// <param name="instanceId">实例标识符,预期格式为 <c>ui_000001</c>。</param>
|
||||
/// <returns>提取到的自增序号;若格式异常则返回 <see cref="int.MinValue" />,使异常值排在最后。</returns>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
global using Godot;
|
||||
global using GFramework.Godot.Extensions;
|
||||
|
||||
@ -13,8 +13,7 @@
|
||||
|
||||
using GFramework.Game.Abstractions.Enums;
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
using GFramework.Godot.Extensions;
|
||||
using Godot;
|
||||
using GFramework.Game.UI;
|
||||
|
||||
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)
|
||||
?? UiInteractionProfiles.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>
|
||||
@ -193,5 +217,25 @@ public abstract class CanvasItemUiPageBehaviorBase<T> : IUiPageBehavior
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user