跳到正文
Hacker News· liminal-dev·· 8 天前AI 评分22

GATE:Godot Engine 的 GDScript 超集

GATE – A Godot Engine GDScript Superset

AI 导读

GATE 是面向 Godot Engine 的 GDScript 超集,在编译为普通 GDScript 的同时加入类型前置声明、空值安全、结构体、接口与命名空间、泛型、联合类型等特性,采用 MIT 许可,要求 Godot 4.7+。合法的 GDScript 即为合法的 GATE,把 .gd 改名为 .gate 即可无错编译并保持行为一致,重新编译其自身输出可得到相同字节。

正文 · AI 翻译

译文尚不完整,完整内容请切换到原文。

GATE 是 GDScript 的超集,适用于 Godot Engine,它添加了额外功能,同时编译为纯 GDScript。

需要 Godot 4.7+。MIT 许可。

有效的 GDScript 就是有效的 GATE。将 .gd 重命名为 .gate,它就能无错误地编译、加载,并且行为完全一致。重新编译 GATE 自身的输出会得到相同的字节。

有问题,或者想展示你构建的东西?Discord。错误和功能请求请提交到 issues。

目录

完整语法见 SYNTAX.md。

安装

将 addons/gate/ 复制到你的项目中,然后在项目设置 -> 插件下启用 GATE。该插件会监视文件系统,并在任何内容发生变化时,将每个 .gate 文件编译为旁边的 .gd。

转换现有脚本

将 foo.gd 重命名为 foo.gate,并将 foo.gd.uid 留在原处。Godot 通过 uid 引用脚本,因此删除它会使每个 ext_resource 回退到文本路径并发出警告。GATE 会重新生成 foo.gd,并且从不触碰 uid。

在重命名脚本后首次打开项目时,输出面板中会出现大量错误。Godot 会在 GATE 写入其 .gd 文件之前加载你打开的场景所使用的脚本,因此每个脚本都会解析失败一次,而 GATE 会为每个尚无法检查的文件报告一次:Pixelorama 约 1,600 行,GodSVG 约 200 行。这没有问题。关闭编辑器并再次打开;第二次会话就很安静。

在大型项目中,首次打开会很慢,因为 GATE 要构建每个文件,而 Godot 要注册它写入的类。Pixelorama,212 个文件,在一台机器上测量:首次约 105 秒,第二次约 30 秒;此后,在未更改的情况下打开约需 14 秒。每个文件只编译一次,仅在首次打开时:在 Godot 注册类之前 GATE 无法检查的文件会被记为已构建但未检查,因此下一次会话会检查它而无需再次构建。

不要动 script_templates/。Godot 会在该文件夹中保留一个 .gdignore,而 GATE 不会编译 .gdignore 下的任何内容,因此重命名为 .gate 的模板永远不会被变回 .gd,并且会直接从 Script Create 对话框中消失。

用法

编写 .gate,得到 .gd。输出是普通的 GDScript,带有一个标明其来源的头部,外加一个 .gd.map,记录每一输出行来自哪个源行。

# player.gate
extends CharacterBody2D

pub int health = 100
Node2D? target

func hurt(amount: int) -> void:
	health -= amount
	target?.notify_damage(amount)
# player.gd, generated
extends CharacterBody2D

var health: int = 100
var target: Node2D = null

func hurt(amount: int) -> void:
	health -= amount
	if target != null:
		target.notify_damage(amount)

输出的缩进方式与项目其余部分一致。在编辑器中,缩进类型和大小来自编辑器设置 -> 文本编辑器 -> 行为 -> 缩进。在没有编辑器运行的情况下,无头构建会采用 .gate 文件本身的第一行缩进行,因此重命名后的 .gd 会保持它原有的外观。制表符是最后的手段。更改该设置会在下一次构建时重写输出。

删除头部,然后删除 .gate 文件,剩下的就是一个可以手动维护的普通脚本:剩余的 .gd.map 可以一并删除,而 .gd.uid 保留。没有运行时,没有依赖,也没有需要发布的东西。

重新构建的内容

用于证明文件是最新的信息保存在 .godot/gate/ 中,因此打开一个没有任何改动的项目不会编译任何内容。删除该文件夹只会导致一次完整构建,别无其他代价。

当文件读取的内容发生变化时,该文件会被重新构建。从另一个 .gate 中,它会读取声明、结构体字段默认值、信号参数类型,以及每个函数可能将其设置为 null 的内容。注释,或函数体内不改变上述任何内容的编辑,只会重新构建它所在的文件。手写的 .gd 只对通过 class_name 以基类、自动加载或路径方式到达它的 .gate 文件有效,并且只通过其声明生效。在 project.godot 中,只有自动加载和 debug/gdscript 警告设置起作用,因此开启或关闭插件不会构建任何内容。

项目 -> 工具 -> 重新编译所有 GATE 文件 会忽略所有这些,并再次查看每个 .gate。

特性

