Merge pull request #188 from GeWuYou/feat/game-content-config-system

docs(config): 添加游戏内容配置系统文档和集成测试
This commit is contained in:
gewuyou 2026-04-06 16:03:56 +08:00 committed by GitHub
commit e9e04d9792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 551 additions and 16 deletions

View File

@ -10,13 +10,13 @@ using NUnit.Framework;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证在 <see cref="Architecture" /> 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。
/// 验证在 <see cref="Architecture" /> 初始化流程中可以通过聚合注册入口加载生成配置表,并通过表访问器读取数据。
/// </summary>
[TestFixture]
public class ArchitectureConfigIntegrationTests
{
/// <summary>
/// 架构初始化期间,通过 <see cref="YamlConfigLoader" /> 注册生成表,
/// 架构初始化期间,通过 <see cref="YamlConfigLoader" /> 的聚合注册入口注册生成表,
/// 并将 <see cref="ConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
/// </summary>
[Test]
@ -147,7 +147,7 @@ public class ArchitectureConfigIntegrationTests
RegisterUtility(Registry);
var loader = new YamlConfigLoader(_configRoot)
.RegisterMonsterTable();
.RegisterAllGeneratedConfigTables();
loader.LoadAsync(Registry).GetAwaiter().GetResult();
MonsterTable = Registry.GetMonsterTable();
}

View File

@ -8,7 +8,7 @@ namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。
/// 可以直接编译并使用生成的聚合注册辅助、强类型访问入口、查询辅助与运行时加载链路。
/// </summary>
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
@ -39,7 +39,7 @@ public class GeneratedConfigConsumerIntegrationTests
/// <summary>
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
/// </summary>
[Test]
public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project()
@ -91,7 +91,7 @@ public class GeneratedConfigConsumerIntegrationTests
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader(_rootPath)
.RegisterMonsterTable();
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(registry);
@ -100,6 +100,13 @@ public class GeneratedConfigConsumerIntegrationTests
Assert.Multiple(() =>
{
Assert.That(
GeneratedConfigCatalog.Tables.Select(static metadata => metadata.TableName),
Does.Contain("monster"));
Assert.That(GeneratedConfigCatalog.TryGetByTableName("monster", out var catalogEntry), Is.True);
Assert.That(catalogEntry.ConfigDomain, Is.EqualTo("monster"));
Assert.That(catalogEntry.ConfigRelativePath, Is.EqualTo("monster"));
Assert.That(catalogEntry.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
Assert.That(MonsterConfigBindings.ConfigDomain, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster"));
Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster"));

View File

@ -168,6 +168,8 @@ public class SchemaConfigGeneratorSnapshotTests
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfigBindings.g.cs",
"MonsterConfigBindings.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "GeneratedConfigCatalog.g.cs",
"GeneratedConfigCatalog.g.txt");
}
/// <summary>
@ -212,4 +214,4 @@ public class SchemaConfigGeneratorSnapshotTests
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
}
}
}

View File

