第一部分 08:Signal 信号系统

2026-05-19
452916 分钟
...

这一章讲 Godot 里非常核心的通信机制:Signal

先用一句话理解:

Signal = Godot 的事件通知机制

一个节点发生了某件事,它可以发出信号;其他节点可以连接这个信号,然后在信号发生时执行自己的函数。Godot 官方文档也这样描述:节点在特定事情发生时会发出信号,比如按钮被按下;其他节点可以连接这些信号并响应事件。(Godot Engine documentation)

1. Signal 是什么

Signal 翻译成中文一般叫:

信号

它的作用是:

一个对象告诉外界我这里发生了一件事

比如:

Button 被点击了
Area2D 检测到玩家进来了
玩家血量变化了
宝箱被打开了
怪物死亡了
天气改变了
任务完成了

这些都很适合用 signal。

2. 前端类比

前端里你肯定熟悉事件:

button.addEventListener("click", handleClick)

Vue 里:

<button @click="handleClick">点击</button>

React 里:

<button onClick={handleClick}>点击</button>

Godot 里类似:

button.pressed.connect(_on_button_pressed)

func _on_button_pressed() -> void:
    print("按钮被点击了")

所以可以这样类比:

前端 click 事件Godot pressed 信号
前端 addEventListenerGodot connect
前端 dispatchEvent / emitGodot signal.emit()

3. 为什么需要 Signal

假设玩家血量变化后,UI 要更新血条。

一种很糟糕的写法是:

player.hud.health_label.text = str(player.health)

这个问题很大。

因为 Player 直接依赖 HUD。

以后你的结构一变,比如 HUD 路径换了、UI 拆成独立场景了、血条换成 ProgressBar 了,Player 代码就要跟着改。

更好的方式是:

Player 只负责发出 health_changed 信号
HUD 自己监听这个信号并更新 UI

代码大概是:

signal health_changed(value: int)

func take_damage(amount: int) -> void:
    health -= amount
    health_changed.emit(health)

HUD 监听:

player.health_changed.connect(_on_player_health_changed)

func _on_player_health_changed(value: int) -> void:
    health_label.text = "生命:" + str(value)

这样 Player 不需要知道 HUD 的存在。

这就是 Signal 最重要的价值:

减少对象之间的直接依赖

Godot 的 Signal 类文档也说明,信号允许连接的 Callable 监听并响应事件,而不需要对象之间直接引用,从而让代码更灵活、更容易管理。(Godot Engine documentation)

4. Signal 的核心流程

Signal 的流程通常是三步:

1. 声明信号
2. 连接信号
3. 发出信号

对应代码:

signal health_changed(value: int)

func _ready() -> void:
    health_changed.connect(_on_health_changed)

func take_damage(amount: int) -> void:
    health -= amount
    health_changed.emit(health)

func _on_health_changed(value: int) -> void:
    print("血量变了:", value)

不过更常见的是:

一个节点发信号
另一个节点连接并响应

比如:

Player 发出 health_changed
HUD 监听 health_changed

5. 内置信号

Godot 很多节点自带信号。

常见的有:

Button.pressed
Area2D.body_entered
Area2D.body_exited
Area2D.area_entered
Area2D.area_exited
Timer.timeout
AnimationPlayer.animation_finished

这些是节点本来就有的,不需要你自己声明。

6. Button.pressed:按钮点击信号

它是什么

pressed 是 Button 的常用信号。

当按钮被按下时触发。

Godot Button 文档里也有示例:创建 Button 后,使用 button.pressed.connect(_button_pressed) 连接按钮点击函数。(Godot Engine documentation)

代码连接方式

场景结构:

Main Control
└── StartButton Button

脚本:

extends Control

@onready var start_button: Button = $StartButton

func _ready() -> void:
    start_button.pressed.connect(_on_start_button_pressed)

func _on_start_button_pressed() -> void:
    print("开始游戏")

编辑器连接方式

也可以在编辑器里连接:

1. 选中 Button
2. 右侧切到 Node 面板
3. 找到 Signals
4. 双击 pressed
5. 选择要连接到哪个节点
6. Godot 自动生成函数

生成出来通常像这样:

func _on_start_button_pressed() -> void:
    pass

7. Area2D.body_entered:身体进入区域

它是什么

body_entered 是 Area2D 很常用的信号。

当一个物理 Body 进入这个区域时触发。

Area2D 官方文档说明,Area2D 是 2D 空间区域,可以检测其他 CollisionObject2D 进入或离开;区域形状由 CollisionShape2D 或 CollisionPolygon2D 子节点定义。(Godot Engine documentation)