类型优先声明

类型放在名称之前,因此带类型的写法比不带类型的写法更短。var 仍然与在 GDScript 中完全一样地工作。

示例
int hp = 100            # var hp: int = 100
str label = "player"    # var label: String = "player"
vec2i[] tiles           # Array[Vector2i]
int[] scores            # Array[int]
{str, int} counts       # Dictionary[String, int]
int[][] grid            # Array[PackedInt32Array] - GDScript rejects nested typed collections

这些短名称是拼写形式,不是新类型。str 就是 String,vec2 是 Vector2,f64 是 float:相同的值,相同的方法,与每个 Godot API 接收和返回的完全相同,没有包装,也没有转换。如果你更喜欢,也可以写 String——两者编译为同一行。

这种形式不会取代任何东西。 var hp: int = 100 的含义一如既往,GATE 自己的类型也可以出现在该位置,因此这里没有任何东西被类型优先的拼写形式所限制:

var target: Node2D? = null      # tracked nullable
var scores: int[] = [1, 2]      # Array[int]
var id: <int | str> = 7         # union, emitted as Variant

如果你觉得更合适,可以不写任何类型优先声明,直接使用 null 跟踪、结构体、泛型等。类型优先之所以存在,是因为在一组字段中,类型是值得首先阅读的内容,而且它更短。这就是全部原因;没有技术上的原因。参数保留 GDScript 自己的 name: Type 形式,这是有意为之的不一致:让它们类型优先是一种拼写变化,会让解析器比语法其余部分需要更多前瞻,因此它被放在 ROADMAP.md 中,而不是在这里半途实现。

Null 安全

T? 是一种被跟踪的可空类型。一个流敏感的分析过程会跟踪守卫、提前退出和赋值,并拒绝它无法证明安全的解引用。它只会对你声明为可空的类型发出警告,因此普通 GDScript 不会触发它。

示例
Node2D? target

func update() -> void:
	target.queue_free()             # error: 'target' may be null
	target?.queue_free()            # fine
	if target != null:
		target.queue_free()         # fine

当 ?. 的左侧为 null 时,它会给出 null,因此其结果本身就是一个可能为 null 的值。在 a 可能为 null 的地方,str n = a?.label 是错误,而 str n = a?.label ?? "" 不是。

结构体

struct 具有值语义,class 具有引用语义。一个由两到四个相同类型的 int 或 float 字段组成的结构体会变成一个 Vector2/3/4,且没有额外开销;其他任何情况都会变成一个类,GATE 会插入副本以保持其值语义。

示例
struct Damage:
	int amount
	str source

	func doubled() -> int:
		return amount * 2

var a = Damage(7, "hit")
var b = a            # a copy, not an alias

接口、特征和命名空间

接口在编译时检查,并可在运行时测试。特征将其成员内联到实现者中。命名空间会降级为内部类。

示例
interface Damageable:
	func take_damage(amount: int) -> void

trait Poolable:
	int _pool_id = -1

	func reset() -> void:
		_pool_id = -1

class Enemy extends CharacterBody2D implements Damageable with Poolable:
	func take_damage(amount: int) -> void:
		pass

全局类

在顶层类上使用 @global 会在声明它的文件旁边为其生成一个独立的 Godot 脚本,带有 class_name——一个真实的、可附加的脚本,会出现在“创建节点”对话框中,而不是 .gate 自身输出中的内部类。该名称是项目范围的,与文件自身的 class_name 相同。

示例
# res://src/bullet.gate
@global class Bullet extends Node2D:
	var speed: float = 400.0
# writes res://src/bullet.Bullet.gd, carrying `class_name Bullet`

泛型

单态化后,Pool<Bullet> 持有的是真正的 Array[Bullet],而不是无类型的。 泛型只在其声明的文件中单态化一次,因此 is Pool<Bullet> 在跨文件时依然成立。

示例
class Pool<T> extends Node:
	T[] _items

	func acquire() -> T?:
		return _items.pop_back() if _items.size() > 0 else null

var bullets = Pool<Bullet>.new()

函数以同样的方式接受类型参数,每使用一组类型实参就生成一份副本。它们是显式写出的,而非推断的。另一个文件的泛型函数通过其类名或预加载的常量调用,并在其声明处单态化,与泛型类相同。

func first<T>(xs: T[]) -> T:
	return xs[0]

print(first<int>(ns))    # __first_int(ns)

联合、元组与带类型的可调用对象

三者都编译为普通的 GDScript 类型,并在编译期检查。联合持有其成员之一,元组是固定长度的数组且每个槽位有类型,带类型的可调用对象声明其接受和返回的内容。

示例
<int | str> id = 7                          # var id: Variant = 7
<int, str> entry = [7, "hp"]                # var entry: Array = [7, "hp"]
func(int) -> bool keep = func(x): return x > 0

func update() -> void:
	id = 1.5                                # error: float is not int or String
	entry[1] = 3                            # error: slot 1 is a String
	keep.call("7")                          # error: expects int

类型别名与带类型的场景

type 为一个类型命名一次;不会为其生成任何内容。PackedScene<T> 使 instantiate() 返回 T。

示例
type Hp = int
type Loot = <Item | Gold>

Hp health = 100                             # var health: int = 100
PackedScene<Enemy> scene = preload("res://enemy.tscn")
Enemy e = scene.instantiate()               # typed, no cast

你用类型声明的信号也会被检查:signal hit(amount: int) 上的 hit.emit("x") 是一个警告。只是警告,因为 Godot 接受它。

收窄与 match 模式

在 if x is T: 之后,或证明它的提前退出之后,x 是 T。一个 match 分支可以一次性测试类型并绑定它,并且可与 when 组合。

示例
func area(s: Shape) -> float:
	match s:
		Circle c:
			return PI * c.r * c.r
		Rect r when r.w == r.h:
			return r.w * r.w
		Rect r:
			return r.w * r.h
	return 0.0

func hit(n: Node) -> void:
	if n is not Enemy:
		return
	n.take_damage(1)                        # n is an Enemy here

除非先证明了 x is Foo,否则 Foo f = x as Foo 是错误,因为失败的 as 是 null。普通 GDScript 的 (x as Foo).bar() 不受影响。

分量重排与对象初始化器

.xy、.xz、.zyx 及其余写法可用于任何静态类型的向量,读取和写入皆可。对象可以在一个表达式中构建并填充。

示例
vec3 p = Vector3(1, 2, 3)
vec2 flat = p.xz                            # Vector2(p.x, p.z)
p.xy = Vector2(5, 6)

var e = scene.instantiate({ position: spawn, hp: 40 + wave * 10, target: player })
var l = Label.new({ text: "Ready", modulate: Color.RED })
var d = Damage({ amount: 3, source: "fire" })

编辑器注解

@required 在调试构建中于 _ready 内断言一个导出的节点已被赋值。@export_if 仅在条件成立时在检查器中显示属性;它需要 @tool,GATE 不会替你添加。

示例
@tool
extends Node

@export @required Node2D target
@export bool use_timer = false
@export_if(use_timer) float delay = 1.0

条件编译

@if / @elif / @else / @endif 在文件编译时解析。被丢弃的块会被置空,而非删除,因此其后的每一行都保持原行号,且它从不被解析,所以它可以包含根本无法编译的代码。符号来自 Godot 本身:DEBUG、RELEASE、EDITOR、EXPORT、平台和架构标签,以及比较中的 GODOT_VERSION。未知符号是编译错误,而非静默的 false。导出时,每个 .gd 都会用预设自身的值重新编译,因此发布构建确实缺少 @if DEBUG 块。

示例
@if DEBUG
print("debug build")
@elif GODOT_VERSION >= 4.7 && WINDOWS
print("windows, recent Godot")
@else
print("release")
@endif

表达式

示例
var name = player?.profile?.name        # safe navigation
var hp = saved ?? 100                   # null coalescing
var [x, y] = get_pos()                  # destructuring
var [int x, int y] = get_pos()          # ...and the names may take types
var msg = f"{player} scored {pts}"      # f-string
a, b = b, a                             # swap
for i, item in enumerate(items):        # index and value
for k, v in scores:                     # dictionary key and value
nums.map((x: int) => x + 1)             # arrow lambda

箭头 lambda 是去掉噪音的单表达式 func lambda:圆括号始终必需,参数遵循与 func lambda 相同的规则。

有两种写法位于项目设置之后,默认都关闭,因为每一种都是 GATE 已有功能的第二种写法:

int[,] grid = [[1, 2]]                  # gate/features/multidim_arrays, = int[][]
var shown = raw |> roundi() |> mini(99) # gate/experiments/pipe_operator, = mini(roundi(raw), 99)

在标志关闭时使用其中任何一种都会报错,并指出该设置。

功能设置

GATE 的写法中有五种是开关,位于 项目设置 > Gate 下。使用某个开关关闭的写法会报编译错误,指出该设置以及应改用的写法,因此不会有任何静默失败。

  • 功能 > 类型优先,开启。关闭时,int hp = 100 是错误,var hp: int = 100 是应改用的写法。没有其他内容随之改变:GATE 的类型在 GDScript 注解中同样有效,因此 null 追踪、结构体、泛型及其余功能仍然可用。唯一没有替代写法的是共享行,int a, b,因为 GDScript 没有多名称声明。
  • 功能 > 简写类型,开启。关闭时,str、vec2、f64 及其余写法在任何地方都不是类型名,应改用 String、Vector2 和 float。你自己的项目用这些名称之一声明的类、结构体、枚举或常量在两种情况下都保持其含义;该设置只决定简写含义是否存在。
  • 功能 > 类型名作为值,关闭。开启时,用作值的简写表示 Godot 类型,因此 vec2.ZERO 是 Vector2.ZERO,vec2(1, 2) 是 Vector2(1, 2)。关闭时,两者都是错误。无论哪种情况,str(...) 作为函数调用都是 GDScript 自带的内置函数。
  • 功能 > 多维数组,关闭。int[,],int[][] 的另一种写法。
  • 实验 > 管道运算符,关闭。x |> f(),f(x) 的另一种写法。

