Compare commits

...

3 Commits

Author SHA1 Message Date
GeWuYou
7fda40de42 feat(game): 添加游戏内容配置系统实现
- 实现基于 YAML 的配置加载器支持
- 添加 JSON Schema 结构验证功能
- 实现一对象一文件的目录组织方式
- 提供运行时只读查询接口
- 添加 Source Generator 生成配置类型和表包装
- 实现 VS Code 插件配置浏览和编辑功能
- 添加开发期热重载支持
- 实现跨表引用校验机制
- 提供完整的配置系统文档说明
2026-04-03 22:58:05 +08:00
GeWuYou
ecf2309e11 docs(game): 添加游戏内容配置系统文档
- 介绍面向静态游戏内容的 AI-First 配表方案
- 说明配置系统管理怪物、物品、技能、任务等静态内容数据
- 描述 YAML 作为配置源文件和 JSON Schema 作为结构描述的支持
- 展示推荐的目录结构和 Schema 示例
- 提供完整的接入模板包括 csproj 配置、启动引导和运行时读取
- 详述运行时校验行为和跨表引用机制
- 说明开发期热重载功能和 VS Code 工具集成
- 列出当前限制和独立 Config Studio 评估结论
2026-04-03 22:01:10 +08:00
GeWuYou
ec4e2edeab feat(config): 添加AI-First游戏内容配置系统
- 实现YAML配置文件与JSON Schema结构描述支持
- 提供一对象一文件的目录组织方式
- 集成Source Generator生成配置类型和表包装代码
- 添加VS Code插件支持配置浏览和表单编辑功能
- 实现运行时只读查询和开发期热重载机制
- 支持跨表引用校验和轻量元数据复用
- 添加配置加载异常诊断和批量编辑入口
2026-04-03 21:17:39 +08:00
10 changed files with 931 additions and 17 deletions

View File

@ -101,6 +101,8 @@ public class GeneratedConfigConsumerIntegrationTests
Is.EqualTo(MonsterConfigBindings.ConfigRelativePath));
Assert.That(MonsterConfigBindings.Metadata.SchemaRelativePath,
Is.EqualTo(MonsterConfigBindings.SchemaRelativePath));
Assert.That(MonsterConfigBindings.References.All, Is.Empty);
Assert.That(MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out _), Is.False);
Assert.That(table.Count, Is.EqualTo(2));
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(table.Get(2).Hp, Is.EqualTo(30));

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

@ -132,7 +132,8 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "string",
"description": "Monster reference id.",
"minLength": 2,
"maxLength": 32
"maxLength": 32,
"x-gframework-ref-table": "monster"
}
}
}

View File

@ -117,6 +117,7 @@ public sealed partial class MonsterConfig
/// <remarks>
/// Schema property path: 'phases[].monsterId'.
/// Constraints: minLength = 2, maxLength = 32.
/// References config table: 'monster'.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string MonsterId { get; set; } = string.Empty;

View File

