From da707c7b4f0ec9dcad1a6e757e85a41213c5799b Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:08:05 +0800 Subject: [PATCH] =?UTF-8?q?docs(game):=20=E6=94=B6=E5=8F=A3=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E4=B8=8EUI=E4=B8=93=E9=A2=98=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 game/scene 与 game/ui 专题页,按当前 router、factory、root、输入与暂停语义说明接入方式\n- 更新 documentation-governance-and-refresh 的 tracking 与 trace,记录 RP-006 与后续 source-generators 核对重点\n- 验证 docs 站点构建通过 --- ...ntation-governance-and-refresh-tracking.md | 20 +- ...umentation-governance-and-refresh-trace.md | 25 + docs/zh-CN/game/scene.md | 756 ++++-------------- docs/zh-CN/game/ui.md | 666 ++++++--------- 4 files changed, 425 insertions(+), 1042 deletions(-) diff --git a/ai-plan/public/documentation-governance-and-refresh/todos/documentation-governance-and-refresh-tracking.md b/ai-plan/public/documentation-governance-and-refresh/todos/documentation-governance-and-refresh-tracking.md index aa5c3d3a..5368e9e1 100644 --- a/ai-plan/public/documentation-governance-and-refresh/todos/documentation-governance-and-refresh-tracking.md +++ b/ai-plan/public/documentation-governance-and-refresh/todos/documentation-governance-and-refresh-tracking.md @@ -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.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/`,避免默认启动入口再次膨胀 diff --git a/ai-plan/public/documentation-governance-and-refresh/traces/documentation-governance-and-refresh-trace.md b/ai-plan/public/documentation-governance-and-refresh/traces/documentation-governance-and-refresh-trace.md index a044fca7..32f0de47 100644 --- a/ai-plan/public/documentation-governance-and-refresh/traces/documentation-governance-and-refresh-trace.md +++ b/ai-plan/public/documentation-governance-and-refresh/traces/documentation-governance-and-refresh-trace.md @@ -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,避免默认入口继续膨胀 diff --git a/docs/zh-CN/game/scene.md b/docs/zh-CN/game/scene.md index 9f79c2af..1b3d358b 100644 --- a/docs/zh-CN/game/scene.md +++ b/docs/zh-CN/game/scene.md @@ -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 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()` 获取工厂,因此项目必须先注册它。 + +工厂的职责通常是: + +- 按 `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(new GameSceneFactory()); +architecture.RegisterSystem(new GameSceneRouter()); +``` -public class GameSceneRegistry : IGameSceneRegistry +如果你的项目还需要动画、黑幕或 loading 过渡,可以继续在 `RegisterHandlers()` 里补自己的处理器。 + +### 5. 在 root 就绪后绑定 + +```csharp +public sealed class SceneRoot : Node2D, ISceneRoot { - private readonly Dictionary _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(); + Seed = "new-game" + }); - // 替换当前场景(清空场景栈) - await sceneRouter.ReplaceAsync("Gameplay"); - } - - public async Task ShowPauseMenu() - { - var sceneRouter = this.GetSystem(); - - // 压入新场景(保留当前场景) - await sceneRouter.PushAsync("Pause"); - } - - public async Task ClosePauseMenu() - { - var sceneRouter = this.GetSystem(); - - // 弹出当前场景(恢复上一个场景) - 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 CanLeaveAsync( - ISceneBehavior from, - string toKey, - ISceneEnterParam? param) - { - // 离开游戏场景前检查是否需要保存 - if (from.Key == "Gameplay") - { - var needsSave = CheckIfNeedsSave(); - if (needsSave) - { - await SaveGameAsync(); - } - } +适合放: - return true; // 允许离开 - } +- 未保存进度拦截 +- 场景解锁条件检查 +- 新手引导流程限制 - public async ValueTask 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(); - - // 检查场景是否已在栈中 - if (sceneRouter.Contains("Settings")) - { - Console.WriteLine("设置场景已打开"); - return; - } - - // 压入设置场景 - await sceneRouter.PushAsync("Settings"); - } - - public void ShowSceneStack() - { - var sceneRouter = this.GetSystem(); - - Console.WriteLine("当前场景栈:"); - foreach (var scene in sceneRouter.Stack) - { - Console.WriteLine($"- {scene.Key}"); - } - } - - public async Task ReturnToMainMenu() - { - var sceneRouter = this.GetSystem(); - - // 清空所有场景并加载主菜单 - 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(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(); - - // 预加载下一关场景 - 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 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.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(); - - 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(); -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` diff --git a/docs/zh-CN/game/ui.md b/docs/zh-CN/game/ui.md index 0d9df87c..71527493 100644 --- a/docs/zh-CN/game/ui.md +++ b/docs/zh-CN/game/ui.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(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(); + [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(); - - // 弹出当前页面(返回上一页) - 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(); + // 项目侧决定如何把 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(); - - // 在 Modal 层显示对话框 - var handle = uiRouter.Show("ConfirmDialog", UiLayer.Modal); - } - - public void ShowToast(string message) - { - var uiRouter = this.GetSystem(); - - // 在 Toast 层显示提示 - var handle = uiRouter.Show("ToastMessage", UiLayer.Toast, - new ToastParam { Message = message }); - } - - public void ShowLoading() - { - var uiRouter = this.GetSystem(); - - // 在 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 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 CanEnterAsync( - string toKey, - IUiPageEnterParam? param) - { - // 进入前的验证 - return true; - } +- 未保存设置拦截 +- 新手引导期间禁用某些页面跳转 +- 多层弹窗切换前的业务确认 - private bool HasUnsavedChanges() => true; - private async Task 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(); +- 页面栈和层级 UI 是两套入口 +- 页面行为不仅有生命周期,还有输入、阻断、暂停契约 +- router 是 UI 语义仲裁中心,项目输入层应主动接入它 - // 显示对话框并保存句柄 - _dialogHandle = uiRouter.Show("ConfirmDialog", UiLayer.Modal); - } +## 推荐阅读 - public void CloseDialog() - { - if (_dialogHandle.HasValue) - { - var uiRouter = this.GetSystem(); - - // 使用句柄关闭对话框 - 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(); - - Console.WriteLine($"UI 栈深度: {uiRouter.Count}"); - - var current = uiRouter.Peek(); - if (current != null) - { - Console.WriteLine($"当前 UI: {current.Key}"); - } - } - - public bool IsSettingsOpen() - { - var uiRouter = this.GetSystem(); - return uiRouter.Contains("Settings"); - } - - public bool IsTopPage(string uiKey) - { - var uiRouter = this.GetSystem(); - 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(); - - // Toast 层支持重入,可以同时显示多个 - uiRouter.Show("Toast1", UiLayer.Toast); - uiRouter.Show("Toast2", UiLayer.Toast); - uiRouter.Show("Toast3", UiLayer.Toast); - } - - public void ClearAllToasts() - { - var uiRouter = this.GetSystem(); - - // 清空 Toast 层的所有 UI - uiRouter.ClearLayer(UiLayer.Toast, destroy: true); - } - - public void HideAllDialogs() - { - var uiRouter = this.GetSystem(); - - // 隐藏 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`