Compare commits

..

8 Commits

Author SHA1 Message Date
GeWuYou
c2ee2209fd docs(community): 添加问题模板和缺陷报告模板
- 创建了 Bug Report 模板,支持中英双语,包含模块选择、版本信息、复现步骤等字段
- 添加了 Question 模板,用于使用咨询和技术问答
- 配置了预提交检查项,确保提交前完成必要验证
- 设计了结构化的表单字段,便于收集准确的问题信息
- 实现了多语言支持,提升社区协作体验
2026-04-14 13:13:06 +08:00
GeWuYou
396bb1566a docs(contributing): 更新贡献指南并添加标准 Issue 模板
- 更新问题报告指引,整合 Bug、功能、文档、咨询四类模板
- 优化 Issue 分诊建议,明确 bug、enhancement、documentation、question 分类
- 修订 PR 提交流程说明,统一模板使用要求
- 新增 README 项目介绍文档,包含模块说明、安装指导等内容
- 添加标准化 Issue 模板配置,支持 Bug 报告、功能建议、文档改进、使用咨询
- 完善 GitHub Issue 配置,提供模板搜索与文档链接指引
2026-04-14 13:06:00 +08:00
gewuyou
6b5acbd99a
Merge pull request #217 from GeWuYou/feat/godot-source-generators-project-metadata 2026-04-14 09:58:48 +08:00
GeWuYou
31a439e184 test(Godot): 添加项目元数据生成器测试
- 验证基于 project.godot 的 AutoLoad 和 Input Action 强类型入口生成
- 测试 AutoLoad 类型非节点继承时的诊断报告功能
- 验证 Input Action 标识符冲突时的后缀追加和警告机制
- 测试多个显式映射指向同一 AutoLoad 时的重复检测
- 验证不同命名空间同名节点类型的隐式映射冲突处理
- 测试 AutoLoad 和 Input Action 重复条目的诊断和保留逻辑
- 验证缺失或空 project.godot 文件时的无生成行为
2026-04-14 09:51:52 +08:00
GeWuYou
bb7abc0d8f test(Godot): 添加项目元数据生成器测试
- 验证 AutoLoad 和 Input Action 强类型入口生成
- 测试非节点类型上的 AutoLoad 标记诊断
- 验证输入动作标识符冲突处理和后缀追加
- 测试多个显式映射指向相同 AutoLoad 的重复检测
- 验证不同命名空间同名节点类型的冲突处理
- 测试 AutoLoad 标识符冲突的诊断和后缀追加
- 验证项目文件中重复 AutoLoad 条目的处理
- 测试重复输入动作条目的诊断和保留机制
2026-04-14 09:23:49 +08:00
GeWuYou
833a295b84 feat(godot): 添加 Godot 集成功能和测试基础设施
- 新增 AdditionalTextGeneratorTestDriver 用于源生成器测试
- 添加 AutoLoadAttribute 特性支持 AutoLoad 类型映射
- 扩展项目构建目标,支持自定义 project.godot 路径验证
- 创建完整 Godot 集成教程文档,涵盖节点生命周期、信号系统等功能
- 添加源代码生成器测试项目配置和相关依赖包引用
2026-04-14 09:05:33 +08:00
GeWuYou
7dafec72be docs(docs): 添加文档配置和API参考
- 新增.vitepress/config.mts配置文件,包含本地搜索、代码块保护等功能
- 添加API参考文档,涵盖核心架构、事件系统、属性系统等完整API
- 添加源码生成器文档,介绍Log、ContextAware、EnumExtensions等生成器用法
- 配置多语言导航和侧边栏结构,完善文档站点设置
- 添加代码示例和使用指南,提供完整的框架使用参考
2026-04-14 08:22:28 +08:00
GeWuYou
61ee3a8f0c feat(Godot.SourceGenerators): 添加 Godot 项目元数据源码生成器
- 实现 project.godot 文件解析功能,支持 AutoLoad 和 Input Action 元数据提取
- 生成 AutoLoads 强类型访问入口,提供 GetRequiredNode 和 TryGetNode 方法
- 生成 InputActions 常量类,避免手写字符串魔法值
- 添加 AutoLoadAttribute 特性支持显式类型映射声明
- 实现标识符冲突检测和自动后缀追加机制
- 添加完整的诊断系统支持,包括类型继承检查和重复条目警告
- 创建 MSBuild 集成目标文件确保生成器正确加载
- 提供详细的 README 文档说明使用方法和最佳实践
2026-04-14 08:22:12 +08:00
24 changed files with 2442 additions and 43 deletions

122
.github/ISSUE_TEMPLATE/01-bug-report.yml vendored Normal file
View File

@ -0,0 +1,122 @@
name: "Bug Report / 缺陷报告"
description: "Report a reproducible defect in GFramework. / 报告可稳定复现的 GFramework 缺陷。"
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug.
感谢你提交缺陷报告。提交前请先搜索已有 Issue并尽量提供最小复现信息。
- type: checkboxes
id: checks
attributes:
label: "Pre-Submission Checks / 提交前检查"
description: "Please confirm the following items before submitting. / 提交前请确认以下事项。"
options:
- label: "I searched existing issues and did not find a duplicate. / 我已搜索现有 Issue未发现重复问题。"
required: true
- label: "I checked the relevant README or docs pages first. / 我已先阅读相关 README 或文档。"
required: true
- label: "I can describe a reproducible scenario or provide a minimal repro. / 我可以描述稳定复现场景或提供最小复现。"
required: true
- type: dropdown
id: module
attributes:
label: "Affected Module / 影响模块"
description: "Choose the module that best matches the problem. / 请选择最符合问题范围的模块。"
options:
- "GFramework.Core"
- "GFramework.Core.Abstractions"
- "GFramework.Game"
- "GFramework.Game.Abstractions"
- "GFramework.Godot"
- "GFramework.SourceGenerators"
- "GFramework.Godot.SourceGenerators"
- "Docs / 文档"
- "Build / CI / Packaging"
- "Unknown / Not sure / 不确定"
validations:
required: true
- type: input
id: version
attributes:
label: "Package or Commit Version / 包版本或提交版本"
description: "Example: NuGet version, commit SHA, or branch. / 例如 NuGet 版本、提交 SHA 或分支。"
placeholder: "e.g. GeWuYou.GFramework.Core 1.2.3 / main@abc1234"
validations:
required: true
- type: textarea
id: summary
attributes:
label: "Bug Summary / 问题概述"
description: "Describe the defect in one or two paragraphs. / 用 1-2 段简要描述问题。"
placeholder: "What is broken, and when does it happen? / 具体哪里出错,什么情况下出现?"
validations:
required: true
- type: textarea
id: steps
attributes:
label: "Steps To Reproduce / 复现步骤"
description: "Provide a deterministic repro whenever possible. / 尽量提供可稳定复现的步骤。"
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: "Expected Behavior / 预期行为"
description: "What should happen instead? / 正常情况下应该发生什么?"
validations:
required: true
- type: textarea
id: actual
attributes:
label: "Actual Behavior / 实际行为"
description: "What actually happens? Include exception text if available. / 实际发生了什么?如有异常请附上。"
validations:
required: true
- type: textarea
id: repro
attributes:
label: "Minimal Repro / 最小复现"
description: "Share a repository, gist, code snippet, or explain why a minimal repro is not yet available. / 提供仓库、gist、代码片段或说明暂时无法提供的原因。"
placeholder: |
Please provide one of the following:
- A GitHub repository or sample project
- A gist or focused code snippet
- Or explain why a minimal repro is not yet available
请提供以下任一内容:
- GitHub 仓库或示例项目
- Gist 或聚焦代码片段
- 或说明暂时无法提供最小复现的原因
render: shell
validations:
required: true
- type: textarea
id: logs
attributes:
label: "Logs and Screenshots / 日志与截图"
description: "Paste relevant logs, stack traces, or attach screenshots. / 粘贴相关日志、堆栈,或补充截图。"
render: shell
- type: textarea
id: environment
attributes:
label: "Environment / 环境信息"
description: "List the environment details that matter for reproduction. / 请列出与复现相关的环境信息。"
placeholder: |
- OS:
- .NET SDK / Runtime:
- Godot version (if applicable):
- IDE / Build tool:
validations:
required: true
- type: textarea
id: impact
attributes:
label: "Impact and Scope / 影响范围"
description: "Explain whether this blocks adoption, breaks compatibility, or affects only a narrow scenario. / 说明该问题是否阻塞使用、破坏兼容性,还是仅影响较窄场景。"

View File