默认值永远不会写入 project.godot,因此不修改这些设置的项目不会为其携带任何内容。

在编辑器中

启用插件后,Godot 将 .gate 视为一种独立的语言。

  • .gate 文件出现在文件系统停靠栏中,并带有自己的图标。在此之前它们是不可见的:Godot 完全不跟踪该扩展名。
  • 双击其中一个会打开真正的脚本编辑器,带有 GATE 的语法着色——类型优先声明、T?、T[]、联合类型、f-string、struct、interface、namespace 等等。每个类型名都会着色,无论是简写还是规范形式,struct、interface、trait、namespace、type 或 class 声明的名称以及泛型的参数也是如此。以 _ 开头的名称会变暗。颜色来自你自己的编辑器主题,并在你更改主题时随之变化。
  • 有些错误会在你输入时加下划线——未终止的字符串、不一致的缩进——这些是能从你面前的文件中判定的。你未闭合的引号会在你打开它的位置报告,而不是在文件不再合理的位置。任何需要项目其余部分的内容都留给构建处理,构建会像以前一样报告到输出面板。
  • Godot's own warnings show in the .gate, in the same warnings panel and in the same words: unused variable, unused local constant, unused parameter, unused signal, and a local, parameter or for variable that shadows a member of this class or of a base - another .gate, a hand-written .gd, or an engine class. Before this they existed only against the generated .gd, which you are told never to open. @warning_ignore, @warning_ignore_start/_restore and the switches under Project Settings -> Debug -> GDScript are honoured, so a warning you turned off stays off. The warnings that need the whole project, and the ones about types, are still the build's.
  • Ctrl-点击或 F1 跳转到声明——跨文件。项目中任何位置声明的结构体、类、接口、trait、命名空间、泛型或别名,另一个 .gate 或手写的 .gd 中的 class_name,其中任何一个的成员(包括它继承的成员),以及你所在文件中的局部变量、参数和字段。它会穿过自动加载,因此 Global.current_project.frames 能到达字段的声明:脚本自动加载代表其脚本,场景自动加载代表其根节点的脚本,或者当根节点没有脚本时代表根节点的引擎类。以路径形式书写的基类 extends "res://base.gd" 会像命名基类一样被跟踪。引擎类会打开其文档。GATE 无法定位某个词时,会通过不加下划线来表示。
  • 断点有效。在 .gate 中设置一个断点,运行中的游戏会停在那里,停止位置会显示在 .gate 中并标记该行,而不是在生成的 .gd 中。单步执行也会跟随。在游戏永远无法停下的行上设置断点——空行、会被降低消除的声明,或注释(注释会被保留,但 Godot 仍然不会在其上中断)——会被撤回并弹出提示说明原因,而不是留在那里永远不触发。Godot 自己的调试器面板仍然命名为 .gd。
  • 自动补全了解 GATE 自身的各种形式。 在点号之后,它会提供左侧内容的成员——来自另一个 .gate 的结构体、一个 T? 字段、一个 class_name、一个手写的 .gd 类、一个自动加载、一个引擎类——包括所有继承来的内容,一路向上,穿过同样以路径形式书写的基础类。?. 也是如此。String、Array、Vector2 以及引擎的其他内置类型都会提供它们的方法、属性和常量,这些来自一张由 Godot 4.7.2 的 API 生成的表。类型化容器所持有的内容会通过索引、返回元素的 Array 和 Dictionary 方法,以及 for 循环的变量传递下去,因此使用 Frame[] frames 时,for f in frames: 内的 frames[0]. 和 f. 都会提供 Frame 所拥有的内容。用 := 声明的变量或常量,其类型来自其初始化器;用普通 = 声明的变量仍然是无类型的。在未限定的情况下,它会提供局部变量、参数、文件自身的成员及其继承的一切、自动加载、项目中的每个类型、GATE 的类型简写和关键字,以及引擎的类。在 @ 之后,它会提供注解,包括 GATE 的和 Godot 的。在另一个对象上,点号会省略以下划线开头的方法,例如 _ready,因为这些方法是用来被重写的;而在 self 上它们会被提供。一个名称只有在光标实际能看到它的地方才会被提供:参数在其自己的函数内,局部变量在其自己的行下方,for 变量在其循环内。在类体中输入虚函数名的开头,会提供基类的可重写方法并填入其签名,而输入调用的左括号会显示该函数的参数。
  • 从场景停靠栏拖入的节点会作为引用落地,与 .gd 会得到的那一行相同。.gate 会以其 extends 链根部的引擎类来回应编辑器,这是场景停靠栏在允许拖放之前所问的唯一问题。
  • 脚本编辑器的方法列表包含每个函数,无论它是用什么写的:priv func、pub func、virtual func、运算符重载,以及其中任何一个带有 static 的,每个都会跳转到其在 .gate 中的行。
  • 打开的 .gate 标签页会在下次会话中恢复。 Godot 自身的布局从不保存它们;GATE 会保留它们并自行重新打开。这样,在关闭 GATE 的会话中就不会找到任何需要重新打开的内容:没有 GATE 时,Godot 会把 .gate 当作纯文本打开,并在退出时重写其缩进,糟糕到它不再能编译。从插件标签页关闭 GATE 会关闭其标签页,并先保存任何未保存的编辑,而关闭 GATE 的会话永远不会重写 .gate。
  • 保存会保留文件的行尾。 使用 CRLF 的 .gate 会以 CRLF 写回;新文件,或使用 LF 的文件,保持 LF。
  • 新建脚本。 Script -> New Script 和文件系统停靠栏的右键 Create -> New GATE Script... 都提供 GATE 作为语言。
  • 附加脚本会创建 .gate 并附加 .gd。 在场景停靠栏的 Attach Script 对话框中选择 GATE,它会根据你选择的模板写入 .gate,编译它,并将生成的 .gd 放到节点上。节点会运行 .gd,这是必须的;.gate 是脚本编辑器中保持打开的内容。如果构建失败,GATE 会在屏幕上说明,保留 .gate 不动以便你修复,并且不附加任何内容。
  • Project -> Tools -> Recompile all GATE files 会忘记构建已经知道的内容,并重新查看每个 .gate。
  • GATE 不会留在导出的游戏中。 .gate 源码、它们的 .uid 文件、 .gd.map sourcemap、编译器以及编辑器集成都不会进入 PCK;最终发布的是编译后的 .gd。来自 addons/gate/editor/ 的四个小脚本确实会随包发布——loader.gd、saver.gd、registry.gd 和 script.gd——因为 Godot 的类列表列出了前两个,而一个找不到 它们的游戏会在启动时打印六条错误。它们在游戏中什么也不做。如果你在自己的导出预设中排除了 addons/gate/,请保留这四个。

