feat(game): 添加游戏内容配置系统实现

- 实现基于 YAML 的配置加载器支持
- 添加 JSON Schema 结构验证功能
- 实现一对象一文件的目录组织方式
- 提供运行时只读查询接口
- 添加 Source Generator 生成配置类型和表包装
- 实现 VS Code 插件配置浏览和编辑功能
- 添加开发期热重载支持
- 实现跨表引用校验机制
- 提供完整的配置系统文档说明
This commit is contained in:
GeWuYou 2026-04-03 22:58:05 +08:00
parent ecf2309e11
commit 7fda40de42
5 changed files with 314 additions and 11 deletions

View File

@ -1,5 +1,4 @@
using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
@ -10,6 +9,8 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class YamlConfigLoaderTests
{
private string _rootPath = null!;
/// <summary>
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
/// </summary>
@ -32,8 +33,6 @@ public class YamlConfigLoaderTests
}
}
private string _rootPath = null!;
/// <summary>
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
/// </summary>
@ -71,6 +70,68 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证加载器支持通过选项对象注册带 schema 校验的配置表。
/// </summary>
[Test]
public async Task RegisterTable_Should_Support_Options_Object()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable(
new YamlConfigTableRegistrationOptions<int, MonsterConfigStub>(
"monster",
"monster",
static config => config.Id)
{
SchemaRelativePath = "schemas/monster.schema.json"
});
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var table = registry.GetTable<int, MonsterConfigStub>("monster");
Assert.Multiple(() =>
{
Assert.That(table.Count, Is.EqualTo(1));
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(table.Get(1).Hp, Is.EqualTo(10));
});
}
/// <summary>
/// 验证加载器会拒绝空的配置表注册选项对象。
/// </summary>
[Test]
public void RegisterTable_Should_Throw_When_Options_Are_Null()
{
var loader = new YamlConfigLoader(_rootPath);
Assert.Throws<ArgumentNullException>(() =>
loader.RegisterTable<int, MonsterConfigStub>(null!));
}
/// <summary>
/// 验证注册的配置目录不存在时会抛出清晰错误。
/// </summary>
@ -999,6 +1060,78 @@ public class YamlConfigLoaderTests
}
}
/// <summary>
/// 验证热重载支持通过选项对象配置回调和防抖延迟。
/// </summary>
[Test]
public async Task EnableHotReload_Should_Support_Options_Object()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable(
new YamlConfigTableRegistrationOptions<int, MonsterConfigStub>(
"monster",
"monster",
static config => config.Id)
{
SchemaRelativePath = "schemas/monster.schema.json"
});
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var reloadTaskSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var hotReload = loader.EnableHotReload(
registry,
new YamlConfigHotReloadOptions
{
OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName),
DebounceDelay = TimeSpan.FromMilliseconds(150)
});
try
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 25
""");
var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(tableName, Is.EqualTo("monster"));
Assert.That(registry.GetTable<int, MonsterConfigStub>("monster").Get(1).Hp, Is.EqualTo(25));
});
}
finally
{
hotReload.UnRegister();
}
}
/// <summary>
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
/// </summary>

View File

@ -0,0 +1,27 @@
namespace GFramework.Game.Config;
/// <summary>
/// 描述开发期热重载的可选行为。
/// 该选项对象集中承载回调和防抖等可扩展参数,
/// 以避免后续继续在 <see cref="YamlConfigLoader.EnableHotReload(GFramework.Game.Abstractions.Config.IConfigRegistry,YamlConfigHotReloadOptions?)" />
/// 上堆叠额外重载。
/// </summary>
public sealed class YamlConfigHotReloadOptions
{
/// <summary>
/// 获取或设置单个配置表重载成功后的可选回调。
/// </summary>
public Action<string>? OnTableReloaded { get; init; }
/// <summary>
/// 获取或设置单个配置表重载失败后的可选回调。
/// 当失败来自加载器本身时,异常通常为 <see cref="GFramework.Game.Abstractions.Config.ConfigLoadException" />。
/// </summary>
public Action<string, Exception>? OnTableReloadFailed { get; init; }
/// <summary>
/// 获取或设置文件系统事件的防抖延迟。
/// 默认值为 200 毫秒,用于吸收编辑器保存时的短时间重复触发。
/// </summary>
public TimeSpan DebounceDelay { get; init; } = TimeSpan.FromMilliseconds(200);
}

View File

@ -1,8 +1,5 @@
using System.Diagnostics;
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace GFramework.Game.Config;
@ -20,6 +17,8 @@ public sealed class YamlConfigLoader : IConfigLoader
private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage =
"Schema relative path cannot be null or whitespace.";
private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200);
private readonly IDeserializer _deserializer;
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
@ -100,8 +99,32 @@ public sealed class YamlConfigLoader : IConfigLoader
Action<string>? onTableReloaded = null,
Action<string, Exception>? onTableReloadFailed = null,
TimeSpan? debounceDelay = null)
{
return EnableHotReload(
registry,
new YamlConfigHotReloadOptions
{
OnTableReloaded = onTableReloaded,
OnTableReloadFailed = onTableReloadFailed,
DebounceDelay = debounceDelay ?? DefaultHotReloadDebounceDelay
});
}
/// <summary>
/// 启用开发期热重载,并通过选项对象集中配置回调和防抖行为。
/// 该入口用于减少继续堆叠位置参数重载的需要,
/// 也为未来扩展过滤策略或日志钩子预留稳定形态。
/// </summary>
/// <param name="registry">要被热重载更新的配置注册表。</param>
/// <param name="options">热重载配置选项;为空时使用默认选项。</param>
/// <returns>用于停止热重载监听的注销句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
public IUnRegister EnableHotReload(
IConfigRegistry registry,
YamlConfigHotReloadOptions? options)
{
ArgumentNullException.ThrowIfNull(registry);
options ??= new YamlConfigHotReloadOptions();
return new HotReloadSession(
_rootPath,
@ -109,9 +132,9 @@ public sealed class YamlConfigLoader : IConfigLoader
registry,
_registrations,
_lastSuccessfulDependencies,
onTableReloaded,
onTableReloadFailed,
debounceDelay ?? TimeSpan.FromMilliseconds(200));
options.OnTableReloaded,
options.OnTableReloadFailed,
options.DebounceDelay);
}
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
@ -142,7 +165,11 @@ public sealed class YamlConfigLoader : IConfigLoader
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return RegisterTableCore(tableName, relativePath, null, keySelector, comparer);
return RegisterTable(
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
{
Comparer = comparer
});
}
/// <summary>
@ -166,7 +193,35 @@ public sealed class YamlConfigLoader : IConfigLoader
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer);
return RegisterTable(
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
{
SchemaRelativePath = schemaRelativePath,
Comparer = comparer
});
}
/// <summary>
/// 使用选项对象注册一个 YAML 配置表定义。
/// 该入口集中承载配置目录、schema 路径、主键提取器和比较器,
/// 以避免未来继续为新增开关叠加更多重载。
/// </summary>
/// <typeparam name="TKey">配置主键类型。</typeparam>
/// <typeparam name="TValue">配置值类型。</typeparam>
/// <param name="options">配置表注册选项。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
public YamlConfigLoader RegisterTable<TKey, TValue>(YamlConfigTableRegistrationOptions<TKey, TValue> options)
where TKey : notnull
{
ArgumentNullException.ThrowIfNull(options);
return RegisterTableCore(
options.TableName,
options.RelativePath,
options.SchemaRelativePath,
options.KeySelector,
options.Comparer);
}
private YamlConfigLoader RegisterTableCore<TKey, TValue>(

View File

@ -0,0 +1,57 @@
namespace GFramework.Game.Config;
/// <summary>
/// 描述一个 YAML 配置表注册项的参数集合。
/// 该选项对象用于替代不断增加的位置参数重载,
/// 让消费者在启用 schema 校验、主键比较器或未来扩展项时仍能保持调用点可读。
/// </summary>
/// <typeparam name="TKey">配置主键类型。</typeparam>
/// <typeparam name="TValue">配置值类型。</typeparam>
public sealed class YamlConfigTableRegistrationOptions<TKey, TValue>
where TKey : notnull
{
/// <summary>
/// 使用最小必需参数创建配置表注册选项。
/// </summary>
/// <param name="tableName">运行时配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="keySelector">配置项主键提取器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
public YamlConfigTableRegistrationOptions(
string tableName,
string relativePath,
Func<TValue, TKey> keySelector)
{
ArgumentNullException.ThrowIfNull(keySelector);
TableName = tableName;
RelativePath = relativePath;
KeySelector = keySelector;
}
/// <summary>
/// 获取运行时配置表名称。
/// </summary>
public string TableName { get; }
/// <summary>
/// 获取相对配置根目录的子目录。
/// </summary>
public string RelativePath { get; }
/// <summary>
/// 获取相对配置根目录的 schema 文件路径。
/// 当该值为空时,当前注册项不会启用 schema 校验。
/// </summary>
public string? SchemaRelativePath { get; init; }
/// <summary>
/// 获取配置项主键提取器。
/// </summary>
public Func<TValue, TKey> KeySelector { get; }
/// <summary>
/// 获取可选的主键比较器。
/// </summary>
public IEqualityComparer<TKey>? Comparer { get; init; }
}

View File

@ -304,6 +304,23 @@ var hotReload = loader.EnableHotReload(
- 热重载失败时应优先依赖 `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)
});
```
## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助:
@ -342,6 +359,20 @@ var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
如果你需要自定义目录、表名或 key selector仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
```csharp
var loader = new YamlConfigLoader("config-root")
.RegisterTable(
new YamlConfigTableRegistrationOptions<int, MonsterConfig>(
"monster",
"monster",
static config => config.Id)
{
SchemaRelativePath = "schemas/monster.schema.json"
});
```
## 运行时校验行为
绑定 schema 的表在加载时会拒绝以下问题: