GATE:Godot Engine 的 GDScript 超集
GATE – A Godot Engine GDScript Superset
GATE 是面向 Godot Engine 的 GDScript 超集,在编译为普通 GDScript 的同时加入类型前置声明、空值安全、结构体、接口与命名空间、泛型、联合类型等特性,采用 MIT 许可,要求 Godot 4.7+。合法的 GDScript 即为合法的 GATE,把 .gd 改名为 .gate 即可无错编译并保持行为一致,重新编译其自身输出可得到相同字节。
译文尚不完整,完整内容请切换到原文。
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 orforvariable 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/_restoreand 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.mapsourcemap、编译器以及编辑器集成都不会进入 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