mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-08 01:24:31 +08:00
Merge pull request #217 from GeWuYou/feat/godot-source-generators-project-metadata
This commit is contained in:
commit
6b5acbd99a
21
AGENTS.md
21
AGENTS.md
@ -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.
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -33,3 +33,9 @@
|
||||
GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
|
||||
GF_AutoExport_007 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
|
||||
GF_AutoExport_008 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
|
||||
GF_Godot_Project_001 | GFramework.Godot | Error | GodotProjectDiagnostics
|
||||
GF_Godot_Project_002 | GFramework.Godot | Warning | GodotProjectDiagnostics
|
||||
GF_Godot_Project_003 | GFramework.Godot | Warning | GodotProjectDiagnostics
|
||||
GF_Godot_Project_004 | GFramework.Godot | Warning | GodotProjectDiagnostics
|
||||
GF_Godot_Project_005 | GFramework.Godot | Warning | GodotProjectDiagnostics
|
||||
GF_Godot_Project_006 | GFramework.Godot | Warning | GodotProjectDiagnostics
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
using GFramework.SourceGenerators.Common.Constants;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 <c>project.godot</c> 的项目元数据生成相关诊断。
|
||||
/// </summary>
|
||||
public static class GodotProjectDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记了 <c>[AutoLoad]</c> 的类型必须继承自 <c>Godot.Node</c>。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor AutoLoadTypeMustDeriveFromNode = new(
|
||||
"GF_Godot_Project_001",
|
||||
"AutoLoad types must derive from Godot.Node",
|
||||
"Type '{0}' uses [AutoLoad] but does not derive from Godot.Node",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 多个类型映射到同一 AutoLoad 名称时会退化为非强类型访问。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor DuplicateAutoLoadMapping = new(
|
||||
"GF_Godot_Project_002",
|
||||
"Duplicate AutoLoad mappings were found",
|
||||
"AutoLoad '{0}' is mapped by multiple types ({1}); the generated accessor falls back to Godot.Node until the mapping is unique",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Warning,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 多个 AutoLoad 名称映射到同一个标识符时会追加稳定后缀。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor AutoLoadIdentifierCollision = new(
|
||||
"GF_Godot_Project_003",
|
||||
"Generated AutoLoad identifier collision",
|
||||
"AutoLoad '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Warning,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 多个 Input Action 名称映射到同一个标识符时会追加稳定后缀。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor InputActionIdentifierCollision = new(
|
||||
"GF_Godot_Project_004",
|
||||
"Generated Input Action identifier collision",
|
||||
"Input action '{0}' collides with another generated identifier '{1}'; a stable numeric suffix was appended",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Warning,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 同一个 <c>project.godot</c> 中存在重复 AutoLoad 条目。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor DuplicateAutoLoadEntry = new(
|
||||
"GF_Godot_Project_005",
|
||||
"Duplicate AutoLoad entry in project.godot",
|
||||
"AutoLoad '{0}' is declared multiple times in project.godot; only the first declaration is used",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Warning,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// 同一个 <c>project.godot</c> 中存在重复 Input Action 条目。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor DuplicateInputActionEntry = new(
|
||||
"GF_Godot_Project_006",
|
||||
"Duplicate Input Action entry in project.godot",
|
||||
"Input action '{0}' is declared multiple times in project.godot; only the first declaration is used",
|
||||
PathContests.GodotNamespace,
|
||||
DiagnosticSeverity.Warning,
|
||||
true);
|
||||
}
|
||||
@ -3,14 +3,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>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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 用法
|
||||
|
||||
|
||||
@ -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' }
|
||||
]
|
||||
|
||||
@ -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) |
|
||||
|
||||
## 常见用法示例
|
||||
|
||||
|
||||
172
docs/zh-CN/source-generators/godot-project-generator.md
Normal file
172
docs/zh-CN/source-generators/godot-project-generator.md
Normal 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)
|
||||
@ -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>()` 方法。
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user