@ -351,4 +351,116 @@ public class SchemaConfigGeneratorTests
Assert.That(tableSource, Does.Not.Contain("TryFindFirstByReward("));
});
}
/// <summary>
/// 验证生成器会为当前消费者项目内的全部 schema 额外产出聚合注册入口,
/// 让 C# 启动代码可以一行注册所有生成表。
/// </summary>
[Test]
public void Run_Should_Generate_Project_Level_Registration_Catalog()
{
const string source = """
using System;
using System.Collections.Generic;
namespace GFramework.Game.Abstractions.Config
{
public interface IConfigTable
{
Type KeyType { get; }
Type ValueType { get; }
int Count { get; }
}
public interface IConfigTable<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All();
}
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return this;
}
}
}
""";
const string monsterSchema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}
""";
const string itemSchema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string" },
"rarity": { "type": "string" }
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", monsterSchema),
("item.schema.json", itemSchema));
var generatedSources = result.Results
.Single()
.GeneratedSources
.ToDictionary(
static sourceResult => sourceResult.HintName,
static sourceResult => sourceResult.SourceText.ToString(),
StringComparer.Ordinal);
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
Assert.That(generatedSources.TryGetValue("GeneratedConfigCatalog.g.cs", out var catalogSource), Is.True);
Assert.Multiple(() =>
{
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigCatalog"));
Assert.That(catalogSource, Does.Contain("public sealed class GeneratedConfigRegistrationOptions"));
Assert.That(catalogSource, Does.Contain("public static class GeneratedConfigRegistrationExtensions"));
Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer<string>? ItemComparer { get; init; }"));
Assert.That(catalogSource, Does.Contain("public global::System.Collections.Generic.IEqualityComparer<int>? MonsterComparer { get; init; }"));
Assert.That(catalogSource, Does.Contain("return RegisterAllGeneratedConfigTables(loader, options: null);"));
Assert.That(catalogSource, Does.Contain("GeneratedConfigRegistrationOptions? options"));
Assert.That(catalogSource, Does.Contain("loader.RegisterItemTable(options.ItemComparer);"));
Assert.That(catalogSource, Does.Contain("loader.RegisterMonsterTable(options.MonsterComparer);"));
Assert.That(catalogSource, Does.Contain("ItemConfigBindings.Metadata.TableName"));
Assert.That(catalogSource, Does.Contain("MonsterConfigBindings.Metadata.TableName"));
Assert.That(catalogSource, Does.Contain("public static bool TryGetByTableName(string tableName, out TableMetadata metadata)"));
});
}
}

View File

@ -0,0 +1,147 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// Provides a project-level catalog for every config table generated from the current consumer project's schemas.
/// Use this entry point when you want the C# runtime bootstrap path to register all generated tables without repeating one call per schema.
/// </summary>
public static class GeneratedConfigCatalog
{
/// <summary>
/// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime.
/// </summary>
public readonly struct TableMetadata
{
/// <summary>
/// Initializes one generated table metadata entry.
/// </summary>
/// <param name="configDomain">Logical config domain derived from the schema base name.</param>
/// <param name="tableName">Runtime registration name.</param>
/// <param name="configRelativePath">Relative YAML directory path.</param>
/// <param name="schemaRelativePath">Relative schema file path.</param>
public TableMetadata(
string configDomain,
string tableName,
string configRelativePath,
string schemaRelativePath)
{
ConfigDomain = configDomain ?? throw new global::System.ArgumentNullException(nameof(configDomain));
TableName = tableName ?? throw new global::System.ArgumentNullException(nameof(tableName));
ConfigRelativePath = configRelativePath ?? throw new global::System.ArgumentNullException(nameof(configRelativePath));
SchemaRelativePath = schemaRelativePath ?? throw new global::System.ArgumentNullException(nameof(schemaRelativePath));
}
/// <summary>
/// Gets the logical config domain derived from the schema base name.
/// </summary>
public string ConfigDomain { get; }
/// <summary>
/// Gets the runtime registration name used by <see cref="global::GFramework.Game.Config.YamlConfigLoader" />.
/// </summary>
public string TableName { get; }
/// <summary>
/// Gets the relative directory that stores YAML files for the generated config table.
/// </summary>
public string ConfigRelativePath { get; }
/// <summary>
/// Gets the relative schema file path collected by the source generator.
/// </summary>
public string SchemaRelativePath { get; }
}
/// <summary>
/// Gets metadata for every generated config table in the current consumer project.
/// </summary>
public static global::System.Collections.Generic.IReadOnlyList<TableMetadata> Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[]
{
new(
MonsterConfigBindings.Metadata.ConfigDomain,
MonsterConfigBindings.Metadata.TableName,
MonsterConfigBindings.Metadata.ConfigRelativePath,
MonsterConfigBindings.Metadata.SchemaRelativePath),
});
/// <summary>
/// Tries to resolve generated table metadata by runtime registration name.
/// </summary>
/// <param name="tableName">Runtime registration name.</param>
/// <param name="metadata">Resolved generated table metadata when the registration name exists; otherwise the default value.</param>
/// <returns><see langword="true" /> when the registration name belongs to a generated config table; otherwise <see langword="false" />.</returns>
public static bool TryGetByTableName(string tableName, out TableMetadata metadata)
{
if (tableName is null)
{
throw new global::System.ArgumentNullException(nameof(tableName));
}
if (string.Equals(tableName, MonsterConfigBindings.Metadata.TableName, global::System.StringComparison.Ordinal))
{
metadata = Tables[0];
return true;
}
metadata = default;
return false;
}
}
/// <summary>
/// Captures optional per-table registration overrides for the generated aggregate registration entry point.
/// </summary>
public sealed class GeneratedConfigRegistrationOptions
{
/// <summary>
/// Gets or sets the optional key comparer forwarded to MonsterConfigBindings.RegisterMonsterTable(global::GFramework.Game.Config.YamlConfigLoader, global::System.Collections.Generic.IEqualityComparer<int>?) when aggregate registration runs.
/// </summary>
public global::System.Collections.Generic.IEqualityComparer<int>? MonsterComparer { get; init; }
}
/// <summary>
/// Provides a single extension method that registers every generated config table discovered in the current consumer project.
/// </summary>
public static class GeneratedConfigRegistrationExtensions
{
/// <summary>
/// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow.
/// </summary>
/// <param name="loader">Target YAML config loader.</param>
/// <returns>The same loader instance after all generated table registrations have been applied.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="loader"/> is null.</exception>
public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(
this global::GFramework.Game.Config.YamlConfigLoader loader)
{
if (loader is null)
{
throw new global::System.ArgumentNullException(nameof(loader));
}
return RegisterAllGeneratedConfigTables(loader, options: null);
}
/// <summary>
/// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers.
/// </summary>
/// <param name="loader">Target YAML config loader.</param>
/// <param name="options">Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior.</param>
/// <returns>The same loader instance after all generated table registrations have been applied.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="loader"/> is null.</exception>
public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(
this global::GFramework.Game.Config.YamlConfigLoader loader,
GeneratedConfigRegistrationOptions? options)
{
if (loader is null)
{
throw new global::System.ArgumentNullException(nameof(loader));
}
options ??= new GeneratedConfigRegistrationOptions();
loader.RegisterMonsterTable(options.MonsterComparer);
return loader;
}
}

View File

@ -41,6 +41,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
$"{result.Schema.EntityName}ConfigBindings.g.cs",
SourceText.From(GenerateBindingsClass(result.Schema), Encoding.UTF8));
});
var collectedSchemas = schemaFiles.Collect();
context.RegisterSourceOutput(collectedSchemas, static (productionContext, results) =>
{
var schemas = results
.Where(static result => result.Schema is not null)
.Select(static result => result.Schema!)
.OrderBy(static schema => schema.TableRegistrationName, StringComparer.Ordinal)
.ToArray();
if (schemas.Length == 0)
{
return;
}
productionContext.AddSource(
"GeneratedConfigCatalog.g.cs",
SourceText.From(GenerateCatalogClass(schemas), Encoding.UTF8));
});
}
/// <summary>
@ -940,6 +959,216 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
/// <summary>
/// 生成项目级聚合辅助源码。
/// 该辅助把当前消费者项目内所有有效 schema 汇总为一个统一入口,
/// 以便运行时快速完成批量注册并在需要时枚举已生成的配置域元数据。
/// </summary>
/// <param name="schemas">当前编译中成功解析的 schema 集合。</param>
/// <returns>聚合辅助源码。</returns>
private static string GenerateCatalogClass(IReadOnlyList<SchemaFileSpec> schemas)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {GeneratedNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Provides a project-level catalog for every config table generated from the current consumer project's schemas.");
builder.AppendLine(
"/// Use this entry point when you want the C# runtime bootstrap path to register all generated tables without repeating one call per schema.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static class GeneratedConfigCatalog");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Describes one generated config table so bootstrap code can enumerate generated domains without re-parsing schema files at runtime.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public readonly struct TableMetadata");
builder.AppendLine(" {");
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Initializes one generated table metadata entry.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"configDomain\">Logical config domain derived from the schema base name.</param>");
builder.AppendLine(" /// <param name=\"tableName\">Runtime registration name.</param>");
builder.AppendLine(" /// <param name=\"configRelativePath\">Relative YAML directory path.</param>");
builder.AppendLine(" /// <param name=\"schemaRelativePath\">Relative schema file path.</param>");
builder.AppendLine(" public TableMetadata(");
builder.AppendLine(" string configDomain,");
builder.AppendLine(" string tableName,");
builder.AppendLine(" string configRelativePath,");
builder.AppendLine(" string schemaRelativePath)");
builder.AppendLine(" {");
builder.AppendLine(
" ConfigDomain = configDomain ?? throw new global::System.ArgumentNullException(nameof(configDomain));");
builder.AppendLine(
" TableName = tableName ?? throw new global::System.ArgumentNullException(nameof(tableName));");
builder.AppendLine(
" ConfigRelativePath = configRelativePath ?? throw new global::System.ArgumentNullException(nameof(configRelativePath));");
builder.AppendLine(
" SchemaRelativePath = schemaRelativePath ?? throw new global::System.ArgumentNullException(nameof(schemaRelativePath));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the logical config domain derived from the schema base name.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ConfigDomain { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the runtime registration name used by <see cref=\"global::GFramework.Game.Config.YamlConfigLoader\" />.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string TableName { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the relative directory that stores YAML files for the generated config table.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string ConfigRelativePath { get; }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the relative schema file path collected by the source generator.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" public string SchemaRelativePath { get; }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets metadata for every generated config table in the current consumer project.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
" public static global::System.Collections.Generic.IReadOnlyList<TableMetadata> Tables { get; } = global::System.Array.AsReadOnly(new TableMetadata[]");
builder.AppendLine(" {");
foreach (var schema in schemas)
{
builder.AppendLine(" new(");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.ConfigDomain,");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.TableName,");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.ConfigRelativePath,");
builder.AppendLine($" {schema.EntityName}ConfigBindings.Metadata.SchemaRelativePath),");
}
builder.AppendLine(" });");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"tableName\">Runtime registration name.</param>");
builder.AppendLine(
" /// <param name=\"metadata\">Resolved generated table metadata when the registration name exists; otherwise the default value.</param>");
builder.AppendLine(
" /// <returns><see langword=\"true\" /> when the registration name belongs to a generated config table; otherwise <see langword=\"false\" />.</returns>");
builder.AppendLine(" public static bool TryGetByTableName(string tableName, out TableMetadata metadata)");
builder.AppendLine(" {");
builder.AppendLine(" if (tableName is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(tableName));");
builder.AppendLine(" }");
builder.AppendLine();
for (var index = 0; index < schemas.Count; index++)
{
var schema = schemas[index];
builder.AppendLine(
$" if (string.Equals(tableName, {schema.EntityName}ConfigBindings.Metadata.TableName, global::System.StringComparison.Ordinal))");
builder.AppendLine(" {");
builder.AppendLine(
$" metadata = Tables[{index.ToString(CultureInfo.InvariantCulture)}];");
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(
"/// Captures optional per-table registration overrides for the generated aggregate registration entry point.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public sealed class GeneratedConfigRegistrationOptions");
builder.AppendLine("{");
for (var index = 0; index < schemas.Count; index++)
{
var schema = schemas[index];
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Gets or sets the optional key comparer forwarded to {schema.EntityName}ConfigBindings.Register{schema.EntityName}Table(global::GFramework.Game.Config.YamlConfigLoader, global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>?) when aggregate registration runs.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? {schema.EntityName}Comparer {{ get; init; }}");
if (index < schemas.Count - 1)
{
builder.AppendLine();
}
}
builder.AppendLine("}");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Provides a single extension method that registers every generated config table discovered in the current consumer project.");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static class GeneratedConfigRegistrationExtensions");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers all generated config tables using schema-derived conventions so bootstrap code can stay one-line even as schemas grow.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"loader\">Target YAML config loader.</param>");
builder.AppendLine(" /// <returns>The same loader instance after all generated table registrations have been applied.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"loader\"/> is null.</exception>");
builder.AppendLine(
" public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(");
builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader)");
builder.AppendLine(" {");
builder.AppendLine(" if (loader is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" return RegisterAllGeneratedConfigTables(loader, options: null);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers all generated config tables while preserving optional per-table overrides such as custom key comparers.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"loader\">Target YAML config loader.</param>");
builder.AppendLine(
" /// <param name=\"options\">Optional per-table overrides for aggregate registration; when null, all tables use their default comparer behavior.</param>");
builder.AppendLine(" /// <returns>The same loader instance after all generated table registrations have been applied.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"loader\"/> is null.</exception>");
builder.AppendLine(
" public static global::GFramework.Game.Config.YamlConfigLoader RegisterAllGeneratedConfigTables(");
builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,");
builder.AppendLine(" GeneratedConfigRegistrationOptions? options)");
builder.AppendLine(" {");
builder.AppendLine(" if (loader is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" options ??= new GeneratedConfigRegistrationOptions();");
builder.AppendLine();
foreach (var schema in schemas)
{
builder.AppendLine($" loader.Register{schema.EntityName}Table(options.{schema.EntityName}Comparer);");
}
builder.AppendLine(" return loader;");
builder.AppendLine(" }");
builder.AppendLine("}");
return builder.ToString().TrimEnd();
}
/// <summary>
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
/// </summary>

View File

@ -13,7 +13,7 @@
- 一对象一文件的目录组织
- 运行时只读查询
- Runtime / Generator / Tooling 共享支持 `minimum``maximum``minLength``maxLength`
- Source Generator 生成配置类型、表包装和注册/访问辅助
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 推荐目录结构
@ -193,8 +193,7 @@ public sealed class GameConfigBootstrap : IDisposable
public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
{
var loader = new YamlConfigLoader(configRootPath)
.RegisterMonsterTable()
.RegisterItemTable();
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(_registry);
@ -223,7 +222,7 @@ public sealed class GameConfigBootstrap : IDisposable
这段模板刻意遵循几个约定:
- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
- 优先使用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口
- 由一个长生命周期对象持有 `ConfigRegistry`
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
@ -331,8 +330,7 @@ public sealed class GameArchitecture : Architecture
var registry = RegisterUtility(new ConfigRegistry());
var loader = new YamlConfigLoader(_configRootPath)
.RegisterMonsterTable()
.RegisterItemTable();
.RegisterAllGeneratedConfigTables();
loader.LoadAsync(registry).GetAwaiter().GetResult();
}
@ -354,7 +352,7 @@ var slime = monsterTable.Get(1);
- 在 `OnInitialize()` 内完成首次 `LoadAsync`
- 初始化完成后只通过注册表和生成表包装访问配置
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。
### 热重载模板
@ -408,7 +406,7 @@ using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable();
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(registry);
@ -416,6 +414,11 @@ var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
这里推荐把“注册全部已生成配置表”和“读取单表强类型元数据”分成两层:
- 启动层优先走 `RegisterAllGeneratedConfigTables()`,避免每新增一个 schema 都要回到启动代码继续补链式调用
- 消费层继续通过 `GetMonsterTable()``MonsterConfigBindings.Metadata` 这类单表入口读取强类型信息
这组辅助会把以下约定固化到生成代码里:
- 配置域常量,例如 `MonsterConfigBindings.ConfigDomain`
@ -433,6 +436,41 @@ var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath;
var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
```
如果你需要在启动或诊断代码里枚举当前消费者项目里有哪些生成表,也可以直接读取项目级目录:
```csharp
foreach (var metadata in GeneratedConfigCatalog.Tables)
{
Console.WriteLine($"{metadata.TableName} -> {metadata.SchemaRelativePath}");
}
```
也可以按表名回查:
```csharp
if (GeneratedConfigCatalog.TryGetByTableName("monster", out var metadata))
{
Console.WriteLine(metadata.ConfigRelativePath);
}
```
如果你需要为某些表保留自定义 key comparer也可以继续走聚合注册入口而不是被迫退回逐表手写
```csharp
var loader = new YamlConfigLoader("config-root")
.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
ItemComparer = StringComparer.OrdinalIgnoreCase
});
```
这里的规则是:
- 未显式配置 comparer 的表,仍然使用各自 `Register{Entity}Table()` 的默认行为
- 需要自定义 comparer 的表,可以通过 `GeneratedConfigRegistrationOptions` 按表覆盖
- 如果项目希望继续完全手写某张表的注册流程,逐表 `Register*Table(...)` 入口仍然保留,作为兼容逃生通道
如果你需要自定义目录、表名或 key selector仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
@ -553,7 +591,7 @@ using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable();
.RegisterAllGeneratedConfigTables();
await loader.LoadAsync(registry);