Merge pull request #217 from GeWuYou/feat/godot-source-generators-project-metadata

This commit is contained in:
gewuyou 2026-04-14 09:58:48 +08:00 committed by GitHub
commit 6b5acbd99a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2074 additions and 12 deletions

View File

@ -19,6 +19,27 @@ All AI agents and contributors must follow these rules when writing, reviewing,
- After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the - After resolving the host Windows Git path, prefer an explicit session-local binding for subsequent commands so the
shell does not fall back to Linux `/usr/bin/git` later in the same WSL session. shell does not fall back to Linux `/usr/bin/git` later in the same WSL session.
## Subagent Usage Rules
- Use subagents only when the task is complex, the context is likely to grow too large, or the work can be split into
independent parallel subtasks.
- The main agent MUST identify the critical path first. Do not delegate the immediate blocking task if the next local
step depends on that result.
- Use `explorer` subagents for read-only discovery, comparison, tracing, and narrow codebase questions.
- Use `worker` subagents only for bounded implementation tasks with an explicit file or module ownership boundary.
- Every delegation MUST specify:
- the concrete objective
- the expected output format
- the files or subsystem the subagent owns
- any constraints about tests, diagnostics, or compatibility
- Subagents are not allowed to revert or overwrite unrelated changes from the user or other agents. They must adapt to
concurrent work instead of assuming exclusive ownership of the repository.
- Prefer lightweight models such as `gpt-5.1-codex-mini` for narrow exploration, indexing, and comparison tasks.
- Prefer stronger models such as `gpt-5.4` for cross-module design work, non-trivial refactors, and tasks that require
higher confidence reasoning.
- The main agent remains responsible for reviewing and integrating subagent output. Unreviewed subagent conclusions do
not count as final results.
## Commenting Rules (MUST) ## Commenting Rules (MUST)
All generated or modified code MUST include clear and meaningful comments where required by the rules below. All generated or modified code MUST include clear and meaningful comments where required by the rules below.

View File

@ -0,0 +1,43 @@
#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>
/// <exception cref="ArgumentException">
/// <paramref name="name" /> 为空字符串或仅包含空白字符。
/// </exception>
public AutoLoadAttribute(string 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>
/// 获取在 <c>project.godot</c> 中声明的 AutoLoad 名称。
/// </summary>
public string Name { get; }
}

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

@ -0,0 +1,112 @@
using System.Collections.Immutable;
using System.IO;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
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>统一使用 LF (<c>\n</c>) 的内容。</returns>
public static string NormalizeLineEndings(string content)
{
return content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", 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,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>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators.Abstractions\GFramework.Godot.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/> <ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,584 @@
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()
{
var source = CreateSource(
"""
namespace TestApp
{
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
[AutoLoad("GameServices")]
public partial class GameServices : Node
{
}
}
""",
includeAutoLoadAttribute: true);
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\";"));
}
/// <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"));
});
}
/// <summary>
/// 验证缺少 <c>project.godot</c> AdditionalText 时不会生成任何源码或诊断。
/// </summary>
[Test]
public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Missing()
{
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
CreateSource("namespace TestApp { }"));
var generatorResult = result.Results.Single();
Assert.Multiple(() =>
{
Assert.That(generatorResult.Diagnostics, Is.Empty);
Assert.That(generatorResult.GeneratedSources, Is.Empty);
});
}
/// <summary>
/// 验证空的 <c>project.godot</c> 内容不会生成任何源码或诊断。
/// </summary>
[Test]
public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Empty()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
string.Empty);
var generatorResult = result.Results.Single();
Assert.Multiple(() =>
{
Assert.That(generatorResult.Diagnostics, Is.Empty);
Assert.That(generatorResult.GeneratedSources, Is.Empty);
});
}
/// <summary>
/// 验证只有空节的 <c>project.godot</c> 不会生成任何源码或诊断。
/// </summary>
[Test]
public void Run_Should_Not_Generate_Sources_When_Project_File_Has_Empty_Sections()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
"""
[autoload]
[input]
""");
var generatorResult = result.Results.Single();
Assert.Multiple(() =>
{
Assert.That(generatorResult.Diagnostics, Is.Empty);
Assert.That(generatorResult.GeneratedSources, Is.Empty);
});
}
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

