从零开始的 Godot 之旅 --- EP10:有限状态机(二)
在上一章中,我们详细设计了有限状态机(FSM)的底层架构。理论终须实践,本节我们将正式在项目中应用这套状态机,并通过改造玩家(Player)场景,将枯燥的代码设计化为生动的角色行为。
新建目录
我们首先来调整一下项目结构,为接下来的状态机实现做好准备。
在res://common/目录下创建state_machine/目录。

没错,我确实删除了原先的 script 根目录。自从在 EP8 中将玩家相关的脚本和资源整合到其场景目录下后,我愈发觉得一个笼统的
script目录意义不大。Godot 与 Java 或 C# 不同,它不以脚本为核心,脚本本身也不具备严格的"包(package)"概念。因此,将所有脚本集中存放,反而会导致我们在场景和脚本目录间频繁切换,既浪费时间也不利于组件的迁移。所以,后续我们的项目结构可能会更多地以"场景"为核心,而非"文件类型"。(至少目前是这么想的)
在state_machine目录中,我们依次创建state_machine.gd、state.gd、state_transition.gd、state_loader.gd这四个文件,并将上一节中提供的代码分别粘贴进去。
请特别注意它们的基类:state_machine 和 state 继承自 Node,而 state_transition 和 state_loader 继承自 RefCounted。
为什么基类不同?这是因为
StateMachine和State作为场景的一部分需要被挂载到节点树上,而StateTransition和StateLoader只是用于承载逻辑和数据的辅助类,无需成为场景节点,使用轻量级的RefCounted可以有效管理内存。
在新建 state_transition 脚本时,可以直接在Godot的创建脚本窗口选择其基类 RefCounted:

至此,我们通用的状态机模块就创建好了。

最新的代码请查阅Gitee。我有时可能更新了代码但未能及时同步博客,所以最好还是以Gitee上的为准。
改造玩家场景
基础的状态机框架搭建完毕,现在是时候改造我们的玩家场景,让它真正"动"起来了。
我们打开玩家场景,在根节点(CharacterBody2D)下添加一个 StateMachine 节点,并将其命名为StateMachine。

在检查器中设置该 StateMachine 节点的属性:
- 将
Auto Load Mode设置为NODE,这样状态机会自动加载其子节点作为状态。 - 将
Initial State设置为idle,这是我们希望的初始状态。
你可能会好奇,为什么我们自己写的脚本类(StateMachine)能像内置节点一样被添加到场景中?答案就在于继承。因为我们的
StateMachine类继承自Node,而 Godot 中所有Node都可以被添加到场景树里。这意味着,我们可以通过继承 Godot 提供的任意节点类来创造功能丰富的自定义节点!
创建状态节点
我们之前为玩家设计了 idle、walk、attack 三个动画,现在需要为它们分别创建对应的状态节点。
首先,我们来创建 idle 状态。在 StateMachine 节点下添加一个 State 节点,将其命名为 idle,并在检查器中将它的 State Name 属性也设置为 idle。

接着,用同样的方法创建 walk 和 attack 两个状态节点,并分别设置好它们的命名和 State Name 属性。
创建完成后的节点结构应如下图所示:

启动状态机
完成上述步骤后,我们回到 player.gd 脚本。按住 Ctrl (Cmd) 键,将场景树中的 StateMachine 节点拖拽到脚本编辑器中。Godot 会为我们自动创建一个 @onready 变量,并将其绑定到该节点上,非常方便。

然后,在 player.gd 中添加 _ready 函数。这个函数会在 Player 节点及其子节点都准备就绪后被调用。我们在其中启动状态机:
GDScript
## 状态机
@onready var state_machine: StateMachine = $StateMachine
func _ready() -> void:
# 启动状态机
state_machine.start()
现在启动游戏,你可以在控制台看到状态机已成功加载了三个状态,并进入了我们设定的默认状态 idle。

