feat(Godot.SourceGenerators): 添加 Godot 项目元数据源码生成器

- 实现 project.godot 文件解析功能,支持 AutoLoad 和 Input Action 元数据提取
- 生成 AutoLoads 强类型访问入口,提供 GetRequiredNode 和 TryGetNode 方法
- 生成 InputActions 常量类,避免手写字符串魔法值
- 添加 AutoLoadAttribute 特性支持显式类型映射声明
- 实现标识符冲突检测和自动后缀追加机制
- 添加完整的诊断系统支持,包括类型继承检查和重复条目警告
- 创建 MSBuild 集成目标文件确保生成器正确加载
- 提供详细的 README 文档说明使用方法和最佳实践
This commit is contained in:
GeWuYou 2026-04-14 08:22:12 +08:00
parent b3066f3a8d
commit 61ee3a8f0c
8 changed files with 1405 additions and 1 deletions

View File

@ -0,0 +1,30 @@
#nullable enable
namespace GFramework.Godot.SourceGenerators.Abstractions;
/// <summary>
/// 显式声明某个 Godot 节点类型与 <c>project.godot</c> 中 AutoLoad 名称之间的映射关系。
/// </summary>
/// <remarks>
/// 当 AutoLoad 条目无法仅靠类型名唯一推断到 C# 节点类型时,
/// 可以通过该特性为生成器提供稳定的强类型映射入口。
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class AutoLoadAttribute : Attribute
{
/// <summary>
/// 初始化 <see cref="AutoLoadAttribute" /> 的新实例。
/// </summary>
/// <param name="name">在 <c>project.godot</c> 中声明的 AutoLoad 名称。</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="name" /> 为 <see langword="null" />。
/// </exception>
public AutoLoadAttribute(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
/// <summary>
/// 获取在 <c>project.godot</c> 中声明的 AutoLoad 名称。
/// </summary>
public string Name { get; }
}

View File

@ -0,0 +1,111 @@
using System.Collections.Immutable;
using System.IO;
namespace GFramework.Godot.SourceGenerators.Tests.Core;
/// <summary>
/// 提供基于 <see cref="AdditionalText" /> 的源生成器测试驱动。
/// </summary>
public static class AdditionalTextGeneratorTestDriver
{
/// <summary>
/// 运行指定的增量生成器,并返回生成结果。
/// </summary>
/// <typeparam name="TGenerator">要运行的生成器类型。</typeparam>
/// <param name="source">输入源码。</param>
/// <param name="additionalFiles">AdditionalFiles 集合。</param>
/// <returns>生成器运行结果。</returns>
public static GeneratorDriverRunResult Run<TGenerator>(
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();
}
/// <summary>
/// 将生成结果转换为文件名到文本的映射,便于断言。
/// </summary>
/// <param name="result">生成器运行结果。</param>
/// <returns>按 HintName 索引的生成源码。</returns>
public static IReadOnlyDictionary<string, string> ToGeneratedSourceMap(GeneratorDriverRunResult result)
{
return result.Results
.Single()
.GeneratedSources
.ToDictionary(
static item => item.HintName,
static item => NormalizeLineEndings(item.SourceText.ToString()),
StringComparer.Ordinal);
}
/// <summary>
/// 规范化换行,避免测试在不同平台上产生伪差异。
/// </summary>
/// <param name="content">待规范化文本。</param>
/// <returns>使用当前平台换行符的内容。</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);
}
private static IEnumerable<MetadataReference> GetMetadataReferences()
{
var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
?? Array.Empty<string>();
return trustedPlatformAssemblies
.Select(static path => MetadataReference.CreateFromFile(path));
}
/// <summary>
/// 用于测试 AdditionalFiles 的内存实现。
/// </summary>
private sealed class InMemoryAdditionalText : AdditionalText
{
private readonly SourceText _text;
/// <summary>
/// 初始化一个内存 AdditionalText。
/// </summary>
/// <param name="path">虚拟文件路径。</param>
/// <param name="content">文件内容。</param>
public InMemoryAdditionalText(
string path,
string content)
{
Path = path;
_text = SourceText.From(content);
}
/// <inheritdoc />
public override string Path { get; }
/// <inheritdoc />
public override SourceText GetText(CancellationToken cancellationToken = default)
{
return _text;
}
}
}

View File