@ -33,3 +33,9 @@
GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_007 | 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_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,38 @@
<!-- This file is automatically generated by the NuGet package --> <!-- This file is automatically generated by the NuGet package -->
<!-- It ensures that the source generators are properly registered during build --> <!-- It ensures that the source generators are properly registered during build -->
<PropertyGroup>
<!--
默认收集消费者项目根目录下的 project.godot
让 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> <ItemGroup>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.dll"/> <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.Godot.SourceGenerators.Abstractions.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)')">
<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 --> <!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile"> <Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework.Godot source generators" Importance="high"/> <Message Text="Loading GFramework.Godot source generators" Importance="high"/>
</Target> </Target>
</Project> </Project>

View File

@ -0,0 +1,782 @@
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;
/// <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))
{
// 显式 [AutoLoad] 映射优先于按类型名推断,因为它代表了用户给出的稳定契约。
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)
{
ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctExplicitTypes);
}
continue;
}
if (!implicitCandidates.TryGetValue(projectAutoLoadName, out var implicitList))
continue;
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;
}
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 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,
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 场景相关的编译期生成能力 - 与 Godot 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现 - 基于 Roslyn 的增量生成器实现
- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码 - `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
- `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码 - `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码
@ -13,6 +14,96 @@
- 仅在 Godot + C# 项目中启用 - 仅在 Godot + C# 项目中启用
- 非 Godot 项目可只使用 GFramework.SourceGenerators - 非 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 中设置:
- 路径可以调整到项目根目录下的其他位置
- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件
```xml
<PropertyGroup>
<GFrameworkGodotProjectFile>Config/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 用法 ## GetNode 用法

View File

@ -251,6 +251,7 @@ export default defineConfig({
{ text: 'ContextAware 生成器', link: '/zh-CN/source-generators/context-aware-generator' }, { text: 'ContextAware 生成器', link: '/zh-CN/source-generators/context-aware-generator' },
{ text: 'Priority 生成器', link: '/zh-CN/source-generators/priority-generator' }, { text: 'Priority 生成器', link: '/zh-CN/source-generators/priority-generator' },
{ text: 'Context Get 注入', link: '/zh-CN/source-generators/context-get-generator' }, { text: 'Context Get 注入', link: '/zh-CN/source-generators/context-get-generator' },
{ text: 'Godot 项目元数据', link: '/zh-CN/source-generators/godot-project-generator' },
{ text: 'GetNode 生成器 (Godot)', link: '/zh-CN/source-generators/get-node-generator' }, { text: 'GetNode 生成器 (Godot)', link: '/zh-CN/source-generators/get-node-generator' },
{ text: 'BindNodeSignal 生成器 (Godot)', link: '/zh-CN/source-generators/bind-node-signal-generator' } { text: 'BindNodeSignal 生成器 (Godot)', link: '/zh-CN/source-generators/bind-node-signal-generator' }
] ]

View File

@ -452,16 +452,17 @@ Godot 引擎集成模块。
#### 常用 Attribute #### 常用 Attribute
| Attribute | 说明 | 文档 | | Attribute | 说明 | 文档 |
|--------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------| |--------------------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | | `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
| `RegisterModelAttribute` | 声明模块内自动注册的 `IModel` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | | `RegisterModelAttribute` | 声明模块内自动注册的 `IModel` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
| `RegisterSystemAttribute` | 声明模块内自动注册的 `ISystem` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | | `RegisterSystemAttribute` | 声明模块内自动注册的 `ISystem` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) | | `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) | | `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) |
| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) | | `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) |
| `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) | | `AutoLoadAttribute` | 显式声明 `project.godot` AutoLoad 与 C# 节点类型映射 | [Godot 项目元数据生成器](../source-generators/godot-project-generator.md) |
| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) | | `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
## 常见用法示例 ## 常见用法示例

View File

@ -0,0 +1,172 @@
# Godot 项目元数据生成器
> 从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口。
## 概述
`GFramework.Godot.SourceGenerators` 会读取 Godot 项目根目录下的 `project.godot`,并把其中最常用的项目级元数据暴露为稳定的编译期
API。
当前覆盖:
- `[autoload]` 段:生成 `GFramework.Godot.Generated.AutoLoads`
- `[input]` 段:生成 `GFramework.Godot.Generated.InputActions`
这项能力的目标不是替代场景级生成器,而是把 Godot 工程配置和 C# 代码之间的字符串约定收敛到编译期。
## 接入方式
### NuGet 引用
当项目通过 NuGet 引用 `GeWuYou.GFramework.Godot.SourceGenerators` 时,生成器会默认把项目根目录下的 `project.godot` 加入
`AdditionalFiles`
如需覆盖默认路径,可以设置:
- 可以改成项目根目录下的其他相对路径
- 文件名必须仍然是 `project.godot`,否则生成器会给出警告并忽略该文件
```xml
<PropertyGroup>
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
</PropertyGroup>
```
### 仓库内直接引用生成器
如果你通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器项目,则需要手动加入:
```xml
<ItemGroup>
<AdditionalFiles Include="project.godot" />
</ItemGroup>
```
## AutoLoad 访问层
### 基础行为
假设 `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))
{
}
```
- 对于能唯一映射到 C# 节点类型的条目,属性会是强类型的
- 对于无法映射或对应非 C# 脚本的条目,属性会退化为 `Godot.Node`
- 生成器通过 `Godot.Engine.GetMainLoop()` 与当前 `SceneTree.Root` 解析 `/root/<AutoLoadName>` 节点
### 显式映射
当 AutoLoad 名称无法仅靠类名唯一推断时,可以使用 `[AutoLoad]` 明确指定:
```csharp
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
[AutoLoad("GameServices")]
public partial class GameServices : Node
{
}
```
规则如下:
- 显式 `[AutoLoad]` 映射优先于隐式类名推断
- 标记了 `[AutoLoad]` 的类型必须继承 `Godot.Node`
- 若多个类型映射到同一个 AutoLoad生成器会报告诊断并退化为 `Godot.Node` 访问器,直到映射唯一
## Input Action 常量
### 基础行为
假设 `project.godot` 中有:
```ini
[input]
move_up={
}
ui_cancel={
}
```
生成器会产出:
```csharp
using GFramework.Godot.Generated;
if (Input.IsActionJustPressed(InputActions.MoveUp))
{
}
```
转换规则:
- `move_up` -> `MoveUp`
- `ui_cancel` -> `UiCancel`
- 非法字符会被清理后再转换为 PascalCase
- 如果多个动作名落到同一个标识符,生成器会追加稳定数字后缀,例如 `MoveUp_2`
## 与现有 Godot 生成器的关系
这项能力和现有的场景级生成器是互补的:
- `AutoLoads` / `InputActions` 解决的是项目级元数据访问
- `[GetNode]` 解决的是场景节点引用注入
- `[BindNodeSignal]` 解决的是节点事件订阅样板
推荐组合方式:
```csharp
using GFramework.Godot.Generated;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
public partial class MainHud : Control
{
[GetNode]
private Button _startButton = null!;
public override void _Ready()
{
__InjectGetNodes_Generated();
if (Input.IsActionPressed(InputActions.UiCancel))
{
}
var services = AutoLoads.GameServices;
}
}
```
## 诊断与约束
当前会重点报告以下问题:
- `[AutoLoad]` 标记在非 `Godot.Node` 类型上
- 多个类型映射到同一个 AutoLoad 名称
- 不同 AutoLoad 名称或 Input Action 名称在清洗后发生标识符冲突
- `project.godot` 内部重复声明同名 AutoLoad 或 Input Action
这些诊断的目的不是阻断所有生成,而是在可能的情况下保留稳定输出,同时把不确定性显式暴露出来。
## 相关文档
- [GetNode 生成器](./get-node-generator)
- [BindNodeSignal 生成器](./bind-node-signal-generator)
- [Godot 集成教程](../tutorials/godot-integration)

View File

