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. 一个脚本文件就是一个类
先看示例工程里一个完整的小脚本的开头。它来自"生命周期"小场景,每个生命周期回调被调用时就往日志里写一行:
## 一个"会说话"的节点:每个生命周期回调被调用时,都往 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#)。写了类型的好处有三个:编辑器补全更准;很多错误在编译时就能发现,不用等运行到那一行;而且带类型的代码运行得更快,因为虚拟机可以跳过运行时的类型判断。本教程的示例代码全部写了类型。
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 = 100 | Variant(动态) | 能 |
var speed: float = 120.0 | float(你写的) | 不能 |
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. 函数
## 参数和返回值都可以写类型;带默认值的参数要放在后面。
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. 控制流
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,但更强:除了匹配常量,还能匹配数组和字典的"形状",并把里面的值取出来:
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. 数组和字典
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]是最后一个。
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),给变量或脚本附加额外的信息。最常用的两个:
@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 负责换场景:
## 当前显示哪个小场景。网页上切换时,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 的写法是在变量声明后面跟一个缩进块:
## 属性的 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 把网页上的参数转发给引擎的全局设置:
@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 = valuepaused 的 setter 里多了一个 if is_inside_tree():setter 可能在节点进树之前就被调用(比如场景文件里保存了这个值,加载时就会赋值),这时 get_tree() 还是 null。写 setter 时要考虑"节点还没准备好"的情况,常用的判断是 is_inside_tree() 或 is_node_ready()。
8. 类、内部类和 static
除了"一个文件一个类",还可以在类里面再写类,叫内部类。适合只在这个文件里用的小数据结构,不值得单独开一个文件:
## 内部类:写在别的类里面的类。适合只在这个文件里用的小数据结构。
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 的成员属于类本身,不属于某个实例:
## 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() 去找一个"日志节点",但静态变量随时都能访问:
## 生命周期日志:一个只有静态成员的类,任何地方都能直接 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:把函数当成值
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 让函数在某一行暂停,等一个信号发出后再从这一行接着执行,而游戏的其余部分照常运行:
## 带 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:第二次倒计时结束这段输出值得仔细看:
- 第一次
countdown()没有写await。它打印"倒计时 3"后遇到await,就立刻返回到调用者,所以紧接着打印的是 B。 - 第二次
await countdown()写了await,await_demo会等它整个跑完。 - 两次倒计时在同时进行,所以"倒计时 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 的一次回调。
每个节点都挂着同一个脚本,每个回调只做一件事:往日志里写一行。
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() 里自己判断。
"重新生成"按钮执行的是下面这段代码。仔细看注释里标出的每个时机:
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,再 _readyRoot 和子节点在 add_child 到 specimens 之前已经组装好了,但它们都还不在场景树里,所以组装期间只有 _init。最后一行把整棵树挂上去,这时 _enter_tree 和 _ready 才按顺序发生。另外两个按钮演示"运行中途"的增删:
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动手试试
- 点"重新生成",看日志左栏:
_init四行、_enter_tree四行(Root 在最前),_ready四行(GrandChild 在最前、Root 在最后)。把"子节点数量"拖到 4,树会立刻重建,再数一遍。 - 点"添加子节点":新节点马上出现
_init、_enter_tree、_ready,说明中途加入的节点同样会 ready。点"删除子节点":先出现queue_free()标记,然后才是_exit_tree和PREDELETE。 - 把"物理帧率"拖到 5:时间轴上紫色竖线变得稀疏,下面在
_physics_process里移动的紫色小球开始一顿一顿地跳,橙色小球仍然平滑。勾上"物理插值",紫色小球又变平滑了:引擎在两个物理步之间插值显示位置。 - 把"时间缩放"拖到 0.5:两条时间轴上竖线的密度不变,但日志右栏里的 delta 变成原来的一半,小球移动变慢。
- 勾上"暂停场景树":两条时间轴都停了,小球也停了,但界面和按钮还能用,因为根节点的
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() 的调用顺序是?
- Main、Player、Weapon、HUD
- HUD、Weapon、Player、Main
- Weapon、Player、HUD、Main
- Weapon、HUD、Player、Main
_ready() 是"子节点全部就绪后才轮到父节点",同级之间按树里的顺序。Player 要等 Weapon,Main 要等 Player 和 HUD,所以是 Weapon、Player、HUD、Main。_enter_tree() 才是选项 1 的顺序。
下面哪种写法能让 label 正确拿到子节点 Label?
var label: Label = $Label(不加注解)@onready var label: Label = $Labelvar label: Label,然后在_init()里写label = $Labelconst label := $Label
成员变量的初始值在对象创建时(_init 之前)计算,这时节点还不在树里,$Label 找不到。@onready 把计算推迟到 _ready() 之前,这时子节点都已经就绪。在 _init() 里取同样太早;const 只能是编译期常量。
执行 var total := 0,然后在一个 lambda 里 total += 1 并调用它三次,之后 total 是多少?
- 0
- 3
- 1
- 编译错误
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。
下一章讲节点之间怎么通信:信号。