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

信号与节点通信

信号是节点之间"我发生了某事"的广播。本章讲内置信号和自定义信号、connect 的各种写法、一次性连接、"向下调用、向上发信号"的原则、事件总线,以及用 await 等待一个信号。

上一章的生命周期回调解决的是"引擎什么时候调用我"。这一章解决另一个问题:节点之间怎么互相通知。按钮被点了,谁去扣血?血量变了,血条怎么知道?宝箱打开了,计分板和成就系统怎么得到消息?Godot 给出的答案是信号(signal)。

本章的示例是"信号实验室":画面上的每一条彩色连线都是一条真实存在的信号连接,是在运行时用 get_signal_connection_list() 读出来的,不是画死的。

1. 为什么需要信号

最直接的写法是互相调用:史莱姆受伤时,直接 get_node("../HealthBar").update_bar(...)。这样能跑,但史莱姆从此离不开血条:把史莱姆单独拿出来测试,或者换一个界面布局,路径就断了;再加一个"受伤时屏幕抖动",又要回来改史莱姆的代码。

信号把"发生了什么"和"谁要做什么"拆开:

  • 史莱姆只声明 signal health_changed(current, maximum),血量变了就 emit()。它不知道、也不关心谁在听。
  • 谁关心血量,谁就把自己的方法 connect() 到这个信号上。可以没有人听,也可以有十个。

这就是观察者模式。和其他语言的事件、回调、委托是一回事,Godot 把它做成了语言和编辑器的一等公民。

2. 内置信号和自定义信号

引擎自带的节点已经定义了大量内置信号:Button.pressed、Timer.timeout、Area2D.body_entered(body)、AnimationPlayer.animation_finished(anim_name)、Node.tree_exited……在检查器旁边的"节点"面板 → "信号"标签页里可以看到选中节点的全部信号。

自己的脚本用 signal 关键字声明自定义信号,可以带参数:

snippets/signals_basics.gd第 6–8 行声明信号在工程中查看 ↗
signal hit                                   # 不带参数
signal health_changed(old: int, new: int)    # 带参数(参数类型只是说明,发信号时不强制检查)
signal item_picked(item_name: String, count: int)

信号实验室里的史莱姆声明了两个信号,受伤时发出它们:

signals/slime.gd第 7–8 行在工程中查看 ↗
signal health_changed(current: int, maximum: int)   ## 血量变了:新血量、最大血量
signal died                                         ## 血量归零
signals/slime.gd第 24–32 行血量变了就 emit,不关心谁在听在工程中查看 ↗
func take_damage(amount: int) -> void:
	if health <= 0:
		return
	health = maxi(health - amount, 0)
	_hit = 1.0
	_squash = 1.0
	health_changed.emit(health, max_health)   # 发信号:谁关心谁去连,史莱姆自己不管
	if health == 0:
		died.emit()

信号名习惯用过去时或者"某某了":died、health_changed、opened,表示"刚刚发生了这件事"。

3. connect 的写法

Godot 4 里信号本身是一个对象(类型是 Signal),连接就是调用它的 connect() 方法,参数是一个 Callable——"一个可以被调用的东西",可以是某个对象的方法,也可以是 lambda:

snippets/signals_basics.gd第 16–33 行连接到方法、连接到 lambda、发出信号在工程中查看 ↗
func connect_styles() -> void:
	# 1. 连到一个方法:最常用
	button.pressed.connect(_on_button_pressed)
	# 2. 连到 lambda:逻辑很短、不想单独写方法时
	button.pressed.connect(func() -> void: log_lines.append("lambda 收到 pressed"))
	# 3. 自定义信号也一样,参数会按顺序传给回调
	health_changed.connect(_on_health_changed)
	# 4. 发信号:参数个数要和声明的一致
	health_changed.emit(100, 80)
	button.pressed.emit()                     # 内置信号也能用代码发出(模拟点击)


func _on_button_pressed() -> void:
	log_lines.append("方法收到 pressed")


func _on_health_changed(old: int, new: int) -> void:
	log_lines.append("血量 %d → %d" % [old, new])

这段代码运行后的输出(实际运行 Godot 4.7 得到)依次是 血量 100 → 80、方法收到 pressed、lambda 收到 pressed。注意两件事:信号参数按顺序传给回调;同一个信号的多个回调按连接的先后顺序依次执行。