@ -9,6 +9,51 @@ namespace GFramework.Game.Config.Generated;
/// </summary>
public static class MonsterConfigBindings
{
/// <summary>
/// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.
/// </summary>
public readonly struct ReferenceMetadata
{
/// <summary>
/// Initializes one generated cross-table reference descriptor.
/// </summary>
/// <param name="displayPath">Schema property path.</param>
/// <param name="referencedTableName">Referenced runtime table name.</param>
/// <param name="valueSchemaType">Schema scalar type used by the reference value.</param>
/// <param name="isCollection">Whether the property stores multiple reference keys.</param>
public ReferenceMetadata(
string displayPath,
string referencedTableName,
string valueSchemaType,
bool isCollection)
{
DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));
ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));
ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));
IsCollection = isCollection;
}
/// <summary>
/// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.
/// </summary>
public string DisplayPath { get; }
/// <summary>
/// Gets the runtime registration name of the referenced config table.
/// </summary>
public string ReferencedTableName { get; }
/// <summary>
/// Gets the schema scalar type used by the referenced key value.
/// </summary>
public string ValueSchemaType { get; }
/// <summary>
/// Gets a value indicating whether the property stores multiple reference keys.
/// </summary>
public bool IsCollection { get; }
}
/// <summary>
/// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.
/// </summary>
@ -55,6 +100,67 @@ public static class MonsterConfigBindings
/// </summary>
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
/// <summary>
/// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.
/// </summary>
public static class References
{
/// <summary>
/// Gets generated reference metadata for schema property path 'dropItems'.
/// </summary>
public static readonly ReferenceMetadata DropItems = new(
"dropItems",
"item",
"string",
true);
/// <summary>
/// Gets generated reference metadata for schema property path 'phases[].monsterId'.
/// </summary>
public static readonly ReferenceMetadata PhasesItemsMonsterId = new(
"phases[].monsterId",
"monster",
"string",
false);
/// <summary>
/// Gets all generated cross-table reference descriptors for the current schema.
/// </summary>
public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]
{
DropItems,
PhasesItemsMonsterId,
});
/// <summary>
/// Tries to resolve generated reference metadata by schema property path.
/// </summary>
/// <param name="displayPath">Schema property path.</param>
/// <param name="metadata">Resolved generated reference metadata when the path is known; otherwise the default value.</param>
/// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>
public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)
{
if (displayPath is null)
{
throw new global::System.ArgumentNullException(nameof(displayPath));
}
if (string.Equals(displayPath, "dropItems", global::System.StringComparison.Ordinal))
{
metadata = DropItems;
return true;
}
if (string.Equals(displayPath, "phases[].monsterId", global::System.StringComparison.Ordinal))
{
metadata = PhasesItemsMonsterId;
return true;
}
metadata = default;
return false;
}
}
/// <summary>
/// Registers the generated config table using the schema-derived runtime conventions.
/// </summary>

View File