更新脚本
至此,我们已经完成了:
- 通用状态机节点的创建
- 玩家场景结构的改造
- 状态机的初步启动
接下来,我们将开始编写各个状态的具体逻辑。在此之前,需要先对 player.gd 做一些清理和调整,移除旧的、将被状态机替代的逻辑。
-
清空
_physics_process函数 :其中的逻辑将由各个状态脚本接管。GDScriptfunc _physics_process(_delta: float) -> void: pass -
移除旧的攻击相关变量 :
attack_duration、attack_timer、is_attacking将被移入攻击状态(Attack State)中管理。 -
更新
update_animation函数 :让它从状态机获取当前状态名称。diff- var current_state: String = "idle" # 或者 "walk" + var current_state: String = state_machine.get_current_state_name()
调整后的 player.gd 完整代码如下:
GDScript
extends CharacterBody2D
# 可在检查器里调整的移动速度
@export var speed: float = 100.0
# 当前输入方向向量(单位化前的原始输入)
var move_direction: Vector2 = Vector2.ZERO
# 玩家朝向,默认朝下
var player_direction: Vector2 = Vector2.DOWN
# 组件引用
## 动画子节点
@onready var animation_player: AnimationPlayer = $AnimationPlayer
## 精灵
@onready var sprite: Sprite2D = $Sprite2D
## 状态机
@onready var state_machine: StateMachine = $StateMachine
func _ready() -> void:
# 启动状态机
state_machine.start()
func _process(_delta: float) -> void:
pass
func _physics_process(_delta: float) -> void:
pass
# 更新朝向和动画
# @return 无返回值
func update_direction_and_animation() -> void:
# 更新朝向(只在有移动时更新,使用标准方向)
if move_direction != Vector2.ZERO:
var new_direction: Vector2
if abs(move_direction.x) > abs(move_direction.y):
new_direction = Vector2.RIGHT if move_direction.x > 0 else Vector2.LEFT
else:
new_direction = Vector2.DOWN if move_direction.y > 0 else Vector2.UP
# 如果朝向发生变化,更新玩家朝向
if new_direction != player_direction:
player_direction = new_direction
# 更新动画
update_animation()
# 更新角色动画
# @return 无返回值
func update_animation() -> void:
var current_state: String = state_machine.get_current_state_name()
var direction: String = get_anim_direction()
var animation_name: String = current_state + "_" + direction
if animation_player.has_animation(animation_name):
animation_player.play(animation_name)
else:
print_debug("动画不存在: ", animation_name)
# 获取动画方向字符串
# @return 方向字符串("down", "up", 或 "side")
func get_anim_direction() -> String:
if abs(player_direction.x) > abs(player_direction.y):
sprite.flip_h = player_direction.x < 0
return "side"
if player_direction.y < 0:
return "up"
return "down"
待机状态(Idle State)
我们先来梳理一下待机状态的逻辑:
- 进入时:播放待机动画。
- 持续时 :
- 确保玩家速度为零,保持静止。
- 持续更新动画以响应玩家的朝向变化。
- 退出条件:由状态转换规则自动处理(例如,检测到移动输入)。
现在,为 idle 状态节点附加一个新脚本。我将其命名为 player_idle_state.gd 并存放在 res://scenes/entities/player/scripts/states/ 目录下。请注意,它需要继承自我们之前创建的 State 基类。
GDScript
# PlayerIdleState.gd
# 玩家空闲状态
# @author chenrui
# @date 2025-08-31
class_name PlayerIdleState
extends State
# 进入状态时调用
func enter(_previous_state: String = "", _data: Dictionary = {}) -> void:
var player: CharacterBody2D = get_state_owner()
if player == null:
printerr("错误: 无法获取玩家节点")
return
if player.has_method("update_animation"):
player.update_animation()
# 物理帧更新
func physics_update(_delta: float) -> void:
var player: CharacterBody2D = get_state_owner()
if player == null:
printerr("错误: 无法获取玩家节点")
return
# 保持静止 - 转换由自动转换规则处理
player.velocity = Vector2.ZERO
player.move_and_slide()
# 更新角色朝向和动画
player.update_direction_and_animation()
在状态脚本中,我们可以通过
get_state_owner()方法方便地获取到玩家节点(CharacterBody2D)的实例,从而直接调用其身上的方法和属性。
行走状态(Walk State)
接着是行走状态的逻辑:
- 进入时:播放行走动画。
- 持续时 :
- 接收玩家的移动输入。
- 根据输入计算归一化的移动方向。
- 更新
player的速度属性并调用move_and_slide()使其移动。 - 持续更新角色朝向和动画。
- 退出条件:由状态转换规则自动处理(例如,不再有移动输入)。
我们为 walk 状态节点新增一个脚本,命名为player_walk_state.gd,同样继承自 State 基类,并存放在相同的状态脚本目录下。
GDScript
# PlayerWalkState.gd
# 玩家行走状态
# @author chenrui
# @date 2025-08-31
class_name PlayerWalkState
extends State
# 进入状态时调用
func enter(_previous_state: String = "", _data: Dictionary = {}) -> void:
var player: CharacterBody2D = get_state_owner()
if player == null:
printerr("错误: 无法获取玩家节点")
return
# 播放行走动画
if player.has_method("update_animation"):
player.update_animation()
# 逻辑帧更新
func update(_delta: float) -> void:
pass
# 物理帧更新
func physics_update(_delta: float) -> void:
var player: CharacterBody2D = get_state_owner()
if player == null:
printerr("错误: 无法获取玩家节点")
return
# 接收输入,计算移动方向
var x_axis: float = Input.get_axis("left", "right")
var y_axis: float = Input.get_axis("up", "down")
var move_direction = Vector2(x_axis, y_axis)
# 移动方向大于1时归一化
if move_direction.length() > 1.0:
move_direction = move_direction.normalized()
player.move_direction = move_direction
# 使用Player中的move_direction和speed
player.velocity = player.move_direction * player.speed
player.move_and_slide()
# 更新角色朝向和动画
player.update_direction_and_animation()
攻击状态(Attack State)
最后是攻击状态的逻辑:
- 进入时 :
- 重置攻击计时器。
- 播放攻击动画。
- 持续时 :
- 计时器累加攻击时长。
- 确保角色在攻击时保持静止。
- 更新玩家朝向和动画。
- 完成条件 (
is_finished) :- 当攻击计时器超过预设的攻击时长后,此函数返回
true,告知状态机该状态已完成,可以进行转换。
- 当攻击计时器超过预设的攻击时长后,此函数返回
攻击状态与待机、行走状态略有不同。为了确保攻击动作的完整性,角色的攻击状态需要等待动画播放完毕后才能退出。在此期间,它不应该被其他状态(如移动)打断。因此,我们需要在攻击状态中实现 is_finished() 函数来告知状态机该状态何时"完成"。
我们为 attack 状态节点新增一个脚本,命名为 player_attack_state.gd,并存放在状态脚本目录下。
GDScript
# PlayerAttackState.gd
# 玩家攻击状态
# @author chenrui
# @date 2025-08-31
class_name PlayerAttackState
extends State
## 攻击持续时间(秒)
@export var attack_duration: float = 0.3
## 攻击计时器,用于跟踪攻击进度
var attack_timer: float = 0.0
# 进入状态时调用
func enter(_previous_state: String = "", _data: Dictionary = {}) -> void:
var player: CharacterBody2D = get_state_owner()
if player == null:
printerr("错误: 无法获取玩家节点")
return
attack_timer = 0.0
# 播放攻击动画
if player.has_method("update_animation"):
player.update_animation()
# 逻辑帧更新
func update(delta: float) -> void:
attack_timer += delta
# 攻击结束的转换由自动转换规则处理
# 物理帧更新
func physics_update(_delta: float) -> void:
var player: CharacterBody2D = get_state_owner()
if player == null:
return
# 攻击时保持静止
player.velocity = Vector2.ZERO
player.move_and_slide()
# 更新角色朝向和动画
player.update_direction_and_animation()
# 检查状态是否完成
# @return 是否完成
func is_finished() -> bool:
return attack_timer >= attack_duration
实现状态切换
现在,各个状态的独立逻辑已经完成。但角色还无法在不同状态间切换,因为我们尚未定义切换的"规则"。
我们设计的状态机提供了一套基于规则的自动转换机制。我们只需创建一系列 StateTransition 实例,并告知状态机"在什么条件下,从哪个状态转换到哪个状态",它就会在每一帧自动检查并执行符合条件的转换。
一个 StateTransition 实例需要以下参数:
_from_state: String- 源状态名称_to_state: String- 目标状态名称_condition: Callable- 转换条件函数_priority: int- 转换优先级
其中,转换条件(condition)是一个返回布尔值的 Callable(通常是匿名函数),用于判断是否满足转换时机。优先级(priority)则用于解决当多个转换规则同时满足时,应优先执行哪一个的问题(数值越大,优先级越高)。
我们以 idle 到 walk 的转换为例:
GDScript
# 从 idle 到 walk:有移动输入时
var idle_to_walk_condition: Callable = func():
return Input.is_action_pressed("up") or Input.is_action_pressed("down") or Input.is_action_pressed("left") or Input.is_action_pressed("right")
var idle_to_walk_transition: StateTransition = StateTransition.new(PlayerConsts.STATE_IDLE, PlayerConsts.STATE_WALK, idle_to_walk_condition, 5)
state_machine.add_transition(idle_to_walk_transition)
这里的 idle_to_walk_condition 就是一个匿名函数,它检查是否有移动键被按下。StateTransition 定义了从 idle 到 walk 的转换,优先级为 5。最后,通过 add_transition 将这个规则注册到状态机中。
为了避免在代码中硬编码大量的状态名字符串(这很容易因拼写错误而出问题),我们最好创建一个玩家常量脚本来统一管理这些名称。
在res://scenes/entities/player/scripts/目录下创建player_consts.gd文件,内容如下:
GDScript
# PlayerConsts.gd
# 玩家常量定义
# @author chenrui
# @date 2025-08-31
class_name PlayerConsts
extends RefCounted
# 状态常量
const STATE_IDLE: String = "idle"
const STATE_WALK: String = "walk"
const STATE_ATTACK: String = "attack"
const STATE_HURT: String = "hurt"
const STATE_DIE: String = "die"
下面,我们在 player.gd 中编写一个 _setup_transitions 函数,集中定义所有状态的转换规则:
GDScript
# 设置状态转换规则
func _setup_transitions() -> void:
# 从任意状态到攻击状态:按下攻击键时
var attack_condition: Callable = func(): return Input.is_action_just_pressed("attack")
var attack_transition: StateTransition = StateTransition.new("*", PlayerConsts.STATE_ATTACK, attack_condition, 10) # 高优先级,确保攻击能打断其他状态
state_machine.add_transition(attack_transition)
# 从 idle 到 walk:有移动输入时
var idle_to_walk_condition: Callable = func():
return Input.is_action_pressed("up") or Input.is_action_pressed("down") or Input.is_action_pressed("left") or Input.is_action_pressed("right")
var idle_to_walk_transition: StateTransition = StateTransition.new(PlayerConsts.STATE_IDLE, PlayerConsts.STATE_WALK, idle_to_walk_condition, 5)
state_machine.add_transition(idle_to_walk_transition)
# 从 walk 到 idle:没有移动输入时
var walk_to_idle_condition: Callable = func():
return not (Input.is_action_pressed("up") or Input.is_action_pressed("down") or Input.is_action_pressed("left") or Input.is_action_pressed("right"))
var walk_to_idle_transition: StateTransition = StateTransition.new(PlayerConsts.STATE_WALK, PlayerConsts.STATE_IDLE, walk_to_idle_condition, 5)
state_machine.add_transition(walk_to_idle_transition)
# 从 attack 到 walk:攻击结束且有移动输入时
var attack_to_walk_condition: Callable = func(): return move_direction != Vector2.ZERO
var attack_to_walk_transition: StateTransition = StateTransition.new(PlayerConsts.STATE_ATTACK, PlayerConsts.STATE_WALK, attack_to_walk_condition, 5)
state_machine.add_transition(attack_to_walk_transition)
# 从 attack 到 idle:攻击结束且没有移动输入时
var attack_to_idle_condition: Callable = func(): return move_direction == Vector2.ZERO
var attack_to_idle_transition: StateTransition = StateTransition.new(PlayerConsts.STATE_ATTACK, PlayerConsts.STATE_IDLE, attack_to_idle_condition, 5)
state_machine.add_transition(attack_to_idle_transition)
最后,别忘了在 _ready 函数中调用这个设置方法,确保在状态机启动前所有规则都已注册:
GDScript
# 初始化函数
func _ready() -> void:
# 添加转换规则
_setup_transitions()
# 启动状态机
state_machine.start()
最终测试
一切准备就绪!现在运行游戏,你会发现角色的行为和之前完全一样,可以流畅地移动、站立和攻击。