回调需要额外的信息,或者不需要信号的某些参数时,用 bind() 和 unbind() 调整 Callable:

snippets/signals_basics.gd第 38–55 行bind 追加参数,unbind 丢掉参数在工程中查看 ↗
func bind_unbind() -> void:
	# bind:在信号参数后面"追加"额外参数。多个按钮共用一个回调时很有用
	for i in 3:
		var b := Button.new()
		b.pressed.connect(_on_slot_pressed.bind(i))
		b.pressed.emit()
		b.free()
	# unbind:丢掉信号的最后 n 个参数。回调不关心这些参数时用
	item_picked.connect(_on_anything.unbind(2))
	item_picked.emit("药水", 3)


func _on_slot_pressed(slot: int) -> void:
	log_lines.append("第 %d 个格子被点了" % slot)


func _on_anything() -> void:
	log_lines.append("有东西被捡起来了")

bind(i) 把 i 追加在信号参数后面:如果信号本身带参数 (a, b),回调收到的是 (a, b, i)。unbind(2) 丢掉信号的最后 2 个参数,这样不带参数的 _on_anything() 也能接 item_picked(name, count)。

信号是同步的

这一点很重要:emit() 会立刻、依次调用所有连接的回调,全部执行完才返回。没有排队,没有"下一帧"。下面的小部件把这个过程放慢了给你看:

如果确实想推迟到这一帧末尾再执行,连接时加 CONNECT_DEFERRED 标志。一般用于回调里要增删节点、而当前又处在物理回调中的情况。

4. 编辑器连接和代码连接

连接信号有两种方式,效果一样:

  • 编辑器里连:选中节点 → "节点"面板 → 双击信号 → 选接收节点,Godot 会生成一个 _on_节点名_信号名 的方法。连接关系保存在 .tscn 文件里([connection signal="pressed" from="Button" to="." method="_on_button_pressed"]),脚本编辑器里该方法左边会出现一个绿色的连接图标。
  • 代码里连:button.pressed.connect(_on_button_pressed)。连接关系写在代码里,阅读脚本时一目了然。运行时才创建的节点(实例化的子弹、敌人)只能用代码连。

两种方式可以混用,但同一个连接不要两边都做一遍:编辑器连过、代码又连一次,会报"已经连接"的错误。本教程的示例统一用代码连接,因为代码会被直接拉进网页里展示,读者看得见。

从 Godot 3 迁移的坑。3.x 的 connect("pressed", self, "_on_pressed") 和 emit_signal("hit", 10) 写法在 4.x 里要换成 pressed.connect(_on_pressed) 和 hit.emit(10)(字符串版本的 connect("pressed", callable)、emit_signal() 还能用,但拼错名字只会在运行时报错)。3.x 的 binds 数组参数没有了,改用 callable.bind();yield(timer, "timeout") 改成 await timer.timeout。另外,对同一个 Callable 重复 connect() 会报错,不确定时先用 is_connected() 检查。

5. 一次性连接和断开

有些事只需要响应一次:第一次开箱的成就、新手引导的第一步、对话结束后的回调。连接时传 CONNECT_ONE_SHOT 标志,触发一次后连接会自动断开:

snippets/signals_basics.gd第 60–68 行在工程中查看 ↗
func one_shot_demo() -> void:
	hit.connect(_on_first_hit, CONNECT_ONE_SHOT)   # 触发一次后自动断开
	hit.emit()
	hit.emit()                                     # 第二次不会再调用 _on_first_hit
	log_lines.append("还连着吗?%s" % hit.is_connected(_on_first_hit))


func _on_first_hit() -> void:
	log_lines.append("第一次被打!")

输出是 第一次被打! 只出现一次,然后 还连着吗?false。手动断开用 disconnect(),参数必须是"同一个" Callable——对象和方法名都相同:

snippets/signals_basics.gd第 73–77 行在工程中查看 ↗
func disconnect_demo() -> void:
	if not hit.is_connected(_on_first_hit):        # 重复 connect 同一个回调会报错,先检查
		hit.connect(_on_first_hit)
	hit.disconnect(_on_first_hit)                  # 断开时传入同一个 Callable
	log_lines.append("连接数:%d" % hit.get_connections().size())

