节点与场景
Godot 的一切都是节点,节点组成树,树可以存成场景再反复实例化。本章讲场景树、实例化、组合、三大节点家族、get_node 路径、owner、分组和唯一名称。
上一章打开了太阳系示例,看到了 Solar → Sun → Earth → Moon 这样一棵节点树。这一章把 Godot 组织游戏的方式讲完整:节点是什么、它们怎么组成树、树怎么保存成场景文件再被反复使用,以及在代码里怎么找到别的节点。这些概念会贯穿后面的每一章。
1. 节点:只做一件事的积木
节点(Node)是 Godot 里最小的功能单位。每种节点只负责一件事:Sprite2D 显示一张图,Camera2D 决定画面看哪里,Timer 负责倒计时,AudioStreamPlayer 播放声音,CollisionShape2D 描述一个碰撞形状。引擎内置了几百种节点,它们都继承自同一个基类 Node。
一个"角色"在 Godot 里不是一个大对象,而是几个节点拼起来的:一个 CharacterBody2D 负责移动和碰撞,下面挂一个 Sprite2D 显示外观、一个 CollisionShape2D 给出形状、一个 AnimationPlayer 播放动画。你写的脚本挂在其中某个节点上,扩展它的行为。
每个节点都有这几样东西:
- 名字(
name):在同一个父节点下不能重复,用来组成路径。 - 父节点和子节点:除了根节点,每个节点恰好有一个父节点,可以有任意多个子节点。
- 回调:
_ready()、_process()等,由引擎在合适的时机调用(第 3 章详细讲顺序)。 - 信号:节点发生了什么事时向外广播(第 4 章)。
- 属性:在检查器里看到的那些值,脚本里用
@export声明的变量也会出现在检查器里。
2. 场景树:父子关系意味着什么
游戏运行时,所有活着的节点都在一棵树上,这棵树由 SceneTree 管理(脚本里用 get_tree() 拿到)。树的根是一个 Window 节点,路径是 /root;自动加载的单例(比如本示例的 Tuning 和 Events)挂在 /root 下面,然后才是当前场景。
把一个节点放到另一个节点下面,不只是"整理文件夹"。父子关系会带来这些实际效果:
- 变换继承:2D 和 3D 节点的位置、旋转、缩放都是相对父节点的。父节点移动,子节点跟着动(第 5 章讲数学)。
- 可见性继承:父节点
visible = false,子节点一起隐藏。 - 生命周期绑定:父节点被删除,整棵子树一起删除;父节点进树,子节点一起进树。
- 处理模式继承:
process_mode默认是"继承",暂停父节点所在的树时,子节点也一起暂停。 - 绘制顺序:2D 里默认按树的顺序绘制,后面的节点画在前面的上面,子节点画在父节点上面。
下面这个小部件是一棵可以自己摆弄的树。左边是场景树,右边是 2D 画面:
试着做三件事:选中 Sun 拖动 rotation,Earth 和 Moon 都绕着它转;把 Moon 拖到 Sun 下面,Moon 不再跟着地球;切换"保持全局变换/保持本地变换"后再重新挂一次,看屏幕上的位置会不会跳。最后这一条对应 Node.reparent() 的第二个参数 keep_global_transform,默认是 true。
3. 场景:可以反复实例化的节点树
一棵节点树可以保存成一个场景文件(.tscn)。场景文件就是"模板":你可以在别的场景里放它的实例,也可以在代码里用 instantiate() 造出任意多份。别的引擎里叫"预制体"(Prefab)的东西,在 Godot 里就是场景。一颗子弹、一个敌人、一个 UI 面板、一整个关卡都是场景,场景里还可以再嵌套场景。
太阳系就是一个场景文件,它是纯文本,可以直接读:
[node name="Solar" type="Node2D"]
script = ExtResource("1")
[node name="Sun" type="Node2D" parent="." groups=["sun"]]
position = Vector2(480, 290)
script = ExtResource("2")
radius = 46.0
spin_speed = 20.0
[node name="Earth" type="Node2D" parent="Sun"]
position = Vector2(190, 0)
script = ExtResource("2")
kind = 1
radius = 20.0
spin_speed = 60.0
distance = 190.0
[node name="Moon" type="Node2D" parent="Sun/Earth"]
unique_name_in_owner = true
position = Vector2(58, 0)
script = ExtResource("2")
kind = 2
radius = 8.0
spin_speed = 90.0
distance = 58.0注意几个细节:根节点没有 parent;parent="Sun/Earth" 是相对于场景根的路径;文件里只记录和默认值不同的属性;groups=["sun"] 和 unique_name_in_owner = true 后面会讲到。
本示例的总入口 main.gd 在网页切换小场景时,做的就是"实例化一个场景、加进树里":
## 场景名 -> [场景文件, 标题, 操作提示]
const SCENES := {
"太阳系": ["res://solar/solar.tscn", "太阳系:父子节点与变换继承", "太阳 → 地球 → 月亮,每个节点只管自己转"],
"信号实验室": ["res://signals/signal_lab.tscn", "信号实验室:谁连接了谁", "点按钮、点宝箱,看信号沿着连线流动"],
"生命周期": ["res://lifecycle/lifecycle.tscn", "生命周期:回调的先后顺序", "点右上角的按钮增删节点,看日志和时间轴"],
}func _load_scene() -> void:
var info: Array = SCENES.get(scene, SCENES["太阳系"])
if _current != null:
_current.queue_free() # 旧场景在这一帧结束时才真正删除
var packed: PackedScene = load(info[0])
_current = packed.instantiate() # 实例化:照着"模板"造出一棵新的节点树
stage.add_child(_current) # 加进场景树后,它的 _ready() 才会被调用
hud.set_title(info[1], info[2])
tree_view.watch(_current)
Tuning.stat("当前场景", scene)实例化分两步:instantiate() 造出一棵新的节点树,这时它还不在场景树里,_ready() 不会执行,你可以先设置属性;add_child() 把它挂进树,从这一刻起它才开始收到 _ready()、_process()。下面是更通用的写法,包括加载场景的两种方式和删除节点:
## preload 在脚本编译时就加载好;路径必须是写死的字符串常量。
const SOLAR_SCENE := preload("res://solar/solar.tscn")
## 也可以在检查器里把 .tscn 拖到这个属性上,不用在代码里写路径。
@export var bullet_scene: PackedScenefunc spawn_solar_system(at: Vector2) -> Node2D:
var solar := SOLAR_SCENE.instantiate() as Node2D # 造出一棵新的节点树(还不在场景树里)
solar.position = at # 进树之前就可以设置属性
add_child(solar) # 进树:这之后它的 _ready() 才会执行
return solarfunc remove_it(node: Node) -> void:
node.queue_free() # 推荐:本帧结束时再删除,当前正在执行的代码不受影响
# node.free() # 立刻删除;如果别处还在用它,会出错- 运行后右上角会显示"运行中的场景树"面板,它每 0.25 秒遍历一次真实的节点树。
%Moon前面的 % 表示它勾选了唯一名称;最下面的Labels是solar.gd在代码里add_child()出来的,场景文件里没有它。 - 把当前场景切到"信号实验室"再切回来。每次切换都是一次
instantiate(),所以太阳系会从初始状态重新开始,轨迹也清空了。左边的"节点总数"会短暂地变化,因为旧场景在queue_free()后要到帧末才真正删除。 - 把太阳缩放拖到 1.5:只改了 Sun 一个节点的
scale,Earth 和 Moon 也一起变大、离得更远。
PackedScene.instance() 改名为 instantiate();get_tree().change_scene("res://x.tscn") 改名为 change_scene_to_file(),传 PackedScene 的版本叫 change_scene_to_packed();onready var 要写成 @onready var。另外,4.x 的 .tscn 文件头是 format=3,用 4.x 打开 3.x 的工程会自动转换,但最好先备份。4. 组合优于继承
面向对象教程常教你用继承搭体系:Entity → Character → Enemy → FlyingEnemy。在 Godot 里更常见的做法是组合:把一种能力做成一个独立的小节点(或小场景),谁需要就挂一个。比如"血量":
signal died
@export var max_health := 100
var health := max_health
func take_damage(amount: int) -> void:
health = maxi(health - amount, 0)
if health == 0:
died.emit()玩家、敌人、可以打碎的木箱都挂一个这样的 Health 节点,各自连接它的 died 信号做不同的事。新加一种"会掉血的东西"时,不需要去改一个所有人共用的父类。Godot 自带的节点本身就是按这个思路设计的:碰撞形状、动画、声音都是独立的子节点,而不是 CharacterBody2D 的成员变量。
继承仍然有用:脚本可以 extends 另一个脚本,场景也可以"继承"另一个场景(编辑器里"新建继承场景")。经验法则是:"是一种"用继承,"有一个"用组合。史莱姆是一种敌人,可以继承敌人场景;史莱姆有血量、有碰撞框,这些用子节点。
5. 三大节点家族速查
编辑器里节点图标的颜色就是家族:蓝色是 2D,绿色是 UI,红色是 3D,白色是通用节点。家族之间的区别主要在"坐标系"上:
| 家族 | 基类 | 坐标 | 常用节点 |
|---|---|---|---|
| 2D(蓝) | Node2D(继承自 CanvasItem) | position/rotation/scale,像素,y 朝下 | Sprite2D、AnimatedSprite2D、CharacterBody2D、Area2D、TileMapLayer、Camera2D、GPUParticles2D |
| UI(绿) | Control(也继承自 CanvasItem) | 锚点 + 偏移 + 容器自动布局,矩形区域 | Label、Button、TextureRect、ProgressBar、VBoxContainer、MarginContainer、Panel |
| 3D(红) | Node3D | Transform3D,米,y 朝上 | MeshInstance3D、CharacterBody3D、Camera3D、DirectionalLight3D、CSGBox3D |
| 通用(白) | Node | 没有位置 | Timer、AnimationPlayer、AudioStreamPlayer、HTTPRequest,以及自动加载单例 |
2D 和 UI 都是 CanvasItem,能混在一棵树里:信号实验室里的"攻击""治疗"按钮就是 Button(Control)直接挂在 Node2D 下面。HUD 通常放在 CanvasLayer 下面,这样摄像机移动时界面不跟着走——本示例左上角的标题卡片就在 Main/HUD 这个 CanvasLayer 里。
6. 在代码里找到别的节点
脚本经常要拿到别的节点,比如角色脚本要拿到自己的 Sprite。最基本的方法是 get_node(路径),它的简写是 $。路径的写法和文件路径很像:
## 第 2 章片段:取得别的节点的几种写法。
## 假设场景结构是:
## Level (本脚本)
## ├─ Player
## │ ├─ Sprite
## │ └─ %HealthBar (勾选了"作为唯一名称访问")
## └─ Enemies
## ├─ Slime
## └─ Batvar player := get_node("Player") # 相对路径:从自己出发,找名叫 Player 的子节点
var sprite := get_node("Player/Sprite") # 用 / 一层层往下走
var slime := $Enemies/Slime # $ 是 get_node() 的简写,效果完全一样
var quoted := $"Enemies/Bat" # 名字里有空格或特殊字符时,加引号var level := player.get_parent() # 往上走一层
var bat := slime.get_node("../Bat") # .. 表示父节点:从 Slime 出发 → Enemies → Bat
var root_window := get_node("/root") # 以 / 开头是绝对路径,从场景树的根(一个 Window)开始
var tuning := get_node("/root/Tuning") # 自动加载的单例就挂在 /root 下面规则只有三条:不以 / 开头的是相对路径,从调用者自己出发;.. 表示父节点;以 / 开头的是绝对路径,从 /root 出发。下面的小部件可以输入任意路径,一步步看它是怎么被解析的:
唯一名称 %Name
直接写路径有个问题:调整场景结构(比如给血条外面套一层容器)后,所有写死的路径都会失效。给节点勾选"作为唯一名称访问"(右键菜单里的 Access as Unique Name)后,同一个场景里的任何脚本都能用 %名字 直接拿到它,不管它藏在第几层:
var bar_long := get_node("Player/HealthBar") # 普通写法:要写全路径
var bar := %HealthBar # 唯一名称:不管藏在第几层,同一场景里直接拿到
var bar_same := get_node("%HealthBar") # 和上一行等价太阳系的 solar.gd 同时用了三种写法:
@onready var sun: CelestialBody = $Sun # 直接子节点
@onready var earth: CelestialBody = $Sun/Earth # 用路径往下找
@onready var moon: CelestialBody = %Moon # 场景唯一名称,不管藏多深都能直接拿到唯一名称的作用范围是一个场景文件。准确地说,%Moon 在调用者的 owner(场景根)范围内查找 owner 相同、且标记了唯一名称的节点。如果 Moon 是另一个被实例化进来的子场景内部的节点,外面的场景就不能用 %Moon 拿到它,需要先拿到那个子场景的根,再从它那里找。
var boss := get_node_or_null("Enemies/Boss") # 找不到时返回 null,不报错
if boss == null:
print("这一关没有 Boss")
if has_node("Enemies/Bat"):
print("有蝙蝠")
var deep := find_child("Sprite", true, false) # 按名字递归搜索(慢,别每帧调用)$Sprite 找不到任何东西。所以要写成 @onready var sprite := $Sprite,它会推迟到 _ready() 前一刻才赋值。另一个坑:在编辑器里给节点改名后,代码里的路径字符串不会自动更新,运行时才会报 "Node not found"。频繁被引用的节点,用 %唯一名称 或 @export var target: Node 在检查器里指定,会稳得多。按上面假设的结构,在 Slime 的脚本里调用 get_node("../../Player/Sprite"),会拿到什么?
- null,因为 Sprite 不是 Slime 的兄弟
- Player 下面的 Sprite
- 报错,路径里不能出现两次 ..
Slime → ..(Enemies)→ ..(Level)→ Player → Sprite。路径里可以有任意多个 ..,每个往上走一层。
7. owner:节点属于哪个场景文件
每个节点有一个 owner 属性,指向"把它保存下来的那个场景的根节点"。在编辑器里搭场景时 owner 是自动设置的,平时不用管;但有两种情况需要了解它:
- 唯一名称就是按 owner 划分作用范围的,上一节已经说过。
- 代码生成节点再保存:
PackedScene.pack()只会打包 owner 等于根节点的那些节点。代码里new()出来的节点 owner 是 null,不手动设置就会被漏掉。编辑器插件、关卡编辑器、存档系统经常踩这个坑。
## owner 决定"这个节点属于哪个场景文件"。代码里新建的节点,owner 默认是 null,
## 用 PackedScene.pack() 保存时会被漏掉;想把它一起存下来,就要手动设置 owner。
func save_with_marker(path: String) -> Error:
var root := Node2D.new()
root.name = "Saved"
var marker := Marker2D.new()
marker.name = "SpawnPoint"
root.add_child(marker)
marker.owner = root # 不设这一行,保存出的场景里就没有 SpawnPoint
var packed := PackedScene.new()
packed.pack(root) # 只打包 root 本身和 owner == root 的节点
root.free()
return ResourceSaver.save(packed, path)和 owner 相关的还有 reparent():把节点移到另一个父节点下面。它默认保持全局变换,也就是屏幕上的位置不跳,内部会反算出新的本地坐标。
func move_under(node: Node2D, new_parent: Node) -> void:
node.reparent(new_parent) # 默认保持全局变换不变:屏幕上看位置不跳
# node.reparent(new_parent, false) # 保持本地变换:position 数值不变,屏幕位置会跳关卡编辑器里,代码用 Marker2D.new() 创建了几个出生点并 add_child() 到关卡根节点下,然后用 PackedScene.pack(关卡根) 保存。重新打开后出生点都不见了。最可能的原因是?
- Marker2D 不能保存到场景里
- 应该用 call_deferred("add_child")
- 没有把出生点的 owner 设为关卡根节点
pack() 只保存 owner 等于根节点的子孙节点。代码创建的节点 owner 是 null,要手动 marker.owner = root。
8. 分组:给节点贴标签
有时候你不关心节点在树的哪里,只想找到"所有敌人""所有可以被打碎的东西"。分组(groups)就是贴在节点上的字符串标签,一个节点可以属于任意多个组。可以在编辑器的"节点 → 分组"面板里加,也可以在代码里 add_to_group():
func _ready() -> void:
add_to_group("enemies") # 代码里加组;也可以在编辑器"节点 → 分组"里加
if is_in_group("enemies"):
print("我是敌人")
func freeze_all_enemies() -> void:
for enemy in get_tree().get_nodes_in_group("enemies"): # 拿到组里所有节点
enemy.set_process(false)
func alert_everyone() -> void:
get_tree().call_group("enemies", "on_alarm") # 对组里每个节点调用 on_alarm()(没有这个方法的会被跳过)
func find_the_sun() -> Node2D:
return get_tree().get_first_node_in_group("sun") as Node2D # 组里的第一个;组是空的就返回 null太阳系里,太阳在场景文件里属于 "sun" 组(见上面 .tscn 第 9 行的 groups=["sun"])。地球和月亮画背光面时需要知道太阳在哪,它们没有用 get_parent() 或写死路径,而是 get_tree().get_first_node_in_group("sun")。这样无论太阳在树的哪个位置、地球被挂到谁下面,代码都不用改。
分组适合"一对多、不关心位置"的场景:给所有敌人发警报(call_group)、统计剩余金币数(平台跳跃示例的 get_node_count_in_group("coins"))、存档时找出所有需要保存的节点。它和信号互补:信号是"我发生了事,谁关心谁来听",分组是"我要找到所有某一类的节点"。
关于唯一名称 %HealthBar,下面哪个说法是对的?
- 它只能在 HealthBar 所在的同一个场景文件内使用
- 它在整个游戏里全局唯一,任何脚本都能用
- 它等价于 get_tree().get_first_node_in_group("HealthBar")
唯一名称按 owner(场景文件)划分作用范围。要全局访问,用自动加载单例;要按类别找,用分组。
小结
- 节点只做一件事,用父子关系组合成更复杂的对象;父子关系带来变换、可见性、生命周期、暂停的继承。
- 场景是保存成文件的节点树,
instantiate()+add_child()就能造出新实例;删除用queue_free()。 - 能力优先做成可复用的子节点(组合),"是一种"的关系才用继承。
- 找节点:
$路径/get_node(),..往上,/root开头是绝对路径;%名字在同一场景内免写路径;配合@onready使用。 owner决定节点属于哪个场景文件;分组是一对多的标签。
下一章系统地过一遍 GDScript 语法,并弄清楚 _ready() 这些回调到底按什么顺序被调用。