GameTech/Godot 4.7 互动教程
目录示例游戏
第一部分 · 入门 · 第 2 章

节点与场景

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 面板、一整个关卡都是场景,场景里还可以再嵌套场景。

太阳系就是一个场景文件,它是纯文本,可以直接读:

solar/solar.tscn第 6–30 行每个 [node] 段落是一个节点,parent 写它的父节点路径在工程中查看 ↗
[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 在网页切换小场景时,做的就是"实例化一个场景、加进树里":

main.gd第 8–13 行场景名对应的 .tscn 文件在工程中查看 ↗
## 场景名 -> [场景文件, 标题, 操作提示]
const SCENES := {
	"太阳系": ["res://solar/solar.tscn", "太阳系:父子节点与变换继承", "太阳 → 地球 → 月亮,每个节点只管自己转"],
	"信号实验室": ["res://signals/signal_lab.tscn", "信号实验室:谁连接了谁", "点按钮、点宝箱,看信号沿着连线流动"],
	"生命周期": ["res://lifecycle/lifecycle.tscn", "生命周期:回调的先后顺序", "点右上角的按钮增删节点,看日志和时间轴"],
}
main.gd第 54–63 行删掉旧场景,实例化新场景在工程中查看 ↗
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()。下面是更通用的写法,包括加载场景的两种方式和删除节点:

snippets/nodes_instance.gd第 5–9 行preload 和 @export 两种拿到 PackedScene 的方式在工程中查看 ↗
## preload 在脚本编译时就加载好;路径必须是写死的字符串常量。
const SOLAR_SCENE := preload("res://solar/solar.tscn")

## 也可以在检查器里把 .tscn 拖到这个属性上,不用在代码里写路径。
@export var bullet_scene: PackedScene
snippets/nodes_instance.gd第 14–18 行在工程中查看 ↗
func spawn_solar_system(at: Vector2) -> Node2D:
	var solar := SOLAR_SCENE.instantiate() as Node2D   # 造出一棵新的节点树(还不在场景树里)
	solar.position = at                                # 进树之前就可以设置属性
	add_child(solar)                                   # 进树:这之后它的 _ready() 才会执行
	return solar
snippets/nodes_instance.gd第 23–25 行删除节点:一般用 queue_free在工程中查看 ↗
func remove_it(node: Node) -> void:
	node.queue_free()        # 推荐:本帧结束时再删除,当前正在执行的代码不受影响
	# node.free()            # 立刻删除;如果别处还在用它,会出错
动手试试
  1. 运行后右上角会显示"运行中的场景树"面板,它每 0.25 秒遍历一次真实的节点树。%Moon 前面的 % 表示它勾选了唯一名称;最下面的 Labels 是 solar.gd 在代码里 add_child() 出来的,场景文件里没有它。
  2. 把当前场景切到"信号实验室"再切回来。每次切换都是一次 instantiate(),所以太阳系会从初始状态重新开始,轨迹也清空了。左边的"节点总数"会短暂地变化,因为旧场景在 queue_free() 后要到帧末才真正删除。
  3. 把太阳缩放拖到 1.5:只改了 Sun 一个节点的 scale,Earth 和 Moon 也一起变大、离得更远。
从 Godot 3 迁移的坑。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 里更常见的做法是组合:把一种能力做成一个独立的小节点(或小场景),谁需要就挂一个。比如"血量":

snippets/nodes_composition.gd第 6–15 行一个只管血量的小组件在工程中查看 ↗
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(红)Node3DTransform3D,米,y 朝上MeshInstance3D、CharacterBody3D、Camera3D、DirectionalLight3D、CSGBox3D
通用(白)Node没有位置Timer、AnimationPlayer、AudioStreamPlayer、HTTPRequest,以及自动加载单例

2D 和 UI 都是 CanvasItem,能混在一棵树里:信号实验室里的"攻击""治疗"按钮就是 Button(Control)直接挂在 Node2D 下面。HUD 通常放在 CanvasLayer 下面,这样摄像机移动时界面不跟着走——本示例左上角的标题卡片就在 Main/HUD 这个 CanvasLayer 里。

不确定该用哪个节点时,在"添加节点"对话框里搜关键词,选中后底部会显示说明。按 F1 可以打开内置文档,查看任何类的属性、方法、信号。

6. 在代码里找到别的节点

脚本经常要拿到别的节点,比如角色脚本要拿到自己的 Sprite。最基本的方法是 get_node(路径),它的简写是 $。路径的写法和文件路径很像:

snippets/nodes_paths.gd第 1–9 行下面几段代码假设的场景结构在工程中查看 ↗
## 第 2 章片段:取得别的节点的几种写法。
## 假设场景结构是:
##   Level (本脚本)
##   ├─ Player
##   │   ├─ Sprite
##   │   └─ %HealthBar   (勾选了"作为唯一名称访问")
##   └─ Enemies
##       ├─ Slime
##       └─ Bat
snippets/nodes_paths.gd第 15–18 行往下找在工程中查看 ↗
var player := get_node("Player")                  # 相对路径:从自己出发,找名叫 Player 的子节点
var sprite := get_node("Player/Sprite")           # 用 / 一层层往下走
var slime := $Enemies/Slime                       # $ 是 get_node() 的简写,效果完全一样
var quoted := $"Enemies/Bat"                      # 名字里有空格或特殊字符时,加引号
snippets/nodes_paths.gd第 22–25 行往上找、绝对路径在工程中查看 ↗
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)后,同一个场景里的任何脚本都能用 %名字 直接拿到它,不管它藏在第几层:

