mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
Merge pull request #272 from GeWuYou/docs/sdk-update-documentation
Docs/sdk update documentation
This commit is contained in:
commit
efc0518996
@ -1,165 +1,145 @@
|
||||
# GFramework.Godot.SourceGenerators
|
||||
|
||||
面向 Godot 场景的源码生成扩展模块,减少模板代码。
|
||||
`GFramework.Godot.SourceGenerators` 负责把 Godot 项目里的重复样板迁移到编译期。
|
||||
|
||||
## 主要功能
|
||||
当前包覆盖三类核心场景:
|
||||
|
||||
- 与 Godot 场景相关的编译期生成能力
|
||||
- 基于 Roslyn 的增量生成器实现
|
||||
- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口
|
||||
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
|
||||
- `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码
|
||||
- `project.godot` 元数据入口:生成 `AutoLoads` 与 `InputActions`
|
||||
- 节点字段与信号接线:`[GetNode]`、`[BindNodeSignal]`
|
||||
- Scene / UI 与启动注册样板:`[AutoScene]`、`[AutoUiPage]`、`[AutoRegisterExportedCollections]`
|
||||
|
||||
## 使用建议
|
||||
它是 Analyzer 包,不是运行时库。
|
||||
|
||||
- 仅在 Godot + C# 项目中启用
|
||||
- 非 Godot 项目可只使用 GFramework.SourceGenerators
|
||||
- 当项目通过 NuGet 包引用本模块时,根目录下的 `project.godot` 会被自动加入 `AdditionalFiles`
|
||||
- 当项目通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器时,需要手动把 `project.godot` 加入
|
||||
`AdditionalFiles`
|
||||
## 包定位
|
||||
|
||||
## project.godot 集成
|
||||
当前生成器主要减少这些重复代码:
|
||||
|
||||
默认情况下,生成器会读取 Godot 项目根目录下的 `project.godot`,并生成:
|
||||
- 从 `project.godot` 手写 AutoLoad / Input Action 字符串
|
||||
- 在 `_Ready()` 里重复写 `GetNode<T>()`
|
||||
- 在 `_Ready()` / `_ExitTree()` 里重复写 CLR event 订阅与解绑
|
||||
- 为 Godot 场景根节点和页面根节点重复声明 `GetScene()` / `GetPage()` 样板
|
||||
- 在启动入口里重复遍历导出集合并逐项注册到 registry
|
||||
|
||||
它不负责:
|
||||
|
||||
- 提供运行时 Scene / UI / 配置实现
|
||||
- 自动接管完整生命周期方法
|
||||
- 代替 `GFramework.Godot` 的宿主适配逻辑
|
||||
|
||||
## 与相邻包的关系
|
||||
|
||||
- `GFramework.Godot`
|
||||
- 负责 Godot 运行时适配。
|
||||
- 本包只负责编译期入口和样板生成。
|
||||
- `GFramework.Godot.SourceGenerators.Abstractions`
|
||||
- 特性定义所在位置。
|
||||
- 当前 `IsPackable=false`,按内部支撑模块处理,不作为独立消费包推广。
|
||||
- `GFramework.SourceGenerators.Common`
|
||||
- 提供公共生成器基础设施与部分类级诊断支持。
|
||||
- 同样按内部支撑模块处理。
|
||||
|
||||
## 子系统地图
|
||||
|
||||
### `GodotProjectMetadataGenerator`
|
||||
|
||||
读取 `project.godot`,生成:
|
||||
|
||||
- `GFramework.Godot.Generated.AutoLoads`
|
||||
- `GFramework.Godot.Generated.InputActions`
|
||||
|
||||
如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置:
|
||||
这是项目级元数据入口,不处理节点字段注入或信号绑定。
|
||||
|
||||
- 路径可以调整到项目根目录下的其他位置
|
||||
- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件
|
||||
### `GetNodeGenerator` 与 `BindNodeSignalGenerator`
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
|
||||
</PropertyGroup>
|
||||
```
|
||||
- `[GetNode]` 负责生成节点字段注入代码
|
||||
- `[BindNodeSignal]` 负责生成 CLR event 绑定 / 解绑辅助方法
|
||||
|
||||
如果你在仓库内通过 analyzer 形式直接引用本项目,则需要显式配置:
|
||||
这两项能力通常一起使用,但职责不同:
|
||||
|
||||
- `[GetNode]` 解决“怎么拿到字段实例”
|
||||
- `[BindNodeSignal]` 解决“字段可用后怎么订阅 / 解绑事件”
|
||||
|
||||
### `Behavior/`
|
||||
|
||||
- `AutoSceneGenerator`
|
||||
- `AutoUiPageGenerator`
|
||||
|
||||
用于给场景根节点和 UI 页面根节点生成稳定的 `GetScene()` / `GetPage()` 包装入口。
|
||||
|
||||
### `Registration/`
|
||||
|
||||
- `AutoRegisterExportedCollectionsGenerator`
|
||||
|
||||
用于把“遍历导出集合并逐项调用 registry 方法”的启动样板收敛成生成方法。
|
||||
|
||||
### `Diagnostics/`
|
||||
|
||||
当前诊断围绕这些方向组织:
|
||||
|
||||
- `project.godot` 文件与元数据约束
|
||||
- `GetNode` / `BindNodeSignal` 的目标成员合法性
|
||||
- `AutoScene` / `AutoUiPage` 的宿主类型与参数合法性
|
||||
- 导出集合注册的成员形状与方法匹配约束
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
### 1. 安装生成器包
|
||||
|
||||
常规 NuGet 引用方式:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GeWuYou.GFramework.Godot.SourceGenerators"
|
||||
Version="x.y.z"
|
||||
PrivateAssets="all"
|
||||
ExcludeAssets="runtime" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
通常还会同时引用:
|
||||
|
||||
```xml
|
||||
<PackageReference Include="GeWuYou.GFramework.Godot" Version="x.y.z" />
|
||||
```
|
||||
|
||||
### 2. 让 `project.godot` 进入 `AdditionalFiles`
|
||||
|
||||
通过 NuGet 包使用时,`GeWuYou.GFramework.Godot.SourceGenerators.targets` 会自动尝试把项目根目录下的 `project.godot`
|
||||
加入 `AdditionalFiles`。
|
||||
|
||||
如果你是仓库内直接通过 `ProjectReference(OutputItemType=Analyzer)` 引用生成器项目,需要手动加入:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<AdditionalFiles Include="project.godot" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
## AutoLoad 强类型访问
|
||||
### 3. 在节点脚本里显式接生成方法
|
||||
|
||||
当某个 AutoLoad 无法仅靠类型名唯一推断到 C# 节点类型时,可以使用 `[AutoLoad]` 显式声明映射:
|
||||
当前最重要的生命周期约束是:
|
||||
|
||||
- `[GetNode]` 在类型手写 `_Ready()` 时,需要显式调用 `__InjectGetNodes_Generated()`
|
||||
- `[BindNodeSignal]` 在手写 `_Ready()` / `_ExitTree()` 时,需要显式调用
|
||||
`__BindNodeSignals_Generated()` 与 `__UnbindNodeSignals_Generated()`
|
||||
- `[AutoScene]`、`[AutoUiPage]`、`[AutoRegisterExportedCollections]` 都只生成辅助入口,不会替你织入生命周期
|
||||
|
||||
也就是说,本包负责生成辅助方法,但调用时机仍由项目侧决定。
|
||||
|
||||
最小接法可以直接写成:
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
[AutoLoad("GameServices")]
|
||||
public partial class GameServices : Node
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
对应 `project.godot`:
|
||||
|
||||
```ini
|
||||
[autoload]
|
||||
GameServices="*res://autoload/game_services.tscn"
|
||||
AudioBus="*res://autoload/audio_bus.gd"
|
||||
```
|
||||
|
||||
生成器会产出统一入口:
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.Generated;
|
||||
|
||||
var gameServices = AutoLoads.GameServices;
|
||||
|
||||
if (AutoLoads.TryGetAudioBus(out var audioBus))
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
- 显式 `[AutoLoad]` 映射优先于隐式类型名推断
|
||||
- 若同名映射冲突,生成器会给出诊断并退化为 `Godot.Node` 访问
|
||||
- 若无法映射到 C# 节点类型,仍会生成可用的 `Godot.Node` 访问器
|
||||
|
||||
## Input Action 常量生成
|
||||
|
||||
`project.godot` 的 `[input]` 段会自动生成稳定常量,避免手写字符串:
|
||||
|
||||
```ini
|
||||
[input]
|
||||
move_up={
|
||||
}
|
||||
ui_cancel={
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.Generated;
|
||||
|
||||
if (Input.IsActionJustPressed(InputActions.MoveUp))
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
- 动作名会转换为可补全的 C# 标识符,例如 `move_up -> MoveUp`
|
||||
- 当多个动作名映射到同一标识符时,会追加稳定后缀并给出警告
|
||||
|
||||
## GetNode 用法
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
public partial class TopBar : HBoxContainer
|
||||
{
|
||||
[GetNode]
|
||||
private HBoxContainer _leftContainer = null!;
|
||||
|
||||
[GetNode]
|
||||
private HBoxContainer _rightContainer = null!;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
__InjectGetNodes_Generated();
|
||||
OnReadyAfterGetNode();
|
||||
}
|
||||
|
||||
private void OnReadyAfterGetNode()
|
||||
{
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当未显式填写路径时,生成器会默认将字段名推导为唯一名路径:
|
||||
|
||||
- `_leftContainer` -> `%LeftContainer`
|
||||
- `m_rightContainer` -> `%RightContainer`
|
||||
|
||||
## BindNodeSignal 用法
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.SourceGenerators.Abstractions;
|
||||
using Godot;
|
||||
|
||||
public partial class Hud : Control
|
||||
public partial class MainMenu : Control
|
||||
{
|
||||
[GetNode]
|
||||
private Button _startButton = null!;
|
||||
|
||||
[GetNode]
|
||||
private SpinBox _startOreSpinBox = null!;
|
||||
|
||||
[BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
|
||||
private void OnStartButtonPressed()
|
||||
{
|
||||
}
|
||||
|
||||
[BindNodeSignal(nameof(_startOreSpinBox), nameof(SpinBox.ValueChanged))]
|
||||
private void OnStartOreValueChanged(double value)
|
||||
{
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
__InjectGetNodes_Generated();
|
||||
@ -170,12 +150,56 @@ public partial class Hud : Control
|
||||
{
|
||||
__UnbindNodeSignals_Generated();
|
||||
}
|
||||
|
||||
[BindNodeSignal(nameof(_startButton), nameof(Button.Pressed))]
|
||||
private void OnStartPressed()
|
||||
{
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
生成器会产出两个辅助方法:
|
||||
### 4. 按场景选特性
|
||||
|
||||
- `__BindNodeSignals_Generated()`:负责统一订阅事件
|
||||
- `__UnbindNodeSignals_Generated()`:负责统一解绑事件
|
||||
- 项目级元数据:
|
||||
- `project.godot` -> `AutoLoads`、`InputActions`
|
||||
- 固定节点字段:
|
||||
- `[GetNode]`
|
||||
- 固定 CLR event 订阅:
|
||||
- `[BindNodeSignal]`
|
||||
- Godot 场景根节点:
|
||||
- `[AutoScene]`
|
||||
- Godot UI 页面根节点:
|
||||
- `[AutoUiPage]`
|
||||
- 启动入口中的集合批量注册:
|
||||
- `[AutoRegisterExportedCollections]`
|
||||
|
||||
当前设计只处理 CLR event 形式的 Godot 事件绑定,不会自动调用 `Connect()` / `Disconnect()`。
|
||||
## 当前约束
|
||||
|
||||
- `GFrameworkGodotProjectFile` 可以改相对路径,但文件名必须仍然是 `project.godot`
|
||||
- `[GetNode]` 与 `[BindNodeSignal]` 都要求宿主类型是顶层 `partial class`
|
||||
- `[BindNodeSignal]` 面向 CLR event,不会自动调用 `Connect()` / `Disconnect()`
|
||||
- `[AutoScene]` 与 `[AutoUiPage]` 只生成行为包装入口,不会替代 `SceneRouterBase` 或 `UiRouterBase`
|
||||
- `[AutoRegisterExportedCollections]` 只适合“集合 -> registry -> 单参数注册方法”这类稳定形状
|
||||
|
||||
## 文档入口
|
||||
|
||||
- 生成器总览:[docs/zh-CN/source-generators/index.md](../docs/zh-CN/source-generators/index.md)
|
||||
- Godot 项目元数据:[docs/zh-CN/source-generators/godot-project-generator.md](../docs/zh-CN/source-generators/godot-project-generator.md)
|
||||
- `GetNode`:[docs/zh-CN/source-generators/get-node-generator.md](../docs/zh-CN/source-generators/get-node-generator.md)
|
||||
- `BindNodeSignal`:[docs/zh-CN/source-generators/bind-node-signal-generator.md](../docs/zh-CN/source-generators/bind-node-signal-generator.md)
|
||||
- `AutoScene`:[docs/zh-CN/source-generators/auto-scene-generator.md](../docs/zh-CN/source-generators/auto-scene-generator.md)
|
||||
- `AutoUiPage`:[docs/zh-CN/source-generators/auto-ui-page-generator.md](../docs/zh-CN/source-generators/auto-ui-page-generator.md)
|
||||
- `AutoRegisterExportedCollections`:[docs/zh-CN/source-generators/auto-register-exported-collections-generator.md](../docs/zh-CN/source-generators/auto-register-exported-collections-generator.md)
|
||||
- Godot 运行时入口:[../GFramework.Godot/README.md](../GFramework.Godot/README.md)
|
||||
- 集成教程:[docs/zh-CN/tutorials/godot-integration.md](../docs/zh-CN/tutorials/godot-integration.md)
|
||||
|
||||
## 什么时候不该先看这个包
|
||||
|
||||
以下场景更适合先回到其他入口:
|
||||
|
||||
- 你在确认 Godot 运行时 Scene / UI / 存储 / 设置的默认实现:
|
||||
- 先看 `GFramework.Godot`
|
||||
- 你只需要 `Game` 契约,不需要 Godot 宿主或生成器:
|
||||
- 先看 `GFramework.Game` 或 `GFramework.Game.Abstractions`
|
||||
- 你在确认项目接线顺序,而不是单个生成器契约:
|
||||
- 先看 `docs/zh-CN/tutorials/godot-integration.md`
|
||||
|
||||
@ -1,19 +1,161 @@
|
||||
# GFramework.Godot
|
||||
|
||||
GFramework 框架的 Godot 引擎集成模块,提供Godot特定的功能和扩展。
|
||||
`GFramework.Godot` 是 `GFramework` 在 Godot 宿主侧的运行时适配包。
|
||||
|
||||
## 主要功能
|
||||
它建立在 `GFramework.Game`、`GFramework.Game.Abstractions` 与 `GFramework.Core.Abstractions` 之上,把框架已有的架构、
|
||||
Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`SceneTree`、`PackedScene`、`FileAccess` 与 `AudioServer`
|
||||
等 Godot 运行时对象上。
|
||||
|
||||
- **Extensions** - Godot节点扩展方法,简化常见开发任务
|
||||
- **Signal** - 流畅的信号连接API,支持链式调用
|
||||
- **Storage** - Godot文件存储系统,支持虚拟路径
|
||||
- **Settings** - Godot设置系统,管理音频和图形设置
|
||||
如果你需要的是 `[GetNode]`、`[BindNodeSignal]`、`AutoLoads` 或 `InputActions` 这类编译期能力,请改为同时安装
|
||||
`GFramework.Godot.SourceGenerators`。这些能力不属于本包。
|
||||
|
||||
## 依赖关系
|
||||
## 包定位
|
||||
|
||||
- 依赖 GFramework.Core
|
||||
- 依赖 GFramework.Core.Abstractions
|
||||
当前包解决的是 Godot 运行时接线,而不是重新定义一套 Godot 专属框架:
|
||||
|
||||
## 详细文档
|
||||
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
||||
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
||||
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
||||
- Godot 特化的配置、存储与设置实现:`GodotYamlConfigLoader`、`GodotFileStorage`、`GodotAudioSettings`
|
||||
- 宿主侧辅助能力:`Signal(...)` fluent API、Godot 时间源、暂停处理、富文本效果
|
||||
|
||||
参见 [docs/zh-CN/godot/](../docs/zh-CN/godot/) 目录下的详细文档。
|
||||
它不负责:
|
||||
|
||||
- 自动生成节点字段注入代码
|
||||
- 自动生成 `_Ready()` / `_ExitTree()` 接线
|
||||
- 自动扫描所有场景或页面并完成统一注册
|
||||
- 提供 `GodotSceneRouter` 或 `GodotUiRouter` 这类额外 router 类型
|
||||
|
||||
## 与相邻包的关系
|
||||
|
||||
- `GFramework.Game`
|
||||
- 提供 Scene / UI / 配置 / 数据等默认运行时契约与基类。
|
||||
- `GFramework.Godot` 负责把这些能力落到 Godot 宿主。
|
||||
- `GFramework.Game.Abstractions`
|
||||
- 提供 `ISceneFactory`、`IUiFactory`、设置与配置相关契约。
|
||||
- 本包的大部分工厂和适配层都实现这些接口。
|
||||
- `GFramework.Core.Abstractions`
|
||||
- 提供架构、日志、环境等基础契约。
|
||||
- `AbstractArchitecture` 与日志 provider 都建立在这层之上。
|
||||
- `GFramework.Godot.SourceGenerators`
|
||||
- 提供 `project.godot` 元数据、`[GetNode]`、`[BindNodeSignal]`、`[AutoScene]`、`[AutoUiPage]` 等编译期样板生成。
|
||||
- 推荐与本包配套使用,但职责边界要分开理解。
|
||||
|
||||
## 子系统地图
|
||||
|
||||
### `Architectures/`
|
||||
|
||||
- `AbstractArchitecture`
|
||||
- `AbstractGodotModule`
|
||||
- `ArchitectureAnchor`
|
||||
- `IGodotModule`
|
||||
|
||||
用于把架构生命周期绑定到 `SceneTree`,并在需要时把 Godot 模块挂到场景树。
|
||||
|
||||
### `Scene/` 与 `UI/`
|
||||
|
||||
- `GodotSceneFactory`、`GodotSceneRegistry`
|
||||
- `GodotUiFactory`、`GodotUiRegistry`
|
||||
- `SceneBehaviorFactory`、`UiPageBehaviorFactory`
|
||||
|
||||
这部分负责把 `PackedScene`、`Control`、`CanvasLayer` 等 Godot 对象接入 `GFramework.Game` 的 Scene / UI 契约。
|
||||
|
||||
### `Config/`、`Storage/` 与 `Setting/`
|
||||
|
||||
- `GodotYamlConfigLoader`
|
||||
- `GodotFileStorage`
|
||||
- `GodotAudioSettings`、`GodotGraphicsSettings`、`GodotLocalizationSettings`
|
||||
|
||||
这部分解决的是 Godot 文件系统、音频总线、图形与本地化设置等宿主差异。
|
||||
|
||||
### `Extensions/`、`Coroutine/`、`Logging/`、`Pause/`、`Text/`、`Pool/`
|
||||
|
||||
- 节点扩展与 `Signal(...)` fluent API
|
||||
- `GodotTimeSource` 与协程时间分段
|
||||
- Godot 日志 provider
|
||||
- 暂停处理、节点池与富文本效果支持
|
||||
|
||||
这些目录都是“宿主适配层”,不是新的 gameplay 抽象层。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
### 1. 先区分运行时包和生成器包
|
||||
|
||||
如果你只需要 Godot 运行时适配:
|
||||
|
||||
```bash
|
||||
dotnet add package GeWuYou.GFramework
|
||||
dotnet add package GeWuYou.GFramework.Godot
|
||||
```
|
||||
|
||||
如果你还需要 `project.godot` 强类型入口、节点字段注入和信号绑定,再额外安装:
|
||||
|
||||
```bash
|
||||
dotnet add package GeWuYou.GFramework.Core.SourceGenerators
|
||||
dotnet add package GeWuYou.GFramework.Godot.SourceGenerators
|
||||
```
|
||||
|
||||
### 2. 保持原有架构注册方式,只把宿主协作接到 Godot
|
||||
|
||||
常规模块继续使用 `InstallModule(...)`。
|
||||
|
||||
只有模块自身暴露 `Node`、需要挂到 `ArchitectureAnchor`,或要在 `OnAttach(...)` / `OnDetach()` 里处理 Godot 生命周期副作用时,
|
||||
再使用 `InstallGodotModule(...)`。
|
||||
|
||||
`GFramework.Godot.Tests/Architectures/AbstractArchitectureModuleInstallationTests.cs` 已覆盖一个关键边界:锚点缺失时会先抛
|
||||
`InvalidOperationException`,不会继续执行模块安装。
|
||||
|
||||
### 3. Scene / UI 继续沿用 `Game` 契约
|
||||
|
||||
当前真实边界是:
|
||||
|
||||
- 没有 `GodotSceneRouter`
|
||||
- 没有 `GodotUiRouter`
|
||||
- `GodotSceneFactory` 在 provider 缺失时回退到 `SceneBehaviorFactory`
|
||||
- `GodotUiFactory` 仍要求 `IUiPageBehaviorProvider`
|
||||
|
||||
也就是说,项目通常仍然继承 `GFramework.Game.Scene.SceneRouterBase` 与 `GFramework.Game.UI.UiRouterBase`,只是把工厂和行为落到
|
||||
Godot 上。
|
||||
|
||||
### 4. 按需接入配置、存储和设置
|
||||
|
||||
当项目已经使用 `Game` family 的配置、存储、设置契约时,再补 Godot 侧实现:
|
||||
|
||||
- 配置:`GodotYamlConfigLoader`
|
||||
- 存储:`GodotFileStorage`
|
||||
- 设置:`GodotAudioSettings`、`GodotGraphicsSettings`、`GodotLocalizationSettings`
|
||||
|
||||
不要把这些宿主实现误写成 `Game` family 的默认行为。
|
||||
|
||||
## `ai-libs/` 里的参考接入线索
|
||||
|
||||
`ai-libs/CoreGrid` 仍是当前最直接的消费者证据来源:
|
||||
|
||||
- 架构侧保持普通模块注册,再按需挂接 Godot 宿主
|
||||
- `project.godot` 元数据与节点样板交给 `GFramework.Godot.SourceGenerators`
|
||||
- Scene / UI 继续沿用 `Game` family 的 router 语义
|
||||
|
||||
当 `ai-libs/` 与源码或测试冲突时,应以当前源码与测试为准。
|
||||
|
||||
## 文档入口
|
||||
|
||||
- Godot 运行时总览:[docs/zh-CN/godot/index.md](../docs/zh-CN/godot/index.md)
|
||||
- 架构集成:[docs/zh-CN/godot/architecture.md](../docs/zh-CN/godot/architecture.md)
|
||||
- 场景系统:[docs/zh-CN/godot/scene.md](../docs/zh-CN/godot/scene.md)
|
||||
- UI 系统:[docs/zh-CN/godot/ui.md](../docs/zh-CN/godot/ui.md)
|
||||
- 节点扩展:[docs/zh-CN/godot/extensions.md](../docs/zh-CN/godot/extensions.md)
|
||||
- 信号系统:[docs/zh-CN/godot/signal.md](../docs/zh-CN/godot/signal.md)
|
||||
- 日志系统:[docs/zh-CN/godot/logging.md](../docs/zh-CN/godot/logging.md)
|
||||
- 集成教程:[docs/zh-CN/tutorials/godot-integration.md](../docs/zh-CN/tutorials/godot-integration.md)
|
||||
- 生成器入口:[../GFramework.Godot.SourceGenerators/README.md](../GFramework.Godot.SourceGenerators/README.md)
|
||||
|
||||
## 什么时候不该把它当成主入口
|
||||
|
||||
以下场景更适合先回到其他包:
|
||||
|
||||
- 只需要 Scene / UI / 配置契约,不需要 Godot 宿主:
|
||||
- 选 `GFramework.Game.Abstractions`
|
||||
- 需要默认运行时实现,但暂时不接 Godot:
|
||||
- 选 `GFramework.Game`
|
||||
- 需要的是 `project.godot` 元数据、节点字段注入或编译期样板:
|
||||
- 选 `GFramework.Godot.SourceGenerators`
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
| `GFramework.Core.SourceGenerators` | Core 侧通用源码生成器与分析器 | [README](GFramework.Core.SourceGenerators/README.md) |
|
||||
| `GFramework.Game.SourceGenerators` | 游戏内容配置 schema 生成器 | [README](GFramework.Game.SourceGenerators/README.md) |
|
||||
| `GFramework.Cqrs.SourceGenerators` | CQRS handler registry 生成器 | [README](GFramework.Cqrs.SourceGenerators/README.md) |
|
||||
| `GFramework.Godot.SourceGenerators` | Godot 场景专用源码生成器 | [README](GFramework.Godot.SourceGenerators/README.md) |
|
||||
| `GFramework.Godot.SourceGenerators` | Godot 项目元数据、节点注入、信号绑定与 Scene/UI 辅助生成器 | [README](GFramework.Godot.SourceGenerators/README.md) |
|
||||
|
||||
## 内部支撑模块
|
||||
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
# Documentation Full Coverage Governance Status History Through RP-016
|
||||
|
||||
以下内容从 active tracking 中迁出,用于保留 `DOCUMENTATION-FULL-COVERAGE-GOV-RP-001` 到
|
||||
`DOCUMENTATION-FULL-COVERAGE-GOV-RP-016` 的阶段性状态、治理结论与恢复背景。默认 `boot` 只需要读取 active
|
||||
tracking 中的最新摘要;若需要追溯已完成波次的详细背景,再回到本归档文件。
|
||||
|
||||
## 阶段里程碑
|
||||
|
||||
### `RP-001` 到 `RP-007`
|
||||
|
||||
- 建立长期 active topic `documentation-full-coverage-governance`,并在 `ai-plan/public/README.md` 中将当前分支
|
||||
`docs/sdk-update-documentation` 映射到该 topic。
|
||||
- 明确消费属性边界:
|
||||
- `GFramework.Ecs.Arch.Abstractions` 是可打包直接消费模块,需要 README 与文档入口。
|
||||
- `GFramework.Core.SourceGenerators.Abstractions`、
|
||||
`GFramework.Godot.SourceGenerators.Abstractions`、`GFramework.SourceGenerators.Common`
|
||||
都按 `IsPackable=false` 的内部支撑模块处理。
|
||||
- 收口 `Core` / `Core.Abstractions` README、landing page 与类型族级 XML inventory。
|
||||
- 收口 `Ecs.Arch` / `Ecs.Arch.Abstractions` README、landing page、抽象页与 `UseArch(...)` 早于
|
||||
`Initialize()` 的采用约束。
|
||||
- 收口 `Cqrs` family 的 runtime / abstractions / source generator 入口,并为缺失的内部类型补齐 XML 注释。
|
||||
- 收口 `Game` family 的 README、landing page、抽象页与类型族级 XML inventory。
|
||||
- 将 `Game` family 从“文档存在但入口失真”推进到“runtime / abstractions / source generator 都有当前可审计入口”。
|
||||
|
||||
### `RP-008` 到 `RP-013`
|
||||
|
||||
- 消化 PR #271 的文档 follow-up,修正 `gframework-pr-review` 脚本与 skill 中的 WSL Git 策略,使其与
|
||||
`AGENTS.md` 保持一致。
|
||||
- 将 `Godot` family 的核心恢复摘要迁回 active topic,避免默认恢复路径继续依赖 archive 细节。
|
||||
- 重写 `GFramework.Godot/README.md` 与 `GFramework.Godot.SourceGenerators/README.md`,补齐当前包关系、
|
||||
子系统地图、最小接入路径与站内文档入口。
|
||||
- 更新根 `README.md`、`docs/zh-CN/source-generators/index.md`、`docs/zh-CN/api-reference/index.md`,把
|
||||
`GFramework.Godot.SourceGenerators` 的 owner 与能力边界收敛到当前源码口径。
|
||||
- 完成 `Godot` docs surface 的 validation-only 巡检,确认 landing、tutorial、API reference 与 README
|
||||
当前保持一致叙述,没有出现新的入口漂移。
|
||||
|
||||
### `RP-014` 到 `RP-016`
|
||||
|
||||
- 重写 `docs/zh-CN/godot/storage.md`,补齐 frontmatter、`GodotFileStorage` 的路径语义、repository 分工与
|
||||
`GodotYamlConfigLoader` 分流边界。
|
||||
- 重写 `docs/zh-CN/godot/setting.md`,改回当前 `ISettingsModel` / `RegisterApplicator(...)` 口径,并补齐
|
||||
`LocalizationMap` fallback 与当前 consumer wiring。
|
||||
- 对 `Godot` docs surface 再做一轮 validation-only 巡检,确认 `storage.md`、`setting.md`、landing、README、
|
||||
tutorial 与 API reference 没有新的回漂。
|
||||
- 重写 `docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md`,把 `Game` persistence docs
|
||||
surface 收口到当前 repository / storage / serializer / settings 责任边界。
|
||||
- 将 `DataRepository`、`UnifiedSettingsDataRepository`、`SaveRepository<TSaveData>` 与 `FileStorage` /
|
||||
`ScopedStorage` / `SettingsModel<TRepository>` 的分工,统一回写到 README、源码与 `PersistenceTests` 已验证的采用路径。
|
||||
|
||||
## 已确认的长期事实
|
||||
|
||||
- 已归档的 `documentation-governance-and-refresh` 只作为历史证据保留,不再作为默认 `boot` 入口。
|
||||
- `Godot` family 当前核心页面集包括:
|
||||
- `docs/zh-CN/godot/index.md`
|
||||
- `docs/zh-CN/godot/architecture.md`
|
||||
- `docs/zh-CN/godot/scene.md`
|
||||
- `docs/zh-CN/godot/ui.md`
|
||||
- `docs/zh-CN/godot/storage.md`
|
||||
- `docs/zh-CN/godot/setting.md`
|
||||
- `docs/zh-CN/godot/signal.md`
|
||||
- `docs/zh-CN/godot/extensions.md`
|
||||
- `docs/zh-CN/godot/logging.md`
|
||||
- `docs/zh-CN/tutorials/godot-integration.md`
|
||||
- `Game` persistence docs surface 当前最值得优先复核的页面集包括:
|
||||
- `docs/zh-CN/game/data.md`
|
||||
- `docs/zh-CN/game/storage.md`
|
||||
- `docs/zh-CN/game/serialization.md`
|
||||
- `docs/zh-CN/game/setting.md`
|
||||
- `GFramework.Cqrs` 在当前 WSL / dotnet 环境下仍会读取失效的 fallback package folder,并在标准 build 中触发
|
||||
`MSB4276` / `MSB4018`;这是已知环境阻塞,不是本 topic 当前文档回归。
|
||||
- 当前 WSL 会话里 `git.exe` 可解析但不可执行,仓库默认 Git 策略应继续优先使用显式
|
||||
`--git-dir` / `--work-tree` 绑定。
|
||||
|
||||
## 关联归档
|
||||
|
||||
- 早期详细验证历史:`ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
- 时间线归档:`ai-plan/public/documentation-full-coverage-governance/archive/traces/documentation-full-coverage-governance-trace-history-through-rp-016.md`
|
||||
@ -0,0 +1,92 @@
|
||||
# Documentation Full Coverage Governance Trace History Through RP-016
|
||||
|
||||
以下内容从 active trace 中迁出,用于保留 `DOCUMENTATION-FULL-COVERAGE-GOV-RP-001` 到
|
||||
`DOCUMENTATION-FULL-COVERAGE-GOV-RP-016` 的阶段时间线、关键决策与主要验证结果。默认 `boot` 只需要读取
|
||||
active trace 中的最新恢复点;若需要追溯旧阶段的执行顺序,再回到本归档文件。
|
||||
|
||||
## 2026-04-22
|
||||
|
||||
### `RP-001`
|
||||
|
||||
- 新建 active topic `documentation-full-coverage-governance`。
|
||||
- 在 `ai-plan/public/README.md` 中将当前 worktree 绑定到该 topic。
|
||||
- 盘点可消费模块与内部支撑模块的边界,作为后续 README / docs 治理基线。
|
||||
|
||||
### `RP-002`
|
||||
|
||||
- 完成 `Core` / `Core.Abstractions` README、landing page 与类型族级 XML inventory 的第一轮收口。
|
||||
- 运行 `docs` 站点构建与局部文档校验,结果通过。
|
||||
|
||||
### `RP-003`
|
||||
|
||||
- 完成 `Ecs.Arch` / `Ecs.Arch.Abstractions` 文档刷新。
|
||||
- 确认 `UseArch(...)` 必须早于 `Initialize()` 的采用顺序,并将该约束写回文档。
|
||||
- 运行 `docs` 站点构建,结果通过。
|
||||
|
||||
### `RP-004`
|
||||
|
||||
- 完成 `Cqrs` family landing、generator topic 与 API 参考入口刷新。
|
||||
- 为 `GFramework.Cqrs` 与 `GFramework.Cqrs.SourceGenerators` 缺失的内部类型补齐 XML 注释。
|
||||
- `GFramework.Cqrs.SourceGenerators` Release build 通过。
|
||||
- `GFramework.Cqrs` Release build 仍受环境级 fallback package folder 问题阻塞,记录为已知非回归风险。
|
||||
|
||||
## 2026-04-23
|
||||
|
||||
### `RP-005`
|
||||
|
||||
- 完成 `Game` family README、landing page、抽象页与类型族级 XML inventory 刷新。
|
||||
- 文档校验与 `docs` 站点构建通过。
|
||||
|
||||
### `RP-006`
|
||||
|
||||
- 更新 `AGENTS.md` 的 WSL Git 回退顺序:
|
||||
- 优先显式 `--git-dir` / `--work-tree` 绑定。
|
||||
- `git.exe` 仅在当前会话可执行时作为 fallback。
|
||||
- `docs` 站点构建通过。
|
||||
|
||||
### `RP-007`
|
||||
|
||||
- 完成 `Game` family validation-only 巡检。
|
||||
- 确认 `config-system.md`、`scene.md`、`ui.md` 与 `source-generators/index.md` 当前没有新的采用漂移。
|
||||
|
||||
### `RP-008` 到 `RP-013`
|
||||
|
||||
- 消化 PR #271 的 review follow-up,修正 `gframework-pr-review` 脚本与 skill 中的 Git 策略。
|
||||
- 将 `Godot` family 的核心恢复摘要迁回 active topic。
|
||||
- 重写 `GFramework.Godot/README.md` 与 `GFramework.Godot.SourceGenerators/README.md`。
|
||||
- 更新根 `README.md`、`docs/zh-CN/source-generators/index.md` 与 `docs/zh-CN/api-reference/index.md` 的
|
||||
`Godot` 入口与 owner 描述。
|
||||
- 针对 `Godot` docs surface 执行 validation-only 巡检,确认当前 landing / topic / tutorial / API reference
|
||||
与 README 保持一致。
|
||||
|
||||
### `RP-014`
|
||||
|
||||
- 重写 `docs/zh-CN/godot/storage.md` 与 `docs/zh-CN/godot/setting.md`。
|
||||
- 运行 `scan_module_evidence.py Godot`、相关文档校验与 `docs` 站点构建,结果通过。
|
||||
|
||||
### `RP-015`
|
||||
|
||||
- 再次执行 `Godot` docs surface validation-only 巡检。
|
||||
- 确认 `storage.md`、`setting.md`、landing、README、tutorial 与 API reference 没有新的回漂。
|
||||
|
||||
### `RP-016`
|
||||
|
||||
- 重写 `docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md`。
|
||||
- 运行 `scan_module_evidence.py Game`、相关文档校验与 `docs` 站点构建,结果通过。
|
||||
- 当前 `Game` persistence docs surface 已回到与源码、README 和 `PersistenceTests` 一致的责任边界叙述。
|
||||
|
||||
## 主要验证汇总
|
||||
|
||||
- `cd docs && bun run build`
|
||||
- 多轮执行通过;仅保留既有 VitePress 大 chunk warning,无构建失败。
|
||||
- `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh ...`
|
||||
- 针对本 topic 涉及的 landing / topic / abstractions 页面多轮执行通过。
|
||||
- `python3 .agents/skills/gframework-doc-refresh/scripts/scan_module_evidence.py Godot`
|
||||
- 通过。
|
||||
- `python3 .agents/skills/gframework-doc-refresh/scripts/scan_module_evidence.py Game`
|
||||
- 通过。
|
||||
|
||||
## 归档关联
|
||||
|
||||
- 状态归档:`ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-status-history-through-rp-016.md`
|
||||
- 早期详细验证历史:`ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
@ -3,7 +3,7 @@
|
||||
## 目标
|
||||
|
||||
建立一个长期 active topic,持续治理 `GFramework` 的 README、`docs/zh-CN`、站点导航、XML 文档和 API
|
||||
参考链路,避免历史上的阶段性刷新完成后再次回漂。
|
||||
参考链路,避免阶段性刷新完成后再次回漂。
|
||||
|
||||
- 用源码、测试、`*.csproj` 和必要的 `ai-libs/` 证据校正文档
|
||||
- 以模块族为单位闭环 README、landing page、专题页、教程入口和 API 参考链路
|
||||
@ -12,113 +12,65 @@
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`DOCUMENTATION-FULL-COVERAGE-GOV-RP-008`
|
||||
- 恢复点编号:`DOCUMENTATION-FULL-COVERAGE-GOV-RP-019`
|
||||
- 当前阶段:`Phase 5 - Governance Maintenance`
|
||||
- 当前焦点:
|
||||
- 消化 PR #271 的 latest-head review follow-up,修正仍在本地成立的 docs / skill / ai-plan 问题
|
||||
- 将 active tracking 的重复验证明细迁出默认 boot 路径,只保留最新可恢复摘要
|
||||
- 评估是否需要把 `Godot` family 的关键 XML inventory 摘要迁回 active topic
|
||||
- 保持 `Game` persistence docs surface 与当前 `README`、源码、`PersistenceTests` 使用同一套 owner / adoption path 叙述
|
||||
- 保持 `GFramework.Godot.SourceGenerators/README.md` 与 `docs/zh-CN/tutorials/godot-integration.md` 在生命周期接法上的一致性
|
||||
- 保持 active tracking / trace 只承载当前恢复入口,把阶段细节留在 `archive/`
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- 已归档的 `documentation-governance-and-refresh` 仅保留为历史证据,不再作为默认 `boot` 入口
|
||||
- 本轮已消化的 PR #271 review follow-up:
|
||||
- 为 `.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py` 补齐 WSL worktree 下的显式 Linux Git 绑定,避免 `git.exe` 在当前会话触发 `Exec format error`
|
||||
- 同步更新 `.agents/skills/gframework-pr-review/SKILL.md`,改为与 `AGENTS.md` 一致的 Git 策略,并把命令示例统一到 `.agents/...` 路径
|
||||
- 为 `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md` 补充 marker 类型放置与命名约定说明
|
||||
- 从 `docs/zh-CN/abstractions/ecs-arch-abstractions.md` 删除误放的 source-generator 内部模块提醒,并微调 `docs/zh-CN/ecs/index.md` 的边界说明语序
|
||||
- 为 `ai-plan/public/archive/documentation-governance-and-refresh/traces/documentation-governance-and-refresh-trace.md` 的归档验证补写结果态
|
||||
- 将 RP-001 至 RP-007 的详细验证历史迁入 `ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
- 本轮已确认的消费属性结论:
|
||||
- `GFramework.Ecs.Arch.Abstractions`:可打包直接消费模块,需要 README 和文档入口
|
||||
- `GFramework.Core.SourceGenerators.Abstractions`:`IsPackable=false`,按内部支撑模块处理
|
||||
- `GFramework.Godot.SourceGenerators.Abstractions`:`IsPackable=false`,按内部支撑模块处理
|
||||
- `GFramework.SourceGenerators.Common`:`IsPackable=false`,按内部支撑模块处理
|
||||
- 本轮已完成的治理动作:
|
||||
- 新建 `GFramework.Ecs.Arch.Abstractions/README.md`
|
||||
- 在根 `README.md` 中补齐 `GFramework.Ecs.Arch.Abstractions` 入口,并声明内部支撑模块 owner
|
||||
- 为抽象接口栏目补齐 `Ecs.Arch.Abstractions` 页面与 sidebar 入口
|
||||
- 将 `docs/zh-CN/api-reference/index.md` 重写为模块到 XML / README / 教程的阅读链路入口
|
||||
- 为 `GFramework.Core/README.md` 补齐 `Services`、`Configuration`、`Environment`、`Pool`、`Rule`、`Time` 等当前目录映射
|
||||
- 为 `GFramework.Core.Abstractions/README.md` 补齐契约族地图与 XML 阅读重点
|
||||
- 将 `docs/zh-CN/abstractions/core-abstractions.md` 从过时的接口摘录页重写为契约边界 / 包关系 / 最小接入路径页面
|
||||
- 为 `docs/zh-CN/core/index.md` 补齐 frontmatter、能力域导航和 API / XML 阅读入口
|
||||
- 为 `GFramework.Core/README.md`、`GFramework.Core.Abstractions/README.md` 补齐类型族级 XML 覆盖基线入口
|
||||
- 为 `docs/zh-CN/core/index.md`、`docs/zh-CN/abstractions/core-abstractions.md` 增加“类型族 -> XML 覆盖状态 -> 代表类型”的 inventory
|
||||
- 基于顶层目录轻量盘点确认:`Core` / `Core.Abstractions` 当前公开 / 内部类型声明都已带 XML 注释,成员级审计留待后续波次
|
||||
- 重写 `docs/zh-CN/ecs/index.md`,收敛当前 ECS family 的包边界、采用顺序和 XML inventory
|
||||
- 重写 `docs/zh-CN/ecs/arch.md`,明确 `UseArch(...)` 需早于 `Initialize()` 的真实接入时机
|
||||
- 刷新 `GFramework.Ecs.Arch/README.md`,使运行时 README 与源码 / 测试一致
|
||||
- 为 `GFramework.Ecs.Arch.Abstractions/README.md` 与 `docs/zh-CN/abstractions/ecs-arch-abstractions.md` 补齐类型族级 XML inventory
|
||||
- 重写 `docs/zh-CN/core/cqrs.md`,将其收敛为 `Cqrs` family landing,并补齐运行时 / 契约层 / 生成器的 XML inventory
|
||||
- 新建 `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`,为 `Cqrs.SourceGenerators` 补齐站内专题入口
|
||||
- 更新 `docs/zh-CN/source-generators/index.md`、`docs/zh-CN/api-reference/index.md` 与 VitePress sidebar,使 `Cqrs` family 的 generator 入口可导航
|
||||
- 为 `GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs` 与 `GFramework.Cqrs.SourceGenerators/Cqrs/CqrsHandlerRegistryGenerator.cs` 中缺失的内部类型补齐 XML 注释,使本轮轻量 inventory 达到声明级闭环
|
||||
- 为 `GFramework.Game/README.md`、`GFramework.Game.Abstractions/README.md`、`GFramework.Game.SourceGenerators/README.md` 补齐 `Game` family 的类型族级 XML inventory
|
||||
- 为 `docs/zh-CN/game/index.md` 补齐 frontmatter,并增加 `Game` / `Game.Abstractions` / `Game.SourceGenerators` 的 XML 覆盖基线入口
|
||||
- 将 `docs/zh-CN/abstractions/game-abstractions.md` 从失真的旧接口摘录页重写为契约边界 / 包关系 / 最小接入路径页面
|
||||
- 基于顶层目录轻量盘点确认:`GFramework.Game` 为 `56/56`、`GFramework.Game.Abstractions` 为 `80/80`、`GFramework.Game.SourceGenerators` 为 `2/2`,当前公开 / 内部类型声明都已带 XML 注释
|
||||
- 更新 `AGENTS.md` 的 WSL Git 策略,将显式 `--git-dir` / `--work-tree` 绑定提升为高于 `git.exe` 的默认优先级
|
||||
- 记录当前环境偏差:本会话 `git.exe` 可解析但执行会触发 `Exec format error`,而 plain Linux `git` 会命中 worktree 路径翻译错误,需要显式仓库绑定
|
||||
- 完成 `Game` family 巡检,确认 `docs/zh-CN/game/config-system.md`、`scene.md`、`ui.md` 与 `docs/zh-CN/source-generators/index.md` 的核心采用说明、包关系与交叉引用仍与当前源码 / README 一致,没有发现需要立刻修正的回漂
|
||||
|
||||
## Inventory(第一版)
|
||||
|
||||
| 模块族 | 当前状态 | 当前证据 | 下一动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Core` / `Core.Abstractions` | `README / landing / 类型族级 XML inventory 已收口,成员级审计待补齐` | 根 README、模块 README、`docs/zh-CN/core/**`、`docs/zh-CN/abstractions/core-abstractions.md` 已对齐当前目录与类型族基线 | 进入巡检;如有新 API 变更,再追加成员级 XML 审计 |
|
||||
| `Cqrs` / `Cqrs.Abstractions` / `Cqrs.SourceGenerators` | `README / landing / generator topic / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Cqrs/README.md`、`GFramework.Cqrs.Abstractions/README.md`、`GFramework.Cqrs.SourceGenerators/README.md`、`docs/zh-CN/core/cqrs.md`、`docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`、`docs/zh-CN/api-reference/index.md` 已对齐当前源码与测试 | 转入巡检;下一波切到 `Game` family 的 XML / 教程链路审计 |
|
||||
| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | `README / landing / abstractions / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Game/README.md`、`GFramework.Game.Abstractions/README.md`、`GFramework.Game.SourceGenerators/README.md`、`docs/zh-CN/game/index.md`、`docs/zh-CN/abstractions/game-abstractions.md` 已对齐当前源码与目录基线 | 转入巡检;优先抽查 `config-system`、`scene`、`ui` 与 `source-generators` 交叉链路是否回漂 |
|
||||
| `Godot` / `Godot.SourceGenerators` | `已验证` | 上一轮归档 topic 已完成核心 landing / topic / tutorial 校验 | 进入巡检周期,重点看回漂 |
|
||||
| `Ecs.Arch` / `Ecs.Arch.Abstractions` | `README / landing / abstractions / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Ecs.Arch/README.md`、`GFramework.Ecs.Arch.Abstractions/README.md`、`docs/zh-CN/ecs/**`、`docs/zh-CN/abstractions/ecs-arch-abstractions.md` 已对齐当前源码与测试 | 转入巡检;后续仅在运行时公共 API 变动时补成员级 XML 细审 |
|
||||
| `SourceGenerators.Common` 与 `*.SourceGenerators.Abstractions` | `已判定为内部支撑` | `*.csproj` 明确 `IsPackable=false` | 由所属模块 README 与生成器栏目说明 owner,不建独立采用页 |
|
||||
|
||||
## 缺口分级
|
||||
|
||||
- `P0`
|
||||
- 错误采用路径、错误包关系、错误 API / 生命周期语义
|
||||
- 站点导航死链、空 landing page、明显错误的模块 owner
|
||||
- `P1`
|
||||
- 直接消费模块缺 README 或缺对应 docs 入口
|
||||
- README / docs 示例与源码实现不一致
|
||||
- 教程仍引用已经过时的默认接线方式
|
||||
- `P2`
|
||||
- 结构重复、交叉链接不足、API 参考链路过薄
|
||||
- 站内页面存在事实正确但组织方式不利于定位的内容
|
||||
- `Core`、`Ecs.Arch`、`Cqrs`、`Game`、`Godot` 五个模块族当前都已有 README / landing / topic / API 参考层级的已验证入口。
|
||||
- `Game` persistence docs surface 当前以 `docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md`
|
||||
作为最小巡检集合;若后续 README、runtime public API 或 `PersistenceTests` 变动,应优先复核这一组页面。
|
||||
- `Godot` runtime 与 generator 入口当前以 `GFramework.Godot/README.md`、
|
||||
`GFramework.Godot.SourceGenerators/README.md`、`docs/zh-CN/godot/index.md`、
|
||||
`docs/zh-CN/source-generators/index.md`、`docs/zh-CN/tutorials/godot-integration.md` 维持统一 owner / adoption path。
|
||||
- `2026-04-23` 基于 PR `#272` 的 review follow-up 已完成:
|
||||
- 为 `docs/zh-CN/game/data.md` 补充 `UnifiedSettingsDataRepository` 的统一文件布局示例
|
||||
- 为 `GFramework.Godot.SourceGenerators/README.md` 补充手写 `_Ready()` / `_ExitTree()` 时显式调用生成方法的最小样例
|
||||
- 将过长的 active tracking / trace 瘦身,并把历史摘要迁回 `archive/`
|
||||
- `2026-04-23` 使用 `$gframework-pr-review` 重新抓取 PR `#272` 后,确认 latest-head review 当前仍有 1 条
|
||||
Greptile open thread,定位到 `docs/zh-CN/godot/setting.md:75` 的 inline code 误写成
|
||||
`SettingsModel<ISettingsDataRepository>`。
|
||||
- 结合当前 PR 已改动的 `docs/zh-CN/godot/storage.md` 做同类巡检后,确认 `SaveRepository<TSaveData>`
|
||||
也会在 VitePress code span 中按字面量渲染;两处现已在本地统一改为真实泛型写法。
|
||||
- 当前剩余的托管侧信号是 GitHub `Title check` 对 PR 标题过泛的 inconclusive 提示;这属于 PR 元数据,不是本地
|
||||
文件缺陷。
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 当前 `Core` / `Core.Abstractions` 只完成了类型族级 XML 基线,不等于成员级契约全审计
|
||||
- 缓解措施:后续只在共享抽象或高风险生命周期接口发生改动时补成员级细审,不在本轮扩张范围
|
||||
- `Godot` family 的治理结论主要留在已归档 topic 中,active topic 当前只保留摘要
|
||||
- 缓解措施:下一恢复点优先判断是否要把关键 XML inventory 摘要迁回 active topic,避免后续 boot 仍过度依赖 archive
|
||||
- 新功能分支若修改 README / docs / 公共 API 却不挂文档 topic,仍可能回漂
|
||||
- 缓解措施:将本 topic 作为长期 active topic 保留,并在后续巡检中记录回漂来源
|
||||
- VitePress 页面不能直接链接到 `docs/` 目录之外的模块 `README.md`
|
||||
- 缓解措施:站内页面用模块路径文本或站内 API 入口表达,仓库级 README 仍保留仓库文件链接
|
||||
- `GFramework.Cqrs` 在当前 WSL / dotnet 环境下,本地 build 仍会读取失效的 fallback package folder 配置,导致无法完成该项目的标准编译验证
|
||||
- 缓解措施:本轮先以 `GFramework.Cqrs.SourceGenerators` 编译通过和 docs site build 通过作为有效验证,并在后续环境治理或构建脚本清理时单独处理 `RestoreFallbackFolders` / 资产文件问题
|
||||
- 当前 WSL 会话中 `git.exe` 虽然可解析,但不能执行
|
||||
- 缓解措施:把显式 `--git-dir` / `--work-tree` 绑定上升为仓库默认回退策略,并仅把 `git.exe` 保留为可执行时的次级 fallback
|
||||
- 当前 `Core` / `Core.Abstractions`、`Ecs.Arch`、`Cqrs`、`Game` 的 XML 治理仍以“类型声明级基线”为主,不等于成员级契约全审计。
|
||||
- `GFramework.Cqrs` 在当前 WSL / dotnet 环境下仍会读取失效的 fallback package folder,并在标准 build 中触发
|
||||
`MSB4276` / `MSB4018`;这是已知环境阻塞,不属于本轮文档回归。
|
||||
- 当前 WSL 会话里 `git.exe` 可解析但不能执行,应继续使用显式 `--git-dir` / `--work-tree` 绑定作为默认 Git 策略。
|
||||
|
||||
## 验证说明
|
||||
## 归档指针
|
||||
|
||||
- 详细验证历史已归档到 `ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
- `2026-04-23` `python3 -B -c "from pathlib import Path; compile(Path('.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py').read_text(encoding='utf-8'), '.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py', 'exec')"`
|
||||
- 结果:通过
|
||||
- `2026-04-23` `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/gframework-current-pr-review.json`
|
||||
- 结果:通过;成功抓取 PR `#271`,并确认当前 latest-head review threads 为 `4` 条 open
|
||||
- `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`
|
||||
- 结果:通过
|
||||
- `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/ecs/index.md`
|
||||
- 结果:通过
|
||||
- `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/abstractions/ecs-arch-abstractions.md`
|
||||
- 结果:通过
|
||||
- `2026-04-23` `cd docs && bun run build`
|
||||
- 结果:通过;仅保留既有 VitePress 大 chunk warning,无构建失败
|
||||
- 详细验证历史(`RP-001` 到 `RP-007`):
|
||||
`ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
- 阶段状态归档(`RP-001` 到 `RP-016`):
|
||||
`ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-status-history-through-rp-016.md`
|
||||
- 时间线归档(`RP-001` 到 `RP-016`):
|
||||
`ai-plan/public/documentation-full-coverage-governance/archive/traces/documentation-full-coverage-governance-trace-history-through-rp-016.md`
|
||||
|
||||
## 最新验证
|
||||
|
||||
- `2026-04-23` `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过;PR `#272` 处于 `OPEN`,latest head commit 存在 1 条 Greptile open thread,定位到
|
||||
`docs/zh-CN/godot/setting.md:75` 的 inline code HTML entity 渲染问题。
|
||||
- `2026-04-23` `rg -n '`[^`]*<[^`]*`|`[^`]*>[^`]*`' GFramework.Godot.SourceGenerators/README.md GFramework.Godot/README.md README.md docs/zh-CN/api-reference/index.md docs/zh-CN/game/data.md docs/zh-CN/game/serialization.md docs/zh-CN/game/setting.md docs/zh-CN/game/storage.md docs/zh-CN/godot/setting.md docs/zh-CN/godot/storage.md docs/zh-CN/source-generators/index.md`
|
||||
- 结果:命中 `docs/zh-CN/godot/setting.md:75` 与 `docs/zh-CN/godot/storage.md:102` 两处同类写法,均已修正。
|
||||
- `2026-04-23` `bun run build`(工作目录:`docs/`)
|
||||
- 结果:通过;仅保留既有 VitePress 大 chunk warning,无构建失败。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 完成本轮 PR #271 follow-up 的针对性验证与 docs build,确认 open threads 是否都已被本地收敛
|
||||
2. 推送当前分支后重新执行 `$gframework-pr-review`,确认 PR #271 的 latest-head open threads 是否按预期收敛
|
||||
3. 评估是否需要把 `Godot` family 的关键 XML inventory 摘要迁回 active topic,避免长期治理只依赖 archive 恢复
|
||||
1. 提交并推送本地对 `docs/zh-CN/godot/setting.md` 与 `docs/zh-CN/godot/storage.md` 的 Markdown 泛型写法修正,
|
||||
然后重新抓取 PR `#272` 确认 Greptile open thread 是否已在新 head commit 上消失。
|
||||
2. 如果 PR `#272` 的 `Title check` 仍需要消除,到 GitHub 上把标题改成更具体的文档治理描述。
|
||||
3. 若后续分支继续调整 `Game` persistence runtime、README 或公共 API,优先复核 `docs/zh-CN/game/data.md`、
|
||||
`storage.md`、`serialization.md`、`setting.md` 与 landing page 是否仍保持同一套职责边界。
|
||||
4. 若后续分支继续调整 `Godot` generator 接法,优先复核 `GFramework.Godot.SourceGenerators/README.md`、
|
||||
`docs/zh-CN/tutorials/godot-integration.md` 与相关专题页是否仍保持一致。
|
||||
|
||||
@ -1,275 +1,58 @@
|
||||
# Documentation Full Coverage Governance Trace
|
||||
|
||||
## 2026-04-22
|
||||
## 2026-04-23
|
||||
|
||||
### 当前恢复点:RP-001
|
||||
### 当前恢复点:RP-019
|
||||
|
||||
- 按长期治理计划新建 active topic `documentation-full-coverage-governance`
|
||||
- 在 `ai-plan/public/README.md` 中将当前分支 `docs/sdk-update-documentation` 映射到该 topic
|
||||
- 复核已知缺口模块的 `*.csproj` 后确认:
|
||||
- `GFramework.Ecs.Arch.Abstractions` 是可打包消费模块,需要独立 README
|
||||
- `GFramework.Core.SourceGenerators.Abstractions`、`GFramework.Godot.SourceGenerators.Abstractions`、
|
||||
`GFramework.SourceGenerators.Common` 都是 `IsPackable=false` 的内部支撑模块
|
||||
- 基于该结论,本轮没有为内部支撑模块新增独立 README,而是在根 README 与 abstractions / API 入口中明确其 owner
|
||||
- 使用 `$gframework-pr-review` 重新复核当前分支 PR `#272`。
|
||||
- GitHub latest-head review 当前暴露 1 条新的 Greptile open thread:
|
||||
`docs/zh-CN/godot/setting.md:75` 在 inline code 中写成
|
||||
`SettingsModel<ISettingsDataRepository>`。
|
||||
- 本地核对当前文档渲染语义后,确认 CommonMark / VitePress 不会在 code span 内解码 HTML entity,
|
||||
该评论成立。
|
||||
- 对当前 PR 已变更的 Godot 文档做同类扫描后,又在 `docs/zh-CN/godot/storage.md:102` 发现
|
||||
`SaveRepository<TSaveData>` 的同型问题。
|
||||
- 本轮执行的修复:
|
||||
- 将 `docs/zh-CN/godot/setting.md` 的 `SettingsModel<ISettingsDataRepository>` 改为
|
||||
`SettingsModel<ISettingsDataRepository>`
|
||||
- 将 `docs/zh-CN/godot/storage.md` 的 `SaveRepository<TSaveData>` 改为
|
||||
`SaveRepository<TSaveData>`
|
||||
- 同步更新 active tracking / trace,记录该 PR review follow-up 与新的恢复点
|
||||
|
||||
### 当前决策
|
||||
### 当前决策(RP-019)
|
||||
|
||||
- 新主题的完成条件采用长期治理口径:`P0` 清零、无 README 缺失、无导航死链,并完成连续两轮稳定巡检
|
||||
- 本轮先做治理基础设施与 inventory,不把整个长期计划伪装成单轮完成
|
||||
- `api-reference` 页面改为“模块 -> README / docs / XML / tutorial”的阅读链路入口,避免继续维护失真的伪签名列表
|
||||
- `Ecs.Arch` family 被列为高优先 backlog:抽象层入口已补齐,但 runtime docs 仍需按源码重写
|
||||
- `Core` / `Core.Abstractions` 波次先收口 README、landing page 和 abstractions 页的目录映射,再补显式 XML 覆盖 inventory
|
||||
- VitePress 站内页面不直接链接仓库根模块 `README.md`;站内仅保留可构建的 docs 链接,模块 README 以文本路径或仓库 README 承接
|
||||
- PR review 结果以 GitHub latest-head open threads 为准;即便 active tracking 曾记录“无 open thread”,也必须按新抓取结果回写。
|
||||
- 对 Markdown inline code 中的 C# 泛型示例,必须直接写真实的 `<T>` 语法,不能在反引号内部再写
|
||||
`<` / `>`,否则 VitePress 会把 entity 当作字面量展示。
|
||||
- 当 latest-head review 命中某个文档表述问题时,应顺手扫描同一批 PR 已改动文档中的同类模式,避免只消掉单条 thread 却把相同渲染缺陷留在相邻页面。
|
||||
- 当前本地修复完成后,下一次 GitHub 侧复核需要基于新提交/新 head commit,而不是旧的 PR review 快照。
|
||||
|
||||
### 当前恢复点:RP-002
|
||||
|
||||
- 完成 `Core` / `Core.Abstractions` 的类型族级 XML inventory:
|
||||
- `GFramework.Core/README.md`
|
||||
- `GFramework.Core.Abstractions/README.md`
|
||||
- `docs/zh-CN/core/index.md`
|
||||
- `docs/zh-CN/abstractions/core-abstractions.md`
|
||||
- 通过顶层目录轻量盘点确认:
|
||||
- `GFramework.Core` 当前各目录族的公开 / 内部类型声明都已带 XML 注释
|
||||
- `GFramework.Core.Abstractions` 当前各契约目录族的公开 / 内部类型声明都已带 XML 注释
|
||||
- 这轮 inventory 明确限定为“类型声明级基线”,不把结果表述成成员级 XML 合规审计
|
||||
|
||||
### 当前决策(RP-002)
|
||||
|
||||
- XML inventory 同时落在模块 README 和站内 landing page:
|
||||
- README 提供仓库侧入口,方便从包目录直接恢复上下文
|
||||
- docs landing 提供更细的类型族 / 代表类型 / 阅读重点表格,方便站内导航
|
||||
- `Core` 波次在补齐基线后转入巡检,不继续在本轮展开成员级 ``<param>`` / ``<returns>`` 审计
|
||||
- 下一恢复点切换到 `Ecs` 波次,优先处理仍明显失真的 runtime docs
|
||||
|
||||
### 当前验证
|
||||
|
||||
- 文档校验:
|
||||
- `validate-all.sh docs/zh-CN/abstractions/index.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/abstractions/ecs-arch-abstractions.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/api-reference/index.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/core/index.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/abstractions/core-abstractions.md`:通过
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过
|
||||
- `DOTNET_CLI_HOME=/tmp/dotnet-home dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.csproj -c Release -p:RestoreFallbackFolders=`:通过,`0 Warning(s) / 0 Error(s)`
|
||||
- `dotnet build GFramework.Ecs.Arch.Abstractions/GFramework.Ecs.Arch.Abstractions.csproj -c Release -p:RestoreFallbackFolders=`:通过,`0 Warning(s) / 0 Error(s)`
|
||||
|
||||
### 当前验证(RP-002)
|
||||
|
||||
- 文档校验:
|
||||
- `validate-all.sh docs/zh-CN/core/index.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/abstractions/core-abstractions.md`:通过
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过;仅保留 VitePress 大 chunk warning,无构建失败
|
||||
|
||||
### 当前恢复点:RP-003
|
||||
|
||||
- 完成 `Ecs.Arch` 波次的运行时文档刷新:
|
||||
- `docs/zh-CN/ecs/index.md`
|
||||
- `docs/zh-CN/ecs/arch.md`
|
||||
- `GFramework.Ecs.Arch/README.md`
|
||||
- 为 `Ecs.Arch.Abstractions` 补齐与运行时页同粒度的 XML inventory:
|
||||
- `GFramework.Ecs.Arch.Abstractions/README.md`
|
||||
- `docs/zh-CN/abstractions/ecs-arch-abstractions.md`
|
||||
- 明确记录一个关键采用事实:
|
||||
- `UseArch(...)` 必须早于 `Initialize()` 调用
|
||||
- 该结论以 `ArchExtensions` 的模块注册方式和 `ExplicitRegistrationTests` 为证据
|
||||
- 将 `Ecs.Arch` family 从“入口存在但失真”推进到“README / landing / abstractions / XML inventory 已对齐源码与测试”
|
||||
|
||||
### 当前决策(RP-003)
|
||||
|
||||
- `Ecs` 波次继续采用与 `Core` 相同的治理粒度:
|
||||
- 模块 README 承担仓库入口
|
||||
- `docs/zh-CN/ecs/index.md` 承担模块族 landing
|
||||
- `docs/zh-CN/ecs/arch.md` 承担运行时默认实现专题页
|
||||
- `docs/zh-CN/abstractions/ecs-arch-abstractions.md` 承担契约边界专题页
|
||||
- `EnableStatistics` 当前仅保留在公开配置面上;文档不再把它写成已验证的运行时行为
|
||||
- 下一恢复点切换到 `Cqrs` 波次,优先解决入口分散和 API / XML 阅读链路不统一的问题
|
||||
|
||||
### 当前验证(RP-003)
|
||||
|
||||
- 文档校验:
|
||||
- `validate-all.sh docs/zh-CN/ecs/index.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/ecs/arch.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/abstractions/ecs-arch-abstractions.md`:通过
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过;仅保留 VitePress 大 chunk warning,无构建失败
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 在 `Cqrs` 波次核对模块 README、`docs/zh-CN/core/cqrs.md` 与 `docs/zh-CN/source-generators/**` 的真实 owner
|
||||
2. 决定 `Cqrs` family 是补 dedicated landing 还是拆分现有入口页
|
||||
|
||||
### 当前恢复点:RP-004
|
||||
|
||||
- 完成 `Cqrs` 波次的模块族入口刷新:
|
||||
- 重写 `docs/zh-CN/core/cqrs.md`
|
||||
- 新建 `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`
|
||||
- 更新 `docs/zh-CN/source-generators/index.md`
|
||||
- 更新 `docs/zh-CN/api-reference/index.md`
|
||||
- 更新 `docs/.vitepress/config.mts`
|
||||
- 将 `Cqrs` family 从“README 已存在但 generator 入口分散”推进到“runtime / abstractions / source generator 都有明确站内入口”
|
||||
- 为 `GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs` 与
|
||||
`GFramework.Cqrs.SourceGenerators/Cqrs/CqrsHandlerRegistryGenerator.cs` 中缺失的内部类型补齐 XML 注释
|
||||
- 基于轻量扫描确认:
|
||||
- `GFramework.Cqrs.Abstractions/Cqrs/` 当前类型声明级 XML 覆盖为 `20/20`
|
||||
- `GFramework.Cqrs` 根入口与 `Internal/` 已补到 `19/19`
|
||||
- `GFramework.Cqrs.SourceGenerators/Cqrs/` 当前类型声明级 XML 覆盖为 `3/3`
|
||||
|
||||
### 当前决策(RP-004)
|
||||
|
||||
- `docs/zh-CN/core/cqrs.md` 继续保留在 `Core` 栏目,但其角色调整为 `Cqrs` family landing,而不再只是 runtime 简介页
|
||||
- `Cqrs.SourceGenerators` 不单独新建一级导航栏目,而是在 `source-generators` 栏目内补一个专用专题页,保持站点 taxonomy 稳定
|
||||
- generator 入口以“专题页 + API reference 链接 + sidebar”三点联动,而不是只在 `source-generators/index.md` 留一个段落链接
|
||||
- XML inventory 仍维持“类型声明级基线”口径,不在本轮扩展成成员级 `param/returns/exception` 细审
|
||||
|
||||
### 当前验证(RP-004)
|
||||
|
||||
- 文档校验:
|
||||
- `validate-all.sh docs/zh-CN/core/cqrs.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`:通过
|
||||
- 轻量 XML inventory:
|
||||
- `GFramework.Cqrs/Internal/`:`14/14`
|
||||
- `GFramework.Cqrs.Abstractions/Cqrs/`:`20/20`
|
||||
- `GFramework.Cqrs.SourceGenerators/Cqrs/`:`3/3`
|
||||
- 构建校验:
|
||||
- `dotnet build GFramework.Cqrs.SourceGenerators/GFramework.Cqrs.SourceGenerators.csproj -c Release -p:RestoreFallbackFolders=`:通过
|
||||
- `cd docs && bun run build`:通过;仅保留 VitePress 大 chunk warning,无构建失败
|
||||
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`:失败;当前 WSL / dotnet 环境仍引用失效的 Windows fallback package folder,并在多目标 inner build 阶段触发 `MSB4276` / `MSB4018`
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 切换到 `Game` family 波次,按 `Core` / `Ecs` / `Cqrs` 已验证模板继续补 XML inventory 与教程链路
|
||||
2. 把 `GFramework.Cqrs` 的本地构建阻塞留给后续环境治理或构建脚本清理,不在本 topic 内扩张为环境修复任务
|
||||
|
||||
### 当前恢复点:RP-005
|
||||
|
||||
- 完成 `Game` 波次的模块族入口刷新:
|
||||
- 更新 `GFramework.Game/README.md`
|
||||
- 更新 `GFramework.Game.Abstractions/README.md`
|
||||
- 更新 `GFramework.Game.SourceGenerators/README.md`
|
||||
- 更新 `docs/zh-CN/game/index.md`
|
||||
- 重写 `docs/zh-CN/abstractions/game-abstractions.md`
|
||||
- 将 `Game` family 从“README / 页面存在但缺少可审计 XML 入口,且 abstractions 页失真”推进到“runtime / abstractions / source generator 都有声明级 XML inventory 与真实采用边界”
|
||||
- 基于轻量扫描确认:
|
||||
- `GFramework.Game` 当前类型声明级 XML 覆盖为 `56/56`
|
||||
- `GFramework.Game.Abstractions` 当前类型声明级 XML 覆盖为 `80/80`
|
||||
- `GFramework.Game.SourceGenerators` 当前类型声明级 XML 覆盖为 `2/2`
|
||||
|
||||
### 当前决策(RP-005)
|
||||
|
||||
- `docs/zh-CN/abstractions/game-abstractions.md` 不再维护虚构接口摘录,而是与源码中的 `Config` / `Data` / `Setting` / `Scene` / `UI` / `Routing` 契约分组保持一致
|
||||
- `Game.SourceGenerators` 继续以 `README + docs/zh-CN/game/config-system.md + docs/zh-CN/source-generators/index.md` 组成入口,不额外新增只为凑数量的专题页
|
||||
- `docs/zh-CN/game/index.md` 补 frontmatter,并承担 `Game` family 的 XML 基线入口;更细的类型族说明继续留在模块 README 与 abstractions 页
|
||||
|
||||
### 当前验证(RP-005)
|
||||
|
||||
- 文档校验:
|
||||
- `validate-all.sh docs/zh-CN/abstractions/game-abstractions.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/game/index.md`:通过
|
||||
- 轻量 XML inventory:
|
||||
- `GFramework.Game`:`56/56`
|
||||
- `GFramework.Game.Abstractions`:`80/80`
|
||||
- `GFramework.Game.SourceGenerators`:`2/2`
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过;仅保留 VitePress 大 chunk warning,无构建失败
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 进入 `Game` family 巡检,优先检查 `config-system.md`、`scene.md`、`ui.md` 与 `source-generators/index.md` 的交叉引用是否回漂
|
||||
2. 评估是否需要把 `Godot` family 的关键 XML inventory 摘要迁回 active topic,减少对 archive 的依赖
|
||||
|
||||
### 当前恢复点:RP-006
|
||||
|
||||
- 更新 `AGENTS.md` 的 WSL Git 规则:
|
||||
- 将显式 `git --git-dir=<...> --work-tree=<...>` 绑定提升为高于 `git.exe` 的默认优先级
|
||||
- 明确 plain Linux `git` 命中 worktree 路径翻译错误时,应先切到显式绑定而不是直接改用 `git.exe`
|
||||
- 明确 `git.exe` 只有在当前会话可执行时才作为次级 fallback
|
||||
- 记录本次恢复任务的环境偏差:
|
||||
- `git.exe` 在当前 WSL 会话中可解析,但执行会触发 `Exec format error`
|
||||
- plain `git` 会把 worktree 元数据路径翻译错并报“not a git repository”
|
||||
- 显式 `--git-dir` / `--work-tree` 绑定是本次已验证可用的 Git 操作方式
|
||||
|
||||
### 当前决策(RP-006)
|
||||
|
||||
- 把 Git 回退顺序写进 `AGENTS.md`,而不是只留在一次性的聊天上下文里
|
||||
- 不额外扩张 `gframework-boot` skill,因为它本身不内嵌 Git 选择逻辑,继续由 `AGENTS.md` 作为唯一准则
|
||||
- 继续把 `git.exe` 保留为 fallback,而不是完全删除,避免在可执行的 WSL 会话里丢掉可用路径
|
||||
|
||||
### 当前验证(RP-006)
|
||||
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过;仅保留 VitePress 大 chunk warning,无构建失败
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 继续 `Game` family 巡检,优先检查 `config-system.md`、`scene.md`、`ui.md` 与 `source-generators/index.md` 的交叉引用是否回漂
|
||||
2. 评估是否需要把 `Godot` family 的关键 XML inventory 摘要迁回 active topic,减少对 archive 的依赖
|
||||
|
||||
### 当前恢复点:RP-007
|
||||
|
||||
- 完成 `Game` family 巡检:
|
||||
- 复核 `docs/zh-CN/game/config-system.md`
|
||||
- 复核 `docs/zh-CN/game/scene.md`
|
||||
- 复核 `docs/zh-CN/game/ui.md`
|
||||
- 复核 `docs/zh-CN/source-generators/index.md`
|
||||
- 对照 `GFramework.Game`、`GFramework.Game.Abstractions`、`GFramework.Game.SourceGenerators` README 与相关源码 / 测试后,未发现需要立刻修正的采用语义回漂
|
||||
- 重点确认的真实语义包括:
|
||||
- `GameConfigBootstrap` / `RegisterAllGeneratedConfigTables(...)` / `GFrameworkConfigSchemaDirectory` 的配置入口仍与文档示例一致
|
||||
- `SceneRouterBase` 仍通过 `SemaphoreSlim` 串行化切换,并拒绝重复 `sceneKey` 入栈
|
||||
- `UiRouterBase` 仍将 `Page` 层与 `Overlay` / `Modal` / `Toast` / `Topmost` 分为两套入口,且 `Show(..., UiLayer.Page)` 会直接拒绝
|
||||
|
||||
### 当前决策(RP-007)
|
||||
|
||||
- 本轮不为“巡检通过”硬造文档改动,先把结论写回 active topic,保持恢复点准确
|
||||
- `Game` family 暂时转入稳定巡检,不在没有源码变化的情况下重复改写 landing page
|
||||
- 默认下一步切到 `Godot` family 摘要是否回迁,减少长期治理对 archive topic 的依赖
|
||||
|
||||
### 当前验证(RP-007)
|
||||
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过;仅保留 VitePress 大 chunk warning,无构建失败
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 评估是否需要把 `Godot` family 的关键 XML inventory 摘要迁回 active topic
|
||||
2. 若不需要迁回,则继续抽查 README / landing page / API reference 之间的 cross-link 是否出现新的漂移
|
||||
|
||||
### 当前恢复点:RP-008
|
||||
|
||||
- 使用 `$gframework-pr-review` 抓取当前分支 PR `#271` 后,确认 latest head review threads 仍有 `4` 条 open:
|
||||
- `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md` 的 marker 类型约定说明缺口
|
||||
- `docs/zh-CN/ecs/index.md` 的边界说明语序问题
|
||||
- `docs/zh-CN/abstractions/ecs-arch-abstractions.md` 误放的 source-generator 内部模块提醒
|
||||
- `ai-plan/public/documentation-full-coverage-governance/todos/documentation-full-coverage-governance-tracking.md` 的验证历史过长,以及
|
||||
`ai-plan/public/archive/documentation-governance-and-refresh/traces/documentation-governance-and-refresh-trace.md` 缺少显式结果态
|
||||
- 在当前 WSL 会话里,`gframework-pr-review` 脚本先命中了 `git.exe` 的 `Exec format error`
|
||||
- 已将 `.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py` 改为优先使用 Linux `git` 的显式
|
||||
`--git-dir` / `--work-tree` 绑定,并仅在无法建立该绑定时回退到旧的可执行解析逻辑
|
||||
- 已同步更新 `.agents/skills/gframework-pr-review/SKILL.md`,使其 Git 策略与命令示例都与当前仓库状态一致
|
||||
- 已把 `DOCUMENTATION-FULL-COVERAGE-GOV-RP-001` 到 `RP-007` 的详细验证历史迁入
|
||||
`ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
|
||||
### 当前决策(RP-008)
|
||||
|
||||
- 继续把 latest-head unresolved threads 作为主信号,只修仍在本地成立的评论,不为已失效的历史 summary 做无意义回写
|
||||
- active tracking 只保留最新验证摘要与恢复点;详细验证历史留在 topic 自己的 archive,而不是继续堆在默认 boot 路径
|
||||
- `gframework-pr-review` 的脚本行为、技能文案与 `AGENTS.md` 必须保持同一套 WSL Git 策略,避免再次出现“文档说法正确但工具实现仍跑偏”的情况
|
||||
|
||||
### 当前验证(RP-008)
|
||||
### 当前验证(RP-019)
|
||||
|
||||
- PR review 抓取:
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/gframework-current-pr-review.json`:通过
|
||||
- 脚本语法校验:
|
||||
- `python3 -B -c "from pathlib import Path; compile(Path('.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py').read_text(encoding='utf-8'), '.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py', 'exec')"`:通过
|
||||
- 文档校验:
|
||||
- `validate-all.sh docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/ecs/index.md`:通过
|
||||
- `validate-all.sh docs/zh-CN/abstractions/ecs-arch-abstractions.md`:通过
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过;PR `#272` 处于 `OPEN`,latest head commit 存在 1 条 Greptile open thread,定位到
|
||||
`docs/zh-CN/godot/setting.md:75` 的 inline code HTML entity 渲染问题。
|
||||
- 同类模式巡检:
|
||||
- `rg -n '`[^`]*<[^`]*`|`[^`]*>[^`]*`' GFramework.Godot.SourceGenerators/README.md GFramework.Godot/README.md README.md docs/zh-CN/api-reference/index.md docs/zh-CN/game/data.md docs/zh-CN/game/serialization.md docs/zh-CN/game/setting.md docs/zh-CN/game/storage.md docs/zh-CN/godot/setting.md docs/zh-CN/godot/storage.md docs/zh-CN/source-generators/index.md`
|
||||
- 结果:命中 `docs/zh-CN/godot/setting.md:75` 与 `docs/zh-CN/godot/storage.md:102` 两处同类写法,均已修正。
|
||||
- 构建校验:
|
||||
- `cd docs && bun run build`:通过;仅保留既有 VitePress 大 chunk warning,无构建失败
|
||||
- `bun run build`(工作目录:`docs/`)
|
||||
- 结果:通过;仅保留既有 VitePress 大 chunk warning,无构建失败。
|
||||
|
||||
### 归档摘要(RP-018)
|
||||
|
||||
- 使用 `$gframework-pr-review` 重新复核当前分支 PR `#272`。
|
||||
- latest-head review 命中 `GFramework.Godot.SourceGenerators/README.md:135` 的错误命名空间引用,并已在本地修正。
|
||||
- README 校验与 `docs/` 站点构建通过,待新提交推送后回 GitHub 侧确认 open thread 消失。
|
||||
|
||||
### 归档指针
|
||||
|
||||
- `ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-validation-history-through-rp-007.md`
|
||||
- `ai-plan/public/documentation-full-coverage-governance/archive/todos/documentation-full-coverage-governance-status-history-through-rp-016.md`
|
||||
- `ai-plan/public/documentation-full-coverage-governance/archive/traces/documentation-full-coverage-governance-trace-history-through-rp-016.md`
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 提交本轮 PR review follow-up
|
||||
2. 推送当前分支后重新执行 `$gframework-pr-review`,观察 PR #271 的 open threads 是否收敛
|
||||
1. 提交并推送本地修正后,再次抓取 PR `#272`,确认 Greptile open thread 是否已在新 head commit 上消失。
|
||||
2. 如果 PR `#272` 的 `Title check` 仍需要处理,到 GitHub 上把标题改成更具体的文档治理描述。
|
||||
|
||||
@ -32,7 +32,7 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题
|
||||
| `Core` / `Core.Abstractions` | `GFramework.Core/README.md`、`GFramework.Core.Abstractions/README.md` | [`../core/index.md`](../core/index.md)、[`../abstractions/core-abstractions.md`](../abstractions/core-abstractions.md) | 架构入口、生命周期、命令 / 查询 / 事件 / 状态 / 资源 / 日志 / 配置 / 并发契约 |
|
||||
| `Cqrs` / `Cqrs.Abstractions` / `Cqrs.SourceGenerators` | `GFramework.Cqrs/README.md`、`GFramework.Cqrs.Abstractions/README.md`、`GFramework.Cqrs.SourceGenerators/README.md` | [`../core/cqrs.md`](../core/cqrs.md)、[`../source-generators/cqrs-handler-registry-generator.md`](../source-generators/cqrs-handler-registry-generator.md) | request / notification / handler / pipeline / registry / fallback contract |
|
||||
| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | `GFramework.Game/README.md`、`GFramework.Game.Abstractions/README.md`、`GFramework.Game.SourceGenerators/README.md` | [`../game/index.md`](../game/index.md)、[`../abstractions/game-abstractions.md`](../abstractions/game-abstractions.md) | 配置、数据、设置、场景、UI、存储、序列化契约 |
|
||||
| `Godot` / `Godot.SourceGenerators` | `GFramework.Godot/README.md`、`GFramework.Godot.SourceGenerators/README.md` | [`../godot/index.md`](../godot/index.md)、[`../source-generators/index.md`](../source-generators/index.md) | 节点扩展、场景 / UI 适配、资源 / 存储 / 日志接入 |
|
||||
| `Godot` / `Godot.SourceGenerators` | `GFramework.Godot/README.md`、`GFramework.Godot.SourceGenerators/README.md` | [`../godot/index.md`](../godot/index.md)、[`../source-generators/godot-project-generator.md`](../source-generators/godot-project-generator.md)、[`../source-generators/get-node-generator.md`](../source-generators/get-node-generator.md)、[`../source-generators/bind-node-signal-generator.md`](../source-generators/bind-node-signal-generator.md) | 节点扩展、场景 / UI 适配、配置 / 存储 / 设置接线、Godot 生成器入口 |
|
||||
| `Ecs.Arch` / `Ecs.Arch.Abstractions` | `GFramework.Ecs.Arch/README.md`、`GFramework.Ecs.Arch.Abstractions/README.md` | [`../ecs/index.md`](../ecs/index.md)、[`../ecs/arch.md`](../ecs/arch.md)、[`../abstractions/ecs-arch-abstractions.md`](../abstractions/ecs-arch-abstractions.md) | ECS 模块契约、系统适配、配置对象和运行时装配边界 |
|
||||
|
||||
## 先看 XML,还是先看教程
|
||||
|
||||
@ -1,709 +1,204 @@
|
||||
---
|
||||
title: 数据与存档系统
|
||||
description: 数据与存档系统提供统一的数据持久化基础能力,支持多槽位存档、版本化数据和仓库抽象。
|
||||
description: 以当前 GFramework.Game 源码与 PersistenceTests 为准,说明 DataRepository、UnifiedSettingsDataRepository 和 SaveRepository 的职责边界。
|
||||
---
|
||||
|
||||
# 数据与存档系统
|
||||
|
||||
## 概述
|
||||
`GFramework.Game` 的数据持久化不是“只有一个万能仓库”。
|
||||
|
||||
数据与存档系统是 GFramework.Game 中用于管理游戏数据持久化的核心组件。它提供了统一的数据加载和保存接口,支持多槽位存档管理、版本化数据模式,以及与存储系统、序列化系统的组合使用。
|
||||
当前更准确的理解是三层分工:
|
||||
|
||||
通过数据系统,你可以将游戏数据保存到本地存储,支持多个存档槽位,并在需要时于应用层实现版本迁移。
|
||||
- `DataRepository`
|
||||
- 面向“一个 location 对应一份持久化对象”的通用数据仓库
|
||||
- `UnifiedSettingsDataRepository`
|
||||
- 面向“多个设置 section 聚合到同一个文件”的设置仓库
|
||||
- `SaveRepository<TSaveData>`
|
||||
- 面向“按槽位组织的版本化存档”
|
||||
|
||||
**主要特性**:
|
||||
如果先把这三类入口分开理解,后续采用路径会清晰很多。
|
||||
|
||||
- 统一的数据持久化接口
|
||||
- 多槽位存档管理
|
||||
- 数据版本控制模式
|
||||
- 异步加载和保存
|
||||
- 批量数据操作
|
||||
- 与存储系统集成
|
||||
## 什么时候用哪个仓库
|
||||
|
||||
## 核心概念
|
||||
### `DataRepository`
|
||||
|
||||
### 数据接口
|
||||
适合:
|
||||
|
||||
`IData` 标记数据类型:
|
||||
- 单份玩家档案
|
||||
- 单份运行时缓存
|
||||
- 一条 location 对应一个文件的普通业务数据
|
||||
|
||||
```csharp
|
||||
public interface IData
|
||||
{
|
||||
// 标记接口,用于标识可持久化的数据
|
||||
}
|
||||
默认语义是:
|
||||
|
||||
- `IDataLocation` 决定 key
|
||||
- 一条 location 对应一份对象
|
||||
- 覆盖保存时可按 `DataRepositoryOptions.AutoBackup` 创建 `<key>.backup`
|
||||
- `SaveAllAsync(...)` 视为一次批量提交,只发送批量事件,不重复发送单项保存事件
|
||||
|
||||
### `UnifiedSettingsDataRepository`
|
||||
|
||||
适合:
|
||||
|
||||
- 音频、图形、语言等多个设置 section 统一落到一份文件
|
||||
- 启动时一次性加载所有设置,再交给 `SettingsModel<TRepository>` 编排
|
||||
|
||||
默认语义是:
|
||||
|
||||
- 底层持久化文件只有一份,默认文件名是 `settings.json`
|
||||
- 各个设置 section 仍然通过 `IDataLocation` 的 key 区分
|
||||
- 保存、删除时会整文件回写,而不是只改单个 section 文件
|
||||
- 开启 `AutoBackup` 时,备份粒度也是整个统一文件,不是单个 section
|
||||
|
||||
当 `DataRepositoryOptions.BasePath = "settings"`,并保持默认文件名时,最小目录结构通常是:
|
||||
|
||||
```text
|
||||
settings/
|
||||
settings.json
|
||||
```
|
||||
|
||||
### 数据仓库
|
||||
如果同时开启 `AutoBackup = true`,则同一路径下还会额外出现:
|
||||
|
||||
`IDataRepository` 提供通用的数据操作:
|
||||
|
||||
```csharp
|
||||
public interface IDataRepository : IUtility
|
||||
{
|
||||
Task<T> LoadAsync<T>(IDataLocation location) where T : class, IData, new();
|
||||
Task SaveAsync<T>(IDataLocation location, T data) where T : class, IData;
|
||||
Task<bool> ExistsAsync(IDataLocation location);
|
||||
Task DeleteAsync(IDataLocation location);
|
||||
Task SaveAllAsync(IEnumerable<(IDataLocation, IData)> dataList);
|
||||
}
|
||||
```text
|
||||
settings/
|
||||
settings.json
|
||||
settings.backup
|
||||
```
|
||||
|
||||
`IDataRepository` 描述的是“仓库语义”,不是固定的落盘格式。实现可以选择每个数据项独立成文件,也可以把多个 section 聚合到同一个文件里。
|
||||
### `SaveRepository<TSaveData>`
|
||||
|
||||
当前内建实现里:
|
||||
适合:
|
||||
|
||||
- `DataRepository` 采用“每个 location 一份持久化对象”的模型
|
||||
- `UnifiedSettingsDataRepository` 采用“所有设置聚合到一个统一文件”的模型
|
||||
- 多槽位存档
|
||||
- 需要版本迁移的 save data
|
||||
- 需要列举现有槽位和删除槽位
|
||||
|
||||
两者对外遵守同一套约定:
|
||||
默认语义是:
|
||||
|
||||
- `SaveAllAsync(...)` 视为一次批量提交,只发送 `DataBatchSavedEvent`,不会再为每个条目重复发送 `DataSavedEvent<T>`
|
||||
- `DeleteAsync(...)` 只有在目标数据真实存在并被删除时才会发送删除事件
|
||||
- 当 `DataRepositoryOptions.AutoBackup = true` 时,覆盖已有数据前会先保留上一份快照
|
||||
- 对 `UnifiedSettingsDataRepository` 来说,备份粒度是整个统一文件,而不是单个设置 section
|
||||
- 按 `SaveRoot` / `SaveSlotPrefix` / `SaveFileName` 组织目录
|
||||
- 槽位不存在时,`LoadAsync(slot)` 返回新的 `TSaveData` 实例,而不是 `null`
|
||||
- `ListSlotsAsync()` 只返回真实存在存档文件的槽位,并按升序排列
|
||||
- 迁移成功后会把升级后的结果自动回写到槽位文件
|
||||
|
||||
### 存档仓库
|
||||
## 当前公开入口
|
||||
|
||||
`ISaveRepository<T>` 专门用于管理游戏存档:
|
||||
### `DataRepository`
|
||||
|
||||
`DataRepository` 是最通用的默认实现。当前仓库和测试确认的行为有几条需要特别记住:
|
||||
|
||||
- `LoadAsync<T>(location)` 在文件不存在时返回 `new T()`,不是抛异常
|
||||
- `DeleteAsync(location)` 只有在目标数据真实存在并被删除时才发送删除事件
|
||||
- `SaveAllAsync(...)` 会抑制逐项 `DataSavedEvent<T>`,只保留一次 `DataBatchSavedEvent`
|
||||
- `AutoBackup = true` 时,覆盖旧值前会先把旧值写到 `<key>.backup`
|
||||
|
||||
最小接法通常是:项目先准备一个 `IDataLocation` 或 `IDataLocationProvider`,再把它交给 `DataRepository` 做
|
||||
`location -> key` 的映射;repository 自己不负责推导业务对象应该落在哪个位置。
|
||||
|
||||
### `UnifiedSettingsDataRepository`
|
||||
|
||||
当前 `SettingsModel<TRepository>` 依赖的默认设置仓库就是它。
|
||||
|
||||
它和普通 `DataRepository` 的关键区别不是接口,而是落盘形态:
|
||||
|
||||
- `DataRepository`
|
||||
- 每个 location 对应一个独立文件
|
||||
- `UnifiedSettingsDataRepository`
|
||||
- 所有 section 聚合到同一个统一文件
|
||||
|
||||
还有两个容易遗漏的点:
|
||||
|
||||
- `LoadAllAsync()` 依赖 `RegisterDataType(location, type)` 建立 section -> 运行时类型映射
|
||||
- 仓库内部会先把统一文件加载进缓存,再在保存 / 删除时基于快照整文件提交
|
||||
|
||||
这就是为什么 `SettingsModel<TRepository>` 会在拿到 `GetData<T>()` 或 `RegisterApplicator(...)` 后主动把类型注册回 repository。
|
||||
|
||||
### `SaveRepository<TSaveData>`
|
||||
|
||||
`SaveRepository<TSaveData>` 用于槽位存档,不直接复用 `IDataLocation`。
|
||||
|
||||
最重要的公开配置是 `SaveConfiguration`:
|
||||
|
||||
```csharp
|
||||
public interface ISaveRepository<TSaveData> : IUtility
|
||||
where TSaveData : class, IData, new()
|
||||
var config = new SaveConfiguration
|
||||
{
|
||||
ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration);
|
||||
Task<bool> ExistsAsync(int slot);
|
||||
Task<TSaveData> LoadAsync(int slot);
|
||||
Task SaveAsync(int slot, TSaveData data);
|
||||
Task DeleteAsync(int slot);
|
||||
Task<IReadOnlyList<int>> ListSlotsAsync();
|
||||
}
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save.json"
|
||||
};
|
||||
```
|
||||
|
||||
`ISaveMigration<TSaveData>` 定义单步迁移:
|
||||
按这个配置,槽位 `1` 的默认文件结构就是:
|
||||
|
||||
```csharp
|
||||
public interface ISaveMigration<TSaveData>
|
||||
where TSaveData : class, IData
|
||||
{
|
||||
int FromVersion { get; }
|
||||
int ToVersion { get; }
|
||||
TSaveData Migrate(TSaveData oldData);
|
||||
}
|
||||
```text
|
||||
saves/
|
||||
slot_1/
|
||||
save.json
|
||||
```
|
||||
|
||||
### 版本化数据
|
||||
当前实现内部会先把根存储包装成 `ScopedStorage(storage, config.SaveRoot)`,再按槽位继续加前缀,因此项目层一般不需要手工再拼一次 `"saves/slot_1"`。
|
||||
|
||||
`IVersionedData` 支持数据版本管理:
|
||||
|
||||
```csharp
|
||||
public interface IVersionedData : IData
|
||||
{
|
||||
int Version { get; }
|
||||
DateTime LastModified { get; }
|
||||
}
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 定义数据类型
|
||||
## 存档迁移的真实语义
|
||||
|
||||
`SaveRepository<TSaveData>` 只有在 `TSaveData` 实现了 `IVersionedData` 时,才支持 `RegisterMigration(...)`。
|
||||
|
||||
当前源码和 `PersistenceTests` 明确约束了下面这些行为:
|
||||
|
||||
- 非版本化 save type 注册迁移器会直接失败
|
||||
- 同一个 `FromVersion` 不能重复注册迁移器
|
||||
- 迁移链缺口会显式抛错,不会静默返回半升级结果
|
||||
- 迁移器声明的 `ToVersion` 必须与实际返回对象的版本一致
|
||||
- 如果读到比当前运行时代码更高版本的存档,也会明确失败
|
||||
- 单次加载会先固定一份迁移表快照,避免并发注册让同一次加载看到变化中的链路
|
||||
|
||||
也就是说,`SaveRepository<TSaveData>` 的迁移语义更偏“严格升级管线”,而不是“尽量帮你读出来”。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
下面是当前 `Game` 层最常见的一套组合方式:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Serializer;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
|
||||
// 简单数据
|
||||
public class PlayerData : IData
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; }
|
||||
}
|
||||
|
||||
// 版本化数据
|
||||
public class SaveData : IVersionedData
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public PlayerData Player { get; set; }
|
||||
public DateTime SaveTime { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 使用存档仓库
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class SaveController : IController
|
||||
{
|
||||
public async Task SaveGame(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 创建存档数据
|
||||
var saveData = new SaveData
|
||||
{
|
||||
Player = new PlayerData
|
||||
{
|
||||
Name = "Player1",
|
||||
Level = 10,
|
||||
Experience = 1000
|
||||
},
|
||||
SaveTime = DateTime.Now
|
||||
};
|
||||
|
||||
// 保存到指定槽位
|
||||
await saveRepo.SaveAsync(slot, saveData);
|
||||
Console.WriteLine($"游戏已保存到槽位 {slot}");
|
||||
}
|
||||
|
||||
public async Task LoadGame(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 检查存档是否存在
|
||||
if (!await saveRepo.ExistsAsync(slot))
|
||||
{
|
||||
Console.WriteLine($"槽位 {slot} 不存在存档");
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载存档
|
||||
var saveData = await saveRepo.LoadAsync(slot);
|
||||
Console.WriteLine($"加载存档: {saveData.Player.Name}, 等级 {saveData.Player.Level}");
|
||||
}
|
||||
|
||||
public async Task DeleteSave(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 删除存档
|
||||
await saveRepo.DeleteAsync(slot);
|
||||
Console.WriteLine($"已删除槽位 {slot} 的存档");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 注册存档仓库
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Data;
|
||||
|
||||
public class GameArchitecture : Architecture
|
||||
{
|
||||
protected override void Init()
|
||||
{
|
||||
// 获取存储系统
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
// 创建存档配置
|
||||
var saveConfig = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save.json"
|
||||
};
|
||||
|
||||
// 注册存档仓库
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig);
|
||||
RegisterUtility<ISaveRepository<SaveData>>(saveRepo);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 列出所有存档
|
||||
|
||||
```csharp
|
||||
public async Task ShowSaveList()
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 获取所有存档槽位
|
||||
var slots = await saveRepo.ListSlotsAsync();
|
||||
|
||||
Console.WriteLine($"找到 {slots.Count} 个存档:");
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var saveData = await saveRepo.LoadAsync(slot);
|
||||
Console.WriteLine($"槽位 {slot}: {saveData.Player.Name}, " +
|
||||
$"等级 {saveData.Player.Level}, " +
|
||||
$"保存时间 {saveData.SaveTime}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 自动保存
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class AutoSaveController : IController
|
||||
{
|
||||
private CancellationTokenSource? _autoSaveCts;
|
||||
|
||||
public void StartAutoSave(int slot, TimeSpan interval)
|
||||
{
|
||||
_autoSaveCts = new CancellationTokenSource();
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (!_autoSaveCts.Token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(interval, _autoSaveCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await SaveGame(slot);
|
||||
Console.WriteLine("自动保存完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"自动保存失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}, _autoSaveCts.Token);
|
||||
}
|
||||
|
||||
public void StopAutoSave()
|
||||
{
|
||||
_autoSaveCts?.Cancel();
|
||||
_autoSaveCts?.Dispose();
|
||||
_autoSaveCts = null;
|
||||
}
|
||||
|
||||
private async Task SaveGame(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
var saveData = CreateSaveData();
|
||||
await saveRepo.SaveAsync(slot, saveData);
|
||||
}
|
||||
|
||||
private SaveData CreateSaveData()
|
||||
{
|
||||
// 从游戏状态创建存档数据
|
||||
return new SaveData();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 数据版本迁移
|
||||
|
||||
`SaveRepository<TSaveData>` 现在支持注册正式的迁移器,并在 `LoadAsync(slot)` 时自动升级旧版本存档。
|
||||
|
||||
迁移规则如下:
|
||||
|
||||
- `TSaveData` 需要实现 `IVersionedData`
|
||||
- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本
|
||||
- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转
|
||||
- 同一个 `FromVersion` 只能注册一个迁移器,且 `ToVersion` 必须严格大于 `FromVersion`
|
||||
- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档
|
||||
- 迁移器返回数据上的 `Version` 必须与声明的 `ToVersion` 一致
|
||||
- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据
|
||||
|
||||
```csharp
|
||||
public sealed class SaveData : IVersionedData
|
||||
{
|
||||
// 当前运行时代码支持的最新版本
|
||||
public int Version { get; set; } = 2;
|
||||
public string PlayerName { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SaveDataMigrationV1ToV2 : ISaveMigration<SaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public SaveData Migrate(SaveData oldData)
|
||||
{
|
||||
return new SaveData
|
||||
{
|
||||
Version = 2,
|
||||
PlayerName = oldData.PlayerName,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Level * 100,
|
||||
LastModified = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SaveModule : AbstractModule
|
||||
{
|
||||
public override void Install(IArchitecture architecture)
|
||||
{
|
||||
var storage = architecture.GetUtility<IStorage>();
|
||||
var saveConfig = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig)
|
||||
.RegisterMigration(new SaveDataMigrationV1ToV2());
|
||||
|
||||
architecture.RegisterUtility<ISaveRepository<SaveData>>(saveRepo);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SaveData> LoadGame(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
// 如果槽位里是 v1,仓库会自动迁移到 v2,并把新版本重新写回存储。
|
||||
return await saveRepo.LoadAsync(slot);
|
||||
}
|
||||
```
|
||||
|
||||
`ISaveMigration<TSaveData>` 接收和返回的是同一个存档类型。也就是说,框架提供的是“当前类型内的版本升级管线”,
|
||||
而不是跨 CLR 类型的双模型反序列化系统。如果旧版本缺失了新字段,反序列化会先使用当前类型的默认值,再由迁移器补齐。
|
||||
|
||||
### 使用数据仓库
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class SettingsController : IController
|
||||
{
|
||||
public async Task SaveSettings()
|
||||
{
|
||||
var dataRepo = this.GetUtility<IDataRepository>();
|
||||
|
||||
var settings = new GameSettings
|
||||
{
|
||||
MasterVolume = 0.8f,
|
||||
MusicVolume = 0.6f,
|
||||
SfxVolume = 0.7f
|
||||
};
|
||||
|
||||
// 定义数据位置
|
||||
var location = new DataLocation("settings", "game_settings.json");
|
||||
|
||||
// 保存设置
|
||||
await dataRepo.SaveAsync(location, settings);
|
||||
}
|
||||
|
||||
public async Task<GameSettings> LoadSettings()
|
||||
{
|
||||
var dataRepo = this.GetUtility<IDataRepository>();
|
||||
var location = new DataLocation("settings", "game_settings.json");
|
||||
|
||||
// 检查是否存在
|
||||
if (!await dataRepo.ExistsAsync(location))
|
||||
{
|
||||
return new GameSettings(); // 返回默认设置
|
||||
}
|
||||
|
||||
// 加载设置
|
||||
return await dataRepo.LoadAsync<GameSettings>(location);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 批量保存数据
|
||||
|
||||
```csharp
|
||||
public async Task SaveAllGameData()
|
||||
{
|
||||
var dataRepo = this.GetUtility<IDataRepository>();
|
||||
|
||||
var dataList = new List<(IDataLocation, IData)>
|
||||
{
|
||||
(new DataLocation("player", "profile.json"), playerData),
|
||||
(new DataLocation("inventory", "items.json"), inventoryData),
|
||||
(new DataLocation("quests", "progress.json"), questData)
|
||||
};
|
||||
|
||||
// 批量保存
|
||||
await dataRepo.SaveAllAsync(dataList);
|
||||
Console.WriteLine("所有数据已保存");
|
||||
}
|
||||
```
|
||||
|
||||
`SaveAllAsync(...)` 的事件语义和逐项调用 `SaveAsync(...)` 不同。它代表一次显式的批量提交,因此适合让监听器在收到 `DataBatchSavedEvent` 时统一刷新 UI、缓存或元数据,而不是对每个条目单独响应。
|
||||
|
||||
### 聚合设置仓库
|
||||
|
||||
如果你希望把设置统一保存到单个文件中,可以使用 `UnifiedSettingsDataRepository`:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Data;
|
||||
using GFramework.Game.Serializer;
|
||||
using GFramework.Game.Storage;
|
||||
|
||||
public sealed class GameArchitecture : Architecture
|
||||
{
|
||||
protected override void Init()
|
||||
var serializer = new JsonSerializer();
|
||||
var storage = new FileStorage("GameData", serializer, ".json");
|
||||
|
||||
ISettingsDataRepository settingsRepository = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
serializer,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
var serializer = new JsonSerializer();
|
||||
BasePath = "settings",
|
||||
AutoBackup = true
|
||||
});
|
||||
|
||||
var settingsRepo = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
serializer,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
AutoBackup = true,
|
||||
EnableEvents = true
|
||||
},
|
||||
"settings.json");
|
||||
|
||||
settingsRepo.RegisterDataType(new DataLocation("settings", "graphics"), typeof(GraphicsSettings));
|
||||
settingsRepo.RegisterDataType(new DataLocation("settings", "audio"), typeof(AudioSettings));
|
||||
|
||||
RegisterUtility<ISettingsDataRepository>(settingsRepo);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个实现依然满足 `IDataRepository` 的通用契约,但有两个实现层面的差异需要明确:
|
||||
|
||||
- 它把所有设置 section 缓存在内存中,并在保存或删除时整文件回写
|
||||
- 开启自动备份时,备份的是整个 `settings.json` 文件,因此适合做“上一次完整设置快照”的恢复,而不是 section 级别回滚
|
||||
|
||||
如果你需要 `LoadAllAsync()`,或者希望在同一个统一文件里混合反序列化多个 section,必须先为每个 section 注册类型:
|
||||
|
||||
```csharp
|
||||
public async Task PrintSettingsSnapshot()
|
||||
var saveConfiguration = new SaveConfiguration
|
||||
{
|
||||
var repo = this.GetUtility<ISettingsDataRepository>();
|
||||
|
||||
var all = await repo.LoadAllAsync();
|
||||
|
||||
var graphics = (GraphicsSettings)all["graphics"];
|
||||
var audio = (AudioSettings)all["audio"];
|
||||
|
||||
Console.WriteLine($"Resolution: {graphics.ResolutionWidth}x{graphics.ResolutionHeight}");
|
||||
Console.WriteLine($"MasterVolume: {audio.MasterVolume}");
|
||||
}
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save.json"
|
||||
};
|
||||
```
|
||||
|
||||
最小采用要求:
|
||||
分工应保持清晰:
|
||||
|
||||
- 项目需要可用的 `IStorage`
|
||||
- 项目需要一个可用的序列化器实例,例如 `GFramework.Game.Serializer.JsonSerializer`
|
||||
- 在注册仓库时,把所有需要参与 `LoadAllAsync()` 或混合 section 反序列化的 location/type 对显式调用一次 `RegisterDataType(...)`
|
||||
- `storage` 只负责底层文件读写
|
||||
- `settingsRepository` 负责统一设置文件
|
||||
- `SaveRepository<TSaveData>` 负责槽位目录和存档迁移
|
||||
|
||||
兼容性说明:
|
||||
## 当前边界
|
||||
|
||||
- 现在 `UnifiedSettingsDataRepository.LoadAsync<T>()` 发送的是 `DataLoadedEvent<T>`,而不是 `DataLoadedEvent<IData>`
|
||||
- 如果你之前监听的是 `DataLoadedEvent<IData>`,需要改成订阅具体类型,例如 `DataLoadedEvent<GraphicsSettings>` 或 `DataLoadedEvent<AudioSettings>`
|
||||
- `DataRepositoryOptions` 描述的是仓库公开行为契约,不是某一种固定落盘格式
|
||||
- `UnifiedSettingsDataRepository` 不是通用万能仓库,它专门服务“多 section 聚合单文件”的场景
|
||||
- `SaveRepository<TSaveData>` 不负责业务层的 autosave 策略、云同步或存档选择 UI
|
||||
- `LoadAsync(...)` 返回新实例的行为适合默认启动路径;如果项目需要“缺档即报错”,应在业务层显式调用 `ExistsAsync(...)`
|
||||
|
||||
### 存档备份
|
||||
## 继续阅读
|
||||
|
||||
```csharp
|
||||
public async Task BackupSave(int slot)
|
||||
{
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
if (!await saveRepo.ExistsAsync(slot))
|
||||
{
|
||||
Console.WriteLine("存档不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载原存档
|
||||
var saveData = await saveRepo.LoadAsync(slot);
|
||||
|
||||
// 保存到备份槽位
|
||||
int backupSlot = slot + 100;
|
||||
await saveRepo.SaveAsync(backupSlot, saveData);
|
||||
|
||||
Console.WriteLine($"存档已备份到槽位 {backupSlot}");
|
||||
}
|
||||
|
||||
public async Task RestoreBackup(int slot)
|
||||
{
|
||||
int backupSlot = slot + 100;
|
||||
var saveRepo = this.GetUtility<ISaveRepository<SaveData>>();
|
||||
|
||||
if (!await saveRepo.ExistsAsync(backupSlot))
|
||||
{
|
||||
Console.WriteLine("备份不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载备份
|
||||
var backupData = await saveRepo.LoadAsync(backupSlot);
|
||||
|
||||
// 恢复到原槽位
|
||||
await saveRepo.SaveAsync(slot, backupData);
|
||||
|
||||
Console.WriteLine($"已从备份恢复到槽位 {slot}");
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用版本化数据**:为存档数据实现 `IVersionedData`
|
||||
```csharp
|
||||
✓ public class SaveData : IVersionedData { public int Version { get; set; } = 1; }
|
||||
✗ public class SaveData : IData { } // 无法进行版本管理
|
||||
```
|
||||
|
||||
2. **定期自动保存**:避免玩家数据丢失
|
||||
```csharp
|
||||
// 每 5 分钟自动保存
|
||||
StartAutoSave(currentSlot, TimeSpan.FromMinutes(5));
|
||||
```
|
||||
|
||||
3. **保存前验证数据**:确保数据完整性
|
||||
```csharp
|
||||
public async Task SaveGame(int slot)
|
||||
{
|
||||
var saveData = CreateSaveData();
|
||||
|
||||
if (!ValidateSaveData(saveData))
|
||||
{
|
||||
throw new InvalidOperationException("存档数据无效");
|
||||
}
|
||||
|
||||
await saveRepo.SaveAsync(slot, saveData);
|
||||
}
|
||||
```
|
||||
|
||||
4. **处理保存失败**:使用 try-catch 捕获异常
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
await saveRepo.SaveAsync(slot, saveData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"保存失败: {ex.Message}");
|
||||
ShowErrorMessage("保存失败,请重试");
|
||||
}
|
||||
```
|
||||
|
||||
5. **提供多个存档槽位**:让玩家可以管理多个存档
|
||||
```csharp
|
||||
// 支持 10 个存档槽位
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
if (await saveRepo.ExistsAsync(i))
|
||||
{
|
||||
ShowSaveSlot(i);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **在关键时刻保存**:场景切换、关卡完成等
|
||||
```csharp
|
||||
public async Task OnLevelComplete()
|
||||
{
|
||||
// 关卡完成时自动保存
|
||||
await SaveGame(currentSlot);
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题:如何实现多个存档槽位?
|
||||
|
||||
**解答**:
|
||||
使用 `ISaveRepository<T>` 的槽位参数:
|
||||
|
||||
```csharp
|
||||
// 保存到不同槽位
|
||||
await saveRepo.SaveAsync(1, saveData); // 槽位 1
|
||||
await saveRepo.SaveAsync(2, saveData); // 槽位 2
|
||||
await saveRepo.SaveAsync(3, saveData); // 槽位 3
|
||||
```
|
||||
|
||||
### 问题:如何处理数据版本升级?
|
||||
|
||||
**解答**:
|
||||
实现 `IVersionedData`,并在仓库初始化阶段注册 `ISaveMigration<TSaveData>`。之后 `LoadAsync(slot)` 会自动执行迁移并回写:
|
||||
|
||||
```csharp
|
||||
var saveRepo = new SaveRepository<SaveData>(storage, saveConfig)
|
||||
.RegisterMigration(new SaveDataMigrationV1ToV2())
|
||||
.RegisterMigration(new SaveDataMigrationV2ToV3());
|
||||
|
||||
var data = await saveRepo.LoadAsync(slot);
|
||||
```
|
||||
|
||||
### 问题:存档数据保存在哪里?
|
||||
|
||||
**解答**:
|
||||
由存储系统决定,通常在:
|
||||
|
||||
- Windows: `%AppData%/GameName/saves/`
|
||||
- Linux: `~/.local/share/GameName/saves/`
|
||||
- macOS: `~/Library/Application Support/GameName/saves/`
|
||||
|
||||
### 问题:如何实现云存档?
|
||||
|
||||
**解答**:
|
||||
实现自定义的 `IStorage`,将数据保存到云端:
|
||||
|
||||
```csharp
|
||||
public class CloudStorage : IStorage
|
||||
{
|
||||
public async Task WriteAsync(string path, byte[] data)
|
||||
{
|
||||
await UploadToCloud(path, data);
|
||||
}
|
||||
|
||||
public async Task<byte[]> ReadAsync(string path)
|
||||
{
|
||||
return await DownloadFromCloud(path);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何加密存档数据?
|
||||
|
||||
**解答**:
|
||||
在保存和加载时进行加密/解密:
|
||||
|
||||
```csharp
|
||||
public async Task SaveEncrypted(int slot, SaveData data)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(data);
|
||||
var encrypted = Encrypt(json);
|
||||
await storage.WriteAsync(path, encrypted);
|
||||
}
|
||||
|
||||
public async Task<SaveData> LoadEncrypted(int slot)
|
||||
{
|
||||
var encrypted = await storage.ReadAsync(path);
|
||||
var json = Decrypt(encrypted);
|
||||
return JsonSerializer.Deserialize<SaveData>(json);
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:存档损坏怎么办?
|
||||
|
||||
**解答**:
|
||||
实现备份和恢复机制:
|
||||
|
||||
```csharp
|
||||
public async Task SaveWithBackup(int slot, SaveData data)
|
||||
{
|
||||
// 先备份旧存档
|
||||
if (await saveRepo.ExistsAsync(slot))
|
||||
{
|
||||
var oldData = await saveRepo.LoadAsync(slot);
|
||||
await saveRepo.SaveAsync(slot + 100, oldData);
|
||||
}
|
||||
|
||||
// 保存新存档
|
||||
await saveRepo.SaveAsync(slot, data);
|
||||
}
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [设置系统](/zh-CN/game/setting) - 游戏设置管理
|
||||
- [场景系统](/zh-CN/game/scene) - 场景切换时保存
|
||||
- [存档系统实现教程](/zh-CN/tutorials/save-system) - 完整示例
|
||||
- [Godot 集成](/zh-CN/godot/index) - Godot 中的数据管理
|
||||
1. [设置系统](./setting.md)
|
||||
2. [存储系统](./storage.md)
|
||||
3. [序列化系统](./serialization.md)
|
||||
4. [Game 入口](./index.md)
|
||||
|
||||
@ -1,811 +1,162 @@
|
||||
---
|
||||
title: 序列化系统
|
||||
description: 序列化系统提供了统一的对象序列化和反序列化接口,支持 JSON 格式和运行时类型处理。
|
||||
description: 以当前 GFramework.Game.JsonSerializer 与 JsonSerializerTests 为准,说明 JSON 序列化器的配置生命周期和使用边界。
|
||||
---
|
||||
|
||||
# 序列化系统
|
||||
|
||||
## 概述
|
||||
`GFramework.Game` 当前在序列化这一层的默认公开入口只有 `JsonSerializer`。
|
||||
|
||||
序列化系统是 GFramework.Game 中用于对象序列化和反序列化的核心组件。它提供了统一的序列化接口,支持将对象转换为字符串格式(如
|
||||
JSON)进行存储或传输,并能够将字符串数据还原为对象。
|
||||
它实现的是:
|
||||
|
||||
序列化系统与数据存储、配置管理、存档系统等模块深度集成,为游戏数据的持久化提供了基础支持。
|
||||
- `ISerializer`
|
||||
- `IRuntimeTypeSerializer`
|
||||
|
||||
**主要特性**:
|
||||
它不负责:
|
||||
|
||||
- 统一的序列化接口
|
||||
- JSON 格式支持
|
||||
- 运行时类型序列化
|
||||
- 泛型和非泛型 API
|
||||
- 与存储系统无缝集成
|
||||
- 类型安全的反序列化
|
||||
- schema 驱动配置生成
|
||||
- 存档槽位管理
|
||||
- 文件路径或目录布局
|
||||
|
||||
## 核心概念
|
||||
这些能力分别属于 source generator、repository 和 storage。
|
||||
|
||||
### 序列化器接口
|
||||
## 当前公开入口
|
||||
|
||||
`ISerializer` 定义了基本的序列化操作:
|
||||
### `JsonSerializer`
|
||||
|
||||
```csharp
|
||||
public interface ISerializer : IUtility
|
||||
{
|
||||
// 将对象序列化为字符串
|
||||
string Serialize<T>(T value);
|
||||
|
||||
// 将字符串反序列化为对象
|
||||
T Deserialize<T>(string data);
|
||||
}
|
||||
```
|
||||
|
||||
### 运行时类型序列化器
|
||||
|
||||
`IRuntimeTypeSerializer` 扩展了基本接口,支持运行时类型处理:
|
||||
|
||||
```csharp
|
||||
public interface IRuntimeTypeSerializer : ISerializer
|
||||
{
|
||||
// 使用运行时类型序列化对象
|
||||
string Serialize(object obj, Type type);
|
||||
|
||||
// 使用运行时类型反序列化对象
|
||||
object Deserialize(string data, Type type);
|
||||
}
|
||||
```
|
||||
|
||||
### JSON 序列化器
|
||||
|
||||
`JsonSerializer` 是基于 Newtonsoft.Json 的实现。它会直接复用构造时提供的
|
||||
`JsonSerializerSettings` 与 `Converters` 集合,因此推荐在组合根统一完成配置,再把同一个实例注册到架构或仓库中复用:
|
||||
|
||||
```csharp
|
||||
public sealed class JsonSerializer : IRuntimeTypeSerializer
|
||||
{
|
||||
string Serialize<T>(T value);
|
||||
T Deserialize<T>(string data);
|
||||
string Serialize(object obj, Type type);
|
||||
object Deserialize(string data, Type type);
|
||||
}
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 注册序列化器
|
||||
|
||||
在架构中注册序列化器:
|
||||
`JsonSerializer` 基于 `Newtonsoft.Json`,既支持泛型 API,也支持运行时类型 API:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Serializer;
|
||||
using GFramework.Game.Serializer;
|
||||
|
||||
public class GameArchitecture : Architecture
|
||||
{
|
||||
protected override void Init()
|
||||
{
|
||||
// 在启动阶段一次性完成配置,后续将该实例视为只读
|
||||
var jsonSerializer = new JsonSerializer();
|
||||
jsonSerializer.Converters.Add(new PlayerDataJsonConverter());
|
||||
|
||||
RegisterUtility<ISerializer>(jsonSerializer);
|
||||
RegisterUtility<IRuntimeTypeSerializer>(jsonSerializer);
|
||||
}
|
||||
}
|
||||
ISerializer serializer = new JsonSerializer();
|
||||
IRuntimeTypeSerializer runtimeSerializer = new JsonSerializer();
|
||||
```
|
||||
|
||||
### 序列化对象
|
||||
当前测试覆盖的核心行为包括:
|
||||
|
||||
使用泛型 API 序列化对象:
|
||||
- 普通对象可正常 round-trip
|
||||
- 注入的 `JsonSerializerSettings` 会直接生效
|
||||
- `Settings` 与 `Converters` 暴露的是同一个活动配置实例
|
||||
- 运行时类型序列化 / 反序列化可处理 `object + Type`
|
||||
- 非法 JSON 会抛出带目标类型上下文的 `InvalidOperationException`
|
||||
- 非法参数(例如空字符串)会保留 `ArgumentException`
|
||||
- 运行时类型序列化允许 `null`,输出 `"null"`
|
||||
|
||||
## 配置生命周期
|
||||
|
||||
这部分是当前实现最容易被旧文档说错的地方。
|
||||
|
||||
`JsonSerializer` 不会复制你传入的 `JsonSerializerSettings`。它会直接持有并复用这份实例,以及里面的 `Converters` 集合。
|
||||
|
||||
这意味着推荐模式是:
|
||||
|
||||
1. 在组合根创建序列化器
|
||||
2. 一次性完成 settings / converters 配置
|
||||
3. 再把同一个实例注册给存储、repository 或 architecture
|
||||
|
||||
推荐写法:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
public class PlayerData
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; }
|
||||
}
|
||||
|
||||
[ContextAware]
|
||||
public partial class SaveController : IController
|
||||
{
|
||||
public void SavePlayer()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
|
||||
var player = new PlayerData
|
||||
{
|
||||
Name = "Player1",
|
||||
Level = 10,
|
||||
Experience = 1000
|
||||
};
|
||||
|
||||
// 序列化为 JSON 字符串
|
||||
string json = serializer.Serialize(player);
|
||||
Console.WriteLine(json);
|
||||
// 输出: {"Name":"Player1","Level":10,"Experience":1000}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 反序列化对象
|
||||
|
||||
从字符串还原对象:
|
||||
|
||||
```csharp
|
||||
public void LoadPlayer()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
|
||||
string json = "{\"Name\":\"Player1\",\"Level\":10,\"Experience\":1000}";
|
||||
|
||||
// 反序列化为对象
|
||||
var player = serializer.Deserialize<PlayerData>(json);
|
||||
|
||||
Console.WriteLine($"玩家: {player.Name}, 等级: {player.Level}");
|
||||
}
|
||||
```
|
||||
|
||||
### 运行时类型序列化
|
||||
|
||||
处理不确定类型的对象:
|
||||
|
||||
```csharp
|
||||
public void SerializeRuntimeType()
|
||||
{
|
||||
var serializer = this.GetUtility<IRuntimeTypeSerializer>();
|
||||
|
||||
object data = new PlayerData { Name = "Player1", Level = 10 };
|
||||
Type dataType = data.GetType();
|
||||
|
||||
// 使用运行时类型序列化
|
||||
string json = serializer.Serialize(data, dataType);
|
||||
|
||||
// 使用运行时类型反序列化
|
||||
object restored = serializer.Deserialize(json, dataType);
|
||||
|
||||
var player = restored as PlayerData;
|
||||
Console.WriteLine($"玩家: {player?.Name}");
|
||||
}
|
||||
```
|
||||
|
||||
### 配置生命周期约束
|
||||
|
||||
`JsonSerializer` 不会复制 `JsonSerializerSettings`。这意味着:
|
||||
|
||||
- 传给构造函数的 settings 会被原样保留
|
||||
- `serializer.Settings` 与 `serializer.Converters` 返回的都是活动配置对象
|
||||
- 一旦序列化器实例已经注册给其他模块或开始被并发调用,就不应继续修改这些配置
|
||||
|
||||
推荐模式:
|
||||
|
||||
```csharp
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
settings.Converters.Add(new Vector2JsonConverter());
|
||||
settings.Converters.Add(new CoordinateConverter());
|
||||
|
||||
var serializer = new JsonSerializer(settings);
|
||||
```
|
||||
|
||||
不推荐写法:
|
||||
|
||||
```csharp
|
||||
var serializer = architecture.GetUtility<IRuntimeTypeSerializer>();
|
||||
|
||||
// 序列化器已经被多个组件共享后,再继续改 converter,容易让并发调用看到不稳定配置。
|
||||
((JsonSerializer)serializer).Converters.Add(new LateBoundConverter());
|
||||
```
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
### 作为底层 serializer 注册
|
||||
|
||||
当前更常见的采用方式不是“业务代码直接到处调 serializer”,而是把它注册给存储和 repository 复用:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Serializer;
|
||||
using GFramework.Game.Serializer;
|
||||
|
||||
var serializer = new JsonSerializer();
|
||||
|
||||
architecture.RegisterUtility<ISerializer>(serializer);
|
||||
architecture.RegisterUtility<IRuntimeTypeSerializer>(serializer);
|
||||
```
|
||||
|
||||
不推荐模式:
|
||||
然后由:
|
||||
|
||||
- `FileStorage`
|
||||
- `UnifiedSettingsDataRepository`
|
||||
- 其他依赖 `ISerializer` / `IRuntimeTypeSerializer` 的组件
|
||||
|
||||
统一复用这一份实例。
|
||||
|
||||
### 直接处理运行时类型
|
||||
|
||||
当业务层拿到的是 `object + Type` 组合,而不是静态泛型类型时,再使用运行时 API:
|
||||
|
||||
```csharp
|
||||
var serializer = architecture.GetUtility<IRuntimeTypeSerializer>();
|
||||
var serializer = new JsonSerializer();
|
||||
|
||||
// 已经共享给运行时后再修改配置,容易让并发调用得到不稳定行为
|
||||
((JsonSerializer)serializer).Converters.Add(new LateBoundConverter());
|
||||
object data = new PlayerState
|
||||
{
|
||||
Name = "Runtime",
|
||||
Level = 11
|
||||
};
|
||||
|
||||
var json = serializer.Serialize(data, data.GetType());
|
||||
var restored = serializer.Deserialize(json, data.GetType());
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
## 与存储系统的关系
|
||||
|
||||
### 与存储系统集成
|
||||
`FileStorage` 已经会调用注入的 `ISerializer` 自己完成对象读写,因此当前默认接法里:
|
||||
|
||||
序列化器与存储系统配合使用:
|
||||
- 你可以直接 `storage.WriteAsync("profile/player", profile)`
|
||||
- 不需要先手工 `serializer.Serialize(profile)` 再把字符串写回存储
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Game.Storage;
|
||||
using GFramework.Core.SourceGenerators.Abstractions.Rule;
|
||||
手工显式调用 `Serialize(...)` 更适合这些场景:
|
||||
|
||||
[ContextAware]
|
||||
public partial class DataManager : IController
|
||||
{
|
||||
public async Task SaveData()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
- 需要把 JSON 发到网络或日志
|
||||
- 需要和外部文本格式做中转
|
||||
- 需要直接调试序列化输出内容
|
||||
|
||||
var gameData = new GameData
|
||||
{
|
||||
Score = 1000,
|
||||
Coins = 500
|
||||
};
|
||||
如果目标只是本地持久化,优先让 `IStorage` / repository 复用 serializer。
|
||||
|
||||
// 序列化数据
|
||||
string json = serializer.Serialize(gameData);
|
||||
## 与配置系统的关系
|
||||
|
||||
// 写入存储
|
||||
await storage.WriteAsync("game_data", json);
|
||||
}
|
||||
不要把 `JsonSerializer` 和 `Game` 的 YAML 配置系统混在一起:
|
||||
|
||||
public async Task<GameData> LoadData()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
- `JsonSerializer`
|
||||
- 负责运行时对象 JSON 序列化
|
||||
- `Game.SourceGenerators + YamlConfigLoader`
|
||||
- 负责 schema 驱动的配置表生成与 YAML 读取
|
||||
|
||||
// 从存储读取
|
||||
string json = await storage.ReadAsync<string>("game_data");
|
||||
如果你的目标是静态内容配置表,而不是运行时持久化对象,请改看 [`config-system.md`](./config-system.md)。
|
||||
|
||||
// 反序列化数据
|
||||
return serializer.Deserialize<GameData>(json);
|
||||
}
|
||||
}
|
||||
```
|
||||
## 当前边界
|
||||
|
||||
### 序列化复杂对象
|
||||
- 当前公开默认实现只有 JSON,没有内建 MessagePack、Binary 或 ProtoBuf 实现
|
||||
- `JsonSerializer` 负责序列化,不负责对象版本迁移;版本迁移属于 `SettingsModel<TRepository>` 或 `SaveRepository<TSaveData>`
|
||||
- 序列化器共享后应视为只读配置对象,避免在运行期继续修改 settings / converters
|
||||
|
||||
处理嵌套和集合类型:
|
||||
## 继续阅读
|
||||
|
||||
```csharp
|
||||
public class InventoryData
|
||||
{
|
||||
public List<ItemData> Items { get; set; }
|
||||
public Dictionary<string, int> Resources { get; set; }
|
||||
}
|
||||
|
||||
public class ItemData
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
|
||||
public void SerializeComplexData()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
|
||||
var inventory = new InventoryData
|
||||
{
|
||||
Items = new List<ItemData>
|
||||
{
|
||||
new ItemData { Id = "sword_01", Name = "铁剑", Quantity = 1 },
|
||||
new ItemData { Id = "potion_hp", Name = "生命药水", Quantity = 5 }
|
||||
},
|
||||
Resources = new Dictionary<string, int>
|
||||
{
|
||||
{ "gold", 1000 },
|
||||
{ "wood", 500 }
|
||||
}
|
||||
};
|
||||
|
||||
// 序列化复杂对象
|
||||
string json = serializer.Serialize(inventory);
|
||||
|
||||
// 反序列化
|
||||
var restored = serializer.Deserialize<InventoryData>(json);
|
||||
|
||||
Console.WriteLine($"物品数量: {restored.Items.Count}");
|
||||
Console.WriteLine($"金币: {restored.Resources["gold"]}");
|
||||
}
|
||||
```
|
||||
|
||||
### 处理多态类型
|
||||
|
||||
序列化继承层次结构:
|
||||
|
||||
```csharp
|
||||
public abstract class EntityData
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerEntityData : EntityData
|
||||
{
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; }
|
||||
}
|
||||
|
||||
public class EnemyEntityData : EntityData
|
||||
{
|
||||
public int Health { get; set; }
|
||||
public int Damage { get; set; }
|
||||
}
|
||||
|
||||
public void SerializePolymorphic()
|
||||
{
|
||||
var serializer = this.GetUtility<IRuntimeTypeSerializer>();
|
||||
|
||||
// 创建不同类型的实体
|
||||
EntityData player = new PlayerEntityData
|
||||
{
|
||||
Id = "player_1",
|
||||
Type = "Player",
|
||||
Level = 10,
|
||||
Experience = 1000
|
||||
};
|
||||
|
||||
EntityData enemy = new EnemyEntityData
|
||||
{
|
||||
Id = "enemy_1",
|
||||
Type = "Enemy",
|
||||
Health = 100,
|
||||
Damage = 20
|
||||
};
|
||||
|
||||
// 使用运行时类型序列化
|
||||
string playerJson = serializer.Serialize(player, player.GetType());
|
||||
string enemyJson = serializer.Serialize(enemy, enemy.GetType());
|
||||
|
||||
// 根据类型反序列化
|
||||
var restoredPlayer = serializer.Deserialize(playerJson, typeof(PlayerEntityData));
|
||||
var restoredEnemy = serializer.Deserialize(enemyJson, typeof(EnemyEntityData));
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义序列化逻辑
|
||||
|
||||
虽然 GFramework 使用 Newtonsoft.Json,但你可以通过特性控制序列化行为:
|
||||
|
||||
```csharp
|
||||
using Newtonsoft.Json;
|
||||
|
||||
public class CustomData
|
||||
{
|
||||
// 忽略此属性
|
||||
[JsonIgnore]
|
||||
public string InternalId { get; set; }
|
||||
|
||||
// 使用不同的属性名
|
||||
[JsonProperty("player_name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
// 仅在值不为 null 时序列化
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string? OptionalField { get; set; }
|
||||
|
||||
// 格式化日期
|
||||
[JsonProperty("created_at")]
|
||||
[JsonConverter(typeof(IsoDateTimeConverter))]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 批量序列化
|
||||
|
||||
处理多个对象的序列化:
|
||||
|
||||
```csharp
|
||||
public async Task SaveMultipleData()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
var dataList = new Dictionary<string, object>
|
||||
{
|
||||
{ "player", new PlayerData { Name = "Player1", Level = 10 } },
|
||||
{ "inventory", new InventoryData { Items = new List<ItemData>() } },
|
||||
{ "settings", new SettingsData { Volume = 0.8f } }
|
||||
};
|
||||
|
||||
// 批量序列化和保存
|
||||
foreach (var (key, data) in dataList)
|
||||
{
|
||||
string json = serializer.Serialize(data);
|
||||
await storage.WriteAsync(key, json);
|
||||
}
|
||||
|
||||
Console.WriteLine($"已保存 {dataList.Count} 个数据文件");
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
处理序列化和反序列化错误:
|
||||
|
||||
```csharp
|
||||
public void SafeDeserialize()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
|
||||
string json = "{\"Name\":\"Player1\",\"Level\":\"invalid\"}"; // 错误的数据
|
||||
|
||||
try
|
||||
{
|
||||
var player = serializer.Deserialize<PlayerData>(json);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
Console.WriteLine($"反序列化失败: {ex.Message}");
|
||||
// 返回默认值或重新尝试
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.WriteLine($"JSON 格式错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerData DeserializeWithFallback(string json)
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
|
||||
try
|
||||
{
|
||||
return serializer.Deserialize<PlayerData>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 返回默认数据
|
||||
return new PlayerData
|
||||
{
|
||||
Name = "DefaultPlayer",
|
||||
Level = 1,
|
||||
Experience = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 版本兼容性
|
||||
|
||||
处理数据结构变化:
|
||||
|
||||
```csharp
|
||||
// 旧版本数据
|
||||
public class PlayerDataV1
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Level { get; set; }
|
||||
}
|
||||
|
||||
// 新版本数据(添加了新字段)
|
||||
public class PlayerDataV2
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int Experience { get; set; } = 0; // 新增字段,提供默认值
|
||||
public DateTime LastLogin { get; set; } = DateTime.Now; // 新增字段
|
||||
}
|
||||
|
||||
public PlayerDataV2 LoadWithMigration(string json)
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
|
||||
try
|
||||
{
|
||||
// 尝试加载新版本
|
||||
return serializer.Deserialize<PlayerDataV2>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果失败,尝试加载旧版本并迁移
|
||||
var oldData = serializer.Deserialize<PlayerDataV1>(json);
|
||||
return new PlayerDataV2
|
||||
{
|
||||
Name = oldData.Name,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Level * 100, // 根据等级计算经验
|
||||
LastLogin = DateTime.Now
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **优先依赖接口,在组合根统一实例化**:业务代码依赖 `ISerializer`,具体 `JsonSerializer` 在启动阶段配置一次即可
|
||||
```csharp
|
||||
✓ var serializer = this.GetUtility<ISerializer>();
|
||||
✓ var serializer = new JsonSerializer(settings); // 仅在组合根/启动阶段配置
|
||||
✗ var serializer = new JsonSerializer(); // 避免在业务逻辑中临时创建
|
||||
```
|
||||
|
||||
2. **为数据类提供默认值**:确保反序列化的健壮性
|
||||
```csharp
|
||||
public class GameData
|
||||
{
|
||||
public string Name { get; set; } = "Default";
|
||||
public int Score { get; set; } = 0;
|
||||
public List<string> Items { get; set; } = new();
|
||||
}
|
||||
```
|
||||
|
||||
3. **处理反序列化异常**:避免程序崩溃
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var data = serializer.Deserialize<GameData>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"反序列化失败: {ex.Message}");
|
||||
return GetDefaultData();
|
||||
}
|
||||
```
|
||||
|
||||
4. **避免序列化敏感数据**:使用 `[JsonIgnore]` 标记
|
||||
```csharp
|
||||
public class UserData
|
||||
{
|
||||
public string Username { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string Password { get; set; } // 不序列化密码
|
||||
}
|
||||
```
|
||||
|
||||
5. **使用运行时类型处理多态**:保持类型信息
|
||||
```csharp
|
||||
var serializer = this.GetUtility<IRuntimeTypeSerializer>();
|
||||
string json = serializer.Serialize(obj, obj.GetType());
|
||||
```
|
||||
|
||||
6. **验证反序列化的数据**:确保数据完整性
|
||||
```csharp
|
||||
var data = serializer.Deserialize<GameData>(json);
|
||||
if (string.IsNullOrEmpty(data.Name) || data.Score < 0)
|
||||
{
|
||||
throw new InvalidDataException("数据验证失败");
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 减少序列化开销
|
||||
|
||||
```csharp
|
||||
// 避免频繁序列化大对象
|
||||
public class CachedSerializer
|
||||
{
|
||||
private string? _cachedJson;
|
||||
private GameData? _cachedData;
|
||||
|
||||
public string GetJson(GameData data)
|
||||
{
|
||||
if (_cachedData == data && _cachedJson != null)
|
||||
{
|
||||
return _cachedJson;
|
||||
}
|
||||
|
||||
var serializer = GetSerializer();
|
||||
_cachedJson = serializer.Serialize(data);
|
||||
_cachedData = data;
|
||||
return _cachedJson;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 异步序列化
|
||||
|
||||
```csharp
|
||||
public async Task SaveDataAsync()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
var data = GetLargeData();
|
||||
|
||||
// 在后台线程序列化
|
||||
string json = await Task.Run(() => serializer.Serialize(data));
|
||||
|
||||
// 异步写入存储
|
||||
await storage.WriteAsync("large_data", json);
|
||||
}
|
||||
```
|
||||
|
||||
### 分块序列化
|
||||
|
||||
```csharp
|
||||
public async Task SaveLargeDataset()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
var largeDataset = GetLargeDataset();
|
||||
|
||||
// 分块保存
|
||||
const int chunkSize = 100;
|
||||
for (int i = 0; i < largeDataset.Count; i += chunkSize)
|
||||
{
|
||||
var chunk = largeDataset.Skip(i).Take(chunkSize).ToList();
|
||||
string json = serializer.Serialize(chunk);
|
||||
await storage.WriteAsync($"data_chunk_{i / chunkSize}", json);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题:如何序列化循环引用的对象?
|
||||
|
||||
**解答**:
|
||||
Newtonsoft.Json 默认不支持循环引用,需要配置:
|
||||
|
||||
```csharp
|
||||
// 注意:GFramework 的 JsonSerializer 使用默认设置
|
||||
// 如需处理循环引用,避免创建循环引用的数据结构
|
||||
// 或使用 [JsonIgnore] 打破循环
|
||||
|
||||
public class Node
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public List<Node> Children { get; set; }
|
||||
|
||||
[JsonIgnore] // 忽略父节点引用,避免循环
|
||||
public Node? Parent { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:序列化后的 JSON 太大怎么办?
|
||||
|
||||
**解答**:
|
||||
使用压缩或分块存储:
|
||||
|
||||
```csharp
|
||||
public async Task SaveCompressed()
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
var data = GetLargeData();
|
||||
string json = serializer.Serialize(data);
|
||||
|
||||
// 压缩 JSON
|
||||
byte[] compressed = Compress(json);
|
||||
|
||||
// 保存压缩数据
|
||||
await storage.WriteAsync("data_compressed", compressed);
|
||||
}
|
||||
|
||||
private byte[] Compress(string text)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionMode.Compress))
|
||||
using (var writer = new StreamWriter(gzip))
|
||||
{
|
||||
writer.Write(text);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何处理不同平台的序列化差异?
|
||||
|
||||
**解答**:
|
||||
使用平台无关的数据类型:
|
||||
|
||||
```csharp
|
||||
public class CrossPlatformData
|
||||
{
|
||||
// 使用 string 而非 DateTime(避免时区问题)
|
||||
public string CreatedAt { get; set; } = DateTime.UtcNow.ToString("O");
|
||||
|
||||
// 使用 double 而非 float(精度一致)
|
||||
public double Score { get; set; }
|
||||
|
||||
// 明确指定编码
|
||||
public string Text { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:反序列化失败时如何恢复?
|
||||
|
||||
**解答**:
|
||||
实现备份和恢复机制:
|
||||
|
||||
```csharp
|
||||
public async Task<GameData> LoadWithBackup(string key)
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
try
|
||||
{
|
||||
// 尝试加载主数据
|
||||
string json = await storage.ReadAsync<string>(key);
|
||||
return serializer.Deserialize<GameData>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 尝试加载备份
|
||||
try
|
||||
{
|
||||
string backupJson = await storage.ReadAsync<string>($"{key}_backup");
|
||||
return serializer.Deserialize<GameData>(backupJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 返回默认数据
|
||||
return new GameData();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何加密序列化的数据?
|
||||
|
||||
**解答**:
|
||||
在序列化后加密:
|
||||
|
||||
```csharp
|
||||
public async Task SaveEncrypted(string key, GameData data)
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
// 序列化
|
||||
string json = serializer.Serialize(data);
|
||||
|
||||
// 加密
|
||||
byte[] encrypted = EncryptString(json);
|
||||
|
||||
// 保存
|
||||
await storage.WriteAsync(key, encrypted);
|
||||
}
|
||||
|
||||
public async Task<GameData> LoadEncrypted(string key)
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var storage = this.GetUtility<IStorage>();
|
||||
|
||||
// 读取
|
||||
byte[] encrypted = await storage.ReadAsync<byte[]>(key);
|
||||
|
||||
// 解密
|
||||
string json = DecryptToString(encrypted);
|
||||
|
||||
// 反序列化
|
||||
return serializer.Deserialize<GameData>(json);
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:序列化器是线程安全的吗?
|
||||
|
||||
**解答**:
|
||||
`JsonSerializer` 的并发读行为只在“配置不再变化”这个前提下才安全。当前实现会暴露活动中的
|
||||
`JsonSerializerSettings` 与 `Converters` 集合,因此:
|
||||
|
||||
- 可以在启动阶段创建并配置一个共享实例
|
||||
- 可以在配置冻结后把该实例注册为 Utility 或注入到仓库
|
||||
- 不应在序列化器已经被多个调用方使用时继续修改 settings、contract resolver 或 converters
|
||||
|
||||
推荐按下面的方式在启动阶段完成配置,然后只做读操作:
|
||||
|
||||
```csharp
|
||||
// 启动阶段完成全部配置
|
||||
var serializer = new JsonSerializer(new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
serializer.Converters.Add(new GameDataJsonConverter());
|
||||
|
||||
architecture.RegisterUtility<ISerializer>(serializer);
|
||||
|
||||
// 运行阶段只复用,不再修改配置
|
||||
public async Task ParallelSave()
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 10).Select(async i =>
|
||||
{
|
||||
var serializer = this.GetUtility<ISerializer>();
|
||||
var data = new GameData { Score = i };
|
||||
string json = serializer.Serialize(data);
|
||||
await SaveToStorage($"data_{i}", json);
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [数据与存档系统](/zh-CN/game/data) - 数据持久化
|
||||
- [存储系统](/zh-CN/game/storage) - 文件存储
|
||||
- [设置系统](/zh-CN/game/setting) - 设置数据序列化
|
||||
- [Utility 系统](/zh-CN/core/utility) - 工具类注册
|
||||
1. [存储系统](./storage.md)
|
||||
2. [数据与存档系统](./data.md)
|
||||
3. [配置系统](./config-system.md)
|
||||
4. [Game 入口](./index.md)
|
||||
|
||||
@ -1,207 +1,204 @@
|
||||
---
|
||||
title: 设置系统
|
||||
description: 以当前 SettingsModel、SettingsSystem 与相关测试为准,说明设置数据、applicator、迁移和持久化的真实接法。
|
||||
---
|
||||
|
||||
# 设置系统
|
||||
|
||||
设置系统负责管理 `ISettingsData`、持久化加载/保存,以及把设置真正应用到运行时环境。
|
||||
`GFramework.Game` 的设置系统负责三件事:
|
||||
|
||||
当前实现以 `SettingsModel<TRepository>` 和 `SettingsSystem` 为核心,已经不是旧文档中的
|
||||
`Get<T>() / Register(IApplyAbleSettings)` 接口模型。
|
||||
- 管理 `ISettingsData` 实例的生命周期
|
||||
- 管理设置 applicator,并把设置真正作用到运行时环境
|
||||
- 在初始化时加载、迁移、保存和重置设置
|
||||
|
||||
## 核心概念
|
||||
当前默认 owner 是:
|
||||
|
||||
### ISettingsData
|
||||
- `SettingsModel<TRepository>`
|
||||
- `SettingsSystem`
|
||||
|
||||
设置数据对象负责保存设置值、提供默认值,并在加载后把外部数据回填到当前实例。
|
||||
而不是旧文档里那种“只靠若干 `Get<T>() / Register(...)` 辅助方法就能自动完成一切”的模型。
|
||||
|
||||
## 当前公开入口
|
||||
|
||||
### `ISettingsData`
|
||||
|
||||
设置数据对象需要同时承担:
|
||||
|
||||
- 默认值持有者
|
||||
- 版本化 section
|
||||
- 从已加载数据回填到当前实例的入口
|
||||
|
||||
当前接口组合是:
|
||||
|
||||
```csharp
|
||||
public interface ISettingsData : IResettable, IVersionedData, ILoadableFrom<ISettingsData>;
|
||||
```
|
||||
|
||||
这意味着一个设置数据类型通常需要实现:
|
||||
这意味着一个设置数据类型至少要处理:
|
||||
|
||||
- `Reset()`:恢复默认值
|
||||
- `Version` / `LastModified`:暴露版本化信息
|
||||
- `LoadFrom(ISettingsData)`:把已加载或迁移后的数据复制到当前实例
|
||||
- `Reset()`
|
||||
- `Version`
|
||||
- `LastModified`
|
||||
- `LoadFrom(ISettingsData source)`
|
||||
|
||||
### IResetApplyAbleSettings
|
||||
### `IResetApplyAbleSettings`
|
||||
|
||||
应用器负责把设置数据作用到引擎或运行时环境:
|
||||
applicator 的职责不是保存数据,而是把设置结果作用到实际运行时对象。
|
||||
|
||||
它当前需要暴露:
|
||||
|
||||
- `Data`
|
||||
- `DataType`
|
||||
- `Reset()`
|
||||
- `ApplyAsync()`
|
||||
|
||||
典型场景包括:
|
||||
|
||||
- 把音量设置同步到音频系统
|
||||
- 把画质设置同步到窗口或渲染配置
|
||||
- 把语言设置同步到本地化服务
|
||||
|
||||
### `SettingsModel<TRepository>`
|
||||
|
||||
这是当前设置系统的核心编排器。按当前源码,它负责:
|
||||
|
||||
- `GetData<T>()`
|
||||
- 返回某个设置类型的唯一实例
|
||||
- `RegisterApplicator(...)`
|
||||
- 注册 applicator,并把其 `Data` 一并纳入模型管理
|
||||
- `RegisterMigration(...)`
|
||||
- 注册同一设置类型的前进式迁移链
|
||||
- `InitializeAsync()`
|
||||
- 从 repository 读取所有设置、执行迁移、回填到当前实例
|
||||
- `SaveAllAsync()`
|
||||
- 持久化所有已登记的设置数据
|
||||
- `ApplyAllAsync()`
|
||||
- 依次应用所有 applicator
|
||||
- `Reset<T>() / ResetAll()`
|
||||
- 重置单个或全部设置
|
||||
|
||||
### `SettingsSystem`
|
||||
|
||||
`SettingsSystem` 是面向业务代码更直接的一层系统封装:
|
||||
|
||||
- `ApplyAll()`
|
||||
- `Apply<T>()`
|
||||
- `SaveAll()`
|
||||
- `Reset<T>()`
|
||||
- `ResetAll()`
|
||||
|
||||
它自己不持有独立设置状态,而是把工作委托给 `ISettingsModel`,并在应用时补发 settings 相关事件。
|
||||
|
||||
## 初始化与迁移的真实语义
|
||||
|
||||
`SettingsModel<TRepository>.InitializeAsync()` 的当前行为,比旧文档里“加载一下就好”更严格一些:
|
||||
|
||||
- 它会先调用 `ISettingsDataRepository.LoadAllAsync()`
|
||||
- 再逐个匹配当前模型里已经登记的设置类型
|
||||
- 如果读到了旧版本设置,会以“当前内存实例声明的 `Version`”为目标版本执行迁移
|
||||
- 迁移完成后通过 `LoadFrom(...)` 回填到现有实例,而不是直接替换对象引用
|
||||
|
||||
当前测试还确认了几个关键边界:
|
||||
|
||||
- 同一设置类型的同一个 `FromVersion` 不能重复注册迁移器
|
||||
- 注册新迁移器后,类型级迁移缓存会失效并重建,不会继续使用旧快照
|
||||
- 如果迁移链缺口导致无法安全升级,模型会保留当前内存中的最新实例,而不是把不完整的旧数据覆盖进来
|
||||
- 单个设置 section 初始化失败时,模型会记录错误并继续处理其他 section
|
||||
|
||||
这套语义更接近“尽量保证运行时实例总是可用”,而不是“任意旧设置都必须成功导入”。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
当前最常见的接法是:
|
||||
|
||||
1. 准备一个 `IStorage`
|
||||
2. 准备一个 `IRuntimeTypeSerializer`
|
||||
3. 注册 `ISettingsDataRepository`
|
||||
4. 注册 `IDataLocationProvider`
|
||||
5. 创建并注册 `SettingsModel<TRepository>`
|
||||
6. 注册 applicator
|
||||
7. 注册 `SettingsSystem`
|
||||
|
||||
示意代码:
|
||||
|
||||
```csharp
|
||||
public interface IResetApplyAbleSettings : IResettable, IApplyAbleSettings
|
||||
{
|
||||
ISettingsData Data { get; }
|
||||
Type DataType { get; }
|
||||
}
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Abstractions.Setting;
|
||||
using GFramework.Game.Data;
|
||||
using GFramework.Game.Serializer;
|
||||
using GFramework.Game.Setting;
|
||||
using GFramework.Game.Storage;
|
||||
|
||||
var serializer = new JsonSerializer();
|
||||
var storage = new FileStorage("GameData", serializer, ".json");
|
||||
|
||||
var repository = new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
serializer,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
BasePath = "settings",
|
||||
AutoBackup = true
|
||||
});
|
||||
|
||||
architecture.RegisterUtility<IStorage>(storage);
|
||||
architecture.RegisterUtility<IRuntimeTypeSerializer>(serializer);
|
||||
architecture.RegisterUtility<ISettingsDataRepository>(repository);
|
||||
// 此处注册项目侧的 IDataLocationProvider 实现,用于把设置类型映射到 section key。
|
||||
|
||||
var settingsModel = new SettingsModel<ISettingsDataRepository>(null, null);
|
||||
// 在注册到架构前,继续补 applicator 与 migration。
|
||||
|
||||
architecture.RegisterModel<ISettingsModel>(settingsModel);
|
||||
architecture.RegisterSystem<ISettingsSystem>(new SettingsSystem());
|
||||
```
|
||||
|
||||
常见用途包括:
|
||||
|
||||
- 把音量设置同步到音频总线
|
||||
- 把图形设置同步到窗口系统
|
||||
- 把语言设置同步到本地化管理器
|
||||
|
||||
## ISettingsModel
|
||||
|
||||
当前 `ISettingsModel` 的主要 API 如下:
|
||||
启动阶段通常是:
|
||||
|
||||
```csharp
|
||||
public interface ISettingsModel : IModel
|
||||
{
|
||||
bool IsInitialized { get; }
|
||||
|
||||
T GetData<T>() where T : class, ISettingsData, new();
|
||||
IEnumerable<ISettingsData> AllData();
|
||||
|
||||
ISettingsModel RegisterApplicator<T>(T applicator)
|
||||
where T : class, IResetApplyAbleSettings;
|
||||
T? GetApplicator<T>() where T : class, IResetApplyAbleSettings;
|
||||
IEnumerable<IResetApplyAbleSettings> AllApplicators();
|
||||
|
||||
ISettingsModel RegisterMigration(ISettingsMigration migration);
|
||||
|
||||
Task InitializeAsync();
|
||||
Task SaveAllAsync();
|
||||
Task ApplyAllAsync();
|
||||
void Reset<T>() where T : class, ISettingsData, new();
|
||||
void ResetAll();
|
||||
}
|
||||
```
|
||||
|
||||
行为说明:
|
||||
|
||||
- `GetData<T>()` 返回某个设置数据的唯一实例
|
||||
- `RegisterApplicator<T>()` 注册应用器,并把其 `Data` 纳入模型管理
|
||||
- `InitializeAsync()` 从 `ISettingsDataRepository` 读取所有已注册设置,并在需要时执行迁移
|
||||
- `SaveAllAsync()` 持久化当前所有设置数据
|
||||
- `ApplyAllAsync()` 依次调用所有 applicator 的 `ApplyAsync()`
|
||||
|
||||
## SettingsSystem
|
||||
|
||||
`SettingsSystem` 是对模型的系统级封装,面向业务代码提供更直接的入口:
|
||||
|
||||
```csharp
|
||||
public interface ISettingsSystem : ISystem
|
||||
{
|
||||
Task ApplyAll();
|
||||
Task Apply<T>() where T : class, IResetApplyAbleSettings;
|
||||
Task SaveAll();
|
||||
Task Reset<T>() where T : class, ISettingsData, IResetApplyAbleSettings, new();
|
||||
Task ResetAll();
|
||||
}
|
||||
```
|
||||
|
||||
它不会自己保存数据,而是把保存、重置和应用逻辑委托给 `ISettingsModel`。
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 定义设置数据
|
||||
|
||||
```csharp
|
||||
public sealed class GameplaySettings : ISettingsData
|
||||
{
|
||||
public float GameSpeed { get; set; } = 1.0f;
|
||||
|
||||
public int Version { get; private set; } = 1;
|
||||
public DateTime LastModified { get; } = DateTime.UtcNow;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
GameSpeed = 1.0f;
|
||||
}
|
||||
|
||||
public void LoadFrom(ISettingsData source)
|
||||
{
|
||||
if (source is not GameplaySettings settings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameSpeed = settings.GameSpeed;
|
||||
Version = settings.Version;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 定义 applicator
|
||||
|
||||
```csharp
|
||||
public sealed class GameplaySettingsApplicator : IResetApplyAbleSettings
|
||||
{
|
||||
public GameplaySettingsApplicator(GameplaySettings data)
|
||||
{
|
||||
Data = data;
|
||||
}
|
||||
|
||||
public ISettingsData Data { get; }
|
||||
public Type DataType => typeof(GameplaySettings);
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Data.Reset();
|
||||
}
|
||||
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
var settings = (GameplaySettings)Data;
|
||||
TimeScale.Current = settings.GameSpeed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用模型和系统
|
||||
|
||||
```csharp
|
||||
var settingsModel = this.GetModel<ISettingsModel>();
|
||||
|
||||
var gameplayData = settingsModel.GetData<GameplaySettings>();
|
||||
gameplayData.GameSpeed = 1.25f;
|
||||
|
||||
settingsModel.RegisterApplicator(new GameplaySettingsApplicator(gameplayData));
|
||||
|
||||
await settingsModel.InitializeAsync();
|
||||
await settingsModel.SaveAllAsync();
|
||||
|
||||
var settingsSystem = this.GetSystem<ISettingsSystem>();
|
||||
await settingsSystem.ApplyAll();
|
||||
await settingsModel.ApplyAllAsync();
|
||||
```
|
||||
|
||||
## 迁移
|
||||
|
||||
设置系统内建了迁移注册入口:
|
||||
退出或显式保存时:
|
||||
|
||||
```csharp
|
||||
public interface ISettingsMigration
|
||||
{
|
||||
Type SettingsType { get; }
|
||||
int FromVersion { get; }
|
||||
int ToVersion { get; }
|
||||
ISettingsData Migrate(ISettingsData oldData);
|
||||
}
|
||||
await settingsModel.SaveAllAsync();
|
||||
```
|
||||
|
||||
当 `InitializeAsync()` 读取到旧版本设置时,会按已注册迁移链逐步升级,再通过 `LoadFrom` 回填到当前实例。
|
||||
## `GetData<T>()` 和 `RegisterApplicator(...)` 的分工
|
||||
|
||||
迁移规则如下:
|
||||
这两个入口经常被混用,但职责不同:
|
||||
|
||||
- 同一个设置类型的同一个 `FromVersion` 只能注册一个迁移器
|
||||
- `ToVersion` 必须严格大于 `FromVersion`
|
||||
- `InitializeAsync()` 会以当前运行时代码里该设置实例的 `Version` 作为目标版本
|
||||
- 如果迁移链缺口、迁移结果类型不兼容、迁移结果版本与声明不一致,或者读取到比当前运行时更高的版本,当前设置节不会覆盖内存中的最新实例,并会记录错误日志
|
||||
- 与 `SaveRepository<TSaveData>` 不同,设置初始化阶段会跳过失败的设置节并继续处理其他设置节,而不是把异常继续向外抛出
|
||||
- `GetData<T>()`
|
||||
- 只保证某个设置数据实例存在,并在 repository / location provider 已就绪时把类型注册回去
|
||||
- `RegisterApplicator(...)`
|
||||
- 同时注册 applicator 和 applicator 绑定的 `Data`
|
||||
|
||||
## 依赖项
|
||||
如果一个设置类型需要真正作用到运行时对象,推荐让它通过 applicator 进入模型;这样 `ApplyAllAsync()`、`ResetAll()` 和
|
||||
`SettingsSystem` 才能完整覆盖到它。
|
||||
|
||||
要让设置系统完整工作,通常需要准备:
|
||||
## 与 repository 的关系
|
||||
|
||||
- `ISettingsDataRepository`
|
||||
- `IDataLocationProvider`
|
||||
- 一个具体的存储实现和序列化器
|
||||
设置系统默认不是直接写文件,而是依赖 `ISettingsDataRepository`。
|
||||
|
||||
如果使用 `UnifiedSettingsDataRepository`,多个设置节会被合并到单个设置文件中统一保存。
|
||||
当前仓库里更推荐的默认实现是 `UnifiedSettingsDataRepository`,原因很直接:
|
||||
|
||||
- 多个设置 section 会被聚合到同一份统一文件
|
||||
- 启动时能一次性 `LoadAllAsync()`
|
||||
- `AutoBackup` 针对整个统一文件生效,更贴近“设置快照”的真实语义
|
||||
|
||||
如果你的项目明确需要“一类设置一个独立文件”,才考虑回到通用 `DataRepository` 路径。
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 设置迁移是内建能力
|
||||
- 设置持久化是内建能力
|
||||
- 设置如何应用到具体引擎由 applicator 决定
|
||||
- 存档系统也支持内建版本迁移,但入口位于 `ISaveRepository<T>.RegisterMigration(...)`,语义是槽位存档升级而不是设置节初始化
|
||||
- `SettingsModel<TRepository>` 负责数据生命周期,`SettingsSystem` 负责系统级调用入口;两者不要混成一个巨型服务
|
||||
- applicator 决定“怎么把数据应用到宿主”,repository 决定“怎么保存数据”,两层职责不要互相侵入
|
||||
- 设置迁移和存档迁移是两条不同管线;后者看 [`data.md`](./data.md) 里的 `SaveRepository<TSaveData>`
|
||||
|
||||
## 继续阅读
|
||||
|
||||
1. [数据与存档系统](./data.md)
|
||||
2. [存储系统](./storage.md)
|
||||
3. [Game 入口](./index.md)
|
||||
|
||||
@ -1,735 +1,181 @@
|
||||
---
|
||||
title: 存储系统详解
|
||||
description: 存储系统提供了灵活的文件存储和作用域隔离功能,支持跨平台数据持久化。
|
||||
title: Game 存储系统
|
||||
description: 以当前 GFramework.Game 源码与持久化测试为准,说明 FileStorage 与 ScopedStorage 的职责、路径语义和复用方式。
|
||||
---
|
||||
|
||||
# 存储系统详解
|
||||
# Game 存储系统
|
||||
|
||||
## 概述
|
||||
`GFramework.Game` 在存储这一层只提供宿主无关的 `IStorage` 默认实现和作用域包装器。
|
||||
|
||||
存储系统是 GFramework.Game 中用于管理文件存储的核心组件。它提供了统一的存储接口,支持键值对存储、作用域隔离、目录操作等功能,让你可以轻松实现游戏数据的持久化。
|
||||
当前真正对外需要理解的入口只有两个:
|
||||
|
||||
存储系统采用装饰器模式设计,通过 `IStorage` 接口定义统一的存储操作,`FileStorage` 提供基于文件系统的实现,`ScopedStorage`
|
||||
提供作用域隔离功能。
|
||||
- `FileStorage`
|
||||
- 负责 `key -> 文件路径 -> 序列化内容` 的落盘读写
|
||||
- `ScopedStorage`
|
||||
- 负责给同一份底层存储加前缀作用域,避免不同子系统直接拼字符串抢同一片键空间
|
||||
|
||||
**主要特性**:
|
||||
它们不负责:
|
||||
|
||||
- 统一的键值对存储接口
|
||||
- 基于文件系统的持久化
|
||||
- 作用域隔离和命名空间管理
|
||||
- 线程安全的并发访问
|
||||
- 支持同步和异步操作
|
||||
- 目录和文件列举功能
|
||||
- 路径安全防护
|
||||
- 跨平台支持(包括 Godot)
|
||||
- 设置 section 的聚合语义
|
||||
- 存档槽位目录约定
|
||||
- 业务数据迁移
|
||||
|
||||
## 核心概念
|
||||
这些都属于上层 repository。
|
||||
|
||||
### 存储接口
|
||||
## 当前公开入口
|
||||
|
||||
`IStorage` 定义了统一的存储操作:
|
||||
### `FileStorage`
|
||||
|
||||
`FileStorage` 是 `IStorage` 的默认文件系统实现。按当前源码,它的职责比较集中:
|
||||
|
||||
- 把业务 key 映射到根目录下的层级文件路径
|
||||
- 通过构造函数注入的 `ISerializer` 负责对象序列化和反序列化
|
||||
- 对同一目标路径使用 `IAsyncKeyLockManager` 做细粒度串行化
|
||||
- 写入时先落 `.tmp` 临时文件,再原子替换目标文件
|
||||
- 自动创建父目录
|
||||
- 拒绝包含 `..` 的非法 key,并清理路径段中的非法文件名字符
|
||||
|
||||
默认文件扩展名是 `.dat`,也可以在构造时改成 `.json` 或其他后缀:
|
||||
|
||||
```csharp
|
||||
public interface IStorage : IUtility
|
||||
{
|
||||
// 检查键是否存在
|
||||
bool Exists(string key);
|
||||
Task<bool> ExistsAsync(string key);
|
||||
|
||||
// 读取数据
|
||||
T Read<T>(string key);
|
||||
T Read<T>(string key, T defaultValue);
|
||||
Task<T> ReadAsync<T>(string key);
|
||||
|
||||
// 写入数据
|
||||
void Write<T>(string key, T value);
|
||||
Task WriteAsync<T>(string key, T value);
|
||||
|
||||
// 删除数据
|
||||
void Delete(string key);
|
||||
Task DeleteAsync(string key);
|
||||
|
||||
// 目录操作
|
||||
Task<IReadOnlyList<string>> ListDirectoriesAsync(string path = "");
|
||||
Task<IReadOnlyList<string>> ListFilesAsync(string path = "");
|
||||
Task<bool> DirectoryExistsAsync(string path);
|
||||
Task CreateDirectoryAsync(string path);
|
||||
}
|
||||
```
|
||||
|
||||
### 文件存储
|
||||
|
||||
`FileStorage` 是基于文件系统的存储实现:
|
||||
|
||||
- 将数据序列化后保存为文件
|
||||
- 支持自定义文件扩展名(默认 `.dat`)
|
||||
- 使用细粒度锁保证线程安全
|
||||
- 自动创建目录结构
|
||||
- 防止路径遍历攻击
|
||||
|
||||
### 作用域存储
|
||||
|
||||
`ScopedStorage` 提供命名空间隔离:
|
||||
|
||||
- 为所有键添加前缀
|
||||
- 支持嵌套作用域
|
||||
- 透明包装底层存储
|
||||
- 实现逻辑分组
|
||||
|
||||
### 存储类型
|
||||
|
||||
`StorageKinds` 枚举定义了不同的存储方式:
|
||||
|
||||
```csharp
|
||||
[Flags]
|
||||
public enum StorageKinds
|
||||
{
|
||||
None = 0,
|
||||
Local = 1 << 0, // 本地文件系统
|
||||
Memory = 1 << 1, // 内存存储
|
||||
Remote = 1 << 2, // 远程存储
|
||||
Database = 1 << 3 // 数据库存储
|
||||
}
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 创建文件存储
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Storage;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Game.Serializer;
|
||||
|
||||
// 创建序列化器
|
||||
var serializer = new JsonSerializer();
|
||||
|
||||
// 创Windows 示例)
|
||||
var storage = new FileStorage(@"C:\MyGame\Data", serializer);
|
||||
|
||||
// 或使用自定义扩展名
|
||||
var storage = new FileStorage(@"C:\MyGame\Data", serializer, ".json");
|
||||
```
|
||||
|
||||
### 写入和读取数据
|
||||
|
||||
```csharp
|
||||
// 写入简单类型
|
||||
storage.Write("player_score", 1000);
|
||||
storage.Write("player_name", "Alice");
|
||||
|
||||
// 写入复杂对象
|
||||
var settings = new GameSettings
|
||||
{
|
||||
Volume = 0.8f,
|
||||
Difficulty = "Hard",
|
||||
Language = "zh-CN"
|
||||
};
|
||||
storage.Write("settings", settings);
|
||||
|
||||
// 读取数据
|
||||
int score = storage.Read<int>("player_score");
|
||||
string name = storage.Read<string>("player_name");
|
||||
var loadedSettings = storage.Read<GameSettings>("settings");
|
||||
|
||||
// 读取数据(带默认值)
|
||||
int highScore = storage.Read("high_score", 0);
|
||||
```
|
||||
|
||||
### 异步操作
|
||||
|
||||
```csharp
|
||||
// 异步写入
|
||||
await storage.WriteAsync("player_level", 10);
|
||||
|
||||
// 异步读取
|
||||
int level = await storage.ReadAsync<int>("player_level");
|
||||
|
||||
// 异步检查存在
|
||||
bool exists = await storage.ExistsAsync("player_level");
|
||||
|
||||
// 异步删除
|
||||
await storage.DeleteAsync("player_level");
|
||||
```
|
||||
|
||||
### 检查和删除
|
||||
|
||||
```csharp
|
||||
// 检查键是否存在
|
||||
if (storage.Exists("player_score"))
|
||||
{
|
||||
Console.WriteLine("存档存在");
|
||||
}
|
||||
|
||||
// 删除数据
|
||||
storage.Delete("player_score");
|
||||
|
||||
// 异步检查
|
||||
bool exists = await storage.ExistsAsync("player_score");
|
||||
```
|
||||
|
||||
### 使用层级键
|
||||
|
||||
```csharp
|
||||
// 使用 / 分隔符创建层级结构
|
||||
storage.Write("player/profile/name", "Alice");
|
||||
storage.Write("player/profile/level", 10);
|
||||
storage.Write("player/inventory/gold", 1000);
|
||||
|
||||
// 文件结构:
|
||||
// Data/
|
||||
// player/
|
||||
// profile/
|
||||
// name.dat
|
||||
// level.dat
|
||||
// inventory/
|
||||
// gold.dat
|
||||
|
||||
// 读取层级数据
|
||||
string name = storage.Read<string>("player/profile/name");
|
||||
int gold = storage.Read<int>("player/inventory/gold");
|
||||
```
|
||||
|
||||
## 作用域存储
|
||||
|
||||
### 创建作用域存储
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Storage;
|
||||
|
||||
// 基于文件存储创建作用域存储
|
||||
var baseStorage = new FileStorage(@"C:\MyGame\Data", serializer);
|
||||
var playerStorage = new ScopedStorage(baseStorage, "player");
|
||||
|
||||
// 所有操作都会添加 "player/" 前缀
|
||||
playerStorage.Write("name", "Alice"); // 实际存储为 "player/name.dat"
|
||||
playerStorage.Write("level", 10); // 实际存储为 "player/level.dat"
|
||||
|
||||
// 读取时也使用相同的前缀
|
||||
string name = playerStorage.Read<string>("name"); // 从 "player/name.dat" 读取
|
||||
var serializer = new JsonSerializer();
|
||||
IStorage storage = new FileStorage("GameData", serializer, ".json");
|
||||
```
|
||||
|
||||
### 嵌套作用域
|
||||
### `ScopedStorage`
|
||||
|
||||
`ScopedStorage` 不额外实现一套落盘逻辑,只是给底层 `IStorage` 包一层前缀。
|
||||
|
||||
它适合做的是:
|
||||
|
||||
- 把 `settings/`、`profiles/`、`runtime-cache/` 这类键空间隔离开
|
||||
- 让多个 repository 或 utility 共用同一份根存储
|
||||
- 避免项目层到处手写 `"settings/xxx"`、`"save/slot_1/xxx"` 之类的字符串拼接
|
||||
|
||||
当前实现还支持继续嵌套:
|
||||
|
||||
```csharp
|
||||
// 创建嵌套作用域
|
||||
var settingsStorage = new ScopedStorage(baseStorage, "settings");
|
||||
var graphicsStorage = new ScopedStorage(settingsStorage, "graphics");
|
||||
|
||||
// 前缀变为 "settings/graphics/"
|
||||
graphicsStorage.Write("resolution", "1920x1080");
|
||||
// 实际存储为 "settings/graphics/resolution.dat"
|
||||
|
||||
// 或使用 Scope 方法
|
||||
var rootStorage = new FileStorage("GameData", new JsonSerializer(), ".json");
|
||||
var settingsStorage = new ScopedStorage(rootStorage, "settings");
|
||||
var audioStorage = settingsStorage.Scope("audio");
|
||||
audioStorage.Write("volume", 0.8f);
|
||||
// 实际存储为 "settings/audio/volume.dat"
|
||||
|
||||
await audioStorage.WriteAsync("master", 0.8f);
|
||||
```
|
||||
|
||||
### 多作用域隔离
|
||||
最终实际写入的 key 会是 `settings/audio/master`。
|
||||
|
||||
```csharp
|
||||
// 创建不同作用域的存储
|
||||
var playerStorage = new ScopedStorage(baseStorage, "player");
|
||||
var gameStorage = new ScopedStorage(baseStorage, "game");
|
||||
var settingsStorage = new ScopedStorage(baseStorage, "settings");
|
||||
## 路径语义
|
||||
|
||||
// 在不同作用域中使用相同的键不会冲突
|
||||
playerStorage.Write("level", 5); // player/level.dat
|
||||
gameStorage.Write("level", "forest_area_1"); // game/level.dat
|
||||
settingsStorage.Write("level", "high"); // settings/level.dat
|
||||
### key 到文件路径的映射
|
||||
|
||||
// 读取时各自独立
|
||||
int playerLevel = playerStorage.Read<int>("level"); // 5
|
||||
string gameLevel = gameStorage.Read<string>("level"); // "forest_area_1"
|
||||
string settingsLevel = settingsStorage.Read<string>("level"); // "high"
|
||||
`FileStorage` 会把 key 中的 `/` 当成目录分隔符,把最后一段作为文件名,并自动附加扩展名。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
key: profile/player
|
||||
root: GameData
|
||||
extension: .json
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
会落到:
|
||||
|
||||
### 目录操作
|
||||
```text
|
||||
GameData/profile/player.json
|
||||
```
|
||||
|
||||
这意味着 key 的语义应该保持“逻辑路径”,而不是“完整文件名”。不要在业务层再自己补一遍 `.json`,否则会得到双重后缀。
|
||||
|
||||
### 安全边界
|
||||
|
||||
当前实现会:
|
||||
|
||||
1. 把 `\` 统一成 `/`
|
||||
2. 拒绝包含 `..` 的 key
|
||||
3. 清理每个路径段中的非法文件名字符
|
||||
|
||||
这套规则能挡住明显的路径逃逸和非法文件名问题,但它不代替业务层做目录规划。哪些 key 属于设置、存档还是缓存,仍应由上层模块统一约定。
|
||||
|
||||
### 同步与异步 API
|
||||
|
||||
`Read`、`Write`、`Exists`、`Delete` 这些同步方法只是对异步 API 的阻塞包装。
|
||||
|
||||
在 UI 线程或带同步上下文的宿主中,优先使用:
|
||||
|
||||
- `ReadAsync<T>()`
|
||||
- `WriteAsync<T>()`
|
||||
- `ExistsAsync()`
|
||||
- `DeleteAsync()`
|
||||
|
||||
只有在无法继续异步传播时,再退回同步封装。
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
如果你只想先拿到一个可复用的本地持久化底座,最短路径如下:
|
||||
|
||||
```csharp
|
||||
// 列举子目录
|
||||
var directories = await storage.ListDirectoriesAsync("player");
|
||||
foreach (var dir in directories)
|
||||
using GFramework.Core.Abstractions.Serializer;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Game.Serializer;
|
||||
using GFramework.Game.Storage;
|
||||
|
||||
var serializer = new JsonSerializer();
|
||||
IStorage storage = new FileStorage("GameData", serializer, ".json");
|
||||
|
||||
await storage.WriteAsync("profiles/player", new Dictionary<string, int>
|
||||
{
|
||||
Console.WriteLine($"目录: {dir}");
|
||||
}
|
||||
["level"] = 12
|
||||
});
|
||||
|
||||
// 列举文件
|
||||
var files = await storage.ListFilesAsync("player/inventory");
|
||||
foreach (var file in files)
|
||||
{
|
||||
Console.WriteLine($"文件: {file}");
|
||||
}
|
||||
|
||||
// 检查目录是否存在
|
||||
bool exists = await storage.DirectoryExistsAsync("player/quests");
|
||||
|
||||
// 创建目录
|
||||
await storage.CreateDirectoryAsync("player/achievements");
|
||||
var loaded = await storage.ReadAsync<Dictionary<string, int>>("profiles/player");
|
||||
```
|
||||
|
||||
### 批量操作
|
||||
如果项目里同时有设置、存档和运行时缓存,推荐先在组合根把作用域拆开:
|
||||
|
||||
```csharp
|
||||
public async Task SaveAllPlayerData(PlayerData player)
|
||||
{
|
||||
var playerStorage = new ScopedStorage(baseStorage, $"player_{player.Id}");
|
||||
var serializer = new JsonSerializer();
|
||||
var rootStorage = new FileStorage("GameData", serializer, ".json");
|
||||
|
||||
// 批量写入
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
playerStorage.WriteAsync("profile", player.Profile),
|
||||
playerStorage.WriteAsync("inventory", player.Inventory),
|
||||
playerStorage.WriteAsync("quests", player.Quests),
|
||||
playerStorage.WriteAsync("achievements", player.Achievements)
|
||||
};
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
Console.WriteLine("所有玩家数据已保存");
|
||||
}
|
||||
|
||||
public async Task<PlayerData> LoadAllPlayerData(int playerId)
|
||||
{
|
||||
var playerStorage = new ScopedStorage(baseStorage, $"player_{playerId}");
|
||||
|
||||
// 批量读取
|
||||
var tasks = new[]
|
||||
{
|
||||
playerStorage.ReadAsync<Profile>("profile"),
|
||||
playerStorage.ReadAsync<Inventory>("inventory"),
|
||||
playerStorage.ReadAsync<QuestData>("quests"),
|
||||
playerStorage.ReadAsync<Achievements>("achievements")
|
||||
};
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
return new PlayerData
|
||||
{
|
||||
Id = playerId,
|
||||
Profile = tasks[0].Result,
|
||||
Inventory = tasks[1].Result,
|
||||
Quests = tasks[2].Result,
|
||||
Achievements = tasks[3].Result
|
||||
};
|
||||
}
|
||||
var settingsStorage = new ScopedStorage(rootStorage, "settings");
|
||||
var saveStorage = new ScopedStorage(rootStorage, "saves");
|
||||
var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache");
|
||||
```
|
||||
|
||||
### 存储迁移
|
||||
不过在默认仓库接法里,项目通常不需要直接创建 `saveStorage` 这种 scoped instance,因为 `SaveRepository<TSaveData>`
|
||||
会再根据 `SaveConfiguration` 自己组织槽位目录。
|
||||
|
||||
```csharp
|
||||
public async Task MigrateStorage(IStorage oldStorage, IStorage newStorage, string path = "")
|
||||
{
|
||||
// 列举所有文件
|
||||
var files = await oldStorage.ListFilesAsync(path);
|
||||
## 与上层 repository 的关系
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var key = string.IsNullOrEmpty(path) ? file : $"{path}/{file}";
|
||||
`FileStorage` / `ScopedStorage` 是持久化最底层,不是最终采用入口。当前更常见的实际分工是:
|
||||
|
||||
// 读取旧数据
|
||||
var data = await oldStorage.ReadAsync<object>(key);
|
||||
- `DataRepository`
|
||||
- 每个 `IDataLocation` 对应一份独立持久化对象
|
||||
- `UnifiedSettingsDataRepository`
|
||||
- 把多个设置 section 聚合到同一个统一文件里保存
|
||||
- `SaveRepository<TSaveData>`
|
||||
- 负责存档槽位、文件名和迁移链
|
||||
|
||||
// 写入新存储
|
||||
await newStorage.WriteAsync(key, data);
|
||||
也就是说:
|
||||
|
||||
Console.WriteLine($"已迁移: {key}");
|
||||
}
|
||||
- 业务层如果想保存一份独立数据,优先看 [`data.md`](./data.md)
|
||||
- 业务层如果想保存设置,优先看 [`setting.md`](./setting.md)
|
||||
- 业务层如果只是需要底层存储实现,才直接依赖 `IStorage`
|
||||
|
||||
// 递归处理子目录
|
||||
var directories = await oldStorage.ListDirectoriesAsync(path);
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
var subPath = string.IsNullOrEmpty(path) ? dir : $"{path}/{dir}";
|
||||
await MigrateStorage(oldStorage, newStorage, subPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
## 当前边界
|
||||
|
||||
### 存储备份
|
||||
- `FileStorage` 已经会通过注入的 `ISerializer` 自动序列化对象;默认接法不需要先手工 `Serialize(...)` 再把字符串写回
|
||||
- `FileStorage` 负责目录列举与目录创建,但不负责“列出所有存档槽位”的业务语义
|
||||
- `ScopedStorage` 只做 key 前缀,不做权限、事务或迁移控制
|
||||
- 锁粒度是“当前实例内的目标路径”,不是跨进程文件锁
|
||||
- 原子写入只覆盖单文件替换,不等于多文件事务
|
||||
|
||||
```csharp
|
||||
public class StorageBackupSystem
|
||||
{
|
||||
private readonly IStorage _storage;
|
||||
private readonly string _backupPrefix = "backup";
|
||||
## 继续阅读
|
||||
|
||||
public async Task CreateBackup(string sourcePath)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
var backupPath = $"{_backupPrefix}/{timestamp}";
|
||||
|
||||
await CopyDirectory(sourcePath, backupPath);
|
||||
Console.WriteLine($"备份已创建: {backupPath}");
|
||||
}
|
||||
|
||||
public async Task RestoreBackup(string backupName, string targetPath)
|
||||
{
|
||||
var backupPath = $"{_backupPrefix}/{backupName}";
|
||||
|
||||
if (!await _storage.DirectoryExistsAsync(backupPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"备份不存在: {backupName}");
|
||||
}
|
||||
|
||||
await CopyDirectory(backupPath, targetPath);
|
||||
Console.WriteLine($"已从备份恢复: {backupName}");
|
||||
}
|
||||
|
||||
private async Task CopyDirectory(string source, string target)
|
||||
{
|
||||
var files = await _storage.ListFilesAsync(source);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var sourceKey = $"{source}/{file}";
|
||||
var targetKey = $"{target}/{file}";
|
||||
var data = await _storage.ReadAsync<object>(sourceKey);
|
||||
await _storage.WriteAsync(targetKey, data);
|
||||
}
|
||||
|
||||
var directories = await _storage.ListDirectoriesAsync(source);
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
await CopyDirectory($"{source}/{dir}", $"{target}/{dir}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 缓存层
|
||||
|
||||
```csharp
|
||||
public class CachedStorage : IStorage
|
||||
{
|
||||
private readonly IStorage _innerStorage;
|
||||
private readonly ConcurrentDictionary<string, object> _cache = new();
|
||||
|
||||
public CachedStorage(IStorage innerStorage)
|
||||
{
|
||||
_innerStorage = innerStorage;
|
||||
}
|
||||
|
||||
public T Read<T>(string key)
|
||||
{
|
||||
// 先从缓存读取
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return (T)cached;
|
||||
}
|
||||
|
||||
// 从存储读取并缓存
|
||||
var value = _innerStorage.Read<T>(key);
|
||||
_cache[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
public void Write<T>(string key, T value)
|
||||
{
|
||||
// 写入存储
|
||||
_innerStorage.Write(key, value);
|
||||
|
||||
// 更新缓存
|
||||
_cache[key] = value;
|
||||
}
|
||||
|
||||
public void Delete(string key)
|
||||
{
|
||||
_innerStorage.Delete(key);
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Godot 集成
|
||||
|
||||
### 使用 Godot 文件存储
|
||||
|
||||
```csharp
|
||||
using GFramework.Godot.Storage;
|
||||
|
||||
// 创建 Godot 文件存储
|
||||
var storage = new GodotFileStorage(serializer);
|
||||
|
||||
// 使用 user:// 路径(用户数据目录)
|
||||
storage.Write("user://saves/slot1.dat", saveData);
|
||||
var data = storage.Read<SaveData>("user://saves/slot1.dat");
|
||||
|
||||
// 使用 res:// 路径(资源目录,只读)
|
||||
var config = storage.Read<Config>("res://config/default.json");
|
||||
|
||||
// 普通文件路径也支持
|
||||
storage.Write("/tmp/temp_data.dat", tempData);
|
||||
```
|
||||
|
||||
### Godot 路径说明
|
||||
|
||||
```csharp
|
||||
// user:// - 用户数据目录
|
||||
// Windows: %APPDATA%/Godot/app_userdata/[project_name]
|
||||
// Linux: ~/.local/share/godot/app_userdata/[project_name]
|
||||
// macOS: ~/Library/Application Support/Godot/app_userdata/[project_name]
|
||||
storage.Write("user://save.dat", data);
|
||||
|
||||
// res:// - 项目资源目录(只读)
|
||||
var config = storage.Read<Config>("res://data/config.json");
|
||||
|
||||
// 绝对路径
|
||||
storage.Write("/home/user/game/data.dat", data);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用作用域隔离不同类型的数据**
|
||||
```csharp
|
||||
✓ var playerStorage = new ScopedStorage(baseStorage, "player");
|
||||
✓ var settingsStorage = new ScopedStorage(baseStorage, "settings");
|
||||
✗ storage.Write("player_name", name); // 不使用作用域
|
||||
```
|
||||
|
||||
2. **使用异步操作避免阻塞**
|
||||
```csharp
|
||||
✓ await storage.WriteAsync("data", value);
|
||||
✗ storage.Write("data", value); // 在 UI 线程中同步操作
|
||||
```
|
||||
|
||||
3. **读取时提供默认值**
|
||||
```csharp
|
||||
✓ int score = storage.Read("score", 0);
|
||||
✗ int score = storage.Read<int>("score"); // 键不存在时抛异常
|
||||
```
|
||||
|
||||
4. **使用层级键组织数据**
|
||||
```csharp
|
||||
✓ storage.Write("player/inventory/gold", 1000);
|
||||
✗ storage.Write("player_inventory_gold", 1000);
|
||||
```
|
||||
|
||||
5. **处理存储异常**
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
await storage.WriteAsync("data", value);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Logger.Error($"存储失败: {ex.Message}");
|
||||
ShowErrorMessage("保存失败,请检查磁盘空间");
|
||||
}
|
||||
```
|
||||
|
||||
6. **定期清理过期数据**
|
||||
```csharp
|
||||
public async Task CleanupOldData(TimeSpan maxAge)
|
||||
{
|
||||
var files = await storage.ListFilesAsync("temp");
|
||||
foreach (var file in files)
|
||||
{
|
||||
var data = await storage.ReadAsync<TimestampedData>($"temp/{file}");
|
||||
if (DateTime.Now - data.Timestamp > maxAge)
|
||||
{
|
||||
await storage.DeleteAsync($"temp/{file}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. **使用合适的序列化器**
|
||||
```csharp
|
||||
// JSON - 可读性好,适合配置文件
|
||||
var jsonStorage = new FileStorage(path, new JsonSerializer(), ".json");
|
||||
|
||||
// 二进制 - 性能好,适合大量数据
|
||||
var binaryStorage = new FileStorage(path, new BinarySerializer(), ".dat");
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题:如何实现跨平台存储路径?
|
||||
|
||||
**解答**:
|
||||
使用 `Environment.GetFolderPath` 获取平台特定路径:
|
||||
|
||||
```csharp
|
||||
public static string GetStoragePath()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.ApplicationData);
|
||||
return Path.Combine(appData, "MyGame", "Data");
|
||||
}
|
||||
|
||||
var storage = new FileStorage(GetStoragePath(), serializer);
|
||||
```
|
||||
|
||||
### 问题:存储系统是否线程安全?
|
||||
|
||||
**解答**:
|
||||
是的,`FileStorage` 使用细粒度锁机制保证线程安全:
|
||||
|
||||
```csharp
|
||||
// 不同键的操作可以并发执行
|
||||
Task.Run(() => storage.Write("key1", value1));
|
||||
Task.Run(() => storage.Write("key2", value2));
|
||||
|
||||
// 相同键的操作会串行化
|
||||
Task.Run(() => storage.Write("key", value1));
|
||||
Task.Run(() => storage.Write("key", value2)); // 等待第一个完成
|
||||
```
|
||||
|
||||
### 问题:如何实现存储加密?
|
||||
|
||||
**解答**:
|
||||
创建加密存储包装器:
|
||||
|
||||
```csharp
|
||||
public class EncryptedStorage : IStorage
|
||||
{
|
||||
private readonly IStorage _innerStorage;
|
||||
private readonly IEncryption _encryption;
|
||||
|
||||
public void Write<T>(string key, T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
var encrypted = _encryption.Encrypt(json);
|
||||
_innerStorage.Write(key, encrypted);
|
||||
}
|
||||
|
||||
public T Read<T>(string key)
|
||||
{
|
||||
var encrypted = _innerStorage.Read<byte[]>(key);
|
||||
var json = _encryption.Decrypt(encrypted);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何限制存储大小?
|
||||
|
||||
**解答**:
|
||||
实现配额管理:
|
||||
|
||||
```csharp
|
||||
public class QuotaStorage : IStorage
|
||||
{
|
||||
private readonly IStorage _innerStorage;
|
||||
private readonly long _maxSize;
|
||||
private long _currentSize;
|
||||
|
||||
public void Write<T>(string key, T value)
|
||||
{
|
||||
var data = Serialize(value);
|
||||
var size = data.Length;
|
||||
|
||||
if (_currentSize + size > _maxSize)
|
||||
{
|
||||
throw new InvalidOperationException("存储配额已满");
|
||||
}
|
||||
|
||||
_innerStorage.Write(key, value);
|
||||
_currentSize += size;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何实现存储压缩?
|
||||
|
||||
**解答**:
|
||||
使用压缩序列化器:
|
||||
|
||||
```csharp
|
||||
public class CompressedSerializer : ISerializer
|
||||
{
|
||||
private readonly ISerializer _innerSerializer;
|
||||
|
||||
public string Serialize<T>(T value)
|
||||
{
|
||||
var json = _innerSerializer.Serialize(value);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var compressed = Compress(bytes);
|
||||
return Convert.ToBase64String(compressed);
|
||||
}
|
||||
|
||||
public T Deserialize<T>(string data)
|
||||
{
|
||||
var compressed = Convert.FromBase64String(data);
|
||||
var bytes = Decompress(compressed);
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
return _innerSerializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
private byte[] Compress(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionMode.Compress))
|
||||
{
|
||||
gzip.Write(data, 0, data.Length);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private byte[] Decompress(byte[] data)
|
||||
{
|
||||
using var input = new MemoryStream(data);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 问题:如何监控存储操作?
|
||||
|
||||
**解答**:
|
||||
实现日志存储包装器:
|
||||
|
||||
```csharp
|
||||
public class LoggingStorage : IStorage
|
||||
{
|
||||
private readonly IStorage _innerStorage;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public void Write<T>(string key, T value)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
_innerStorage.Write(key, value);
|
||||
_logger.Info($"写入成功: {key}, 耗时: {stopwatch.ElapsedMilliseconds}ms");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"写入失败: {key}, 错误: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public T Read<T>(string key)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var value = _innerStorage.Read<T>(key);
|
||||
_logger.Info($"读取成功: {key}, 耗时: {stopwatch.ElapsedMilliseconds}ms");
|
||||
return value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"读取失败: {key}, 错误: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [数据与存档系统](/zh-CN/game/data) - 数据持久化
|
||||
- [序列化系统](/zh-CN/game/serialization) - 数据序列化
|
||||
- [Godot 集成](/zh-CN/godot/index) - Godot 中的存储
|
||||
- [存档系统教程](/zh-CN/tutorials/save-system) - 完整示例
|
||||
1. [数据与存档系统](./data.md)
|
||||
2. [设置系统](./setting.md)
|
||||
3. [序列化系统](./serialization.md)
|
||||
4. [Game 入口](./index.md)
|
||||
|
||||
@ -1,603 +1,156 @@
|
||||
# Godot 设置模块 (Godot Settings Module)
|
||||
---
|
||||
title: Godot 设置系统
|
||||
description: 以当前 GFramework.Godot 源码、测试与 CoreGrid 接线为准,说明 Godot settings applicator 的职责、注册方式和运行时边界。
|
||||
---
|
||||
|
||||
## 概述
|
||||
# Godot 设置系统
|
||||
|
||||
Godot 设置模块是 GFramework.Godot 的核心组件之一,专门为 Godot 引擎提供游戏设置系统的实现。该模块将通用的设置框架与 Godot
|
||||
引擎的特定功能相结合,提供了音频设置、图形设置和本地化设置的完整解决方案。
|
||||
`GFramework.Godot` 在设置这一层做的事情很克制:它没有重新发明一套设置模型,而是给
|
||||
`GFramework.Game` 的 `ISettingsModel` 提供三个 Godot 宿主 applicator:
|
||||
|
||||
## 核心类
|
||||
- `GodotAudioSettings`
|
||||
- `GodotGraphicsSettings`
|
||||
- `GodotLocalizationSettings`
|
||||
|
||||
### 音频设置系统
|
||||
这些类型的职责是“把已经存在的设置数据应用到 Godot 引擎和框架运行时”,不是负责设置 UI、设置持久化或设置迁移。
|
||||
|
||||
#### AudioBusMap
|
||||
## 当前公开入口
|
||||
|
||||
音频总线映射配置类,用于定义音频系统中不同类型音频的总线名称。
|
||||
### `GodotAudioSettings`
|
||||
|
||||
**属性:**
|
||||
`GodotAudioSettings` 从 `ISettingsModel` 读取 `AudioSettings`,再按 `AudioBusMap` 中的总线名把音量写入
|
||||
`AudioServer`。
|
||||
|
||||
- `Master` - 主音频总线名称(默认:"Master")
|
||||
- `Bgm` - 背景音乐音频总线名称(默认:"BGM")
|
||||
- `Sfx` - 音效音频总线名称(默认:"SFX")
|
||||
当前行为有几个关键点:
|
||||
|
||||
#### GodotAudioApplier
|
||||
- `Master`、`Bgm`、`Sfx` 三类音量都来自 `AudioSettings`
|
||||
- 应用前会把线性音量限制在 `0.0001f ~ 1f`,再转换成分贝
|
||||
- 如果找不到对应 bus,当前实现只会 `GD.PushWarning(...)`,不会抛异常中断整个设置流程
|
||||
|
||||
音频设置应用器,负责将音频设置应用到 Godot 引擎的音频总线系统。
|
||||
`AudioBusMap` 默认值是:
|
||||
|
||||
**功能:**
|
||||
- `Master`
|
||||
- `BGM`
|
||||
- `SFX`
|
||||
|
||||
- 应用音量设置到指定音频总线
|
||||
- 处理音量格式转换(线性值到分贝)
|
||||
- 音频总线存在性检查和警告
|
||||
如果项目里的 Godot Audio Bus 命名不同,需要在注册 applicator 时替换映射,而不是改写 applicator 本身。
|
||||
|
||||
#### GodotAudioSettings
|
||||
### `GodotGraphicsSettings`
|
||||
|
||||
Godot 音频设置实现类,接收 AudioSettings 配置并实现 IApplyAbleSettings 接口,负责将音频配置应用到 Godot 音频系统。
|
||||
`GodotGraphicsSettings` 从 `ISettingsModel` 读取 `GraphicsSettings`,并把结果同步到 `DisplayServer`:
|
||||
|
||||
**实现关系:**
|
||||
- `Fullscreen = true` 时切到 `ExclusiveFullscreen`
|
||||
- 同时把 `Borderless` flag 设为 `true`
|
||||
- `Fullscreen = false` 时切回窗口模式,设置窗口尺寸,并按主屏尺寸重新居中
|
||||
|
||||
```
|
||||
AudioSettings (配置数据)
|
||||
↓ [组合]
|
||||
GodotAudioSettings (Godot 特定实现) → IApplyAbleSettings (可应用设置接口)
|
||||
```
|
||||
当前实现没有扩展到分辨率档位之外的图形质量、渲染后端或平台特定显示策略。本页不再把这些未实现能力写成既成事实。
|
||||
|
||||
**功能:**
|
||||
### `GodotLocalizationSettings`
|
||||
|
||||
- 接收 AudioSettings 配置对象和 AudioBusMap 总线映射
|
||||
- 实现 `ApplyAsync()` 方法,将音量设置应用到指定音频总线
|
||||
- 支持自定义音频总线映射
|
||||
- 自动处理音量格式转换(线性值到分贝)
|
||||
`GodotLocalizationSettings` 负责把 `LocalizationSettings.Language` 同时同步到:
|
||||
|
||||
### 图形设置系统
|
||||
- Godot `TranslationServer.SetLocale(...)`
|
||||
- GFramework `ILocalizationManager.SetLanguage(...)`
|
||||
|
||||
#### GodotGraphicsSettings
|
||||
这一步依赖 `LocalizationMap` 把“用户可见语言值”拆成两套目标值:
|
||||
|
||||
Godot 图形设置实现类,继承自 GraphicsSettings 并实现 IApplyAbleSettings。
|
||||
- Godot locale,例如 `zh_CN`
|
||||
- 框架语言码,例如 `zhs`
|
||||
|
||||
**功能:**
|
||||
当前默认映射是:
|
||||
|
||||
- 分辨率设置和窗口尺寸调整
|
||||
- 全屏模式切换
|
||||
- 窗口位置自动居中
|
||||
- 多显示器支持
|
||||
- `简体中文` -> Godot `zh_CN`,框架 `zhs`
|
||||
- `English` -> Godot `en`,框架 `eng`
|
||||
|
||||
### 本地化设置系统
|
||||
`GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs` 已覆盖三条关键边界:
|
||||
|
||||
#### LocalizationMap
|
||||
- 英文会同步到 `en` / `eng`
|
||||
- 简体中文会同步到 `zh_CN` / `zhs`
|
||||
- 未知语言值会稳定回退到英文,而不是让 Godot locale 与框架语言状态分裂
|
||||
|
||||
本地化映射配置类,用于把设置系统中保存的用户可见语言值解析为:
|
||||
如果当前架构上下文里解析不到 `ILocalizationManager`,Godot locale 仍会被设置,只是不会额外同步框架语言管理器。
|
||||
|
||||
- Godot `TranslationServer` 使用的 locale
|
||||
- GFramework `ILocalizationManager` 使用的语言码
|
||||
## 最小接入路径
|
||||
|
||||
默认映射如下:
|
||||
|
||||
- `"简体中文"` -> Godot `zh_CN`,框架语言码 `zhs`
|
||||
- `"English"` -> Godot `en`,框架语言码 `eng`
|
||||
|
||||
未知语言值会稳定回退到英文,避免重启后出现设置值与运行时语言状态不一致。
|
||||
|
||||
#### GodotLocalizationSettings
|
||||
|
||||
Godot 本地化设置实现类,负责把 `LocalizationSettings` 同时应用到 Godot 引擎与 GFramework 本地化管理器。
|
||||
|
||||
**功能:**
|
||||
|
||||
- 将语言设置应用到 `TranslationServer.SetLocale(...)`
|
||||
- 同步 `ILocalizationManager.SetLanguage(...)`
|
||||
- 通过统一映射避免 Godot locale 与框架语言码分裂
|
||||
|
||||
## 架构设计
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[AudioSettings] --> B[GodotAudioSettings]
|
||||
C[GraphicsSettings] --> D[GodotGraphicsSettings]
|
||||
E[LocalizationSettings] --> F[GodotLocalizationSettings]
|
||||
G[IApplyAbleSettings] --> B
|
||||
G --> D
|
||||
G --> F
|
||||
|
||||
H[AudioBusMap] --> B
|
||||
I[LocalizationMap] --> F
|
||||
|
||||
B --> J[AudioServer API]
|
||||
D --> K[DisplayServer API]
|
||||
F --> L[TranslationServer API]
|
||||
F --> M[ILocalizationManager]
|
||||
|
||||
N[SettingsSystem] --> O[ApplyAsync Method]
|
||||
O --> B
|
||||
O --> D
|
||||
O --> F
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 音频设置配置
|
||||
|
||||
#### 基本音频设置
|
||||
当前消费者 `ai-libs/CoreGrid` 的接法,是先注册 `SettingsModel<ISettingsDataRepository>`,再把 Godot applicator
|
||||
挂进去:
|
||||
|
||||
```csharp
|
||||
// 创建音频配置数据
|
||||
var settings = new AudioSettings
|
||||
{
|
||||
MasterVolume = 0.8f, // 80% 主音量
|
||||
BgmVolume = 0.6f, // 60% 背景音乐音量
|
||||
SfxVolume = 0.9f // 90% 音效音量
|
||||
};
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Abstractions.Setting;
|
||||
using GFramework.Game.Setting;
|
||||
using GFramework.Godot.Setting;
|
||||
using GFramework.Godot.Setting.Data;
|
||||
|
||||
// 创建 Godot 音频设置应用器
|
||||
var audioSettings = new GodotAudioSettings(settings, new AudioBusMap());
|
||||
var settingsDataRepository = architecture.Context.GetUtility<ISettingsDataRepository>();
|
||||
|
||||
// 应用设置
|
||||
await audioSettings.ApplyAsync();
|
||||
architecture.RegisterModel(
|
||||
new SettingsModel<ISettingsDataRepository>(
|
||||
new SettingDataLocationProvider(),
|
||||
settingsDataRepository)
|
||||
.Also(it =>
|
||||
{
|
||||
it.RegisterApplicator(new GodotAudioSettings(it, new AudioBusMap()))
|
||||
.RegisterApplicator(new GodotGraphicsSettings(it))
|
||||
.RegisterApplicator(new GodotLocalizationSettings(it, new LocalizationMap()));
|
||||
}));
|
||||
```
|
||||
|
||||
#### 自定义音频总线映射
|
||||
这条接法说明了当前边界:
|
||||
|
||||
- 设置数据和生命周期由 `SettingsModel` 管
|
||||
- `GodotAudioSettings` / `GodotGraphicsSettings` / `GodotLocalizationSettings` 只是 applicator
|
||||
- 保存、加载和迁移仍然走 `ISettingsDataRepository`、`SettingsModel.InitializeAsync()`、`SaveAllAsync()` 等 `Game`
|
||||
family 入口
|
||||
|
||||
## 运行时使用方式
|
||||
|
||||
业务代码通常不会直接 new 一次 applicator 然后立即调用,而是通过 `ISettingsSystem` 或 `ISettingsModel` 触发应用:
|
||||
|
||||
```csharp
|
||||
// 自定义音频总线映射
|
||||
var customBusMap = new AudioBusMap
|
||||
{
|
||||
Master = "Master_Bus",
|
||||
Bgm = "Background_Music",
|
||||
Sfx = "Sound_Effects"
|
||||
};
|
||||
using GFramework.Game.Abstractions.Setting;
|
||||
using GFramework.Godot.Setting;
|
||||
|
||||
// 创建音频配置
|
||||
var settings = new AudioSettings
|
||||
{
|
||||
MasterVolume = 0.7f,
|
||||
BgmVolume = 0.5f,
|
||||
SfxVolume = 0.8f
|
||||
};
|
||||
|
||||
// 使用自定义总线映射应用设置
|
||||
var audioSettings = new GodotAudioSettings(settings, customBusMap);
|
||||
await audioSettings.ApplyAsync();
|
||||
```
|
||||
|
||||
#### 通过设置系统使用
|
||||
|
||||
```csharp
|
||||
// 注册音频设置到设置模型
|
||||
var settingsModel = this.GetModel<ISettingsModel>();
|
||||
var audioSettingsData = settingsModel.Get<AudioSettings>();
|
||||
audioSettingsData.MasterVolume = 0.8f;
|
||||
audioSettingsData.BgmVolume = 0.6f;
|
||||
audioSettingsData.SfxVolume = 0.9f;
|
||||
var audioData = settingsModel.GetData<AudioSettings>();
|
||||
audioData.MasterVolume = 0.8f;
|
||||
audioData.BgmVolume = 0.6f;
|
||||
audioData.SfxVolume = 0.9f;
|
||||
|
||||
// 创建 Godot 音频设置应用器
|
||||
var godotAudioSettings = new GodotAudioSettings(audioSettingsData, new AudioBusMap());
|
||||
await godotAudioSettings.ApplyAsync();
|
||||
var settingsSystem = this.GetSystem<ISettingsSystem>();
|
||||
await settingsSystem.Apply<GodotAudioSettings>();
|
||||
```
|
||||
|
||||
### 图形设置配置
|
||||
对图形和语言设置的调用方式相同,区别只是 applicator 类型不同。
|
||||
|
||||
#### 基本图形设置
|
||||
## 当前边界
|
||||
|
||||
```csharp
|
||||
// 创建图形设置
|
||||
var graphicsSettings = new GodotGraphicsSettings
|
||||
{
|
||||
ResolutionWidth = 1920,
|
||||
ResolutionHeight = 1080,
|
||||
Fullscreen = true
|
||||
};
|
||||
- 这三个类型都不是设置数据对象;它们读取的是 `AudioSettings`、`GraphicsSettings`、`LocalizationSettings`
|
||||
- 它们不负责设置持久化;是否保存到文件由 `ISettingsDataRepository` 和存储层决定
|
||||
- `ApplyAsync()` 当前都只是同步推进 Godot 引擎调用后返回 `Task.CompletedTask`,不会启动后台工作线程
|
||||
- `GodotAudioSettings` 依赖项目里已经存在对应 bus 名称;缺失时只会警告,不会帮你自动创建总线
|
||||
- `GodotGraphicsSettings` 当前只覆盖窗口模式、尺寸和居中,不等于一个完整的图形选项系统
|
||||
- `GodotLocalizationSettings` 解决的是“用户语言值 -> Godot locale / 框架语言码”双向对齐,不负责翻译资源本身的组织方式
|
||||
|
||||
// 应用设置
|
||||
await graphicsSettings.ApplyAsync();
|
||||
```
|
||||
## 什么时候应该改看别的入口
|
||||
|
||||
#### 窗口模式切换
|
||||
### 先理解设置模型和仓库
|
||||
|
||||
```csharp
|
||||
public class DisplayManager : Node
|
||||
{
|
||||
private GodotGraphicsSettings _graphicsSettings;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_graphicsSettings = new GodotGraphicsSettings();
|
||||
}
|
||||
|
||||
public async Task ToggleFullscreen()
|
||||
{
|
||||
_graphicsSettings.Fullscreen = !_graphicsSettings.Fullscreen;
|
||||
await _graphicsSettings.ApplyAsync();
|
||||
}
|
||||
|
||||
public async Task SetResolution(int width, int height)
|
||||
{
|
||||
_graphicsSettings.ResolutionWidth = width;
|
||||
_graphicsSettings.ResolutionHeight = height;
|
||||
_graphicsSettings.Fullscreen = false; // 窗口化时自动关闭全屏
|
||||
await _graphicsSettings.ApplyAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
如果你想先理解 `ISettingsData`、`IResetApplyAbleSettings`、`SettingsModel`、`SettingsSystem` 与设置迁移,先看
|
||||
[`../game/setting.md`](../game/setting.md)。
|
||||
|
||||
#### 预设分辨率配置
|
||||
### 先理解设置如何被持久化
|
||||
|
||||
```csharp
|
||||
public class ResolutionPresets
|
||||
{
|
||||
public static readonly (int width, int height)[] CommonResolutions =
|
||||
{
|
||||
(1920, 1080), // Full HD
|
||||
(2560, 1440), // QHD
|
||||
(3840, 2160), // 4K
|
||||
(1280, 720), // HD
|
||||
(1366, 768), // 常见笔记本分辨率
|
||||
};
|
||||
|
||||
public static async Task ApplyResolution(GodotGraphicsSettings settings, int width, int height)
|
||||
{
|
||||
settings.ResolutionWidth = width;
|
||||
settings.ResolutionHeight = height;
|
||||
settings.Fullscreen = false;
|
||||
await settings.ApplyAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
如果你关注的是统一设置文件、备份、数据位置和底层存储实现,应该回到:
|
||||
|
||||
## API 详细说明
|
||||
- [`../game/storage.md`](../game/storage.md)
|
||||
- [Godot 存储系统](./storage.md)
|
||||
|
||||
### AudioBusMap
|
||||
本页只补 Godot 宿主如何“应用”设置,不重复维护一份完整设置系统手册。
|
||||
|
||||
```csharp
|
||||
public sealed class AudioBusMap
|
||||
{
|
||||
public string Master { get; init; } = "Master";
|
||||
public string Bgm { get; init; } = "BGM";
|
||||
public string Sfx { get; init; } = "SFX";
|
||||
}
|
||||
```
|
||||
## 继续阅读
|
||||
|
||||
**特点:**
|
||||
|
||||
- 使用 `init` 属性,创建后不可修改
|
||||
- 提供合理的默认值
|
||||
- 支持对象初始化语法
|
||||
|
||||
### GodotAudioSettings
|
||||
|
||||
```csharp
|
||||
public class GodotAudioSettings(AudioSettings settings, AudioBusMap busMap) : IApplyAbleSettings
|
||||
{
|
||||
public Task ApplyAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**构造函数参数:**
|
||||
|
||||
- `settings` - AudioSettings 配置对象,包含音量设置
|
||||
- `busMap` - AudioBusMap 对象,定义音频总线映射
|
||||
|
||||
**Apply 方法实现:**
|
||||
|
||||
```csharp
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
SetBus(busMap.Master, settings.MasterVolume);
|
||||
SetBus(busMap.Bgm, settings.BgmVolume);
|
||||
SetBus(busMap.Sfx, settings.SfxVolume);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
### GodotGraphicsSettings
|
||||
|
||||
```csharp
|
||||
public class GodotGraphicsSettings : GraphicsSettings, IApplyAbleSettings
|
||||
{
|
||||
public Task ApplyAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**Apply 方法功能:**
|
||||
|
||||
- 设置窗口边框标志
|
||||
- 切换窗口模式(窗口化/全屏)
|
||||
- 调整窗口尺寸
|
||||
- 自动居中窗口
|
||||
|
||||
## 技术实现细节
|
||||
|
||||
### 音频音量转换
|
||||
|
||||
Godot 音频系统使用分贝(dB)作为音量单位,而我们通常使用线性值(0-1):
|
||||
|
||||
```csharp
|
||||
// 线性值到分贝转换
|
||||
float linearVolume = 0.5f; // 50% 音量
|
||||
float dbVolume = Mathf.LinearToDb(linearVolume); // 转换为分贝
|
||||
|
||||
// 应用到音频总线
|
||||
AudioServer.SetBusVolumeDb(busIndex, dbVolume);
|
||||
```
|
||||
|
||||
### 音量限制和保护
|
||||
|
||||
为避免完全静音(-inf dB),应用了最小音量限制:
|
||||
|
||||
```csharp
|
||||
float clampedVolume = Mathf.Clamp(linear, 0.0001f, 1f);
|
||||
float dbVolume = Mathf.LinearToDb(clampedVolume);
|
||||
```
|
||||
|
||||
### 窗口管理
|
||||
|
||||
#### 全屏模式
|
||||
|
||||
```csharp
|
||||
// 设置全屏
|
||||
DisplayServer.WindowSetMode(DisplayServer.WindowMode.ExclusiveFullscreen);
|
||||
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, true);
|
||||
```
|
||||
|
||||
#### 窗口化模式
|
||||
|
||||
```csharp
|
||||
// 设置窗口化
|
||||
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
|
||||
DisplayServer.WindowSetSize(newSize);
|
||||
|
||||
// 居中窗口
|
||||
var screen = DisplayServer.GetPrimaryScreen();
|
||||
var screenSize = DisplayServer.ScreenGetSize(screen);
|
||||
var position = (screenSize - newSize) / 2;
|
||||
DisplayServer.WindowSetPosition(position);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 音频设置管理
|
||||
|
||||
#### 音量变化平滑过渡
|
||||
|
||||
```csharp
|
||||
public class AudioManager : Node
|
||||
{
|
||||
private Tween _volumeTween;
|
||||
|
||||
public async Task SmoothVolumeTransition(float targetMasterVolume, float duration = 1.0f)
|
||||
{
|
||||
var currentVolume = AudioServer.GetBusVolumeDb(AudioServer.GetBusIndex("Master"));
|
||||
var currentLinear = Mathf.DbToLinear(currentVolume);
|
||||
|
||||
_volumeTween?.Kill();
|
||||
_volumeTween = CreateTween();
|
||||
|
||||
_volumeTween.TweenMethod(
|
||||
new Callable(this, nameof(SetMasterVolume)),
|
||||
currentLinear,
|
||||
targetMasterVolume,
|
||||
duration
|
||||
);
|
||||
}
|
||||
|
||||
private async void SetMasterVolume(float linearVolume)
|
||||
{
|
||||
var settings = new AudioSettings { MasterVolume = linearVolume };
|
||||
var audioSettings = new GodotAudioSettings(settings, new AudioBusMap());
|
||||
await audioSettings.ApplyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// 使用自定义总线映射的平滑过渡
|
||||
public class CustomAudioManager : Node
|
||||
{
|
||||
private Tween _volumeTween;
|
||||
private AudioBusMap _customBusMap;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_customBusMap = new AudioBusMap
|
||||
{
|
||||
Master = "Master_Bus",
|
||||
Bgm = "Background_Music",
|
||||
Sfx = "Sound_Effects"
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SmoothVolumeTransition(float targetMasterVolume, float duration = 1.0f)
|
||||
{
|
||||
var settings = new AudioSettings { MasterVolume = targetMasterVolume };
|
||||
var currentVolume = AudioServer.GetBusVolumeDb(AudioServer.GetBusIndex(_customBusMap.Master));
|
||||
var currentLinear = Mathf.DbToLinear(currentVolume);
|
||||
|
||||
_volumeTween?.Kill();
|
||||
_volumeTween = CreateTween();
|
||||
|
||||
_volumeTween.TweenMethod(
|
||||
new Callable(this, nameof(SetMasterVolume)),
|
||||
currentLinear,
|
||||
targetMasterVolume,
|
||||
duration
|
||||
);
|
||||
}
|
||||
|
||||
private async void SetMasterVolume(float linearVolume)
|
||||
{
|
||||
var audioSettingsData = new AudioSettings { MasterVolume = linearVolume };
|
||||
var audioSettings = new GodotAudioSettings(audioSettingsData, _customBusMap);
|
||||
await audioSettings.ApplyAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 音频设置验证
|
||||
|
||||
```csharp
|
||||
public static class AudioSettingsValidator
|
||||
{
|
||||
public static bool ValidateBusNames(AudioBusMap busMap)
|
||||
{
|
||||
var masterIndex = AudioServer.GetBusIndex(busMap.Master);
|
||||
var bgmIndex = AudioServer.GetBusIndex(busMap.Bgm);
|
||||
var sfxIndex = AudioServer.GetBusIndex(busMap.Sfx);
|
||||
|
||||
return masterIndex >= 0 && bgmIndex >= 0 && sfxIndex >= 0;
|
||||
}
|
||||
|
||||
public static void LogMissingBuses(AudioBusMap busMap)
|
||||
{
|
||||
if (AudioServer.GetBusIndex(busMap.Master) < 0)
|
||||
GD.PrintErr($"Master bus not found: {busMap.Master}");
|
||||
|
||||
if (AudioServer.GetBusIndex(busMap.Bgm) < 0)
|
||||
GD.PrintErr($"BGM bus not found: {busMap.Bgm}");
|
||||
|
||||
if (AudioServer.GetBusIndex(busMap.Sfx) < 0)
|
||||
GD.PrintErr($"SFX bus not found: {busMap.Sfx}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 图形设置管理
|
||||
|
||||
#### 分辨率变更安全检查
|
||||
|
||||
```csharp
|
||||
public static class DisplayValidator
|
||||
{
|
||||
public static bool IsResolutionSupported(int width, int height)
|
||||
{
|
||||
var screen = DisplayServer.GetPrimaryScreen();
|
||||
var screenSize = DisplayServer.ScreenGetSize(screen);
|
||||
|
||||
return width <= screenSize.x && height <= screenSize.y;
|
||||
}
|
||||
|
||||
public static (int width, int height) GetMaxSafeResolution()
|
||||
{
|
||||
var screen = DisplayServer.GetPrimaryScreen();
|
||||
var screenSize = DisplayServer.ScreenGetSize(screen);
|
||||
|
||||
return ((int)screenSize.x, (int)screenSize.y);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 图形设置持久化
|
||||
|
||||
```csharp
|
||||
public class GraphicsSettingsManager : Node
|
||||
{
|
||||
private const string SettingsKey = "graphics_settings";
|
||||
private GodotGraphicsSettings _settings;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var storage = new GodotFileStorage(new JsonSerializer());
|
||||
|
||||
try
|
||||
{
|
||||
_settings = storage.Read<GodotGraphicsSettings>(SettingsKey);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
_settings = new GodotGraphicsSettings
|
||||
{
|
||||
ResolutionWidth = 1920,
|
||||
ResolutionHeight = 1080,
|
||||
Fullscreen = false
|
||||
};
|
||||
SaveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveSettings()
|
||||
{
|
||||
var storage = new GodotFileStorage(new JsonSerializer());
|
||||
storage.Write(SettingsKey, _settings);
|
||||
}
|
||||
|
||||
public async Task ApplyAndSave()
|
||||
{
|
||||
await _settings.ApplyAsync();
|
||||
SaveSettings();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
### 1. 音频设置应用
|
||||
|
||||
- 音频总线查找是 O(1) 操作
|
||||
- 音量转换计算开销很小
|
||||
- 建议批量应用多个音量设置
|
||||
|
||||
### 2. 图形设置应用
|
||||
|
||||
- 窗口操作需要系统调用,相对较慢
|
||||
- 分辨率变更可能触发窗口重建
|
||||
- 避免频繁切换显示模式
|
||||
|
||||
### 3. 设置持久化
|
||||
|
||||
- 使用异步文件 I/O
|
||||
- 考虑设置变更防抖机制
|
||||
- 压缩设置文件以减少 I/O 开销
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 音频总线未找到
|
||||
|
||||
```
|
||||
错误:Audio bus not found: CustomBGM
|
||||
解决:确保在 Godot 项目中创建了对应的音频总线
|
||||
```
|
||||
|
||||
#### 2. 分辨率设置无效
|
||||
|
||||
```
|
||||
错误:分辨率无法设置到指定值
|
||||
解决:检查分辨率是否超出显示器支持范围
|
||||
```
|
||||
|
||||
#### 3. 全屏模式问题
|
||||
|
||||
```
|
||||
错误:全屏切换失败
|
||||
解决:检查是否在调试器中运行,某些全屏模式在调试时可能不可用
|
||||
```
|
||||
|
||||
### 调试技巧
|
||||
|
||||
#### 音频调试
|
||||
|
||||
```csharp
|
||||
// 打印所有音频总线信息
|
||||
for (int i = 0; i < AudioServer.GetBusCount(); i++)
|
||||
{
|
||||
var name = AudioServer.GetBusName(i);
|
||||
var volume = AudioServer.GetBusVolumeDb(i);
|
||||
GD.Print($"Bus {i}: {name} ({volume} dB)");
|
||||
}
|
||||
```
|
||||
|
||||
#### 图形调试
|
||||
|
||||
```csharp
|
||||
// 打印当前显示信息
|
||||
var screen = DisplayServer.GetPrimaryScreen();
|
||||
var screenSize = DisplayServer.ScreenGetSize(screen);
|
||||
var windowSize = DisplayServer.WindowGetSize();
|
||||
var windowPos = DisplayServer.WindowGetPosition();
|
||||
var windowMode = DisplayServer.WindowGetMode();
|
||||
|
||||
GD.Print($"Screen: {screenSize}");
|
||||
GD.Print($"Window: {windowSize} at {windowPos}");
|
||||
GD.Print($"Mode: {windowMode}");
|
||||
```
|
||||
1. [Godot 运行时集成](./index.md)
|
||||
2. [Game 设置系统](../game/setting.md)
|
||||
3. [Godot 存储系统](./storage.md)
|
||||
4. [Godot 集成教程](../tutorials/godot-integration.md)
|
||||
|
||||
@ -1,275 +1,138 @@
|
||||
# 存储模块 (Storage Module)
|
||||
---
|
||||
title: Godot 存储系统
|
||||
description: 以当前 GFramework.Godot 源码与 CoreGrid 接线为准,说明 GodotFileStorage 的职责、路径边界和最小接入方式。
|
||||
---
|
||||
|
||||
## 概述
|
||||
# Godot 存储系统
|
||||
|
||||
存储模块是 GFramework.Godot 的核心存储实现,专门为 Godot 引擎设计的文件存储系统。该模块支持 Godot 的虚拟路径系统(如
|
||||
`res://` 和 `user://`),并提供了按键级别的细粒度锁机制来保证线程安全。
|
||||
`GFramework.Godot` 在存储这一层提供的核心入口只有 `GodotFileStorage`。
|
||||
|
||||
## 核心类
|
||||
它实现 `GFramework.Game` 侧统一的 `IStorage` 契约,负责把序列化后的读写、目录列举和路径处理接到 Godot 的
|
||||
`res://`、`user://` 和普通文件系统路径上,而不是另外提供一套独立的“Godot 专属存档框架”。
|
||||
|
||||
### GodotFileStorage
|
||||
## 当前公开入口
|
||||
|
||||
Godot 特化的文件存储实现,实现了 `IStorage` 接口。
|
||||
### `GodotFileStorage`
|
||||
|
||||
**主要特性:**
|
||||
`GodotFileStorage` 的当前职责比较集中:
|
||||
|
||||
- ✅ Godot 虚拟路径支持(`res://`, `user://`)
|
||||
- ✅ 线程安全(按键级别的细粒度锁)
|
||||
- ✅ 同步/异步读写操作
|
||||
- ✅ 自动创建目录结构
|
||||
- ❌ 删除操作(Delete 方法未实现)
|
||||
- 对外暴露 `IStorage` 约定的 `Read`、`Write`、`Exists`、`Delete`、目录列举与目录创建能力
|
||||
- 识别并保留 Godot 虚拟路径:`res://`、`user://`
|
||||
- 对普通文件系统路径做段级清理,并拒绝包含 `..` 的非法 key
|
||||
- 使用 `IAsyncKeyLockManager` 对“绝对路径 / Godot 路径”做按 key 细粒度串行化
|
||||
|
||||
## 功能特性
|
||||
构造函数默认会在未注入锁管理器时创建内部 `AsyncKeyLockManager`。这意味着:
|
||||
|
||||
### 路径处理
|
||||
- 同一个 `GodotFileStorage` 实例内,不同文件可以并发访问
|
||||
- 同一个目标路径的读写 / 删除会被串行化
|
||||
- 锁作用域只限当前进程内的当前实例,不是跨进程文件锁
|
||||
|
||||
该存储系统支持三种路径类型:
|
||||
## 路径语义
|
||||
|
||||
#### 1. Godot 资源路径 (`res://`)
|
||||
### `res://`
|
||||
|
||||
- **用途**:存储游戏资源文件
|
||||
- **特点**:只读,包含在游戏构建中
|
||||
- **示例**:`res://config/game_settings.json`
|
||||
`res://` 更适合作为只读资源或配置源目录。
|
||||
|
||||
#### 2. Godot 用户数据路径 (`user://`)
|
||||
当前实现不会阻止你把它传给 `ReadAsync`、`ExistsAsync` 之类的方法,但在导出后的 Godot 项目里,`res://`
|
||||
通常不应被当作用户可写存储根目录。存档、设置和运行时缓存应优先落到 `user://`。
|
||||
|
||||
- **用途**:存储用户数据、存档、配置等
|
||||
- **特点**:可读写,游戏可访问的用户目录
|
||||
- **示例**:`user://saves/save_001.dat`
|
||||
### `user://`
|
||||
|
||||
#### 3. 普通文件系统路径
|
||||
`user://` 是当前推荐的可写路径:
|
||||
|
||||
- **用途**:存储临时文件或调试数据
|
||||
- **特点**:完整的文件系统访问
|
||||
- **示例**:`C:/Games/MyGame/logs/debug.log`
|
||||
- 用户设置
|
||||
- 存档
|
||||
- 运行时缓存
|
||||
- 导出后仍需要读写的 JSON / YAML / 二进制数据
|
||||
|
||||
### 路径验证与清理
|
||||
如果调用 `ListDirectoriesAsync()` 或 `ListFilesAsync()` 时传入空字符串,当前实现会默认从 `user://` 根开始列举。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[输入路径] --> B{包含 ".." ?}
|
||||
B -->|是| C[抛出异常]
|
||||
B -->|否| D{是 Godot 路径?}
|
||||
D -->|是| E[直接使用]
|
||||
D -->|否| F[清理路径段]
|
||||
F --> G[替换无效字符]
|
||||
G --> H[创建目录结构]
|
||||
H --> I[返回绝对路径]
|
||||
C --> J[结束]
|
||||
E --> J
|
||||
I --> J
|
||||
```
|
||||
### 普通文件系统路径
|
||||
|
||||
### 线程安全机制
|
||||
当 key 不是 Godot 路径时,`GodotFileStorage` 会:
|
||||
|
||||
每个文件路径都有独立的锁对象,确保:
|
||||
1. 把 `\` 统一成 `/`
|
||||
2. 拒绝包含 `..` 的 key
|
||||
3. 按路径段清理非法文件名字符
|
||||
4. 在写入或建目录前自动补父目录
|
||||
|
||||
1. **细粒度锁** - 不同文件可以并发访问
|
||||
2. **避免死锁** - 锁的获取顺序一致
|
||||
3. **高性能** - 减少锁竞争
|
||||
这条路径更适合测试、桌面工具链或显式指定外部目录的宿主环境,不建议在项目业务层自己重新拼装一套路径清理逻辑。
|
||||
|
||||
## API 接口
|
||||
## 最小接入路径
|
||||
|
||||
### IStorage 接口
|
||||
当前消费者 `ai-libs/CoreGrid` 的接法是先注册同一个序列化器和存储实例,再让设置仓库、存档仓库等上层组件复用它:
|
||||
|
||||
```csharp
|
||||
public interface IStorage
|
||||
{
|
||||
// 读取操作
|
||||
T Read<T>(string key);
|
||||
T Read<T>(string key, T defaultValue);
|
||||
Task<T> ReadAsync<T>(string key);
|
||||
|
||||
// 写入操作
|
||||
void Write<T>(string key, T value);
|
||||
Task WriteAsync<T>(string key, T value);
|
||||
|
||||
// 检查存在性
|
||||
bool Exists(string key);
|
||||
Task<bool> ExistsAsync(string key);
|
||||
|
||||
// 删除操作(未实现)
|
||||
void Delete(string key);
|
||||
}
|
||||
using GFramework.Core.Abstractions.Serializer;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Storage;
|
||||
using GFramework.Godot.Storage;
|
||||
using Godot;
|
||||
|
||||
var jsonSerializer = new JsonSerializer();
|
||||
architecture.RegisterUtility<ISerializer>(jsonSerializer);
|
||||
|
||||
var storage = new GodotFileStorage(jsonSerializer);
|
||||
architecture.RegisterUtility<IStorage>(storage);
|
||||
|
||||
architecture.RegisterUtility(new UnifiedSettingsDataRepository(
|
||||
storage,
|
||||
jsonSerializer,
|
||||
new DataRepositoryOptions
|
||||
{
|
||||
BasePath = ProjectSettings.GetSetting("application/config/save/setting_path").AsString(),
|
||||
AutoBackup = true
|
||||
}));
|
||||
|
||||
architecture.RegisterUtility<ISaveRepository<GameSaveData>>(new SaveRepository<GameSaveData>(
|
||||
storage,
|
||||
new SaveConfiguration
|
||||
{
|
||||
SaveRoot = ProjectSettings.GetSetting("application/config/save/save_path").AsString(),
|
||||
SaveSlotPrefix = ProjectSettings.GetSetting("application/config/save/save_slot_prefix").AsString(),
|
||||
SaveFileName = ProjectSettings.GetSetting("application/config/save/save_file_name").AsString()
|
||||
}));
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
这里的分工是:
|
||||
|
||||
### 基本使用
|
||||
- `GodotFileStorage` 负责底层 key -> 文件读写
|
||||
- `UnifiedSettingsDataRepository` 负责设置节聚合与持久化
|
||||
- `SaveRepository<TSaveData>` 负责存档结构和保存槽位语义
|
||||
|
||||
```csharp
|
||||
// 创建存储实例(需要序列化器)
|
||||
var serializer = new JsonSerializer(); // 或其他序列化器
|
||||
var storage = new GodotFileStorage(serializer);
|
||||
不要把 `GodotFileStorage` 本身写成“设置系统”或“存档系统”的 owner。
|
||||
|
||||
// 写入用户数据
|
||||
var userData = new UserData
|
||||
{
|
||||
PlayerName = "Alice",
|
||||
Level = 5,
|
||||
Score = 1000
|
||||
};
|
||||
storage.Write("user://player.dat", userData);
|
||||
## 什么时候应该改看别的入口
|
||||
|
||||
// 读取用户数据
|
||||
var loadedData = storage.Read<UserData>("user://player.dat");
|
||||
Console.WriteLine($"Player: {loadedData.PlayerName}, Level: {loadedData.Level}");
|
||||
```
|
||||
### 配置 YAML / schema 文本加载
|
||||
|
||||
### 异步操作
|
||||
如果你的目标是读取 `res://` 下的 YAML 配置,并在导出态同步到运行时缓存,请优先看
|
||||
[`../game/config-system.md`](../game/config-system.md) 里的 `GodotYamlConfigLoader` 接法。
|
||||
|
||||
```csharp
|
||||
// 异步写入游戏配置
|
||||
var config = new GameConfig
|
||||
{
|
||||
Resolution = "1920x1080",
|
||||
Fullscreen = true,
|
||||
Volume = 0.8f
|
||||
};
|
||||
await storage.WriteAsync("user://config.json", config);
|
||||
这类场景的重点不是通用键值存储,而是:
|
||||
|
||||
// 异步读取配置
|
||||
var loadedConfig = await storage.ReadAsync<GameConfig>("user://config.json");
|
||||
```
|
||||
- `res://` 与 `user://` 缓存切换
|
||||
- 生成器表元数据
|
||||
- 热重载可用性边界
|
||||
|
||||
### 不同路径类型使用
|
||||
### 通用存储契约
|
||||
|
||||
```csharp
|
||||
// 读取游戏资源(只读)
|
||||
var levelData = storage.Read<LevelData>("res://levels/level_001.json");
|
||||
如果你想先理解 `IStorage`、`ScopedStorage`、`FileStorage` 和统一数据仓库的宿主无关语义,应先看
|
||||
[`../game/storage.md`](../game/storage.md)。
|
||||
|
||||
// 存储用户存档
|
||||
var saveData = new SaveData { /* ... */ };
|
||||
storage.Write("user://saves/slot_001.dat", saveData);
|
||||
本页只补 Godot 宿主差异,不重复维护一份跨宿主 API 手册。
|
||||
|
||||
// 存储调试信息(普通路径)
|
||||
var debugLog = new DebugLog { /* ... */ };
|
||||
storage.Write("logs/debug_" + DateTime.UtcNow.Ticks + ".json", debugLog);
|
||||
```
|
||||
## 当前边界
|
||||
|
||||
### 存在性检查
|
||||
- 同步 `Read` / `Write` / `Delete` / `Exists` 只是对异步方法的阻塞包装;在带同步上下文的宿主里,优先使用异步 API
|
||||
- `GodotFileStorage` 不负责文件扩展名约定、作用域前缀或保存槽位策略,这些属于上层 repository / scoped storage
|
||||
- 路径安全只覆盖当前 key 的格式校验与路径段清理,不代替业务层的目录规划
|
||||
- 当前实现支持目录列举与目录创建,但没有额外的“监视目录变化”或“自动迁移目录结构”能力
|
||||
|
||||
```csharp
|
||||
// 检查文件是否存在
|
||||
if (storage.Exists("user://settings.json"))
|
||||
{
|
||||
var settings = storage.Read<AppSettings>("user://settings.json");
|
||||
// 使用设置...
|
||||
}
|
||||
else
|
||||
{
|
||||
// 使用默认设置
|
||||
var defaultSettings = new AppSettings();
|
||||
storage.Write("user://settings.json", defaultSettings);
|
||||
}
|
||||
```
|
||||
## 继续阅读
|
||||
|
||||
### 带默认值的读取
|
||||
|
||||
```csharp
|
||||
// 尝试读取,如果文件不存在则返回默认值
|
||||
var settings = storage.Read("user://user_prefs.json", new UserPrefs
|
||||
{
|
||||
Language = "en",
|
||||
Volume = 1.0f,
|
||||
Difficulty = 1
|
||||
});
|
||||
```
|
||||
|
||||
## 路径扩展
|
||||
|
||||
该模块使用了路径扩展方法:
|
||||
|
||||
```csharp
|
||||
public static class GodotPathExtensions
|
||||
{
|
||||
public static bool IsUserPath(this string path);
|
||||
public static bool IsResPath(this string path);
|
||||
public static bool IsGodotPath(this string path);
|
||||
}
|
||||
```
|
||||
|
||||
**使用示例:**
|
||||
|
||||
```csharp
|
||||
string path1 = "user://save.dat";
|
||||
string path2 = "res://config.json";
|
||||
string path3 = "C:/temp/file.txt";
|
||||
|
||||
Console.WriteLine(path1.IsGodotPath()); // true
|
||||
Console.WriteLine(path1.IsUserPath()); // true
|
||||
Console.WriteLine(path2.IsResPath()); // true
|
||||
Console.WriteLine(path3.IsGodotPath()); // false
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
### 1. 锁机制
|
||||
|
||||
- 每个文件路径独立锁,减少锁竞争
|
||||
- 读写操作串行化,避免数据损坏
|
||||
|
||||
### 2. 文件访问
|
||||
|
||||
- Godot 虚拟路径使用 `FileAccess` API
|
||||
- 普通路径使用标准 .NET 文件 I/O
|
||||
- 自动创建目录结构
|
||||
|
||||
### 3. 内存使用
|
||||
|
||||
- 锁对象使用 `ConcurrentDictionary` 管理
|
||||
- 锁对象按需创建,避免内存泄漏
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 常见异常
|
||||
|
||||
1. **ArgumentException** - 路径参数无效
|
||||
- 空路径
|
||||
- 包含 ".." 的路径
|
||||
- 无效的存储键
|
||||
|
||||
2. **FileNotFoundException** - 文件不存在
|
||||
- 读取不存在的文件时抛出
|
||||
|
||||
3. **IOException** - 文件操作失败
|
||||
- 写入权限不足
|
||||
- 磁盘空间不足
|
||||
|
||||
### 错误处理示例
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var data = storage.Read<UserData>("user://save.dat");
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
Console.WriteLine("存档文件不存在,创建新的存档");
|
||||
var newSave = new UserData();
|
||||
storage.Write("user://save.dat", newSave);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"读取存档失败: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **路径选择**
|
||||
- 游戏资源使用 `res://`
|
||||
- 用户数据使用 `user://`
|
||||
- 调试/临时文件使用普通路径
|
||||
|
||||
2. **异常处理**
|
||||
- 总是处理 `FileNotFoundException`
|
||||
- 使用带默认值的 `Read` 重载方法
|
||||
|
||||
3. **性能优化**
|
||||
- 批量读写时使用异步方法
|
||||
- 避免频繁的小文件操作
|
||||
|
||||
4. **序列化器选择**
|
||||
- JSON:人类可读,调试友好
|
||||
- 二进制:性能更好,文件更小
|
||||
1. [Godot 运行时集成](./index.md)
|
||||
2. [Game 存储系统](../game/storage.md)
|
||||
3. [Game 配置系统](../game/config-system.md)
|
||||
4. [Godot 集成教程](../tutorials/godot-integration.md)
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
---
|
||||
title: Source Generators
|
||||
description: 按模块梳理 GFramework 当前发布的源码生成器包、运行时归属与推荐选包入口。
|
||||
---
|
||||
|
||||
# Source Generators
|
||||
|
||||
`Source Generators` 栏目对应 `GFramework` 当前按模块拆分发布的编译期工具链。
|
||||
@ -23,7 +28,7 @@ GFramework 当前发布的生成器包是:
|
||||
- 选择 `GeWuYou.GFramework.Game.SourceGenerators`
|
||||
- 想让 CQRS handler registry 在编译期生成,缩小运行时反射扫描范围:
|
||||
- 选择 `GeWuYou.GFramework.Cqrs.SourceGenerators`
|
||||
- 想在 Godot 项目里生成 AutoLoad / Input Action 入口,或减少节点与信号样板代码:
|
||||
- 想在 Godot 项目里生成 AutoLoad / Input Action 入口、节点 / 信号样板,或补齐 Scene/UI 包装与导出集合注册辅助:
|
||||
- 选择 `GeWuYou.GFramework.Godot.SourceGenerators`
|
||||
|
||||
## 与运行时的关系
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user