@ -0,0 +1,80 @@
name: "Feature Request / 功能建议"
description: "Suggest a new capability or an API improvement. / 提出新能力或 API 改进建议。"
title: "[Feature]: "
body:
- type: markdown
attributes:
value: |
Use this form for feature proposals, API improvements, and workflow enhancements.
该模板适用于新功能、API 改进和工作流优化建议。请优先描述问题和动机,而不只是直接给出实现方案。
- type: checkboxes
id: checks
attributes:
label: "Pre-Submission Checks / 提交前检查"
description: "Please confirm the following items before submitting. / 提交前请确认以下事项。"
options:
- label: "I searched existing issues and did not find the same request. / 我已搜索现有 Issue未发现相同建议。"
required: true
- label: "I checked the relevant docs, examples, or current APIs first. / 我已先检查相关文档、示例或现有 API。"
required: true
- label: "I can explain the user problem or workflow gap this request solves. / 我可以说明该建议要解决的用户问题或工作流缺口。"
required: true
- type: dropdown
id: module
attributes:
label: "Target Module / 目标模块"
description: "Choose the module that should own this capability. / 请选择最适合承载该能力的模块。"
options:
- "GFramework.Core"
- "GFramework.Core.Abstractions"
- "GFramework.Game"
- "GFramework.Game.Abstractions"
- "GFramework.Godot"
- "GFramework.SourceGenerators"
- "GFramework.Godot.SourceGenerators"
- "Docs / 文档"
- "Build / CI / Packaging"
- "Cross-cutting / 跨模块"
- "Unknown / Not sure / 不确定"
validations:
required: true
- type: textarea
id: problem
attributes:
label: "Problem Statement / 问题背景"
description: "What problem are you facing today? / 你当前遇到的核心问题是什么?"
placeholder: "Describe the workflow pain, limitation, or missing capability. / 描述当前流程痛点、限制或缺失能力。"
validations:
required: true
- type: textarea
id: proposal
attributes:
label: "Proposed Solution / 建议方案"
description: "Describe the behavior, API shape, or user experience you want. / 描述你期望的行为、API 形态或使用体验。"
placeholder: "What should GFramework provide? / 希望 GFramework 提供什么?"
validations:
required: true
- type: textarea
id: use-cases
attributes:
label: "Use Cases / 使用场景"
description: "Show the practical scenarios this would unlock or simplify. / 说明该能力能解决或简化哪些实际场景。"
validations:
required: true
- type: textarea
id: api-sketch
attributes:
label: "API or Design Sketch / API 或设计草图"
description: "Optional but helpful: provide pseudocode, API examples, or a rough design. / 可选但强烈建议补充伪代码、API 示例或设计草图。"
render: csharp
- type: textarea
id: alternatives
attributes:
label: "Alternatives Considered / 已考虑的替代方案"
description: "Describe current workarounds or alternatives and why they are insufficient. / 描述现有替代方案或绕过方式,以及为什么不足。"
- type: textarea
id: compatibility
attributes:
label: "Compatibility and Migration Impact / 兼容性与迁移影响"
description: "State whether this needs breaking changes, opt-in behavior, or migration notes. / 说明该建议是否涉及破坏性变更、显式开关或迁移说明。"

View File

@ -0,0 +1,61 @@
name: "Documentation / 文档改进"
description: "Report missing, outdated, or unclear documentation. / 报告缺失、过期或不清晰的文档。"
title: "[Docs]: "
body:
- type: markdown
attributes:
value: |
Documentation issues are product issues in this repository.
文档问题同样是产品问题。请尽量指出具体页面、段落和建议修正方向,方便快速处理。
- type: checkboxes
id: checks
attributes:
label: "Pre-Submission Checks / 提交前检查"
description: "Please confirm the following items before submitting. / 提交前请确认以下事项。"
options:
- label: "I searched existing issues and did not find the same documentation problem. / 我已搜索现有 Issue未发现相同文档问题。"
required: true
- label: "I checked the latest docs site or repository docs pages first. / 我已先检查最新文档站点或仓库文档页面。"
required: true
- type: input
id: page
attributes:
label: "Document Path or URL / 文档路径或链接"
description: "Provide the file path or docs URL if you know it. / 如果知道,请提供文档文件路径或页面链接。"
placeholder: "e.g. docs/zh-CN/core/architecture.md"
validations:
required: true
- type: dropdown
id: doc-issue-type
attributes:
label: "Issue Type / 问题类型"
description: "Choose the primary documentation problem. / 请选择主要问题类型。"
options:
- "Missing content / 缺少内容"
- "Outdated content / 内容过期"
- "Incorrect content / 内容错误"
- "Unclear explanation / 说明不清晰"
- "Missing example / 缺少示例"
- "Translation issue / 翻译问题"
validations:
required: true
- type: textarea
id: current-problem
attributes:
label: "Current Problem / 当前问题"
description: "Describe what is confusing, wrong, or missing. / 说明当前哪里令人困惑、错误或缺失。"
validations:
required: true
- type: textarea
id: expected-docs
attributes:
label: "Expected Improvement / 期望改进"
description: "Describe the improvement you expect. / 说明你期望如何改进。"
validations:
required: true
- type: textarea
id: references
attributes:
label: "Related Code or References / 相关代码或参考资料"
description: "Link related source files, PRs, issues, or external references if helpful. / 如有帮助请附上相关源码、PR、Issue 或外部参考资料。"

66
.github/ISSUE_TEMPLATE/04-question.yml vendored Normal file
View File

@ -0,0 +1,66 @@
name: "Question / 使用咨询"
description: "Ask for guidance about usage, behavior, or adoption. / 询问用法、行为或接入方式。"
title: "[Question]: "
body:
- type: markdown
attributes:
value: |
Use this form when your question is specific to GFramework behavior, APIs, or adoption guidance.
如果你的问题与 GFramework 的行为、API 或接入方式直接相关,请使用此模板。一般咨询请先查看 README、贡献指南与 docs。
- type: checkboxes
id: checks
attributes:
label: "Pre-Submission Checks / 提交前检查"
description: "Please confirm the following items before submitting. / 提交前请确认以下事项。"
options:
- label: "I searched existing issues and read the relevant docs first. / 我已先搜索现有 Issue 并阅读相关文档。"
required: true
- label: "This is not a private support request or unrelated general programming question. / 这不是私有支持请求,也不是与本项目无关的泛编程问题。"
required: true
- type: dropdown
id: topic
attributes:
label: "Topic Area / 主题领域"
description: "Choose the area closest to your question. / 请选择最接近问题的主题。"
options:
- "Architecture / 架构"
- "Core APIs / Core API"
- "Game Module / Game 模块"
- "Godot Integration / Godot 集成"
- "Source Generators / 源生成器"
- "Build / Packaging / 构建与打包"
- "Docs / 文档"
- "Other / 其他"
validations:
required: true
- type: textarea
id: goal
attributes:
label: "What Are You Trying To Do? / 你想实现什么?"
description: "Explain your goal before describing the problem. / 请先说明你的目标,再描述遇到的问题。"
placeholder: "I want to... / 我想要……"
validations:
required: true
- type: textarea
id: current-attempt
attributes:
label: "Current Attempt / 当前尝试"
description: "Show what you already tried, including code, docs, or configuration. / 说明你已经尝试过什么,包括代码、文档或配置。"
render: csharp
validations:
required: true
- type: textarea
id: question
attributes:
label: "Specific Question / 具体问题"
description: "Ask the narrowest question that would unblock you. / 提出能真正帮你解阻的最小问题。"
validations:
required: true
- type: textarea
id: environment
attributes:
label: "Relevant Environment / 相关环境"
description: "Include the framework version, runtime, engine version, or project context. If not applicable, write N/A. / 请补充框架版本、运行时、引擎版本或项目上下文;如不适用请填写 N/A。"
validations:
required: true

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: "Search Existing Issues / 搜索现有 Issues"
url: "https://github.com/GeWuYou/GFramework/issues?q=is%3Aissue"
about: "Check whether your topic has already been reported or discussed. / 先确认是否已有相同问题或讨论。"
- name: "Read Contribution Guide / 阅读贡献指南"
url: "https://github.com/GeWuYou/GFramework/blob/main/docs/zh-CN/contributing.md"
about: "Review issue and pull request expectations before submitting. / 提交前先阅读 Issue 与 PR 的协作约定。"
- name: "Browse Documentation / 查看文档"
url: "https://github.com/GeWuYou/GFramework/tree/main/docs/zh-CN"
about: "Read docs, tutorials, and troubleshooting pages first. / 先查看文档、教程与排障页面。"

View File

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

View File

