第一部分 02:Scene 和 Node 的关系
这一章是 Godot 的地基。
你以后看到的这些东西:
TileMapLayer
Player
Camera2D
Sprite2D
CollisionShape2D
Area2D
CanvasLayer
Button
Label
AnimationPlayer
本质上都绕不开两个概念:
Scene = 一棵节点树,可以保存、复用、实例化
Node = 组成这棵树的一个个功能节点
Godot 官方文档的说法很直接:Node 是 Godot 的构建块,节点可以成为其他节点的子节点,从而形成树状结构;一棵节点树就叫 Scene,Scene 可以保存到磁盘,也可以实例化到其他 Scene 中。 (Godot Engine documentation)
1. 先用一句话理解
Node 是零件,Scene 是由一堆零件组装好的模块。
比如一个玩家:
Player CharacterBody2D
├── Sprite2D
├── CollisionShape2D
├── AnimationPlayer
└── Camera2D
这里每一个都是 Node。
整个 Player 这一棵树,可以保存成:
res://scenes/player.tscn
这个 player.tscn 就是一个 Scene。
2. 前端类比
你可以把 Godot 的 Scene 和 Node 类比成前端里的组件和 DOM 树。
前端里
function Player() {
return (
<div className="player">
<img className="sprite" />
<div className="collision-box" />
<Camera />
</div>
)
}
Godot 里
Player CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── Camera2D
但是 Godot 比前端组件更“实体”。
前端组件主要负责:
结构
样式
状态
事件
渲染
Godot Scene 还会负责:
位置
旋转
缩放
碰撞
物理
动画
声音
摄像机
输入
生命周期
所以你可以这样理解:
Godot Scene ≈ 可视化组件 + 物理对象 + 行为逻辑 + 资源引用
3. Node 是什么
Node 是 Godot 的最小功能单位。
每一种 Node 都有自己的职责。
比如:
Node 最基础节点
Node2D 2D 节点基础类
Sprite2D 显示图片
CharacterBody2D 角色移动和碰撞
CollisionShape2D 碰撞形状
Camera2D 2D 摄像机
Area2D 区域检测
AudioStreamPlayer 播放音频
AnimationPlayer 播放动画
Control UI 节点基础类
Button 按钮
Label 文本
一个 Node 本身可以很简单,也可以很强大。
例如:
Sprite2D
它的核心职责就是显示一张图片。
而:
CharacterBody2D
它的职责是做角色移动、碰撞处理、物理检测。
4. Scene 是什么
Scene 不是单纯的“地图”。
这是很多新手最容易误会的地方。
在 Godot 里,Scene 可以是:
一张地图
一个玩家
一个敌人
一个宝箱
一个 NPC
一个技能特效
一个 UI 面板
一个按钮
一个天气系统
一个背包系统
一个房间
一个房子
一个对话框
也就是说:
只要它是一组节点的组合,并且你想保存、复用、实例化,它就适合做成 Scene。
比如你做 RPG,可以有这些 Scene:
res://scenes/main.tscn
res://scenes/world/world_map.tscn
res://scenes/player/player.tscn
res://scenes/npc/villager.tscn
res://scenes/ui/hud.tscn
res://scenes/ui/dialog_box.tscn
res://scenes/items/chest.tscn
res://scenes/effects/rain_effect.tscn
这就很像前端项目里拆组件:
App.vue
Map.vue
Player.vue
Npc.vue
Hud.vue
DialogBox.vue
Chest.vue
RainEffect.vue
5. Scene 必须有一个根节点
每个 Scene 都有且只有一个根节点。
比如玩家场景:
Player CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
这里的根节点是:
Player CharacterBody2D
比如主场景:
Main Node2D
├── World TileMapLayer
├── Player
├── Camera2D
└── UI CanvasLayer
这里的根节点是:
Main Node2D
这个根节点很重要,因为别人实例化这个 Scene 的时候,拿到的就是这个根节点。
比如你加载 player.tscn:
var player_scene = preload("res://scenes/player.tscn")
var player = player_scene.instantiate()
add_child(player)
这里的 player 实际上就是 Player CharacterBody2D 这个根节点的实例。
6. Node 的父子关系
Godot 的节点是一棵树。
Main
├── World
├── Player
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── AnimationPlayer
└── UI
├── HealthBar
└── InventoryButton
这里:
Main 是 World、Player、UI 的父节点
Player 是 Sprite2D、CollisionShape2D、AnimationPlayer 的父节点
Sprite2D、CollisionShape2D、AnimationPlayer 是兄弟节点
Godot 官方文档提到,一个节点可以包含任意数量的子节点,但同一个父节点下的直接子节点名称应该唯一。(Godot Engine documentation)
也就是说,这样可以:
Player
├── Sprite2D
└── CollisionShape2D
但不建议这样:
Player
├── Sprite2D
└── Sprite2D
因为同级重名会让查找路径变得混乱。
7. 节点路径 NodePath
Godot 通过路径找节点。
比如结构是:
Player
├── Sprite2D
└── Camera2D
在 Player 脚本里可以这样拿子节点:
var sprite = get_node("Sprite2D")
var camera = get_node("Camera2D")
Godot 有简写:
var sprite = $Sprite2D
var camera = $Camera2D
如果节点更深:
Player
└── UI
└── HealthBar
可以这样:
var health_bar = $UI/HealthBar
官方文档也说明,可以用 Node.get_node() 获取节点引用,并且如果你想确保子节点已经存在,通常在父节点的 _ready() 中获取比较安全。(Godot Engine documentation)
8. $ 是什么
这个很常见。
$Sprite2D
基本等价于:
get_node("Sprite2D")
所以:
$Sprite2D.visible = false
等价于:
get_node("Sprite2D").visible = false
再比如:
$AnimationPlayer.play("walk")
意思是:
找到当前节点下面叫 AnimationPlayer 的子节点,然后调用它的 play 方法。
9. @onready 是什么
你经常会看到:
@onready var sprite: Sprite2D = $Sprite2D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
意思是:
等节点进入场景并准备好之后,再获取这些子节点。
为什么要这样?
因为脚本刚加载的时候,子节点可能还没完全准备好。
所以这种写法很常见:
extends CharacterBody2D
@onready var sprite: Sprite2D = $Sprite2D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready():
animation_player.play("idle")
你可以把它类比成前端里的:
const ref = useRef(null)
useEffect(() => {
// DOM 已经挂载后再操作
}, [])
不是完全一样,但理解上很接近。
10. Scene 文件和运行时实例不是一回事
这是非常关键的一点。
假设你有一个文件:
res://scenes/player.tscn
它只是一个场景资源文件。
它还不是游戏里真正跑起来的玩家。
当你把它放进主场景,或者通过代码实例化:
var player_scene = preload("res://scenes/player.tscn")
var player = player_scene.instantiate()
add_child(player)
这时才产生了一个真正存在于游戏里的玩家节点。
前端类比
组件定义:
function Player() {
return <div>Player</div>
}
还不是真实 DOM。
使用它:
<Player />
React 渲染之后,才有真实 DOM。
Godot 也是:
player.tscn 场景模板
Player 实例 运行时对象
11. 一个 Scene 可以实例化很多次
比如你做一个史莱姆敌人:
res://scenes/enemies/slime.tscn
它的结构是:
Slime CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
你可以在地图上放很多个:
World
├── Slime
├── Slime2
├── Slime3
├── Slime4
└── Slime5
它们都来自同一个 slime.tscn,但运行时是不同实例。
类似前端里:
{slimes.map(slime => <Slime key={slime.id} />)}
同一个组件,渲染出多个实例。
Godot 官方文档也有专门的 “Creating instances” 教程,用来说明如何把一个场景作为实例添加到另一个场景中。(Godot Engine documentation)
12. Scene 嵌套 Scene
Godot 的强大之处就在这里。
你可以用 Scene 组成更大的 Scene。
比如:
Player.tscn
内容:
Player CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
然后主场景:
Main.tscn
内容:
Main Node2D
├── World TileMapLayer
├── Player ← 这是 player.tscn 的实例
├── Camera2D
└── UI
再比如 UI 也可以是一个 Scene:
HUD.tscn
内容:
HUD CanvasLayer
├── HealthBar
├── ManaBar
└── InventoryButton
主场景里:
Main Node2D
├── World
├── Player
└── HUD ← hud.tscn 的实例
这样项目结构就会很清楚。
13. 编辑器里的 Scene 和运行时的 SceneTree
Godot 里有两个很容易混的概念:
Scene
SceneTree
Scene
Scene 是你保存的场景文件。
比如:
player.tscn
main.tscn
hud.tscn
它们在磁盘上。
SceneTree
SceneTree 是游戏运行时真正活着的节点树。
官方文档说明,SceneTree 负责管理节点层级和场景本身,节点可以被添加、获取和移除,当前场景也可以被暂停、切换或重新加载。(Godot Engine documentation)
可以这样理解:
Scene 文件 = 设计图纸
SceneTree = 游戏运行时真正搭起来的建筑
14. 节点什么时候才算“活了”
一个节点只有进入 SceneTree 后,才真正参与游戏运行。
官方文档的 SceneTree 教程里说,一个场景被加载或通过脚本创建后,它的根节点会被加入 root Viewport 或其子节点中;新加入场景的每个节点会收到 enter_tree 通知,对应 GDScript 的 _enter_tree() 回调。(Godot Engine documentation)
所以流程大概是:
1. 加载 scene 文件
2. instantiate 生成节点实例
3. add_child 加入 SceneTree
4. 节点进入树
5. 调用 _enter_tree
6. 调用 _ready
7. 开始 _process / _physics_process
代码:
var enemy_scene = preload("res://scenes/enemies/slime.tscn")
var enemy = enemy_scene.instantiate()
add_child(enemy)
只有执行了:
add_child(enemy)
这个敌人才真正进入当前场景树。
15. 生命周期顺序
你以后会经常用这些方法:
func _enter_tree():
pass
func _ready():
pass
func _process(delta):
pass
func _physics_process(delta):
pass
func _exit_tree():
pass
_enter_tree
节点进入 SceneTree 时触发。
func _enter_tree():
print("我进入场景树了")
适合做一些比较早的初始化。
_ready
节点和它的子节点都准备好后触发。
func _ready():
print("我准备好了")
最常用。
你一般可以在这里获取子节点、初始化状态、连接信号。
@onready var sprite = $Sprite2D
func _ready():
sprite.visible = true
官方 Node 文档中也提到,节点加入 SceneTree 时父节点的 _enter_tree() 会先于子节点触发;而 _ready() 是在节点及其子节点都进入树之后触发。(Godot Engine documentation)
_process
每帧调用。
func _process(delta):
rotation += delta
适合:
视觉效果
普通计时
非物理更新
UI 动态变化
简单动画逻辑
_physics_process
固定物理帧调用。
func _physics_process(delta):
move_and_slide()
适合:
角色移动
碰撞判断
物理相关逻辑
_exit_tree
节点离开 SceneTree 时调用。
func _exit_tree():
print("我离开场景树了")
适合清理逻辑。
16. 常用节点操作方法
add_child
添加子节点。
add_child(enemy)
意思是:
把 enemy 加到当前节点下面。
前端类比:
parent.appendChild(enemyElement)
remove_child
从父节点移除子节点。
remove_child(enemy)
注意:这只是从节点树中移除,不一定销毁内存。
可以理解为:
先从场景里拿掉,但对象可能还在。
queue_free
安全删除节点。
enemy.queue_free()
这个非常常用。
比如:
func die():
queue_free()
常见场景:
敌人死亡
子弹命中后消失
特效播放完删除
临时 UI 关闭
掉落物被拾取
get_parent
获取父节点。
var parent = get_parent()
比如:
get_parent().add_child(effect)
意思是把 effect 加到当前节点的父节点下面。
get_tree
获取 SceneTree。
var tree = get_tree()
常用于:
切换场景
暂停游戏
获取当前场景
调用分组节点
退出游戏
比如:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
17. PackedScene 是什么
你以后会经常看到:
var enemy_scene: PackedScene = preload("res://scenes/enemies/slime.tscn")
PackedScene 可以理解为:
打包好的场景资源
它还不是节点。
你需要调用:
instantiate()
才会生成节点实例。
完整写法:
extends Node2D
@export var slime_scene: PackedScene
func spawn_slime():
var slime = slime_scene.instantiate()
add_child(slime)
slime.position = Vector2(200, 100)
如果不用 Inspector 暴露,也可以这样:
const SLIME_SCENE = preload("res://scenes/enemies/slime.tscn")
func spawn_slime():
var slime = SLIME_SCENE.instantiate()
add_child(slime)
官方的 “Nodes and scene instances” 文档也把“获取节点、创建节点、添加子节点、从代码实例化场景”放在一起讲,因为这几个操作本质上都是在操作运行时节点树。(Godot Engine documentation)
18. preload 和 load 的区别
常见写法:
const PLAYER_SCENE = preload("res://scenes/player.tscn")
还有:
var player_scene = load("res://scenes/player.tscn")
简单理解:
preload:脚本加载时提前加载资源
load:运行到这一行时再加载资源
入门阶段你可以先记:
固定会用到的资源,用 preload
运行时根据条件才加载的资源,用 load
比如玩家、子弹、常用敌人:
const BULLET_SCENE = preload("res://scenes/bullet.tscn")
比如根据地图名加载不同地图:
var map_scene = load("res://scenes/maps/%s.tscn" % map_name)
19. 创建节点的两种方式
方式一:编辑器里创建
适合固定结构。
比如 Player:
Player CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
这种结构一般在编辑器里搭好。
方式二:代码里创建
适合动态生成。
比如运行时生成一个 Label:
func create_label():
var label = Label.new()
label.text = "Hello Godot"
add_child(label)
生成一个 Sprite2D:
func create_sprite():
var sprite = Sprite2D.new()
sprite.texture = preload("res://assets/player.png")
add_child(sprite)
不过入门时建议:
复杂对象做成 Scene,然后 instantiate
简单临时节点可以用 .new()
比如敌人、子弹、道具,推荐做成 Scene:
var bullet = bullet_scene.instantiate()
add_child(bullet)
不要每次都代码手搓:
var bullet = CharacterBody2D.new()
var sprite = Sprite2D.new()
var collision = CollisionShape2D.new()
...
那样维护起来会很累。
20. 一个 RPG 项目的场景拆分示例
你现在想做类似星露谷、RPG、玄幻小镇这种游戏,可以这样拆:
scenes/
├── main.tscn
├── world/
│ ├── outdoor_map.tscn
│ ├── house_interior.tscn
│ └── dungeon_01.tscn
├── player/
│ └── player.tscn
├── npc/
│ ├── villager.tscn
│ └── merchant.tscn
├── items/
│ ├── chest.tscn
│ ├── dropped_item.tscn
│ └── collectable.tscn
├── ui/
│ ├── hud.tscn
│ ├── dialog_box.tscn
│ └── inventory_panel.tscn
└── systems/
├── weather_manager.tscn
├── time_manager.tscn
└── audio_manager.tscn
主场景可以是:
Main Node
├── World Node2D
├── Player
├── Systems Node
│ ├── WeatherManager
│ ├── TimeManager
│ └── AudioManager
└── UI CanvasLayer
├── HUD
└── DialogBox
这样拆的好处是:
地图可以单独改
玩家可以单独改
UI 可以单独改
天气系统可以单独改
时间系统可以单独改
NPC 可以复用
宝箱可以复用
这就很像前端项目里拆模块。
21. 什么时候做成一个 Scene
这个判断很重要。
适合做成 Scene 的东西
满足以下任意条件,就适合做成 Scene:
会重复使用
结构比较复杂
有自己的脚本逻辑
需要单独编辑和测试
可能被动态生成
可能被其他场景嵌套
比如:
Player
Slime
NPC
Chest
Door
DialogBox
InventoryPanel
RainEffect
FireballSkill
SavePoint
都适合做 Scene。
不一定需要单独做 Scene 的东西
一个普通 Sprite2D
一个简单 Label
一个临时 Debug 节点
一个纯装饰小物件
当然,如果后面它变复杂了,也可以再拆成 Scene。
22. 什么时候只是一个 Node
如果它只是某个对象的一部分,通常只是子节点。
比如玩家里的:
Sprite2D
CollisionShape2D
AnimationPlayer
它们是 Player 的组成部分,不一定单独保存成 Scene。
宝箱里的:
Chest StaticBody2D
├── Sprite2D
├── CollisionShape2D
├── Area2D
└── AnimationPlayer
这里 Sprite2D、CollisionShape2D、Area2D 是宝箱的零件。
整个 Chest 适合保存为 Scene。
23. 节点命名建议
Godot 项目里,节点命名很重要。
不建议这样:
Node2D
├── Sprite2D
├── Sprite2D2
├── Area2D
└── CollisionShape2D
建议这样:
Player CharacterBody2D
├── BodySprite Sprite2D
├── Hitbox Area2D
├── CollisionShape2D
└── AnimationPlayer
或者:
Chest StaticBody2D
├── ClosedSprite Sprite2D
├── OpenArea Area2D
├── CollisionShape2D
└── AnimationPlayer
因为你代码里经常会写:
@onready var body_sprite = $BodySprite
@onready var hitbox = $Hitbox
如果名字全是默认的 Sprite2D、Area2D,后面会越来越难看。
24. 不要让节点关系太乱
新手很容易把所有东西都塞进一个大场景:
Main
├── TileMapLayer
├── Player
├── Enemy1
├── Enemy2
├── Enemy3
├── NPC1
├── NPC2
├── Chest1
├── Chest2
├── Rain
├── Snow
├── DialogBox
├── Inventory
├── HealthBar
├── TimeLabel
├── Audio1
├── Audio2
└── ...
短期能跑,长期会乱。
更推荐:
Main
├── World
│ ├── TileMapLayer
│ ├── Objects
│ ├── NPCs
│ └── Enemies
├── Player
├── Systems
│ ├── WeatherManager
│ ├── TimeManager
│ └── AudioManager
└── UI
├── HUD
├── DialogBox
└── InventoryPanel
这样你一看就知道:
World 管地图和世界对象
Player 管玩家
Systems 管全局系统
UI 管界面
25. 一个完整小例子:生成敌人
假设你有一个敌人场景:
res://scenes/enemies/slime.tscn
结构:
Slime CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
然后你在主场景里有一个生成器:
Main Node2D
├── EnemySpawner Node2D
└── Enemies Node2D
EnemySpawner.gd:
extends Node2D
@export var slime_scene: PackedScene
@export var enemies_root: Node2D
func spawn_slime(spawn_position: Vector2):
var slime = slime_scene.instantiate()
enemies_root.add_child(slime)
slime.global_position = spawn_position
这里发生了什么?
1. slime_scene 是一个 PackedScene 资源
2. instantiate() 创建了一个 Slime 节点实例
3. enemies_root.add_child(slime) 把它加入场景树
4. slime.global_position 设置它在世界中的位置
这就是 Godot 里“动态生成对象”的标准思路。
26. position 和 global_position
这个也要早点理解。
如果节点结构是:
World Node2D
└── Player CharacterBody2D
Player.position 表示它相对于父节点 World 的位置。
Player.global_position 表示它在整个世界坐标中的位置。
如果父节点没有移动,两者可能一样。
但如果父节点移动了:
World.position = Vector2(100, 100)
Player.position = Vector2(50, 50)
那么:
Player.position = Vector2(50, 50)
Player.global_position = Vector2(150, 150)
简单记:
position:相对父节点
global_position:世界坐标
前端类比:
position ≈ 相对父容器的 left/top
global_position ≈ 页面整体坐标中的 left/top
27. owner 是什么,先简单知道
这个可以先不用深究,但你以后可能会遇到。
owner 跟“节点是否会被保存到场景文件”有关。
如果你在编辑器里创建节点,它通常会有正确的 owner。
如果你在代码里动态创建节点,它默认只是运行时对象,不一定会保存进 .tscn 文件。
入门阶段记一句就行:
运行时 add_child 出来的节点,通常只是运行时存在,不会自动写回场景文件。
这个对编辑器插件或运行时生成并保存场景时比较重要,普通游戏逻辑暂时不用纠结。
28. 新手常见误区
误区 1:Scene 就是地图
不是。
Scene 可以是任何节点树。
玩家是 Scene
敌人是 Scene
UI 是 Scene
特效是 Scene
地图也是 Scene
误区 2:节点在文件里存在,就等于运行时存在
不是。
player.tscn 是文件
实例化并 add_child 后,才是运行时对象
误区 3:instantiate 后就能显示
不一定。
你只是生成了对象:
var player = player_scene.instantiate()
但还没有加入场景树。
还需要:
add_child(player)
误区 4:queue_free 和 remove_child 是一回事
不是。
remove_child(node)
是从父节点移除。
node.queue_free()
是安全删除节点。
大多数“不要这个对象了”的情况,用:
queue_free()
误区 5:节点路径随便写
不行。
如果结构是:
Player
└── Visual
└── Sprite2D
你不能写:
$Sprite2D
应该写:
$Visual/Sprite2D
或者:
get_node("Visual/Sprite2D")
29. 这一章你应该记住的核心
1. Node 是 Godot 的功能单位。
2. Scene 是一棵 Node 树。
3. Scene 可以保存成 .tscn 文件。
4. Scene 可以实例化到其他 Scene 里。
5. 运行时真正活着的是 SceneTree 里的节点实例。
6. instantiate() 只是创建实例。
7. add_child() 才是加入场景树。
8. $NodeName 是 get_node("NodeName") 的简写。
9. @onready 常用于安全获取子节点。
10. 复杂可复用对象,应该做成独立 Scene。
最后用一句话收尾
Godot 的开发方式不是“一个大脚本控制全世界”,而是“把游戏拆成一棵棵 Scene,每个 Scene 由多个 Node 组成,再让这些 Scene 互相组合、复用、实例化”。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录