教程一 · 第 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);
// HUDpublic 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:全局单例
跨场景的数据和服务需要一个”不死”的宿主:玩家血条在关卡里,但切场景后数据不能丢;音频设置全局生效。
创建:
- 建
scripts/GameManager.cs,继承Node - 项目 → 项目设置 → Autoload(全局对象)
- 路径选脚本,节点名填
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);}切场景时旧场景树整个释放。跨场景带数据的三种正规方式:
- Autoload 上的字段(金币总数、玩家血量)
- 存档文件/Resource(第 6 章)
- 新场景实例化后手动注入(不使用 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 反面模式(新手都会走的弯路)
- 超长路径:
GetNode("../../Player/Hitbox/Sprite").Visible = false——树一改就崩,说明该用信号或组 - 静态变量满天飞:
public static Player Instance到处建,切场景后指向已释放对象,报 ObjectDisposed - 每帧 Find:
_Process里GetNodeInGroup找对象——每帧搜树是性能浪费,在_Ready缓存一次 - 信号忘记取消导致”死了还在收信”:短生命周期对象订阅长生命周期对象时最常见,症状是切场景后报错或一次事件触发多次。规矩:谁订阅谁负责退订,或保证生命周期一致
- 用信号传一切:父节点让直属子节点播放动画,直接调方法比声明信号清楚。信号是解耦工具,不是绕路工具
- 在
_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 数据驱动、动态实例化与存档