diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md
index 3a540912..903e06ee 100644
--- a/.agents/agent-knowledge.md
+++ b/.agents/agent-knowledge.md
@@ -13,8 +13,12 @@
- 解决方案入口是 `GalNet.slnx`。
- `docs/spec/` 描述已经实现的稳定事实;`docs/design/` 描述设计和分阶段计划。
- Runtime/Core 应保持与 Avalonia、WPF、Skia、具体文件系统和其他平台实现解耦。
+- Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为主内置入口。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/adr/0002-entry-schema-execution-registration.md 和 docs/development.md。
+- 当前 GameSnapshot 版本为 3,不兼容旧版;读档取消旧运行 scope 并替换场景对象。Engine 不拥有执行 view,宿主先释放 Engine,再异步释放 view 与后端。
+- Engine 提供终态 StopAsync 和 StateChanged,覆盖玩家推进、Choice 与阻塞实例自然完成;不新增通用 Runner。AvaloniaGamePresenters 只组合端口,SampleGamePresentation 管理平台资源。见 docs/design/game-runner-refactor.md 和 docs/spec/runtime.md。
+- Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前 20 个生产项目。GameContent.Settings 包含项目设置,项目/安装加载器与两个 Sample 已接通自定义音频轨道配置。
- 旧的 `GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移并删除;当前共享 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`,页面架构见 `docs/spec/control.md`。
-- 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由 Engine 根据状态重放展示。
+- 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由宿主通过 PersistentSceneRestorer 根据状态重放展示。
- 视觉扩展应通过展示抽象和渲染端口接入;不要把平台类型或渲染资源泄漏进 Core/Runtime。
- 当前已有大型阶段计划:[runtime-presentation-decoupling-phase-plan.md](../docs/design/runtime-presentation-decoupling-phase-plan.md) 和 [render-effects-pipeline-plan.md](../docs/design/render-effects-pipeline-plan.md)。新增 feature 应链接它们,不要复制其中的长期设计。
@@ -32,6 +36,8 @@
- [F-20261009-02-video-entries](../features/F-20261009-02-video-entries/feature.md) 已实现剧情视频图层、阻塞分段 seek、停止/替换清理和非阻塞视频定义恢复。平台视频必须经 AssetManager 获取,解码帧通过租约进入场景管线;播放游标不入存档。验证见 feature summary。
+- [F-20261010-02-runtime-i18n](../features/F-20261010-02-runtime-i18n/summary.md) Phase 1–5 已实现。Sample 用 Core 2.1.2 + ReflectionBinding;每 JSON 独立 provider,culture-first 查询内嵌/包内/External/额外源;首次选择在 Engine 前原子保存 locale/count。设置切换不重译当前剧情。安装器保留 External 并拒绝覆盖不同包,导出不包含第三方文件。当前契约见 `docs/spec/i18n.md`,人工 GUI 全程与原生媒体 opt-in 验证限制见 summary。
+
- `F-20260926-01-gallery-meta-pages` 的实现、正式文档与示例 Gallery 解锁 smoke test 已完成;其外部阻塞项 `EditorSettingsSerializationTests.LastDockLayout_RoundTripsAsAString` 已由 `F-20260929-04-quality-baseline` 修复,Gallery feature 可按自身流程收尾。
- [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 收尾仍待完成。
@@ -49,6 +55,9 @@
## 经验索引
- [受限环境中的 .NET/Avalonia 构建](lessons/L-20260922-01-dotnet-build-sandbox.md):禁用 Roslyn shared compilation,并关闭 Avalonia build telemetry。
+- [读档必须隔离旧运行实例](lessons/L-20261010-02-restore-run-scope.md):取消与场景对象隔离同时使用;句柄复用的清理需校验实例 identity;快照不能共享 mutable 对象。
+- [删除项目后检查依赖锁图](lessons/L-20261010-03-lock-graph-after-project-removal.md):检查 lock 内容;缓存可能保留旧项目,需要对受影响 roots 强制重算再 locked restore。
+- [Engine 停止覆盖所有续跑](lessons/L-20261010-04-engine-stop-continuations.md):首次推进返回不等于运行结束;停止须取消 scope、等待内部 continuation,统一发布终态。
- [平台无关的进度契约归属](lessons/L-20260922-02-core-progress-contract.md):已被 ADR-0001 和 F-20260929-07 取代;当前进度端口由实际消费者 Runtime 拥有。
- [受限环境中的平台 SDK 枚举](lessons/L-20260925-01-platform-sdk-sandbox.md):全解决方案构建可能只因 Android/Browser/iOS 的 SDK 探测读取用户目录失败;应区分平台环境限制和业务项目编译结果。
- [受限环境中的 Avalonia licensing 枚举](lessons/L-20260926-01-avalonia-license-sandbox.md):Avalonia BuildServices 即使关闭 telemetry 仍可能读取用户级 licensing tickets;应与业务编译失败区分。
@@ -69,3 +78,6 @@
- [项目移动后必须清理旧构建中间产物](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 防止晚发布和旧完成误清理。
+- [DynamicLocalization Avalonia 主版本兼容性](lessons/L-20261010-05-dynamiclocalization-avalonia-version.md):2.1.0 与 2.1.2 的 Avalonia extension 都不适用于当前 Avalonia 12 Sample;后者虽能编译仍在启动时调用已移除的 Binding 构造函数。Sample 使用 Core + 自有 `ReflectionBinding` 适配层。
+- [JSON provider 的整 culture 替换](lessons/L-20261010-07-json-provider-replaces-culture.md):2.1.2 的 LoadJsonString 不增量合并,也不做父 culture 回退;分文件需独立 provider 与宿主有序查询。
+- [Render 内的粒子耗尽清理](lessons/L-20261010-08-render-pass-particle-drain.md):confirmed;UI Dispatch 在 UI 线程同步执行,不能作为渲染回调清理的延迟队列;使用保证异步的 `Post`,并以 emitter identity 防止旧回调删除替换实例。
diff --git a/.agents/lessons/L-20261010-02-restore-run-scope.md b/.agents/lessons/L-20261010-02-restore-run-scope.md
new file mode 100644
index 00000000..722dccdf
--- /dev/null
+++ b/.agents/lessons/L-20261010-02-restore-run-scope.md
@@ -0,0 +1,22 @@
+# 读档必须隔离旧运行实例
+
+状态:confirmed
+来源:Entry 契约/执行注册重构(docs/design/entry-registration-refactor.md)
+
+## 现象与根因
+
+仅清空 Engine 活动列表并在原 SceneState 上恢复内容,不能阻止仍持有 Runtime 的旧异步实例在 finally 中清理新状态。取消令牌只是一种协作信号,不保证旧任务已退出。
+
+## 发现与证据
+
+审查发现 Engine 原先读档不更新 scope cancellation。新增 GameEnginePrimitiveDispatchTests.RestoreCancelsOldScopeAndIsolatesSceneAndSnapshots 验证旧 scope 被取消、旧实例的场景引用不能清理新场景、控制流操作抛出取消异常、调用者快照修改不污染 Engine。
+
+## 正确做法
+
+先验证存档版本并复制输入,再取消旧 scope;恢复时创建新的 SceneState/SceneInstanceManager。实例收到 RunScopedRuntime,持有该代场景对象,变量和控制流操作检查该代令牌。外部快照与 checkpoint 参数深复制。句柄复用时,异步清理还须检查实例 identity,不能仅按字符串句柄移除。
+
+## 适用范围与禁止做法
+
+适用于读档、重载、重启、同句柄替换。禁止把 ClearActive 当作取消;禁止让旧 finally 按句柄无条件清理新状态;禁止取消现有运行后才检查非法存档版本。
+
+关联:docs/spec/runtime.md;GameRuntimeSnapshotTests;AnimationPrimitiveInstance;AnimationPlanPrimitiveInstance。
diff --git a/.agents/lessons/L-20261010-03-lock-graph-after-project-removal.md b/.agents/lessons/L-20261010-03-lock-graph-after-project-removal.md
new file mode 100644
index 00000000..dc98f8a8
--- /dev/null
+++ b/.agents/lessons/L-20261010-03-lock-graph-after-project-removal.md
@@ -0,0 +1,14 @@
+# 删除项目后检查依赖锁图
+
+状态:confirmed
+来源:Entry 注册重构,删除 Presentation.Defaults
+
+## 现象与根因
+
+删除 csproj 和 ProjectReference 后,普通 restore 与后续 locked restore 返回成功,但外层 packages.lock.json 仍保存已删除项目及其旧依赖。还原缓存没有重新生成锁图;成功返回不能替代检查锁文件内容。
+
+## 证据与正确做法
+
+本轮用 rg 检查仍存在的 galnet.presentation.defaults 锁图条目。对 Integration、Editor.Tests、Presentation.Tests 的具体 csproj 执行 dotnet restore --force-evaluate 后,所有受影响生产与测试 lock 文件移除了旧项目;再执行 --locked-mode 验证。不要手写锁文件或仅依据退出码认定依赖图已同步。
+
+适用范围:项目删除、依赖移除或跨项目引用变化。边界:此记录不建议无条件删除 bin/obj;强制重算只针对已确认受影响的 restore roots。
diff --git a/.agents/lessons/L-20261010-04-engine-stop-continuations.md b/.agents/lessons/L-20261010-04-engine-stop-continuations.md
new file mode 100644
index 00000000..f9b3c4d6
--- /dev/null
+++ b/.agents/lessons/L-20261010-04-engine-stop-continuations.md
@@ -0,0 +1,16 @@
+# Engine 停止必须覆盖自动续跑,而不只跟踪首次推进
+
+状态:confirmed
+来源:docs/design/game-runner-refactor.md;GameEngineLifecycleTests
+
+## 现象与根因
+
+旧 Sample 用 GameRunCoordinator 只追踪首次 AdvanceAsync,后续玩家点击、选择结果和阻塞实例完成的续跑不在同一停止边界。只取消首次推进任务不能取消 Engine scope,也无法保证旧 continuation 不再访问宿主资源。
+
+## 正确做法
+
+Engine 在终态停止时先拒绝新请求、取消执行 scope,再清理队列并等待内部 continuation 退出。选择等待必须通过 scope token 中断,不能无条件等待不协作取消的 presenter Task。终态通知必须覆盖所有续跑入口并在执行锁外发布,宿主按 Engine、执行 view、平台后端顺序释放。
+
+## 验证与禁止做法
+
+GameEngineLifecycleTests 验证自然阻塞完成与 Choice 的完成/失败通知、不协作 Choice 的停止、执行中与排队推进的停止、正常结束后不重放和 view 非拥有关系。禁止把“首次 AdvanceAsync 已返回”当成游戏已停止;禁止让宿主分别猜测每种异步入口的终态;禁止在状态事件中同步等待 Engine 异步任务。
diff --git a/.agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md b/.agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md
new file mode 100644
index 00000000..0b01abe0
--- /dev/null
+++ b/.agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md
@@ -0,0 +1,30 @@
+# DynamicLocalization Avalonia package must match the host Avalonia major version
+
+状态:confirmed
+
+来源 Feature:F-20261010-02-runtime-i18n
+
+## 现象
+
+`DynamicLocalization.Avalonia` 2.1.0 的 NuGet manifest 为 net6.0 至 net9.0 target groups 声明了 `Avalonia (>= 11.0.0)`,其 assembly 在载入时要求 `Avalonia.Markup.Xaml, Version=11.0.0.0`。GalNet 的 Avalonia Sample 已使用 Avalonia 12.0.5,因此不能直接采用该版本的 XAML markup extension。2.1.2 虽将依赖范围更新为 `Avalonia [11.0.0, 13.0.0)`,但运行时仍调用 Avalonia 12 已移除的 `Binding(string, BindingMode)` 构造函数。
+
+## 根因
+
+该包的 2.1.0 release 尚未针对 Avalonia 12 编译;Core package 是平台无关的,不具有此 Avalonia assembly dependency。2.1.2 的包元数据虽然允许 Avalonia 12,但实际 `LocalizeExtension` 仍为旧 `Binding` API 编译,因此 restore 和 build 都不能证明可启动。
+
+## 正确做法
+
+保留 Avalonia 12,不为使用 markup extension 降级全仓 Avalonia。Sample 只保留 `DynamicLocalization.Core` 的 `CultureService`,自有 extension 按官方 demo 绑定 Core 的 `LocalizedString.Value`,将平台 binding 换成 Avalonia 12 `ReflectionBinding`。`LocalizedString` 监听 culture/provider change,不能只在自己的 ApplyCulture wrapper 中发通知,否则外部直接改 culture 时不会更新。任何未来重新引入第三方 extension 的方案都必须做真实启动验证。
+
+## 适用范围
+
+任何将 `DynamicLocalization.Avalonia` 引入当前 Avalonia 12 GalNet 应用的 feature。
+
+## 不要做什么
+
+不要仅因 package 可 restore 就假定其 XAML extension 可与当前 Avalonia major 运行,也不要为单个本地化功能降级共享 Avalonia 依赖。
+
+## 证据
+
+- 用户于 2026-10-10 报告的 Sample 启动堆栈:`LocalizeExtension.ProvideValue` 调用缺失的 `Avalonia.Data.Binding..ctor(string, BindingMode)`。
+- `GalNet.Sample.Avalonia` 在移除 `DynamicLocalization.Avalonia`、保留 Core 并改用自有适配层后成功构建;输出目录不再有 `DynamicLocalization.Avalonia.dll`。
diff --git a/.agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md b/.agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md
new file mode 100644
index 00000000..ff10f02c
--- /dev/null
+++ b/.agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md
@@ -0,0 +1,13 @@
+# 已存储的 i18n key 与执行位置
+
+状态:confirmed(用户确认,2026-10-10)
+
+来源:F-20261010-02-runtime-i18n。
+
+现象:实现讨论把 raw ID 当成需要透传 Runtime 的本地化身份,随后又提出在编译时将作者文本改写成 key;两者均偏离用户确认的契约。
+
+根因:混淆了 Editor 生成 key 的输入与 Runtime 消费的完整 key。每个 group 的 Entry.Id 在编译前后都表示位置,组合展开会改变编号。content 在 raw 阶段已是 key。
+
+正确做法:未来 Editor 自动生成 key 时使用 raw 阶段的 ID;key 保存后原样编译、展开、执行。不要按运行时序号重新生成,不增加仅为解析 key 使用的 SourceEntryId 属性,不擅自把 raw content 解释为源文本。
+
+适用:GalNet 内置文本条目与未来翻译编辑工具。自定义参数的本地化语义仍由 implement 决定。
diff --git a/.agents/lessons/L-20261010-07-json-provider-replaces-culture.md b/.agents/lessons/L-20261010-07-json-provider-replaces-culture.md
new file mode 100644
index 00000000..c8da1352
--- /dev/null
+++ b/.agents/lessons/L-20261010-07-json-provider-replaces-culture.md
@@ -0,0 +1,23 @@
+# DynamicLocalization JSON 逐文件加载会替换同 culture 的字典
+
+状态:confirmed
+
+来源 Feature:F-20261010-02-runtime-i18n
+
+## 现象与根因
+
+DynamicLocalization.Core 2.1.2 的 `JsonLocalizationProvider.LoadJsonString` 将解析结果赋给该 culture 的整个字典,而非增量合并。对同 provider 依次加载不同 group 的文件,之前文件的独有 key 消失。该版本 provider 的 `TryGetString` 也不递归查询父 culture。
+
+## 证据与发现方式
+
+完整 GameTestCase 的四条语言/路线运行暴露人物名、选项、后续 group 回显 key;单文件重复 key 测试无法发现字典丢失。递归加载测试中 a.json 的独有 fallback key 在 z.json 加载后消失。直接调用安装的 2.1.2 DLL,只有 zh 资源时,查询 zh 成功而 zh-CN 失败。
+
+## 正确做法与适用范围
+
+宿主为每个 JSON 分别建立 provider,保留确定性源顺序;查询时先遍历当前 culture、父 culture,再遍历游戏默认 culture 链,每个 culture 内从后向前查找。库继续解析 JSON,GalNet 不合并文件或重复实现 JSON 展平算法。Sample 两个宿主共享源码适配器,Core/Runtime 不引入库类型。
+
+## 禁止做法
+
+不要把“后文件重复 key 生效”当作“逐文件增量合并”的证据。版本变更必须同时测试重复 key、独有 key 保留和父 culture 回退。
+
+关联文档:`features/F-20261010-02-runtime-i18n/design.md`、`docs/spec/i18n.md`。
diff --git a/.agents/lessons/L-20261010-08-render-pass-particle-drain.md b/.agents/lessons/L-20261010-08-render-pass-particle-drain.md
new file mode 100644
index 00000000..6aa854bf
--- /dev/null
+++ b/.agents/lessons/L-20261010-08-render-pass-particle-drain.md
@@ -0,0 +1,28 @@
+# 粒子耗尽清理不能在渲染调用栈内同步修改场景集合
+
+状态:confirmed(通过 presenter 的延后清理与同 ID 替换回归测试验证)
+
+来源 Feature:F-20261010-02-runtime-i18n 验收期间发现;问题位于既有粒子展示链。
+
+## 现象与发现方式
+
+用户报告游戏结尾附近崩溃。`GameTestCase/.galnet/logs/game-20261010.log` 的 2026-10-10 21:59:32 致命异常为 `Visual was invalidated during the render pass`。该次运行没有记录 Engine Completed;栈明确来自粒子耗尽后的场景集合移除,不是 i18n 解析或 Engine 终态释放。
+
+## 根因
+
+`SceneLayerHost.Render` 同步调用 `ParticleEmitter.Update`;耗尽时触发 `Drained`。`AvaloniaParticlePresenter.OnParticleDrained` 注释称移除被延后,但调用的 `IAvaloniaUiDispatcher.Dispatch` 在 UI 线程立即执行。因此 `StopParticleEmitter` 在 Render 内移除 `SceneRenderables`,集合通知触发 `RebuildPlan` 与 `InvalidateVisual`,Avalonia 抛出异常。
+
+## 正确做法
+
+为渲染回调的清理使用保证异步排队的调度,待本次 Render 返回后再修改场景集合。不要只判断是否处于 UI 线程;UI 线程不代表当前位于安全的渲染阶段。排队清理还应核对 emitter 实例身份,避免替换同 ID 后误删新实例,并覆盖 presenter 释放后的回调。`IAvaloniaUiDispatcher.Post` 明确提供该保证;`Dispatch` 仍可在 UI 线程同步执行,不能用于渲染期清理。
+
+## 适用范围与禁止做法
+
+适用于帧更新中触发的 drained/finished 回调及渲染内的集合或属性变更。不要把“Dispatch 到 UI 线程”视为“延迟执行”;不要仅吞掉 InvalidateVisual 异常而继续在枚举中修改集合。Headless 剧情测试和 Avalonia 启动验证不覆盖真实粒子耗尽的 GUI 帧路径。
+
+## 关联位置
+
+- `src/Presentation/GalNet.Avalonia.Rendering/Scene/SceneLayerHost.cs`
+- `src/Presentation/GalNet.Avalonia.Rendering/Scene/ParticleEmitter.cs`
+- `src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaParticlePresenter.cs`
+- `src/Presentation/GalNet.Avalonia.GameView/Presentation/IAvaloniaUiDispatcher.cs`
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f1bb612a..8f9a64e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,14 +23,36 @@ jobs:
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.401
- - name: Restore workloads
- run: dotnet workload restore GalNet.slnx --version 10.0.401.1
- - name: Restore locked dependencies
- run: dotnet restore GalNet.slnx --locked-mode
+ - name: Restore locked dependencies (excluding launcher)
+ shell: pwsh
+ run: |
+ $projects = Get-ChildItem src, test -Filter *.csproj -Recurse |
+ Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } |
+ Sort-Object FullName
+ foreach ($project in $projects) {
+ dotnet restore $project.FullName --locked-mode
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
- name: Verify whitespace
- run: dotnet format whitespace GalNet.slnx --no-restore --verify-no-changes --verbosity quiet
+ shell: pwsh
+ run: |
+ $projects = Get-ChildItem src, test -Filter *.csproj -Recurse |
+ Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } |
+ Sort-Object FullName
+ foreach ($project in $projects) {
+ dotnet format whitespace $project.FullName --no-restore --verify-no-changes --verbosity quiet
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
- name: Verify imports
- run: dotnet format style GalNet.slnx --no-restore --verify-no-changes --diagnostics IDE0005 --verbosity quiet
+ shell: pwsh
+ run: |
+ $projects = Get-ChildItem src, test -Filter *.csproj -Recurse |
+ Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } |
+ Sort-Object FullName
+ foreach ($project in $projects) {
+ dotnet format style $project.FullName --no-restore --verify-no-changes --diagnostics IDE0005 --verbosity quiet
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
desktop-build-and-test:
runs-on: windows-latest
diff --git a/.gitignore b/.gitignore
index 4efd7ce1..b4daf120 100644
--- a/.gitignore
+++ b/.gitignore
@@ -462,3 +462,5 @@ riderModule.iml
# Project-specific
dev/*
.galnet/
+GameTestCase/Output/
+GameTestCase/Output-new/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index aa52da8a..fdee1e9d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,8 +22,7 @@
-
-
+
diff --git a/GalNet.slnx b/GalNet.slnx
index 522901d6..a4e2f5be 100644
--- a/GalNet.slnx
+++ b/GalNet.slnx
@@ -24,7 +24,6 @@
-
diff --git a/GameTestCase/Graph/graph.json b/GameTestCase/Graph/graph.json
index e4bb4b73..1c335993 100644
--- a/GameTestCase/Graph/graph.json
+++ b/GameTestCase/Graph/graph.json
@@ -10,8 +10,8 @@
"name": "Choose a route",
"branchType": "Choice",
"options": [
- { "text": "Use the tiled background" },
- { "text": "Use the filled background" }
+ { "text": "branch.route_choice.option.0.text" },
+ { "text": "branch.route_choice.option.1.text" }
]
},
{ "id": "downtown", "type": "Group", "name": "Tiled route", "file": "groups/downtown.rawgalgroup" },
diff --git a/GameTestCase/Graph/groups/downtown.rawgalgroup b/GameTestCase/Graph/groups/downtown.rawgalgroup
index 97e77409..b65d874a 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." } }
+ { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.downtown.entry.line.content" } }
]
}
diff --git a/GameTestCase/Graph/groups/ending.rawgalgroup b/GameTestCase/Graph/groups/ending.rawgalgroup
index 3cd3030d..075115da 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." } }
+ { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.ending.entry.line.content" } }
]
}
diff --git a/GameTestCase/Graph/groups/harbor.rawgalgroup b/GameTestCase/Graph/groups/harbor.rawgalgroup
index 718cd751..a7592e0c 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." } }
+ { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.line.content" } }
]
}
diff --git a/GameTestCase/Graph/groups/intro.rawgalgroup b/GameTestCase/Graph/groups/intro.rawgalgroup
index 2065667c..56213c3b 100644
--- a/GameTestCase/Graph/groups/intro.rawgalgroup
+++ b/GameTestCase/Graph/groups/intro.rawgalgroup
@@ -7,38 +7,38 @@
{ "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. The test BGM is fading in on the bgm track." } },
+ { "id": "intro-line-1", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-1.content" } },
{ "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": "intro-video-background", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-video-background.content" } },
{ "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": "intro-line-effect-on", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-effect-on.content" } },
{ "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": "intro-line-effect-off", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-effect-off.content" } },
{ "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": "intro-line-low-pass", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-low-pass.content" } },
{ "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": "intro-line-high-pass", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-high-pass.content" } },
{ "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." } },
+ { "id": "intro-line-2", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-2.content" } },
{ "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." } },
- { "id": "intro-line-4", "type": "dialogue.text", "parameters": { "speaker": "Guide", "content": "Now the non-blocking portrait loop is stopped explicitly." } },
+ { "id": "intro-line-3", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-3.content" } },
+ { "id": "intro-line-4", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-4.content" } },
{ "id": "stop-portrait-jitter", "type": "animation.stop", "parameters": { "playbackHandleId": "portrait-jitter", "mode": "CompleteImmediately" } },
{ "id": "dialogue-hide-for-nvl", "type": "dialogue.hide", "parameters": {} },
{ "id": "nvl-show", "type": "nvl.show", "parameters": {} },
- { "id": "nvl-append-rich", "type": "nvl.append", "parameters": { "content": "NVL is visible.\\skip Rich text and typewriter skip boundaries are active." } },
- { "id": "nvl-append-history", "type": "nvl.append", "parameters": { "content": "This is the second paragraph in the scrollable NVL history." } },
+ { "id": "nvl-append-rich", "type": "nvl.append", "parameters": { "content": "group.intro.entry.nvl-append-rich.content" } },
+ { "id": "nvl-append-history", "type": "nvl.append", "parameters": { "content": "group.intro.entry.nvl-append-history.content" } },
{ "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-append-after-clear", "type": "nvl.append", "parameters": { "content": "group.intro.entry.nvl-append-after-clear.content" } },
{ "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." } }
+ { "id": "intro-line-5", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-5.content" } }
]
}
diff --git a/GameTestCase/I18n/catalog/characters.json b/GameTestCase/I18n/catalog/characters.json
new file mode 100644
index 00000000..dd630cf7
--- /dev/null
+++ b/GameTestCase/I18n/catalog/characters.json
@@ -0,0 +1,6 @@
+[
+ {
+ "id": "guide",
+ "displayNameKey": "character.guide.name"
+ }
+]
diff --git a/GameTestCase/I18n/en-US/branches.json b/GameTestCase/I18n/en-US/branches.json
new file mode 100644
index 00000000..787ce92b
--- /dev/null
+++ b/GameTestCase/I18n/en-US/branches.json
@@ -0,0 +1,4 @@
+{
+ "branch.route_choice.option.0.text": "Use the tiled background",
+ "branch.route_choice.option.1.text": "Use the filled background"
+}
diff --git a/GameTestCase/I18n/en-US/characters.json b/GameTestCase/I18n/en-US/characters.json
new file mode 100644
index 00000000..0082d8b8
--- /dev/null
+++ b/GameTestCase/I18n/en-US/characters.json
@@ -0,0 +1,3 @@
+{
+ "character.guide.name": "Guide"
+}
diff --git a/GameTestCase/I18n/en-US/groups/downtown.json b/GameTestCase/I18n/en-US/groups/downtown.json
new file mode 100644
index 00000000..fa06e278
--- /dev/null
+++ b/GameTestCase/I18n/en-US/groups/downtown.json
@@ -0,0 +1,3 @@
+{
+ "group.downtown.entry.line.content": "The tiled route uses a compiled cross-fade and a portrait animation."
+}
diff --git a/GameTestCase/I18n/en-US/groups/ending.json b/GameTestCase/I18n/en-US/groups/ending.json
new file mode 100644
index 00000000..49a07596
--- /dev/null
+++ b/GameTestCase/I18n/en-US/groups/ending.json
@@ -0,0 +1,3 @@
+{
+ "group.ending.entry.line.content": "Runtime smoke test complete."
+}
diff --git a/GameTestCase/I18n/en-US/groups/harbor.json b/GameTestCase/I18n/en-US/groups/harbor.json
new file mode 100644
index 00000000..66b5e9c2
--- /dev/null
+++ b/GameTestCase/I18n/en-US/groups/harbor.json
@@ -0,0 +1,3 @@
+{
+ "group.harbor.entry.line.content": "The route combines a persistent rate emitter with a one-shot, per-particle flipbook burst."
+}
diff --git a/GameTestCase/I18n/en-US/groups/intro.json b/GameTestCase/I18n/en-US/groups/intro.json
new file mode 100644
index 00000000..263e889c
--- /dev/null
+++ b/GameTestCase/I18n/en-US/groups/intro.json
@@ -0,0 +1,15 @@
+{
+ "group.intro.entry.intro-line-1.content": "The Runtime sample now builds from Raw authoring content. The test BGM is fading in on the bgm track.",
+ "group.intro.entry.intro-video-background.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.",
+ "group.intro.entry.intro-line-effect-on.content": "A seffect.gain effect is now active, ducking this BGM track while the scene continues.",
+ "group.intro.entry.intro-line-effect-off.content": "The track effect has been removed and the BGM volume is restored.",
+ "group.intro.entry.intro-line-low-pass.content": "A low-pass SEffect now removes the BGM's higher bands. Advance to remove it.",
+ "group.intro.entry.intro-line-high-pass.content": "A high-pass SEffect now removes the BGM's lower bands. Advance to restore the full track.",
+ "group.intro.entry.intro-line-2.content": "The portrait is rapidly bobbing through a non-blocking additive loop.",
+ "group.intro.entry.intro-line-3.content": "The blocking rotation has completed before this line is shown.",
+ "group.intro.entry.intro-line-4.content": "Now the non-blocking portrait loop is stopped explicitly.",
+ "group.intro.entry.nvl-append-rich.content": "NVL is visible.\\skip Rich text and typewriter skip boundaries are active.",
+ "group.intro.entry.nvl-append-history.content": "This is the second paragraph in the scrollable NVL history.",
+ "group.intro.entry.nvl-append-after-clear.content": "The prior NVL history was cleared, then this paragraph was appended.",
+ "group.intro.entry.intro-line-5.content": "The BGM is now fading out mid-track. Choose a route to see a transition and particles."
+}
diff --git a/GameTestCase/I18n/zh-CN/branches.json b/GameTestCase/I18n/zh-CN/branches.json
new file mode 100644
index 00000000..960af122
--- /dev/null
+++ b/GameTestCase/I18n/zh-CN/branches.json
@@ -0,0 +1,4 @@
+{
+ "branch.route_choice.option.0.text": "使用平铺背景",
+ "branch.route_choice.option.1.text": "使用填充背景"
+}
diff --git a/GameTestCase/I18n/zh-CN/characters.json b/GameTestCase/I18n/zh-CN/characters.json
new file mode 100644
index 00000000..ac596623
--- /dev/null
+++ b/GameTestCase/I18n/zh-CN/characters.json
@@ -0,0 +1,3 @@
+{
+ "character.guide.name": "向导"
+}
diff --git a/GameTestCase/I18n/zh-CN/groups/downtown.json b/GameTestCase/I18n/zh-CN/groups/downtown.json
new file mode 100644
index 00000000..b8f6f827
--- /dev/null
+++ b/GameTestCase/I18n/zh-CN/groups/downtown.json
@@ -0,0 +1,3 @@
+{
+ "group.downtown.entry.line.content": "平铺路线使用了编译后的交叉淡入淡出转场和立绘动画。"
+}
diff --git a/GameTestCase/I18n/zh-CN/groups/ending.json b/GameTestCase/I18n/zh-CN/groups/ending.json
new file mode 100644
index 00000000..7ef5ff46
--- /dev/null
+++ b/GameTestCase/I18n/zh-CN/groups/ending.json
@@ -0,0 +1,3 @@
+{
+ "group.ending.entry.line.content": "运行时冒烟测试完成。"
+}
diff --git a/GameTestCase/I18n/zh-CN/groups/harbor.json b/GameTestCase/I18n/zh-CN/groups/harbor.json
new file mode 100644
index 00000000..ab3babb7
--- /dev/null
+++ b/GameTestCase/I18n/zh-CN/groups/harbor.json
@@ -0,0 +1,3 @@
+{
+ "group.harbor.entry.line.content": "这条路线同时展示持续发射器,以及使用逐粒子序列帧的一次性粒子爆发。"
+}
diff --git a/GameTestCase/I18n/zh-CN/groups/intro.json b/GameTestCase/I18n/zh-CN/groups/intro.json
new file mode 100644
index 00000000..98a2ee31
--- /dev/null
+++ b/GameTestCase/I18n/zh-CN/groups/intro.json
@@ -0,0 +1,15 @@
+{
+ "group.intro.entry.intro-line-1.content": "运行时示例由 raw 作者内容编译而成。测试背景音乐正在 bgm 轨道上淡入。",
+ "group.intro.entry.intro-video-background.content": "niko.mp4 是位于立绘下方的场景图层,带有旋转、缩放和透明度。非阻塞视频允许对话继续推进;推进后将停止视频。",
+ "group.intro.entry.intro-line-effect-on.content": "seffect.gain 轨道效果已启用,场景继续时背景音乐音量会降低。",
+ "group.intro.entry.intro-line-effect-off.content": "轨道效果已移除,背景音乐音量已恢复。",
+ "group.intro.entry.intro-line-low-pass.content": "低通 SEffect 正在滤除背景音乐的高频。推进以移除效果。",
+ "group.intro.entry.intro-line-high-pass.content": "高通 SEffect 正在滤除背景音乐的低频。推进以恢复完整音频。",
+ "group.intro.entry.intro-line-2.content": "立绘正在通过非阻塞的叠加循环快速上下浮动。",
+ "group.intro.entry.intro-line-3.content": "这句话显示之前,阻塞旋转动画已完成。",
+ "group.intro.entry.intro-line-4.content": "现在将显式停止立绘的非阻塞循环。",
+ "group.intro.entry.nvl-append-rich.content": "NVL 已显示。\\skip 富文本和打字机跳过边界均已启用。",
+ "group.intro.entry.nvl-append-history.content": "这是可滚动 NVL 历史中的第二段。",
+ "group.intro.entry.nvl-append-after-clear.content": "之前的 NVL 历史已清空,随后追加了这一段。",
+ "group.intro.entry.intro-line-5.content": "背景音乐正在播放途中淡出。选择路线以查看转场和粒子效果。"
+}
diff --git a/GameTestCase/README.md b/GameTestCase/README.md
index 4f674c87..b3794945 100644
--- a/GameTestCase/README.md
+++ b/GameTestCase/README.md
@@ -2,6 +2,26 @@
This Raw-authoring game exercises the compiled runtime slice:
+The game includes English (`en-US`, its configured default) and Simplified Chinese
+(`zh-CN`). All raw dialogue/NVL content and choice text are localization keys.
+`speaker` references the `guide` character in `I18n/catalog/characters.json`;
+character names, choice text and each group's text live in separate per-culture JSON
+files. Change the language in the Avalonia Sample settings to localize subsequent
+dialogue, NVL paragraphs and choices using the same culture as the UI.
+
+Third-party translations go in `/I18n/External//**/*.json`.
+They override built-in game and UI keys (for example `App.Title`); files are applied
+in ordinal relative-path order, last file wins. Invalid files are logged and skipped.
+New languages appear in settings. Restart to load edited files; currently displayed
+dialogue and NVL history do not change when selecting a language. External files are
+excluded from exported packages and preserved when reopening an existing installation.
+
+On the Avalonia Sample's first successful launch for a profile, a language-selection
+page appears before the title. Confirm to persist the choice and increment the startup
+count; cancel exits without completing onboarding. Later launches restore the language
+and increment the count. Debug **Clear game data** resets player variables and restarts,
+so the picker reappears. Use `--profile ` for an isolated test profile.
+
- `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;
@@ -16,7 +36,7 @@ The `.rawgalgroup` files are the sole authoring source. Build them with `GalNet.
the generated `.galgroup` files use the version 2 compiled format (`typeId` plus `arguments`). Every
resource reference is its stable GUID; human-readable paths occur only in adjacent `.meta` files.
-The fixture follows the editable project layout: `Graph/graph.json`, `Graph/groups/*.galgroup`, and
+The fixture follows the editable project layout: `Graph/graph.json`, `Graph/groups/*.rawgalgroup`, and
`Assets/` with each resource's `.meta`. Gallery data is derived from the metadata rather than stored
as an editable root `gallery.json`. The classroom background is also a `cg` Gallery item (ID `100`),
so a host that supplies the standard Gallery pages shows the Gallery button on its title page. The
@@ -27,6 +47,7 @@ Build and run from the repository root:
```text
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
+dotnet run --project src/Samples/GalNet.Sample.Avalonia/GalNet.Sample.Avalonia.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.
diff --git a/GameTestCase/settings.json b/GameTestCase/settings.json
new file mode 100644
index 00000000..309da3d9
--- /dev/null
+++ b/GameTestCase/settings.json
@@ -0,0 +1,13 @@
+{
+ "targetLocale": {
+ "code": "en-US"
+ },
+ "availableLocales": [
+ {
+ "code": "en-US"
+ },
+ {
+ "code": "zh-CN"
+ }
+ ]
+}
diff --git a/docs/adr/0001-runtime-storage-contract-ownership.md b/docs/adr/0001-runtime-storage-contract-ownership.md
deleted file mode 100644
index e8d1038f..00000000
--- a/docs/adr/0001-runtime-storage-contract-ownership.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# ADR-0001:运行时与存储契约归属
-
-- 状态:Accepted
-- 日期:2026-09-29
-- 决策者:项目维护者
-- 关联 feature:`F-20260929-05-contract-ownership`
-
-## 上下文
-
-`GalNet.Storage.Abstractions` 同时定义资源访问、内容加载、存档、变量和 Gallery 组合类型。它只引用 Core,但公开类型位于 `GalNet.Core.Assets`、`GalNet.Core.Services` 和 `GalNet.Core.Gallery` 命名空间;Runtime、Assets、Storage.FileSystem、Rendering 与 Editor 均直接引用它。
-
-该结构造成三个问题:程序集名称不能说明职责;Runtime 的真实依赖与架构文档不一致;新增契约容易继续堆入一个横向的 Abstractions 项目。
-
-## 候选方案
-
-### A. 保留 Storage.Abstractions
-
-迁移最少,但资源、运行时端口和 Gallery 继续共处,职责与命名错位不会消失。
-
-### B. 全部并入 Core
-
-依赖图最简单,但会让 Core 持有存档、宿主和运行时编排协议,持续扩大通用协议仓库。
-
-### C. 契约由策略消费者拥有,并删除 Storage.Abstractions
-
-资源契约归 Core.Assets;内容、存档和变量端口归 Runtime;具体文件系统、PAK 和资源管理实现留在外层项目。Gallery 的领域接口与 DTO 留在 Core,依赖运行时变量端口的组合实现进入 Runtime。
-
-## 决定
-
-采用方案 C。用户于 2026-09-29 明确接受该方案。
-
-- `IAssetProvider`、`IArchive`、`IGameFile`、`IAssetManager`、`IAssetDecoder`、`AssetHandle` 移入 `GalNet.Core`,保持 `GalNet.Core.Assets` 命名空间。
-- `IGameContentProvider` 与 `GameContent` 移入 `GalNet.Runtime.Content`。
-- `ISaveService`、`SaveRequest`、`SaveSlotInfo`、`IPlayerVariableStore` 移入 `GalNet.Runtime.Persistence`。
-- `IVariableService` 移入 `GalNet.Runtime.Variables`。
-- `GalleryDataSource` 移入 `GalNet.Runtime.Gallery`;`IGalleryDataSource`、Gallery catalog 与 DTO 保持在 Core。
-- 不创建 `GalNet.Runtime.Abstractions`;Runtime 自己拥有其策略端口。
-- 完成仓库内消费者迁移后删除 `GalNet.Storage.Abstractions`。
-
-## 后果
-
-正面影响:
-
-- 程序集、命名空间和策略消费者表达一致的职责。
-- Runtime 恢复为只依赖 Core 与 Presentation.Abstractions。
-- Assets 与 Storage.FileSystem 成为实现层,不再拥有 Runtime 使用的端口。
-- 新持久化能力有明确入口,不再默认加入通用 Storage 抽象程序集。
-
-代价与风险:
-
-- 后续迁移会修改公开命名空间,并需要一次更新所有仓库内消费者。
-- Core.Assets 会包含资源访问协议,但不包含目录、PAK、缓存实现或平台解码资源。
-- 未证明外部消费者兼容需求,因此本次决定不要求 type forwarding;若迁移前发现真实外部消费者,应另行决定过渡版本。
-
-## 边界
-
-- 本 ADR 不决定音频接口;`IAudioService` 由音频 feature 管理。
-- 本 ADR 不迁移 Presentation/Builtins 命名空间,也不决定 Editor 插件 API。
-- 本 ADR 描述目标结构;迁移完成前,`GalNet.Storage.Abstractions` 仍是当前实现的一部分。
diff --git a/docs/adr/0002-entry-schema-execution-registration.md b/docs/adr/0002-entry-schema-execution-registration.md
new file mode 100644
index 00000000..dd5a6bdf
--- /dev/null
+++ b/docs/adr/0002-entry-schema-execution-registration.md
@@ -0,0 +1,25 @@
+# ADR-0002:Entry 契约与执行注册分离
+
+状态:Accepted(2026-10-10)
+
+## 上下文
+
+旧 EntryModule 同时保存参数 schema、composite 展开和 primitive 工厂。编译器必须创建运行期模块,平台依赖与契约耦合;按模块/前缀排他注册不利于局部替换。此次迁移不保留旧 API 或存档兼容,不改造 Editor/Launcher 功能。
+
+## 决定
+
+`IEntryModule` 只声明 schema 和编译期展开;`IPrimitiveModule` 贡献完整指令 ID 与实例工厂并拥有共享状态。`GameRegistration` 独立组合两者。前缀和 schema 模块名不排他,仅完整 ID 必须唯一。主内置入口 `AddBuiltins` 组合 schema 和所有执行模块;开发者可只选择 schema、部分内置逻辑或全部自定义实现。
+
+轨道规则留在平台无关 `AudioRuntime`,播放由 `IAudioPlaybackHost` 提供。Avalonia/LibVLC 和 Unity/原生音频均通过组合接口接入;不强制继承内置基类。执行模块是共享状态的生命周期单元,指令是路由单元。
+
+## 候选与取舍
+
+- 逐条注册可用于小扩展,但不强迫所有开发者采用;领域模块便于共享轨道和服务。
+- 继续将工厂放入 schema 会维持编译器/平台耦合,因此不采用。
+- 强制平台继承轨道基类会绑定后端结构;组合接口更容易适配已有播放系统。
+
+## 后果
+
+宿主须明确管理 Engine、view、presenter 的释放顺序;schema-only view 可构建,但 GameEngine 拒绝缺失执行实现的 Graph。新执行模块必须提供对应 schema,重复 ID 明确失败。旧模块工厂 API 与无消费者音频协议删除;快照移除冗余索引并升级版本 3。验证由注册、运行时、内置和跨层集成测试覆盖,使用示例见 [development](../development.md)。
+
+旧 Presentation.Defaults 的唯一剩余空 view 移入 Presentation.Abstractions,独立 Defaults 项目和无用引用删除。
diff --git a/docs/design/entry-registration-refactor.md b/docs/design/entry-registration-refactor.md
new file mode 100644
index 00000000..3cd06975
--- /dev/null
+++ b/docs/design/entry-registration-refactor.md
@@ -0,0 +1,60 @@
+# Entry 契约与执行注册迁移
+
+用户授权:不保留旧 API/存档兼容;排除 Editor/Launcher 功能改造,允许迁移其公共 API 调用点以保持构建。
+
+## 目标与决定
+
+Entry module 只拥有参数契约和编译期 composite。执行 module 只拥有完整指令 ID 与实例工厂,并可持有共享服务/状态。模块名和指令前缀不是排他注册键,完整 ID 才是。宿主通过 GameRegistration 独立注册契约与实现,在创建游戏时冻结并转移执行模块所有权。
+
+内置入口 AddBuiltins 组合 AddBuiltinSchemas 与 AddBuiltinImplementations;每个领域有独立实现注册入口。音频轨道逻辑可复用 AudioRuntime,平台通过 IAudioPlaybackHost 组合实现;无需继承播放器基类。平台后端不进入 schema。
+
+## 实施顺序
+
+1. 契约/工厂拆分、注册入口、领域拆文件、消费者迁移与注册测试。
+2. 删除旧协议与重复状态,统一快照复制及读档取消,补充生命周期回归测试。
+3. 修正音频 pending-load/命令顺序、动画统一播放时间源、诊断和宿主生命周期。
+4. 顺序执行边界测试、集成测试、格式检查并同步当前文档。
+
+## 验收
+
+纯 schema 注册不创建执行服务;schema-only、自定义实现、部分内置实现、全内置均可组合;相同前缀不同指令可注册,完整 ID 重复报错;缺失实现明确失败;旧异步任务不能污染读档后状态;快照与调用者隔离;停止待加载音频后不能晚开始;动画轨道与事件使用同一播放进度。
+
+## 风险
+
+公共 API 破坏性迁移需要同步测试及所有消费者。取消并不等待所有外部任务,旧任务必须使用运行代际/状态对象隔离。后端资源销毁必须先取消待加载、等待工作结束后删除临时目录。
+
+## 实现结果(2026-10-10)
+
+目标已落地。领域执行模块与 runtime actions 分文件维护,schema 的语义验证用于编译和运行期,包括 composite 展开的指令;重复的执行层视频参数检查移除。缺失实现、未编译作者条目、参数错误和 BatchId 不一致均明确拒绝。
+
+删除无消费者 IAudioService、LibVlcAudioController、调用栈、冗余场景 ID 索引,并把空 GameView 移入展示抽象,删除 Presentation.Defaults 项目。旧文件可通过 Git 恢复。GameSnapshot 升级为 3;输入/输出快照复制,读档重置结束标记并隔离旧 scope/场景。
+
+音频逐 voice 有序提交,Stop 取消 pending Start,平台 host 跟踪加载并异步排空;native 回调只排队信号,播放器清理不在回调线程。新 fade 取消旧 fade。Sample 明确释放 engine、view、后端与 DI scope。动画事件由 presenter 的同一帧进度驱动,旧完成按 identity 清理。项目/安装内容现在读取 settings.json,两种 Sample 将自定义轨道配置注入 Runtime。
+
+## 验证与边界
+
+- 10 个测试项目顺序执行:Architecture 24、Core 57、Runtime 38、Builtins 52、Assets 32、Storage.FileSystem 12、Editor.Shared 16、Editor 28、Presentation 76、Integration 38,共 373 项通过。测试构建关闭分析器并禁用 shared compilation。
+- 使用 Sample 携带的 Windows x64 LibVLC,显式运行 AvaloniaAudioLifetimeTests 的停止/销毁两个 native 测试,均通过。
+- Sample.Headless 和 Editor.Headless 单独构建成功;Editor 仅公共 API/namespace 消费点迁移,Launcher 未修改。
+- 受影响依赖锁强制重算后 locked restore 成功。solution whitespace/imports 门禁检查通过,git diff --check 无错误。
+- 普通 Shared 构建开启分析器后无错误,但仍有命名、性能与生命周期等非阻断告警;Avalonia 仍有既存 AVLN3001 告警。本轮未声称全仓零告警,也未进行完整 GUI 交互验收或实现 Unity 后端。
+
+使用方式见 [二次开发手册](../development.md),当前契约见 [runtime](../spec/runtime.md),决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。
+
+## 实现结果(2026-10-10)
+
+目标已落地。领域执行模块与 runtime actions 分文件维护,schema 的语义验证用于编译和运行期,包括 composite 展开的指令;重复的执行层视频参数检查移除。缺失实现、未编译作者条目、参数错误和 BatchId 不一致均明确拒绝。
+
+删除无消费者 IAudioService、LibVlcAudioController、调用栈、冗余场景 ID 索引,并把空 GameView 移入展示抽象,删除 Presentation.Defaults 项目。旧文件可通过 Git 恢复。GameSnapshot 升级为 3;输入/输出快照复制,读档重置结束标记并隔离旧 scope/场景。
+
+音频逐 voice 有序提交,Stop 取消 pending Start,平台 host 跟踪加载并异步排空;native 回调只排队信号,播放器清理不在回调线程。新 fade 取消旧 fade。Sample 明确释放 engine、view、后端与 DI scope。动画事件由 presenter 的同一帧进度驱动,旧完成按 identity 清理。项目/安装内容现在读取 settings.json,两种 Sample 将自定义轨道配置注入 Runtime。
+
+## 验证与边界
+
+- 10 个测试项目顺序执行:Architecture 24、Core 57、Runtime 38、Builtins 52、Assets 32、Storage.FileSystem 12、Editor.Shared 16、Editor 28、Presentation 76、Integration 38,共 373 项通过。测试构建关闭分析器并禁用 shared compilation。
+- 使用 Sample 携带的 Windows x64 LibVLC,显式运行 AvaloniaAudioLifetimeTests 的停止/销毁两个 native 测试,均通过。
+- Sample.Headless 和 Editor.Headless 单独构建成功;Editor 仅公共 API/namespace 消费点迁移,Launcher 未修改。
+- 受影响依赖锁强制重算后 locked restore 成功。solution whitespace/imports 门禁检查通过,git diff --check 无错误。
+- 普通 Shared 构建开启分析器后无错误,但仍有命名、性能与生命周期等非阻断告警;Avalonia 仍有既存 AVLN3001 告警。本轮未声称全仓零告警,也未进行完整 GUI 交互验收或实现 Unity 后端。
+
+使用方式见 [二次开发手册](../development.md),当前契约见 [runtime](../spec/runtime.md),决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。
diff --git a/docs/design/game-runner-refactor.md b/docs/design/game-runner-refactor.md
new file mode 100644
index 00000000..e55fb503
--- /dev/null
+++ b/docs/design/game-runner-refactor.md
@@ -0,0 +1,31 @@
+# 游戏运行编排与 Avalonia 呈现收敛
+
+## 范围
+
+保留页面、presenter 和运行会话的职责边界。不改变 Editor/Launcher 功能;Editor 仅迁移共享类型名称和事件订阅 API。
+
+## 设计
+
+- 不新增通用 Runner。Engine 统一报告运行、完成、失败和停止,覆盖玩家推进、选择结果和阻塞实例自然完成三个入口。
+- Engine 提供 `StopAsync` 和异步释放,停止先拒绝新请求并取消 scope,再清理活动实例、等待内部 continuation 退出。停止是 Engine 的终态,重开创建新实例。
+- 状态事件在执行锁外按发生顺序发布;订阅者异常诊断但不改变剧情状态。事件只报告事实,不承担 UI、日志文案或存储业务。
+- 准备、页面切换、首次推进留在宿主;内置场景重放继续复用 Builtins 入口。
+- `AvaloniaGamePageView` 改名为 `AvaloniaGamePresenters`。玩家推进直接从页面 ViewModel 接到宿主,不经过 presenter 集合转发。
+- Sample 的平台对象装配和统一清理集中到一个 `SampleGamePresentation`;执行模块先于借用的 presenter/后端释放。清理一个对象失败不能阻止其他对象清理。
+- 删除未使用的 `SampleMediaViews`,Sample Runner 只保留平台绑定、日志和 UI 更新。
+
+## 验证阶段
+
+1. **verified**:Engine 停止、状态通知与生命周期测试,新增 11 个用例。
+2. **verified**:Avalonia 命名/API 迁移、Sample 装配与事件绑定、旧代码清理。
+3. **verified**:全仓 10 个测试项目共 384 项通过;另外 2 项原生音频取消测试通过。Sample.Headless 和 Editor.Headless 构建通过,solution whitespace / IDE0005 门禁及 git diff --check 通过。
+
+当前状态:已实现并验证。
+
+## 结果和限制
+
+- 平台实现只提供呈现端口,无需继承 Runner 基类、管理 Engine continuation 或重复判断剧情终态。
+- SampleGameRunner 保留页面准备/切换、日志和 UI 反馈;平台对象由 SampleGamePresentation 统一装配与释放。删除旧媒体占位对象和推进事件的两层转发。
+- 放弃最初的通用 GameRunner 草案,职责直接归 Engine;不兼容旧 AvaloniaGamePageView 类型名。
+- Editor 仅迁移共享 API,原预览编排未重构;Launcher 未改动。
+- 常规测试不包含显式视频原生测试,本次未进行完整 GUI 操作验收。Avalonia 构建有既有 AVLN3001 警告;沙箱许可目录读取限制通过授权验证处理,没有修改产品代码绕过。
diff --git a/docs/development.md b/docs/development.md
index 7ae2371d..fce5fc72 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -47,83 +47,37 @@ var reputationParameters = new DynamicParameterTable(
`DynamicParameterDescriptor` 可声明 required、JSON 默认值与 JSON constraints。若主要目的是提供编辑器控件提示,也可用 `EntrySchema.DynamicParameters(...)` 由 `EntryParameterType` 构建 schema。
-### 2. 选择原语的生命周期
+### 2. 分别定义契约和执行模块
-同步且立即结束的逻辑使用 `ImmediatePrimitiveInstance`:
+schema 不引用游戏服务或平台对象。执行模块持有共享数据和服务,贡献完整指令 ID 与实例工厂:
```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`:
+using GalNet.Presentation.Abstractions.Execution;
-```csharp
-public sealed class WaitForSignalInstance : PrimitiveInstance
+public sealed class MyGameSchemas : EntryModuleBase
{
- 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();
+ public MyGameSchemas()
+ : base("mygame",
+ [
+ new DefaultPrimitiveEntryBase(
+ "mygame.reputation.add",
+ new DynamicParameterTable(
+ [
+ new DynamicParameterDescriptor("faction", typeof(string), isRequired: true),
+ new DynamicParameterDescriptor("amount", typeof(int),
+ defaultValue: JsonSerializer.SerializeToElement(1))
+ ]))
+ ])
+ { }
}
-```
-
-原语实现必须遵守以下规则:
-
-- 让基类调用 `Dispatch()`;只在 `OnDispatch()` 启动一次行为。
-- 成功、取消或错误路径最终都只调用一次 `TryComplete()`。
-- 创建实例时原样传入 `context.BatchId`;不要生成不同的 batch ID。
-- `OnSkip()` 必须幂等,且只处理本实例自己的资源。
-- non-blocking 原语在返回前提交可存档状态;平台 Task、控件和播放游标不属于存档。
-### 3. 将定义组成模块
-
-```csharp
-public sealed class MyGameModule : EntryModuleBase
+public sealed class MyGameModule : PrimitiveModule
{
- 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",
+ : base(
[
- new DefaultPrimitiveEntryBase(
- "mygame.reputation.add",
- ReputationParameters,
- context => new ImmediatePrimitiveInstance(() =>
+ new PrimitiveImplementation("mygame.reputation.add", context =>
+ new ImmediatePrimitiveInstance(() =>
{
var faction = context.Arguments.GetProperty("faction").GetString()!;
var amount = context.Arguments.GetProperty("amount").GetInt32();
@@ -134,28 +88,50 @@ public sealed class MyGameModule : EntryModuleBase
}
```
-模块构造时会冻结 primitive 与 composite 两张表,并拒绝模块内或跨模块的重复 type ID。模块有资源时覆写 `Dispose()`;`CompositeGameView` 会按相反顺序释放已挂载模块。
+不必逐条在组合根注册。一个领域模块可以贡献多个指令,并在实例间共享轨道、缓存或服务;也可用 `RegisterInstruction` 单独贡献指令。同前缀可以由多个模块贡献不同指令,只有完整 type ID 重复才冲突。schema 模块无资源生命周期;执行模块可覆写 `Dispose` / `DisposeAsync`。
-### 4. 同时接入作者期与运行期
+异步或可跳过行为继承 `PrimitiveInstance`,在 `OnDispatch` 启动受 scope cancellation 控制的任务;成功、取消和失败都应完成实例,失败需明确诊断。不要用未经观察异常的 `async void`。创建实例时原样传入 `context.BatchId`,`OnSkip` 必须幂等且只处理自己的资源。non-blocking 原语应先提交可存档状态,再启动展示。
-必须让编辑器/编译器的 target profile 与运行时 view 使用**等价的模块集合和 schema**。实际宿主可以各自创建模块实例(例如编辑器没有 renderer,游戏有 renderer),但 type ID、参数和 composite 展开契约必须一致。
+### 3. 在游戏组合根独立注册
```csharp
-// 编辑器或导出器:决定允许作者使用哪些 entry。
-var authoringModules = new IEntryModule[]
-{
- new MyGameModule(editorReputationService)
-};
-var profile = new TargetProfileEntryCatalog(authoringModules);
+using GalNet.Presentation.Abstractions.View;
-// 游戏会话:提供同一批 primitive 的实际工厂和平台依赖。
-using IGameView view = new CompositeGameView(
-[
- new MyGameModule(runtimeReputationService)
-]);
+// 编译器只需契约,不构造执行服务。
+using var registration = new GameRegistration()
+ .RegisterSchema(new MyGameSchemas())
+ .RegisterImplementation(new MyGameModule(reputationService));
+var profile = registration.BuildCatalog();
+
+// BuildView 冻结注册并将执行模块所有权转移给 view。
+await using var view = registration.BuildView();
```
-只把模块挂到 `CompositeGameView` 会使 Runtime 能执行、编辑器却不能作者化/编译;只挂到 `TargetProfileEntryCatalog` 则相反。不要依赖全局内置目录作为回退。
+编译期 target profile 和运行期 view 使用等价的 schema;执行模块只在运行期创建。宿主先停止/释放 Engine,再异步释放 view,最后释放它借用的 presenter 和平台资源。注册失败时释放 registration,避免遗留已创建的执行模块。
+
+平台实现只需要提供 presenter/后端,不需要实现 Runner、跟踪 Engine continuation 或自行推断剧情结束。宿主调用 `AdvanceAsync()`,通过 `StateChanged` 接收统一运行状态,关闭时调用 `StopAsync()` 或异步释放 Engine。选择或阻塞实例自然完成后的推进与终态通知由 Engine 负责。页面切换前的快照恢复及 Builtins 展示重放仍由宿主编排。
+
+### 4. 自由选择内置能力
+
+```csharp
+// 主入口:所有内置 schema 与实现,平台能力通过端口提供。
+await using var full = new GameRegistration()
+ .AddBuiltins(dialoguePresenter: dialogue, audioHost: audioHost)
+ .BuildView();
+
+// 内置 schema + 部分内置逻辑 + 自己的实现。
+await using var custom = new GameRegistration()
+ .AddBuiltinSchemas()
+ .AddBuiltinVariables()
+ .RegisterImplementation(new MyPlatformModule())
+ .BuildView();
+```
+
+`AddBuiltinSchemas` 不创建播放器或运行期状态;`AddBuiltinImplementations` 注册全部内置实现;`AddBuiltinAudio`、`AddBuiltinAnimations`、`AddBuiltinVariables` 等按领域选择实现。也可以完全不使用内置契约。
+
+只注册 schema 的 view 可以构建,但不能执行尚未提供实现的指令。Engine 创建时检查 Graph 引用的所有 primitive;缺失实现直接报错,不静默跳过。没有 presenter 的内置模块不等于具备平台播放能力:部分模块可只提交状态,对话等需要交互的能力仍要求对应 presenter。
+
+音频用组合而非强制继承:`BuiltinAudioModule`/`AudioRuntime` 管理轨道与 voice,`IAudioPlaybackHost` 管理平台播放。Avalonia 可提供 LibVLC host,Unity 可提供原生音频 host;若轨道语义也需替换,只注册内置 schema 和自己的音频执行模块。
## 创建 composite(可选)
@@ -200,7 +176,7 @@ public sealed class AlertCompositeEntry : CompositeEntry
- 自定义 type ID 在目标 profile 中存在,且无重复。
- Raw Group 可编译为只含 primitive 的 `.galgroup`。
-- Runtime 的 `CompositeGameView` 挂载等价模块集合。
+- Runtime 的 view 使用等价 schema,且 Graph 所需指令都有实现。
- 参数默认值、必填项和 JSON 类型有测试。
- blocking、skip、取消和完成事件分别有测试。
- 任何影响长期画面的状态先写入可恢复的状态模型,再调用平台呈现。
diff --git a/docs/engine.md b/docs/engine.md
index fcb8a1e3..5b6992e8 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -16,6 +16,8 @@ Graph + 已编译 .galgroup
`IGameRuntime` 是运行事实的唯一来源,保存当前节点、条目位置、变量和 `SceneState`。呈现端口负责显示,不反过来拥有剧情或可存档状态。
+Engine 的 `StateChanged` 统一报告开始运行、完成、失败和停止,覆盖选择结果和阻塞实例自然完成;宿主无需在多个推进入口重复判断结束。关闭会话使用 `await engine.StopAsync()` 或异步释放,随后释放执行 view 与平台后端。停止是 Engine 终态,重开创建新实例。详细契约见[运行时参考](spec/runtime.md#状态通知和停止)。
+
## 内容项目与发布
开发项目的最小布局如下:
@@ -66,7 +68,7 @@ Project/
## 默认能力与宿主
-`GalNet.Primitives.Builtins` 是可选的推荐模块集合,提供对话、NVL、图层、动画、效果、粒子、音频、视频、等待、变量和 Gallery 等 schema 与相应的已实现能力。具体媒体后端能力由所选宿主提供;宿主可以只挂载自定义模块,或在内置模块之外追加能力。
+`GalNet.Primitives.Builtins` 提供对话、NVL、图层、动画、效果、粒子、音频、视频、等待、变量和 Gallery 等 schema 与执行模块。`GameRegistration.AddBuiltins` 是主内置注册入口;也可只注册 `AddBuiltinSchemas`,再选择部分内置实现或全部自定义实现。媒体后端通过平台端口组合,不要求继承播放器基类;多个领域模块可共享指令前缀,但完整 ID 必须唯一。示例见[二次开发手册](development.md)。
官方 Avalonia 宿主 `GalNet.Avalonia.GameView` 提供 `GameShell`、导航、页面和默认 presenter。它不读取项目目录,也不决定媒体或存档实现;这些由游戏宿主在组合根提供。页面集成见[Avalonia 游戏页面](spec/control.md)。
diff --git a/docs/glossary.md b/docs/glossary.md
index 8eae415a..5a85a559 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -49,7 +49,7 @@
| 游戏导航服务 | `IGameNavigationService` | 管理游戏 Scope 内的当前页面、回退历史和导航转场。 |
| 页面注册表 | `IPageViewRegistry` / `IPageViewFactory` | 在组合期建立并解析不可变的 ViewModel→View 映射。 |
| 游戏根页面 | `GameShell` | 承载页面切换、页面内容和游戏截图入口的 Avalonia 根控件。 |
-| 游戏页面呈现 | `AvaloniaGamePageView` | 将 presenter 端口接到 Avalonia 游戏页面和场景渲染。 |
+| 游戏页面呈现 | `AvaloniaGamePresenters` | 将 presenter 端口接到 Avalonia 游戏页面和场景渲染,不负责 Engine 运行编排。 |
| Gallery 页面注册表 | `IGalleryPageRegistry` | 将精确 Gallery `typeId` 映射到页面 ViewModel 和 View 的组合期注册表;不按资源类型推断或 fallback。 |
`WidgetTemplate`、`WidgetInstance`、`ScreenTemplate`、`ScreenInstance`、`UiProject` 与调色板模板体系是历史设计术语,不属于当前游戏页面宿主。
diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md
index 790e3097..cf72c8f7 100644
--- a/docs/spec/architecture.md
+++ b/docs/spec/architecture.md
@@ -22,7 +22,7 @@ Editor / Samples ───────── 组合根与平台宿主
```text
src/
Shared/ Core、Runtime、Assets、Presentation.Abstractions、Primitives.Builtins
- Presentation/ Avalonia.Controls、Avalonia.Rendering、Avalonia.GameView、Presentation.Defaults
+ Presentation/ Avalonia.Controls、Avalonia.Rendering、Avalonia.GameView
Infrastructure/ Storage.FileSystem
Editor/ Editor.Abstraction、Editor.Shared、Editor、Editor.Headless
Samples/ Sample.Avalonia、Sample.Headless
@@ -31,7 +31,7 @@ src/
物理分组用于表达查找和所有权,不改变程序集名称、根命名空间或依赖方向。新增生产项目应放入与其职责一致的分组,并同步 `GalNet.slnx`;测试项目统一位于 `test/.Tests`。
-程序集拥有自己的根命名空间:展示端口位于 `GalNet.Presentation.Abstractions.*`,推荐内置 entry、primitive 和 module 位于 `GalNet.Primitives.Builtins`。扩展程序集不得在 `GalNet.Core.*` 下声明类型;Core 只保留领域基础类型和扩展协议。
+程序集拥有自己的根命名空间:展示与执行注册端口位于 `GalNet.Presentation.Abstractions.*`,内置 entry、primitive 和 module 位于 `GalNet.Primitives.Builtins`。Entry schema 不依赖执行工厂,执行模块按领域共享状态。Builtins 可依赖 Runtime 的平台无关逻辑(如音频轨道),Runtime 不反向依赖 Builtins。扩展程序集不得在 `GalNet.Core.*` 下声明类型;Core 只保留领域基础类型和扩展协议。注册拆分决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。
资源层只向上暴露 `IAssetProvider`、`IArchive`、`IGameFile` 和 `IAssetManager`。provider 定位源文件;manager 负责 GUID 查询、decoder 分派、缓存和 `AssetHandle` 生命周期。Core、Runtime 与 Editor.Shared 不引用 Avalonia。
@@ -51,9 +51,9 @@ src/
## 游戏运行
-`GameEngine` 持有 `IGameRuntime`,解释图、条目、Choice、skip batch 和快照;它不认识具体 UI。`IGameView` 解析 entry schema、创建 primitive instance 并调用 presenter 端口。影响场景的 primitive 先写入 `SceneState`,再通知呈现端;读取快照后由 Engine 根据状态重放。
+`GameEngine` 持有 `IGameRuntime`,解释图、条目、Choice、skip batch 和快照;它不认识具体 UI。Engine 统一报告运行状态,负责停止、取消执行 scope 和等待内部 continuation 退出,不拥有执行 view 或平台后端。`IGameView` 解析 entry schema、创建 primitive instance 并调用 presenter 端口。影响场景的 primitive 先写入 `SceneState`,再通知呈现端;读取快照后由宿主调用 Builtins 的场景重放入口。
-Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePageView` 只创建并暴露这些 presenter,`CompositeGameView` 仍只负责把内置 entry module 组合为 Runtime 使用的 `IGameView`。官方 Avalonia Sample 的页面会话只串行化公开命令和顶层 UI 状态:`SampleGameResourceScope` 管理安装内容与资源,`SampleSaveSession` 管理玩家持久化与 Gallery 数据,`SampleGameRunner` 管理 engine 和页面展示生命周期,`PersistentSceneRestorer` 在 Runtime 快照恢复后重放 renderer-owned 场景对象。runner 必须先于资源 scope 释放。
+Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePresenters` 只创建并暴露这些 presenter,`CompositeGameView` 组合独立的 schema catalog 与执行模块,提供 Runtime 使用的 `IGameView`。官方 Avalonia Sample 的页面会话只串行化公开命令和顶层 UI 状态:`SampleGameResourceScope` 管理安装内容与资源,`SampleSaveSession` 管理玩家持久化与 Gallery 数据,`SampleGameRunner` 绑定 Engine 与页面、准备读档及首次推进,`SampleGamePresentation` 管理执行 view 和平台展示资源,`PersistentSceneRestorer` 复用 Builtins 重放 renderer-owned 场景对象。没有通用 Runner 抽象或必须继承的宿主基类。runner 必须先于资源 scope 异步释放。
Gallery catalog 是平台无关的静态内容。宿主把其自动生成的 `gallery__unlocked` Player bool 注册到变量服务;`GalleryDataSource` 合并 catalog 与当前 Player 值。Gallery 解锁不存于独立 UI 状态或存档槽。
diff --git a/docs/spec/control.md b/docs/spec/control.md
index e53ac17d..40741e2c 100644
--- a/docs/spec/control.md
+++ b/docs/spec/control.md
@@ -11,7 +11,7 @@
└─ GameShell
├─ IGameNavigationService
├─ 标题、加载、存读档、设置、鉴赏、关于页面
- └─ GamePage + AvaloniaGamePageView
+ └─ GamePage + AvaloniaGamePresenters
├─ Dialogue / Layer / Animation / Particle presenter
└─ CompositeGameView ── Runtime.GameEngine
```
@@ -38,15 +38,23 @@
Gallery 使用独立的 `IGalleryPageRegistry`,以精确 Gallery `typeId` 映射到页面 ViewModel 和 View。`Add` 注册新类型,`Replace` 显式覆盖既有类型;多个 type ID 可以映射到同一页面。该注册同时写入普通 ViewModel→View registry 和 DI,构建后不可修改。
+### 设置与界面文本
+
+`SettingsPageViewModel` 通过通用的 `IPlayerSettingsService.Items` 获取设置项,不绑定语言专用服务。页面支持 `ChoicePlayerSetting` 和 `RangePlayerSetting`;默认服务提供文本速度,宿主可在 DI 中替换服务并提供其他设置。设置应用、恢复和持久化由宿主负责,共享页面不读取配置文件。
+
+共享页面的静态文案使用 `PlayerTextExtension`,未接入宿主本地化时显示默认英文。官方 Sample 提供 `LocalizedString` 并绑定其 `Value`,沿用 DynamicLocalization 的 Culture/Provider 变更通知机制;宿主设置标签也可以使用同样的动态对象。该绑定不会自动翻译任意业务字符串或剧情内容。
+
+Sample 在游戏会话初始化成功后,把设置接入现有 Player 变量存储,恢复语言与文本速度。语言变更(包括直接调用 CultureService)立即刷新绑定并排队持久化;清空玩家状态和销毁会话前等待写入完成。
+
`GalleryNavigationService` 不按资源类型推断页面:没有有内容类型时入口不可用,只有一个时直接打开该页,多个时先进入类型选择页;未注册页面会显示诊断页。CG、视频和音频是独立页面/媒体状态,不使用 renderer enum 或单一多媒体 ViewModel。
## 运行期呈现边界
-Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、NVL、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePageView` 是页面侧 presenter 的组合器,分别暴露 dialogue/NVL/choice、layer、animation 和 particle 端口;宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。
+Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、NVL、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePresenters` 是页面侧 presenter 的组合器,分别暴露 dialogue/NVL/choice、layer、animation 和 particle 端口;它不是页面或执行 view。宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。
-各 presenter 共享 `IAvaloniaUiDispatcher`,但分别拥有自己的等待、动画和粒子生命周期。`AvaloniaGamePageView` 通过宿主提供的 `IGamePageLayerFactory` 解析图层图像,因而共享页面不会自行推断资源路径。动画逐帧更新由呈现实现完成;Runtime 只维护可存档的场景状态和动画结束后的稳定属性。
+各 presenter 共享 `IAvaloniaUiDispatcher`,但分别拥有自己的等待、动画和粒子生命周期。`AvaloniaGamePresenters` 通过宿主提供的 `IGamePageLayerFactory` 解析图层图像,因而共享页面不会自行推断资源路径。玩家推进从 `GamePageViewModel.AdvanceRequested` 直接连接宿主,不经过 dialogue presenter 或 presenter 集合转发。动画逐帧更新由呈现实现完成;Runtime 只维护可存档的场景状态和动画结束后的稳定属性。
-官方 Sample 使用 `SampleGameResourceScope`、`SampleSaveSession`、`SampleGameRunner` 和 `PersistentSceneRestorer` 分离资源、玩家持久化、运行编排与读档展示重放。读档顺序固定为恢复 Runtime snapshot、重放持久展示、页面切换后启动 prepared run;reload 和清空玩家状态必须先停止并释放 runner,再释放资源或重建持久化状态。
+官方 Sample 使用 `SampleGameResourceScope`、`SampleSaveSession`、`SampleGameRunner` 和 `PersistentSceneRestorer` 分离资源、玩家持久化、页面运行编排与读档展示重放。`SampleGamePresentation` 集中创建和释放执行 view 与平台后端,创建失败与正常销毁使用同一条清理路径;单个对象释放失败仍继续释放其他对象。Runner 只绑定页面操作、处理 Engine 状态通知和宿主反馈,不重复推断剧情终态。读档顺序固定为恢复 Runtime snapshot、重放持久展示、页面切换后首次调用 `AdvanceAsync`;reload 和清空玩家状态必须先停止并释放 runner,再释放资源或重建持久化状态。
## 宿主接入约束
diff --git a/docs/spec/entry-types.md b/docs/spec/entry-types.md
index edbd8b7d..1d329561 100644
--- a/docs/spec/entry-types.md
+++ b/docs/spec/entry-types.md
@@ -6,7 +6,7 @@
- `.rawgalgroup` 的 `parameters` 与编译后 `.galgroup` 的 `arguments` 都是 JSON 对象;数字、布尔值、对象和数组不被压平为字符串。
- 某个 entry 是否可编辑、可编译,完全由当前 target profile 中挂载模块导出的 entry schema 决定。未挂载的类型被拒绝,不会回退到全局内置目录。
- 每个 entry 定义的 `DynamicParameterTable` 是只读 schema:每个参数以 `typeof(T)` 指定进程内运行时类型,并包含 required、JSON 默认值和 JSON constraints。CLR 类型名绝不写入内容、metadata、存档或 pak;内容只保存 JSON 值。
-- Runtime 只接收通用 `PrimitiveEntry`,并通过 `IGameView.Dispatch` 动态路由。Core/Runtime 不内置或要求任何推荐 primitive 实现。
+- Runtime 只接收通用 `PrimitiveEntry`,并通过 `IGameView.Dispatch` 动态路由。Core/Runtime 不要求任何内置 primitive 实现。
`EntryParameterType` 是当前编辑器的 UI hint。它由参数 constraints 生成,不是执行协议。
@@ -14,10 +14,10 @@
每个 `IEntryModule` 持有两张冻结表:
-- `PrimitiveEntries`:Runtime 可执行 primitive schema 与 instance 工厂。
+- `PrimitiveEntries`:primitive 参数契约,不含执行工厂或平台依赖。
- `CompositeEntries`:只服务编辑器和编译器的 authoring 展开。
-同一模块内 primitive/composite 不得重名;多个挂载模块之间 primitive 名称也不得重复。组合根可以把同一组模块用于 `TargetProfileEntryCatalog` 和 `CompositeGameView`,使编辑器 schema 与 Runtime 工厂来自同一事实来源。
+同一模块内 primitive/composite 不得重名;跨模块仅完整 type ID 不得重复,模块名称和指令前缀可重复。`TargetProfileEntryCatalog` 只依赖 schema;运行时通过 `GameRegistration` 独立注册 `IPrimitiveModule` 执行模块,再构建 `CompositeGameView`。一个执行模块可以共享服务与状态,并贡献多个指令;每个实现必须有 primitive schema。
## Primitive Entry
@@ -35,7 +35,7 @@
Composite 仅存在于 `.rawgalgroup`,并在编译时展开为通用 `PrimitiveEntry` 序列;`.galgroup` 只包含 primitive envelope。Composite 自己决定展开结果的 batch 划分,框架不规定“一个 Composite 等于一个 Batch”。
-推荐 Builtins 在 animation module 的 composite 表中提供:
+内置 schema 模块的 composite 表提供:
```text
transition.crossFade
@@ -48,9 +48,9 @@ transition.fadeColor
这些 entry 不是 Runtime primitive,也不绕过 target profile:展开后的每一个 primitive 都必须在所选 profile 中存在。
-## 推荐 primitive ID
+## 内置 primitive ID
-`GalNet.Primitives.Builtins` 提供可选推荐 authoring profile 与运行时模块。当前推荐 ID 为:
+`GalNet.Primitives.Builtins` 提供独立可选的 schema 与运行时领域模块。主入口是 `GameRegistration.AddBuiltins`,也可单独调用 `AddBuiltinSchemas`,并组合部分内置或自定义执行模块。当前内置 ID 为:
内置 entry DTO、primitive instance 和 module 都使用 `GalNet.Primitives.Builtins` 命名空间;`GalNet.Core.Entry` 只包含通用 entry 基类、schema 和 catalog 基础设施。内容格式只保存下列稳定 `TypeId`,不保存 CLR 类型名。
@@ -71,9 +71,9 @@ flow.wait, variable.set, gallery.unlock
`nvl.show` 和 `nvl.hide` 只改变 NVL 覆盖层的可见性;`nvl.clear` 移除已累积的历史行;`nvl.append` 只接收长文本 `content`,以与 `dialogue.text` 相同的富文本和打字机语义追加一段,并在文字完成后等待一次玩家推进。它不会自动显示 NVL,剧情应显式调用 `nvl.show`。
-这些名称和参数只是推荐语义。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`nvl`、`layer` 或任何其他模块存在。
+这些名称和参数属于可选内置契约。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`nvl`、`layer` 或任何其他模块存在。
-当前已实现 Runtime 行为的推荐能力见 [runtime.md](runtime.md)。
+当前已实现 Runtime 行为的内置能力见 [runtime.md](runtime.md)。
## 视频图层 entry
diff --git a/docs/spec/file-formats.md b/docs/spec/file-formats.md
index e90a57c5..f95164b6 100644
--- a/docs/spec/file-formats.md
+++ b/docs/spec/file-formats.md
@@ -12,11 +12,15 @@ Project/
**/* 原始资源
**/*.meta 资源 metadata 与 Gallery 标注
I18n/
+ catalog/characters.json 人物 ID 与姓名 key
+ /**/*.json 游戏文本
Output/ Temp/ .galnet/ 工作文件,不是游戏内容
```
项目根不存在可编辑 `gallery.json`。Gallery 从 `Assets/**/*.meta` 的 `gallery[]` 聚合:类型由宿主代码/插件注册,item ID 为全局唯一正整数,资源 ID 是父 metadata 的 GUID。开发运行在内容加载时聚合一次;发行运行只读取导出生成的 `gallery.json`。
+`I18n/` 的人物表、文本源与默认语言完整性检查见 [国际化](i18n.md)。它是特殊内容,不是资源 provider 输入。
+
## Graph 与 Group
`Graph/graph.json` 是版本 2 的编辑图,包含稳定节点/边 ID、坐标、变量和 Group 的 `groups/.rawgalgroup` 文件引用。`.rawgalgroup` 是版本 2、`kind: Raw` 的编辑源,可包含 primitive 和 composite;`.galgroup` 是 `kind: Compiled` 的运行产物,只能包含 primitive。旧分号文本、旧版本 Group 格式和运行时加载 Raw 文档均不受支持。
diff --git a/docs/spec/i18n.md b/docs/spec/i18n.md
index edf810ea..cd7864c6 100644
--- a/docs/spec/i18n.md
+++ b/docs/spec/i18n.md
@@ -5,23 +5,61 @@
编辑器 UI 与游戏文本使用不同的边界。
- 编辑器 UI 使用 `IEditorLocalizationService`。它通过 DynamicLocalization 从 `GalNet.Editor/Localization` 的嵌入 JSON 资源加载 `zh-CN`、`en-US`、`ja-JP`、`ko-KR`,切换语言时通知 `CurrentCulture` 与索引器绑定。
-- Runtime 不依赖 DynamicLocalization 或某种 JSON 结构。宿主向 `GameEngine` 提供 `ITextResolver`,`EntryContext.GetText()` 用它解析文本;未提供时使用 `PassthroughTextResolver`,即把输入原样返回。
+- Avalonia Sample UI 使用 `DynamicLocalization.Core` 2.1.2 与 Sample 自有的 Avalonia 12 绑定适配层,从 Sample 程序集 `Localization/` 的嵌入 JSON 加载 `en-US` 与 `zh-CN`;`{loc:Localize ...}` 绑定读取同一 culture service。该 Sample UI 来源与游戏包 `I18n/` 内容仍是不同边界。
+- Runtime 不依赖 DynamicLocalization 或某种 JSON 结构。宿主向 `GameEngine` 提供 `ITextResolver`;内置 dialogue、NVL 和分支选项在展示前调用它。未提供时使用 `PassthroughTextResolver`,即把 key 原样返回。
-因此剧情文本字段(例如 `.galgroup` 内的 `speaker`、`content`,以及图中的分支选项)既可以直接保存文本,也可以保存宿主能识别的键。Runtime 不会自动生成、扫描或覆写游戏翻译文件。
+内置 `content` 与分支选项文本从 raw 开始就是 key;编译和复合展开原样传递。`speaker` 是人物 ID,空值表示无说话人。宿主把 `CharacterCatalog` 传给 `AddBuiltins`、`AddBuiltinImplementations` 或 `AddBuiltinDialogue`;catalog 记录唯一 `Id` 和非空 `DisplayNameKey`,内置对话先查人物再解析姓名 key,未知人物 ID 抛出内容错误。自定义参数如何本地化由 implement 决定。
## 键与稳定性
+Sample UI 按 DynamicLocalization 官方 Avalonia JSON demo 绑定 `LocalizedString.Value`;这些对象自行监听 `CultureChanged` 和 `ProvidersChanged`,因此直接更改 culture 也会刷新已有绑定。Sample 使用 Avalonia 12 的 `ReflectionBinding`,并在退出时释放缓存的 `LocalizedString` 订阅。共享页面通过宿主提供的 observable text 适配获取文案。
+
+共享 Settings 页依赖通用 `IPlayerSettingsService.Items`,按选择项与数值范围项的模板显示宿主设置,不依赖语言专用服务。Sample 注册语言和文字速度两项,变更写入当前 session 使用的 `FileVariableService`:`player.galnet.settings.locale` 与 `player.galnet.settings.textSpeed` 位于 profile 的 `player-variables.json`,不进入游戏存档。成功初始化 session 后恢复偏好;正常关闭等待已排队的保存,清空玩家数据同时删除这些偏好。
+
`I18nKey` 是点分隔键的轻量封装。推荐使用稳定的 Group/Branch ID,而不是可变的显示名称:
```csharp
-I18nKey.ForEntry(groupId, entryId, "content");
+I18nKey.ForEntry(groupId, rawEntryId, "content");
I18nKey.ForBranchOption(branchId, optionIndex, "text");
```
-这会生成如 `group-1.entry_3.content` 和 `branch-1.option_0.text` 的键。键的命名、翻译文件位置、回退策略及玩家覆盖方式均由具体宿主决定;它们不属于当前 Core/Runtime 文件格式的固定规范。
+条目 helper 接收 raw ID(字符串或整数),生成如 `group.group-1.entry.3.content` 的键;分支 helper 生成 `branch-1.option_0.text`。未来 Editor 在生成 key 时使用 raw 阶段的 ID;保存后的 key 不随位置重排或组合展开改变。编译前后的整数 `Entry.Id` 表示所在阶段的顺序位置,无需为本地化新增 source ID 属性。翻译文件加载与回退由宿主提供。
## 项目设置
-`ProjectSettings` 保存目标语言 `TargetLocale` 与可用语言 `AvailableLocales`;`I18nLocale` 内置常用的 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 值。创建项目时会建立 `I18n/` 目录,并在导出时将其打入 `Assets/content.pak`,但当前导出器不解释该目录的内容。
+`ProjectSettings` 保存默认语言 `TargetLocale` 与可用语言 `AvailableLocales`;`I18nLocale` 内置常用的 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 值。项目构建递归复制 `I18n/`,导出作为 `.galpak` 的特殊内容逐文件分发,不进入资源 PAK。
+
+## 包内游戏文本加载
+
+项目和安装内容 provider 使用相同的 `GameLocalizationLoader`,从游戏根目录读取:
+
+```text
+I18n/catalog/characters.json 人物 metadata 数组
+I18n//**/*.json 平铺或嵌套对象,叶值必须是字符串
+```
+
+人物记录如 `{"id":"guide","displayNameKey":"character.guide.name"}`。文本文件按规范化相对路径的 ordinal 顺序递归加载;catalog 与 External 子树不作为内置文本源,递归枚举不跟随 reparse point,根和 catalog 路径拒绝链接。`GameContent.Localization` 提供 catalog 和 JSON 来源描述,Core/Runtime 不引用 DynamicLocalization。
+
+加载时验证默认语言包含所有人物姓名、编译后的内置 dialogue/NVL content 和 choice 文本 key,并拒绝未知 speaker ID。无效包内 JSON 或默认语言缺 key 拒绝内容加载;自定义 entry 参数不作本地化推断。构建器只编译/复制,此检查发生在内容加载时。
+
+Sample 的 `GameTextResolver` 为每个文件创建独立 DynamicLocalization JSON provider,不合并文件;同 culture 内后文件覆盖前文件,独有 key 保留。查询顺序为当前语言 → 父语言链 → 游戏默认语言及其父语言 → key。Avalonia 使用同一个 CultureService 配置 UI 与游戏 resolver,切换设置影响后续展示文本,不重新翻译已有剧情历史。Headless 使用游戏默认语言。嵌入 UI、包内文本与外部来源在同一 adapter 中按 culture 优先查询,替换游戏保留嵌入 UI 的内容。
+
+## 外部文本覆盖
+
+`I18n/External//**/*.json` 在启动时递归读取。同 culture 内优先级是嵌入 UI → 包内游戏 → 外部 JSON;每层按 ordinal 相对路径排序,最后文件优先。损坏 JSON、非字符串叶值、无效 culture 或不可读取的外部文件记入 `GameContent.Localization.Diagnostics` 并跳过,Sample 输出诊断;外部树不跟随 reparse point。默认语言完整性只检查包内文本,不能靠外部文件补齐。
+
+外部新增语言自动进入 Sample 设置语言列表,显示 culture 的本地名称。外部来源不会修改人物 metadata,也不进入资源 provider。宿主可通过 `SampleLocalizationService.AttachGame(content, additionalSources)` 或 `GameTextResolver` 的 `additionalSources` 参数提供更高优先级的自定义 JSON 来源;其加载方式由宿主自行实现。修改文件后重新启动生效,不提供文件监听热更新。
+
+安装器创建空 External 目录,复用相同安装时保留用户文件。不同/损坏包仍拒绝覆盖既有目录,因此不会删除翻译;没有新增覆盖更新协议。导出 `.galpak` 排除 External,安装器拒绝将该保留路径作为包 payload。
+
+## 首次启动
+
+Avalonia Sample 在载入游戏内容和玩家变量后、显示标题和创建 Engine 前运行 `SampleStartupService`。`player.galnet.startup.launchCount` 缺失、无效或为零时显示语言选择页;预选已保存的可用语言,否则游戏默认语言,再否则首个可用语言,不自动采用 OS 语言。候选语言来自实际加载的内嵌/包内/外部来源,并以本地语言名称显示。
+
+语言选择页采用 MVVM:DI 注册的 `LanguageSelectionViewModel` 管理语言列表、当前选择、确认/取消命令及取消等待,View 仅初始化并使用编译绑定,窗口只协调显示与隐藏。首次选择在开始等待时从 `CultureService.AvailableCultures` 读取一次;设置页在 session attach(游戏来源加载完成后)更新语言列表。两者不订阅 provider 变化来动态刷新选项,运行时新增来源的列表刷新由二次开发宿主自行实现;UI 文案仍照常响应语言和 provider 变化。
+
+确认后将所选 locale 与启动次数一起提交到 profile 的 `player-variables.json`,成功写盘才更新内存和 culture;每次成功启动加一,计数在 Int32 上限饱和。取消选择退出窗口;关闭窗口会取消等待;取消、内容加载失败或写盘失败不会提交启动次数。后续启动跳过选择页,恢复可用的已保存语言;已移除语言回退到游戏默认/首个可用语言。没有游戏目录的 bootstrap 启动不计数。
+
+现有“清空游戏数据”会重置玩家变量并重启,因此下次再次显示首次选择。默认 profile 是每个游戏的 `.galnet`,也可用 `--profile` 显式指定;用户主动共享 profile 时也会共享偏好。该流程只在 Sample 宿主实现,Headless 不显示首次选择页。设置切换不强制重启:UI 即时刷新,已显示的人物名、对话、选项和 NVL 历史保持原文,后续条目使用新语言。
资源的区域变体可由 `AssetLocaleResolver` 通过宿主选择的目录规则定位;这与文本的 `ITextResolver` 相互独立。
diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md
index df77c135..96bd24c0 100644
--- a/docs/spec/runtime.md
+++ b/docs/spec/runtime.md
@@ -22,6 +22,8 @@ Runtime 只执行 primitive entry。Composite entry 只能存在于 `.rawgalgrou
public Task AdvanceAsync(CancellationToken cancellationToken = default);
public GameSnapshot CreateSaveData();
public void RestoreFrom(GameSnapshot data);
+public Task StopAsync();
+public ValueTask DisposeAsync();
```
一次 `AdvanceAsync()` 的顺序是:
@@ -34,6 +36,14 @@ public void RestoreFrom(GameSnapshot data);
Blocking instance 自然完成时,Engine 只执行无 skip 权限的内部 continue;它不是第二个玩家入口,也不能跳过下一个新遇到的 batch。
+### 状态通知和停止
+
+`State` 和 `StateChanged(GameEngineStateChange)` 统一报告 `Ready`、`Running`、`Completed`、`Faulted`、`Stopped`;失败通知附带原始异常。自然阻塞完成、Choice 结果/异常和玩家推进都经过同一状态机制,宿主不需要分别推断终态。通知在执行锁外按发生顺序发布,重复状态不重复通知;订阅者异常只诊断,不改变剧情状态。事件处理器不应同步等待 Engine 的异步操作;UI 线程切换、日志文案和存档槽刷新由宿主处理。
+
+`StopAsync()` 是该 Engine 的终态操作:立即拒绝新推进,取消执行 scope,清理活动实例和 pending Choice,并等待内部 continuation 退出后报告 `Stopped`。Choice 后端即使没有协作取消,Engine 的等待也能退出;平台资源本身仍由宿主释放。停止后推进返回 `false`,不能恢复该 Engine;重开使用新 Engine。正常完成后再次推进不会重放已经结束的内容。未停止时 `RestoreFrom()` 取消旧 scope 并重新进入 `Ready`。
+
+Engine 同时支持同步和异步释放;异步宿主优先使用 `await using`。释放 Engine 不释放借用的 `IGameView`:先停止/释放 Engine,再异步释放执行 view,最后释放 presenters、媒体后端及资源。不新增通用 Runner 或要求平台实现继承宿主基类。
+
## Batch 与活动实例
`PrimitiveEntry.BatchId` 是编译后的可选局部分组字段。GameView 创建实例时把它原样传入 `PrimitiveInstance.BatchId`,Engine 实际按 `(GroupExecutionId, BatchId)` 匹配 skip 批次:
@@ -62,6 +72,8 @@ Choice 节点由 Engine 求值并过滤可见选项,再通过 `IChoicePresente
`GameRuntime` 是游戏事实的唯一来源:
+内容 provider 将项目与安装目录的 `settings.json` 解析为 `GameContent.Settings`;两个 Sample 都把它注入 Runtime 的 `SettingsContainer`,自定义音频轨道不会退回默认轨道。缺少文件使用默认设置,非法 JSON 明确拒绝。
+
| 状态 | 说明 |
| --- | --- |
| `CurrentNodeId` / `EntryIndex` | 当前流程位置 |
@@ -71,7 +83,7 @@ Choice 节点由 Engine 求值并过滤可见选项,再通过 `IChoicePresente
| `SceneState` / `SceneInstances` | 可存档场景快照与按句柄管理的活跃实例 |
| 变量存储 | Player / Save 作用域变量与表达式求值 |
-`GameSnapshot` 包含 `NodeId`、`EntryIndex`、变量字典与 `SceneState`。`CreateSaveData()` 返回 Engine 保存的最后稳定快照,而不是即时抓取可能仍在异步变化的状态。
+`GameSnapshot` 当前版本为 3,旧版本直接拒绝,不提供迁移兼容。它包含 `NodeId`、`EntryIndex`、变量字典与 `SceneState`。`CreateSaveData()` 返回 Engine 保存的最后稳定快照,而不是即时抓取可能仍在异步变化的状态。所有对外快照均深复制;调用者修改保存数据或事件参数不会影响 Engine。读档取消旧 scope,并替换 SceneState/SceneInstances;旧实例保留旧场景,不能更改新运行的变量与流程,恢复时重置结束标记。
Save scope 随存档槽保存和恢复;Player scope 由宿主变量服务独立持久化,普通读档不得回滚。内置系统可以通过 `SystemVariableDefinition` 注册生成变量。Gallery 为每个 item 注册默认 `false` 的 Player bool `gallery__unlocked`;`gallery.unlock` 校验 item ID 后把该变量设为 `true`。
@@ -115,31 +127,27 @@ public void Skip();
## Entry Module 与 GameView
-宿主把一个或多个 `IEntryModule` 挂到 `CompositeGameView`。每个模块持有两张冻结表:
-
-- `PrimitiveEntries`:Runtime 可执行 primitive schema 与 instance 工厂。
-- `CompositeEntries`:只服务编辑器和编译器的 authoring 展开。
+契约和执行独立注册:
-`CompositeGameView.Dispatch()` 负责:
+- `IEntryModule` 导出 primitive 参数 schema 和编译期 composite,不创建执行服务。
+- `IPrimitiveModule` 导出完整指令 ID 到 `PrimitiveImplementation` 的表,持有游戏会话共享状态。
+- `GameRegistration` 组合两者;`BuildCatalog` 提供编译契约,`BuildView` 冻结注册并转移执行模块所有权。
+- 同前缀可多次注册不同指令;重复完整 ID、实现无对应 schema 都会报错。
+- `AddBuiltins` 是主内置入口;`AddBuiltinSchemas` 和各领域 `AddBuiltin*` 可独立选择。注册不等于提供平台能力,具体 presenter/host 由宿主传入。
-1. 按 `PrimitiveEntry.Type` 查找 `PrimitiveEntryBase`。
-2. 使用该 entry 的 `DynamicParameterTable` 规范化参数和默认值。
-3. 构造 `PrimitiveCreateContext`,其中包含 definition、规范化后的 entry、runtime、scope cancellation、arguments 与 BatchId。
-4. 调用工厂创建 instance。
-5. 验证 instance BatchId 与 entry BatchId 一致。
-6. 调用一次 `Dispatch()` 并把 instance 返回给 Engine。
+`CompositeGameView.Dispatch()` 查找完整 ID,按 schema 规范化参数并执行语义验证,构造 `PrimitiveCreateContext`,调用工厂、验证 BatchId,最后分发实例。缺失实现抛错,不返回 null;Engine 初始化时检查 Graph 所需的全部 primitive 实现。普通分发失败也不会被忽略或推进该条目的游标。
-未知 primitive 返回 `null`,由 Engine 记录诊断并继续执行。
+Engine 不拥有 view。宿主先释放 Engine,再 `await view.DisposeAsync()`,最后释放借用的 presenter 和后端;view 逆序释放全部执行模块并汇总错误。未转移的模块由 registration 释放。
-## 当前推荐模块
+## 当前内置模块
-`GalNet.Primitives.Builtins` 提供可选推荐模块。当前已实现运行时行为的能力包括:
+`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` 调用。
+- `effect.apply` / `effect.stop`:维护 `SceneState.ActiveEffects` 和 `IEffectPresenter` 调用,不另存冗余的 effect ID 索引。
- `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 帧不单独存档。
@@ -149,4 +157,8 @@ public void Skip();
- `variable.set`:求值后写入 Runtime 变量。
- `gallery.unlock`:按稳定 item ID 解锁 Gallery,写入对应的系统 Player bool。
-视频图层与音频的轨道播放、淡化、队列和当前轨道 SEffect 行为由推荐模块实现。bus/master 处理、item effect、参数动画、存档恢复和玩家音量设置仍由后续 feature 补齐。Gallery 的平台无关 catalog 与解锁数据层由 Core/Storage 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。
+视频图层与音频的轨道播放、淡化、队列和当前轨道 SEffect 行为由内置模块实现。bus/master 处理、item effect、参数动画、存档恢复和玩家音量设置仍由后续 feature 补齐。Gallery 的平台无关 catalog 与解锁数据层由 Core/Storage 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。
+
+动画 plan 的事件与轨道共用 presenter 的播放时间源。`PlayAnimationPlanAsync` 必须在采样前 await `beforeFrame(elapsedFrames)`,正常完成前提供末帧;Loop/PingPong 的 elapsedFrames 单调增加,不在循环时归零。跳过不补发未来 particle burst。
+
+音频后端请求按 voice 顺序提交,Stop 即刻取消尚未完成的 Start;异步销毁等待排队请求结束。Avalonia host 先登记待加载音频,停止/销毁取消加载,等待加载结束后释放 LibVLC 与临时目录。音频轨道状态不进入当前存档;读档会停止旧 scope 音频。
diff --git a/features/F-20261010-01-warning-cleanup/design.md b/features/F-20261010-01-warning-cleanup/design.md
new file mode 100644
index 00000000..5e014c62
--- /dev/null
+++ b/features/F-20261010-01-warning-cleanup/design.md
@@ -0,0 +1,3 @@
+# Design
+
+Use behavior-preserving substitutions only: cache immutable JSON serialization options and test expectations, use invariant numeric formatting for variable strings, select the memory-based stream write overload, and use the standard disposed-object helper. Do not narrow public return types or rename domain APIs solely to satisfy analyzers.
diff --git a/features/F-20261010-01-warning-cleanup/feature.md b/features/F-20261010-01-warning-cleanup/feature.md
new file mode 100644
index 00000000..dfe8a680
--- /dev/null
+++ b/features/F-20261010-01-warning-cleanup/feature.md
@@ -0,0 +1,16 @@
+# F-20261010-01 Warning cleanup
+
+## Goal
+
+Reduce mechanically fixable build warnings without suppressing diagnostics or changing public contracts.
+
+## Scope
+
+- Remove allocation and formatting analyzer findings in directly affected production and test code.
+- Keep domain naming, public API shape, and larger ownership/lifetime findings out of scope.
+
+## Acceptance criteria
+
+- Updated production projects compile successfully.
+- Core, Runtime, Assets, and Editor.Shared targeted tests pass.
+- No warning suppression is introduced.
diff --git a/features/F-20261010-01-warning-cleanup/phase-plan.md b/features/F-20261010-01-warning-cleanup/phase-plan.md
new file mode 100644
index 00000000..4b413bb2
--- /dev/null
+++ b/features/F-20261010-01-warning-cleanup/phase-plan.md
@@ -0,0 +1,11 @@
+# Phase plan
+
+## Phase 1 — Low-risk analyzer cleanup
+
+- Status: complete
+- Exit criteria: affected code and tests compile; targeted tests pass.
+
+## Phase 2 — Verify
+
+- Status: complete
+- Evidence: `dotnet test` passed for Core (57), Runtime (35), and Editor.Shared (16); Assets (32) passed before the final test-only cleanup.
diff --git a/features/F-20261010-01-warning-cleanup/summary.md b/features/F-20261010-01-warning-cleanup/summary.md
new file mode 100644
index 00000000..9fd97a2a
--- /dev/null
+++ b/features/F-20261010-01-warning-cleanup/summary.md
@@ -0,0 +1,23 @@
+# Summary
+
+Implemented behavior-preserving warning cleanup across Core, Assets, Runtime, Presentation abstractions, Editor, and their tests.
+
+## Implemented
+
+- Cached indented `JsonSerializerOptions` instances instead of allocating them for each write.
+- Used invariant integer formatting for `VariableValue.Int` string conversion.
+- Used the memory-based `Stream.WriteAsync` overload.
+- Replaced handwritten disposed checks with `ObjectDisposedException.ThrowIf`.
+- Reused static test expectations and corrected a test host's intentionally inert interface event implementation.
+
+## Validation
+
+- Core tests: 57 passed.
+- Runtime tests: 35 passed.
+- Editor.Shared tests: 16 passed.
+- Assets tests: 32 passed.
+- `git diff --check` passed.
+
+## Known limitations
+
+Avalonia-dependent test projects cannot be built in this restricted environment because Avalonia BuildServices is denied access to its user-local licensing ticket directory. Remaining analyzer warnings involving public naming, API shapes, ownership/lifetime, and broad type changes were intentionally retained for separate review.
diff --git a/features/F-20261010-02-runtime-i18n/design.md b/features/F-20261010-02-runtime-i18n/design.md
new file mode 100644
index 00000000..1f3759e9
--- /dev/null
+++ b/features/F-20261010-02-runtime-i18n/design.md
@@ -0,0 +1,155 @@
+# F-20261010-02 设计:游戏文本国际化与 DynamicLocalization
+
+**状态:实现完成(保留设计取舍;当前事实见 i18n spec)**
+
+## 结论
+
+首期不按 galgroup 动态装卸翻译,也不为每个 group 增加 i18n 文件 ID 列表或独立 i18n 包元数据。游戏启动时一次性、递归读取包内 `I18n/` 的 JSON,并按确定性顺序逐个交给独立的 DynamicLocalization JSON provider 解析;宿主适配器按 culture 优先、同 culture 内后文件优先查询。外部目录在 Phase 4 已加载,覆盖同 culture、同 key 的内置值。
+
+这让 `ITextResolver` 始终能按照当前 culture 解出任意已加载 group 的 key,避免进入 group 时异步加载、离开 group 后丢失已显示文本、以及保存/读档时 catalog 状态不一致的问题。文本 JSON 很小,按 group 拆文件只服务于作者组织和 diff,不影响运行时载入策略。
+
+DynamicLocalization 仅在 Sample 的宿主层使用:它拥有 culture 切换和 provider 注册,Sample 自有 Avalonia 12 `ReflectionBinding` 适配层负责 UI 绑定。Core/Runtime 继续只依赖 `ITextResolver`。`DynamicLocalization.Avalonia` 2.1.2 虽声明兼容 Avalonia 11–12,却在运行时调用了 Avalonia 12 已移除的 Binding 构造函数,因此不使用该 XAML extension。宿主把同一个文化服务同时用于 UI 与 Runtime adapter,因此外部 JSON 可以覆盖 UI 与游戏文本;键命名空间避免意外碰撞。
+
+## 当前事实
+
+- Runtime 已有 `ITextResolver.Resolve(string)`;未提供实现时会回显输入 key。
+- `GameContent.Localization` 包含人物 catalog 和平台无关的 JSON 来源描述;两个内容 provider 已读取包内 `I18n/` 并验证默认语言完整性。
+- 导出器与内容构建器已递归复制 `I18n/`,并把它作为非资源的特殊内容随 `.galpak` 分发。
+- `ProjectSettings.Speakers` 是 Editor 自动补全用的显示名字符串列表,不是人物表;当前 `dialogue.text` 的 `speaker` 参数也是字符串。
+- Sample 的玩家变量独立于存档槽,保存到 profile 目录的 `player-variables.json`;有游戏目录时默认 profile 为 `/.galnet`。
+
+## 目标与边界
+
+本设计覆盖:
+
+- Avalonia Sample UI 的嵌入 JSON、本机语言选择与运行时切换。
+- 游戏包中人物表、人物名称、galgroup 对话和分支选项的键化文本。
+- 包内内置 JSON 与安装目录外部 JSON 的递归合并、回退和覆盖。
+- 后续宿主注册自定义文本源的扩展点。
+
+本设计不覆盖:Editor i18n 迁移、翻译模板导出、Editor 翻译编辑器、图片/语音/图层等二进制本地化、网络下载与热更新协议。
+
+## 统一键空间与文件布局
+
+所有本地化文件保留 DynamicLocalization 的 JSON 格式和 `.json` 扩展名;不采用 `x_zh-CN.png` 一类资源后缀约定。建议键空间为:
+
+```text
+ui.. Sample UI
+character..name 人物显示名
+group..entry.. 对话/NVL 等条目字段
+branch..option..text 选项文本
+```
+
+项目内置文件按 culture 集中而按内容分片:
+
+```text
+I18n/
+ catalog/
+ characters.json
+ zh-CN/
+ characters.json
+ groups/
+ intro.json
+ chapter-01.json
+ en-US/
+ characters.json
+ groups/
+ intro.json
+```
+
+`I18n/catalog/characters.json` 是不随语言重复的 character catalog;它只保存角色 ID 与名称 key。`I18n//characters.json` 才保存各 culture 的名称文本。每个文本 JSON 是普通平铺或嵌套 key/value JSON;其 culture 由目录名提供,文件名不承载 locale。`groups/.json` 是作者组织约定,不是 Runtime 必需的引用关系。导出器已经递归复制 `I18n/`,不需要将这些文件写入资源 PAK。
+
+Sample UI 使用程序集嵌入的标准 DynamicLocalization JSON,例如 `Localization/zh-CN.json`;它和游戏的 `I18n/` 不共享物理位置,但在运行时合并到同一键空间。约定 `ui.` 只由 Sample UI 使用,游戏文本绝不占用该前缀。
+
+外部覆盖目录建议由安装器在解压后创建为:
+
+```text
+/I18n/External//**/*.json
+```
+
+它与包内文件同样按 culture、递归读取。安装/更新必须保留该目录;若未来安装器以重建安装目录的方式更新,则应在重建前保留并恢复 `I18n/External`,而不是把第三方翻译与包内文件混放。
+
+## 人物表
+
+新增游戏内容的 character catalog,而不是把 `ProjectSettings.Speakers` 扩展成隐式人物表。最小人物记录仅包含:
+
+```text
+CharacterId 稳定、项目内唯一,供剧情引用
+DisplayNameKey 必填,例如 character.alice.name
+```
+
+`dialogue.text`、`dialogue.textVoice` 等文本条目的 `speaker` 参数存储 `CharacterId`。Runtime 或 built-in dialogue 模块在调用 presenter 前解析人物 ID 到 DisplayNameKey,再经 `ITextResolver` 解析成显示名。缺失人物 ID 应为内容验证错误,而不是把 ID 当名称静默显示。
+
+旧内容的 speaker 显示名需要由作者显式迁移成人物 ID,并建立姓名 key;不自动把显示字符串当人物记录。内置对话实现拒绝未知非空人物 ID。人物 catalog 在内容层加载并由宿主传入模块;GameTestCase 已迁移。Editor 自动补全接线与翻译作者工具一并后续处理,不属于本期 Sample/game 范围。
+
+## 条目 key 的稳定性
+
+每个 group 的 entry 在编译前后都有表示顺序位置的 `Entry.Id`;组合条目展开会增加 entry 数量并重新编号。`content` 在 raw 中已经是完整 i18n key,编译器与 Runtime 原样传递,禁止根据执行位置重新生成。
+
+未来 Editor 自动生成 key 时,使用 raw 阶段的 ID 作为 `group..entry..` 的一段;`sourceEntryId` 仅指生成时的输入,不新增持久化属性。key 一旦保存就独立于顺序位置,后续重排和复合展开不改变它。现有序列化的 raw string `id` 与 `#<展开序号>` 编译 envelope ID 继续保留原协议,不承担运行时文本解析职责。
+
+## 哪些字段是 key
+
+不能把任意 `string` 参数自动视为 i18n key:许多 entry 的字符串是资源 ID、变量名、表达式、轨道 ID 或扩展自己的协议值。内置 gameplay schema 必须显式声明可本地化字段:人物显示名经 character catalog 解析;`dialogue.text` / `dialogue.textVoice` 的 `content`、NVL 内容和 branch option 文本均为 key。`speaker` 仅是 CharacterId,不是文本 key。
+
+自定义 entry 不受这套内置字段约定约束:其参数 schema 只负责描述参数形态,是否把某个参数视为 i18n key、如何解析和何时解析,完全由该 entry 的 implement 决定。框架不对自定义字符串做推断或转换。这样“游戏文本一律是 key”的规则只约束内置 gameplay 文本字段,不会破坏资源和插件协议。
+
+## 加载、覆盖与回退
+
+启动某游戏时,宿主的游戏 i18n 加载协调器(名称为设计建议)按如下顺序把每个 JSON 单独交给 DynamicLocalization:
+
+```text
+embedded Sample UI JSON
+ -> package I18n//**/*.json
+ -> external I18n/External//**/*.json
+ -> DynamicLocalization culture service
+ -> UI binding / ITextResolver adapter
+```
+
+合并顺序固定:
+
+1. embedded Sample UI;
+2. 包内游戏 JSON;
+3. 外部 JSON。
+
+同一层内部按相对路径的 ordinal 顺序加载,后加载者覆盖先加载者;后一层覆盖前一层。这使外部 JSON 能覆盖 UI 和游戏 key。包内无效 JSON 是内容错误,拒绝启动;外部源加载会逐文件诊断并跳过错误文件。
+
+文本解析顺序固定:请求 culture → 父 culture → `ProjectSettings.TargetLocale` → 原 key。默认语言必须包含所有运行时必需的游戏 key;内容构建/验证应检查该不变量。Runtime 不承担 culture 查找或文件 I/O,adapter 负责这一逻辑。
+
+实测 2.1.2 的 `LoadJsonString` 会替换同 culture 的整个字典,且 JSON provider 不查询父 culture。因此采用设计中的宿主聚合 adapter 退路:每份文件使用独立 provider,由 `GameTextResolver` 查询当前/父/默认语言,每个 culture 内倒序查源。这样保留独有 key 和后文件覆盖语义;不预合并 JSON,不重写库的 JSON 展平算法。两个 Sample 以 linked source 共享纯宿主适配器,避免为示例新增生产项目或让 Headless 依赖 Avalonia。内容加载验证默认语言必需 key,构建器本身仍只编译/复制;Editor 构建时诊断后续处理。
+
+## 为什么不在载入 galgroup 时动态载入
+
+候选方案是每个 group meta 保存 JSON 文件 ID,进入 group 时注册相应 provider,并在离开后卸载。首期不选它,因为:
+
+- `ITextResolver` 只接收 key,不带 group 上下文;选择、历史 NVL、回放和异步展示可能在 group 切换后仍需旧文本。
+- provider 注册/卸载造成 culture 切换与读档重放的时序状态,需要额外可存档或可重建的加载集。
+- group 文本通常远小于图像、音频和视频;一次性载入更可预测,且与当前递归复制整个 `I18n/` 的发布模型一致。
+
+因此 group 的 i18n 文件不需要在 `.galgroup` meta 中保存 ID。未来若有测得的文本规模问题,可新增按 group 的 lazy catalog cache;它应由完整的文档/运行状态管理,而不是把文件路径泄漏给 Runtime。
+
+## 包、更新与元数据
+
+现有 `.galpak` 导出对包内 `I18n/**` 逐文件打包,但排除保留的 `I18n/External/**`,安装过程已有包 manifest 的文件完整性职责。因此首期不创建“这是 i18n 包”的额外元数据文件,也不创建独立 i18n PAK。
+
+未来热更新或补丁若确实需要独立下载、版本协商或卸载翻译层,可在那个 feature 中引入版本化 localization manifest。其触发条件应是实际的补丁/网络分发需求,而不是为静态本地 JSON 提前增加协议。
+
+## 首次语言选择
+
+2026-10-11 后续整理采用 MVVM:ViewModel 负责选择状态、确认/取消命令和异步等待;View 使用编译绑定,窗口保留显示协调。语言列表在游戏来源完成加载后读取 `CultureService.AvailableCultures` 快照,不监听运行时 provider 变化;设置页在 session attach 时刷新一次,动态新增来源的交互扩展留给宿主二次开发。
+
+Sample 读取本机 profile 的玩家变量:首次启动计数为零时,以嵌入 UI 的 bootstrap culture 显示语言选择页。用户确认后将所选 locale 与启动计数一起持久化,写盘成功后才发布内存更新。取消或异常不增加计数,因此下次仍显示选择页。后续启动读取所选 locale 并在创建 GameEngine 前设置 catalog/current culture;重置玩家变量会重新触发该流程。
+
+## 验收影响
+
+- Character catalog 验证唯一 ID、非空 display-name key,并由内置对话拒绝未知的 speaker ID。
+- 同一 culture 下的多个内置与外部 JSON 的覆盖结果可复现,且不依赖枚举顺序。
+- UI markup 与 Runtime dialogue/choice 使用同一选择 culture;外部文件可以覆盖两者。
+- 默认语言缺失必需游戏 key 时,内容验证失败;非默认语言缺失时按回退链解析。
+- 首次选择、确认、取消、重启与重置玩家变量均可自动化验证。
+
+## 风险与后续决策
+
+- 需在实现前 pin DynamicLocalization 版本并验证其 Avalonia markup、动态 provider 和 culture-change 行为。
+- Character catalog 的具体持久化文件(例如 `I18n/characters.json` 的 metadata 与 display key 分离,或独立 `characters.json`)需要以 Editor/项目设置序列化的现有约束选择;它不应随着 culture 重复。
+- 外部目录在包更新时的保留需要由安装器实现测试保证。
diff --git a/features/F-20261010-02-runtime-i18n/feature.md b/features/F-20261010-02-runtime-i18n/feature.md
new file mode 100644
index 00000000..f0af3a28
--- /dev/null
+++ b/features/F-20261010-02-runtime-i18n/feature.md
@@ -0,0 +1,124 @@
+# F-20261010-02 Runtime i18n with DynamicLocalization
+
+**Status:** complete
+
+Phase 1–5 已完成并验证;外部覆盖、首次选择、按游戏保存语言与启动计数均已接通。实现总结与测试边界见 [summary.md](summary.md)。
+
+**Selected package:** [DynamicLocalization.Core](https://github.com/StarryXYJ/DynamicLocalization) 2.1.2, MIT-licensed. The Sample uses its JSON provider, culture service, and `LocalizedString`; an Avalonia 12 binding adapter replaces the incompatible platform extension.
+
+## Original goal
+
+Use the DynamicLocalization package to implement end-to-end i18n. The feature should cover UI localization and language switching; game text and character localization; embedded i18n resources and runtime loading of third-party external language resources; and a first-launch language-selection page.
+
+The proposed runtime loading model is:
+
+- Load embedded i18n resources from designated folders.
+- Create an empty, documented external-language directory beside an installed, extracted game package.
+- Recursively discover third-party JSON localization files in that external directory at runtime.
+- Keep interfaces in Core and register/pass implementations through the DI composition root, because DynamicLocalization is DI-based.
+
+The proposed content model is:
+
+- UI text uses the package's Avalonia extensions or view-model bindings and built-in JSON files.
+- Character records store a stable i18n key for their display name.
+- Dialogue and other repeated narrative content stored in `.galgroup` use i18n keys.
+- A galgroup owns its associated localization JSON files.
+
+## Problem
+
+The current repository has only the two boundary contracts documented in `docs/spec/i18n.md`: Editor UI localization via DynamicLocalization and Runtime text resolution via `ITextResolver`. It deliberately leaves game translation file shape, player override discovery, fallback behavior, and asset locale selection to individual hosts. There is no defined player-facing language setting, first-launch selection flow, external translator-pack contract, or runtime implementation that binds those choices together.
+
+## Scope
+
+- Define and implement (in later feature phases) a player UI localization and settings-switching experience based on DynamicLocalization and Avalonia.
+- Preserve a platform-neutral Core/Runtime text-localization abstraction, with DynamicLocalization-specific adaptation and registration outside Core/Runtime.
+- Define embedded-resource, installed-package, and external third-party language-pack boundaries.
+- Localize game text, including dialogue, branch choices, and character display names, through stable keys rather than user-visible source text.
+- Deliver this in phases, beginning with the Avalonia Sample as the reference implementation; broader player/launcher adoption follows only after Sample validation.
+- Provide first-run language selection through machine-local player-variable storage that records launch count, supports existing player-data reset behavior, and persists the selected language for subsequent launches. This preference is outside game-save scope and is not synchronized across games or machines.
+- Preserve the existing project `I18n/` special-content boundary and `.galpak` installation model.
+
+## Non-goals
+
+- Implementing the design, changing production code, adding package references, or selecting a package version during discovery.
+- Replacing the existing Editor localization service without an explicit compatibility/migration decision.
+- Localizing arbitrary third-party plug-in UI unless its extension boundary is defined as part of the later design.
+- Making raw filesystem paths the formal identity of game assets; stable asset IDs remain authoritative.
+- Generating translator templates or adding Editor translation authoring/import tooling; those follow after the runtime loading and override model is proven.
+- Localizing non-text assets (images, voice, layers, and other binary resources), including locale-suffixed filename conventions.
+
+## Initial acceptance criteria
+
+- A player can select a language on first entry before normal UI content is shown; the selection persists and can later be changed in settings.
+- Built-in UI translations load from embedded resources and update UI bindings after a language change.
+- Game text resolves through a Runtime/Core-owned contract, independently of DynamicLocalization's concrete JSON/API types.
+- Installed content can supply built-in game translations, and valid third-party JSON translations placed below the designated external directory are recursively discovered at runtime.
+- Runtime behavior is deterministic when built-in and external sources conflict; external JSON can override both Sample UI and game-text keys.
+- A character catalog combines stable character IDs with character-name i18n keys; dialogue `speaker` parameters store character IDs rather than display names.
+- Character names, dialogue, and branch options require stable keys rather than direct text. Resolution falls back to the configured default language; only a key missing even from that default language is displayed verbatim.
+- Invalid external localization content cannot crash normal startup; valid external translations use a deterministic, documented override order.
+- Automated tests cover culture selection/persistence, source precedence, text fallback, external discovery, and character-ID speaker resolution.
+
+## Constraints and existing facts
+
+- `docs/spec/i18n.md` currently specifies that Runtime does not reference DynamicLocalization or a JSON format. It receives `ITextResolver` from its host; `PassthroughTextResolver` preserves existing direct-text content when no resolver is provided.
+- There is no character table today. `ProjectSettings.Speakers` is only a mutable `List` used for Editor dialogue-speaker autocomplete; `.galgroup` dialogue entries pass `speaker` as a plain string. This feature needs a later design decision to replace or supplement that list with a stable character catalog whose records own a character ID and display-name i18n key.
+- `content` is already a localization key in raw content and is passed unchanged through compilation and Runtime. Future Editor key generation uses the raw-stage entry ID once; entry positions may change during composite expansion. No extra SourceEntryId field is required in compiled or Runtime content.
+- `ProjectGameContentProvider` and `InstalledGameContentProvider` already treat `I18n/` as special content. It is not an `IAssetProvider` input. Installed resource PAKs are limited to `Assets/Paks/**/*.pak`.
+- `FilePlayerVariableStore` already persists player variables separately from save slots as `player-variables.json` in the selected profile directory. The Avalonia Sample defaults that profile to `/.galnet` when a game directory is supplied, which matches a local, per-game, non-save scope; its existing clear/reset path resets these variables.
+- Assets remain formally identified by stable IDs, not paths. This feature does not add localized asset variants or filename conventions.
+- Core/Runtime must remain decoupled from Avalonia, DynamicLocalization, and concrete filesystem implementations.
+- The package API and license must be verified from the requested GitHub link before a design commits to its registration, resource schema, or Avalonia binding APIs.
+
+## Assumptions to validate
+
+- “UI” means the Avalonia Sample/player UI; the existing Editor UI localization service is outside this feature.
+- Built-in game translations are distributed inside the game package under `I18n/`; third-party translations reside in the installed game's reserved I18n/External directory, outside manifest-managed package payloads.
+- Third-party language sources are data-only JSON packs, not code or arbitrary resource archives.
+- External language sources may add new languages and override built-in text for an existing language. They apply to both UI and game text, and do not supply localized binary assets.
+- The selected locale and onboarding counter are local to the machine's player-variable storage. They are intentionally excluded from a game save and have no cross-game or cross-machine synchronization requirement.
+- Game-localizable fields are keys, not direct display strings. The configured project default language must provide the authoritative base translation for every required game-text key.
+
+## Confirmed product decisions
+
+- Scope is game i18n and the Avalonia Sample; migration of the existing Editor UI localization service is out of scope.
+- Delivery is phased, with the Avalonia Sample implemented and validated first.
+- External third-party resources are recursively discovered JSON files in the designated directory and may enhance or override a language's built-in text.
+- First-launch detection is based on a persisted player variable holding a launch count. Each game startup increments the count. Resetting player data resets the count to zero, making the onboarding flow testable.
+- Text fallback is exact locale → parent locale → configured default locale → visible i18n key. Direct text is not a supported game-text fallback or authoring form.
+- Localized binary resources and filename suffix conventions are out of scope; only text JSON resources are localized in this feature.
+- The character catalog and character i18n are one game-content feature: dialogue `speaker` stores a stable character ID, and the catalog resolves that ID to a display-name key.
+
+## Candidate implementation order (not yet a formal phase plan)
+
+1. **Avalonia Sample UI:** register DynamicLocalization in the Sample composition root, load its built-in embedded UI resources through the Avalonia-facing integration, bind UI text, and support runtime language switching.
+2. **Built-in game content loading and character catalog:** define the platform-neutral game i18n source/resolver boundary; add a stable character catalog whose records contain display-name i18n keys; change dialogue `speaker` to store character IDs; load the bundled character and galgroup translation resources, then pass the resolver service to Runtime.
+3. **Game text adoption:** introduce stable character/display-name and galgroup text keys, text fallback, and Runtime resolution without exposing DynamicLocalization types to Core/Runtime.
+4. **External JSON overlays:** recursively load the designated third-party text directory, apply deterministic override order to the shared UI/game key space, and allow host applications/developers to register additional sources through the service/composition boundary. Template export and Editor tooling are deferred.
+5. **First-run language selection:** add the Sample's machine-local player variables, picker flow, reset behavior, and persistence.
+## Design
+
+The proposed architecture, file layout, provider/adapter boundary, eager game-text catalog, external overlay rules, character catalog, and reasons for rejecting per-galgroup dynamic loading are in [design.md](design.md).
+
+The dependency-ordered, independently verifiable implementation plan is in [phase-plan.md](phase-plan.md).
+
+**Implementation progress:** Phases 1–5 are verified. Embedded UI, character/game keys, recursive built-in/external JSON loading, deterministic culture-first overlays and fallback, persisted settings, first-run selection and successful-startup counting are implemented. Editor authoring tools, binary localization and live retranslating displayed story text remain out of scope. Verification, deviations and limitations are recorded in [summary.md](summary.md) and [review.md](review.md).
+
+## Required design decisions created by the confirmed scope
+
+- Define the machine-local player-variable name and the transaction/order of: read count → show language picker when zero → persist locale → increment count. The flow must avoid marking onboarding complete when the user exits/cancels before a locale is committed.
+- Define whether the language picker reappears after a data reset before the first playable game session, and whether an OS locale is only a preselected suggestion.
+- Define a deterministic external-pack ordering and collision rule: external packs override built-in sources; among external files, lexical normalized-relative-path order is applied last-wins. Collision logging is optional diagnostics, not required behavior.
+- Decide whether DynamicLocalization's `ICultureService` is used directly only by UI and wrapped by a host-side `ITextResolver` adapter for Runtime game text. Core/Runtime must not reference DynamicLocalization package types.
+
+## Open questions
+
+1. Should operating-system language merely preselect the first-run picker choice, with explicit user confirmation required? This is optional; it does not block the implemented settings flow.
+
+## Related documents
+
+- `docs/spec/i18n.md`
+- `docs/spec/assets.md`
+- `docs/spec/architecture.md`
+- `docs/spec/file-formats.md`
+- `features/F-20260929-01-compiled-content-pipeline/design.md`
diff --git a/features/F-20261010-02-runtime-i18n/history/summary-20261010.md b/features/F-20261010-02-runtime-i18n/history/summary-20261010.md
new file mode 100644
index 00000000..8aa6838e
--- /dev/null
+++ b/features/F-20261010-02-runtime-i18n/history/summary-20261010.md
@@ -0,0 +1,29 @@
+# Runtime i18n 阶段总结
+
+日期:2026-10-10。状态:阶段性提交,Feature 仍在实现中。
+
+## 已实现
+
+- Sample 使用 DynamicLocalization.Core 2.1.2 的嵌入 JSON、CultureService 与 LocalizedString.Value。自有 Avalonia 12 ReflectionBinding 适配层替代不兼容的平台扩展;直接修改 CultureService 也更新已有绑定。
+- 共享页面提供通用 IPlayerSettingsService 和选择/数值设置项,Sample 提供语言与文本速度。设置标签、常用静态页面文本及调试窗口使用动态本地化绑定。
+- 设置复用会话的 Player 变量存储,不覆盖其他玩家变量;会话初始化后恢复,修改时排队写入,清空玩家状态及销毁会话前等待写入。
+- Core 提供人物 ID/name-key catalog;内置 dialogue/NVL 通过角色 ID 和 ITextResolver 解析文本。raw content 中已有的 key 原样通过编译和运行期,不按执行位置重写。
+- Sample 启动脚本将默认 profile 放在项目 .galnet 中,避免把玩家数据放入可替换的构建输出。
+
+## 验证
+
+本次提交前执行代码空白格式化、IDE0005 无用 using 清理及 git diff --check。
+测试通过:Core 61、Builtins 52、Presentation 76、Integration 43、Architecture 24,共 256 项,0 失败。Presentation 输出另有真实媒体场景跳过提示,不能据此声称本机媒体验收完成。集成测试包含真实 Avalonia TextBlock 绑定的直接 culture 更新、设置持久化/恢复及其他 Player 变量保留、嵌入资源与 JSON 同 key 覆盖。
+
+## 未完成与边界
+
+- Phase 3 的游戏人物文件加载、游戏翻译加载和 Sample resolver 接线未完成。因此旧 Sample 剧情的 Guide 尚未提供角色记录,点击启动游戏的 Unknown character ID 异常仍是已知问题,不是本阶段完成的验收项。
+- Phase 4 的外部翻译目录发现、验证及确定性覆盖未完成。provider 单元级 JSON 覆盖测试不等于完整外部包加载已实现。
+- Phase 5 首次语言选择与启动计数未完成。设置存储仅在游戏会话成功初始化后接入,不能声称无游戏/初始化失败时的设置已持久化。
+- 任意 ViewModel 业务字符串及已显示剧情不会因 culture 自动翻译;只有接入 LocalizedString 的动态绑定具备当前刷新行为。
+- 未完成完整 GUI 游戏流程验收;已有分析器和 AVLN3001 构建警告仍保留。
+- 原 Output 目录替换失败的访问拒绝未确认最终根因;独立 Output-new 可重复构建不代表原目录问题已修复。生成输出和官方参考仓库不进入提交。
+
+## 文档与经验
+
+实现事实同步至 docs/spec/i18n.md 与 docs/spec/control.md。经验记录见 L-20261010-05(平台扩展二进制兼容与官方动态绑定机制)及 L-20261010-06(已存储 key 的作者边界)。后续继续按 phase-plan.md 推进,不将阶段性提交标记为整个 Feature 完成。
diff --git a/features/F-20261010-02-runtime-i18n/phase-plan.md b/features/F-20261010-02-runtime-i18n/phase-plan.md
new file mode 100644
index 00000000..737d9ba2
--- /dev/null
+++ b/features/F-20261010-02-runtime-i18n/phase-plan.md
@@ -0,0 +1,185 @@
+# F-20261010-02 实施 Phase Plan
+
+**设计依据:** [design.md](design.md)
+
+**状态:** implementation complete
+
+## Phase 1 — Avalonia Sample UI 本地化基础
+
+**状态:** verified(2026-10-10)
+
+**目标:** 在 Avalonia Sample 宿主中引入固定版本的 DynamicLocalization.Core,加载程序集内嵌 UI JSON,使用 Sample 自有 Avalonia 12 绑定适配层支持运行时 culture 切换,但不改变游戏内容格式。
+
+**前置条件:** 确认目标 NuGet 版本与 Avalonia 目标框架兼容。
+
+**涉及模块:** `GalNet.Sample.Avalonia`、Sample composition root、必要的 Presentation 测试项目。
+
+**任务:**
+
+1. 固定 `DynamicLocalization.Core` 2.1.2;以小型 characterization test 验证嵌入 JSON、culture change、逐文件动态加载与重复 key 的后加载覆盖语义。
+2. 在 Sample composition root 注册唯一的 culture service,嵌入 `ui.*` JSON,并通过 Sample 的 Avalonia 12 绑定适配层暴露本地化文本。
+3. 保证 UI culture 改变会刷新现有页面绑定,且未加载游戏时仍能显示 bootstrap UI。
+
+**测试与验证:**
+
+- 嵌入 `zh-CN`、`en-US` 等 UI 资源可被解析。
+- culture 切换后 UI binding 刷新。
+- 两个按确定顺序加载的 JSON 含同 key 时,后加载值生效。
+
+**风险:** 库的多 provider 优先级可能不同于预期;本 Phase 的 characterization test 是后续所有覆盖规则的依据。
+
+**退出条件:** Sample UI 独立完成嵌入资源加载和切换;库的加载/覆盖行为有自动化证据。
+
+**验证证据:**
+
+- `dotnet build src\Samples\GalNet.Sample.Avalonia\GalNet.Sample.Avalonia.csproj --no-restore -m:1 -p:UseSharedCompilation=false -p:AvaloniaTelemetryOptOut=true` 成功;移除 `DynamicLocalization.Avalonia` 2.1.2 后输出不含其 DLL,且仅有既有 Avalonia runtime-loader 可达性警告。
+- `dotnet test test\GalNet.IntegrationTests\GalNet.IntegrationTests.csproj --no-restore -m:1 -p:UseSharedCompilation=false -p:AvaloniaTelemetryOptOut=true --filter FullyQualifiedName~SampleLocalizationTests`:1/1 通过,验证内嵌中英文 JSON 与后加载覆盖。
+
+## Phase 2 — 稳定游戏文本契约与人物表
+
+**状态:** verified(2026-10-10)
+
+**目标:** 建立人物 catalog 与内置文本字段的 key 契约;raw 中已有 key 在编译、复合展开和 Runtime 之间保持一致。
+
+**前置条件:** Phase 1 已确认库行为;不依赖外部语言文件。
+
+**涉及模块:** `GalNet.Core`(人物与 key 契约)、`GalNet.Runtime`(compiled group loader)、`GalNet.Primitives.Builtins`(dialogue/NVL/choice)、Editor 共享编译/验证层及相关测试。
+
+**任务:**
+
+1. 定义 character catalog DTO/验证:唯一 CharacterId、必填 DisplayNameKey;以模块参数接收 catalog。文件加载留在 Phase 3;Editor 自动补全随后续作者工具处理。
+2. 编译前后继续用 `Entry.Id` 表示顺序位置,不增加 SourceEntryId 持久化字段。key 由未来 Editor 根据 raw 阶段的 ID 生成一次并保存为 content;本期 compiler 原样传递。
+3. 将内置 `speaker` 统一解释为 CharacterId;内置 content/NVL/choice 文本统一解释为 key。未知人物 ID 是内容错误;默认语言必需 key 校验归 Phase 3。
+4. 保持自定义 entry 不受通用字符串推断影响;其实现自行决定参数的本地化语义。
+
+**测试与验证:**
+
+- 在 group 插入或重排条目后,既有 raw entry ID 对应的 key 不变。
+- voice-text composite 展开后,文本 primitive 保留原 key。
+- 有效/重复人物 ID、未知 speaker CharacterId、key 化 choice/dialogue 的验证测试。
+
+**风险:** 现有项目使用 speaker 显示名。迁移或拒绝策略必须在实现时以明确诊断处理,不能静默改变显示名称。
+
+**退出条件:** 内置游戏文本和人物显示名经 resolver 解析,编译和展开不改写 content key;无需 Runtime source identity。
+
+**验证证据:**
+
+- Core i18n 测试 12/12 通过:人物 ID/姓名 key 必填、重复 ID 拒绝与生成 helper。
+- Builtins 全组 52/52 通过。
+- Integration 定向测试 28/28 通过:编译展开与插入后 key 不变、姓名/对话/NVL/choice 解析、未知人物拒绝及原有 loader/engine 行为。
+- 未新增 compiled 或 Runtime 的 SourceEntryId 字段,未改写 raw content,现有文件格式保持不变。
+- 人物 catalog 文件加载、Sample 的 catalog/resolver 接线和项目示例迁移待 Phase 3;现有含非空 speaker 的宿主需提供 catalog。
+
+## Phase 3 — 包内游戏 i18n 加载与 Runtime resolver
+
+**状态:** verified(2026-10-10)
+
+**目标:** 从项目/安装内容的 `I18n/` 递归加载人物 catalog 与 per-culture JSON,并把同一 culture service 适配为 Runtime `ITextResolver`。
+
+**前置条件:** Phase 2 契约和测试通过。
+
+**涉及模块:** `GalNet.Runtime.Content`、`GalNet.Storage.FileSystem` 内容 providers、Sample game/session composition、`GalNet.Core.Services.ITextResolver` adapter、集成测试。
+
+**任务:**
+
+1. 实现 `I18n/catalog/characters.json` 与 `I18n//**/*.json` 的安全递归枚举、路径规范化和 JSON 验证。
+2. 在项目和安装内容 provider 中加载 i18n 描述,向 Sample composition 暴露足以建立 resolver 的内容;保持 `I18n/` 不进入 `IAssetProvider`。
+3. 按明确顺序逐文件调用 DynamicLocalization 动态加载,不在 GalNet 中重写 JSON 合并算法。
+4. 实现 `ITextResolver` adapter:请求 culture → 父 culture → `ProjectSettings.TargetLocale` → key;Core/Runtime 不引用第三方包类型。
+5. 对默认语言做完整性验证,至少覆盖人物、group entry 与 branch key。
+
+**实施调整:** 2.1.2 的 `LoadJsonString` 替换整 culture 字典、JSON provider 不做父语言回退。采用每文件独立 provider + 宿主有序查询的 adapter,不合并 JSON 文件。两个 Sample 以 linked source 共享 `GameTextResolver`,无新增生产项目,Core/Runtime 无第三方依赖。默认必需 key 在内容加载时验证;构建时诊断与 Editor 人物自动补全仍属后续作者工具。
+
+**测试与验证:**
+
+- Project 与 installed content 对相同 i18n tree 得到等价解析结果。
+- dialogue、NVL、choice 与人物名按当前/父/默认 culture 回退。
+- 默认语言缺 key 被内容验证拒绝;非默认语言缺 key 正确回退。
+- `I18n/` 文件不被资源 asset provider 枚举。
+
+**风险:** 载入时机必须先于 GameEngine 建立;包内 UI/game key 的命名空间冲突应由 `ui.` 保留前缀防止。
+
+**退出条件:** 仅靠包内内容,Sample 能以所选 culture 运行 key 化剧情和人物名,并保持 Runtime 无第三方包依赖。
+
+**验证证据:**
+
+- GameTestCase 补齐人物表、游戏默认语言与 en-US/zh-CN 的人物、分支、四个 group 文本;所有 raw content 保持为 key。
+- Integration 全组 52/52 通过,包括中英文 × 两条路线运行到 Completed,dialogue/NVL/choice/人物名解析,以及 Project/Installed 等价、逐文件保留/覆盖、父/默认回退、默认 key 缺失与未知人物拒绝、内嵌 UI provider 保留。
+- Architecture 24/24 通过;Headless 构建 0 warning/0 error。
+- Editor.Headless 构建 `artifacts/sample-i18n-build` 成功(4 groups);Headless 实际运行 tiled 路线到 `Runtime smoke test complete.`,exit 0。
+- Avalonia 实际启动使用独立测试 profile,日志确认 `Session initialized`、6 个 sprite 预载成功、无初始化异常;验证后关闭了测试进程。未手工验收 GUI 全程点击、视频/音频效果;逻辑双路线由自动化测试覆盖。
+- 当前事实同步到 `docs/spec/i18n.md`、`docs/spec/file-formats.md` 和 GameTestCase README。外置覆盖、首次语言页不在本阶段完成范围。
+
+## Phase 4 — 外部 JSON 覆盖源
+
+**状态:** verified(2026-10-11)
+
+**目标:** 让已安装游戏递归加载 `I18n/External//**/*.json`,以确定性方式覆盖包内游戏和 Sample UI 文本。
+
+**前置条件:** Phase 3 完成且逐文件加载覆盖语义已由 Phase 1 锁定。
+
+**涉及模块:** 安装/内容加载边界、Sample composition、文件系统集成测试;必要时安装器。
+
+**任务:**
+
+1. 创建并保留 External 目录;更新/重新安装流程不得删除第三方语言文件。
+2. 路径按 ordinal 相对路径排序后逐个加载;外部层位于内置层之后。
+3. 将无效 JSON 限制为单个文件诊断,不损害已加载语言和正常启动。
+4. 提供宿主注册额外 source 的组合根扩展点,而不将文件系统或 DynamicLocalization 类型泄漏进 Core/Runtime。
+
+**测试与验证:**
+
+- 外部 JSON 覆盖一个 `ui.*` key 和一个 group/character key。
+- 嵌套外部文件的相对路径确定最后覆盖值。
+- 更新/install 保留 external 文件;损坏外部文件不会阻止其余翻译加载。
+
+**风险:** 当前安装器的更新策略需要被实际验证;不要在没有证据时假定目录保留。
+
+**退出条件:** 翻译者手动放入 JSON 即可覆盖界面和剧情文本,且行为跨启动可复现。
+
+**验证证据:** Integration 55/55 通过:递归顺序、损坏/非字符串文件跳过、UI 与游戏覆盖、独有 key 保留、culture 优先、自定义更高优先级来源和新增语言设置选项。安装复用与拒绝不同包均保留第三方文件,导出排除 External。安装器未新增覆盖更新机制,继续拒绝覆盖不同安装。嵌入 UI provider 纳入同一有序 adapter,避免 provider 注册顺序让外部 UI 覆盖失效。
+
+## Phase 5 — 首次语言选择与持久化
+
+**状态:** verified(2026-10-11)
+
+用户调整顺序,先完成设置页;Phase 5 现已接通首次语言页与启动计数,候选语言来自实际加载来源。正常 UI 在初始化/确认完成后才显示;设置变更的剧情文本从下一条起生效,不强制重启。
+
+验证:Sample 构建成功;`SampleLocalizationTests` 3/3 通过,包括已建立的 Avalonia TextBlock binding 在直接 SetCulture 后更新,以及偏好保存/恢复且保留其他玩家变量。
+
+**目标:** 在 Sample 以本机、按游戏、非存档的玩家变量保存语言与启动次数,并实现可重置的首次语言选择页。
+
+**前置条件:** Phase 1 culture service 和 Phase 3 game resolver 均可在 Engine 创建前配置。
+
+**涉及模块:** `GalNet.Sample.Avalonia` session/startup、`FilePlayerVariableStore` 使用点、Sample settings UI、自动化测试。
+
+**任务:**
+
+1. 定义受保留命名空间保护的 player variables:所选 locale 与启动计数。
+2. 启动时先读 player variables;计数为零时用 bootstrap UI 显示选择页。
+3. 确认选择时原子地保存 locale 后增加计数;取消/异常不增加计数。
+4. 后续启动在 Engine 创建前应用已保存 locale;设置页可重新选择语言;既有“清空游戏数据”重置这两个变量。
+
+**测试与验证:**
+
+- 首次、确认、取消、重启和 reset 流程。
+- 选择 language 后 UI 与游戏文本同步更新;保存不进入 save slot。
+- profile 内不同游戏目录互不污染。
+
+**风险:** language picker 在游戏内容未准备好时仍需有可用 bootstrap UI;不要让失败的内容加载永久写入 onboarding 完成状态。
+
+**退出条件:** Sample 首次启动可选语言、后续可持久化并从设置切换,重置后可重复验收。
+
+**验证证据:** Integration 60/60 通过(新增五项 startup/control 测试),覆盖首次确认、取消/关闭 token、写盘失败、重复启动计数、重置、profile 隔离、外部新增语言和选择控件按钮。启动事务在 settings attach/Engine 建立前写盘;取消或内容初始化失败不提交计数。真实 Avalonia 空 profile 启动等待选择时正常关闭,进程退出且没有创建玩家变量文件;没有进行完整 GUI 确认后剧情全程的人工验收。Core 61、Runtime 49、Builtins 52、Storage 12、Presentation 77、Architecture 24 均通过;原生影音 opt-in 测试未启用。Headless 构建成功。
+
+## Phase 5 后续整理 — MVVM 语言选择(2026-10-11)
+
+语言选择页改为 DI ViewModel + 编译绑定,选择状态与确认/取消命令不再放在 View code-behind。首次页及设置页从已加载服务读取 culture 快照;设置在 session attach 时更新,不订阅运行时 provider 变化。验证:Integration 62/62 通过,包含命令绑定、选择状态、并发选择拒绝、取消/关闭、外部新增 culture、回退预选和语言列表快照。
+
+Architecture 24/24 通过;本次变更文件已局部格式整理,diff whitespace 检查通过。真实 Sample 用独立 profile 启动并在首次选择期间正常关闭,未写入玩家变量;未进行完整 GUI 剧情人工验收。
+
+## 跨 Phase 文档与完成条件
+
+- 每个 Phase 落地后更新 `docs/spec/i18n.md` 与受影响的内容格式文档,只记录已验证事实。
+- 任何发现的非显然问题先写入 `.agents/lessons/`;结束时按 closeout 流程整理。
+- 完成前应顺序运行受影响项目的测试,避免共享 `obj` 并行构建争用;验证具体命令由实施时的项目依赖与环境确定。
diff --git a/features/F-20261010-02-runtime-i18n/review.md b/features/F-20261010-02-runtime-i18n/review.md
new file mode 100644
index 00000000..6eb7ee7f
--- /dev/null
+++ b/features/F-20261010-02-runtime-i18n/review.md
@@ -0,0 +1,23 @@
+# Runtime i18n 交付检查
+
+日期:2026-10-11
+
+结论:pass-with-follow-up
+
+本检查由实现 agent 对需求、设计、diff、测试和生命周期做交付自查,不等同于独立 reviewer 的审核。没有发现阻止本期文本国际化交付的问题。
+
+## 检查结果
+
+- Core/Runtime 无 DynamicLocalization/Avalonia 新依赖;JSON I/O 在 FileSystem,provider/resolver 和 UI 在 Sample。
+- raw key 原样传递;默认语言完整性在包内内容加载时检查,外部翻译不能掩盖缺失默认文本。
+- culture-first、同 culture 内层级/文件顺序覆盖及独有 key 保留有测试;UI 通过同一 adapter 获得覆盖。
+- External 不进入导出 manifest 或资源源;相同安装复用保留翻译,不同安装仍拒绝覆盖。
+- 首次选择发生在 Engine 创建前。语言/count 同一次写盘,失败不发布内存;settings attach 在其后,避免分开的后台偏好保存提前标记首次流程完成。
+- 窗口关闭取消 startup token,再等待 session 生命周期锁;真实进程关闭验证没有死锁或计数写入。
+- 重置使用已有玩家变量清空/重启路径,不为此重构进程内资源释放。
+
+## 验证与后续
+
+Integration 60、Core 61、Runtime 49、Builtins 52、Storage 12、Presentation 77、Architecture 24 通过。Headless 构建通过。真实 Avalonia 首次选择等待期间关闭验证通过。
+
+后续人工验收应覆盖真实 GUI 确认 → 中英文剧情两条路线 → 设置切换 → 退出/再次启动 → 调试重置。当前自动化覆盖各组成部分及逻辑剧情;未冒充完整 GUI 全程验证。原生影音 opt-in 测试未启用。安装目录覆盖更新协议、编辑器作者工具、文件监听热更新、非文本资源与当前剧情即时重译不属于本期实现。
diff --git a/features/F-20261010-02-runtime-i18n/summary.md b/features/F-20261010-02-runtime-i18n/summary.md
new file mode 100644
index 00000000..90864384
--- /dev/null
+++ b/features/F-20261010-02-runtime-i18n/summary.md
@@ -0,0 +1,39 @@
+# Runtime i18n 实现总结
+
+日期:2026-10-11
+
+原 [2026-10-10 阶段性总结](history/summary-20261010.md) 保留为历史;其中未完成事项描述的是当时状态,并非当前结论。
+
+Phase 1–5 已完成;[阶段证据](phase-plan.md)、[交付自查](review.md) 和 [当前国际化契约](../../docs/spec/i18n.md) 是具体依据。
+
+## 实现结果
+
+- Sample UI 嵌入中英文资源;同一个 culture service 驱动 UI 与游戏文本 resolver。
+- 人物 catalog 保存 ID/姓名 key;dialogue、NVL 和选项从 raw 起保存 key,编译展开不生成或改写它。
+- FileSystem 递归加载包内文本,校验默认语言、人物引用和 JSON 结构;外部目录递归覆盖 UI/game,损坏文件独立诊断跳过。
+- 宿主可传入额外 JSON source;第三方新增语言进入设置和首次选择列表。
+- 首次选择确认后原子保存语言/count,后续启动恢复语言并增加 count;取消/失败不计数,清空玩家变量后再次选择。
+- 后续整理将首次语言页统一为 DI ViewModel + 编译绑定;语言选项从已加载 CultureService 读取快照,设置页在 session attach 后更新,不动态监听语言源变化。
+- 语言切换保持 UI 即时刷新、剧情从下一条起生效;不强制重启,不重译已显示对话/选项/NVL 历史。
+
+## 模块与关键取舍
+
+Core/Builtins 定义人物/key 和展示前解析契约。Runtime.Content 只持有来源描述与诊断。FileSystem 加载/验证,安装器保留 External;Editor 仅在导出时排除第三方文件。Sample composition 和共享源码 adapter 使用 DynamicLocalization,未新增生产项目或让 Headless 依赖 Avalonia。
+
+DynamicLocalization.Avalonia 2.1.2 与 Avalonia 12 的运行时 Binding API 不兼容,改用 Core + Sample ReflectionBinding。JSON provider 同 culture 是整字典替换且无父语言回退,采用每文件独立 provider、culture-first 有序查询;不合并文件或重写 JSON 展平算法。人物自动补全和模板工具留给 Editor 后续功能;默认 key 检查发生在内容加载,而非构建器。
+
+External 不随包发布。安装器仍拒绝覆盖不同/损坏安装,复用已有安装不删除第三方文件;没有新增包更新协议。文件修改后重新启动加载。
+
+## 验证
+
+Integration 60/60;Core 61/61;Runtime 49/49;Builtins 52/52;Storage 12/12;Presentation 77/77;Architecture 24/24。Headless 构建 0 warning/0 error。此前已验证中英文 × 两条逻辑路线到结尾及真实 Headless 运行;本期补充真实 Avalonia 首次选择期间正常关闭、无计数写入。
+
+未执行完整 GUI 确认后剧情的人工全程验收;原生影音 opt-in 测试未启用。现有 analyzer/XAML warning 不在本期清理范围。
+
+MVVM 后续整理另行验证 Integration 62/62、Architecture 24/24;原 Phase 1–5 的验证记录保留。新增测试覆盖 View 命令绑定、语言快照和设置的加载后 attach。真实 Sample DI 启动及首次选择期间关闭验证通过,未创建玩家变量文件。
+
+## 提交与后续
+
+按用户要求按 Phase 提交:Phase 4 为 `2ac4d03`,Phase 5 随本总结独立提交。粒子结尾崩溃已由用户在本轮前修复,没有混入这两次提交。
+
+Editor 模板/自动生成 key/人物自动补全、非文本本地化、热更新协议与当前文本实时刷新另行规划。相关包兼容/逐文件行为/渲染回调 lesson 保留在 agent 知识索引,不复制到正式 spec。
diff --git a/scripts/run-avalonia-sample.ps1 b/scripts/run-avalonia-sample.ps1
index 5dc09ec1..ce61002b 100644
--- a/scripts/run-avalonia-sample.ps1
+++ b/scripts/run-avalonia-sample.ps1
@@ -12,6 +12,7 @@ $ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$Project = if ($Project) { [System.IO.Path]::GetFullPath($Project) } else { Join-Path $repositoryRoot 'GameTestCase' }
$gameDirectory = if ($BuildOutput) { [System.IO.Path]::GetFullPath($BuildOutput) } else { Join-Path $Project 'Output' }
+$profileDirectory = if ($Profile) { [System.IO.Path]::GetFullPath($Profile) } else { Join-Path $Project '.galnet' }
$sampleProject = Join-Path $repositoryRoot 'src\Samples\GalNet.Sample.Avalonia\GalNet.Sample.Avalonia.csproj'
$editorProject = Join-Path $repositoryRoot 'src\Editor\GalNet.Editor.Headless\GalNet.Editor.Headless.csproj'
@@ -20,11 +21,7 @@ if (-not $SkipBuild)
& dotnet run --project $editorProject -- build $Project --output $gameDirectory
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
-$sampleArguments = @($gameDirectory)
-if ($Profile)
-{
- $sampleArguments += '--profile', [System.IO.Path]::GetFullPath($Profile)
-}
+$sampleArguments = @($gameDirectory, '--profile', $profileDirectory)
Write-Host "Launching Avalonia sample: $gameDirectory" -ForegroundColor Cyan
& dotnet run --project $sampleProject -- @sampleArguments
diff --git a/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs b/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs
index e12cb4ff..c4fb7d4f 100644
--- a/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs
+++ b/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs
@@ -85,8 +85,7 @@ public GalProject(string id, string name, string rootPath, ProjectSettings setti
public IDisposable RegisterClosingCallback(Func callback)
{
ArgumentNullException.ThrowIfNull(callback);
- if (_disposed)
- throw new ObjectDisposedException(nameof(GalProject));
+ ObjectDisposedException.ThrowIf(_disposed, this);
_closingCallbacks.Add(callback);
return new CallbackRegistration(_closingCallbacks, callback);
diff --git a/src/Editor/GalNet.Editor.Headless/Program.cs b/src/Editor/GalNet.Editor.Headless/Program.cs
index 85b3e55e..1fc3616e 100644
--- a/src/Editor/GalNet.Editor.Headless/Program.cs
+++ b/src/Editor/GalNet.Editor.Headless/Program.cs
@@ -27,7 +27,7 @@ public static async Task Main(string[] args)
if (args.Length == 0 || args[0] is "help" or "--help" or "-h") { PrintHelp(); return 0; }
var catalog = new EditorCommandCatalog();
var fileCatalog = new AssetFileCommandCatalog();
- var entryCatalog = BuiltinEntryModules.CreateRecommendedTargetProfile();
+ var entryCatalog = BuiltinSchemas.CreateCatalog();
if (args[0].Equals("commands", StringComparison.OrdinalIgnoreCase))
{
WriteJson(catalog.GetAll().Concat(fileCatalog.GetAll()).Select(CommandSummary));
@@ -57,7 +57,7 @@ public static async Task Main(string[] args)
case "entries": return WriteEntries(document, args);
case "variables": WriteJson(new { player = document.Graph.PlayerVariables, save = document.Graph.SaveVariables }); return 0;
case "validate":
- var validation = new EditorDocumentValidator().Validate(document);
+ var validation = EditorDocumentValidator.Validate(document);
WriteJson(new { success = validation.IsValid, diagnostics = validation.Diagnostics });
return validation.IsValid ? 0 : 4;
case "execute":
@@ -72,7 +72,7 @@ public static async Task Main(string[] args)
}
private static async Task ExecuteAsync(string projectPath, EditorProjectDocument original,
- IEditorCommandCatalog catalog, IProjectFileCommandCatalog fileCatalog, IEntryCatalog entryCatalog, string json)
+ EditorCommandCatalog catalog, AssetFileCommandCatalog fileCatalog, IEntryCatalog entryCatalog, string json)
{
using var parsed = JsonDocument.Parse(json);
var root = parsed.RootElement;
@@ -102,7 +102,7 @@ private static async Task ExecuteAsync(string projectPath, EditorProjectDoc
changed.AddRange(execution.ChangedResources);
if (execution.Description is not null) descriptions.Add(execution.Description.Description);
}
- var validation = new EditorDocumentValidator().Validate(working);
+ var validation = EditorDocumentValidator.Validate(working);
if (!validation.IsValid) { WriteJson(new { success = false, diagnostics = validation.Diagnostics }); return 5; }
var dryRun = root.TryGetProperty("dryRun", out var dryRunElement) && dryRunElement.GetBoolean();
if (!dryRun) await new DirectProjectPersistence(projectPath, new EditorDocumentRepository(entryCatalog)).SaveAsync(working);
@@ -173,7 +173,7 @@ private static int WriteEntries(EditorProjectDocument document, string[] args)
private static object CreateSummary(string projectPath, EditorProjectDocument document)
{
- var validation = new EditorDocumentValidator().Validate(document);
+ var validation = EditorDocumentValidator.Validate(document);
return new
{
project = new { name = document.Graph.Name, path = projectPath },
diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs
index 85cc8503..64633ab5 100644
--- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs
+++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs
@@ -34,7 +34,7 @@ public IProjectFileCommand Deserialize(string commandId, JsonElement payload, Js
?? throw new JsonException($"Asset command '{commandId}' deserialized to null.");
}
- private static IProjectCommandDefinition Define(string id, string description) where T : IProjectFileCommand
+ private static Definition Define(string id, string description) where T : IProjectFileCommand
{
var parameters = typeof(T).GetConstructors().OrderByDescending(item => item.GetParameters().Length).First().GetParameters()
.Select(parameter => new EditorCommandParameter(
diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs
index a11bc4d8..dec6bf63 100644
--- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs
+++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Text.Json;
using GalNet.Core.Entry;
using GalNet.Editor.Abstraction.Commands;
@@ -70,7 +71,7 @@ private static async Task ImportAsync(
for (var index = 0; index < sources.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
- var staged = Path.Combine(staging, index.ToString("D4") + Path.GetExtension(sources[index]));
+ var staged = Path.Combine(staging, index.ToString("D4", CultureInfo.InvariantCulture) + Path.GetExtension(sources[index]));
File.Copy(sources[index], staged, false);
File.Move(staged, destinations[index]);
moved.Add(destinations[index]);
@@ -148,7 +149,7 @@ private FileCommandResult Delete(string projectPath, string root, DeleteAssetCom
return Success(transactionId, $"Moved asset '{command.RelativePath}' to the project recovery area.", [Resource(root, source)]);
}
- private IReadOnlyList FindReferences(string projectPath, string assetPath, string relativePath)
+ private List FindReferences(string projectPath, string assetPath, string relativePath)
{
var values = new HashSet(StringComparer.OrdinalIgnoreCase)
{
diff --git a/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs b/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs
index 719c874b..22e732a8 100644
--- a/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs
+++ b/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs
@@ -338,7 +338,7 @@ private static void RemapMovedOutlet(EditorProjectDocument document, string node
}
}
- private static void Renumber(IReadOnlyList entries)
+ private static void Renumber(List entries)
{
for (var index = 0; index < entries.Count; index++)
entries[index].Id = index + 1;
diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs
index da2437e3..8012837d 100644
--- a/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs
+++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs
@@ -15,7 +15,7 @@ public sealed class EditorCommandCatalog : IEditorCommandCatalog
};
private readonly IReadOnlyList _definitions;
- private readonly IReadOnlyDictionary _byId;
+ private readonly Dictionary _byId;
public EditorCommandCatalog()
{
@@ -75,7 +75,7 @@ private static IReadOnlyList CreateDefinitions() =>
Define("project.settings.patch", "Updates selected project settings after validating each supported field.")
];
- private static IProjectCommandDefinition Define(string id, string description)
+ private static ProjectCommandDefinition Define(string id, string description)
where TCommand : IProjectEditCommand =>
new ProjectCommandDefinition(
id,
diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs
index 328a3fa4..2d9ebda7 100644
--- a/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs
+++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs
@@ -5,7 +5,7 @@ namespace GalNet.Editor.Shared.Commands;
public sealed class EditorDocumentValidator
{
- public ValidationResult Validate(EditorProjectDocument document)
+ public static ValidationResult Validate(EditorProjectDocument document)
{
var diagnostics = new List();
AddDuplicateDiagnostics(document.Graph.Nodes.Select(node => node.Id), "graph.node.duplicateId", "node", diagnostics);
@@ -57,7 +57,7 @@ private static void AddDuplicateDiagnostics(
IEnumerable values,
string code,
string kind,
- ICollection diagnostics)
+ List diagnostics)
{
foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)).GroupBy(value => value, StringComparer.Ordinal).Where(group => group.Count() > 1))
diagnostics.Add(EditorDiagnostic.Error(code, $"Duplicate {kind} identifier '{value.Key}'."));
diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs
index f57fa540..dff234fe 100644
--- a/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs
+++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs
@@ -8,6 +8,8 @@ namespace GalNet.Editor.Shared.Commands;
public static class EditorProjectCreator
{
+ private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true };
+
public static async Task CreateAsync(
string projectPath,
IEntryCatalog catalog,
@@ -62,7 +64,7 @@ public static async Task CreateAsync(
await new DirectProjectPersistence(projectPath, repository).SaveAsync(document, cancellationToken);
await File.WriteAllTextAsync(
Path.Combine(projectPath, ".galnet", "editor-state.json"),
- JsonSerializer.Serialize(new GalNet.Editor.Abstraction.Project.EditorProjectState(), new JsonSerializerOptions { WriteIndented = true }),
+ JsonSerializer.Serialize(new GalNet.Editor.Abstraction.Project.EditorProjectState(), IndentedJsonOptions),
cancellationToken);
}
}
diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs
index 0f50c922..d68e0820 100644
--- a/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs
+++ b/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs
@@ -13,6 +13,7 @@ namespace GalNet.Editor.Shared.Services;
public sealed class EditorPlayerVariableStore : IEditorPlayerVariableStore
{
+ private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true };
private readonly IProjectService _projectService;
private readonly IVariableDefinitionService _variableDefinitions;
private readonly Dictionary _variables = new(StringComparer.Ordinal);
@@ -158,7 +159,7 @@ private void Save()
try
{
Directory.CreateDirectory(Path.GetDirectoryName(GetStoragePath(project))!);
- var json = JsonSerializer.Serialize(_variables, new JsonSerializerOptions { WriteIndented = true });
+ var json = JsonSerializer.Serialize(_variables, IndentedJsonOptions);
File.WriteAllText(GetStoragePath(project), json);
}
catch (Exception ex)
@@ -179,7 +180,7 @@ private void OnDefinitionsChanged(VariableScope scope)
Changed?.Invoke();
}
- private IReadOnlyList<(string Name, Variable DefaultValue)> GetDefinitions()
+ private List<(string Name, Variable DefaultValue)> GetDefinitions()
{
var definitions = new Dictionary(StringComparer.Ordinal);
foreach (var definition in _systemVariables.Values)
diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs
index 09c28eb1..105de269 100644
--- a/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs
+++ b/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs
@@ -12,6 +12,7 @@ namespace GalNet.Editor.Shared.Services;
///
public sealed class EditorSettingsService : IEditorSettingsService
{
+ private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true };
private readonly string _settingsPath;
private EditorSettings? _settings;
@@ -60,7 +61,7 @@ private void Save(EditorSettings settings)
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
- var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
+ var json = JsonSerializer.Serialize(settings, IndentedJsonOptions);
File.WriteAllText(_settingsPath, json);
}
catch (Exception ex)
diff --git a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs
index 335f6d25..ba374d8a 100644
--- a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs
+++ b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs
@@ -34,7 +34,7 @@ public static async Task ExportAsync(
ArgumentNullException.ThrowIfNull(resourceTypes);
ArgumentNullException.ThrowIfNull(galleryTypes);
stagingRoot = Path.Combine(Path.GetTempPath(), $"galnet-export-{Guid.NewGuid():N}");
- await new ProjectContentBuilder(BuiltinEntryModules.CreateRecommendedTargetProfile())
+ await new ProjectContentBuilder(BuiltinSchemas.CreateCatalog())
.BuildAsync(projectRoot, stagingRoot, cancellationToken)
.ConfigureAwait(false);
var assets = await LoadAssetsAsync(Path.Combine(stagingRoot, "Assets"), resourceTypes, cancellationToken);
@@ -78,11 +78,12 @@ private static async Task> LoadAssetsAsync(string asset
return archive.AssetIds.OrderBy(id => id, StringComparer.Ordinal).Select(id => archive.GetAsset(id)!).ToArray();
}
- private static IReadOnlyList<(string Path, byte[] Data)> BuildContentFiles(string projectRoot, GalleryCatalog gallery, CancellationToken ct)
+ private static (string Path, byte[] Data)[] BuildContentFiles(string projectRoot, GalleryCatalog gallery, CancellationToken ct)
{
return Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(projectRoot, path).Replace('\\', '/'))
.Where(path => path is "settings.json" || path.StartsWith("Graph/", StringComparison.OrdinalIgnoreCase) || path.StartsWith("I18n/", StringComparison.OrdinalIgnoreCase))
+ .Where(path => !path.StartsWith("I18n/External/", StringComparison.OrdinalIgnoreCase))
.Where(path => !path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) && !path.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.Ordinal)
.Select(path =>
diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs
index d9cda498..850d2153 100644
--- a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs
+++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs
@@ -18,7 +18,7 @@ public sealed class ProjectContentBuilder(IEntryCatalog? entryCatalog = null)
Converters = { new JsonStringEnumConverter() }
};
- private readonly IEntryCatalog _entryCatalog = entryCatalog ?? BuiltinEntryModules.CreateRecommendedTargetProfile();
+ private readonly IEntryCatalog _entryCatalog = entryCatalog ?? BuiltinSchemas.CreateCatalog();
public async Task BuildAsync(
string projectRoot,
diff --git a/src/Editor/GalNet.Editor/App.axaml.cs b/src/Editor/GalNet.Editor/App.axaml.cs
index 56a57b98..dcca1cd3 100644
--- a/src/Editor/GalNet.Editor/App.axaml.cs
+++ b/src/Editor/GalNet.Editor/App.axaml.cs
@@ -20,7 +20,7 @@ public partial class App : Application
/// DI container for application-level services.
public static IServiceProvider? ServiceProvider { get; private set; }
- public ISettingsService GetSettingsService() =>
+ public static ISettingsService GetSettingsService() =>
ServiceProvider!.GetRequiredService();
public override void Initialize()
diff --git a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs
index c0663571..27cd58e3 100644
--- a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs
+++ b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs
@@ -27,7 +27,7 @@ public static class EditorServiceCollectionExtensions
{
public static IServiceCollection AddEditorServices(this IServiceCollection services)
{
- var targetProfile = BuiltinEntryModules.CreateRecommendedTargetProfile();
+ var targetProfile = BuiltinSchemas.CreateCatalog();
services.AddSingleton(targetProfile);
services.AddEditorCoreServices();
services.AddEditorCommands(targetProfile);
@@ -60,7 +60,6 @@ private static IServiceCollection AddEditorCoreServices(this IServiceCollection
services.AddSingleton();
services.AddScoped();
services.AddScoped();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -86,7 +85,7 @@ private static IServiceCollection AddEditorCoreServices(this IServiceCollection
return services;
}
- private static IServiceCollection AddEditorCommands(this IServiceCollection services, IEntryCatalog targetProfile)
+ private static IServiceCollection AddEditorCommands(this IServiceCollection services, TargetProfileEntryCatalog targetProfile)
{
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs b/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs
index e9f26745..be37ad3f 100644
--- a/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs
+++ b/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs
@@ -107,7 +107,7 @@ private void RebuildCategories()
RaiseSelectedProperties();
}
- private IReadOnlyList GetOptions() => Options?.Cast().ToArray() ?? [];
+ private EntryTypeOption[] GetOptions() => Options?.Cast().ToArray() ?? [];
private EntryTypeShortcutCommand? FindTypeCommand(string type) =>
ShortcutService?.Commands.OfType().FirstOrDefault(command => command.EntryType == type);
diff --git a/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs b/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs
index 817dd18f..0b222186 100644
--- a/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs
+++ b/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs
@@ -52,7 +52,7 @@ public string Serialize(IRootDock layout)
return JsonSerializer.Serialize(new LayoutData { Version = FormatVersion, Root = root, Windows = windows }, JsonOptions);
}
- public IRootDock? Deserialize(string serialized)
+ public static IRootDock? Deserialize(string serialized)
{
if (string.IsNullOrWhiteSpace(serialized))
return null;
diff --git a/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs b/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs
index 0e2cc9b1..bbacf074 100644
--- a/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs
+++ b/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs
@@ -314,7 +314,7 @@ private Document CreateDocument(IDockPanelContribution panel, object? parameter
return document;
}
- private IEnumerable CreateDefaultPanels(DockPanelPlacement placement, params Document[] builtIns)
+ private List CreateDefaultPanels(DockPanelPlacement placement, params Document[] builtIns)
{
var documents = builtIns.Cast().ToList();
foreach (var panel in _extensions.DockPanelContributions.Where(panel => panel.IsDefaultPanel && panel.Placement == placement && builtIns.All(document => document.Id != panel.PanelId)))
diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs
index de573541..e8e6e72d 100644
--- a/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs
+++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
@@ -187,7 +188,7 @@ private async Task LoadImagePreviewAsync()
catch (Exception ex) { ImagePreviewError = ex.Message; }
}
- private static string FormatTime(double milliseconds) => TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)).ToString(@"m\:ss");
+ private static string FormatTime(double milliseconds) => TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)).ToString(@"m\:ss", CultureInfo.CurrentCulture);
public void Dispose()
{
Workspace.PropertyChanged -= OnWorkspacePropertyChanged;
diff --git a/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs b/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs
index 10e5dfe6..a683650b 100644
--- a/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs
+++ b/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs
@@ -4,10 +4,10 @@
namespace GalNet.Editor.Models;
/// Owns ordered editor variable definitions and their collection invariants.
-public sealed class VariableDefinitionCollection
+public sealed class VariableDefinitions
{
private readonly List _definitions;
- public VariableDefinitionCollection(List definitions) => _definitions = definitions;
+ public VariableDefinitions(List definitions) => _definitions = definitions;
public int Count => _definitions.Count;
public IEnumerable Items => _definitions;
public ProjectVariableDefinition Add(string name)
diff --git a/src/Editor/GalNet.Editor/Program.cs b/src/Editor/GalNet.Editor/Program.cs
index 9e65ff74..0f16d0e4 100644
--- a/src/Editor/GalNet.Editor/Program.cs
+++ b/src/Editor/GalNet.Editor/Program.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using Avalonia;
using Dock.Model.Core;
using Dock.Settings;
@@ -18,8 +19,10 @@ public static void Main(string[] args)
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console(
+ formatProvider: CultureInfo.InvariantCulture,
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.Debug(
+ formatProvider: CultureInfo.InvariantCulture,
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.Sink(EditorLogSink.Instance)
.CreateLogger();
diff --git a/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs b/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs
index 028b3609..38a43f91 100644
--- a/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs
+++ b/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs
@@ -47,7 +47,7 @@ public EditorLocalizationService()
public string Format(string key, params object[] args)
{
var template = this[key];
- return string.Format(CultureInfo.CurrentUICulture, template, args);
+ return string.Format(CultureInfo.CurrentCulture, template, args);
}
public void ApplyLocale(string localeCode)
diff --git a/src/Editor/GalNet.Editor/Services/EditorLogSink.cs b/src/Editor/GalNet.Editor/Services/EditorLogSink.cs
index 96c53f8a..1dd20642 100644
--- a/src/Editor/GalNet.Editor/Services/EditorLogSink.cs
+++ b/src/Editor/GalNet.Editor/Services/EditorLogSink.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Collections.ObjectModel;
using Avalonia.Threading;
using Serilog.Core;
@@ -24,7 +25,7 @@ public void Emit(LogEvent logEvent)
{
Timestamp = logEvent.Timestamp,
Level = logEvent.Level,
- Message = logEvent.RenderMessage(),
+ Message = logEvent.RenderMessage(CultureInfo.InvariantCulture),
Exception = logEvent.Exception?.ToString(),
Source = GetSource(logEvent),
Channel = GetChannel(logEvent)
diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs
index 58b97398..fa66cbb0 100644
--- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs
+++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs
@@ -11,7 +11,6 @@
using GalNet.Core.Settings;
using GalNet.Presentation.Abstractions.Runtime;
using GalNet.Presentation.Abstractions.View;
-using GalNet.Presentation.Defaults;
using GalNet.Primitives.Builtins;
using GalNet.Rendering.Scene;
using GalNet.Runtime.Content;
@@ -95,7 +94,7 @@ internal sealed partial class EditorPreviewSessionService : ObservableObject, IG
private readonly ObservableCollection _slots = [];
private readonly ReadOnlyObservableCollection _readOnlySlots;
private readonly NullGameView _fallback = new();
- private AvaloniaGamePageView? _pageView;
+ private AvaloniaGamePresenters? _pageView;
private EditorPreviewLayerFactory? _layers;
private AvaloniaEffectRuntime? _effects;
private AvaloniaAudioPlaybackHost? _audio;
@@ -188,6 +187,7 @@ public void Dispose()
{
_disposed = true;
DisposeEngine();
+ (_context.Saves as IDisposable)?.Dispose();
}
}
@@ -250,7 +250,7 @@ private void DisposeEngine()
{
(GalleryResources as IDisposable)?.Dispose();
GalleryResources = null;
- if (_pageView is not null) _pageView.AdvanceRequested -= OnAdvanceRequested;
+ _gameplay.AdvanceRequested -= OnAdvanceRequested;
_engine?.Dispose();
_pageView?.Dispose();
_layers?.Dispose();
@@ -279,10 +279,10 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken)
OnPropertyChanged(nameof(GalleryDataSource));
OnPropertyChanged(nameof(GalleryResources));
_layers = new EditorPreviewLayerFactory(_preloadedTextures);
- _pageView = new AvaloniaGamePageView(_gameplay, _page, _layers);
+ _pageView = new AvaloniaGamePresenters(_gameplay, _page, _layers);
_effects = new AvaloniaEffectRuntime(_gameplay);
_audio = new AvaloniaAudioPlaybackHost(_context.Assets);
- var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(
+ var view = new GameRegistration().AddBuiltins(
_pageView.DialoguePresenter,
_pageView.LayerPresenter,
_pageView.AnimationPresenter,
@@ -290,10 +290,10 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken)
content.Gallery,
_pageView.ParticlePresenter,
_pageView.NvlPresenter,
- _audio));
+ _audio).BuildView();
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;
+ _gameplay.AdvanceRequested += OnAdvanceRequested;
_context.RuntimeCreated(runtime);
await RefreshSlotsAsync(cancellationToken);
}
diff --git a/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs b/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs
index d495e28d..25ab5f2e 100644
--- a/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs
+++ b/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs
@@ -12,7 +12,7 @@ namespace GalNet.Editor.Services;
public sealed class EditorViewFactory : IEditorViewFactory
{
private readonly IServiceProvider _serviceProvider;
- private static readonly IReadOnlyDictionary ViewModelToViewMap = new Dictionary
+ private static readonly Dictionary ViewModelToViewMap = new()
{
[typeof(ProjectSettingsPanelViewModel)] = typeof(ProjectSettingsPanelView),
[typeof(EditorSettingsPanelViewModel)] = typeof(EditorSettingsPanelView),
diff --git a/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs b/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs
index 1730b578..3e72bb79 100644
--- a/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs
+++ b/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs
@@ -12,20 +12,17 @@ public sealed class EditorWorkspacePersistence
private readonly IEditorDocumentRepository _repository;
private readonly IEditorDocumentService _documents;
private readonly IEditorSaveCoordinator _saveCoordinator;
- private readonly GraphDocumentMapper _mapper;
public EditorWorkspacePersistence(
IProjectService projects,
IEditorDocumentRepository repository,
IEditorDocumentService documents,
- IEditorSaveCoordinator saveCoordinator,
- GraphDocumentMapper mapper)
+ IEditorSaveCoordinator saveCoordinator)
{
_projects = projects;
_repository = repository;
_documents = documents;
_saveCoordinator = saveCoordinator;
- _mapper = mapper;
}
public GraphDocumentLoadResult? LoadCurrentProject()
@@ -44,7 +41,7 @@ public EditorWorkspacePersistence(
}
_documents.Load(loaded);
- return _mapper.Load(loaded);
+ return GraphDocumentMapper.Load(loaded);
}
public async Task SaveCurrentProjectAsync(
@@ -58,7 +55,7 @@ public async Task SaveCurrentProjectAsync(
_saveCoordinator.SaveProjectDocument(
project.RootPath,
document,
- _mapper.CreateGroupEntriesSnapshot(nodes));
+ GraphDocumentMapper.CreateGroupEntriesSnapshot(nodes));
await _projects.SaveAsync();
return true;
}
@@ -75,14 +72,14 @@ public string BuildPreviewData(
return _saveCoordinator.BuildPreviewData(
previewPath,
document,
- _mapper.CreateGroupEntriesSnapshot(nodes));
+ GraphDocumentMapper.CreateGroupEntriesSnapshot(nodes));
}
private EditorGraphDocument CreateDocument(
string projectName,
IEnumerable nodes,
IEnumerable edges) =>
- _mapper.CreateDocument(
+ GraphDocumentMapper.CreateDocument(
projectName,
_documents.CurrentDocument.Version,
nodes,
diff --git a/src/Editor/GalNet.Editor/Services/FileDialogService.cs b/src/Editor/GalNet.Editor/Services/FileDialogService.cs
index f4a32793..28a15e7d 100644
--- a/src/Editor/GalNet.Editor/Services/FileDialogService.cs
+++ b/src/Editor/GalNet.Editor/Services/FileDialogService.cs
@@ -11,7 +11,7 @@ namespace GalNet.Editor.Services;
public sealed class FileDialogService : IFileDialogService
{
- private static TopLevel? GetOwner() => Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
+ private static Window? GetOwner() => Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
? desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow
: null;
@@ -20,7 +20,7 @@ public sealed class FileDialogService : IFileDialogService
var owner = GetOwner();
if (owner?.StorageProvider is null) return null;
var folders = await owner.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = title, AllowMultiple = false });
- return folders.FirstOrDefault()?.Path.LocalPath;
+ return folders.Count == 0 ? null : folders[0].Path.LocalPath;
}
public async Task> OpenFilePickerAsync(string title, bool allowMultiple = true)
diff --git a/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs b/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs
index fb2da25a..ffb0bc77 100644
--- a/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs
+++ b/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs
@@ -12,7 +12,7 @@ namespace GalNet.Editor.Services;
/// Maps persisted graph data to the editor presentation model and back.
public sealed class GraphDocumentMapper
{
- public GraphDocumentLoadResult Load(LoadedEditorProjectDocument loaded)
+ public static GraphDocumentLoadResult Load(LoadedEditorProjectDocument loaded)
{
var nodes = loaded.Document.Nodes
.Select(dto => CreateNode(dto, loaded.GroupEntries))
@@ -34,7 +34,7 @@ public GraphDocumentLoadResult Load(LoadedEditorProjectDocument loaded)
return new GraphDocumentLoadResult(nodes, edges);
}
- public EditorGraphDocument CreateDocument(
+ public static EditorGraphDocument CreateDocument(
string projectName,
int version,
IEnumerable nodes,
@@ -52,7 +52,7 @@ public EditorGraphDocument CreateDocument(
SaveVariables = saveVariables.Select(variable => variable.Clone()).ToList()
};
- public IReadOnlyDictionary> CreateGroupEntriesSnapshot(IEnumerable nodes) =>
+ public static IReadOnlyDictionary> CreateGroupEntriesSnapshot(IEnumerable nodes) =>
nodes.Where(node => node.NodeKind == GraphNodeKind.LinearGroup)
.ToDictionary(
node => node.Id,
@@ -90,7 +90,7 @@ private static EditorGraphNodeDto ToDto(GraphNode node)
return dto;
}
- private static GraphNode CreateNode(EditorGraphNodeDto dto, IReadOnlyDictionary> groupEntries)
+ private static GraphNode CreateNode(EditorGraphNodeDto dto, Dictionary> groupEntries)
{
var kind = dto.Type.Equals("Entry", StringComparison.OrdinalIgnoreCase) ? GraphNodeKind.Entry
: dto.Type.Equals("Group", StringComparison.OrdinalIgnoreCase) ? GraphNodeKind.LinearGroup
@@ -102,7 +102,7 @@ private static GraphNode CreateNode(EditorGraphNodeDto dto, IReadOnlyDictionary<
GraphNodeKind.LinearGroup => new Group { Id = dto.Id, Name = dto.Name },
GraphNodeKind.ChoiceBranch => new Branch { Id = dto.Id, Name = dto.Name, BranchType = BranchType.Choice },
GraphNodeKind.ConditionBranch => new Branch { Id = dto.Id, Name = dto.Name, BranchType = BranchType.Condition },
- _ => throw new ArgumentOutOfRangeException()
+ _ => throw new ArgumentOutOfRangeException(nameof(dto), dto, null)
};
var graphNode = new GraphNode(node, kind) { X = dto.X, Y = dto.Y };
if (kind == GraphNodeKind.LinearGroup)
diff --git a/src/Editor/GalNet.Editor/Services/GraphEditingService.cs b/src/Editor/GalNet.Editor/Services/GraphEditingService.cs
index fa680296..674cf838 100644
--- a/src/Editor/GalNet.Editor/Services/GraphEditingService.cs
+++ b/src/Editor/GalNet.Editor/Services/GraphEditingService.cs
@@ -92,7 +92,7 @@ public IReadOnlyList InsertEntries(GraphNode groupNode
if (groupNode.NodeKind != GraphNodeKind.LinearGroup || count < 1)
return [];
- var definition = _catalog.Definitions.FirstOrDefault();
+ var definition = _catalog.Definitions.Count > 0 ? _catalog.Definitions[0] : null;
if (definition is null)
return [];
@@ -150,9 +150,9 @@ public bool AddChoiceOption(ObservableCollection nodes, ObservableCol
return true;
}
- public bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option)
+ public bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption)
{
- if (node.NodeKind != GraphNodeKind.ChoiceBranch || !node.Options.Remove(option))
+ if (node.NodeKind != GraphNodeKind.ChoiceBranch || !node.Options.Remove(choiceOption))
return false;
node.RefreshConnectors();
@@ -161,12 +161,12 @@ public bool RemoveChoiceOption(ObservableCollection nodes, Observable
return true;
}
- public bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option, int newIndex)
+ public bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption, int newIndex)
{
if (node.NodeKind != GraphNodeKind.ChoiceBranch)
return false;
- return MoveBranchItemWithEdges(nodes, edges, node, node.Options, option, newIndex);
+ return MoveBranchItemWithEdges(nodes, edges, node, node.Options, choiceOption, newIndex);
}
public bool AddCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node)
diff --git a/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs b/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs
index 4de6bcb4..751341a1 100644
--- a/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs
+++ b/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs
@@ -14,8 +14,8 @@ public interface IGraphEditingService
bool RemoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry);
bool MoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry, int newIndex);
bool AddChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node);
- bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option);
- bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option, int newIndex);
+ bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption);
+ bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption, int newIndex);
bool AddCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node);
bool RemoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition);
bool MoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition, int newIndex);
diff --git a/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs b/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs
index 9cf54fe0..4240adb9 100644
--- a/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs
+++ b/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs
@@ -95,7 +95,6 @@ public void Dispose()
private void ThrowIfDisposed()
{
- if (_disposed)
- throw new ObjectDisposedException(nameof(ProjectSaveScheduler));
+ ObjectDisposedException.ThrowIf(_disposed, this);
}
}
diff --git a/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs
index 8263dab9..96fb0672 100644
--- a/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs
@@ -186,7 +186,7 @@ private async Task DeleteSelectedAsync()
try { await ExecuteFileCommandAsync(new DeleteAssetCommand(SelectedEntry.RelativePath)); SelectedEntry = null; LoadEntries(); }
catch (Exception ex) { ErrorText = ex.Message; }
}
- public bool CanMoveTo(AssetEntry target, string sourceRelativePath)
+ public static bool CanMoveTo(AssetEntry target, string sourceRelativePath)
{
if (!target.IsDirectory || string.IsNullOrWhiteSpace(sourceRelativePath)) return false;
return CanMoveToDirectory(target.RelativePath, sourceRelativePath);
diff --git a/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs
index 55f663a0..37ea5897 100644
--- a/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs
@@ -220,7 +220,7 @@ private void DeleteLayout(string? name)
if (string.IsNullOrWhiteSpace(serialized)) return null;
try
{
- var layout = _dockLayoutSerializer.Deserialize(serialized);
+ var layout = DockLayoutSerializer.Deserialize(serialized);
if (layout is null)
return null;
@@ -394,9 +394,10 @@ public void Dispose()
_dockFactory.LayoutChanged -= OnDockLayoutChanged;
_projectService.CurrentChanged -= OnCurrentProjectChanged;
L.PropertyChanged -= _localizationChangedHandler;
+ GC.SuppressFinalize(this);
}
- private IList BuildViewMenuItems() => new AvaloniaList(
+ private AvaloniaList BuildViewMenuItems() => new(
_dockFactory.ViewMenuPanels.Select(panel => new MenuData
{
HeaderKey = panel.TitleKey,
@@ -406,7 +407,7 @@ public void Dispose()
IsChecked = panel.IsGlobal && _dockFactory.HasGlobalPanel(panel.PanelId)
}));
- private IList BuildLoadLayoutItems()
+ private AvaloniaList BuildLoadLayoutItems()
{
var items = new AvaloniaList { new() { Header = "Default", Command = LoadLayoutCommand, CommandParameter = "Default" } };
foreach (var name in _editorSettingsService.GetSettings().DockLayouts.Keys.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
@@ -414,7 +415,7 @@ private IList BuildLoadLayoutItems()
return items;
}
- private IList BuildDeleteLayoutItems() => new AvaloniaList(
+ private AvaloniaList BuildDeleteLayoutItems() => new(
_editorSettingsService.GetSettings().DockLayouts.Keys
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.Select(name => new MenuData { Header = name, Command = new RelayCommand(DeleteLayout), CommandParameter = name }));
@@ -430,7 +431,7 @@ private void RefreshViewMenuChecks()
}
}
- private async Task RequestLayoutNameAsync()
+ private static async Task RequestLayoutNameAsync()
{
var owner = GetMainWindow();
if (owner is null) return null;
diff --git a/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs
index 8a82d928..a66bc6d2 100644
--- a/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs
@@ -125,6 +125,7 @@ public void Dispose()
_variableDefinitionService.DefinitionsChanged -= _definitionsChangedHandler;
_histories.Changed -= OnHistoryChanged;
_graphChangeTracker.Dispose();
+ GC.SuppressFinalize(this);
}
public void SelectNode(GraphNode? node, bool additive = false)
@@ -388,7 +389,7 @@ public void SaveGraphDocument()
if (edits.Count > 0) PushGraphEdit(new CompositeEdit("Move nodes", edits));
}
- public void PersistGraphDocument()
+ public static void PersistGraphDocument()
{
// The live ViewModels are the canonical GUI state. SaveCoreAsync maps them to files.
}
@@ -758,7 +759,7 @@ private void ReplaceEdges(IReadOnlyList edges)
private void RefreshGraphNode(GraphNode node) { node.RefreshConnectors(); UpdateConnectorStates(); }
- private IUndoableEdit CreateBranchMoveEdit(string description, GraphNode node, ObservableCollection items,
+ private DelegateEdit CreateBranchMoveEdit(string description, GraphNode node, ObservableCollection items,
int before, int after, IReadOnlyDictionary oldOutlets, IReadOnlyDictionary newOutlets)
{
void Apply(int from, int to, IReadOnlyDictionary outlets)
diff --git a/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs
index 8cf91bd7..b1b18f32 100644
--- a/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs
@@ -324,13 +324,18 @@ private async Task StopPreviewAsync()
public async ValueTask DisposeAsync()
{
await DisposePreviewForProjectCloseAsync();
+ GC.SuppressFinalize(this);
}
///
/// Dock.Model disposes document contexts synchronously. Start the same idempotent
/// cleanup path without blocking the UI thread that owns the document close.
///
- public void Dispose() => _ = DisposeAsync();
+ public void Dispose()
+ {
+ _ = DisposeAsync().AsTask();
+ GC.SuppressFinalize(this);
+ }
private async Task DisposePreviewForProjectCloseAsync()
{
diff --git a/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs
index e05bfb1a..84340047 100644
--- a/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs
@@ -13,7 +13,7 @@ namespace GalNet.Editor.ViewModels;
public partial class ProjectSettingsPanelViewModel : ObservableObject, IDisposable, IUndoRedoTarget
{
- public IReadOnlyList ResolutionPresets => NewProjectPanelViewModel.ResolutionPresets;
+ public IReadOnlyList ResolutionPresets { get; } = 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;
@@ -70,6 +70,7 @@ public void Dispose()
{
_projectService.CurrentChanged -= OnProjectChanged;
_histories.Settings.Changed -= UpdateDirtyText;
+ GC.SuppressFinalize(this);
}
private void LoadFromProject()
diff --git a/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs
index 1d5a09cf..8fa4d63d 100644
--- a/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading.Tasks;
@@ -57,7 +58,7 @@ private void RefreshRecentProjects()
? Path.GetFileName(Path.TrimEndingDirectorySeparator(info.Path))
: info.Name,
Path = info.Path,
- LastOpened = info.LastOpened.ToString("yyyy-MM-dd HH:mm")
+ LastOpened = info.LastOpened.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture)
});
}
}
diff --git a/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs b/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs
index 83dc7c2a..1eaa4ee3 100644
--- a/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs
+++ b/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs
@@ -15,7 +15,7 @@ namespace GalNet.Editor.ViewModels;
public sealed partial class VariableListEditorViewModel : ObservableObject
{
- private readonly VariableDefinitionCollection _definitions;
+ private readonly VariableDefinitions _definitions;
private readonly VariableScope _scope;
private readonly Action _persistDefinitions;
private readonly Func _getCurrentValue;
@@ -46,7 +46,7 @@ public VariableListEditorViewModel(
Action persistDefinitions,
Action? onNameConflict = null)
{
- _definitions = new VariableDefinitionCollection(source);
+ _definitions = new VariableDefinitions(source);
_scope = scope;
_persistDefinitions = persistDefinitions;
_getCurrentValue = getCurrentValue;
diff --git a/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs b/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs
index 2008e367..54eff4b6 100644
--- a/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs
+++ b/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs
@@ -53,7 +53,17 @@ private void OnDragOver(object? sender, DragEventArgs e)
return;
}
var files = e.DataTransfer.TryGetFiles();
- e.DragEffects = files?.Any() == true ? DragDropEffects.Copy : DragDropEffects.None;
+ var fileEnumerator = files?.GetEnumerator();
+ try
+ {
+ e.DragEffects = fileEnumerator?.MoveNext() == true
+ ? DragDropEffects.Copy
+ : DragDropEffects.None;
+ }
+ finally
+ {
+ (fileEnumerator as IDisposable)?.Dispose();
+ }
if (DataContext is AssetPanelViewModel vm) vm.SetExternalDropActive(e.DragEffects == DragDropEffects.Copy);
e.Handled = true;
}
@@ -93,7 +103,7 @@ private void OnEntryDragOver(object? sender, DragEventArgs e)
{
if (sender is not Avalonia.Controls.Control { DataContext: AssetEntry target } || DataContext is not AssetPanelViewModel vm || !e.DataTransfer.Formats.Contains(InternalAssetFormat)) return;
var sourcePath = e.DataTransfer.TryGetValue(InternalAssetFormat);
- var allowed = sourcePath is not null && vm.CanMoveTo(target, sourcePath);
+ var allowed = sourcePath is not null && AssetPanelViewModel.CanMoveTo(target, sourcePath);
e.DragEffects = allowed ? DragDropEffects.Move : DragDropEffects.None;
vm.SetDropTarget(allowed ? target : null);
e.Handled = true;
diff --git a/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs b/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs
index d395edbf..45a0c98f 100644
--- a/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs
+++ b/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs
@@ -17,10 +17,10 @@ public GamePreviewPanelView()
DetachedFromVisualTree += OnDetachedFromVisualTree;
}
- private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ private async void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
if (_vm is not null)
- _ = _vm.DisposeAsync();
+ await _vm.DisposeAsync();
}
protected override void OnDataContextChanged(EventArgs e)
diff --git a/src/Editor/GalNet.Editor/packages.lock.json b/src/Editor/GalNet.Editor/packages.lock.json
index d1b82a19..cd20dfc7 100644
--- a/src/Editor/GalNet.Editor/packages.lock.json
+++ b/src/Editor/GalNet.Editor/packages.lock.json
@@ -139,11 +139,12 @@
},
"DynamicLocalization.Core": {
"type": "Direct",
- "requested": "[2.1.0, )",
- "resolved": "2.1.0",
- "contentHash": "xZYqTkryy36XVPUSuOGU68KEcS2Nk4AR+5dDCJdAGBgdX6MtCZ+XEOJH1CGF8QDMrU3WDk921uVsfIlqqc683g==",
+ "requested": "[2.1.2, )",
+ "resolved": "2.1.2",
+ "contentHash": "UdDvJeBnNoif/bi73CBDi7GiOhGUB9EkKl3v0JTNdVcPhCqWY+Jy9qr4IqNS9bQ5Xwn6uTYimYLDqGNxEEDjqw==",
"dependencies": {
- "YamlDotNet": "18.1.0"
+ "System.Text.Json": "[6.0.10, 10.0.0)",
+ "YamlDotNet": "[11.0.1, 19.0.0)"
}
},
"Irihi.Ursa": {
@@ -436,6 +437,11 @@
"resolved": "3.119.4",
"contentHash": "XOpbx/4CReO2wYsq2s6rbvdauc6dntG4Zv499sHGTJ87bwZaFXszFkwql3+FIZMc8kUPeaj3Mx2ezIJmo8a1Kg=="
},
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "6.0.10",
+ "contentHash": "NSB0kDipxn2ychp88NXWfFRFlmi1bst/xynOutbnpEfRCT9JZkZ7KOmF/I/hNKo2dILiMGnqblm+j1sggdLB9g=="
+ },
"Tmds.DBus.Protocol": {
"type": "Transitive",
"resolved": "0.92.0",
@@ -443,8 +449,8 @@
},
"YamlDotNet": {
"type": "Transitive",
- "resolved": "18.1.0",
- "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw=="
+ "resolved": "11.0.1",
+ "contentHash": "UvICfSN4gJfAmOsuY/go0hb5SFfr3ZYCXb7jg66yT7kh+wSJEt7tUiwuoCNMpt8ScEXLDwXttpFoWKWeirYU1A=="
},
"galnet.assets": {
"type": "Project",
@@ -468,7 +474,6 @@
"GalNet.Avalonia.Rendering": "[1.0.0, )",
"GalNet.Core": "[1.0.0, )",
"GalNet.Presentation.Abstractions": "[1.0.0, )",
- "GalNet.Presentation.Defaults": "[1.0.0, )",
"Irihi.Ursa": "[2.1.0, )",
"LibVLCSharp": "[3.10.0, )",
"LibVLCSharp.Avalonia": "[3.10.0, )",
@@ -511,13 +516,6 @@
"GalNet.Core": "[1.0.0, )"
}
},
- "galnet.presentation.defaults": {
- "type": "Project",
- "dependencies": {
- "GalNet.Presentation.Abstractions": "[1.0.0, )",
- "LibVLCSharp": "[3.10.0, )"
- }
- },
"galnet.primitives.builtins": {
"type": "Project",
"dependencies": {
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs
index fba45e2a..4611f15b 100644
--- a/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs
@@ -6,7 +6,7 @@
namespace GalNet.Storage.FileSystem;
/// File-backed store for player variables that survive individual save slots.
-public sealed class FilePlayerVariableStore : IPlayerVariableStore
+public sealed class FilePlayerVariableStore : IPlayerVariableStore, IDisposable
{
private readonly string _path;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -45,6 +45,12 @@ public async Task SaveAsync(IReadOnlyDictionary variables,
finally { _gate.Release(); }
}
+ public void Dispose()
+ {
+ _gate.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
private static GalVariable Clone(string name, GalVariable source)
{
var clone = new GalVariable { Name = name };
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs
index 15f96014..d848b8db 100644
--- a/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs
@@ -5,7 +5,7 @@
namespace GalNet.Storage.FileSystem;
/// File-backed slot and quick-save store.
-public sealed class FileSaveService : ISaveService
+public sealed class FileSaveService : ISaveService, IDisposable
{
private const int FormatVersion = GameSnapshot.CurrentFormatVersion;
private readonly string _root;
@@ -106,7 +106,7 @@ private async Task GetInfoAsync(int slot, bool quick, Cancellation
catch { return new SaveSlotInfo { SlotIndex = slot, IsQuickSave = quick, IsCorrupt = true }; }
}
- private async Task ReadAsync(string path, CancellationToken ct)
+ private static async Task ReadAsync(string path, CancellationToken ct)
{
if (!File.Exists(path)) return null;
try
@@ -127,6 +127,12 @@ private void DeleteFiles(int slot, bool quick)
if (File.Exists(path)) File.Delete(path);
}
+ public void Dispose()
+ {
+ _gate.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
private sealed class StoredSave
{
public int Version { get; set; }
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs
index f660726f..385a40b4 100644
--- a/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs
@@ -9,7 +9,7 @@ namespace GalNet.Storage.FileSystem;
/// Platform-neutral runtime variable bridge backed by an .
/// Variables prefixed with player. persist globally; all other variables remain in the active save.
///
-public sealed class FileVariableService : IVariableService
+public sealed class FileVariableService : IVariableService, IDisposable
{
private readonly IPlayerVariableStore _playerStore;
private readonly Dictionary _playerVariables;
@@ -66,6 +66,16 @@ public void NotifyVariableChanged(VariableScope scope, string name, GalVariable
public Task FlushPlayerVariablesAsync(CancellationToken ct = default) => _playerStore.SaveAsync(_playerVariables, ct);
+ /// Commits related startup preferences together before publishing their in-memory values.
+ public async Task UpdatePlayerVariablesAsync(IReadOnlyDictionary updates, CancellationToken ct = default)
+ {
+ var committed = updates.ToDictionary(pair => pair.Key, pair => Clone(pair.Key, pair.Value), StringComparer.Ordinal);
+ var next = _playerVariables.ToDictionary(pair => pair.Key, pair => Clone(pair.Key, pair.Value), StringComparer.Ordinal);
+ foreach (var pair in committed) next[pair.Key] = Clone(pair.Key, pair.Value);
+ await _playerStore.SaveAsync(next, ct);
+ foreach (var pair in committed) NotifyVariableChanged(VariableScope.Player, pair.Key, pair.Value);
+ }
+
/// Restores all persisted player variables to their configured defaults.
public async Task ResetPlayerVariablesAsync(CancellationToken ct = default)
{
@@ -78,6 +88,12 @@ public async Task ResetPlayerVariablesAsync(CancellationToken ct = default)
VariableChanged?.Invoke(VariableScope.Player, pair.Key, pair.Value);
}
+ public void Dispose()
+ {
+ (_playerStore as IDisposable)?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
private static GalVariable Clone(string name, GalVariable source)
{
var clone = new GalVariable { Name = name };
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/GalpakInstaller.cs b/src/Infrastructure/GalNet.Storage.FileSystem/GalpakInstaller.cs
index d003b365..25698fa3 100644
--- a/src/Infrastructure/GalNet.Storage.FileSystem/GalpakInstaller.cs
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/GalpakInstaller.cs
@@ -31,7 +31,11 @@ public static async Task InstallAsync(string packagePath, string? instal
if (Directory.Exists(targetDirectory))
{
- if (await MatchesInstalledPackageAsync(targetDirectory, manifest, ct)) return targetDirectory;
+ if (await MatchesInstalledPackageAsync(targetDirectory, manifest, ct))
+ {
+ EnsureExternalDirectory(targetDirectory);
+ return targetDirectory;
+ }
throw new InvalidOperationException($"Installation directory '{targetDirectory}' already contains a different package. Choose a new directory or remove it explicitly.");
}
if (File.Exists(targetDirectory)) throw new InvalidOperationException($"Installation path '{targetDirectory}' is a file.");
@@ -55,6 +59,7 @@ public static async Task InstallAsync(string packagePath, string? instal
await using (var manifestDestination = File.Create(installedManifest))
await manifestSource.CopyToAsync(manifestDestination, ct);
+ EnsureExternalDirectory(stagingDirectory);
Directory.Move(stagingDirectory, targetDirectory);
return targetDirectory;
}
@@ -65,6 +70,16 @@ public static async Task InstallAsync(string packagePath, string? instal
}
}
+ private static void EnsureExternalDirectory(string root)
+ {
+ foreach (var directory in new[] { Path.Combine(root, "I18n"), Path.Combine(root, "I18n", "External") })
+ {
+ Directory.CreateDirectory(directory);
+ if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
+ throw new InvalidDataException($"External translation directory cannot be a symbolic link: '{directory}'.");
+ }
+ }
+
private static ZipArchiveEntry FindManifestEntry(ZipArchive zip)
{
var manifests = zip.Entries
@@ -93,6 +108,8 @@ private static void ValidateManifest(GalpakManifest manifest, ZipArchive zip)
if (package.Size < 0 || string.IsNullOrWhiteSpace(package.Sha256) || package.Sha256.Length != 64 || !package.Sha256.All(char.IsAsciiHexDigit))
throw new InvalidDataException($"Package manifest entry '{package.Path}' has an invalid checksum or size.");
ValidateRelativePath(package.Path);
+ if (package.Path.StartsWith("I18n/External/", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("I18n/External is reserved for player-installed translations, not package payloads.");
if (package.Path.EndsWith(".galnet", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The package manifest cannot also be a payload file.");
if (!paths.Add(package.Path)) throw new InvalidDataException($"Package manifest repeats '{package.Path}'.");
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/GameLocalizationLoader.cs b/src/Infrastructure/GalNet.Storage.FileSystem/GameLocalizationLoader.cs
new file mode 100644
index 00000000..a3a737cf
--- /dev/null
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/GameLocalizationLoader.cs
@@ -0,0 +1,159 @@
+using System.Globalization;
+using System.Text.Json;
+using GalNet.Core.Entry;
+using GalNet.Core.Graph;
+using GalNet.Core.I18n;
+using GalNet.Core.Settings;
+using GalNet.Runtime.Content;
+
+namespace GalNet.Storage.FileSystem;
+
+/// Reads package text sources without referencing a concrete localization library.
+public static class GameLocalizationLoader
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public static GameLocalizationContent Load(string gameDirectory, Graph graph, ProjectSettings settings,
+ CancellationToken cancellationToken = default)
+ {
+ var root = Path.Combine(Path.GetFullPath(gameDirectory), "I18n");
+ var charactersPath = Path.Combine(root, "catalog", "characters.json");
+ var characters = CharacterCatalog.Empty;
+ if (File.Exists(charactersPath))
+ {
+ RejectLink(root);
+ RejectLink(Path.GetDirectoryName(charactersPath)!);
+ RejectLink(charactersPath);
+ try
+ {
+ var definitions = JsonSerializer.Deserialize>(File.ReadAllText(charactersPath), JsonOptions)
+ ?? throw new InvalidDataException("Character catalog must be a JSON array.");
+ characters = new CharacterCatalog(definitions);
+ }
+ catch (Exception error) when (error is JsonException or ArgumentException)
+ {
+ throw new InvalidDataException($"Invalid character catalog '{charactersPath}'.", error);
+ }
+ }
+
+ var sources = new List();
+ var defaultKeys = new HashSet(StringComparer.Ordinal);
+ if (Directory.Exists(root))
+ {
+ RejectLink(root);
+ var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false };
+ foreach (var path in Directory.EnumerateFiles(root, "*.json", options)
+ .OrderBy(path => Path.GetRelativePath(root, path).Replace('\\', '/'), StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
+ var cultureDirectory = relative.Split('/')[0];
+ if (cultureDirectory.Equals("catalog", StringComparison.OrdinalIgnoreCase)
+ || cultureDirectory.Equals("External", StringComparison.OrdinalIgnoreCase)) continue;
+ if (!relative.Contains('/')) throw new InvalidDataException($"Localization file '{relative}' must be below a culture directory.");
+ var culture = CultureInfo.GetCultureInfo(cultureDirectory);
+ var json = File.ReadAllText(path);
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ var keys = new HashSet(StringComparer.Ordinal);
+ ReadKeys(document.RootElement, "", keys);
+ if (string.Equals(culture.Name, settings.TargetLocale.Code, StringComparison.OrdinalIgnoreCase))
+ defaultKeys.UnionWith(keys);
+ }
+ catch (JsonException error)
+ {
+ throw new InvalidDataException($"Invalid localization file '{relative}'.", error);
+ }
+ sources.Add(new(culture.Name, relative, json));
+ }
+ }
+
+ ValidateRequiredKeys(graph, characters, defaultKeys, settings.TargetLocale.Code);
+ var diagnostics = new List();
+ var externalSources = LoadExternal(Path.Combine(root, "External"), diagnostics, cancellationToken);
+ return new GameLocalizationContent
+ {
+ Characters = characters,
+ Sources = sources.AsReadOnly(),
+ ExternalSources = externalSources,
+ Diagnostics = diagnostics.AsReadOnly()
+ };
+ }
+
+ private static IReadOnlyList LoadExternal(string root,
+ List diagnostics, CancellationToken cancellationToken)
+ {
+ var sources = new List();
+ if (!Directory.Exists(root)) return sources;
+ try
+ {
+ RejectLink(root);
+ var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true };
+ foreach (var path in Directory.EnumerateFiles(root, "*.json", options)
+ .OrderBy(path => Path.GetRelativePath(root, path).Replace('\\', '/'), StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
+ try
+ {
+ if (!relative.Contains('/')) throw new InvalidDataException("External JSON must be below a culture directory.");
+ var culture = CultureInfo.GetCultureInfo(relative.Split('/')[0]);
+ var json = File.ReadAllText(path);
+ using var document = JsonDocument.Parse(json);
+ ReadKeys(document.RootElement, "", new(StringComparer.Ordinal));
+ sources.Add(new(culture.Name, $"External/{relative}", json));
+ }
+ catch (Exception error) when (error is IOException or InvalidDataException or UnauthorizedAccessException or JsonException or ArgumentException)
+ {
+ diagnostics.Add(new($"External/{relative}", error.Message));
+ }
+ }
+ }
+ catch (Exception error) when (error is IOException or InvalidDataException or UnauthorizedAccessException)
+ {
+ diagnostics.Add(new("External", error.Message));
+ }
+ return sources.AsReadOnly();
+ }
+
+ private static void ReadKeys(JsonElement element, string prefix, HashSet keys)
+ {
+ if (element.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Localization documents must contain JSON objects with string leaves.");
+ foreach (var property in element.EnumerateObject())
+ {
+ if (string.IsNullOrWhiteSpace(property.Name)) throw new InvalidDataException("Localization keys cannot be empty.");
+ var key = prefix.Length == 0 ? property.Name : $"{prefix}.{property.Name}";
+ if (property.Value.ValueKind == JsonValueKind.Object) ReadKeys(property.Value, key, keys);
+ else if (property.Value.ValueKind == JsonValueKind.String) keys.Add(key);
+ else throw new InvalidDataException($"Localization key '{key}' must have a string value.");
+ }
+ }
+
+ private static void ValidateRequiredKeys(Graph graph, CharacterCatalog characters, HashSet defaultKeys, string defaultCulture)
+ {
+ foreach (var character in characters.Characters) Require(character.DisplayNameKey);
+ foreach (var group in graph.Nodes.OfType())
+ foreach (var entry in group.Entries.OfType())
+ {
+ if (entry.Type is not ("dialogue.text" or "nvl.append")) continue;
+ if (entry.Type == "dialogue.text" && entry.Arguments.TryGetProperty("speaker", out var speaker)
+ && !string.IsNullOrEmpty(speaker.GetString())) characters.GetRequired(speaker.GetString()!);
+ Require(entry.Arguments.TryGetProperty("content", out var content) ? content.GetString() ?? "" : "");
+ }
+ foreach (var branch in graph.Nodes.OfType().Where(branch => branch.BranchType == BranchType.Choice))
+ foreach (var option in branch.Options) Require(option.Text);
+
+ void Require(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key) || !defaultKeys.Contains(key))
+ throw new InvalidDataException($"Default language '{defaultCulture}' is missing required localization key '{key}'.");
+ }
+ }
+
+ private static void RejectLink(string path)
+ {
+ if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
+ throw new InvalidDataException($"Localization paths cannot be symbolic links: '{path}'.");
+ }
+}
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs b/src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs
new file mode 100644
index 00000000..327468a2
--- /dev/null
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs
@@ -0,0 +1,28 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GalNet.Core.Settings;
+
+namespace GalNet.Storage.FileSystem;
+
+internal static class GameProjectSettingsLoader
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public static ProjectSettings Load(string directory)
+ {
+ var path = Path.Combine(directory, "settings.json");
+ if (!File.Exists(path)) return new ProjectSettings();
+ try
+ {
+ return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions)
+ ?? throw new InvalidDataException("Project settings must be a JSON object.");
+ }
+ catch (JsonException error)
+ {
+ throw new InvalidDataException($"Invalid project settings in '{path}'.", error);
+ }
+ }
+}
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs b/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs
index c71c3344..57e52cd0 100644
--- a/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs
@@ -22,6 +22,8 @@ public Task LoadAsync(CancellationToken cancellationToken = default
cancellationToken.ThrowIfCancellationRequested();
var graph = GameGraphContentLoader.Load(_directory, Path.Combine("Graph", "graph.json"), Path.Combine("Graph", "groups"), cancellationToken);
var gallery = GalleryFileLoader.LoadGenerated(Path.Combine(_directory, "gallery.json"), _galleryTypes);
- return new GameContent { Graph = graph, Gallery = gallery };
+ var settings = GameProjectSettingsLoader.Load(_directory);
+ var localization = GameLocalizationLoader.Load(_directory, graph, settings, cancellationToken);
+ return new GameContent { Graph = graph, Gallery = gallery, Settings = settings, Localization = localization };
}, cancellationToken);
}
diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs b/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs
index 0b4bbf19..6deb760a 100644
--- a/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs
+++ b/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs
@@ -28,6 +28,8 @@ public Task LoadAsync(CancellationToken cancellationToken = default
using var archive = assets.OpenArchive("assets");
var gallery = new GalleryCatalogCompiler(_resourceTypes, _galleryTypes)
.Compile(archive.AssetIds.Select(id => archive.GetAsset(id)!.Metadata));
- return new GameContent { Graph = graph, AssetRoot = assetsDirectory, Gallery = gallery };
+ var settings = GameProjectSettingsLoader.Load(_directory);
+ var localization = GameLocalizationLoader.Load(_directory, graph, settings, cancellationToken);
+ return new GameContent { Graph = graph, AssetRoot = assetsDirectory, Gallery = gallery, Settings = settings, Localization = localization };
}, cancellationToken);
}
diff --git a/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs b/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs
index 674b3d32..ef48bbd7 100644
--- a/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs
+++ b/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs
@@ -81,7 +81,7 @@ public Task StartAsync(CancellationToken cancellationToken = default)
IsCompleted = false;
Text = null;
(Inlines ?? throw new InvalidOperationException("TextBlock did not provide an inline collection.")).Clear();
- _ = RenderAsync(SourceText, _runCancellation.Token, _completion, _skipSignal);
+ _ = RenderAsync(SourceText, _completion, _skipSignal, _runCancellation.Token);
return _completion.Task;
}
@@ -99,9 +99,9 @@ public void Skip()
private async Task RenderAsync(
string? source,
- CancellationToken cancellationToken,
TaskCompletionSource completion,
- TaskCompletionSource skipSignal)
+ TaskCompletionSource skipSignal,
+ CancellationToken cancellationToken)
{
try
{
@@ -125,14 +125,14 @@ private async Task RenderAsync(
cancellationToken.ThrowIfCancellationRequested();
run.Text += character;
if (CharactersPerSecond > 0)
- await DelayOrSkipAsync(TimeSpan.FromSeconds(1d / CharactersPerSecond), cancellationToken, skipSignal);
+ await DelayOrSkipAsync(TimeSpan.FromSeconds(1d / CharactersPerSecond), skipSignal, cancellationToken);
}
break;
case RichTypewriterTokenKind.LineBreak:
inlines.Add(new LineBreak());
break;
case RichTypewriterTokenKind.Delay:
- await DelayOrSkipAsync(TimeSpan.FromMilliseconds(token.DelayMilliseconds), cancellationToken, skipSignal);
+ await DelayOrSkipAsync(TimeSpan.FromMilliseconds(token.DelayMilliseconds), skipSignal, cancellationToken);
break;
case RichTypewriterTokenKind.Instant:
lock (_skipGate)
@@ -170,7 +170,7 @@ private async Task RenderAsync(
}
}
- private async Task DelayOrSkipAsync(TimeSpan duration, CancellationToken cancellationToken, TaskCompletionSource skipSignal)
+ private async Task DelayOrSkipAsync(TimeSpan duration, TaskCompletionSource skipSignal, CancellationToken cancellationToken)
{
Task signal;
lock (_skipGate)
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs
index 0d7af882..bd9449a1 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs
@@ -39,6 +39,7 @@ public static IServiceCollection AddAvaloniaGameViewPages(
services.AddScoped(provider =>
provider.GetRequiredService());
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj b/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj
index 1413b698..a31c2a88 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj
+++ b/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj
@@ -17,7 +17,6 @@
-
diff --git a/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs b/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs
new file mode 100644
index 00000000..9b3192b0
--- /dev/null
+++ b/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs
@@ -0,0 +1,13 @@
+using Avalonia.Data;
+
+namespace GalNet.Avalonia.GameView.MarkupExtensions;
+
+/// Host supplies observable text values; shared pages retain English fallbacks.
+public sealed class PlayerTextExtension(string key, string fallback)
+{
+ public static Func? Resolve { get; set; }
+
+ public object ProvideValue(IServiceProvider serviceProvider) => Resolve is { } resolve
+ ? new ReflectionBinding("Value") { Source = resolve(key), Mode = BindingMode.OneWay }
+ : fallback;
+}
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs
index 1d57001c..980a820f 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs
@@ -73,7 +73,7 @@ private static string NormalizeTypeId(string? value)
public sealed class GalleryPageRegistry(IReadOnlyDictionary pages) : IGalleryPageRegistry
{
- private readonly IReadOnlyDictionary _pages = new Dictionary(pages, StringComparer.Ordinal);
+ private readonly Dictionary _pages = new(pages, StringComparer.Ordinal);
public Type GetViewModelType(string galleryTypeId) =>
TryGetViewModelType(galleryTypeId, out var viewModelType)
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs
index d52d9d99..01858a59 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs
@@ -259,7 +259,7 @@ public IPageViewRegistry Build()
/// Immutable runtime page mapping.
public sealed class PageViewRegistry : IPageViewRegistry
{
- private readonly IReadOnlyDictionary _views;
+ private readonly Dictionary _views;
internal PageViewRegistry(IReadOnlyDictionary views) =>
_views = new Dictionary(views);
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml
index f75c1a6c..2f0eb084 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml
@@ -1,5 +1,6 @@
-
-
+
-
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/AudioGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/AudioGalleryPage.axaml
index 6069e9b4..543d189c 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/AudioGalleryPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/AudioGalleryPage.axaml
@@ -1,10 +1,11 @@
-
-
+
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryImagePage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryImagePage.axaml
index 01c7cf97..625627cd 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryImagePage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryImagePage.axaml
@@ -1,7 +1,8 @@
-
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml
index ebba22ea..a44cebec 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml
@@ -1,10 +1,11 @@
-
-
+
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml
index 3f037abd..e110fae5 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml
@@ -1,5 +1,6 @@
-
-
+
+
-
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml
index 20d59291..24b5498d 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml
@@ -1,5 +1,6 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs
index 03797250..9a97ba82 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs
@@ -60,6 +60,7 @@ public void Dispose()
_gameplay.ScreenshotRequested -= OnScreenshotRequested;
_gameplay.ReturnToTitleRequested -= OnReturnToTitleRequested;
}
+ GC.SuppressFinalize(this);
}
private void OnSessionPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs eventArgs)
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml
index 68b568f7..0adf5748 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml
@@ -1,7 +1,8 @@
-
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/SaveSlotsPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/SaveSlotsPage.axaml
index 387bd121..67e7208a 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/SaveSlotsPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/SaveSlotsPage.axaml
@@ -1,5 +1,6 @@
-
+
-
@@ -34,6 +35,6 @@
-
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/ScreenshotDialog.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/ScreenshotDialog.axaml
index 81152ffe..fe7a9bb8 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/ScreenshotDialog.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/ScreenshotDialog.axaml
@@ -1,5 +1,6 @@
-
@@ -23,18 +24,18 @@
ColumnDefinitions="*,Auto"
ColumnSpacing="8">
-
+
+ Content="{text:PlayerText Screenshot.ChooseFolder, 'Choose folder…'}" />
-
+
@@ -42,7 +43,7 @@
+ Content="{text:PlayerText Screenshot.IncludeUi, 'Include player UI'}" />
@@ -53,10 +54,10 @@
Orientation="Horizontal"
Spacing="8">
+ Content="{text:PlayerText Common.Cancel, 'Cancel'}" />
+ Content="{text:PlayerText Common.Save, 'Save'}" />
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/SettingsPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/SettingsPage.axaml
index 04e2749a..f7f43225 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/SettingsPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/SettingsPage.axaml
@@ -2,25 +2,43 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:GalNet.Avalonia.GameView.Page"
xmlns:vm="using:GalNet.Avalonia.GameView.ViewModels"
+ xmlns:settings="using:GalNet.Avalonia.GameView.Services"
+ xmlns:text="using:GalNet.Avalonia.GameView.MarkupExtensions"
x:Class="GalNet.Avalonia.GameView.Page.SettingsPage"
x:DataType="vm:SettingsPageViewModel">
-
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/TitlePage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/TitlePage.axaml
index f63684d7..bd786018 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/TitlePage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/TitlePage.axaml
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:GalNet.Avalonia.GameView.Page"
xmlns:vm="using:GalNet.Avalonia.GameView.ViewModels"
+ xmlns:text="using:GalNet.Avalonia.GameView.MarkupExtensions"
x:Class="GalNet.Avalonia.GameView.Page.TitlePage"
x:DataType="vm:TitlePageViewModel">
@@ -13,17 +14,17 @@
TextAlignment="Center" Opacity="0.68"
Margin="0,2,0,40" />
-
-
-
-
-
-
-
-
+
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml
index 73596f84..e4612a1a 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml
+++ b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml
@@ -1,8 +1,9 @@
-
+
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs
index 6f1f4977..71948972 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs
@@ -116,7 +116,7 @@ await _dispatcher.InvokeAsync(() =>
public async Task PlayAnimationPlanAsync(
AnimationPlanDefinition plan,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken, Func? beforeFrame = null)
{
cancellationToken.ThrowIfCancellationRequested();
var active = new ActivePlan(plan, Interlocked.Increment(ref _sequence));
@@ -145,6 +145,7 @@ public async Task PlayAnimationPlanAsync(
}
var elapsedFrames = started.Elapsed.TotalSeconds * plan.FrameRate;
+ if (beforeFrame is not null) await beforeFrame(elapsedFrames);
var frame = GetPlanFrame(plan, elapsedFrames);
await ApplyPlanFrameAsync(tracks, frame);
if (plan.LoopMode == AnimationLoopMode.Once && elapsedFrames >= plan.DurationFrames)
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs
index 47e4781f..8328d222 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs
@@ -16,12 +16,10 @@ public AvaloniaDialoguePresenter(GamePageViewModel state, GamePage page, IAvalon
_state = state;
_page = page;
_dispatcher = dispatcher;
- _state.AdvanceRequested += OnAdvanceRequested;
_state.NvlClearRequested += OnNvlClearRequested;
}
public Task InitialPresentationReady => _initialPresentationReady.Task;
- public event Action? AdvanceRequested;
public void CompleteInitialPresentation() => _initialPresentationReady.TrySetResult();
public void FailInitialPresentation(Exception exception) => _initialPresentationReady.TrySetException(exception);
@@ -71,10 +69,8 @@ Task IChoicePresenter.ChooseAsync(IReadOnlyList options, Cancellati
public void Dispose()
{
- _state.AdvanceRequested -= OnAdvanceRequested;
_state.NvlClearRequested -= OnNvlClearRequested;
}
- private void OnAdvanceRequested() => AdvanceRequested?.Invoke();
private void OnNvlClearRequested() => _dispatcher.Dispatch(_page.Nvl.Clear);
}
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePresenters.cs
similarity index 89%
rename from src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs
rename to src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePresenters.cs
index 68d02bf6..1ca6fe02 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePresenters.cs
@@ -11,13 +11,13 @@ public interface IGamePageLayerFactory : ISceneTextureResolver
}
/// Composes the presentation ports used by one shared game page.
-public sealed class AvaloniaGamePageView : IDisposable
+public sealed class AvaloniaGamePresenters : IDisposable
{
private readonly AvaloniaDialoguePresenter _dialogue;
private readonly AvaloniaAnimationPresenter _animation;
private readonly AvaloniaParticlePresenter _particles;
- public AvaloniaGamePageView(
+ public AvaloniaGamePresenters(
GamePageViewModel state,
GamePage page,
IGamePageLayerFactory layers,
@@ -43,12 +43,6 @@ public AvaloniaGamePageView(
public IParticlePresenter ParticlePresenter { get; }
public Task InitialPresentationReady => _dialogue.InitialPresentationReady;
- public event Action? AdvanceRequested
- {
- add => _dialogue.AdvanceRequested += value;
- remove => _dialogue.AdvanceRequested -= value;
- }
-
public void CompleteInitialPresentation() => _dialogue.CompleteInitialPresentation();
public void FailInitialPresentation(Exception exception) => _dialogue.FailInitialPresentation(exception);
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaParticlePresenter.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaParticlePresenter.cs
index f2cc9fba..17efcbc6 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaParticlePresenter.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaParticlePresenter.cs
@@ -79,7 +79,13 @@ private void RegisterAnimation(ParticleEmitterRequest request, string property,
// Drain can occur while the frame host enumerates renderables, so removal is deferred.
private void OnParticleDrained(ParticleEmitter emitter) =>
- _dispatcher.Dispatch(() => StopParticleEmitter(emitter.HandleId));
+ _dispatcher.Post(() => StopParticleEmitter(emitter));
+
+ private void StopParticleEmitter(ParticleEmitter emitter)
+ {
+ if (!_emitters.TryGetValue(emitter.HandleId, out var current) || !ReferenceEquals(current, emitter)) return;
+ StopParticleEmitter(emitter.HandleId);
+ }
private void StopParticleEmitter(string instanceId)
{
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/IAvaloniaUiDispatcher.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/IAvaloniaUiDispatcher.cs
index aff31668..90b84eef 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/IAvaloniaUiDispatcher.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/IAvaloniaUiDispatcher.cs
@@ -4,7 +4,10 @@ namespace GalNet.Avalonia.GameView.Presentation;
public interface IAvaloniaUiDispatcher
{
+ /// Runs immediately when the caller already owns the UI thread.
void Dispatch(Action action);
+ /// Always queues work after the current UI callback returns.
+ void Post(Action action);
Task InvokeAsync(Func action);
Task InvokeAsync(Func> action);
}
@@ -29,6 +32,12 @@ public void Dispatch(Action action)
Dispatcher.UIThread.Post(action);
}
+ public void Post(Action action)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+ Dispatcher.UIThread.Post(action);
+ }
+
public Task InvokeAsync(Func action)
{
ArgumentNullException.ThrowIfNull(action);
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs b/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs
index d0747cb3..39101c6a 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text;
+using System.Threading.Channels;
using GalNet.Core.Assets;
using GalNet.Core.Audio;
using GalNet.Presentation.Abstractions.Audio;
@@ -9,13 +10,21 @@
namespace GalNet.Avalonia.GameView.Services;
/// LibVLC-backed audio host. Logical queue and track ownership remains in AudioRuntime.
-public sealed class AvaloniaAudioPlaybackHost : IAudioPlaybackHost
+public sealed class AvaloniaAudioPlaybackHost : IAudioPlaybackHost, IAsyncDisposable
{
private readonly object _gate = new();
private readonly IAssetManager _assets;
private readonly string _directory = Path.Combine(Path.GetTempPath(), "GalNet", "Audio", Guid.NewGuid().ToString("N"));
private readonly Dictionary _voices = new(StringComparer.Ordinal);
private readonly Dictionary _paths = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _pending = new(StringComparer.Ordinal);
+ private readonly List _loads = [];
+ private readonly CancellationTokenSource _shutdown = new();
+ private readonly SemaphoreSlim _materialization = new(1, 1);
+ private readonly Channel<(Voice Voice, AudioBackendEventKind Kind, string? Message)> _terminalEvents =
+ Channel.CreateUnbounded<(Voice, AudioBackendEventKind, string?)>(new UnboundedChannelOptions { SingleReader = true });
+ private readonly Task _terminalWorker;
+ private Task? _disposeTask;
private readonly AvaloniaAudioSEffectRegistry _effects;
private readonly LibVLC? _libVlc;
private bool _disposed;
@@ -38,6 +47,7 @@ public AvaloniaAudioPlaybackHost(IAssetManager assets, IEnumerable _ = DisposeAsync().AsTask();
+
+ public ValueTask DisposeAsync()
{
lock (_gate)
{
- if (_disposed) return;
+ if (_disposeTask is not null) return new ValueTask(_disposeTask);
_disposed = true;
+ _shutdown.Cancel();
foreach (var voice in _voices.Values) voice.Dispose();
_voices.Clear();
+ _terminalEvents.Writer.TryComplete();
+ _disposeTask = FinishDisposalAsync(_loads.ToArray());
+ return new ValueTask(_disposeTask);
+ }
+ }
+
+ private async Task FinishDisposalAsync(Task[] loads)
+ {
+ // Ensure cancellation callbacks have left the disposal lock before cleanup.
+ await Task.Yield();
+ try { await Task.WhenAll(loads.Append(_terminalWorker)).ConfigureAwait(false); }
+ finally
+ {
_paths.Clear();
_libVlc?.Dispose();
+ _materialization.Dispose();
+ _shutdown.Dispose();
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+ }
+
+ private Task StartTrackedAsync(AudioBackendRequest request, CancellationToken cancellationToken)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_pending.TryGetValue(request.VoiceId, out var previous)) previous.Cancellation.Cancel();
+ var pending = new PendingLoad(request.Generation,
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token));
+ _pending[request.VoiceId] = pending;
+ var task = ObserveStartAsync(request, pending);
+ _loads.RemoveAll(load => load.IsCompleted);
+ _loads.Add(task);
+ return task;
+ }
+ }
+
+ private async Task ObserveStartAsync(AudioBackendRequest request, PendingLoad pending)
+ {
+ try { await StartAsync(request, pending.Cancellation.Token).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (pending.Cancellation.IsCancellationRequested) { }
+ finally
+ {
+ lock (_gate)
+ if (_pending.TryGetValue(request.VoiceId, out var current) && ReferenceEquals(current, pending))
+ _pending.Remove(request.VoiceId);
+ pending.Cancellation.Dispose();
}
- if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private async Task StartAsync(AudioBackendRequest request, CancellationToken cancellationToken)
@@ -99,6 +156,7 @@ private async Task StartAsync(AudioBackendRequest request, CancellationToken can
string path;
try { path = await MaterializeAsync(request.AssetId, cancellationToken).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception exception)
{
Raise(new(AudioBackendEventKind.Failed, request.TrackId, request.VoiceId, request.Generation, exception.Message));
@@ -108,6 +166,7 @@ private async Task StartAsync(AudioBackendRequest request, CancellationToken can
Voice voice;
lock (_gate)
{
+ cancellationToken.ThrowIfCancellationRequested();
if (_disposed) return;
RemoveVoice(request.VoiceId);
var player = new MediaPlayer(_libVlc);
@@ -127,33 +186,44 @@ private async Task StartAsync(AudioBackendRequest request, CancellationToken can
private async Task MaterializeAsync(string assetId, CancellationToken cancellationToken)
{
- lock (_gate)
- if (_paths.TryGetValue(assetId, out var existing)) return existing;
- var file = await _assets.GetFileAsync(assetId, cancellationToken).ConfigureAwait(false)
- ?? throw new FileNotFoundException($"Audio asset '{assetId}' was not found.");
- using var handle = await _assets.AcquireAsync(assetId, cancellationToken).ConfigureAwait(false)
- ?? throw new InvalidDataException($"Audio asset '{assetId}' could not be decoded as bytes.");
- var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(assetId))).ToLowerInvariant();
- var path = Path.Combine(_directory, name + Path.GetExtension(file.Path));
- await File.WriteAllBytesAsync(path, handle.Value, cancellationToken).ConfigureAwait(false);
- lock (_gate)
+ await _materialization.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- if (_disposed) throw new ObjectDisposedException(nameof(AvaloniaAudioPlaybackHost));
- _paths.TryAdd(assetId, path);
- return _paths[assetId];
+ lock (_gate)
+ if (_paths.TryGetValue(assetId, out var existing)) return existing;
+ var file = await _assets.GetFileAsync(assetId, cancellationToken).ConfigureAwait(false)
+ ?? throw new FileNotFoundException($"Audio asset '{assetId}' was not found.");
+ using var handle = await _assets.AcquireAsync(assetId, cancellationToken).ConfigureAwait(false)
+ ?? throw new InvalidDataException($"Audio asset '{assetId}' could not be decoded as bytes.");
+ var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(assetId))).ToLowerInvariant();
+ var path = Path.Combine(_directory, name + Path.GetExtension(file.Path));
+ await File.WriteAllBytesAsync(path, handle.Value, cancellationToken).ConfigureAwait(false);
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _paths.TryAdd(assetId, path);
+ return _paths[assetId];
+ }
}
+ finally { _materialization.Release(); }
}
- private void Stop(AudioBackendRequest request, float fadeSeconds) => WithVoice(request, voice =>
+ private void Stop(AudioBackendRequest request, float fadeSeconds)
{
- if (fadeSeconds <= 0)
+ lock (_gate)
+ if (_pending.TryGetValue(request.VoiceId, out var pending) && pending.Generation == request.Generation)
+ pending.Cancellation.Cancel();
+ WithVoice(request, voice =>
{
- voice.Player.Stop();
- RemoveVoice(voice.Request.VoiceId);
- return;
- }
- _ = FadeAsync(voice, voice.FadeGain, 0, fadeSeconds, stopWhenDone: true);
- });
+ if (fadeSeconds <= 0)
+ {
+ voice.Player.Stop();
+ RemoveVoice(voice.Request.VoiceId);
+ return;
+ }
+ _ = FadeAsync(voice, voice.FadeGain, 0, fadeSeconds, stopWhenDone: true);
+ });
+ }
private void ApplySEffect(AudioBackendRequest request) => WithVoice(request, voice =>
{
@@ -170,11 +240,17 @@ private void StopSEffect(AudioBackendRequest request) => WithVoice(request, voic
private async Task FadeAsync(Voice voice, float from, float to, float seconds, bool stopWhenDone)
{
const int steps = 20;
+ CancellationToken cancellation;
+ lock (_gate)
+ {
+ if (_disposed || !_voices.TryGetValue(voice.Request.VoiceId, out var current) || !ReferenceEquals(current, voice)) return;
+ cancellation = voice.BeginFade();
+ }
try
{
for (var index = 1; index <= steps; index++)
{
- await Task.Delay(TimeSpan.FromSeconds(seconds / steps), voice.FadeCancellation.Token).ConfigureAwait(false);
+ await Task.Delay(TimeSpan.FromSeconds(seconds / steps), cancellation).ConfigureAwait(false);
lock (_gate)
{
if (_disposed || !_voices.TryGetValue(voice.Request.VoiceId, out var current) || !ReferenceEquals(current, voice)) return;
@@ -205,14 +281,29 @@ private void WithVoice(AudioBackendRequest request, Action action)
private void Ended(Voice voice)
{
- lock (_gate) RemoveVoice(voice.Request.VoiceId);
- Raise(new(AudioBackendEventKind.Ended, voice.Request.TrackId, voice.Request.VoiceId, voice.Request.Generation));
+ _terminalEvents.Writer.TryWrite((voice, AudioBackendEventKind.Ended, null));
}
private void Failed(Voice voice, string message)
{
- lock (_gate) RemoveVoice(voice.Request.VoiceId);
- Raise(new(AudioBackendEventKind.Failed, voice.Request.TrackId, voice.Request.VoiceId, voice.Request.Generation, message));
+ _terminalEvents.Writer.TryWrite((voice, AudioBackendEventKind.Failed, message));
+ }
+
+ private async Task DrainTerminalEventsAsync()
+ {
+ // LibVLC callbacks must not Stop/Dispose a player or wait for our gate:
+ // player disposal can itself wait for the native callback to return.
+ await foreach (var item in _terminalEvents.Reader.ReadAllAsync().ConfigureAwait(false))
+ {
+ lock (_gate)
+ {
+ if (_disposed || !_voices.TryGetValue(item.Voice.Request.VoiceId, out var current) ||
+ !ReferenceEquals(current, item.Voice)) continue;
+ RemoveVoice(item.Voice.Request.VoiceId);
+ }
+ Raise(new(item.Kind, item.Voice.Request.TrackId, item.Voice.Request.VoiceId,
+ item.Voice.Request.Generation, item.Message));
+ }
}
private void RemoveVoice(string voiceId)
@@ -249,7 +340,14 @@ private sealed class Voice(AudioBackendRequest request, MediaPlayer player) : ID
public float FadeGain { get; set; } = 1;
public float EffectGain { get; set; } = 1;
public Dictionary Effects { get; } = new(StringComparer.Ordinal);
- public CancellationTokenSource FadeCancellation { get; } = new();
+ private CancellationTokenSource FadeCancellation { get; set; } = new();
+ public CancellationToken BeginFade()
+ {
+ FadeCancellation.Cancel();
+ FadeCancellation.Dispose();
+ FadeCancellation = new CancellationTokenSource();
+ return FadeCancellation.Token;
+ }
public void Dispose()
{
FadeCancellation.Cancel();
@@ -259,4 +357,5 @@ public void Dispose()
}
private sealed record ActiveSEffect(string Id, System.Text.Json.JsonElement Parameters);
+ private sealed record PendingLoad(long Generation, CancellationTokenSource Cancellation);
}
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs b/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs
index 25a3126c..a519b81c 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Collections.ObjectModel;
using System.ComponentModel;
@@ -39,5 +40,5 @@ public interface IPreparedGameSessionService
/// Presentation-safe save slot data. The host owns loading, writing and preview bytes.
public sealed record GameSaveSlot(int SlotIndex, DateTime Timestamp, string Description, bool IsEmpty, bool IsCorrupt)
{
- public string DisplayTimestamp => Timestamp == default ? string.Empty : Timestamp.ToString("g");
+ public string DisplayTimestamp => Timestamp == default ? string.Empty : Timestamp.ToString("g", CultureInfo.CurrentCulture);
}
diff --git a/src/Presentation/GalNet.Avalonia.GameView/Services/IPlayerSettingsService.cs b/src/Presentation/GalNet.Avalonia.GameView/Services/IPlayerSettingsService.cs
new file mode 100644
index 00000000..09dbc6ea
--- /dev/null
+++ b/src/Presentation/GalNet.Avalonia.GameView/Services/IPlayerSettingsService.cs
@@ -0,0 +1,61 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace GalNet.Avalonia.GameView.Services;
+
+/// Host-defined settings rendered by the shared player settings page.
+public interface IPlayerSettingsService
+{
+ IReadOnlyList Items { get; }
+}
+
+public abstract class PlayerSetting(string id, object label) : ObservableObject
+{
+ public string Id { get; } = id;
+ public object Label { get; } = label;
+}
+
+public sealed record SettingOption(string Value, string DisplayName);
+
+public sealed class ChoicePlayerSetting(string id, object label, IReadOnlyList options,
+ Func read, Action write) : PlayerSetting(id, label)
+{
+ public IReadOnlyList Options { get; } = options;
+ public SettingOption? SelectedOption
+ {
+ get => Options.FirstOrDefault(option => option.Value == read());
+ set
+ {
+ if (value is null || !Options.Contains(value) || value.Value == read()) return;
+ write(value.Value);
+ Refresh();
+ }
+ }
+ public void Refresh() => OnPropertyChanged(nameof(SelectedOption));
+}
+
+public sealed class RangePlayerSetting(string id, object label, double minimum, double maximum,
+ Func read, Action write) : PlayerSetting(id, label)
+{
+ public double Minimum { get; } = minimum;
+ public double Maximum { get; } = maximum;
+ public double Value
+ {
+ get => read();
+ set
+ {
+ if (!double.IsFinite(value)) return;
+ var next = Math.Clamp(value, Minimum, Maximum);
+ if (next == read()) return;
+ write(next);
+ Refresh();
+ }
+ }
+
+ public void Refresh() => OnPropertyChanged(nameof(Value));
+}
+
+internal sealed class DefaultPlayerSettingsService(ViewModels.GamePageViewModel gameplay) : IPlayerSettingsService
+{
+ public IReadOnlyList Items { get; } =
+ [new RangePlayerSetting("textSpeed", "Text speed", 0, 120, () => gameplay.TextSpeed, value => gameplay.TextSpeed = value)];
+}
diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs
index 85a1357e..9598a96d 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -21,7 +22,7 @@ public Task ActivateAsync(GalleryItemData item, CancellationToken cancellationTo
Image?.Dispose();
Image = null;
ErrorMessage = null;
- Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString() : item.Item.Title;
+ Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString(CultureInfo.InvariantCulture) : item.Item.Title;
if (!item.IsUnlocked) { ErrorMessage = "This Gallery item is locked."; return Task.CompletedTask; }
var path = media.ResolvePath(item.Item.ResourceId);
try { Image = path is null ? null : new Bitmap(path); }
diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs
index 1017e39f..db8f2d53 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using GalNet.Core.Gallery;
@@ -8,7 +9,7 @@ public sealed partial class GalleryEntryViewModel(GalleryItemData data) : Observ
{
public GalleryItemData Data { get; } = data;
public int Id => Data.Item.Id;
- public string Title => string.IsNullOrWhiteSpace(Data.Item.Title) ? Data.Item.Id.ToString() : Data.Item.Title;
+ public string Title => string.IsNullOrWhiteSpace(Data.Item.Title) ? Data.Item.Id.ToString(CultureInfo.InvariantCulture) : Data.Item.Title;
public bool IsUnlocked => Data.IsUnlocked;
public bool IsLocked => !IsUnlocked;
diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs
index a1483f7d..c0aea65e 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs
@@ -64,6 +64,7 @@ public void Dispose()
_thumbnailCancellation = null;
foreach (var item in Items) item.Dispose();
Items.Clear();
+ GC.SuppressFinalize(this);
}
protected IGameNavigationService Navigation => navigation;
diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/SettingsPageViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/SettingsPageViewModel.cs
index 13899210..1883b8b7 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/SettingsPageViewModel.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/SettingsPageViewModel.cs
@@ -1,11 +1,13 @@
using CommunityToolkit.Mvvm.Input;
using GalNet.Avalonia.GameView.Navigation;
+using GalNet.Avalonia.GameView.Services;
namespace GalNet.Avalonia.GameView.ViewModels;
-public sealed partial class SettingsPageViewModel(GamePageViewModel gameplay, IGameNavigationService navigation)
+public sealed partial class SettingsPageViewModel(IGameNavigationService navigation,
+ IPlayerSettingsService settings)
: PageViewModelBase
{
- public double TextSpeed { get => gameplay.TextSpeed; set => gameplay.TextSpeed = value; }
+ public IReadOnlyList Items => settings.Items;
[RelayCommand] private void Back() => navigation.GoBack();
}
diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs
index 49e58013..669c4b90 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs
+++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GalNet.Avalonia.GameView.Navigation;
@@ -18,7 +19,7 @@ public sealed partial class VideoGalleryPlayerPageViewModel(
public Task ActivateAsync(GalleryItemData item, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString() : item.Item.Title;
+ Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString(CultureInfo.InvariantCulture) : item.Item.Title;
ErrorMessage = null;
if (!item.IsUnlocked) ErrorMessage = "This Gallery item is locked.";
else { media.PlayVideo(item); ErrorMessage = media.LastError; }
diff --git a/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json b/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json
index 4b4e537c..c24ec6cb 100644
--- a/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json
+++ b/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json
@@ -168,13 +168,6 @@
"GalNet.Core": "[1.0.0, )"
}
},
- "galnet.presentation.defaults": {
- "type": "Project",
- "dependencies": {
- "GalNet.Presentation.Abstractions": "[1.0.0, )",
- "LibVLCSharp": "[3.10.0, )"
- }
- },
"Avalonia.Skia": {
"type": "CentralTransitive",
"requested": "[12.0.5, )",
diff --git a/src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj b/src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj
deleted file mode 100644
index 97960202..00000000
--- a/src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
diff --git a/src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs b/src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs
deleted file mode 100644
index 055cdfd1..00000000
--- a/src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using LibVLCSharp.Shared;
-using LibVlcMedia = LibVLCSharp.Shared.Media;
-
-namespace GalNet.Presentation.Defaults.Media;
-
-/// Framework-neutral LibVLC audio controller for UI hosts that choose this backend.
-public sealed class LibVlcAudioController : IDisposable
-{
- private readonly LibVLC? _libVlc;
- private readonly Dictionary _players = new(StringComparer.OrdinalIgnoreCase);
-
- public LibVlcAudioController(LibVLC? libVlc) => _libVlc = libVlc;
-
- public void Play(string channel, string assetId, float volume)
- {
- if (_libVlc is null) return;
- var player = GetPlayer(channel);
- player.Stop();
- using var media = new LibVlcMedia(_libVlc, assetId);
- player.Play(media);
- player.Volume = (int)(Math.Clamp(volume, 0, 1) * 100);
- }
-
- public void Stop(string channel) => TryGetPlayer(channel)?.Stop();
- public void Pause(string channel) => TryGetPlayer(channel)?.Pause();
- public void Resume(string channel) => TryGetPlayer(channel)?.Play();
-
- public void Enqueue(string channel, string assetId)
- {
- if (_libVlc is null) return;
- GetPlayer(channel).Play(new LibVlcMedia(_libVlc, assetId));
- }
-
- public void StopAll()
- {
- foreach (var player in _players.Values) player.Stop();
- }
-
- public void Dispose()
- {
- foreach (var player in _players.Values) player.Dispose();
- _players.Clear();
- }
-
- private MediaPlayer GetPlayer(string channel)
- {
- if (_libVlc is null) throw new InvalidOperationException("LibVLC is unavailable.");
- if (!_players.TryGetValue(channel, out var player))
- {
- player = new MediaPlayer(_libVlc);
- _players[channel] = player;
- }
- return player;
- }
-
- private MediaPlayer? TryGetPlayer(string channel) => _players.GetValueOrDefault(channel);
-}
diff --git a/src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs b/src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs
deleted file mode 100644
index c2baa6ae..00000000
--- a/src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using GalNet.Core.Entry;
-using GalNet.Core.Primitives;
-using GalNet.Core.Runtime;
-using GalNet.Presentation.Abstractions.View;
-
-namespace GalNet.Presentation.Defaults;
-
-/// A headless view with no mounted primitive capabilities.
-public class NullGameView : IGameView
-{
- public IReadOnlyCollection Primitives => [];
- public bool TryGetEntry(string primitiveType, out PrimitiveEntryBase? entry)
- {
- entry = null;
- return false;
- }
- public PrimitiveInstance? Dispatch(PrimitiveEntry entry, IGameRuntime runtime, CancellationToken cancellationToken) => null;
- public virtual void Dispose() { }
-}
diff --git a/src/Presentation/GalNet.Presentation.Defaults/packages.lock.json b/src/Presentation/GalNet.Presentation.Defaults/packages.lock.json
deleted file mode 100644
index 0504907f..00000000
--- a/src/Presentation/GalNet.Presentation.Defaults/packages.lock.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "version": 2,
- "dependencies": {
- "net10.0": {
- "LibVLCSharp": {
- "type": "Direct",
- "requested": "[3.10.0, )",
- "resolved": "3.10.0",
- "contentHash": "VHuOfq8Xtb/lNz0asvCm5qDjgTmfB3m3lC40Dz9iH9oL3DM0fSXHjZaLxdtYVr5nsldJoh5BTeG8MtODprhC9Q=="
- },
- "galnet.core": {
- "type": "Project"
- },
- "galnet.presentation.abstractions": {
- "type": "Project",
- "dependencies": {
- "GalNet.Core": "[1.0.0, )"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/Samples/GalNet.Sample.Avalonia/App.axaml b/src/Samples/GalNet.Sample.Avalonia/App.axaml
index c1452a19..a25761e1 100644
--- a/src/Samples/GalNet.Sample.Avalonia/App.axaml
+++ b/src/Samples/GalNet.Sample.Avalonia/App.axaml
@@ -3,10 +3,16 @@
x:Class="GalNet.Sample.Avalonia.App"
xmlns:semi="https://irihi.tech/semi"
xmlns:usemi="https://irihi.tech/ursa/themes/semi"
+ xmlns:loc="using:DynamicLocalization.Core"
RequestedThemeVariant="Default">
+
+
+
+
+
diff --git a/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs b/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs
index a958805d..2aa221e5 100644
--- a/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs
+++ b/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs
@@ -5,13 +5,17 @@
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
+using DynamicLocalization.Core;
+using DynamicLocalization.Core.Providers;
using GalNet.Avalonia.GameView.Composition;
using GalNet.Avalonia.GameView.Navigation;
using GalNet.Avalonia.GameView.Page;
using GalNet.Avalonia.GameView.Services;
using GalNet.Avalonia.GameView.ViewModels;
+using GalNet.Runtime.Logging;
using GalNet.Sample.Avalonia.Debug;
using GalNet.Sample.Avalonia.Services;
+using GalNet.Sample.Avalonia.ViewModels;
using GalNet.Sample.Avalonia.Views;
using Microsoft.Extensions.DependencyInjection;
@@ -21,7 +25,7 @@ public partial class App : Application
{
internal static GameLaunchOptions LaunchOptions { get; set; } = new(null, null);
private ServiceProvider? _rootServices;
- private IServiceScope? _gameScope;
+ private AsyncServiceScope? _gameScope;
public override void Initialize()
{
@@ -33,18 +37,47 @@ public override void OnFrameworkInitializationCompleted()
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var services = new ServiceCollection();
- services.AddAvaloniaGameViewPages();
- services.AddScoped();
- services.AddScoped(sp => sp.GetRequiredService());
- services.AddSampleDebug();
- services.AddTransient();
+ services.AddAvaloniaGameViewPages()
+ .AddSingleton(_ => CreateLocalizationService())
+ .AddScoped()
+ .AddScoped()
+ .AddScoped()
+ .AddScoped(provider => provider.GetRequiredService())
+ .AddScoped()
+ .AddScoped(sp => sp.GetRequiredService())
+ .AddSampleDebug()
+ .AddTransient