diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 74a849a4..3a540912 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -30,9 +30,12 @@ ## 当前工作 +- [F-20261009-02-video-entries](../features/F-20261009-02-video-entries/feature.md) 已实现剧情视频图层、阻塞分段 seek、停止/替换清理和非阻塞视频定义恢复。平台视频必须经 AssetManager 获取,解码帧通过租约进入场景管线;播放游标不入存档。验证见 feature summary。 + - `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-20260916-02-audio-system](../features/F-20260916-02-audio-system/feature.md) 已完成首期:轨道逻辑、`AudioRuntime`、`IAudioPlaybackHost`、Avalonia LibVLC host、语音文本 composite 和 Sample 验收已落地。Runtime 只能传 asset ID;平台 host 必须经 `IAssetManager` 实体化临时媒体文件。后续 [F-20261009-01-track-seffects](../features/F-20261009-01-track-seffects/feature.md) 增加 host 注册的 `SEffectCatalog`、`seffect.gain` 与基于 LibVLC equalizer 的 `seffect.lowPass` / `seffect.highPass`;item scope、动画、存档、玩家音量和角色默认轨道均未实现。 - `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 +60,12 @@ - [动画存档保存稳定语义而非播放游标](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 文件。 + +- [动态视频图层的加载与帧生命周期](lessons/L-20261010-01-video-layer-lifetime.md):待加载源先登记,hide/replace 取消加载;帧租约与独立播放 identity 防止晚发布和旧完成误清理。 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/.agents/lessons/L-20261010-01-video-layer-lifetime.md b/.agents/lessons/L-20261010-01-video-layer-lifetime.md new file mode 100644 index 00000000..6b998cfa --- /dev/null +++ b/.agents/lessons/L-20261010-01-video-layer-lifetime.md @@ -0,0 +1,22 @@ +# 动态视频图层的加载与帧生命周期 + +状态:confirmed +来源 Feature:F-20261009-02-video-entries + +## 现象与根因 + +异步加载完成后才登记视频图层,会使加载期间的 hide/replace 找不到待播放源;加载完成后视频可能重新覆盖后来的画面。动态纹理还会被解码线程替换,直接释放旧帧可能与渲染线程读取相竞争。 + +## 发现与证据 + +代码审查确认加载后登记缺少取消路径;新增真实宿主测试使用延迟 AssetManager,在 GetFileAsync 未结束时 hide 或替换为颜色图层,证明修改后立即取消加载且不会晚发布。帧租约测试验证当前帧在发布新帧前保持可读,真实 niko.mp4 解码验证帧进入同一场景管线。 + +## 正确做法 + +先登记可释放的待播放 FrameSource;hide/replace 释放源并取消尚未完成的加载。每次播放独立 identity,旧完成只能清理自己的源。帧使用租约同步发布/读取,未被读取的 source bitmap 同样必须释放。native 完成/失败事件只发信号,Stop/Dispose 在回调之外执行;会话销毁等待所有播放器释放后再释放 LibVLC。 + +## 适用范围与禁止做法 + +适用于异步动态 Layer 源。不要在加载后无条件发布图层,不要按 handle 无条件清理旧播放,不要跨渲染线程直接释放仍可能读取的帧,不要在 LibVLC callback 内 Stop/Dispose。 + +关联:features/F-20261009-02-video-entries/design.md;VideoNativeSmokeTests;VideoLayerTests。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 602a7219..f1bb612a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: with: dotnet-version: 10.0.401 - name: Restore workloads - run: dotnet workload restore GalNet.slnx + run: dotnet workload restore GalNet.slnx --version 10.0.401.1 - name: Restore locked dependencies run: dotnet restore GalNet.slnx --locked-mode - name: Verify whitespace @@ -110,7 +110,7 @@ jobs: with: dotnet-version: 10.0.401 - name: Restore workloads - run: dotnet workload restore GalNet.slnx + run: dotnet workload restore GalNet.slnx --version 10.0.401.1 - name: Restore locked dependencies run: dotnet restore GalNet.slnx --locked-mode - name: Build full solution 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/Assets/niko.mp4 b/GameTestCase/Assets/niko.mp4 new file mode 100644 index 00000000..c1d24ec0 Binary files /dev/null and b/GameTestCase/Assets/niko.mp4 differ diff --git a/GameTestCase/Assets/niko.mp4.meta b/GameTestCase/Assets/niko.mp4.meta new file mode 100644 index 00000000..312b6b05 --- /dev/null +++ b/GameTestCase/Assets/niko.mp4.meta @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000011","type":"video","path":"niko.mp4","compress":"none"} diff --git "a/GameTestCase/Assets/\345\277\203.mp3" "b/GameTestCase/Assets/\345\277\203.mp3" new file mode 100644 index 00000000..5bb271c6 Binary files /dev/null and "b/GameTestCase/Assets/\345\277\203.mp3" differ diff --git "a/GameTestCase/Assets/\345\277\203.mp3.meta" "b/GameTestCase/Assets/\345\277\203.mp3.meta" new file mode 100644 index 00000000..5fb3805d --- /dev/null +++ "b/GameTestCase/Assets/\345\277\203.mp3.meta" @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000010","type":"audio","path":"心.mp3","compress":"none"} diff --git a/GameTestCase/Graph/groups/downtown.rawgalgroup b/GameTestCase/Graph/groups/downtown.rawgalgroup index 500a6dac..97e77409 100644 --- a/GameTestCase/Graph/groups/downtown.rawgalgroup +++ b/GameTestCase/Graph/groups/downtown.rawgalgroup @@ -4,6 +4,6 @@ "entries": [ { "id": "cross-fade", "type": "transition.crossFade", "parameters": { "playbackHandleId": "route-crossfade", "oldHandleId": "background", "newHandleId": "route-background", "assetId": "a1000000000000000000000000000003", "transform": { "x": 0, "y": 0, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 0, "displayMode": "Tile", "durationFrames": 36 } }, { "id": "portrait-move", "type": "animation.animate", "parameters": { "playbackHandleId": "portrait-settle", "handleId": "teacher", "property": "transform.x", "to": 500, "duration": 0.5, "curve": "EaseOut", "blocking": "false", "skippable": "false" } }, - { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The tiled route uses a compiled cross-fade and a portrait animation.", "voice": "" } } + { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The tiled route uses a compiled cross-fade and a portrait animation." } } ] } diff --git a/GameTestCase/Graph/groups/ending.rawgalgroup b/GameTestCase/Graph/groups/ending.rawgalgroup index 38e29497..3cd3030d 100644 --- a/GameTestCase/Graph/groups/ending.rawgalgroup +++ b/GameTestCase/Graph/groups/ending.rawgalgroup @@ -4,6 +4,6 @@ "entries": [ { "id": "stop-snow", "type": "particle.stop", "parameters": { "instanceId": "route-particles" } }, { "id": "hide-portrait", "type": "layer.hide", "parameters": { "handleId": "teacher" } }, - { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Runtime smoke test complete.", "voice": "" } } + { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Runtime smoke test complete." } } ] } diff --git a/GameTestCase/Graph/groups/harbor.rawgalgroup b/GameTestCase/Graph/groups/harbor.rawgalgroup index 6b1edb0f..718cd751 100644 --- a/GameTestCase/Graph/groups/harbor.rawgalgroup +++ b/GameTestCase/Graph/groups/harbor.rawgalgroup @@ -5,6 +5,6 @@ { "id": "route-blinds", "type": "transition.blinds", "parameters": { "playbackHandleId": "route-blinds", "oldHandleId": "background", "newHandleId": "route-background", "assetId": "a1000000000000000000000000000002", "maskProgram": "a1000000000000000000000000000008", "transform": { "x": 0, "y": 0, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 0, "displayMode": "Fill", "bladeCount": 14, "orientation": "Vertical", "durationFrames": 42 } }, { "id": "snow", "type": "particle.play", "parameters": { "instanceId": "route-particles", "z": 100, "parameters": { "particleTexture": "a1000000000000000000000000000006", "rate": 72, "maxParticles": 240, "seed": 20260929, "initial": { "lifetime": { "min": 3, "max": 4 }, "velocityX": { "min": -28, "max": -16 }, "velocityY": { "min": 68, "max": 92 }, "size": { "min": 0.6, "max": 0.9 }, "rotationDegrees": { "min": 0, "max": 360 }, "angularVelocityDegrees": { "min": -25, "max": 25 }, "color": { "min": "#DDEFFFFF", "max": "#FFFFFFFF" } }, "motion": { "gravityY": 12, "noise": 24, "drag": 0.08 }, "lifetime": { "opacity": [ { "time": 0, "value": 0 }, { "time": 0.12, "value": 1 }, { "time": 0.8, "value": 1 }, { "time": 1, "value": 0 } ], "size": [ { "time": 0, "value": 0.8 }, { "time": 1, "value": 1.1 } ] }, "shape": { "type": "box", "x": 640, "y": -12, "width": 1280, "height": 0 } } } }, { "id": "spark-burst", "type": "particle.burst", "parameters": { "count": 48, "z": 110, "parameters": { "particleTexture": "a1000000000000000000000000000005", "maxParticles": 48, "seed": 20260930, "initial": { "lifetime": { "min": 1.2, "max": 1.8 }, "velocityX": { "min": -20, "max": 20 }, "velocityY": { "min": -110, "max": -70 }, "size": { "min": 0.45, "max": 0.65 }, "rotationDegrees": { "min": 0, "max": 360 }, "angularVelocityDegrees": { "min": -180, "max": 180 }, "color": { "min": "#FFF0A0FF", "max": "#FFFFFFFF" } }, "motion": { "gravityY": 120, "radialVelocity": 80, "drag": 0.25 }, "lifetime": { "size": [ { "time": 0, "value": 0.5 }, { "time": 0.25, "value": 1 }, { "time": 1, "value": 0.2 } ], "opacity": [ { "time": 0, "value": 1 }, { "time": 0.7, "value": 1 }, { "time": 1, "value": 0 } ], "velocity": [ { "time": 0, "value": 1 }, { "time": 1, "value": 0.35 } ], "color": [ { "time": 0, "color": "#FFFFFFFF" }, { "time": 1, "color": "#FF7040FF" } ] }, "shape": { "type": "circle", "x": 640, "y": 420, "radius": 24 }, "flipbook": { "columns": 2, "rows": 2, "frameCount": 4, "cyclesOverLifetime": { "min": 0.9, "max": 1.1 }, "startFrame": { "min": 0, "max": 1 }, "loop": false } } } }, - { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The route combines a persistent rate emitter with a one-shot, per-particle flipbook burst.", "voice": "" } } + { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The route combines a persistent rate emitter with a one-shot, per-particle flipbook burst." } } ] } diff --git a/GameTestCase/Graph/groups/intro.rawgalgroup b/GameTestCase/Graph/groups/intro.rawgalgroup index 98972e1b..2065667c 100644 --- a/GameTestCase/Graph/groups/intro.rawgalgroup +++ b/GameTestCase/Graph/groups/intro.rawgalgroup @@ -3,16 +3,42 @@ "kind": "Raw", "entries": [ { "id": "dialogue-show", "type": "dialogue.show", "parameters": {} }, + { "id": "bgm-fade-in", "type": "audio.play", "parameters": { "trackId": "bgm", "asset": "a1000000000000000000000000000010", "gain": 1, "placement": "replace", "fadeIn": 2 } }, { "id": "background", "type": "layer.show", "parameters": { "handleId": "background", "assetId": "a1000000000000000000000000000001", "transform": { "x": 0, "y": 0, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 0, "opacity": 1, "displayMode": "Fill" } }, { "id": "unlock-gallery", "type": "gallery.unlock", "parameters": { "id": 100 } }, { "id": "portrait", "type": "layer.show", "parameters": { "handleId": "teacher", "assetId": "a1000000000000000000000000000004", "transform": { "x": 420, "y": 80, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 10, "opacity": 1, "displayMode": "Native" } }, - { "id": "intro-line-1", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The Runtime sample now builds from Raw authoring content.", "voice": "" } }, + { "id": "intro-line-1", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The Runtime sample now builds from Raw authoring content. The test BGM is fading in on the bgm track." } }, + { "id": "hide-dialogue-for-video", "type": "dialogue.hide", "parameters": {} }, + { "id": "video-blocking", "type": "video.play", "parameters": { "asset": "a1000000000000000000000000000011", "handleId": "sample-video", "blocking": "true", "skipPoints": [3, 6, 9, 999999], "z": 5, "opacity": 0.85, "displayMode": "Uniform" } }, + { "id": "show-dialogue-after-video", "type": "dialogue.show", "parameters": {} }, + { "id": "video-background", "type": "video.play", "parameters": { "asset": "a1000000000000000000000000000011", "handleId": "sample-video", "blocking": "false", "transform": { "x": -160, "y": 60, "scaleX": 0.5, "scaleY": 0.5, "rotationDegrees": 10 }, "z": 5, "opacity": 0.7, "displayMode": "Uniform" } }, + { "id": "intro-video-background", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "niko.mp4 is a scene layer below the portrait, with rotation, scale and opacity. This non-blocking video lets dialogue advance. Advance to stop it." } }, + { "id": "video-stop", "type": "video.stop", "parameters": { "handleId": "sample-video" } }, + { "id": "bgm-duck", "type": "audio.seffect.apply", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-duck", "id": "seffect.gain", "parameters": { "gain": 0.35 } } }, + { "id": "intro-line-effect-on", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "A seffect.gain effect is now active, ducking this BGM track while the scene continues." } }, + { "id": "bgm-unduck", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-duck" } }, + { "id": "intro-line-effect-off", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The track effect has been removed and the BGM volume is restored." } }, + { "id": "bgm-low-pass", "type": "audio.seffect.apply", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-low-pass", "id": "seffect.lowPass", "parameters": { "cutoffHz": 900, "attenuationDb": -20 } } }, + { "id": "intro-line-low-pass", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "A low-pass SEffect now removes the BGM's higher bands. Advance to remove it." } }, + { "id": "bgm-stop-low-pass", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-low-pass" } }, + { "id": "bgm-high-pass", "type": "audio.seffect.apply", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-high-pass", "id": "seffect.highPass", "parameters": { "cutoffHz": 1200, "attenuationDb": -20 } } }, + { "id": "intro-line-high-pass", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "A high-pass SEffect now removes the BGM's lower bands. Advance to restore the full track." } }, + { "id": "bgm-stop-high-pass", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-high-pass" } }, { "id": "portrait-jitter", "type": "animation.animate", "parameters": { "playbackHandleId": "portrait-jitter", "handleId": "teacher", "property": "transform.y", "from": 0, "to": 12, "duration": 0.06, "curve": "EaseInOut", "blocking": "false", "skippable": "false", "loopMode": "PingPong", "blendMode": "Additive" } }, - { "id": "intro-line-2", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The portrait is rapidly bobbing through a non-blocking additive loop.", "voice": "" } }, + { "id": "intro-line-2", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The portrait is rapidly bobbing through a non-blocking additive loop." } }, { "id": "portrait-spin", "type": "animation.animate", "parameters": { "playbackHandleId": "portrait-spin", "handleId": "teacher", "property": "transform.rotationDegrees", "from": 0, "to": 360, "duration": 0.7, "curve": "EaseInOut", "blocking": "true", "skippable": "true", "loopMode": "Once", "blendMode": "Replace" } }, - { "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": "intro-line-3", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The blocking rotation has completed before this line is shown." } }, + { "id": "intro-line-4", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Now the non-blocking portrait loop is stopped explicitly." } }, { "id": "stop-portrait-jitter", "type": "animation.stop", "parameters": { "playbackHandleId": "portrait-jitter", "mode": "CompleteImmediately" } }, - { "id": "intro-line-5", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Choose a route to see a transition and particles.", "voice": "" } } + { "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": "bgm-fade-out", "type": "audio.stop", "parameters": { "trackId": "bgm", "clearQueue": "true", "fadeOut": 2 } }, + { "id": "intro-line-5", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "The BGM is now fading out mid-track. Choose a route to see a transition and particles." } } ] } diff --git a/GameTestCase/README.md b/GameTestCase/README.md index 7777b002..4f674c87 100644 --- a/GameTestCase/README.md +++ b/GameTestCase/README.md @@ -3,11 +3,14 @@ This Raw-authoring game exercises the compiled runtime slice: - `layer.show`, `layer.replace`, `layer.move`, and `layer.hide` through `ILayerPresenter`; +- `niko.mp4` as a real scene layer: blocking `video.play` with `[3, 6, 9, 999999]` second skip points, then transformed non-blocking playback and explicit `video.stop`; the portrait stays above it by z order; - 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; - Runtime-backed particle start/stop with GUID-backed texture resources. +- Configurable `bgm`, `sfx`, and `vocal` tracks: the intro uses `心.mp3` to demonstrate in-progress BGM fade-in, `seffect.gain` apply/stop, `seffect.lowPass` / `seffect.highPass` apply/stop, and in-progress fade-out. The `.rawgalgroup` files are the sole authoring source. Build them with `GalNet.Editor.Headless`; the generated `.galgroup` files use the version 2 compiled format (`typeId` plus `arguments`). Every @@ -25,3 +28,7 @@ Build and run from the repository root: dotnet run --project src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj -- build GameTestCase --output artifacts/sample-build dotnet run --project src/Samples/GalNet.Sample.Headless/GalNet.Sample.Headless.csproj -- artifacts/sample-build ``` + +Video smoke: advance the first dialogue to reach blocking playback. Each left click seeks to the next point after the current position; the oversized final point ends playback. The following non-blocking playback uses rotation, scale and opacity below the portrait, then stops on dialogue advance. Headless has no video presenter and completes these entries immediately. + +Native decode tests are opt-in: set `GALNET_VIDEO_ASSETS` to the absolute `GameTestCase/Assets` directory and `GALNET_VIDEO_LIBVLC` to the native LibVLC directory, then run Presentation tests with `--filter FullyQualifiedName~VideoNativeSmokeTests`. `GALNET_VIDEO_CAPTURE` optionally exports a composed scene PNG. The tests check actual decoded pixels, sequential seeks, natural completion and cancellation of loading during hide/replace. diff --git a/README.md b/README.md index e929e600..033acb62 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ scripts/ 可直接运行的样例脚本 GameTestCase/ 示例工程与 smoke test 内容 ``` -程序集边界、依赖方向和测试职责见[架构文档](docs/spec/architecture.md)。 +从[引擎使用手册](docs/engine.md)了解内容与运行期;基于本项目制作客户端或添加玩法能力时,请从[二次开发手册](docs/development.md)开始。程序集边界、依赖方向和测试职责见[架构文档](docs/spec/architecture.md)。 ## 构建与测试 diff --git a/docs/README.md b/docs/README.md index 89b71cd0..9a2f53d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,13 +1,16 @@ # GalNet 文档 +- [引擎使用手册](engine.md):内容、编译、Runtime、推进、存档和宿主的总览入口。 +- [二次开发手册](development.md):不修改 Shared 层时,如何添加项目自有原语、模块、页面和资源能力。 - [架构](spec/architecture.md):当前程序集边界、游戏运行、编辑器工作流与维护规则。 -- [运行时](spec/runtime.md):游戏引擎、变量、快照与条目执行。 -- [条目类型](spec/entry-types.md):内置条目及参数。 +- [运行时](spec/runtime.md):游戏引擎、变量、快照与条目执行的精确契约。 +- [条目类型](spec/entry-types.md):primitive/composite、模块和内置条目的精确契约。 - [文件格式](spec/file-formats.md):项目目录、图、条目组和当前 `.galpak` 发布布局。 - [资源](spec/assets.md):资源提供者、归档和包格式。 - [Avalonia 游戏页面](spec/control.md):共享游戏页面、导航、组合和 Avalonia 呈现边界。 - [国际化](spec/i18n.md):编辑器本地化、运行时文本解析器和项目语言设置。 - [术语表](glossary.md):项目术语。 -- [杂项待办](design/misc-todo.md):与游戏本体解耦的工程性后续工作。 +- [杂项待办](design/misc-todo.md):尚未实现的工程性后续工作。 - [Feature 工作流与 Agent 知识维护](design/feature-workflow.md):推荐的 feature 记录流程,以及开发者文档、feature 历史与 agent 知识的职责边界。 - [分阶段渲染与 Effect 管线计划](design/render-effects-pipeline-plan.md):Layer Source、Shader 效果、UI 前后全屏后处理与 Flipbook 的实施计划。 +- [音频系统设计](design/audio-system-design.md):尚未实施的音频能力设计。 diff --git a/docs/design/audio-system-design.md b/docs/design/audio-system-design.md index 758a6ce3..6f952405 100644 --- a/docs/design/audio-system-design.md +++ b/docs/design/audio-system-design.md @@ -1,6 +1,8 @@ # GalNet 音频系统设计 -> 状态:设计稿。对应 [Avalonia 游戏页面与内容对接计划](avalonia-game-page-integration-plan.md) 的 Phase 4;本文不代表已实现的接口或行为。 +> 状态:设计稿。本文不代表已实现的接口或行为;当前已实现的行为以 Runtime、Entry 和对应测试为准。 + +> 2026-10-09 已实现基线:`AudioRuntime`、`IAudioPlaybackHost`、固定 bus 上的项目轨道、Avalonia LibVLC host、`audio.*` 原语、语音文本 composite,以及 host 注册的轨道 `seffect.gain`、`seffect.lowPass` 和 `seffect.highPass`。其中低/高通由 LibVLC 10 段 equalizer 近似实现;item scope、参数动画、编辑器表单、其他 DSP、存档与精确调度仍为设计,不应当作现有承诺。 ## 目标与边界 @@ -26,7 +28,7 @@ | **播放项(item)** | 队列中的一次播放请求,引用一个音频资源并携带本次音量、循环和完成策略。 | | **放置策略(placement)** | 新播放项如何进入轨道:入队、下一首、替换当前或替换全部。 | | **选择模式(selection mode)** | 队列在需要下一项时按顺序取出还是随机取出。 | -| **音频 Effect** | 对 PCM/采样流或混音节点施加的处理器,如低通、声像、均衡、空间化。与图像 Effect 同为可配置实例,但不进入图像渲染管线。 | +| **SEffect** | 对 PCM/采样流或混音节点施加的处理器,如低通、声像、均衡、空间化。与图像 Effect 同为可配置实例,但不进入图像渲染管线。 | `sfx` 不应被理解为“只允许一个声音的音效轨道”。默认 `sfx` 是一个可多声部的逻辑轨道;需要严格排他的音效时,作者可另建 `ui_sfx`、`ambience` 等轨道并设置 `maxVoices: 1`。 @@ -37,7 +39,7 @@ flowchart LR Entry[剧情原语\naudio.play / next / pause] --> Runtime[AudioRuntime\n队列、状态、诊断、快照] Project[项目轨道定义] --> Runtime Settings[玩家设置\nMaster + Bus/Track 音量] --> Mixer - Runtime --> Request[IAudioView / IAudioPlaybackHost] + Runtime --> Request[IAudioPlaybackHost] Request --> Backend[宿主音频后端\n解码、时钟、结束事件] Backend --> ItemFx[播放项 Effect 链] ItemFx --> TrackFx[轨道 Effect 链] @@ -65,6 +67,8 @@ Core 保存可序列化的定义、原语数据、状态和 Effect 元数据;R "defaultVolume": 0.8, "selectionMode": "sequential", "maxVoices": 1, + "maxQueuedItems": 64, + "queueOverflowPolicy": "dropNewest", "overflowPolicy": "stopOldest" }, { @@ -74,6 +78,8 @@ Core 保存可序列化的定义、原语数据、状态和 Effect 元数据;R "defaultVolume": 1.0, "selectionMode": "sequential", "maxVoices": 8, + "maxQueuedItems": 0, + "queueOverflowPolicy": "dropNewest", "overflowPolicy": "dropNewest" }, { @@ -83,6 +89,8 @@ Core 保存可序列化的定义、原语数据、状态和 Effect 元数据;R "defaultVolume": 1.0, "selectionMode": "sequential", "maxVoices": 1, + "maxQueuedItems": 64, + "queueOverflowPolicy": "dropNewest", "overflowPolicy": "stopOldest" } ] @@ -96,11 +104,13 @@ Core 保存可序列化的定义、原语数据、状态和 Effect 元数据;R | --- | --- | | `id` | 项目内唯一、非空、建议小写 kebab-case。它是剧情和玩家设置的稳定键;创建后不支持直接编辑。重命名通过编辑器的“重命名并更新引用”重构操作完成。 | | `name` | 仅编辑器显示名,可随时修改,不参与运行时查找。 | -| `bus` | 初版限定 `bgm`、`sfx`、`vocal` 三个内置 bus;自定义 bus 留给后续,不阻止创建自定义轨道。 | +| `bus` | 初版限定并内置 `bgm`、`sfx`、`vocal` 三个 bus;轨道可选择其中之一。本 feature 不提供 bus CRUD;自定义 bus 留给后续。 | | `defaultVolume` | 作者的轨道基础增益,范围 `[0, 1]`。不是玩家偏好。 | | `selectionMode` | `sequential` 或 `shuffle`,只影响从待播队列取下一项的顺序。 | | `maxVoices` | 正整数。`1` 表示单声部的“当前项”;大于 `1` 时允许同时播放多个项。 | -| `overflowPolicy` | 超过声部上限时的策略。初版支持 `dropNewest`、`stopOldest`;`stopQuietest` 可后续添加。 | +| `maxQueuedItems` | 非负整数,限制待播项数量。`0` 禁止排队:声部饱和时直接按 `overflowPolicy` 处理,避免短 SFX 延迟播放。 | +| `queueOverflowPolicy` | 队列已满时的策略。初版支持 `dropNewest`、`dropOldest`。 | +| `overflowPolicy` | 尝试立即启动但超过活动声部上限时的策略。初版支持 `dropNewest`、`stopOldest`;`stopQuietest` 可后续添加。 | 删除被剧情条目、轨道 Effect 或玩家设置引用的轨道时,编辑器应显示引用清单并要求作者先迁移引用或确认删除。Runtime 遇到不存在的轨道仅记录诊断并安全跳过。 @@ -118,7 +128,7 @@ effectiveGain = masterGain × playerBusGain × playerTrackGain × trackDefaultGa - `trackDefaultGain`:项目作者在轨道定义中设定的基础增益。 - `itemGain`:一次 `audio.play` 的相对增益,默认 `1`,不写入玩家设置。 -静音是对应层增益为 `0` 的明确状态,不能通过停止音频实现;静音期间队列、结束事件和剧情流程仍正常运行。Phase 4 不需展示所有自定义轨道的设置滑块,但 `GameSettings` 的持久化模型应从固定 `BgmVolume/SfxVolume/VoiceVolume` 演进为 `master`、按 bus 的映射和按 track ID 的映射,并兼容迁移现有三个字段。 +静音是对应层增益为 `0` 的明确状态,不能通过停止音频实现;静音期间队列、结束事件和剧情流程仍正常运行。首期只实现作者轨道基础增益和播放项局部增益;master、按 bus/track 的玩家音量以及 UI 留给后续 feature,旧 `BgmVolume/SfxVolume/VoiceVolume` 不迁移。 ## 队列与播放项 @@ -133,7 +143,7 @@ effectiveGain = masterGain × playerBusGain × playerTrackGain × trackDefaultGa "fadeIn": { "duration": 0.8, "curve": "equalPower" }, "fadeOut": { "duration": 0.8, "curve": "equalPower" }, "effects": [ - { "id": "audio.lowPass", "parameters": { "cutoffHz": 2400 } } + { "id": "seffect.lowPass", "parameters": { "cutoffHz": 2400 } } ] } ``` @@ -146,7 +156,7 @@ effectiveGain = masterGain × playerBusGain × playerTrackGain × trackDefaultGa | `completion` | `discard`:完成后移除;`requeueTail`:完成后放回待播队尾。它服务于歌单/轮播逻辑,不保证无缝。 | | `fadeIn` | 可选的启动包络,含非负 `duration`(秒)和 `curve`。不存在或 `duration: 0` 表示立即到达目标增益。 | | `fadeOut` | 可选的结束包络,含非负 `duration` 和 `curve`。它只用于播放项离开轨道且没有后继项接管切换时。 | -| `effects` | 可选的单播放项 Effect attachment 列表;与资源同时创建,按列表顺序执行,且输出仍会经过轨道 Effect 链。 | +| `effects` | 可选的单播放项 SEffect attachment 列表;与资源同时创建,按列表顺序执行,且输出仍会经过轨道 SEffect 链。 | `loopCount` 和 `completion` 不能互相替代:无限 BGM 循环应使用 `loopCount: infinite`,A/B 两首歌轮播应使用 `completion: requeueTail`。后者的重新入队可能有解码/调度间隙。 @@ -172,12 +182,12 @@ effectiveGain = masterGain × playerBusGain × playerTrackGain × trackDefaultGa | `placement` | 语义 | | --- | --- | -| `enqueue` | 加到待播队列尾部;轨道无活动声部时立即按选择模式启动一个项。 | -| `next` | 插入当前播放项之后;若轨道没有活动项,等价于立即播放。单声部轨道只有一个“之后”;多声部轨道中插入到此轨道的优先待播区。 | +| `enqueue` | 有空闲声部时立即启动;否则若允许排队则加到待播队列尾部。`maxQueuedItems: 0` 时直接按 `overflowPolicy` 处理;允许排队但队列满时按 `queueOverflowPolicy` 处理。Runtime 在每次声部释放后 pump 队列,因此多声部轨道在未满时会立即叠加播放。 | +| `next` | 插入单声部轨道当前项之后;若无活动项,等价于立即播放。多声部轨道没有唯一“下一首”,初版调用必须诊断并安全完成。 | | `replace` | 立即停止当前活动项并启动新项,保留既有待播队列。多声部轨道停止所有活动声部。 | | `replaceAll` | 立即停止活动项、清空待播队列,再启动新项。BGM/语音切换通常使用它。 | -`audio.next` 丢弃当前活动项,并从优先待播区/普通队列按选择规则启动后继项;队列为空则使轨道停止。暂停轨道执行 `next` 后应进入播放状态,避免“下一首已选中却无声音”的隐式状态。 +`audio.next` 仅适用于 `maxVoices: 1`:它丢弃唯一活动项,并从优先待播区/普通队列按选择规则启动后继项;队列为空则使轨道停止。暂停轨道执行 `next` 后应进入播放状态,避免“下一首已选中却无声音”的隐式状态。 `audio.stop` 需显式接受 `clearQueue`(默认 `true`)。需要仅终止当前项而保留歌单时使用 `clearQueue: false`;也可提供等价且更易发现的 `audio.clear` 原语清空待播项。不能让 `stop` 的清队行为含糊。 @@ -208,11 +218,11 @@ effectiveGain = masterGain × playerBusGain × playerTrackGain × trackDefaultGa ```json { "type": "audio.play", "trackId": "bgm", "asset": "audio/bgm/forest.ogg", "placement": "replaceAll", "loopCount": "infinite", "fadeIn": { "duration": 0.8, "curve": "equalPower" } } -{ "type": "audio.play", "trackId": "sfx", "asset": "audio/sfx/door.ogg", "placement": "enqueue", "gain": 0.9, "effects": [{ "id": "audio.pan", "parameters": { "pan": -0.4 } }] } +{ "type": "audio.play", "trackId": "sfx", "asset": "audio/sfx/door.ogg", "placement": "enqueue", "gain": 0.9, "effects": [{ "id": "seffect.pan", "parameters": { "pan": -0.4 } }] } { "type": "audio.play", "trackId": "vocal", "asset": "audio/voice/a_001.ogg", "placement": "replaceAll" } ``` -当前 `text.voice` 应在编译/运行时转换为等价的 `audio.play(trackId: "vocal", placement: "replaceAll")`,而非继续通过 `ITypewriterView.SetVoice` 建立第二条播放路径。需要定义点击推进时的策略:初版建议默认不停止配音,由下一个 `text.voice` 或场景/作者明确的 `audio.stop` 控制;项目级设置可后续增加“推进时停止语音”。 +`dialogue.text` 不再拥有 `voice` 参数,`nvl.append` 保持纯文本。新增 composite `dialogue.textVoice` 与 `nvl.appendVoice`,分别编译为音频播放加文本 primitive;它们的音频附加参数只有 `gain` 与 `trackId`,后者默认 `vocal`。播放使用 `replaceIfDifferent`:同一轨道正在播放相同资源时不重放,仅更新局部 gain;不同资源才替换。它们不再通过 `IDialoguePresenter.SetVoice` 建立第二条路径。点击推进默认不停止配音,由下一个 voice entry、明确的 `audio.stop` 或 scope disposal 控制。 所有原语都应对无效轨道、非音频资源、无效枚举/数值产生诊断并安全完成。若剧情确实需要等待声音结束,应以后续单独设计的 `audio.wait` 或一个等待型语义实现;不要把普通 `audio.play` 改成隐式阻塞,也不要把完成回调直接变成任意剧情跳转。 @@ -233,73 +243,75 @@ AudioRuntimeState 每次启动一个后端播放项都生成单调递增的 `generation`。后端报告 `Ended(trackId, generation)` 或 `Failed(trackId, generation, reason)` 时,Runtime 仅处理仍匹配当前活动项的事件。这样旧 BGM 被 `replaceAll` 停止后迟到的结束回调不会错误推进新队列。 -存档应保存项目可恢复的逻辑状态:队列、优先队列、活动资源、暂停状态、运行时轨道覆盖音量、随机状态和 Effect attachment。第一版可采用“重启式恢复”:恢复后从活动资源开头重新播放,且不承诺毫秒级位置;文档和 UI 必须说明此限制。后续若后端稳定支持 seek,可扩展保存位置(毫秒)和循环轮次,但恢复失败仍应降级为从头播放并记录诊断。 +后续存档若实现,应保存项目可恢复的逻辑状态:队列、优先队列、活动资源、暂停状态、运行时轨道覆盖音量、随机状态和 SEffect attachment;本 feature 不改存档格式。可采用“重启式恢复”:恢复后从活动资源开头重新播放,且不承诺毫秒级位置;文档和 UI 必须说明此限制。后端稳定支持 seek 后,才可扩展保存位置(毫秒)和循环轮次,恢复失败仍应降级为从头播放并记录诊断。 退出游戏页、切换项目或释放宿主时必须先使 Runtime 停止并取消所有活动 generation,再释放后端 player/媒体资源。资源加载失败、设备不可用、解码异常不会使游戏流程抛出;它们通过统一诊断/日志向作者和宿主暴露。 ## 宿主端口与资源定位 -现有 `IAudioView` 的字符串 `mode`、`times`、`ConfigureAudioQueue` 无法表达完整队列状态,也使运行时/后端的职责不清。Phase 4 应以强类型请求替换为一个独立端口,例如: +当前 `BuiltinAudioModule` 的音频条目只注册静态 schema,创建的是空的即时原语;仓库不存在可替换的 `IAudioView`。历史 `AudioChannelManager`、`IAudioService` 和 `LibVlcAudioController` 的字符串 `mode`、`times` 与每 channel 单 player 模型也无法表达完整队列状态。Phase 4 应新建独立的强类型宿主端口,并在 Builtin 模块中创建实际的非阻塞音频原语,例如: ```csharp -public interface IAudioView +public interface IAudioPlaybackHost { Task ApplyAudioCommandAsync(AudioCommand command, CancellationToken ct); } ``` -`AudioCommand` 是 Core 中的可判别、强类型数据(播放、暂停、继续、下一首、停止、清队、设置运行时音量、恢复快照),其中只含 `trackId`、资源 ID、增益和枚举。宿主以项目的资源提供器把资源 ID 解析为可播放流或临时 URI;Runtime 不传 `FileInfo`、磁盘路径或 LibVLC 类型。 +`AudioCommand` 是 Core 中的可判别、强类型数据(播放、暂停、继续、下一首、停止、清队、设置运行时音量、恢复快照),其中只含 `trackId`、资源 ID、增益和枚举。Runtime 负责队列并将其降为带 `(trackId, voiceId, generation)` 的声部级宿主请求;宿主不得自行排队。宿主以项目的资源提供器把资源 ID 解析为可播放流或临时 URI;Runtime 不传 `FileInfo`、磁盘路径或 LibVLC 类型。 -需要后端事件时,宿主以一个受控事件/回调接口上报 `Started`、`Ended`、`Failed`,并回传 command/item generation。事件转回引擎线程或由 AudioRuntime 串行化处理,不能在后端回调线程直接修改 UI 或游戏状态。 +需要后端事件时,宿主以一个受控事件/回调接口上报 `Started`、`Ended`、`Failed`,并回传 `trackId`、`voiceId` 和 generation。事件转回引擎线程或由 AudioRuntime 串行化处理,不能在后端回调线程直接修改 UI 或游戏状态。宿主还必须声明 duration、scheduled start、seek、DSP 等能力;无间隙循环和自然结束前的交叉淡化只在对应能力可验证时启用。 `LibVlcAudioController` 可以作为初期宿主适配器,但其当前“一轨道一个 `MediaPlayer`、`Enqueue` 立即 `Play`”的实现不能满足真实队列、并发 SFX、循环和结束事件语义。后端选型不应锁死在 LibVLC;只要适配相同端口即可替换。 -## 音频 Effect 设计 +## SEffect 后处理设计 ### 为什么独立于图像 Effect -图像 Effect 处理的是 `Texture → Texture` 的画面 pass,并挂在 Layer/ScenePost。音频 Effect 处理的是音频流或混音节点,拓扑是 `PCM/voice → item effects → track effects → bus → master`。二者共享“独立实例、声明式 descriptor、参数校验、生命周期和可动画参数”的架构思想,但不能复用 `EffectStage`、`IImageEffect`、SkSL 或 Layer handle。 +图像 Effect 处理的是 `Texture → Texture` 的画面 pass,并挂在 Layer/ScenePost。SEffect 处理的是音频流或混音节点,拓扑是 `PCM/voice → item SEffects → track SEffects → bus → master`。二者共享“独立实例、声明式 descriptor、参数校验、生命周期和可动画参数”的架构思想,但不能复用 `EffectStage`、`IImageEffect`、SkSL 或 Layer handle。 -音频 Effect 是统一的 descriptor、参数和生命周期模型;作者只选择 attachment 的目标。scope 只有以下两种: +SEffect 是统一的 descriptor、参数和生命周期模型;作者只选择 attachment 的目标。scope 只有以下两种: | Scope | 挂载点 | 适合的效果 | 生命周期 | | --- | --- | --- | --- | | `item` | 一次播放项/声部 | 一句电话滤镜、一次性方位、音高变化 | 播放项结束、被替换或停止时自动释放。 | | `track` | 轨道的混音节点 | BGM 低通、Vocal 压缩、整条轨道声像 | 显式停止、轨道删除/释放时结束;可被存档恢复。 | -同一声音的拓扑固定为 `item Effect 链 → track Effect 链 → bus`。因此单播放项 Effect 是**叠加并先行处理**轨道声音,而不是绕过或隐式替换轨道 Effect。例如一段语音的 `audio.lowPass` 仍会继续经过轨道上的 `audio.gain`;两个低通串联的听感会更强,这不是参数覆盖。若未来确有“暂时替换轨道某个 Effect”的叙事需求,应引入带稳定 attachment ID 的显式 enable/disable 或参数动画,不能依据相同 `effectId` 偷偷合并。 +同一声音的拓扑固定为 `item SEffect 链 → track SEffect 链 → bus`。因此单播放项 SEffect 是**叠加并先行处理**轨道声音,而不是绕过或隐式替换轨道 SEffect。例如一段语音的 `seffect.lowPass` 仍会继续经过轨道上的 `seffect.gain`;两个低通串联的听感会更强,这不是参数覆盖。若未来确有“暂时替换轨道某个 SEffect”的叙事需求,应引入带稳定 attachment ID 的显式 enable/disable 或参数动画,不能依据相同 `effectId` 偷偷合并。 Bus/master 级 Effect(例如总线限制器)是未来扩展,不放进 Phase 4 作者原语;它们更接近项目混音配置,不能由任意剧情条目随意附着到全局输出。 ### Effect 数据与目录 -每个具体 Effect 由后端模块提供一个平台无关的 `AudioEffectDefinition`,承担编辑器下拉、参数表单、范围验证、可动画属性提示和宿主能力声明的唯一来源。结构应和视觉 `EffectCatalog` 对齐,但单独定义类型: +每个具体后处理由后端模块提供一个平台无关的 `SEffectDefinition`,承担编辑器下拉、参数表单、范围验证、可动画属性提示和宿主能力声明的唯一来源。结构应和视觉 `EffectCatalog` 对齐,但单独定义类型: ```csharp -enum AudioEffectScope { Item, Track } +enum SEffectScope { Item, Track } -record AudioEffectDefinition( +record SEffectDefinition( string Id, - IReadOnlySet SupportedScopes, - IReadOnlyList Parameters, + IReadOnlySet SupportedScopes, + IReadOnlyList Parameters, IReadOnlyList AnimatableProperties, bool RequiresRealtimeDsp = true); ``` -`AnimatableProperties` 表示某个参数**具备**连续动画能力,而不是每一个 attachment 必然存在动画。初版中 `audio.lowPass.cutoffHz`、`audio.lowPass.resonance`、`audio.pan.pan`、`audio.gain.gain`、`audio.pitch.semitones` 和 `audio.spatial` 的坐标/距离参数均可声明为可动画;布尔开关、枚举、资源引用等仍为静态参数。 +当前基线已经实现 `SEffectCatalog`,但它只描述并校验 host 已注册的轨道 effect。Avalonia host 在创建时收集 `IAudioSEffectProcessor`,由注册项形成不可变 catalog;Runtime 在保存 attachment 前校验 ID、scope 和参数。当前注册 `seffect.gain`(`gain: 0..1`)以及 `seffect.lowPass` / `seffect.highPass`(必填 `cutoffHz: 20..20000`、可选 `attenuationDb: -20..0`)。未知、未注册或非法参数会保留干声并产生诊断。低/高通的频段近似不支持 `resonance`。 + +`AnimatableProperties` 表示某个参数**具备**连续动画能力,而不是每一个 attachment 必然存在动画。初版中 `seffect.lowPass.cutoffHz`、`seffect.lowPass.resonance`、`seffect.pan.pan`、`seffect.gain.gain`、`seffect.pitch.semitones` 和 `seffect.spatial` 的坐标/距离参数均可声明为可动画;布尔开关、枚举、资源引用等仍为静态参数。 初始内置 descriptor 建议如下: | ID | Scope | 参数 | 说明 | | --- | --- | --- | --- | -| `audio.lowPass` | item、track | `cutoffHz`、`resonance` | 低通滤波,适合隔墙、收音机、远景。 | -| `audio.highPass` | item、track | `cutoffHz`、`resonance` | 高通滤波,适合电话/去低频。 | -| `audio.pan` | item、track | `pan` (`-1..1`) | 立体声声像;不等同于三维空间化。 | -| `audio.gain` | item、track | `gain` | 额外动态增益;应与持久音量层区分。 | -| `audio.pitch` | item | `semitones` | 音高/速度处理取决于后端能力,初版可不实现。 | -| `audio.spatial` | item | `x`、`y`、`z`、`minDistance`、`maxDistance` | 预留三维方位/衰减;要求后端提供位置与听者模型。 | +| `seffect.lowPass` | item、track | `cutoffHz`、`resonance` | 低通滤波,适合隔墙、收音机、远景。 | +| `seffect.highPass` | item、track | `cutoffHz`、`resonance` | 高通滤波,适合电话/去低频。 | +| `seffect.pan` | item、track | `pan` (`-1..1`) | 立体声声像;不等同于三维空间化。 | +| `seffect.gain` | item、track | `gain` | 额外动态增益;应与持久音量层区分。 | +| `seffect.pitch` | item | `semitones` | 音高/速度处理取决于后端能力,初版可不实现。 | +| `seffect.spatial` | item | `x`、`y`、`z`、`minDistance`、`maxDistance` | 预留三维方位/衰减;要求后端提供位置与听者模型。 | -`audio.lowPass` 等是稳定作者 ID,而不是作者传入任意 DSP/shader 文本。每个宿主后端可以声明它支持的 ID;不支持的 Effect 必须保持干声通过、记录一次诊断,而不能使资源静音或崩溃。 +`seffect.lowPass` 等是稳定作者 ID,而不是作者传入任意 DSP/shader 文本。每个宿主后端可以声明它支持的 ID;不支持的 SEffect 必须保持干声通过、记录一次诊断,而不能使资源静音或崩溃。 ### Attachment 入口与顺序 @@ -328,20 +340,20 @@ audio.effect.stop(instanceId) 轨道提供稳定的运行时存在期:它在项目配置中有 `trackId`,持续的轨道 Effect 又有 `instanceId`。因此 Runtime 可以将轨道 Effect 暴露为通用动画系统的目标,形式为 `AudioTrackEffectInstance(instanceId) + parameter`。编辑器把“动画此轨道 Effect 参数”的声明编译为该内部目标;作者不需要手写临时播放器句柄。 -`audio.play.effects` 的 item attachment 虽然使用同一个 `AudioEffectDefinition`,其可动画参数也仍在 descriptor 中声明,但该 attachment 随播放项创建和销毁,尤其在多声部 SFX 下不存在可供后续条目可靠寻址的唯一句柄。因此它只接受播放时给定的静态参数,**不能成为 `animate` 的目标**。这不是人为削减 Effect 能力,而是音频资源播放没有类似 Layer 的稳定、可寻址实例句柄所导致的边界。 +`audio.play.effects` 的 item attachment 虽然使用同一个 `SEffectDefinition`,其可动画参数也仍在 descriptor 中声明,但该 attachment 随播放项创建和销毁,尤其在多声部 SFX 下不存在可供后续条目可靠寻址的唯一句柄。因此它只接受播放时给定的静态参数,**不能成为 `animate` 的目标**。这不是人为削减 SEffect 能力,而是音频资源播放没有类似 Layer 的稳定、可寻址实例句柄所导致的边界。 -若某段声音确实需要被剧情持续动画化(例如 BGM 的低通扫频、持续环境音的声像移动),作者应把它放在专用的单声部轨道上,并将 Effect 附着到该轨道;不要试图对一次性 SFX 播放项补造公开句柄。实时 DSP 参数更新应使用平滑 ramp(例如 5–20 ms 或由后端决定),防止低通截止频率或 gain 突变造成 click/pop。音频 Effect 动画默认不阻塞剧情;需要叙事同步时由独立等待能力表达。 +若某段声音确实需要被剧情持续动画化(例如 BGM 的低通扫频、持续环境音的声像移动),作者应把它放在专用的单声部轨道上,并将 SEffect 附着到该轨道;不要试图对一次性 SFX 播放项补造公开句柄。实时 DSP 参数更新应使用平滑 ramp(例如 5–20 ms 或由后端决定),防止低通截止频率或 gain 突变造成 click/pop。SEffect 动画默认不阻塞剧情;需要叙事同步时由独立等待能力表达。 ### 方位与空间化的取舍 -`pan` 是廉价、确定的立体声左右平衡,推荐 Phase 4 或紧随其后的实现。`spatial` 是三维声源模型,必须先定义:听者坐标从何而来、世界坐标和屏幕坐标是否关联、距离衰减曲线、立体声资源如何处理、耳机/HRTF 是否可用。因此只保留 descriptor 和后端能力协商,不在 Phase 4 承诺实现。对于视觉小说常见的“从左侧说话”,`audio.pan` 已足够。 +`pan` 是廉价、确定的立体声左右平衡,推荐后续 SEffect feature 实现。`spatial` 是三维声源模型,必须先定义:听者坐标从何而来、世界坐标和屏幕坐标是否关联、距离衰减曲线、立体声资源如何处理、耳机/HRTF 是否可用。因此只保留 descriptor 和后端能力协商,不在本 feature 承诺实现。对于视觉小说常见的“从左侧说话”,`seffect.pan` 已足够。 ## 编辑器、验证与诊断 - 项目设置页应提供轨道表格:ID、名称、bus、默认音量、顺序/随机、最大声部、溢出策略。ID 创建后锁定,提供安全重构入口。 - 剧情条目的 `trackId` 使用项目轨道下拉,而非写死 `bgm/sfx/voice`;资源选择器只显示音频资源。 - `audio.play` 根据轨道 `maxVoices` 显示策略提示:多声部 SFX 的 `replaceAll` 会停止全部活动音效,应当明显标注。 -- Effect 面板通过 `AudioEffectCatalog` 动态生成 scope、参数、范围、单位和能力诊断,不能在编辑器中为每个 Effect 写一套表单。 +- SEffect 面板通过 `SEffectCatalog` 动态生成 scope、参数、范围、单位和能力诊断,不能在编辑器中为每个 SEffect 写一套表单。 - 编辑器预览使用隔离的 preview track,不污染游戏运行时队列和玩家音量偏好;预览停止时释放所有试听声部和 Effect。 - 运行时至少记录:未知轨道、轨道 ID 重复、资源类型不符、资源解析/解码失败、无可用音频设备、后端不支持的 Effect、无效 Effect 参数、过期 generation 事件、声部溢出和存档 seek 降级。 @@ -351,42 +363,42 @@ audio.effect.stop(instanceId) 1. 定义项目 `AudioTrackDefinition`、默认三轨及编辑器 CRUD/引用验证。 2. 以 `trackId` 和强类型 `AudioCommand` 替换当前硬编码 channel、字符串 `mode/times` 和空的 `ConfigureAudioQueue`。 -3. 实现单声部 BGM/Vocal、可配置多声部 SFX、入队/下一首/替换/替换全部、淡入淡出与后继项接管的交叉淡化、暂停/继续/停止/清队和结束 generation 防护。 +3. 实现单声部 BGM/Vocal、可配置多声部 SFX、有界入队、单声部下一首、替换/替换全部、暂停/继续/停止/清队和结束 generation 防护;命令触发的交叉淡化仅在后端支持时完成,否则记录诊断并停止后开始。自然结束前交叉淡化和无间隙循环留给具备预加载/调度能力的后续阶段。 4. 接通资源 ID 定位、音量分层、Sample 的 BGM 切换与 SFX/Vocal 播放。 -验收:Sample 能按流程切换循环 BGM,同时叠加音效,播放/替换语音;错误资源或轨道不终止流程。 +验收:Windows Avalonia Sample 能按流程切换循环 BGM,同时叠加音效,播放/替换语音;错误资源、轨道、设备或不支持的淡化能力均不终止流程并产生诊断。Headless fake host 覆盖队列、声部溢出和 generation 语义。 -### Phase 4B:持久化与播放器设置 +### 后续:持久化与播放器设置 1. 将轨道逻辑状态加入 `GameSnapshot`,完成重启式恢复。 -2. 迁移现有 BGM/SFX/Voice 设置到按 bus/track 的音量映射。 +2. 单独决定是否引入按 bus/track 的玩家音量映射;当前固定 BGM/SFX/Voice 设置不迁移。 3. 保存 shuffle 状态、队列和暂停状态;实现资源失败和 seek 不可用的降级诊断。 验收:读档后 BGM/队列/暂停和玩家音量语义一致,随机歌单不无故重排。 -### Phase 4C:音频 Effect 基础 +### 后续:SEffect 基础 -1. 建立统一的 `AudioEffectDefinition` / catalog、scope/order、校验、生命周期和后端能力声明。 -2. 接入 `audio.lowPass`、`audio.pan`、`audio.gain` 的 track attachment 与 `audio.play.effects` item attachment;不支持后端干声降级。 +1. 建立统一的 `SEffectDefinition` / catalog、scope/order、校验、生命周期和后端能力声明。 +2. 接入 `seffect.lowPass`、`seffect.pan`、`seffect.gain` 的 track attachment 与 `audio.play.effects` item attachment;不支持后端干声降级。 3. 将可动画的轨道 Effect 映射为通用动画目标;明确拒绝动画 item attachment。 -4. 将 Effect attachments 和已提交的轨道动画值纳入快照;提供最小编辑器表单和 Sample 验证场景。 +4. 将 SEffect attachments 和已提交的轨道动画值纳入快照;提供最小编辑器表单和 Sample 验证场景。 -验收:BGM 轨道可持续低通,单句语音可向左/右声像,停止或替换播放项时 item Effect 无泄漏。 +验收:BGM 轨道可持续低通,单句语音可向左/右声像,停止或替换播放项时 item SEffect 无泄漏。 ### 后续阶段 -ducking、精确播放位置恢复、`audio.wait`、`shuffleCycle`、高通/压缩/混响、动态 range 和三维 `audio.spatial` 按需求逐项设计和实现。它们不得通过改变本文件中已定义的队列、音量、淡入淡出、可动画边界或生命周期语义来“补丁式”加入。 +ducking、精确播放位置恢复、`audio.wait`、`shuffleCycle`、高通/压缩/混响、动态 range 和三维 `seffect.spatial` 按需求逐项设计和实现。它们不得通过改变本文件中已定义的队列、音量、淡入淡出、可动画边界或生命周期语义来“补丁式”加入。 ## 测试策略 -- **Core/Runtime 单元测试**:轨道 ID 验证、默认轨道、音量合成、各放置策略、停止是否清队、顺序/随机、completion/loop 区分、后继项淡入接管交叉淡化、队尾/停止时淡出、轨道 Effect 参数动画、拒绝 item Effect 动画目标、声部溢出、过期 generation 事件。 -- **序列化测试**:轨道配置、音频运行时快照、随机状态、Effect attachment、旧 `BgmVolume/SfxVolume/VoiceVolume` 迁移。 +- **Core/Runtime 单元测试**:轨道 ID 验证、默认轨道、音量合成、各放置策略、停止是否清队、顺序/随机、completion/loop 区分、后继项淡入接管交叉淡化、队尾/停止时淡出、轨道 SEffect 参数动画、拒绝 item SEffect 动画目标、声部溢出、过期 generation 事件。 +- **序列化测试**:轨道配置、音频运行时快照、随机状态和 SEffect attachment。旧 `BgmVolume/SfxVolume/VoiceVolume` 不兼容且不迁移。 - **后端契约测试**:资源定位、结束/失败事件、暂停恢复、释放后不再上报有效事件、Effect 不支持时干声降级。 -- **集成测试**:剧情条目驱动 BGM 切换、SFX 重叠、`text.voice` 的 Vocal 路由、读档恢复、页面离开释放资源。 +- **集成测试**:剧情条目驱动 BGM 切换、SFX 重叠、`dialogue.textVoice`/`nvl.appendVoice` 的 Vocal 路由、页面离开释放资源。 - **听感/手动验收**:低通和声像参数变化无爆音;多 SFX 不异常截断;BGM 的 equal-power 交叉淡化无明显响度下陷;无限循环在目标后端不产生可察觉间隙。 ## 与现有实现的迁移决策 -当前 `AudioChannelManager`、`IAudioService`、`IAudioView` 和 `LibVlcAudioController` 使用预置 channel、单个 `CurrentAsset`、字符串 `mode/times` 与不具备真实队列语义的 `Enqueue`。它们可作为 Phase 4 前的占位实现和测试基线,但不能直接扩展为本设计的核心模型。 +当前 `AudioChannelManager`、`IAudioService` 和 `LibVlcAudioController` 使用预置 channel、单个 `CurrentAsset`、字符串 `mode/times` 与不具备真实队列语义的 `Enqueue`;`BuiltinAudioModule` 则只提供空的 schema 原语。它们可作为 Phase 4 前的占位实现和测试基线,但不能直接扩展为本设计的核心模型。 -迁移时应以新设计取代旧 channel 约定,而非将 `bgm2`、`sfx1` 等命名作为兼容层永久固化。旧项目内容可映射为:`bgm → bgm`、`voice → vocal`、`sfx`/`sfxN → sfx`;无法确定的 channel 记录迁移诊断并要求作者选择目标轨道。 +本设计以新模型取代旧 channel 约定,而非将 `bgm2`、`sfx1` 等命名作为兼容层永久固化。旧项目内容和已编译内容不做迁移:遇到旧 schema/format 应明确拒绝并要求按新 schema 重编译。 diff --git a/docs/design/avalonia-game-page-integration-plan.md b/docs/design/avalonia-game-page-integration-plan.md deleted file mode 100644 index ac5019d5..00000000 --- a/docs/design/avalonia-game-page-integration-plan.md +++ /dev/null @@ -1,49 +0,0 @@ -# Avalonia 游戏页面与内容对接计划 - -## 目标 - -补全官方 Avalonia Sample 与 `GalNet.Avalonia.GameView` 中可复用游戏页面的功能,并让各项页面能力能够由游戏流程和资源内容驱动。 - -## 维护约定 - -- 当前阶段使用 `进行中` 标记;未开始阶段保留完整工作项。 -- 某阶段完成、验证并提交后,将其详细工作项压缩为一条完成摘要,并标记为 `已完成`。 -- 每个阶段单独提交 Git,提交信息以 `feat(game-page): ...` 为前缀;文档状态变更包含在该提交中。 - -## Phase 1:Layer 页面层级(已完成) - -已完成:JSON `.galgroup` 编译、隐藏句柄的泛型动态实例管理、Layer 的 Transform/z/五种展示模式、`layer.replace`、场景恢复,以及 Sample/编辑器预览共享渲染链路。`hide` 会删除实例并立即使句柄失效;失效操作记录诊断后安全跳过。 - - -## Phase 2:动画原语与转场(已完成) - -已完成:通用 `AnimationRequest` 与内置曲线语法糖、可序列化关键帧 `AnimationPlan`(Step/Linear/Hermite、帧事件、Replace、阻塞/跳过/batchId)、独立 `IAnimationView`、稳定状态提交和 Avalonia 实时多轨播放。Sample 已用手写 48 帧交叉淡化验证第 0 帧显示、双背景透明度轨道、结束帧隐藏与批次跳过。 - -补充完成:两类动画均有独立播放句柄、`Loop` 与 `animation.stop`。Loop 强制非阻塞、不可跳过;每轮事件完整重放,Layer 句柄翻译为本轮局部实例并在轮末清理,不进入存档。`AfterIteration` 与 `CompleteImmediately` 分别在轮末或跳到终点后结束。Loop 的局部 `show`/`hide` 不做静态配对校验,由作者控制,运行时始终执行轮末兜底清理。 - -## Phase 3:Effect 效果(已完成) - -已完成:平台无关的 `effect.apply` / `effect.stop`、可动画且可存档恢复的 `EffectInstance`、Overlay/Layer 双向关联,以及由普通 Animation Plan 驱动的 `transition.blinds`。Avalonia Effect factory 自动发现并自持渲染、停止和清理逻辑;内置 CPU 对象池粒子发射器与 Layer 百叶窗 mask,Sample 第二分支已实际覆盖二者及粒子参数动画。 - -已完成:`EffectCatalog` 从 factory 元数据生成,向编辑器提供 effect 下拉、Scope/参数/可动画属性提示和 JSON 诊断;运行时只记录诊断,始终保持宽松执行。持续 Effect、Loop 与已提交动画值可存档恢复;`effect.stop` 立即移除逻辑 effect,粒子排空仅保留为本次运行的视觉尾迹。 - -## Phase 4:音频 - -- 接入 BGM、音效和语音的播放、停止、循环与音量控制。 -- 将资源定位、播放状态和页面/流程调用完成连接。 - -验收:Sample 能按游戏流程切换 BGM 并播放音效或语音。 - -## Phase 5:视频 - -- 支持开场、过场和背景视频的基本播放。 -- 处理结束回调、跳过、转场衔接和资源释放。 - -验收:Sample 可播放并跳过一段由游戏内容触发的视频。 - -## Phase 6:Live2D - -- 接入模型显示、模型切换及基础表情/动作触发。 -- 将对话和游戏流程与模型参数、动作事件连接。 - -验收:Sample 可通过游戏内容显示模型并触发表情或动作。 diff --git a/docs/design/primitive-module-runtime-design.md b/docs/design/primitive-module-runtime-design.md deleted file mode 100644 index 954eefbb..00000000 --- a/docs/design/primitive-module-runtime-design.md +++ /dev/null @@ -1,222 +0,0 @@ -# Entry Module 与 PrimitiveInstance 运行时设计 - -> 状态:已收敛到当前实现。本文描述新的运行时职责边界;旧的 Handler、Descriptor、OperationManager 方案已废弃。 - -## 1. 目标 - -Entry 系统只解决三件事: - -1. 用模块组织编辑器可用的 Entry 定义。 -2. 在编译期把 Composite Entry 展开为 Primitive Entry。 -3. 在运行期由 `IGameView` 创建并分发单条 primitive instance,由 `GameEngine` 保存和调度活动实例。 - -设计要求: - -- Core/Runtime 不依赖 Avalonia、Skia、具体文件系统或平台控件。 -- 模块注册事实只有一份:entry 定义自己持有参数 schema 和运行期工厂。 -- 每次 primitive 调用创建独立 `PrimitiveInstance`。 -- 玩家推进入口只有 `GameEngine.AdvanceAsync`。 -- 快照由 Engine 在稳定边界统一更新,不由 primitive 元数据决定。 -- Choice、条件、edge 映射和节点跳转保留为 Engine 内置控制流。 - -## 2. 总体结构 - -```text -IEntryModule -├─ PrimitiveEntries : IReadOnlyDictionary -└─ CompositeEntries : IReadOnlyDictionary - -.rawgalgroup Entry - -> GalgroupCompiler - -> PrimitiveEntry { Type, Arguments, BatchId, Condition } - -> CompositeGameView.Dispatch - -> PrimitiveInstance - -> GameEngine active queue -``` - -三个阶段的对象严格分离: - -- `EntryBase` 及其派生定义是模块中的 schema,生命周期与模块一致。 -- `PrimitiveEntry` 是编译后的运行期调用数据,可序列化。 -- `PrimitiveInstance` 是一次调用产生的运行状态,不序列化。 - -## 3. 模块与参数 schema - -每个模块构造后冻结两张表。表 key 与 entry `Name` 一致;同一模块内 primitive/composite 不得重名,多个挂载模块之间也不得有重复 primitive 名称。 - -`EntryBase.Parameters` 是编辑器表单、编译校验、默认值填充和运行期工厂的唯一参数 schema 来源。`DynamicParameterTable` 只读保存: - -- 参数名。 -- 进程内运行时 `Type`。 -- 是否必填。 -- JSON 默认值。 -- JSON constraints,例如 editor type 和选项。 - -内容文件和存档只保存 JSON 值,不保存 CLR 类型名。 - -## 4. Primitive 工厂上下文 - -`PrimitiveEntryBase.CreateInstance()` 接收 `PrimitiveCreateContext`: - -```text -PrimitiveCreateContext -├─ Definition : PrimitiveEntryBase -├─ Entry : PrimitiveEntry -├─ Runtime : IGameRuntime -├─ ScopeCancellation : CancellationToken -├─ Parameters : DynamicParameterTable -├─ Arguments : JsonElement -└─ BatchId : string? -``` - -`CompositeGameView` 在创建上下文前先用 `PrimitiveArgumentHelper.Normalize()` 规范化参数。进入工厂的 arguments 已包含默认值,且已移除编译期提升到通用字段的 `batchId` 参数。 - -`DefaultPrimitiveEntryBase` 允许模块用构造传入的委托创建 instance,不要求每个 primitive 定义一个独立 CLR 类型。 - -## 5. BatchId - -`batchId` 是 authoring 输入中的普通可选参数,但编译后提升到 `PrimitiveEntry.BatchId`。运行时不从领域参数 JSON 再解释批次。 - -Engine 将局部 batch 与内部 `GroupExecutionId` 组合使用: - -```text -runtime batch key = (GroupExecutionId, PrimitiveInstance.BatchId) -``` - -因此: - -- 同一次 Group 执行内,同名 batch 的已分发实例可一起响应一次 skip。 -- 跨 Group 或重复进入同一 Group 时,同名 batch 不关联。 -- 空 batch 表示实例独立成批。 - -## 6. PrimitiveInstance - -公共基类只暴露 blocking、skippable、completed、BatchId、Dispatch 和 Skip。它不提供通用 result、origin、policy、execution control 或 invocation 包装。 - -实现约束: - -- `Dispatch()` 只执行一次。 -- `Skip()` 幂等,并在实例内部重新检查当前阶段。 -- 自然完成和 skip 都最终收敛到 `TryComplete()`。 -- Non-blocking 一次性实例在 `Dispatch()` 返回前必须提交最终可存档逻辑状态;没有终态的循环展示提交第 0 帧稳定状态与完整重放定义。 -- 异步呈现失败不得遗留未观察异常或长期活动引用。 - -## 7. GameView 与 Engine - -`IGameView` 只负责一条 primitive: - -- 查找 entry definition。 -- 规范化参数。 -- 创建 instance。 -- 调用一次 `Dispatch()`。 -- 返回 instance 给 Engine。 - -`GameEngine` 负责: - -- 读取当前节点和 Group 条目。 -- 判断条件。 -- 保存活动 instance 队列、sequence 和 group execution id。 -- 选择 skip batch。 -- 订阅 blocking 完成并触发内部 continue。 -- 处理 Choice 和条件分支。 -- 更新稳定快照。 - -`IGameView` 不保存活动队列,不推进 Group,不处理 Choice,不决定 skip。 - -## 8. Advance 规则 - -一次玩家 `AdvanceAsync` 只对调用开始时最早 blocking instance 所属 batch 拥有 skip 权限。 - -如果该 batch 被解除阻塞,Engine 可以继续分发同步和 non-blocking 内容,直到遇到新的 blocking instance、Choice 或剧情结束;新遇到的 blocking batch 本次不会被 skip。 - -如果当前 blocking batch 没有任何可跳过实例,本次 Advance 不穿透它。 - -Blocking 自然完成只触发内部 continue,这条路径没有 skip 权限。 - -## 9. Dialogue 与 `\skip` - -`dialogue.text` 使用一个 `DialoguePrimitiveInstance` 管理两个阶段: - -```text -Typing -> WaitingAdvance -> Completed -``` - -规则: - -- `\skip` 是不可见控制语法。 -- 正常打字经过 `\skip` 不暂停。 -- Typing 阶段收到 skip,只立即显示到下一个 `\skip` 或文本结尾。 -- 到达文本结尾后进入等待阶段,不自动完成。 -- WaitingAdvance 阶段收到下一次 skip 才完成实例。 -- `\\` 表示字面反斜杠。 -- 普通文本和富文本复用同一个指令识别器。 - -## 10. Animation 与 Effect - -Animation 是普通 primitive instance,不再由 Avalonia 页面决定剧情批次。 - -`animation.animate`: - -- 以一个 `AnimationRequest` 表示单属性动画。 -- `Blocking` 与 `Skippable` 来自参数。 -- `Once` 在 `Dispatch()` 时把最终值写入可存档 runtime state;`Loop` / `PingPong` 保持第 0 帧值并记录完整重放定义,再启动 presenter 动画。 -- `Skip()` 请求 presenter 立即完成并完成 instance。 - -`animation.play`: - -- 一个 plan 对应一个 `AnimationPlanPrimitiveInstance`。 -- 整个 timeline 是一个 instance 和一个 BatchId。 -- plan 内部事件只支持模块私有的 layer/effect 事件处理,不作为嵌套 Entry 重新分发。 -- `Once` 在 `Dispatch()` 时提交最终 runtime state;循环 plan 只提交第 0 帧状态并保存完整 plan,再按时间通知 presenter。 - -`effect.apply` / `effect.stop`: - -- 更新 `SceneState.ActiveEffects`。 -- 同步维护目标 Layer 的 effect instance id 列表。 -- 调用 `IEffectPresenter` 启动或停止平台呈现。 -- Effect 参数仍对 Runtime 不透明;可动画数值保存在 `EffectInstance.AnimationValues`。 - -## 11. 稳定快照 - -快照不要求活动队列为空。只有同时满足下列条件时才更新最后稳定快照: - -1. 当前剧情游标已提交到可恢复边界。 -2. 当前没有未完成 blocking instance。 -3. 当前没有 pending Choice。 - -未完成的 non-blocking 呈现实例不会阻止快照;它们的异步部分只能更新可重建的展示状态。读档只恢复数据,不恢复活动 Task 或 instance。 - -循环动画属于可重建展示状态:存档不保存当前帧、循环次数或 ping-pong 方向,而是恢复第 0 帧逻辑状态后从头播放。Blocking 动画仍使用进入该阻塞边界前的最后稳定快照。 - -## 12. 明确不引入的公共抽象 - -当前模型不引入: - -- Handler registry。 -- Primitive descriptor。 -- CreatesCheckpoint 元数据。 -- Primitive invocation / origin / context 包装。 -- Primitive dispatch result。 -- Primitive execution control。 -- OperationManager。 -- `interaction.choice` primitive。 -- View 端活动队列或页面级 skip batch。 - -如果后续确实需要其中某个能力,必须先写明不可替代的使用场景并修订设计,不能为了迁移旧代码默认恢复。 - -## 13. 当前实现状态 - -已验证: - -- Entry module 两张表冻结,重复注册失败。 -- `PrimitiveEntry.BatchId`、`PrimitiveEntryDocument.BatchId` 和 instance BatchId 闭环。 -- `GameEngine.AdvanceAsync` 是唯一玩家入口。 -- Engine 拥有活动队列、Batch skip、Choice、条件和快照。 -- Dialogue/typewriter、layer、animation、effect、flow.wait、variable.set 已接入 instance 模型。 -- Avalonia 页面不再提供页面级 animation batch skip。 - -仍留给后续 feature: - -- 音频、视频、粒子、画廊的完整产品级运行行为。 -- 资源类型模块化。 -- 更完整的 animation timeline authoring 与内部事件能力。 diff --git a/docs/design/primitive-module-runtime-phase-plan.md b/docs/design/primitive-module-runtime-phase-plan.md deleted file mode 100644 index d523f23a..00000000 --- a/docs/design/primitive-module-runtime-phase-plan.md +++ /dev/null @@ -1,121 +0,0 @@ -# Entry Module 与 PrimitiveInstance 运行时:分阶段计划 - -> 状态:当前 Entry Module + PrimitiveInstance 主路径已实现。本文保留跨 feature 的长期迁移边界,具体本轮实现记录见 `features/F-20260922-01-entry-instance-runtime/`。 - -## 已确认边界 - -- 不保留旧 Handler、Descriptor、OperationManager、ExecutionControl 或 Result 模型。 -- Core/Runtime 不引用 Avalonia 或其他平台实现。 -- Runtime 只执行编译后的 `PrimitiveEntry`;Composite 仅供 authoring/编译。 -- GameView 只分发单条 primitive;活动队列、Batch skip、Choice、节点跳转和快照由 Engine 管理。 -- 对玩家只暴露 `GameEngine.AdvanceAsync`。 -- 快照由 Engine 在稳定边界统一更新,不由 primitive 元数据决定。 -- 影响存档事实的 non-blocking primitive 必须在 `Dispatch()` 返回前提交最终逻辑状态。 - -```mermaid -flowchart LR - P1[Phase 1\nEntry 与 Instance 契约] - P2[Phase 2\nEngine 队列与 Advance] - P3[Phase 3\n快照与 Dialogue] - P4[Phase 4\n推荐模块与宿主接入] - P5[Phase 5\n后续媒体/资源扩展] - P1 --> P2 --> P3 --> P4 --> P5 -``` - -## Phase 1:Entry 与 Instance 契约 - -**状态:verified** - -已完成: - -- `EntryBase`、`PrimitiveEntryBase`、`DefaultPrimitiveEntryBase`、`CompositeEntryBase`、`DefaultCompositeEntryBase` 和 `PrimitiveInstance`。 -- `IEntryModule` 的 `PrimitiveEntries` / `CompositeEntries` 两张冻结表。 -- `DynamicParameterTable` 作为唯一参数 schema 来源。 -- `PrimitiveCreateContext` 暴露 definition、entry、runtime、scope cancellation、parameters、arguments 和 BatchId。 -- `PrimitiveEntry` / `PrimitiveEntryDocument` 持有通用 `BatchId`。 -- 旧 Handler/Descriptor/ExecutionControl 契约从代码路径移除。 - -验证: - -- 模块冻结、重复项、默认工厂、共享参数 helper、工厂上下文字段、BatchId 原样传递和实例单次 dispatch 测试。 - -## Phase 2:Engine 队列与 Advance 调度 - -**状态:verified** - -已完成: - -- `CompositeGameView` 只解析并 dispatch 一条 primitive,不保存活动队列。 -- `GameEngine` 拥有活动 instance 队列、sequence 和 group execution id。 -- `AdvanceAsync` 按最早 blocking batch 选择 skip 候选。 -- 同 group 同 batch 的已分发 non-blocking/skippable instance 会一起收到 skip。 -- Blocking 自然完成触发无 skip 权限的内部 continue。 -- Choice、条件、edge 映射和节点跳转由 Engine 内置处理。 -- `CreateSaveData()` 返回最后稳定快照。 - -验证: - -- 局部 batch、跨 group 同名 batch 隔离、不可跳过 blocker、自然完成、Choice 可见索引、未知 primitive 安全跳过、non-blocking 快照边界等测试。 - -## Phase 3:快照边界与 Dialogue `\skip` - -**状态:verified** - -已完成: - -- 稳定快照条件改为“无未完成 blocking instance 且无 pending Choice”,不要求活动队列为空。 -- 普通文本和富文本共享反斜杠指令识别。 -- `dialogue.text` 使用 `DialoguePrimitiveInstance` 管理 Typing 与 WaitingAdvance。 -- `\skip` 每次 Advance 至多跨一个边界;全文显示后下一次 Advance 才完成对话。 -- Avalonia 和 Headless 对话路径迁移到新 presenter 端口。 - -验证: - -- 普通/富文本解析、转义、连续 skip、自然完成等待、Advance 完成语义和稳定快照测试。 - -## Phase 4:推荐模块与宿主接入 - -**状态:partially verified** - -已完成: - -- `layer.*`:更新 runtime `SceneState`,再通知 `ILayerPresenter`。 -- `animation.animate`:创建 `AnimationPrimitiveInstance`,先提交最终逻辑状态,再调用 `IAnimationPresenter`。 -- `animation.play`:一个 plan 对应一个 `AnimationPlanPrimitiveInstance`;内部 layer/effect 事件作为 animation 模块私有事件处理,不重新进入通用 Entry 分发。 -- `animation.stop`:请求已保存的播放句柄停止,`CompleteImmediately` 可要求 presenter 立即完成。 -- `effect.apply` / `effect.stop`:维护 `ActiveEffects`、目标 Layer 的 effect id 列表和 `IEffectPresenter`。 -- `flow.wait`:blocking、skippable 的等待 instance。 -- `variable.set`:求值后写入 Runtime 变量。 -- Avalonia Sample、Editor Preview 和 Headless Sample 的组合根已传入 animation/effect presenter。 -- Avalonia 页面级 `SkipAnimationBatch` 已移除;批次由 Engine 管理。 - -验证: - -- `dotnet build test/GeneralTest/GeneralTest.csproj --no-restore -m:1 -v:minimal -p:UseSharedCompilation=false`,需设置 `AVALONIA_TELEMETRY_OPTOUT=1`。 -- `dotnet test test/GeneralTest/GeneralTest.csproj --no-restore -m:1 -v:minimal -p:UseSharedCompilation=false`:219/219 通过(2026-09-22)。 -- 新增测试覆盖 animation/effect runtime state、presenter 调用、plan final state 和 `flow.wait` skip。 - -仍未完成: - -- 音频、视频、粒子和画廊只保留推荐 schema,完整产品行为留给后续 feature。 -- Animation timeline 内部事件目前是模块私有 layer/effect 事件;更完整的 authoring 事件能力需要单独设计。 -- 资源类型模块化仍是独立后续工作。 - -## Phase 5:后续扩展 - -**状态:planned** - -后续 feature 可在当前模型上继续推进: - -- 完整音频系统与媒体模块。 -- 粒子模块与粒子状态恢复。 -- 画廊解锁与进度服务整合。 -- 资源类型模块化和资源 metadata 动态参数 schema。 -- 更完整的 animation authoring、timeline event 校验和平台优化。 - -## 实施纪律 - -- 每个 Phase 完成后记录验证命令和已知偏差。 -- 正式 `docs/spec/` 只写当前已经实现的事实。 -- `docs/design/` 可以记录路线,但不得要求恢复旧 Handler/Operation 模型。 -- 不为过渡便利增加兼容读取、旧 ID 别名、双写快照或双注册表。 diff --git a/docs/design/runtime-presentation-decoupling-phase-plan.md b/docs/design/runtime-presentation-decoupling-phase-plan.md deleted file mode 100644 index cf9c2ac7..00000000 --- a/docs/design/runtime-presentation-decoupling-phase-plan.md +++ /dev/null @@ -1,252 +0,0 @@ -# 游戏运行时与展示层解耦:分阶段改造计划 - -## 1. 目标与边界 - -本计划把游戏运行逻辑、视觉实现、文件系统实现和编辑器职责分开: - -```text -游戏文件(group / graph / asset references) - | - v - GalNet.Core + GalNet.Runtime(平台无关) - | - +--------------+----------------+----------------+ - | | | - v v v -Presentation.Abstractions Storage.Abstractions GameState/Snapshot - | | - v v -Avalonia 示例客户端 / CLI / 编辑器预览 文件系统 / pak / 云端等实现 -``` - -最终原则: - -- `GalNet.Runtime` 只处理图推进、条目语义、变量、场景状态、存档状态和等待条件;不得引用 Avalonia、WPF、Qt、音频库或具体文件系统。 -- 最终游戏客户端是独立工程。官方只提供一个完整 Avalonia 示例工程,开发者可复制、替换服务或自行从零实现宿主。 -- 编辑器只编辑游戏文件,并提供继承编辑器主题的基础预览;不再保存或编辑最终游戏 UI 的主题、布局或视觉控件配置。 -- 转场和特效是可高度定制的扩展点,分别定义接口和数据对象。音频、视频、图层、对话等保持专用、强类型的固定接口。 -- 文件读取、资源访问、存档、玩家全局变量和进度均以接口定义,默认文件系统实现位于外层基础设施项目。 - -## 2. 目标项目与依赖方向 - -新增项目均放在 `src/`。仓库当前未跟踪 `.sln` 文件;开始实施时应先确认实际解决方案位置,或在仓库根目录新建并纳入版本控制。 - -```text -GalNet.Presentation.Abstractions - └─ 视觉与交互接口、转场/特效请求 DTO;零 UI 框架依赖 - -GalNet.Presentation.Defaults --> Presentation.Abstractions - └─ 无 UI 框架差异的默认实现:NullGameView、跨平台音频后端等 - -GalNet.Core - └─ 游戏文件模型、Graph、Entry、SceneState、Snapshot、值对象 - -GalNet.Storage.Abstractions --> GalNet.Core - └─ 内容、资源、存档、玩家变量、游戏进度接口 - -GalNet.Runtime --> Core + 两个 Abstractions - └─ GameEngine、GameRuntime、内建 EntryHandler、表达式与状态机 - -GalNet.Assets --> Core + Storage.Abstractions - └─ pak、归档、资源文件实现 - -GalNet.Storage.FileSystem --> Core + Runtime + Storage.Abstractions - └─ 目录游戏内容、文件存档、玩家变量、游戏进度实现 - -GalNet.Avalonia.Controls --> Presentation.Abstractions - └─ 无样式/默认样式 Avalonia 控件;不依赖 Runtime 或 Assets - -GalNet.Avalonia.GameView --> Avalonia.Controls + Presentation.Defaults + Presentation.Abstractions - └─ 可复用的 Avalonia 游戏页面、图层/输入适配与动态转场/特效入口;不读取游戏文件 - -GalNet.Sample.Avalonia --> Runtime + Assets + Storage.FileSystem + Avalonia.GameView -GalNet.Sample.Headless --> Runtime + Presentation.Abstractions -GalNet.Editor --> Runtime + Assets + Storage.FileSystem + Avalonia.GameView -``` - -`GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移清理并从仓库删除。当前 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`。 - -## 3. 核心契约设计 - -### 3.1 视觉与输入:`GalNet.Presentation.Abstractions` - -保留 `IGameView` 作为组合入口,便于宿主一次性实现和注入;但它只组合小接口,运行时代码优先依赖实际所需的小接口,不新增 UI 框架类型。 - -```csharp -public interface IGameView : - ILayerView, - IDialogueView, - IAudioView, - IVideoView, - IInteractionView, - ITransitionView, - IEffectView -{ -} -``` - -- 图层、对话、音频、视频保持现有专用接口和参数语义,例如 `PlayAudio(channel, assetId, volume, mode, times)`。 -- `IInteractionView` 保持等待继续和选择输入的职责;它是平台输入适配层,不包含状态机规则。 -- `IGameView` 不再放在 `IGameRuntime` 中。由 `GameEngine` 构造函数显式接收,或通过运行时组装选项传入;DI 容器只在 CLI、Avalonia 示例工程、编辑器等组合根使用。 - -转场和特效必须分开: - -```csharp -public interface ITransitionView -{ - Task PlayTransitionAsync(TransitionRequest request, CancellationToken ct); -} - -public interface IEffectView -{ - Task StartEffectAsync(EffectRequest request, CancellationToken ct); - Task StopEffectAsync(string instanceId, CancellationToken ct); -} - -public sealed record TransitionRequest( - string Id, - string? FromImageId, - string? ToImageId, - TimeSpan Duration, - bool IsBlocking, - string Parameters); - -public sealed record EffectRequest( - string Id, - string InstanceId, - TimeSpan? Duration, - bool IsBlocking, - string Parameters); -``` - -`Parameters` 保存原始 JSON 字符串(也允许空字符串)。核心不解析高定制参数;示例客户端按 `Id` 从注册表查找实现,并由该实现自行解析。`IsBlocking` 决定引擎是否 `await` 宿主任务。`TransitionRequest` 的 `FromImageId`、`ToImageId` 保留当前转场需要的源/目标图像信息。 - -### 3.2 文件、存档与全局变量:`GalNet.Storage.Abstractions` - -将现有散落在 `GalNet.Core.Services` 和具体项目中的契约按用途迁移: - -- `IGameContentProvider`:加载 Graph、group、元数据等游戏内容。 -- `IAssetProvider`、`IAssetManager`、`IArchive`:按资源 ID 读取资源与归档。 -- `ISaveService`:槽位存档、快速存档、缩略图及 `GameSnapshot`。 -- `IPlayerVariableStore`:跨存档的玩家变量读写;与单个存档内变量分离。 -- `IGameProgressService`:画廊解锁、已读记录等跨局进度。 - -`GalNet.Storage.FileSystem` 提供默认的目录、文件和 pak 组合实现。以后接入云存档、Steam Cloud 或自定义加密时,只需替换实现并在宿主工程注册。 - -### 3.3 状态机职责 - -`GameRuntime` 只包含游戏事实:当前位置、变量、调用栈、`SceneState`、当前对话/选项/等待原因。`GameEngine` 负责把 Entry 转换为状态变化和接口调用。 - -- 每个会影响场景的条目必须先更新 `SceneState`,再通知对应展示接口。 -- 读取存档后,引擎根据 `SceneState` 调用视图恢复接口;视图不作为状态唯一来源。 -- 文本条目:写入当前对话状态,调用打字机接口;继续/跳过由 `IInteractionView` 返回,再推进状态机。 -- 选择条目:写入可见选项并等待输入;引擎只接受合法索引。 -- 等待条目:改为注入 `IClock`/计时抽象以保证可测试性,禁止 Handler 直接使用 `DateTime.UtcNow`。 -- 可视异步操作仅在对应 `IsBlocking` 为真时阻塞状态机;音频、视频和普通图层操作默认不阻塞。 - -## 4. 分阶段执行 - -### Phase 0:基线与项目骨架(已完成) - -- 已建立展示/存储契约、默认文件系统实现、Avalonia 控件与 Sample/Player 宿主骨架,并纳入 `GalNet.slnx`。 - -### Phase 1:抽取视觉与存储契约(已完成) - -- 展示、转场/特效与存储/玩家变量契约已分离到专用抽象项目,默认文件系统实现可由任意宿主替换。 - -### Phase 2:运行时状态机与宿主边界(已完成) - -- `IGameRuntime` 与 Runtime Context 已移除 `IGameView`;Core 不再引用展示抽象项目。 -- `GameEngine` 显式接收每会话的 `IGameView` 和 `TimeProvider`,Runtime Handler 通过显式参数执行,不再隐藏地从状态对象取得 UI 服务。 -- 图层状态会先写入 `SceneState`;读档恢复 Runtime 状态后由 Engine 重放可见图层。文本、选择、等待、音视频、对话、变量及画廊 Handler 已迁入新执行模型。 -- 转场条目使用 `transitionId`、`transitionDuration`、`transitionBlocking`、`transitionParameters`;特效条目使用 `id`、`instanceId`、`duration`、`blocking`、`parameters`。动态参数保持原始 JSON,Core 不解析。 -- 已实现构造函数注入的 `CompositeGameView`。组合根创建各展示服务后传入 facade;Runtime 与 facade 均不依赖 `IServiceProvider`。 -- `NullGameView` 已切换到新契约;`GalNet.Player.Console` 可运行真实游戏目录并演示组合根注入。旧的硬编码 `GalNet.Headless` 已删除。 -- 已将无平台差异的 `NullGameView` 与 LibVLC 音频控制器移入 `GalNet.Presentation.Defaults`;新增 `GalNet.Avalonia.GameView` 作为 Avalonia 动态展示服务的唯一入口。编辑器与官方 Avalonia 示例均引用 `GalNet.Avalonia.Controls`,Headless 示例已改名为 `GalNet.Sample.Headless` 并移入 `Samples`。 -- `GalNet.Control.Tests` 已合并到 `GeneralTest`,保留项目生命周期测试;旧 UI 配置测试已删除。 - -验证:`GeneralTest`、命令行 Sample、Runtime、Avalonia Sample 和 Editor 的验证不再包含旧 Control 项目。 - -`AssetPickerFilter` 现由 `GalNet.Editor.Models` 持有;旧 UI 预设 schema 删除后,不再需要 `GalNet.Control.Abstraction` 作为编辑器依赖。 - -项目取舍: - -| 项目 | Phase 2 决策 | 原因 | -| --- | --- | --- | -| `GalNet.Presentation.Abstractions` | 保留 | 面向所有宿主的稳定展示端口。 | -| `GalNet.Avalonia.Controls` | 保留 | 是用户可直接引用和覆写模板的控件库,不应并入示例应用。 | -| `GalNet.Storage.Abstractions` + `GalNet.Storage.FileSystem` | 保留为两个项目 | 前者定义可替换存储端口,后者只是默认磁盘实现;合并会重新耦合云端或平台存储。 | -| `GalNet.Editor.Abstraction` + `GalNet.Editor.Shared` | 暂不合并 | 前者是编辑器协议/扩展边界,后者是文件与命令实现;目前边界有效。 | -| `GalNet.Editor.Headless` | 保留,可在后续重命名为 `GalNet.Editor.Cli` | 它提供真实的项目创建、校验、命令执行与导出 CLI,不是游戏播放器重复实现。 | -| `GalNet.Headless` | 迁移后删除 | 当前是写死示例数据的旧游戏演示;由 `GalNet.Player.Console` 取代。 | -| `GalNet.Control.Tests` | 合并后删除 | 仅两类测试,独立测试程序集没有长期价值。 | -| `GalNet.Launcher.Headless` | 已删除 | 仅包含 `Hello World`,没有调用方或产品职责。 | -| `GalNet.Launcher` 及 Android/iOS/Browser/Desktop 外壳 | 暂不删除 | 目前仍是模板级实现,是否作为“游戏库启动器”保留与最终示例客户端是产品决策;Phase 2 不再让它引用或承载新的游戏客户端功能。 | - -验收:Runtime 单元测试能借助假接口覆盖剧情推进、阻塞/非阻塞转场、特效、读档恢复和取消。 - -### Phase 3:命令行持久化补全 - -`GalNet.Sample.Headless` 已支持真实项目、存档、全局变量和文本记号解析;仓库根目录的 `GameTestCase` 是 Headless 与 Avalonia Sample 共用的冒烟案例,分别由 `run-headless-sample.ps1` 和 `run-avalonia-sample.ps1` 启动。 - -### Phase 4:Avalonia 控件库 - -`GalNet.Avalonia.Controls` 提供纯绑定/宿主调用的文本、对话、选项、存档和场景控件,默认样式可由最终客户端覆盖。 - -### Phase 5:官方 Avalonia 示例客户端(已完成) - -- `GalNet.Avalonia.GameView` 现在提供共享的 `GameShell`、标题页、游戏页、存档页、设置页、画廊页、关于页和 `IGameNavigationService`;它们全部是可替换的 `UserControl`。`AvaloniaGamePageView` 把游戏页的小型展示接口组合为 `IGameView` 所需的图层、对话、打字机与输入端口;页面不引用 Runtime、编辑器、文件系统或游戏目录。 -- 默认壳使用 DI 的 VM-first 导航:`IGameNavigationService.Navigate()` 从当前游戏 Scope 解析 VM,`IPageViewFactory` 通过 `IPageViewRegistry` 从同一 Scope 取得页面并设置 `DataContext`。导航参数使用 `IActivatablePageViewModel.ActivateAsync` 传入,绝不注册到 DI。 -- 默认页面 VM、页面 View、导航器、会话服务均为 Scoped,即一个游戏会话内唯一;弹窗或一次性视觉元素才应为 Transient。Sample 在窗口生命周期创建一个 Scope;编辑器预览可在重启或切换项目时释放旧 Scope 并创建新 Scope。 -- `IGamePageLayerFactory` 是页面唯一的宿主视觉扩展点:Sample 和将来的 Editor 预览各自把资源 ID 映射为 Avalonia 控件。游戏文件读取、存档、全局变量、进度、设置与媒体后端仍由各自组合根装配。 -- `GalNet.Sample.Avalonia` 使用共享页完成默认游戏屏幕、对话、NVL、选项、存读档、标题、设置和画廊外壳;转场在组合根按 `black`、`white`、`cross` 动态注册,特效保留按 ID 的动态入口。截图/复杂媒体呈现继续是最终客户端可替换的宿主服务,不进入共享页。 -- 共享 `GamePage` 或控件默认模板的修改会同时反映在官方 Sample 和编辑器预览;只修改 Sample 的资源工厂、媒体/转场实现、窗口外壳或样式则只影响 Sample。开发者可覆盖模板、替换单个服务或复制 Sample,而无需修改 `Core`/`Runtime`。 - -验证:共享壳及官方示例工程均以隔离输出编译成功;回归测试通过。编辑器实际接入该壳留在 Phase 6,使其以编辑器主题和预览服务装配同一页面。 - -推荐的开发者模板形态: - -```text -基础客户端模板 - MyGame.View --> GalNet.Avalonia.GameView - MyGame.Client --> MyGame.View + Runtime + Storage.FileSystem - -含编辑器模板 - MyGame.View --> GalNet.Avalonia.GameView - MyGame.Client --> MyGame.View + Runtime + Storage.FileSystem - MyGame.Editor --> MyGame.View + Editor + Runtime + Storage.FileSystem -``` - -`MyGame.View` 是开发者修改页面、样式、资源图层工厂和默认服务的共同位置;客户端和编辑器都引用它,故修改会同时生效。没有编辑器的模板只包含前两项。官方 `GalNet.Avalonia.GameView` 保持可升级的默认实现,开发者优先在自己的 `MyGame.View` 覆盖或组合页面,而非直接修改官方工程。 - -### Phase 6:编辑器瘦身与基础预览(已完成) - -- `EditorPreviewHost` 在独立游戏 Scope 中承载共享壳与预览占位服务;预览重启、读档和释放 Scope 会先停止旧执行任务,避免多个引擎驱动同一页面。 -- `GameCanvasHost` 已下沉到 `GalNet.Avalonia.Controls`;编辑器按项目设计尺寸使用它,官方 Sample 在 1920×1080 外层画布中承载同一个 `GameShell`。壳本身不包含缩放规则。 -- `GameShell` 现为完整 Avalonia 玩家根:标题、剧情、保存/加载、设置、画廊、关于和截图均在壳内。运行期页面表不可变,`MyGame.View` 仍能在组合期覆盖 VM→View。 -- 游戏和编辑器复用无框架 `NavigationHistory` 栈算法,但保持独立契约:`IGameNavigationService` 只切换游戏壳内页面,`INavigationService` 只管理编辑器启动、项目与 Dock 工作区页面。 - -验证:Editor、Editor.Headless、官方 Avalonia Sample 和 `GeneralTest` 构建/回归均应在提交前执行。纯 DI 构造 XAML View 的 `AVLN3001` 提示不影响 DI 路径。 - -### Phase 7:文件格式迁移、清理与发布 - -工作项: - -1. 增加游戏文件格式版本,写入转场/特效新字段。 -2. 固化新字段与校验规则;不提供旧字段兼容读取。 -3. 更新导出器:只导出游戏数据和资源,不导出编辑器 UI 配置。 -4. 更新架构、运行时、条目、文件格式与控件库文档。 -5. [本次清理完成] 删除旧 `GalNet.Control`、`GalNet.Control.Abstraction` 及遗留 UI 定制链。 - -验收:新格式项目可由 CLI 和示例 Avalonia 客户端运行;最终依赖图无反向引用,完整测试通过。 - -## 5. 不在本次短期范围 - -- 编辑器直接加载、调试或热重载某个开发者自定义客户端工程。 -- 允许编辑器设计最终客户端 UI。 -- 云存档、平台账户、Steam 集成等具体基础设施实现。 -- 为所有转场或特效定义统一参数架构;它们只共享 ID、生命周期字段和原始参数载荷。 - -## 6. 实施顺序 - -严格按 Phase 0 → 3 先稳定核心和 CLI,再实施控件库、示例客户端和编辑器。这样可以先以 `NullGameView` 与命令行验证状态机、存档和扩展命令语义,避免 UI 实现反复牵动核心协议。 diff --git a/docs/design/services-analysis.md b/docs/design/services-analysis.md deleted file mode 100644 index 7ec81324..00000000 --- a/docs/design/services-analysis.md +++ /dev/null @@ -1,294 +0,0 @@ -# GalNet — Services Analysis - -> 游戏运行所需的服务清单、职责边界、依赖关系。 -> -> **更新于 2026-07-11**:Editor 服务已拆分至 Abstraction(接口/模型,插件可用)和 Shared(实现,Headless + Editor 共用)。 -> -> **历史说明**:本文记录旧版 `GalNet.Control` 迁移前的设计分析,不是当前实现清单。旧 Control 项目已删除;当前 Avalonia 游戏页面宿主是 `GalNet.Avalonia.GameView`,当前架构事实以 `docs/spec/architecture.md` 和 `docs/spec/control.md` 为准。 - ---- - -## 项目分层(服务视角) - -``` -GalNet.Editor ← UI 相关服务(本地化实现、主题、文件对话框、视图工厂) -GalNet.Editor.Shared ← 非 UI 服务实现(Headless + Editor 共用,插件不可用) -GalNet.Editor.Abstraction ← 抽象接口与模型(插件可用) -GalNet.Runtime ← 游戏执行引擎 -GalNet.Core ← 领域模型 & 共享协议 -``` - ---- - -## 服务清单总览 - -| # | 服务/接口 | Core 层 | 实现位置 | 状态 | 消费者 | -|---|----------|---------|---------|------|--------| -| 1 | **GameEngine** | 无接口(sealed) | Runtime | ✅ 已实现 | Launcher / Editor | -| 2 | **IGameRuntime** | 已有 | Runtime/GameRuntime | ✅ 已实现 | EntryHandler / GameEngine | -| 3 | **IGameView** | 已有 | Presentation.Abstractions / AvaloniaGamePageView | ✅ 已实现 | EntryHandler / Avalonia 宿主 | -| 4 | **I18n** | `ICultureService`(外部) | DynamicLocalization | ✅ 已集成 | EntryHandler / UI | -| 5 | **INavigationHost** | 🆕 Core | GameHostView (Control) | 🆕 新建 | 所有 Screen ViewModel | -| 6 | **ISettingsService** | 🆕 Core | Control/Services | 🆕 新建 | SettingsScreen / DefaultGameView | -| 7 | **ISaveService** | 🆕 Core | Control/Services | 🆕 新建 | SaveLoadScreen / GameEngine | -| 8 | **IAudioService** | 🆕 Core | Control/Services | 🆕 新建 | EntryHandler(↔IGameView↔) / SettingsScreen | -| 9 | **IInputService** | 🆕 Core | Control/Services | 🆕 新建 | GameHostView | -| 10 | **ILogService** | `Serilog.ILogger`(外部) | 已集成 | ✅ 已集成 | 全局 | - ---- - -## 逐个详细分析 - -### 1. GameEngine — 游戏状态机 - -**状态:** ✅ 已实现。 - -**职责:** 图加载 → 条目编译 → 状态机循环 → 节点转移 → 存档快照。 - -**备注:** 不需要接口(唯一实现),保持现状。 - ---- - -### 2. IGameRuntime — 运行时状态 - -**状态:** ✅ 已实现。 - -**职责:** 位置跟踪、变量读写、条件求值、场景状态、I18n 引用、快照。 - -**变动:** `SettingsContainer` 从 Runtime 剥离到 `ISettingsService`,Runtime 通过注入获取。 - ---- - -### 3. IGameView — 当前呈现端口 - -**状态:** ✅ 已实现。 - -`IGameView` 位于 `GalNet.Presentation.Abstractions`,Runtime 通过它发送游戏内呈现请求。`AvaloniaGamePageView` 提供官方 Avalonia 页面实现;`NullGameView` 为测试和无界面宿主提供空实现。页面导航由 `IGameNavigationService` 独立负责,不再由游戏 View 承担。 - ---- - -### 4. I18n — 多语言 - -**状态:** ✅ `DynamicLocalization.Core.ICultureService`。 - -**当前设计完全够用。** EntryHandler 通过 `ctx.GetText(key)` 解析展示文本。 - ---- - -### 5. INavigationHost — 导航中介(新建) - -**为什么需要:** Screen ViewModel 需要触发导航/弹窗,但 ViewModel 不能直接引用 View。`INavigationHost` 是轻量中介。 - -**接口(Core/Services/):** - -```csharp -public interface INavigationHost -{ - /// 替换主内容区。 - void NavigateTo(string key, object? parameter = null); - - /// 通过 Ursa DialogHost 弹出模态。 - void ShowModal(string key, object? parameter = null); - - /// 关闭当前模态。 - void CloseModal(); - - /// Toast 通知。 - void ShowToast(string message); -} -``` - -**谁实现:** `GameHostView` 直接实现此接口。不独立注册到 DI,GameHostView 初始化时将自己注册为单例。 - -**ViewModel 使用示例:** - -```csharp -public class TitleScreenViewModel : ViewModelBase -{ - private readonly INavigationHost _nav; - - public TitleScreenViewModel(INavigationHost nav) - { - _nav = nav; - } - - [RelayCommand] void NewGame() => _nav.NavigateTo("ChapterSelect"); - [RelayCommand] void OpenSettings() => _nav.ShowModal("Settings"); -} -``` - ---- - -### 6. ISettingsService — 游戏设置(新建) - -**接口(Core/Services/):** - -```csharp -public interface ISettingsService -{ - double BgmVolume { get; set; } - double SfxVolume { get; set; } - double VoiceVolume { get; set; } - double TextSpeed { get; set; } - bool Fullscreen { get; set; } - - GameSettings GetSnapshot(); - void ApplySnapshot(GameSettings settings); - - Task LoadAsync(string path); - Task SaveAsync(string path); - - event Action? Changed; -} -``` - -**实现:** `Control/Services/SettingsService`,内部持有 `GameSettings` 实例。 - -**消费者:** -- `SettingsScreenViewModel` — 绑定修改 -- `DefaultGameView` — 读取 TextSpeed 决定打字机延迟 -- `GameEngine` — 初始化时获取默认值 - -**与现有 `SettingsContainer` 的关系:** 共存。`SettingsContainer` 是 Core 通用容器,`SettingsService` 封装 `GameSettings` + 持久化。 - ---- - -### 7. ISaveService — 存档服务(新建) - -**接口(Core/Services/):** - -```csharp -public interface ISaveService -{ - int MaxSlots { get; } - IReadOnlyList ListSlots(); - - Task SaveAsync(int slot, GameSnapshot snapshot); - Task LoadAsync(int slot); - Task DeleteAsync(int slot); - - Task QuickSaveAsync(GameSnapshot snapshot); - Task QuickLoadAsync(); -} -``` - -**与现有 `SaveManager` 的关系:** -- `SaveManager`(Runtime)是纯静态序列化工具 -- `ISaveService` 管理槽位、文件路径、索引 -- `SaveService` 内部调用 `SaveManager.Serialize/SaveToFile` - ---- - -### 8. IAudioService — 音频服务(新建) - -**接口(Core/Services/):** - -```csharp -// Channel 约定: -// 0 = BGM 1 = BGM2 2 = Voice 3..N = SFX -public interface IAudioService -{ - void Play(int channel, string assetId, float volume = 1f, string mode = "once", int times = 1); - void Stop(int channel); - void Pause(int channel); - void Resume(int channel); - void Enqueue(int channel, string assetId, int times = 1); - - float GetVolume(int channel); - void SetVolume(int channel, float volume); - void SetMasterVolume(float volume); -} -``` - -**说明:** -- 用 `int` 而非 `string` 作为 channel 标识 -- 0/1/2 约定为 BGM/Voice 保留,SFX 从 3 开始(数量由 `GameSettings.SfxChannelCount` 配置) -- `IGameView` 的方法委托给 `IAudioService` -- `SettingsScreenViewModel` 直接注入 `IAudioService` 控制音量 - ---- - -### 9. IInputService — 快捷键/输入(新建) - -**接口(Core/Services/):** - -```csharp -public interface IInputService -{ - IDisposable RegisterHotkey(KeyGesture gesture, Action handler); -} -``` - -**消费者:** `GameHostView` 注册全局快捷键(Esc→设置、F5→快存、F9→快读、Ctrl→跳过)。 - ---- - -## 删减项 - -| 原设计项 | 决定 | 原因 | -|---------|------|------| -| `IGameState` | ❌ 删除 | 变量外部查看 → 直接读 `GameSnapshot.Variables` 即可 | -| `ISceneService` | ❌ 删除 | `SceneState` 已在 Runtime 中,图层操作在 `IGameView`,够用 | -| `IVariableService` | ❌ 不拆 | `IGameRuntime.SetVariable/GetVariable/EvaluateCondition` 已经覆盖,Handler 通过 `ctx.Runtime` 调用 | - ---- - -## 依赖注入图 - -``` -GameHostView : INavigationHost - ├── IServiceProvider - │ - ├── ContentControl (主内容) - │ ├── GameScreenView → DefaultGameView : IGameView - │ │ ├── ISettingsService - │ │ └── IAudioService - │ │ - │ └── TitleScreenView - │ └── TitleScreenViewModel - │ └── INavigationHost - │ - ├── Ursa DialogHost(模态宿主) - │ ├── SettingsScreenView → VM ← ISettingsService - │ ├── SaveLoadScreenView → VM ← ISaveService - │ └── ConfirmDialog → VM ← INavigationHost - │ - ├── Ursa ToastHost - └── IInputService (快捷键) -``` - ---- - -## 线程模型 - -| 层 | 线程 | 访问的 Service | -|---|---|---| -| GameEngine.StepAsync | ThreadPool | IGameRuntime(所有操作同步) | -| EntryHandler | 引擎线程 | IGameRuntime + IGameView | -| DefaultGameView | **UI 线程**(Dispatcher) | ISettingsService, IAudioService | -| GameHostView | **UI 线程** | IServiceProvider, IInputService | -| Screen ViewModels | **UI 线程** | INavigationHost, ISettingsService, ISaveService | - -**规则:** -- 修改 UI 状态的方法必须在 UI 线程调用 -- 纯数据 Service(`ISaveService`)可跨线程访问(内部自行处理同步) -- Handler 不直接感知 UI Service — 通过 `IGameView` 间接使用音频 - ---- - -## 实现优先级 - -| 优先级 | 任务 | 依赖 | -|--------|------|------| -| P0 | 创建 `Core/Services/` 全部接口 | 无 | -| P0 | `GameHostView` — 导航 + DialogHost + ToastHost + INavigationHost | Ursa 库 | -| P0 | 清理 `DefaultGameView` | GameHostView | -| P1 | `SettingsService` 实现 | ISettingsService | -| P1 | `TitleScreenView` + ViewModel(注入 INavigationHost) | INavigationHost | -| P1 | `SettingsScreenView` + ViewModel(注入 ISettingsService + INavigationHost) | SettingsService | -| P1 | 更新 Editor MVP 适配新架构 | 以上全部 | -| P2 | `SaveService` 实现(复用 SaveManager) | ISaveService | -| P2 | `SaveLoadScreenView` + ViewModel | ISaveService | -| P2 | `InputService` 实现 + GameHostView 快捷键 | IInputService | -| P3 | `AudioService` 实现 | 平台音频后端 | -| P3 | `ChapterSelectView` + ViewModel | ISaveService | diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..7ae2371d --- /dev/null +++ b/docs/development.md @@ -0,0 +1,208 @@ +# 二次开发手册 + +本手册面向“基于 GalNet 做自己的游戏/客户端”的开发者。默认原则是:**不修改 `src/Shared` 中的 Core、Runtime、Assets、Presentation.Abstractions 或 Builtins;在自己的外层程序集和组合根中扩展。** Shared 层是可升级的框架契约,而不是项目玩法代码的位置。 + +## 先决定扩展落点 + +| 需求 | 推荐落点 | 不应做的事 | +| --- | --- | --- | +| 自定义剧情指令 | 自己的 Entry Module | 往 Core 写某个项目专属 primitive | +| 自定义即时或异步玩法逻辑 | 自己的 `PrimitiveInstance` | 让 Engine 知道项目的指令名 | +| 高级作者指令 | 自己的 composite,编译为 primitive | 让 Runtime 直接执行 composite | +| 新页面、主题、窗口或媒体后端 | `MyGame.View` / 客户端宿主 | 修改 `GalNet.Avalonia.GameView` 默认页面 | +| 新资源类型、decoder、Gallery 页面 | 外层注册和平台程序集 | 让 Core 引用 Avalonia/Skia | +| 项目存档位置、账户、平台服务 | 客户端宿主基础设施 | 修改 Runtime 的状态机 | + +推荐结构: + +```text +MyGame/ + MyGame.Gameplay/ 自定义 module、primitive、纯玩法服务 + MyGame.View/ Avalonia 页面、主题、presenter、renderer、decoder + MyGame.Client/ Composition root、窗口和平台服务 + MyGame.Editor/ 可选:编辑器扩展和同一 target profile +``` + +`MyGame.Gameplay` 可以依赖 Core、Runtime 所需的公开协议和 Presentation.Abstractions;`MyGame.View` 才依赖 Avalonia 或其他具体渲染技术。项目自有类型不要声明在 `GalNet.Core.*` 命名空间下。 + +## 创建一个自定义原语 + +### 1. 选定稳定 type ID 与参数 + +type ID 使用点分隔稳定名称,例如 `mygame.reputation.add`。模块 ID 必须是小写且不含点,例如 `mygame`。参数 schema 是编辑器、编译器和运行时共用的唯一事实来源;参数使用 JSON 类型,不写 CLR 类型名。 + +```csharp +using System.Text.Json; +using GalNet.Core.Entry; +using GalNet.Core.Primitives; + +var reputationParameters = new DynamicParameterTable( +[ + new DynamicParameterDescriptor("faction", typeof(string), isRequired: true), + new DynamicParameterDescriptor( + "amount", typeof(int), + defaultValue: JsonSerializer.SerializeToElement(1)) +]); +``` + +`DynamicParameterDescriptor` 可声明 required、JSON 默认值与 JSON constraints。若主要目的是提供编辑器控件提示,也可用 `EntrySchema.DynamicParameters(...)` 由 `EntryParameterType` 构建 schema。 + +### 2. 选择原语的生命周期 + +同步且立即结束的逻辑使用 `ImmediatePrimitiveInstance`: + +```csharp +new DefaultPrimitiveEntryBase( + "mygame.reputation.add", + reputationParameters, + context => new ImmediatePrimitiveInstance(() => + { + var faction = context.Arguments.GetProperty("faction").GetString()!; + var amount = context.Arguments.GetProperty("amount").GetInt32(); + reputationService.Add(faction, amount); + }, batchId: context.BatchId)); +``` + +有等待、动画或用户跳过语义时,继承 `PrimitiveInstance`: + +```csharp +public sealed class WaitForSignalInstance : PrimitiveInstance +{ + private readonly IMySignalService _signals; + private readonly string _signal; + private CancellationTokenSource? _cancellation; + + public WaitForSignalInstance(IMySignalService signals, string signal, + string? batchId, CancellationToken scopeCancellation) : base(batchId) + { + _signals = signals; + _signal = signal; + ScopeCancellation = scopeCancellation; + } + + private CancellationToken ScopeCancellation { get; } + public override bool IsBlocking => true; + public override bool IsSkippable => true; + + protected override async void OnDispatch() + { + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(ScopeCancellation); + try { await _signals.WaitAsync(_signal, _cancellation.Token); } + catch (OperationCanceledException) { } + finally { TryComplete(); } + } + + protected override void OnSkip() => _cancellation?.Cancel(); +} +``` + +原语实现必须遵守以下规则: + +- 让基类调用 `Dispatch()`;只在 `OnDispatch()` 启动一次行为。 +- 成功、取消或错误路径最终都只调用一次 `TryComplete()`。 +- 创建实例时原样传入 `context.BatchId`;不要生成不同的 batch ID。 +- `OnSkip()` 必须幂等,且只处理本实例自己的资源。 +- non-blocking 原语在返回前提交可存档状态;平台 Task、控件和播放游标不属于存档。 + +### 3. 将定义组成模块 + +```csharp +public sealed class MyGameModule : EntryModuleBase +{ + private static readonly DynamicParameterTable ReputationParameters = new( + [ + new DynamicParameterDescriptor("faction", typeof(string), isRequired: true), + new DynamicParameterDescriptor( + "amount", typeof(int), + defaultValue: JsonSerializer.SerializeToElement(1)) + ]); + + public MyGameModule(IReputationService reputationService) + : base("mygame", + [ + new DefaultPrimitiveEntryBase( + "mygame.reputation.add", + ReputationParameters, + context => new ImmediatePrimitiveInstance(() => + { + var faction = context.Arguments.GetProperty("faction").GetString()!; + var amount = context.Arguments.GetProperty("amount").GetInt32(); + reputationService.Add(faction, amount); + }, batchId: context.BatchId)) + ]) + { } +} +``` + +模块构造时会冻结 primitive 与 composite 两张表,并拒绝模块内或跨模块的重复 type ID。模块有资源时覆写 `Dispose()`;`CompositeGameView` 会按相反顺序释放已挂载模块。 + +### 4. 同时接入作者期与运行期 + +必须让编辑器/编译器的 target profile 与运行时 view 使用**等价的模块集合和 schema**。实际宿主可以各自创建模块实例(例如编辑器没有 renderer,游戏有 renderer),但 type ID、参数和 composite 展开契约必须一致。 + +```csharp +// 编辑器或导出器:决定允许作者使用哪些 entry。 +var authoringModules = new IEntryModule[] +{ + new MyGameModule(editorReputationService) +}; +var profile = new TargetProfileEntryCatalog(authoringModules); + +// 游戏会话:提供同一批 primitive 的实际工厂和平台依赖。 +using IGameView view = new CompositeGameView( +[ + new MyGameModule(runtimeReputationService) +]); +``` + +只把模块挂到 `CompositeGameView` 会使 Runtime 能执行、编辑器却不能作者化/编译;只挂到 `TargetProfileEntryCatalog` 则相反。不要依赖全局内置目录作为回退。 + +## 创建 composite(可选) + +Composite 用于改善作者体验,不是另一种 Runtime 指令。继承 `CompositeEntry`,在 `Compile(EntryCompileContext)` 中返回 primitive 序列,并通过 `DefaultCompositeEntryBase` 注册到模块的 `compositeEntries`。 + +```csharp +public sealed class AlertCompositeEntry : CompositeEntry +{ + public override string Type => "mygame.alert"; + + public override IReadOnlyList Compile(EntryCompileContext context) => + [ + new PrimitiveEntry("mygame.ui.showAlert", + JsonSerializer.SerializeToElement(new { message = Values["message"] })) + { Condition = context.Condition } + ]; +} +``` + +展开得到的每一个 primitive 都必须已在 target profile 注册。Composite 本身不会写入 `.galgroup`,也不应该创建平台对象。 + +## 平台相关逻辑怎么写 + +有两种合规路径: + +1. **推荐:窄端口。** 在 `MyGame.Gameplay` 定义项目自己的小接口,例如 `IMyNotificationPresenter`;Avalonia/Unity/其他宿主在 `MyGame.View` 实现它。primitive 依赖接口,逻辑仍可 Headless 测试。 +2. **单平台项目:直接依赖。** 让模块闭包捕获 Avalonia 页面服务、Skia renderer 或媒体后端也可行,但该模块必须留在 `MyGame.View` 或客户端层。它将无法用于 Headless/其他前端,且读档后仍需由宿主按 `SceneState` 或自己的稳定状态重建视觉结果。 + +无论哪种路径,都不能让 `GalNet.Core`、`GalNet.Runtime` 或 `GalNet.Editor.Shared` 反向依赖具体平台程序集。 + +## 扩展页面、资源和宿主 + +- 在 `MyGame.View` 使用 `AddAvaloniaGameViewPages()` 的注册回调替换默认 View,或追加页面;运行期 registry 构建后不可修改。 +- 新 Gallery 类型通过 `IGalleryPageRegistry` 按精确 `typeId` 注册,不按资源类型推断 fallback。 +- 新资源类型要在组合期注册同一份 `IResourceTypeCatalog`;decoder 按 `(typeId, CLR 类型)` 注册到资产管理器。开发内容、导出器、安装内容和预览必须使用同一份冻结 catalog。 +- 平台媒体若需要文件路径,先通过 `IAssetManager` acquire 内容,再实体化到受控会话临时目录;不要扫描项目目录或 PAK。 +- 每个游戏会话使用独立的 Runtime、页面 scope 与模块实例;切换项目、重载或关闭时先停止 runner,再释放资源与 scope。 + +页面和资源边界的详细约束见[Avalonia 游戏页面](spec/control.md)、[资源](spec/assets.md)和[架构](spec/architecture.md)。 + +## 二次开发的验收清单 + +- 自定义 type ID 在目标 profile 中存在,且无重复。 +- Raw Group 可编译为只含 primitive 的 `.galgroup`。 +- Runtime 的 `CompositeGameView` 挂载等价模块集合。 +- 参数默认值、必填项和 JSON 类型有测试。 +- blocking、skip、取消和完成事件分别有测试。 +- 任何影响长期画面的状态先写入可恢复的状态模型,再调用平台呈现。 +- 读档后验证状态重放,而不是只验证第一次播放。 +- 项目程序集没有让 Shared/Core/Runtime 依赖具体 UI、渲染或文件系统实现。 diff --git a/docs/engine.md b/docs/engine.md new file mode 100644 index 00000000..fcb8a1e3 --- /dev/null +++ b/docs/engine.md @@ -0,0 +1,80 @@ +# 引擎使用手册 + +本页说明 GalNet 已实现的引擎契约:内容如何进入 Runtime、剧情如何推进,以及存档和呈现如何协作。它面向使用引擎制作游戏的人;如果目标是给项目增加自己的原语、页面或平台能力,请阅读[二次开发手册](development.md)。 + +## 运行全景 + +```text +Graph + 已编译 .galgroup + -> GameEngine + -> IGameView.Dispatch + -> PrimitiveInstance + -> presenter / 场景渲染 +``` + +`GameEngine` 是剧情流程和活动 primitive 队列的唯一拥有者。玩家和 UI 只应调用 `AdvanceAsync()` 推进;Choice、条件、分支映射和节点跳转属于 Engine 内置控制流,不能实现为普通 primitive。 + +`IGameRuntime` 是运行事实的唯一来源,保存当前节点、条目位置、变量和 `SceneState`。呈现端口负责显示,不反过来拥有剧情或可存档状态。 + +## 内容项目与发布 + +开发项目的最小布局如下: + +```text +Project/ + settings.json + Graph/ + graph.json + groups/*.rawgalgroup + Assets/ + **/* + **/*.meta + I18n/ +``` + +- `graph.json` 是编辑图;Group 的编辑源是 `*.rawgalgroup`。 +- Raw Group 可以包含 primitive 和 composite。编译后产生的 `*.galgroup` 只能包含 primitive,Runtime 只加载这一种文件。 +- 原语参数始终是 JSON;内容和存档中不保存 CLR 类型名。 +- 资源引用应使用 `.meta` 的稳定 `id`(通常为 GUID),而不是资源路径。 + +导出结果是 `.galpak`:它包含 Graph、I18n、settings、生成的 `gallery.json` 和资源 PAK。安装后,特殊内容由内容 provider 读取,只有 `Assets/Paks/**/*.pak` 会成为资源 provider。完整格式见[文件格式](spec/file-formats.md)和[资源](spec/assets.md)。 + +## 条目、原语和编译 + +一条剧情动作称为 Entry。它有两种形式: + +- **primitive**:运行期直接执行的通用 envelope,字段为 `id`、`typeId`、`arguments`、`condition` 和可选 `batchId`。 +- **composite**:仅作者期存在的高级指令。编译时展开成有序 primitive 序列,不能直接进入 Runtime。 + +例如,一个转场 composite 可展开为显示图层、等待和隐藏图层;Runtime 只会收到展开后的三个 primitive。 + +每次 primitive 调用都会创建一个 `PrimitiveInstance`。它在创建后固定 `IsBlocking` 和 `BatchId`,并通过 `Dispatch()` 启动,通过一次 `TryComplete()` 结束。`IsSkippable` 可随执行阶段变化,`Skip()` 必须幂等。 + +`batchId` 是单次 Group 执行内的跳过分组:同 batch 的可跳过实例会一起收到 skip;空值表示独立分组。它不是全局 ID,也不跨两次进入同一 Group 生效。 + +## 推进、阻塞和存档 + +一次 `AdvanceAsync()` 会清理已完成实例、在稳定边界更新快照,并继续消费剧情,直到遇到新的 blocking primitive、Choice 或结尾。若调用开始时已有 blocking primitive,Engine 只跳过其所属 batch,不会把同一次输入延伸到之后才分发的动作。 + +稳定快照只在以下条件同时满足时更新:剧情游标已提交、没有未完成 blocking primitive、没有 pending Choice。因此: + +- non-blocking primitive 必须在 `Dispatch()` 返回前写入它的最终可存档逻辑状态; +- blocking primitive 的中间展示状态不会成为新的稳定存档; +- 读档不恢复 Task、CancellationToken、平台控件或活动 `PrimitiveInstance`;宿主必须根据 `SceneState` 重放长期展示。 + +影响场景的原语应先写 `SceneState`,再调用 presenter。这样存档恢复、Headless 和平台前端能保持同一游戏事实。 + +## 默认能力与宿主 + +`GalNet.Primitives.Builtins` 是可选的推荐模块集合,提供对话、NVL、图层、动画、效果、粒子、音频、视频、等待、变量和 Gallery 等 schema 与相应的已实现能力。具体媒体后端能力由所选宿主提供;宿主可以只挂载自定义模块,或在内置模块之外追加能力。 + +官方 Avalonia 宿主 `GalNet.Avalonia.GameView` 提供 `GameShell`、导航、页面和默认 presenter。它不读取项目目录,也不决定媒体或存档实现;这些由游戏宿主在组合根提供。页面集成见[Avalonia 游戏页面](spec/control.md)。 + +## 推荐验证路径 + +1. 在编辑器或 `GalgroupCompiler` 中用目标 profile 编译 Raw Group。 +2. 用 Headless 宿主验证 primitive 的状态、条件、阻塞和存档语义。 +3. 用平台宿主验证 presenter、资源解码和恢复后的场景重放。 +4. 为同步、阻塞/跳过、取消和读档后重放分别写测试;不要只验证“画面曾出现”。 + +更多准确的逐项契约以[运行时参考](spec/runtime.md)、[条目类型](spec/entry-types.md)和[架构](spec/architecture.md)为准。 diff --git a/docs/spec/control.md b/docs/spec/control.md index e8d9b7b3..e53ac17d 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 只维护可存档的场景状态和动画结束后的稳定属性。 @@ -54,3 +54,11 @@ Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细 - 宿主负责实现 `IGamePageLayerFactory`、`IGameContentProvider`、存档、玩家变量、进度和媒体端口。 - 共享页面可以被替换或覆写;最终客户端的窗口、主题、资源定位和平台服务不进入共享页面项目。 - 新增页面时,需要补充 ViewModel、View、组合注册和导航测试。 + +## 剧情视频图层 + +`AvaloniaVideoPresenter` 是宿主注入的 `IVideoPresenter` 实现,Sample 在会话构造时将它挂入推荐视频模块。它通过 `IAssetManager` 获取 video 资源并实体化临时媒体文件,LibVLC 像素回调按实际视频尺寸发布 `SceneFrameSource`。视频帧经过现有 `SceneLayerItem` 和场景合成管线,参与 Layer effect 与 ScenePost,而不是独立的原生 VideoView。 + +帧源租约保护渲染线程读取的纹理;被新帧替换或停止的源在安全时释放。UI 失效通知合并,播放完成/失败的 native callback 只发出信号,停止和释放播放器在回调线程之外完成。隐藏、替换、会话取消会停止对应视频,包括尚未完成的资源加载;异步会话销毁等待所有播放清理后释放 LibVLC。 + +当前实现使用 CPU 解码帧上传,帧尺寸上限 8192×8192;真实解码验证在 Windows LibVLC 后端完成。其他平台需提供可用的原生 LibVLC,缺失后端会诊断并安全完成剧情视频。Gallery 保持自己的媒体生命周期。 diff --git a/docs/spec/entry-types.md b/docs/spec/entry-types.md index c010f281..edbd8b7d 100644 --- a/docs/spec/entry-types.md +++ b/docs/spec/entry-types.md @@ -55,10 +55,12 @@ transition.fadeColor 内置 entry DTO、primitive instance 和 module 都使用 `GalNet.Primitives.Builtins` 命名空间;`GalNet.Core.Entry` 只包含通用 entry 基类、schema 和 catalog 基础设施。内容格式只保存下列稳定 `TypeId`,不保存 CLR 类型名。 ```text -dialogue.text, dialogue.show, dialogue.hide +dialogue.text, dialogue.textVoice, dialogue.show, dialogue.hide +nvl.show, nvl.hide, nvl.clear, nvl.append, nvl.appendVoice 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 +audio.play, audio.stop, audio.pause, audio.resume, audio.enqueue, audio.next, audio.clear +audio.seffect.apply, audio.seffect.stop video.play, video.stop effect.apply, effect.stop particle.play, particle.stop @@ -67,6 +69,29 @@ 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)。 + +## 视频图层 entry + +`video.play`: + +| 参数 | 默认值 | 含义 | +| --- | --- | --- | +| `asset` | 必填 | video 资源 ID | +| `handleId` | `video` | 场景图层 handle;同 handle 新播放替换旧播放 | +| `blocking` | `true` | 是否等待视频结束;沿用推荐 entry 的 true/false 选项 | +| `skipPoints` | `[]` | 以秒为单位的 JSON 数字数组,运行时校验为非负、有限、严格递增 | +| `transform` | `{}` | 与 Layer 相同的位置、旋转和缩放 | +| `z` | `0` | 场景排序 | +| `opacity` | `1` | 图层透明度 | +| `displayMode` | `Uniform` | `Native` / `Tile` / `Fill` / `Uniform` / `UniformToFill` | + +阻塞时,推进取严格大于当前实际播放位置与上次请求跳转位置的较大值的下一个时间戳;无下一个或目标达到/超过已知时长即结束。空列表点击直接结束。加载未就绪时不执行 seek;非阻塞播放不消费推进输入。自然播完也解除阻塞。 + +`video.stop` 接收 `handleId`(默认 `video`),停止对应视频并移除其图层。不同 handle 可独立播放。`layer.hide`、`layer.show`、`layer.replace` 对视频源同样执行释放,旧播放的延迟完成不会清理替换后的图层。停止与自然结束不保留末帧;视频自带声音由视频后端直接输出,尚未接入音频轨道。 + +视频图层可使用普通 Layer 的变换/透明度动画与纹理效果。阻塞视频存档采用此前稳定快照;非阻塞视频保存源定义并在读档时从头播放,无 presenter 的 Headless 安全完成视频 entry。 diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 0496859a..df77c135 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -136,13 +136,17 @@ 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` 调用。 +- `audio.play` / `audio.stop` / `audio.pause` / `audio.resume` / `audio.enqueue` / `audio.next` / `audio.clear`:`AudioRuntime` 持有轨道队列与 voice 生命周期,`IAudioPlaybackHost` 只执行平台播放请求。`dialogue.textVoice` 与 `nvl.appendVoice` 会先发出非阻塞的 `audio.play(replaceIfDifferent)`,默认使用 `vocal` 轨道,再按正常文本语义阻塞。 +- `audio.seffect.apply` / `audio.seffect.stop`:以 `trackId` 和 `instanceId` 附着或移除宿主 catalog 已注册的轨道 SEffect;当前 Avalonia host 支持 `seffect.gain`、基于 equalizer 频段近似的 `seffect.lowPass` 与 `seffect.highPass`。未知 ID、非法参数或不支持 scope 只记录诊断,不会静默附着。 - `particle.play` / `particle.stop`:用 `rate` 创建和停止持续 emitter,维护 `SceneState.ActiveParticleEmitters` 与 Runtime emitter instance。存档只保存 emitter 定义、排序与动画值;宿主读档后通过 `BuiltinPresentationReplay` 以相同 seed 从头重建,不保存单颗粒子。`initial` 的 lifetime、velocity、size、rotation、angular velocity、color 以及 `flipbook` 的播放速度与起始帧都按粒子确定性采样;`motion` 提供 gravity、noise、drag、radial、orbit、attractor,`lifetime` 曲线线性插值 size、opacity、velocity、rotation、color。当前粒子年龄、位置和 flipbook 帧不单独存档。 - `particle.burst`:用 `count` 向 `IParticlePresenter` 发出一次性 burst,不创建 Runtime scene state,也不参与存档恢复。有限持续效果由流程或 animation plan 编排 play/stop;重复 burst 由 animation plan 事件表达,跳过 plan 时不补发未来 burst。 +- `video.play` / `video.stop`:视频是 Runtime Layer,`IVideoPresenter` 将平台解码帧送入场景管线;阻塞时每次推进跳到当前播放位置之后的下一时间戳,跳转期间保持阻塞,结束后继续剧情。稳定存档只保存视频图层定义,非阻塞视频恢复时从零播放;播放 identity 和游标不入存档。 - `flow.wait`:blocking、skippable 的等待实例。 - `variable.set`:求值后写入 Runtime 变量。 - `gallery.unlock`:按稳定 item ID 解锁 Gallery,写入对应的系统 Player bool。 -音频和视频仍保留推荐 schema;完整产品级剧情原语行为由后续 feature 或宿主自定义模块补齐。Gallery 的平台无关 catalog 与解锁数据层由 Core/Storage 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。 +视频图层与音频的轨道播放、淡化、队列和当前轨道 SEffect 行为由推荐模块实现。bus/master 处理、item effect、参数动画、存档恢复和玩家音量设置仍由后续 feature 补齐。Gallery 的平台无关 catalog 与解锁数据层由 Core/Storage 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。 diff --git a/features/F-20260916-02-audio-system/design.md b/features/F-20260916-02-audio-system/design.md new file mode 100644 index 00000000..39fd02ee --- /dev/null +++ b/features/F-20260916-02-audio-system/design.md @@ -0,0 +1,124 @@ +--- +feature: F-20260916-02-audio-system +status: confirmed +created: 2026-09-30 +updated: 2026-09-30 +--- + +# GalNet 音频系统:实施前设计复核 + +## 结论 + +原始设计的核心分层是正确的:作者面对稳定的逻辑轨道和资源 ID;Runtime 拥有队列与逻辑状态;宿主拥有解码、设备和精确时钟。该分层与 Ren'Py 的 channel/mixer 区分、Godot 的 bus 与有限复音、Unreal 的 source/submix/DSP 图相符。 + +但它还不能直接进入实现。复核发现四个必须写成契约的缺口:多声部的队列与 `next`、有界排队、后端能力/自然衔接、以及当前原语与 Editor 的真实接入点。它们不改变原始架构方向;本设计把它们收束为 Phase 4A 的前置约束,并把精确衔接、恢复位置和 Effect 留在后续阶段。 + +## 当前事实 + +- `BuiltinAudioModule` 目前只注册 `audio.play`、`stop`、`pause`、`resume`、`enqueue` 的静态 schema;它们创建的是空的即时原语,不会播放声音。 +- 当前不存在设计稿所称的 `IAudioView`。`IGameView` 通过可组合的 primitive module 工作;新的音频端口须作为该组合中的显式依赖接入,而非假定已有 View 方法可替换。 +- 当前 `dialogue.text` 的 `voice` 参数仍直接调用 `IDialoguePresenter.SetVoice`,而 Avalonia 实现只显示状态文字。因此它必须在同一迁移中拆为 `dialogue.textVoice`,并改走音频 Runtime,不能留下第二条播放路径。 +- `AudioChannelManager`、`IAudioService` 与 `LibVlcAudioController` 是历史占位模型;`LibVlcAudioController` 按 channel 只保存一个 `MediaPlayer`,也不会传递结束事件。它们不能作为新 Runtime 的数据模型。 +- 项目已引用 `LibVLCSharp`,而 Avalonia Sample/Editor 只在 Windows 引入 `VideoLAN.LibVLC.Windows`。已确认:音频后端是平台实现;首期由 Avalonia 提供真实播放 host,Headless 使用可观测的 fake host,不假装可以播放声音。 +- Entry schema 的 `Select` 选项在 recommended target profile 中是静态的;Editor 现只为 Effect catalog 特殊注入动态选项。因此“`trackId` 使用项目轨道下拉”需要一个通用的项目上下文选项提供器,不能沿用硬编码的 `ParameterOptions`。 + +## 参考引擎带来的约束 + +- Ren'Py 将单声道的正常 channel(播放或排队)和可同时播放、但不支持排队的 `audio` channel 明确区分;这支持把“播放队列”和“多声部”视为两个需分别定义的维度,而非默认互相兼容。[Ren'Py Audio](https://www.renpy.org/doc/html/audio.html) +- Godot 的播放器同时暴露 bus、`max_polyphony` 与自然结束信号;达到复音上限时会截断最老声音。GalNet 应明确 overflow 作用对象与结束事件身份,而不能只存一个 track state。[Godot AudioStreamPlayer](https://docs.godotengine.org/en/stable/classes/class_audiostreamplayer.html) +- Unreal 把 source、submix 和 DSP 分开;这验证 item → track → bus → master 的处理拓扑,但不要求第一期实现 DSP。[Unreal Audio Mixer](https://dev.epicgames.com/documentation/unreal-engine/audio-mixer-overview-in-unreal-engine) +- Unity 的无间隙拼接依赖预先调度及可供准备的音频时钟。因而自然结束前的无间隙交叉淡化不能只由 Runtime 数据模型承诺,必须受后端能力约束。[Unity PlayScheduled](https://docs.unity3d.com/ScriptReference/AudioSource.PlayScheduled.html) + +## 目标与边界 + +Phase 4A 的交付是:Avalonia Sample 可播放单声部 BGM/Vocal、有限复音 SFX,且 Runtime 在无音频设备、解码失败和错误资源时安全完成剧情。项目设置只提供轨道配置;bus 是预定义的 `bgm`、`sfx`、`vocal` 路由,不提供 bus CRUD 或玩家音量设置 UI。逻辑命令、队列状态和后台事件在没有实际设备的测试 host 中可完全验证。`GameTestCase/Assets/心.mp3` 必须演示播放中淡入/淡出,以及最小轨道 SEffect 的启用和关闭。 + +4A 不承诺 sample-accurate 无间隙循环、自然结束前交叉淡化、毫秒级读档恢复、DSP/Effect、3D 空间化,或 Android/iOS/browser 的实际播放。这些能力不得通过静默退化伪装为已支持。 + +## 运行时模型 + +```text +Author entry / dialogue.textVoice / nvl.appendVoice + │ + ▼ +Audio primitive module ──► AudioRuntime(单序列化器) + │ │ queue / active voices / generation + │ ▼ + │ IAudioPlaybackHost + │ │ resolve and own playable source + ▼ ▼ +non-blocking completion backend players ──► backend events + │ │ + └──────────────┘ + dispatch to AudioRuntime +``` + +`AudioRuntime` 是 `GalNet.Runtime` 的游戏 scope 服务。它将作者命令转换为**声部级** start/pause/resume/stop/volume 请求;宿主不得自行排队或决定下一个播放项。每一个 start 生成 `(trackId, voiceId, generation)`,事件必须带回这一完整身份。Runtime 只接收仍存在且 generation 匹配的事件,销毁 scope 时先取消 Runtime,再释放 host。 + +Core 保存可序列化的 `AudioTrackDefinition`、`AudioItemDefinition`、枚举与快照 DTO;`Presentation.Abstractions` 保存 host contract;具体资源实体化、LibVLC 对象和设备线程都停留在 Presentation/宿主。资源解析必须通过 `IAssetManager` acquire 后由宿主会话实体化临时源并持有其释放责任,不能把路径或 `MediaPlayer` 放入 Core/Runtime。 + +建议端口分成命令和事件,而非一个含糊的 `ApplyAudioCommandAsync`: + +```csharp +public interface IAudioPlaybackHost : IDisposable +{ + AudioBackendCapabilities Capabilities { get; } + Task ApplyAsync(AudioBackendRequest request, CancellationToken cancellationToken); + event Action? EventReceived; +} +``` + +`AudioBackendRequest` 只有声部级动作与稳定身份;`AudioBackendEvent` 至少为 Started、Ended、Failed。`AudioBackendCapabilities` 显式声明多声部、暂停、可查询 duration、scheduled start、seek 和 item/track `SEffect` 的支持度。不能满足的需求由 Runtime 在命令前给出诊断,并执行文档化的降级或拒绝。 + +## 队列、复音与溢出 + +原设计对 `maxVoices > 1` 的 `enqueue` 和 `next` 有歧义。采用以下规则: + +1. `enqueue` 在有空闲声部时立即启动;没有空闲声部且允许排队时才加入待播队列。Runtime 每次声部释放后 pump 一个或多个待播项。因此 SFX 在未满时会立即重叠,满时才等待。 +2. 每个轨道增加明确的 `maxQueuedItems`,并为等待中的队列溢出单独声明 `dropNewest` 或 `dropOldest`。`maxVoices` 只限制活动声部,绝不隐式限制或无限制地积累队列。 +3. `maxQueuedItems: 0` 表示不允许等待队列;此时声部已满的 `enqueue` 直接按 `overflowPolicy` 处置,初版为 `dropNewest` 与 `stopOldest`。允许排队的轨道在队列已满时使用 `queueOverflowPolicy`。track 配置和诊断必须分别显示 active-voice 与 queued-item 的策略。 +4. `next` 仅对 `maxVoices == 1` 有作者语义:停止唯一活动项并 pump 一个后继。多声部轨道使用 `stop`、`clear` 或新的播放命令;在 4A 中调用 `next` 必须产生诊断且不修改状态,避免任意挑选一个 SFX 声部。 +5. `replace` 停止全部 active voices、保留队列;`replaceAll` 还清空队列;`clear` 只清空队列;`stop(clearQueue: true)` 是两者的组合。停止可使用每个 voice 的 fade-out,但状态变更立即生效,迟到事件由 generation 忽略。 + +初始轨道为:`bgm`(1 voice、可排队)、`vocal`(1 voice、可排队)和 `sfx`(8 voices、默认 queue capacity 0)。这保留常见视觉小说的直觉:BGM/语音可排歌单,普通 SFX 不会在繁忙时延迟播放成过期声音;作者若确需排队,可新建专用单声道 track。 + +## 淡化、循环与后端降级 + +命令触发的单声道替换可以在后端能同时持有旧/新声部时做 equal-power fade;否则 4A 必须记录 `CrossfadeUnavailable` 并按“停止旧项、开始新项”降级。自然结束前启动淡出或无间隙循环额外要求 duration、预加载和 scheduled start,故不属于 4A 验收;仅在能力为真时才启用,并以契约测试证明。 + +`loopCount: infinite` 在后端支持原生循环时使用该能力。不能原生循环的 host 可从头重启并记录 `SeamlessLoopUnavailable`;它不能宣称无间隙。`fadeIn`/`fadeOut` 的曲线、范围和后继拥有切换权规则保持原设计,但仅对实际能力可达的场景生效。 + +## 作者与 Editor 接入 + +所有音频原语迁移为 `trackId`;旧 `channel/mode/times` schema 在同一变更中移除,避免编译 profile 同时接受相互冲突的模型。音频模块创建非阻塞 primitive instance 并提交给 Runtime;它不直接触碰 host。 + +`dialogue.text` 删除 `voice` 参数,`nvl.append` 保持纯文本。新增非原语 composite entries `dialogue.textVoice` 与 `nvl.appendVoice`:它们分别编译为 `audio.play` 加 `dialogue.text` 或 `nvl.append`。除文本自身的 `speaker`/`content` 与必填音频资源外,音频附加参数只有 `gain`(局部音量)和 `trackId`(默认 `vocal`)。播放使用 `replaceIfDifferent`:同一轨道当前项引用同一资源时保留播放位置与暂停状态,仅更新局部 gain;资源不同才替换当前 vocal。这样每句文本仍按原有阻塞语义推进,音频仍是非阻塞的。 + +点击推进、skip、auto、场景切换默认不停止已发出的 vocal;下一段 voice、显式 `audio.stop` 或 scope disposal 才停止它。没有音频 host 时记录一次诊断、正常展示文本。 + +`trackId` 是明确作者选择时的最高优先级。未来若项目获得稳定的角色 ID,可增加可选 speaker audio profile:`speakerId → defaultVocalTrackId`,只在 `textVoice`/`appendVoice` 未显式指定轨道时生效。它不自动为每个角色创建轨道;作者可按角色、角色组或统一 `vocal` 组织轨道,避免把角色名、玩家音量偏好和轨道路由永久绑定。 + +Editor 将新增一个可注入的 project-context option provider。它在编辑时读取项目 audio track definitions,给 `trackId` 提供选择项;核心编译器仍只验证字符串、唯一轨道表和资源类型,因而不依赖 Editor。删除或重命名轨道须列出 entry、Effect 和玩家偏好引用;重命名操作以原子重构更新引用。 + +## 设置、存档与迁移 + +本期有效增益为 `trackDefaultGain × itemGain`;bus/master/player-track 音量和 UI 不在范围内,静音也不通过 stop 实现。bus 字段已经是轨道定义的一部分,但只允许三个内置 bus,bus 配置与玩家音量设置留给后续 feature。 + +本 feature 不考虑向后兼容:旧 `channel/mode/times` authoring 或已编译内容直接被当前 profile/format 拒绝,既有 `GameSettings` 音量字段不迁移。存档恢复、播放器设置和 Effect 不属于首期交付;后续恢复若实现,默认 BGM 可从头恢复,SFX 与 vocal 不重播。 + +## SEffect 后处理命名 + +音频后处理使用 `SEffect` 前缀,避免与图像 `EffectDefinition`/`EffectCatalog` 混淆:`SEffectDefinition`、`SEffectCatalog`、`SEffectScope` 与 `SEffectParameterDefinition`。作者侧稳定 ID 采用 `seffect.*`,如 `seffect.lowPass`、`seffect.pan` 与 `seffect.gain`。它们描述 item → track → bus 的音频处理链。本期提供最小轨道 SEffect 的 apply/stop、生命周期和 capability 降级,并在 `GameTestCase` 演示;参数编辑器、动画与多种 DSP 留给后续 feature。 + +## 备选方案与取舍 + +1. **继续扩展 `LibVlcAudioController`**:拒绝。它按 channel 保存单 player,会让 queue、voice identity 和 Runtime 状态落在错误层。 +2. **让后端接收 track 级 queue**:拒绝。会让 Host 决定作者语义,无法以 fake host 确定性测试、序列化或安全处理迟到事件。 +3. **把多声部 SFX 伪装成 `sfx1…sfxN`**:拒绝。它把设备实现细节泄漏给作者,无法表达统一的 bus/voice policy。 +4. **第一期保证所有交叉淡化和无缝循环**:拒绝。准确调度是后端能力,不可用 Runtime 计时器替代。 + +## 风险、验收影响与实施门槛 + +必须先以 fake host 覆盖 queue pump、active/queued overflow、generation、暂停、替换、错误资源和 scope dispose。Windows LibVLC host 还须独立验证资源实体化、事件线程回投、同 track 多 player 的释放,以及设备缺失时不阻塞剧情。 + +已确认的 Phase Plan 按“轨道模型与文本 composite → Runtime + fake host → Avalonia host/Sample”拆分;每期都以可执行契约测试为退出条件。bus CRUD、玩家音量/存档、SEffect 参数编辑器/动画和角色默认轨道随后另立 feature。 diff --git a/features/F-20260916-02-audio-system/feature.md b/features/F-20260916-02-audio-system/feature.md index 5d6d8a31..45e249f4 100644 --- a/features/F-20260916-02-audio-system/feature.md +++ b/features/F-20260916-02-audio-system/feature.md @@ -2,23 +2,29 @@ id: F-20260916-02-audio-system title: GalNet 音频系统 type: feature -status: discovery +status: completed created: 2026-09-16 -updated: 2026-09-16 +updated: 2026-09-30 --- # GalNet 音频系统 +## 2026-09-30 设计复核结论 + +音频的轨道、bus、Runtime 与宿主后端分层可继续推进,但原始设计不能原样进入实现:多声部队列/`next`、有界排队、后端能力和真实原语接入点此前未形成可执行契约。复核后的建议、当前代码事实、迁移边界和已确认的产品决策见 [设计复核](design.md)。 + +本 feature 已实现首期:Avalonia 提供真实后端,项目只配置轨道,旧音频内容不做兼容;Vocal 不参与读档重播。`GameTestCase` 使用 `Assets/心.mp3` 演示播放中淡入/淡出,以及轨道 SEffect 的开启和关闭。实现范围、验证与后续项见 [实现总结](summary.md)。 + ## 原始目标 以 [音频系统设计](../../docs/design/audio-system-design.md) 为基础,审查其合理性、识别实现难点和潜在问题,并澄清需要产品或技术决策的范围;后续将其实现为 GalNet 的音频 feature。 ## 当前事实 -- 音频设计文档是设计稿,尚未成为实现契约。 -- 当前 `audio.play` / `audio.stop` / `audio.pause` / `audio.resume` / `audio.enqueue` 仅支持固定的 `channel`、字符串 `mode` 和 `times`;没有 `trackId`、播放项、队列状态或后端事件模型。 -- `IAudioView` 和 `LibVlcAudioController` 是占位式接口与适配器:后者以每 channel 一个 `MediaPlayer` 播放,`Enqueue` 直接调用 `Play`,不具备真实队列、并发 SFX、结束事件或 DSP 语义。 -- Avalonia Sample 的 `SampleMediaViews` 目前只显示“Audio unavailable”,还没有可验收的实际音频后端。 +- `ProjectSettings.AudioTracks` 提供默认 `bgm`、`sfx`、`vocal` 轨道;编辑器设置页可添加、删除和编辑轨道配置,并在保存时校验 ID、bus、增益与声部/队列上限。 +- `AudioRuntime` 持有 logical voice、queue 与 generation;`IAudioPlaybackHost` 负责实际播放并按能力声明淡化与轨道 SEffect 支持。 +- `AvaloniaAudioPlaybackHost` 通过 `IAssetManager` 实体化会话临时文件并为每个 voice 创建 LibVLC player;Sample 与 Editor Preview 均已装配此 host。 +- `dialogue.text` 是纯文本,`dialogue.textVoice` 与 `nvl.appendVoice` composite 会编译为 `audio.play(replaceIfDifferent)` 与原文本 entry。 - `GameSnapshot` 尚未保存音频状态;玩家设置目前固定为 `BgmVolume`、`SfxVolume`、`VoiceVolume`。 ## 初步合理性结论 @@ -29,7 +35,7 @@ updated: 2026-09-16 - 以不可随意编辑的 `trackId` 寻址、以资源 ID 而非文件路径传递,符合项目迁移、重构和 Runtime/Core 解耦要求。 - 将队列/播放意图交给 Runtime、将解码/设备时钟交给宿主,并用 generation 抵御迟到回调,是可测试且可恢复的职责划分。 - 明确区分作者混音、玩家偏好和单次播放增益,避免设置页覆盖作品本身的相对混音。 -- 把 item Effect 与 track Effect 的生命周期和动画能力分开,避免给短命多声部 SFX 伪造不可靠的公开句柄。 +- 把 item SEffect 与 track SEffect 的生命周期和动画能力分开,避免给短命多声部 SFX 伪造不可靠的公开句柄。 - 将空间化、精确播放位置恢复、复杂 Effect 等排出基础版本,方向正确。 ## 主要风险与难点 @@ -38,30 +44,30 @@ updated: 2026-09-16 2. **多声部的 `enqueue` 语义尚不完整。** 文档写明无活动声部时才启动入队项,但这会把同一 `sfx` 轨道串行化,和“短音效可重叠”冲突。需要明确:容量未满时 `enqueue` 是否立即启动新声部、何时才保留为待播队列,以及多声部 `next` 选中/停止哪些活动声部。 3. **后端能力是关键依赖。** 当前 LibVLC 控制器不能提供设计所需的并发 voice、准确结束事件、可取消回调、预加载/时长、淡化及实时 DSP。必须先决定基础版本使用的后端、支持的平台/格式和允许的能力降级;不能把 LibVLC 当前实现直接扩展成核心状态机。 4. **资源定位尚未落地。** 设计要求 Runtime 只传资源 ID,但当前适配器把 `assetId` 直接交给 `LibVLC Media`。需要定义宿主如何经项目资源提供器得到可播放流或 URI,以及资源打包、临时文件与释放责任。 -5. **迁移会影响公开作者内容和设置。** 既有 channel 值、`mode/times`、`text.voice`、三项固定音量设置和已有存档都需要版本化迁移/诊断规则;迁移失败时仅记录诊断还是阻止项目打开,需要明确。 +5. **不兼容变更会影响已有作者内容。** 既有 channel 值、`mode/times`、文本条目的 `voice` 参数和固定音量设置不迁移;旧 schema/format 必须明确拒绝并要求重编译。 6. **并发与生命周期需先定串行化边界。** 后端结束/失败事件可能在非引擎线程、旧 generation 或宿主释放后到达;需要明确 AudioRuntime 的拥有者、调度器、取消顺序和事件是否可重入。 7. **存档语义和内容变更存在边界。** “重启式恢复”可行,但应定义读档时轨道/资源已删除、Effect 不受支持、项目配置变化和设备不可用时的统一降级结果。 8. **交叉淡化不能只由数据模型保证。** 自然结束前启动淡出需要时长与后继资源预加载;若后端无法保证无间隙衔接,应明确定义可观察的降级行为及编辑器诊断,而不是默默改变播放曲线。 9. **Effect 不应阻塞基础可播放版本。** 4C 依赖真实 PCM/mixer/DSP 管线和通用动画目标;应确认它是本 feature 的后续 Phase,而不是 4A 的交付前置条件。 -## 范围(待确认) +## 首期范围 - 以轨道为单位的基础播放控制:播放、暂停、继续、停止、清队与播放状态。 - BGM、语音的单声部及 SFX 的多声部行为;队列、placement、循环、溢出与淡入淡出语义。 - 项目轨道定义、编辑器配置和既有内容的迁移。 - 资源 ID 定位、宿主音频端口、后端事件、诊断和资源释放。 -- 玩家音量设置迁移,以及在确认范围后加入的快照恢复和音频 Effect。 +- bus 配置、玩家音量设置和快照恢复留给后续 feature;本期实现最小轨道 SEffect 开启/关闭与示例演示。 ## 非目标(当前) -- 在 discovery 阶段修改业务代码、选择未经确认的第三方后端或创建实现 Phase Plan。 +- 在本 feature 中配置自定义 bus、迁移旧音频内容或实现玩家音量设置/存档恢复。 - 网络同步、实时录音、任意 VST/AU、精确逐样本跨设备恢复。 - 把三维空间化、ducking、`audio.wait`、`shuffleCycle` 或完整 DSP 效果链默认纳入基础播放交付。 ## 验收标准(discovery) - 明确首个可交付范围是仅 4A,还是包括 4B/4C 的哪些部分。 -- 为多声部队列、播放/停止、跨淡化和 `text.voice` 定义无歧义的作者侧语义。 +- 为多声部队列、播放/停止、跨淡化和 `dialogue.textVoice`/`nvl.appendVoice` 定义无歧义的作者侧语义。 - 决定目标平台、音频格式、基础后端与不能满足能力时的降级策略。 - 明确旧内容、玩家设置和存档的迁移与失败策略。 - 确定 Runtime、宿主后端和编辑器之间的资源定位、线程与生命周期边界,之后才进入设计阶段。 @@ -73,21 +79,17 @@ updated: 2026-09-16 - 首版普通 `audio.play` 保持非阻塞;等待声音结束必须是另行明确的能力。 - 当前实现与既有未提交的 workflow 文档均为工作区内容;本 feature 不覆盖它们。 -## 开放问题 +## 已决策的后续边界 -1. 第一个实施目标应只交付 Phase 4A,还是必须在同一 feature 内完成存档/设置(4B)和 Effect(4C)? -2. 目标平台、最低支持的音频格式、是否允许新增音频依赖,以及是否必须继续使用 LibVLC,分别是什么? -3. 对 `maxVoices > 1` 的轨道,`enqueue` 在尚有容量时应立即并发播放,还是始终只排队?`next` 和 `replace` 分别作用于哪些 active voices? -4. 旧项目内容和存档是否必须自动迁移?对于无法映射的 channel、删除的轨道或丢失资源,应该阻止加载、要求作者修复,还是记录诊断后安全跳过? -5. `text.voice` 在点击推进、自动播放、快速模式、场景切换和读档恢复时的默认停止/继续策略是什么? -6. “无间隙循环”和 natural-end 交叉淡化是基础版本硬性验收,还是允许在不支持的后端降级并给出诊断? -7. 4C 的实时 Effect 与通用动画是否属于此 feature 的必须交付,还是待基础后端验证后独立排期? +已确认的设计决策记录于 [设计复核](design.md) 与 [实施计划](phase-plan.md)。角色默认语音轨道将在未来角色 profile feature 中设计,不阻塞本期。 ## 相关链接 +- [设计复核](design.md) +- [实施计划](phase-plan.md) - [音频系统设计](../../docs/design/audio-system-design.md) -- [Avalonia 游戏页面与内容对接计划:Phase 4](../../docs/design/avalonia-game-page-integration-plan.md) -- [当前音频条目类型](../../src/GalNet.Core/Entry/MediaEntries.cs) -- [当前呈现端口](../../src/GalNet.Presentation.Abstractions/View/IAudioView.cs) -- [当前 LibVLC 适配器](../../src/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs) -- [当前快照模型](../../src/GalNet.Core/Runtime/GameSnapshot.cs) +- [引擎使用手册](../../docs/engine.md) +- [当前音频条目类型](../../src/Shared/GalNet.Primitives.Builtins/Entry/MediaEntries.cs) +- [当前 Builtin 音频模块](../../src/Shared/GalNet.Primitives.Builtins/BuiltinEntryModules.cs) +- [当前 LibVLC 适配器](../../src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs) +- [当前快照模型](../../src/Shared/GalNet.Core/Runtime/GameSnapshot.cs) diff --git a/features/F-20260916-02-audio-system/phase-plan.md b/features/F-20260916-02-audio-system/phase-plan.md new file mode 100644 index 00000000..529798fe --- /dev/null +++ b/features/F-20260916-02-audio-system/phase-plan.md @@ -0,0 +1,95 @@ +--- +feature: F-20260916-02-audio-system +status: planned +created: 2026-09-30 +updated: 2026-09-30 +--- + +# GalNet 音频系统实施计划 + +## 已确认范围 + +本计划仅交付可配置的逻辑轨道、Runtime 音频状态机、Avalonia 实际播放 host、最小轨道 SEffect,以及新的带语音文本 composite entries。bus 是固定的 `bgm`、`sfx`、`vocal`,轨道可选择其一;本期不做 bus CRUD、玩家音量/设置、存档恢复或角色 profile。旧 audio schema 和已编译内容不兼容,直接拒绝,不实现迁移器。 + +## Phase 1 — 轨道模型与作者入口 + +**状态:completed(静态 schema 的 trackId 仍为文本字段;未实现 project-context 下拉)** + +目标:将项目设置、authoring schema 和编译输出切换到唯一的新音频模型,并消除对 `IDialoguePresenter.SetVoice` 的依赖。 + +前置条件:无。 + +涉及模块:`GalNet.Core`(`ProjectSettings` 与 audio DTO)、`GalNet.Primitives.Builtins`、`GalNet.Editor` / `GalNet.Editor.Shared`、编译器及其测试。 + +工作: + +- 定义 `AudioTrackDefinition` 和固定三条默认轨道;校验唯一不可变 ID、内置 bus、voice/queue 上限、增益与溢出策略。 +- 在项目设置页实现轨道 CRUD、轨道引用校验和安全重命名;不添加 bus CRUD。 +- 将音频 primitive schema 改为 `trackId`、资源、局部 `gain` 与 placement,并新增 `replaceIfDifferent`;移除旧 `channel/mode/times` schema 与 `SfxChannelCount` 的音频职责。 +- 将 `dialogue.text` 变为纯文本;新增 `dialogue.textVoice` 和 `nvl.appendVoice` composites。它们产生非阻塞 `audio.play` 后紧接原有文本 primitive,默认 `trackId: vocal`,并在同资源时不重启播放。 +- 移除 `IDialoguePresenter.SetVoice`、Headless/Avalonia 的占位实现及所有旧入口;让 target profile 和 compiler 拒绝旧格式。 +- 扩展 Editor 的 project-context option provider,使 `trackId` 使用当前项目轨道下拉,不将项目数据写入静态 primitive schema。 + +验证:ProjectSettings JSON round-trip、轨道唯一性/引用验证、Editor CRUD/重命名测试、composite source-map/编译测试、旧 schema 拒绝测试、`textVoice`/`appendVoice` 输出顺序和参数测试。 + +风险:现有静态 Select 选项不能表示项目轨道;先引入通用上下文 option provider,禁止为 audio 再建一条专用 Editor 分支。 + +退出条件:新项目自动拥有三条轨道,Editor 可安全维护它们;已编译内容中没有旧 channel 参数或 presenter voice 调用。 + +## Phase 2 — Runtime 状态机与无设备契约 + +**状态:completed** + +目标:实现平台无关的队列、voice identity、淡化意图和错误降级,并在 fake host 中确定性验证。 + +前置条件:Phase 1 的新 primitive/composite schema 已通过编译测试。 + +涉及模块:`GalNet.Runtime`、`GalNet.Presentation.Abstractions`、`GalNet.Primitives.Builtins`、Runtime/Integration tests。 + +工作: + +- 建立 Core audio DTO 与 `AudioRuntime`;每个 start 分配 `(trackId, voiceId, generation)`,由游戏 scope 串行化命令和后台事件。 +- 定义 `IAudioPlaybackHost`、声部级 request/event、capabilities 和会话生命周期。Host 解析与持有可播放资源;Runtime 绝不接触路径、LibVLC 或平台对象。 +- 实现单声部 BGM/Vocal、有限复音 SFX、queue pump、`maxQueuedItems`、active/queued overflow、暂停、恢复、停止、清队、`next`(仅单声部)、`replace`、`replaceAll`、`replaceIfDifferent`。 +- 保留 item fade-in/fade-out 与后继接管的逻辑定义;后端没有并发/调度能力时,Runtime 生成可观察诊断并采用停止后开始的降级,而不声称无间隙或自然结束交叉淡化。 +- 通过 non-blocking primitive instance 将 audio command 投递给 Runtime;未知轨道、非音频资源、解码失败、无 host 和过期事件都安全完成剧情。 +- 提供最小 `SEffectDefinition`/catalog 和 `audio.seffect.apply`/`audio.seffect.stop` 原语;本期只要求轨道 attachment 的生命周期和 capability 降级,具体 DSP 由 host 决定。 +- 提供 fake host,以可控 Started/Ended/Failed 事件、能力矩阵和资源错误支持单元/集成测试。 + +验证:队列与 voice 溢出矩阵、同资源 `replaceIfDifferent` 不重播且更新 gain、迟到 generation、scope dispose、暂停/恢复、缺少 host/资源、淡化能力降级、SEffect apply/stop 生命周期,以及带语音文本不会改变文本阻塞/skip 语义。 + +风险:不得让 Host 排队或直接从回调线程修改 Engine 状态;事件身份缺少 `voiceId` 或 generation 时必须拒绝该实现。 + +退出条件:在不含 LibVLC 的测试进程中,所有作者侧音频语义和诊断都可重复验证。 + +## Phase 3 — Avalonia 播放 host 与示例验收 + +**状态:completed(已完成编译/集成验证;实际听感仍需目标设备人工验收)** + +目标:让 Avalonia 游戏 scope 将 Runtime 声部请求转为实际 LibVLC 播放,并验证资源与生命周期边界。 + +前置条件:Phase 2 fake-host 契约已通过;Avalonia 可取得项目 scope 的 `IAssetManager`/资源会话。 + +涉及模块:`GalNet.Avalonia.GameView`、`GalNet.Presentation.Defaults`(仅在抽象可复用时)、Avalonia Sample/Editor composition、手动 smoke test。 + +工作: + +- 实现 Avalonia `IAudioPlaybackHost`:由资源 handle 安全实体化会话临时播放源,按 `voiceId` 管理 player,回传完整 backend event,并在 scope dispose 时停止、解除订阅和释放临时资源。 +- 初始化 LibVLC 的失败路径、设备不可用路径和线程回投;仅声明已被契约测试证实的 capability。 +- 将 host 装配进 Avalonia game scope;`GameTestCase/Assets/心.mp3` 提供可复现的 BGM 播放中淡入/淡出、轨道 SEffect apply/stop、重叠 SFX、同资源不重播 vocal 与错误资源诊断场景。 +- 将 Headless 保持为 fake/no-op diagnostics,不为无音频环境添加隐式平台依赖。 + +验证:Avalonia 自动化/集成测试覆盖 composition 与释放;Windows 手动验收 BGM、SFX、Vocal、暂停/继续、页面离开和无设备降级。验证同轨多 player 不泄漏,且迟到 LibVLC 事件不影响新 voice。 + +风险:LibVLC 的 duration、精确调度或 simultaneous fade 并非默认可保证;未验证即把 capability 设为 false,保留文档化降级。 + +退出条件:Avalonia Sample 在支持设备上完成三类播放,在错误或无设备环境中继续剧情并报告诊断;资源和 player 生命周期无泄漏证据。 + +## 延后事项 + +- bus CRUD、玩家 master/bus/track 音量与存档恢复; +- SEffect 参数编辑器、动画和额外 DSP; +- 角色 profile 的 `speakerId → defaultVocalTrackId` 默认路由; +- 精确 seek、无间隙循环、自然结束交叉淡化、ducking 与 `audio.wait`。 + +每项均应以独立 feature 重新确认后端能力和存档/作者语义,不能借本期接口暗中实现。 diff --git a/features/F-20260916-02-audio-system/summary.md b/features/F-20260916-02-audio-system/summary.md new file mode 100644 index 00000000..099ce2b8 --- /dev/null +++ b/features/F-20260916-02-audio-system/summary.md @@ -0,0 +1,31 @@ +--- +feature: F-20260916-02-audio-system +status: completed +completed: 2026-09-30 +--- + +# 音频系统首期实现总结 + +## 交付 + +- `ProjectSettings.AudioTracks` 定义默认 `bgm`、`sfx`、`vocal` 轨道;编辑器可增删改,并在保存时校验轨道 ID、固定 bus、增益、声部与队列限制。 +- `AudioRuntime` 拥有 track queue、voice generation、放置策略、暂停/继续、清队、淡化意图及轨道 SEffect 生命周期;`IAudioPlaybackHost` 是平台端口。 +- Avalonia host 以 `IAssetManager` 获取资源并在会话临时目录实体化,按 voice 管理 LibVLC player,支持线性淡化和 `seffect.gain`。 +- `dialogue.text` 与 `nvl.append` 保持纯文本;`dialogue.textVoice`、`nvl.appendVoice` 编译为非阻塞的 `audio.play(replaceIfDifferent)` 后接原文本。 +- `GameTestCase` 使用 `Assets/心.mp3` 演示 BGM 淡入、`seffect.gain` 启停与中途淡出。 + +## 验证 + +- `dotnet test test/GalNet.Runtime.Tests/GalNet.Runtime.Tests.csproj --no-restore`:34 通过。 +- `dotnet test test/GalNet.Primitives.Builtins.Tests/GalNet.Primitives.Builtins.Tests.csproj --no-restore`:43 通过。 +- `dotnet test test/GalNet.IntegrationTests/GalNet.IntegrationTests.csproj --no-restore`:33 通过。 +- `dotnet build src/Editor/GalNet.Editor/GalNet.Editor.csproj --no-restore` 与 Avalonia Sample build 通过。 +- Headless 编译 `GameTestCase` 成功,生成四个 `.galgroup`。 + +## 已知边界 + +- 轨道选项尚未成为 entry 编辑器的项目上下文下拉;`trackId` 目前是文本字段,运行时会诊断未知轨道。 +- 本期只实现 host 可验证的 `seffect.gain`;SEffect catalog、参数编辑器、动画及其他 DSP 后续另立 feature。 +- 不实现 bus CRUD、玩家音量设置、音频存档恢复、角色默认 vocal track、seek、自然结尾交叉淡化或无缝循环。 + +正式长期设计见 [音频系统设计](../../docs/design/audio-system-design.md),过程设计见 [design.md](design.md) 与 [phase-plan.md](phase-plan.md)。 diff --git a/features/F-20260922-01-entry-instance-runtime/feature.md b/features/F-20260922-01-entry-instance-runtime/feature.md index 6c1fd6a3..c0d89738 100644 --- a/features/F-20260922-01-entry-instance-runtime/feature.md +++ b/features/F-20260922-01-entry-instance-runtime/feature.md @@ -91,6 +91,6 @@ updated: 2026-09-26 - [设计](design.md) - [实施计划](phase-plan.md) -- `docs/design/primitive-module-runtime-design.md` -- `docs/design/primitive-module-runtime-phase-plan.md` +- `docs/engine.md` +- `docs/development.md` - `docs/spec/entry-types.md` diff --git a/features/F-20260922-01-entry-instance-runtime/phase-plan.md b/features/F-20260922-01-entry-instance-runtime/phase-plan.md index 6fbe4a2e..665f96b9 100644 --- a/features/F-20260922-01-entry-instance-runtime/phase-plan.md +++ b/features/F-20260922-01-entry-instance-runtime/phase-plan.md @@ -98,7 +98,7 @@ 1. 迁移 layer、animation、effect、particle、flow、variable、gallery 及现有媒体模块;animation plan 整体作为一个 instance,不迁移通用嵌套 Entry 分发。 2. 长期行为使用可序列化 Handle,启动实例及时完成。 3. 删除 Avalonia 页面旧活动动画批次决策和 Runtime Handler 残留。 -4. 同步 `primitive-module-runtime-design.md`、长期 phase plan、runtime 和 entry spec。 +4. 同步运行时和 entry 的正式规范,并更新当时的长期计划。 **测试:** 解决方案构建、全量单元测试、Editor/Headless 组合根测试及关键运行路径冒烟。 @@ -110,7 +110,7 @@ **计划变更(2026-09-24,设计已确认):** 已实现的 `gallery.unlock` 仍临时通过 `IGameProgressService` 写入玩家级进度;Gallery 的最终方向已确认为内置、平台无关的数据层,而不是通用功能模块。Gallery 类型只登记 `(typeId, resourceTypeName)`,item 使用稳定字符串 ID 并生成 Player bool,Avalonia 自主消费类型、资源和解锁状态。完整 Gallery 产品切片移到 Phase 5-8;Phase 4 只负责确认现有 primitive 已进入新 instance 执行路径。Audio、video 与 particle 的剧情 primitive 产品行为仍等待各自范围确认。 -**验证证据(2026-09-22):** `GeneralTest` 全量 219 项通过;新增测试覆盖 animation/effect runtime state、presenter 调用、plan final state 和 `flow.wait` skip。受限环境中构建/测试需设置 `AVALONIA_TELEMETRY_OPTOUT=1` 并传入 `-p:UseSharedCompilation=false`。文档已重写 `docs/spec/runtime.md`、`docs/spec/entry-types.md`、`docs/spec/architecture.md`、`docs/glossary.md`、`docs/design/primitive-module-runtime-design.md` 和长期 phase plan。 +**验证证据(2026-09-22):** `GeneralTest` 全量 219 项通过;新增测试覆盖 animation/effect runtime state、presenter 调用、plan final state 和 `flow.wait` skip。受限环境中构建/测试需设置 `AVALONIA_TELEMETRY_OPTOUT=1` 并传入 `-p:UseSharedCompilation=false`。当时已更新运行时、条目、架构和术语规范,以及阶段计划;阶段计划后来收敛并删除,当前事实见正式规范。 ## Phase 5:Gallery 数据契约与内容加载 diff --git a/features/F-20260922-01-entry-instance-runtime/review.md b/features/F-20260922-01-entry-instance-runtime/review.md index 965c2ec5..46629ab4 100644 --- a/features/F-20260922-01-entry-instance-runtime/review.md +++ b/features/F-20260922-01-entry-instance-runtime/review.md @@ -41,7 +41,7 @@ ## P2:历史服务分析文档仍提及旧执行模型 -`docs/design/services-analysis.md` 仍描述 `EntryHandler` 和 `GameEngine.StepAsync`。它不作为本 feature 的实现依据,但在 Phase 4 收尾时应更新或标记为历史,避免读者重新引入已移除的术语。 +当时的旧服务分析仍描述 `EntryHandler` 和 `GameEngine.StepAsync`。它不作为本 feature 的实现依据;该历史文档后来已删除,以免读者重新引入已移除的术语。 ## 不构成问题的项 diff --git a/features/F-20260929-01-compiled-content-pipeline/feature.md b/features/F-20260929-01-compiled-content-pipeline/feature.md index 64fe8ca6..64b5c49c 100644 --- a/features/F-20260929-01-compiled-content-pipeline/feature.md +++ b/features/F-20260929-01-compiled-content-pipeline/feature.md @@ -62,7 +62,7 @@ updated: 2026-09-29 - [实施计划](phase-plan.md) - [文件格式规范](../../docs/spec/file-formats.md) - [Runtime 规范](../../docs/spec/runtime.md) -- [运行时与呈现计划](../../docs/design/runtime-presentation-decoupling-phase-plan.md) +- [运行时参考](../../docs/spec/runtime.md) - [渲染/粒子计划](../../docs/design/render-effects-pipeline-plan.md) - [GalgroupCompiler](../../src/GalNet.Core/Compilation/GalgroupCompiler.cs) - [Headless CLI](../../src/GalNet.Editor.Headless/Program.cs) diff --git a/features/F-20260929-03-maintainability-roadmap/feature.md b/features/F-20260929-03-maintainability-roadmap/feature.md index 47c0ee82..2279dc4c 100644 --- a/features/F-20260929-03-maintainability-roadmap/feature.md +++ b/features/F-20260929-03-maintainability-roadmap/feature.md @@ -73,6 +73,6 @@ updated: 2026-09-29 - [设计](design.md) - [实施计划](phase-plan.md) - [当前架构](../../docs/spec/architecture.md) -- [运行时与呈现解耦计划](../../docs/design/runtime-presentation-decoupling-phase-plan.md) +- [架构](../../docs/spec/architecture.md) - [Feature 工作流](../../docs/design/feature-workflow.md) - [杂项待办](../../docs/design/misc-todo.md) 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..c6076046 --- /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) +- [运行时参考](../../docs/spec/runtime.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/features/F-20261009-01-track-seffects/design.md b/features/F-20261009-01-track-seffects/design.md new file mode 100644 index 00000000..d9b0afeb --- /dev/null +++ b/features/F-20261009-01-track-seffects/design.md @@ -0,0 +1,15 @@ +--- +feature: F-20261009-01-track-seffects +status: confirmed +created: 2026-10-09 +--- + +# 设计 + +`SEffectCatalog` 位于 Core,只保存稳定 ID、支持 scope 和参数边界。平台 host 通过 `IAudioSEffectProcessor` 注册实现,registry 同时构造 catalog;Core/Runtime 不认识 LibVLC、频段或设备。 + +当前只实现 `Track` scope。Runtime 在 `audio.seffect.apply` 时查询 host catalog 并校验 JSON 参数,失败即诊断并不保存 attachment;stop 保持幂等。 + +Avalonia 的 processor 聚合一个 voice 的所有 active attachment:`gain` 乘入 output gain,低/高通向 LibVLC equalizer 频段贡献负 dB,贡献相加后被 LibVLC 的 [-20, 20] dB 范围限制。LibVLC 可在播放中应用 equalizer,但它不是参数化双二阶滤波器,因此效果是频段近似;descriptor 不声明 resonance。 + +注册只在 host 创建期间进行,避免播放时并发改变 capability;未来平台可提供自己的 registry 和同 ID 的更精确 processor。作者 ID 不允许提交任意 DSP 文本。 diff --git a/features/F-20261009-01-track-seffects/feature.md b/features/F-20261009-01-track-seffects/feature.md new file mode 100644 index 00000000..1d572eae --- /dev/null +++ b/features/F-20261009-01-track-seffects/feature.md @@ -0,0 +1,31 @@ +--- +id: F-20261009-01-track-seffects +title: 可注册的轨道 SEffect +type: feature +status: completed +created: 2026-10-09 +--- + +# 可注册的轨道 SEffect + +## 目标 + +为已交付的音频轨道扩展可注册的 SEffect descriptor 与 Avalonia processor registry,并交付实际可听的 `seffect.lowPass`、`seffect.highPass`。未知或不受宿主支持的 effect 必须在 Runtime 诊断并保持干声,而不是被静默忽略。 + +## 范围 + +- Core 定义可校验的 `SEffectDefinition` / `SEffectCatalog`。 +- `IAudioPlaybackHost` 暴露自己已注册的 catalog;Runtime 在附着前验证 ID、scope 和参数。 +- Avalonia host 以 processor registry 注册 `gain`、低通和高通;低/高通使用 LibVLC equalizer 的频段近似。 +- 加入 catalog、Runtime 拒绝与 processor 聚合测试。 + +## 非目标 + +- item scope、参数动画、编辑器参数表单、bus/master DSP、任意插件 DLL 或 VST/AU。 +- 逐样本的滤波、resonance、pan、pitch、spatial、存档恢复与音频后端替换。 + +## 验收 + +- 宿主可按构造期注册 processor;catalog 由注册项动态形成,重复 ID 失败。 +- 已注册的低/高通能在播放中应用与移除;多个 effect 的 gain/EQ 贡献重新聚合。 +- 不支持或参数非法的 effect 不进入轨道状态,且产生诊断。 diff --git a/features/F-20261009-01-track-seffects/phase-plan.md b/features/F-20261009-01-track-seffects/phase-plan.md new file mode 100644 index 00000000..21ae0247 --- /dev/null +++ b/features/F-20261009-01-track-seffects/phase-plan.md @@ -0,0 +1,23 @@ +--- +feature: F-20261009-01-track-seffects +status: completed +created: 2026-10-09 +--- + +# 实施计划 + +## Phase 1 — descriptor 与 Runtime 防护 + +**状态:completed** + +建立 catalog、host capability 连接与参数校验;覆盖注册、未知 ID、错误参数和合法 attachment。 + +退出条件:Runtime 绝不向 host 发送未注册或不合法的 apply request。 + +## Phase 2 — Avalonia processors + +**状态:completed** + +实现 registry、gain、低通和高通 processor,按 voice 重算 output gain/equalizer,覆盖 apply/stop 聚合。 + +退出条件:已达成。Core、Runtime 和 Presentation 测试通过;Headless 成功编译 GameTestCase。 diff --git a/features/F-20261009-01-track-seffects/summary.md b/features/F-20261009-01-track-seffects/summary.md new file mode 100644 index 00000000..2d0febaa --- /dev/null +++ b/features/F-20261009-01-track-seffects/summary.md @@ -0,0 +1,30 @@ +--- +feature: F-20261009-01-track-seffects +status: completed +completed: 2026-10-09 +--- + +# 实现总结 + +## 交付 + +- Core 增加不可变 `SEffectCatalog`,校验 stable ID、scope、必填数值和参数范围。 +- `IAudioPlaybackHost` 暴露其注册 catalog;`AudioRuntime` 拒绝未知或无效轨道 effect,避免旧实现的 host 静默忽略。 +- Avalonia host 增加构造期 processor registry。默认处理器为 `seffect.gain`、`seffect.lowPass` 和 `seffect.highPass`,也可注入平台/项目专用 processor。 +- 低/高通将各 attachment 的衰减叠加到 LibVLC equalizer 的频段;移除 attachment 后重新聚合。淡化同时保持 effect gain。 +- `GameTestCase` BGM 增加低通、高通的 apply/stop 可听示例。 + +## 验证 + +- `dotnet test test/GalNet.Core.Tests/GalNet.Core.Tests.csproj --no-restore`:57 passed。 +- `dotnet test test/GalNet.Runtime.Tests/GalNet.Runtime.Tests.csproj --no-restore`:35 passed。 +- `dotnet test test/GalNet.Presentation.Tests/GalNet.Presentation.Tests.csproj --no-restore`:73 passed。 +- `dotnet run --project src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj --no-restore -- build GameTestCase --output artifacts/audio-seffects-build`:成功,4 个 group。 + +## 边界 + +- LibVLC 的 equalizer 是有限频段近似,不是带 resonance 的逐样本低/高通。 +- registry 在 host 创建时固定;它支持按平台组合注册,不支持剧情运行中加载任意 DLL/DSP。 +- 仅 track scope;item scope、参数动画、编辑器动态表单、pan/pitch/spatial、bus/master DSP 和存档恢复仍未实现。 + +正式事实已同步至 [音频系统设计](../../docs/design/audio-system-design.md)、[Runtime 规范](../../docs/spec/runtime.md) 与 [Entry 类型](../../docs/spec/entry-types.md)。 diff --git a/features/F-20261009-02-video-entries/design.md b/features/F-20261009-02-video-entries/design.md new file mode 100644 index 00000000..dd83dd48 --- /dev/null +++ b/features/F-20261009-02-video-entries/design.md @@ -0,0 +1,17 @@ +# 视频图层设计 + +用户已授权实现。视频采用真正的场景 Layer:解码帧进入 Skia 场景合成,因此排序、变换、透明度、Layer effect 和 ScenePost 沿用现有管线。独立原生 VideoView 无法参与同一纹理链,不采用该方案。 + +`video.play` 接收 `asset`、`handleId`、`blocking`(默认 true)、`skipPoints`(秒,严格递增非负有限数组),以及现有图层的 `transform`、`z`、`opacity`、`displayMode`。不同 handle 可独立播放;同 handle 替换。`video.stop(handleId)` 停止并移除对应视频。结束、失败和取消均清理;不保留末帧。视频原声由视频后端直接播放,首期不加入 AudioRuntime 轨道。 + +平台无关 `IVideoPresenter` 提供播放到结束的 Task、分段推进和按 handle 停止。每次播放携带独立 playback ID,防止旧播放的异步结束清理新图层。Builtin instance 先登记 Runtime Layer,再启动 presenter;分段跳转不完成阻塞实例。无 presenter 的无界面宿主安全完成;实际播放失败产生诊断。 + +Avalonia 宿主通过 AssetManager 获取视频字节并实体化到独立临时目录。LibVLC 使用格式和像素回调,按媒体实际尺寸生成帧。发布帧与渲染读取通过租约同步,避免 GPU/截图读取已释放像素;回调不操作 UI,也不在 LibVLC 事件线程停止或销毁播放器。UI 帧失效通知合并,防止逐帧消息积压。 + +左键从实际播放位置与最后请求跳转位置的较大值寻找下一个点,避免连续点击在 seek 尚未生效时重复跳到同一点。没有下一个,或目标达到/超过已知时长时停止。加载未就绪时不执行 seek;无法 seek 的后端必须诊断并退出,不能永久阻塞。 + +Layer 仅持久化视频源标记和资源/图层定义,不保存解码器或游标。阻塞期间继续返回之前稳定快照;非阻塞视频随稳定场景保存,读档从零恢复。`layer.hide`、`layer.show` 和 `layer.replace` 对视频源执行释放;旧播放不得移除替换后的图层。 + +兼容性:保留现有 `asset` 参数;新增 `handleId` 默认 `video`,旧单视频脚本仍可编译。停止默认同一 handle。新增视频状态缺省 false,不影响旧快照。 + +验证覆盖推进边界、自然结束、失败/取消、连续 seek、多个 handle、替换竞争、动态图层排序/透明度/效果、快照与恢复,以及 `Assets/niko.mp4` 的真实解码 smoke。性能限制:首期 CPU 解码回调上传纹理,不承诺零拷贝或任意数量高清视频。 diff --git a/features/F-20261009-02-video-entries/feature.md b/features/F-20261009-02-video-entries/feature.md new file mode 100644 index 00000000..6b51e47a --- /dev/null +++ b/features/F-20261009-02-video-entries/feature.md @@ -0,0 +1,58 @@ +--- +id: F-20261009-02-video-entries +title: 剧情视频播放与分段跳过 entry +type: feature +status: validation-blocked +created: 2026-10-09 +--- + +# 剧情视频播放与分段跳过 entry + +## 用户原始目标 + +“设计视频相关的 entry,大概就开始播放、结束播放,然后开始播放有个列表参数,如果阻塞,那么根据列表的时间戳每次左键跳转到下一个,如果下一个不存在或超过视频时长就结束播放,这么设计如何。” + +## 已确认需求与授权 + +- 用户确认按当前播放位置寻找严格更大的下一时间戳。 +- 用户确认视频采用真正场景图层的方向,并授权开始实现;提供 `GameTestCase/Assets/niko.mp4` 用于验证。 + +## 实施范围与默认决定 + +- `video.play` / `video.stop` 具有实际 Runtime 行为,按 `handleId` 管理独立视频图层。 +- `video.play` 接收 `asset`、`blocking`、`skipPoints` 和现有 Layer 展示参数。 +- 默认 `handleId=video`、`blocking=true`、`skipPoints=[]`;时间单位秒,要求非负、有限、严格递增。 +- 左键从实际位置与上次请求 seek 位置的较大值向后寻找下一个点。无下一个点、目标达到/超过时长时结束;加载未就绪时不执行 seek。 +- 不同 handle 可同时播放,同 handle 替换;自然结束、stop、取消、失败或隐藏/替换源均释放播放资源并移除对应视频源,不保留末帧。 +- 非阻塞视频不消费推进输入,保持普通图层动画、排序、变换、透明度与效果语义。 +- 阻塞播放沿用此前稳定存档;非阻塞视频保存图层定义,读档从零播放,不保存实时游标。 +- 上述默认值和结束策略为授权后的实现决定,用户尚未逐项确认;后续可按反馈修订。 + +## 非目标 + +- 暂停/恢复/循环、保留末帧、字幕编辑、视频编码、Gallery 改造。 +- 视频原声整合 AudioRuntime 轨道、零拷贝解码与所有平台的原生媒体实现。 + +## 验收标准 + +- 视频帧进入场景图层合成,参与排序、变换、透明度和纹理效果。 +- 分段跳转保持阻塞;自然结束或停止后继续剧情。 +- 多 handle 与替换生命周期互不误清理,旧播放完成不能移除新图层。 +- 加载期间隐藏/替换会取消加载,不会在稍后重新覆盖画面。 +- 存档和恢复保持上述稳定语义。 +- 测试视频实际解码并输出可检查的场景帧;Raw 示例可编译,Headless 可运行至结尾。 + +## 约束 + +Core/Runtime 不依赖 Avalonia、LibVLC 或路径;平台从 `IAssetManager` 获取媒体。参数使用 JSON 类型。视频属于现有 Layer 类型,平台解码资源与 playback identity 不写入存档。 + +## 相关链接 + +- [提交前复查](review.md) +- [实现总结与既有架构门禁阻塞](summary.md) +- [设计](design.md) +- [阶段计划](phase-plan.md) +- [Entry 格式与推荐 ID](../../docs/spec/entry-types.md) +- [Runtime](../../docs/spec/runtime.md) +- [展示边界](../../docs/spec/control.md) +- [渲染效果长期计划](../../docs/design/render-effects-pipeline-plan.md) diff --git a/features/F-20261009-02-video-entries/phase-plan.md b/features/F-20261009-02-video-entries/phase-plan.md new file mode 100644 index 00000000..71b39c7c --- /dev/null +++ b/features/F-20261009-02-video-entries/phase-plan.md @@ -0,0 +1,23 @@ +# 实施阶段 + +## Phase 1:契约与运行时(verified) + +前置:用户确认推进规则并授权实现。定义视频 presenter、entry 参数、Layer 视频源状态与实例生命周期。验证阻塞分段跳过、停止/替换和稳定快照。退出:Builtin/Runtime 测试通过。 + +证据:Builtins 51、Runtime 35 项通过;Integration 的视频测试确认连续推进仍阻塞并保存此前快照,自然结束自动继续。 + +## Phase 2:解码与场景合成(verified) + +依赖 Phase 1。接入 LibVLC 像素回调、帧租约和现有图层生命周期,组合到 Sample。验证视频像素进入同一排序/变换/效果管线与资源清理。退出:Presentation 测试与 Sample 构建通过,真实 niko.mp4 解码有证据。 + +证据:Sample 编译成功;Presentation 77 项通过;4 项 opt-in native smoke 通过,实际解码 1280×738 帧并输出场景 PNG,覆盖连续 seek、自然结束、加载期 hide/replace。 + +## Phase 3:样例与文档(verified) + +依赖 Phase 2。添加视频 meta 和 Raw 样例,编译内容、运行 Headless,同步 spec 和 feature 总结。退出:编译内容与无界面 smoke 通过;记录可用验证、剩余限制,不把未执行的人工观看宣称通过。 + +证据:Editor CLI 编译 4 个 Raw group 成功;提供控制台输入后 Headless 运行至结尾且 exit 0;本次修改文件的 whitespace 和 IDE0005 检查通过。Core 57、Integration 34 项通过。 + +## 整体收尾状态 + +视频功能实现和以上阶段已验证,但架构门禁存在既有失败(24 通过、1 失败):Builtins 项目在 HEAD 已引用 Runtime,当前允许依赖白名单尚未列入 Runtime。未修改白名单或既有音频结构;feature 保持 `validation-blocked`,不宣称所有门禁通过。详情见 [summary.md](summary.md)。 diff --git a/features/F-20261009-02-video-entries/review.md b/features/F-20261009-02-video-entries/review.md new file mode 100644 index 00000000..f74ceccf --- /dev/null +++ b/features/F-20261009-02-video-entries/review.md @@ -0,0 +1,22 @@ +# 提交前复查 + +日期:2026-10-10。结论:`pass-with-follow-up`。 + +复查 feature 需求、设计、阶段计划、实际 diff,以及平台宿主、运行时实例、图层清理和测试。没有发现阻止视频功能提交的 P1/P2 问题。用户本轮反馈测试通过并授权整理、提交。 + +## 已检查 + +- 阻塞推进保持等待,结束后继续剧情;seek 同时考虑实际位置与最后请求位置。 +- Runtime Layer 与平台帧源分层,资源获取不绕过 AssetManager。 +- 加载取消、同 handle 替换、旧播放完成和帧读取生命周期有对应回归证据。 +- 稳定快照复制视频定义,播放 identity 与游标不入存档;恢复从零播放。 +- 单元、集成、真实媒体解码、示例编译和 Headless 证据见 [summary.md](summary.md)。没有代码行为变更,不重复已通过的测试。 +- 本轮 `git diff --check` 通过;之前 whitespace 与 IDE0005 检查通过。 + +## 后续事项 + +既有 Architecture 白名单没有允许 Builtins → Runtime,但 HEAD 中的音频实现已经使用该依赖。这项基线失败需单独协调,保留 feature 的 `validation-blocked` 状态;它不是本次视频引入的依赖,也不阻止提交已验证的视频功能。 + +CPU 解码上传、Windows 以外平台尚未实测、视频原声尚未接入音频轨道均为已记录限制。首期范围内不需要额外修改。 + +提交范围仅含视频实现、测试、用户提供的测试视频及 metadata、示例与文档;不包含已暂存的 ADR 删除。 diff --git a/features/F-20261009-02-video-entries/summary.md b/features/F-20261009-02-video-entries/summary.md new file mode 100644 index 00000000..103b2311 --- /dev/null +++ b/features/F-20261009-02-video-entries/summary.md @@ -0,0 +1,56 @@ +# 视频图层实现总结 + +完成日期:2026-10-10。视频功能已实现并通过针对性验证;整体 feature 保留 `validation-blocked`,原因是既有架构依赖门禁失败。 + +2026-10-10 用户反馈测试通过并授权整理、提交;[提交前复查](review.md) 结论为 `pass-with-follow-up`,未发现阻止视频功能提交的问题。保留既有门禁失败及首期限制。 + +## 结果 + +`video.play` 创建真正的场景视频 Layer,LibVLC 解码帧经过现有 Skia 排序、变换、透明度、Layer effect 与 ScenePost 管线。阻塞播放的每次左键推进寻找当前实际位置之后的下一时间戳;连续点击同时考虑已请求但尚未生效的 seek。无下一点或目标达到/超过已知时长时结束,视频自然结束也继续剧情。 + +`video.stop` 按 handle 停止播放。不同 handle 独立,同 handle 替换;隐藏/替换图层、播放失败、取消和会话销毁均清理。待加载源提前登记,隐藏或替换会取消资源加载,旧加载不能晚发布。播放 identity 防止旧完成误删新图层,帧租约防止渲染线程访问已释放纹理。LibVLC callback 只完成信号,停止和释放发生在回调线程外。 + +Layer 保存视频源标记和展示定义;非阻塞视频读档从零重放,阻塞视频仍使用此前稳定快照。播放器、播放 identity、当前帧与游标不写入存档。恢复入口也重放静态图层,使恢复的视频、普通图层和效果使用同一场景状态。 + +## 修改模块与样例 + +- Core/Runtime:Layer 视频标记及快照复制。 +- Presentation.Abstractions/Builtins:`IVideoPresenter`、播放请求、seek cursor、entry 参数、primitive 生命周期与恢复。 +- Avalonia.Rendering/GameView:动态帧源、帧租约、解码宿主与 Layer 替换/隐藏释放。 +- Sample:视频宿主组合和异步销毁。 +- `GameTestCase`:`niko.mp4.meta`,intro 的阻塞分段播放、非阻塞变换播放和显式停止。用户提供的视频文件未修改。 + +## 验证证据 + +| 验证 | 结果 | +| --- | --- | +| Core tests | 57 通过 | +| Runtime tests | 35 通过 | +| Builtins tests | 51 通过 | +| Presentation tests | 77 通过 | +| Integration tests | 34 通过 | +| Native video opt-in tests | 4 通过 | +| Architecture tests | 24 通过、1 既有失败 | +| Avalonia Sample build | 成功,0 errors | +| Editor CLI 编译 Raw 示例 | 成功,4 groups | +| Headless 示例(提供推进和选择输入) | 运行至 smoke test complete,exit 0 | +| 修改文件 whitespace / IDE0005 | verify-no-changes 通过 | + +共 258 项功能/运行时测试通过;另有 24 项架构断言通过。Native smoke 使用用户的 `niko.mp4`(54.869 秒),解码出 1280×738 帧,经场景管线输出 [video-smoke.png](../../artifacts/video-smoke.png)。已检查图像包含视频内容。Native tests 还验证连续 seek 后结束、跳至尾部后自然结束,以及资源加载期间 hide/replace 立即取消。 + +Native 测试通过设置 `GALNET_VIDEO_ASSETS`、`GALNET_VIDEO_LIBVLC` 后运行 `dotnet test test/GalNet.Presentation.Tests/GalNet.Presentation.Tests.csproj --filter FullyQualifiedName~VideoNativeSmokeTests`。测试不启动游戏窗口;原生日志仍有 vout “Failed to set on top” 及快速 seek/关闭期间 decoder 警告,所有解码、结束与清理断言通过。 + +## 既有阻塞与限制 + +架构失败为 `GalNet.Primitives.Builtins_UsesOnlyAllowedProjectDependencies`:项目直接引用 `GalNet.Runtime`,白名单不允许该引用。`git show HEAD:src/Shared/GalNet.Primitives.Builtins/GalNet.Primitives.Builtins.csproj` 已包含此引用,现有音频模块使用 `AudioRuntime`;本 feature 没有改 csproj 或白名单,未通过放宽测试掩盖问题。正式收尾需另行协调音频结构与白名单。 + +当前使用 CPU 解码帧上传,未承诺零拷贝或多个高清视频的性能。真实视频验证在 Windows 完成,未进行其他平台及人工窗口观看验收。视频原声直接由 LibVLC 输出,尚未接入 AudioRuntime 轨道。首期不提供暂停、循环或保留末帧。 + +## 文档与经验 + +- [Entry 参数与推进语义](../../docs/spec/entry-types.md) +- [Runtime 与稳定存档](../../docs/spec/runtime.md) +- [平台视频与渲染边界](../../docs/spec/control.md) +- [视频加载与帧生命周期 lesson](../../.agents/lessons/L-20261010-01-video-layer-lifetime.md) + +计划偏差:补充加载期取消的真实宿主回归测试;整体架构门禁的既有失败保留为外部收尾阻塞。没有改变用户已有的 ADR 删除或音频行为。 diff --git a/src/Editor/GalNet.Editor.Headless/packages.lock.json b/src/Editor/GalNet.Editor.Headless/packages.lock.json index 81587d6c..50697ff0 100644 --- a/src/Editor/GalNet.Editor.Headless/packages.lock.json +++ b/src/Editor/GalNet.Editor.Headless/packages.lock.json @@ -44,7 +44,8 @@ "type": "Project", "dependencies": { "GalNet.Core": "[1.0.0, )", - "GalNet.Presentation.Abstractions": "[1.0.0, )" + "GalNet.Presentation.Abstractions": "[1.0.0, )", + "GalNet.Runtime": "[1.0.0, )" } }, "galnet.runtime": { diff --git a/src/Editor/GalNet.Editor.Shared/packages.lock.json b/src/Editor/GalNet.Editor.Shared/packages.lock.json index 9f60901e..a85f5a5a 100644 --- a/src/Editor/GalNet.Editor.Shared/packages.lock.json +++ b/src/Editor/GalNet.Editor.Shared/packages.lock.json @@ -39,7 +39,8 @@ "type": "Project", "dependencies": { "GalNet.Core": "[1.0.0, )", - "GalNet.Presentation.Abstractions": "[1.0.0, )" + "GalNet.Presentation.Abstractions": "[1.0.0, )", + "GalNet.Runtime": "[1.0.0, )" } }, "galnet.runtime": { diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs index 38afd783..58b97398 100644 --- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs +++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs @@ -98,6 +98,7 @@ internal sealed partial class EditorPreviewSessionService : ObservableObject, IG private AvaloniaGamePageView? _pageView; private EditorPreviewLayerFactory? _layers; private AvaloniaEffectRuntime? _effects; + private AvaloniaAudioPlaybackHost? _audio; private GameEngine? _engine; private readonly Dictionary> _preloadedTextures = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _lifecycle = new(1, 1); @@ -254,11 +255,13 @@ private void DisposeEngine() _pageView?.Dispose(); _layers?.Dispose(); _effects?.Dispose(); + _audio?.Dispose(); foreach (var handle in _preloadedTextures.Values) handle.Dispose(); _preloadedTextures.Clear(); _pageView = null; _layers = null; _effects = null; + _audio = null; _engine = null; } @@ -278,13 +281,16 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) _layers = new EditorPreviewLayerFactory(_preloadedTextures); _pageView = new AvaloniaGamePageView(_gameplay, _page, _layers); _effects = new AvaloniaEffectRuntime(_gameplay); + _audio = new AvaloniaAudioPlaybackHost(_context.Assets); var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( _pageView.DialoguePresenter, _pageView.LayerPresenter, _pageView.AnimationPresenter, _effects, content.Gallery, - _pageView.ParticlePresenter)); + _pageView.ParticlePresenter, + _pageView.NvlPresenter, + _audio)); 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/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs index 7f030442..e05bfb1a 100644 --- a/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using GalNet.Core.Audio; using GalNet.Editor.Abstraction.Services; using GalNet.Editor.History; using Serilog; @@ -12,6 +14,8 @@ namespace GalNet.Editor.ViewModels; public partial class ProjectSettingsPanelViewModel : ObservableObject, IDisposable, IUndoRedoTarget { public IReadOnlyList ResolutionPresets => NewProjectPanelViewModel.ResolutionPresets; + public IReadOnlyList AudioBuses { get; } = [GalNet.Core.Audio.AudioBuses.Bgm, GalNet.Core.Audio.AudioBuses.Sfx, GalNet.Core.Audio.AudioBuses.Vocal]; + public ObservableCollection AudioTracks { get; } = []; private readonly IProjectService _projectService; private readonly EditorHistories _histories; private readonly IEditorSettingsService _editorSettings; @@ -80,6 +84,13 @@ private void LoadFromProject() SaveSlotCount = settings.SaveSlotCount; SfxChannelCount = settings.SfxChannelCount; TargetLocale = settings.TargetLocale.Code; + foreach (var track in AudioTracks) track.Changed -= TriggerAutoSave; + AudioTracks.Clear(); + foreach (var track in settings.AudioTracks) + { + var item = AttachTrack(AudioTrackEditorItem.From(track)); + AudioTracks.Add(item); + } _isLoading = false; UpdateDirtyText(); } @@ -96,8 +107,9 @@ private async Task SaveSettingsAsync() { if (_projectService.Current is not { } project) return; var before = SettingsSnapshot.From(project.Settings); - var after = new SettingsSnapshot(DefaultWidth, DefaultHeight, SaveSlotCount, SfxChannelCount, TargetLocale); - if (before == after) return; + var after = new SettingsSnapshot(DefaultWidth, DefaultHeight, SaveSlotCount, SfxChannelCount, TargetLocale, AudioTracks.Select(item => item.ToDefinition()).ToArray()); + ValidateTracks(after.AudioTracks); + if (before.Matches(after)) return; after.Apply(project.Settings); _histories.Settings.PushAlreadyApplied(new DelegateEdit("Change project settings", () => { before.Apply(project.Settings); LoadFromProject(); }, @@ -125,6 +137,46 @@ partial void OnResolutionChanged(string value) partial void OnSfxChannelCountChanged(int value) => TriggerAutoSave(); partial void OnTargetLocaleChanged(string value) => TriggerAutoSave(); + [RelayCommand] + private void AddAudioTrack() + { + var item = new AudioTrackEditorItem + { + Id = $"track_{AudioTracks.Count + 1}", + Name = "New track", + Bus = GalNet.Core.Audio.AudioBuses.Sfx, + DefaultGain = 1, + MaxVoices = 1, + MaxQueuedItems = 0 + }; + AudioTracks.Add(AttachTrack(item)); + TriggerAutoSave(); + } + + [RelayCommand] + private void RemoveAudioTrack(AudioTrackEditorItem? item) + { + if (item is null || !AudioTracks.Remove(item)) return; + item.Changed -= TriggerAutoSave; + TriggerAutoSave(); + } + + private AudioTrackEditorItem AttachTrack(AudioTrackEditorItem item) + { + item.Changed += TriggerAutoSave; + item.RemoveRequested = RemoveAudioTrack; + return item; + } + + private static void ValidateTracks(IReadOnlyList tracks) + { + if (tracks.Count == 0) throw new InvalidDataException("At least one audio track is required."); + if (tracks.Any(track => string.IsNullOrWhiteSpace(track.Id)) || tracks.Select(track => track.Id).Distinct(StringComparer.Ordinal).Count() != tracks.Count) + throw new InvalidDataException("Audio track IDs must be non-empty and unique."); + if (tracks.Any(track => !GalNet.Core.Audio.AudioBuses.IsValid(track.Bus) || !float.IsFinite(track.DefaultGain) || track.DefaultGain < 0 || track.DefaultGain > 1 || track.MaxVoices <= 0 || track.MaxQueuedItems < 0)) + throw new InvalidDataException("Audio track configuration contains an invalid bus, gain, or voice limit."); + } + private void TriggerAutoSave() { if (_isLoading || _projectService.Current is null) @@ -134,14 +186,77 @@ private void TriggerAutoSave() private void OnProjectChanged(Abstraction.Project.GalProject? _) => LoadFromProject(); - private sealed record SettingsSnapshot(int Width, int Height, int SaveSlots, int SfxChannels, string Locale) + private sealed record SettingsSnapshot(int Width, int Height, int SaveSlots, int SfxChannels, string Locale, IReadOnlyList AudioTracks) { public static SettingsSnapshot From(Core.Settings.ProjectSettings settings) => - new(settings.DefaultWidth, settings.DefaultHeight, settings.SaveSlotCount, settings.SfxChannelCount, settings.TargetLocale.Code); + new(settings.DefaultWidth, settings.DefaultHeight, settings.SaveSlotCount, settings.SfxChannelCount, settings.TargetLocale.Code, settings.AudioTracks.Select(CopyTrack).ToArray()); + public bool Matches(SettingsSnapshot other) => + Width == other.Width && Height == other.Height && SaveSlots == other.SaveSlots && SfxChannels == other.SfxChannels && Locale == other.Locale && + AudioTracks.Count == other.AudioTracks.Count && AudioTracks.Zip(other.AudioTracks).All(pair => Same(pair.First, pair.Second)); public void Apply(Core.Settings.ProjectSettings settings) { settings.DefaultWidth = Width; settings.DefaultHeight = Height; settings.SaveSlotCount = SaveSlots; settings.SfxChannelCount = SfxChannels; settings.TargetLocale = new Core.I18n.I18nLocale(Locale); + settings.AudioTracks = AudioTracks.Select(CopyTrack).ToList(); } + + private static bool Same(AudioTrackDefinition left, AudioTrackDefinition right) => + left.Id == right.Id && left.Name == right.Name && left.Bus == right.Bus && left.DefaultGain == right.DefaultGain && + left.MaxVoices == right.MaxVoices && left.MaxQueuedItems == right.MaxQueuedItems && + left.OverflowPolicy == right.OverflowPolicy && left.QueueOverflowPolicy == right.QueueOverflowPolicy; + private static AudioTrackDefinition CopyTrack(AudioTrackDefinition track) => new() + { + Id = track.Id, + Name = track.Name, + Bus = track.Bus, + DefaultGain = track.DefaultGain, + MaxVoices = track.MaxVoices, + MaxQueuedItems = track.MaxQueuedItems, + OverflowPolicy = track.OverflowPolicy, + QueueOverflowPolicy = track.QueueOverflowPolicy + }; } } + +public sealed partial class AudioTrackEditorItem : ObservableObject +{ + public event Action? Changed; + public Action? RemoveRequested { get; set; } + [ObservableProperty] private string _id = ""; + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _bus = AudioBuses.Sfx; + [ObservableProperty] private double _defaultGain = 1; + [ObservableProperty] private int _maxVoices = 1; + [ObservableProperty] private int _maxQueuedItems; + + public static AudioTrackEditorItem From(AudioTrackDefinition track) => new() + { + Id = track.Id, + Name = track.Name, + Bus = track.Bus, + DefaultGain = track.DefaultGain, + MaxVoices = track.MaxVoices, + MaxQueuedItems = track.MaxQueuedItems + }; + public AudioTrackDefinition ToDefinition() => new() + { + Id = Id, + Name = Name, + Bus = Bus, + DefaultGain = (float)DefaultGain, + MaxVoices = MaxVoices, + MaxQueuedItems = MaxQueuedItems, + OverflowPolicy = AudioOverflowPolicy.StopOldest, + QueueOverflowPolicy = AudioQueueOverflowPolicy.DropNewest + }; + + [RelayCommand] + private void Remove() => RemoveRequested?.Invoke(this); + + partial void OnIdChanged(string value) => Changed?.Invoke(); + partial void OnNameChanged(string value) => Changed?.Invoke(); + partial void OnBusChanged(string value) => Changed?.Invoke(); + partial void OnDefaultGainChanged(double value) => Changed?.Invoke(); + partial void OnMaxVoicesChanged(int value) => Changed?.Invoke(); + partial void OnMaxQueuedItemsChanged(int value) => Changed?.Invoke(); +} diff --git a/src/Editor/GalNet.Editor/Views/ProjectSettingsPanelView.axaml b/src/Editor/GalNet.Editor/Views/ProjectSettingsPanelView.axaml index 55663857..e40f9952 100644 --- a/src/Editor/GalNet.Editor/Views/ProjectSettingsPanelView.axaml +++ b/src/Editor/GalNet.Editor/Views/ProjectSettingsPanelView.axaml @@ -37,6 +37,26 @@ Minimum="1" Maximum="16" Increment="1" Foreground="{DynamicResource SemiColorText0}" /> + + +