feat(godot): 添加 Godot 集成功能和测试基础设施

- 新增 AdditionalTextGeneratorTestDriver 用于源生成器测试
- 添加 AutoLoadAttribute 特性支持 AutoLoad 类型映射
- 扩展项目构建目标,支持自定义 project.godot 路径验证
- 创建完整 Godot 集成教程文档,涵盖节点生命周期、信号系统等功能
- 添加源代码生成器测试项目配置和相关依赖包引用
This commit is contained in:
GeWuYou 2026-04-14 09:05:33 +08:00
parent 7dafec72be
commit 833a295b84
11 changed files with 379 additions and 16 deletions

View File

@ -18,9 +18,22 @@ public sealed class AutoLoadAttribute : Attribute
/// <exception cref="ArgumentNullException">
/// <paramref name="name" /> 为 <see langword="null" />。
/// </exception>
/// <exception cref="ArgumentException">
/// <paramref name="name" /> 为空字符串或仅包含空白字符。
/// </exception>
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;
}
/// <summary>

View File

@ -0,0 +1,49 @@
using GFramework.Godot.SourceGenerators.Abstractions;
namespace GFramework.Godot.SourceGenerators.Tests.Abstractions;
/// <summary>
/// 验证 <see cref="AutoLoadAttribute" /> 的参数约束。
/// </summary>
[TestFixture]
public class AutoLoadAttributeTests
{
/// <summary>
/// 验证构造函数会保留合法的 AutoLoad 名称。
/// </summary>
[Test]
public void Constructor_Should_Store_Name_When_Name_Is_Valid()
{
var attribute = new AutoLoadAttribute("GameServices");
Assert.That(attribute.Name, Is.EqualTo("GameServices"));
}
/// <summary>
/// 验证构造函数会拒绝空引用。
/// </summary>
[Test]
public void Constructor_Should_Throw_When_Name_Is_Null()
{
var exception = Assert.Throws<ArgumentNullException>(() => new AutoLoadAttribute(null!));
Assert.That(exception!.ParamName, Is.EqualTo("name"));
}
/// <summary>
/// 验证构造函数会拒绝空字符串与仅空白字符串。
/// </summary>
[TestCase("")]
[TestCase(" ")]
[TestCase("\t")]
public void Constructor_Should_Throw_When_Name_Is_Empty_Or_Whitespace(string name)
{
var exception = Assert.Throws<ArgumentException>(() => new AutoLoadAttribute(name));
Assert.Multiple(() =>
{
Assert.That(exception!.ParamName, Is.EqualTo("name"));
Assert.That(exception.Message, Does.Contain("empty or whitespace"));
});
}
}

View File

@ -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
/// 规范化换行,避免测试在不同平台上产生伪差异。
/// </summary>
/// <param name="content">待规范化文本。</param>
/// <returns>使用当前平台换行符的内容。</returns>
/// <returns>统一使用 LF (<c>\n</c>) 的内容。</returns>
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<MetadataReference> GetMetadataReferences()

View File

@ -0,0 +1,21 @@
namespace GFramework.Godot.SourceGenerators.Tests.Core;
/// <summary>
/// 验证 <see cref="AdditionalTextGeneratorTestDriver" /> 的文本规范化行为。
/// </summary>
[TestFixture]
public class AdditionalTextGeneratorTestDriverTests
{
/// <summary>
/// 验证不同平台换行最终都会被统一为 LF。
/// </summary>
[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"));
}
}

View File

@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators.Abstractions\GFramework.Godot.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
</ItemGroup>

View File

