mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
feat(Godot.SourceGenerators): 添加 Godot 项目元数据源码生成器
- 实现 project.godot 文件解析功能,支持 AutoLoad 和 Input Action 元数据提取 - 生成 AutoLoads 强类型访问入口,提供 GetRequiredNode 和 TryGetNode 方法 - 生成 InputActions 常量类,避免手写字符串魔法值 - 添加 AutoLoadAttribute 特性支持显式类型映射声明 - 实现标识符冲突检测和自动后缀追加机制 - 添加完整的诊断系统支持,包括类型继承检查和重复条目警告 - 创建 MSBuild 集成目标文件确保生成器正确加载 - 提供详细的 README 文档说明使用方法和最佳实践
This commit is contained in:
parent
b3066f3a8d
commit
61ee3a8f0c
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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\";"));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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 用法
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user