信号实验室的"首次开箱"成就就是一次性连接。网页上的开关会在两种连接方式之间切换:

signals/signal_lab.gd第 51–58 行根据参数决定是否使用 CONNECT_ONE_SHOT在工程中查看 ↗
func _connect_first_chest() -> void:
	var callable := achievements.on_first_chest
	if chest.opened.is_connected(callable):
		chest.opened.disconnect(callable)
	if achievements.unlocked[0] and one_shot_badge:
		return   # 已经解锁过了,一次性连接不再需要
	var flags := CONNECT_ONE_SHOT if one_shot_badge else 0
	chest.opened.connect(callable, flags)   # ONE_SHOT:第一次触发后自动断开

另外,接收方被释放时,连到它的连接会自动断开,不需要在 _exit_tree() 里手动 disconnect。

6. 向下调用,向上发信号

有了信号以后,还需要一个原则来决定"谁连谁"。Godot 社区普遍遵循的是:

  • 父节点可以直接调用子节点的方法(向下调用)。父节点创建了子节点、知道它们的存在,依赖它们是合理的。
  • 子节点不直接找父节点或兄弟节点,只发信号(向上发信号)。子节点不假设自己被放在哪里,这样可以单独运行、到处复用。
  • 兄弟节点之间的联系由共同的父节点来连。

信号实验室完全按这个原则组织。根节点 SignalLab 是父节点,它在 _ready() 里把子节点连起来:

signals/signal_lab.gd第 30–38 行父节点负责把子节点的信号连到该去的地方在工程中查看 ↗
func _ready() -> void:
	viz.watch([self, hurt_button, heal_button, player, health_bar, chest, score_board, achievements, Events])
	# 由父节点把子节点的信号连起来:按钮 → 自己,史莱姆 → 血条,宝箱 → 计分板
	hurt_button.pressed.connect(_on_hurt_pressed)
	heal_button.pressed.connect(_on_heal_pressed)
	player.health_changed.connect(health_bar.update_bar)
	player.died.connect(_on_player_died)
	chest.opened.connect(score_board.add_gold)
	_connect_first_chest()

注意 player.health_changed.connect(health_bar.update_bar) 这一行:史莱姆和血条是兄弟,它们互相不认识,是父节点把它们接上的。血条那边只是一个普通方法,它也不知道是谁在调用:

signals/health_bar.gd第 15–18 行在工程中查看 ↗
func update_bar(current: int, maximum: int) -> void:
	_target = float(current) / maxi(maximum, 1)
	_text = "HP %d / %d" % [current, maximum]
	_flash = 1.0

按钮被点后,父节点收到信号,再向下调用史莱姆的方法。画面上这种直接调用用灰色虚线表示:

signals/signal_lab.gd第 63–80 行按钮信号 → 父节点 → 直接调用子节点在工程中查看 ↗
func _on_hurt_pressed() -> void:
	SignalViz.report(self, player, "take_damage(%d)" % damage)   # 只为了在画面上画出这次调用
	player.take_damage(damage)        # 父节点直接调用子节点的方法:向下调用


func _on_heal_pressed() -> void:
	SignalViz.report(self, player, "heal(%d)" % heal_amount)
	player.heal(heal_amount)


func _on_player_died() -> void:
	hurt_button.disabled = true
	heal_button.disabled = true
	await get_tree().create_timer(1.6).timeout   # 等 1.6 秒再复活,期间游戏照常运行
	SignalViz.report(self, player, "revive()")
	player.revive()
	hurt_button.disabled = false
	heal_button.disabled = false

代码里的 SignalViz.report(...) 只是为了把这次调用画到屏幕上,和游戏逻辑无关。

