> 上一篇跑通了一个方块。这一篇回答更根本的问题:Godot 为什么这样设计?理解了「节点 + 场景 + 信号」三件套,后面七级台阶全是 downhill。
一、节点:单一职责的最小单元
Godot 里没有 Unity 那种「挂一堆 Component 的 GameObject」。它的原子单位是节点(Node),每个节点只干一件事:
Sprite2D只负责显示一张图;CollisionShape2D只负责声明一块碰撞区域;Camera2D只负责「从这里看世界」;Timer只负责计时并在时间到时发一个信号。
CharacterBody2D(管物理) + Sprite2D(管外观) + CollisionShape2D(管碰撞)。功能靠树的形状来组合,而不是靠往一个对象上贴组件。这是两种哲学:Unity 是「组件斜挂在一个宿主上」,Godot 是「职责垂直长成一棵树」。节点不是随便长成一团的——它是一棵树,节点从构造上就知道自己的父与子。由此诞生了 Godot 最重要的行为模型:树就是调用链。
二、生命周期:树的呼吸节律
节点的一生按树序展开,这几个回调的顺序必须刻进肌肉记忆:
_init() —— 对象构造,还不属于树
_enter_tree() —— 被放进树(父先于子)
_ready() —— 自底向上调用:先所有子节点,最后父节点
_process(delta) —— 每渲染帧
_physics_process(delta) —— 每物理帧(默认 60 Hz,与渲染帧率解耦)
_exit_tree() —— 被移出树
最反直觉也最重要的是 _ready() 自底向上:父节点的 _ready() 跑的时候,所有子节点都已就绪。所以「初始化依赖子节点」的代码放父的 _ready() 里是安全的,这也是为什么配对资源、建立引用的惯例位置是 _ready()。
_process 与 _physics_process 的分工要背下来:移动、碰撞相关放 physics;外观、动画、UI 放 process。物理帧率固定(可在项目设置改),渲染帧率浮动(可能是 30 也可能是 240),两边的数据混用就会出现「手感时好时坏」这类玄学问题。另外 4.x 提供了 physics_ticks_per_second 与 physics/common/physics_interpolation(物理插值,4.5 又重做过),让物理步进与渲染平滑兼得——这是进阶话题,第九级再表。
三、场景:节点树的持久化单位
把一棵节点树存成文件,就是场景(.tscn)。场景是 Godot 的「类」——不是 OOP 的类,而是可复用的组合单元:
- 一个 Player.tscn 可以实例化 100 次,生成 100 个独立玩家;
- 一棵树里的每个节点都能被单独编辑、覆盖属性;
- 场景可以继承(Inherited Scene):改基类,所有派生场景自动跟进。
- 实例化:把 .tscn 从 FileSystem 拖进视口,或代码里
preload("res://enemy.tscn").instantiate()。 - 可编辑子场景(Editable Children):实例化的场景默认是「只读团块」,右键勾选后可局部改子节点属性而不动本体。
- 4.6 的独特节点 ID:从 4.6 起,节点拥有引擎内部唯一 ID,改名、移动、跨场景继承都不会再弄丢引用——旧版本里「改个名全场景报错」的著名惨案从此绝迹。官方提示:老项目需用「项目 → 工具 → 升级项目文件」重存一遍场景以获得此能力。
var enemy := ENEMY_SCENE.instantiate()
add_child(enemy) # 挂到当前节点下,_enter_tree/_ready 随即触发
enemy.queue_free() # 帧末安全销毁;立即销毁用 free(),慎用
四、信号:Godot 的解耦神经
树决定了「父子关系」,而信号(Signal)解决「兄弟之间、陌生人之间怎么说话」。
信号是观察者模式的语言级实现。节点自己不关心谁在听,只管「喊一嗓子」:
# 声明与发射
signal health_changed(new_value: int)
health_changed.emit(hp)
# 连接(代码方式)
player.health_changed.connect(_on_player_health_changed)
编辑器里则是在节点面板右下角「节点」标签页双击信号、选择接收节点,一秒连好。四条实战心法:
- 自带信号够用九成:
Timer.timeout、Button.pressed、Area2D.body_entered、AnimationPlayer.animation_finished……先查有没有现成的再自造。 - 连接用 Callable:4.x 里
connect(btn.pressed)传的是Callable对象,btn.pressed.connect(_on_pressed.bind(item_id))用.bind()附带参数,这是与 3.x 的最大差异。 - await 让异步线性化:
await player.health_changed会挂起当前协程等信号,await get_tree().create_timer(1.5).timeout就是一行延时——游戏脚本从此有了「读起来像流程」的写法。 - 断连要负责:被销毁的对象的连接会自动清理,但长生命周期对象连接短生命周期信号时要
disconnect或用CONNECT_ONE_SHOT。
五、组合优于继承:一个真实的设计对比
需求:敌人有两种——哥布林会近战,蝙蝠会飞但血薄。继承派的写法是 Enemy -> GoblinEnemy / BatEnemy 层层派生;Godot 的组合派则是:
Player与Enemy都拥有Health.tscn(一个管血量的可复用场景:节点 + 信号died+hurt(amount)方法);- 哥布林 = 移动场景(地面型)+ 近战武器场景 + Health;
- 蝙蝠 = 移动场景(飞行型)+ 远程武器场景 + Health(血量上限参数不同)。
六、本篇速记
- 节点单一职责,树即调用链;
_ready()自底向上。 - 场景 = 可实例化、可继承、可局部覆写的节点树文件。
- 信号解耦通信,Callable + await 是 4.x 语法。
- 组合横切场景,继承留给真正的 is-a。
*本篇配图:一棵场景树,信号沿树流动。*