From 7fda40de42767d9df3b3d06535681ffffcf14cef Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:58:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于 YAML 的配置加载器支持 - 添加 JSON Schema 结构验证功能 - 实现一对象一文件的目录组织方式 - 提供运行时只读查询接口 - 添加 Source Generator 生成配置类型和表包装 - 实现 VS Code 插件配置浏览和编辑功能 - 添加开发期热重载支持 - 实现跨表引用校验机制 - 提供完整的配置系统文档说明 --- .../Config/YamlConfigLoaderTests.cs | 139 +++++++++++++++++- .../Config/YamlConfigHotReloadOptions.cs | 27 ++++ GFramework.Game/Config/YamlConfigLoader.cs | 71 ++++++++- .../YamlConfigTableRegistrationOptions.cs | 57 +++++++ docs/zh-CN/game/config-system.md | 31 ++++ 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 GFramework.Game/Config/YamlConfigHotReloadOptions.cs create mode 100644 GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 476f3dea..bd81952f 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -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!; + /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -32,8 +33,6 @@ public class YamlConfigLoaderTests } } - private string _rootPath = null!; - /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -71,6 +70,68 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证加载器支持通过选项对象注册带 schema 校验的配置表。 + /// + [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( + "monster", + "monster", + static config => config.Id) + { + SchemaRelativePath = "schemas/monster.schema.json" + }); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("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)); + }); + } + + /// + /// 验证加载器会拒绝空的配置表注册选项对象。 + /// + [Test] + public void RegisterTable_Should_Throw_When_Options_Are_Null() + { + var loader = new YamlConfigLoader(_rootPath); + + Assert.Throws(() => + loader.RegisterTable(null!)); + } + /// /// 验证注册的配置目录不存在时会抛出清晰错误。 /// @@ -999,6 +1060,78 @@ public class YamlConfigLoaderTests } } + /// + /// 验证热重载支持通过选项对象配置回调和防抖延迟。 + /// + [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( + "monster", + "monster", + static config => config.Id) + { + SchemaRelativePath = "schemas/monster.schema.json" + }); + var registry = new ConfigRegistry(); + await loader.LoadAsync(registry); + + var reloadTaskSource = new TaskCompletionSource(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("monster").Get(1).Hp, Is.EqualTo(25)); + }); + } + finally + { + hotReload.UnRegister(); + } + } + /// /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。 /// diff --git a/GFramework.Game/Config/YamlConfigHotReloadOptions.cs b/GFramework.Game/Config/YamlConfigHotReloadOptions.cs new file mode 100644 index 00000000..52b17006 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigHotReloadOptions.cs @@ -0,0 +1,27 @@ +namespace GFramework.Game.Config; + +/// +/// 描述开发期热重载的可选行为。 +/// 该选项对象集中承载回调和防抖等可扩展参数, +/// 以避免后续继续在 +/// 上堆叠额外重载。 +/// +public sealed class YamlConfigHotReloadOptions +{ + /// + /// 获取或设置单个配置表重载成功后的可选回调。 + /// + public Action? OnTableReloaded { get; init; } + + /// + /// 获取或设置单个配置表重载失败后的可选回调。 + /// 当失败来自加载器本身时,异常通常为 。 + /// + public Action? OnTableReloadFailed { get; init; } + + /// + /// 获取或设置文件系统事件的防抖延迟。 + /// 默认值为 200 毫秒,用于吸收编辑器保存时的短时间重复触发。 + /// + public TimeSpan DebounceDelay { get; init; } = TimeSpan.FromMilliseconds(200); +} \ No newline at end of file diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 5034f2cf..dd219ff2 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -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> _lastSuccessfulDependencies = @@ -100,8 +99,32 @@ public sealed class YamlConfigLoader : IConfigLoader Action? onTableReloaded = null, Action? onTableReloadFailed = null, TimeSpan? debounceDelay = null) + { + return EnableHotReload( + registry, + new YamlConfigHotReloadOptions + { + OnTableReloaded = onTableReloaded, + OnTableReloadFailed = onTableReloadFailed, + DebounceDelay = debounceDelay ?? DefaultHotReloadDebounceDelay + }); + } + + /// + /// 启用开发期热重载,并通过选项对象集中配置回调和防抖行为。 + /// 该入口用于减少继续堆叠位置参数重载的需要, + /// 也为未来扩展过滤策略或日志钩子预留稳定形态。 + /// + /// 要被热重载更新的配置注册表。 + /// 热重载配置选项;为空时使用默认选项。 + /// 用于停止热重载监听的注销句柄。 + /// 为空时抛出。 + 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 loadedTables) @@ -142,7 +165,11 @@ public sealed class YamlConfigLoader : IConfigLoader IEqualityComparer? comparer = null) where TKey : notnull { - return RegisterTableCore(tableName, relativePath, null, keySelector, comparer); + return RegisterTable( + new YamlConfigTableRegistrationOptions(tableName, relativePath, keySelector) + { + Comparer = comparer + }); } /// @@ -166,7 +193,35 @@ public sealed class YamlConfigLoader : IConfigLoader IEqualityComparer? comparer = null) where TKey : notnull { - return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer); + return RegisterTable( + new YamlConfigTableRegistrationOptions(tableName, relativePath, keySelector) + { + SchemaRelativePath = schemaRelativePath, + Comparer = comparer + }); + } + + /// + /// 使用选项对象注册一个 YAML 配置表定义。 + /// 该入口集中承载配置目录、schema 路径、主键提取器和比较器, + /// 以避免未来继续为新增开关叠加更多重载。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表注册选项。 + /// 当前加载器实例,以便链式注册。 + /// 为空时抛出。 + public YamlConfigLoader RegisterTable(YamlConfigTableRegistrationOptions options) + where TKey : notnull + { + ArgumentNullException.ThrowIfNull(options); + + return RegisterTableCore( + options.TableName, + options.RelativePath, + options.SchemaRelativePath, + options.KeySelector, + options.Comparer); } private YamlConfigLoader RegisterTableCore( diff --git a/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs new file mode 100644 index 00000000..16f1a7b7 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs @@ -0,0 +1,57 @@ +namespace GFramework.Game.Config; + +/// +/// 描述一个 YAML 配置表注册项的参数集合。 +/// 该选项对象用于替代不断增加的位置参数重载, +/// 让消费者在启用 schema 校验、主键比较器或未来扩展项时仍能保持调用点可读。 +/// +/// 配置主键类型。 +/// 配置值类型。 +public sealed class YamlConfigTableRegistrationOptions + where TKey : notnull +{ + /// + /// 使用最小必需参数创建配置表注册选项。 + /// + /// 运行时配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 为 null 时抛出。 + public YamlConfigTableRegistrationOptions( + string tableName, + string relativePath, + Func keySelector) + { + ArgumentNullException.ThrowIfNull(keySelector); + + TableName = tableName; + RelativePath = relativePath; + KeySelector = keySelector; + } + + /// + /// 获取运行时配置表名称。 + /// + public string TableName { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + public string RelativePath { get; } + + /// + /// 获取相对配置根目录的 schema 文件路径。 + /// 当该值为空时,当前注册项不会启用 schema 校验。 + /// + public string? SchemaRelativePath { get; init; } + + /// + /// 获取配置项主键提取器。 + /// + public Func KeySelector { get; } + + /// + /// 获取可选的主键比较器。 + /// + public IEqualityComparer? Comparer { get; init; } +} \ No newline at end of file diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 8fdc20ea..b390afd7 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -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( + "monster", + "monster", + static config => config.Id) + { + SchemaRelativePath = "schemas/monster.schema.json" + }); +``` + ## 运行时校验行为 绑定 schema 的表在加载时会拒绝以下问题: