信号与节点通信
信号是节点之间"我发生了某事"的广播。本章讲内置信号和自定义信号、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 关键字声明自定义信号,可以带参数:
signal hit # 不带参数
signal health_changed(old: int, new: int) # 带参数(参数类型只是说明,发信号时不强制检查)
signal item_picked(item_name: String, count: int)信号实验室里的史莱姆声明了两个信号,受伤时发出它们:
signal health_changed(current: int, maximum: int) ## 血量变了:新血量、最大血量
signal died ## 血量归零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:
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:
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)。连接关系写在代码里,阅读脚本时一目了然。运行时才创建的节点(实例化的子弹、敌人)只能用代码连。
两种方式可以混用,但同一个连接不要两边都做一遍:编辑器连过、代码又连一次,会报"已经连接"的错误。本教程的示例统一用代码连接,因为代码会被直接拉进网页里展示,读者看得见。
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 标志,触发一次后连接会自动断开:
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——对象和方法名都相同:
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())信号实验室的"首次开箱"成就就是一次性连接。网页上的开关会在两种连接方式之间切换:
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() 里把子节点连起来:
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) 这一行:史莱姆和血条是兄弟,它们互相不认识,是父节点把它们接上的。血条那边只是一个普通方法,它也不知道是谁在调用:
func update_bar(current: int, maximum: int) -> void:
_target = float(current) / maxi(maximum, 1)
_text = "HP %d / %d" % [current, maximum]
_flash = 1.0按钮被点后,父节点收到信号,再向下调用史莱姆的方法。画面上这种直接调用用灰色虚线表示:
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(...) 只是为了把这次调用画到屏幕上,和游戏逻辑无关。
- 点"攻击":先是粉色的
pressed从按钮流向 SignalLab,然后灰色虚线take_damage(20)向下到史莱姆,最后health_changed(80, 100)流向血条。这三步在真实游戏里是在同一帧、一瞬间按这个顺序完成的;把动画速度调到 0.3 可以看得更清楚。 - 点宝箱:
opened同时流向计分板和成就。开第二次时,opened → on_first_chest [ONE_SHOT]那条线已经消失了,成就面板右上角的"开箱回调"次数停在 1。 - 关掉首次开箱用 ONE_SHOT,再开几次箱子:这次那条线一直在,"开箱回调"次数每次都加 1——不过成就本身只解锁一次,因为
_unlock()里有判断。 - 把攻击伤害调到 60,连点两下:史莱姆死亡,
died向上流到 SignalLab,按钮变灰;1.6 秒后父节点调用revive()。这段等待用的是下面要讲的await。
史莱姆脚本里需要让血条更新。按"向下调用、向上发信号",下面哪种写法最合适?
- 史莱姆里写
get_parent().get_node("HealthBar").update_bar(health, max_health) - 史莱姆只
health_changed.emit(health, max_health),由共同的父节点把它连到血条的update_bar - 血条每帧读取
get_node("../Player").health
选项 1 让史莱姆依赖父节点和兄弟的名字,单独运行史莱姆场景就会报错;选项 3 是轮询,每帧都在做无用功,同样依赖路径。选项 2 中双方互不依赖。
7. 事件总线:跨越整棵树的信号
"向上发信号"在两个节点离得很远时会变得很麻烦:计分板在关卡里,成就面板在 UI 里,中间隔了好几层,要一层层往上转发信号。这时可以用事件总线:一个自动加载(autoload)的单例,只声明信号,不写任何逻辑:
signal gold_changed(total: int)
signal achievement_unlocked(title: String)任何节点都能通过全局名字 Events 发出或连接这些信号。计分板加完金币后广播:
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() 里订阅:
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 信号:
## 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 的结果就是参数(多个参数时是一个数组)。
最常见的用法是等计时器。宝箱打开后等一段时间再关上:
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 = falseget_tree().create_timer(秒数) 返回一个一次性的 SceneTreeTimer,它的 timeout 信号会在时间到时发出。不需要往场景里加 Timer 节点。
await 之后的代码可能在几秒后才执行,那时候这个节点可能已经被 queue_free() 了(比如切换了场景)。在 Godot 4 里,节点被释放后它挂起的 await 不会再恢复(调试运行时输出面板可能出现一条 instance is gone 的错误),不会崩溃;但如果你等的是别的对象的信号,而那个对象先被删掉了,函数就会永远停在那里。等待时间较长或者涉及别的对象时,恢复后先检查 is_instance_valid() 和 is_inside_tree()。函数 A 里调用了 chest.open()(open() 内部有 await 一个 1 秒的计时器),A 的下一行什么时候执行?
- 1 秒以后,等 open() 全部执行完
- 下一帧
- 马上执行:open() 遇到 await 就先返回了
遇到 await 时函数会先返回。如果 A 想等 open() 完全结束,要写成 await chest.open()。
9. 连接是可以在运行时读出来的
最后看一下信号实验室的可视化是怎么做的。每个对象都能用 get_signal_connection_list(信号名) 列出某个信号当前的所有连接,每一项是一个字典,里面有 callable(接收方的对象和方法)和 flags(是否 ONE_SHOT 等)。可视化节点每帧调用一次,就能把连线画出来,一次性连接触发后连线自然消失:
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})脉冲动画则靠"监听者":可视化节点比其他人更早连接到每个信号,所以它总是第一个收到通知,记录下的顺序和真实的执行顺序一致:
## 监视这些对象发出的信号。监视器必须最先连接,这样它总是第一个收到信号,
## 记录下来的顺序才和真实的执行顺序一致。
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 信号让函数暂停等待,调用者会先拿到控制权;等待较久时注意对象是否还有效。
下一章讲向量和变换,看懂父子节点的位置为什么会"继承"。