# 游戏内容配置系统
> 面向静态游戏内容的 AI-First 配表方案
该配置系统用于管理怪物、物品、技能、任务等静态内容数据。
它与 `GFramework.Core.Configuration` 不同,后者面向运行时键值配置;它也不同于 `GFramework.Game.Setting`,后者面向玩家设置和持久化。
## 当前能力
- YAML 作为配置源文件
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`minLength`、`maxLength`
- Source Generator 生成配置类型、表包装和注册/访问辅助
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 推荐目录结构
```text
GameProject/
├─ config/
│ ├─ monster/
│ │ ├─ slime.yaml
│ │ └─ goblin.yaml
│ └─ item/
│ └─ potion.yaml
├─ schemas/
│ ├─ monster.schema.json
│ └─ item.schema.json
```
## Schema 示例
```json
{
"title": "Monster Config",
"description": "定义怪物静态配置。",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {
"type": "integer",
"description": "怪物主键。"
},
"name": {
"type": "string",
"title": "Monster Name",
"description": "怪物显示名。",
"default": "Slime"
},
"hp": {
"type": "integer",
"default": 10
},
"rarity": {
"type": "string",
"enum": ["common", "rare", "boss"]
},
"dropItems": {
"type": "array",
"description": "掉落物品表主键。",
"items": {
"type": "string",
"enum": ["potion", "slime_gel", "bomb"]
},
"x-gframework-ref-table": "item"
}
}
}
```
## YAML 示例
```yaml
id: 1
name: Slime
hp: 10
dropItems:
- potion
- slime_gel
```
## 推荐接入模板
如果你准备在一个真实游戏项目里首次接入这套配置系统,建议直接采用下面这套目录与启动模板,而不是零散拼装。
### 目录模板
```text
GameProject/
├─ GameProject.csproj
├─ Config/
│ ├─ GameConfigBootstrap.cs
│ └─ GameConfigRuntime.cs
├─ config/
│ ├─ monster/
│ │ ├─ slime.yaml
│ │ └─ goblin.yaml
│ └─ item/
│ └─ potion.yaml
└─ schemas/
├─ monster.schema.json
└─ item.schema.json
```
推荐约定如下:
- `schemas/` 放所有 `*.schema.json`,由 Source Generator 自动拾取
- `config/` 放运行时加载的 YAML 数据,一对象一文件
- `Config/` 放你自己的接入代码,例如启动注册、热重载句柄和对外读取入口
### `csproj` 模板
如果你在仓库内直接用项目引用,最小模板可以写成下面这样:
```xml
net10.0
disable
enable
```
这段配置的作用:
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现
- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
如果你使用打包后的 NuGet,而不是仓库内项目引用,原则保持不变:
- 运行时项目需要引用 `GeWuYou.GFramework.Game`
- 生成器项目需要引用 `GeWuYou.GFramework.SourceGenerators`
- schema 目录默认仍然是 `schemas/`
如果你的 schema 不放在默认目录,可以在项目文件里覆盖:
```xml
GameSchemas
```
### 启动引导模板
推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
```csharp
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
namespace GameProject.Config;
///
/// 负责初始化游戏内容配置运行时入口。
///
public sealed class GameConfigBootstrap : IDisposable
{
private readonly ConfigRegistry _registry = new();
private IUnRegister? _hotReload;
///
/// 获取当前游戏进程共享的配置注册表。
///
public IConfigRegistry Registry => _registry;
///
/// 从指定配置根目录加载所有已注册配置表。
///
/// 配置根目录。
/// 是否启用开发期热重载。
public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
{
var loader = new YamlConfigLoader(configRootPath)
.RegisterMonsterTable()
.RegisterItemTable();
await loader.LoadAsync(_registry);
if (enableHotReload)
{
_hotReload = loader.EnableHotReload(
_registry,
onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
onTableReloadFailed: static (_, exception) =>
{
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
});
}
}
///
/// 停止开发期热重载并释放相关资源。
///
public void Dispose()
{
_hotReload?.UnRegister();
}
}
```
这段模板刻意遵循几个约定:
- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
- 由一个长生命周期对象持有 `ConfigRegistry`
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
### 运行时读取模板
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
```csharp
using GFramework.Game.Config.Generated;
namespace GameProject.Config;
///
/// 封装游戏内容配置读取入口。
///
public sealed class GameConfigRuntime
{
private readonly IConfigRegistry _registry;
///
/// 使用已初始化的配置注册表创建读取入口。
///
/// 配置注册表。
public GameConfigRuntime(IConfigRegistry registry)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
}
///
/// 获取指定怪物配置。
///
/// 怪物主键。
/// 强类型怪物配置。
public MonsterConfig GetMonster(int monsterId)
{
return _registry.GetMonsterTable().Get(monsterId);
}
///
/// 获取怪物配置表。
///
/// 生成的强类型表包装。
public MonsterTable GetMonsterTable()
{
return _registry.GetMonsterTable();
}
}
```
这样做的收益:
- 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string
- 后续如果你要复用配置域、schema 路径或引用元数据,可以继续依赖 `MonsterConfigBindings.Metadata` 和
`MonsterConfigBindings.References`
- 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低
### 生成查询辅助
从当前阶段开始,生成的 `*Table` 包装会为“顶层、非主键、非引用的标量字段”额外产出轻量查询辅助。
如果 `monster.schema.json` 包含顶层标量字段 `name`、`faction`,则可以直接这样使用:
```csharp
var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.FindByName("Slime");
if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
{
Console.WriteLine(firstDungeonMonster.Name);
}
```
当前生成规则刻意保持保守:
- 只为顶层标量字段生成 `FindBy*` 与 `TryFindFirstBy*`
- 主键字段继续只走 `Get / TryGet`
- 嵌套对象、对象数组、标量数组和 `x-gframework-ref-table` 字段暂不生成查询辅助
- 查询实现基于 `All()` 做线性扫描,不引入运行时索引或缓存
这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。
如果你依赖 `TryFindFirstBy*`,应当把它理解为“返回当前表快照遍历顺序下的第一个匹配项”,而不是固定排序语义。
### Architecture 推荐接入模板
如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility:
```csharp
using GFramework.Core.Architectures;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
public sealed class GameArchitecture : Architecture
{
private readonly string _configRootPath;
public GameArchitecture(string configRootPath)
{
_configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath));
}
protected override void OnInitialize()
{
var registry = RegisterUtility(new ConfigRegistry());
var loader = new YamlConfigLoader(_configRootPath)
.RegisterMonsterTable()
.RegisterItemTable();
loader.LoadAsync(registry).GetAwaiter().GetResult();
}
}
```
初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口:
```csharp
var registry = Context.GetUtility();
var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
推荐遵循以下顺序:
- 先注册 `ConfigRegistry`
- 再构造并配置 `YamlConfigLoader`
- 在 `OnInitialize()` 内完成首次 `LoadAsync`
- 初始化完成后只通过注册表和生成表包装访问配置
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。
### 热重载模板
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
```csharp
var hotReload = loader.EnableHotReload(
registry,
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
onTableReloadFailed: (tableName, exception) =>
{
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
Console.WriteLine($"Reload failed: {tableName}");
Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
});
```
建议只在开发期启用这项能力:
- 生产环境默认更适合静态加载和固定生命周期
- 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示
- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串
如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数:
```csharp
var hotReload = loader.EnableHotReload(
registry,
new YamlConfigHotReloadOptions
{
OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
OnTableReloadFailed = (tableName, exception) =>
{
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}");
},
DebounceDelay = TimeSpan.FromMilliseconds(150)
});
```
## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助:
```csharp
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable();
await loader.LoadAsync(registry);
var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
这组辅助会把以下约定固化到生成代码里:
- 配置域常量,例如 `MonsterConfigBindings.ConfigDomain`
- 表注册名,例如 `monster`
- 配置目录相对路径,例如 `monster`
- schema 相对路径,例如 `schemas/monster.schema.json`
- 主键提取逻辑,例如 `config => config.Id`
如果你希望把这些约定作为一个统一入口传递或复用,也可以优先读取 `MonsterConfigBindings.Metadata` 下的常量:
```csharp
var domain = MonsterConfigBindings.Metadata.ConfigDomain;
var tableName = MonsterConfigBindings.Metadata.TableName;
var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath;
var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
```
如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
```csharp
var loader = new YamlConfigLoader("config-root")
.RegisterTable(
new YamlConfigTableRegistrationOptions(
"monster",
"monster",
static config => config.Id)
{
SchemaRelativePath = "schemas/monster.schema.json"
});
```
## 运行时校验行为
绑定 schema 的表在加载时会拒绝以下问题:
- 缺失必填字段
- 未在 schema 中声明的未知字段
- 标量类型不匹配
- 数组元素类型不匹配
- 嵌套对象字段类型不匹配
- 对象数组元素结构不匹配
- 数值字段违反 `minimum` / `maximum`
- 字符串字段违反 `minLength` / `maxLength`
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
跨表引用当前使用最小扩展关键字:
```json
{
"type": "object",
"required": ["id", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
```
约束如下:
- 仅支持 `string`、`integer` 及其标量数组声明跨表引用
- 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry`
- 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态
如果你希望在消费者代码里复用这些跨表约定,而不是继续手写字段路径或目标表名,生成的 `*ConfigBindings` 还会暴露引用元数据:
```csharp
var allReferences = MonsterConfigBindings.References.All;
if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var reference))
{
Console.WriteLine(reference.ReferencedTableName);
Console.WriteLine(reference.ValueSchemaType);
Console.WriteLine(reference.IsCollection);
}
```
当 schema 中存在具体引用字段时,还可以直接通过生成成员访问,例如 `MonsterConfigBindings.References.DropItems`。
当前还支持以下“轻量元数据”:
- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题
- `description`:供表单提示、生成代码 XML 文档和接入说明复用
- `default`:供生成类型属性初始值和工具提示复用
- `enum`:供运行时校验、VS Code 校验和表单枚举选择复用
- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
加载失败时,`YamlConfigLoader` 会抛出 `ConfigLoadException`。你可以通过 `exception.Diagnostic` 读取稳定字段,而不必解析消息文本:
```csharp
using GFramework.Game.Abstractions.Config;
try
{
await loader.LoadAsync(registry);
}
catch (ConfigLoadException exception)
{
Console.WriteLine(exception.Diagnostic.FailureKind);
Console.WriteLine(exception.Diagnostic.TableName);
Console.WriteLine(exception.Diagnostic.YamlPath);
Console.WriteLine(exception.Diagnostic.SchemaPath);
Console.WriteLine(exception.Diagnostic.DisplayPath);
}
```
当前诊断对象会优先暴露这些字段:
- `FailureKind`
- `TableName`
- `YamlPath`
- `SchemaPath`
- `DisplayPath`
- `ReferencedTableName`
- `RawValue`
## 开发期热重载
如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载:
```csharp
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable();
await loader.LoadAsync(registry);
var hotReload = loader.EnableHotReload(
registry,
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
onTableReloadFailed: (tableName, exception) =>
{
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
Console.WriteLine($"Reload failed: {tableName}, {exception.Message}");
Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
});
```
当前热重载行为如下:
- 监听已注册表对应的配置目录
- 监听该表绑定的 schema 文件
- 检测到变更后按表粒度重载
- 若变更表被其他表通过跨表引用依赖,会联动重验受影响表
- 重载成功后替换该表在 `IConfigRegistry` 中的注册
- 重载失败时保留旧表,并通过失败回调提供诊断
这项能力默认定位为开发期工具,不承诺生产环境热更新平台语义。
## 生成器接入约定
配置生成器会从 `*.schema.json` 生成以下代码:
- 配置类型
- 表包装类型
- `YamlConfigLoader` 注册辅助
- `IConfigRegistry` 强类型访问辅助
通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`。
如果你在仓库内直接使用项目引用而不是打包后的 NuGet,请确认 schema 文件同样被加入 `AdditionalFiles`。
## VS Code 工具
仓库中的 `tools/gframework-config-tool` 当前提供以下能力:
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
对象数组编辑器当前支持:
- 新增和删除对象项
- 编辑对象项中的标量字段
- 编辑对象项中的标量数组
- 编辑对象项中的嵌套对象字段
如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
## 当前限制
以下能力尚未完全完成:
- 更完整的 JSON Schema 支持
- VS Code 中更深层对象数组嵌套的安全表单编辑器
- 更强的复杂数组与更深 schema 关键字支持
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。
## 独立 Config Studio 评估
当前阶段的结论是:`不建议立即启动独立 Config Studio`,继续以 `VS Code Extension` 作为主工具形态更合适。
当前不单独启动桌面版的原因:
- 当前已落地的能力主要仍围绕 schema 校验、轻量表单、批量编辑和 raw YAML 回退,这些都能在 VS Code 宿主里低成本迭代
- runtime、generator、tooling 之间仍在持续收敛 schema 子集和元数据语义,过早拆出桌面工具会放大版本协同成本
- 当前待补强点仍是更完整 schema 支持和复杂编辑体验,先在插件里验证真实工作流更稳妥
- 仓库当前的主要使用者仍偏开发者和技术策划,独立桌面版带来的“免开发环境”收益还不足以抵消额外维护面
只有在以下条件明显成立时,再建议启动独立 `Config Studio`:
- 主要使用者变成非开发人员,且 VS Code 安装与使用成本成为持续阻力
- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程
- 插件形态已经频繁受限于 VS Code Webview/Extension API,而不是 schema 与工作流本身
- 已经沉淀出稳定的 schema 元数据约定,能够支撑单独桌面产品的长期维护