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] =?UTF-8?q?feat(godot):=20=E6=B7=BB=E5=8A=A0=20Godot=20?=
=?UTF-8?q?=E9=9B=86=E6=88=90=E5=8A=9F=E8=83=BD=E5=92=8C=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=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