@ -634,6 +634,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var getMethodName = $"Get{schema.EntityName}Table";
var tryGetMethodName = $"TryGet{schema.EntityName}Table";
var bindingsClassName = $"{schema.EntityName}ConfigBindings";
var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray();
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
@ -650,6 +651,59 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($"public static class {bindingsClassName}");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public readonly struct ReferenceMetadata");
builder.AppendLine(" {");
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Initializes one generated cross-table reference descriptor.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
builder.AppendLine(" /// <param name=\"referencedTableName\">Referenced runtime table name.</param>");
builder.AppendLine(
" /// <param name=\"valueSchemaType\">Schema scalar type used by the reference value.</param>");
builder.AppendLine(
" /// <param name=\"isCollection\">Whether the property stores multiple reference keys.</param>");
builder.AppendLine(" public ReferenceMetadata(");
builder.AppendLine(" string displayPath,");
builder.AppendLine(" string referencedTableName,");
builder.AppendLine(" string valueSchemaType,");
builder.AppendLine(" bool isCollection)");
builder.AppendLine(" {");
builder.AppendLine(
" DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));");
builder.AppendLine(
" ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));");
builder.AppendLine(
" ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));");
builder.AppendLine(" IsCollection = isCollection;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string DisplayPath { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the runtime registration name of the referenced config table.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ReferencedTableName { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the schema scalar type used by the referenced key value.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ValueSchemaType { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets a value indicating whether the property stores multiple reference keys.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public bool IsCollection { get; }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.");
builder.AppendLine(" /// </summary>");
@ -704,6 +758,97 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public static class References");
builder.AppendLine(" {");
foreach (var referenceSpec in referenceSpecs)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Gets generated reference metadata for schema property path '{EscapeXmlDocumentation(referenceSpec.DisplayPath)}'.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public static readonly ReferenceMetadata {referenceSpec.MemberName} = new(");
builder.AppendLine(
$" {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)},");
builder.AppendLine(
$" {SymbolDisplay.FormatLiteral(referenceSpec.ReferencedTableName, true)},");
builder.AppendLine(
$" {SymbolDisplay.FormatLiteral(referenceSpec.ValueSchemaType, true)},");
builder.AppendLine(
$" {(referenceSpec.IsCollection ? "true" : "false")});");
builder.AppendLine();
}
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Gets all generated cross-table reference descriptors for the current schema.");
builder.AppendLine(" /// </summary>");
if (referenceSpecs.Length == 0)
{
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.Empty<ReferenceMetadata>();");
}
else
{
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]");
builder.AppendLine(" {");
foreach (var referenceSpec in referenceSpecs)
{
builder.AppendLine($" {referenceSpec.MemberName},");
}
builder.AppendLine(" });");
}
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
builder.AppendLine(
" /// <param name=\"metadata\">Resolved generated reference metadata when the path is known; otherwise the default value.</param>");
builder.AppendLine(
" /// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>");
builder.AppendLine(
" public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)");
builder.AppendLine(" {");
builder.AppendLine(" if (displayPath is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(displayPath));");
builder.AppendLine(" }");
builder.AppendLine();
if (referenceSpecs.Length == 0)
{
builder.AppendLine(" metadata = default;");
builder.AppendLine(" return false;");
}
else
{
foreach (var referenceSpec in referenceSpecs)
{
builder.AppendLine(
$" if (string.Equals(displayPath, {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)}, global::System.StringComparison.Ordinal))");
builder.AppendLine(" {");
builder.AppendLine($" metadata = {referenceSpec.MemberName};");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
}
builder.AppendLine();
builder.AppendLine(" metadata = default;");
builder.AppendLine(" return false;");
}
builder.AppendLine(" }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers the generated config table using the schema-derived runtime conventions.");
builder.AppendLine(" /// </summary>");
@ -782,6 +927,78 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
/// <summary>
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
/// </summary>
/// <param name="rootObject">根对象模型。</param>
/// <returns>生成期引用元数据集合。</returns>
private static IEnumerable<GeneratedReferenceSpec> CollectReferenceSpecs(SchemaObjectSpec rootObject)
{
var memberNameCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties))
{
var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath);
if (memberNameCounts.ContainsKey(baseMemberName))
{
memberNameCounts[baseMemberName]++;
baseMemberName =
$"{baseMemberName}{memberNameCounts[baseMemberName].ToString(CultureInfo.InvariantCulture)}";
}
else
{
memberNameCounts[baseMemberName] = 0;
}
yield return new GeneratedReferenceSpec(
baseMemberName,
referenceSeed.DisplayPath,
referenceSeed.ReferencedTableName,
referenceSeed.ValueSchemaType,
referenceSeed.IsCollection);
}
}
/// <summary>
/// 递归枚举对象树中所有带 ref-table 元数据的字段。
/// </summary>
/// <param name="properties">对象属性集合。</param>
/// <returns>原始引用字段信息。</returns>
private static IEnumerable<GeneratedReferenceSeed> EnumerateReferenceSeeds(
IEnumerable<SchemaPropertySpec> properties)
{
foreach (var property in properties)
{
if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
{
yield return new GeneratedReferenceSeed(
property.DisplayPath,
property.TypeSpec.RefTableName!,
property.TypeSpec.Kind == SchemaNodeKind.Array
? property.TypeSpec.ItemTypeSpec?.SchemaType ?? property.TypeSpec.SchemaType
: property.TypeSpec.SchemaType,
property.TypeSpec.Kind == SchemaNodeKind.Array);
}
if (property.TypeSpec.NestedObject is not null)
{
foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.NestedObject.Properties))
{
yield return nestedReference;
}
}
if (property.TypeSpec.ItemTypeSpec?.NestedObject is not null)
{
foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.ItemTypeSpec.NestedObject
.Properties))
{
yield return nestedReference;
}
}
}
}
/// <summary>
/// 递归生成配置对象类型。
/// </summary>
@ -1004,6 +1221,28 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
}
/// <summary>
/// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
/// </summary>
/// <param name="displayPath">Schema 字段路径。</param>
/// <returns>稳定的成员名。</returns>
private static string BuildReferenceMemberName(string displayPath)
{
var segments = displayPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
var builder = new StringBuilder();
foreach (var segment in segments)
{
var normalizedSegment = segment
.Replace("[]", "Items")
.Replace("[", " ")
.Replace("]", " ");
builder.Append(ToPascalCase(normalizedSegment));
}
return builder.Length == 0 ? "Reference" : builder.ToString();
}
/// <summary>
/// 为 AdditionalFiles 诊断创建文件位置。
/// </summary>
@ -1371,6 +1610,34 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
SchemaObjectSpec? NestedObject,
SchemaTypeSpec? ItemTypeSpec);
/// <summary>
/// 生成代码前的跨表引用字段种子信息。
/// </summary>
/// <param name="DisplayPath">Schema 字段路径。</param>
/// <param name="ReferencedTableName">目标表名称。</param>
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
/// <param name="IsCollection">是否为数组引用。</param>
private sealed record GeneratedReferenceSeed(
string DisplayPath,
string ReferencedTableName,
string ValueSchemaType,
bool IsCollection);
/// <summary>
/// 已分配稳定成员名的生成期跨表引用信息。
/// </summary>
/// <param name="MemberName">生成到绑定类中的成员名。</param>
/// <param name="DisplayPath">Schema 字段路径。</param>
/// <param name="ReferencedTableName">目标表名称。</param>
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
/// <param name="IsCollection">是否为数组引用。</param>
private sealed record GeneratedReferenceSpec(
string MemberName,
string DisplayPath,
string ReferencedTableName,
string ValueSchemaType,
bool IsCollection);
/// <summary>
/// 属性解析结果包装。
/// </summary>