@ -0,0 +1,43 @@
#nullable enable
namespace GFramework.Godot.SourceGenerators.Abstractions;
/// <summary>
/// 显式声明某个 Godot 节点类型与 <c>project.godot</c> 中 AutoLoad 名称之间的映射关系。
/// </summary>
/// <remarks>
/// 当 AutoLoad 条目无法仅靠类型名唯一推断到 C# 节点类型时,
/// 可以通过该特性为生成器提供稳定的强类型映射入口。
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class AutoLoadAttribute : Attribute
{
/// <summary>
/// 初始化 <see cref="AutoLoadAttribute" /> 的新实例。
/// </summary>
/// <param name="name">在 <c>project.godot</c> 中声明的 AutoLoad 名称。</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="name" /> 为 <see langword="null" />。
/// </exception>
/// <exception cref="ArgumentException">
/// <paramref name="name" /> 为空字符串或仅包含空白字符。
/// </exception>
public AutoLoadAttribute(string name)
{
if (name is null)
{
throw new ArgumentNullException(nameof(name));
}
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("AutoLoad name cannot be empty or whitespace.", nameof(name));
}
Name = name;
}
/// <summary>
/// 获取在 <c>project.godot</c> 中声明的 AutoLoad 名称。
/// </summary>
public string Name { get; }
}

View File

@ -0,0 +1,49 @@
using GFramework.Godot.SourceGenerators.Abstractions;
namespace GFramework.Godot.SourceGenerators.Tests.Abstractions;
/// <summary>
/// 验证 <see cref="AutoLoadAttribute" /> 的参数约束。
/// </summary>
[TestFixture]
public class AutoLoadAttributeTests
{
/// <summary>
/// 验证构造函数会保留合法的 AutoLoad 名称。
/// </summary>
[Test]
public void Constructor_Should_Store_Name_When_Name_Is_Valid()
{
var attribute = new AutoLoadAttribute("GameServices");
Assert.That(attribute.Name, Is.EqualTo("GameServices"));
}
/// <summary>
/// 验证构造函数会拒绝空引用。
/// </summary>
[Test]
public void Constructor_Should_Throw_When_Name_Is_Null()
{
var exception = Assert.Throws<ArgumentNullException>(() => new AutoLoadAttribute(null!));
Assert.That(exception!.ParamName, Is.EqualTo("name"));
}
/// <summary>
/// 验证构造函数会拒绝空字符串与仅空白字符串。
/// </summary>
[TestCase("")]
[TestCase(" ")]
[TestCase("\t")]
public void Constructor_Should_Throw_When_Name_Is_Empty_Or_Whitespace(string name)
{
var exception = Assert.Throws<ArgumentException>(() => new AutoLoadAttribute(name));
Assert.Multiple(() =>
{
Assert.That(exception!.ParamName, Is.EqualTo("name"));
Assert.That(exception.Message, Does.Contain("empty or whitespace"));
});
}
}

View File

