本节摘要:信号是引擎内置的观察者模式——发射者宣告事件,接收者按连接表回调,双方互不持有。本节讲信号的声明与发射、编辑器连接与代码连接的选择、四个连接标志的适用场景,以及用信号总线解开无父子关系模块的耦合,最后给出信号架构的三条纪律。
一个对照先摆出来。不用信号:角色脚本里写"死亡时更新界面、播音乐、掉装备、记成就",四处引用、四处修改,加一个系统就改一次角色代码。用信号:角色只宣告"我死了",四个系统各自监听、各自处理,角色代码从此不用再动。前者是中心的膨胀,后者是广播的订阅——这就是信号存在的理由。
# 角色场景根脚本 class_name Player extends CharacterBody2D signal health_changed(current: int, max_value: int) # 带类型参数的声明 signal died var health := 100 func take_hit(dmg: int) -> void: health -= dmg health_changed.emit(health, 100) # 发射:只宣告事实,不指定听众 if health <= 0: died.emit()
接收方在就绪回调里建立连接,回调函数签名与信号参数对齐:
# 血条界面脚本 extends Control func _ready() -> void: var player := get_tree().get_first_node_in_group(&"player") player.health_changed.connect(_on_health_changed) func _on_health_changed(current: int, max_value: int) -> void: %Bar.value = float(current) / float(max_value) * 100.0
连接建立后,发射即触发回调。默认连接是同步的——发射处立即执行回调,回调返回后才继续跑发射后的代码。这解释了一个重要现象:回调里改动的状态,发射后的代码立刻可见。把发射点理解为一次"现场召集",比理解为"发封邮件"更准确。
编辑器连接:在节点面板里选中信号拖到目标脚本,连接信息随场景文件保存,加载即生效。优点是零代码、可视化、连接关系一目了然;缺点是只能连接场景内已知节点,且连接逻辑藏在场景文件里,代码审查时看不见。
代码连接:就绪回调里写连接语句。优点是灵活——目标可以运行时才确定,可以带参数绑定;缺点是连接散落在各脚本里,忘写就收不到事件。
选择的经验法则:父子之间、固定不变的连接用编辑器连(按钮按下、计时器超时);跨场景、动态对象、需要绑定参数的连接用代码连。混合使用是常态,不是妥协。
四个连接标志解决特殊时序需求。延迟连接把回调推迟到本帧末尾执行,物理回调里改场景结构时用它避免遍历冲突;一次性连接触发后自动断开,适合"只等一次"的初始化;延迟一次性组合两者,用于"等当前帧忙完再响应一次";罕见的引用计数标志服务于多持有者共同维护连接的场景。
⚠️ 常见坑:在物理碰撞回调里直接调用排队销毁之外的树结构修改(比如当场生成新节点再挪动兄弟),偶发崩溃或行为错乱。给这类连接加上延迟标志,把结构调整推迟到安全时点。
界面想知道敌人死了,成就系统也想知道,但两者跟敌人没有任何层级关系。让界面与成就各自去场景树里翻找敌人节点?那是最脆的耦合。信号总线模式是标准解法:一个全局单例专职转发事件,谁关心谁订阅。
# 注册为全局的 EventBus:只放信号声明,不放逻辑 extends Node signal enemy_died(enemy_name: String) signal level_completed(level_id: int) signal coin_collected(amount: int)
# 战斗侧:只管发射 func _on_died() -> void: EventBus.enemy_died.emit(display_name) # 成就侧:只管订阅 func _ready() -> void: EventBus.enemy_died.connect(_on_enemy_died) func _on_enemy_died(enemy_name: String) -> void: _kill_count += 1
战斗代码不知道成就系统的存在,反过来也一样;两边共同认识的只有事件的名字。新增一个关心者(比如统计面板),零改动旧代码——解耦的收益就长这样。

**纪律一:事件向上,控制向下。**子节点用信号向父汇报,父节点用方法调用命令子节点。反过来让父节点发信号"命令"子节点,或子节点直接调用父的方法,都会把层级搅浑。
纪律二:信号命名用过去式。"敌人已死"是宣告事实,"杀死敌人"是发出指令——信号只做前者。命名纪律直接约束设计:一个信号应该读起来像新闻标题,而不是待办事项。
**纪律三:连接要有断开的对偶。**长寿命对象监听短寿命对象的信号时,短寿命者离场前要么显式断开,要么确保引擎自动清理生效(第 2 章讲过弱引用机制)。全局单例监听临时节点是最危险的组合,必须显式管理。
💡 关键直觉:信号不是"函数调用的另一种写法",而是模块间的合同。合同要少而稳定——先设计好一组事件名,再围绕它写实现;而不是想到哪连到哪,最后连成蛛网。
把本节知识装进一个端到端的小案例。玩家踩到尖刺:尖刺是区域检测节点,检测到身体进入时发内置信号;挂在其上的脚本收到后,不直接改玩家数据,而是转发一个自定义信号"玩家受伤"到事件总线;界面层的血条订阅了这个全局信号,收到即刷新显示;音效层同样订阅,播放受击音。整个链条上,尖刺不知道血条的存在,血条也不知道尖刺的存在——它们共同认识的只有事件总线上的一个信号名。
这个案例值得复盘的细节有三处。尖刺脚本只做"转发"不做"处置",职责单一;血条在就绪回调里建立连接,幂等写法防重复;若血条界面会被反复装卸,还要在离树回调里断开连接。三处细节对应三条纪律——转发不处置、连接幂等、断开对偶。信号的工程价值不在"能用",而在用得干净。
结构、组织、通信三大件齐了。下一节是一场实战演习:把它们放进真实故障里淬火。