Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .agents/agent-knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand All @@ -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 防止晚发布和旧完成误清理。
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sample 调试重置/重载应先导航再释放游戏展示

状态:candidate
状态:superseded
来源:2026-09-29 用户在 Sample Avalonia 调试清空流程中报告的黑屏

## 现象
Expand All @@ -11,9 +11,13 @@

`SampleGameSessionService.DisposeEngineAsync` 会重置游戏场景展示;`IGameNavigationService.ResetToAsync<TitlePageViewModel>` 可以在游戏引擎仍存活时先完成无动画标题页导航。

## 正确做法
## 原假设

Sample 的顶层调试操作应先 `ResetToAsync<TitlePageViewModel>(NavigationTransition.None)` 并等待页面切换完成,再在会话生命周期锁内停止运行、销毁引擎或重新加载资源。这样旧游戏画布被释放时,标题页已经是可见内容。
曾假设 Sample 的顶层调试操作应先 `ResetToAsync<TitlePageViewModel>(NavigationTransition.None)` 并等待页面切换完成,再在会话生命周期锁内停止运行、销毁引擎或重新加载资源。

## 失效原因

用户确认采用该顺序后,清空与进程内资源重载仍会黑屏。该顺序不足以保证当前页面主机与 Runner 销毁之间的稳定呈现;后续调试操作改用受控进程重启,见 `L-20260929-09`。

## 适用范围

Expand Down
30 changes: 30 additions & 0 deletions .agents/lessons/L-20260929-09-sample-debug-restart-after-reset.md
Original file line number Diff line number Diff line change
@@ -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`
36 changes: 36 additions & 0 deletions .agents/lessons/L-20260929-10-sample-debug-source-ignore.md
Original file line number Diff line number Diff line change
@@ -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/`
Original file line number Diff line number Diff line change
@@ -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 文档
22 changes: 22 additions & 0 deletions .agents/lessons/L-20261010-01-video-layer-lifetime.md
Original file line number Diff line number Diff line change
@@ -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。
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
Binary file added GameTestCase/Assets/niko.mp4
Binary file not shown.
1 change: 1 addition & 0 deletions GameTestCase/Assets/niko.mp4.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id":"a1000000000000000000000000000011","type":"video","path":"niko.mp4","compress":"none"}
Binary file added GameTestCase/Assets/心.mp3
Binary file not shown.
1 change: 1 addition & 0 deletions GameTestCase/Assets/心.mp3.meta
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id":"a1000000000000000000000000000010","type":"audio","path":"心.mp3","compress":"none"}
2 changes: 1 addition & 1 deletion GameTestCase/Graph/groups/downtown.rawgalgroup
Original file line number Diff line number Diff line change
Expand Up @@ -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." } }
]
}
2 changes: 1 addition & 1 deletion GameTestCase/Graph/groups/ending.rawgalgroup
Original file line number Diff line number Diff line change
Expand Up @@ -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." } }
]
}
2 changes: 1 addition & 1 deletion GameTestCase/Graph/groups/harbor.rawgalgroup
Original file line number Diff line number Diff line change
Expand Up @@ -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." } }
]
}
Loading
Loading