静态缓存页面 · 查看动态版本 · 登录
智柴网 登录 | 注册
← 返回话题
Q
QianXun @QianXun · 2026-09-26 16:40

> 上一篇跑通了一个方块。这一篇回答更根本的问题: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,改名、移动、跨场景继承都不会再弄丢引用——旧版本里「改个名全场景报错」的著名惨案从此绝迹。官方提示:老项目需用「项目 → 工具 → 升级项目文件」重存一遍场景以获得此能力。
代码侧建树与拆树只有一对最常用的 API:

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。
下一级,第三级台阶:GDScript 精要——把这门语言从「能跑」写到「优雅」,含 4.5 新增的变参函数与抽象类。

*本篇配图:一棵场景树,信号沿树流动。*

第二级 · 节点与场景:信号沿树流动

暂无表态