From 0e3f47f57027d71d4837271128cb203a4576c507 Mon Sep 17 00:00:00 2001 From: sakuya Date: Wed, 30 Sep 2026 00:58:33 +0800 Subject: [PATCH 1/6] nvl --- .agents/agent-knowledge.md | 6 +- ...2-sample-debug-navigate-before-teardown.md | 10 +- ...929-09-sample-debug-restart-after-reset.md | 30 +++++ ...-20260929-10-sample-debug-source-ignore.md | 36 ++++++ ...9-11-log-tail-extent-is-not-user-scroll.md | 29 +++++ .gitignore | 2 + GameTestCase/Graph/groups/intro.rawgalgroup | 8 ++ GameTestCase/README.md | 1 + docs/spec/control.md | 2 +- docs/spec/entry-types.md | 5 +- docs/spec/runtime.md | 1 + .../feature.md | 81 +++++++++++++ .../Services/EditorPreviewHost.cs | 3 +- .../GalNet.Avalonia.Controls/NvlPresenter.cs | 84 ++++++++++++- .../Styles/Generic.axaml | 27 +++-- .../Page/GamePage.axaml | 5 +- .../Presentation/AvaloniaDialoguePresenter.cs | 23 ++-- .../Presentation/AvaloniaGamePageView.cs | 2 + .../ViewModels/GamePageViewModel.cs | 6 +- .../Debug/SampleDebugLogDirectory.cs | 17 +++ .../Debug/SampleDebugSaveKind.cs | 15 +++ .../Debug/SampleDebugSnapshotFactory.cs | 36 ++++++ .../Debug/SampleDebugViewModel.cs | 111 ++++++++++++++++++ .../Services/SampleApplicationRestarter.cs | 32 +++++ .../SampleDebugServiceCollectionExtensions.cs | 21 ++-- .../Services/SampleGameRunner.cs | 3 +- .../Services/SampleGameSessionService.cs | 78 ++++++------ .../Views/DebugLogWindow.axaml | 1 + .../Views/DebugLogWindow.axaml.cs | 39 +++++- .../Views/MainWindow.axaml | 16 ++- .../Views/MainWindow.axaml.cs | 84 +++++-------- .../HeadlessConsolePresentation.cs | 13 +- src/Samples/GalNet.Sample.Headless/Program.cs | 2 +- .../View/INvlPresenter.cs | 11 ++ .../BuiltinEntryModules.cs | 30 ++++- .../Entry/NvlEntries.cs | 29 +++++ .../NvlAppendPrimitiveInstance.cs | 105 +++++++++++++++++ .../SampleDebugSnapshotFactoryTests.cs | 63 ++++++++++ .../Entry/EntryModelTests.cs | 14 ++- .../Runtime/NvlPrimitiveInstanceTests.cs | 63 ++++++++++ 40 files changed, 987 insertions(+), 157 deletions(-) create mode 100644 .agents/lessons/L-20260929-09-sample-debug-restart-after-reset.md create mode 100644 .agents/lessons/L-20260929-10-sample-debug-source-ignore.md create mode 100644 .agents/lessons/L-20260929-11-log-tail-extent-is-not-user-scroll.md create mode 100644 features/F-20260929-15-replay-save-format/feature.md create mode 100644 src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugLogDirectory.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSaveKind.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSnapshotFactory.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugViewModel.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Services/SampleApplicationRestarter.cs create mode 100644 src/Shared/GalNet.Presentation.Abstractions/View/INvlPresenter.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Entry/NvlEntries.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/NvlAppendPrimitiveInstance.cs create mode 100644 test/GalNet.IntegrationTests/SampleDebugSnapshotFactoryTests.cs create mode 100644 test/GalNet.Primitives.Builtins.Tests/Runtime/NvlPrimitiveInstanceTests.cs diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 74a849a4..96d70d8c 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -33,6 +33,7 @@ - `F-20260926-01-gallery-meta-pages` 的实现、正式文档与示例 Gallery 解锁 smoke test 已完成;其外部阻塞项 `EditorSettingsSerializationTests.LastDockLayout_RoundTripsAsAString` 已由 `F-20260929-04-quality-baseline` 修复,Gallery feature 可按自身流程收尾。 - 当前 discovery feature:[F-20260916-02-audio-system](../features/F-20260916-02-audio-system/feature.md),用于澄清音频系统的首个可交付范围、后端能力和迁移语义。 - `F-20260929-01-compiled-content-pipeline` 正在实施;粒子 Primitive、Runtime 状态与 Presenter 桥接及存档后的 effect/particle 展示重放已经验证,完整 Sample/Headless smoke 与 Phase 4 收尾仍待完成。 +- [F-20260929-15-replay-save-format](../features/F-20260929-15-replay-save-format/feature.md) 已暂缓:正式存档继续使用完整稳定快照;后续节点解锁/快速进入能力应另立 feature。 - `F-20260929-03-maintainability-roadmap` 及其后续 `F-20260929-14-format-baseline` 已完成。资源协议归 Core.Assets;内容、保存、变量、Gallery 组合和进度端口归 Runtime;Editor 设置/退出协议归 Editor.Abstraction;展示端口使用 `GalNet.Presentation.Abstractions.*`,推荐内置类型使用 `GalNet.Primitives.Builtins`。`GalNet.Storage.Abstractions` 与无消费者历史接口已删除,`ISaveService` 已统一为异步可取消 API。测试已按 10 个生产/运行边界拆分,跨外层场景集中在 `GalNet.IntegrationTests`,Architecture 门禁同时约束生产与测试项目引用。Editor Workspace、Avalonia presenter、Sample 会话和 Editor 扩展边界均已按路线图收敛。21 个生产项目的物理路径现按 Shared、Presentation、Infrastructure、Editor、Samples 和 Launcher 分组。CI 使用 solution 级 whitespace 与 `IDE0005` import 检查阻止格式基线回退。 - 后续 feature、验证命令和新经验在实际工作中补充,并保留来源链接或 feature ID。 @@ -57,7 +58,10 @@ - [动画存档保存稳定语义而非播放游标](lessons/L-20260929-04-animation-save-semantics.md):一次性非阻塞动画保存末值,循环动画保存第 0 帧状态与完整定义并从头重播,阻塞动画沿用此前稳定快照。 - [Skia atlas 变换使用 source 局部坐标](lessons/L-20260929-05-skia-atlas-local-transform.md):`DrawAtlas` 的旋转缩放中心应使用 source rect 的宽高,不能重复带入 atlas 偏移。 - [Sample 调试清空的语义](lessons/L-20260929-01-sample-debug-reset-scope.md):顶层“清空游戏数据”重置玩家状态;日志清空仅属于日志面板。 -- [Sample 调试会话销毁的导航顺序](lessons/L-20260929-02-sample-debug-navigate-before-teardown.md):先切换到标题页,再停止引擎或释放场景展示,避免重置/重载期间黑屏。 +- [Sample 调试进程内销毁的导航顺序](lessons/L-20260929-02-sample-debug-navigate-before-teardown.md):已被用户复现推翻;仅调整导航顺序不足以避免黑屏。 +- [Sample 调试重置后的重启](lessons/L-20260929-09-sample-debug-restart-after-reset.md):当前调试清空和资源重载在必要操作后重启进程,避免进程内 Runner 销毁路径。 +- [Sample Debug 源码的忽略规则](lessons/L-20260929-10-sample-debug-source-ignore.md):全局 `[Dd]ebug/` 会吞掉该目录的新源码,必须精确反忽略。 +- [实时日志的尾部追随](lessons/L-20260929-11-log-tail-extent-is-not-user-scroll.md):追加内容只改变 Extent;只以 Offset 变化判断用户是否离开底部。 - [格式门禁必须建立在干净基线上](lessons/L-20260929-06-format-gate-clean-baseline.md):Windows 仓库在统一行尾前直接启用 `dotnet format` 会把既有行尾、using 和空白债务混入功能改动;先做独立机械格式化,再启用阻断式门禁。 - [测试项目不要用独立 MSBuild 进程并行构建共享依赖](lessons/L-20260929-07-parallel-test-shared-obj-lock.md):多个 `dotnet test` 进程会争用共享生产项目的 `obj` 输出;全仓测试应顺序执行或由单一 MSBuild graph 调度。 - [项目移动后必须清理旧构建中间产物](lessons/L-20260929-08-project-move-stale-restore-path.md):移动 .NET 项目后先删除随项目移动的 `bin/obj`,再从新路径 locked restore,避免旧绝对路径缓存重建旧目录和 lock 文件。 diff --git a/.agents/lessons/L-20260929-02-sample-debug-navigate-before-teardown.md b/.agents/lessons/L-20260929-02-sample-debug-navigate-before-teardown.md index 26a3f0c5..951fede1 100644 --- a/.agents/lessons/L-20260929-02-sample-debug-navigate-before-teardown.md +++ b/.agents/lessons/L-20260929-02-sample-debug-navigate-before-teardown.md @@ -1,6 +1,6 @@ # Sample 调试重置/重载应先导航再释放游戏展示 -状态:candidate +状态:superseded 来源:2026-09-29 用户在 Sample Avalonia 调试清空流程中报告的黑屏 ## 现象 @@ -11,9 +11,13 @@ `SampleGameSessionService.DisposeEngineAsync` 会重置游戏场景展示;`IGameNavigationService.ResetToAsync` 可以在游戏引擎仍存活时先完成无动画标题页导航。 -## 正确做法 +## 原假设 -Sample 的顶层调试操作应先 `ResetToAsync(NavigationTransition.None)` 并等待页面切换完成,再在会话生命周期锁内停止运行、销毁引擎或重新加载资源。这样旧游戏画布被释放时,标题页已经是可见内容。 +曾假设 Sample 的顶层调试操作应先 `ResetToAsync(NavigationTransition.None)` 并等待页面切换完成,再在会话生命周期锁内停止运行、销毁引擎或重新加载资源。 + +## 失效原因 + +用户确认采用该顺序后,清空与进程内资源重载仍会黑屏。该顺序不足以保证当前页面主机与 Runner 销毁之间的稳定呈现;后续调试操作改用受控进程重启,见 `L-20260929-09`。 ## 适用范围 diff --git a/.agents/lessons/L-20260929-09-sample-debug-restart-after-reset.md b/.agents/lessons/L-20260929-09-sample-debug-restart-after-reset.md new file mode 100644 index 00000000..0efa867d --- /dev/null +++ b/.agents/lessons/L-20260929-09-sample-debug-restart-after-reset.md @@ -0,0 +1,30 @@ +# Sample 调试重置应避免进程内 Runner 销毁 + +状态:candidate +来源:2026-09-29 用户确认清空与进程内资源重载在调整导航顺序后仍黑屏 + +## 现象 + +Sample 的 `SampleGameRunner.DisposeAsync` 会重置场景展示。清空或重载时,即使先导航标题页,用户仍观察到黑屏。 + +## 已确认事实 + +清空玩家状态的磁盘操作由 `SampleSaveSession.ClearAsync` 完成;它会清空存档、重置并持久化玩家变量、删除阅读进度以及重建默认游戏设置。此操作不需要销毁正在显示的 Runner 才能完成。 + +## 当前做法 + +调试清空先停止 Runner、完成 `SampleSaveSession.ClearAsync`,但不在当前窗口中销毁 Runner;随后以当前进程路径和原始命令行参数启动替代进程,并通过 Avalonia 的正常关闭流程释放旧进程资源。调试“重新加载游戏资源”也采用重启而非进程内重载。 + +## 适用范围 + +`GalNet.Sample.Avalonia` 的开发期调试菜单。不是通用游戏内资源热重载方案。 + +## 不要做什么 + +在未隔离并复现页面主机生命周期问题前,不要将此调试入口改回进程内 `SampleGameRunner.DisposeAsync` 加资源重建。 + +## 证据和关联文档 + +- `src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs` +- `src/Samples/GalNet.Sample.Avalonia/Services/SampleSaveSession.cs` +- `src/Samples/GalNet.Sample.Avalonia/Services/SampleApplicationRestarter.cs` diff --git a/.agents/lessons/L-20260929-10-sample-debug-source-ignore.md b/.agents/lessons/L-20260929-10-sample-debug-source-ignore.md new file mode 100644 index 00000000..aab9dff0 --- /dev/null +++ b/.agents/lessons/L-20260929-10-sample-debug-source-ignore.md @@ -0,0 +1,36 @@ +# Sample Debug 源码不应被构建目录忽略规则吞掉 + +状态:candidate +来源:2026-09-29 Sample Avalonia 调试菜单扩展 + +## 现象 + +向 `src/Samples/GalNet.Sample.Avalonia/Debug/` 新增 C# 文件后,项目能编译,但 `git status` 不显示这些文件,因此常规提交会遗漏实现。 + +## 根因 + +仓库根目录的 Visual Studio 通用忽略规则 `[Dd]ebug/` 会匹配任意层级名为 `Debug` 的目录;Sample 恰好把调试源码放在该目录中。 + +## 正确做法 + +在该通用规则之后,为源码目录及其 `.cs` 文件添加精确反忽略规则: + +```gitignore +!src/Samples/GalNet.Sample.Avalonia/Debug/ +!src/Samples/GalNet.Sample.Avalonia/Debug/*.cs +``` + +之后用 `git status` 和 `git diff --check` 确认新增源码可见;不要依赖一次性的 `git add -f`。 + +## 适用范围 + +所有位于名称为 `Debug` 目录下、需要版本化的源码目录。 + +## 不要做什么 + +不要删除全局 `[Dd]ebug/` 规则,它仍用于排除正常构建输出。 + +## 证据和关联文档 + +- `.gitignore` +- `src/Samples/GalNet.Sample.Avalonia/Debug/` diff --git a/.agents/lessons/L-20260929-11-log-tail-extent-is-not-user-scroll.md b/.agents/lessons/L-20260929-11-log-tail-extent-is-not-user-scroll.md new file mode 100644 index 00000000..bc43d7c1 --- /dev/null +++ b/.agents/lessons/L-20260929-11-log-tail-extent-is-not-user-scroll.md @@ -0,0 +1,29 @@ +# 日志追加的 Extent 变化不等于用户离开底部 + +状态:candidate +来源:2026-09-29 Sample Avalonia DebugLogWindow 自动追随修复 + +## 现象 + +仅在日志追加时调用“是否在底部”的判断,会让用户已向上滚动的视图被强制拉回底部;直接在每个 `ScrollChanged` 事件中重算状态,又会把日志追加造成的内容高度变化误判为用户离开了底部。 + +## 根因 + +Avalonia 的 `ScrollChangedEventArgs` 同时报告 `ExtentDelta`、`OffsetDelta` 和 `ViewportDelta`。追加日志只会改变 `Extent`,而用户拖动或滚轮滚动会改变 `Offset`。 + +## 正确做法 + +维护独立的 tail-follow 状态:仅当 `OffsetDelta.Y` 非零时按当前位置更新该状态;追加时仅在状态为 true 时排队 `ScrollIntoView`,并在真正滚动前再次检查状态。 + +## 适用范围 + +Avalonia 中任何有实时追加内容、并允许用户查看历史的日志或聊天列表。 + +## 不要做什么 + +不要用内容高度变化作为用户滚动意图的代理,也不要在新项到达后无条件执行 `ScrollIntoView`。 + +## 证据和关联文档 + +- `src/Samples/GalNet.Sample.Avalonia/Views/DebugLogWindow.axaml.cs` +- Avalonia 12 `ScrollChangedEventArgs` API 文档 diff --git a/.gitignore b/.gitignore index ea8b60fe..4efd7ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ mono_crash.* # Build results [Dd]ebug/ +!src/Samples/GalNet.Sample.Avalonia/Debug/ +!src/Samples/GalNet.Sample.Avalonia/Debug/*.cs [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ diff --git a/GameTestCase/Graph/groups/intro.rawgalgroup b/GameTestCase/Graph/groups/intro.rawgalgroup index 98972e1b..cde17262 100644 --- a/GameTestCase/Graph/groups/intro.rawgalgroup +++ b/GameTestCase/Graph/groups/intro.rawgalgroup @@ -13,6 +13,14 @@ { "id": "intro-line-3", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The blocking rotation has completed before this line is shown.", "voice": "" } }, { "id": "intro-line-4", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Now the non-blocking portrait loop is stopped explicitly.", "voice": "" } }, { "id": "stop-portrait-jitter", "type": "animation.stop", "parameters": { "playbackHandleId": "portrait-jitter", "mode": "CompleteImmediately" } }, + { "id": "dialogue-hide-for-nvl", "type": "dialogue.hide", "parameters": {} }, + { "id": "nvl-show", "type": "nvl.show", "parameters": {} }, + { "id": "nvl-append-rich", "type": "nvl.append", "parameters": { "content": "NVL is visible.\\skip Rich text and typewriter skip boundaries are active." } }, + { "id": "nvl-append-history", "type": "nvl.append", "parameters": { "content": "This is the second paragraph in the scrollable NVL history." } }, + { "id": "nvl-clear", "type": "nvl.clear", "parameters": {} }, + { "id": "nvl-append-after-clear", "type": "nvl.append", "parameters": { "content": "The prior NVL history was cleared, then this paragraph was appended." } }, + { "id": "nvl-hide", "type": "nvl.hide", "parameters": {} }, + { "id": "dialogue-show-after-nvl", "type": "dialogue.show", "parameters": {} }, { "id": "intro-line-5", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Choose a route to see a transition and particles.", "voice": "" } } ] } diff --git a/GameTestCase/README.md b/GameTestCase/README.md index 7777b002..8f59eccb 100644 --- a/GameTestCase/README.md +++ b/GameTestCase/README.md @@ -4,6 +4,7 @@ This Raw-authoring game exercises the compiled runtime slice: - `layer.show`, `layer.replace`, `layer.move`, and `layer.hide` through `ILayerPresenter`; - blocking `dialogue.text` with typewriter directives and `\skip` boundaries; +- explicit `nvl.show`, `nvl.append`, `nvl.clear`, and `nvl.hide`, including rich-text and typewriter NVL history; - Engine-owned Choice filtering/index mapping, condition evaluation, and node jumps; - a composite `transition.crossFade` compiled into an animation plan; - authored layer and particle-property animations; diff --git a/docs/spec/control.md b/docs/spec/control.md index e8d9b7b3..282b3cb2 100644 --- a/docs/spec/control.md +++ b/docs/spec/control.md @@ -42,7 +42,7 @@ Gallery 使用独立的 `IGalleryPageRegistry`,以精确 Gallery `typeId` 映 ## 运行期呈现边界 -Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePageView` 是页面侧 presenter 的组合器,分别暴露 dialogue/choice、layer、animation 和 particle 端口;宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。 +Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、NVL、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePageView` 是页面侧 presenter 的组合器,分别暴露 dialogue/NVL/choice、layer、animation 和 particle 端口;宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。 各 presenter 共享 `IAvaloniaUiDispatcher`,但分别拥有自己的等待、动画和粒子生命周期。`AvaloniaGamePageView` 通过宿主提供的 `IGamePageLayerFactory` 解析图层图像,因而共享页面不会自行推断资源路径。动画逐帧更新由呈现实现完成;Runtime 只维护可存档的场景状态和动画结束后的稳定属性。 diff --git a/docs/spec/entry-types.md b/docs/spec/entry-types.md index c010f281..5c582b5f 100644 --- a/docs/spec/entry-types.md +++ b/docs/spec/entry-types.md @@ -56,6 +56,7 @@ transition.fadeColor ```text dialogue.text, dialogue.show, dialogue.hide +nvl.show, nvl.hide, nvl.clear, nvl.append layer.show, layer.showColor, layer.hide, layer.move, layer.replace animation.animate, animation.play, animation.stop audio.play, audio.stop, audio.pause, audio.resume, audio.enqueue @@ -67,6 +68,8 @@ flow.wait, variable.set, gallery.unlock `gallery.unlock` 的参数是正整数 `id`,其值必须是当前 `GalleryCatalog` 中存在的稳定 Gallery item ID。执行时写入 `player.gallery__unlocked = true`;Gallery item 从资源 `.meta.gallery[]` 聚合,发行运行读取其生成快照。旧的 category/sequence 参数不受支持。 -这些名称和参数只是推荐语义。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`layer` 或任何其他模块存在。 +`nvl.show` 和 `nvl.hide` 只改变 NVL 覆盖层的可见性;`nvl.clear` 移除已累积的历史行;`nvl.append` 只接收长文本 `content`,以与 `dialogue.text` 相同的富文本和打字机语义追加一段,并在文字完成后等待一次玩家推进。它不会自动显示 NVL,剧情应显式调用 `nvl.show`。 + +这些名称和参数只是推荐语义。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`nvl`、`layer` 或任何其他模块存在。 当前已实现 Runtime 行为的推荐能力见 [runtime.md](runtime.md)。 diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 0496859a..199358c5 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -136,6 +136,7 @@ public void Skip(); `GalNet.Primitives.Builtins` 提供可选推荐模块。当前已实现运行时行为的能力包括: - `dialogue.text`:对话与打字机阶段,支持 `\skip` 分段跳过;`dialogue.show` / `dialogue.hide` 控制显示状态。 +- `nvl.show` / `nvl.hide` / `nvl.clear`:分别显示、隐藏和清空 NVL 历史;`nvl.append` 以相同的富文本和打字机语义追加一行,完成打字后等待玩家推进。NVL 不会因 append 自动显示。 - `layer.*`:show/showColor/hide/move/replace,先更新 `SceneState` 再通知 `ILayerPresenter`。 - `animation.animate` / `animation.play` / `animation.stop`:创建 animation primitive instance;一次性动画先提交最终逻辑状态,循环动画提交第 0 帧状态并保存完整重放定义,再启动呈现动画。plan 是单个 instance,内部事件只作为 animation 模块私有 layer/effect 事件处理,不重新进入通用 Entry 分发。 - `effect.apply` / `effect.stop`:维护 `SceneState.ActiveEffects`、目标 Layer 的 effect 索引和 `IEffectPresenter` 调用。 diff --git a/features/F-20260929-15-replay-save-format/feature.md b/features/F-20260929-15-replay-save-format/feature.md new file mode 100644 index 00000000..d2e69deb --- /dev/null +++ b/features/F-20260929-15-replay-save-format/feature.md @@ -0,0 +1,81 @@ +--- +id: F-20260929-15-replay-save-format +title: 基于原语追帧的稳定点存档(暂缓) +type: feature +status: deferred +created: 2026-09-29 +updated: 2026-09-29 +--- + +# 基于原语追帧的稳定点存档 + +## 2026-09-29 范围决策 + +用户决定不继续推进追帧存档。现有“最近无阻塞稳定状态 + Save scope 变量 + 完整 `SceneState`”快照继续作为正式存档模型,本 feature 不产生设计、Phase Plan 或业务实现。 + +后续另行规划“按 graph node 记录完整通过状态,并从已解锁 group 快速进入”的进度/入口能力。该能力不以原语 Replay、内容哈希或 replay-save 格式为前置条件。 + +## 原始目标 + +将正式存档从保存完整 `SceneState` 转为保存最近一次无阻塞稳定状态的位置、分支轨迹和存档变量。读档时由原语自身定义 Replay 语义,静默、快速地重新执行剧情路径以重建画面。所有已注册原语都必须支持 Replay;不接受“不支持追帧”的运行时降级。 + +存档失败或内容不兼容时,宿主弹窗只提供“删除”和“确认(保留存档)”:删除移除存档并刷新列表;确认关闭提示且不进入游戏。 + +## 当前事实与问题 + +- 当前 `GameEngine.CreateSaveData()` 返回最近无阻塞稳定快照,`GameSnapshot` 格式包含 `NodeId`、`EntryIndex`、Save scope 变量和完整 `SceneState`。 +- Runtime 在读取完整快照后由宿主重放 `SceneState` 到平台展示端;这会随 effect、动画、粒子和未来媒体状态扩张存档格式与恢复契约。 +- 引擎已在稳定边界避免保存阻塞原语的中途状态,但尚未记录跨节点的有序分支轨迹,也没有统一的原语 Replay 协议。 +- Graph 内节点 ID 被视为唯一;节点持有其 galgroup 资源引用。存档应以 `GraphId` 与节点 ID 定位内容,不重复保存资源 ID。 + +## 范围 + +- 定义 replay-save 数据:格式版本、`GraphId`、场景缓存锚点、有序局部执行轨迹、最终 `NodeId + EntryIndex`、Save scope 变量和发布内容哈希;不保存用户态 `SceneState`。 +- 在最近无阻塞稳定状态持续维护从最近场景缓存锚点到当前位置的局部轨迹;轨迹保留重复执行,并以出口和 group 内条件 entry 的结果消除歧义。 +- 定义并接入原语 Replay 协议。每个已注册原语必须有确定的 Replay 行为:立即重建其场景结果、按轨迹选择,或作为明确无副作用的完成;不存在“不支持”的运行时分支。 +- 追帧先恢复 Save scope 变量,再只执行原语的 Replay 行为;不再次执行存档变量写入、玩家进度写入、外部回调或媒体实际播放。 +- 追帧失败、轨迹无法匹配或内容版本不兼容时,把可判定失败传递给宿主;Sample/Avalonia 存档 UI 提供“删除/确认(保留)”处理。 +- 为未来多 graph 预留 `GraphId`,但本期继续支持单 graph 默认值,不实现章节选择 UI 或多 graph 运行编排。 +- 编译期为每个可达 graph group node 生成其“进入 group 前”的场景基线缓存;缓存索引以 `(GraphId, NodeId)` 定位,场景值按规范化哈希去重。复用同一 galgroup 资源的多个节点默认拥有独立索引,不按资源 ID 盲目共享上下文。 +- 补充 Runtime、存储、Sample host 的自动化测试和格式兼容测试。 + +## 非目标 + +- 不实现多 graph 的内容选择、章节菜单或跨 graph 跳转。 +- 不保证精确恢复动画帧、粒子年龄、媒体播放时间或 Task 状态;这些由原语的 Replay 语义决定。 +- 不允许通过缺失 Replay 实现静默回落到旧的完整 `SceneState` 恢复。 +- 不在本 feature 中改变 Sample Debug 菜单、日志面板或进程重启调试行为。 + +## 验收标准 + +- 正常存档不再序列化 `SceneState`,但在同一内容版本下读档后可通过原语 Replay 恢复等价的可见场景与后续剧情逻辑。 +- Save scope 变量在读档前恢复;追帧期间变量原语不会二次写入或污染持久状态。 +- 场景缓存锚点加局部轨迹可重放重复访问的节点、分支与选择;最终定位精确到 graph node 和 group entry,且不需要从 graph 根节点重演整段历史。 +- 每个 Runtime 可执行原语都拥有可测试的 Replay 策略;遗漏实现必须在注册、编译或测试门禁中失败。 +- 发布内容哈希、轨迹或资源解析失败会得到可识别的存档加载失败;Sample UI 可删除该存档或保留并退出提示。 +- 现有单 graph 存档使用默认 `GraphId`;格式为将来多 graph 解析预留字段。 + +## 约束与默认 + +- `GraphId` 在当前单 graph 内容中使用稳定默认值;真正的多 graph catalog 留给后续 feature。 +- 轨迹必须保留重复执行,并覆盖缓存锚点之后的跨节点出口和 group 内条件 entry 的执行结果;最终位置另存 `(NodeId, EntryIndex)`。仅记录节点序列无法在跳过变量写入时重建历史条件结果。 +- 节点到 galgroup 资源的映射继续由 graph 清单负责;存档不冗余资源 ID。 +- 发布内容哈希由编译器对可运行内容清单生成:graph、已编译 galgroup、资源元数据及其稳定路径/内容标识共同参与;不兼容不尝试猜测路径。 +- v1 为每个可达 graph group node 输出缓存,以避免运行时搜索最近 anchor;场景缓存值去重,避免同一基线随节点数线性膨胀。存档轨迹只覆盖缓存锚点之后的局部段,不再随完整游戏流程线性增长。 +- Runtime/Core 继续与 Avalonia 和具体文件存储解耦;弹窗及“删除/保留”交互属于宿主 UI。 +- 不考虑向后兼容:旧 `GameSnapshot` 格式直接判为不可加载,不提供迁移或旧读取器。 + +## 开放问题 + +1. 编译器如何证明每个 graph node 的进入场景基线在所有前驱路径下等价;不能证明时是构建失败,还是要求作者插入明确的场景同步节点? +2. group 内条件 entry 的 trace 编码:记录每次条件求值结果、实际执行的 entry ID,还是记录完整的 replay 指令流? +3. 非 group node(例如等待选项的 branch)达到稳定状态时,缓存锚点选择为前一个 group 起点还是也为此类 node 生成空/控制状态缓存? + +## 相关链接 + +- [Runtime 规范](../../docs/spec/runtime.md) +- [架构规范](../../docs/spec/architecture.md) +- [原语模块 Runtime 设计](../../docs/design/primitive-module-runtime-design.md) +- [编译内容管线](../F-20260929-01-compiled-content-pipeline/feature.md) +- [GameSnapshot](../../src/Shared/GalNet.Core/Runtime/GameSnapshot.cs) +- [GameEngine](../../src/Shared/GalNet.Runtime/Engine/GameEngine.cs) diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs index 38afd783..6dfb3fd3 100644 --- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs +++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs @@ -284,7 +284,8 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) _pageView.AnimationPresenter, _effects, content.Gallery, - _pageView.ParticlePresenter)); + _pageView.ParticlePresenter, + _pageView.NvlPresenter)); var runtime = new GameRuntime(null, content.Graph.RootNodeId, new SettingsContainer(), _context.Variables); _engine = new GameEngine(content.Graph, runtime, view, _context.Progress, _pageView.ChoicePresenter); _pageView.AdvanceRequested += OnAdvanceRequested; diff --git a/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs b/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs index 811560da..0a471b75 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs +++ b/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs @@ -1,11 +1,87 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; namespace GalNet.Game.Controls; -/// Scrollable NVL-mode dialogue history. Its items and presentation remain host-owned. -public class NvlPresenter : ItemsControl +/// Scrollable NVL dialogue history with an independently typewritten line for each append. +public sealed class NvlPresenter : TemplatedControl { + private StackPanel? _lines; + private ScrollViewer? _scrollViewer; + private NvlLinePresenter? _latestLine; + + /// Adds a line and completes after its rich text/typewriter presentation finishes. + public Task AppendAsync(string text, double charactersPerSecond, CancellationToken cancellationToken = default) + { + ApplyTemplate(); + var lines = _lines ?? throw new InvalidOperationException("NvlPresenter requires the PART_Lines template part."); + var line = new NvlLinePresenter + { + Text = text, + CharactersPerSecond = charactersPerSecond + }; + lines.Children.Add(line); + _latestLine = line; + line.ApplyTemplate(); + _scrollViewer?.ScrollToEnd(); + return line.StartAsync(cancellationToken); + } + + /// Skips the currently appended line through its next typewriter skip boundary. + public void SkipText() => _latestLine?.Skip(); + + /// Removes all accumulated lines. + public void Clear() + { + _lines?.Children.Clear(); + _latestLine = null; + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _lines = e.NameScope.Find("PART_Lines"); + _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); + } } -/// Default item shape for ; applications may use their own item template instead. -public sealed record NvlLine(string? Speaker, string Text); +/// Templatable rich typewriter line used by . +public sealed class NvlLinePresenter : TemplatedControl +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty CharactersPerSecondProperty = + AvaloniaProperty.Register(nameof(CharactersPerSecond), 30d); + + private TypewriterTextBlock? _typewriter; + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public double CharactersPerSecond + { + get => GetValue(CharactersPerSecondProperty); + set => SetValue(CharactersPerSecondProperty, value); + } + + public Task StartAsync(CancellationToken cancellationToken = default) + { + ApplyTemplate(); + return (_typewriter ?? throw new InvalidOperationException( + "NvlLinePresenter requires the PART_Typewriter template part.")) + .StartAsync(cancellationToken); + } + + public void Skip() => _typewriter?.Skip(); + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _typewriter = e.NameScope.Find("PART_Typewriter"); + } +} diff --git a/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml b/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml index 9d18902f..41743bc6 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml +++ b/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml @@ -87,20 +87,25 @@ + + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml index a0420a8f..20d59291 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml @@ -12,8 +12,9 @@ - + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs index 8a2c1737..bb09fd1e 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs @@ -4,7 +4,7 @@ namespace GalNet.Avalonia.GameView.Presentation; -public sealed class AvaloniaDialoguePresenter : IDialoguePresenter, IChoicePresenter, IDisposable +public sealed class AvaloniaDialoguePresenter : IDialoguePresenter, INvlPresenter, IChoicePresenter, IDisposable { private readonly TaskCompletionSource _initialPresentationReady = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly GamePageViewModel _state; @@ -17,6 +17,7 @@ public AvaloniaDialoguePresenter(GamePageViewModel state, GamePage page, IAvalon _page = page; _dispatcher = dispatcher; _state.AdvanceRequested += OnAdvanceRequested; + _state.NvlClearRequested += OnNvlClearRequested; } public Task InitialPresentationReady => _initialPresentationReady.Task; @@ -27,15 +28,16 @@ public AvaloniaDialoguePresenter(GamePageViewModel state, GamePage page, IAvalon public void ShowDialogue() => _dispatcher.Dispatch(() => _state.IsDialogueVisible = true); public void HideDialogue() => _dispatcher.Dispatch(() => _state.IsDialogueVisible = false); + public void ShowNvl() => _dispatcher.Dispatch(() => _state.IsNvlMode = true); + public void HideNvl() => _dispatcher.Dispatch(() => _state.IsNvlMode = false); + public void ClearNvl() => _dispatcher.Dispatch(_page.Nvl.Clear); + public Task AppendNvlTextAsync(string text, CancellationToken cancellationToken) => + _dispatcher.InvokeAsync(() => _page.Nvl.AppendAsync(text, _state.TextSpeed, cancellationToken)); + public void SkipNvlText() => _dispatcher.Dispatch(_page.Nvl.SkipText); + public Task StartTypewriter(string widgetInstanceId, string speaker, string text, CancellationToken cancellationToken) => _dispatcher.InvokeAsync(async () => { - if (_state.IsNvlMode) - { - _state.PresentNvlLine(speaker, text); - return; - } - _state.IsDialogueVisible = true; _page.Dialogue.Speaker = speaker; _page.Dialogue.Text = text; @@ -70,7 +72,12 @@ Task IDialoguePresenter.PresentTextAsync(string speaker, string text, Cancellati Task IChoicePresenter.ChooseAsync(IReadOnlyList options, CancellationToken cancellationToken) => WaitForChoiceAsync("default_choice", options.ToArray(), cancellationToken); - public void Dispose() => _state.AdvanceRequested -= OnAdvanceRequested; + public void Dispose() + { + _state.AdvanceRequested -= OnAdvanceRequested; + _state.NvlClearRequested -= OnNvlClearRequested; + } private void OnAdvanceRequested() => AdvanceRequested?.Invoke(); + private void OnNvlClearRequested() => _dispatcher.Dispatch(_page.Nvl.Clear); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs index 6220c3f3..68d02bf6 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs @@ -28,6 +28,7 @@ public AvaloniaGamePageView( _animation = new AvaloniaAnimationPresenter(state, dispatcher); _particles = new AvaloniaParticlePresenter(state, layers, dispatcher); DialoguePresenter = _dialogue; + NvlPresenter = _dialogue; ChoicePresenter = _dialogue; LayerPresenter = new AvaloniaLayerPresenter(state, layers, dispatcher); AnimationPresenter = _animation; @@ -35,6 +36,7 @@ public AvaloniaGamePageView( } public IDialoguePresenter DialoguePresenter { get; } + public INvlPresenter NvlPresenter { get; } public IChoicePresenter ChoicePresenter { get; } public ILayerPresenter LayerPresenter { get; } public IAnimationPresenter AnimationPresenter { get; } diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GamePageViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GamePageViewModel.cs index f8f33d03..c42fb6a6 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GamePageViewModel.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GamePageViewModel.cs @@ -25,7 +25,6 @@ public sealed partial class GamePageViewModel : PageViewModelBase /// GPU scene objects composited with Layers before ScenePost and GameShell UI. public ObservableCollection SceneRenderables { get; } = []; public ObservableCollection Choices { get; } = []; - public ObservableCollection NvlLines { get; } = []; [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] private bool _isDialogueVisible; [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] private bool _isChoiceVisible; @@ -40,6 +39,7 @@ public sealed partial class GamePageViewModel : PageViewModelBase public bool IsChoiceOverlayVisible => IsChoiceVisible && !IsUiHidden; public event Action? AdvanceRequested; + public event Action? NvlClearRequested; public event Action? ScreenshotRequested; public event Action? ReturnToTitleRequested; /// Host-observable player actions, kept logger-free so the shared view remains reusable. @@ -121,8 +121,6 @@ private void CompleteChoice(int selectedIndex) InteractionObserved?.Invoke($"choice.selected:{selectedIndex}"); _choiceWaiter?.TrySetResult(selectedIndex); } - public void PresentNvlLine(string speaker, string text) => NvlLines.Add(new NvlLine(speaker, text)); - public void SetLayer(string id, SceneLayerItem item) { var existing = Layers.FirstOrDefault(layer => layer.HandleId == id); @@ -162,7 +160,7 @@ public void ResetScenePresentation() IsDialogueVisible = false; IsChoiceVisible = false; IsGameUiReady = false; - NvlLines.Clear(); + NvlClearRequested?.Invoke(); } /// Shows game chrome after the engine reaches its first interactive boundary. diff --git a/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugLogDirectory.cs b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugLogDirectory.cs new file mode 100644 index 00000000..e018fad0 --- /dev/null +++ b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugLogDirectory.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; + +namespace GalNet.Sample.Avalonia.Debug; + +/// Opens the physical directory containing the Sample's rolling logs. +public sealed class SampleDebugLogDirectory +{ + private readonly GameLaunchOptions _launchOptions = App.LaunchOptions; + + public void Open() + { + var directory = _launchOptions.LogDirectory; + Directory.CreateDirectory(directory); + if (Process.Start(new ProcessStartInfo(directory) { UseShellExecute = true }) is null) + throw new InvalidOperationException("无法打开日志目录。"); + } +} diff --git a/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSaveKind.cs b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSaveKind.cs new file mode 100644 index 00000000..d5480205 --- /dev/null +++ b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSaveKind.cs @@ -0,0 +1,15 @@ +namespace GalNet.Sample.Avalonia.Debug; + +/// Deliberately reduced save forms intended for repeatable debug testing. +internal enum SampleDebugSaveKind +{ + ProgressOnly, + GalGroupStart +} + +/// Result of attempting to write one of the visible debug save slots. +internal sealed record SampleDebugSaveResult(int? SlotIndex, bool RequiresOverwriteConfirmation) +{ + public static SampleDebugSaveResult Saved(int slotIndex) => new(slotIndex, false); + public static SampleDebugSaveResult RequiresOverwrite() => new(null, true); +} diff --git a/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSnapshotFactory.cs b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSnapshotFactory.cs new file mode 100644 index 00000000..ebae142a --- /dev/null +++ b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugSnapshotFactory.cs @@ -0,0 +1,36 @@ +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Core.Variable; +using GalVariable = GalNet.Core.Variable.Variable; + +namespace GalNet.Sample.Avalonia.Debug; + +/// Creates reduced debug snapshots without mutating the live engine snapshot. +internal static class SampleDebugSnapshotFactory +{ + public static GameSnapshot Create(GameSnapshot source, SampleDebugSaveKind kind) + { + ArgumentNullException.ThrowIfNull(source); + return new GameSnapshot + { + Version = source.Version, + NodeId = source.NodeId, + EntryIndex = kind == SampleDebugSaveKind.GalGroupStart ? 0 : source.EntryIndex, + Variables = source.Variables.ToDictionary(pair => pair.Key, pair => CloneVariable(pair.Value)), + SceneState = new SceneState() + }; + } + + private static GalVariable CloneVariable(GalVariable variable) + { + var copy = new GalVariable { Uid = variable.Uid, Name = variable.Name }; + switch (variable.Type) + { + case VariableType.Bool: copy.SetValue(variable.AsBool()); break; + case VariableType.Int: copy.SetValue(variable.AsInt()); break; + case VariableType.Float: copy.SetValue(variable.AsFloat()); break; + default: copy.SetValue(variable.AsString()); break; + } + return copy; + } +} diff --git a/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugViewModel.cs b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugViewModel.cs new file mode 100644 index 00000000..1148f196 --- /dev/null +++ b/src/Samples/GalNet.Sample.Avalonia/Debug/SampleDebugViewModel.cs @@ -0,0 +1,111 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace GalNet.Sample.Avalonia.Debug; + +/// Command surface for optional sample debug tools; window-specific presentation stays in the host view. +internal sealed partial class SampleDebugViewModel(SampleDebugActions actions) : ObservableObject +{ + public event Func>? ConfirmationRequested; + public event Func? NotificationRequested; + public event Action? LogRequested; + + [RelayCommand] + private Task ShowLogAsync() + { + LogRequested?.Invoke(); + return Task.CompletedTask; + } + + [RelayCommand] + private async Task ClearPlayerStateAsync() + { + const string message = "这会停止当前游戏,删除所有存档和阅读进度,并将所有玩家变量还原为默认值,然后自动重新启动。此操作无法撤销。"; + if (!await ConfirmAsync(message, "清空游戏数据")) + return; + + await RunAsync(actions.ClearPlayerStateAsync, "清空游戏数据失败"); + } + + [RelayCommand] + private Task ReloadGameResourcesAsync() => + RunAsync(actions.ReloadGameResourcesAsync, "重新加载游戏资源失败"); + + [RelayCommand] + private Task SaveProgressOnlyAsync() => + SaveDebugSnapshotAsync(SampleDebugSaveKind.ProgressOnly, "仅进度存档"); + + [RelayCommand] + private Task SaveGalGroupStartAsync() => + SaveDebugSnapshotAsync(SampleDebugSaveKind.GalGroupStart, "galgroup 存档"); + + [RelayCommand] + private Task OpenLogDirectoryAsync() => + RunAsync(actions.OpenLogDirectory, "打开日志目录失败"); + + private async Task SaveDebugSnapshotAsync(SampleDebugSaveKind kind, string displayName) + { + try + { + var result = await actions.SaveDebugSnapshotAsync(kind); + if (result.RequiresOverwriteConfirmation) + { + const string overwriteMessage = "可见的 12 个存档槽均已占用。是否覆盖第 1 槽?"; + if (!await ConfirmAsync(overwriteMessage, displayName)) + return; + + result = await actions.SaveDebugSnapshotAsync(kind, overwriteWhenAllVisibleSlotsAreOccupied: true); + } + + await NotifyAsync($"{displayName}已写入第 {result.SlotIndex!.Value + 1} 槽。", displayName); + } + catch (Exception exception) + { + await NotifyAsync(exception.Message, $"{displayName}失败"); + } + } + + private async Task RunAsync(Func action, string failureTitle) + { + try + { + await action(CancellationToken.None); + } + catch (Exception exception) + { + await NotifyAsync(exception.Message, failureTitle); + } + } + + private async Task RunAsync(Action action, string failureTitle) + { + try + { + action(); + } + catch (Exception exception) + { + await NotifyAsync(exception.Message, failureTitle); + } + } + + private async Task ConfirmAsync(string message, string title) + { + var handlers = ConfirmationRequested; + if (handlers is null) + return false; + + foreach (Func> handler in handlers.GetInvocationList()) + if (!await handler(new SampleDebugPrompt(message, title))) + return false; + + return true; + } + + private Task NotifyAsync(string message, string title) => + NotificationRequested?.Invoke(new SampleDebugNotification(message, title)) ?? Task.CompletedTask; +} + +internal sealed record SampleDebugPrompt(string Message, string Title); + +internal sealed record SampleDebugNotification(string Message, string Title); diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleApplicationRestarter.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleApplicationRestarter.cs new file mode 100644 index 00000000..51cb698b --- /dev/null +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleApplicationRestarter.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; + +namespace GalNet.Sample.Avalonia.Services; + +/// Restarts the Sample with the exact host command and launch arguments it received. +internal static class SampleApplicationRestarter +{ + public static void Restart() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) + throw new InvalidOperationException("The Sample can only restart from a desktop application lifetime."); + + var processPath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(processPath)) + throw new InvalidOperationException("The current process path is unavailable."); + + var startInfo = new ProcessStartInfo(processPath) + { + UseShellExecute = false, + WorkingDirectory = Environment.CurrentDirectory + }; + foreach (var argument in Environment.GetCommandLineArgs().Skip(1)) + startInfo.ArgumentList.Add(argument); + + if (Process.Start(startInfo) is null) + throw new InvalidOperationException("Unable to start the replacement Sample process."); + + desktop.Shutdown(); + } +} diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleDebugServiceCollectionExtensions.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleDebugServiceCollectionExtensions.cs index 67b62518..45787760 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleDebugServiceCollectionExtensions.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleDebugServiceCollectionExtensions.cs @@ -1,5 +1,3 @@ -using GalNet.Avalonia.GameView.Navigation; -using GalNet.Avalonia.GameView.ViewModels; using GalNet.Sample.Avalonia.Services; using GalNet.Sample.Avalonia.Views; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +17,9 @@ public static IServiceCollection AddSampleDebug( logStore ??= SampleDebugLogStore.Shared; logStore.Enable(); services.TryAddSingleton(logStore); + services.TryAddSingleton(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddTransient(); return services; } @@ -28,20 +28,25 @@ public static IServiceCollection AddSampleDebug( /// Debug-only orchestration kept outside the reusable game pages. internal sealed class SampleDebugActions( SampleGameSessionService session, - IGameNavigationService navigation) + SampleDebugLogDirectory logDirectory) { public async Task ClearPlayerStateAsync(CancellationToken cancellationToken = default) { - await ReturnToTitleAsync(cancellationToken); await session.ClearPlayerStateAsync(cancellationToken); + SampleApplicationRestarter.Restart(); } public async Task ReloadGameResourcesAsync(CancellationToken cancellationToken = default) { - await ReturnToTitleAsync(cancellationToken); - await session.ReloadGameResourcesAsync(cancellationToken); + await session.StopAsync(cancellationToken); + SampleApplicationRestarter.Restart(); } - private Task ReturnToTitleAsync(CancellationToken cancellationToken) => - navigation.ResetToAsync(NavigationTransition.None, cancellationToken); + public Task SaveDebugSnapshotAsync( + SampleDebugSaveKind kind, + bool overwriteWhenAllVisibleSlotsAreOccupied = false, + CancellationToken cancellationToken = default) => + session.SaveDebugSnapshotAsync(kind, overwriteWhenAllVisibleSlotsAreOccupied, cancellationToken); + + public void OpenLogDirectory() => logDirectory.Open(); } diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs index 85e4635b..c18b5b8c 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs @@ -100,7 +100,8 @@ public static async Task CreateAsync( pageView.AnimationPresenter, effects, resources.Content.Gallery, - pageView.ParticlePresenter)); + pageView.ParticlePresenter, + pageView.NvlPresenter)); var settings = new SettingsContainer(); settings.Set(saveSession.Settings); var runtime = new GameRuntime( diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs index 86c58ce4..a954218c 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs @@ -9,6 +9,7 @@ using GalNet.Core.Runtime; using GalNet.Primitives.Builtins; using GalNet.Runtime.Logging; +using GalNet.Sample.Avalonia.Debug; namespace GalNet.Sample.Avalonia.Services; @@ -24,7 +25,6 @@ internal sealed partial class SampleGameSessionService : ObservableObject, IGame private SampleGameResourceScope? _resources; private SampleSaveSession? _saveSession; private SampleGameRunner? _runner; - private GameLaunchOptions? _launchOptions; private readonly SemaphoreSlim _lifecycle = new(1, 1); private bool _disposed; @@ -49,7 +49,6 @@ public SampleGameSessionService(GamePageViewModel gameplay, GamePage page) public async Task InitializeAsync(GameLaunchOptions options, CancellationToken cancellationToken = default) { - _launchOptions = options; await _lifecycle.WaitAsync(cancellationToken); try { @@ -58,23 +57,6 @@ public async Task InitializeAsync(GameLaunchOptions options, CancellationToken c finally { _lifecycle.Release(); } } - internal async Task ReloadGameResourcesAsync(CancellationToken cancellationToken = default) - { - if (_launchOptions is null) - throw new InvalidOperationException("The game session has not been initialized."); - - GameLog.Logger.Information("Reloading sample game resources"); - await _lifecycle.WaitAsync(cancellationToken); - try - { - await StopCurrentRunAsync(); - await DisposeRunnerAsync(); - await DisposeGameResourcesAsync(); - await InitializeGameResourcesAsync(_launchOptions, cancellationToken); - } - finally { _lifecycle.Release(); } - } - private async Task InitializeGameResourcesAsync(GameLaunchOptions options, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(options.GameDirectory)) @@ -186,17 +168,15 @@ internal async Task ClearPlayerStateAsync(CancellationToken cancellationToken = try { await StopCurrentRunAsync(); - await DisposeRunnerAsync(); var saveSession = _saveSession; if (saveSession is not null) await saveSession.ClearAsync(cancellationToken); - await RefreshSlotsAsync(cancellationToken); await OnUiAsync(() => { _gameplay.TextSpeed = saveSession?.Settings.TextSpeed ?? _gameplay.TextSpeed; _gameplay.IsNvlMode = false; _gameplay.IsUiHidden = false; - _gameplay.StatusMessage = "Player state cleared."; + _gameplay.StatusMessage = "Player state cleared. Restarting..."; }); } finally { _lifecycle.Release(); } @@ -215,6 +195,37 @@ public async Task SaveAsync(int slotIndex, CancellationToken cancellationToken = finally { _lifecycle.Release(); } } + internal async Task SaveDebugSnapshotAsync( + SampleDebugSaveKind kind, + bool overwriteWhenAllVisibleSlotsAreOccupied, + CancellationToken cancellationToken = default) + { + await _lifecycle.WaitAsync(cancellationToken); + try + { + if (_runner is null || _saveSession is null) + throw new InvalidOperationException("没有进行中的游戏可供存档。"); + + var firstEmptySlot = _saveSlots + .Where(slot => slot.IsEmpty) + .OrderBy(slot => slot.SlotIndex) + .FirstOrDefault(); + if (firstEmptySlot is null && !overwriteWhenAllVisibleSlotsAreOccupied) + return SampleDebugSaveResult.RequiresOverwrite(); + + var targetSlot = firstEmptySlot?.SlotIndex + ?? _saveSlots.OrderBy(slot => slot.SlotIndex).FirstOrDefault()?.SlotIndex + ?? throw new InvalidOperationException("没有可用的存档槽。"); + var snapshot = SampleDebugSnapshotFactory.Create(_runner.CreateSaveData(), kind); + await _saveSession.SaveAsync(targetSlot, snapshot, cancellationToken); + await RefreshSlotsAsync(cancellationToken); + await OnUiAsync(() => _gameplay.StatusMessage = $"Debug saved to slot {targetSlot}."); + GameLog.Logger.Information("Created {DebugSaveKind} debug save in slot {SlotIndex}", kind, targetSlot); + return SampleDebugSaveResult.Saved(targetSlot); + } + finally { _lifecycle.Release(); } + } + public async Task LoadAsync(int slotIndex, CancellationToken cancellationToken = default) { await PrepareLoadAsync(slotIndex, cancellationToken); @@ -296,29 +307,6 @@ private async Task DisposeRunnerAsync() await runner.DisposeAsync(); } - private async Task DisposeGameResourcesAsync() - { - _saveSession?.Dispose(); - _saveSession = null; - GalleryResources = null; - GalleryDataSource = null; - OnPropertyChanged(nameof(GalleryResources)); - OnPropertyChanged(nameof(GalleryDataSource)); - - _resources?.Dispose(); - _resources = null; - - await OnUiAsync(() => - { - _saveSlots.Clear(); - IsReady = false; - IsPlaying = false; - CanContinue = false; - StatusMessage = "Reloading game resources..."; - _gameplay.StatusMessage = "Reloading game resources..."; - }); - } - private Task ResetScenePresentationAsync() { if (Dispatcher.UIThread.CheckAccess()) diff --git a/src/Samples/GalNet.Sample.Avalonia/Views/DebugLogWindow.axaml b/src/Samples/GalNet.Sample.Avalonia/Views/DebugLogWindow.axaml index 8963e5b2..9c5e8614 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Views/DebugLogWindow.axaml +++ b/src/Samples/GalNet.Sample.Avalonia/Views/DebugLogWindow.axaml @@ -11,6 +11,7 @@