@ -16,6 +16,7 @@ GFramework.SourceGenerators 是 GFramework 框架的源代码生成器包,通
- [Priority 属性生成器](#priority-属性生成器) - [Priority 属性生成器](#priority-属性生成器)
- [Context Get 注入生成器](#context-get-注入生成器) - [Context Get 注入生成器](#context-get-注入生成器)
- [AutoRegisterModule 生成器](#autoregistermodule-生成器) - [AutoRegisterModule 生成器](#autoregistermodule-生成器)
- [Godot 项目元数据生成](#godot-项目元数据生成)
- [GetNode 生成器 (Godot)](#getnode-生成器) - [GetNode 生成器 (Godot)](#getnode-生成器)
- [BindNodeSignal 生成器 (Godot)](#bindnodesignal-生成器) - [BindNodeSignal 生成器 (Godot)](#bindnodesignal-生成器)
- [AutoUiPage 生成器 (Godot)](#autouipage-生成器) - [AutoUiPage 生成器 (Godot)](#autouipage-生成器)
@ -52,6 +53,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
### Godot 专用生成器 ### Godot 专用生成器
- **Godot 项目元数据生成 (Godot)**:从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口
- **[GetNode] 属性 (Godot)**:自动获取 Godot 节点引用,支持多种查找模式 - **[GetNode] 属性 (Godot)**:自动获取 Godot 节点引用,支持多种查找模式
- **[BindNodeSignal] 属性 (Godot)**:自动生成 Godot 节点信号绑定与解绑逻辑 - **[BindNodeSignal] 属性 (Godot)**:自动生成 Godot 节点信号绑定与解绑逻辑
- **[AutoUiPage] 属性 (Godot)**:自动生成 UI 页面行为包装与页面 Key - **[AutoUiPage] 属性 (Godot)**:自动生成 UI 页面行为包装与页面 Key
@ -435,6 +437,64 @@ public enum PlayerState
| GenerateIsMethods | bool | true | 是否为每个枚举值生成 IsX 方法 | | GenerateIsMethods | bool | true | 是否为每个枚举值生成 IsX 方法 |
| GenerateIsInMethod | bool | true | 是否生成 IsIn 方法 | | GenerateIsInMethod | bool | true | 是否生成 IsIn 方法 |
## Godot 项目元数据生成
Godot 项目元数据生成器会读取 `project.godot`,把项目级配置转换为稳定的编译期 API。
当前生成两个统一入口:
- `GFramework.Godot.Generated.AutoLoads`
- `GFramework.Godot.Generated.InputActions`
默认情况下NuGet 包引用会自动把项目根目录下的 `project.godot` 加入 `AdditionalFiles`。如果你是在仓库内通过 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
{
}
```
生成后可以直接使用:
```csharp
using GFramework.Godot.Generated;
var services = AutoLoads.GameServices;
if (AutoLoads.TryGetAudioBus(out var audioBus))
{
}
```
### Input Action 常量
`[input]` 段会被转换为强类型常量:
```csharp
using GFramework.Godot.Generated;
if (Input.IsActionPressed(InputActions.MoveUp))
{
}
```
完整说明请见:[Godot 项目元数据生成器](./godot-project-generator)
## GetNode 生成器 ## GetNode 生成器
GetNode 生成器为标记了 `[GetNode]` 特性的字段自动生成 Godot 节点获取代码,无需手动调用 `GetNode<T>()` 方法。 GetNode 生成器为标记了 `[GetNode]` 特性的字段自动生成 Godot 节点获取代码,无需手动调用 `GetNode<T>()` 方法。

View File

@ -14,6 +14,25 @@
## Godot 特定功能 ## Godot 特定功能
### 0. project.godot 编译期接入
如果项目引用了 `GFramework.Godot.SourceGenerators`,现在可以直接把 `project.godot` 中的 AutoLoad 与 Input Action 暴露为强类型
API
```csharp
using GFramework.Godot.Generated;
var services = AutoLoads.GameServices;
if (Input.IsActionPressed(InputActions.MoveUp))
{
}
```
这项能力适合和 `[GetNode]``[BindNodeSignal]` 一起使用:前者解决项目级配置入口,后两者解决场景级节点和事件样板。
详细说明见:[Godot 项目元数据生成器](../source-generators/godot-project-generator)
### 1. 节点生命周期绑定 ### 1. 节点生命周期绑定
GFramework.Godot 提供了与 Godot 节点生命周期的无缝集成,确保框架初始化与 Godot 场景树同步。 GFramework.Godot 提供了与 Godot 节点生命周期的无缝集成,确保框架初始化与 Godot 场景树同步。
@ -1335,4 +1354,4 @@ public class GodotPerformanceTests
--- ---
**教程版本**: 1.0.0 **教程版本**: 1.0.0
**更新日期**: 2026-01-12 **更新日期**: 2026-04-14