@ -316,4 +316,245 @@ public class GodotProjectMetadataGeneratorTests
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Does.Contain("public const string MoveUp_2 = \"move-up\";"));
}
/// <summary>
/// 验证多个显式映射指向同一个 AutoLoad 时会报告重复映射,并退化为 <c>Godot.Node</c>。
/// </summary>
[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<global::Godot.Node>(\"AudioBus\");"));
});
}
/// <summary>
/// 验证不同命名空间下的同名节点类型会触发隐式映射冲突诊断,并退化为 <c>Godot.Node</c>。
/// </summary>
[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<global::Godot.Node>(\"AudioBus\");"));
});
}
/// <summary>
/// 验证 AutoLoad 标识符冲突时会追加稳定后缀并报告诊断。
/// </summary>
[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<global::Godot.Node>(\"audio_bus\");"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Contain("public static global::Godot.Node AudioBus_2 => GetRequiredNode<global::Godot.Node>(\"audio-bus\");"));
});
}
/// <summary>
/// 验证重复 AutoLoad 条目会报告诊断,并只保留第一条声明参与生成。
/// </summary>
[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<global::Godot.Node>(\"GameServices\");"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Not.Contain("GameServices_2"));
});
}
/// <summary>
/// 验证重复 Input Action 条目会报告诊断,并只保留第一条声明参与生成。
/// </summary>
[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<GodotProjectMetadataGenerator>(
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<T>(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;
}
}

View File

@ -9,6 +9,12 @@
让 Godot 项目元数据生成能力在常规 Godot C# 工程里开箱即用。
-->
<GFrameworkGodotProjectFile Condition="'$(GFrameworkGodotProjectFile)' == ''">project.godot</GFrameworkGodotProjectFile>
<!--
当前生成器按文件名识别 project.godot因此允许调整相对路径
但不支持把目标文件重命名为其他文件名。
-->
<_GFrameworkGodotProjectFileName>$([System.IO.Path]::GetFileName('$(GFrameworkGodotProjectFile)'))</_GFrameworkGodotProjectFileName>
<_GFrameworkGodotProjectFileNameLower>$([System.String]::Copy('$(_GFrameworkGodotProjectFileName)').ToLowerInvariant())</_GFrameworkGodotProjectFileNameLower>
</PropertyGroup>
<ItemGroup>
@ -21,6 +27,12 @@
<AdditionalFiles Include="$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)"/>
</ItemGroup>
<Target Name="ValidateGFrameworkGodotProjectFileName"
BeforeTargets="CoreCompile"
Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)') and '$(_GFrameworkGodotProjectFileNameLower)' != 'project.godot'">
<Warning Text="GFrameworkGodotProjectFile can change the relative path, but the file name must remain 'project.godot'; otherwise GodotProjectMetadataGenerator will ignore the file."/>
</Target>
<!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework.Godot source generators" Importance="high"/>

View File

@ -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,8 +204,15 @@ 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<INamedTypeSymbol> duplicateTypes)
{
context.ReportDiagnostic(Diagnostic.Create(
GodotProjectDiagnostics.DuplicateAutoLoadMapping,
Location.None,
autoLoadName,
string.Join(
", ",
duplicateTypes.Select(static type =>
type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)))));
}
private static IReadOnlyList<GeneratedAutoLoadMember> CreateAutoLoadMembers(
SourceProductionContext context,
ProjectMetadataParseResult projectResult,

View File

@ -25,11 +25,14 @@
- `GFramework.Godot.Generated.AutoLoads`
- `GFramework.Godot.Generated.InputActions`
如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置:
如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置:
- 路径可以调整到项目根目录下的其他位置
- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件
```xml
<PropertyGroup>
<GFrameworkGodotProjectFile>project.godot</GFrameworkGodotProjectFile>
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
</PropertyGroup>
```

View File

@ -23,9 +23,12 @@ API。
如需覆盖默认路径,可以设置:
- 可以改成项目根目录下的其他相对路径
- 文件名必须仍然是 `project.godot`,否则生成器会给出警告并忽略该文件
```xml
<PropertyGroup>
<GFrameworkGodotProjectFile>project.godot</GFrameworkGodotProjectFile>
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
</PropertyGroup>
```

View File

@ -1354,4 +1354,4 @@ public class GodotPerformanceTests
---
**教程版本**: 1.0.0
**更新日期**: 2026-01-12
**更新日期**: 2026-04-14