@ -0,0 +1,112 @@
using System.Collections.Immutable;
using System.IO;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
namespace GFramework.Godot.SourceGenerators.Tests.Core;
/// <summary>
/// 提供基于 <see cref="AdditionalText" /> 的源生成器测试驱动。
/// </summary>
public static class AdditionalTextGeneratorTestDriver
{
/// <summary>
/// 运行指定的增量生成器,并返回生成结果。
/// </summary>
/// <typeparam name="TGenerator">要运行的生成器类型。</typeparam>
/// <param name="source">输入源码。</param>
/// <param name="additionalFiles">AdditionalFiles 集合。</param>
/// <returns>生成器运行结果。</returns>
public static GeneratorDriverRunResult Run<TGenerator>(
string source,
params (string path, string content)[] additionalFiles)
where TGenerator : IIncrementalGenerator, new()
{
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var compilation = CSharpCompilation.Create(
typeof(TGenerator).Name + "Tests",
new[] { syntaxTree },
GetMetadataReferences(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var additionalTexts = additionalFiles
.Select(static item => (AdditionalText)new InMemoryAdditionalText(item.path, item.content))
.ToImmutableArray();
GeneratorDriver driver = CSharpGeneratorDriver.Create(
generators: new[] { new TGenerator().AsSourceGenerator() },
additionalTexts: additionalTexts,
parseOptions: (CSharpParseOptions)syntaxTree.Options);
driver = driver.RunGenerators(compilation);
return driver.GetRunResult();
}
/// <summary>
/// 将生成结果转换为文件名到文本的映射,便于断言。
/// </summary>
/// <param name="result">生成器运行结果。</param>
/// <returns>按 HintName 索引的生成源码。</returns>
public static IReadOnlyDictionary<string, string> ToGeneratedSourceMap(GeneratorDriverRunResult result)
{
return result.Results
.Single()
.GeneratedSources
.ToDictionary(
static item => item.HintName,
static item => NormalizeLineEndings(item.SourceText.ToString()),
StringComparer.Ordinal);
}
/// <summary>
/// 规范化换行,避免测试在不同平台上产生伪差异。
/// </summary>
/// <param name="content">待规范化文本。</param>
/// <returns>统一使用 LF (<c>\n</c>) 的内容。</returns>
public static string NormalizeLineEndings(string content)
{
return content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\r", "\n", StringComparison.Ordinal);
}
private static IEnumerable<MetadataReference> GetMetadataReferences()
{
var trustedPlatformAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
?? Array.Empty<string>();
return trustedPlatformAssemblies
.Select(static path => MetadataReference.CreateFromFile(path));
}
/// <summary>
/// 用于测试 AdditionalFiles 的内存实现。
/// </summary>
private sealed class InMemoryAdditionalText : AdditionalText
{
private readonly SourceText _text;
/// <summary>
/// 初始化一个内存 AdditionalText。
/// </summary>
/// <param name="path">虚拟文件路径。</param>
/// <param name="content">文件内容。</param>
public InMemoryAdditionalText(
string path,
string content)
{
Path = path;
_text = SourceText.From(content);
}
/// <inheritdoc />
public override string Path { get; }
/// <inheritdoc />
public override SourceText GetText(CancellationToken cancellationToken = default)
{
return _text;
}
}
}

View File

@ -0,0 +1,21 @@
namespace GFramework.Godot.SourceGenerators.Tests.Core;
/// <summary>
/// 验证 <see cref="AdditionalTextGeneratorTestDriver" /> 的文本规范化行为。
/// </summary>
[TestFixture]
public class AdditionalTextGeneratorTestDriverTests
{
/// <summary>
/// 验证不同平台换行最终都会被统一为 LF。
/// </summary>
[Test]
public void NormalizeLineEndings_Should_Convert_All_Line_Endings_To_Lf()
{
const string content = "line1\r\nline2\rline3\nline4";
var normalized = AdditionalTextGeneratorTestDriver.NormalizeLineEndings(content);
Assert.That(normalized, Is.EqualTo("line1\nline2\nline3\nline4"));
}
}

View File

@ -21,6 +21,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators.Abstractions\GFramework.Godot.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/> <ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,584 @@
using GFramework.Godot.SourceGenerators.Tests.Core;
namespace GFramework.Godot.SourceGenerators.Tests.Project;
/// <summary>
/// 验证基于 <c>project.godot</c> 的项目元数据生成行为。
/// </summary>
[TestFixture]
public class GodotProjectMetadataGeneratorTests
{
/// <summary>
/// 验证会根据 AutoLoad 与 Input Action 生成稳定的强类型入口。
/// </summary>
[Test]
public void Run_Should_Generate_AutoLoads_And_InputActions()
{
var source = CreateSource(
"""
namespace TestApp
{
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
[AutoLoad("GameServices")]
public partial class GameServices : Node
{
}
}
""",
includeAutoLoadAttribute: true);
const string projectFile = """
[autoload]
GameServices="*res://autoload/game_services.tscn"
AudioBus="*res://autoload/audio_bus.gd"
[input]
move_up={
"deadzone": 0.5
}
ui_cancel={
"deadzone": 0.5
}
""";
const string expectedAutoLoads = """
// <auto-generated />
#nullable enable
namespace GFramework.Godot.Generated;
/// <summary>
/// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。
/// </summary>
public static partial class AutoLoads
{
/// <summary>
/// 获取 AutoLoad <c>GameServices</c>。
/// </summary>
public static global::TestApp.GameServices GameServices => GetRequiredNode<global::TestApp.GameServices>("GameServices");
/// <summary>
/// 尝试获取 AutoLoad <c>GameServices</c>。
/// </summary>
public static bool TryGetGameServices(out global::TestApp.GameServices? value)
{
return TryGetNode("GameServices", out value);
}
/// <summary>
/// 获取 AutoLoad <c>AudioBus</c>。
/// </summary>
public static global::Godot.Node AudioBus => GetRequiredNode<global::Godot.Node>("AudioBus");
/// <summary>
/// 尝试获取 AutoLoad <c>AudioBus</c>。
/// </summary>
public static bool TryGetAudioBus(out global::Godot.Node? value)
{
return TryGetNode("AudioBus", out value);
}
/// <summary>
/// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。
/// </summary>
/// <typeparam name="TNode">节点类型。</typeparam>
/// <param name="autoLoadName">AutoLoad 名称。</param>
/// <returns>已解析的 AutoLoad 节点。</returns>
private static TNode GetRequiredNode<TNode>(string autoLoadName)
where TNode : global::Godot.Node
{
if (TryGetNode(autoLoadName, out TNode? value))
{
return value!;
}
throw new global::System.InvalidOperationException($"AutoLoad '{autoLoadName}' is not available on the active SceneTree root.");
}
/// <summary>
/// 尝试从当前 SceneTree 根节点解析 AutoLoad。
/// </summary>
/// <typeparam name="TNode">节点类型。</typeparam>
/// <param name="autoLoadName">AutoLoad 名称。</param>
/// <param name="value">解析到的节点实例。</param>
/// <returns>若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad则返回 <c>true</c>。</returns>
private static bool TryGetNode<TNode>(string autoLoadName, out TNode? value)
where TNode : global::Godot.Node
{
value = default;
if (global::Godot.Engine.GetMainLoop() is not global::Godot.SceneTree sceneTree)
{
return false;
}
var root = sceneTree.Root;
if (root is null)
{
return false;
}
value = root.GetNodeOrNull<TNode>($"/root/{autoLoadName}");
return value is not null;
}
}
""";
const string expectedInputActions = """
// <auto-generated />
#nullable enable
namespace GFramework.Godot.Generated;
/// <summary>
/// 提供 project.godot 中 Input Action 名称的强类型常量。
/// </summary>
public static partial class InputActions
{
/// <summary>
/// Input Action <c>move_up</c> 的稳定名称。
/// </summary>
public const string MoveUp = "move_up";
/// <summary>
/// Input Action <c>ui_cancel</c> 的稳定名称。
/// </summary>
public const string UiCancel = "ui_cancel";
}
""";
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
source,
("project.godot", projectFile));
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Is.EqualTo(AdditionalTextGeneratorTestDriver.NormalizeLineEndings(expectedAutoLoads)));
Assert.That(
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Is.EqualTo(AdditionalTextGeneratorTestDriver.NormalizeLineEndings(expectedInputActions)));
}
/// <summary>
/// 验证 <c>[AutoLoad]</c> 标记在非节点类型上时会产生诊断。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_AutoLoad_Type_Does_Not_Derive_From_Node()
{
const string source = """
using System;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class AutoLoadAttribute : Attribute
{
public AutoLoadAttribute(string name)
{
Name = name;
}
public string Name { get; }
}
}
namespace Godot
{
public class Node
{
}
}
namespace TestApp
{
using GFramework.Godot.SourceGenerators.Abstractions;
[AutoLoad("ConfigHub")]
public partial class ConfigHub
{
}
}
""";
const string projectFile = """
[autoload]
ConfigHub="*res://autoload/config_hub.tscn"
""";
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
source,
("project.godot", projectFile));
var diagnostic = result.Results.Single().Diagnostics.Single();
Assert.Multiple(() =>
{
Assert.That(diagnostic.Id, Is.EqualTo("GF_Godot_Project_001"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("ConfigHub"));
});
}
/// <summary>
/// 验证 Input Action 标识符冲突时会追加稳定后缀并给出警告。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_Input_Action_Identifiers_Collide()
{
const string source = """
namespace Godot
{
public class MainLoop
{
}
public class Node
{
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
public sealed class SceneTree : MainLoop
{
public Node? Root { get; set; }
}
public static class Engine
{
public static MainLoop? GetMainLoop() => default;
}
}
""";
const string projectFile = """
[input]
move_up={
}
move-up={
}
""";
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
source,
("project.godot", projectFile));
var diagnostics = result.Results.Single().Diagnostics;
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_004" }));
Assert.That(
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Does.Contain("public const string MoveUp = \"move_up\";"));
Assert.That(
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Does.Contain("public const string MoveUp_2 = \"move-up\";"));
}
/// <summary>
/// 验证多个显式映射指向同一个 AutoLoad 时会报告重复映射,并退化为 <c>Godot.Node</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Explicit_AutoLoad_Mappings_Are_Duplicated()
{
var result = RunGenerator(
CreateSource(
"""
namespace TestApp
{
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
[AutoLoad("AudioBus")]
public partial class PrimaryAudioBus : Node
{
}
[AutoLoad("AudioBus")]
public partial class SecondaryAudioBus : Node
{
}
}
""",
includeAutoLoadAttribute: true),
"""
[autoload]
AudioBus="*res://autoload/audio_bus.tscn"
""");
var diagnostics = result.Results.Single().Diagnostics;
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.Multiple(() =>
{
Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" }));
Assert.That(diagnostics.Single().GetMessage(), Does.Contain("PrimaryAudioBus"));
Assert.That(diagnostics.Single().GetMessage(), Does.Contain("SecondaryAudioBus"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode<global::Godot.Node>(\"AudioBus\");"));
});
}
/// <summary>
/// 验证不同命名空间下的同名节点类型会触发隐式映射冲突诊断,并退化为 <c>Godot.Node</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Implicit_AutoLoad_Mappings_Are_Ambiguous()
{
var result = RunGenerator(
CreateSource(
"""
namespace TestApp.Audio
{
using Godot;
public partial class AudioBus : Node
{
}
}
namespace TestApp.Debug
{
using Godot;
public partial class AudioBus : Node
{
}
}
"""),
"""
[autoload]
AudioBus="*res://autoload/audio_bus.tscn"
""");
var diagnostics = result.Results.Single().Diagnostics;
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.Multiple(() =>
{
Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_002" }));
Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Audio.AudioBus"));
Assert.That(diagnostics.Single().GetMessage(), Does.Contain("Debug.AudioBus"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode<global::Godot.Node>(\"AudioBus\");"));
});
}
/// <summary>
/// 验证 AutoLoad 标识符冲突时会追加稳定后缀并报告诊断。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_And_Append_Suffix_When_AutoLoad_Identifiers_Collide()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
"""
[autoload]
audio_bus="*res://autoload/audio_bus.tscn"
audio-bus="*res://autoload/audio_bus_debug.tscn"
""");
var diagnostics = result.Results.Single().Diagnostics;
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.Multiple(() =>
{
Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_003" }));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Contain("public static global::Godot.Node AudioBus => GetRequiredNode<global::Godot.Node>(\"audio_bus\");"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Contain("public static global::Godot.Node AudioBus_2 => GetRequiredNode<global::Godot.Node>(\"audio-bus\");"));
});
}
/// <summary>
/// 验证重复 AutoLoad 条目会报告诊断,并只保留第一条声明参与生成。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_AutoLoads()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
"""
[autoload]
GameServices="*res://autoload/game_services.tscn"
GameServices="*res://autoload/game_services_debug.tscn"
""");
var diagnostics = result.Results.Single().Diagnostics;
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.Multiple(() =>
{
Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_005" }));
Assert.That(diagnostics.Single().GetMessage(), Does.Contain("GameServices"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Contain("public static global::Godot.Node GameServices => GetRequiredNode<global::Godot.Node>(\"GameServices\");"));
Assert.That(
generatedSources["GFramework_Godot_Generated_AutoLoads.g.cs"],
Does.Not.Contain("GameServices_2"));
});
}
/// <summary>
/// 验证重复 Input Action 条目会报告诊断,并只保留第一条声明参与生成。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Project_File_Contains_Duplicate_Input_Actions()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
"""
[input]
move_up={
}
move_up={
}
""");
var diagnostics = result.Results.Single().Diagnostics;
var generatedSources = AdditionalTextGeneratorTestDriver.ToGeneratedSourceMap(result);
Assert.Multiple(() =>
{
Assert.That(diagnostics.Select(static item => item.Id), Is.EqualTo(new[] { "GF_Godot_Project_006" }));
Assert.That(diagnostics.Single().GetMessage(), Does.Contain("move_up"));
Assert.That(
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Does.Contain("public const string MoveUp = \"move_up\";"));
Assert.That(
generatedSources["GFramework_Godot_Generated_InputActions.g.cs"],
Does.Not.Contain("MoveUp_2"));
});
}
/// <summary>
/// 验证缺少 <c>project.godot</c> AdditionalText 时不会生成任何源码或诊断。
/// </summary>
[Test]
public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Missing()
{
var result = AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
CreateSource("namespace TestApp { }"));
var generatorResult = result.Results.Single();
Assert.Multiple(() =>
{
Assert.That(generatorResult.Diagnostics, Is.Empty);
Assert.That(generatorResult.GeneratedSources, Is.Empty);
});
}
/// <summary>
/// 验证空的 <c>project.godot</c> 内容不会生成任何源码或诊断。
/// </summary>
[Test]
public void Run_Should_Not_Generate_Sources_When_Project_File_Is_Empty()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
string.Empty);
var generatorResult = result.Results.Single();
Assert.Multiple(() =>
{
Assert.That(generatorResult.Diagnostics, Is.Empty);
Assert.That(generatorResult.GeneratedSources, Is.Empty);
});
}
/// <summary>
/// 验证只有空节的 <c>project.godot</c> 不会生成任何源码或诊断。
/// </summary>
[Test]
public void Run_Should_Not_Generate_Sources_When_Project_File_Has_Empty_Sections()
{
var result = RunGenerator(
CreateSource("namespace TestApp { }"),
"""
[autoload]
[input]
""");
var generatorResult = result.Results.Single();
Assert.Multiple(() =>
{
Assert.That(generatorResult.Diagnostics, Is.Empty);
Assert.That(generatorResult.GeneratedSources, Is.Empty);
});
}
private static GeneratorDriverRunResult RunGenerator(
string source,
string projectFile)
{
return AdditionalTextGeneratorTestDriver.Run<GodotProjectMetadataGenerator>(
source,
("project.godot", projectFile));
}
private static string CreateSource(
string applicationSource,
bool includeAutoLoadAttribute = false)
{
var autoLoadAttributeSource = includeAutoLoadAttribute
? """
using System;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class AutoLoadAttribute : Attribute
{
public AutoLoadAttribute(string name)
{
Name = name;
}
public string Name { get; }
}
}
"""
: string.Empty;
return autoLoadAttributeSource + """
namespace Godot
{
public class MainLoop
{
}
public class Node
{
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
public sealed class SceneTree : MainLoop
{
public Node? Root { get; set; }
}
public static class Engine
{
public static MainLoop? GetMainLoop() => default;
}
}
""" + applicationSource;
}
}

