Godot 里没有“组件”这个东西,至少引擎不强迫你用组件。你新建一个项目,默认场景就是一个光秃秃的节点,右键能加的东西是节点,代码挂在节点上,节点挂在节点上,最后长成一棵树。这套设计跟很多人的直觉是反的——习惯了“空物体挂一堆组件”的人,第一次打开 Godot 会懵:为什么我加一个精灵,还要外面套一个节点?
答案藏在Node这个基类里。它不是一个“容器”,它是 Godot 里最小的可寻址、可执行、可序列化单元。名字、父子关系、生命周期回调、每帧处理开关、暂停策略、网络权限,这些东西全都定义在Node上,而不是分散在某个“组件系统”里。理解了Node的属性,你就理解了 Godot 的运行骨架,后面用 Godot 做 2D、3D、UI、工具,走的都是同一套逻辑。
这篇东西是我自己踩了几轮坑之后整理的,重点放在Node基类那几个容易被忽略的属性上——尤其是owner、process_mode、unique_name_in_owner这三个,翻车的项目里十有八九有它们的影子。顺带把常用节点的选型、节点生命周期的时序、性能开关、以及排查思路一起讲透。不管你是刚上手 Godot 4 的新手,还是从别的引擎转过来、写了两三个demo但总觉得“结构乱”的人,应该都能捞到点能直接抄的东西。
1. 节点到底是什么:先把 Godot 的世界观掰正
1.1 一切皆节点,节点组成树
Godot 的场景就是一棵节点树。根节点是最上面的那个,往下每一层是子节点,get_node("A/B/C")这种路径写法就是顺着树往下找。你现在看到的整个游戏运行时,本质上是若干棵树互相挂接——主场景树、被实例化进来的子场景树、运行时动态创建的节点,全都挂在同一棵以根节点为顶的树上。
这个模型带来的最大好处是统一。一个敌人是节点,一个按钮是节点,一个计时器是节点,一个播放音乐的玩意儿也是节点。你不需要记“这个东西要不要挂在实体上”“那个东西是不是独立系统”,所有东西都遵循同一套规则:有父、有子、有名字、有生命周期。坏处也很明显:树一旦深,路径一长,节点一多,找起来就痛苦。所以 Godot 项目做得越大,“怎么组织树”就越重要,这个后面第 4 章会细说。
需要提前说清楚一件事,避免搜索时被带偏:Godot 的Node和 Node.js 里的 Node完全没有关系。一个是游戏引擎的场景单元,一个是服务端 JS 运行时,只是名字撞了。你在搜“Godot 节点”的时候会看到大量服务端环境配置的内容,直接划过去就行,别浪费时间。
1.2 继承链决定能力边界
Node本身是个非常“空”的类,它只有身份、树关系、生命周期和处理开关,没有位置,没有尺寸,没有画面。真正让节点“能看见”的,是它的子类。这条继承链值得背下来:
Object→Node:所有场景节点的根,提供生命周期、分组、信号、RPC。Node→CanvasItem→Node2D/Control:2D 世界里所有“有位置”的东西都从CanvasItem分出来。Node2D用像素坐标,Control用锚点和边距做 UI 布局。Node→Node3D:3D 世界,带Transform3D、旋转、缩放。Node→Timer/AnimationPlayer/AudioStreamPlayer:纯逻辑或纯播放类,它们没有空间属性,因为不需要。
选节点的第一原则就是往上找最近的那个能覆盖你需求的基类,别乱用。你要一个能移动的精灵,就该用Sprite2D(它是Node2D的子类),而不是给一个Node硬塞位置逻辑。你要一个 UI 面板,用Control或它的容器子类,而不是Node2D加一堆手动坐标——那样窗口一缩放就全乱。
1.3 生命周期回调的调用顺序
这是最容易搞错的地方。Godot 的节点回调不是随便触发的,顺序有严格约定,我列成表:
| 回调 | 触发时机 | 调用方向 | 这个阶段能干什么 |
|---|---|---|---|
_init() | 对象构造完成,还没进树 | 单个 | 初始化纯数据字段,不能碰$Path |
_enter_tree() | 节点进入场景树 | 父 → 子 | 注册分组、连接父节点信号 |
_ready() | 自己和所有子节点都进树完成 | 子 → 父 | 拿@onready引用、初始化显示、连信号 |
_process(delta) | 每渲染帧 | 按优先级 | 与帧率相关的逻辑、动画插值 |
_physics_process(delta) | 固定步长(默认 60Hz) | 按优先级 | 物理相关、稳定位移 |
_exit_tree() | 离开场景树 | 父 → 子 | 清理外部引用、断开连接 |
NOTIFICATION_PREDELETE | 对象被释放前 | 单个 | 最后清理,极少用 |
关键点有两个。第一,_ready是子节点先于父节点执行的,这意味着父节点在_ready里访问子节点时,子节点一定已经准备好了,这个顺序对写代码非常友好。第二,_init执行的时候节点还没进树,$Sprite这种东西百分百是null,新手写func _init(): $Sprite.modulate = ...报错就是栽在这。
注意:如果一个节点从进树到出树的生命周期中会反复进出(比如被
remove_child再加回来),_ready只会执行一次,_enter_tree会执行多次。别把“每次出现都要做的事”写进_enter_tree,那个才是真的会被反复调。
1.4 一个节点只能有一个父亲
add_child的行为需要理解清楚:当你把一个已经有父节点的节点add_child到新父节点下,Godot 会自动把它从原来的父节点上摘下来,不会报错,也不会克隆。这个特性在某些“转移归属”的场景里很好用,但它也是 bug 来源——你以为复制了一份,其实只是搬家了。
真要复制,用duplicate(),或者如果是场景,用PackedScene.instantiate()。这两个的区别是:duplicate()复制的是当前这个节点的状态,包括运行时的属性改动;instantiate()是从.tscn文件重新生成一份干净的,好处是可复用、可池化,坏处是它没有你在编辑器里手动改过的运行时状态。实战里做子弹、特效、敌人这类东西,一律走instantiate(),别用duplicate(),因为前者能配合资源预加载和对象池,后者会把内存里那份状态也带过去,容易出诡异的遗留数据。
2. Node 节点属性逐项拆解
2.1 身份三件套:name、owner、unique_name_in_owner
name是节点在树里的标识,get_node走的就是它。同名节点被加进同一个父节点下时,Godot 会自动改名为@Node2D@2这种带@的形式,能跑但很难看,也说明你的命名设计有问题。
owner是最容易被忽略、也最重要的一个属性。它的含义是:“这个节点属于哪个场景的根”。动态创建的节点默认owner是null,你在编辑器里点“保存场景”,那些owner为null的节点不会被写进.tscn文件。很多人做编辑器插件或者运行时生成关卡,保存后发现节点全没了,就是这个原因。记住一条:
var node := Node2D.new() add_child(node) node.owner = self # 或者 owner = get_tree().edited_scene_root(编辑器插件场景)unique_name_in_owner是 Godot 4 加的好东西,对应代码里的%Sprite写法,编辑器里节点名字旁边有个百分号图标可以勾。勾上之后,这个节点在它所在的场景根范围内名字唯一,任何属于这个场景的脚本都能用%Sprite直接拿到它,不用写$A/B/C/D/Sprite这种长路径。这玩意儿对重构极其友好:你调整树结构,只要节点还在同一个 owner 场景里,%引用就不需要改。我的习惯是,凡是会被别的脚本跨层级访问的节点,一律勾上unique_name_in_owner,凡是只在本节点内部用的,用$短路径。
2.2 处理开关:process_mode 与优先级
Godot 4 把暂停策略做成了process_mode这个枚举,它替代了 Godot 3 的pause_mode:
| 枚举值 | 含义 | 典型用途 |
|---|---|---|
PROCESS_MODE_INHERIT | 跟随父节点(默认) | 绝大多数节点 |
PROCESS_MODE_PAUSABLE | 暂停时停止处理 | 玩家、敌人、普通 UI |
PROCESS_MODE_WHEN_PAUSED | 只在暂停时处理 | 暂停菜单的动画 |
PROCESS_MODE_ALWAYS | 无视暂停,一直处理 | 全局管理器、音乐播放 |
PROCESS_MODE_DISABLED | 完全停止 | 临时冻结某棵子树 |
get_tree().paused = true之后,PAUSABLE的节点全部停摆,ALWAYS的照常跑。想做一个暂停菜单,最省事的组合就是:菜单根节点设成ALWAYS,内部按钮照常响应;游戏世界设成PAUSABLE(默认就是继承,只要根节点对就行)。
process_priority和process_physics_priority控制的是同类处理之间的执行顺序,数值越小越先执行。默认都是 0。这个有什么实战意义?典型场景是相机跟随。相机需要每帧读完玩家位置之后再更新,如果相机的_process在玩家之前执行,画面上就会慢一帧,快速移动时肉眼可见抖动。把相机节点的process_priority设成比玩家大一点,比如玩家 0、相机 10,就能消除这个延迟。
注意:
process_priority只影响同一棵树内同类处理的相对顺序,不改变“渲染帧”和“物理帧”的关系。物理和渲染的时序问题要靠别的手段解决,别指望调一个数字搞定所有事。
2.3 手动开关:set_process 与 set_physics_process
比在函数里if not active: return更省的做法,是直接把处理关掉:
func _ready() -> void: set_process(false) # 关闭 _process set_physics_process(false) # 关闭 _physics_process关掉之后,引擎根本不会调用你的回调,函数体的分支判断也被省了。一个场景里有几百个敌人,其中大部分时间处于待机状态,把它们set_process(false)是实打实的性能收益。开启同理,状态切换时再set_process(true)。
这套开关还有几个兄弟:set_process_input()、set_process_unhandled_input()、set_process_unhandled_key_input(),分别对应输入回调。很多人加了_unhandled_input却发现自己不需要,把这个关掉也能省一点。
2.4 网络与权限:multiplayer_authority
Node.multiplayer是个MultiplayerAPI引用,你通过它调rpc()、rpc_id()。Godot 4 里更值得注意的是权限这个属性:
set_multiplayer_authority(peer_id) # 设置本节点及其子节点的权限归属 is_multiplayer_authority() # 当前实例是否拥有权限 get_multiplayer_authority() # 拿到当前归属的 id默认情况下,权限 id 是 1(服务器)。这意味着如果你不加处理就写is_multiplayer_authority(),客户端会得到false。做多人游戏时,玩家输入一定要在is_multiplayer_authority()为真的那一端处理,否则会出现“我在本地动了,同步过去又被打回来”的抖动手感。
set_multiplayer_authority第二个参数是recursive,默认true,会把所有子节点一起设。这个默认值有利有弊:一次性设置整棵玩家子树很方便,但如果你有子节点需要单独授给不同的人,就要在设置完父节点后再单独覆写子节点。
配合MultiplayerSynchronizer和MultiplayerSpawner这两个节点,Godot 4 能覆盖大部分中低频同步需求。这套东西细节不少,但Node层面的关键点就一句话:先想清楚哪个节点归谁管,再谈同步。
2.5 编辑器辅助属性
editor_description是个纯编辑器字段,可以给节点写备注,运行时读不到(也不该读)。团队协作时,把“这个节点为什么存在”“动它之前先看哪个文档”写进去,比写代码注释更容易被看见,因为它在检查器面板里直接显示。
还有一个scene_file_path,只读,返回这个节点所属场景文件的路径。做编辑器工具或者调试面板时挺有用,比如你想打印“当前选中的节点来自哪个.tscn”,直接读它就行。
3. 常用节点选型地图
3.1 2D 表现层节点
做 2D 内容,日常就那么几个:Sprite2D负责静态图,AnimatedSprite2D负责帧动画,AnimationPlayer负责关键帧动画(可以驱动任意属性,不只是位置),TileMapLayer负责格子地图(Godot 4.3 之后TileMap被拆成了TileMapLayer,这个变更让瓦片层的层级管理清晰了很多)。
CollisionShape2D、Area2D、CharacterBody2D、RigidBody2D这几个是物理相关的。选的时候记一条:要自己控制位移就用CharacterBody2D,要物理模拟推箱子就用RigidBody2D,想检测重叠区域用Area2D。别在RigidBody2D上手动改position,那个属性是物理引擎在管的,手动改会被下一步模拟覆盖掉,表现就是“抖动或者瞬移”。
3.2 UI 与容器节点
UI 全部从Control派生。核心原则是:用容器,别手写坐标。HBoxContainer横排,VBoxContainer竖排,MarginContainer撑边距,CenterContainer居中,GridContainer网格。容器会自动计算子节点位置和尺寸,窗口缩放时自动重排,这就是用Control而不是Node2D做 UI 的全部理由。
新手最常见的坑是:给Control手动设了position和size,然后把它塞进一个容器,发现位置完全不是自己设的那样。原因是容器接管了子节点的布局,会覆写这些值。要改子节点在容器里的表现,应该改它的size_flags_horizontal、size_flags_vertical、custom_minimum_size这些属性,而不是position。
3.3 逻辑与控制节点
纯逻辑节点里最常用的是Timer。它的两种用法要分清:autostart打开 +one_shot控制是否循环,或者代码里timer.start(0.5)再await timer.timeout。后者在协程式写法里特别顺手:
await get_tree().create_timer(0.3).timeout # 0.3 秒后继续执行这行代码创建了一个临时SceneTreeTimer,不需要你管理节点生命周期,到期自动清理。做一次性延迟、技能后摇、动画衔接,比挂一个Timer节点省事得多。要注意的是,SceneTreeTimer默认不受paused影响?实际上它有个process_always参数,默认true,也就是暂停时它仍然会走。要让延迟跟着暂停停住,得显式传false。
Node本身也常被直接拿来当分组容器或纯逻辑脚本载体。比如一个GameManager节点,什么画面都没有,extends Node,挂在主场景根下,管全局状态。这种用法很正当,不是“偷懒”,因为它就是没有空间属性的东西,用Node是最贴合语义的选择。
3.4 选型对照速查表
| 需求 | 该用的节点 | 别用的节点 | 理由 |
|---|---|---|---|
| 2D 精灵显示 | Sprite2D | Node2D+ 手动画 | 前者自带纹理渲染 |
| UI 面板 | Control/PanelContainer | Node2D | UI 需要锚点和布局系统 |
| 玩家角色(自控) | CharacterBody2D | RigidBody2D | 自己管位移更可控 |
| 检测进入区域 | Area2D | CollisionShape2D单挂 | CollisionShape2D必须挂在物理体下 |
| 全局状态管理 | Node | Node3D/Node2D | 不需要空间属性就别加 |
| 定时任务 | Timer节点或create_timer | 手写累加计时 | 现成的别造轮子 |
| 可复用敌人 | 独立.tscn+instantiate | duplicate | 场景可池化、状态干净 |
4. 实操:搭一个能打的玩家节点结构
4.1 场景树怎么分层
我写 2D 玩家一般这么分,这套结构用了几个项目都没大改过:
Player (CharacterBody2D) ├── Body (Node2D) # 视觉根,方便整体翻转 │ ├── Sprite2D │ └── AnimationPlayer ├── CollisionShape2D ├── HurtBox (Area2D) # 受击检测 │ └── CollisionShape2D ├── GroundCheck (RayCast2D) # 地面检测 └── Camera2D # 相机跟在这分层的逻辑是:所有会整体翻转、整体缩放的东西放在一个中间节点下。因为CharacterBody2D的缩放会影响碰撞体的物理表现,把scale.x = -1直接加在CharacterBody2D上,碰撞形状也跟着翻,某些情况下会出现碰撞异常。正确做法是翻转里面的Body节点,物理体本身不动。
Camera2D挂在玩家下面,打开position_smoothing_enabled就能获得平滑跟随。同时别忘了前面说的process_priority,把相机设成 10 左右,避免跟随延迟。
4.2 引用节点:@onready 与 @export 的分工
Godot 4 的@onready关键字会在_ready时机执行赋值,写法很干净:
extends CharacterBody2D @export var move_speed: float = 280.0 @export_range(0.0, 1.0, 0.01) var accel_lerp: float = 0.2 @export var bullet_scene: PackedScene @onready var body: Node2D = $Body @onready var sprite: Sprite2D = $Body/Sprite2D @onready var anim: AnimationPlayer = $Body/AnimationPlayer @onready var ground_check: RayCast2D = $GroundCheck var facing: int = 1这里的分工要讲清楚:@export是给编辑器出的接口,让策划或者你自己在检查器里调数值,改完不用碰代码;@onready是内部引用,只在代码里用,不出现在检查器。还有一个@export_group/@export_subgroup可以把检查器里一堆参数折叠分类,参数量上来之后这个很关键:
@export_group("Movement") @export var move_speed: float = 280.0 @export var jump_force: float = -420.0 @export_group("Combat") @export var attack_damage: int = 10提示:
@onready变量的赋值时机是_ready之前,所以你在_ready里可以直接用它们;但如果你在_init里用,拿到的是null。这个顺序关系一定要记住。
4.3 信号连接与解耦
Godot 4 的信号连接语法变了,不再用connect("name", self, "method"),而是:
func _ready() -> void: $Body/AnimationPlayer.animation_finished.connect(_on_animation_finished) add_to_group("player") func _on_animation_finished(anim_name: StringName) -> void: if anim_name == &"attack": set_process_unhandled_input(true)信号的价值在于调用方不需要知道被调用方的类型。玩家受击时不需要if enemy is EnemyA: ... elif enemy is EnemyB: ...,只要敌人发出damaged(amount)信号,玩家连上就行。这在项目变大、敌人种类变多之后是决定性的。
自定义信号用signal关键字声明,位置在类顶部:
signal health_changed(current: int, maximum: int) signal died func take_damage(amount: int) -> void: hp -= amount health_changed.emit(hp, max_hp) if hp <= 0: died.emit()带类型参数的信号在 Godot 4 里能获得编辑器补全,这个体验提升比想象中大,强烈建议给所有自定义信号写参数类型。
4.4 分组:批量化操作的正确姿势
add_to_group("enemy")之后,一行代码就能批量操作:
get_tree().call_group("enemy", "on_player_died") var enemies := get_tree().get_nodes_in_group("enemy")分组适合做“广播式”行为,比如玩家死亡时所有敌人停止追击、关卡切换时所有可拾取物重置。它的成本要注意:get_nodes_in_group会构造一个数组,如果在_process里每帧调用,会产生持续的内存分配。正确做法是在需要的时候调一次,缓存结果。
还有一个propagate_call,它会沿着子节点树递归调用同名方法:
propagate_call("set_paused_visual", [true])这个方法名必须存在于所有被递归到的节点上?不需要,不存在就跳过(配合SetCallMode参数)。做全局视觉效果统一开关时挺好用。
4.5 实例化、owner 与释放
动态生成子弹的标准写法:
func shoot() -> void: var bullet := bullet_scene.instantiate() bullet.global_position = $Muzzle.global_position bullet.direction = Vector2(facing, 0) get_tree().current_scene.add_child(bullet)这里挂到current_scene而不是玩家自己下面,是为了让子弹不跟着玩家移动。被添加到current_scene之后,如果这个游戏场景已经保存了,子弹的owner仍然是null——这是对的,运行时生成的东西不该被写回场景文件。
释放节点一律用queue_free(),不要用free()。区别在于queue_free是在当前帧的所有处理结束后才真正释放,安全;free()是立刻释放,如果此时节点正在处理回调或者信号里,会直接崩。我刚学的时候图省事用free(),结果在_process里释放自己,整个游戏当场退出,日志里只有一行访问已释放对象的报错。
5. 性能:节点开销到底花在哪
5.1 每帧开销的两个来源
节点对性能的影响集中在两处:一是遍历,二是回调。遍历是指引擎每帧要维护场景树状态、处理输入派发、动画更新这些系统性工作,节点越多越慢;回调是指你自己写的_process/_physics_process被调用的次数。这两者叠加,一千个活跃节点和一百个活跃节点的差距是肉眼可见的。
优化的顺序应该是先砍回调,再砍节点。具体做法就是前面说的set_process(false)和set_physics_process(false)。一个屏幕外的敌人,把这两样关掉,再把visible设成false,几乎就不占什么开销了。
5.2 用对象池替代频繁实例化
子弹、粒子和飘字这类高频生的东西,用对象池:
var _pool: Array[Node] = [] func acquire() -> Node: if _pool.is_empty(): return bullet_scene.instantiate() return _pool.pop_back() func release(node: Node) -> void: node.visible = false node.set_process(false) node.set_physics_process(false) _pool.append(node)注意release里不要remove_child,那样会触发_exit_tree和后续的_enter_tree,反而更贵。保持它在树里,只是不可见、不处理,这才是池化的意义。取出来用的时候再打开visible和处理开关。
需要节制的一点是:池化会增加状态管理的复杂度。一个对象回收时如果有没重置干净的属性(比如速度、朝向、碰撞层),下一个使用者就会莫名其妙地表现异常。我的做法是给池化对象写一个reset()方法,release时统一调用,把该归零的归零,该复位的复位。
5.3 深层查找的代价
get_node("A/B/C/D/E")每次都要顺着路径逐层查字典,别在_process里反复调。@onready缓存一次就够。find_child("xxx", true, false)会递归扫描整棵子树,成本更高,只能用在初始化阶段,绝对不能放进每帧逻辑。
如果确实需要每帧拿某个节点,用@onready存成变量,或者用%UniqueName。%的查找也是逐层往上的,但它缓存在owner上,比路径查找快,而且结构改动时不用改代码。
6. 常见问题与排查实录
6.1 空引用:最常见的报错源头
Invalid get index 'xxx' on base 'null instance'这个报错,99% 是节点路径写错了或者时机不对。排查按这个顺序走:
- 打印路径验证:
print(get_node_or_null("Path/To/Node")),如果打出<null>就是路径问题。 - 检查节点名大小写与特殊字符。Godot 里节点名区分大小写,
Sprite2D写成sprite2d找不到。 - 检查是否在
_init里访问了$。_init阶段节点还没进树,$一定是null。 - 检查是不是被
queue_free了但引用还留着。访问已释放对象也会报类似错误,日志里会多一行previously freed。
6.2 时序问题
“我的_ready里读到的数据是初始值,不是设置后的值”——这类问题基本都是时序。_ready是子先父后,父节点的_ready里读子节点的某个值,如果那个值是在子的_ready里设的,能读到;如果是在子的_process里设的,就读不到。保证初始化逻辑都放在_ready里,是规避这类问题的基本纪律。
还有一类是await引入的延迟。await get_tree().create_timer(0.5).timeout之后继续执行的代码,已经不在原来的调用栈里了,此时如果节点已经被释放,继续操作它就会出问题。写法上要在await之后补一句:
await get_tree().create_timer(0.5).timeout if not is_inside_tree(): return6.3 暂停不生效
get_tree().paused = true之后发现某些东西还在动,通常有两个原因。第一,那个节点的process_mode是ALWAYS或者它继承的父节点是ALWAYS。INHERIT会一路往上找,一直找到没有父节点为止,所以要检查整条链。第二,那个东西在_input里处理,而输入回调不受paused影响,_input和_unhandled_input的主线程部分照常派发。要拦,得在回调里自己判断get_tree().paused。
6.4 节点保存不进场景文件
前面提过,根因是owner为null。编辑器里手动加的节点,owner会自动设成当前编辑的场景根,所以能存。代码里Node2D.new()创建的没有owner,保存时被跳过。给它们设上owner就正常了。
6.5 速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
null instance报错 | 路径错 / 时机早 | 用get_node_or_null验证,挪到_ready |
| 保存场景后节点消失 | owner为null | 创建后设置owner = self |
| 暂停时部分节点还在动 | process_mode为ALWAYS | 检查节点及父链的process_mode |
| 相机跟随有延迟感 | process_priority顺序不对 | 相机优先级设为大于玩家 |
| 手动物体位置会抖动 | 用了RigidBody2D还改position | 改用CharacterBody2D或施加力 |
| UI 位置不受控 | 在容器里改position | 改size_flags或custom_minimum_size |
同名节点自动被加@后缀 | 兄弟节点重名 | 统一命名规范 |
| 递归查找卡顿 | find_child放进了每帧逻辑 | 移出_process,改用缓存引用 |
7. 踩过几次坑之后,我对节点设计的一点看法
用了这么久,我越来越觉得 Godot 的节点系统真正的难点不在 API,而在边界划分。同样一个功能,你可以写成一个五百行的玩家脚本,也可以拆成玩家、状态机、血量组件、输入处理四个节点。两种都能跑,但后者在需求变化时改动成本低得多。我现在的习惯是:一个脚本超过两百行就考虑拆节点,一个节点承担超过两类职责就考虑拆节点。拆的依据不是“代码行数好看”,而是“这个地方有没有可能独立变化”。血量规则会不会变?会,那就拆。输入映射会不会变?会,那就拆。
另外一个体会是关于“向下依赖”。父节点知道子节点是正常的($Body/Sprite2D),但子节点不该假设自己的父节点是某个具体类型。子节点要跟外部通信,一律走信号往上传,或者用分组去广播。这条规矩听起来教条,但项目做到中期之后,你会发现它能救你不少时间——因为你永远不知道那个敌人节点以后会被挂到哪个父节点下面。
最后留一个我常用来验证节点结构是否合理的小练习:把主场景完整跑起来,在调试器里选中一个节点,问自己“如果把这个节点整棵子树删掉,游戏还能跑吗?”如果答案是“崩溃”,说明耦合过紧,该用信号或者分组解耦了。如果答案是“能跑,只是少了某个功能”,那这个子树的设计就是干净的。这个检验方式很粗暴,但比读一堆架构文章管用。