Godot 4 信号模式 — 完整指南

1. 引言 — Godot 4 中有哪些变化

Godot 4 彻底重构了信号的工作方式。如果你是从 Godot 3 过来的,那么旧的基于字符串的 object.connect("signal_name", target, "method_name") 语法已经被移除了。取而代之的是,Godot 4 采用了一套基于 Callable 的 API,它类型安全、便于重构,并且能在编译期而非运行期捕获错误。

本指南涵盖了你在 Godot 4.4+ 中关于信号需要了解的一切:声明、连接、断开、发射、await,以及最实用的实战模式。

核心要点

在 Godot 4 中,信号是一等对象。你以属性的方式访问它们(例如 button.pressed),并在其上调用方法(.connect().emit().disconnect())。不再需要基于字符串的 API。

2. 声明自定义信号

在脚本顶部使用 signal 关键字声明信号。你还可以选择性地指定参数名和类型,以便生成文档和在编辑器中自动补全。

player.gd
# Simple signal with no parameters
signal game_over

# Signal with typed parameters
signal health_changed(new_health: int)

# Signal with multiple parameters
signal item_picked_up(item_name: String, quantity: int)

# Signal with no type hints (works, but typed is recommended)
signal something_happened(data)
提示

始终为信号参数添加类型提示。这样可以在编辑器中启用自动补全,并让代码自带文档。带类型提示的信号还会在节点面板的「信号」标签页中显示参数信息。

3. 连接信号(全新方式)

Godot 4 中最大的变化是连接信号的方式。你不再传递字符串,而是把信号当作属性,并用一个 Callable(对某个函数的引用)来调用 .connect()

基本连接

GDScript
# Godot 3 (OLD — no longer works in Godot 4)
# button.connect("pressed", self, "_on_button_pressed")

# Godot 4 — callable-based connection
button.pressed.connect(_on_button_pressed)

func _on_button_pressed() -> void:
    print("Button was pressed!")

通过 bind() 附带额外参数的连接

使用 .bind() 可以随信号一起传递额外数据。当你把多个信号连接到同一个方法时,这非常有用。

GDScript
# Pass extra data via bind()
buy_button.pressed.connect(_on_item_button.bind("sword"))
sell_button.pressed.connect(_on_item_button.bind("shield"))

func _on_item_button(item_id: String) -> void:
    print("Selected item: ", item_id)

Lambda(内联)连接

对于简短的处理函数,你可以直接使用 lambda 函数。这样能把简单逻辑放在紧挨连接点的地方。

GDScript
# Lambda — great for one-liners
button.pressed.connect(func(): print("Button pressed!"))

# Lambda with parameters
health_changed.connect(func(hp: int): health_label.text = str(hp))

# Multi-line lambda
enemy.died.connect(func():
    score += 100
    score_label.text = "Score: %d" % score
    print("Enemy defeated!")
)
警告

Lambda 连接不容易被断开,因为你没有对这个匿名函数的引用。如果之后需要断开,请把 Callable 保存到变量中,或改用具名方法。

在编辑器中连接(节点面板)

你仍然可以通过 Godot 编辑器界面连接信号。选中一个节点,打开节点面板 > 信号标签页,双击某个信号,然后选择目标节点和方法。编辑器会在你的脚本中自动生成类似 _on_button_pressed() 的方法。这与在代码中调用 .connect() 完全等价 — 只是省去了打字。

4. 断开信号

当你不再想接收某个信号时,就断开它。这对于避免节点被释放或切换游戏状态时出错非常重要。

GDScript
# Disconnect a signal
button.pressed.disconnect(_on_button_pressed)

# Always check before disconnecting to avoid errors
if button.pressed.is_connected(_on_button_pressed):
    button.pressed.disconnect(_on_button_pressed)
最佳实践

对于跨场景的信号连接(例如连接到某个 Autoload),请始终在 _exit_tree() 中断开,以防节点被释放时留下悬空引用:

GDScript
func _ready() -> void:
    EventBus.player_died.connect(_on_player_died)

func _exit_tree() -> void:
    if EventBus.player_died.is_connected(_on_player_died):
        EventBus.player_died.disconnect(_on_player_died)