常见用途

玩家靠近 NPC
玩家进入宝箱交互范围
玩家进入门口
怪物进入攻击范围
角色踩到陷阱
进入天气区域
进入室内区域

基础例子

场景结构:

TalkArea Area2D
└── CollisionShape2D

脚本:

extends Area2D

func _ready() -> void:
    body_entered.connect(_on_body_entered)
    body_exited.connect(_on_body_exited)

func _on_body_entered(body: Node2D) -> void:
    print(body.name, "进入区域")

func _on_body_exited(body: Node2D) -> void:
    print(body.name, "离开区域")

8. body_entered 和 area_entered 的区别

Area2D 有几类常见信号:

body_entered
body_exited
area_entered
area_exited

它们区别是:

body_entered
检测 PhysicsBody2D 进入比如 CharacterBody2DStaticBody2DRigidBody2D

area_entered
检测另一个 Area2D 进入

简单记:

检测玩家身体进来body_entered
检测另一个检测区域进来area_entered

比如玩家是:

Player CharacterBody2D

那就用:

body_entered.connect(_on_body_entered)

如果你检测的是玩家身上的攻击范围:

AttackArea Area2D

那可能用:

area_entered.connect(_on_area_entered)

9. Timer.timeout:计时结束信号

它是什么

Timer 到时间后会发出 timeout 信号。

比如:

 1 秒更新时间
 3 秒生成怪物
技能冷却结束
提示框 2 秒后隐藏
天气每隔一段时间变化

示例:每秒打印一次

场景结构:

Main Node
└── Timer

Timer 设置:

Wait Time: 1
One Shot: false
Autostart: true

脚本:

extends Node

@onready var timer: Timer = $Timer

func _ready() -> void:
    timer.timeout.connect(_on_timer_timeout)

func _on_timer_timeout() -> void:
    print("过了 1 秒")

10. AnimationPlayer.animation_finished:动画结束信号

它是什么

AnimationPlayer 播放完动画后会发出:

animation_finished

常见用途:

宝箱打开动画结束后发奖励
攻击动画结束后恢复状态
死亡动画结束后删除敌人
淡出动画结束后切场景
UI 弹出动画结束后允许操作

示例:动画结束后删除节点

extends Node2D

@onready var animation_player: AnimationPlayer = $AnimationPlayer

func _ready() -> void:
    animation_player.animation_finished.connect(_on_animation_finished)

func die() -> void:
    animation_player.play("die")

func _on_animation_finished(anim_name: StringName) -> void:
    if anim_name == "die":
        queue_free()

这里的逻辑是:

播放死亡动画
动画结束
删除节点

这比直接 queue_free() 更自然。

11. 自定义信号 signal

内置信号不够用时,可以自己声明 signal。

官方文档也有 Custom signals 部分,说明可以在脚本中定义自己的信号,比如玩家血量归零时发出 diedhealth_depleted。(Godot Engine documentation)

基础写法

signal health_changed(value: int)

这声明了一个叫 health_changed 的信号。

它会携带一个参数:

value: int

发出信号

health_changed.emit(health)

完整:

extends Node

signal health_changed(value: int)

var health: int = 100

func take_damage(amount: int) -> void:
    health -= amount
    health_changed.emit(health)

12. emit:发出信号

emit 就是“发出这个信号”。

health_changed.emit(health)

意思是:

通知所有连接了 health_changed 的对象
血量变化了新血量是 health

如果信号没有参数:

signal died

func die() -> void:
    died.emit()

如果信号有多个参数:

signal item_collected(item_id: String, amount: int)

func collect_item() -> void:
    item_collected.emit("potion", 1)

13. connect:连接信号

connect 的作用是:

当这个信号发出时调用某个函数

基础写法:

player.health_changed.connect(_on_player_health_changed)

完整:

func _ready() -> void:
    player.health_changed.connect(_on_player_health_changed)

func _on_player_health_changed(value: int) -> void:
    print("玩家血量变化:", value)

注意这里不是:

player.health_changed.connect(_on_player_health_changed())

不要加括号。

因为你不是现在调用函数,而是把函数本身交给信号系统。

正确:

connect(_on_button_pressed)

错误:

connect(_on_button_pressed())

这个坑特别常见。

14. 信号参数怎么传

假设信号这样声明:

signal health_changed(value: int)

发出时:

health_changed.emit(80)

接收函数要能接住这个参数:

func _on_health_changed(value: int) -> void:
    print(value)

如果信号有两个参数:

signal item_collected(item_id: String, amount: int)

发出:

item_collected.emit("potion", 2)

接收:

func _on_item_collected(item_id: String, amount: int) -> void:
    print(item_id, amount)

参数数量最好对应。

15. 例子:玩家血量变化更新 UI

这是 Signal 最经典的用法。

Player.gd

class_name Player
extends CharacterBody2D

signal health_changed(value: int)
signal died

@export var max_health: int = 100

var health: int

func _ready() -> void:
    health = max_health
    health_changed.emit(health)

func take_damage(amount: int) -> void:
    if health <= 0:
        return

    health -= amount
    health = max(health, 0)

    health_changed.emit(health)

    if health <= 0:
        died.emit()

HUD.gd

class_name HUD
extends Control

@onready var health_label: Label = $HealthLabel

func bind_player(player: Player) -> void:
    player.health_changed.connect(_on_player_health_changed)
    player.died.connect(_on_player_died)

func _on_player_health_changed(value: int) -> void:
    health_label.text = "生命:" + str(value)

func _on_player_died() -> void:
    health_label.text = "生命:0"
    print("显示死亡界面")

Main.gd

extends Node2D

@onready var player: Player = $Player
@onready var hud: HUD = $UI/HUD

func _ready() -> void:
    hud.bind_player(player)

这里的关系很清楚:

Player 负责血量逻辑
HUD 负责显示
Main 负责把它们连接起来

Player 不知道 HUD 长什么样。

HUD 不负责扣血。

这就干净很多。

16. 例子:宝箱打开后通知主场景加金币

Chest.gd

class_name Chest
extends StaticBody2D

signal opened(reward_gold: int)

@export var reward_gold: int = 100

@onready var animation_player: AnimationPlayer = $AnimationPlayer

var is_opened: bool = false

func interact() -> void:
    if is_opened:
        return

    is_opened = true
    animation_player.play("open")
    opened.emit(reward_gold)

Main.gd

extends Node2D

var gold: int = 0

@onready var chest: Chest = $World/Objects/Chest
@onready var hud: HUD = $UI/HUD

func _ready() -> void:
    chest.opened.connect(_on_chest_opened)

func _on_chest_opened(reward_gold: int) -> void:
    gold += reward_gold
    hud.update_gold(gold)

这里宝箱只负责:

我打开了
奖励金币是 reward_gold

它不应该直接操作 HUD。

17. 例子:怪物死亡后掉落物品

Enemy.gd

class_name Enemy
extends CharacterBody2D

signal died(enemy: Enemy)

@export var max_health: int = 30

var health: int

func _ready() -> void:
    health = max_health

func take_damage(amount: int) -> void:
    health -= amount

    if health <= 0:
        die()

func die() -> void:
    died.emit(self)
    queue_free()

EnemySpawner.gd

extends Node2D

@export var drop_scene: PackedScene

func spawn_enemy(enemy_scene: PackedScene, pos: Vector2) -> void:
    var enemy: Enemy = enemy_scene.instantiate()
    add_child(enemy)

    enemy.global_position = pos
    enemy.died.connect(_on_enemy_died)

func _on_enemy_died(enemy: Enemy) -> void:
    var drop = drop_scene.instantiate()
    add_child(drop)
    drop.global_position = enemy.global_position

这里注意:

died.emit(self)

把死亡的敌人自己传出去。

这样外部可以知道:

是谁死了
死在哪里
掉落物该生成在哪里

18. 例子:NPC 对话开始和结束

NPC.gd

class_name NPC
extends CharacterBody2D

signal dialog_requested(npc_name: String, dialog_text: String)

@export var npc_name: String = "村民"
@export var dialog_text: String = "你好,旅行者。"

func interact() -> void:
    dialog_requested.emit(npc_name, dialog_text)

DialogBox.gd

class_name DialogBox
extends Control

signal dialog_finished

@onready var name_label: Label = $NameLabel
@onready var text_label: Label = $TextLabel

func show_dialog(npc_name: String, dialog_text: String) -> void:
    visible = true
    name_label.text = npc_name
    text_label.text = dialog_text

func close_dialog() -> void:
    visible = false
    dialog_finished.emit()

Main.gd

extends Node2D

@onready var npc: NPC = $World/NPCs/NPC
@onready var dialog_box: DialogBox = $UI/DialogBox
@onready var player: Player = $Player

func _ready() -> void:
    npc.dialog_requested.connect(_on_dialog_requested)
    dialog_box.dialog_finished.connect(_on_dialog_finished)

func _on_dialog_requested(npc_name: String, dialog_text: String) -> void:
    player.input_locked = true
    dialog_box.show_dialog(npc_name, dialog_text)

func _on_dialog_finished() -> void:
    player.input_locked = false

职责分工:

NPC我请求显示对话
DialogBox我负责显示对话
Main我协调玩家输入锁和 UI
Player我只管能不能移动

19. 例子:天气变化通知世界和 UI

WeatherManager.gd

class_name WeatherManager
extends Node

signal weather_changed(weather: String)

var current_weather: String = "sunny"

func set_weather(weather: String) -> void:
    if current_weather == weather:
        return

    current_weather = weather
    weather_changed.emit(current_weather)

World.gd

extends Node2D

func bind_weather_manager(weather_manager: WeatherManager) -> void:
    weather_manager.weather_changed.connect(_on_weather_changed)

func _on_weather_changed(weather: String) -> void:
    match weather:
        "sunny":
            modulate = Color(1, 1, 1)
        "rainy":
            modulate = Color(0.75, 0.8, 0.9)
        "foggy":
            modulate = Color(0.8, 0.8, 0.8)

HUD.gd

extends Control

@onready var weather_label: Label = $WeatherLabel

func bind_weather_manager(weather_manager: WeatherManager) -> void:
    weather_manager.weather_changed.connect(_on_weather_changed)

func _on_weather_changed(weather: String) -> void:
    weather_label.text = "天气:" + weather

一个天气变化,同时通知:

世界画面变化
UI 文本变化
音效变化
NPC 行为变化

这就是 Signal 很适合系统解耦的地方。

20. 连接信号的两种方式

Signal 可以通过两种方式连接:

编辑器连接
代码连接

编辑器连接

适合初学者,直观。

比如 Button:

选中 Button
Node 面板
Signals
pressed
Connect
选择目标节点
生成函数

优点:

可视化
不容易写错信号名
适合简单按钮和 Area2D

缺点:

项目大了不容易追踪
动态生成的节点不方便
多人协作时容易漏看连接关系

代码连接

button.pressed.connect(_on_button_pressed)

优点:

清晰写在代码里
动态生成节点时方便
方便集中管理
适合复杂项目

缺点:

需要自己写对路径和函数名

我的建议:

入门阶段编辑器连接可以用
正式项目核心逻辑尽量用代码连接

21. 动态生成节点时连接信号

比如你生成多个按钮。

extends Control

@onready var button_container: VBoxContainer = $ButtonContainer

func create_option_button(text: String) -> void:
    var button := Button.new()
    button.text = text
    button_container.add_child(button)

    button.pressed.connect(_on_option_button_pressed.bind(text))

func _on_option_button_pressed(option_text: String) -> void:
    print("选择了:", option_text)

这里用了:

bind(text)

它可以把额外参数绑定给回调函数。

22. bind:给信号回调额外传参

假设你有多个按钮:

攻击
防御
道具
逃跑

每个按钮都连接同一个函数,但需要知道点的是哪个。

func _ready() -> void:
    $AttackButton.pressed.connect(_on_action_pressed.bind("attack"))
    $DefendButton.pressed.connect(_on_action_pressed.bind("defend"))
    $ItemButton.pressed.connect(_on_action_pressed.bind("item"))
    $RunButton.pressed.connect(_on_action_pressed.bind("run"))

func _on_action_pressed(action: String) -> void:
    print("选择动作:", action)

这样就不用写四个函数:

_on_attack_pressed
_on_defend_pressed
_on_item_pressed
_on_run_pressed

23. 断开信号 disconnect

有时候你需要断开信号。

player.health_changed.disconnect(_on_player_health_changed)

但要注意,如果没有连接却去 disconnect,可能会报错。

可以先判断:

if player.health_changed.is_connected(_on_player_health_changed):
    player.health_changed.disconnect(_on_player_health_changed)

常见用途:

切换玩家角色
关闭临时 UI
移除监听
对象销毁前清理
避免重复连接

24. 避免重复连接

重复连接可能导致一个信号触发多次。

比如你不小心多次执行:

button.pressed.connect(_on_button_pressed)

结果点一次按钮,函数执行多次。

更稳的写法:

if not button.pressed.is_connected(_on_button_pressed):
    button.pressed.connect(_on_button_pressed)

不过正常情况下,只要你在 _ready() 里连接一次,通常没问题。

25. await 等待信号

Signal 不只能 connect,还可以 await。

比如等待 1 秒:

await get_tree().create_timer(1.0).timeout
print("1 秒后执行")

等待动画结束:

animation_player.play("open")
await animation_player.animation_finished
print("打开动画结束")

宝箱例子:

func interact() -> void:
    if is_opened:
        return

    is_opened = true
    animation_player.play("open")
    await animation_player.animation_finished

    print("发放奖励")

这种写法很像前端里的:

await sleep(1000)

但 Godot 等待的是信号。

26. connect 和 await 的区别

connect

适合长期监听。

player.health_changed.connect(_on_player_health_changed)

意思是:

以后每次玩家血量变化我都要响应

适合:

血量变化
金币变化
天气变化
时间变化
按钮点击
Area2D 进入离开

await

适合临时等待一次。

await animation_player.animation_finished

意思是:

我现在等这一次动画结束然后继续往下执行

适合:

等待动画结束
等待计时器结束
等待淡入淡出结束
等待对话结束

27. 信号命名建议

信号名建议用过去式或事件式。

推荐:

signal health_changed(value: int)
signal died
signal opened
signal item_collected(item_id: String, amount: int)
signal dialog_started
signal dialog_finished
signal weather_changed(weather: String)
signal day_changed(day: int)
signal quest_completed(quest_id: String)

不太推荐:

signal change_health
signal open
signal do_dialog
signal weather

因为 Signal 表示“发生了什么”,不是“命令别人做什么”。

也就是说:

health_changed = 血量已经变化了
opened = 已经打开了
quest_completed = 任务完成了

而不是:

change_health = 去改变血量
open = 去打开

这个语义差别很重要。

28. Signal 适合表达事件,不适合表达命令

推荐:

signal opened

意思是:

我打开了关心的人自己处理

不推荐把 Signal 当命令总线乱用:

signal make_player_move
signal force_ui_update
signal create_enemy_now

当然项目大了可以做 EventBus,但入门阶段先不要把所有东西都丢进全局信号中心。那东西用好了是事件总线,用烂了是电子垃圾桶。

29. 谁应该发信号

一个简单规则:

谁拥有这个状态谁发出这个状态变化的信号

比如:

Player 拥有 health所以 Player health_changed
Chest 拥有 opened 状态所以 Chest opened
WeatherManager 拥有 current_weather所以 WeatherManager weather_changed
TimeManager 拥有 current_day/current_hour所以 TimeManager time_changed/day_changed
Inventory 拥有 items所以 Inventory item_added/item_removed
QuestManager 拥有任务状态所以 QuestManager quest_completed

不要让 HUD 发 health_changed

HUD 只是显示血量,不拥有血量。

30. 谁应该连接信号

谁关心这个事件,谁连接。

比如:

HUD 关心 Player.health_changed
Main 关心 Chest.opened
AudioManager 关心 WeatherManager.weather_changed
QuestManager 关心 NPC.dialog_finished
Door 关心 Player 是否有钥匙

有时也可以由一个上层节点统一连接。

比如 Main:

func _ready() -> void:
    player.health_changed.connect(hud.update_health)
    weather_manager.weather_changed.connect(world.apply_weather)
    time_manager.day_changed.connect(hud.update_day)

这种写法很清楚:

Main 负责组装关系
各个模块负责自己的事

31. 常见结构:Main 负责连接各模块

推荐结构:

Main Node2D
├── World Node2D
├── Player
├── Systems Node
│   ├── WeatherManager
│   └── TimeManager
└── UI CanvasLayer
    └── HUD

Main.gd:

extends Node2D

@onready var player: Player = $Player
@onready var weather_manager: WeatherManager = $Systems/WeatherManager
@onready var time_manager: TimeManager = $Systems/TimeManager
@onready var hud: HUD = $UI/HUD
@onready var world: Node2D = $World

func _ready() -> void:
    player.health_changed.connect(hud.update_health)
    weather_manager.weather_changed.connect(hud.update_weather)
    weather_manager.weather_changed.connect(world.apply_weather)
    time_manager.day_changed.connect(hud.update_day)

这里很优雅:

Player 不知道 HUD
WeatherManager 不知道 World HUD
HUD 不知道天气怎么计算
Main 负责把模块插起来

很像前端里的根组件组合子组件和 store。

32. 直接调用和信号怎么选

这是重点。

直接调用适合什么

当 A 明确需要让 B 立刻做某件事,并且 A 合理地知道 B 存在时,可以直接调用。

比如:

animation_player.play("open")

宝箱内部调用自己的 AnimationPlayer,很合理。

current_interactable.interact()

玩家调用当前交互物的 interact(),也合理。

信号适合什么

当 A 只是宣布“我发生了某件事”,但不应该知道谁会响应时,用 signal。

比如:

health_changed.emit(health)
opened.emit(reward_gold)
weather_changed.emit(current_weather)

简单判断:

我是在命令某个具体对象做事
用直接调用

我是在通知外界某件事发生了
用信号

33. 不要所有东西都用信号

Signal 很好,但不要滥用。

不推荐:

signal move_requested(direction)
signal animation_requested(anim_name)
signal position_change_requested(pos)

如果这些都是同一个对象内部逻辑,直接调用函数就好。

比如 Player 内部:

func _physics_process(delta: float) -> void:
    var direction := get_move_direction()
    update_animation(direction)

没必要写成:

movement_changed.emit(direction)

Signal 主要用来跨对象通信。

34. 例子:TimeManager 时间变化

TimeManager.gd

class_name TimeManager
extends Node

signal hour_changed(hour: int)
signal day_changed(day: int)

var day: int = 1
var hour: int = 6

func advance_hour() -> void:
    hour += 1

    if hour >= 24:
        hour = 0
        day += 1
        day_changed.emit(day)

    hour_changed.emit(hour)

HUD.gd

class_name HUD
extends Control

@onready var time_label: Label = $TimeLabel
@onready var day_label: Label = $DayLabel

func bind_time_manager(time_manager: TimeManager) -> void:
    time_manager.hour_changed.connect(_on_hour_changed)
    time_manager.day_changed.connect(_on_day_changed)

func _on_hour_changed(hour: int) -> void:
    time_label.text = "时间:" + str(hour) + ":00"

func _on_day_changed(day: int) -> void:
    day_label.text = "第 " + str(day) + " 天"

这样以后:

NPC 作息系统
商店开关门
昼夜光照
天气变化概率
农作物成长

都可以监听 TimeManager 的信号。

35. 例子:Inventory 背包变化

Inventory.gd

class_name Inventory
extends Node

signal item_added(item_id: String, amount: int)
signal item_removed(item_id: String, amount: int)
signal inventory_changed

var items: Dictionary = {}

func add_item(item_id: String, amount: int = 1) -> void:
    if not items.has(item_id):
        items[item_id] = 0

    items[item_id] += amount

    item_added.emit(item_id, amount)
    inventory_changed.emit()

func remove_item(item_id: String, amount: int = 1) -> void:
    if not items.has(item_id):
        return

    items[item_id] -= amount

    if items[item_id] <= 0:
        items.erase(item_id)

    item_removed.emit(item_id, amount)
    inventory_changed.emit()

InventoryPanel.gd

class_name InventoryPanel
extends Control

var inventory: Inventory

func bind_inventory(value: Inventory) -> void:
    inventory = value
    inventory.inventory_changed.connect(refresh)

func refresh() -> void:
    print("刷新背包 UI")

这里 Inventory 不知道 UI 怎么显示。

它只通知:

背包变了

UI 自己刷新。

36. 例子:任务完成

QuestManager.gd

class_name QuestManager
extends Node

signal quest_completed(quest_id: String)

var completed_quests: Array[String] = []

func complete_quest(quest_id: String) -> void:
    if completed_quests.has(quest_id):
        return

    completed_quests.append(quest_id)
    quest_completed.emit(quest_id)

HUD.gd

func bind_quest_manager(quest_manager: QuestManager) -> void:
    quest_manager.quest_completed.connect(_on_quest_completed)

func _on_quest_completed(quest_id: String) -> void:
    print("任务完成:", quest_id)

AudioManager.gd

func bind_quest_manager(quest_manager: QuestManager) -> void:
    quest_manager.quest_completed.connect(_on_quest_completed)

func _on_quest_completed(quest_id: String) -> void:
    print("播放任务完成音效")

一个信号可以被多个对象监听:

HUD 显示提示
AudioManager 播放音效
SaveManager 保存进度

这就是信号的威力。

37. 一个信号可以连接多个函数

比如:

player.died.connect(hud.show_game_over)
player.died.connect(audio_manager.play_game_over_music)
player.died.connect(game_manager.stop_game)

当玩家死亡:

died.emit()

这三个函数都会响应。

这和前端事件很像:

eventEmitter.on("playerDied", showGameOver)
eventEmitter.on("playerDied", playMusic)
eventEmitter.on("playerDied", stopGame)

38. 一个函数也可以响应多个信号

比如多个按钮都调用同一个函数:

$StartButton.pressed.connect(_on_menu_button_pressed.bind("start"))
$LoadButton.pressed.connect(_on_menu_button_pressed.bind("load"))
$QuitButton.pressed.connect(_on_menu_button_pressed.bind("quit"))

