diff --git a/GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs b/GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs
new file mode 100644
index 0000000..cbbb51a
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/GetNodeAttribute.cs
@@ -0,0 +1,40 @@
+#nullable enable
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 标记 Godot 节点字段,Source Generator 会为其生成节点获取逻辑。
+///
+[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+public sealed class GetNodeAttribute : Attribute
+{
+ ///
+ /// 初始化 的新实例。
+ ///
+ public GetNodeAttribute()
+ {
+ }
+
+ ///
+ /// 初始化 的新实例,并指定节点路径。
+ ///
+ /// 节点路径。
+ public GetNodeAttribute(string path)
+ {
+ Path = path;
+ }
+
+ ///
+ /// 获取或设置节点路径。未设置时将根据字段名推导。
+ ///
+ public string? Path { get; set; }
+
+ ///
+ /// 获取或设置节点是否必填。默认为 true。
+ ///
+ public bool Required { get; set; } = true;
+
+ ///
+ /// 获取或设置节点查找模式。默认为 。
+ ///
+ public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs b/GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs
new file mode 100644
index 0000000..f78a634
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Abstractions/NodeLookupMode.cs
@@ -0,0 +1,28 @@
+#nullable enable
+namespace GFramework.Godot.SourceGenerators.Abstractions;
+
+///
+/// 节点路径的查找模式。
+///
+public enum NodeLookupMode
+{
+ ///
+ /// 自动推断。未显式设置路径时默认按唯一名查找。
+ ///
+ Auto = 0,
+
+ ///
+ /// 按唯一名查找,对应 Godot 的 %Name 语法。
+ ///
+ UniqueName = 1,
+
+ ///
+ /// 按相对路径查找。
+ ///
+ RelativePath = 2,
+
+ ///
+ /// 按绝对路径查找。
+ ///
+ AbsolutePath = 3
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs b/GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs
new file mode 100644
index 0000000..8e98e31
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/Core/GeneratorTest.cs
@@ -0,0 +1,37 @@
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace GFramework.Godot.SourceGenerators.Tests.Core;
+
+///
+/// 提供源代码生成器测试的通用功能。
+///
+/// 要测试的源代码生成器类型,必须具有无参构造函数。
+public static class GeneratorTest
+ where TGenerator : new()
+{
+ ///
+ /// 运行源代码生成器测试。
+ ///
+ /// 输入源代码。
+ /// 期望生成的源文件集合。
+ public static async Task RunAsync(
+ string source,
+ params (string filename, string content)[] generatedSources)
+ {
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" }
+ };
+
+ foreach (var (filename, content) in generatedSources)
+ test.TestState.GeneratedSources.Add(
+ (typeof(TGenerator), filename, content));
+
+ await test.RunAsync();
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj
new file mode 100644
index 0000000..541c8b8
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/GFramework.Godot.SourceGenerators.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ $(TestTargetFrameworks)
+ disable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs b/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs
new file mode 100644
index 0000000..b0df01f
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/GetNode/GetNodeGeneratorTests.cs
@@ -0,0 +1,243 @@
+using GFramework.Godot.SourceGenerators.Tests.Core;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+
+namespace GFramework.Godot.SourceGenerators.Tests.GetNode;
+
+[TestFixture]
+public class GetNodeGeneratorTests
+{
+ [Test]
+ public async Task Generates_InferredUniqueNameBindings_And_ReadyHook_WhenReadyIsMissing()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+ public sealed class GetNodeAttribute : Attribute
+ {
+ public GetNodeAttribute() {}
+ public GetNodeAttribute(string path) { Path = path; }
+ public string? Path { get; set; }
+ public bool Required { get; set; } = true;
+ public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
+ }
+
+ public enum NodeLookupMode
+ {
+ Auto = 0,
+ UniqueName = 1,
+ RelativePath = 2,
+ AbsolutePath = 3
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+ public T GetNode(string path) where T : Node => throw new InvalidOperationException(path);
+ public T? GetNodeOrNull(string path) where T : Node => default;
+ }
+
+ public class HBoxContainer : Node
+ {
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class TopBar : HBoxContainer
+ {
+ [GetNode]
+ private HBoxContainer _leftContainer = null!;
+
+ [GetNode]
+ private HBoxContainer m_rightContainer = null!;
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class TopBar
+ {
+ private void __InjectGetNodes_Generated()
+ {
+ _leftContainer = GetNode("%LeftContainer");
+ m_rightContainer = GetNode("%RightContainer");
+ }
+
+ partial void OnGetNodeReadyGenerated();
+
+ public override void _Ready()
+ {
+ __InjectGetNodes_Generated();
+ OnGetNodeReadyGenerated();
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_TopBar.GetNode.g.cs", expected));
+ }
+
+ [Test]
+ public async Task Generates_ManualInjectionOnly_WhenReadyAlreadyExists()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+ public sealed class GetNodeAttribute : Attribute
+ {
+ public GetNodeAttribute() {}
+ public GetNodeAttribute(string path) { Path = path; }
+ public string? Path { get; set; }
+ public bool Required { get; set; } = true;
+ public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
+ }
+
+ public enum NodeLookupMode
+ {
+ Auto = 0,
+ UniqueName = 1,
+ RelativePath = 2,
+ AbsolutePath = 3
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+ public T GetNode(string path) where T : Node => throw new InvalidOperationException(path);
+ public T? GetNodeOrNull(string path) where T : Node => default;
+ }
+
+ public class HBoxContainer : Node
+ {
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class TopBar : HBoxContainer
+ {
+ [GetNode("%LeftContainer")]
+ private HBoxContainer _leftContainer = null!;
+
+ [GetNode(Required = false, Lookup = NodeLookupMode.RelativePath)]
+ private HBoxContainer? _rightContainer;
+
+ public override void _Ready()
+ {
+ __InjectGetNodes_Generated();
+ }
+ }
+ }
+ """;
+
+ const string expected = """
+ //
+ #nullable enable
+
+ namespace TestApp;
+
+ partial class TopBar
+ {
+ private void __InjectGetNodes_Generated()
+ {
+ _leftContainer = GetNode("%LeftContainer");
+ _rightContainer = GetNodeOrNull("RightContainer");
+ }
+ }
+
+ """;
+
+ await GeneratorTest.RunAsync(
+ source,
+ ("TestApp_TopBar.GetNode.g.cs", expected));
+ }
+
+ [Test]
+ public async Task Reports_Diagnostic_When_FieldType_IsNotGodotNode()
+ {
+ const string source = """
+ using System;
+ using GFramework.Godot.SourceGenerators.Abstractions;
+ using Godot;
+
+ namespace GFramework.Godot.SourceGenerators.Abstractions
+ {
+ [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
+ public sealed class GetNodeAttribute : Attribute
+ {
+ public string? Path { get; set; }
+ public bool Required { get; set; } = true;
+ public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
+ }
+
+ public enum NodeLookupMode
+ {
+ Auto = 0,
+ UniqueName = 1,
+ RelativePath = 2,
+ AbsolutePath = 3
+ }
+ }
+
+ namespace Godot
+ {
+ public class Node
+ {
+ public virtual void _Ready() {}
+ public T GetNode(string path) where T : Node => throw new InvalidOperationException(path);
+ public T? GetNodeOrNull(string path) where T : Node => default;
+ }
+ }
+
+ namespace TestApp
+ {
+ public partial class TopBar : Node
+ {
+ [GetNode]
+ private string _leftContainer = string.Empty;
+ }
+ }
+ """;
+
+ var test = new CSharpSourceGeneratorTest
+ {
+ TestState =
+ {
+ Sources = { source }
+ },
+ DisabledDiagnostics = { "GF_Common_Trace_001" }
+ };
+
+ test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_GetNode_004", DiagnosticSeverity.Error)
+ .WithSpan(39, 24, 39, 38)
+ .WithArguments("_leftContainer"));
+
+ await test.RunAsync();
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs b/GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..4d27181
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs
@@ -0,0 +1,18 @@
+// Copyright (c) 2025 GeWuYou
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+global using System;
+global using System.Collections.Generic;
+global using System.Linq;
+global using System.Threading;
+global using System.Threading.Tasks;
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000..2080dcd
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Shipped.md
@@ -0,0 +1,3 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
diff --git a/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000..a78788c
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,13 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+ Rule ID | Category | Severity | Notes
+----------------------|------------------|----------|--------------------
+ GF_Godot_GetNode_001 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_002 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_003 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_004 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_005 | GFramework.Godot | Error | GetNodeDiagnostics
+ GF_Godot_GetNode_006 | GFramework.Godot | Warning | GetNodeDiagnostics
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/Diagnostics/GetNodeDiagnostics.cs b/GFramework.Godot.SourceGenerators/Diagnostics/GetNodeDiagnostics.cs
new file mode 100644
index 0000000..14a720d
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/Diagnostics/GetNodeDiagnostics.cs
@@ -0,0 +1,82 @@
+using GFramework.SourceGenerators.Common.Constants;
+using Microsoft.CodeAnalysis;
+
+namespace GFramework.Godot.SourceGenerators.Diagnostics;
+
+///
+/// GetNode 生成器相关诊断。
+///
+public static class GetNodeDiagnostics
+{
+ ///
+ /// 嵌套类型不受支持。
+ ///
+ public static readonly DiagnosticDescriptor NestedClassNotSupported =
+ new(
+ "GF_Godot_GetNode_001",
+ "Nested classes are not supported",
+ "Class '{0}' cannot use [GetNode] inside a nested type",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// static 字段不受支持。
+ ///
+ public static readonly DiagnosticDescriptor StaticFieldNotSupported =
+ new(
+ "GF_Godot_GetNode_002",
+ "Static fields are not supported",
+ "Field '{0}' cannot be static when using [GetNode]",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// readonly 字段不受支持。
+ ///
+ public static readonly DiagnosticDescriptor ReadOnlyFieldNotSupported =
+ new(
+ "GF_Godot_GetNode_003",
+ "Readonly fields are not supported",
+ "Field '{0}' cannot be readonly when using [GetNode]",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 字段类型必须继承自 Godot.Node。
+ ///
+ public static readonly DiagnosticDescriptor FieldTypeMustDeriveFromNode =
+ new(
+ "GF_Godot_GetNode_004",
+ "Field type must derive from Godot.Node",
+ "Field '{0}' must be a Godot.Node type to use [GetNode]",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 无法从字段名推导路径。
+ ///
+ public static readonly DiagnosticDescriptor CannotInferNodePath =
+ new(
+ "GF_Godot_GetNode_005",
+ "Cannot infer node path",
+ "Field '{0}' does not provide a path and its name cannot be converted to a node path",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Error,
+ true);
+
+ ///
+ /// 现有 _Ready 中未调用生成注入逻辑。
+ ///
+ public static readonly DiagnosticDescriptor ManualReadyHookRequired =
+ new(
+ "GF_Godot_GetNode_006",
+ "Call generated injection from _Ready",
+ "Class '{0}' defines _Ready(); call __InjectGetNodes_Generated() there or remove _Ready() to use the generated hook",
+ PathContests.GodotNamespace,
+ DiagnosticSeverity.Warning,
+ true);
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj b/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj
index c62c5dd..3934979 100644
--- a/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj
+++ b/GFramework.Godot.SourceGenerators/GFramework.Godot.SourceGenerators.csproj
@@ -60,11 +60,6 @@
-
-
-
-
-
-
+
diff --git a/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
new file mode 100644
index 0000000..4518608
--- /dev/null
+++ b/GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
@@ -0,0 +1,568 @@
+using System.Collections.Immutable;
+using System.Text;
+using GFramework.Godot.SourceGenerators.Diagnostics;
+using GFramework.SourceGenerators.Common.Constants;
+using GFramework.SourceGenerators.Common.Diagnostics;
+using GFramework.SourceGenerators.Common.Extensions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace GFramework.Godot.SourceGenerators;
+
+///
+/// 为带有 [GetNode] 的字段生成 Godot 节点获取逻辑。
+///
+[Generator]
+public sealed class GetNodeGenerator : IIncrementalGenerator
+{
+ private const string GodotAbsolutePathPrefix = "/";
+ private const string GodotUniqueNamePrefix = "%";
+
+ private const string GetNodeAttributeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.GetNodeAttribute";
+
+ private const string GetNodeLookupModeMetadataName =
+ $"{PathContests.GodotSourceGeneratorsAbstractionsPath}.NodeLookupMode";
+
+ private const string InjectionMethodName = "__InjectGetNodes_Generated";
+ private const string ReadyHookMethodName = "OnGetNodeReadyGenerated";
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ static (node, _) => IsCandidate(node),
+ static (ctx, _) => Transform(ctx))
+ .Where(static candidate => candidate is not null);
+
+ var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
+
+ context.RegisterSourceOutput(compilationAndCandidates,
+ static (spc, pair) => { Execute(spc, pair.Left, pair.Right); });
+ }
+
+ private static bool IsCandidate(SyntaxNode node)
+ {
+ if (node is not VariableDeclaratorSyntax
+ {
+ Parent: VariableDeclarationSyntax
+ {
+ Parent: FieldDeclarationSyntax fieldDeclaration
+ }
+ })
+ return false;
+
+ return fieldDeclaration.AttributeLists
+ .SelectMany(static list => list.Attributes)
+ .Any(static attribute => attribute.Name.ToString().Contains("GetNode", StringComparison.Ordinal));
+ }
+
+ private static FieldCandidate? Transform(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not VariableDeclaratorSyntax variable)
+ return null;
+
+ if (ModelExtensions.GetDeclaredSymbol(context.SemanticModel, variable) is not IFieldSymbol fieldSymbol)
+ return null;
+
+ return new FieldCandidate(variable, fieldSymbol);
+ }
+
+ private static void Execute(
+ SourceProductionContext context,
+ Compilation compilation,
+ ImmutableArray candidates)
+ {
+ if (candidates.IsDefaultOrEmpty)
+ return;
+
+ var getNodeAttribute = compilation.GetTypeByMetadataName(GetNodeAttributeMetadataName);
+ var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node");
+
+ if (getNodeAttribute is null || godotNodeSymbol is null)
+ return;
+
+ var fieldCandidates = candidates
+ .Where(static candidate => candidate is not null)
+ .Select(static candidate => candidate!)
+ .Where(candidate => ResolveAttribute(candidate.FieldSymbol, getNodeAttribute) is not null)
+ .ToList();
+
+ foreach (var group in GroupByContainingType(fieldCandidates))
+ {
+ var typeSymbol = group.TypeSymbol;
+
+ if (!CanGenerateForType(context, group, typeSymbol))
+ continue;
+
+ var bindings = new List();
+
+ foreach (var candidate in group.Fields)
+ {
+ var attribute = ResolveAttribute(candidate.FieldSymbol, getNodeAttribute);
+ if (attribute is null)
+ continue;
+
+ if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding))
+ continue;
+
+ bindings.Add(binding);
+ }
+
+ if (bindings.Count == 0)
+ continue;
+
+ ReportMissingReadyHookCall(context, group, typeSymbol);
+
+ var source = GenerateSource(typeSymbol, bindings, FindReadyMethod(typeSymbol) is null);
+ context.AddSource(GetHintName(typeSymbol), source);
+ }
+ }
+
+ private static bool CanGenerateForType(
+ SourceProductionContext context,
+ TypeGroup group,
+ INamedTypeSymbol typeSymbol)
+ {
+ if (typeSymbol.ContainingType is not null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ GetNodeDiagnostics.NestedClassNotSupported,
+ group.Fields[0].Variable.Identifier.GetLocation(),
+ typeSymbol.Name));
+ return false;
+ }
+
+ if (IsPartial(typeSymbol))
+ return true;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ CommonDiagnostics.ClassMustBePartial,
+ group.Fields[0].Variable.Identifier.GetLocation(),
+ typeSymbol.Name));
+
+ return false;
+ }
+
+ private static bool TryCreateBinding(
+ SourceProductionContext context,
+ FieldCandidate candidate,
+ AttributeData attribute,
+ INamedTypeSymbol godotNodeSymbol,
+ out NodeBindingInfo binding)
+ {
+ binding = default!;
+
+ if (candidate.FieldSymbol.IsStatic)
+ {
+ ReportFieldDiagnostic(context,
+ GetNodeDiagnostics.StaticFieldNotSupported,
+ candidate);
+ return false;
+ }
+
+ if (candidate.FieldSymbol.IsReadOnly)
+ {
+ ReportFieldDiagnostic(context,
+ GetNodeDiagnostics.ReadOnlyFieldNotSupported,
+ candidate);
+ return false;
+ }
+
+ if (!IsGodotNodeType(candidate.FieldSymbol.Type, godotNodeSymbol))
+ {
+ ReportFieldDiagnostic(context,
+ GetNodeDiagnostics.FieldTypeMustDeriveFromNode,
+ candidate);
+ return false;
+ }
+
+ if (!TryResolvePath(candidate.FieldSymbol, attribute, out var path))
+ {
+ ReportFieldDiagnostic(context,
+ GetNodeDiagnostics.CannotInferNodePath,
+ candidate);
+ return false;
+ }
+
+ binding = new NodeBindingInfo(
+ candidate.FieldSymbol,
+ path,
+ ResolveRequired(attribute));
+
+ return true;
+ }
+
+ private static void ReportFieldDiagnostic(
+ SourceProductionContext context,
+ DiagnosticDescriptor descriptor,
+ FieldCandidate candidate)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ descriptor,
+ candidate.Variable.Identifier.GetLocation(),
+ candidate.FieldSymbol.Name));
+ }
+
+ private static void ReportMissingReadyHookCall(
+ SourceProductionContext context,
+ TypeGroup group,
+ INamedTypeSymbol typeSymbol)
+ {
+ var readyMethod = FindReadyMethod(typeSymbol);
+ if (readyMethod is null || CallsGeneratedInjection(readyMethod))
+ return;
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ GetNodeDiagnostics.ManualReadyHookRequired,
+ readyMethod.Locations.FirstOrDefault() ?? group.Fields[0].Variable.Identifier.GetLocation(),
+ typeSymbol.Name));
+ }
+
+ private static AttributeData? ResolveAttribute(
+ IFieldSymbol fieldSymbol,
+ INamedTypeSymbol getNodeAttribute)
+ {
+ return fieldSymbol.GetAttributes()
+ .FirstOrDefault(attribute =>
+ SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, getNodeAttribute));
+ }
+
+ private static bool IsPartial(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.DeclaringSyntaxReferences
+ .Select(static reference => reference.GetSyntax())
+ .OfType()
+ .All(static declaration =>
+ declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
+ }
+
+ private static bool IsGodotNodeType(ITypeSymbol typeSymbol, INamedTypeSymbol godotNodeSymbol)
+ {
+ var current = typeSymbol as INamedTypeSymbol;
+ while (current is not null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, godotNodeSymbol) ||
+ SymbolEqualityComparer.Default.Equals(current, godotNodeSymbol))
+ return true;
+
+ current = current.BaseType;
+ }
+
+ return false;
+ }
+
+ private static IMethodSymbol? FindReadyMethod(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.GetMembers()
+ .OfType()
+ .FirstOrDefault(static method =>
+ method.Name == "_Ready" &&
+ !method.IsStatic &&
+ method.Parameters.Length == 0 &&
+ method.MethodKind == MethodKind.Ordinary);
+ }
+
+ private static bool CallsGeneratedInjection(IMethodSymbol readyMethod)
+ {
+ foreach (var syntaxReference in readyMethod.DeclaringSyntaxReferences)
+ {
+ if (syntaxReference.GetSyntax() is not MethodDeclarationSyntax methodSyntax)
+ continue;
+
+ if (methodSyntax.DescendantNodes()
+ .OfType()
+ .Any(IsGeneratedInjectionInvocation))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsGeneratedInjectionInvocation(InvocationExpressionSyntax invocation)
+ {
+ switch (invocation.Expression)
+ {
+ case IdentifierNameSyntax identifierName:
+ return string.Equals(
+ identifierName.Identifier.ValueText,
+ InjectionMethodName,
+ StringComparison.Ordinal);
+ case MemberAccessExpressionSyntax memberAccess:
+ return string.Equals(
+ memberAccess.Name.Identifier.ValueText,
+ InjectionMethodName,
+ StringComparison.Ordinal);
+ default:
+ return false;
+ }
+ }
+
+ private static bool ResolveRequired(AttributeData attribute)
+ {
+ return attribute.GetNamedArgument("Required", true);
+ }
+
+ private static bool TryResolvePath(
+ IFieldSymbol fieldSymbol,
+ AttributeData attribute,
+ out string path)
+ {
+ var explicitPath = ResolveExplicitPath(attribute);
+ if (!string.IsNullOrWhiteSpace(explicitPath))
+ return ReturnResolvedPath(explicitPath!, out path);
+
+ var inferredName = InferNodeName(fieldSymbol.Name);
+ if (string.IsNullOrWhiteSpace(inferredName))
+ {
+ path = string.Empty;
+ return false;
+ }
+
+ var resolvedName = inferredName!;
+ return TryResolveInferredPath(attribute, resolvedName, out path);
+ }
+
+ private static bool ReturnResolvedPath(string resolvedPath, out string path)
+ {
+ path = resolvedPath;
+ return true;
+ }
+
+ private static bool TryResolveInferredPath(
+ AttributeData attribute,
+ string inferredName,
+ out string path)
+ {
+ path = BuildPathPrefix(ResolveLookup(attribute)) + inferredName;
+ return true;
+ }
+
+ private static string BuildPathPrefix(NodeLookupModeValue lookupMode)
+ {
+ switch (lookupMode)
+ {
+ case NodeLookupModeValue.RelativePath:
+ return string.Empty;
+ case NodeLookupModeValue.AbsolutePath:
+ return GodotAbsolutePathPrefix;
+ default:
+ return GodotUniqueNamePrefix;
+ }
+ }
+
+ private static string? ResolveExplicitPath(AttributeData attribute)
+ {
+ var namedPath = attribute.GetNamedArgument("Path");
+ if (!string.IsNullOrWhiteSpace(namedPath))
+ return namedPath;
+
+ if (attribute.ConstructorArguments.Length == 0)
+ return null;
+
+ return attribute.ConstructorArguments[0].Value as string;
+ }
+
+ private static NodeLookupModeValue ResolveLookup(AttributeData attribute)
+ {
+ foreach (var namedArgument in attribute.NamedArguments)
+ {
+ if (!string.Equals(namedArgument.Key, "Lookup", StringComparison.Ordinal))
+ continue;
+
+ if (namedArgument.Value.Type?.ToDisplayString() != GetNodeLookupModeMetadataName)
+ continue;
+
+ if (namedArgument.Value.Value is int value)
+ return (NodeLookupModeValue)value;
+ }
+
+ return NodeLookupModeValue.Auto;
+ }
+
+ private static string? InferNodeName(string fieldName)
+ {
+ var workingName = fieldName.TrimStart('_');
+ if (workingName.StartsWith("m_", StringComparison.OrdinalIgnoreCase))
+ workingName = workingName.Substring(2);
+
+ workingName = workingName.TrimStart('_');
+ if (string.IsNullOrWhiteSpace(workingName))
+ return null;
+
+ if (workingName.IndexOfAny(['_', '-', ' ']) >= 0)
+ {
+ var parts = workingName
+ .Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries);
+
+ return parts.Length == 0
+ ? null
+ : string.Concat(parts.Select(ToPascalToken));
+ }
+
+ return ToPascalToken(workingName);
+ }
+
+ private static string ToPascalToken(string token)
+ {
+ if (string.IsNullOrEmpty(token))
+ return token;
+
+ if (token.Length == 1)
+ return token.ToUpperInvariant();
+
+ return char.ToUpperInvariant(token[0]) + token.Substring(1);
+ }
+
+ private static string GenerateSource(
+ INamedTypeSymbol typeSymbol,
+ IReadOnlyList bindings,
+ bool generateReadyOverride)
+ {
+ var namespaceName = typeSymbol.GetNamespace();
+ var generics = typeSymbol.ResolveGenerics();
+
+ var sb = new StringBuilder()
+ .AppendLine("// ")
+ .AppendLine("#nullable enable");
+
+ if (namespaceName is not null)
+ {
+ sb.AppendLine()
+ .AppendLine($"namespace {namespaceName};");
+ }
+
+ sb.AppendLine()
+ .AppendLine($"partial class {typeSymbol.Name}{generics.Parameters}");
+
+ foreach (var constraint in generics.Constraints)
+ sb.AppendLine($" {constraint}");
+
+ sb.AppendLine("{")
+ .AppendLine($" private void {InjectionMethodName}()")
+ .AppendLine(" {");
+
+ foreach (var binding in bindings)
+ {
+ var typeName = binding.FieldSymbol.Type
+ .WithNullableAnnotation(NullableAnnotation.None)
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ var accessor = binding.Required ? "GetNode" : "GetNodeOrNull";
+ var pathLiteral = EscapeStringLiteral(binding.Path);
+ sb.AppendLine(
+ $" {binding.FieldSymbol.Name} = {accessor}<{typeName}>(\"{pathLiteral}\");");
+ }
+
+ sb.AppendLine(" }");
+
+ if (generateReadyOverride)
+ {
+ sb.AppendLine()
+ .AppendLine($" partial void {ReadyHookMethodName}();")
+ .AppendLine()
+ .AppendLine(" public override void _Ready()")
+ .AppendLine(" {")
+ .AppendLine($" {InjectionMethodName}();")
+ .AppendLine($" {ReadyHookMethodName}();")
+ .AppendLine(" }");
+ }
+
+ sb.AppendLine("}");
+
+ return sb.ToString();
+ }
+
+ private static string GetHintName(INamedTypeSymbol typeSymbol)
+ {
+ return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ .Replace("global::", string.Empty)
+ .Replace("<", "_")
+ .Replace(">", "_")
+ .Replace(",", "_")
+ .Replace(" ", string.Empty)
+ .Replace(".", "_") + ".GetNode.g.cs";
+ }
+
+ private static string EscapeStringLiteral(string value)
+ {
+ return value
+ .Replace("\\", "\\\\")
+ .Replace("\"", "\\\"");
+ }
+
+ private static IReadOnlyList GroupByContainingType(IEnumerable candidates)
+ {
+ var groupMap = new Dictionary(SymbolEqualityComparer.Default);
+ var orderedGroups = new List();
+
+ foreach (var candidate in candidates)
+ {
+ var typeSymbol = candidate.FieldSymbol.ContainingType;
+ if (!groupMap.TryGetValue(typeSymbol, out var group))
+ {
+ group = new TypeGroup(typeSymbol);
+ groupMap.Add(typeSymbol, group);
+ orderedGroups.Add(group);
+ }
+
+ group.Fields.Add(candidate);
+ }
+
+ return orderedGroups;
+ }
+
+ private sealed class FieldCandidate
+ {
+ public FieldCandidate(
+ VariableDeclaratorSyntax variable,
+ IFieldSymbol fieldSymbol)
+ {
+ Variable = variable;
+ FieldSymbol = fieldSymbol;
+ }
+
+ public VariableDeclaratorSyntax Variable { get; }
+
+ public IFieldSymbol FieldSymbol { get; }
+ }
+
+ private sealed class NodeBindingInfo
+ {
+ public NodeBindingInfo(
+ IFieldSymbol fieldSymbol,
+ string path,
+ bool required)
+ {
+ FieldSymbol = fieldSymbol;
+ Path = path;
+ Required = required;
+ }
+
+ public IFieldSymbol FieldSymbol { get; }
+
+ public string Path { get; }
+
+ public bool Required { get; }
+ }
+
+ private enum NodeLookupModeValue
+ {
+ Auto = 0,
+ UniqueName = 1,
+ RelativePath = 2,
+ AbsolutePath = 3
+ }
+
+ private sealed class TypeGroup
+ {
+ public TypeGroup(INamedTypeSymbol typeSymbol)
+ {
+ TypeSymbol = typeSymbol;
+ }
+
+ public INamedTypeSymbol TypeSymbol { get; }
+
+ public List Fields { get; } = new();
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Godot.SourceGenerators/README.md b/GFramework.Godot.SourceGenerators/README.md
index 6da7cb3..291c557 100644
--- a/GFramework.Godot.SourceGenerators/README.md
+++ b/GFramework.Godot.SourceGenerators/README.md
@@ -6,8 +6,40 @@
- 与 Godot 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现
+- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode()` 样板代码
## 使用建议
- 仅在 Godot + C# 项目中启用
- 非 Godot 项目可只使用 GFramework.SourceGenerators
+
+## GetNode 用法
+
+```csharp
+using GFramework.Godot.SourceGenerators.Abstractions;
+using Godot;
+
+public partial class TopBar : HBoxContainer
+{
+ [GetNode]
+ private HBoxContainer _leftContainer = null!;
+
+ [GetNode]
+ private HBoxContainer _rightContainer = null!;
+
+ public override void _Ready()
+ {
+ __InjectGetNodes_Generated();
+ OnReadyAfterGetNode();
+ }
+
+ private void OnReadyAfterGetNode()
+ {
+ }
+}
+```
+
+当未显式填写路径时,生成器会默认将字段名推导为唯一名路径:
+
+- `_leftContainer` -> `%LeftContainer`
+- `m_rightContainer` -> `%RightContainer`
diff --git a/GFramework.csproj b/GFramework.csproj
index 3d65cb3..1594e9a 100644
--- a/GFramework.csproj
+++ b/GFramework.csproj
@@ -46,6 +46,7 @@
+
@@ -85,6 +86,7 @@
+
@@ -110,6 +112,7 @@
+
diff --git a/GFramework.sln b/GFramework.sln
index 7076c6a..0cef5d4 100644
--- a/GFramework.sln
+++ b/GFramework.sln
@@ -34,6 +34,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Ecs.Arch.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GFramework.Game.Tests\GFramework.Game.Tests.csproj", "{738DC58A-0387-4D75-AA96-1C1D8C29D350}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.SourceGenerators.Tests", "GFramework.Godot.SourceGenerators.Tests\GFramework.Godot.SourceGenerators.Tests.csproj", "{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -248,6 +250,18 @@ Global
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x64.Build.0 = Release|Any CPU
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.ActiveCfg = Release|Any CPU
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.Build.0 = Release|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.Build.0 = Debug|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.Build.0 = Debug|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.ActiveCfg = Release|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.Build.0 = Release|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.ActiveCfg = Release|Any CPU
+ {E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Godot/script_templates/Node/ControllerTemplate.cs b/Godot/script_templates/Node/ControllerTemplate.cs
index 155deb8..5931e7f 100644
--- a/Godot/script_templates/Node/ControllerTemplate.cs
+++ b/Godot/script_templates/Node/ControllerTemplate.cs
@@ -2,6 +2,7 @@
// meta-description: 负责管理场景的生命周期和架构关联
using Godot;
using GFramework.Core.Abstractions.Controller;
+using GFramework.Godot.SourceGenerators.Abstractions;
using GFramework.SourceGenerators.Abstractions.Logging;
using GFramework.SourceGenerators.Abstractions.Rule;
@@ -16,7 +17,15 @@ public partial class _CLASS_ :_BASE_,IController
///
public override void _Ready()
{
-
+ __InjectGetNodes_Generated();
+ OnReadyAfterGetNode();
+ }
+
+ ///
+ /// 节点注入完成后的初始化钩子。
+ ///
+ private void OnReadyAfterGetNode()
+ {
}
}
diff --git a/Godot/script_templates/Node/PageControllerTemplate.cs b/Godot/script_templates/Node/PageControllerTemplate.cs
index 5e9c606..24601e2 100644
--- a/Godot/script_templates/Node/PageControllerTemplate.cs
+++ b/Godot/script_templates/Node/PageControllerTemplate.cs
@@ -5,6 +5,7 @@ using GFramework.Core.Abstractions.Controller;
using GFramework.Core.Extensions;
using GFramework.Game.Abstractions.UI;
using GFramework.Godot.UI;
+using GFramework.Godot.SourceGenerators.Abstractions;
using GFramework.SourceGenerators.Abstractions.Logging;
using GFramework.SourceGenerators.Abstractions.Rule;
@@ -19,7 +20,15 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage
///
public override void _Ready()
{
-
+ __InjectGetNodes_Generated();
+ OnReadyAfterGetNode();
+ }
+
+ ///
+ /// 节点注入完成后的初始化钩子。
+ ///
+ private void OnReadyAfterGetNode()
+ {
}
///
/// 页面行为实例的私有字段
@@ -84,4 +93,4 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage
{
}
-}
\ No newline at end of file
+}