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] =?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 用法