mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
feat(godot): 添加 Godot 集成功能和测试基础设施
- 新增 AdditionalTextGeneratorTestDriver 用于源生成器测试 - 添加 AutoLoadAttribute 特性支持 AutoLoad 类型映射 - 扩展项目构建目标,支持自定义 project.godot 路径验证 - 创建完整 Godot 集成教程文档,涵盖节点生命周期、信号系统等功能 - 添加源代码生成器测试项目配置和相关依赖包引用
This commit is contained in:
parent
7dafec72be
commit
833a295b84
@ -18,9 +18,22 @@ public sealed class AutoLoadAttribute : Attribute
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="name" /> 为 <see langword="null" />。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="name" /> 为空字符串或仅包含空白字符。
|
||||
/// </exception>
|
||||
public AutoLoadAttribute(string name)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(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>
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace GFramework.Godot.SourceGenerators.Tests.Core;
|
||||
|
||||
@ -60,13 +62,12 @@ public static class AdditionalTextGeneratorTestDriver
|
||||
/// 规范化换行,避免测试在不同平台上产生伪差异。
|
||||
/// </summary>
|
||||
/// <param name="content">待规范化文本。</param>
|
||||
/// <returns>使用当前平台换行符的内容。</returns>
|
||||
/// <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)
|
||||
.Replace("\n", Environment.NewLine, StringComparison.Ordinal);
|
||||
.Replace("\r", "\n", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IEnumerable<MetadataReference> GetMetadataReferences()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -316,4 +316,245 @@ public class GodotProjectMetadataGeneratorTests
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,12 @@
|
||||
让 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>
|
||||
@ -21,6 +27,12 @@
|
||||
<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"/>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
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;
|
||||
|
||||
@ -178,6 +181,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
|
||||
|
||||
foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal))
|
||||
{
|
||||
// 显式 [AutoLoad] 映射优先于按类型名推断,因为它代表了用户给出的稳定契约。
|
||||
if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList))
|
||||
{
|
||||
var distinctExplicitTypes = DistinctTypeSymbols(explicitList);
|
||||
@ -188,14 +192,7 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
|
||||
}
|
||||
else if (distinctExplicitTypes.Length > 1)
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(
|
||||
GodotProjectDiagnostics.DuplicateAutoLoadMapping,
|
||||
Location.None,
|
||||
projectAutoLoadName,
|
||||
string.Join(
|
||||
", ",
|
||||
distinctExplicitTypes.Select(static type =>
|
||||
type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)))));
|
||||
ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctExplicitTypes);
|
||||
}
|
||||
|
||||
continue;
|
||||
@ -207,8 +204,15 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
|
||||
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;
|
||||
}
|
||||
@ -228,6 +232,21 @@ public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
|
||||
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,
|
||||
|
||||
@ -25,11 +25,14 @@
|
||||
- `GFramework.Godot.Generated.AutoLoads`
|
||||
- `GFramework.Godot.Generated.InputActions`
|
||||
|
||||
如果你需要覆盖默认项目文件名,可以在 MSBuild 中设置:
|
||||
如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置:
|
||||
|
||||
- 路径可以调整到项目根目录下的其他位置
|
||||
- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<GFrameworkGodotProjectFile>project.godot</GFrameworkGodotProjectFile>
|
||||
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
|
||||
@ -23,9 +23,12 @@ API。
|
||||
|
||||
如需覆盖默认路径,可以设置:
|
||||
|
||||
- 可以改成项目根目录下的其他相对路径
|
||||
- 文件名必须仍然是 `project.godot`,否则生成器会给出警告并忽略该文件
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<GFrameworkGodotProjectFile>project.godot</GFrameworkGodotProjectFile>
|
||||
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
|
||||
@ -1354,4 +1354,4 @@ public class GodotPerformanceTests
|
||||
---
|
||||
|
||||
**教程版本**: 1.0.0
|
||||
**更新日期**: 2026-01-12
|
||||
**更新日期**: 2026-04-14
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user