From 2b30e859e906ebf1a9c0103e19aad60962896c2c Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:42:34 +0800 Subject: [PATCH] =?UTF-8?q?docs(config):=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?=E6=96=87=E6=A1=A3=E5=92=8C=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增游戏内容配置系统完整文档,包含 YAML 配置、JSON Schema 结构描述 - 添加推荐目录结构和配置示例,支持怪物、物品、技能等静态内容管理 - 实现 Source Generator 自动生成配置类型、表包装和注册访问辅助功能 - 集成 VS Code 插件提供配置浏览、raw 编辑、schema 打开和校验功能 - 添加生成查询辅助,为顶层标量字段生成 FindBy* 与 TryFindFirstBy* 方法 - 实现开发期热重载功能,支持配置文件修改后自动刷新运行时表 - 添加跨表引用校验,支持 x-gframework-ref-table 声明的引用关系检查 - 新增集成测试验证生成器自动拾取 schema 并支持强类型访问入口 - 添加 IsExternalInit 类型支持低版本 .NET 框架的 init-only setter 功能 --- .../Internals/IsExternalInit.cs | 38 ++--- .../ArchitectureConfigIntegrationTests.cs | 151 ++++++++++++++++++ ...GeneratedConfigConsumerIntegrationTests.cs | 27 +++- .../schemas/monster.schema.json | 7 +- .../Config/SchemaConfigGeneratorTests.cs | 113 ++++++++++++- .../SchemaConfigGenerator/MonsterTable.g.txt | 98 ++++++++++++ .../Config/SchemaConfigGenerator.cs | 127 ++++++++++++++- docs/zh-CN/game/config-system.md | 76 +++++++++ 8 files changed, 611 insertions(+), 26 deletions(-) create mode 100644 GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs diff --git a/GFramework.Core.Abstractions/Internals/IsExternalInit.cs b/GFramework.Core.Abstractions/Internals/IsExternalInit.cs index 1de16c68..8a76104d 100644 --- a/GFramework.Core.Abstractions/Internals/IsExternalInit.cs +++ b/GFramework.Core.Abstractions/Internals/IsExternalInit.cs @@ -1,20 +1,20 @@ -// IsExternalInit.cs -// This type is required to support init-only setters and record types -// when targeting netstandard2.0 or older frameworks. - -#if !NET5_0_OR_GREATER -using System.ComponentModel; - -// ReSharper disable CheckNamespace - -namespace System.Runtime.CompilerServices; - -/// -/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。 -/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。 -/// -[EditorBrowsable(EditorBrowsableState.Never)] -internal static class IsExternalInit -{ -} +// IsExternalInit.cs +// This type is required to support init-only setters and record types +// when targeting netstandard2.0 or older frameworks. + +#if !NET5_0_OR_GREATER +using System.ComponentModel; + +// ReSharper disable CheckNamespace + +namespace System.Runtime.CompilerServices; + +/// +/// 提供一个占位符类型,用于支持 C# 9.0 的 init 访问器功能。 +/// 该类型在 .NET 5.0 及更高版本中已内置,因此仅在较低版本的 .NET 中定义。 +/// +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit +{ +} #endif \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs new file mode 100644 index 00000000..d4a448c5 --- /dev/null +++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GFramework.Core.Architectures; +using GFramework.Game.Config; +using GFramework.Game.Config.Generated; +using NUnit.Framework; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证在 初始化流程中可以注册配置注册表、执行加载并通过生成的表访问器读取数据。 +/// +[TestFixture] +public class ArchitectureConfigIntegrationTests +{ + /// + /// 架构初始化期间,通过 注册生成表, + /// 并将 作为 utility 暴露给架构上下文读取。 + /// + [Test] + public async Task ConfigLoaderCanRunDuringArchitectureInitialization() + { + var rootPath = CreateTempConfigRoot(); + ConsumerArchitecture? architecture = null; + var initialized = false; + try + { + architecture = new ConsumerArchitecture(rootPath); + await architecture.InitializeAsync(); + initialized = true; + + var table = architecture.MonsterTable; + + Assert.Multiple(() => + { + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(2).Hp, Is.EqualTo(30)); + Assert.That(table.FindByFaction("dungeon").Select(static config => config.Name), + Is.EquivalentTo(new[] { "Slime", "Goblin" })); + Assert.That(architecture.Registry.TryGetMonsterTable(out var retrieved), Is.True); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(architecture.Context.GetUtility(), Is.SameAs(architecture.Registry)); + }); + } + finally + { + if (architecture is not null && initialized) + { + await architecture.DestroyAsync(); + } + + DeleteDirectoryIfExists(rootPath); + } + } + + private static string CreateTempConfigRoot() + { + var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + Directory.CreateDirectory(Path.Combine(rootPath, "schemas")); + Directory.CreateDirectory(Path.Combine(rootPath, "monster")); + File.WriteAllText(Path.Combine(rootPath, "schemas", "monster.schema.json"), MonsterSchemaJson); + File.WriteAllText(Path.Combine(rootPath, "monster", "slime.yaml"), MonsterSlimeYaml); + File.WriteAllText(Path.Combine(rootPath, "monster", "goblin.yaml"), MonsterGoblinYaml); + return rootPath; + } + + /// + /// 最佳努力尝试删除临时目录。 + /// + private static void DeleteDirectoryIfExists(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, true); + } + catch (IOException) + { + // Ignored: cleanup is best effort and should not fail the test. + } + } + + private const string MonsterSchemaJson = @"{ + ""title"": ""Monster Config"", + ""description"": ""Defines one monster entry for the generated consumer integration test."", + ""type"": ""object"", + ""required"": [ + ""id"", + ""name"", + ""hp"", + ""faction"" + ], + ""properties"": { + ""id"": { + ""type"": ""integer"", + ""description"": ""Monster identifier."" + }, + ""name"": { + ""type"": ""string"", + ""description"": ""Monster display name."" + }, + ""hp"": { + ""type"": ""integer"", + ""description"": ""Monster base health."" + }, + ""faction"": { + ""type"": ""string"", + ""description"": ""Used by the integration test to validate generated non-unique queries."" + } + } +}"; + + private const string MonsterSlimeYaml = + "id: 1\nname: Slime\nhp: 10\nfaction: dungeon\n"; + + private const string MonsterGoblinYaml = + "id: 2\nname: Goblin\nhp: 30\nfaction: dungeon\n"; + + private sealed class ConsumerArchitecture : Architecture + { + private readonly string _configRoot; + + public ConfigRegistry Registry { get; } + + public MonsterTable MonsterTable { get; private set; } = null!; + + public ConsumerArchitecture(string configRoot) + { + _configRoot = configRoot ?? throw new ArgumentNullException(nameof(configRoot)); + Registry = new ConfigRegistry(); + } + + protected override void OnInitialize() + { + RegisterUtility(Registry); + + var loader = new YamlConfigLoader(_configRoot) + .RegisterMonsterTable(); + loader.LoadAsync(Registry).GetAwaiter().GetResult(); + MonsterTable = Registry.GetMonsterTable(); + } + } +} diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs index 7556db18..d515788d 100644 --- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -1,4 +1,6 @@ +using System; using System.IO; +using System.Linq; using GFramework.Game.Config; using GFramework.Game.Config.Generated; @@ -6,7 +8,7 @@ namespace GFramework.Game.Tests.Config; /// /// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后, -/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。 +/// 可以直接编译并使用生成的注册辅助、强类型访问入口、查询辅助与运行时加载链路。 /// [TestFixture] public class GeneratedConfigConsumerIntegrationTests @@ -37,7 +39,7 @@ public class GeneratedConfigConsumerIntegrationTests /// /// 验证生成器自动拾取消费者项目的 schema 后, - /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据。 + /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。 /// [Test] public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project() @@ -49,7 +51,7 @@ public class GeneratedConfigConsumerIntegrationTests "title": "Monster Config", "description": "Defines one monster entry for the end-to-end consumer integration test.", "type": "object", - "required": ["id", "name", "hp"], + "required": ["id", "name", "hp", "faction"], "properties": { "id": { "type": "integer", @@ -62,6 +64,10 @@ public class GeneratedConfigConsumerIntegrationTests "hp": { "type": "integer", "description": "Monster base health." + }, + "faction": { + "type": "string", + "description": "Used by the integration test to validate generated non-unique queries." } } } @@ -72,6 +78,7 @@ public class GeneratedConfigConsumerIntegrationTests id: 1 name: Slime hp: 10 + faction: dungeon """); CreateFile( "monster/goblin.yaml", @@ -79,6 +86,7 @@ public class GeneratedConfigConsumerIntegrationTests id: 2 name: Goblin hp: 30 + faction: dungeon """); var registry = new ConfigRegistry(); @@ -88,6 +96,7 @@ public class GeneratedConfigConsumerIntegrationTests await loader.LoadAsync(registry); var table = registry.GetMonsterTable(); + var dungeonMonsters = table.FindByFaction("dungeon"); Assert.Multiple(() => { @@ -106,6 +115,16 @@ public class GeneratedConfigConsumerIntegrationTests 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)); + Assert.That(table.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 })); + Assert.That(dungeonMonsters.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" })); + Assert.That(table.TryFindFirstByName("Goblin", out var goblin), Is.True); + Assert.That(goblin, Is.Not.Null); + Assert.That(goblin!.Id, Is.EqualTo(2)); + Assert.That(table.TryFindFirstByFaction("dungeon", out var firstDungeonMonster), Is.True); + Assert.That(firstDungeonMonster, Is.Not.Null); + Assert.That(firstDungeonMonster!.Name, Is.AnyOf("Slime", "Goblin")); + Assert.That(table.TryFindFirstByFaction("forest", out var missingMonster), Is.False); + Assert.That(missingMonster, Is.Null); Assert.That(registry.TryGetMonsterTable(out var generatedTable), Is.True); Assert.That(generatedTable, Is.Not.Null); Assert.That(generatedTable!.All().Select(static config => config.Name), @@ -131,4 +150,4 @@ public class GeneratedConfigConsumerIntegrationTests File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal)); } -} \ No newline at end of file +} diff --git a/GFramework.Game.Tests/schemas/monster.schema.json b/GFramework.Game.Tests/schemas/monster.schema.json index 4b16aa59..5bda7ba0 100644 --- a/GFramework.Game.Tests/schemas/monster.schema.json +++ b/GFramework.Game.Tests/schemas/monster.schema.json @@ -5,7 +5,8 @@ "required": [ "id", "name", - "hp" + "hp", + "faction" ], "properties": { "id": { @@ -19,6 +20,10 @@ "hp": { "type": "integer", "description": "Monster base health." + }, + "faction": { + "type": "string", + "description": "Used by integration tests to validate generated non-unique queries." } } } diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 07b3b81c..133884b2 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -236,4 +236,115 @@ public class SchemaConfigGeneratorTests Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 =")); Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 =")); } -} \ No newline at end of file + + /// + /// 验证生成器只为顶层非主键标量字段生成轻量查询辅助, + /// 避免把数组、对象和引用字段误生成为查询 API。 + /// + [Test] + public void Run_Should_Generate_Query_Helpers_Only_For_Top_Level_Scalar_Properties() + { + 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 : IConfigTable + where TKey : notnull + { + TValue Get(TKey key); + bool TryGet(TKey key, out TValue? value); + 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; + } + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { "type": "integer" }, + "dropItems": { + "type": "array", + "items": { "type": "string" } + }, + "targetId": { + "type": "string", + "x-gframework-ref-table": "monster" + }, + "reward": { + "type": "object", + "properties": { + "gold": { "type": "integer" } + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + 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("MonsterTable.g.cs", out var tableSource), Is.True); + + Assert.Multiple(() => + { + Assert.That(tableSource, Does.Contain("FindByName(string value)")); + Assert.That(tableSource, Does.Contain("TryFindFirstByName(string value, out MonsterConfig? result)")); + Assert.That(tableSource, Does.Contain("FindByHp(int? value)")); + Assert.That(tableSource, Does.Contain("TryFindFirstByHp(int? value, out MonsterConfig? result)")); + Assert.That(tableSource, Does.Not.Contain("FindById(")); + Assert.That(tableSource, Does.Not.Contain("FindByDropItems(")); + Assert.That(tableSource, Does.Not.Contain("FindByTargetId(")); + Assert.That(tableSource, Does.Not.Contain("FindByReward(")); + }); + } +} diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt index 2e1a442b..f9cd82a2 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt @@ -52,4 +52,102 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions. { return _inner.All(); } + + /// + /// Finds all config entries whose property 'name' equals the supplied value. + /// + /// The property value to match. + /// A read-only snapshot containing every matching config entry. + /// + /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure. + /// + public global::System.Collections.Generic.IReadOnlyList FindByName(string value) + { + var matches = new global::System.Collections.Generic.List(); + + // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data. + foreach (var candidate in All()) + { + if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Name, value)) + { + matches.Add(candidate); + } + } + + return matches.Count == 0 ? global::System.Array.Empty() : matches.AsReadOnly(); + } + + /// + /// Tries to find the first config entry whose property 'name' equals the supplied value. + /// + /// The property value to match. + /// The first matching config entry when lookup succeeds; otherwise . + /// when a matching config entry is found; otherwise . + /// + /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order. + /// + public bool TryFindFirstByName(string value, out MonsterConfig? result) + { + // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches. + foreach (var candidate in All()) + { + if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Name, value)) + { + result = candidate; + return true; + } + } + + result = null; + return false; + } + + /// + /// Finds all config entries whose property 'hp' equals the supplied value. + /// + /// The property value to match. + /// A read-only snapshot containing every matching config entry. + /// + /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure. + /// + public global::System.Collections.Generic.IReadOnlyList FindByHp(int? value) + { + var matches = new global::System.Collections.Generic.List(); + + // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data. + foreach (var candidate in All()) + { + if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Hp, value)) + { + matches.Add(candidate); + } + } + + return matches.Count == 0 ? global::System.Array.Empty() : matches.AsReadOnly(); + } + + /// + /// Tries to find the first config entry whose property 'hp' equals the supplied value. + /// + /// The property value to match. + /// The first matching config entry when lookup succeeds; otherwise . + /// when a matching config entry is found; otherwise . + /// + /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order. + /// + public bool TryFindFirstByHp(int? value, out MonsterConfig? result) + { + // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches. + foreach (var candidate in All()) + { + if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Hp, value)) + { + result = candidate; + return true; + } + } + + result = null; + return false; + } } diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index dc33408f..60f3ec53 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -560,6 +560,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private static string GenerateTableClass(SchemaFileSpec schema) { var builder = new StringBuilder(); + var queryableProperties = CollectQueryableProperties(schema).ToArray(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine(); @@ -620,6 +621,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" {"); builder.AppendLine(" return _inner.All();"); builder.AppendLine(" }"); + + foreach (var property in queryableProperties) + { + builder.AppendLine(); + AppendFindByPropertyMethod(builder, schema, property); + builder.AppendLine(); + AppendTryFindFirstByPropertyMethod(builder, schema, property); + } + builder.AppendLine("}"); return builder.ToString().TrimEnd(); } @@ -975,6 +985,121 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + /// + /// 收集适合生成轻量查询辅助的根级标量字段。 + /// 当前实现故意限定在顶层非主键标量字段,避免把嵌套结构、数组或引用语义提前固化为运行时契约。 + /// + /// 生成器级 schema 模型。 + /// 可生成查询辅助的属性集合。 + private static IEnumerable CollectQueryableProperties(SchemaFileSpec schema) + { + foreach (var property in schema.RootObject.Properties) + { + if (property.TypeSpec.Kind != SchemaNodeKind.Scalar) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName)) + { + continue; + } + + if (string.Equals(property.PropertyName, schema.KeyPropertyName, StringComparison.Ordinal)) + { + continue; + } + + yield return property; + } + } + + /// + /// 生成按字段匹配全部结果的轻量查询辅助。 + /// + /// 输出缓冲区。 + /// 生成器级 schema 模型。 + /// 要生成查询辅助的字段模型。 + private static void AppendFindByPropertyMethod( + StringBuilder builder, + SchemaFileSpec schema, + SchemaPropertySpec property) + { + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// Finds all config entries whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The property value to match."); + builder.AppendLine(" /// A read-only snapshot containing every matching config entry."); + builder.AppendLine(" /// "); + builder.AppendLine( + " /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure."); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}> FindBy{property.PropertyName}({property.TypeSpec.ClrType} value)"); + builder.AppendLine(" {"); + builder.AppendLine( + $" var matches = new global::System.Collections.Generic.List<{schema.ClassName}>();"); + builder.AppendLine(); + builder.AppendLine( + " // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data."); + builder.AppendLine($" foreach (var candidate in All())"); + builder.AppendLine(" {"); + builder.AppendLine( + $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))"); + builder.AppendLine(" {"); + builder.AppendLine(" matches.Add(candidate);"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine( + $" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();"); + builder.AppendLine(" }"); + } + + /// + /// 生成按字段匹配首个结果的轻量查询辅助。 + /// + /// 输出缓冲区。 + /// 生成器级 schema 模型。 + /// 要生成查询辅助的字段模型。 + private static void AppendTryFindFirstByPropertyMethod( + StringBuilder builder, + SchemaFileSpec schema, + SchemaPropertySpec property) + { + builder.AppendLine(" /// "); + builder.AppendLine( + $" /// Tries to find the first config entry whose property '{EscapeXmlDocumentation(property.DisplayPath)}' equals the supplied value."); + builder.AppendLine(" /// "); + builder.AppendLine(" /// The property value to match."); + builder.AppendLine( + " /// The first matching config entry when lookup succeeds; otherwise ."); + builder.AppendLine(" /// when a matching config entry is found; otherwise ."); + builder.AppendLine(" /// "); + builder.AppendLine( + " /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order."); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public bool TryFindFirstBy{property.PropertyName}({property.TypeSpec.ClrType} value, out {schema.ClassName}? result)"); + builder.AppendLine(" {"); + builder.AppendLine( + " // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches."); + builder.AppendLine(" foreach (var candidate in All())"); + builder.AppendLine(" {"); + builder.AppendLine( + $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))"); + builder.AppendLine(" {"); + builder.AppendLine(" result = candidate;"); + builder.AppendLine(" return true;"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" result = null;"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + } + /// /// 递归枚举对象树中所有带 ref-table 元数据的字段。 /// @@ -1717,4 +1842,4 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator Object, Array } -} \ 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 b390afd7..ab7d9b2d 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -280,6 +280,82 @@ public sealed class GameConfigRuntime `MonsterConfigBindings.References` - 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低 +### 生成查询辅助 + +从当前阶段开始,生成的 `*Table` 包装会为“顶层、非主键、非引用的标量字段”额外产出轻量查询辅助。 + +如果 `monster.schema.json` 包含顶层标量字段 `name`、`faction`,则可以直接这样使用: + +```csharp +var monsterTable = registry.GetMonsterTable(); + +var slime = monsterTable.FindByName("Slime"); + +if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster)) +{ + Console.WriteLine(firstDungeonMonster.Name); +} +``` + +当前生成规则刻意保持保守: + +- 只为顶层标量字段生成 `FindBy*` 与 `TryFindFirstBy*` +- 主键字段继续只走 `Get / TryGet` +- 嵌套对象、对象数组、标量数组和 `x-gframework-ref-table` 字段暂不生成查询辅助 +- 查询实现基于 `All()` 做线性扫描,不引入运行时索引或缓存 + +这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。 + +如果你依赖 `TryFindFirstBy*`,应当把它理解为“返回当前表快照遍历顺序下的第一个匹配项”,而不是固定排序语义。 + +### Architecture 推荐接入模板 + +如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility: + +```csharp +using GFramework.Core.Architectures; +using GFramework.Game.Config; +using GFramework.Game.Config.Generated; + +public sealed class GameArchitecture : Architecture +{ + private readonly string _configRootPath; + + public GameArchitecture(string configRootPath) + { + _configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath)); + } + + protected override void OnInitialize() + { + var registry = RegisterUtility(new ConfigRegistry()); + + var loader = new YamlConfigLoader(_configRootPath) + .RegisterMonsterTable() + .RegisterItemTable(); + + loader.LoadAsync(registry).GetAwaiter().GetResult(); + } +} +``` + +初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口: + +```csharp +var registry = Context.GetUtility(); +var monsterTable = registry.GetMonsterTable(); +var slime = monsterTable.Get(1); +``` + +推荐遵循以下顺序: + +- 先注册 `ConfigRegistry` +- 再构造并配置 `YamlConfigLoader` +- 在 `OnInitialize()` 内完成首次 `LoadAsync` +- 初始化完成后只通过注册表和生成表包装访问配置 + +当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + Register*Table()` 组合已经足够作为官方推荐接入路径。 + ### 热重载模板 如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本: