2212 字
11 分钟
Godot C# 零基础教程 05:信号与节点通信

教程一 · 第 5 章:信号与节点通信——项目架构的分水岭#

小游戏靠 GetNode 互相喊就能跑;项目一大,互相引用就变成”意大利面”。这一章系统讲 Godot 的通信手段,以及什么场景该用哪种。

5.1 先回顾:信号长什么样#

public partial class Player : CharacterBody2D
{
// 声明:[Signal] + delegate,命名固定以 EventHandler 结尾
[Signal]
public delegate void HealthChangedEventHandler(int current, int max);
}

Godot 源生成器会据此自动生成三样东西:

  • 事件 HealthChanged(C# 风格订阅用)
  • 字符串常量 SignalName.HealthChanged(EmitSignal 用)
  • 发射辅助方法(也可以直接 EmitSignal(SignalName.HealthChanged, current, max))

订阅方写法:

player.HealthChanged += OnPlayerHealthChanged; // 订阅
player.HealthChanged -= OnPlayerHealthChanged; // 取消订阅

参数类型必须逐一匹配,信号是 int,int,回调就得是 void M(int a, int b)。

5.2 六种通信方式对比#

方式关系耦合度典型用途
GetNode + 直接调方法父→子 / 明确路径高父节点初始化、指挥直属子节点
[Export] NodePath/节点编辑器连线中预制关联、可在编辑器配置
信号事件方→任意关心者低受伤、死亡、得分、UI 更新
组(Group)按标签找一批中所有敌人、所有可交互物
Autoload 单例全局低(但全局可见)游戏状态、音频总线、场景切换
资源(Resource)共享数据驱动低配置、存档、多对象共享数据

经验法则:

  • 往下(父→子):直接调用最清楚
  • 往上(子→父)或平级:信号
  • 跨场景、跨关卡都要用:Autoload
  • “所有 XX”:组
  • 一批静态数据:自定义 Resource

5.3 信号的实战模式#

模式 1:UI 订阅游戏实体(最常见)#

血条不该认识玩家内部,玩家也不该知道有几个 UI 在显示自己:

// Player
[Signal] public delegate void HealthChangedEventHandler(int hp);
// HUD
public override void _Ready()
{
var player = GetTree().GetFirstNodeInGroup("player");
((Player)player).HealthChanged += hp => _bar.Value = hp;
}

这里用组找玩家,是因为 HUD 和 Player 在场景树里的相对位置以后可能变;组名就是稳定的契约。上面用 Lambda 订阅很方便,但注意:Lambda 无法用 -= 取消,节点不释放就长期存在。短生命周期节点订阅长生命周期节点时,要么用具名方法并在 _ExitTree 取消,要么直接用 Godot 的连接 API:

// 节点离开树时自动断开,省心
player.Connect(Player.SignalName.HealthChanged, Callable.From<int>(OnHp));
// 对应:确保节点生命周期一致,Godot 会在双方释放时自动清理

模式 2:中转信号(信号转发)#

Main 场景里的敌人死亡,GameManager 要知道、音效要播、成就系统要记。敌人不直接连三家,而是:

// Enemy 发 Died
// Level(敌人的父节点)统一订阅后转发为自己的信号
enemy.Died += () => EnemyDied?.Invoke();

或者干脆让敌人直接连 Autoload(见 5.5)。层级少时直连,层级多时用一层中转,不要层层 GetNode("../../..")。

模式 3:请求信号(UI → 游戏逻辑)#

方向反过来:暂停菜单发 PauseRequested,游戏流程节点响应。UI 只表达”用户点了”,不负责改游戏状态。这样 UI 重做十版,游戏逻辑一行不动。

模式 4:编辑器可视化连接#

选中按钮 → 节点面板 → 信号 → 双击 pressed → 选接收节点 → 生成 _OnButtonPressed()。适合:

  • UI 按钮这种纯静态、场景里固定存在的连接
  • 原型快速验证

代码连接适合:动态生成的实例、需要条件连接/断开的逻辑。两种混着用,以清晰为准。

5.4 组:批量操作的标签#

// 代码加组(也可在编辑器"节点→组"面板加)
AddToGroup("enemy");
// 全体扣血
foreach (Node node in GetTree().GetNodesInGroup("enemy"))
{
if (node is Enemy enemy) enemy.TakeDamage(10);
}
// 组还能让信号"广播":
GetTree().CallGroup("enemy", "stun", 2f); // 对所有 enemy 调 Stun(2f)

⚠️ CallGroup 用字符串调方法,没有编译期检查,拼错运行时才炸。中大型项目更推荐”取组 + 接口/基类调用”(教程二第 5 章讲接口后你会懂为什么):

foreach (var node in GetTree().GetNodesInGroup("damageable"))
if (node is IDamageable d) d.TakeDamage(10);

5.5 Autoload:全局单例#

跨场景的数据和服务需要一个”不死”的宿主:玩家血条在关卡里,但切场景后数据不能丢;音频设置全局生效。

创建:

  1. 建 scripts/GameManager.cs,继承 Node
  2. 项目 → 项目设置 → Autoload(全局对象)
  3. 路径选脚本,节点名填 GameManager,添加

它会在游戏启动时自动创建,切换场景时不被销毁,任何脚本里直接:

public partial class GameManager : Node
{
public int Coins { get; private set; }
[Signal] public delegate void CoinsChangedEventHandler(int total);
public void AddCoin()
{
Coins++;
EmitSignal(SignalName.CoinsChanged, Coins);
}
}

别的脚本:

// 任意节点里直接用类名访问(GameManager 实例挂在 /root/GameManager)
GameManager.Instance.AddCoin();

类名直访需要一行小配置,给 GameManager 加:

public static GameManager Instance { get; private set; } = null!;
public override void _Ready() => Instance = this;

Autoload 使用纪律(重要):

  • 适合:游戏状态、设置、存档、音频管理、场景切换器、事件总线
  • 不适合:塞进所有游戏逻辑。Autoload 多了就是”全局变量地狱”,测试困难、耦合隐形
  • 一个项目常见 3–6 个:GameManager、AudioManager、SceneManager、EventBus、SaveManager,再多就要反思架构

EventBus 模式:放一个只含信号的 Autoload,谁都能发、谁都能订:

public partial class EventBus : Node
{
[Signal] public delegate void PlayerDiedEventHandler();
[Signal] public delegate void LevelCompleteEventHandler(string levelId);
}

敌人不用认识玩家、UI 不用认识敌人,全部通过 EventBus 喊话。适合事件类型多、关系网复杂的中大型项目;小项目直连更直观,别过度设计。

5.6 场景切换与数据传递#

// SceneManager(Autoload)
public void ChangeScene(string path)
{
// 带延迟时用 await;async 方法 Godot 支持,下一节讲
GetTree().ChangeSceneToFile(path);
}

切场景时旧场景树整个释放。跨场景带数据的三种正规方式:

  1. Autoload 上的字段(金币总数、玩家血量)
  2. 存档文件/Resource(第 6 章)
  3. 新场景实例化后手动注入(不使用 ChangeSceneToFile,而是 PackedScene.Instantiate() 后设置属性再 AddChild):
var next = ResourceLoader.Load<PackedScene>("res://scenes/level2.tscn").Instantiate<Level2>();
next.EntryDoorId = 3; // 先注入数据
GetTree().Root.AddChild(next); // 再进树(_Ready 此时才触发,数据已就位)
GetTree().CurrentScene.QueueFree();

5.7 await:Godot 里的异步直觉写法#

信号和计时器最常配 await,C# 在 Godot 里写异步意外地舒服:

public override async void _Ready()
{
GD.Print("等 1 秒");
await ToSignal(GetTree().CreateTimer(1.0f), SceneTree.SignalName.Timeout);
GD.Print("1 秒到");
var door = GetNode<Door>("Door");
await ToSignal(door, Door.SignalName.Opened); // 等门开信号
GD.Print("门开了,继续");
}

要点:

  • async void 只在重写 Godot 生命周期方法时用(_Ready 等),普通异步方法用 async Task(教程二第 6 章)
  • await ToSignal(对象, 信号名) 是 Godot 的等待原语
  • 帧等待:await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame)
  • 玩家死亡后延迟 2 秒重生、过场动画播完再进下一关,全是这个套路

5.8 反面模式(新手都会走的弯路)#

  1. 超长路径:GetNode("../../Player/Hitbox/Sprite").Visible = false——树一改就崩,说明该用信号或组
  2. 静态变量满天飞:public static Player Instance 到处建,切场景后指向已释放对象,报 ObjectDisposed
  3. 每帧 Find:_Process 里 GetNodeInGroup 找对象——每帧搜树是性能浪费,在 _Ready 缓存一次
  4. 信号忘记取消导致”死了还在收信”:短生命周期对象订阅长生命周期对象时最常见,症状是切场景后报错或一次事件触发多次。规矩:谁订阅谁负责退订,或保证生命周期一致
  5. 用信号传一切:父节点让直属子节点播放动画,直接调方法比声明信号清楚。信号是解耦工具,不是绕路工具
  6. 在 _Ready 里假设兄弟节点已就绪:兄弟节点的 _Ready 顺序不保证。需要”整棵树都准备好了”时用 await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame) 或实现 ISerializationListener 风格的初始化;更简单的规矩:父节点在自己的 _Ready 里主动调用子节点初始化方法(父 _Ready 在子 _Ready 全部完成后才执行)

关于最后一条的精确规则:子节点 _Ready 先于父节点执行。所以父在 _Ready 里访问子节点是安全的,子在 _Ready 里直接访问父/兄弟则要小心。

5.9 一张决策流程图#

我要让 A 影响 B,怎么连?
B 是 A 的直属子节点?
├─ 是 → GetNode/[Export] 直接调用
└─ 否 → 关系稳定且在同一场景?
├─ 是 → [Export] 拖拽引用 或 信号
└─ 否 → A 发的是"发生了什么事"的通知?
├─ 是 → 信号(局部)/ EventBus(全局)
└─ 否,只是找一批同类对象 → 组 + 接口
需要跨场景存活?→ Autoload

下一章讲 Resource 与存档:怎么把武器数值、敌人配置做成可复用资产,怎么把游戏进度存到磁盘。


《Godot C# 零基础》系列导航

⬅️ 上一篇:Godot C# 零基础教程 04:2D 平台跳跃 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:Godot C# 零基础教程 06:Resource 数据驱动、动态实例化与存档

Godot C# 零基础教程 05:信号与节点通信
https://021028.xyz/posts/default/165/
作者
021028
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0