但这一次,我们的代码结构已经焕然一新。查看控制台输出的日志,可以看到角色正在我们定义的规则下,于各个状态间精准地切换。

本节小结
在本节中,我们将上一章设计的有限状态机架构成功应用到了项目中,彻底重构了玩家的控制逻辑。我们不仅完成了代码层面的迁移,更重要的是,我们建立了一套清晰、可扩展的角色行为管理框架。
核心实践回顾
- 状态机集成:将通用的状态机脚本和节点集成到玩家场景中,并完成了初始化配置。
- 状态实现 :为"待机"、"行走"和"攻击"创建了各自独立的状态脚本,将原本耦合在
player.gd中的逻辑进行了解耦和拆分。 - 规则驱动的转换 :利用
StateTransition定义了清晰的状态切换规则,实现了状态的自动、智能切换,彻底告别了复杂的if-else嵌套。 - 代码重构 :通过移除旧的逻辑和变量,让
player.gd变得更加轻量,只负责维护通用的属性和方法,而将具体的状态行为全权委托给状态机。
通过这次实践,我们不仅让代码变得更易于维护和扩展,也为未来添加更多复杂功能(如受伤、死亡、翻滚等)打下了坚实的基础。
下一步
角色控制已经步入正轨,接下来,我们将把目光投向游戏世界的构建。在下一节中,我们将一同学习 Godot 强大的 TileMap(瓦片地图)系统,用它来搭建我们的第一个游戏场景。