@ -0,0 +1,319 @@
using GFramework.Godot.SourceGenerators.Tests.Core;
namespace GFramework.Godot.SourceGenerators.Tests.Project;
/// <summary>
/// 验证基于 <c>project.godot</c> 的项目元数据生成行为。
/// </summary>
[TestFixture]
public class GodotProjectMetadataGeneratorTests
{
/// <summary>
/// 验证会根据 AutoLoad 与 Input Action 生成稳定的强类型入口。
/// </summary>
[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<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;
}
}
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 = """
// <auto-generated />
#nullable enable
namespace GFramework.Godot.Generated;
/// <summary>
/// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。
/// </summary>
public static partial class AutoLoads
{
/// <summary>
/// 获取 AutoLoad <c>GameServices</c>。
/// </summary>
public static global::TestApp.GameServices GameServices => GetRequiredNode<global::TestApp.GameServices>("GameServices");
/// <summary>
/// 尝试获取 AutoLoad <c>GameServices</c>。
/// </summary>
public static bool TryGetGameServices(out global::TestApp.GameServices? value)
{
return TryGetNode("GameServices", out value);
}
/// <summary>
/// 获取 AutoLoad <c>AudioBus</c>。
/// </summary>
public static global::Godot.Node AudioBus => GetRequiredNode<global::Godot.Node>("AudioBus");
/// <summary>
/// 尝试获取 AutoLoad <c>AudioBus</c>。
/// </summary>
public static bool TryGetAudioBus(out global::Godot.Node? value)
{
return TryGetNode("AudioBus", out value);
}
/// <summary>
/// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。
/// </summary>
/// <typeparam name="TNode">节点类型。</typeparam>
/// <param name="autoLoadName">AutoLoad 名称。</param>
/// <returns>已解析的 AutoLoad 节点。</returns>
private static TNode GetRequiredNode<TNode>(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.");
}
/// <summary>
/// 尝试从当前 SceneTree 根节点解析 AutoLoad。
/// </summary>
/// <typeparam name="TNode">节点类型。</typeparam>
/// <param name="autoLoadName">AutoLoad 名称。</param>
/// <param name="value">解析到的节点实例。</param>
/// <returns>若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad则返回 <c>true</c>。</returns>
private static bool TryGetNode<TNode>(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<TNode>($"/root/{autoLoadName}");
return value is not null;
}
}
""";
const string expectedInputActions = """
// <auto-generated />
#nullable enable
namespace GFramework.Godot.Generated;
/// <summary>
/// 提供 project.godot 中 Input Action 名称的强类型常量。
/// </summary>
public static partial class InputActions
{
/// <summary>
/// Input Action <c>move_up</c> 的稳定名称。
/// </summary>
public const string MoveUp = "move_up";
/// <summary>
/// Input Action <c>ui_cancel</c> 的稳定名称。
/// </summary>
public const string UiCancel = "ui_cancel";
}
""";
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
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)));
}
/// <summary>
/// 验证 <c>[AutoLoad]</c> 标记在非节点类型上时会产生诊断。
/// </summary>
[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<GodotProjectMetadataGenerator>(
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"));
});
}
/// <summary>
/// 验证 Input Action 标识符冲突时会追加稳定后缀并给出警告。
/// </summary>
[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<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;
}
}
""";
const string projectFile = """
[input]
move_up={
}
move-up={
}
""";
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
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\";"));
}
}

View File

@ -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

View File

@ -0,0 +1,75 @@
using GFramework.SourceGenerators.Common.Constants;
namespace GFramework.Godot.SourceGenerators.Diagnostics;
/// <summary>
/// 基于 <c>project.godot</c> 的项目元数据生成相关诊断。
/// </summary>
public static class GodotProjectDiagnostics
{
/// <summary>
/// 标记了 <c>[AutoLoad]</c> 的类型必须继承自 <c>Godot.Node</c>。
/// </summary>
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);
/// <summary>
/// 多个类型映射到同一 AutoLoad 名称时会退化为非强类型访问。
/// </summary>
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);
/// <summary>
/// 多个 AutoLoad 名称映射到同一个标识符时会追加稳定后缀。
/// </summary>
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);
/// <summary>
/// 多个 Input Action 名称映射到同一个标识符时会追加稳定后缀。
/// </summary>
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);
/// <summary>
/// 同一个 <c>project.godot</c> 中存在重复 AutoLoad 条目。
/// </summary>
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);
/// <summary>
/// 同一个 <c>project.godot</c> 中存在重复 Input Action 条目。
/// </summary>
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);
}

View File

@ -3,14 +3,26 @@
<!-- This file is automatically generated by the NuGet package -->
<!-- It ensures that the source generators are properly registered during build -->
<PropertyGroup>
<!--
默认收集消费者项目根目录下的 project.godot
让 Godot 项目元数据生成能力在常规 Godot C# 工程里开箱即用。
-->
<GFrameworkGodotProjectFile Condition="'$(GFrameworkGodotProjectFile)' == ''">project.godot</GFrameworkGodotProjectFile>
</PropertyGroup>
<ItemGroup>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.Abstractions.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/>
</ItemGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)')">
<AdditionalFiles Include="$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)"/>
</ItemGroup>
<!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework.Godot source generators" Importance="high"/>
</Target>
</Project>
</Project>

View File

@ -0,0 +1,763 @@
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
namespace GFramework.Godot.SourceGenerators;
/// <summary>
/// 读取 <c>project.godot</c> 项目元数据,并生成 AutoLoad 与 Input Action 的强类型访问入口。
/// </summary>
/// <remarks>
/// 该生成器把 Godot 项目层面的事实模型暴露为稳定的编译期 API
/// <list type="bullet">
/// <item>
/// <description>从 <c>[autoload]</c> 段生成统一访问入口,并在可唯一解析到 C# 节点类型时生成强类型属性。</description>
/// </item>
/// <item>
/// <description>从 <c>[input]</c> 段生成输入动作常量,避免手写魔法字符串。</description>
/// </item>
/// </list>
/// 对于类型映射冲突或标识符冲突,该生成器会优先给出诊断并退化为可工作的稳定输出,而不是静默生成不确定代码。
/// </remarks>
[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";
/// <inheritdoc />
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<ProjectMetadataParseResult> projectFileResults,
ImmutableArray<GodotTypeCandidate?> 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<string, INamedTypeSymbol> BuildTypedMappings(
SourceProductionContext context,
ProjectMetadataParseResult projectResult,
IReadOnlyList<GodotTypeCandidate> typeCandidates,
INamedTypeSymbol? autoLoadAttributeSymbol,
INamedTypeSymbol godotNodeSymbol)
{
var projectAutoLoadNames = new HashSet<string>(
projectResult.AutoLoads.Select(static entry => entry.Name),
StringComparer.Ordinal);
var explicitMappings = new Dictionary<string, List<INamedTypeSymbol>>(StringComparer.Ordinal);
var implicitCandidates = new Dictionary<string, List<INamedTypeSymbol>>(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<INamedTypeSymbol>();
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<INamedTypeSymbol>();
explicitMappings.Add(autoLoadName, explicitList);
}
explicitList.Add(typeSymbol);
}
var resolvedMappings = new Dictionary<string, INamedTypeSymbol>(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<GeneratedAutoLoadMember> CreateAutoLoadMembers(
SourceProductionContext context,
ProjectMetadataParseResult projectResult,
IReadOnlyDictionary<string, INamedTypeSymbol> typedMappings)
{
var identifierCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var members = new List<GeneratedAutoLoadMember>(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<GeneratedInputActionMember> CreateInputActionMembers(
SourceProductionContext context,
ProjectMetadataParseResult projectResult)
{
var identifierCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var members = new List<GeneratedInputActionMember>(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<string, int> 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<INamedTypeSymbol> types)
{
var results = new List<INamedTypeSymbol>();
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<string>();
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<string> 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<GeneratedAutoLoadMember> members)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {GeneratedNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine("/// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static partial class AutoLoads");
builder.AppendLine("{");
foreach (var member in members)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// 获取 AutoLoad <c>{member.AutoLoadName}</c>。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public static {member.TypeName} {member.Identifier} => GetRequiredNode<{member.TypeName}>({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)});");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// 尝试获取 AutoLoad <c>{member.AutoLoadName}</c>。");
builder.AppendLine(" /// </summary>");
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(" /// <summary>");
builder.AppendLine(" /// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <typeparam name=\"TNode\">节点类型。</typeparam>");
builder.AppendLine(" /// <param name=\"autoLoadName\">AutoLoad 名称。</param>");
builder.AppendLine(" /// <returns>已解析的 AutoLoad 节点。</returns>");
builder.AppendLine(" private static TNode GetRequiredNode<TNode>(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(" /// <summary>");
builder.AppendLine(" /// 尝试从当前 SceneTree 根节点解析 AutoLoad。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <typeparam name=\"TNode\">节点类型。</typeparam>");
builder.AppendLine(" /// <param name=\"autoLoadName\">AutoLoad 名称。</param>");
builder.AppendLine(" /// <param name=\"value\">解析到的节点实例。</param>");
builder.AppendLine(" /// <returns>若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad则返回 <c>true</c>。</returns>");
builder.AppendLine(" private static bool TryGetNode<TNode>(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<TNode>($\"/root/{autoLoadName}\");");
builder.AppendLine(" return value is not null;");
builder.AppendLine(" }");
builder.AppendLine("}");
return builder.ToString();
}
private static string GenerateInputActionsSource(IReadOnlyList<GeneratedInputActionMember> members)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {GeneratedNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine("/// 提供 project.godot 中 Input Action 名称的强类型常量。");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static partial class InputActions");
builder.AppendLine("{");
foreach (var member in members)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// Input Action <c>{member.ActionName}</c> 的稳定名称。");
builder.AppendLine(" /// </summary>");
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<ProjectAutoLoadEntry>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<Diagnostic>.Empty);
}
var currentSection = string.Empty;
var autoLoads = new List<ProjectAutoLoadEntry>();
var inputActions = new List<string>();
var diagnostics = new List<Diagnostic>();
var seenAutoLoads = new HashSet<string>(StringComparer.Ordinal);
var seenInputActions = new HashSet<string>(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
{
/// <summary>
/// 创建一个类型候选。
/// </summary>
/// <param name="classDeclaration">类型语法节点。</param>
/// <param name="typeSymbol">类型符号。</param>
public GodotTypeCandidate(
ClassDeclarationSyntax classDeclaration,
INamedTypeSymbol typeSymbol)
{
ClassDeclaration = classDeclaration;
TypeSymbol = typeSymbol;
}
/// <summary>
/// 获取类型声明语法。
/// </summary>
public ClassDeclarationSyntax ClassDeclaration { get; }
/// <summary>
/// 获取类型符号。
/// </summary>
public INamedTypeSymbol TypeSymbol { get; }
}
private sealed class ProjectAutoLoadEntry
{
/// <summary>
/// 初始化 AutoLoad 条目。
/// </summary>
/// <param name="name">AutoLoad 名称。</param>
/// <param name="resourcePath">资源路径。</param>
public ProjectAutoLoadEntry(
string name,
string resourcePath)
{
Name = name;
ResourcePath = resourcePath;
}
/// <summary>
/// 获取 AutoLoad 名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取资源路径。
/// </summary>
public string ResourcePath { get; }
}
private sealed class GeneratedAutoLoadMember
{
/// <summary>
/// 初始化一个生成后的 AutoLoad 成员描述。
/// </summary>
/// <param name="autoLoadName">原始 AutoLoad 名称。</param>
/// <param name="identifier">生成后的标识符。</param>
/// <param name="typeName">类型名。</param>
/// <param name="resourcePath">资源路径。</param>
public GeneratedAutoLoadMember(
string autoLoadName,
string identifier,
string typeName,
string resourcePath)
{
AutoLoadName = autoLoadName;
Identifier = identifier;
TypeName = typeName;
ResourcePath = resourcePath;
}
/// <summary>
/// 获取原始 AutoLoad 名称。
/// </summary>
public string AutoLoadName { get; }
/// <summary>
/// 获取生成后的标识符。
/// </summary>
public string Identifier { get; }
/// <summary>
/// 获取类型名。
/// </summary>
public string TypeName { get; }
/// <summary>
/// 获取资源路径。
/// </summary>
public string ResourcePath { get; }
}
private sealed class GeneratedInputActionMember
{
/// <summary>
/// 初始化一个生成后的 Input Action 成员描述。
/// </summary>
/// <param name="actionName">原始动作名。</param>
/// <param name="identifier">生成后的标识符。</param>
public GeneratedInputActionMember(
string actionName,
string identifier)
{
ActionName = actionName;
Identifier = identifier;
}
/// <summary>
/// 获取原始动作名。
/// </summary>
public string ActionName { get; }
/// <summary>
/// 获取生成后的标识符。
/// </summary>
public string Identifier { get; }
}
private sealed class ProjectMetadataParseResult
{
/// <summary>
/// 初始化一个项目元数据解析结果。
/// </summary>
/// <param name="filePath">项目文件路径。</param>
/// <param name="autoLoads">AutoLoad 条目。</param>
/// <param name="inputActions">Input Action 条目。</param>
/// <param name="diagnostics">解析过程中的诊断。</param>
public ProjectMetadataParseResult(
string filePath,
ImmutableArray<ProjectAutoLoadEntry> autoLoads,
ImmutableArray<string> inputActions,
ImmutableArray<Diagnostic> diagnostics)
{
FilePath = filePath;
AutoLoads = autoLoads;
InputActions = inputActions;
Diagnostics = diagnostics;
}
/// <summary>
/// 获取项目文件路径。
/// </summary>
public string FilePath { get; }
/// <summary>
/// 获取 AutoLoad 条目。
/// </summary>
public ImmutableArray<ProjectAutoLoadEntry> AutoLoads { get; }
/// <summary>
/// 获取 Input Action 条目。
/// </summary>
public ImmutableArray<string> InputActions { get; }
/// <summary>
/// 获取解析过程中的诊断。
/// </summary>
public ImmutableArray<Diagnostic> Diagnostics { get; }
}
}

View File

@ -6,6 +6,7 @@
- 与 Godot 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现
- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
- `[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
<PropertyGroup>
<GFrameworkGodotProjectFile>project.godot</GFrameworkGodotProjectFile>
</PropertyGroup>
```
如果你在仓库内通过 analyzer 形式直接引用本项目,则需要显式配置:
```xml
<ItemGroup>
<AdditionalFiles Include="project.godot" />
</ItemGroup>
```
## 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 用法