有两件事是刻意不工作的。

  • 自动补全宁可什么都不提供,也不去猜测。 当点号左侧内容的类型 无法推断时——用普通 = 声明且没有 类型的变量、索引之后的无类型容器、GATE 无法跟踪的引擎方法返回值——列表会是空的,而不是从别处填充。它只跟踪 名称、调用和索引的链条,而不是任意表达式。
  • Ctrl-点击只跟踪 GATE 能确定类型的内容。 通过 GATE 看不到类型的值 访问到的成员不会加下划线,引擎类的成员 也不会。

在另一个编辑器中编辑 .gate

GATE 使用 LSP,因此 .gate 可以在 Neovim、Helix、Zed 或 VS Code 中编辑。服务器 运行在 Godot 编辑器内部,就像 Godot 自己的 GDScript 语言服务器那样,这 使得它告诉你的编辑器的内容与脚本编辑器显示给你的内容来自 同一份代码,而不是来自两个会逐渐偏离的实现。

除非你主动要求,否则它是关闭的。在 Project -> Project Settings -> GATE 中:

  • gate/lsp/enabled - 默认关闭。没有人会得到一个自己并不想要的监听套接字。
  • gate/lsp/port - 默认 6100。它只绑定 127.0.0.1,不绑定其他任何东西。如果该端口 已被占用,GATE 会说明并停止,而不是换到另一个端口:客户端 配置的是固定端口,而一个悄悄换端口的服务器是客户端 永远找不到的服务器。

Godot 必须正在运行并打开该项目,才会有任何响应。

当有客户端连接时,GATE 会将编辑器的主循环保持在约 60 Hz。Godot 在窗口未聚焦时每帧之间休眠 100 ms,而你在别处输入时它一直处于未聚焦状态,这恰恰是它 在响应的时候:服务器每帧轮询一次套接字,因此这个周期就是回复可能等待的时长。 客户端断开连接的那一刻就会交还控制权,而一个已聚焦的编辑器本来 就比 60 Hz 更快,因此不受影响。

让编辑器指向它

服务器是一个套接字而不是一个程序,因此客户端必须连接到它, 而不是启动它。这与 Godot 自己的 GDScript 服务器形态相同,也正是为什么一个 只能启动语言服务器的编辑器需要中间层。

Neovim 可以自行连接到套接字:

vim.lsp.start({
  name = "gate",
  cmd = vim.lsp.rpc.connect("127.0.0.1", 6100),
  root_dir = vim.fs.dirname(vim.fs.find({ "project.godot" }, { upward = true })[1]),
})