View File

@ -33,3 +33,9 @@
GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_006 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_007 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_007 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_AutoExport_008 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics GF_AutoExport_008 | GFramework.Godot.SourceGenerators.Registration | Error | AutoRegisterExportedCollectionsDiagnostics
GF_Godot_Project_001 | GFramework.Godot | Error | GodotProjectDiagnostics
GF_Godot_Project_002 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_003 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_004 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_005 | GFramework.Godot | Warning | GodotProjectDiagnostics
GF_Godot_Project_006 | GFramework.Godot | Warning | GodotProjectDiagnostics

View File

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

View File

@ -3,14 +3,38 @@
<!-- This file is automatically generated by the NuGet package --> <!-- This file is automatically generated by the NuGet package -->
<!-- It ensures that the source generators are properly registered during build --> <!-- It ensures that the source generators are properly registered during build -->
<PropertyGroup>
<!--
默认收集消费者项目根目录下的 project.godot
让 Godot 项目元数据生成能力在常规 Godot C# 工程里开箱即用。
-->
<GFrameworkGodotProjectFile Condition="'$(GFrameworkGodotProjectFile)' == ''">project.godot</GFrameworkGodotProjectFile>
<!--
当前生成器按文件名识别 project.godot因此允许调整相对路径
但不支持把目标文件重命名为其他文件名。
-->
<_GFrameworkGodotProjectFileName>$([System.IO.Path]::GetFileName('$(GFrameworkGodotProjectFile)'))</_GFrameworkGodotProjectFileName>
<_GFrameworkGodotProjectFileNameLower>$([System.String]::Copy('$(_GFrameworkGodotProjectFileName)').ToLowerInvariant())</_GFrameworkGodotProjectFileNameLower>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.Abstractions.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.Godot.SourceGenerators.Abstractions.dll"/>
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/> <Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/>
</ItemGroup> </ItemGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)')">
<AdditionalFiles Include="$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)"/>
</ItemGroup>
<Target Name="ValidateGFrameworkGodotProjectFileName"
BeforeTargets="CoreCompile"
Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkGodotProjectFile)') and '$(_GFrameworkGodotProjectFileNameLower)' != 'project.godot'">
<Warning Text="GFrameworkGodotProjectFile can change the relative path, but the file name must remain 'project.godot'; otherwise GodotProjectMetadataGenerator will ignore the file."/>
</Target>
<!-- Ensure the analyzers are loaded --> <!-- Ensure the analyzers are loaded -->
<Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile"> <Target Name="EnsureGFrameworkGodotAnalyzers" BeforeTargets="CoreCompile">
<Message Text="Loading GFramework.Godot source generators" Importance="high"/> <Message Text="Loading GFramework.Godot source generators" Importance="high"/>
</Target> </Target>
</Project> </Project>

View File

