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
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)
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>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators.Abstractions\GFramework.Godot.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
</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_007 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_008 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_Godot_Project_001 | GFramework.Godot | Error | GodotProjectDiagnostics
GF_Godot_Project_002 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_003 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_004 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_005 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_006 | GFramework.Godot | Warning | GodotProjectDiagnostics

View File

@ -0,0 +1,75 @@
using GFramework.SourceGenerators.Common.Constants;
namespace GFramework.Godot.SourceGenerators.Diagnostics;
/// <summary>
/// 基于 <c>project.godot</c> 的项目元数据生成相关诊断。
/// </summary>
public static class GodotProjectDiagnostics
{
/// <summary>
/// 标记了 <c>[AutoLoad]</c> 的类型必须继承自 <c>Godot.Node</c>。
/// </summary>
public static readonly DiagnosticDescriptor AutoLoadTypeMustDeriveFromNode = new(
"GF_Godot_Project_001",
"AutoLoad types must derive from Godot.Node",
"Type '{0}' uses [AutoLoad] but does not derive from Godot.Node",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
/// <summary>
/// 多个类型映射到同一 AutoLoad 名称时会退化为非强类型访问。
/// </summary>
public static readonly DiagnosticDescriptor DuplicateAutoLoadMapping = new(
"GF_Godot_Project_002",
"Duplicate AutoLoad mappings were found",
"AutoLoad '{0}' is mapped by multiple types ({1}); the generated accessor falls back to Godot.Node until the mapping is unique",
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
/// <summary>
/// 多个 AutoLoad 名称映射到同一个标识符时会追加稳定后缀。
/// </summary>
public static readonly DiagnosticDescriptor AutoLoadIdentifierCollision = new(
"GF_Godot_Project_003",
"Generated AutoLoad identifier collision",
"AutoLoad '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended",
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
/// <summary>
/// 多个 Input Action 名称映射到同一个标识符时会追加稳定后缀。
/// </summary>
public static readonly DiagnosticDescriptor InputActionIdentifierCollision = new(
"GF_Godot_Project_004",
"Generated Input Action identifier collision",
"Input action '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended",
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
/// <summary>
/// 同一个 <c>project.godot</c> 中存在重复 AutoLoad 条目。
/// </summary>
public static readonly DiagnosticDescriptor DuplicateAutoLoadEntry = new(
"GF_Godot_Project_005",
"Duplicate AutoLoad entry in project.godot",
"AutoLoad '{0}' is declared multiple times in project.godot; only the first declaration is used",
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
/// <summary>
/// 同一个 <c>project.godot</c> 中存在重复 Input Action 条目。
/// </summary>
public static readonly DiagnosticDescriptor DuplicateInputActionEntry = new(
"GF_Godot_Project_006",
"Duplicate Input Action entry in project.godot",
"Input action '{0}' is declared multiple times in project.godot; only the first declaration is used",
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
}

View File

@ -3,14 +3,38 @@
<!-- 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>
<!--
当前生成器按文件名识别 project.godot因此允许调整相对路径
但不支持把目标文件重命名为其他文件名。
-->
<_GFrameworkGodotProjectFileName>$([System.IO.Path]::GetFileName('$(GFrameworkGodotProjectFile)'))</_GFrameworkGodotProjectFileName>
<_GFrameworkGodotProjectFileNameLower>$([System.String]::Copy('$(_GFrameworkGodotProjectFileName)').ToLowerInvariant())</_GFrameworkGodotProjectFileNameLower>
</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>
<Target Name="ValidateGFrameworkGodotProjectFileName"
BeforeTargets="CoreCompile"
Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)') and '$(_GFrameworkGodotProjectFileNameLower)' != 'project.godot'">
<Warning Text="GFrameworkGodotProjectFile can change the relative path, but the file name must remain 'project.godot'; otherwise GodotProjectMetadataGenerator will ignore the file."/>
</Target>
<!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework.Godot source generators" Importance="high"/>
</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 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现
- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
- `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码
@ -13,6 +14,96 @@
- 仅在 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 中设置:
- 路径可以调整到项目根目录下的其他位置
- 文件名必须仍然是 `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 用法

View File

@ -251,6 +251,7 @@ export default defineConfig({
{ text: 'ContextAware 生成器', link: '/zh-CN/source-generators/context-aware-generator' },
{ text: 'Priority 生成器', link: '/zh-CN/source-generators/priority-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: 'BindNodeSignal 生成器 (Godot)', link: '/zh-CN/source-generators/bind-node-signal-generator' }
]

View File

@ -452,16 +452,17 @@ Godot 引擎集成模块。
#### 常用 Attribute
| Attribute | 说明 | 文档 |
|--------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------|
| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [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) |
| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) |
| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) |
| `AutoRegisterExportedCollectionsAttribute` | 为宿主类开启导出集合批量注册生成 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
| `RegisterExportedCollectionAttribute` | 指定集合与注册器成员的映射关系 | [AutoRegisterExportedCollections 生成器](../source-generators/auto-register-exported-collections-generator.md) |
| Attribute | 说明 | 文档 |
|--------------------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| `AutoRegisterModuleAttribute` | 为模块类生成 `Install(IArchitecture)` | [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) |
| `RegisterUtilityAttribute` | 声明模块内自动注册的 `IUtility` 类型 | [AutoRegisterModule 生成器](../source-generators/auto-register-module-generator.md) |
| `AutoUiPageAttribute` | 为 `CanvasItem` 页面节点生成 `GetPage()` | [AutoUiPage 生成器](../source-generators/auto-ui-page-generator.md) |
| `AutoSceneAttribute` | 为场景根节点生成 `GetScene()` | [AutoScene 生成器](../source-generators/auto-scene-generator.md) |
| `AutoLoadAttribute` | 显式声明 `project.godot` AutoLoad 与 C# 节点类型映射 | [Godot 项目元数据生成器](../source-generators/godot-project-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-属性生成器)
- [Context Get 注入生成器](#context-get-注入生成器)
- [AutoRegisterModule 生成器](#autoregistermodule-生成器)
- [Godot 项目元数据生成](#godot-项目元数据生成)
- [GetNode 生成器 (Godot)](#getnode-生成器)
- [BindNodeSignal 生成器 (Godot)](#bindnodesignal-生成器)
- [AutoUiPage 生成器 (Godot)](#autouipage-生成器)
@ -52,6 +53,7 @@ GFramework.SourceGenerators 利用 Roslyn 源代码生成器技术,在编译
### Godot 专用生成器
- **Godot 项目元数据生成 (Godot)**:从 `project.godot` 生成 AutoLoad 与 Input Action 的强类型访问入口
- **[GetNode] 属性 (Godot)**:自动获取 Godot 节点引用,支持多种查找模式
- **[BindNodeSignal] 属性 (Godot)**:自动生成 Godot 节点信号绑定与解绑逻辑
- **[AutoUiPage] 属性 (Godot)**:自动生成 UI 页面行为包装与页面 Key
@ -435,6 +437,64 @@ public enum PlayerState
| GenerateIsMethods | bool | true | 是否为每个枚举值生成 IsX 方法 |
| 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]` 特性的字段自动生成 Godot 节点获取代码,无需手动调用 `GetNode<T>()` 方法。

View File

@ -14,6 +14,25 @@
## 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. 节点生命周期绑定
GFramework.Godot 提供了与 Godot 节点生命周期的无缝集成,确保框架初始化与 Godot 场景树同步。
@ -1335,4 +1354,4 @@ public class GodotPerformanceTests
---
**教程版本**: 1.0.0
**更新日期**: 2026-01-12
**更新日期**: 2026-04-14