VS Code 完全没有内置的方式来附加到语言服务器;那里的每种语言 都通过扩展提供。在这个仓库旁边的 GateVsCode 仓库中有一个用于 GATE 的扩展, 未发布,通过复制文件夹来安装。

Helix 和 Zed 将语言服务器作为子进程启动,并通过其 标准输入和输出与之通信,因此它们需要一个将 stdio 转发到套接字的桥接。 nc、socat 或一个三行脚本都可以做到;它们默认都不安装在 Windows 上。

它今天能回答什么。诊断、补全、签名帮助、转到定义、悬停提示和文档大纲。其他任何请求都会回答“方法不存在”,这正是让客户端回退到自己的词表、而不是断定 GATE 无话可说的原因。

转到定义宁可拒绝也不猜测。它的回答与脚本编辑器中 Ctrl+点击的回答完全一致,因为它询问的是同一份代码:局部变量、参数、成员、函数、信号、枚举、在另一个 .gate 中声明的类型、手写 .gd 中的 class_name、autoload,以及从基类继承的任何东西,包括以路径形式书写的基类。它无法定位的词会什么都不回答,而不是给出最接近的相似名称。引擎类也什么都不回答,因为没有文件可打开;悬停提示仍会描述它。答案始终是你所写内容中的某个位置——.gate,或你自己写的 .gd——而绝不是 GATE 生成的 .gd。

枚举成员会带你到它的枚举。GATE 的解析器把枚举的键作为声明上的名称保留,不给它们单独的行,所以 Mood.CALM 会打开 enum Mood 所在的那一行。对于单行枚举,光标会落在键本身上;对于枚举体跨越多行的枚举,它会把你带到枚举的顶部。

悬停显示声明及其声明的类型。你写的那一行,以及 GATE 为它推断的类型。没有类型标注的 var 不会得到类型行,因为这类东西的类型来自构建过程、针对整个项目,而悬停提示只从一个文件回答。它宁可什么都不说,也不显示空工具提示。

大纲是文件自身的声明。类、结构体、接口、trait、命名空间、枚举及其成员、函数、信号、常量和字段,按你书写的方式嵌套。正在编辑中的文件仍会得到大纲:一个只写了一半的声明只损失它自己的条目,别无其他。

诊断分两层,区别很重要。在你输入时,你会得到 Godot 脚本编辑器在同一文件上划线的那些内容:词法分析器的错误以及 Godot 自身的未使用变量和变量遮蔽警告。当你保存时,你还会得到编译发现的内容——未知类型、空值分析、超集检查——也就是 GATE 关于该文件所知道的一切。在另一个 .gate 中声明的类型能够解析,因为编辑器已经持有项目的注册表。GATE 有提示的地方会作为相关信息出现,这样你的编辑器可以在行上显示消息、按需显示提示,而两层都发现的问题只报告一次。

这种划分并非随意。词法分析这一遍在小型文件上耗时 0.2 毫秒,在 238 行的文件上耗时 12.5 毫秒;编译同样这两个文件分别耗时 5.8 毫秒和 259 毫秒,而且它在编辑器的主线程上运行,这意味着 Godot 在此期间不做其他任何事情。在你每次输入停顿时都付出这个代价,意味着每分钟要付出几十次,而答案通常并没有变化。所以一次按键换来快速层,一次保存换来两层。如果你想得到 GATE 对某个文件的完整意见,就保存它。

它仍然不承载什么。项目的其余部分。你会得到你保存的那个文件的全部诊断,而不是依赖它的文件的诊断,构建仍会把那些报告到 Godot 的输出面板。

补全同样只提供确定的结果,不做猜测。列表就是脚本编辑器给出的那个,顺序相同,GATE 的种类映射到你编辑器的图标。当 GATE 无法确定点号左侧内容的类型时,答案就是空列表。签名帮助会显示你当前所在的调用名称,并高亮你正在输入的参数,包括嵌套调用中逗号之后的位置。Variant 内置方法(如 String 或 Array)完全没有签名:Godot 自己的 API 转储记录这些方法时只有名称和返回类型,没有参数,因此没有参数列表可显示,而显示空列表会表示该方法不接受任何参数。

未保存的更改只对当前文件生效,且仅限该文件。当你的编辑器打开一个文件时,GATE 关于该文件的一切判断都基于你的缓冲区。任何需要从其他文件读取的内容——比如基类,当它要判断某个局部变量是否遮蔽了基类的成员时——都从磁盘读取。因此,你添加到基类但尚未保存的成员,在 GATE 查看继承它的文件时不会被考虑。保存后即可修复,前提是 Godot 已注意到文件发生了变化,这会在编辑器下次扫描文件系统时发生。

一个文件,一个编辑器。同一个 .gate 同时在 Godot 脚本编辑器和外部编辑器中打开,就是一个文件上的两个缓冲区,GATE 不会在它们之间仲裁。请选择其中一个。

性能

生成的代码不比你手写的 GDScript 慢:在 110 项基准测试中,中位数比率为 0.98,处于 1.6% 的噪声范围内。

