mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
feat(game): 添加游戏内容配置系统实现
- 实现基于 YAML 的配置加载器支持 - 添加 JSON Schema 结构验证功能 - 实现一对象一文件的目录组织方式 - 提供运行时只读查询接口 - 添加 Source Generator 生成配置类型和表包装 - 实现 VS Code 插件配置浏览和编辑功能 - 添加开发期热重载支持 - 实现跨表引用校验机制 - 提供完整的配置系统文档说明
This commit is contained in:
parent
ecf2309e11
commit
7fda40de42
@ -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>
|
||||
|
||||
27
GFramework.Game/Config/YamlConfigHotReloadOptions.cs
Normal file
27
GFramework.Game/Config/YamlConfigHotReloadOptions.cs
Normal 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);
|
||||
}
|
||||
@ -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>(
|
||||
|
||||
57
GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
Normal file
57
GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
Normal 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; }
|
||||
}
|
||||
@ -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 的表在加载时会拒绝以下问题:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user