diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 561555d7..b02b1307 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -9,7 +9,7 @@ namespace GFramework.SourceGenerators.Tests.Config; public class SchemaConfigGeneratorSnapshotTests { /// - /// 验证一个最小 monster schema 能生成配置类型和表包装。 + /// 验证一个最小 monster schema 能生成配置类型、表包装和注册辅助。 /// [Test] public async Task Snapshot_SchemaConfigGenerator() @@ -35,6 +35,32 @@ public class SchemaConfigGeneratorSnapshotTests bool ContainsKey(TKey key); IReadOnlyCollection All(); } + + public interface IConfigRegistry + { + IConfigTable GetTable(string name) + where TKey : notnull; + + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + } + } + + namespace GFramework.Game.Config + { + public sealed class YamlConfigLoader + { + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + string schemaRelativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + return this; + } + } } """; @@ -130,6 +156,8 @@ public class SchemaConfigGeneratorSnapshotTests await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt"); await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt"); + await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfigBindings.g.cs", + "MonsterConfigBindings.g.txt"); } /// diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt new file mode 100644 index 00000000..9893f455 --- /dev/null +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt @@ -0,0 +1,89 @@ +// +#nullable enable + +namespace GFramework.Game.Config.Generated; + +/// +/// Auto-generated registration and lookup helpers for schema file 'monster.schema.json'. +/// The helper centralizes table naming, config directory, schema path, and strong-typed registry access so consumer projects do not need to duplicate the same conventions. +/// +public static class MonsterConfigBindings +{ + /// + /// Gets the runtime registration name of the generated config table. + /// + public const string TableName = "monster"; + + /// + /// Gets the config directory path expected by the generated registration helper. + /// + public const string ConfigRelativePath = "monster"; + + /// + /// Gets the schema file path expected by the generated registration helper. + /// + public const string SchemaRelativePath = "schemas/monster.schema.json"; + + /// + /// Registers the generated config table using the schema-derived runtime conventions. + /// + /// The target YAML config loader. + /// Optional key comparer for the generated table registration. + /// The same loader instance so registration can keep chaining. + public static global::GFramework.Game.Config.YamlConfigLoader RegisterMonsterTable( + this global::GFramework.Game.Config.YamlConfigLoader loader, + global::System.Collections.Generic.IEqualityComparer? comparer = null) + { + if (loader is null) + { + throw new global::System.ArgumentNullException(nameof(loader)); + } + + return loader.RegisterTable( + TableName, + ConfigRelativePath, + SchemaRelativePath, + static config => config.Id, + comparer); + } + + /// + /// Gets the generated config table wrapper from the registry. + /// + /// The source config registry. + /// The generated strong-typed table wrapper. + /// When is null. + public static MonsterTable GetMonsterTable(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry) + { + if (registry is null) + { + throw new global::System.ArgumentNullException(nameof(registry)); + } + + return new MonsterTable(registry.GetTable(TableName)); + } + + /// + /// Tries to get the generated config table wrapper from the registry. + /// + /// The source config registry. + /// The generated strong-typed table wrapper when lookup succeeds; otherwise null. + /// True when the generated table is registered and type-compatible; otherwise false. + /// When is null. + public static bool TryGetMonsterTable(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out MonsterTable? table) + { + if (registry is null) + { + throw new global::System.ArgumentNullException(nameof(registry)); + } + + if (registry.TryGetTable(TableName, out var innerTable) && innerTable is not null) + { + table = new MonsterTable(innerTable); + return true; + } + + table = null; + return false; + } +} diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 663f5313..6428f624 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -41,6 +41,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator productionContext.AddSource( $"{result.Schema.TableName}.g.cs", SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8)); + productionContext.AddSource( + $"{result.Schema.EntityName}ConfigBindings.g.cs", + SourceText.From(GenerateBindingsClass(result.Schema), Encoding.UTF8)); }); } @@ -128,12 +131,18 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator idProperty.TypeSpec.SchemaType)); } + var schemaBaseName = GetSchemaBaseName(file.Path); var schema = new SchemaFileSpec( Path.GetFileName(file.Path), + entityName, schemaObject.ClassName, $"{entityName}Table", GeneratedNamespace, idProperty.TypeSpec.ClrType.TrimEnd('?'), + idProperty.PropertyName, + schemaBaseName, + schemaBaseName, + GetSchemaRelativePath(file.Path), TryGetMetadataString(root, "title"), TryGetMetadataString(root, "description"), schemaObject); @@ -607,6 +616,131 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return builder.ToString().TrimEnd(); } + /// + /// 生成运行时注册与访问辅助源码。 + /// 该辅助类型把 schema 命名约定、配置目录和 schema 相对路径固化为生成代码, + /// 让消费端无需重复手写字符串常量和主键提取逻辑。 + /// + /// 已解析的 schema 模型。 + /// 辅助类型源码。 + private static string GenerateBindingsClass(SchemaFileSpec schema) + { + var registerMethodName = $"Register{schema.EntityName}Table"; + var getMethodName = $"Get{schema.EntityName}Table"; + var tryGetMethodName = $"TryGet{schema.EntityName}Table"; + var bindingsClassName = $"{schema.EntityName}ConfigBindings"; + + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {schema.Namespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + $"/// Auto-generated registration and lookup helpers for schema file '{schema.FileName}'."); + builder.AppendLine( + "/// The helper centralizes table naming, config directory, schema path, and strong-typed registry access so consumer projects do not need to duplicate the same conventions."); + builder.AppendLine("/// "); + builder.AppendLine($"public static class {bindingsClassName}"); + builder.AppendLine("{"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the runtime registration name of the generated config table."); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper."); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper."); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine( + " /// Registers the generated config table using the schema-derived runtime conventions."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The target YAML config loader."); + builder.AppendLine( + " /// Optional key comparer for the generated table registration."); + builder.AppendLine(" /// The same loader instance so registration can keep chaining."); + builder.AppendLine( + $" public static global::GFramework.Game.Config.YamlConfigLoader {registerMethodName}("); + builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,"); + builder.AppendLine( + $" global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? comparer = null)"); + 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 loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>("); + builder.AppendLine(" TableName,"); + builder.AppendLine(" ConfigRelativePath,"); + builder.AppendLine(" SchemaRelativePath,"); + builder.AppendLine($" static config => config.{schema.KeyPropertyName},"); + builder.AppendLine(" comparer);"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Gets the generated config table wrapper from the registry."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The source config registry."); + builder.AppendLine(" /// The generated strong-typed table wrapper."); + builder.AppendLine( + " /// When is null."); + builder.AppendLine( + $" public static {schema.TableName} {getMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (registry is null)"); + builder.AppendLine(" {"); + builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine( + $" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName));"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Tries to get the generated config table wrapper from the registry."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The source config registry."); + builder.AppendLine( + " /// The generated strong-typed table wrapper when lookup succeeds; otherwise null."); + builder.AppendLine( + " /// True when the generated table is registered and type-compatible; otherwise false."); + builder.AppendLine( + " /// When is null."); + builder.AppendLine( + $" public static bool {tryGetMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out {schema.TableName}? table)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (registry is null)"); + builder.AppendLine(" {"); + builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine( + $" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName, out var innerTable) && innerTable is not null)"); + builder.AppendLine(" {"); + builder.AppendLine($" table = new {schema.TableName}(innerTable);"); + builder.AppendLine(" return true;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" table = null;"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + return builder.ToString().TrimEnd(); + } + /// /// 递归生成配置对象类型。 /// @@ -773,6 +907,32 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return Path.GetFileNameWithoutExtension(fileName); } + /// + /// 解析生成注册辅助时要使用的 schema 相对路径。 + /// 生成器优先保留 `schemas/` 目录以下的相对路径,以便消费端默认约定和 MSBuild AdditionalFiles 约定保持一致。 + /// + /// Schema 文件路径。 + /// 用于运行时注册的 schema 相对路径。 + private static string GetSchemaRelativePath(string path) + { + var normalizedPath = path.Replace('\\', '/'); + const string rootMarker = "schemas/"; + const string nestedMarker = "/schemas/"; + + if (normalizedPath.StartsWith(rootMarker, StringComparison.OrdinalIgnoreCase)) + { + return normalizedPath; + } + + var nestedMarkerIndex = normalizedPath.LastIndexOf(nestedMarker, StringComparison.OrdinalIgnoreCase); + if (nestedMarkerIndex >= 0) + { + return normalizedPath.Substring(nestedMarkerIndex + 1); + } + + return $"schemas/{Path.GetFileName(path)}"; + } + /// /// 将 schema 名称转换为 PascalCase 标识符。 /// @@ -996,19 +1156,29 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 生成器级 schema 模型。 /// /// Schema 文件名。 + /// 实体名基础标识。 /// 根配置类型名。 /// 配置表包装类型名。 /// 目标命名空间。 /// 主键 CLR 类型。 + /// 生成配置类型中的主键属性名。 + /// 运行时注册名。 + /// 配置目录相对路径。 + /// Schema 文件相对路径。 /// 根标题元数据。 /// 根描述元数据。 /// 根对象模型。 private sealed record SchemaFileSpec( string FileName, + string EntityName, string ClassName, string TableName, string Namespace, string KeyClrType, + string KeyPropertyName, + string TableRegistrationName, + string ConfigRelativePath, + string SchemaRelativePath, string? Title, string? Description, SchemaObjectSpec RootObject); diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 64eb7726..c2f1138c 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Source Generator 生成配置类型和表包装 +- Source Generator 生成配置类型、表包装和注册/访问辅助 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 ## 推荐目录结构 @@ -83,27 +83,31 @@ dropItems: ## 运行时接入 -当你希望加载后的配置在运行时以只读表形式暴露时,可以使用 `YamlConfigLoader` 和 `ConfigRegistry`: +当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助: ```csharp using GFramework.Game.Config; +using GFramework.Game.Config.Generated; var registry = new ConfigRegistry(); var loader = new YamlConfigLoader("config-root") - .RegisterTable( - "monster", - "monster", - "schemas/monster.schema.json", - static config => config.Id); + .RegisterMonsterTable(); await loader.LoadAsync(registry); -var monsterTable = registry.GetTable("monster"); +var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.Get(1); ``` -这个重载会先按 schema 校验,再进行反序列化和注册。 +这组辅助会把以下约定固化到生成代码里: + +- 表注册名,例如 `monster` +- 配置目录相对路径,例如 `monster` +- schema 相对路径,例如 `schemas/monster.schema.json` +- 主键提取逻辑,例如 `config => config.Id` + +如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。 ## 运行时校验行为 @@ -187,7 +191,12 @@ var hotReload = loader.EnableHotReload( ## 生成器接入约定 -配置生成器会从 `*.schema.json` 生成配置类型和表包装类。 +配置生成器会从 `*.schema.json` 生成以下代码: + +- 配置类型 +- 表包装类型 +- `YamlConfigLoader` 注册辅助 +- `IConfigRegistry` 强类型访问辅助 通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`。