@ -0,0 +1,782 @@
using System.IO;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Extensions;
using Microsoft.CodeAnalysis.Text;
namespace GFramework.Godot.SourceGenerators;
/// <summary>
/// 读取 <c>project.godot</c> 项目元数据,并生成 AutoLoad 与 Input Action 的强类型访问入口。
/// </summary>
/// <remarks>
/// 该生成器把 Godot 项目层面的事实模型暴露为稳定的编译期 API
/// <list type="bullet">
/// <item>
/// <description>从 <c>[autoload]</c> 段生成统一访问入口,并在可唯一解析到 C# 节点类型时生成强类型属性。</description>
/// </item>
/// <item>
/// <description>从 <c>[input]</c> 段生成输入动作常量,避免手写魔法字符串。</description>
/// </item>
/// </list>
/// 对于类型映射冲突或标识符冲突,该生成器会优先给出诊断并退化为可工作的稳定输出,而不是静默生成不确定代码。
/// </remarks>
[Generator]
public sealed class GodotProjectMetadataGenerator : IIncrementalGenerator
{
private const string ProjectFileName = "project.godot";
private const string GeneratedNamespace = $"{PathContests.GodotNamespace}.Generated";
private const string AutoLoadAttributeMetadataName =
$"{PathContests.GodotSourceGeneratorsAbstractionsPath}.AutoLoadAttribute";
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var projectFiles = context.AdditionalTextsProvider
.Where(static file =>
string.Equals(Path.GetFileName(file.Path), ProjectFileName, StringComparison.OrdinalIgnoreCase))
.Select(static (file, cancellationToken) => ParseProjectFile(file, cancellationToken))
.Collect();
var typeCandidates = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) => node is ClassDeclarationSyntax,
static (syntaxContext, _) => TransformTypeCandidate(syntaxContext))
.Where(static candidate => candidate is not null)
.Collect();
var generationInput = context.CompilationProvider.Combine(projectFiles).Combine(typeCandidates);
context.RegisterSourceOutput(generationInput, static (productionContext, input) =>
Execute(productionContext, input.Left.Left, input.Left.Right, input.Right));
}
private static GodotTypeCandidate? TransformTypeCandidate(GeneratorSyntaxContext context)
{
if (context.Node is not ClassDeclarationSyntax classDeclaration)
return null;
if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
return null;
return new GodotTypeCandidate(classDeclaration, typeSymbol);
}
private static void Execute(
SourceProductionContext context,
Compilation compilation,
ImmutableArray<ProjectMetadataParseResult> projectFileResults,
ImmutableArray<GodotTypeCandidate?> typeCandidates)
{
if (projectFileResults.IsDefaultOrEmpty)
return;
var projectResult = projectFileResults
.OrderBy(static result => result.FilePath, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
foreach (var diagnostic in projectResult.Diagnostics)
{
context.ReportDiagnostic(diagnostic);
}
var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node");
if (godotNodeSymbol is null)
return;
var autoLoadAttributeSymbol = compilation.GetTypeByMetadataName(AutoLoadAttributeMetadataName);
var concreteCandidates = typeCandidates
.Where(static candidate => candidate is not null)
.Select(static candidate => candidate!)
.ToArray();
var typedMappings = BuildTypedMappings(
context,
projectResult,
concreteCandidates,
autoLoadAttributeSymbol,
godotNodeSymbol);
if (projectResult.AutoLoads.Length > 0)
{
var autoLoadMembers = CreateAutoLoadMembers(context, projectResult, typedMappings);
context.AddSource(
"GFramework_Godot_Generated_AutoLoads.g.cs",
SourceText.From(GenerateAutoLoadSource(autoLoadMembers), Encoding.UTF8));
}
if (projectResult.InputActions.Length > 0)
{
var inputActionMembers = CreateInputActionMembers(context, projectResult);
context.AddSource(
"GFramework_Godot_Generated_InputActions.g.cs",
SourceText.From(GenerateInputActionsSource(inputActionMembers), Encoding.UTF8));
}
}
private static Dictionary<string, INamedTypeSymbol> BuildTypedMappings(
SourceProductionContext context,
ProjectMetadataParseResult projectResult,
IReadOnlyList<GodotTypeCandidate> typeCandidates,
INamedTypeSymbol? autoLoadAttributeSymbol,
INamedTypeSymbol godotNodeSymbol)
{
var projectAutoLoadNames = new HashSet<string>(
projectResult.AutoLoads.Select(static entry => entry.Name),
StringComparer.Ordinal);
var explicitMappings = new Dictionary<string, List<INamedTypeSymbol>>(StringComparer.Ordinal);
var implicitCandidates = new Dictionary<string, List<INamedTypeSymbol>>(StringComparer.Ordinal);
foreach (var candidate in typeCandidates)
{
var typeSymbol = candidate.TypeSymbol;
var derivesFromNode = typeSymbol.IsAssignableTo(godotNodeSymbol);
if (derivesFromNode)
{
if (!implicitCandidates.TryGetValue(typeSymbol.Name, out var implicitList))
{
implicitList = new List<INamedTypeSymbol>();
implicitCandidates.Add(typeSymbol.Name, implicitList);
}
implicitList.Add(typeSymbol);
}
if (autoLoadAttributeSymbol is null)
continue;
var autoLoadAttribute = typeSymbol.GetAttributes()
.FirstOrDefault(attribute =>
SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, autoLoadAttributeSymbol));
if (autoLoadAttribute is null)
continue;
if (!derivesFromNode)
{
context.ReportDiagnostic(Diagnostic.Create(
GodotProjectDiagnostics.AutoLoadTypeMustDeriveFromNode,
candidate.ClassDeclaration.Identifier.GetLocation(),
typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
continue;
}
if (!TryGetAutoLoadName(autoLoadAttribute, out var autoLoadName))
continue;
if (!projectAutoLoadNames.Contains(autoLoadName))
continue;
if (!explicitMappings.TryGetValue(autoLoadName, out var explicitList))
{
explicitList = new List<INamedTypeSymbol>();
explicitMappings.Add(autoLoadName, explicitList);
}
explicitList.Add(typeSymbol);
}
var resolvedMappings = new Dictionary<string, INamedTypeSymbol>(StringComparer.Ordinal);
foreach (var projectAutoLoadName in projectAutoLoadNames.OrderBy(static name => name, StringComparer.Ordinal))
{
// 显式 [AutoLoad] 映射优先于按类型名推断,因为它代表了用户给出的稳定契约。
if (explicitMappings.TryGetValue(projectAutoLoadName, out var explicitList))
{
var distinctExplicitTypes = DistinctTypeSymbols(explicitList);
if (distinctExplicitTypes.Length == 1)
{
resolvedMappings.Add(projectAutoLoadName, distinctExplicitTypes[0]);
}
else if (distinctExplicitTypes.Length > 1)
{
ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctExplicitTypes);
}
continue;
}
if (!implicitCandidates.TryGetValue(projectAutoLoadName, out var implicitList))
continue;
var distinctImplicitTypes = DistinctTypeSymbols(implicitList);
if (distinctImplicitTypes.Length == 1)
{
resolvedMappings.Add(projectAutoLoadName, distinctImplicitTypes[0]);
}
else if (distinctImplicitTypes.Length > 1)
{
// 隐式推断只在唯一命中时才安全;出现同名候选时改为诊断并退化成 Godot.Node。
ReportDuplicateAutoLoadMapping(context, projectAutoLoadName, distinctImplicitTypes);
}
}
return resolvedMappings;
}
private static bool TryGetAutoLoadName(AttributeData attribute, out string autoLoadName)
{
autoLoadName = string.Empty;
if (attribute.ConstructorArguments.Length != 1 ||
attribute.ConstructorArguments[0].Value is not string rawName ||
string.IsNullOrWhiteSpace(rawName))
{
return false;
}
autoLoadName = rawName;
return true;
}
private static void ReportDuplicateAutoLoadMapping(
SourceProductionContext context,
string autoLoadName,
IEnumerable<INamedTypeSymbol> duplicateTypes)
{
context.ReportDiagnostic(Diagnostic.Create(
GodotProjectDiagnostics.DuplicateAutoLoadMapping,
Location.None,
autoLoadName,
string.Join(
", ",
duplicateTypes.Select(static type =>
type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)))));
}
private static IReadOnlyList<GeneratedAutoLoadMember> CreateAutoLoadMembers(
SourceProductionContext context,
ProjectMetadataParseResult projectResult,
IReadOnlyDictionary<string, INamedTypeSymbol> typedMappings)
{
var identifierCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var members = new List<GeneratedAutoLoadMember>(projectResult.AutoLoads.Length);
foreach (var entry in projectResult.AutoLoads)
{
var baseIdentifier = SanitizeIdentifier(entry.Name, "AutoLoad");
var identifier = ResolveUniqueIdentifier(
context,
identifierCounts,
entry.Name,
baseIdentifier,
GodotProjectDiagnostics.AutoLoadIdentifierCollision);
var typeName = typedMappings.TryGetValue(entry.Name, out var typeSymbol)
? typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
: "global::Godot.Node";
members.Add(new GeneratedAutoLoadMember(entry.Name, identifier, typeName, entry.ResourcePath));
}
return members;
}
private static IReadOnlyList<GeneratedInputActionMember> CreateInputActionMembers(
SourceProductionContext context,
ProjectMetadataParseResult projectResult)
{
var identifierCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var members = new List<GeneratedInputActionMember>(projectResult.InputActions.Length);
foreach (var actionName in projectResult.InputActions)
{
var baseIdentifier = SanitizeIdentifier(actionName, "Action");
var identifier = ResolveUniqueIdentifier(
context,
identifierCounts,
actionName,
baseIdentifier,
GodotProjectDiagnostics.InputActionIdentifierCollision);
members.Add(new GeneratedInputActionMember(actionName, identifier));
}
return members;
}
private static string ResolveUniqueIdentifier(
SourceProductionContext context,
IDictionary<string, int> identifierCounts,
string originalName,
string baseIdentifier,
DiagnosticDescriptor collisionDiagnostic)
{
if (!identifierCounts.TryGetValue(baseIdentifier, out var count))
{
identifierCounts.Add(baseIdentifier, 1);
return baseIdentifier;
}
count++;
identifierCounts[baseIdentifier] = count;
context.ReportDiagnostic(Diagnostic.Create(
collisionDiagnostic,
Location.None,
originalName,
baseIdentifier));
return $"{baseIdentifier}_{count}";
}
private static INamedTypeSymbol[] DistinctTypeSymbols(IEnumerable<INamedTypeSymbol> types)
{
var results = new List<INamedTypeSymbol>();
foreach (var type in types)
{
if (results.Any(existing => SymbolEqualityComparer.Default.Equals(existing, type)))
continue;
results.Add(type);
}
return results.ToArray();
}
private static string SanitizeIdentifier(
string rawName,
string fallbackPrefix)
{
var tokens = new List<string>();
var tokenBuilder = new StringBuilder();
foreach (var character in rawName)
{
if (char.IsLetterOrDigit(character))
{
tokenBuilder.Append(character);
continue;
}
FlushToken(tokens, tokenBuilder);
}
FlushToken(tokens, tokenBuilder);
var identifier = tokens.Count == 0
? fallbackPrefix
: string.Concat(tokens);
if (string.IsNullOrWhiteSpace(identifier))
identifier = fallbackPrefix;
if (!SyntaxFacts.IsIdentifierStartCharacter(identifier[0]))
identifier = fallbackPrefix + identifier;
return SyntaxFacts.GetKeywordKind(identifier) != SyntaxKind.None
? identifier + "Value"
: identifier;
}
private static void FlushToken(
ICollection<string> tokens,
StringBuilder tokenBuilder)
{
if (tokenBuilder.Length == 0)
return;
var token = tokenBuilder.ToString();
tokenBuilder.Clear();
if (token.Length == 1)
{
tokens.Add(token.ToUpperInvariant());
return;
}
tokens.Add(char.ToUpperInvariant(token[0]) + token.Substring(1));
}
private static string GenerateAutoLoadSource(IReadOnlyList<GeneratedAutoLoadMember> members)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {GeneratedNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine("/// 提供 project.godot 中 AutoLoad 单例的强类型访问入口。");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static partial class AutoLoads");
builder.AppendLine("{");
foreach (var member in members)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// 获取 AutoLoad <c>{member.AutoLoadName}</c>。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public static {member.TypeName} {member.Identifier} => GetRequiredNode<{member.TypeName}>({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)});");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// 尝试获取 AutoLoad <c>{member.AutoLoadName}</c>。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public static bool TryGet{member.Identifier}(out {member.TypeName}? value)");
builder.AppendLine(" {");
builder.AppendLine(
$" return TryGetNode({SymbolDisplay.FormatLiteral(member.AutoLoadName, true)}, out value);");
builder.AppendLine(" }");
builder.AppendLine();
}
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// 获取一个必填的 AutoLoad 节点;缺失时抛出异常。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <typeparam name=\"TNode\">节点类型。</typeparam>");
builder.AppendLine(" /// <param name=\"autoLoadName\">AutoLoad 名称。</param>");
builder.AppendLine(" /// <returns>已解析的 AutoLoad 节点。</returns>");
builder.AppendLine(" private static TNode GetRequiredNode<TNode>(string autoLoadName)");
builder.AppendLine(" where TNode : global::Godot.Node");
builder.AppendLine(" {");
builder.AppendLine(" if (TryGetNode(autoLoadName, out TNode? value))");
builder.AppendLine(" {");
builder.AppendLine(" return value!;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
" throw new global::System.InvalidOperationException($\"AutoLoad '{autoLoadName}' is not available on the active SceneTree root.\");");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// 尝试从当前 SceneTree 根节点解析 AutoLoad。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <typeparam name=\"TNode\">节点类型。</typeparam>");
builder.AppendLine(" /// <param name=\"autoLoadName\">AutoLoad 名称。</param>");
builder.AppendLine(" /// <param name=\"value\">解析到的节点实例。</param>");
builder.AppendLine(" /// <returns>若当前进程存在 SceneTree 且根节点中能解析到该 AutoLoad则返回 <c>true</c>。</returns>");
builder.AppendLine(" private static bool TryGetNode<TNode>(string autoLoadName, out TNode? value)");
builder.AppendLine(" where TNode : global::Godot.Node");
builder.AppendLine(" {");
builder.AppendLine(" value = default;");
builder.AppendLine();
builder.AppendLine(" if (global::Godot.Engine.GetMainLoop() is not global::Godot.SceneTree sceneTree)");
builder.AppendLine(" {");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" var root = sceneTree.Root;");
builder.AppendLine(" if (root is null)");
builder.AppendLine(" {");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" value = root.GetNodeOrNull<TNode>($\"/root/{autoLoadName}\");");
builder.AppendLine(" return value is not null;");
builder.AppendLine(" }");
builder.AppendLine("}");
return builder.ToString();
}
private static string GenerateInputActionsSource(IReadOnlyList<GeneratedInputActionMember> members)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {GeneratedNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine("/// 提供 project.godot 中 Input Action 名称的强类型常量。");
builder.AppendLine("/// </summary>");
builder.AppendLine("public static partial class InputActions");
builder.AppendLine("{");
foreach (var member in members)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// Input Action <c>{member.ActionName}</c> 的稳定名称。");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string {member.Identifier} = {SymbolDisplay.FormatLiteral(member.ActionName, true)};");
builder.AppendLine();
}
builder.AppendLine("}");
return builder.ToString();
}
private static ProjectMetadataParseResult ParseProjectFile(
AdditionalText file,
CancellationToken cancellationToken)
{
var text = file.GetText(cancellationToken);
if (text is null)
{
return new ProjectMetadataParseResult(
file.Path,
ImmutableArray<ProjectAutoLoadEntry>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<Diagnostic>.Empty);
}
var currentSection = string.Empty;
var autoLoads = new List<ProjectAutoLoadEntry>();
var inputActions = new List<string>();
var diagnostics = new List<Diagnostic>();
var seenAutoLoads = new HashSet<string>(StringComparer.Ordinal);
var seenInputActions = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in text.Lines)
{
var content = line.ToString().Trim();
if (string.IsNullOrWhiteSpace(content) || content.StartsWith(";", StringComparison.Ordinal))
continue;
if (content.StartsWith("[", StringComparison.Ordinal) && content.EndsWith("]", StringComparison.Ordinal))
{
currentSection = content.Substring(1, content.Length - 2).Trim();
continue;
}
if (!TryParseAssignment(content, out var key, out var value))
continue;
if (string.Equals(currentSection, "autoload", StringComparison.OrdinalIgnoreCase))
{
if (!seenAutoLoads.Add(key))
{
diagnostics.Add(Diagnostic.Create(
GodotProjectDiagnostics.DuplicateAutoLoadEntry,
CreateFileLocation(file.Path),
key));
continue;
}
autoLoads.Add(new ProjectAutoLoadEntry(
key,
NormalizeProjectPath(value)));
continue;
}
if (string.Equals(currentSection, "input", StringComparison.OrdinalIgnoreCase))
{
if (!seenInputActions.Add(key))
{
diagnostics.Add(Diagnostic.Create(
GodotProjectDiagnostics.DuplicateInputActionEntry,
CreateFileLocation(file.Path),
key));
continue;
}
inputActions.Add(key);
}
}
return new ProjectMetadataParseResult(
file.Path,
autoLoads.ToImmutableArray(),
inputActions.ToImmutableArray(),
diagnostics.ToImmutableArray());
}
private static string NormalizeProjectPath(string rawValue)
{
var trimmed = rawValue.Trim();
if (trimmed.Length >= 2 &&
trimmed[0] == '"' &&
trimmed[trimmed.Length - 1] == '"')
{
trimmed = trimmed.Substring(1, trimmed.Length - 2);
}
return trimmed.TrimStart('*');
}
private static bool TryParseAssignment(
string line,
out string key,
out string value)
{
key = string.Empty;
value = string.Empty;
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
return false;
key = line.Substring(0, separatorIndex).Trim();
if (string.IsNullOrWhiteSpace(key))
return false;
value = line.Substring(separatorIndex + 1).Trim();
return true;
}
private static Location CreateFileLocation(string filePath)
{
return Location.Create(filePath, TextSpan.FromBounds(0, 0),
new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 0)));
}
private sealed class GodotTypeCandidate
{
/// <summary>
/// 创建一个类型候选。
/// </summary>
/// <param name="classDeclaration">类型语法节点。</param>
/// <param name="typeSymbol">类型符号。</param>
public GodotTypeCandidate(
ClassDeclarationSyntax classDeclaration,
INamedTypeSymbol typeSymbol)
{
ClassDeclaration = classDeclaration;
TypeSymbol = typeSymbol;
}
/// <summary>
/// 获取类型声明语法。
/// </summary>
public ClassDeclarationSyntax ClassDeclaration { get; }
/// <summary>
/// 获取类型符号。
/// </summary>
public INamedTypeSymbol TypeSymbol { get; }
}
private sealed class ProjectAutoLoadEntry
{
/// <summary>
/// 初始化 AutoLoad 条目。
/// </summary>
/// <param name="name">AutoLoad 名称。</param>
/// <param name="resourcePath">资源路径。</param>
public ProjectAutoLoadEntry(
string name,
string resourcePath)
{
Name = name;
ResourcePath = resourcePath;
}
/// <summary>
/// 获取 AutoLoad 名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取资源路径。
/// </summary>
public string ResourcePath { get; }
}
private sealed class GeneratedAutoLoadMember
{
/// <summary>
/// 初始化一个生成后的 AutoLoad 成员描述。
/// </summary>
/// <param name="autoLoadName">原始 AutoLoad 名称。</param>
/// <param name="identifier">生成后的标识符。</param>
/// <param name="typeName">类型名。</param>
/// <param name="resourcePath">资源路径。</param>
public GeneratedAutoLoadMember(
string autoLoadName,
string identifier,
string typeName,
string resourcePath)
{
AutoLoadName = autoLoadName;
Identifier = identifier;
TypeName = typeName;
ResourcePath = resourcePath;
}
/// <summary>
/// 获取原始 AutoLoad 名称。
/// </summary>
public string AutoLoadName { get; }
/// <summary>
/// 获取生成后的标识符。
/// </summary>
public string Identifier { get; }
/// <summary>
/// 获取类型名。
/// </summary>
public string TypeName { get; }
/// <summary>
/// 获取资源路径。
/// </summary>
public string ResourcePath { get; }
}
private sealed class GeneratedInputActionMember
{
/// <summary>
/// 初始化一个生成后的 Input Action 成员描述。
/// </summary>
/// <param name="actionName">原始动作名。</param>
/// <param name="identifier">生成后的标识符。</param>
public GeneratedInputActionMember(
string actionName,
string identifier)
{
ActionName = actionName;
Identifier = identifier;
}
/// <summary>
/// 获取原始动作名。
/// </summary>
public string ActionName { get; }
/// <summary>
/// 获取生成后的标识符。
/// </summary>
public string Identifier { get; }
}
private sealed class ProjectMetadataParseResult
{
/// <summary>
/// 初始化一个项目元数据解析结果。
/// </summary>
/// <param name="filePath">项目文件路径。</param>
/// <param name="autoLoads">AutoLoad 条目。</param>
/// <param name="inputActions">Input Action 条目。</param>
/// <param name="diagnostics">解析过程中的诊断。</param>
public ProjectMetadataParseResult(
string filePath,
ImmutableArray<ProjectAutoLoadEntry> autoLoads,
ImmutableArray<string> inputActions,
ImmutableArray<Diagnostic> diagnostics)
{
FilePath = filePath;
AutoLoads = autoLoads;
InputActions = inputActions;
Diagnostics = diagnostics;
}
/// <summary>
/// 获取项目文件路径。
/// </summary>
public string FilePath { get; }
/// <summary>
/// 获取 AutoLoad 条目。
/// </summary>
public ImmutableArray<ProjectAutoLoadEntry> AutoLoads { get; }
/// <summary>
/// 获取 Input Action 条目。
/// </summary>
public ImmutableArray<string> InputActions { get; }
/// <summary>
/// 获取解析过程中的诊断。
/// </summary>
public ImmutableArray<Diagnostic> Diagnostics { get; }
}
}