动手试试
  1. 点"攻击":先是粉色的 pressed 从按钮流向 SignalLab,然后灰色虚线 take_damage(20) 向下到史莱姆,最后 health_changed(80, 100) 流向血条。这三步在真实游戏里是在同一帧、一瞬间按这个顺序完成的;把动画速度调到 0.3 可以看得更清楚。
  2. 点宝箱:opened 同时流向计分板和成就。开第二次时,opened → on_first_chest [ONE_SHOT] 那条线已经消失了,成就面板右上角的"开箱回调"次数停在 1。
  3. 关掉首次开箱用 ONE_SHOT,再开几次箱子:这次那条线一直在,"开箱回调"次数每次都加 1——不过成就本身只解锁一次,因为 _unlock() 里有判断。
  4. 把攻击伤害调到 60,连点两下:史莱姆死亡,died 向上流到 SignalLab,按钮变灰;1.6 秒后父节点调用 revive()。这段等待用的是下面要讲的 await。

史莱姆脚本里需要让血条更新。按"向下调用、向上发信号",下面哪种写法最合适?

  1. 史莱姆里写 get_parent().get_node("HealthBar").update_bar(health, max_health)
  2. 史莱姆只 health_changed.emit(health, max_health),由共同的父节点把它连到血条的 update_bar
  3. 血条每帧读取 get_node("../Player").health

选项 1 让史莱姆依赖父节点和兄弟的名字,单独运行史莱姆场景就会报错;选项 3 是轮询,每帧都在做无用功,同样依赖路径。选项 2 中双方互不依赖。

7. 事件总线:跨越整棵树的信号

"向上发信号"在两个节点离得很远时会变得很麻烦:计分板在关卡里,成就面板在 UI 里,中间隔了好几层,要一层层往上转发信号。这时可以用事件总线:一个自动加载(autoload)的单例,只声明信号,不写任何逻辑:

events.gd第 7–8 行events.gd,在项目设置 → 全局 → 自动加载里注册为 Events在工程中查看 ↗
signal gold_changed(total: int)
signal achievement_unlocked(title: String)

任何节点都能通过全局名字 Events 发出或连接这些信号。计分板加完金币后广播:

signals/score_board.gd第 11–15 行在工程中查看 ↗
func add_gold(amount: int) -> void:
	gold += amount
	_pop = 1.0
	SignalViz.report(self, Events, "gold_changed.emit(%d)" % gold)   # 只为了在画面上画出这次调用
	Events.gold_changed.emit(gold)   # 通过事件总线广播,不需要知道谁在听

成就面板在自己的 _ready() 里订阅:

signals/achievements.gd第 15–23 行在工程中查看 ↗
func _ready() -> void:
	Events.gold_changed.connect(_on_gold_changed)


func _on_gold_changed(total: int) -> void:
	if total >= 50:
		_unlock(1)
	if total >= 100:
		_unlock(2)

计分板不知道有成就系统,成就系统也不知道计分板在哪。实验室右下角的 Events 卡片就是这个总线,紫色的 gold_changed 连线从它出发。

事件总线好用,但不要把所有通信都塞进去。父子、兄弟之间的通信用普通信号就够了,代码更容易追踪;只有真正"全局"的事件(金币变化、关卡完成、游戏暂停、成就解锁)才放进总线。总线里的信号多到几十个时,就说明它变成了另一种形式的全局变量。

8. await:等一个信号

信号还有一种用法:在函数中间停下来,等某个信号发出后再继续。写法是 await 信号:

snippets/signals_basics.gd第 82–91 行在工程中查看 ↗
## await 一个信号:函数在这里暂停,等信号发出后再继续,信号参数作为 await 的结果返回。
func wait_for_click() -> void:
	log_lines.append("等玩家点按钮……")
	await button.pressed
	log_lines.append("按钮被点了,继续往下走")


func wait_for_pickup() -> void:
	var args: Array = await item_picked             # 多个参数时,结果是一个数组
	log_lines.append("捡到 %s × %d" % args)

运行时的输出顺序是:等玩家点按钮…… → wait_for_click() 已经返回,但它还在等 → (按钮被点)→ 按钮被点了,继续往下走。也就是说,遇到 await 时函数会先返回给调用者,游戏照常运行;等信号到了,函数从暂停处接着执行。信号带参数时,await 的结果就是参数(多个参数时是一个数组)。

最常见的用法是等计时器。宝箱打开后等一段时间再关上:

signals/chest.gd第 6–21 行发出 opened 信号,然后 await 计时器在工程中查看 ↗
signal opened(gold: int)

@export var gold := 10          ## 每次开箱给多少金币
@export var cooldown := 1.0     ## 打开后多久自动关上(秒)

