第八部分:状态机 State Machine:让 Player.gd 不再变成一锅粥
状态机 State Machine,这一部分很重要
你现在的 Player.gd 里已经开始有这些东西了:
移动
动画
交互
对话时锁定控制
附近交互物检测
接下来还会继续加:
攻击
翻滚 / 冲刺
受伤
死亡
采集
钓鱼
开宝箱
释放卡牌技能
骑马 / 上载具
如果所有逻辑都写在一个 _physics_process() 里,很快就会变成这样:
func _physics_process(delta):
if is_dead:
...
elif is_hurt:
...
elif is_attacking:
...
elif is_talking:
...
elif is_dashing:
...
elif is_moving:
...
else:
...
这还只是开始,后面再加动画、音效、冷却、输入锁定,代码会逐渐变成野生藤蔓,哪哪都缠着。
状态机就是为了解决这个问题。
1. 状态机是什么
状态机,全称通常叫:
Finite State Machine
也就是有限状态机。
简单理解:
一个对象在同一时间,只处于一种主要状态
每种状态有自己的行为
状态之间可以切换
比如玩家可以有这些状态:
Idle:站立
Walk:行走
Attack:攻击
Dialogue:对话
Hurt:受伤
Dead:死亡
同一时间,玩家通常不会既在攻击、又在死亡、又在对话。
所以可以规定:
当前状态是 Idle,就只执行 Idle 逻辑
当前状态是 Walk,就只执行 Walk 逻辑
当前状态是 Attack,就只执行 Attack 逻辑
GDQuest 对有限状态机的解释也很适合游戏开发:它把代码拆成多个状态,每个状态有自己的行为,并且机器同一时间只能处于一个状态。状态机会接收输入或事件,然后按规则切换到另一个状态。(GDQuest)
2. 为什么 Player 特别需要状态机
因为玩家是游戏里最容易膨胀的对象。
它既要响应输入,又要播放动画,又要处理物理移动,还要和地图、NPC、敌人、UI 互动。
不用状态机时,你会写很多布尔变量:
var can_move = true
var is_attacking = false
var is_hurt = false
var is_dead = false
var is_talking = false
var is_dashing = false
然后到处判断:
if not can_move:
return
if is_attacking:
return
if is_hurt:
return
if is_talking:
return
这会越来越乱。
状态机的目标是把这些“布尔地狱”变成更清楚的状态:
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE,
HURT,
DEAD
}
var current_state: PlayerState = PlayerState.IDLE
这样你只关心:
当前玩家处于什么状态
这个状态允许做什么
这个状态什么时候切换到下一个状态
3. 状态机解决的核心问题
状态机主要解决三个问题:
1. 当前该执行哪一段逻辑
2. 当前能不能响应某个输入
3. 当前应该播放什么动画
比如:
Idle 状态:
可以移动
可以攻击
可以交互
Walk 状态:
可以继续移动
可以攻击
可以交互
Attack 状态:
不能移动
不能交互
攻击动画结束后回到 Idle
Dialogue 状态:
不能移动
不能攻击
只能按 E 继续对话
Hurt 状态:
不能移动
不能攻击
受伤动画结束后回到 Idle
Dead 状态:
什么都不能做
这样逻辑就清楚很多。
4. 状态机和动画状态机不是一回事
这里很容易混。
Godot 里 AnimationTree 有自己的 AnimationNodeStateMachine。官方文档说明,AnimationNodeStateMachine 是给 AnimationTree 使用的动画状态机,它包含多个动画状态节点,并且可以通过图上的连接进行自动或代码控制的动画切换。(Godot Engine documentation)
但我们现在讲的是:
代码逻辑状态机
不是动画树里的状态机。
区别:
代码状态机:
管理玩家行为,比如能不能移动、能不能攻击、能不能交互
动画状态机:
管理动画怎么从 idle 切到 walk,再切到 attack
它们可以配合,但不是同一个东西。
前期建议你先做代码状态机。
动画仍然先用:
animated_sprite.play("walk_down")
不要一开始就上 AnimationTree,否则你会同时被“代码状态”和“动画状态”双打,脑袋直接冒烟。
5. 最简单的状态机:enum + match
Godot 里最简单的状态机写法是:
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE,
HURT,
DEAD
}
var current_state: PlayerState = PlayerState.IDLE
然后在 _physics_process() 里:
func _physics_process(delta):
match current_state:
PlayerState.IDLE:
physics_idle(delta)
PlayerState.WALK:
physics_walk(delta)
PlayerState.ATTACK:
physics_attack(delta)
PlayerState.DIALOGUE:
physics_dialogue(delta)
PlayerState.HURT:
physics_hurt(delta)
PlayerState.DEAD:
physics_dead(delta)
这样每个状态都有自己的函数。
这就比一个超长 _physics_process() 舒服很多。
6. match 是什么
match 有点像其他语言里的 switch。
比如:
match current_state:
PlayerState.IDLE:
print("站立")
PlayerState.WALK:
print("行走")
PlayerState.ATTACK:
print("攻击")
前端类比:
switch (currentState) {
case 'idle':
break
case 'walk':
break
case 'attack':
break
}
Godot 的 GDScript 里常用 match 来根据枚举值分发逻辑。
7. 状态切换函数 change_state()
不要到处直接写:
current_state = PlayerState.ATTACK
更推荐封装一个方法:
func change_state(next_state: PlayerState):
if current_state == next_state:
return
exit_state(current_state)
current_state = next_state
enter_state(current_state)
这样每次切换状态时,都能统一执行:
离开旧状态时做什么
进入新状态时做什么
比如:
func enter_state(state: PlayerState):
match state:
PlayerState.IDLE:
print("进入 Idle")
PlayerState.ATTACK:
print("进入 Attack")
velocity = Vector2.ZERO
func exit_state(state: PlayerState):
match state:
PlayerState.ATTACK:
print("离开 Attack")
这个设计非常关键。
因为后面你会需要:
进入攻击状态:停止移动,播放攻击动画
离开攻击状态:清理攻击 hitbox
进入对话状态:锁定移动,显示 UI
离开对话状态:恢复移动
进入死亡状态:停止输入,播放死亡动画
都应该放在状态切换里统一管理。
8. 一个最小 Player 状态机
先看一个小版本。
extends CharacterBody2D
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE
}
@export var speed: float = 120.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
var current_state: PlayerState = PlayerState.IDLE
var last_direction: Vector2 = Vector2.DOWN
func _physics_process(delta):
match current_state:
PlayerState.IDLE:
physics_idle(delta)
PlayerState.WALK:
physics_walk(delta)
PlayerState.ATTACK:
physics_attack(delta)
PlayerState.DIALOGUE:
physics_dialogue(delta)
func _unhandled_input(event):
match current_state:
PlayerState.IDLE, PlayerState.WALK:
handle_normal_input(event)
PlayerState.DIALOGUE:
handle_dialogue_input(event)
func physics_idle(delta):
var direction = get_input_direction()
if direction != Vector2.ZERO:
change_state(PlayerState.WALK)
return
velocity = Vector2.ZERO
move_and_slide()
update_animation(Vector2.ZERO)
func physics_walk(delta):
var direction = get_input_direction()
if direction == Vector2.ZERO:
change_state(PlayerState.IDLE)
return
velocity = direction * speed
move_and_slide()
update_animation(direction)
func physics_attack(delta):
velocity = Vector2.ZERO
move_and_slide()
func physics_dialogue(delta):
velocity = Vector2.ZERO
move_and_slide()
func handle_normal_input(event):
if event.is_action_pressed("attack"):
change_state(PlayerState.ATTACK)
return
if event.is_action_pressed("interact"):
try_interact()
return
func handle_dialogue_input(event):
pass
func get_input_direction() -> Vector2:
return Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
func change_state(next_state: PlayerState):
if current_state == next_state:
return
exit_state(current_state)
current_state = next_state
enter_state(current_state)
func enter_state(state: PlayerState):
match state:
PlayerState.IDLE:
update_animation(Vector2.ZERO)
PlayerState.ATTACK:
velocity = Vector2.ZERO
animated_sprite.play("attack_" + get_direction_name(last_direction))
PlayerState.DIALOGUE:
velocity = Vector2.ZERO
func exit_state(state: PlayerState):
pass
func update_animation(direction: Vector2):
if direction == Vector2.ZERO:
animated_sprite.play("idle_" + get_direction_name(last_direction))
return
last_direction = direction
animated_sprite.play("walk_" + get_direction_name(direction))
func get_direction_name(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
return "right" if direction.x > 0 else "left"
return "down" if direction.y > 0 else "up"
这个版本还不完整,但结构已经出来了。
重点不是代码多高级,而是它把逻辑分开了。
9. 状态分发的好处
以前你可能这样写:
func _physics_process(delta):
if is_dialogue:
return
if is_attacking:
velocity = Vector2.ZERO
move_and_slide()
return
var direction = Input.get_vector(...)
velocity = direction * speed
move_and_slide()
状态多了之后会越来越乱。
现在变成:
match current_state:
PlayerState.IDLE:
physics_idle(delta)
PlayerState.WALK:
physics_walk(delta)
PlayerState.ATTACK:
physics_attack(delta)
PlayerState.DIALOGUE:
physics_dialogue(delta)
读起来非常清楚:
Idle 逻辑在 physics_idle
Walk 逻辑在 physics_walk
Attack 逻辑在 physics_attack
Dialogue 逻辑在 physics_dialogue
以后新增一个 HURT 状态,也不会到处乱插 if。
10. 状态机的三个核心函数
一个舒服的状态机一般有这几个函数:
func enter_state(state):
pass
func update_state(delta):
pass
func exit_state(state):
pass
在我们当前写法里:
enter_state:进入状态时执行一次
physics_xxx:状态持续期间每个物理帧执行
exit_state:离开状态时执行一次
例如攻击状态:
进入攻击状态:
停止移动
播放攻击动画
打开攻击判定
攻击状态中:
保持不能移动
离开攻击状态:
关闭攻击判定
回到 Idle
这就比到处写 is_attacking 清楚很多。
11. Idle 状态
Idle 是站立状态。
特点:
可以接收移动输入
可以攻击
可以交互
播放 idle 动画
速度为 0
代码:
func physics_idle(delta):
var direction = get_input_direction()
if direction != Vector2.ZERO:
change_state(PlayerState.WALK)
return
velocity = Vector2.ZERO
move_and_slide()
update_animation(Vector2.ZERO)
意思是:
如果有移动输入,就切到 Walk
否则保持站立
12. Walk 状态
Walk 是行走状态。
特点:
持续读取方向输入
可以攻击
可以交互
播放 walk 动画
方向为空时回到 Idle
代码:
func physics_walk(delta):
var direction = get_input_direction()
if direction == Vector2.ZERO:
change_state(PlayerState.IDLE)
return
velocity = direction * speed
move_and_slide()
update_animation(direction)
意思是:
只要还有方向输入,就移动
方向没了,就回到 Idle
13. Attack 状态
Attack 是攻击状态。
特点:
不能移动
不能交互
播放攻击动画
攻击动画结束后回到 Idle
先写基础版:
func enter_state(state: PlayerState):
match state:
PlayerState.ATTACK:
velocity = Vector2.ZERO
animated_sprite.play("attack_" + get_direction_name(last_direction))
攻击中:
func physics_attack(delta):
velocity = Vector2.ZERO
move_and_slide()
那什么时候回到 Idle?
可以先简单用 Timer。
func enter_state(state: PlayerState):
match state:
PlayerState.ATTACK:
velocity = Vector2.ZERO
animated_sprite.play("attack_" + get_direction_name(last_direction))
await get_tree().create_timer(0.35).timeout
if current_state == PlayerState.ATTACK:
change_state(PlayerState.IDLE)
这个版本能跑,但有一点要注意:
await 写在 enter_state 里可以用
但状态切换多了之后要小心旧 await 回来影响新状态
所以这里加了判断:
if current_state == PlayerState.ATTACK:
防止攻击期间被打断后,旧 timer 又把状态切回 Idle。
14. 更好的攻击结束方式:监听动画结束
如果你用 AnimatedSprite2D,它有动画结束信号。
AnimatedSprite2D 可以播放 SpriteFrames 中的动画,并且有 animation_finished 等信号可用于知道动画何时播放完。(Godot Engine documentation)
可以在 _ready() 里连接:
func _ready():
animated_sprite.animation_finished.connect(_on_animation_finished)
然后:
func _on_animation_finished():
if current_state == PlayerState.ATTACK:
change_state(PlayerState.IDLE)
这样攻击持续时间由动画本身决定。
比写死 0.35 秒更自然。
推荐这种。
15. Dialogue 状态
Dialogue 是对话状态。
特点:
不能移动
不能攻击
不能重复交互
等待 DialogueBox 处理输入
对话结束后回到 Idle
进入对话状态:
func enter_state(state: PlayerState):
match state:
PlayerState.DIALOGUE:
velocity = Vector2.ZERO
update_animation(Vector2.ZERO)
对话中:
func physics_dialogue(delta):
velocity = Vector2.ZERO
move_and_slide()
对话开始时,Player 可以切到 Dialogue:
func lock_control_for_dialogue():
change_state(PlayerState.DIALOGUE)
对话结束时:
func unlock_control_from_dialogue():
change_state(PlayerState.IDLE)
然后 UI 里可以这样调用:
func _on_dialogue_started():
if player != null and player.has_method("lock_control_for_dialogue"):
player.lock_control_for_dialogue()
func _on_dialogue_finished():
if player != null and player.has_method("unlock_control_from_dialogue"):
player.unlock_control_from_dialogue()
这样就不需要再用一堆 can_move、can_interact 乱飞了。
16. 状态机版本的 Player 控制锁
之前我们是:
var can_move = true
var can_interact = true
状态机之后,可以少用这些布尔变量。
因为:
Dialogue 状态天然不能移动、不能攻击、不能交互
Attack 状态天然不能移动、不能交互
Dead 状态天然什么都不能做
也就是说,与其问:
can_move 是 true 吗?
can_interact 是 true 吗?
is_attacking 是 false 吗?
is_dialogue 是 false 吗?
不如问:
当前状态是不是允许移动的状态?
例如:
func can_accept_normal_input() -> bool:
return current_state == PlayerState.IDLE or current_state == PlayerState.WALK
这样就干净很多。
17. 输入也要根据状态分发
不推荐所有输入都直接响应。
比如:
func _unhandled_input(event):
if event.is_action_pressed("attack"):
change_state(PlayerState.ATTACK)
if event.is_action_pressed("interact"):
try_interact()
这会导致对话时也可能攻击、攻击时也可能开宝箱。
更推荐:
func _unhandled_input(event):
match current_state:
PlayerState.IDLE, PlayerState.WALK:
handle_normal_input(event)
PlayerState.ATTACK:
handle_attack_input(event)
PlayerState.DIALOGUE:
handle_dialogue_input(event)
PlayerState.HURT, PlayerState.DEAD:
pass
然后:
func handle_normal_input(event):
if event.is_action_pressed("attack"):
change_state(PlayerState.ATTACK)
return
if event.is_action_pressed("interact"):
try_interact()
return
这样很清楚:
只有 Idle / Walk 状态能响应攻击和交互
Dialogue 状态不让 Player 响应这些输入
18. 状态切换规则
状态机最重要的不只是“当前状态是什么”,还有“哪些状态可以切到哪些状态”。
比如:
Idle → Walk
Walk → Idle
Idle → Attack
Walk → Attack
Idle → Dialogue
Walk → Dialogue
Attack → Idle
Dialogue → Idle
Hurt → Idle
Any → Dead
但有些切换应该禁止:
Dead → Walk 不允许
Dialogue → Attack 不允许
Attack → Dialogue 不允许
Hurt → Attack 不允许
可以在 change_state() 里检查:
func can_change_to(next_state: PlayerState) -> bool:
if current_state == PlayerState.DEAD:
return false
if current_state == PlayerState.DIALOGUE:
return next_state == PlayerState.IDLE
if current_state == PlayerState.ATTACK:
return next_state == PlayerState.IDLE or next_state == PlayerState.HURT
return true
然后:
func change_state(next_state: PlayerState):
if current_state == next_state:
return
if not can_change_to(next_state):
return
exit_state(current_state)
current_state = next_state
enter_state(current_state)
这个会让状态切换更安全。
19. 先不要把状态机做得太复杂
状态机也有一个坑:
刚开始就过度设计
比如你一上来就拆成 20 个脚本:
IdleState.gd
WalkState.gd
AttackState.gd
HurtState.gd
DeadState.gd
DialogueState.gd
StateMachine.gd
PlayerState.gd
BaseState.gd
这对初学阶段会有点重。
你现在先用:
enum + match + change_state
就够了。
等状态真的多了,再拆成节点式状态机。
20. 两种常见状态机写法
Godot 里常见两种:
1. enum 简单状态机
2. 节点式状态机
enum 简单状态机:
所有状态逻辑还在 Player.gd
但用 match 分开
适合状态较少的角色
节点式状态机:
每个状态是一个独立脚本/节点
IdleState.gd 管 Idle
WalkState.gd 管 Walk
AttackState.gd 管 Attack
适合状态很多的角色、敌人 AI
GDQuest 的状态机教程也提到,既可以用简单变量、if / match 和函数实现 FSM,也可以用更复杂的节点式 FSM;不同写法适合不同复杂度。(GDQuest)
你现在建议先用简单状态机。
别急着一步登天,游戏开发里“先能跑,再变优雅”是活命法则。
21. 完整一点的 Player.gd 状态机版本
下面这份可以当作你当前阶段的参考模板。
extends CharacterBody2D
signal interactable_changed(interactable: Node)
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE,
HURT,
DEAD
}
@export var speed: float = 120.0
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var interact_area: Area2D = $InteractArea
var current_state: PlayerState = PlayerState.IDLE
var last_direction: Vector2 = Vector2.DOWN
var nearby_interactables: Array[Node] = []
var current_interactable: Node = null
func _ready():
interact_area.area_entered.connect(_on_interact_area_entered)
interact_area.area_exited.connect(_on_interact_area_exited)
animated_sprite.animation_finished.connect(_on_animation_finished)
func _physics_process(delta):
match current_state:
PlayerState.IDLE:
physics_idle(delta)
PlayerState.WALK:
physics_walk(delta)
PlayerState.ATTACK:
physics_attack(delta)
PlayerState.DIALOGUE:
physics_dialogue(delta)
PlayerState.HURT:
physics_hurt(delta)
PlayerState.DEAD:
physics_dead(delta)
func _unhandled_input(event):
match current_state:
PlayerState.IDLE, PlayerState.WALK:
handle_normal_input(event)
PlayerState.DIALOGUE:
pass
PlayerState.ATTACK, PlayerState.HURT, PlayerState.DEAD:
pass
func physics_idle(delta):
var direction := get_input_direction()
if direction != Vector2.ZERO:
change_state(PlayerState.WALK)
return
velocity = Vector2.ZERO
move_and_slide()
update_animation(Vector2.ZERO)
func physics_walk(delta):
var direction := get_input_direction()
if direction == Vector2.ZERO:
change_state(PlayerState.IDLE)
return
velocity = direction * speed
move_and_slide()
update_animation(direction)
func physics_attack(delta):
velocity = Vector2.ZERO
move_and_slide()
func physics_dialogue(delta):
velocity = Vector2.ZERO
move_and_slide()
update_animation(Vector2.ZERO)
func physics_hurt(delta):
velocity = Vector2.ZERO
move_and_slide()
func physics_dead(delta):
velocity = Vector2.ZERO
move_and_slide()
func handle_normal_input(event):
if event.is_action_pressed("attack"):
change_state(PlayerState.ATTACK)
return
if event.is_action_pressed("interact"):
try_interact()
return
func get_input_direction() -> Vector2:
return Input.get_vector(
"move_left",
"move_right",
"move_up",
"move_down"
)
func try_interact():
if current_interactable == null:
return
if current_interactable.has_method("interact"):
current_interactable.interact(self)
func lock_control_for_dialogue():
change_state(PlayerState.DIALOGUE)
func unlock_control_from_dialogue():
change_state(PlayerState.IDLE)
func change_state(next_state: PlayerState):
if current_state == next_state:
return
if not can_change_to(next_state):
return
exit_state(current_state)
current_state = next_state
enter_state(current_state)
func can_change_to(next_state: PlayerState) -> bool:
if current_state == PlayerState.DEAD:
return false
if current_state == PlayerState.DIALOGUE:
return next_state == PlayerState.IDLE
if current_state == PlayerState.ATTACK:
return next_state == PlayerState.IDLE or next_state == PlayerState.HURT or next_state == PlayerState.DEAD
return true
func enter_state(state: PlayerState):
match state:
PlayerState.IDLE:
update_animation(Vector2.ZERO)
PlayerState.WALK:
pass
PlayerState.ATTACK:
velocity = Vector2.ZERO
animated_sprite.play("attack_" + get_direction_name(last_direction))
PlayerState.DIALOGUE:
velocity = Vector2.ZERO
update_animation(Vector2.ZERO)
PlayerState.HURT:
velocity = Vector2.ZERO
animated_sprite.play("hurt_" + get_direction_name(last_direction))
PlayerState.DEAD:
velocity = Vector2.ZERO
animated_sprite.play("dead")
func exit_state(state: PlayerState):
match state:
PlayerState.ATTACK:
pass
PlayerState.DIALOGUE:
pass
func update_animation(direction: Vector2):
if current_state == PlayerState.ATTACK:
return
if current_state == PlayerState.HURT:
return
if current_state == PlayerState.DEAD:
return
if direction == Vector2.ZERO:
animated_sprite.play("idle_" + get_direction_name(last_direction))
return
last_direction = direction
animated_sprite.play("walk_" + get_direction_name(direction))
func get_direction_name(direction: Vector2) -> String:
if abs(direction.x) > abs(direction.y):
return "right" if direction.x > 0 else "left"
return "down" if direction.y > 0 else "up"
func _on_animation_finished():
if current_state == PlayerState.ATTACK:
change_state(PlayerState.IDLE)
return
if current_state == PlayerState.HURT:
change_state(PlayerState.IDLE)
return
func _on_interact_area_entered(area):
if area.is_in_group("interactable"):
nearby_interactables.append(area)
update_current_interactable()
func _on_interact_area_exited(area):
if area.is_in_group("interactable"):
nearby_interactables.erase(area)
update_current_interactable()
func update_current_interactable():
current_interactable = null
var nearest_distance := INF
for interactable in nearby_interactables:
if not is_instance_valid(interactable):
continue
var distance = global_position.distance_to(interactable.global_position)
if distance < nearest_distance:
nearest_distance = distance
current_interactable = interactable
interactable_changed.emit(current_interactable)
这份代码已经比前面的版本更像“真正项目里的 Player 控制器”了。
22. 这个版本解决了什么问题
它解决了:
移动和攻击不会混在一起
对话时不会移动
对话时不会攻击
攻击时不会交互
死亡后不会再乱切状态
动画结束后能回到 Idle
交互检测仍然保留
并且结构比较清楚:
physics_idle:站立逻辑
physics_walk:移动逻辑
physics_attack:攻击逻辑
physics_dialogue:对话逻辑
enter_state:进入状态做一次性操作
exit_state:离开状态做清理
change_state:统一切换状态
23. 为什么 update_animation 里要判断状态
因为攻击、受伤、死亡动画不能被普通 idle/walk 动画覆盖。
如果你不判断,可能出现:
进入 Attack 状态
播放 attack_down
下一帧 _physics_process 又调用 update_animation(Vector2.ZERO)
立刻变回 idle_down
攻击动画根本看不到
所以:
func update_animation(direction: Vector2):
if current_state == PlayerState.ATTACK:
return
if current_state == PlayerState.HURT:
return
if current_state == PlayerState.DEAD:
return
意思是:
特殊状态下,不要让普通移动动画接管
这个坑非常常见。
24. HURT 受伤状态
现在可以先简单预留。
受伤时:
func take_damage(amount: int):
if current_state == PlayerState.DEAD:
return
hp -= amount
if hp <= 0:
change_state(PlayerState.DEAD)
return
change_state(PlayerState.HURT)
如果你现在还没做 hp,就先不用加。
但要知道受伤状态一般会:
停止移动
播放受伤动画
短暂无敌
击退
动画结束后回到 Idle
后面怪物攻击你时,会用到这个。
25. DEAD 死亡状态
死亡状态通常是终点状态。
特点:
不能移动
不能攻击
不能交互
不能再被普通状态覆盖
播放死亡动画
显示失败 UI 或重生
所以在 can_change_to() 里:
if current_state == PlayerState.DEAD:
return false
意思是:
死了就不要再切回 Walk / Idle
除非你做复活系统,那可以专门写一个 revive() 方法。
26. Attack 状态和攻击判定
攻击不只是动画。
以后你还会加:
AttackArea
Hitbox
伤害数值
攻击方向
攻击冷却
攻击音效
击中特效
攻击状态进入时:
播放攻击动画
根据 last_direction 设置攻击范围位置
打开 hitbox
攻击状态离开时:
关闭 hitbox
清空已命中目标
这就是为什么 enter_state() 和 exit_state() 很重要。
比如以后可以写:
func enter_state(state: PlayerState):
match state:
PlayerState.ATTACK:
velocity = Vector2.ZERO
update_attack_area_position()
attack_area.monitoring = true
animated_sprite.play("attack_" + get_direction_name(last_direction))
func exit_state(state: PlayerState):
match state:
PlayerState.ATTACK:
attack_area.monitoring = false
这个后面讲战斗系统时会详细展开。
27. Dialogue 状态和 DialogueBox 的关系
建议关系是:
NPC interact(player)
↓
DialogueBox.start_dialogue()
↓
DialogueBox 发出 dialogue_started
↓
UI 调用 player.lock_control_for_dialogue()
↓
Player 切到 DIALOGUE 状态
对话结束:
DialogueBox 发出 dialogue_finished
↓
UI 调用 player.unlock_control_from_dialogue()
↓
Player 回到 IDLE
这样 Player 不直接控制对话框,对话框也不直接写 Player 移动逻辑。
它们通过信号和方法协作。
28. 状态机里的“状态”和“变量”不是敌人
用了状态机,不代表完全不能用布尔变量。
有些变量仍然需要:
var has_key: bool = false
var is_invincible: bool = false
var can_combo: bool = false
var opened: bool = false
状态机主要负责“当前主行为”。
而变量负责“附加信息”。
比如:
当前主状态:HURT
附加变量:is_invincible = true
不要为了“纯状态机”把所有东西都硬塞成状态。
否则你会得到这种怪物:
IDLE_WITH_KEY
IDLE_WITHOUT_KEY
WALK_INVINCIBLE
WALK_NOT_INVINCIBLE
ATTACK_CAN_COMBO
ATTACK_CANNOT_COMBO
这就过头了,属于状态机开始反噬人类。
29. 什么时候该新增状态
可以这样判断:
如果这个行为会持续一段时间
如果它会独占输入
如果它有自己的动画
如果它会禁止某些操作
如果它有明确开始和结束
那它适合成为状态。
比如适合做状态:
Attack
Dialogue
Hurt
Dead
Dash
CastSpell
Fishing
Mining
Cutscene
不一定适合做状态:
是否有钥匙
是否有任务
当前金币数量
当前装备武器
是否下雨
当前地图名字
这些更像普通数据。
30. 玩家状态和游戏全局状态
不要混。
玩家状态:
Idle
Walk
Attack
Dialogue
Hurt
Dead
游戏全局状态:
Exploring:探索中
Paused:暂停
Inventory:打开背包
Cutscene:剧情演出
Battle:战斗中
Menu:主菜单
以后你可能会有两个状态机:
PlayerStateMachine:管理玩家动作
GameStateMachine:管理游戏整体模式
比如打开背包时:
GameState = Inventory
PlayerState = Idle 或 Locked
不要把所有状态都塞进 Player。
否则 Player 会变成整个游戏宇宙的中枢神经,听着很酷,维护起来很惨。
31. 状态机和前端开发的类比
你是前端开发,可以这样类比:
状态机 ≈ 更严格的 UI 状态管理
比如一个按钮可能有:
normal
hover
loading
disabled
success
error
你不会希望它同时是:
loading + success + disabled + error
所以通常会设计一个主状态:
type ButtonState = 'normal' | 'loading' | 'success' | 'error'
Godot 里的玩家也是一样:
enum PlayerState {
IDLE,
WALK,
ATTACK,
DIALOGUE,
HURT,
DEAD
}
本质都是:
用一个明确状态,代替一堆互相打架的布尔值
32. 节点式状态机是什么
等你状态很多后,可以把每个状态拆成节点。
结构可能是:
Player CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── InteractArea
└── StateMachine Node
├── IdleState Node
├── WalkState Node
├── AttackState Node
├── HurtState Node
└── DeadState Node
每个状态一个脚本:
IdleState.gd
WalkState.gd
AttackState.gd
它们都有类似方法:
func enter():
pass
func physics_update(delta):
pass
func exit():
pass
这样 Player.gd 会更轻。
但这个版本更抽象,不适合你现在刚开始地图、交互、对话阶段就上。
先用 enum 版,真的够用。
33. AnimationTree 的状态机什么时候用
等你动画变复杂时,可以考虑 AnimationTree。
比如你有:
idle_up/down/left/right
walk_up/down/left/right
attack_up/down/left/right
hurt
dead
再加:
不同武器
不同速度
混合动画
转身动画
攻击连招
这时 AnimationTree 和它的动画状态机就有价值了。
Godot 官方文档里提到,AnimationTree 的 root 可以使用多种节点,包括 AnimationNodeStateMachine、BlendSpace1D、BlendSpace2D、BlendTree 等,用来组织和混合动画。(Godot Engine documentation)
但现在你先别急。
现在你用:
AnimatedSprite2D.play("walk_down")
足够。
等角色动画真的复杂了,再学 AnimationTree。
34. 当前阶段推荐状态
你现在建议先做这些状态:
IDLE
WALK
DIALOGUE
然后再加:
ATTACK
HURT
DEAD
顺序建议:
1. IDLE / WALK
2. DIALOGUE
3. ATTACK
4. HURT
5. DEAD
不要一次性全上。
你可以先让状态机只管理移动和对话。
等稳定后,再把攻击接进去。
35. 最小状态机练习
先做一个最小版本:
enum PlayerState {
IDLE,
WALK,
DIALOGUE
}
实现:
站着不动是 IDLE
按方向键切到 WALK
松开方向键回到 IDLE
对话开始切到 DIALOGUE
对话结束回到 IDLE
DIALOGUE 状态不能移动
这一步很重要。
你先不要急着加攻击。
先确认状态切换是对的。
可以在 change_state() 里打印:
func change_state(next_state: PlayerState):
if current_state == next_state:
return
print("State: ", PlayerState.keys()[current_state], " -> ", PlayerState.keys()[next_state])
exit_state(current_state)
current_state = next_state
enter_state(current_state)
这样你能在 Output 里看到:
State: IDLE -> WALK
State: WALK -> IDLE
State: IDLE -> DIALOGUE
State: DIALOGUE -> IDLE
这个调试非常有用。
36. 常见错误 1:状态一直来回切
比如你写:
func physics_idle(delta):
change_state(PlayerState.WALK)
没有条件,就会每帧切过去。
或者 Walk 里又立刻切回 Idle。
所以状态切换一定要有条件:
if direction != Vector2.ZERO:
change_state(PlayerState.WALK)
if direction == Vector2.ZERO:
change_state(PlayerState.IDLE)
37. 常见错误 2:攻击动画被 idle 覆盖
前面说过,这是因为 update_animation() 没判断当前状态。
解决:
func update_animation(direction: Vector2):
if current_state == PlayerState.ATTACK:
return
if current_state == PlayerState.HURT:
return
if current_state == PlayerState.DEAD:
return
特殊状态动画不要被普通移动动画覆盖。
38. 常见错误 3:对话结束后还不能动
如果用状态机,检查:
DialogueBox 有没有发 dialogue_finished
UI 有没有调用 unlock_control_from_dialogue
Player 是否允许 DIALOGUE -> IDLE
can_change_to 里有没有写错
尤其是:
if current_state == PlayerState.DIALOGUE:
return next_state == PlayerState.IDLE
如果这里写错,对话就回不去了。
39. 常见错误 4:死亡后还能移动
检查:
DEAD 状态有没有禁止切换
_physics_process 有没有 physics_dead
_unhandled_input DEAD 时有没有 pass
死亡状态通常应该拦住所有普通输入。
if current_state == PlayerState.DEAD:
return false
40. 常见错误 5:状态枚举顺序变了,存档出问题
如果你以后要存档,不建议直接存枚举数字。
比如:
save_data["player_state"] = current_state
因为枚举背后是数字。
以后你调整顺序:
enum PlayerState {
IDLE,
WALK,
DASH,
ATTACK
}
旧存档可能就错位。
更推荐存字符串:
save_data["player_state"] = PlayerState.keys()[current_state]
不过 Player 当前状态通常不一定需要存档。
这属于后面存档系统内容。
41. 这一部分最重要的记忆点
状态机用一个明确状态,代替一堆互相打架的 bool
玩家同一时间只处于一个主要状态
Idle / Walk / Attack / Dialogue / Hurt / Dead 都适合做状态
状态切换最好统一走 change_state()
进入状态用 enter_state()
离开状态用 exit_state()
每个状态自己的持续逻辑写成 physics_xxx()
输入也要根据状态分发
特殊动画不要被普通 idle/walk 动画覆盖
前期用 enum + match 就够,不要一上来节点式状态机
42. 你现在的小练习
给 Player 加一个简单状态机:
1. 创建 enum PlayerState
2. 添加 IDLE、WALK、DIALOGUE
3. current_state 默认 IDLE
4. _physics_process 用 match 分发
5. IDLE 检测方向,有方向切 WALK
6. WALK 没方向切 IDLE
7. 对话开始切 DIALOGUE
8. 对话结束切 IDLE
9. DIALOGUE 状态下不能移动
10. change_state 里打印状态变化
做完这个之后,你的 Player.gd 会从“功能堆叠”变成“结构化控制”。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录