View File

@ -6,6 +6,7 @@
- 与 Godot 场景相关的编译期生成能力 - 与 Godot 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现 - 基于 Roslyn 的增量生成器实现
- `project.godot` 项目元数据生成,产出 AutoLoad 与 Input Action 的强类型访问入口
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码 - `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
- `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码 - `[BindNodeSignal]` 方法绑定,减少 `_Ready()` / `_ExitTree()` 中重复的事件订阅样板代码
@ -13,6 +14,96 @@
- 仅在 Godot + C# 项目中启用 - 仅在 Godot + C# 项目中启用
- 非 Godot 项目可只使用 GFramework.SourceGenerators - 非 Godot 项目可只使用 GFramework.SourceGenerators
- 当项目通过 NuGet 包引用本模块时,根目录下的 `project.godot` 会被自动加入 `AdditionalFiles`
- 当项目通过 `ProjectReference(OutputItemType=Analyzer)` 直接引用生成器时,需要手动把 `project.godot` 加入
`AdditionalFiles`
## project.godot 集成
默认情况下,生成器会读取 Godot 项目根目录下的 `project.godot`,并生成:
- `GFramework.Godot.Generated.AutoLoads`
- `GFramework.Godot.Generated.InputActions`
如果你需要覆盖默认项目文件路径,可以在 MSBuild 中设置:
- 路径可以调整到项目根目录下的其他位置
- 文件名必须仍然是 `project.godot`,否则生成器会发出警告并忽略该文件
```xml
<PropertyGroup>
<GFrameworkGodotProjectFile>Config/project.godot</GFrameworkGodotProjectFile>
</PropertyGroup>
```
如果你在仓库内通过 analyzer 形式直接引用本项目,则需要显式配置:
```xml
<ItemGroup>
<AdditionalFiles Include="project.godot" />
</ItemGroup>
```
## AutoLoad 强类型访问
当某个 AutoLoad 无法仅靠类型名唯一推断到 C# 节点类型时,可以使用 `[AutoLoad]` 显式声明映射:
```csharp
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
[AutoLoad("GameServices")]
public partial class GameServices : Node
{
}
```
对应 `project.godot`
```ini
[autoload]
GameServices="*res://autoload/game_services.tscn"
AudioBus="*res://autoload/audio_bus.gd"
```
生成器会产出统一入口:
```csharp
using GFramework.Godot.Generated;
var gameServices = AutoLoads.GameServices;
if (AutoLoads.TryGetAudioBus(out var audioBus))
{
}
```
- 显式 `[AutoLoad]` 映射优先于隐式类型名推断
- 若同名映射冲突,生成器会给出诊断并退化为 `Godot.Node` 访问
- 若无法映射到 C# 节点类型,仍会生成可用的 `Godot.Node` 访问器
## Input Action 常量生成
`project.godot``[input]` 段会自动生成稳定常量,避免手写字符串:
```ini
[input]
move_up={
}
ui_cancel={
}
```
```csharp
using GFramework.Godot.Generated;
if (Input.IsActionJustPressed(InputActions.MoveUp))
{
}
```
- 动作名会转换为可补全的 C# 标识符,例如 `move_up -> MoveUp`
- 当多个动作名映射到同一标识符时,会追加稳定后缀并给出警告
## GetNode 用法 ## GetNode 用法

