mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
docs(game): 收口场景与UI专题文档
- 重写 game/scene 与 game/ui 专题页,按当前 router、factory、root、输入与暂停语义说明接入方式\n- 更新 documentation-governance-and-refresh 的 tracking 与 trace,记录 RP-006 与后续 source-generators 核对重点\n- 验证 docs 站点构建通过
This commit is contained in:
parent
9ccfed3ad9
commit
da707c7b4f
@ -7,19 +7,20 @@
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`DOCUMENTATION-GOVERNANCE-REFRESH-RP-005`
|
||||
- 恢复点编号:`DOCUMENTATION-GOVERNANCE-REFRESH-RP-006`
|
||||
- 当前阶段:`Phase 3`
|
||||
- 当前焦点:
|
||||
- 已完成 `docs/zh-CN/core/events.md`、`property.md` 与 `logging.md` 的专题页重写
|
||||
- 已按源码与测试复核 `docs/zh-CN/core/state-management.md`、`coroutine.md`,当前内容与实现基本一致,无需再做
|
||||
机械改写
|
||||
- 下一轮需要把重心转到 `docs/zh-CN/game/*` 与 `docs/zh-CN/source-generators/*` 的专题页核对
|
||||
- 已完成 `docs/zh-CN/game/scene.md` 与 `ui.md` 的专题页重写,当前内容已回到“项目自接 factory/root + router 基类”的真实边界
|
||||
- 下一轮需要把重心转到 `docs/zh-CN/source-generators/*` 的专题页核对
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- 文档治理规则已收口到仓库规范,README、站点入口与采用链路不再依赖旧文档自证
|
||||
- 高优先级模块入口与 `core` 关键专题页已回到可作为默认导航入口的状态,本轮计划中的 `core` 剩余高风险页面已完成收口
|
||||
- 当前主题仍是 active topic,因为 `game` 与 `source-generators` 栏目下仍可能包含与实现漂移的旧内容
|
||||
- 当前主题仍是 active topic,因为 `source-generators` 栏目下仍可能包含与实现漂移的旧内容
|
||||
|
||||
## 当前活跃事实
|
||||
|
||||
@ -39,11 +40,17 @@
|
||||
- `docs/zh-CN/core/property.md` 已明确记录 `BindableProperty<T>.Comparer` 的闭合泛型级共享语义,避免文档继续误导读者把
|
||||
`WithComparer(...)` 当成实例级配置
|
||||
- `docs/zh-CN/core/state-management.md` 与 `coroutine.md` 已按当前 runtime / 测试重新核对,当前内容可继续保留
|
||||
- `docs/zh-CN/game/scene.md` 已改成“真实公开入口、场景栈语义、factory/root 装配、过渡处理器与守卫扩展点”的结构,
|
||||
不再暗示框架自带统一场景注册与完整引擎装配
|
||||
- `docs/zh-CN/game/ui.md` 已改成“Page 栈、layer UI、输入动作仲裁、World 阻断与暂停语义”的结构,明确 `Show(...)`
|
||||
不适用于 `UiLayer.Page`
|
||||
- 本轮重写后再次执行 `cd docs && bun run build` 通过,当前 `game` 栏目入口与专题页改动没有破坏站点构建
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 旧专题页示例失真风险:`docs/zh-CN/game/*` 与 `source-generators/*` 中仍可能保留看似合理但与真实实现不一致的示例
|
||||
- 缓解措施:继续按源码、测试、`*.csproj` 与 `ai-libs/` 下已验证参考实现核对,不把旧文档当事实来源
|
||||
- 缓解措施:`game/scene.md` 与 `ui.md` 已完成收口;继续按源码、测试、`*.csproj` 与 `ai-libs/` 下已验证参考实现核对
|
||||
`source-generators/*`,不把旧文档当事实来源
|
||||
- 采用路径误导风险:根聚合包与模块边界若再次被写错,会继续误导消费者的包选择
|
||||
- 缓解措施:保持“源码与包关系优先”的证据顺序,改动采用说明时同步核对包依赖与生成器 wiring
|
||||
- Active 入口回膨胀风险:后续若把栏目级重写过程直接追加到 active 文档,会再次拖慢恢复
|
||||
@ -66,6 +73,7 @@
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 继续核对 `docs/zh-CN/game/*`,优先处理仍引用旧安装方式、旧状态系统或旧 UI / Scene 接法的页面
|
||||
2. 再推进 `docs/zh-CN/source-generators/*`,重点核对生成器 wiring、包关系与最小接入示例
|
||||
1. 继续核对 `docs/zh-CN/source-generators/*`,优先处理仍引用旧初始化方式、旧聚合包名或过时生成器 wiring 的页面
|
||||
2. 重点复核 `priority-generator.md`、`context-aware-generator.md` 与 Godot 相关生成器页面,确认示例仍与当前 runtime /
|
||||
generator 入口一致
|
||||
3. 若 active trace 再积累新的已完成阶段,按恢复点粒度迁入 `archive/traces/`,避免默认启动入口再次膨胀
|
||||
|
||||
@ -127,3 +127,28 @@
|
||||
1. 继续核对 `docs/zh-CN/game/*`,优先处理仍引用旧安装方式、旧状态系统或旧 UI / Scene 接法的页面
|
||||
2. 再推进 `docs/zh-CN/source-generators/*`,重点核对生成器 wiring、包关系与最小接入示例
|
||||
3. 若 active trace 继续累计多个已完成恢复点,按 `archive/traces/` 粒度归档旧阶段细节
|
||||
|
||||
### 阶段:Game Scene / UI 专题页收口(RP-006)
|
||||
|
||||
- 依据 `documentation-governance-and-refresh` active tracking 的下一步,优先复核 `docs/zh-CN/game/scene.md` 与
|
||||
`docs/zh-CN/game/ui.md`
|
||||
- 对照 `GFramework.Game.Abstractions/Scene/*`、`GFramework.Game.Abstractions/UI/*`、`GFramework.Game/Scene/SceneRouterBase.cs`、
|
||||
`GFramework.Game/UI/UiRouterBase.cs`、`GFramework.Game/README.md` 与 `ai-libs/CoreGrid` 参考接法后确认:
|
||||
- `scene.md` 仍把场景系统写成框架自带完整注册/装配的一体化方案,没有突出 `ISceneFactory`、`ISceneRoot` 和项目侧
|
||||
router 派生类的责任边界
|
||||
- `ui.md` 仍按旧教程式结构展开,没有清楚区分 `Page` 栈与 `Overlay/Modal/Toast/Topmost` 层级 UI,也缺少当前
|
||||
`UiInteractionProfile`、`TryDispatchUiAction(...)` 与 World 输入阻断语义
|
||||
- 重写 `scene.md`,使其回到“当前公开入口、场景栈语义、最小接入路径、守卫/过渡处理器扩展点、与旧写法的边界”的结构
|
||||
- 重写 `ui.md`,使其回到“页面栈与层级 UI 分流、输入仲裁、暂停/阻断语义、最小接入路径、扩展点”的结构
|
||||
- 新版两页都明确了:factory、root、引擎节点与注册表仍由项目或适配层提供,框架当前提供的是 router 基类与通用编排
|
||||
|
||||
### 验证(RP-006)
|
||||
|
||||
- `cd docs && bun run build`
|
||||
|
||||
### 下一步(RP-006)
|
||||
|
||||
1. 继续核对 `docs/zh-CN/source-generators/*`,优先处理仍引用旧初始化方式、旧聚合包名或过时 generator wiring 的页面
|
||||
2. 重点复核 `priority-generator.md`、`context-aware-generator.md` 与 Godot 相关生成器页面,确认示例仍与当前 runtime /
|
||||
generator 入口一致
|
||||
3. 若 `source-generators` 出现多页连续收口结果,再按恢复点粒度整理 active trace,避免默认入口继续膨胀
|
||||
|
||||
@ -1,658 +1,224 @@
|
||||
---
|
||||
title: 场景系统
|
||||
description: 场景系统提供了完整的场景生命周期管理、路由导航和转换控制功能。
|
||||
description: 说明 GFramework.Game 场景路由的当前入口、项目侧接入职责与扩展边界。
|
||||
---
|
||||
|
||||
# 场景系统
|
||||
|
||||
## 概述
|
||||
`GFramework.Game` 的场景系统是“路由基类 + 场景契约 + 过渡管线”的组合,不是替你包办注册表、节点树和引擎对象装配的
|
||||
一体化方案。
|
||||
|
||||
场景系统是 GFramework.Game 中用于管理游戏场景的核心组件。它提供了场景的加载、卸载、切换、暂停和恢复等完整生命周期管理,以及基于栈的场景导航机制。
|
||||
框架当前负责的是:
|
||||
|
||||
通过场景系统,你可以轻松实现场景之间的平滑切换,管理场景栈(如主菜单 -> 游戏 -> 暂停菜单),并在场景转换时执行自定义逻辑。
|
||||
- 场景栈管理
|
||||
- `Load -> Enter -> Pause -> Resume -> Exit -> Unload` 生命周期顺序
|
||||
- 路由守卫与过渡处理器执行时机
|
||||
- `SceneRouterBase` 这一层的默认切换编排
|
||||
|
||||
**主要特性**:
|
||||
项目或引擎适配层仍然需要自己提供:
|
||||
|
||||
- 完整的场景生命周期管理
|
||||
- 基于栈的场景导航
|
||||
- 场景转换管道和钩子
|
||||
- 路由守卫(Route Guard)
|
||||
- 场景工厂和行为模式
|
||||
- 异步加载和卸载
|
||||
- `ISceneFactory`
|
||||
- `ISceneRoot`
|
||||
- 具体的 `ISceneBehavior` / `IScene`
|
||||
- 场景键和资源、节点、预制体之间的映射关系
|
||||
|
||||
## 核心概念
|
||||
如果你把它理解为“可复用的场景路由底座”而不是“现成的完整场景框架”,后续接法会更贴近源码。
|
||||
|
||||
### 场景接口
|
||||
## 当前公开入口
|
||||
|
||||
`IScene` 定义了场景的完整生命周期:
|
||||
### `IScene`
|
||||
|
||||
业务场景生命周期契约,描述加载、进入、暂停、恢复、退出、卸载这六个阶段。
|
||||
|
||||
### `ISceneBehavior`
|
||||
|
||||
路由器直接操作的运行时对象。它除了场景生命周期外,还携带:
|
||||
|
||||
- `Key`
|
||||
- `Original`
|
||||
- `IsLoaded`
|
||||
- `IsActive`
|
||||
- `IsTransitioning`
|
||||
|
||||
如果你的引擎对象本身就能承担这些语义,可以直接实现 `ISceneBehavior`。如果你更想把业务逻辑放在纯 C# 场景类中,也可以由
|
||||
项目侧行为包装器承载真正的引擎节点,再把业务场景逻辑委托出去。
|
||||
|
||||
### `ISceneRouter`
|
||||
|
||||
当前公开的路由接口,重点入口是:
|
||||
|
||||
- `BindRoot(ISceneRoot root)`
|
||||
- `ReplaceAsync(string sceneKey, ISceneEnterParam? param = null)`
|
||||
- `PushAsync(string sceneKey, ISceneEnterParam? param = null)`
|
||||
- `PopAsync()`
|
||||
- `ClearAsync()`
|
||||
- `Contains(string sceneKey)`
|
||||
|
||||
### `SceneRouterBase`
|
||||
|
||||
`GFramework.Game` 提供的默认实现基类。它会:
|
||||
|
||||
- 在 `OnInit()` 中获取 `ISceneFactory`
|
||||
- 通过 `SemaphoreSlim` 串行化切换
|
||||
- 调用守卫、过渡处理器和环绕处理器
|
||||
- 维护场景栈与恢复顺序
|
||||
|
||||
通常项目不会直接修改框架里的 `SceneRouterBase`,而是在项目层继承它。
|
||||
|
||||
## 场景栈的真实语义
|
||||
|
||||
按当前实现,最常用的三个动作语义如下:
|
||||
|
||||
- `ReplaceAsync`
|
||||
- 清空整个栈,再加载并进入目标场景。
|
||||
- `PushAsync`
|
||||
- 先检查守卫,再创建新场景,挂到 `ISceneRoot`,执行 `OnLoadAsync()`,暂停当前栈顶,最后让新场景 `OnEnterAsync()`。
|
||||
- `PopAsync`
|
||||
- 对栈顶执行离开检查,通过后退出并卸载它,再从 `ISceneRoot` 移除,然后恢复新的栈顶。
|
||||
|
||||
当前还有两个容易被旧文档误导的点:
|
||||
|
||||
- `SceneRouterBase` 默认不允许同一个 `sceneKey` 在栈中重复存在;内部会先做 `Contains(sceneKey)` 检查
|
||||
- 框架不会替你实现“场景键 -> 具体场景实例”的注册逻辑;这仍然是 `ISceneFactory` 或项目注册表的职责
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
推荐按下面的顺序接入。
|
||||
|
||||
### 1. 准备项目自己的 router
|
||||
|
||||
```csharp
|
||||
public interface IScene
|
||||
using GFramework.Game.Scene;
|
||||
using LoggingTransitionHandler = GFramework.Game.Scene.Handler.LoggingTransitionHandler;
|
||||
|
||||
public sealed class GameSceneRouter : SceneRouterBase
|
||||
{
|
||||
ValueTask OnLoadAsync(ISceneEnterParam? param); // 加载资源
|
||||
ValueTask OnEnterAsync(); // 进入场景
|
||||
ValueTask OnPauseAsync(); // 暂停场景
|
||||
ValueTask OnResumeAsync(); // 恢复场景
|
||||
ValueTask OnExitAsync(); // 退出场景
|
||||
ValueTask OnUnloadAsync(); // 卸载资源
|
||||
}
|
||||
```
|
||||
|
||||
### 场景路由
|
||||
|
||||
`ISceneRouter` 管理场景的导航和切换:
|
||||
|
||||
```csharp
|
||||
public interface ISceneRouter : ISystem
|
||||
{
|
||||
ISceneBehavior? Current { get; } // 当前场景
|
||||
string? CurrentKey { get; } // 当前场景键
|
||||
IEnumerable<ISceneBehavior> Stack { get; } // 场景栈
|
||||
bool IsTransitioning { get; } // 是否正在切换
|
||||
|
||||
ValueTask ReplaceAsync(string sceneKey, ISceneEnterParam? param = null);
|
||||
ValueTask PushAsync(string sceneKey, ISceneEnterParam? param = null);
|
||||
ValueTask PopAsync();
|
||||
ValueTask ClearAsync();
|
||||
}
|
||||
```
|
||||
|
||||
### 场景行为
|
||||
|
||||
`ISceneBehavior` 封装了场景的具体实现和引擎集成:
|
||||
|
||||
```csharp
|
||||
public interface ISceneBehavior
|
||||
{
|
||||
string Key { get; } // 场景唯一标识
|
||||
IScene Scene { get; } // 场景实例
|
||||
ValueTask LoadAsync(ISceneEnterParam? param);
|
||||
ValueTask UnloadAsync();
|
||||
}
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 定义场景
|
||||
|
||||
实现 `IScene` 接口创建场景:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Scene;
|
||||
|
||||
public class MainMenuScene : IScene
|
||||
{
|
||||
public async ValueTask OnLoadAsync(ISceneEnterParam? param)
|
||||
protected override void RegisterHandlers()
|
||||
{
|
||||
// 加载场景资源
|
||||
Console.WriteLine("加载主菜单资源");
|
||||
await Task.Delay(100); // 模拟加载
|
||||
}
|
||||
|
||||
public async ValueTask OnEnterAsync()
|
||||
{
|
||||
// 进入场景
|
||||
Console.WriteLine("进入主菜单");
|
||||
// 显示 UI、播放音乐等
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask OnPauseAsync()
|
||||
{
|
||||
// 暂停场景
|
||||
Console.WriteLine("暂停主菜单");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask OnResumeAsync()
|
||||
{
|
||||
// 恢复场景
|
||||
Console.WriteLine("恢复主菜单");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask OnExitAsync()
|
||||
{
|
||||
// 退出场景
|
||||
Console.WriteLine("退出主菜单");
|
||||
// 隐藏 UI、停止音乐等
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask OnUnloadAsync()
|
||||
{
|
||||
// 卸载场景资源
|
||||
Console.WriteLine("卸载主菜单资源");
|
||||
await Task.Delay(50); // 模拟卸载
|
||||
RegisterHandler(new LoggingTransitionHandler());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注册场景
|
||||
这一步只解决“切换流程怎么跑”,不解决“场景从哪来”。
|
||||
|
||||
在场景注册表中注册场景:
|
||||
### 2. 提供 `ISceneFactory`
|
||||
|
||||
`SceneRouterBase` 会在初始化阶段通过 `GetUtility<ISceneFactory>()` 获取工厂,因此项目必须先注册它。
|
||||
|
||||
工厂的职责通常是:
|
||||
|
||||
- 按 `sceneKey` 找到项目自己的注册表、预制体或资源描述
|
||||
- 创建或获取 `ISceneBehavior`
|
||||
- 决定行为对象如何包裹引擎节点与业务场景逻辑
|
||||
|
||||
如果项目里已经有场景注册表,也建议把它收口在 factory 内部,而不是让文档继续暗示框架自带统一注册中心。
|
||||
|
||||
### 3. 提供 `ISceneRoot`
|
||||
|
||||
`ISceneRoot` 只做两件事:
|
||||
|
||||
- `AddScene(ISceneBehavior scene)`
|
||||
- `RemoveScene(ISceneBehavior scene)`
|
||||
|
||||
也就是说,root 是“挂载/移除容器”,不是路由器本身。当前 `ai-libs/` 参考实现也是在项目自己的 Godot 节点里实现
|
||||
`ISceneRoot`,并在 `_Ready()` 时调用 `BindRoot(this)`。
|
||||
|
||||
### 4. 把 router 和 factory 装进架构
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Scene;
|
||||
architecture.RegisterUtility<ISceneFactory>(new GameSceneFactory());
|
||||
architecture.RegisterSystem(new GameSceneRouter());
|
||||
```
|
||||
|
||||
public class GameSceneRegistry : IGameSceneRegistry
|
||||
如果你的项目还需要动画、黑幕或 loading 过渡,可以继续在 `RegisterHandlers()` 里补自己的处理器。
|
||||
|
||||
### 5. 在 root 就绪后绑定
|
||||
|
||||
```csharp
|
||||
public sealed class SceneRoot : Node2D, ISceneRoot
|
||||
{
|
||||
private readonly Dictionary<string, Type> _scenes = new();
|
||||
[GetSystem] private ISceneRouter _sceneRouter = null!;
|
||||
|
||||
public GameSceneRegistry()
|
||||
public override void _Ready()
|
||||
{
|
||||
// 注册场景
|
||||
Register("MainMenu", typeof(MainMenuScene));
|
||||
Register("Gameplay", typeof(GameplayScene));
|
||||
Register("Pause", typeof(PauseScene));
|
||||
__InjectContextBindings_Generated();
|
||||
_sceneRouter.BindRoot(this);
|
||||
}
|
||||
|
||||
public void Register(string key, Type sceneType)
|
||||
public void AddScene(ISceneBehavior scene)
|
||||
{
|
||||
_scenes[key] = sceneType;
|
||||
// 项目侧决定如何把 scene.Original 挂进引擎节点树
|
||||
}
|
||||
|
||||
public Type? GetSceneType(string key)
|
||||
public void RemoveScene(ISceneBehavior scene)
|
||||
{
|
||||
return _scenes.TryGetValue(key, out var type) ? type : null;
|
||||
// 项目侧决定如何移除并释放引擎对象
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 切换场景
|
||||
|
||||
使用场景路由进行导航:
|
||||
### 6. 从业务代码发起导航
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class GameController : IController
|
||||
{
|
||||
public async Task StartGame()
|
||||
await sceneRouter.ReplaceAsync(
|
||||
"Gameplay",
|
||||
new GameplayEnterParam
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
Seed = "new-game"
|
||||
});
|
||||
|
||||
// 替换当前场景(清空场景栈)
|
||||
await sceneRouter.ReplaceAsync("Gameplay");
|
||||
}
|
||||
|
||||
public async Task ShowPauseMenu()
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
|
||||
// 压入新场景(保留当前场景)
|
||||
await sceneRouter.PushAsync("Pause");
|
||||
}
|
||||
|
||||
public async Task ClosePauseMenu()
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
|
||||
// 弹出当前场景(恢复上一个场景)
|
||||
await sceneRouter.PopAsync();
|
||||
}
|
||||
}
|
||||
await sceneRouter.PushAsync("PauseMenu");
|
||||
await sceneRouter.PopAsync();
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 场景参数传递
|
||||
|
||||
通过 `ISceneEnterParam` 传递数据:
|
||||
|
||||
```csharp
|
||||
// 定义场景参数
|
||||
public class GameplayEnterParam : ISceneEnterParam
|
||||
{
|
||||
public int Level { get; set; }
|
||||
public string Difficulty { get; set; }
|
||||
}
|
||||
|
||||
// 在场景中接收参数
|
||||
public class GameplayScene : IScene
|
||||
{
|
||||
private int _level;
|
||||
private string _difficulty;
|
||||
|
||||
public async ValueTask OnLoadAsync(ISceneEnterParam? param)
|
||||
{
|
||||
if (param is GameplayEnterParam gameplayParam)
|
||||
{
|
||||
_level = gameplayParam.Level;
|
||||
_difficulty = gameplayParam.Difficulty;
|
||||
Console.WriteLine($"加载关卡 {_level},难度: {_difficulty}");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ... 其他生命周期方法
|
||||
}
|
||||
|
||||
// 切换场景时传递参数
|
||||
await sceneRouter.ReplaceAsync("Gameplay", new GameplayEnterParam
|
||||
{
|
||||
Level = 1,
|
||||
Difficulty = "Normal"
|
||||
});
|
||||
```
|
||||
## 扩展点
|
||||
|
||||
### 路由守卫
|
||||
|
||||
使用路由守卫控制场景切换:
|
||||
如果你要在进入或离开场景前做业务检查,实现 `ISceneRouteGuard`:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Scene;
|
||||
- `CanEnterAsync(string sceneKey, ISceneEnterParam? param)`
|
||||
- `CanLeaveAsync(string sceneKey)`
|
||||
|
||||
public class SaveGameGuard : ISceneRouteGuard
|
||||
{
|
||||
public async ValueTask<bool> CanLeaveAsync(
|
||||
ISceneBehavior from,
|
||||
string toKey,
|
||||
ISceneEnterParam? param)
|
||||
{
|
||||
// 离开游戏场景前检查是否需要保存
|
||||
if (from.Key == "Gameplay")
|
||||
{
|
||||
var needsSave = CheckIfNeedsSave();
|
||||
if (needsSave)
|
||||
{
|
||||
await SaveGameAsync();
|
||||
}
|
||||
}
|
||||
适合放:
|
||||
|
||||
return true; // 允许离开
|
||||
}
|
||||
- 未保存进度拦截
|
||||
- 场景解锁条件检查
|
||||
- 新手引导流程限制
|
||||
|
||||
public async ValueTask<bool> CanEnterAsync(
|
||||
string toKey,
|
||||
ISceneEnterParam? param)
|
||||
{
|
||||
// 进入场景前的验证
|
||||
if (toKey == "Gameplay")
|
||||
{
|
||||
// 检查是否满足进入条件
|
||||
var canEnter = CheckGameplayRequirements();
|
||||
return canEnter;
|
||||
}
|
||||
### 过渡处理器
|
||||
|
||||
return true;
|
||||
}
|
||||
`SceneRouterBase` 公开了:
|
||||
|
||||
private bool CheckIfNeedsSave() => true;
|
||||
private async Task SaveGameAsync() => await Task.Delay(100);
|
||||
private bool CheckGameplayRequirements() => true;
|
||||
}
|
||||
- `RegisterHandler(ISceneTransitionHandler handler, SceneTransitionHandlerOptions? options = null)`
|
||||
- `RegisterAroundHandler(ISceneAroundTransitionHandler handler, SceneTransitionHandlerOptions? options = null)`
|
||||
|
||||
// 注册守卫
|
||||
sceneRouter.AddGuard(new SaveGameGuard());
|
||||
```
|
||||
适合放:
|
||||
|
||||
### 场景转换处理器
|
||||
- 日志
|
||||
- 黑幕、淡入淡出或 loading 动画
|
||||
- 切场前后的指标采集
|
||||
|
||||
自定义场景转换逻辑:
|
||||
如果你的项目已经有复杂引擎过渡逻辑,优先把这些逻辑放进 handler,而不是把 `SceneRouterBase` 派生类本身做成巨型协调器。
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Scene;
|
||||
## 与旧写法的边界
|
||||
|
||||
public class FadeTransitionHandler : ISceneTransitionHandler
|
||||
{
|
||||
public async ValueTask OnBeforeLoadAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"准备加载场景: {@event.ToKey}");
|
||||
// 显示加载画面
|
||||
await ShowLoadingScreen();
|
||||
}
|
||||
下面这些说法不再适合作为默认接入指导:
|
||||
|
||||
public async ValueTask OnAfterLoadAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"场景加载完成: {@event.ToKey}");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
- “框架会帮你直接注册和发现所有场景类型”
|
||||
- “只要写一个 `IScene` 就能自动接入所有引擎对象”
|
||||
- “场景系统本身自带统一注册表和完整项目结构”
|
||||
|
||||
public async ValueTask OnBeforeEnterAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"准备进入场景: {@event.ToKey}");
|
||||
// 播放淡入动画
|
||||
await PlayFadeIn();
|
||||
}
|
||||
当前更准确的理解是:
|
||||
|
||||
public async ValueTask OnAfterEnterAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"已进入场景: {@event.ToKey}");
|
||||
// 隐藏加载画面
|
||||
await HideLoadingScreen();
|
||||
}
|
||||
- 框架提供通用场景切换编排
|
||||
- 项目提供 factory、root、资源映射和具体引擎装配
|
||||
- 文档中的最小示例应优先说明职责边界,而不是继续堆叠大而全教程
|
||||
|
||||
public async ValueTask OnBeforeExitAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"准备退出场景: {@event.FromKey}");
|
||||
// 播放淡出动画
|
||||
await PlayFadeOut();
|
||||
}
|
||||
## 推荐阅读
|
||||
|
||||
public async ValueTask OnAfterExitAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"已退出场景: {@event.FromKey}");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ShowLoadingScreen() => await Task.Delay(100);
|
||||
private async Task HideLoadingScreen() => await Task.Delay(100);
|
||||
private async Task PlayFadeIn() => await Task.Delay(200);
|
||||
private async Task PlayFadeOut() => await Task.Delay(200);
|
||||
}
|
||||
|
||||
// 注册转换处理器
|
||||
sceneRouter.AddTransitionHandler(new FadeTransitionHandler());
|
||||
```
|
||||
|
||||
### 场景栈管理
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class SceneNavigationController : IController
|
||||
{
|
||||
public async Task NavigateToSettings()
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
|
||||
// 检查场景是否已在栈中
|
||||
if (sceneRouter.Contains("Settings"))
|
||||
{
|
||||
Console.WriteLine("设置场景已打开");
|
||||
return;
|
||||
}
|
||||
|
||||
// 压入设置场景
|
||||
await sceneRouter.PushAsync("Settings");
|
||||
}
|
||||
|
||||
public void ShowSceneStack()
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
|
||||
Console.WriteLine("当前场景栈:");
|
||||
foreach (var scene in sceneRouter.Stack)
|
||||
{
|
||||
Console.WriteLine($"- {scene.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReturnToMainMenu()
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
|
||||
// 清空所有场景并加载主菜单
|
||||
await sceneRouter.ClearAsync();
|
||||
await sceneRouter.ReplaceAsync("MainMenu");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景加载进度
|
||||
|
||||
```csharp
|
||||
public class GameplayScene : IScene
|
||||
{
|
||||
public async ValueTask OnLoadAsync(ISceneEnterParam? param)
|
||||
{
|
||||
var resourceManager = GetResourceManager();
|
||||
|
||||
// 加载多个资源并报告进度
|
||||
var resources = new[]
|
||||
{
|
||||
"textures/player.png",
|
||||
"textures/enemy.png",
|
||||
"audio/bgm.mp3",
|
||||
"models/level.obj"
|
||||
};
|
||||
|
||||
for (int i = 0; i < resources.Length; i++)
|
||||
{
|
||||
await resourceManager.LoadAsync<object>(resources[i]);
|
||||
|
||||
// 报告进度
|
||||
var progress = (i + 1) / (float)resources.Length;
|
||||
ReportProgress(progress);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportProgress(float progress)
|
||||
{
|
||||
// 发送进度事件
|
||||
Console.WriteLine($"加载进度: {progress * 100:F0}%");
|
||||
}
|
||||
|
||||
// ... 其他生命周期方法
|
||||
}
|
||||
```
|
||||
|
||||
### 场景预加载
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class PreloadController : IController
|
||||
{
|
||||
public async Task PreloadNextLevel()
|
||||
{
|
||||
var sceneFactory = this.GetUtility<ISceneFactory>();
|
||||
|
||||
// 预加载下一关场景
|
||||
var scene = sceneFactory.Create("Level2");
|
||||
await scene.OnLoadAsync(null);
|
||||
|
||||
Console.WriteLine("下一关预加载完成");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **在 OnLoad 中加载资源,在 OnUnload 中释放**:保持资源管理清晰
|
||||
```csharp
|
||||
public async ValueTask OnLoadAsync(ISceneEnterParam? param)
|
||||
{
|
||||
_texture = await LoadTextureAsync("player.png");
|
||||
}
|
||||
|
||||
public async ValueTask OnUnloadAsync()
|
||||
{
|
||||
_texture?.Dispose();
|
||||
_texture = null;
|
||||
}
|
||||
```
|
||||
|
||||
2. **使用 Push/Pop 管理临时场景**:如暂停菜单、设置界面
|
||||
```csharp
|
||||
// 打开暂停菜单(保留游戏场景)
|
||||
await sceneRouter.PushAsync("Pause");
|
||||
|
||||
// 关闭暂停菜单(恢复游戏场景)
|
||||
await sceneRouter.PopAsync();
|
||||
```
|
||||
|
||||
3. **使用 Replace 切换主要场景**:如从菜单到游戏
|
||||
```csharp
|
||||
// 开始游戏(清空场景栈)
|
||||
await sceneRouter.ReplaceAsync("Gameplay");
|
||||
```
|
||||
|
||||
4. **在 OnPause/OnResume 中管理状态**:暂停和恢复游戏逻辑
|
||||
```csharp
|
||||
public async ValueTask OnPauseAsync()
|
||||
{
|
||||
// 暂停游戏逻辑
|
||||
_gameTimer.Pause();
|
||||
_audioSystem.PauseBGM();
|
||||
}
|
||||
|
||||
public async ValueTask OnResumeAsync()
|
||||
{
|
||||
// 恢复游戏逻辑
|
||||
_gameTimer.Resume();
|
||||
_audioSystem.ResumeBGM();
|
||||
}
|
||||
```
|
||||
|
||||
5. **使用路由守卫处理业务逻辑**:如保存检查、权限验证
|
||||
```csharp
|
||||
public async ValueTask<bool> CanLeaveAsync(...)
|
||||
{
|
||||
if (HasUnsavedChanges())
|
||||
{
|
||||
var confirmed = await ShowSaveDialog();
|
||||
if (confirmed)
|
||||
{
|
||||
await SaveAsync();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
6. **避免在场景切换时阻塞**:使用异步操作
|
||||
```csharp
|
||||
✓ await sceneRouter.ReplaceAsync("Gameplay");
|
||||
✗ sceneRouter.ReplaceAsync("Gameplay").Wait(); // 可能死锁
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题:Replace、Push、Pop 有什么区别?
|
||||
|
||||
**解答**:
|
||||
|
||||
- **Replace**:清空场景栈,加载新场景(用于主要场景切换)
|
||||
- **Push**:压入新场景,暂停当前场景(用于临时场景)
|
||||
- **Pop**:弹出当前场景,恢复上一个场景(用于关闭临时场景)
|
||||
|
||||
```csharp
|
||||
// 场景栈示例
|
||||
await sceneRouter.ReplaceAsync("MainMenu"); // [MainMenu]
|
||||
await sceneRouter.PushAsync("Settings"); // [MainMenu, Settings]
|
||||
await sceneRouter.PushAsync("About"); // [MainMenu, Settings, About]
|
||||
await sceneRouter.PopAsync(); // [MainMenu, Settings]
|
||||
await sceneRouter.PopAsync(); // [MainMenu]
|
||||
```
|
||||
|
||||
### 问题:如何在场景之间传递数据?
|
||||
|
||||
**解答**:
|
||||
有几种方式:
|
||||
|
||||
1. **通过场景参数**:
|
||||
|
||||
```csharp
|
||||
await sceneRouter.ReplaceAsync("Gameplay", new GameplayEnterParam
|
||||
{
|
||||
Level = 5
|
||||
});
|
||||
```
|
||||
|
||||
2. **通过 Model**:
|
||||
|
||||
```csharp
|
||||
var gameModel = this.GetModel<GameModel>();
|
||||
gameModel.CurrentLevel = 5;
|
||||
await sceneRouter.ReplaceAsync("Gameplay");
|
||||
```
|
||||
|
||||
3. **通过事件**:
|
||||
|
||||
```csharp
|
||||
this.SendEvent(new LevelSelectedEvent { Level = 5 });
|
||||
await sceneRouter.ReplaceAsync("Gameplay");
|
||||
```
|
||||
|
||||
### 问题:场景切换时如何显示加载画面?
|
||||
|
||||
**解答**:
|
||||
使用场景转换处理器:
|
||||
|
||||
```csharp
|
||||
public class LoadingScreenHandler : ISceneTransitionHandler
|
||||
{
|
||||
public async ValueTask OnBeforeLoadAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
await ShowLoadingScreen();
|
||||
}
|
||||
|
||||
public async ValueTask OnAfterEnterAsync(SceneTransitionEvent @event)
|
||||
{
|
||||
await HideLoadingScreen();
|
||||
}
|
||||
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何防止用户在场景切换时操作?
|
||||
|
||||
**解答**:
|
||||
检查 `IsTransitioning` 状态:
|
||||
|
||||
```csharp
|
||||
public async Task ChangeScene(string sceneKey)
|
||||
{
|
||||
var sceneRouter = this.GetSystem<ISceneRouter>();
|
||||
|
||||
if (sceneRouter.IsTransitioning)
|
||||
{
|
||||
Console.WriteLine("场景正在切换中,请稍候");
|
||||
return;
|
||||
}
|
||||
|
||||
await sceneRouter.ReplaceAsync(sceneKey);
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:场景切换失败怎么办?
|
||||
|
||||
**解答**:
|
||||
使用 try-catch 捕获异常:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
await sceneRouter.ReplaceAsync("Gameplay");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"场景切换失败: {ex.Message}");
|
||||
// 回退到安全场景
|
||||
await sceneRouter.ReplaceAsync("MainMenu");
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何实现场景预加载?
|
||||
|
||||
**解答**:
|
||||
在后台预先加载场景资源:
|
||||
|
||||
```csharp
|
||||
// 在当前场景中预加载下一个场景
|
||||
var factory = this.GetUtility<ISceneFactory>();
|
||||
var nextScene = factory.Create("NextLevel");
|
||||
await nextScene.OnLoadAsync(null);
|
||||
|
||||
// 稍后快速切换
|
||||
await sceneRouter.ReplaceAsync("NextLevel");
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [UI 系统](/zh-CN/game/ui) - UI 页面管理
|
||||
- [资源管理系统](/zh-CN/core/resource) - 场景资源加载
|
||||
- [状态机系统](/zh-CN/core/state-machine) - 场景状态管理
|
||||
- [Godot 场景系统](/zh-CN/godot/scene) - Godot 引擎集成
|
||||
- [存档系统实现教程](/zh-CN/tutorials/save-system) - 场景切换时保存数据
|
||||
1. [game/index.md](./index.md)
|
||||
2. [ui.md](./ui.md)
|
||||
3. `GFramework.Game/README.md`
|
||||
4. `GFramework.Game.Abstractions/README.md`
|
||||
|
||||
@ -1,509 +1,293 @@
|
||||
---
|
||||
title: UI 系统
|
||||
description: UI 系统提供了完整的 UI 页面管理、路由导航和多层级显示功能。
|
||||
description: 说明 GFramework.Game UI 路由当前的页面栈、层级 UI、输入语义与项目侧接入方式。
|
||||
---
|
||||
|
||||
# UI 系统
|
||||
|
||||
## 概述
|
||||
`GFramework.Game` 的 UI 系统不是单纯的“页面栈”。按当前实现,它同时覆盖:
|
||||
|
||||
UI 系统是 GFramework.Game 中用于管理游戏 UI 界面的核心组件。它提供了 UI 页面的生命周期管理、基于栈的导航机制,以及多层级的
|
||||
UI 显示系统(Page、Overlay、Modal、Toast、Topmost)。
|
||||
- `UiLayer.Page` 的页面导航
|
||||
- `Overlay` / `Modal` / `Toast` / `Topmost` 的层级 UI
|
||||
- UI 语义动作捕获与分发
|
||||
- World 输入阻断
|
||||
- 由 UI 可见性驱动的暂停语义
|
||||
|
||||
通过 UI 系统,你可以轻松实现 UI 页面之间的切换,管理 UI 栈(如主菜单 -> 设置 -> 关于),以及在不同层级显示各种类型的
|
||||
UI(对话框、提示、加载界面等)。
|
||||
因此,新的接入文档不应再把它写成“只有 Push/Pop 的传统页面管理器”。
|
||||
|
||||
**主要特性**:
|
||||
## 当前公开入口
|
||||
|
||||
- 完整的 UI 生命周期管理
|
||||
- 基于栈的 UI 导航
|
||||
- 多层级 UI 显示(5 个层级)
|
||||
- UI 转换管道和钩子
|
||||
- 路由守卫(Route Guard)
|
||||
- UI 工厂和行为模式
|
||||
### `IUiPage`
|
||||
|
||||
## 核心概念
|
||||
最轻量的页面生命周期契约,暴露:
|
||||
|
||||
### UI 页面接口
|
||||
- `OnEnter`
|
||||
- `OnExit`
|
||||
- `OnPause`
|
||||
- `OnResume`
|
||||
- `OnShow`
|
||||
- `OnHide`
|
||||
|
||||
`IUiPage` 定义了 UI 页面的生命周期:
|
||||
如果你的页面逻辑只想表达这些生命周期阶段,停留在 `IUiPage` 就够了。
|
||||
|
||||
### `IUiPageBehavior`
|
||||
|
||||
路由器真正操作的运行时页面行为。相比 `IUiPage`,它还携带:
|
||||
|
||||
- `Key`
|
||||
- `Layer`
|
||||
- `Handle`
|
||||
- `View`
|
||||
- `IsAlive`
|
||||
- `IsVisible`
|
||||
- `IsModal`
|
||||
- `BlocksInput`
|
||||
- `InteractionProfile`
|
||||
- `TryHandleUiAction(UiInputAction action)`
|
||||
|
||||
也就是说,页面栈和层级 UI 都是围绕 `IUiPageBehavior` 工作的,而不是只围绕 `IUiPage`。
|
||||
|
||||
### `IUiRouter`
|
||||
|
||||
当前最常用的入口分成两组。
|
||||
|
||||
页面栈:
|
||||
|
||||
- `PushAsync(...)`
|
||||
- `ReplaceAsync(...)`
|
||||
- `PopAsync(...)`
|
||||
- `ClearAsync()`
|
||||
- `Peek()`
|
||||
- `PeekKey()`
|
||||
|
||||
层级 UI:
|
||||
|
||||
- `Show(...)`
|
||||
- `Hide(...)`
|
||||
- `Resume(...)`
|
||||
- `ClearLayer(...)`
|
||||
- `HideByKey(...)`
|
||||
- `GetAllFromLayer(...)`
|
||||
|
||||
输入与阻断:
|
||||
|
||||
- `GetUiActionOwner(UiInputAction action)`
|
||||
- `TryDispatchUiAction(UiInputAction action)`
|
||||
- `BlocksWorldPointerInput()`
|
||||
- `BlocksWorldActionInput()`
|
||||
|
||||
### `UiLayer`
|
||||
|
||||
当前层级语义如下:
|
||||
|
||||
- `Page`
|
||||
- 页面栈层。请用 `PushAsync` / `ReplaceAsync`,不要用 `Show(...)`。
|
||||
- `Overlay`
|
||||
- 可叠加的浮层。
|
||||
- `Modal`
|
||||
- 默认阻断下层输入的模态层。
|
||||
- `Toast`
|
||||
- 轻量提示层。
|
||||
- `Topmost`
|
||||
- 最顶层的系统级 UI。
|
||||
|
||||
### `UiTransitionPolicy` 与 `UiPopPolicy`
|
||||
|
||||
页面栈的两个关键策略:
|
||||
|
||||
- `UiTransitionPolicy.Exclusive`
|
||||
- 新页面独占显示,下层页面会 `Pause + Hide`
|
||||
- `UiTransitionPolicy.Overlay`
|
||||
- 新页面覆盖显示,下层页面只 `Pause`
|
||||
- `UiPopPolicy.Destroy`
|
||||
- 弹出时直接销毁页面实例
|
||||
- `UiPopPolicy.Suspend`
|
||||
- 弹出时保留页面实例,供后续恢复
|
||||
|
||||
## UI 路由的真实语义
|
||||
|
||||
### 页面栈和层级 UI 是两套入口
|
||||
|
||||
当前源码里:
|
||||
|
||||
- `Page` 层属于栈语义,用 `PushAsync` / `ReplaceAsync` / `PopAsync`
|
||||
- `Overlay`、`Modal`、`Toast`、`Topmost` 属于层级语义,用 `Show` / `Hide` / `Resume`
|
||||
|
||||
`Show(..., UiLayer.Page)` 在当前实现里会直接抛异常,因此旧文档里那种“所有 UI 都统一通过 Show 进入”的写法不再准确。
|
||||
|
||||
### 输入不是页面自己抢,而是 router 先仲裁
|
||||
|
||||
`UiInteractionProfile` 用来描述页面的交互契约,例如:
|
||||
|
||||
- 捕获哪些 `UiInputAction`
|
||||
- 是否阻断 World 指针输入
|
||||
- 是否阻断 World 语义动作输入
|
||||
- 页面可见时是否推动暂停栈
|
||||
|
||||
输入层先把设备输入映射成 `UiInputAction`,再交给 `IUiRouter.TryDispatchUiAction(...)`。最终谁拥有动作捕获权,由当前可见页面和层级顺序决定。
|
||||
|
||||
### 页面可见性会影响暂停与阻断
|
||||
|
||||
这也是 UI 系统和普通页面栈最不同的地方之一。当前实现里:
|
||||
|
||||
- `Modal` / `Topmost` 默认具有更强的输入阻断语义
|
||||
- 页面的 `InteractionProfile` 可以驱动暂停栈
|
||||
- `BlocksWorldPointerInput()` 与 `BlocksWorldActionInput()` 是给项目输入层做统一判断的
|
||||
|
||||
如果你的项目有“打开设置页后暂停世界”“Modal 打开时地图点击失效”这类需求,优先接这个契约,而不是每个页面自己散落地写输入屏蔽逻辑。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
### 1. 提供项目自己的 router
|
||||
|
||||
```csharp
|
||||
public interface IUiPage
|
||||
using GFramework.Game.UI;
|
||||
using LoggingTransitionHandler = GFramework.Game.UI.Handler.LoggingTransitionHandler;
|
||||
|
||||
public sealed class GameUiRouter : UiRouterBase
|
||||
{
|
||||
void OnEnter(IUiPageEnterParam? param); // 进入页面
|
||||
void OnExit(); // 退出页面
|
||||
void OnPause(); // 暂停页面
|
||||
void OnResume(); // 恢复页面
|
||||
void OnShow(); // 显示页面
|
||||
void OnHide(); // 隐藏页面
|
||||
}
|
||||
```
|
||||
|
||||
### UI 路由
|
||||
|
||||
`IUiRouter` 管理 UI 的导航和切换:
|
||||
|
||||
```csharp
|
||||
public interface IUiRouter : ISystem
|
||||
{
|
||||
int Count { get; } // UI 栈深度
|
||||
IUiPageBehavior? Peek(); // 栈顶 UI
|
||||
|
||||
ValueTask PushAsync(string uiKey, IUiPageEnterParam? param = null);
|
||||
ValueTask PopAsync(UiPopPolicy policy = UiPopPolicy.Destroy);
|
||||
ValueTask ReplaceAsync(string uiKey, IUiPageEnterParam? param = null);
|
||||
ValueTask ClearAsync();
|
||||
}
|
||||
```
|
||||
|
||||
### UI 层级
|
||||
|
||||
UI 系统支持 5 个显示层级:
|
||||
|
||||
```csharp
|
||||
public enum UiLayer
|
||||
{
|
||||
Page, // 页面层(栈管理,不可重入)
|
||||
Overlay, // 浮层(可重入,对话框等)
|
||||
Modal, // 模态层(可重入,带遮罩)
|
||||
Toast, // 提示层(可重入,轻量提示)
|
||||
Topmost // 顶层(不可重入,系统级)
|
||||
}
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 定义 UI 页面
|
||||
|
||||
实现 `IUiPage` 接口创建 UI 页面:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
|
||||
public class MainMenuPage : IUiPage
|
||||
{
|
||||
public void OnEnter(IUiPageEnterParam? param)
|
||||
protected override void RegisterHandlers()
|
||||
{
|
||||
Console.WriteLine("进入主菜单");
|
||||
// 初始化 UI、绑定事件
|
||||
}
|
||||
|
||||
public void OnExit()
|
||||
{
|
||||
Console.WriteLine("退出主菜单");
|
||||
// 清理资源、解绑事件
|
||||
}
|
||||
|
||||
public void OnPause()
|
||||
{
|
||||
Console.WriteLine("暂停主菜单");
|
||||
// 暂停动画、停止交互
|
||||
}
|
||||
|
||||
public void OnResume()
|
||||
{
|
||||
Console.WriteLine("恢复主菜单");
|
||||
// 恢复动画、启用交互
|
||||
}
|
||||
|
||||
public void OnShow()
|
||||
{
|
||||
Console.WriteLine("显示主菜单");
|
||||
// 显示 UI 元素
|
||||
}
|
||||
|
||||
public void OnHide()
|
||||
{
|
||||
Console.WriteLine("隐藏主菜单");
|
||||
// 隐藏 UI 元素
|
||||
RegisterHandler(new LoggingTransitionHandler());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 切换 UI 页面
|
||||
### 2. 提供 `IUiFactory`
|
||||
|
||||
使用 UI 路由进行导航:
|
||||
`UiRouterBase` 会通过 `IUiFactory.Create(string uiKey)` 获取页面行为实例,因此项目需要自己决定:
|
||||
|
||||
- `uiKey` 如何映射到页面行为
|
||||
- 页面行为如何包裹具体引擎视图
|
||||
- 预挂载节点、调试节点或动态实例化页面如何接入
|
||||
|
||||
如果你在 Godot 项目里使用 `AutoUiPage` 相关生成器,它可以帮你减少部分行为样板,但 factory / root / 实际页面注册仍然是项目职责。
|
||||
|
||||
### 3. 提供 `IUiRoot`
|
||||
|
||||
`IUiRoot` 负责把页面行为挂进真实 UI 容器:
|
||||
|
||||
- `AddUiPage(IUiPageBehavior child)`
|
||||
- `AddUiPage(IUiPageBehavior child, UiLayer layer, int orderInLayer = 0)`
|
||||
- `RemoveUiPage(IUiPageBehavior child)`
|
||||
|
||||
当前 `ai-libs/` 的参考实现就是在项目自己的 `CanvasLayer` 上为每个 `UiLayer` 建独立容器,再在 `_Ready()` 时执行
|
||||
`_uiRouter.BindRoot(this)`。
|
||||
|
||||
### 4. 装配 router 与 factory
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
architecture.RegisterUtility<IUiFactory>(new GameUiFactory());
|
||||
architecture.RegisterSystem(new GameUiRouter());
|
||||
```
|
||||
|
||||
[ContextAware]
|
||||
public partial class UiController : IController
|
||||
### 5. 在 root 就绪后绑定
|
||||
|
||||
```csharp
|
||||
public sealed class UiRoot : CanvasLayer, IUiRoot
|
||||
{
|
||||
public async Task ShowSettings()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
[GetSystem] private IUiRouter _uiRouter = null!;
|
||||
|
||||
// 压入设置页面(保留当前页面)
|
||||
await uiRouter.PushAsync("Settings");
|
||||
public override void _Ready()
|
||||
{
|
||||
__InjectContextBindings_Generated();
|
||||
_uiRouter.BindRoot(this);
|
||||
}
|
||||
|
||||
public async Task CloseSettings()
|
||||
public void AddUiPage(IUiPageBehavior child)
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 弹出当前页面(返回上一页)
|
||||
await uiRouter.PopAsync();
|
||||
AddUiPage(child, UiLayer.Page);
|
||||
}
|
||||
|
||||
public async Task ShowMainMenu()
|
||||
public void AddUiPage(IUiPageBehavior child, UiLayer layer, int orderInLayer = 0)
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
// 项目侧决定如何把 child.View 挂到具体容器
|
||||
}
|
||||
|
||||
// 替换所有页面(清空 UI 栈)
|
||||
await uiRouter.ReplaceAsync("MainMenu");
|
||||
public void RemoveUiPage(IUiPageBehavior child)
|
||||
{
|
||||
// 项目侧决定如何移除并释放视图
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 显示不同层级的 UI
|
||||
### 6. 从业务代码区分两类入口
|
||||
|
||||
页面栈:
|
||||
|
||||
```csharp
|
||||
[ContextAware]
|
||||
public partial class UiController : IController
|
||||
{
|
||||
public void ShowDialog()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 在 Modal 层显示对话框
|
||||
var handle = uiRouter.Show("ConfirmDialog", UiLayer.Modal);
|
||||
}
|
||||
|
||||
public void ShowToast(string message)
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 在 Toast 层显示提示
|
||||
var handle = uiRouter.Show("ToastMessage", UiLayer.Toast,
|
||||
new ToastParam { Message = message });
|
||||
}
|
||||
|
||||
public void ShowLoading()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 在 Topmost 层显示加载界面
|
||||
var handle = uiRouter.Show("LoadingScreen", UiLayer.Topmost);
|
||||
}
|
||||
}
|
||||
await uiRouter.ReplaceAsync("MainMenu");
|
||||
await uiRouter.PushAsync("Settings", new SettingsEnterParam());
|
||||
await uiRouter.PopAsync(UiPopPolicy.Destroy);
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### UI 参数传递
|
||||
层级 UI:
|
||||
|
||||
```csharp
|
||||
// 定义 UI 参数
|
||||
public class SettingsEnterParam : IUiPageEnterParam
|
||||
{
|
||||
public string Category { get; set; }
|
||||
}
|
||||
var modalHandle = uiRouter.Show(
|
||||
"ConfirmExit",
|
||||
UiLayer.Modal,
|
||||
new ConfirmExitParam());
|
||||
|
||||
// 在 UI 中接收参数
|
||||
public class SettingsPage : IUiPage
|
||||
{
|
||||
private string _category;
|
||||
|
||||
public void OnEnter(IUiPageEnterParam? param)
|
||||
{
|
||||
if (param is SettingsEnterParam settingsParam)
|
||||
{
|
||||
_category = settingsParam.Category;
|
||||
Console.WriteLine($"打开设置分类: {_category}");
|
||||
}
|
||||
}
|
||||
|
||||
// ... 其他生命周期方法
|
||||
}
|
||||
|
||||
// 传递参数
|
||||
await uiRouter.PushAsync("Settings", new SettingsEnterParam
|
||||
{
|
||||
Category = "Audio"
|
||||
});
|
||||
uiRouter.Hide(modalHandle, UiLayer.Modal);
|
||||
```
|
||||
|
||||
## 扩展点
|
||||
|
||||
### 路由守卫
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
如果你要在进入或离开页面前做业务检查,实现 `IUiRouteGuard`:
|
||||
|
||||
public class UnsavedChangesGuard : IUiRouteGuard
|
||||
{
|
||||
public async ValueTask<bool> CanLeaveAsync(
|
||||
IUiPageBehavior from,
|
||||
string toKey,
|
||||
IUiPageEnterParam? param)
|
||||
{
|
||||
// 检查是否有未保存的更改
|
||||
if (from.Key == "Settings" && HasUnsavedChanges())
|
||||
{
|
||||
var confirmed = await ShowConfirmDialog();
|
||||
return confirmed;
|
||||
}
|
||||
- `CanEnterAsync(string uiKey, IUiPageEnterParam? param)`
|
||||
- `CanLeaveAsync(string uiKey)`
|
||||
|
||||
return true;
|
||||
}
|
||||
适合放:
|
||||
|
||||
public async ValueTask<bool> CanEnterAsync(
|
||||
string toKey,
|
||||
IUiPageEnterParam? param)
|
||||
{
|
||||
// 进入前的验证
|
||||
return true;
|
||||
}
|
||||
- 未保存设置拦截
|
||||
- 新手引导期间禁用某些页面跳转
|
||||
- 多层弹窗切换前的业务确认
|
||||
|
||||
private bool HasUnsavedChanges() => true;
|
||||
private async Task<bool> ShowConfirmDialog() => await Task.FromResult(true);
|
||||
}
|
||||
### 过渡处理器
|
||||
|
||||
// 注册守卫
|
||||
uiRouter.AddGuard(new UnsavedChangesGuard());
|
||||
```
|
||||
`IUiRouter` 当前公开的是:
|
||||
|
||||
### UI 转换处理器
|
||||
- `RegisterHandler(IUiTransitionHandler handler, UiTransitionHandlerOptions? options = null)`
|
||||
- `UnregisterHandler(IUiTransitionHandler handler)`
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
适合放:
|
||||
|
||||
public class FadeTransitionHandler : IUiTransitionHandler
|
||||
{
|
||||
public async ValueTask OnBeforeEnterAsync(UiTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"准备进入 UI: {@event.ToKey}");
|
||||
await PlayFadeIn();
|
||||
}
|
||||
- UI 转场动画
|
||||
- 统一日志
|
||||
- 栈变化埋点
|
||||
|
||||
public async ValueTask OnAfterEnterAsync(UiTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"已进入 UI: {@event.ToKey}");
|
||||
}
|
||||
### 输入适配层
|
||||
|
||||
public async ValueTask OnBeforeExitAsync(UiTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"准备退出 UI: {@event.FromKey}");
|
||||
await PlayFadeOut();
|
||||
}
|
||||
如果项目已经有自己的输入系统,推荐把它适配成:
|
||||
|
||||
public async ValueTask OnAfterExitAsync(UiTransitionEvent @event)
|
||||
{
|
||||
Console.WriteLine($"已退出 UI: {@event.FromKey}");
|
||||
}
|
||||
1. 设备输入 -> `UiInputAction`
|
||||
2. `IUiRouter.TryDispatchUiAction(...)`
|
||||
3. 若未被 UI 捕获,再决定是否把输入继续交给 World
|
||||
|
||||
private async Task PlayFadeIn() => await Task.Delay(200);
|
||||
private async Task PlayFadeOut() => await Task.Delay(200);
|
||||
}
|
||||
这样可以直接复用当前路由器的动作捕获与阻断语义。
|
||||
|
||||
// 注册转换处理器
|
||||
uiRouter.RegisterHandler(new FadeTransitionHandler());
|
||||
```
|
||||
## 与旧写法的边界
|
||||
|
||||
### UI 句柄管理
|
||||
以下说法不再适合作为默认指导:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
- “所有 UI 都统一通过一个 Show API 管理”
|
||||
- “UI 系统只有页面栈,不涉及输入阻断和暂停语义”
|
||||
- “Modal / Topmost 只是视觉层级,不影响交互”
|
||||
|
||||
[ContextAware]
|
||||
public partial class DialogController : IController
|
||||
{
|
||||
private UiHandle? _dialogHandle;
|
||||
当前更准确的理解是:
|
||||
|
||||
public void ShowDialog()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
- 页面栈和层级 UI 是两套入口
|
||||
- 页面行为不仅有生命周期,还有输入、阻断、暂停契约
|
||||
- router 是 UI 语义仲裁中心,项目输入层应主动接入它
|
||||
|
||||
// 显示对话框并保存句柄
|
||||
_dialogHandle = uiRouter.Show("ConfirmDialog", UiLayer.Modal);
|
||||
}
|
||||
## 推荐阅读
|
||||
|
||||
public void CloseDialog()
|
||||
{
|
||||
if (_dialogHandle.HasValue)
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 使用句柄关闭对话框
|
||||
uiRouter.Hide(_dialogHandle.Value, UiLayer.Modal, destroy: true);
|
||||
_dialogHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UI 栈管理
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class NavigationController : IController
|
||||
{
|
||||
public void ShowUiStack()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
Console.WriteLine($"UI 栈深度: {uiRouter.Count}");
|
||||
|
||||
var current = uiRouter.Peek();
|
||||
if (current != null)
|
||||
{
|
||||
Console.WriteLine($"当前 UI: {current.Key}");
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSettingsOpen()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
return uiRouter.Contains("Settings");
|
||||
}
|
||||
|
||||
public bool IsTopPage(string uiKey)
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
return uiRouter.IsTop(uiKey);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 多层级 UI 管理
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class LayerController : IController
|
||||
{
|
||||
public void ShowMultipleToasts()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// Toast 层支持重入,可以同时显示多个
|
||||
uiRouter.Show("Toast1", UiLayer.Toast);
|
||||
uiRouter.Show("Toast2", UiLayer.Toast);
|
||||
uiRouter.Show("Toast3", UiLayer.Toast);
|
||||
}
|
||||
|
||||
public void ClearAllToasts()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 清空 Toast 层的所有 UI
|
||||
uiRouter.ClearLayer(UiLayer.Toast, destroy: true);
|
||||
}
|
||||
|
||||
public void HideAllDialogs()
|
||||
{
|
||||
var uiRouter = this.GetSystem<IUiRouter>();
|
||||
|
||||
// 隐藏 Modal 层的所有对话框
|
||||
uiRouter.HideByKey("ConfirmDialog", UiLayer.Modal, hideAll: true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用合适的层级**:根据 UI 类型选择正确的层级
|
||||
```csharp
|
||||
✓ Page: 主要页面(主菜单、设置、游戏界面)
|
||||
✓ Overlay: 浮层(信息面板、小窗口)
|
||||
✓ Modal: 模态对话框(确认框、输入框)
|
||||
✓ Toast: 轻量提示(消息、通知)
|
||||
✓ Topmost: 系统级(加载界面、全屏遮罩)
|
||||
```
|
||||
|
||||
2. **使用 Push/Pop 管理临时 UI**:如设置、帮助页面
|
||||
```csharp
|
||||
// 打开设置(保留当前页面)
|
||||
await uiRouter.PushAsync("Settings");
|
||||
|
||||
// 关闭设置(返回上一页)
|
||||
await uiRouter.PopAsync();
|
||||
```
|
||||
|
||||
3. **使用 Replace 切换主要页面**:如从菜单到游戏
|
||||
```csharp
|
||||
// 开始游戏(清空 UI 栈)
|
||||
await uiRouter.ReplaceAsync("Gameplay");
|
||||
```
|
||||
|
||||
4. **在 OnEnter/OnExit 中管理资源**:保持资源管理清晰
|
||||
```csharp
|
||||
public void OnEnter(IUiPageEnterParam? param)
|
||||
{
|
||||
// 加载资源、绑定事件
|
||||
BindEvents();
|
||||
}
|
||||
|
||||
public void OnExit()
|
||||
{
|
||||
// 清理资源、解绑事件
|
||||
UnbindEvents();
|
||||
}
|
||||
```
|
||||
|
||||
5. **使用句柄管理非栈 UI**:对于 Overlay、Modal、Toast 层
|
||||
```csharp
|
||||
// 保存句柄
|
||||
var handle = uiRouter.Show("Dialog", UiLayer.Modal);
|
||||
|
||||
// 使用句柄关闭
|
||||
uiRouter.Hide(handle, UiLayer.Modal, destroy: true);
|
||||
```
|
||||
|
||||
6. **避免在 UI 切换时阻塞**:使用异步操作
|
||||
```csharp
|
||||
✓ await uiRouter.PushAsync("Settings");
|
||||
✗ uiRouter.PushAsync("Settings").Wait(); // 可能死锁
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题:Push、Pop、Replace 有什么区别?
|
||||
|
||||
**解答**:
|
||||
|
||||
- **Push**:压入新 UI,暂停当前 UI(用于临时页面)
|
||||
- **Pop**:弹出当前 UI,恢复上一个 UI(用于关闭临时页面)
|
||||
- **Replace**:清空 UI 栈,加载新 UI(用于主要页面切换)
|
||||
|
||||
### 问题:什么时候使用不同的 UI 层级?
|
||||
|
||||
**解答**:
|
||||
|
||||
- **Page**:主要页面,使用栈管理
|
||||
- **Overlay**:浮层,可叠加显示
|
||||
- **Modal**:模态对话框,阻挡下层交互
|
||||
- **Toast**:轻量提示,不阻挡交互
|
||||
- **Topmost**:系统级,最高优先级
|
||||
|
||||
### 问题:如何在 UI 之间传递数据?
|
||||
|
||||
**解答**:
|
||||
|
||||
1. 通过 UI 参数
|
||||
2. 通过 Model
|
||||
3. 通过事件
|
||||
|
||||
### 问题:UI 切换时如何显示过渡动画?
|
||||
|
||||
**解答**:
|
||||
使用 UI 转换处理器在 `OnBeforeEnter`/`OnAfterExit` 中播放动画。
|
||||
|
||||
### 问题:如何防止用户在 UI 切换时操作?
|
||||
|
||||
**解答**:
|
||||
在转换处理器中显示遮罩或禁用输入。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [场景系统](/zh-CN/game/scene) - 场景管理
|
||||
- [Godot UI 系统](/zh-CN/godot/ui) - Godot 引擎集成
|
||||
- [事件系统](/zh-CN/core/events) - UI 事件通信
|
||||
- [状态机系统](/zh-CN/core/state-machine) - UI 状态管理
|
||||
1. [game/index.md](./index.md)
|
||||
2. [scene.md](./scene.md)
|
||||
3. [../source-generators/auto-ui-page-generator.md](../source-generators/auto-ui-page-generator.md)
|
||||
4. `GFramework.Game/README.md`
|
||||
5. `GFramework.Game.Abstractions/README.md`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user