GATE 更快的地方,是因为它采用了你不会手写的降级方式。一个可证明不会逃逸出其函数的 struct 局部变量会变成每个字段一个普通局部变量,没有分配也没有复制——在 30 万次迭代的循环上快 13.5 倍。一个打包进 Vector 的 struct 数组比惯用的 Array[SomeClass] 快 9.9 倍。

@soa 是需要小心处理的一个:它的构建速度约慢 2.4 倍,迭代速度约快 1.9 倍,因此只有当你每次重建后遍历超过约 1.3 次时才划算。

已知限制

  • lambda 体或分组括号内的注释会被丢弃。注释会被带入输出,包括 ## 文档,这涵盖了写在数组字面量、字典字面量、enum 体或调用参数列表内的注释——这类构造会采用每行一个的形式,使每条注释独占一行,而不带注释的构造则完全按原样输出。有两种情况无处安放:作为参数传入的 lambda 内部的注释属于该 lambda 体中的语句,而非参数列表;分组括号内的注释则无所依附,因为 GATE 会丢弃括号并重新输出表达式。在 3,495 个真实文件上测量,写在括号内的 764 条注释中有 626 条被保留,这 138 条则没有。写在代码行末尾的注释也会被丢弃,前提是该行变成了多行输出或没有输出:写在单行上的 @rpc func f() 会输出为两行,此时就没有一个合适的位置放它。
  • Godot 的调试器面板会命名生成的 .gd。游戏运行的是 .gd,因此堆栈帧列表和运行时错误都指向它;一个提示会指出每个来自哪一行 .gate。断点和单步执行显示在 .gate 中。在生成的行上停止,而其后没有 .gate 行——这是辅助工具 GATE 写入的——会故意保持显示 .gd,而不是指向一个并非正在运行的 .gate 行。
  • as 只在 GATE 自己的声明中检查。当转换失败时,x as T 为 null。在函数内部,除非有 if x is Foo: 或提前退出证明了它,或者你将其声明为 Foo?,否则 Foo f = x as Foo 是一个错误。在其他所有地方,(x as Foo).field、var y = x as Foo、参数和字段初始化器,转换都被信任,因此普通 GDScript 保持其含义。这是 null 分析中一个已知的不健全之处。
  • 别名由类型判断。对 d.next 的写入会清除 GATE 对每个可能是同一对象的引用所知道的关于 next 的信息:同一个类或相关类,或未知类型。仍存在三个缺口。GATE 无法看穿的调用,比如引擎方法,会清除其给定引用下的内容,但不会清除它们的别名。其返回值被视为一个新对象。而一个通过持有参数的数组或字典进行写入的函数(先 var arr = [c] 再 arr[0].next = null),其调用者看不到它对 c 的写入。
  • 静态字段只在 GATE 索引它们的地方被跟踪。null 分析会跟踪文件自身声明的静态字段,涵盖函数访问它时使用的每一种拼写,而写入该字段的调用会为调用者清除它。在另一个文件中声明的静态字段完全不被跟踪。通过类名到达的某个路径之下的路径,Reg.cached.next,会回退到上面的别名规则,而不是回退到字段本身。而子类的静态函数不会跟踪它从基类继承的静态字段。
  • is 会收窄局部变量、参数和有类型的实例字段。无类型字段、静态变量或自动加载可以被 GATE 无法追踪的调用重新绑定,因此对其中一个的检查根本不会收窄它。把它复制到一个局部变量再测试。
  • 通过无类型容器读取的结构体字段保持原样。当 GATE 知道容器持有什么时——一个已声明的 S[]、一个参数、一个循环变量或某个结构体的字面量——arr[0].c 会映射到 Vector 分量。对于用 append 增长的无类型局部变量,或无类型参数,没有元素类型,读取会在运行时失败。
  • 结构体只在使用结构体的地方受到保护。在使用类降级结构体的文件中,如果未知类型的值最终是结构体,它会在运行时被复制。普通文件保持原样,因此它从无类型数组或字典中读出的结构体是存储的那个,而不是副本。
  • 一个表达式最多可嵌套 180 层,每个运算符、调用和成员算一层:180 个项的扁平链,或 90 步 a()[0]。解析器之后的每一遍都会按层遍历树一次,而 GDScript 在 1024 次调用时停止,因此比这更深时,各遍会耗尽栈并返回被悄悄截断的答案。嵌套更深的文件会被拒绝,并给出一个错误,指出表达式从哪里开始。这是一个有效 GDScript 被拒绝的地方:Godot 自己的解析器能接受它。深层成员链还会带来与其深度成二次方的时间开销,这只有在远超真实代码所达到的深度时才会显现。
  • 括号最多嵌套 48 层。解析器中另有一个上限,解析器是用 GDScript 编写的递归下降解析器。达到该上限本身开销就很大,每层大约二十次调用,因此 not (not (...)) 超过 46 层就会在达到上限并报告之前耗尽栈,而 GATE 为此类文件写出的内容无法重新编译回自身。
  • 引用了同一构建中另一个文件所声明的 class_name 的文件会被写入并附带警告,并在该类注册后(通常在下一次构建时)进行验证。Godot 从编辑器的类列表中解析全局类名,而该列表只有在文件写入磁盘后才会更新。
  • 接口不会回退到结构检查。对于 GATE 未编译的类,x is SomeInterface 为 false。
  • 带命名空间的类不能作为脚本附加到节点上。标记为 @global 的顶层类可以;@global 不会深入到 namespace 内部。
  • @global 类自身的主体只能引用它自己、项目范围内的名称,或它自己声明的内容——不能引用在同一文件自身顶层声明的普通同级项:一个类、结构体、接口、trait、命名空间、枚举、常量、变量、信号或函数,如果它本身不是 @global,因为一旦该类被提取到自己的脚本中,该同级项就不在那里了。在编译时会被拒绝,并指出该同级项;也把它标记为 @global,或将其移入类内部。
  • 在编辑器中保存 .gate 会重写其缩进,改为 Text Editor -> Behavior -> Indent 所指定的内容,正如 Godot 对 .gd 所做的那样。GATE 对制表符和空格一视同仁,因此无论哪种方式输出都不变。
  • 修改项目中大多数文件都会引用到的某个文件中的声明,会重建整个项目。添加一个方法,或更改签名、结构体字段默认值、信号的参数类型,或某个函数可以设为 null 的内容,都会重建所有引用到被编辑文件的文件。在 Pixelorama 上,几乎所有内容都通过一个 autoload 传递,向该 autoload 引用到的文件添加一个方法测得约 35 秒;同一文件中的注释或主体编辑测得约 2 秒。
  • 当声明发生变化时,补全会重新读取文件。在 130 行的文件中这大约花费 16 毫秒,900 行时 100 毫秒,4,200 行时半秒。输入一个表达式——补全请求通常紧随其后——会复用上一次的读取结果,在任何规模下都保持在约 3 毫秒。
  • 会话中的首次补全请求可能需要数秒。编辑器打开后,GATE 会在后台读取项目,每帧几个文件。在此之前发出的请求会一次性读取其余部分:在 Pixelorama 上测得约 7 秒,而之后发出的请求只需不到一秒。
  • 在 GATE 1.1.0 之前打开的 .gate 会保持“Plain Text”高亮。该选择按文件记录在 .godot/editor/script_editor_cache.cfg 中;从高亮器菜单中选一次 GATE 即可保持。
  • 条件编译没有文件局部的 @define。自定义符号仅来自项目设置 gate/compiler/defines。比较运算符仅为 GODOT_VERSION 定义。指令基于行:不能丢弃半个表达式。编辑器将被丢弃的块显示为普通代码;它不会变灰。
  • 尚无游戏随其发布。

