Godot API 调用与对象生命周期
以「给编趣 Quaver 画下落音符」引入:跑通第一个跨边界调用,弄清 godot 模块提供什么、三条命名规则、Variant 桥接的两条实践准则,以及最重要的一课——Godot 拥有 Godot 对象,跨帧引用必须先验有效性。
知识点地图
- 知识类别:godot 模块互操作——类与 Variant 的桥接规则、命名差异、call() 字符串协议、对象生命周期归属。
- 解决什么问题:TypeScript 与引擎之间的值怎么过去、对象归谁管、跨语言调用怎么写才不崩。
- 什么时候用到:写第一个真实功能(用到 Vector3、场景树、全局单例)时;排查”持有引用几帧后调用就崩”。
- 本篇是全模块的边界篇:050 的属性/信号/RPC、085 测试篇的 FakeBridge 替身,都建立在本篇的协议与生命周期规则上。
上一篇你在编趣 Quaver 同款的目录结构里写出了第一个 theory.ts。现在给它加一个真实功能:把乐理算出的音高序列画成下落音符。你马上会撞上三类新问题——Vector3 从哪来?引擎版本、场景树这些全局能力怎么调?以及最阴险的一个:持有某个节点的引用,几帧之后节点被 Godot 释放了,一调用就崩。这三件事的答案都在同一个地方:godot 模块,TypeScript 与 Godot 之间的唯一边界。本篇把这个边界讲透,读完后你能解释”值怎么过去、对象归谁管”。
动手:跑通第一个探测脚本
新建 res://scripts/path_probe.ts,挂到一个 Node3D 上运行:
import { Engine, GD, Node3D, PackedVector3Array, Vector3 } from "godot";
export default class PathProbe extends Node3D {
_ready(): void {
const points = new PackedVector3Array([
Vector3.ZERO,
new Vector3(0, 2, 0),
new Vector3(2, 2, 0),
]);
GD.print(Engine.get_version_info());
GD.print(GD.var_to_str(points));
}
}输出形如(版本号随引擎而异):
{ major: 4, minor: 7, ... }
(0, 0, 0, 0, 2, 0, 2, 2, 0)十行代码把边界上的四种角色凑齐了,逐个认脸:
Engine:运行时单例。引擎级全局能力(版本、帧率、物理帧设置)都从单例进;GD:工具命名空间。print打日志,var_to_str把任意 Godot 值转成 Godot 风格字符串,打印复杂对象时比 JSON 直观;Node3D:Godot 类,你的脚本基类;Vector3、PackedVector3Array:内置 Variant 类型。注意new PackedVector3Array([...])直接吃了一个 JS 数组,元素混用了常量Vector3.ZERO和显式构造的new Vector3(...)——这是合法的,下面讲为什么。
讲为什么:godot 模块里有什么
godot 模块提供五类内容,全部是生成绑定:Godot 类(Node、CharacterBody3D 等)、运行时单例(Engine、DisplayServer、ResourceLoader 等)、内置 Variant 类型(Vector3、Color 等)、集合类型(Array、Dictionary、Packed 系列),以及 GD 命名空间的工具函数。
“生成绑定”不是随口一说:这些绑定由仓库 generator/ 目录的代码生成框架(Python 加 Jinja2)从 godot-cpp 的 extension_api.json 自动生成,TypeScript 声明文件随插件发布在 types/ 目录。知道这一点有两个实际好处:一是补全信息与 Godot 官方文档天然一致,因为同源;二是某个 API 在 TS 侧补全不出来时,去 Godot 文档按原名搜,大概率是你写错了命名(见下一节),而不是功能不存在。
三条命名规则,写错就是编译错
- API 保持 Godot 原生 snake_case:
add_child、position、_physics_process,与 GDScript 完全一致。不要手滑写成 JS 风格的addChild——TypeScript 会立刻报”属性不存在”,这是 Gode 新手报错榜第一名; - 内置类型的静态常量是大写蛇形:
Vector3.ZERO、Vector3.UP; - Packed 数组构造函数可直接传 JS 数组:
new PackedVector3Array([v1, v2, v3])。
值怎么过去:Variant 桥接与两条准则
TypeScript 与 Godot 是两个类型世界,中间靠 Variant(Godot 的通用动态值类型)转换。Gode 的规则是:转换明确时,绑定接受并返回 Godot 数组、TypedArray、PackedArray、boxed 内置值(Vector3 这类值对象)以及部分普通 JS 值。
由此得出两条实践准则:
第一,API 需要 Godot 类型时,显式构造。需要 Vector3 就写 new Vector3(0, 1, 0),需要 Packed 数组就用对应构造函数,别指望裸 JS 对象 {x: 0, y: 1, z: 0} 被自动理解——它不会被理解。
第二,高频代码别在紧密循环里反复做 Variant 转换。每次跨界转换都有成本,把”每帧 new 一批 Vector3”改成复用成员变量,能在 Godot 类型体系内完成的计算就别搬回 JS。Quaver 的钢琴卷帘每帧重绘上百个音符,这类代码对转换次数敏感,移动平台尤甚。
对象归谁管:本篇最重要的一课
官方文档一句话说完:“Godot owns Godot objects. TypeScript wrappers provide access to those objects, but they do not make Godot nodes immortal.”——Godot 拥有 Godot 对象,TypeScript 侧的引用只是访问通道,不会让节点永生。
后果具体到一行代码:Godot 侧把节点 free() 之后,你手里的 TypeScript 引用还在,但它指向的对象已经没了,此时调用方法轻则报错重则崩溃。TS 侧没有任何机制提醒你。所以跨帧持有引用,必须学 GDScript 的老规矩——用前检查。官方 TargetTracker 示例就是这个模式:
import { GD, Node } from "godot";
export default class TargetTracker extends Node {
target?: Node;
processTarget(): void {
if (this.target && GD.is_instance_valid(this.target)) {
this.target.call("refresh");
}
}
}两道关卡缺一不可:this.target 判断”我到底存没存过引用”,GD.is_instance_valid(this.target) 判断”对象还活着吗”。把这两行背下来,它能挡住一大类”对象已被释放”的运行时崩溃。GD 命名空间常用的就四个:print(日志)、printerr(错误日志,同样保证进 Godot 输出面板)、is_instance_valid(有效性检查)、var_to_str(值转字符串)。
顺带看上例最后一行:call("refresh") 是任意 Godot 对象都支持的动态调用——字符串方法名、动态派发、松耦合,但编辑器无法校验方法名拼写与参数匹配。它适合边界场景(跨语言、配置驱动、信号回调),不适合当日常首选:能直接 node.refresh() 就直接调,让编译器帮你把关。
与 GDScript 怎么分工
站内 Godot 模块对比过三条脚本路线:GDScript 零配置、与引擎结合最紧密;C# 走 .NET;Gode 把 TypeScript 与 npm 生态带进 Godot。Quaver 的分工是按”谁离引擎近、谁离纯计算近”划的:场景演出用 GDScript,乐理计算用 TS。无论怎么分,本篇的”显式构造 Godot 类型、用前检查有效性”在 TS 侧都是必修课。
坑点与自检
- API 写成驼峰
addChild报”属性不存在”——Godot 绑定保持 snake_case,与 GDScript 文档对照原名; - 裸 JS 对象当 Vector3 传给引擎 API,行为不符预期——显式
new Vector3(...),Variant 桥接不做猜测; - 节点被释放后调用其方法崩溃——跨帧引用先过
GD.is_instance_valid这道闸;释放节点的一方才是根因,检查你的队列里是否缓存了没有清理的引用; - 打印复杂对象得到
[object Object]——那是 JS 对象;Godot 值用GD.var_to_str打; - 自检问题一:
godot模块五类成员分别是什么?(类、单例、Variant 类型、集合、工具函数) - 自检问题二:为什么补全和 Godot 官方文档长得一样?(绑定由 extension_api.json 同源生成)
- 自检问题三:
this.target非空为什么还要is_instance_valid?(TS 引用不延长对象寿命,Godot 释放后引用依然”非空”)
练习
- 写一个脚本打印当前引擎版本的主次版本号与当前帧率(提示:
Engine.get_version_info()与Engine.get_frames_per_second()),用GD.print输出; - 给 theory.ts 的音高序列写一个
toPositions(count: number): PackedVector3Array,把 N 个音符映射成下落路径坐标,验证 Packed 数组可以直接从 JS 数组构造、也能被 GDScript 侧读取; - 制造一次崩溃再修好它:脚本 A 里
queue_free()一个节点,脚本 B 的成员变量持有它的引用并每帧调用,观察报错;随后加上两道关卡检查,验证不再崩。
先自己写,写完对照参考实现。第 1 题的验收点是输出形如 4 / 7 @ 60fps;第 2 题的验收点是 GDScript 侧能取回数组并读出元素;第 3 题的验收点是修复前后一次报错、一次安静。
参考实现
第 1 题——版本信息返回的是一个 Dictionary,字段访问用 major / minor:
import { Engine, GD, Node } from "godot";
export default class EnvProbe extends Node {
_ready(): void {
const info = Engine.get_version_info();
GD.print(`${info.major} / ${info.minor} @ ${Engine.get_frames_per_second()}fps`);
}
}第 2 题——把下落轨道抽象成”4 条车道、从上方落向判定线”,坐标全部在 TS 侧算好,一次打包成 Packed 数组过边界:
import { Node, PackedVector3Array, Vector3 } from "godot";
const LANES = 4;
const LANE_WIDTH = 1.5;
const START_Y = 10.0;
const STEP_Y = -1.0;
export default class FallingNotes extends Node {
toPositions(count: number): PackedVector3Array {
const points: Vector3[] = [];
for (let i = 0; i < count; i++) {
points.push(new Vector3((i % LANES) * LANE_WIDTH, START_Y + i * STEP_Y, 0));
}
// Packed 数组构造函数直接吃 JS 数组
return new PackedVector3Array(points);
}
}GDScript 侧验证读取:
var positions: PackedVector3Array = $FallingNotes.to_positions(8)
print(positions.size()) # 8
print(positions[0]) # (0, 10, 0)第 3 题——先复现。脚本 A 挂在一个按钮或计时器上:
import { Node, Node3D } from "godot";
export default class ReleaseDemo extends Node3D {
cube?: Node3D;
_ready(): void {
this.cube = this.get_node("Cube") as Node3D;
}
release(): void {
this.cube?.queue_free(); // 释放的是 Godot 侧对象
}
}脚本 B 每物理帧调用 cube 的方法。先不加检查,点一次释放后立刻得到运行时报错(对象已被释放);然后补上两道关卡:
import { GD, Node, Node3D } from "godot";
export default class SafeConsumer extends Node {
target?: Node3D;
_physics_process(_delta: number): void {
// 关卡一:我到底存没存过引用;关卡二:对象还活着吗
if (this.target && GD.is_instance_valid(this.target)) {
this.target.call("refresh");
}
}
}修复的验收标准:释放后不再报错,且 _physics_process 安静地跳过无效目标。如果调试时想确认对象到底是”没存过”还是”已释放”,在这两道关卡之间用 GD.print 分别记录两种情况即可。
下一步
- 让 TS 脚本被编辑器”看见”:导出属性、信号与 RPC 注解(050 篇);
- GDScript 侧怎么加载与调用 TS(preload、autoload):040 篇;
- Quaver 仓库的”GDScript 演出 + TS 乐理”分工样本:https://github.com/fanquanpp/quaver