5. 发射信号

在 Godot 4 中,使用 .emit() 代替旧的 emit_signal()。信号是一个对象,因此你直接在它上面调用方法。

player.gd
signal health_changed(new_health: int)
signal died

var health: int = 100

func take_damage(amount: int) -> void:
    health -= amount
    health_changed.emit(health)  # Emit with argument

    if health <= 0:
        died.emit()  # Emit with no arguments
注意

旧的 emit_signal("signal_name") 在 Godot 4 中技术上仍然可用,但已被弃用。新代码请始终优先使用 signal_name.emit()。基于字符串的版本可能会在未来的 Godot 版本中被移除。

6. await 信号

Godot 4 用 await 取代了 yield()。它可以让一个函数暂停,直到某个信号发射为止,非常适合过场动画、教程、连续动画和定时事件。

GDScript
func play_cutscene() -> void:
    # Wait for a timer
    await get_tree().create_timer(2.0).timeout

    # Wait for an animation to finish
    animation_player.play("intro")
    await animation_player.animation_finished

    # Wait for player input (custom signal)
    dialogue_label.text = "Press any key to continue..."
    await player_pressed_continue

    # Continue execution after the signal fires
    print("Cutscene complete!")

从 await 的信号中获取返回值

如果信号发射了参数,await 会把它们返回。对于单个参数,你会直接得到该值;对于多个参数,你会得到一个数组。

GDScript
# Single parameter — returns the value directly
var final_health: int = await health_changed
print("Health is now: ", final_health)

# Multiple parameters — returns an array
var result = await item_picked_up
var item_name: String = result[0]
var quantity: int = result[1]

7. 信号连接标志

Godot 提供了连接标志,作为 .connect() 的第二个参数,用于改变连接的行为。

CONNECT_ONE_SHOT

信号发射一次后,该连接会被自动移除。非常适合一次性事件,例如死亡动画或成就解锁。

GDScript
# Auto-disconnects after firing once
enemy.died.connect(_on_first_kill, CONNECT_ONE_SHOT)

func _on_first_kill() -> void:
    unlock_achievement("first_blood")

CONNECT_DEFERRED

被连接的方法不会立即调用,而是在当前帧结束时(空闲时段)调用。当信号处理函数会修改场景树时这很有用,因为在物理处理或信号处理期间修改场景树并不安全。

GDScript
# Called at end of frame — safe for scene tree changes
button.pressed.connect(_on_restart, CONNECT_DEFERRED)

func _on_restart() -> void:
    get_tree().reload_current_scene()

组合使用标志

GDScript
# One-shot AND deferred
trigger.body_entered.connect(_on_trigger, CONNECT_ONE_SHOT | CONNECT_DEFERRED)

8. 常见模式

EventBus(全局信号中枢)

EventBus 模式使用一个 Autoload 单例来解耦节点。任何节点都可以发射或监听全局事件,而无需持有对另一个节点的直接引用。这是 Godot 中最强大的模式之一。

event_bus.gd (Autoload)
extends Node

# Define all global signals in one place
signal player_died
signal score_changed(new_score: int)
signal level_completed(level_id: int)
signal item_collected(item_name: String)
signal settings_changed
player.gd (emitter)
func die() -> void:
    # Any script can emit global signals
    EventBus.player_died.emit()
hud.gd (listener)
func _ready() -> void:
    # Any script can listen to global signals
    EventBus.score_changed.connect(_on_score_changed)
    EventBus.player_died.connect(_on_player_died)

func _on_score_changed(new_score: int) -> void:
    score_label.text = "Score: %d" % new_score

func _on_player_died() -> void:
    game_over_screen.show()
设置 Autoload

进入项目 > 项目设置 > 自动加载,添加你的 event_bus.gd 脚本,并将其命名为 EventBus。它会自动成为一个全局可访问的单例。

信号中继(父子通信)

父节点监听其子节点的信号,并对信息进行中继或聚合。子节点之间永远不需要互相了解。

inventory.gd
signal inventory_updated

func _ready() -> void:
    # Connect to all slot children
    for slot in get_children():
        if slot.has_signal("item_changed"):
            slot.item_changed.connect(_on_slot_changed)