func _on_menu_button_pressed(action: String) -> void:
    match action:
        "start":
            start_game()
        "load":
            load_game()
        "quit":
            quit_game()

这样代码更集中。

39. 信号和 Group 的区别

后面会单独讲 Group,但这里先简单区分。

Signal:

某个对象发生事件通知监听者

Group:

把一批节点归类然后统一调用它们

比如:

Signal
Player.health_changedHUD 更新血量

Group
所有 enemies 组里的敌人全部暂停

对应:

get_tree().call_group("enemies", "freeze")

简单记:

Signal 是事件通知
Group 是批量管理

40. 信号和 Autoload/EventBus

项目大了以后,可能会用一个全局事件总线。

比如:

# Events.gd
extends Node

signal weather_changed(weather: String)
signal quest_completed(quest_id: String)
signal item_collected(item_id: String, amount: int)

然后任何地方都能:

Events.quest_completed.emit("first_quest")

或者:

Events.quest_completed.connect(_on_quest_completed)

这个方式有用,但不建议一开始就滥用。

因为它的问题是:

谁都能发
谁都能听
关系容易变隐形
调试起来比较麻烦

入门阶段推荐:

局部对象之间用直接信号连接
全局跨系统事件再考虑 EventBus

比如:

Player.health_changed 不一定要放 EventBus
QuestManager.quest_completed 可以考虑全局
WeatherManager.weather_changed 可以看项目规模决定

41. 信号常见错误

错误 1:connect 时加了括号

错误:

button.pressed.connect(_on_button_pressed())

正确:

button.pressed.connect(_on_button_pressed)

原因:

不加括号 = 把函数引用传进去
加括号 = 现在就调用这个函数

错误 2:信号参数和函数参数对不上

信号:

signal health_changed(value: int)

发出:

health_changed.emit(health)

回调却写:

func _on_health_changed() -> void:
    pass

不推荐。

应该:

func _on_health_changed(value: int) -> void:
    pass

错误 3:重复连接导致执行多次

如果你多次执行:

button.pressed.connect(_on_button_pressed)

可能会导致点一次按钮,回调执行多次。

可以防一下:

if not button.pressed.is_connected(_on_button_pressed):
    button.pressed.connect(_on_button_pressed)

错误 4:节点路径错了,导致连接失败

比如结构是:

UI
└── HUD
    └── StartButton

你写:

@onready var start_button: Button = $StartButton

就错了。

应该:

@onready var start_button: Button = $UI/HUD/StartButton

或者脚本挂在 HUD 上:

@onready var start_button: Button = $StartButton

错误 5:发信号的对象已经被删除

比如敌人死亡:

died.emit()
queue_free()

这个顺序通常可以。

但如果你先:

queue_free()
died.emit()

就不合理。

应该先通知,再删除。

错误 6:用信号替代所有函数调用

不推荐为了“解耦”把内部逻辑全做成信号。

比如 Player 内部播放动画,直接调用:

animation_player.play("walk_down")

没必要发:

animation_requested.emit("walk_down")

42. Signal 命名和函数命名习惯

信号名:

signal health_changed(value: int)

回调函数名通常写:

func _on_player_health_changed(value: int) -> void:
    pass

格式:

_on_谁_什么信号

比如:

func _on_start_button_pressed() -> void:
    pass

func _on_interact_area_body_entered(body: Node2D) -> void:
    pass

func _on_animation_player_animation_finished(anim_name: StringName) -> void:
    pass

func _on_weather_manager_weather_changed(weather: String) -> void:
    pass

这个命名会稍微长一点,但可读性好。

43. 推荐你的 RPG 项目常用信号

Player:

signal health_changed(value: int)
signal mana_changed(value: int)
signal died
signal interacted(target: Node)
signal gold_changed(value: int)

Chest:

signal opened(reward_gold: int)

NPC:

signal dialog_requested(npc: NPC)

Inventory:

signal item_added(item_id: String, amount: int)
signal item_removed(item_id: String, amount: int)
signal inventory_changed

TimeManager:

signal hour_changed(hour: int)
signal day_changed(day: int)
signal season_changed(season: String)

WeatherManager:

signal weather_changed(weather: String)

QuestManager:

signal quest_started(quest_id: String)
signal quest_completed(quest_id: String)
signal objective_updated(quest_id: String)

DialogBox:

signal dialog_started
signal dialog_finished
signal line_changed(index: int)

44. 一个完整小型信号架构例子