View File

@ -122,9 +122,11 @@ GFramework.sln
欢迎提交 Issue 与 Pull Request 欢迎提交 Issue 与 Pull Request
1. Fork 本仓库并创建特性分支 1. 提交 Issue 时请优先选择对应模板:`Bug Report / 缺陷报告``Feature Request / 功能建议``Documentation / 文档改进``Question / 使用咨询`
2. 补充必要的测试或文档更新 2. 提交前先搜索现有 Issues并阅读相关 README、文档或排障页面
3. 提交 PR描述变更背景、方案与验证结果 3. Fork 本仓库并创建特性分支
4. 补充必要的测试或文档更新
5. 提交 PR描述变更背景、方案与验证结果
## 许可证 ## 许可证

View File

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

View File

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

View File

@ -34,40 +34,35 @@ GFramework 是一个开源的游戏开发框架,我们欢迎所有形式的贡
### 报告问题 ### 报告问题
发现 Bug 或有功能建议时,请通过 GitHub Issues 提交 发现 Bug、有功能建议、文档问题或使用疑问时,请通过 GitHub Issues 提交,并优先使用仓库提供的 Issue Forms
1. **搜索现有 Issue**:避免重复提交 - **Bug Report / 缺陷报告**:用于可复现缺陷、异常行为、回归问题
2. **使用清晰的标题**:简洁描述问题 - **Feature Request / 功能建议**用于新能力、API 改进、工作流增强
3. **提供详细信息** - **Documentation / 文档改进**:用于文档缺失、过期、错误、示例不足
- Bug 报告:复现步骤、预期行为、实际行为、环境信息 - **Question / 使用咨询**:用于与 GFramework 行为、API、接入方式直接相关的问题
- 功能建议:使用场景、预期效果、可能的实现方案
**Bug 报告模板** 提交前请遵循以下原则
```markdown 1. **先搜索现有 Issue**:避免重复提交
**描述** 2. **先查阅文档**:优先阅读相关 README、`docs/` 页面和排障内容
简要描述 Bug 3. **选择最合适的模板**:让问题更容易分诊和跟进
4. **补齐最小必要信息**
- Bug复现步骤、预期行为、实际行为、环境信息、最小复现或日志
- 功能建议:问题背景、使用场景、建议方案、替代方案、兼容性影响
- 文档问题:文档路径、问题类型、当前问题、期望改进
- 使用咨询:目标、当前尝试、具体问题、相关环境
**复现步骤** ### Issue 分诊建议
1. 执行操作 A
2. 执行操作 B
3. 观察到错误
**预期行为** 为便于维护者快速处理,建议按以下方式理解 Issue 类型:
应该发生什么
**实际行为** - **bug**:当前行为与契约、文档或既有能力不一致,且可以描述具体异常或错误结果
实际发生了什么 - **enhancement**:现有行为合理但不足,希望新增能力或改进 API / 工作流
- **documentation**:主要问题在文档内容,而不是运行时行为
- **question**:主要诉求是澄清如何使用、如何设计或如何接入
- **needs-repro / needs-info**:当缺少复现仓库、版本信息或关键上下文时,维护者可在分诊时补充使用
**环境信息** > 仓库中的标签集合可能会继续演进,但以上分类建议应保持稳定。
- GFramework 版本:
- .NET 版本:
- 操作系统:
- Godot 版本(如适用):
**附加信息**
日志、截图等
```
### 提交 Pull Request ### 提交 Pull Request
@ -830,7 +825,7 @@ npm run docs:build
如果你在贡献过程中遇到问题: 如果你在贡献过程中遇到问题:
- **GitHub Issues**提问或报告问题 - **GitHub Issues**使用对应模板提问、报告问题或提出建议
- **GitHub Discussions**:参与讨论 - **GitHub Discussions**:参与讨论
- **代码注释**:查看现有代码的注释和文档 - **代码注释**:查看现有代码的注释和文档

View File

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

View File

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

View File

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