第二部分:Godot 的运行结构:SceneTree、节点生命周期、脚本、信号
这一部分很重要。你后面做 RPG、地图、角色移动、天气、四季、怪物、交互、UI,都会不断碰到这几个概念:
Node 节点
Scene 场景
SceneTree 场景树
Script 脚本
Signal 信号
可以先把 Godot 理解成:
一个游戏 = 一棵正在运行的树
树上的每个对象 = Node
一组节点保存成文件 = Scene
场景运行后挂到总树上 = SceneTree
节点之间通过 Signal 通信
Godot 官方文档也把 nodes、scenes、scene tree、signals 归为最核心的概念:节点是游戏的最小构建块,多个节点组合成场景,场景再嵌入场景树,信号用于让不同节点响应彼此发生的事件。(Godot Engine documentation)
1. SceneTree 是什么
SceneTree 可以理解为 Godot 游戏运行时的“总 DOM 树”。
你是前端开发,可以这样类比:
前端页面:
document
└── app
├── header
├── main
└── button
Godot 游戏:
SceneTree
└── Main
├── Player
├── TileMapLayer
├── Camera2D
└── CanvasLayer
在前端里,元素必须挂到 DOM 上,才会被浏览器渲染和参与交互。
在 Godot 里,节点必须进入 SceneTree,才会真正“活过来”:能显示、能处理输入、能播放声音、能执行 _process()、能参与物理、能发出和接收通知。官方文档说明,节点连接到 root viewport 后就成为 scene tree 的一部分,并会获得 _enter_tree()、_ready()、_exit_tree() 等回调。(Godot Engine documentation)
2. Scene 场景是什么
Scene 不只是“地图场景”那个场景,它更像是一个可复用的节点组合模板。
比如:
Player.tscn
└── Player CharacterBody2D
├── Sprite2D
├── CollisionShape2D
├── AnimationPlayer
└── Camera2D
这个 Player.tscn 是一个场景。
它既可以是一个完整关卡,也可以只是一个角色、一个怪物、一个箱子、一个按钮、一张卡牌、一个 UI 面板。
Godot 的场景本质上是由节点组成的树结构。官方文档里也说,当你把节点组织成一棵树并保存后,这个结构就叫 scene;保存后的 scene 又可以像新的节点类型一样添加到其他节点下面。(Godot Engine documentation)
也就是说,Godot 的 Scene 很像前端组件:
前端组件:
<Player />
Godot 场景:
Player.tscn
你可以把一个怪物做成 Slime.tscn,以后地图里要几个史莱姆,就实例化几个。
3. Root、Current Scene 和 Main Scene
Godot 运行游戏时,会有一个最顶层的 root。
大致结构可以理解为:
/root
└── Main
├── World
├── Player
├── UI
└── AudioManager
/root 是最顶层。
Main 通常是你的主场景。
Player、World、UI 都是 Main 的子节点。
在项目设置里,你会设置一个 Main Scene,也就是游戏启动时第一个加载的场景。
比如你的 RPG 项目可能是:
Main.tscn
└── Main Node2D
├── World Node2D
│ └── TileMapLayer
├── Player CharacterBody2D
├── Camera2D
├── WeatherManager Node
├── TimeManager Node
└── UI CanvasLayer
这就像前端里的入口文件:
createApp(App).mount('#app')
Godot 的 Main Scene 就有点像这里的 App.vue 或 App.tsx。
4. get_tree() 是什么
在任意已经进入 SceneTree 的节点里,都可以用:
get_tree()
拿到当前游戏的 SceneTree。
常见用途:
get_tree().quit()
退出游戏。
get_tree().paused = true
暂停游戏。
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
切换场景。
官方文档提到,SceneTree 管理节点层级和场景本身,可以添加、获取、移除节点,也可以暂停整棵场景树、加载、切换和重载场景。(Godot Engine documentation)
你可以把 get_tree() 理解成前端里访问全局应用实例或路由系统:
router.push('/menu')
app.unmount()
Godot 里则是:
get_tree().change_scene_to_file("res://menu.tscn")
get_tree().quit()
5. 节点生命周期
这是 Godot 必须熟的内容。
一个节点从创建到销毁,大概经历这些阶段:
创建节点
↓
进入 SceneTree
↓
_enter_tree()
↓
子节点也进入 SceneTree
↓
_ready()
↓
_process(delta)
↓
_physics_process(delta)
↓
退出 SceneTree
↓
_exit_tree()
↓
销毁
最常见的几个生命周期方法是:
func _enter_tree():
pass
func _ready():
pass
func _process(delta):
pass
func _physics_process(delta):
pass
func _exit_tree():
pass
6. _enter_tree()
_enter_tree() 表示节点刚刚进入 SceneTree。
这时候节点已经进入树了,但它的子节点不一定都准备好了。
所以你一般不会在这里频繁获取子节点。
例如:
func _enter_tree():
print("我进入场景树了")
生命周期顺序上,父节点的 _enter_tree() 会先执行,然后才是子节点的 _enter_tree()。官方文档说明,节点加入 SceneTree 时,会先触发父节点 _enter_tree(),再触发子节点 _enter_tree()。(Godot Engine documentation)
简单理解:
_enter_tree 更早
_ready 更安全
初学阶段,很多初始化逻辑直接放 _ready() 就行。
7. _ready()
_ready() 是最常用的初始化函数。
它表示:这个节点和它的子节点基本都准备好了。
例如:
@onready var sprite: Sprite2D = $Sprite2D
func _ready():
sprite.visible = true
为什么这里可以用 $Sprite2D?
因为 _ready() 的时候,子节点已经在树里了,比较适合获取子节点引用。官方文档说明,使用 get_node() 获取子节点时,放在父节点的 _ready() 中可以保证子节点已经存在于场景树里。(Godot Engine documentation)
前端类比:
Godot _ready()
≈ Vue mounted()
≈ React useEffect(() => {}, [])
比如:
func _ready():
print("角色初始化完成")
适合写:
获取子节点
初始化变量
绑定信号
设置初始状态
读取配置
播放开场动画
8. _process(delta)
_process(delta) 每帧执行一次。
比如游戏 60 FPS,它大概一秒执行 60 次。
func _process(delta):
rotation += 1.0 * delta
delta 表示上一帧到这一帧经过了多少秒。
为什么要乘以 delta?
因为不同电脑帧率不同,如果不乘 delta,高帧率电脑会动得更快,低帧率电脑会动得更慢。
比如:
position.x += 100 * delta
意思是:
每秒移动 100 像素
而不是:
每帧移动 100 像素
官方文档说明,普通 _process() 会尽可能快地按帧率执行,并且会把处理时间差 delta 作为参数传入。(Godot Engine documentation)
适合放在 _process() 里的内容:
普通动画
UI 数值渐变
相机平滑跟随
天气视觉效果
计时器显示
不依赖物理碰撞的视觉变化
不太适合放:
角色物理移动
碰撞检测
刚体/物理相关逻辑
9. _physics_process(delta)
_physics_process(delta) 是固定频率执行的物理更新。
Godot 默认物理频率通常是每秒 60 次。官方文档说明,_physics_process() 以固定次数执行,默认 60 次/秒,适合和物理引擎相关的代码。(Godot Engine documentation)
角色移动通常写在这里。
例如 2D RPG 角色移动:
extends CharacterBody2D
@export var speed: float = 120.0
func _physics_process(delta):
var direction = Vector2.ZERO
if Input.is_action_pressed("move_right"):
direction.x += 1
if Input.is_action_pressed("move_left"):
direction.x -= 1
if Input.is_action_pressed("move_down"):
direction.y += 1
if Input.is_action_pressed("move_up"):
direction.y -= 1
direction = direction.normalized()
velocity = direction * speed
move_and_slide()
这里有几个关键点:
direction 是方向。
velocity 是速度。
move_and_slide() 是让 CharacterBody2D 按速度移动,并处理碰撞滑动。
direction.normalized() 是为了防止斜向移动更快。
比如同时按右和下:
Vector2(1, 1)
如果不归一化,斜向长度会比单方向长,角色斜着走会更快。
10. _process 和 _physics_process 的区别
可以这样记:
_process:
每一帧执行,适合视觉、UI、非物理逻辑
_physics_process:
固定频率执行,适合角色移动、碰撞、物理逻辑
前端类比:
_process
≈ requestAnimationFrame
_physics_process
≈ 固定 tick 的游戏循环
更直接一点:
角色移动:优先 _physics_process
天气粒子:可以 _process
UI 数字变化:可以 _process
碰撞相关:优先 _physics_process
11. _exit_tree()
_exit_tree() 表示节点离开 SceneTree。
比如:
func _exit_tree():
print("节点离开场景树")
适合做一些清理工作:
取消连接
清理引用
停止计时
保存状态
但是大部分初学阶段不会频繁写 _exit_tree()。
12. queue_free()
销毁节点常用:
queue_free()
例如敌人死亡:
func die():
queue_free()
queue_free() 不是立即销毁,而是把节点加入删除队列,等当前帧处理结束后再安全删除。
官方文档也推荐通常使用 queue_free(),因为 free() 是立即销毁,可能让已有引用瞬间变成无效状态;而 queue_free() 会在当前帧结束时删除,更安全。(Godot Engine documentation)
比如子弹碰到墙:
func _on_body_entered(body):
queue_free()
敌人血量归零:
func take_damage(amount: int):
hp -= amount
if hp <= 0:
queue_free()
注意:删除一个父节点时,它的子节点也会一起被删除。官方文档说明,释放一个节点也会释放它的所有子节点,所以删除一整个分支时,只需要删除最上层父节点。(Godot Engine documentation)
13. 获取节点:get_node 和 $
Godot 中经常需要获取子节点。
比如结构是:
Player
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
可以这样写:
func _ready():
var sprite = get_node("Sprite2D")
var anim = get_node("AnimationPlayer")
也可以简写:
func _ready():
var sprite = $Sprite2D
var anim = $AnimationPlayer
$Sprite2D 本质上就是 get_node("Sprite2D") 的简写。官方文档也说明,GDScript 中 $ 是 get_node() 的简写。(Godot Engine documentation)
更推荐这样写:
@onready var sprite: Sprite2D = $Sprite2D
@onready var anim: AnimationPlayer = $AnimationPlayer
func _ready():
anim.play("idle")
@onready 的意思是:等节点 ready 之前再初始化这个变量。
如果你直接这样写:
var sprite = $Sprite2D
有时候会太早,因为节点还没 ready。
所以常见写法是:
@onready var sprite = $Sprite2D
这个非常常用,要记住。
14. NodePath 节点路径
节点可以通过路径查找。
假设结构:
UI
└── HealthBar
└── Label
可以这样拿:
@onready var label = $HealthBar/Label
也可以:
@onready var label = get_node("HealthBar/Label")
类似文件路径:
./HealthBar/Label
也可以用 .. 找父级:
get_node("../Player")
不过初学阶段不建议大量用 ..。
因为这会让节点之间耦合很重。
比如 UI 去找 Player:
get_node("../Player")
这意味着只要层级一变,代码就挂。
更好的方式通常是:
信号
分组
全局管理器
导出变量
这些后面慢慢讲。
15. 创建节点 add_child()
你可以在代码里动态创建节点。
例如创建一个 Sprite2D:
func _ready():
var sprite = Sprite2D.new()
add_child(sprite)
new() 是创建对象。
add_child() 是把它挂到当前节点下面。
如果不 add_child(),这个节点只是内存里的对象,不会出现在场景树里,也不会显示。
官方文档说明,通过代码创建节点时,可以调用节点类型的 new() 方法,然后用 add_child() 添加为当前节点的子节点。(Godot Engine documentation)
前端类比:
const div = document.createElement('div')
document.body.appendChild(div)
Godot 里:
var node = Node2D.new()
add_child(node)
16. 实例化场景 instantiate()
比起动态创建单个节点,更常见的是动态实例化一个场景。
比如你有一个怪物场景:
Slime.tscn
└── Slime CharacterBody2D
├── Sprite2D
├── CollisionShape2D
└── AnimationPlayer
你可以在地图里生成它:
var slime_scene = preload("res://scenes/Slime.tscn")
func spawn_slime():
var slime = slime_scene.instantiate()
add_child(slime)
slime.global_position = Vector2(200, 120)
这里分两步:
var slime_scene = preload("res://scenes/Slime.tscn")
先加载场景资源。
var slime = slime_scene.instantiate()
再创建一个实例。
官方文档说明,实例化场景通常是两步:先加载本地场景文件,再通过 PackedScene.instantiate() 创建实际节点树,然后把它作为子节点加入当前节点。(Godot Engine documentation)
这个在游戏里极其常用:
生成敌人
生成子弹
生成掉落物
生成卡牌
生成特效
生成对话框
生成伤害数字
比如伤害数字:
var damage_text_scene = preload("res://ui/DamageText.tscn")
func show_damage(value: int, pos: Vector2):
var text = damage_text_scene.instantiate()
add_child(text)
text.global_position = pos
text.set_value(value)
17. 信号 Signal 是什么
Signal 是 Godot 里非常重要的通信机制。
它可以理解成:
事件
EventEmitter
自定义事件
观察者模式
官方文档说明,信号是节点在某件事发生时发出的消息,例如按钮被按下;其他节点可以连接这个信号,并在事件发生时调用函数。信号能降低对象之间的耦合,让代码更灵活。(Godot Engine documentation)
前端类比:
button.addEventListener('click', () => {
console.log('clicked')
})
Godot 里:
button.pressed.connect(_on_button_pressed)
func _on_button_pressed():
print("按钮被点击")
18. 内置信号
很多节点自带信号。
比如 Button 有:
pressed
button_down
button_up
Area2D 有:
body_entered
body_exited
area_entered
area_exited
Timer 有:
timeout
比如 Timer:
@onready var timer: Timer = $Timer
func _ready():
timer.timeout.connect(_on_timer_timeout)
func _on_timer_timeout():
print("时间到了")
比如 Button:
@onready var start_button: Button = $StartButton
func _ready():
start_button.pressed.connect(_on_start_button_pressed)
func _on_start_button_pressed():
print("开始游戏")
19. 自定义信号
你也可以自己定义信号。
比如玩家血量变化:
extends CharacterBody2D
signal health_changed(current_hp: int, max_hp: int)
signal died
@export var max_hp: int = 100
var current_hp: int = 100
func take_damage(amount: int):
current_hp -= amount
current_hp = max(current_hp, 0)
health_changed.emit(current_hp, max_hp)
if current_hp <= 0:
died.emit()
queue_free()
UI 可以监听玩家血量变化:
@export var player: Node
func _ready():
player.health_changed.connect(_on_player_health_changed)
func _on_player_health_changed(current_hp: int, max_hp: int):
print("当前血量:", current_hp, "/", max_hp)
这样 UI 不需要每一帧去问:
player.current_hp 是多少?
而是玩家血量变化时主动通知 UI。
这就是信号的核心思想:
不要一直查
发生变化时通知
20. 为什么 Signal 很适合游戏
比如你的 RPG 游戏里会有这些情况:
玩家血量变化 → UI 血条更新
玩家进入房子 → 切换室内地图
玩家靠近 NPC → 显示交互提示
怪物死亡 → 掉落物品
任务完成 → 更新任务面板
天气变化 → 地图视觉效果变化
时间到晚上 → 点亮灯光
按钮点击 → 打开背包
卡牌使用 → 触发技能效果
如果全部互相 get_node(),代码会变得很乱。
比如:
player.get_node("../UI/HealthBar").update_hp()
这种写法短期能跑,但项目大了会越来越难维护。
更好的做法是:
Player 发出 health_changed
UI 自己监听 health_changed
这样 Player 不需要知道 UI 在哪里。
UI 也不需要每帧轮询 Player。
这就是低耦合。
21. 一个完整小例子:玩家受伤,UI 自动更新
场景结构:
Main
├── Player
└── UI
└── HPLabel
Player.gd:
extends CharacterBody2D
signal health_changed(current_hp: int, max_hp: int)
@export var max_hp: int = 100
var current_hp: int = 100
func take_damage(amount: int):
current_hp -= amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
UI.gd:
extends CanvasLayer
@onready var hp_label: Label = $HPLabel
@export var player: Node
func _ready():
player.health_changed.connect(_on_player_health_changed)
_on_player_health_changed(player.current_hp, player.max_hp)
func _on_player_health_changed(current_hp: int, max_hp: int):
hp_label.text = "HP: %d / %d" % [current_hp, max_hp]
Main 里把 Player 拖给 UI 的 player 导出变量。
这样结构就很清楚:
Player 管血量
UI 管显示
Signal 负责通知
22. @export 是什么
@export 可以把脚本变量暴露到 Godot 编辑器的 Inspector 面板。
例如:
@export var speed: float = 120.0
@export var max_hp: int = 100
这样你不用改代码,就能在 Inspector 里调参数。
这对做游戏很重要。
比如:
@export var move_speed: float = 120.0
@export var jump_force: float = 300.0
@export var attack_damage: int = 10
以后你调角色速度、怪物血量、攻击力,不需要每次进代码改。
前端类比的话,@export 有点像组件 props + 可视化配置面板:
<Player speed={120} hp={100} />
Godot 里是:
@export var speed = 120
@export var hp = 100
然后在 Inspector 里直接调。
23. @onready 是什么
@onready 表示变量等到节点 ready 阶段再初始化。
常见写法:
@onready var sprite: Sprite2D = $Sprite2D
@onready var anim: AnimationPlayer = $AnimationPlayer
@onready var collision: CollisionShape2D = $CollisionShape2D
为什么不用普通 var?
因为普通变量初始化可能发生得太早,子节点还没准备好。
@onready 就是告诉 Godot:
等当前节点即将执行 _ready() 时,再去找这些子节点
这在 Godot 里非常常见,基本每个脚本都会用。
24. 推荐你现在形成的习惯
写角色脚本时,优先这样组织:
extends CharacterBody2D
@export var speed: float = 120.0
@onready var sprite: Sprite2D = $Sprite2D
@onready var anim: AnimationPlayer = $AnimationPlayer
func _ready():
pass
func _physics_process(delta):
handle_movement()
func handle_movement():
pass
写 UI 脚本时,优先这样组织:
extends CanvasLayer
@onready var hp_label: Label = $HPLabel
func _ready():
pass
func update_hp(current_hp: int, max_hp: int):
hp_label.text = "HP: %d / %d" % [current_hp, max_hp]
写管理器时,优先这样组织:
extends Node
signal time_changed(hour: int, minute: int)
var hour: int = 8
var minute: int = 0
func advance_time():
minute += 10
if minute >= 60:
minute = 0
hour += 1
time_changed.emit(hour, minute)
25. 这一部分最重要的记忆点
SceneTree 是正在运行的总节点树。
Scene 是可保存、可复用的节点组合。
Node 是 Godot 里最基础的对象。
_ready() 适合初始化和获取子节点。
_process(delta) 适合每帧视觉更新。
_physics_process(delta) 适合角色移动和物理逻辑。
queue_free() 是安全删除节点的常用方式。
$NodeName 是 get_node("NodeName") 的简写。
@onready 用来等节点准备好后再获取子节点。
@export 用来把变量暴露到 Inspector。
signal 用来让节点之间低耦合通信。
26. 你现在可以做的小练习
建一个简单场景:
Main
├── Player CharacterBody2D
│ ├── Sprite2D
│ └── CollisionShape2D
└── UI CanvasLayer
└── Label
目标:
1. Player 有 hp
2. 按空格让 Player 扣血
3. Player 发出 health_changed 信号
4. UI 监听信号并更新 Label
这个练习做完,你就真正摸到 Godot 的核心了。
因为后面的 RPG 项目,本质上就是不断重复这些模式:
节点组织
生命周期初始化
输入处理
状态变化
信号通知
UI 更新
场景实例化
节点销毁如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录