验证

GATE 是针对一个包含 26 个阶段的测试套件开发的:与 Godot 本身进行差分执行、真实世界的超集语料库、跨编辑器重启的构建流水线场景、实时编辑器会话、sourcemap 保真度、字节级精确输出比较以及随机输入。该测试套件不属于本仓库;这里发布的是这个插件。

它在 v1.1.0 处确立的内容:

  • 196 个真实 Godot 项目根目录,从它们各自的目录中扫描——包括官方演示、教程和基准测试仓库,以及十个已发布的应用程序和游戏。Godot 本身在那里加载的每个文件,共 1,552 个文件和 176,219 行:零编译错误,零无法加载的输出,零定点数中断。另外八个根目录,即 OpenXR 演示,从未完成无头运行,因此不计入。
  • 3,291 个真实 .gd 文件,两个语料库中每一个不是 GATE 自身副本的文件,先编译,然后从它们自己的输出重新编译,零无法加载的输出,零定点数中断。那 17 个无法编译的文件是 Godot 3 语法,或者缩进中混用了制表符和空格,而 Godot 4 也拒绝它们中的每一个。
  • GodSVG 被整体转换——所有 191 个非插件文件重命名为 .gate 并通过该插件构建,然后在三次编辑器重启中的每一次之后再次构建——零失败文件,且每个输出都能加载。

差分测试框架才是关键所在。它不问 GATE 是否接受了该文件,而是问 Godot 的答案是否等于 GATE 的答案。这个项目中的每一个静默错误值缺陷都是通过这种方式发现的,没有一个是其他方式发现的。

文档

本仓库中的文档,即本 README、SYNTAX.md、CHANGELOG.md 和 ROADMAP.md,由 AI 编写,然后由我在发布前检查。关于行为的每一项声明都是我对照测试套件或编译器本身验证过的,引用的每一个数字都来自一次运行而非估计。如果仍有错误,那是我遗漏的错误,所以请提交 issue,我会修复它。

许可证

MIT。参见 LICENSE。

来源:Hacker News · github.com