var is_open := false


func open() -> void:
	if is_open:
		return
	is_open = true
	opened.emit(gold)
	_burst()
	await get_tree().create_timer(cooldown).timeout   # 等计时器的 timeout 信号,期间不会卡住游戏
	is_open = false

get_tree().create_timer(秒数) 返回一个一次性的 SceneTreeTimer,它的 timeout 信号会在时间到时发出。不需要往场景里加 Timer 节点。

常见坑:await 期间节点被删了。await 之后的代码可能在几秒后才执行,那时候这个节点可能已经被 queue_free() 了(比如切换了场景)。在 Godot 4 里,节点被释放后它挂起的 await 不会再恢复(调试运行时输出面板可能出现一条 instance is gone 的错误),不会崩溃;但如果你等的是别的对象的信号,而那个对象先被删掉了,函数就会永远停在那里。等待时间较长或者涉及别的对象时,恢复后先检查 is_instance_valid() 和 is_inside_tree()。

函数 A 里调用了 chest.open()(open() 内部有 await 一个 1 秒的计时器),A 的下一行什么时候执行?

  1. 1 秒以后,等 open() 全部执行完
  2. 下一帧
  3. 马上执行:open() 遇到 await 就先返回了

遇到 await 时函数会先返回。如果 A 想等 open() 完全结束,要写成 await chest.open()。

9. 连接是可以在运行时读出来的

最后看一下信号实验室的可视化是怎么做的。每个对象都能用 get_signal_connection_list(信号名) 列出某个信号当前的所有连接,每一项是一个字典,里面有 callable(接收方的对象和方法)和 flags(是否 ONE_SHOT 等)。可视化节点每帧调用一次,就能把连线画出来,一次性连接触发后连线自然消失:

signals/signal_viz.gd第 137–148 行从真实的连接列表生成连线在工程中查看 ↗
func _rebuild_wires() -> void:
	_wires.clear()
	for w: Array in _watched:
		var n: Object = w[0]
		for c: Dictionary in n.get_signal_connection_list(w[1]):
			var callable: Callable = c.callable
			var target := callable.get_object()
			if target == self or not _nodes.has(target):
				continue
			_wires.append({"from": n, "to": target, "color": _color_of(w[1]),
				"label": "%s → %s" % [w[1], callable.get_method()],
				"one_shot": (c.flags & CONNECT_ONE_SHOT) != 0})

脉冲动画则靠"监听者":可视化节点比其他人更早连接到每个信号,所以它总是第一个收到通知,记录下的顺序和真实的执行顺序一致:

signals/signal_viz.gd第 39–55 行在工程中查看 ↗
## 监视这些对象发出的信号。监视器必须最先连接,这样它总是第一个收到信号,
## 记录下来的顺序才和真实的执行顺序一致。
func watch(nodes: Array) -> void:
	_nodes.assign(nodes)
	for n: Object in nodes:
		for s: Dictionary in _signals_of(n):
			var argc: int = s.args.size()
			var spy: Callable = [_spy0, _spy1, _spy2][argc]
			n.connect(s.name, spy.bind(n, s.name))
			_watched.append([n, s.name])


func _signals_of(n: Object) -> Array:
	if n is BaseButton:
		return [{"name": "pressed", "args": []}]
	var script: Script = n.get_script()
	return script.get_script_signal_list() if script else []

这种写法在调试时也很实用:遇到"为什么这个回调没被调用",可以打印 get_signal_connection_list() 看看连接到底在不在。编辑器运行游戏时,"远程"场景树里选中节点,也能在"节点"面板里看到它的信号连接。

小结

  • 信号把"发生了什么"和"谁去处理"分开。内置信号直接用,自定义信号用 signal 声明、emit() 发出。
  • sig.connect(callable);bind() 追加参数,unbind() 丢参数;CONNECT_ONE_SHOT 只触发一次。
  • emit() 是同步的,回调按连接顺序依次执行完才返回。
  • 向下调用、向上发信号;兄弟之间由父节点连接;真正全局的事件放进 autoload 事件总线。
  • await 信号 让函数暂停等待,调用者会先拿到控制权;等待较久时注意对象是否还有效。

下一章讲向量和变换,看懂父子节点的位置为什么会"继承"。