snippets/nodes_paths.gd第 29–31 行在工程中查看 ↗
var bar_long := get_node("Player/HealthBar")      # 普通写法:要写全路径
var bar := %HealthBar                             # 唯一名称:不管藏在第几层,同一场景里直接拿到
var bar_same := get_node("%HealthBar")            # 和上一行等价

太阳系的 solar.gd 同时用了三种写法:

solar/solar.gd第 21–23 行$ 子节点、$ 路径、% 唯一名称在工程中查看 ↗
@onready var sun: CelestialBody = $Sun            # 直接子节点
@onready var earth: CelestialBody = $Sun/Earth    # 用路径往下找
@onready var moon: CelestialBody = %Moon          # 场景唯一名称,不管藏多深都能直接拿到

唯一名称的作用范围是一个场景文件。准确地说,%Moon 在调用者的 owner(场景根)范围内查找 owner 相同、且标记了唯一名称的节点。如果 Moon 是另一个被实例化进来的子场景内部的节点,外面的场景就不能用 %Moon 拿到它,需要先拿到那个子场景的根,再从它那里找。

snippets/nodes_paths.gd第 35–40 行找不到也不报错的写法在工程中查看 ↗
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)     # 按名字递归搜索(慢,别每帧调用)
常见坑:在 _ready 之前用 $。成员变量的初始化发生在节点进树之前,这时 $Sprite 找不到任何东西。所以要写成 @onready var sprite := $Sprite,它会推迟到 _ready() 前一刻才赋值。另一个坑:在编辑器里给节点改名后,代码里的路径字符串不会自动更新,运行时才会报 "Node not found"。频繁被引用的节点,用 %唯一名称 或 @export var target: Node 在检查器里指定,会稳得多。

按上面假设的结构,在 Slime 的脚本里调用 get_node("../../Player/Sprite"),会拿到什么?

  1. null,因为 Sprite 不是 Slime 的兄弟
  2. Player 下面的 Sprite
  3. 报错,路径里不能出现两次 ..

Slime → ..(Enemies)→ ..(Level)→ Player → Sprite。路径里可以有任意多个 ..,每个往上走一层。

7. owner:节点属于哪个场景文件

每个节点有一个 owner 属性,指向"把它保存下来的那个场景的根节点"。在编辑器里搭场景时 owner 是自动设置的,平时不用管;但有两种情况需要了解它:

  • 唯一名称就是按 owner 划分作用范围的,上一节已经说过。
  • 代码生成节点再保存:PackedScene.pack() 只会打包 owner 等于根节点的那些节点。代码里 new() 出来的节点 owner 是 null,不手动设置就会被漏掉。编辑器插件、关卡编辑器、存档系统经常踩这个坑。
snippets/nodes_instance.gd第 37–49 行手动设置 owner 才能被一起保存在工程中查看 ↗
## 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():把节点移到另一个父节点下面。它默认保持全局变换,也就是屏幕上的位置不跳,内部会反算出新的本地坐标。

snippets/nodes_instance.gd第 30–32 行在工程中查看 ↗
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(关卡根) 保存。重新打开后出生点都不见了。最可能的原因是?

  1. Marker2D 不能保存到场景里
  2. 应该用 call_deferred("add_child")
  3. 没有把出生点的 owner 设为关卡根节点

pack() 只保存 owner 等于根节点的子孙节点。代码创建的节点 owner 是 null,要手动 marker.owner = root。

8. 分组:给节点贴标签

有时候你不关心节点在树的哪里,只想找到"所有敌人""所有可以被打碎的东西"。分组(groups)就是贴在节点上的字符串标签,一个节点可以属于任意多个组。可以在编辑器的"节点 → 分组"面板里加,也可以在代码里 add_to_group():

snippets/nodes_groups.gd第 5–21 行加组、取组里的节点、对整组调用方法在工程中查看 ↗
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,下面哪个说法是对的?

  1. 它只能在 HealthBar 所在的同一个场景文件内使用
  2. 它在整个游戏里全局唯一,任何脚本都能用
  3. 它等价于 get_tree().get_first_node_in_group("HealthBar")

唯一名称按 owner(场景文件)划分作用范围。要全局访问,用自动加载单例;要按类别找,用分组。

小结

  • 节点只做一件事,用父子关系组合成更复杂的对象;父子关系带来变换、可见性、生命周期、暂停的继承。
  • 场景是保存成文件的节点树,instantiate() + add_child() 就能造出新实例;删除用 queue_free()。
  • 能力优先做成可复用的子节点(组合),"是一种"的关系才用继承。
  • 找节点:$路径 / get_node(),.. 往上,/root 开头是绝对路径;%名字 在同一场景内免写路径;配合 @onready 使用。
  • owner 决定节点属于哪个场景文件;分组是一对多的标签。

下一章系统地过一遍 GDScript 语法,并弄清楚 _ready() 这些回调到底按什么顺序被调用。