From 61ee3a8f0c02aac7100a0033f87c8d2a0da05698 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:22:12 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(Godot.SourceGenerators):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Godot=20=E9=A1=B9=E7=9B=AE=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E7=A0=81=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 project.godot 文件解析功能,支持 AutoLoad 和 Input Action 元数据提取 - 生成 AutoLoads 强类型访问入口,提供 GetRequiredNode 和 TryGetNode 方法 - 生成 InputActions 常量类,避免手写字符串魔法值 - 添加 AutoLoadAttribute 特性支持显式类型映射声明 - 实现标识符冲突检测和自动后缀追加机制 - 添加完整的诊断系统支持,包括类型继承检查和重复条目警告 - 创建 MSBuild 集成目标文件确保生成器正确加载 - 提供详细的 README 文档说明使用方法和最佳实践 --- .../AutoLoadAttribute.cs | 30 + .../Core/AdditionalTextGeneratorTestDriver.cs | 111 +++ .../GodotProjectMetadataGeneratorTests.cs | 319 ++++++++ .../AnalyzerReleases.Unshipped.md | 6 + .../Diagnostics/GodotProjectDiagnostics.cs | 75 ++ ....GFramework.Godot.SourceGenerators.targets | 14 +- .../GodotProjectMetadataGenerator.cs | 763 ++++++++++++++++++ GFramework.Godot.SourceGenerators/README.md | 88 ++ 8 files changed, 1405 insertions(+), 1 deletion(-) create mode 100644 GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs create mode 100644 GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs create mode 100644 GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs diff --git a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs new file mode 100644 index 00000000..e406f8b6 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs @@ -0,0 +1,30 @@ +#nullable enable +namespace GFramework.Godot.SourceGenerators.Abstractions; + +/// +/// 显式声明某个 Godot 节点类型与 project.godot 中 AutoLoad 名称之间的映射关系。 +/// +/// +/// 当 AutoLoad 条目无法仅靠类型名唯一推断到 C# 节点类型时, +/// 可以通过该特性为生成器提供稳定的强类型映射入口。 +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class AutoLoadAttribute : Attribute +{ + /// + /// 初始化 的新实例。 + /// + /// project.godot 中声明的 AutoLoad 名称。 + /// + /// 。 + /// + public AutoLoadAttribute(string name) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// 获取在 project.godot 中声明的 AutoLoad 名称。 + /// + public string Name { get; } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs new file mode 100644 index 00000000..f1aaa03f --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs @@ -0,0 +1,111 @@ +using System.Collections.Immutable; +using System.IO; + +namespace GFramework.Godot.SourceGenerators.Tests.Core; + +/// +/// 提供基于 的源生成器测试驱动。 +/// +public static class AdditionalTextGeneratorTestDriver +{ + /// + /// 运行指定的增量生成器,并返回生成结果。 + /// + /// 要运行的生成器类型。 + /// 输入源码。 + /// AdditionalFiles 集合。 + /// 生成器运行结果。 + public static GeneratorDriverRunResult Run( + string source, + params (string path, string content)[] additionalFiles) + where TGenerator : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + typeof(TGenerator).Name + "Tests", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var additionalTexts = additionalFiles + .Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content)) + .ToImmutableArray(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new[] { new TGenerator().AsSourceGenerator() }, + additionalTexts: additionalTexts, + parseOptions: (CSharpParseOptions)syntaxTree.Options); + + driver = driver.RunGenerators(compilation); + return driver.GetRunResult(); + } + + /// + /// 将生成结果转换为文件名到文本的映射,便于断言。 + /// + /// 生成器运行结果。 + /// 按 HintName 索引的生成源码。 + public static IReadOnlyDictionary ToGeneratedSourceMap(GeneratorDriverRunResult result) + { + return result.Results + .Single() + .GeneratedSources + .ToDictionary( + static item => item.HintName, + static item => NormalizeLineEndings(item.SourceText.ToString()), + StringComparer.Ordinal); + } + + /// + /// 规范化换行,避免测试在不同平台上产生伪差异。 + /// + /// 待规范化文本。 + /// 使用当前平台换行符的内容。 + public static string NormalizeLineEndings(string content) + { + return content + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal) + .Replace("\n", Environment.NewLine, StringComparison.Ordinal); + } + + private static IEnumerable GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + ?? Array.Empty(); + + return trustedPlatformAssemblies + .Select(static path => MetadataReference.CreateFromFile(path)); + } + + /// + /// 用于测试 AdditionalFiles 的内存实现。 + /// + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + /// + /// 初始化一个内存 AdditionalText。 + /// + /// 虚拟文件路径。 + /// 文件内容。 + public InMemoryAdditionalText( + string path, + string content) + { + Path = path; + _text = SourceText.From(content); + } + + /// + public override string Path { get; } + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return _text; + } + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs new file mode 100644 index 00000000..252e7e1e --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs @@ -0,0 +1,319 @@ +using GFramework.Godot.SourceGenerators.Tests.Core; + +namespace GFramework.Godot.SourceGenerators.Tests.Project; + +/// +/// 验证基于 project.godot 的项目元数据生成行为。 +/// +[TestFixture] +public class GodotProjectMetadataGeneratorTests +{ + /// + /// 验证会根据 AutoLoad 与 Input Action 生成稳定的强类型入口。 + /// + [Test] + public void Run_Should_Generate_AutoLoads_And_InputActions() + { + const string source = """ + using System; + + namespace GFramework.Godot.SourceGenerators.Abstractions + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoLoadAttribute : Attribute + { + public AutoLoadAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } + } + + namespace Godot + { + public class MainLoop + { + } + + public class Node + { + public T? GetNodeOrNull(string path) where T : Node => default; + } + + public sealed class SceneTree : MainLoop + { + public Node? Root { get; set; } + } + + public static class Engine + { + public static MainLoop? GetMainLoop() => default; + } + } + + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + using Godot; + + [AutoLoad("GameServices")] + public partial class GameServices : Node + { + } + } + """; + + const string projectFile = """ + [autoload] + GameServices="*res://autoload/game_services.tscn" + AudioBus="*res://autoload/audio_bus.gd" + + [input] + move_up={ + "deadzone": 0.5 + } + ui_cancel={ + "deadzone": 0.5 + } + """; + + const string expectedAutoLoads = """ + // + #nullable enable + + namespace GFramework.Godot.Generated; + + /// + /// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。 + /// + public static partial class AutoLoads + { + /// + /// 获取 AutoLoad GameServices。 + /// + public static global::TestApp.GameServices GameServices => GetRequiredNode("GameServices"); + + /// + /// 尝试获取 AutoLoad GameServices。 + /// + public static bool TryGetGameServices(out global::TestApp.GameServices? value) + { + return TryGetNode("GameServices", out value); + } + + /// + /// 获取 AutoLoad AudioBus。 + /// + public static global::Godot.Node AudioBus => GetRequiredNode("AudioBus"); + + /// + /// 尝试获取 AutoLoad AudioBus。 + /// + public static bool TryGetAudioBus(out global::Godot.Node? value) + { + return TryGetNode("AudioBus", out value); + } + + /// + /// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。 + /// + /// 节点类型。 + /// AutoLoad 名称。 + /// 已解析的 AutoLoad 节点。 + private static TNode GetRequiredNode(string autoLoadName) + where TNode : global::Godot.Node + { + if (TryGetNode(autoLoadName, out TNode? value)) + { + return value!; + } + + throw new global::System.InvalidOperationException($"AutoLoad '{autoLoadName}' is not available on the active SceneTree root."); + } + + /// + /// 尝试从当前 SceneTree 根节点解析 AutoLoad。 + /// + /// 节点类型。 + /// AutoLoad 名称。 + /// 解析到的节点实例。 + /// 若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad,则返回 true + private static bool TryGetNode(string autoLoadName, out TNode? value) + where TNode : global::Godot.Node + { + value = default; + + if (global::Godot.Engine.GetMainLoop() is not global::Godot.SceneTree sceneTree) + { + return false; + } + + var root = sceneTree.Root; + if (root is null) + { + return false; + } + + value = root.GetNodeOrNull($"/root/{autoLoadName}"); + return value is not null; + } + } + + """; + + const string expectedInputActions = """ + // + #nullable enable + + namespace GFramework.Godot.Generated; + + /// + /// 提供 project.godot 中 Input Action 名称的强类型常量。 + /// + public static partial class InputActions + { + /// + /// Input Action move_up 的稳定名称。 + /// + public const string MoveUp = "move_up"; + + /// + /// Input Action ui_cancel 的稳定名称。 + /// + public const string UiCancel = "ui_cancel"; + + } + + """; + + var result = AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Is.EqualTo(AdditionalTextGeneratorTestDriver.NormalizeLineEndings(expectedAutoLoads))); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Is.EqualTo(AdditionalTextGeneratorTestDriver.NormalizeLineEndings(expectedInputActions))); + } + + /// + /// 验证 [AutoLoad] 标记在非节点类型上时会产生诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AutoLoad_Type_Does_Not_Derive_From_Node() + { + const string source = """ + using System; + + namespace GFramework.Godot.SourceGenerators.Abstractions + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoLoadAttribute : Attribute + { + public AutoLoadAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } + } + + namespace Godot + { + public class Node + { + } + } + + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + + [AutoLoad("ConfigHub")] + public partial class ConfigHub + { + } + } + """; + + const string projectFile = """ + [autoload] + ConfigHub="*res://autoload/config_hub.tscn" + """; + + var result = AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_Godot_Project_001")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("ConfigHub")); + }); + } + + /// + /// 验证 Input Action 标识符冲突时会追加稳定后缀并给出警告。 + /// + [Test] + public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_Input_Action_Identifiers_Collide() + { + const string source = """ + namespace Godot + { + public class MainLoop + { + } + + public class Node + { + public T? GetNodeOrNull(string path) where T : Node => default; + } + + public sealed class SceneTree : MainLoop + { + public Node? Root { get; set; } + } + + public static class Engine + { + public static MainLoop? GetMainLoop() => default; + } + } + """; + + const string projectFile = """ + [input] + move_up={ + } + move-up={ + } + """; + + var result = AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_004" })); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Contain("public const string MoveUp = \"move_up\";")); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Contain("public const string MoveUp_2 = \"move-up\";")); + } +} diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md index 73b213f9..cb47a83d 100644 --- a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -33,3 +33,9 @@ GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_007 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_008 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics + GF_Godot_Project_001 | GFramework.Godot | Error | GodotProjectDiagnostics + GF_Godot_Project_002 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_003 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_004 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_005 | GFramework.Godot | Warning | GodotProjectDiagnostics + GF_Godot_Project_006 | GFramework.Godot | Warning | GodotProjectDiagnostics diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs new file mode 100644 index 00000000..2533372e --- /dev/null +++ b/GFramework.Godot.SourceGenerators/Diagnostics/GodotProjectDiagnostics.cs @@ -0,0 +1,75 @@ +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.Godot.SourceGenerators.Diagnostics; + +/// +/// 基于 project.godot 的项目元数据生成相关诊断。 +/// +public static class GodotProjectDiagnostics +{ + /// + /// 标记了 [AutoLoad] 的类型必须继承自 Godot.Node。 + /// + public static readonly DiagnosticDescriptor AutoLoadTypeMustDeriveFromNode = new( + "GF_Godot_Project_001", + "AutoLoad types must derive from Godot.Node", + "Type '{0}' uses [AutoLoad] but does not derive from Godot.Node", + PathContests.GodotNamespace, + DiagnosticSeverity.Error, + true); + + /// + /// 多个类型映射到同一 AutoLoad 名称时会退化为非强类型访问。 + /// + public static readonly DiagnosticDescriptor DuplicateAutoLoadMapping = new( + "GF_Godot_Project_002", + "Duplicate AutoLoad mappings were found", + "AutoLoad '{0}' is mapped by multiple types ({1}); the generated accessor falls back to Godot.Node until the mapping is unique", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 多个 AutoLoad 名称映射到同一个标识符时会追加稳定后缀。 + /// + public static readonly DiagnosticDescriptor AutoLoadIdentifierCollision = new( + "GF_Godot_Project_003", + "Generated AutoLoad identifier collision", + "AutoLoad '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 多个 Input Action 名称映射到同一个标识符时会追加稳定后缀。 + /// + public static readonly DiagnosticDescriptor InputActionIdentifierCollision = new( + "GF_Godot_Project_004", + "Generated Input Action identifier collision", + "Input action '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 同一个 project.godot 中存在重复 AutoLoad 条目。 + /// + public static readonly DiagnosticDescriptor DuplicateAutoLoadEntry = new( + "GF_Godot_Project_005", + "Duplicate AutoLoad entry in project.godot", + "AutoLoad '{0}' is declared multiple times in project.godot; only the first declaration is used", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); + + /// + /// 同一个 project.godot 中存在重复 Input Action 条目。 + /// + public static readonly DiagnosticDescriptor DuplicateInputActionEntry = new( + "GF_Godot_Project_006", + "Duplicate Input Action entry in project.godot", + "Input action '{0}' is declared multiple times in project.godot; only the first declaration is used", + PathContests.GodotNamespace, + DiagnosticSeverity.Warning, + true); +} diff --git a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets index 37971c98..cf19200c 100644 --- a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets +++ b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets @@ -3,14 +3,26 @@ + + + project.godot + + + + + + - \ No newline at end of file + diff --git a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs new file mode 100644 index 00000000..ba3ddc21 --- /dev/null +++ b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs @@ -0,0 +1,763 @@ +using GFramework.Godot.SourceGenerators.Diagnostics; +using GFramework.SourceGenerators.Common.Constants; + +namespace GFramework.Godot.SourceGenerators; + +/// +/// 读取 project.godot 项目元数据,并生成 AutoLoad 与 Input Action 的强类型访问入口。 +/// +/// +/// 该生成器把 Godot 项目层面的事实模型暴露为稳定的编译期 API: +/// +/// +/// [autoload] 段生成统一访问入口,并在可唯一解析到 C# 节点类型时生成强类型属性。 +/// +/// +/// [input] 段生成输入动作常量,避免手写魔法字符串。 +/// +/// +/// 对于类型映射冲突或标识符冲突,该生成器会优先给出诊断并退化为可工作的稳定输出,而不是静默生成不确定代码。 +/// +[Generator] +public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator +{ + private const string ProjectFileName = "project.godot"; + private const string GeneratedNamespace = $"{PathContests.GodotNamespace}.Generated"; + + private const string AutoLoadAttributeMetadataName = + $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoLoadAttribute"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var projectFiles = context.AdditionalTextsProvider + .Where(static file => + string.Equals(Path.GetFileName(file.Path), ProjectFileName, StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => ParseProjectFile(file, cancellationToken)) + .Collect(); + + var typeCandidates = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax, + static (syntaxContext, _) => TransformTypeCandidate(syntaxContext)) + .Where(static candidate => candidate is not null) + .Collect(); + + var generationInput = context.CompilationProvider.Combine(projectFiles).Combine(typeCandidates); + context.RegisterSourceOutput(generationInput, static (productionContext, input) => + Execute(productionContext, input.Left.Left, input.Left.Right, input.Right)); + } + + private static GodotTypeCandidate? TransformTypeCandidate(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax classDeclaration) + return null; + + if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol) + return null; + + return new GodotTypeCandidate(classDeclaration, typeSymbol); + } + + private static void Execute( + SourceProductionContext context, + Compilation compilation, + ImmutableArray projectFileResults, + ImmutableArray typeCandidates) + { + if (projectFileResults.IsDefaultOrEmpty) + return; + + var projectResult = projectFileResults + .OrderBy(static result => result.FilePath, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + foreach (var diagnostic in projectResult.Diagnostics) + { + context.ReportDiagnostic(diagnostic); + } + + var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node"); + if (godotNodeSymbol is null) + return; + + var autoLoadAttributeSymbol = compilation.GetTypeByMetadataName(AutoLoadAttributeMetadataName); + var concreteCandidates = typeCandidates + .Where(static candidate => candidate is not null) + .Select(static candidate => candidate!) + .ToArray(); + + var typedMappings = BuildTypedMappings( + context, + projectResult, + concreteCandidates, + autoLoadAttributeSymbol, + godotNodeSymbol); + + if (projectResult.AutoLoads.Length > 0) + { + var autoLoadMembers = CreateAutoLoadMembers(context, projectResult, typedMappings); + context.AddSource( + "GFramework_Godot_Generated_AutoLoads.g.cs", + SourceText.From(GenerateAutoLoadSource(autoLoadMembers), Encoding.UTF8)); + } + + if (projectResult.InputActions.Length > 0) + { + var inputActionMembers = CreateInputActionMembers(context, projectResult); + context.AddSource( + "GFramework_Godot_Generated_InputActions.g.cs", + SourceText.From(GenerateInputActionsSource(inputActionMembers), Encoding.UTF8)); + } + } + + private static Dictionary BuildTypedMappings( + SourceProductionContext context, + ProjectMetadataParseResult projectResult, + IReadOnlyList typeCandidates, + INamedTypeSymbol? autoLoadAttributeSymbol, + INamedTypeSymbol godotNodeSymbol) + { + var projectAutoLoadNames = new HashSet( + projectResult.AutoLoads.Select(static entry => entry.Name), + StringComparer.Ordinal); + + var explicitMappings = new Dictionary>(StringComparer.Ordinal); + var implicitCandidates = new Dictionary>(StringComparer.Ordinal); + + foreach (var candidate in typeCandidates) + { + var typeSymbol = candidate.TypeSymbol; + var derivesFromNode = typeSymbol.IsAssignableTo(godotNodeSymbol); + + if (derivesFromNode) + { + if (!implicitCandidates.TryGetValue(typeSymbol.Name, out var implicitList)) + { + implicitList = new List(); + implicitCandidates.Add(typeSymbol.Name, implicitList); + } + + implicitList.Add(typeSymbol); + } + + if (autoLoadAttributeSymbol is null) + continue; + + var autoLoadAttribute = typeSymbol.GetAttributes() + .FirstOrDefault(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, autoLoadAttributeSymbol)); + + if (autoLoadAttribute is null) + continue; + + if (!derivesFromNode) + { + context.ReportDiagnostic(Diagnostic.Create( + GodotProjectDiagnostics.AutoLoadTypeMustDeriveFromNode, + candidate.ClassDeclaration.Identifier.GetLocation(), + typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + continue; + } + + if (!TryGetAutoLoadName(autoLoadAttribute, out var autoLoadName)) + continue; + + if (!projectAutoLoadNames.Contains(autoLoadName)) + continue; + + if (!explicitMappings.TryGetValue(autoLoadName, out var explicitList)) + { + explicitList = new List(); + explicitMappings.Add(autoLoadName, explicitList); + } + + explicitList.Add(typeSymbol); + } + + var resolvedMappings = new Dictionary(StringComparer.Ordinal); + + foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal)) + { + if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList)) + { + var distinctExplicitTypes = DistinctTypeSymbols(explicitList); + + if (distinctExplicitTypes.Length == 1) + { + resolvedMappings.Add(projectAutoLoadName, distinctExplicitTypes[0]); + } + else if (distinctExplicitTypes.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateAutoLoadMapping, + Location.None, + projectAutoLoadName, + string.Join( + ", ", + distinctExplicitTypes.Select(static type => + type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))))); + } + + continue; + } + + if (!implicitCandidates.TryGetValue(projectAutoLoadName, out var implicitList)) + continue; + + var distinctImplicitTypes = DistinctTypeSymbols(implicitList); + + if (distinctImplicitTypes.Length == 1) + resolvedMappings.Add(projectAutoLoadName, distinctImplicitTypes[0]); + } + + return resolvedMappings; + } + + private static bool TryGetAutoLoadName(AttributeData attribute, out string autoLoadName) + { + autoLoadName = string.Empty; + + if (attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Value is not string rawName || + string.IsNullOrWhiteSpace(rawName)) + { + return false; + } + + autoLoadName = rawName; + return true; + } + + private static IReadOnlyList CreateAutoLoadMembers( + SourceProductionContext context, + ProjectMetadataParseResult projectResult, + IReadOnlyDictionary typedMappings) + { + var identifierCounts = new Dictionary(StringComparer.Ordinal); + var members = new List(projectResult.AutoLoads.Length); + + foreach (var entry in projectResult.AutoLoads) + { + var baseIdentifier = SanitizeIdentifier(entry.Name, "AutoLoad"); + var identifier = ResolveUniqueIdentifier( + context, + identifierCounts, + entry.Name, + baseIdentifier, + GodotProjectDiagnostics.AutoLoadIdentifierCollision); + + var typeName = typedMappings.TryGetValue(entry.Name, out var typeSymbol) + ? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + : "global::Godot.Node"; + + members.Add(new GeneratedAutoLoadMember(entry.Name, identifier, typeName, entry.ResourcePath)); + } + + return members; + } + + private static IReadOnlyList CreateInputActionMembers( + SourceProductionContext context, + ProjectMetadataParseResult projectResult) + { + var identifierCounts = new Dictionary(StringComparer.Ordinal); + var members = new List(projectResult.InputActions.Length); + + foreach (var actionName in projectResult.InputActions) + { + var baseIdentifier = SanitizeIdentifier(actionName, "Action"); + var identifier = ResolveUniqueIdentifier( + context, + identifierCounts, + actionName, + baseIdentifier, + GodotProjectDiagnostics.InputActionIdentifierCollision); + + members.Add(new GeneratedInputActionMember(actionName, identifier)); + } + + return members; + } + + private static string ResolveUniqueIdentifier( + SourceProductionContext context, + IDictionary identifierCounts, + string originalName, + string baseIdentifier, + DiagnosticDescriptor collisionDiagnostic) + { + if (!identifierCounts.TryGetValue(baseIdentifier, out var count)) + { + identifierCounts.Add(baseIdentifier, 1); + return baseIdentifier; + } + + count++; + identifierCounts[baseIdentifier] = count; + + context.ReportDiagnostic(Diagnostic.Create( + collisionDiagnostic, + Location.None, + originalName, + baseIdentifier)); + + return $"{baseIdentifier}_{count}"; + } + + private static INamedTypeSymbol[] DistinctTypeSymbols(IEnumerable types) + { + var results = new List(); + + foreach (var type in types) + { + if (results.Any(existing => SymbolEqualityComparer.Default.Equals(existing, type))) + continue; + + results.Add(type); + } + + return results.ToArray(); + } + + private static string SanitizeIdentifier( + string rawName, + string fallbackPrefix) + { + var tokens = new List(); + var tokenBuilder = new StringBuilder(); + + foreach (var character in rawName) + { + if (char.IsLetterOrDigit(character)) + { + tokenBuilder.Append(character); + continue; + } + + FlushToken(tokens, tokenBuilder); + } + + FlushToken(tokens, tokenBuilder); + + var identifier = tokens.Count == 0 + ? fallbackPrefix + : string.Concat(tokens); + + if (string.IsNullOrWhiteSpace(identifier)) + identifier = fallbackPrefix; + + if (!SyntaxFacts.IsIdentifierStartCharacter(identifier[0])) + identifier = fallbackPrefix + identifier; + + return SyntaxFacts.GetKeywordKind(identifier) != SyntaxKind.None + ? identifier + "Value" + : identifier; + } + + private static void FlushToken( + ICollection tokens, + StringBuilder tokenBuilder) + { + if (tokenBuilder.Length == 0) + return; + + var token = tokenBuilder.ToString(); + tokenBuilder.Clear(); + + if (token.Length == 1) + { + tokens.Add(token.ToUpperInvariant()); + return; + } + + tokens.Add(char.ToUpperInvariant(token[0]) + token.Substring(1)); + } + + private static string GenerateAutoLoadSource(IReadOnlyList members) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {GeneratedNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine("/// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。"); + builder.AppendLine("/// "); + builder.AppendLine("public static partial class AutoLoads"); + builder.AppendLine("{"); + + foreach (var member in members) + { + builder.AppendLine(" /// "); + builder.AppendLine($" /// 获取 AutoLoad {member.AutoLoadName}。"); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public static {member.TypeName} {member.Identifier} => GetRequiredNode<{member.TypeName}>({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)});"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine($" /// 尝试获取 AutoLoad {member.AutoLoadName}。"); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public static bool TryGet{member.Identifier}(out {member.TypeName}? value)"); + builder.AppendLine(" {"); + builder.AppendLine( + $" return TryGetNode({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)}, out value);"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + builder.AppendLine(" /// "); + builder.AppendLine(" /// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// 节点类型。"); + builder.AppendLine(" /// AutoLoad 名称。"); + builder.AppendLine(" /// 已解析的 AutoLoad 节点。"); + builder.AppendLine(" private static TNode GetRequiredNode(string autoLoadName)"); + builder.AppendLine(" where TNode : global::Godot.Node"); + builder.AppendLine(" {"); + builder.AppendLine(" if (TryGetNode(autoLoadName, out TNode? value))"); + builder.AppendLine(" {"); + builder.AppendLine(" return value!;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine( + " throw new global::System.InvalidOperationException($\"AutoLoad '{autoLoadName}' is not available on the active SceneTree root.\");"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" /// "); + builder.AppendLine(" /// 尝试从当前 SceneTree 根节点解析 AutoLoad。"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// 节点类型。"); + builder.AppendLine(" /// AutoLoad 名称。"); + builder.AppendLine(" /// 解析到的节点实例。"); + builder.AppendLine(" /// 若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad,则返回 true"); + builder.AppendLine(" private static bool TryGetNode(string autoLoadName, out TNode? value)"); + builder.AppendLine(" where TNode : global::Godot.Node"); + builder.AppendLine(" {"); + builder.AppendLine(" value = default;"); + builder.AppendLine(); + builder.AppendLine(" if (global::Godot.Engine.GetMainLoop() is not global::Godot.SceneTree sceneTree)"); + builder.AppendLine(" {"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" var root = sceneTree.Root;"); + builder.AppendLine(" if (root is null)"); + builder.AppendLine(" {"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" value = root.GetNodeOrNull($\"/root/{autoLoadName}\");"); + builder.AppendLine(" return value is not null;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + private static string GenerateInputActionsSource(IReadOnlyList members) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine($"namespace {GeneratedNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine("/// 提供 project.godot 中 Input Action 名称的强类型常量。"); + builder.AppendLine("/// "); + builder.AppendLine("public static partial class InputActions"); + builder.AppendLine("{"); + + foreach (var member in members) + { + builder.AppendLine(" /// "); + builder.AppendLine($" /// Input Action {member.ActionName} 的稳定名称。"); + builder.AppendLine(" /// "); + builder.AppendLine( + $" public const string {member.Identifier} = {SymbolDisplay.FormatLiteral(member.ActionName, true)};"); + builder.AppendLine(); + } + + builder.AppendLine("}"); + return builder.ToString(); + } + + private static ProjectMetadataParseResult ParseProjectFile( + AdditionalText file, + CancellationToken cancellationToken) + { + var text = file.GetText(cancellationToken); + if (text is null) + { + return new ProjectMetadataParseResult( + file.Path, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty); + } + + var currentSection = string.Empty; + var autoLoads = new List(); + var inputActions = new List(); + var diagnostics = new List(); + var seenAutoLoads = new HashSet(StringComparer.Ordinal); + var seenInputActions = new HashSet(StringComparer.Ordinal); + + foreach (var line in text.Lines) + { + var content = line.ToString().Trim(); + if (string.IsNullOrWhiteSpace(content) || content.StartsWith(";", StringComparison.Ordinal)) + continue; + + if (content.StartsWith("[", StringComparison.Ordinal) && content.EndsWith("]", StringComparison.Ordinal)) + { + currentSection = content.Substring(1, content.Length - 2).Trim(); + continue; + } + + if (!TryParseAssignment(content, out var key, out var value)) + continue; + + if (string.Equals(currentSection, "autoload", StringComparison.OrdinalIgnoreCase)) + { + if (!seenAutoLoads.Add(key)) + { + diagnostics.Add(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateAutoLoadEntry, + CreateFileLocation(file.Path), + key)); + continue; + } + + autoLoads.Add(new ProjectAutoLoadEntry( + key, + NormalizeProjectPath(value))); + continue; + } + + if (string.Equals(currentSection, "input", StringComparison.OrdinalIgnoreCase)) + { + if (!seenInputActions.Add(key)) + { + diagnostics.Add(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateInputActionEntry, + CreateFileLocation(file.Path), + key)); + continue; + } + + inputActions.Add(key); + } + } + + return new ProjectMetadataParseResult( + file.Path, + autoLoads.ToImmutableArray(), + inputActions.ToImmutableArray(), + diagnostics.ToImmutableArray()); + } + + private static string NormalizeProjectPath(string rawValue) + { + var trimmed = rawValue.Trim(); + + if (trimmed.Length >= 2 && + trimmed[0] == '"' && + trimmed[trimmed.Length - 1] == '"') + { + trimmed = trimmed.Substring(1, trimmed.Length - 2); + } + + return trimmed.TrimStart('*'); + } + + private static bool TryParseAssignment( + string line, + out string key, + out string value) + { + key = string.Empty; + value = string.Empty; + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) + return false; + + key = line.Substring(0, separatorIndex).Trim(); + if (string.IsNullOrWhiteSpace(key)) + return false; + + value = line.Substring(separatorIndex + 1).Trim(); + return true; + } + + private static Location CreateFileLocation(string filePath) + { + return Location.Create(filePath, TextSpan.FromBounds(0, 0), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0))); + } + + private sealed class GodotTypeCandidate + { + /// + /// 创建一个类型候选。 + /// + /// 类型语法节点。 + /// 类型符号。 + public GodotTypeCandidate( + ClassDeclarationSyntax classDeclaration, + INamedTypeSymbol typeSymbol) + { + ClassDeclaration = classDeclaration; + TypeSymbol = typeSymbol; + } + + /// + /// 获取类型声明语法。 + /// + public ClassDeclarationSyntax ClassDeclaration { get; } + + /// + /// 获取类型符号。 + /// + public INamedTypeSymbol TypeSymbol { get; } + } + + private sealed class ProjectAutoLoadEntry + { + /// + /// 初始化 AutoLoad 条目。 + /// + /// AutoLoad 名称。 + /// 资源路径。 + public ProjectAutoLoadEntry( + string name, + string resourcePath) + { + Name = name; + ResourcePath = resourcePath; + } + + /// + /// 获取 AutoLoad 名称。 + /// + public string Name { get; } + + /// + /// 获取资源路径。 + /// + public string ResourcePath { get; } + } + + private sealed class GeneratedAutoLoadMember + { + /// + /// 初始化一个生成后的 AutoLoad 成员描述。 + /// + /// 原始 AutoLoad 名称。 + /// 生成后的标识符。 + /// 类型名。 + /// 资源路径。 + public GeneratedAutoLoadMember( + string autoLoadName, + string identifier, + string typeName, + string resourcePath) + { + AutoLoadName = autoLoadName; + Identifier = identifier; + TypeName = typeName; + ResourcePath = resourcePath; + } + + /// + /// 获取原始 AutoLoad 名称。 + /// + public string AutoLoadName { get; } + + /// + /// 获取生成后的标识符。 + /// + public string Identifier { get; } + + /// + /// 获取类型名。 + /// + public string TypeName { get; } + + /// + /// 获取资源路径。 + /// + public string ResourcePath { get; } + } + + private sealed class GeneratedInputActionMember + { + /// + /// 初始化一个生成后的 Input Action 成员描述。 + /// + /// 原始动作名。 + /// 生成后的标识符。 + public GeneratedInputActionMember( + string actionName, + string identifier) + { + ActionName = actionName; + Identifier = identifier; + } + + /// + /// 获取原始动作名。 + /// + public string ActionName { get; } + + /// + /// 获取生成后的标识符。 + /// + public string Identifier { get; } + } + + private sealed class ProjectMetadataParseResult + { + /// + /// 初始化一个项目元数据解析结果。 + /// + /// 项目文件路径。 + /// AutoLoad 条目。 + /// Input Action 条目。 + /// 解析过程中的诊断。 + public ProjectMetadataParseResult( + string filePath, + ImmutableArray autoLoads, + ImmutableArray inputActions, + ImmutableArray diagnostics) + { + FilePath = filePath; + AutoLoads = autoLoads; + InputActions = inputActions; + Diagnostics = diagnostics; + } + + /// + /// 获取项目文件路径。 + /// + public string FilePath { get; } + + /// + /// 获取 AutoLoad 条目。 + /// + public ImmutableArray AutoLoads { get; } + + /// + /// 获取 Input Action 条目。 + /// + public ImmutableArray InputActions { get; } + + /// + /// 获取解析过程中的诊断。 + /// + public ImmutableArray Diagnostics { get; } + } +} diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md index 87f423d5..1310807f 100644 --- a/GFramework.Godot.SourceGenerators/README.md +++ b/GFramework.Godot.SourceGenerators/README.md @@ -6,6 +6,7 @@ - 与 Godot 场景相关的编译期生成能力 - 基于 Roslyn 的增量生成器实现 +- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口 - `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode()` 样板代码 - `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码 @@ -13,6 +14,93 @@ - 仅在 Godot + C# 项目中启用 - 非 Godot 项目可只使用 GFramework.SourceGenerators +- 当项目通过 NuGet 包引用本模块时,根目录下的 `project.godot` 会被自动加入 `AdditionalFiles` +- 当项目通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器时,需要手动把 `project.godot` 加入 + `AdditionalFiles` + +## project.godot 集成 + +默认情况下,生成器会读取 Godot 项目根目录下的 `project.godot`,并生成: + +- `GFramework.Godot.Generated.AutoLoads` +- `GFramework.Godot.Generated.InputActions` + +如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置: + +```xml + + project.godot + +``` + +如果你在仓库内通过 analyzer 形式直接引用本项目,则需要显式配置: + +```xml + + + +``` + +## AutoLoad 强类型访问 + +当某个 AutoLoad 无法仅靠类型名唯一推断到 C# 节点类型时,可以使用 `[AutoLoad]` 显式声明映射: + +```csharp +using GFramework.Godot.SourceGenerators.Abstractions; +using Godot; + +[AutoLoad("GameServices")] +public partial class GameServices : Node +{ +} +``` + +对应 `project.godot`: + +```ini +[autoload] +GameServices="*res://autoload/game_services.tscn" +AudioBus="*res://autoload/audio_bus.gd" +``` + +生成器会产出统一入口: + +```csharp +using GFramework.Godot.Generated; + +var gameServices = AutoLoads.GameServices; + +if (AutoLoads.TryGetAudioBus(out var audioBus)) +{ +} +``` + +- 显式 `[AutoLoad]` 映射优先于隐式类型名推断 +- 若同名映射冲突,生成器会给出诊断并退化为 `Godot.Node` 访问 +- 若无法映射到 C# 节点类型,仍会生成可用的 `Godot.Node` 访问器 + +## Input Action 常量生成 + +`project.godot` 的 `[input]` 段会自动生成稳定常量,避免手写字符串: + +```ini +[input] +move_up={ +} +ui_cancel={ +} +``` + +```csharp +using GFramework.Godot.Generated; + +if (Input.IsActionJustPressed(InputActions.MoveUp)) +{ +} +``` + +- 动作名会转换为可补全的 C# 标识符,例如 `move_up -> MoveUp` +- 当多个动作名映射到同一标识符时,会追加稳定后缀并给出警告 ## GetNode 用法 From 7dafec72be83c068e9aa0000eee479e3fc4db777 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:22:28 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(docs):=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=85=8D=E7=BD=AE=E5=92=8CAPI=E5=8F=82=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增.vitepress/config.mts配置文件,包含本地搜索、代码块保护等功能 - 添加API参考文档,涵盖核心架构、事件系统、属性系统等完整API - 添加源码生成器文档,介绍Log、ContextAware、EnumExtensions等生成器用法 - 配置多语言导航和侧边栏结构,完善文档站点设置 - 添加代码示例和使用指南,提供完整的框架使用参考 --- AGENTS.md | 21 +++ docs/.vitepress/config.mts | 1 + docs/zh-CN/api-reference/index.md | 21 +-- .../godot-project-generator.md | 169 ++++++++++++++++++ docs/zh-CN/source-generators/index.md | 60 +++++++ docs/zh-CN/tutorials/godot-integration.md | 21 ++- 6 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 docs/zh-CN/source-generators/godot-project-generator.md diff --git a/AGENTS.md b/AGENTS.md index 8e2d97b3..c63a2bfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,27 @@ All AI agents and contributors must follow these rules when writing, reviewing, - After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the shell does not fall back to Linux `/usr/bin/git` later in the same WSL session. +## Subagent Usage Rules + +- Use subagents only when the task is complex, the context is likely to grow too large, or the work can be split into + independent parallel subtasks. +- The main agent MUST identify the critical path first. Do not delegate the immediate blocking task if the next local + step depends on that result. +- Use `explorer` subagents for read-only discovery, comparison, tracing, and narrow codebase questions. +- Use `worker` subagents only for bounded implementation tasks with an explicit file or module ownership boundary. +- Every delegation MUST specify: + - the concrete objective + - the expected output format + - the files or subsystem the subagent owns + - any constraints about tests, diagnostics, or compatibility +- Subagents are not allowed to revert or overwrite unrelated changes from the user or other agents. They must adapt to + concurrent work instead of assuming exclusive ownership of the repository. +- Prefer lightweight models such as `gpt-5.1-codex-mini` for narrow exploration, indexing, and comparison tasks. +- Prefer stronger models such as `gpt-5.4` for cross-module design work, non-trivial refactors, and tasks that require + higher confidence reasoning. +- The main agent remains responsible for reviewing and integrating subagent output. Unreviewed subagent conclusions do + not count as final results. + ## Commenting Rules (MUST) All generated or modified code MUST include clear and meaningful comments where required by the rules below. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3b93e141..bcac727e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -251,6 +251,7 @@ export default defineConfig({ { text: 'ContextAware 生成器', link: '/zh-CN/source-generators/context-aware-generator' }, { text: 'Priority 生成器', link: '/zh-CN/source-generators/priority-generator' }, { text: 'Context Get 注入', link: '/zh-CN/source-generators/context-get-generator' }, + { text: 'Godot 项目元数据', link: '/zh-CN/source-generators/godot-project-generator' }, { text: 'GetNode 生成器 (Godot)', link: '/zh-CN/source-generators/get-node-generator' }, { text: 'BindNodeSignal 生成器 (Godot)', link: '/zh-CN/source-generators/bind-node-signal-generator' } ] diff --git a/docs/zh-CN/api-reference/index.md b/docs/zh-CN/api-reference/index.md index b491cc5a..6ecbafe8 100644 --- a/docs/zh-CN/api-reference/index.md +++ b/docs/zh-CN/api-reference/index.md @@ -452,16 +452,17 @@ Godot 引擎集成模块。 #### 常用 Attribute -| Attribute | 说明 | 文档 | -|--------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------| -| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | -| `RegisterModelAttribute` | 声明模块内自动注册的 `IModel` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | -| `RegisterSystemAttribute` | 声明模块内自动注册的 `ISystem` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | -| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | -| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) | -| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) | -| `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) | -| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) | +| Attribute | 说明 | 文档 | +|--------------------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | +| `RegisterModelAttribute` | 声明模块内自动注册的 `IModel` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | +| `RegisterSystemAttribute` | 声明模块内自动注册的 `ISystem` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | +| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | +| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) | +| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) | +| `AutoLoadAttribute` | 显式声明 `project.godot` AutoLoad 与 C# 节点类型映射 | [Godot 项目元数据生成器](../source-generators/godot-project-generator.md) | +| `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) | +| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) | ## 常见用法示例 diff --git a/docs/zh-CN/source-generators/godot-project-generator.md b/docs/zh-CN/source-generators/godot-project-generator.md new file mode 100644 index 00000000..96bc19b1 --- /dev/null +++ b/docs/zh-CN/source-generators/godot-project-generator.md @@ -0,0 +1,169 @@ +# Godot 项目元数据生成器 + +> 从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口。 + +## 概述 + +`GFramework.Godot.SourceGenerators` 会读取 Godot 项目根目录下的 `project.godot`,并把其中最常用的项目级元数据暴露为稳定的编译期 +API。 + +当前覆盖: + +- `[autoload]` 段:生成 `GFramework.Godot.Generated.AutoLoads` +- `[input]` 段:生成 `GFramework.Godot.Generated.InputActions` + +这项能力的目标不是替代场景级生成器,而是把 Godot 工程配置和 C# 代码之间的字符串约定收敛到编译期。 + +## 接入方式 + +### NuGet 引用 + +当项目通过 NuGet 引用 `GeWuYou.GFramework.Godot.SourceGenerators` 时,生成器会默认把项目根目录下的 `project.godot` 加入 +`AdditionalFiles`。 + +如需覆盖默认路径,可以设置: + +```xml + + project.godot + +``` + +### 仓库内直接引用生成器 + +如果你通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器项目,则需要手动加入: + +```xml + + + +``` + +## AutoLoad 访问层 + +### 基础行为 + +假设 `project.godot` 中声明了: + +```ini +[autoload] +GameServices="*res://autoload/game_services.tscn" +AudioBus="*res://autoload/audio_bus.gd" +``` + +生成器会产出: + +```csharp +using GFramework.Godot.Generated; + +var gameServices = AutoLoads.GameServices; + +if (AutoLoads.TryGetAudioBus(out var audioBus)) +{ +} +``` + +- 对于能唯一映射到 C# 节点类型的条目,属性会是强类型的 +- 对于无法映射或对应非 C# 脚本的条目,属性会退化为 `Godot.Node` +- 生成器通过 `Godot.Engine.GetMainLoop()` 与当前 `SceneTree.Root` 解析 `/root/` 节点 + +### 显式映射 + +当 AutoLoad 名称无法仅靠类名唯一推断时,可以使用 `[AutoLoad]` 明确指定: + +```csharp +using GFramework.Godot.SourceGenerators.Abstractions; +using Godot; + +[AutoLoad("GameServices")] +public partial class GameServices : Node +{ +} +``` + +规则如下: + +- 显式 `[AutoLoad]` 映射优先于隐式类名推断 +- 标记了 `[AutoLoad]` 的类型必须继承 `Godot.Node` +- 若多个类型映射到同一个 AutoLoad,生成器会报告诊断,并退化为 `Godot.Node` 访问器,直到映射唯一 + +## Input Action 常量 + +### 基础行为 + +假设 `project.godot` 中有: + +```ini +[input] +move_up={ +} +ui_cancel={ +} +``` + +生成器会产出: + +```csharp +using GFramework.Godot.Generated; + +if (Input.IsActionJustPressed(InputActions.MoveUp)) +{ +} +``` + +转换规则: + +- `move_up` -> `MoveUp` +- `ui_cancel` -> `UiCancel` +- 非法字符会被清理后再转换为 PascalCase +- 如果多个动作名落到同一个标识符,生成器会追加稳定数字后缀,例如 `MoveUp_2` + +## 与现有 Godot 生成器的关系 + +这项能力和现有的场景级生成器是互补的: + +- `AutoLoads` / `InputActions` 解决的是项目级元数据访问 +- `[GetNode]` 解决的是场景节点引用注入 +- `[BindNodeSignal]` 解决的是节点事件订阅样板 + +推荐组合方式: + +```csharp +using GFramework.Godot.Generated; +using GFramework.Godot.SourceGenerators.Abstractions; +using Godot; + +public partial class MainHud : Control +{ + [GetNode] + private Button _startButton = null!; + + public override void _Ready() + { + __InjectGetNodes_Generated(); + + if (Input.IsActionPressed(InputActions.UiCancel)) + { + } + + var services = AutoLoads.GameServices; + } +} +``` + +## 诊断与约束 + +当前会重点报告以下问题: + +- `[AutoLoad]` 标记在非 `Godot.Node` 类型上 +- 多个类型映射到同一个 AutoLoad 名称 +- 不同 AutoLoad 名称或 Input Action 名称在清洗后发生标识符冲突 +- `project.godot` 内部重复声明同名 AutoLoad 或 Input Action + +这些诊断的目的不是阻断所有生成,而是在可能的情况下保留稳定输出,同时把不确定性显式暴露出来。 + +## 相关文档 + +- [GetNode 生成器](./get-node-generator) +- [BindNodeSignal 生成器](./bind-node-signal-generator) +- [Godot 集成教程](../tutorials/godot-integration) diff --git a/docs/zh-CN/source-generators/index.md b/docs/zh-CN/source-generators/index.md index 60aada08..8fd91e66 100644 --- a/docs/zh-CN/source-generators/index.md +++ b/docs/zh-CN/source-generators/index.md @@ -16,6 +16,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通 - [Priority 属性生成器](#priority-属性生成器) - [Context Get 注入生成器](#context-get-注入生成器) - [AutoRegisterModule 生成器](#autoregistermodule-生成器) +- [Godot 项目元数据生成](#godot-项目元数据生成) - [GetNode 生成器 (Godot)](#getnode-生成器) - [BindNodeSignal 生成器 (Godot)](#bindnodesignal-生成器) - [AutoUiPage 生成器 (Godot)](#autouipage-生成器) @@ -52,6 +53,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译 ### Godot 专用生成器 +- **Godot 项目元数据生成 (Godot)**:从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口 - **[GetNode] 属性 (Godot)**:自动获取 Godot 节点引用,支持多种查找模式 - **[BindNodeSignal] 属性 (Godot)**:自动生成 Godot 节点信号绑定与解绑逻辑 - **[AutoUiPage] 属性 (Godot)**:自动生成 UI 页面行为包装与页面 Key @@ -435,6 +437,64 @@ public enum PlayerState | GenerateIsMethods | bool | true | 是否为每个枚举值生成 IsX 方法 | | GenerateIsInMethod | bool | true | 是否生成 IsIn 方法 | +## Godot 项目元数据生成 + +Godot 项目元数据生成器会读取 `project.godot`,把项目级配置转换为稳定的编译期 API。 + +当前生成两个统一入口: + +- `GFramework.Godot.Generated.AutoLoads` +- `GFramework.Godot.Generated.InputActions` + +默认情况下,NuGet 包引用会自动把项目根目录下的 `project.godot` 加入 `AdditionalFiles`。如果你是在仓库内通过 analyzer +形式直接引用生成器,则需要手动加入: + +```xml + + + +``` + +### AutoLoad 映射 + +当某个 AutoLoad 不能仅靠类名唯一推断到 C# 节点类型时,可以使用 `[AutoLoad]` 指定映射: + +```csharp +using GFramework.Godot.SourceGenerators.Abstractions; +using Godot; + +[AutoLoad("GameServices")] +public partial class GameServices : Node +{ +} +``` + +生成后可以直接使用: + +```csharp +using GFramework.Godot.Generated; + +var services = AutoLoads.GameServices; + +if (AutoLoads.TryGetAudioBus(out var audioBus)) +{ +} +``` + +### Input Action 常量 + +`[input]` 段会被转换为强类型常量: + +```csharp +using GFramework.Godot.Generated; + +if (Input.IsActionPressed(InputActions.MoveUp)) +{ +} +``` + +完整说明请见:[Godot 项目元数据生成器](./godot-project-generator) + ## GetNode 生成器 GetNode 生成器为标记了 `[GetNode]` 特性的字段自动生成 Godot 节点获取代码,无需手动调用 `GetNode()` 方法。 diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md index d1d90d93..b9352ac8 100644 --- a/docs/zh-CN/tutorials/godot-integration.md +++ b/docs/zh-CN/tutorials/godot-integration.md @@ -14,6 +14,25 @@ ## Godot 特定功能 +### 0. project.godot 编译期接入 + +如果项目引用了 `GFramework.Godot.SourceGenerators`,现在可以直接把 `project.godot` 中的 AutoLoad 与 Input Action 暴露为强类型 +API: + +```csharp +using GFramework.Godot.Generated; + +var services = AutoLoads.GameServices; + +if (Input.IsActionPressed(InputActions.MoveUp)) +{ +} +``` + +这项能力适合和 `[GetNode]`、`[BindNodeSignal]` 一起使用:前者解决项目级配置入口,后两者解决场景级节点和事件样板。 + +详细说明见:[Godot 项目元数据生成器](../source-generators/godot-project-generator) + ### 1. 节点生命周期绑定 GFramework.Godot 提供了与 Godot 节点生命周期的无缝集成,确保框架初始化与 Godot 场景树同步。 @@ -1335,4 +1354,4 @@ public class GodotPerformanceTests --- **教程版本**: 1.0.0 -**更新日期**: 2026-01-12 \ No newline at end of file +**更新日期**: 2026-01-12 From 833a295b84f9e3942ba19102875d8b69ac510f17 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:05:33 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(godot):=20=E6=B7=BB=E5=8A=A0=20Godot?= =?UTF-8?q?=20=E9=9B=86=E6=88=90=E5=8A=9F=E8=83=BD=E5=92=8C=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AdditionalTextGeneratorTestDriver 用于源生成器测试 - 添加 AutoLoadAttribute 特性支持 AutoLoad 类型映射 - 扩展项目构建目标,支持自定义 project.godot 路径验证 - 创建完整 Godot 集成教程文档,涵盖节点生命周期、信号系统等功能 - 添加源代码生成器测试项目配置和相关依赖包引用 --- .../AutoLoadAttribute.cs | 15 +- .../Abstractions/AutoLoadAttributeTests.cs | 49 ++++ .../Core/AdditionalTextGeneratorTestDriver.cs | 7 +- .../AdditionalTextGeneratorTestDriverTests.cs | 21 ++ ...mework.Godot.SourceGenerators.Tests.csproj | 1 + .../GodotProjectMetadataGeneratorTests.cs | 241 ++++++++++++++++++ ....GFramework.Godot.SourceGenerators.targets | 12 + .../GodotProjectMetadataGenerator.cs | 35 ++- GFramework.Godot.SourceGenerators/README.md | 7 +- .../godot-project-generator.md | 5 +- docs/zh-CN/tutorials/godot-integration.md | 2 +- 11 files changed, 379 insertions(+), 16 deletions(-) create mode 100644 GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs create mode 100644 GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs diff --git a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs index e406f8b6..a6b54ede 100644 --- a/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs +++ b/GFramework.Godot.SourceGenerators.Abstractions/AutoLoadAttribute.cs @@ -18,9 +18,22 @@ public sealed class AutoLoadAttribute : Attribute /// /// 。 /// + /// + /// 为空字符串或仅包含空白字符。 + /// public AutoLoadAttribute(string name) { - Name = name ?? throw new ArgumentNullException(nameof(name)); + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("AutoLoad name cannot be empty or whitespace.", nameof(name)); + } + + Name = name; } /// diff --git a/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs b/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs new file mode 100644 index 00000000..9d8ddff8 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Abstractions/AutoLoadAttributeTests.cs @@ -0,0 +1,49 @@ +using GFramework.Godot.SourceGenerators.Abstractions; + +namespace GFramework.Godot.SourceGenerators.Tests.Abstractions; + +/// +/// 验证 的参数约束。 +/// +[TestFixture] +public class AutoLoadAttributeTests +{ + /// + /// 验证构造函数会保留合法的 AutoLoad 名称。 + /// + [Test] + public void Constructor_Should_Store_Name_When_Name_Is_Valid() + { + var attribute = new AutoLoadAttribute("GameServices"); + + Assert.That(attribute.Name, Is.EqualTo("GameServices")); + } + + /// + /// 验证构造函数会拒绝空引用。 + /// + [Test] + public void Constructor_Should_Throw_When_Name_Is_Null() + { + var exception = Assert.Throws(() => new AutoLoadAttribute(null!)); + + Assert.That(exception!.ParamName, Is.EqualTo("name")); + } + + /// + /// 验证构造函数会拒绝空字符串与仅空白字符串。 + /// + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + public void Constructor_Should_Throw_When_Name_Is_Empty_Or_Whitespace(string name) + { + var exception = Assert.Throws(() => new AutoLoadAttribute(name)); + + Assert.Multiple(() => + { + Assert.That(exception!.ParamName, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("empty or whitespace")); + }); + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs index f1aaa03f..127c5ed4 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriver.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; using System.IO; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; namespace GFramework.Godot.SourceGenerators.Tests.Core; @@ -60,13 +62,12 @@ public static class AdditionalTextGeneratorTestDriver /// 规范化换行,避免测试在不同平台上产生伪差异。 /// /// 待规范化文本。 - /// 使用当前平台换行符的内容。 + /// 统一使用 LF (\n) 的内容。 public static string NormalizeLineEndings(string content) { return content .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace("\r", "\n", StringComparison.Ordinal) - .Replace("\n", Environment.NewLine, StringComparison.Ordinal); + .Replace("\r", "\n", StringComparison.Ordinal); } private static IEnumerable GetMetadataReferences() diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs new file mode 100644 index 00000000..e6f7f343 --- /dev/null +++ b/GFramework.Godot.SourceGenerators.Tests/Core/AdditionalTextGeneratorTestDriverTests.cs @@ -0,0 +1,21 @@ +namespace GFramework.Godot.SourceGenerators.Tests.Core; + +/// +/// 验证 的文本规范化行为。 +/// +[TestFixture] +public class AdditionalTextGeneratorTestDriverTests +{ + /// + /// 验证不同平台换行最终都会被统一为 LF。 + /// + [Test] + public void NormalizeLineEndings_Should_Convert_All_Line_Endings_To_Lf() + { + const string content = "line1\r\nline2\rline3\nline4"; + + var normalized = AdditionalTextGeneratorTestDriver.NormalizeLineEndings(content); + + Assert.That(normalized, Is.EqualTo("line1\nline2\nline3\nline4")); + } +} diff --git a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj index d02277db..b4ce059d 100644 --- a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj +++ b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs index 252e7e1e..63265bd2 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs @@ -316,4 +316,245 @@ public class GodotProjectMetadataGeneratorTests generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], Does.Contain("public const string MoveUp_2 = \"move-up\";")); } + + /// + /// 验证多个显式映射指向同一个 AutoLoad 时会报告重复映射,并退化为 Godot.Node。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Explicit_AutoLoad_Mappings_Are_Duplicated() + { + var result = RunGenerator( + CreateSource( + """ + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + using Godot; + + [AutoLoad("AudioBus")] + public partial class PrimaryAudioBus : Node + { + } + + [AutoLoad("AudioBus")] + public partial class SecondaryAudioBus : Node + { + } + } + """, + includeAutoLoadAttribute: true), + """ + [autoload] + AudioBus="*res://autoload/audio_bus.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("PrimaryAudioBus")); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("SecondaryAudioBus")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"AudioBus\");")); + }); + } + + /// + /// 验证不同命名空间下的同名节点类型会触发隐式映射冲突诊断,并退化为 Godot.Node。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Implicit_AutoLoad_Mappings_Are_Ambiguous() + { + var result = RunGenerator( + CreateSource( + """ + namespace TestApp.Audio + { + using Godot; + + public partial class AudioBus : Node + { + } + } + + namespace TestApp.Debug + { + using Godot; + + public partial class AudioBus : Node + { + } + } + """), + """ + [autoload] + AudioBus="*res://autoload/audio_bus.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Audio.AudioBus")); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Debug.AudioBus")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"AudioBus\");")); + }); + } + + /// + /// 验证 AutoLoad 标识符冲突时会追加稳定后缀并报告诊断。 + /// + [Test] + public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_AutoLoad_Identifiers_Collide() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [autoload] + audio_bus="*res://autoload/audio_bus.tscn" + audio-bus="*res://autoload/audio_bus_debug.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_003" })); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode(\"audio_bus\");")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node AudioBus_2 => GetRequiredNode(\"audio-bus\");")); + }); + } + + /// + /// 验证重复 AutoLoad 条目会报告诊断,并只保留第一条声明参与生成。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_AutoLoads() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [autoload] + GameServices="*res://autoload/game_services.tscn" + GameServices="*res://autoload/game_services_debug.tscn" + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_005" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("GameServices")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Contain("public static global::Godot.Node GameServices => GetRequiredNode(\"GameServices\");")); + Assert.That( + generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"], + Does.Not.Contain("GameServices_2")); + }); + } + + /// + /// 验证重复 Input Action 条目会报告诊断,并只保留第一条声明参与生成。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_Input_Actions() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [input] + move_up={ + } + move_up={ + } + """); + + var diagnostics = result.Results.Single().Diagnostics; + var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result); + + Assert.Multiple(() => + { + Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_006" })); + Assert.That(diagnostics.Single().GetMessage(), Does.Contain("move_up")); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Contain("public const string MoveUp = \"move_up\";")); + Assert.That( + generatedSources["GFramework_Godot_Generated_InputActions.g.cs"], + Does.Not.Contain("MoveUp_2")); + }); + } + + private static GeneratorDriverRunResult RunGenerator( + string source, + string projectFile) + { + return AdditionalTextGeneratorTestDriver.Run( + source, + ("project.godot", projectFile)); + } + + private static string CreateSource( + string applicationSource, + bool includeAutoLoadAttribute = false) + { + var autoLoadAttributeSource = includeAutoLoadAttribute + ? """ + using System; + + namespace GFramework.Godot.SourceGenerators.Abstractions + { + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class AutoLoadAttribute : Attribute + { + public AutoLoadAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } + } + """ + : string.Empty; + + return autoLoadAttributeSource + """ + namespace Godot + { + public class MainLoop + { + } + + public class Node + { + public T? GetNodeOrNull(string path) where T : Node => default; + } + + public sealed class SceneTree : MainLoop + { + public Node? Root { get; set; } + } + + public static class Engine + { + public static MainLoop? GetMainLoop() => default; + } + } + + """ + applicationSource; + } } diff --git a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets index cf19200c..5116e4da 100644 --- a/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets +++ b/GFramework.Godot.SourceGenerators/GeWuYou.GFramework.Godot.SourceGenerators.targets @@ -9,6 +9,12 @@ 让 Godot 项目元数据生成能力在常规 Godot C# 工程里开箱即用。 --> project.godot + + <_GFrameworkGodotProjectFileName>$([System.IO.Path]::GetFileName('$(GFrameworkGodotProjectFile)')) + <_GFrameworkGodotProjectFileNameLower>$([System.String]::Copy('$(_GFrameworkGodotProjectFileName)').ToLowerInvariant()) @@ -21,6 +27,12 @@ + + + + diff --git a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs index ba3ddc21..c080cc71 100644 --- a/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs +++ b/GFramework.Godot.SourceGenerators/GodotProjectMetadataGenerator.cs @@ -1,5 +1,8 @@ +using System.IO; using GFramework.Godot.SourceGenerators.Diagnostics; using GFramework.SourceGenerators.Common.Constants; +using GFramework.SourceGenerators.Common.Extensions; +using Microsoft.CodeAnalysis.Text; namespace GFramework.Godot.SourceGenerators; @@ -178,6 +181,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal)) { + // 显式 [AutoLoad] 映射优先于按类型名推断,因为它代表了用户给出的稳定契约。 if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList)) { var distinctExplicitTypes = DistinctTypeSymbols(explicitList); @@ -188,14 +192,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator } else if (distinctExplicitTypes.Length > 1) { - context.ReportDiagnostic(Diagnostic.Create( - GodotProjectDiagnostics.DuplicateAutoLoadMapping, - Location.None, - projectAutoLoadName, - string.Join( - ", ", - distinctExplicitTypes.Select(static type => - type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))))); + ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctExplicitTypes); } continue; @@ -207,7 +204,14 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator var distinctImplicitTypes = DistinctTypeSymbols(implicitList); if (distinctImplicitTypes.Length == 1) + { resolvedMappings.Add(projectAutoLoadName, distinctImplicitTypes[0]); + } + else if (distinctImplicitTypes.Length > 1) + { + // 隐式推断只在唯一命中时才安全;出现同名候选时改为诊断并退化成 Godot.Node。 + ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctImplicitTypes); + } } return resolvedMappings; @@ -228,6 +232,21 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator return true; } + private static void ReportDuplicateAutoLoadMapping( + SourceProductionContext context, + string autoLoadName, + IEnumerable duplicateTypes) + { + context.ReportDiagnostic(Diagnostic.Create( + GodotProjectDiagnostics.DuplicateAutoLoadMapping, + Location.None, + autoLoadName, + string.Join( + ", ", + duplicateTypes.Select(static type => + type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat))))); + } + private static IReadOnlyList CreateAutoLoadMembers( SourceProductionContext context, ProjectMetadataParseResult projectResult, diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md index 1310807f..2e83adb3 100644 --- a/GFramework.Godot.SourceGenerators/README.md +++ b/GFramework.Godot.SourceGenerators/README.md @@ -25,11 +25,14 @@ - `GFramework.Godot.Generated.AutoLoads` - `GFramework.Godot.Generated.InputActions` -如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置: +如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置: + +- 路径可以调整到项目根目录下的其他位置 +- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件 ```xml - project.godot + Config/project.godot ``` diff --git a/docs/zh-CN/source-generators/godot-project-generator.md b/docs/zh-CN/source-generators/godot-project-generator.md index 96bc19b1..9e26f242 100644 --- a/docs/zh-CN/source-generators/godot-project-generator.md +++ b/docs/zh-CN/source-generators/godot-project-generator.md @@ -23,9 +23,12 @@ API。 如需覆盖默认路径,可以设置: +- 可以改成项目根目录下的其他相对路径 +- 文件名必须仍然是 `project.godot`,否则生成器会给出警告并忽略该文件 + ```xml - project.godot + Config/project.godot ``` diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md index b9352ac8..94f1d51c 100644 --- a/docs/zh-CN/tutorials/godot-integration.md +++ b/docs/zh-CN/tutorials/godot-integration.md @@ -1354,4 +1354,4 @@ public class GodotPerformanceTests --- **教程版本**: 1.0.0 -**更新日期**: 2026-01-12 +**更新日期**: 2026-04-14 From bb7abc0d8f32264a92e675a6db87c1c5f21749dc Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:23:49 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test(Godot):=20=E6=B7=BB=E5=8A=A0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=85=83=E6=95=B0=E6=8D=AE=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 验证 AutoLoad 和 Input Action 强类型入口生成 - 测试非节点类型上的 AutoLoad 标记诊断 - 验证输入动作标识符冲突处理和后缀追加 - 测试多个显式映射指向相同 AutoLoad 的重复检测 - 验证不同命名空间同名节点类型的冲突处理 - 测试 AutoLoad 标识符冲突的诊断和后缀追加 - 验证项目文件中重复 AutoLoad 条目的处理 - 测试重复输入动作条目的诊断和保留机制 --- .../GodotProjectMetadataGeneratorTests.cs | 62 ++++--------------- 1 file changed, 13 insertions(+), 49 deletions(-) diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs index 63265bd2..96dbe30f 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs @@ -14,56 +14,20 @@ public class GodotProjectMetadataGeneratorTests [Test] public void Run_Should_Generate_AutoLoads_And_InputActions() { - const string source = """ - using System; + var source = CreateSource( + """ + namespace TestApp + { + using GFramework.Godot.SourceGenerators.Abstractions; + using Godot; - namespace GFramework.Godot.SourceGenerators.Abstractions - { - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class AutoLoadAttribute : Attribute - { - public AutoLoadAttribute(string name) - { - Name = name; - } - - public string Name { get; } - } - } - - namespace Godot - { - public class MainLoop - { - } - - public class Node - { - public T? GetNodeOrNull(string path) where T : Node => default; - } - - public sealed class SceneTree : MainLoop - { - public Node? Root { get; set; } - } - - public static class Engine - { - public static MainLoop? GetMainLoop() => default; - } - } - - namespace TestApp - { - using GFramework.Godot.SourceGenerators.Abstractions; - using Godot; - - [AutoLoad("GameServices")] - public partial class GameServices : Node - { - } - } - """; + [AutoLoad("GameServices")] + public partial class GameServices : Node + { + } + } + """, + includeAutoLoadAttribute: true); const string projectFile = """ [autoload] From 31a439e18411ebc18e8ed9b26bf2887bb26d7ebe Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:51:52 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(Godot):=20=E6=B7=BB=E5=8A=A0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=85=83=E6=95=B0=E6=8D=AE=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 验证基于 project.godot 的 AutoLoad 和 Input Action 强类型入口生成 - 测试 AutoLoad 类型非节点继承时的诊断报告功能 - 验证 Input Action 标识符冲突时的后缀追加和警告机制 - 测试多个显式映射指向同一 AutoLoad 时的重复检测 - 验证不同命名空间同名节点类型的隐式映射冲突处理 - 测试 AutoLoad 和 Input Action 重复条目的诊断和保留逻辑 - 验证缺失或空 project.godot 文件时的无生成行为 --- .../GodotProjectMetadataGeneratorTests.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs index 96dbe30f..b033a865 100644 --- a/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs +++ b/GFramework.Godot.SourceGenerators.Tests/Project/GodotProjectMetadataGeneratorTests.cs @@ -463,6 +463,66 @@ public class GodotProjectMetadataGeneratorTests }); } + /// + /// 验证缺少 project.godot AdditionalText 时不会生成任何源码或诊断。 + /// + [Test] + public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Missing() + { + var result = AdditionalTextGeneratorTestDriver.Run( + CreateSource("namespace TestApp { }")); + + var generatorResult = result.Results.Single(); + + Assert.Multiple(() => + { + Assert.That(generatorResult.Diagnostics, Is.Empty); + Assert.That(generatorResult.GeneratedSources, Is.Empty); + }); + } + + /// + /// 验证空的 project.godot 内容不会生成任何源码或诊断。 + /// + [Test] + public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Empty() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + string.Empty); + + var generatorResult = result.Results.Single(); + + Assert.Multiple(() => + { + Assert.That(generatorResult.Diagnostics, Is.Empty); + Assert.That(generatorResult.GeneratedSources, Is.Empty); + }); + } + + /// + /// 验证只有空节的 project.godot 不会生成任何源码或诊断。 + /// + [Test] + public void Run_Should_Not_Generate_Sources_When_Project_File_Has_Empty_Sections() + { + var result = RunGenerator( + CreateSource("namespace TestApp { }"), + """ + [autoload] + + [input] + """); + + var generatorResult = result.Results.Single(); + + Assert.Multiple(() => + { + Assert.That(generatorResult.Diagnostics, Is.Empty); + Assert.That(generatorResult.GeneratedSources, Is.Empty); + }); + } + private static GeneratorDriverRunResult RunGenerator( string source, string projectFile)