func _on_slot_changed() -> void:
    inventory_updated.emit()

Signal + Await 实现顺序化的游戏流程

level_manager.gd
func run_level() -> void:
    spawn_enemies()
    await EventBus.all_enemies_defeated

    show_treasure_chest()
    await EventBus.chest_opened

    play_exit_animation()
    await get_tree().create_timer(1.5).timeout

    load_next_level()

为生成的节点建立动态连接

enemy_spawner.gd
func spawn_enemy(pos: Vector2) -> void:
    var enemy = enemy_scene.instantiate()
    enemy.position = pos

    # Connect signals before adding to scene tree
    enemy.died.connect(_on_enemy_died.bind(enemy))
    enemy.health_changed.connect(_on_enemy_health_changed)

    add_child(enemy)

func _on_enemy_died(enemy: Node) -> void:
    enemies_alive -= 1
    enemy.queue_free()

9. 迁移速查表(Godot 3 → 4)

移植 Godot 3 项目时,把这个表格加入书签以便快速查阅。

操作 Godot 3 Godot 4
连接 connect("sig", obj, "method") sig.connect(method)
断开 disconnect("sig", obj, "method") sig.disconnect(method)
发射 emit_signal("sig", args) sig.emit(args)
检查连接 is_connected("sig", obj, "method") sig.is_connected(method)
等待信号 yield(obj, "sig") await obj.sig
绑定额外参数 connect("sig", obj, "method", [data]) sig.connect(method.bind(data))
一次性 connect("sig", obj, "method", [], CONNECT_ONESHOT) sig.connect(method, CONNECT_ONE_SHOT)

10. 常见错误

1. 使用基于字符串的 connect(Godot 3 风格)

GDScript
# WRONG — Godot 3 syntax, will not compile
button.connect("pressed", self, "_on_button_pressed")

# CORRECT — Godot 4 syntax
button.pressed.connect(_on_button_pressed)

2. 使用 emit_signal() 而非 .emit()

虽然 emit_signal() 仍然可用,但它会绕过编译期检查,并且已被官方弃用。请使用 .emit()

GDScript
# AVOID — deprecated, no compile-time checks
emit_signal("health_changed", health)

# PREFER — type-safe, catches typos at compile time
health_changed.emit(health)

3. 连接到已被释放的节点

如果你把一个信号连接到某个节点上的方法,而该节点被 queue_free() 释放了,那么下一次信号发射就会崩溃。解决方法:

  • _exit_tree() 中断开
  • 对一次性事件使用 CONNECT_ONE_SHOT
  • 发射前用 is_instance_valid(target) 检查
GDScript
# Safe emission pattern
for connection in my_signal.get_connections():
    if is_instance_valid(connection["callable"].get_object()):
        pass  # Connection is still valid
my_signal.emit()  # Godot handles invalid connections gracefully in 4.x

4. 忘记断开跨场景信号

对 Autoload 信号的连接会跨场景切换持续存在。如果你在 _ready() 中连接却从不断开,那么当场景被重新加载时就会出现错误或意外行为。

GDScript
# WRONG — never disconnects, leaks across scene changes
func _ready() -> void:
    EventBus.score_changed.connect(_on_score_changed)

# CORRECT — clean disconnect
func _ready() -> void:
    EventBus.score_changed.connect(_on_score_changed)

func _exit_tree() -> void:
    EventBus.score_changed.disconnect(_on_score_changed)

5. 把同一个信号连接两次

如果 _ready() 被多次调用(例如重新设置节点的父级),你可能会不小心把同一个方法连接两次。这样处理函数就会在每次发射时执行两次。

GDScript
# Guard against double-connection
func _ready() -> void:
    if not EventBus.score_changed.is_connected(_on_score_changed):
        EventBus.score_changed.connect(_on_score_changed)

想让 AI 来管理你的信号架构吗?

Godot MCP Pro 把像 Claude 这样的 AI 助手直接连接到你的 Godot 编辑器。它可以自动地连接、断开、审查并可视化整个项目中的信号流 — 全部自动完成。

analyze_signal_flow find_signal_connections connect_signal disconnect_signal get_signals
获取 Godot MCP Pro — $15