第一部分 05:生命周期方法
这一章讲 Godot 写脚本时最常见的几个方法:
_ready()
_process(delta)
_physics_process(delta)
_input(event)
_unhandled_input(event)
_enter_tree()
_exit_tree()
你可以先用一句话理解:
生命周期方法 = Godot 在特定时机自动帮你调用的方法
它们不是你主动调用的,而是 Godot 引擎在节点进入场景、准备完成、每帧更新、物理更新、接收输入、离开场景时自动调用。
Godot 官方文档把 _process() 称为 idle processing,也就是每帧处理;_physics_process() 是 physics processing,用固定时间间隔运行,默认物理帧率是 60 次每秒,适合角色移动、碰撞等物理相关逻辑。(Godot Engine documentation)
1. 生命周期方法是什么
在前端里,你可能熟悉这些概念:
Vue:
onMounted
onUnmounted
watch
computed
事件监听
React:
useEffect
useLayoutEffect
onClick
组件卸载清理
Godot 里类似:
_ready 节点准备好了
_process 每一帧执行
_physics_process 每个物理帧执行
_input 收到输入事件
_unhandled_input 收到还没被 UI/其他节点处理的输入
_exit_tree 离开场景树
举个最简单的例子:
extends Node2D
func _ready():
print("节点准备好了")
func _process(delta):
print("每一帧都会执行")
你不用写:
_ready()
_process()
Godot 会自动调用。
2. 最常用的四个方法
入门阶段,先重点掌握这四个:
func _ready():
pass
func _process(delta):
pass
func _physics_process(delta):
pass
func _input(event):
pass
它们分别负责:
_ready:初始化
_process:普通每帧逻辑
_physics_process:物理和移动逻辑
_input:输入事件
最常见的错误就是:
角色移动放错地方
初始化代码写太早
每帧检测和一次性按键事件混用
UI 输入和游戏输入互相打架
这一章就是专门解决这些问题。
3. _ready:节点准备完成
它是什么
_ready() 会在节点进入场景树,并且它的子节点也准备好之后调用。
常见用途:
获取子节点
初始化变量
连接信号
设置初始 UI
播放默认动画
初始化玩家状态
Node 文档中提到,节点进入 SceneTree 后会有对应回调,_ready() 发生在节点及其子节点都准备完成之后。(Godot Engine documentation)
基础例子
extends Node2D
func _ready():
print("我准备好了")
运行场景时,它会打印:
我准备好了
获取子节点
比如场景结构是:
Player CharacterBody2D
├── BodySprite Sprite2D
├── CollisionShape2D
└── AnimationPlayer
脚本:
extends CharacterBody2D
func _ready():
$BodySprite.visible = true
$AnimationPlayer.play("idle")
意思是:
节点准备好后,显示玩家图片,并播放 idle 动画
更推荐的写法:@onready
通常我们会这样写:
extends CharacterBody2D
@onready var body_sprite: Sprite2D = $BodySprite
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready():
body_sprite.visible = true
animation_player.play("idle")
@onready 的意思是:
等节点 ready 时再赋值
这很适合获取子节点。
前端类比
React 里你不会在 DOM 还没挂载时操作 DOM。
你会等组件挂载后:
useEffect(() => {
// DOM 已经存在后再做事情
}, [])
Godot 里类似:
func _ready():
# 子节点已经准备好,可以安全访问
_ready 适合做什么
适合:
设置初始状态
连接信号
拿子节点引用
初始化 UI
读取配置
播放初始动画
初始化血量、金币、任务状态
比如:
extends Node
signal health_changed(value: int)
@export var max_health: int = 100
var health: int
func _ready():
health = max_health
health_changed.emit(health)
_ready 不适合做什么
不适合:
持续移动角色
每帧检测输入
不断刷新 UI
物理碰撞移动
因为 _ready() 只执行一次。
4. _process(delta):每一帧执行
它是什么
_process(delta) 会在每一帧执行。
比如游戏 60 FPS,它大概每秒执行 60 次。
如果游戏 144 FPS,它可能每秒执行 144 次。
Godot 文档说明,_process() 是 idle processing,会尽可能每一帧运行;执行频率取决于游戏运行的帧率。(Godot Engine documentation)
基础例子
extends Node2D
func _process(delta):
rotation += 1.0 * delta
这个节点会持续旋转。
delta 是什么
delta 是距离上一帧过去了多少秒。
比如:
60 FPS 时,delta 大约是 0.016
30 FPS 时,delta 大约是 0.033
为什么需要 delta?
因为不同电脑帧率不同。
错误写法:
position.x += 10
意思是每帧移动 10 像素。
如果一台电脑 60 FPS:
每秒移动 600 像素
另一台电脑 144 FPS:
每秒移动 1440 像素
这就离谱了,玩家像被风油精灌了三瓶。
正确写法:
position.x += 100 * delta
意思是:
每秒移动 100 像素
不管帧率多少,速度都比较稳定。
_process 适合做什么
适合:
普通视觉更新
非物理动画
UI 数值刷新
倒计时显示
镜头平滑逻辑
天气视觉效果
鼠标悬浮效果
简单特效
例子:让云缓慢移动
extends Node2D
@export var cloud_speed: float = 20.0
func _process(delta):
position.x += cloud_speed * delta
例子:倒计时显示
extends Label
var time_left: float = 10.0
func _process(delta):
time_left -= delta
text = "剩余时间:" + str(round(time_left))
_process 不适合做什么
不推荐把角色物理移动放这里:
func _process(delta):
velocity = direction * speed
move_and_slide()
角色移动、碰撞、物理判断,更推荐放到:
func _physics_process(delta):
pass
5. _physics_process(delta):物理帧执行
它是什么
_physics_process(delta) 按固定时间间隔执行。
默认情况下,Godot 物理处理是每秒 60 次。这个固定频率适合让碰撞、角色移动、物理交互更稳定。(Godot Engine documentation)
基础例子
extends CharacterBody2D
@export var speed: float = 120.0
func _physics_process(delta):
var direction = Vector2.RIGHT
velocity = direction * speed
move_and_slide()
这个角色会持续向右移动。
为什么角色移动放这里
因为角色移动通常涉及:
碰撞检测
墙体阻挡
滑动
地面判断
移动稳定性
物理交互
这些东西应该和物理系统同步。
Godot 的角色移动教程也会把玩家移动逻辑放在 _physics_process() 里,因为它专门用于物理相关代码,比如移动 kinematic/rigid body,并且按固定间隔更新。(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("ui_right"):
direction.x += 1
if Input.is_action_pressed("ui_left"):
direction.x -= 1
if Input.is_action_pressed("ui_down"):
direction.y += 1
if Input.is_action_pressed("ui_up"):
direction.y -= 1
velocity = direction.normalized() * speed
move_and_slide()
这里的逻辑是:
1. 每个物理帧读取输入
2. 计算方向
3. 设置 velocity
4. 调用 move_and_slide()
为什么要 normalized
如果同时按右和下:
direction = Vector2(1, 1)
这个方向的长度比单独向右更长,会导致斜着走更快。
所以要:
direction.normalized()
它会把方向变成单位向量,保证上下左右和斜向移动速度一致。
_physics_process 适合做什么
适合:
玩家移动
NPC 移动
怪物追踪
子弹移动
碰撞相关逻辑
地面检测
攻击判定
物理相关状态更新
例子:怪物追玩家
extends CharacterBody2D
@export var speed: float = 80.0
@export var player: Node2D
func _physics_process(delta):
if player == null:
return
var direction = (player.global_position - global_position).normalized()
velocity = direction * speed
move_and_slide()
_physics_process 不适合做什么
不太适合:
纯 UI 刷新
普通文字变化
不涉及物理的视觉动画
按钮点击响应
一次性按键触发
这些更适合 _process、_input 或信号。
6. _process 和 _physics_process 的区别
你可以这样记:
_process:
每一帧执行,帧率变化时执行次数也变化。
适合视觉、UI、非物理逻辑。
_physics_process:
固定物理帧执行,默认每秒 60 次。
适合角色移动、碰撞、物理逻辑。
前端类比
_process 有点像:
requestAnimationFrame(() => {
// 每帧视觉更新
})
_physics_process 更像游戏引擎内部固定 tick:
每秒稳定跑 60 次的物理更新循环
前端开发里不常直接遇到这种固定物理循环,但写游戏时很重要。
简单判断法
看到这个问题:
这个逻辑和碰撞、移动、物理有关吗?
如果答案是“是”,优先放 _physics_process。
看到这个问题:
这个逻辑只是显示、动画、UI、视觉变化吗?
优先放 _process。
7. _input(event):输入事件
它是什么
_input(event) 会在节点收到输入事件时被调用。
比如:
按键按下
按键松开
鼠标点击
鼠标移动
触摸事件
手柄输入
Godot 的输入事件系统会把键盘、鼠标、手柄、触摸等输入封装成 InputEvent。官方文档也说明,_input() 可以在 _unhandled_input() 之前拦截和处理输入事件。(Godot Engine documentation)
基础例子
extends Node
func _input(event):
if event.is_action_pressed("ui_accept"):
print("按下确认键")
一次性按键适合放这里
比如打开背包:
extends Node
@onready var inventory_panel = $UI/InventoryPanel
func _input(event):
if event.is_action_pressed("open_inventory"):
inventory_panel.visible = !inventory_panel.visible
这类“一按触发一次”的逻辑适合输入事件。
不建议用 _input 做持续移动
不推荐:
func _input(event):
if event.is_action_pressed("ui_right"):
position.x += 10
因为 _input 是事件驱动,不是稳定的移动循环。
持续移动更适合:
func _physics_process(delta):
if Input.is_action_pressed("ui_right"):
direction.x += 1
Input.is_action_pressed 和 event.is_action_pressed 的区别
这两个很容易混。
Input.is_action_pressed
主动查询当前状态。
if Input.is_action_pressed("ui_right"):
print("右键正在按着")
适合持续行为:
移动
蓄力
持续瞄准
按住跑步
通常放在 _process 或 _physics_process。
event.is_action_pressed
判断当前这个输入事件是不是某个动作按下。
func _input(event):
if event.is_action_pressed("ui_accept"):
print("这一次按下了确认键")
适合一次性行为:
打开背包
确认对话
跳跃触发
攻击触发
暂停游戏
8. _unhandled_input(event):未处理输入
它是什么
_unhandled_input(event) 会在输入事件没有被其他地方处理后调用。
这对于游戏输入非常好用。
Godot 文档的玩家输入教程提到,处理玩家输入主要有两类工具:内置输入回调,尤其是 _unhandled_input(),适合响应不需要每帧发生的事件,比如按 Space 跳跃。(Godot Engine documentation)
为什么它很重要
假设你有一个 UI 按钮。
玩家点击按钮时,按钮应该先处理这个点击。
如果按钮已经处理了,游戏世界就不应该再把这次点击当成攻击、移动、交互。
所以:
_input:比较早收到输入
_unhandled_input:只有没人处理时才收到输入
RPG 里推荐用法
比如玩家按 E 交互:
extends CharacterBody2D
func _unhandled_input(event):
if event.is_action_pressed("interact"):
try_interact()
这样当玩家正在操作 UI,比如对话框、菜单、背包时,可以避免输入冲突。
和 UI 的关系
Control 节点可以处理 UI 输入。Godot 文档提到,Control 可以调用 accept_event(),这样事件会变成已处理,后续 _unhandled_input() 就不会再处理它。(Godot Engine documentation)
这非常适合:
点击 UI 按钮时,不触发游戏场景里的点击逻辑
打开菜单时,不让玩家继续攻击
操作背包时,不让角色乱动
9. _gui_input(event):UI 节点专用输入
它是什么
_gui_input(event) 主要用于 Control 节点。
比如:
Button
Panel
TextureRect
InventorySlot
DialogBox
它适合写 UI 控件自己的输入逻辑。
背包格子例子
extends TextureRect
func _gui_input(event):
if event is InputEventMouseButton:
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
print("点击了背包格子")
什么时候用它
适合:
背包格子点击
技能栏拖拽
UI 图片按钮
自定义控件
鼠标悬停提示
如果只是普通按钮,优先用 Button 自带的 pressed 信号。
10. _enter_tree:进入场景树
它是什么
_enter_tree() 会在节点进入 SceneTree 时调用。
它比 _ready() 更早。
extends Node
func _enter_tree():
print("进入场景树")
func _ready():
print("准备完成")
大概顺序是:
进入场景树
准备完成
它和 _ready 的区别
_enter_tree:
节点刚进入场景树时触发,比较早。
_ready:
节点和子节点都准备好后触发,更适合初始化。
入门阶段,大多数初始化写在 _ready() 就够了。
什么时候用 _enter_tree
适合比较少见的情况:
很早注册自己
和场景树结构相关的早期逻辑
某些底层初始化
插件或工具脚本
普通 RPG 开发中,不需要频繁使用。
11. _exit_tree:离开场景树
它是什么
_exit_tree() 会在节点离开 SceneTree 时调用。
extends Node
func _exit_tree():
print("我要离开场景树了")
常见触发情况:
节点被 queue_free 删除
切换场景
父节点被删除
节点被 remove_child 移出场景树
适合做什么
适合清理工作:
断开信号
保存临时状态
停止音效
取消引用
关闭临时效果
例子:
extends Node
func _exit_tree():
print("清理临时数据")
不过 Godot 很多资源会自动管理,入门阶段不用过度手动清理。
12. 生命周期执行顺序
假设一个节点从创建到销毁,流程大概是:
1. 节点被创建
2. 节点加入 SceneTree
3. 调用 _enter_tree()
4. 子节点也进入并准备
5. 调用 _ready()
6. 游戏运行中持续调用 _process(delta)
7. 游戏运行中持续调用 _physics_process(delta)
8. 收到输入时调用 _input(event)
9. 未处理输入时调用 _unhandled_input(event)
10. 节点离开 SceneTree
11. 调用 _exit_tree()
常用简化版:
_enter_tree
_ready
_process / _physics_process / input callbacks
_exit_tree
13. 父节点和子节点的 ready 顺序
这个是新手容易踩坑的地方。
结构:
Parent Node
└── Child Node
通常理解上:
_enter_tree:父节点先进入,再到子节点
_ready:子节点先 ready,再到父节点
这意味着:
父节点的 _ready 执行时,子节点一般已经 ready 了
所以在父节点 _ready() 里拿子节点,通常是安全的。
extends Node
@onready var child = $Child
func _ready():
child.do_something()
14. 一次性初始化和持续更新
很多初学者会把代码乱放,核心是没分清:
一次性逻辑
持续逻辑
输入事件
物理逻辑
一次性逻辑放 _ready
func _ready():
health = max_health
$AnimationPlayer.play("idle")
持续视觉逻辑放 _process
func _process(delta):
$Clouds.position.x += 20 * delta
物理移动放 _physics_process
func _physics_process(delta):
velocity = direction * speed
move_and_slide()
一次性输入放 _input 或 _unhandled_input
func _unhandled_input(event):
if event.is_action_pressed("interact"):
interact()
15. 玩家脚本完整例子
这是一个比较标准的 2D RPG 玩家脚本结构:
extends CharacterBody2D
@export var speed: float = 120.0
@onready var body_sprite: Sprite2D = $BodySprite
@onready var animation_player: AnimationPlayer = $AnimationPlayer
var last_direction: Vector2 = Vector2.DOWN
func _ready():
animation_player.play("idle_down")
func _physics_process(delta):
var direction = get_move_direction()
if direction != Vector2.ZERO:
last_direction = direction
velocity = direction.normalized() * speed
play_walk_animation(direction)
else:
velocity = Vector2.ZERO
play_idle_animation(last_direction)
move_and_slide()
func _unhandled_input(event):
if event.is_action_pressed("interact"):
try_interact()
func get_move_direction() -> Vector2:
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
return direction
func play_walk_animation(direction: Vector2):
if abs(direction.x) > abs(direction.y):
if direction.x > 0:
animation_player.play("walk_right")
else:
animation_player.play("walk_left")
else:
if direction.y > 0:
animation_player.play("walk_down")
else:
animation_player.play("walk_up")
func play_idle_animation(direction: Vector2):
if abs(direction.x) > abs(direction.y):
if direction.x > 0:
animation_player.play("idle_right")
else:
animation_player.play("idle_left")
else:
if direction.y > 0:
animation_player.play("idle_down")
else:
animation_player.play("idle_up")
func try_interact():
print("尝试交互")
这里的职责分工很清楚:
_ready:
播放初始动画
_physics_process:
处理移动、速度、动画状态
_unhandled_input:
处理一次性交互输入
get_move_direction:
获取移动方向
play_walk_animation:
播放行走动画
play_idle_animation:
播放待机动画
16. UI 脚本完整例子
比如 HUD:
HUD Control
├── HealthLabel Label
├── GoldLabel Label
└── InventoryButton Button
脚本:
extends Control
@onready var health_label: Label = $HealthLabel
@onready var gold_label: Label = $GoldLabel
@onready var inventory_button: Button = $InventoryButton
var gold: int = 0
var health: int = 100
func _ready():
update_health_label()
update_gold_label()
inventory_button.pressed.connect(_on_inventory_button_pressed)
func update_health_label():
health_label.text = "生命:" + str(health)
func update_gold_label():
gold_label.text = "金币:" + str(gold)
func _on_inventory_button_pressed():
print("打开背包")
这里没有用 _process()。
因为 UI 不需要每帧刷新。
只有当数据变化时,主动调用更新函数就行。
17. 宝箱脚本完整例子
结构:
Chest StaticBody2D
├── Sprite2D
├── CollisionShape2D
├── InteractArea Area2D
│ └── CollisionShape2D
└── AnimationPlayer
脚本:
extends StaticBody2D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var interact_shape: CollisionShape2D = $InteractArea/CollisionShape2D
var opened: bool = false
var player_inside: bool = false
func _ready():
$InteractArea.body_entered.connect(_on_body_entered)
$InteractArea.body_exited.connect(_on_body_exited)
func _unhandled_input(event):
if event.is_action_pressed("interact") and player_inside:
open()
func open():
if opened:
return
opened = true
animation_player.play("open")
interact_shape.set_deferred("disabled", true)
print("宝箱打开")
func _on_body_entered(body):
if body.name == "Player":
player_inside = true
func _on_body_exited(body):
if body.name == "Player":
player_inside = false
这里的职责:
_ready:
连接进入/离开区域信号
_unhandled_input:
玩家按交互键时打开宝箱
open:
执行打开逻辑
body_entered/body_exited:
记录玩家是否在附近
18. 什么时候不需要 _process
很多新手会习惯性写:
func _process(delta):
pass
但其实没必要。
如果没有每帧逻辑,就不要写。
比如按钮、宝箱、NPC 对话,很多逻辑都可以靠信号和输入事件触发。
不要把所有东西都塞进 _process()。
这种写法会越来越乱:
func _process(delta):
check_player_input()
check_chest()
check_npc()
update_ui()
update_weather()
update_dialog()
更好的方式是:
玩家移动:Player._physics_process
宝箱交互:Chest._unhandled_input + Area2D 信号
UI 更新:数据变化时主动调用
天气变化:WeatherManager 用 Timer 或专门方法
NPC 对话:Area2D + interact 输入
19. Timer 和 _process 的选择
如果你想每隔几秒做一次事情,不一定要在 _process() 里手动累计时间。
可以用 Timer。
用 _process 累计时间
var timer: float = 0.0
func _process(delta):
timer += delta
if timer >= 3.0:
timer = 0.0
print("每 3 秒执行一次")
用 Timer
func _ready():
$Timer.timeout.connect(_on_timer_timeout)
$Timer.start()
func _on_timer_timeout():
print("时间到了")
更推荐初学者用 Timer,因为直观,也能在编辑器里配置。
20. process_mode:暂停时是否继续执行
Godot 节点有处理模式,决定游戏暂停时这个节点是否继续处理。
这在做暂停菜单时很重要。
比如:
游戏暂停了
玩家不能动
怪物不能动
但暂停菜单按钮还要能点
这就涉及 process_mode。
入门先记:
普通游戏对象:暂停时停止处理
暂停菜单 UI:暂停时仍然处理
这部分后面讲“暂停系统”时单独展开。
21. set_process 和 set_physics_process
Godot 可以手动开启或关闭 _process() 和 _physics_process()。
set_process(false)
set_physics_process(false)
比如敌人死亡后,不想再执行逻辑:
func die():
set_physics_process(false)
$AnimationPlayer.play("die")
再比如某个节点只有激活后才需要每帧处理:
func activate():
set_process(true)
func deactivate():
set_process(false)
不过入门阶段不用频繁用,先知道有这个能力。
22. 常见错误
错误 1:在 _ready 里写移动
func _ready():
position.x += 100
这只会移动一次。
持续移动应该写:
func _process(delta):
position.x += 100 * delta
角色碰撞移动写:
func _physics_process(delta):
velocity.x = 100
move_and_slide()
错误 2:在 _process 里写角色碰撞移动
不推荐:
func _process(delta):
velocity = direction * speed
move_and_slide()
推荐:
func _physics_process(delta):
velocity = direction * speed
move_and_slide()
错误 3:忘记使用 delta
不推荐:
func _process(delta):
position.x += 10
推荐:
func _process(delta):
position.x += 100 * delta
错误 4:把一次性输入写成持续检测
比如打开背包:
不推荐:
func _process(delta):
if Input.is_action_pressed("open_inventory"):
inventory.visible = !inventory.visible
这样按住一瞬间可能开关疯狂闪烁。
推荐:
func _unhandled_input(event):
if event.is_action_pressed("open_inventory"):
inventory.visible = !inventory.visible
错误 5:所有节点都写 _process
不是每个节点都需要 _process()。
能用信号就用信号。
能数据变化时更新,就不要每帧更新。
能用 Timer,就不用自己每帧累计。
23. 生命周期速查
初始化节点:
_ready
每帧视觉更新:
_process(delta)
角色移动、碰撞、物理:
_physics_process(delta)
输入事件:
_input(event)
游戏输入但避开 UI:
_unhandled_input(event)
UI 控件自己的输入:
_gui_input(event)
节点进入场景树:
_enter_tree
节点离开场景树:
_exit_tree
24. 给你的 2D RPG 推荐使用规则
对于你现在要做的 RPG,可以先按这个规则来:
Player:
_ready 初始化
_physics_process 移动
_unhandled_input 交互
NPC:
_ready 初始化
_physics_process 移动或巡逻
Area2D 信号检测玩家靠近
Chest:
_ready 连接信号
_unhandled_input 打开
AnimationPlayer 播放动画
HUD:
_ready 初始化 UI
数据变化时更新 UI
按钮用 pressed 信号
WeatherManager:
_ready 初始化
Timer 或 _process 控制视觉变化
TimeManager:
Timer 更新时间
发 signal 通知 UI 和世界变化
Camera2D:
跟随玩家可以用 _process
涉及物理插值时再优化
25. 这一章最重要的一句话
_ready 做初始化,_process 做每帧视觉更新,_physics_process 做移动和碰撞,_unhandled_input 做游戏输入。
再压缩一下:
初始化:_ready
视觉:_process
物理:_physics_process
输入:_unhandled_input如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录