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/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/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 @@ -