场景结构:

Main Node2D
├── Player
├── World Node2D
│   └── Chest
├── Systems Node
│   ├── TimeManager
│   └── WeatherManager
└── UI CanvasLayer
    └── HUD

Player.gd

class_name Player
extends CharacterBody2D

signal health_changed(value: int)
signal died

@export var max_health: int = 100

var health: int

func _ready() -> void:
    health = max_health
    health_changed.emit(health)

func take_damage(amount: int) -> void:
    health -= amount
    health = max(health, 0)
    health_changed.emit(health)

    if health == 0:
        died.emit()

Chest.gd

class_name Chest
extends StaticBody2D

signal opened(reward_gold: int)

@export var reward_gold: int = 100

var is_opened: bool = false

func interact() -> void:
    if is_opened:
        return

    is_opened = true
    opened.emit(reward_gold)

TimeManager.gd

class_name TimeManager
extends Node

signal hour_changed(hour: int)

var hour: int = 6

func advance_hour() -> void:
    hour += 1

    if hour >= 24:
        hour = 0

    hour_changed.emit(hour)

WeatherManager.gd

class_name WeatherManager
extends Node

signal weather_changed(weather: String)

var current_weather: String = "sunny"

func set_weather(weather: String) -> void:
    current_weather = weather
    weather_changed.emit(current_weather)

HUD.gd

class_name HUD
extends Control

@onready var health_label: Label = $HealthLabel
@onready var gold_label: Label = $GoldLabel
@onready var time_label: Label = $TimeLabel
@onready var weather_label: Label = $WeatherLabel

func update_health(value: int) -> void:
    health_label.text = "生命:" + str(value)

func update_gold(value: int) -> void:
    gold_label.text = "金币:" + str(value)

func update_hour(hour: int) -> void:
    time_label.text = "时间:" + str(hour) + ":00"

func update_weather(weather: String) -> void:
    weather_label.text = "天气:" + weather

Main.gd

extends Node2D

@onready var player: Player = $Player
@onready var chest: Chest = $World/Chest
@onready var time_manager: TimeManager = $Systems/TimeManager
@onready var weather_manager: WeatherManager = $Systems/WeatherManager
@onready var hud: HUD = $UI/HUD

var gold: int = 0

func _ready() -> void:
    player.health_changed.connect(hud.update_health)
    player.died.connect(_on_player_died)

    chest.opened.connect(_on_chest_opened)

    time_manager.hour_changed.connect(hud.update_hour)
    weather_manager.weather_changed.connect(hud.update_weather)

func _on_chest_opened(reward_gold: int) -> void:
    gold += reward_gold
    hud.update_gold(gold)

func _on_player_died() -> void:
    print("玩家死亡,显示 Game Over")

这个结构很适合你现在的 RPG 项目初期。

45. 这套结构的好处

Player 不直接操作 HUD
Chest 不直接操作金币 UI
TimeManager 不直接操作 Label
WeatherManager 不直接操作世界节点
Main 负责连接模块关系
HUD 只负责显示

这比所有节点互相 $../../UI/HUD/xxx 到处乱找要舒服很多。

前端类比就是:

子组件发 emit
父组件接住
父组件更新状态或传给其他组件

Godot 里 Main 就像一个组合层。

46. Signal 使用速查

声明信号
signal health_changed(value: int)

发出信号
health_changed.emit(health)

连接信号
player.health_changed.connect(_on_player_health_changed)

断开信号
player.health_changed.disconnect(_on_player_health_changed)

判断是否已连接
player.health_changed.is_connected(_on_player_health_changed)

等待信号
await animation_player.animation_finished

按钮点击
button.pressed.connect(_on_button_pressed)

Area2D 进入
area.body_entered.connect(_on_body_entered)

Timer 到点
timer.timeout.connect(_on_timer_timeout)

47. 这一章你先记住这些

Signal 是事件通知机制
节点发生事情时可以 emit 信号
其他节点可以 connect 信号并响应
内置信号包括 pressedbody_enteredtimeoutanimation_finished
自定义信号用 signal 声明
connect 时不要给函数加括号
Signal 适合跨对象通信不适合同一个对象内部乱用
谁拥有状态谁发状态变化信号
谁关心事件谁连接信号
Main 可以作为模块连接中心

48. 最重要的一句话

Signal 的核心不是调用别人”,而是通知别人我发生了某件事”。

再压缩一下:

按钮被点了pressed
玩家进区域了body_entered
血量变了health_changed
宝箱打开了opened
天气变了weather_changed
任务完成了quest_completed

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录