GameTech/Godot 4.7 互动教程
目录示例游戏
第一部分 · 入门 · 第 3 章

GDScript 速成

用一章的篇幅把 GDScript 过一遍:静态类型和 := 推断、函数、控制流和 match、数组和字典、@export/@onready、setter、类和内部类、static、lambda、await,最后用可以单步执行的时间轴弄清楚 _init、_enter_tree、_ready、_process、_physics_process、_exit_tree 的先后顺序。

GDScript 是 Godot 自带的脚本语言。语法像 Python:靠缩进分块,没有分号和大括号。但它不是 Python,它是为引擎量身定做的:每个脚本就是一个类,可以直接继承引擎里的节点类型;Vector2、Color 这些游戏里天天用的类型是语言内置的;编辑器能根据类型提示做补全和编译期检查。

这一章假设你写过别的语言(JavaScript、Python、C# 都行),所以不会解释"什么是变量",而是把重点放在 GDScript 和别的语言不一样的地方,以及新手最常踩的坑。本章所有语法片段都放在示例工程的 snippets/ 文件夹里,能直接编译运行,文中写的输出结果都是在 Godot 4.7.2 里实际跑出来的。

1. 一个脚本文件就是一个类

先看示例工程里一个完整的小脚本的开头。它来自"生命周期"小场景,每个生命周期回调被调用时就往日志里写一行:

lifecycle/life_node.gd第 1–11 行脚本开头:说明、类名、父类、成员变量在工程中查看 ↗
## 一个"会说话"的节点:每个生命周期回调被调用时,都往 LifeLog 里写一行。
class_name LifeNode
extends Node

var label := ""
var feeds_timeline := false   # 只有 Root 把每一帧的调用时间记到时间轴上
var process_calls := 0
var physics_calls := 0
var last_callback := ""
var last_usec := 0

逐行看:

  • ## 开头的是文档注释,会显示在编辑器的帮助和检查器的悬停提示里;普通注释用一个 #。
  • class_name LifeNode 给这个类起一个全局名字。之后在任何脚本里都可以直接写 LifeNode.new(),或者把它当类型用:var n: LifeNode。不写 class_name 也行,这时候这个类只能通过文件路径来用:preload("res://lifecycle/life_node.gd").new()。
  • extends Node 表示继承自 Node。没写 extends 时默认继承 RefCounted(一个引用计数的普通对象,不是节点)。
  • 文件顶层的 var 是成员变量,每个实例各有一份。

把脚本挂到场景里的节点上,那个节点就变成了这个类的实例。所以 Godot 里"写一个敌人"的意思,通常是"新建一个 CharacterBody2D 节点,给它挂一个 extends CharacterBody2D 的脚本"。

2. 变量和静态类型

GDScript 是渐进式类型的:可以完全不写类型(像 Python),也可以处处写类型(像 C#)。写了类型的好处有三个:编辑器补全更准;很多错误在编译时就能发现,不用等运行到那一行;而且带类型的代码运行得更快,因为虚拟机可以跳过运行时的类型判断。本教程的示例代码全部写了类型。

snippets/gdscript_basics.gd第 6–14 行三种声明方式:不写类型、显式类型、:= 推断在工程中查看 ↗
var hp = 100                 # 动态类型:以后可以赋任何类型的值
var speed: float = 120.0     # 显式静态类型
var player_name := "Alice"   # := 根据右边的值推断类型(这里是 String),之后不能再赋别的类型
var lives := 3               # 推断为 int
var ratio := 3 / 2           # 两个 int 相除还是 int,结果是 1
var ratio_f := 3 / 2.0       # 只要有一个是 float,结果就是 1.5
const MAX_LIVES := 5         # 常量,不能修改
enum State { IDLE, RUN, JUMP }   # 枚举,本质是一组 int 常量:IDLE=0, RUN=1, JUMP=2
var state := State.IDLE

三种写法的区别:

写法变量的类型之后能赋别的类型吗
var hp = 100Variant(动态)能
var speed: float = 120.0float(你写的)不能
var player_name := "Alice"String(从右边推断)不能

:= 是最常用的写法:不用重复写类型,又能得到静态类型的全部好处。只有当右边的类型不明确(比如 null,或者一个返回 Variant 的函数)时,才需要显式写类型。

const 声明常量,enum 声明一组有名字的整数常量。enum State { IDLE, RUN, JUMP } 之后,State.RUN 就是 1。枚举很适合做状态机的状态,后面的实战章节会大量使用。

下面这些小实验每一个都容易猜错。先自己想一下结果,再点开看 Godot 的实际输出:

两个最容易踩的类型坑

  • 整数除法:5 / 2 是 2,不是 2.5。算比例、算百分比时,常见的 bug 是 current / maximum 两个都是 int,结果永远是 0 或 1。示例里血条的写法是 float(current) / maxi(maximum, 1),先转成 float 再除。
  • 静默截断:var g := 10 之后 g = 2.7,g 会变成 2,不报错,只在编辑器里给一个黄色警告。所以写速度、坐标这类量时,初始值要写成 0.0 而不是 0:var speed := 0 会被推断成 int。

3. 函数

snippets/gdscript_basics.gd第 19–28 行参数类型、默认值、返回值类型、格式化字符串在工程中查看 ↗
## 参数和返回值都可以写类型;带默认值的参数要放在后面。
func damage(amount: int, critical := false) -> int:
	var result := amount * 2 if critical else amount   # 三元表达式:值 if 条件 else 值
	hp -= result
	return result


## 没有返回值就写 -> void
func say_hello(who: String) -> void:
	print("你好,%s!" % who)   # % 格式化:%s 字符串,%d 整数,%.2f 两位小数

要点:

  • 函数用 func 声明,参数写成 名字: 类型,带默认值的参数可以用 := 推断类型,返回类型写在 -> 后面。没有返回值写 -> void。
  • GDScript 没有函数重载(同名函数不同参数),也没有命名参数。参数多了时,常见的做法是传一个字典或者一个 Resource。
  • 三元表达式的顺序和 Python 一样:值1 if 条件 else 值2。
  • 字符串格式化用 %:"%s 得了 %d 分" % [name, score]。占位符和 C 的 printf 基本一致:%5.1f 宽度 5、一位小数,%-4d 左对齐,%05.1f 用 0 补齐。实测 "%05.1f|%-4d|%s" % [3.14159, 7, true] 的结果是 003.1|7 |true。

以下划线开头的函数(比如 _ready)通常是引擎回调:你不调用它们,引擎在特定时机调用。自己写的私有辅助函数也习惯加下划线(比如示例里的 _log()),但这只是约定,GDScript 没有真正的 private。

4. 控制流

snippets/gdscript_basics.gd第 33–49 行if / elif / else、for、while在工程中查看 ↗
func control_flow_demo() -> void:
	if hp > 50:
		print("状态良好")
	elif hp > 0:
		print("快不行了")
	else:
		print("倒下了")

	for i in 3:                       # 0, 1, 2(等价于 range(3))
		print("第 ", i, " 次")
	for i in range(10, 0, -3):        # 10, 7, 4, 1
		print(i)

	var n := 0
	while n < 3:
		n += 1
	print("n = ", n)

for i in 3 是 for i in range(3) 的简写,得到 0、1、2。range(10, 0, -3) 的三个参数是起点、终点(不含)、步长,实际输出 10、7、4、1。for 也可以直接遍历数组的元素、字典的键、字符串的字符。没有 C 风格的 for (i = 0; …),也没有 i++,要写 i += 1。

match 相当于别的语言的 switch,但更强:除了匹配常量,还能匹配数组和字典的"形状",并把里面的值取出来:

snippets/gdscript_basics.gd第 54–69 行match 的几种模式在工程中查看 ↗
func describe(value: Variant) -> String:
	match value:
		0:
			return "零"
		1, 2, 3:
			return "很小"
		"start":
			return "字符串 start"
		[var x, var y]:                       # 数组模式:正好两个元素,并把它们绑定到 x、y
			return "二元数组 %s, %s" % [x, y]
		{"type": "coin", ..}:                  # 字典模式:有 type 键且值是 coin,.. 表示允许有别的键
			return "金币字典"
		var other when other is int and other < 0:   # 守卫条件(4.4 起支持 when)
			return "负数"
		_:
			return "其他"

实际调用的结果:describe(2) 返回"很小",describe([1, 2]) 返回"二元数组 1, 2",describe({"type": "coin", "value": 5}) 返回"金币字典",describe(-7) 返回"负数",describe(3.5) 落到 _ 返回"其他"。注意 match 的分支不会贯穿(没有 fallthrough),也不需要 break,匹配到第一个就停。

5. 数组和字典

snippets/gdscript_basics.gd第 74–84 行Array 和类型化数组 Array[int]在工程中查看 ↗
func arrays_demo() -> void:
	var items := ["剑", "盾", 3]               # 普通数组:什么类型都能放
	var scores: Array[int] = [90, 72, 85]     # 类型化数组:只能放 int
	scores.append(60)
	scores.sort()                             # [60, 72, 85, 90]
	print(scores, " 最高分 ", scores.max(), " 共 ", scores.size(), " 个")
	print(items[0], " / 最后一个:", items[-1])  # 负数下标从末尾数
	var passed := scores.filter(func(s: int) -> bool: return s >= 80)   # [85, 90]
	var doubled := scores.map(func(s: int) -> int: return s * 2)
	print(passed, " ", doubled)
	print("有 72 吗?", 72 in scores)

输出是 [60, 72, 85, 90] 最高分 90 共 4 个、剑 / 最后一个:3、[85, 90] [120, 144, 170, 180]。几点说明:

  • 普通的 Array 什么都能放;Array[int] 是类型化数组,只能放 int,往里 append("x") 会在运行时报错并且不会放进去。类型化数组遍历时编辑器知道元素类型,补全和性能都更好。
  • filter、map、reduce、any、all、sort_custom 都接收一个函数(下面第 9 节的 lambda)。
  • in 判断是否包含。负数下标从末尾数,items[-1] 是最后一个。
snippets/gdscript_basics.gd第 89–99 行Dictionary 和类型化字典 Dictionary[String, int]在工程中查看 ↗
func dicts_demo() -> void:
	var player := {"name": "Alice", "hp": 100, "pos": Vector2(3, 4)}
	player["hp"] -= 30
	player.level = 2                          # 键是字符串时,也可以用点号读写
	print(player.name, " hp=", player["hp"], " 等级 ", player.get("level", 1))
	print("有 mp 键吗?", player.has("mp"), "  mp 默认 ", player.get("mp", 0))
	for key in player:                        # 遍历的是键(按插入顺序)
		print(key, " -> ", player[key])
	var stock: Dictionary[String, int] = {"苹果": 3, "香蕉": 5}   # 类型化字典(4.4 起)
	stock["苹果"] += 1
	print(stock)

字典保持插入顺序,遍历得到的是键。键是字符串时可以用点号访问,player.name 和 player["name"] 等价。读一个不存在的键时,player["mp"] 会报错,player.get("mp", 0) 返回默认值,后者更安全。从 Godot 4.4 起字典也可以写类型:Dictionary[String, int],上面的 stock 最后打印出 { "苹果": 4, "香蕉": 5 }。

数组和字典是引用类型。var b := a 之后,b 和 a 是同一个数组,往 b 里加元素,a 也会变。需要独立的副本时用 a.duplicate()(嵌套的数组/字典要 duplicate(true) 深拷贝)。int、float、Vector2、String 这些则是值类型,赋值就是复制。上面的"猜猜结果"小部件里有一个对应的例子。

6. @export 和 @onready

以 @ 开头的是注解(annotation),给变量或脚本附加额外的信息。最常用的两个:

snippets/gdscript_classes.gd第 6–14 行@export 系列和 @onready在工程中查看 ↗
@export var max_speed := 300.0                 # 出现在检查器里,可以在编辑器里改
@export_range(0, 100, 5) var armor := 20       # 带范围的滑块
@export var tint := Color.WHITE
@export_enum("战士", "法师", "弓手") var job := "战士"
@export_group("掉落")                           # 检查器里的分组标题
@export var drop_table: Array[String] = ["金币"]

@onready var label: Label = $Label             # 等节点进树、_ready() 之前才取值
@onready var start_pos := position             # 也可以是任意表达式
  • @export:把变量显示在检查器里,可以在编辑器中按实例修改,修改的值保存在 .tscn 场景文件里。同一个敌人场景实例化 10 次,每个实例可以有不同的速度。@export_range、@export_enum、@export_group 等变体控制检查器里的显示方式。
  • @onready:变量的初始值不是在对象创建时计算,而是推迟到节点进入场景树、_ready() 被调用之前才计算。$Label(也就是 get_node("Label"))只有在节点进树之后才能找到子节点,所以取子节点的变量几乎都要加 @onready。

本教程网页上的每个滑块,背后都是一个 @export 变量。比如入门示例的场景切换,就是一个带 @export_enum 的字符串变量,网页改它的值时,setter 负责换场景:

main.gd第 17–22 行网页上的「当前场景」下拉框对应的变量在工程中查看 ↗
## 当前显示哪个小场景。网页上切换时,Tuning 会直接给它赋值,setter 负责换场景。
@export_enum("太阳系", "信号实验室", "生命周期") var scene := "太阳系":
	set(value):
		scene = value
		if is_node_ready():
			_load_scene()

从 Godot 3 迁移过来的常见坑

  • export var speed = 100 → @export var speed := 100.0;onready var → @onready var;export(int, 0, 100) → @export_range(0, 100)。
  • setget 关键字没有了,改成下一节的 set(value): / get: 写法。
  • yield(obj, "signal") 没有了,改成 await obj.signal。
  • tool → @tool,master/puppet 这类网络关键字被 @rpc 取代。
  • 在 _init() 里用 $Child 取子节点:在 Godot 3 和 4 里都是错的,这时节点还不在树里。要么用 @onready,要么放到 _ready()。

7. setter 和 getter

给成员变量赋值时自动执行一段代码,是游戏里非常常见的需求:血量变了要刷新血条,网格大小变了要重建网格。GDScript 4 的写法是在变量声明后面跟一个缩进块:

snippets/gdscript_classes.gd第 19–31 行set(value): 和 get:在工程中查看 ↗
## 属性的 setter / getter:赋值时自动执行一段代码。
var health := 100:
	set(value):
		health = clampi(value, 0, max_health)   # 在 setter 里给自己赋值,不会再次触发 setter
		if health == 0:
			print("倒下了")
	get:
		return health

var max_health := 100
var is_alive: bool:                             # 只有 getter 的"计算属性"
	get:
		return health > 0

实际运行:health = 150 之后 health 是 100(被 setter 限制住了);health -= 200 会打印"倒下了",之后 is_alive 是 false。注意 health -= 200 同样会经过 setter,+=、-= 都是赋值。

在 setter 内部给 health 自己赋值,不会再次触发 setter,所以不会死循环。但在同一个类的其他方法里给 health 赋值,会触发 setter,这和 Godot 3 的 setget 不一样(Godot 3 里类内部要写 self.health 才触发)。

生命周期示例用 setter 把网页上的参数转发给引擎的全局设置:

lifecycle/lifecycle.gd第 17–34 行改变量 = 改引擎设置在工程中查看 ↗
@export var physics_ticks := 60:            ## 每秒物理帧数
	set(value):
		physics_ticks = value
		Engine.physics_ticks_per_second = value
@export var time_scale := 1.0:              ## 时间缩放:传给回调的 delta 会乘上它
	set(value):
		time_scale = value
		Engine.time_scale = value
@export var paused := false:                ## 暂停场景树
	set(value):
		paused = value
		if is_inside_tree():
			get_tree().paused = value
@export var physics_interpolation := false: ## 物理插值:让低物理帧率下的移动也显得平滑
	set(value):
		physics_interpolation = value
		if is_inside_tree():
			get_tree().physics_interpolation = value

paused 的 setter 里多了一个 if is_inside_tree():setter 可能在节点进树之前就被调用(比如场景文件里保存了这个值,加载时就会赋值),这时 get_tree() 还是 null。写 setter 时要考虑"节点还没准备好"的情况,常用的判断是 is_inside_tree() 或 is_node_ready()。

8. 类、内部类和 static

除了"一个文件一个类",还可以在类里面再写类,叫内部类。适合只在这个文件里用的小数据结构,不值得单独开一个文件:

snippets/gdscript_classes.gd第 36–50 行class Item:内部类、构造函数 _init、_to_string在工程中查看 ↗
## 内部类:写在别的类里面的类。适合只在这个文件里用的小数据结构。
class Item:
	var name: String
	var price: int

	func _init(p_name: String, p_price := 1) -> void:   # 构造函数,new() 的参数传到这里
		name = p_name
		price = p_price

	func _to_string() -> String:                          # print() 时显示的文字
		return "%s(%d 金)" % [name, price]


func make_items() -> Array[Item]:
	return [Item.new("药水", 5), Item.new("钥匙")]

_init() 是构造函数,Item.new("药水", 5) 的参数会传给它。_to_string() 决定 print() 时显示什么,所以 print(make_items()) 输出 [药水(5 金), 钥匙(1 金)]。

static 的成员属于类本身,不属于某个实例:

snippets/gdscript_classes.gd第 55–63 行静态变量和静态函数在工程中查看 ↗
## static:属于"类"本身,不属于某个实例。不用创建对象就能调用。
static var created_count := 0      # 所有实例共享的一个变量

static func lerp_color(a: Color, b: Color, t: float) -> Color:
	return a.lerp(b, t)


func _init() -> void:
	created_count += 1

创建两个实例后,created_count 是 2,两个实例看到的是同一个变量。静态函数里不能访问实例成员(没有 self)。

生命周期示例里的 LifeLog 就是一个只有静态成员的工具类。用静态变量的原因写在注释里:_init() 被调用时节点还不在场景树里,没办法用 get_node() 去找一个"日志节点",但静态变量随时都能访问:

lifecycle/life_log.gd第 1–20 行LifeLog:任何地方都能直接 LifeLog.write(...)在工程中查看 ↗
## 生命周期日志:一个只有静态成员的类,任何地方都能直接 LifeLog.write(...)。
## 用静态变量是因为 _init() 被调用时节点还不在场景树里,没法用 get_node 去找别的节点。
class_name LifeLog
extends RefCounted

const WINDOW_USEC := 1_500_000            # 时间轴显示最近 1.5 秒

static var entries: Array[Dictionary] = []
static var max_frame_logs := 3            # 每个节点只记录前几次 _process/_physics_process
static var process_times: Array[int] = []    # 最近 1.5 秒内每次 _process 的时间戳(微秒)
static var physics_times: Array[int] = []
static var last_process_delta := 0.0
static var last_physics_delta := 0.0


static func write(who: String, callback: String, extra := "") -> void:
	entries.append({"who": who, "cb": callback, "extra": extra,
		"frame": Engine.get_process_frames(), "usec": Time.get_ticks_usec()})
	if entries.size() > 300:
		entries.pop_front()

什么时候用 static,什么时候用自动加载?静态变量适合纯数据、纯函数(数学工具、日志、配置常量)。需要节点功能的全局对象(要收 _process、要发信号、要挂子节点、要在编辑器里看到)用自动加载(Autoload),下一章的事件总线就是一个例子。

9. lambda:把函数当成值

snippets/gdscript_lambda_await.gd第 8–24 行lambda 的写法和捕获规则在工程中查看 ↗
func lambda_demo() -> void:
	var square := func(x: int) -> int: return x * x    # 把函数存进变量,类型是 Callable
	print(square.call(7))                              # 49

	var counter := 0
	var add := func() -> void: counter += 1            # lambda 捕获的是外部变量当时的"值"
	add.call()
	print("counter 还是 ", counter)                    # 0:基本类型按值捕获

	var hits: Array[int] = []
	var record := func(n: int) -> void: hits.append(n)  # 数组是引用类型,改的是同一个数组
	record.call(5)
	print("hits = ", hits)                             # [5]

	var words := ["banana", "fig", "apple"]
	words.sort_custom(func(a: String, b: String) -> bool: return a.length() < b.length())
	print(words)                                       # ["fig", "apple", "banana"]

lambda 用 func(参数) -> 返回类型: 函数体 写,结果是一个 Callable,调用时要写 .call()。实际输出依次是 49、counter 还是 0、hits = [5]、["fig", "apple", "banana"]。

第二个输出是 GDScript lambda 最反直觉的地方:lambda 按值捕获外部的局部变量。add 创建时把 counter 的值 0 复制了一份,之后在 lambda 里 counter += 1 改的是那份副本。数组、字典、对象是引用类型,复制的是"引用",所以 record 往 hits 里加元素是有效的。需要在 lambda 里累加一个数时,可以用成员变量,或者把数放进数组里。

lambda 在游戏代码里最常见的用途是给信号连一个很短的回调,以及数组的 filter/map/sort_custom。信号的部分下一章细讲。

10. await:等一等再往下走

游戏里有很多"等":等 1 秒再复活、等动画播完再切场景、等玩家点按钮。如果用计时变量在 _process 里一帧帧地数,代码会被拆得很碎。await 让函数在某一行暂停,等一个信号发出后再从这一行接着执行,而游戏的其余部分照常运行:

snippets/gdscript_lambda_await.gd第 29–47 行await 计时器、await 自定义信号、调用协程时写不写 await 的区别在工程中查看 ↗
## 带 await 的函数就是"协程":执行到 await 时先返回,等信号来了再从这里接着往下走。
func countdown() -> void:
	for i in range(3, 0, -1):
		print("倒计时 ", i)
		await get_tree().create_timer(0.5).timeout    # 等 0.5 秒(SceneTreeTimer 的 timeout 信号)
	print("出发!")


func ask() -> String:
	var text: String = await answered               # 等这个信号,并拿到它的参数
	return "收到回答:" + text


func await_demo() -> void:
	print("A:调用 countdown 之前")
	countdown()                                     # 没写 await:不等它,马上往下走
	print("B:countdown 已经开始,但还没结束")
	await countdown()                               # 写了 await:等它整个执行完
	print("C:第二次倒计时结束")

调用 await_demo() 的实际输出(每次倒计时间隔 0.5 秒):

A:调用 countdown 之前
倒计时 3
B:countdown 已经开始,但还没结束
倒计时 3
倒计时 2
倒计时 2
倒计时 1
倒计时 1
出发!
出发!
C:第二次倒计时结束

这段输出值得仔细看:

  1. 第一次 countdown() 没有写 await。它打印"倒计时 3"后遇到 await,就立刻返回到调用者,所以紧接着打印的是 B。
  2. 第二次 await countdown() 写了 await,await_demo 会等它整个跑完。
  3. 两次倒计时在同时进行,所以"倒计时 2""倒计时 1""出发!"各出现两次,交替打印。C 要等第二次倒计时结束才出现。

ask() 演示了 await 一个带参数的信号:var text: String = await answered,信号发出时传的参数就是 await 表达式的值。信号有多个参数时,结果是一个数组。

await 的两个坑

  • 节点在等待期间被删除了,等待就会作废,函数后半段不会执行。这通常正是你想要的,但如果后半段有"必须执行"的清理工作,要另想办法。
  • await 只能等信号或者另一个协程函数。await 1.0 不会报错但不会等待,要写 await get_tree().create_timer(1.0).timeout。

11. 生命周期:回调的先后顺序

节点脚本里最重要的几个函数都不是你调用的,而是引擎在特定时机调用的。弄清楚它们的顺序,能避免一大类"为什么这里是 null"的 bug:

回调什么时候调用典型用途
_init()对象被创建时(new() 或场景实例化)初始化纯数据。这时还不在树里,不能取子节点、不能 get_tree()
_enter_tree()节点加入场景树时,父节点先、子节点后很少用。需要在子节点 ready 之前做事时用
_ready()节点和它的所有子节点都进树之后,子节点先、父节点后;每个节点只调用一次取子节点、连接信号、初始化状态。最常用
_process(delta)每画一帧画面调用一次动画、UI、不涉及物理的移动
_physics_process(delta)每个固定的物理步调用一次(默认每秒 60 次)移动角色、物理相关的逻辑
_exit_tree()离开场景树时,子节点先、父节点后收尾:断开外部连接、保存数据

为什么 _ready() 是子节点先?因为父节点在 _ready() 里经常要用子节点(读它的属性、连它的信号),这时子节点必须已经准备好了。反过来,_enter_tree() 父节点先,因为子节点进树的前提是父节点已经在树里。

下面这个小部件模拟了一棵四个节点的小树从创建到删除的全过程。用"下一步"一步步看,注意左边树里节点状态的变化:

小部件里的顺序和真实引擎一致:我用一个测试脚本在 Godot 4.7.2 里建了同样的四个节点,输出的顺序是 _init Root、Player、Sprite、HUD;_enter_tree Root、Player、Sprite、HUD;_ready Sprite、Player、HUD、Root;每帧先跑完这一帧所有的 _physics_process,再跑 _process,每一轮都按树的顺序(从上到下)调用;queue_free() 之后 Player 当帧仍然收到 _process,帧结束时 Sprite 先 _exit_tree,然后才是 Player。

两种 process 和累加器

_process 跟着画面走:显示器 144Hz,它就一秒调用 144 次;卡到 20 帧,就一秒 20 次。delta 是距离上一帧的秒数,每帧都不一样。

_physics_process 按固定频率走,默认 60 次/秒,delta 恒定是 1/60。它的实现是一个累加器:每帧把这一帧经过的时间加进累加器,只要累加器里够一个物理步,就扣掉一步、跑一次 _physics_process,直到不够为止:

acc += 帧间隔; while acc ≥ 1/物理帧率: acc -= 1/物理帧率; 跑一次物理步

所以渲染帧率高于物理帧率时,有的帧没有物理步;低于时,一帧里会连着补跑好几步(每帧最多补 8 步,见 Engine.max_physics_steps_per_frame,防止越卡越补、越补越卡)。在小部件里把"渲染帧率"拖到 20、"物理帧率"保持 60,就能看到每帧补跑 3 步;把渲染帧率拖到 144,就能看到很多帧的物理行是空的。

在真实引擎里看

入门示例的"生命周期"小场景做的是同一件事,只不过是真的引擎在跑。左边是 Specimens 下的节点树;中间左栏按顺序记录进树、就绪、离开,右栏是每帧回调的前几次;下面两个小球分别在 _process 和 _physics_process 里移动;最下面的时间轴每条竖线是 Root 的一次回调。

每个节点都挂着同一个脚本,每个回调只做一件事:往日志里写一行。

lifecycle/life_node.gd第 14–49 行LifeNode:每个生命周期回调都写一行日志在工程中查看 ↗
func _init(p_label := "") -> void:
	label = p_label
	_log("_init", "还不在场景树里")


func _enter_tree() -> void:
	_log("_enter_tree")


func _ready() -> void:
	_log("_ready", "子节点都已就绪")


func _process(delta: float) -> void:
	process_calls += 1
	if feeds_timeline:
		LifeLog.tick(false, delta)
	if process_calls <= LifeLog.max_frame_logs:
		_log("_process", "delta = %.4f" % delta)


func _physics_process(delta: float) -> void:
	physics_calls += 1
	if feeds_timeline:
		LifeLog.tick(true, delta)
	if physics_calls <= LifeLog.max_frame_logs:
		_log("_physics_process", "delta = %.4f" % delta)


func _exit_tree() -> void:
	_log("_exit_tree")


func _notification(what: int) -> void:
	if what == NOTIFICATION_PREDELETE:
		_log("PREDELETE", "对象真正被释放")

注意最后的 _notification(what)。所有的生命周期回调本质上都是"通知"(notification),_ready() 只是 NOTIFICATION_READY 的一个方便写法。有些通知没有对应的虚函数,比如对象真正被释放前的 NOTIFICATION_PREDELETE,就要在 _notification() 里自己判断。

"重新生成"按钮执行的是下面这段代码。仔细看注释里标出的每个时机:

lifecycle/lifecycle.gd第 82–98 行先在树外搭好整棵树,最后一次性 add_child在工程中查看 ↗
func respawn() -> void:
	if is_instance_valid(_root):
		_root.free()                          # 立即删除:马上触发 _exit_tree
	LifeLog.write("", "", "———— 重新生成 ————")
	_root = LifeNode.new("Root")              # _init() 在这一行被调用,此时还不在场景树里
	_root.name = "Root"
	_root.feeds_timeline = true
	for i in child_count:
		var child := LifeNode.new("Child%d" % (i + 1))
		child.name = child.label
		_root.add_child(child)                # Root 还不在树里,所以这里不会触发 _enter_tree
		if i == 0:
			var grand := LifeNode.new("GrandChild")
			grand.name = grand.label
			child.add_child(grand)
	_next_id = child_count + 1
	specimens.add_child(_root)                # 整棵树一起进树:先 _enter_tree,再 _ready

Root 和子节点在 add_child 到 specimens 之前已经组装好了,但它们都还不在场景树里,所以组装期间只有 _init。最后一行把整棵树挂上去,这时 _enter_tree 和 _ready 才按顺序发生。另外两个按钮演示"运行中途"的增删:

lifecycle/lifecycle.gd第 103–118 行中途添加:马上 ready;queue_free:等这一帧结束在工程中查看 ↗
func add_child_node() -> void:
	if not is_instance_valid(_root):
		return
	LifeLog.write("", "", "———— 添加子节点 ————")
	var child := LifeNode.new("Child%d" % _next_id)
	child.name = child.label
	_next_id += 1
	_root.add_child(child)          # Root 已经在树里,新节点马上 _enter_tree、_ready


func remove_child_node() -> void:
	if not is_instance_valid(_root) or _root.get_child_count() == 0:
		return
	var last := _root.get_child(-1)
	LifeLog.write("", "", "———— %s.queue_free() ————" % last.name)
	last.queue_free()               # 不会立刻删除,等这一帧结束才 _exit_tree

动手试试

  1. 点"重新生成",看日志左栏:_init 四行、_enter_tree 四行(Root 在最前),_ready 四行(GrandChild 在最前、Root 在最后)。把"子节点数量"拖到 4,树会立刻重建,再数一遍。
  2. 点"添加子节点":新节点马上出现 _init、_enter_tree、_ready,说明中途加入的节点同样会 ready。点"删除子节点":先出现 queue_free() 标记,然后才是 _exit_tree 和 PREDELETE。
  3. 把"物理帧率"拖到 5:时间轴上紫色竖线变得稀疏,下面在 _physics_process 里移动的紫色小球开始一顿一顿地跳,橙色小球仍然平滑。勾上"物理插值",紫色小球又变平滑了:引擎在两个物理步之间插值显示位置。
  4. 把"时间缩放"拖到 0.5:两条时间轴上竖线的密度不变,但日志右栏里的 delta 变成原来的一半,小球移动变慢。
  5. 勾上"暂停场景树":两条时间轴都停了,小球也停了,但界面和按钮还能用,因为根节点的 process_mode 是 ALWAYS。

第 4 步的现象值得记住:Engine.time_scale 只缩放传给回调的 delta,不改变调用次数。我实测过:time_scale = 0.5、物理帧率 20 时,2 秒真实时间里 _physics_process 被调用了 42 次(约每秒 21 次,和 time_scale 为 1 时一样),每次的 delta 是 0.025(= 0.05 × 0.5)。所以做慢动作时,只要你的移动代码都乘了 delta,整个游戏就会一起变慢;如果某处写死了"每帧移动 5 像素",它就不会跟着变慢。这也是为什么移动一定要乘 delta。

场景树是 Main → [Player → [Weapon], HUD]。游戏启动时,_ready() 的调用顺序是?

  1. Main、Player、Weapon、HUD
  2. HUD、Weapon、Player、Main
  3. Weapon、Player、HUD、Main
  4. Weapon、HUD、Player、Main

_ready() 是"子节点全部就绪后才轮到父节点",同级之间按树里的顺序。Player 要等 Weapon,Main 要等 Player 和 HUD,所以是 Weapon、Player、HUD、Main。_enter_tree() 才是选项 1 的顺序。

下面哪种写法能让 label 正确拿到子节点 Label?

  1. var label: Label = $Label(不加注解)
  2. @onready var label: Label = $Label
  3. var label: Label,然后在 _init() 里写 label = $Label
  4. const label := $Label

成员变量的初始值在对象创建时(_init 之前)计算,这时节点还不在树里,$Label 找不到。@onready 把计算推迟到 _ready() 之前,这时子节点都已经就绪。在 _init() 里取同样太早;const 只能是编译期常量。

执行 var total := 0,然后在一个 lambda 里 total += 1 并调用它三次,之后 total 是多少?

  1. 0
  2. 3
  3. 1
  4. 编译错误

GDScript 的 lambda 创建时按值捕获局部变量,lambda 里改的是自己的副本,外面的 total 仍然是 0。要在 lambda 里累加,用成员变量或者引用类型(数组、字典、对象)。

小结

  • 用 := 推断类型,速度、坐标的初始值写成 0.0;小心整数除法和 float 赋给 int 时的截断。
  • 数组、字典是引用类型,复制要 duplicate();类型化的 Array[T]、Dictionary[K, V] 更安全也更快。
  • @export 把变量交给检查器(和本教程的滑块);取子节点用 @onready;赋值时要做事用 setter,并考虑节点还没进树的情况。
  • lambda 按值捕获局部变量;await 让函数在等信号时先返回,调用者写不写 await 决定它要不要一起等。
  • 顺序:_init → _enter_tree(父先)→ _ready(子先)→ 每帧先物理步后 _process → _exit_tree(子先)。time_scale 只缩放 delta。

下一章讲节点之间怎么通信:信号。