View File

@ -82,6 +82,245 @@ dropItems:
- 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Game\GFramework.Game.csproj" />
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\GFramework.SourceGenerators\GFramework.SourceGenerators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<Import Project="..\GFramework.SourceGenerators\GeWuYou.GFramework.SourceGenerators.targets" />
</Project>
```
这段配置的作用:
- `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
<PropertyGroup>
<GFrameworkConfigSchemaDirectory>GameSchemas</GFrameworkConfigSchemaDirectory>
</PropertyGroup>
```
### 启动引导模板
推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
```csharp
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
namespace GameProject.Config;
/// <summary>
/// 负责初始化游戏内容配置运行时入口。
/// </summary>
public sealed class GameConfigBootstrap : IDisposable
{
private readonly ConfigRegistry _registry = new();
private IUnRegister? _hotReload;
/// <summary>
/// 获取当前游戏进程共享的配置注册表。
/// </summary>
public IConfigRegistry Registry => _registry;
/// <summary>
/// 从指定配置根目录加载所有已注册配置表。
/// </summary>
/// <param name="configRootPath">配置根目录。</param>
/// <param name="enableHotReload">是否启用开发期热重载。</param>
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}");
});
}
}
/// <summary>
/// 停止开发期热重载并释放相关资源。
/// </summary>
public void Dispose()
{
_hotReload?.UnRegister();
}
}
```
这段模板刻意遵循几个约定:
- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
- 由一个长生命周期对象持有 `ConfigRegistry`
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
### 运行时读取模板
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
```csharp
using GFramework.Game.Config.Generated;
namespace GameProject.Config;
/// <summary>
/// 封装游戏内容配置读取入口。
/// </summary>
public sealed class GameConfigRuntime
{
private readonly IConfigRegistry _registry;
/// <summary>
/// 使用已初始化的配置注册表创建读取入口。
/// </summary>
/// <param name="registry">配置注册表。</param>
public GameConfigRuntime(IConfigRegistry registry)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
}
/// <summary>
/// 获取指定怪物配置。
/// </summary>
/// <param name="monsterId">怪物主键。</param>
/// <returns>强类型怪物配置。</returns>
public MonsterConfig GetMonster(int monsterId)
{
return _registry.GetMonsterTable().Get(monsterId);
}
/// <summary>
/// 获取怪物配置表。
/// </summary>
/// <returns>生成的强类型表包装。</returns>
public MonsterTable GetMonsterTable()
{
return _registry.GetMonsterTable();
}
}
```
这样做的收益:
- 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string
- 后续如果你要复用配置域、schema 路径或引用元数据,可以继续依赖 `MonsterConfigBindings.Metadata`
`MonsterConfigBindings.References`
- 如果未来把配置初始化接入 `Architecture``Module`,迁移成本也更低
### 热重载模板
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
```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)
});
```
## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助:
@ -120,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 的表在加载时会拒绝以下问题:
@ -158,6 +411,21 @@ var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
- 引用目标表需要由同一个 `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 插件表单和批量编辑入口显示更友好的字段标题
@ -205,14 +473,11 @@ catch (ConfigLoadException exception)
```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")
.RegisterTable<int, MonsterConfig>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
.RegisterMonsterTable();
await loader.LoadAsync(registry);