从零开始的 Godot 之旅 — EP9:有限状态机(一)
上一节中我们实现了角色待机和行走的动画,并且配合键盘输入的监听,让角色能在两个动画间切换。本节我们将继续完善角色功能,实现攻击系统,并引入有限状态机这个重要的设计模式来优化我们的代码结构。
实现角色攻击功能
在上一节的课后作业中,我们需要完成攻击动画的制作,并将攻击绑定到J键。现在我们先来实现这个功能,这将为我们后续学习状态机打下基础。
制作攻击动画
先来看下图片,可以发现攻击的动画只有4帧,而行走和待机有6帧。

所以我们创建一个attack_down动画,从第36帧开始,每0.1秒切换一帧,持续0.3秒;这样我们就完成一个向下的攻击动画。

小技巧:当我们的素材是连续的,并且我们需要固定间隔插入关键帧时,我们可以在打开动画编辑器的前提下,在第一帧时直接点击
Frame后面的插入关键帧按钮,这样就会自动插入下一帧到固定间隔。这个技巧可以大大提高动画制作的效率。

完成所有方向的攻击动画
现在我们创建完了所有方向的攻击动画:
- attack_down:向下攻击动画
- attack_side:横向攻击动画(左右共用)
- attack_up:向上攻击动画

配置输入映射
在开始编写脚本之前,我们需要先配置输入映射。进入项目 → 项目设置 → 输入映射,添加一个名为attack的输入动作,并绑定到J键。

不要忘记在项目中配置输入映射,否则无法检测到按键输入。
定义攻击规则
在实现攻击功能之前,我们先确定几个规则:
- 触发条件:当角色处于待机状态或移动状态时,按下J键都可以触发攻击动画
- 攻击持续时间:玩家的攻击需要持续0.3秒(与动画时长匹配)
- 状态限制:在攻击的0.3秒内,玩家无法移动,也无法切换到待机状态
这些规则确保了攻击动作的完整性和游戏体验的流畅性。
实现攻击逻辑
为了满足上面的需求,我们先定义几个成员变量:
# 攻击持续时长,与动画时长匹配,代表角色攻击需要的时间
var attack_duration: float = 0.3
# 攻击计时器,用来跟踪攻击用了多久
var attack_timer: float = 0.0
# 是否正在攻击
var is_attacking: bool = false
1. 攻击触发逻辑
接着我们在_physics_process中添加攻击判断。攻击开始后,我们需要重置攻击计时器,并且将攻击状态改成true:
if !is_attacking and Input.is_action_just_pressed("attack"):
# 只有不处于攻击状态并且按下攻击键时,才进入攻击逻辑
velocity = Vector2.ZERO
is_attacking = true
attack_timer = 0.0
# 其他攻击的逻辑处理
2. 攻击计时器逻辑
接着我们继续添加攻击计时器逻辑:
我们已经知道
_physics_process会在物理帧回调中被调用,它的参数delta是与上一帧的间隔。那么这就是一个很好的计时器,我们只需要每帧加上这个间隔,就能准确跟踪攻击的持续时间。
最后我们还需要在动画播放结束后,将is_attacking设置为false,这样我们就可以在动画播放结束后,继续移动了:
if is_attacking:
# 正在攻击,计时并检查是否攻击结束
attack_timer += delta
if attack_timer >= attack_duration:
# 攻击结束,更新状态
attack_timer = 0.0
is_attacking = false
测试攻击功能
至此我们已经完成了角色攻击的功能。运行游戏,按下J键,可以看到角色成功执行攻击动画:

代码结构的隐患
到现在我们已经完成了角色待机、移动、攻击等功能,看起来一切都很顺利。但是,后续我们还要实现受到攻击、死亡、翻滚、拾取等等功能。试想一下我们的代码要怎么写?
是不是全部都写在player.gd的_physics_process中?
问题示例
如果按照当前的方式继续扩展,我们的代码可能会变成这样,陷入混乱的if-else中:
func _physics_process(delta: float) -> void:
# 移动逻辑
if is_moving:
move_and_slide()
if 更多判断:
更多逻辑
# 攻击逻辑
if is_attacking:
attack()
# 受到攻击逻辑
if is_hit:
hit()
# 死亡逻辑
if is_dead:
die()
# 翻滚逻辑
if is_rolling:
roll()
# 拾取逻辑
if is_picking:
pick()
......
问题分析
这样我们的代码就会变得非常混乱,难以维护。主要问题包括:
- 逻辑耦合:所有状态的逻辑都混在一起,难以分离
- 可维护性差:修改一个状态可能影响其他状态
- 可扩展性差:添加新状态需要修改大量代码
- 可读性差:大量的if-else嵌套让代码难以理解
所以我们需要一种更好的方式来管理我们的代码,这就是有限状态机要解决的问题。
有限状态机
有限状态机(Finite State Machine,简称FSM)是一种强大的设计模式,它能够优雅地解决我们上面遇到的问题。
什么是有限状态机?
有限状态机是一种设计模式,它将对象的状态分为有限个状态,每个状态处理自己的逻辑,并且可以按照预定义的规则互相转换,从而实现对象的行为。
状态机的核心思想
对于我们的玩家角色来说,它同一时间只会处于一种状态,处于每种状态时玩家会执行对应的动作,每种状态之间切换都有固定的规则。就如同下图所示:

状态机的优势
如果我们将每个状态的逻辑都写在对应的脚本中,并且让一个管理员(状态机)来管理状态的切换,那么我们就成功地:
- 消除大量if-else:每个状态的逻辑独立管理,不再需要复杂的条件判断
- 提高代码可维护性:修改一个状态不会影响其他状态
- 增强代码可扩展性:添加新状态只需要创建新的状态类
- 提升代码可读性:代码结构清晰,逻辑一目了然
有限状态机的架构设计
要想实现一个有限状态机,我们至少需要以下几个核心组件:
- 状态机(StateMachine):负责维护当前状态,控制状态的切换,调用状态的逻辑
- 状态(State):每个状态都有自己的逻辑,负责处理自己的状态行为
- 状态转换条件(StateTransition):负责维护状态转换的规则和条件
类示意图如下:

核心组件说明
状态机(StateMachine)类
- 维护当前状态(
current_state)、状态集合(states)、状态转换条件(transitions)等 - 对外提供添加、删除状态、添加状态转换条件等方法
- 每帧都会:
- 调用当前状态的
update(physics_update)方法 - 调用自动状态转换方法,检查是否需要转换状态
- 调用当前状态的
- 每当需要转换状态时,状态机会调用当前状态的
exit()方法,调用新状态的enter()方法,并且将状态切换为新状态 - 状态机支持自动加载状态(目前支持两种模式)
- NODE 模式:状态机自动从子节点中加载状态
- SCRIPT 模式:状态机自动从脚本中加载状态
状态类(State)
- 该类是状态的抽象基类,所有具体状态都应继承此类
- 每个状态都有自己的逻辑,负责处理自己的状态行为
- 状态类需要实现以下方法:
enter(params):状态进入时调用exit():状态退出时调用physics_update(delta):状态物理更新时调用update(delta):状态逻辑更新时调用handle_input(event):处理输入事件
状态转换条件类(StateTransition)
- 该类是状态转换条件类,定义状态间的转换规则
- 每当我们有一个状态转换的条件,我们就需要实例一个状态转换条件类
- 支持优先级设置,当多个转换条件同时满足时,选择优先级最高的
具体实现代码
下面我们来看看具体的实现代码:
状态基类
# State.gd
# 状态基类,所有具体状态都应继承此类
# @author chenrui
# @date 2025-08-31
class_name State
extends Node
# 状态机引用
var state_machine: StateMachine
## 状态名称(可在检查器中配置,NODE 模式优先使用此值,为空则使用节点名称)
@export var state_name: String = ""
# 进入状态时调用
# @param _previous_state 前一个状态名称
# @param _data 数据
func enter(_previous_state: String = "", _data: Dictionary = {}) -> void:
pass
# 退出状态时调用
# @param _next_state 下一个状态名称
func exit(_next_state: String = "") -> void:
pass
# 每帧更新
# @param _delta 帧间隔时间
func update(_delta: float) -> void:
pass
# 物理帧更新
# @param _delta 物理帧间隔时间
func physics_update(_delta: float) -> void:
pass
# 处理输入事件
# @param _event 输入事件
func handle_input(_event: InputEvent) -> void:
pass
# 获取状态机的拥有者(通常是角色节点)
# @return 状态机的拥有者节点
func get_state_owner() -> Node:
return state_machine.owner_node if state_machine else null
# 检查状态是否完成
# @return 是否完成
func is_finished() -> bool:
return true
状态转换条件基类
# StateTransition.gd
# 状态转换条件管理器,定义状态间的转换规则
# @author chenrui
# @date 2025-08-31
class_name StateTransition
extends RefCounted
# 源状态名称("*" 表示任意状态)
var from_state: String
# 目标状态名称
var to_state: String
# 转换条件函数
var condition: Callable
# 转换优先级(数值越大优先级越高)
var priority: int = 0
# 构造函数
# @param _from_state 源状态名称
# @param _to_state 目标状态名称
# @param _condition 转换条件函数
# @param _priority 转换优先级
func _init(
_from_state: String, _to_state: String, _condition: Callable, _priority: int = 0
) -> void:
from_state = _from_state
to_state = _to_state
self.condition = _condition
priority = _priority
# 检查转换条件是否满足
# @param current_state 当前状态名称
# @return 是否可以转换
func can_transition(current_state: String) -> bool:
# 检查源状态是否匹配("*" 匹配任意状态)
if from_state != "*" and from_state != current_state:
return false
# 检查转换条件
if condition.is_valid():
return condition.call()
return false
状态机
# StateMachine.gd
# 核心状态机管理器,负责状态的切换、更新和生命周期管理
# @author chenrui
# @date 2025-08-31
class_name StateMachine
extends Node
# 信号定义
# @signal state_changed 状态改变信号
signal state_changed(previous_state: String, new_state: String)
# @signal state_entered 状态进入信号
signal state_entered(state_name: String)
# @signal state_exited 状态退出信号
signal state_exited(state_name: String)
## 自动加载模式
@export_enum("NODE", "SCRIPT") var auto_load_mode: String = "NODE"
## 自动加载基础路径(仅auto_load_mode为SCRIPT时有效)
@export var auto_load_base_path: String = ""
## 状态机的拥有者节点(用于路径推断和状态访问,如果为空则自动使用父节点)
@export var owner_node: Node = null
## 初始状态
@export var initial_state: String = ""
# 当前状态
var current_state: State
# 所有注册的状态
var states: Dictionary = {}
# 状态转换规则
# TODO author: chenrui for:这个数据结构不够高效,每次判断转换都需要全量遍历 date:2025-11-08
var transitions: Array[StateTransition] = []
# 是否启用状态机
var enabled: bool = true
func _init(custom_name: String = "StateMachine") -> void:
name = custom_name
# 初始化
func _ready() -> void:
# 设置为单线程处理模式,确保状态切换的原子性
set_process_mode(Node.PROCESS_MODE_INHERIT)
# 如果 owner_node 为空,则使用父节点
if owner_node == null:
owner_node = get_parent()
# 自动加载状态
_auto_load_states()
# 每帧更新
func _process(delta: float) -> void:
if not enabled or current_state == null:
return
current_state.update(delta)
_check_transitions()
# 物理帧更新
func _physics_process(delta: float) -> void:
if not enabled or current_state == null:
return
current_state.physics_update(delta)
# 处理输入事件
func _input(event: InputEvent) -> void:
if not enabled or current_state == null:
return
current_state.handle_input(event)
# 添加状态
# @param state_name 状态名称
# @param state 状态实例
func add_state(state_name: String, state: State) -> void:
states[state_name] = state
state.state_machine = self
# 如果状态没有设置 state_name,则使用传入的名称
if state.state_name == "":
state.state_name = state_name
# 移除状态
# @param state_name 状态名称
func remove_state(state_name: String) -> void:
if state_name in states:
states.erase(state_name)
# 添加状态转换规则
# @param transition 转换规则
func add_transition(transition: StateTransition) -> void:
transitions.append(transition)
# 启动状态机
# @param initial_state 初始状态名称
func start(state: String = "") -> void:
if state == "":
state = initial_state
if state in states:
_change_state(state, {})
else:
print("StateMachine: 初始状态不存在: " + state)
# 手动切换到指定状态
# @param state_name 目标状态名称
func transition_to(state_name: String, data: Dictionary = {}, force: bool = false) -> bool:
if not force:
# 检查是否有有效的转换规则
var can_transition: bool = false
var current_name: String = current_state.state_name if current_state else ""
if current_state != null and not current_state.is_finished():
var owner_name: String = str(owner_node.name) if owner_node else "Unknown"
print("[" + owner_name + "] StateMachine: 当前状态[" + current_name + "]未结束,无法转换到 " + state_name)
return false
for transition: StateTransition in transitions:
if transition.can_transition(current_name) and transition.to_state == state_name:
can_transition = true
break
if not can_transition:
var owner_name: String = str(owner_node.name) if owner_node else "Unknown"
print("[" + owner_name + "] StateMachine: 无有效转换规则从 " + current_name + " 到 " + state_name)
return false
return _change_state(state_name, data)
# 自动加载状态(内部方法,在 _ready 时自动调用)
func _auto_load_states() -> void:
var loaded_states: Dictionary = {}
if auto_load_mode == "NODE":
loaded_states = StateLoader.load_states_from_nodes(self)
elif auto_load_mode == "SCRIPT":
# 使用 owner_node(如果为空则已自动设置为父节点)
loaded_states = StateLoader.load_states_from_owner(owner_node, auto_load_base_path)
for state_name: String in loaded_states.keys():
add_state(state_name, loaded_states[state_name])
# 自动加载状态(保留此方法以兼容旧代码,但建议使用自动加载)
# @param object_name 对象名称
func auto_load_states(object_name: String) -> void:
# 确定加载路径
var base_path: String = auto_load_base_path
var loaded_states: Dictionary = StateLoader.load_states(object_name, base_path)
for state_name: String in loaded_states.keys():
add_state(state_name, loaded_states[state_name])
# 获取当前状态名称
# @return 当前状态名称
func get_current_state_name() -> String:
return current_state.state_name if current_state else ""
# 检查是否处于指定状态
# @param state_name 状态名称
# @return 是否处于指定状态
func is_in_state(state_name: String) -> bool:
return current_state != null and current_state.state_name == state_name
# 暂停状态机
func pause() -> void:
enabled = false
# 恢复状态机
func resume() -> void:
enabled = true
# 内部:执行状态切换
# @param state_name 目标状态名称
# @param data 数据
# @return 是否切换成功
func _change_state(state_name: String, data: Dictionary = {}) -> bool:
if not state_name in states:
print("StateMachine: 状态不存在: " + state_name)
return false
var previous_state_name: String = ""
# 退出当前状态
if current_state != null:
previous_state_name = current_state.state_name
current_state.exit(state_name)
emit_signal("state_exited", previous_state_name)
# 进入新状态
current_state = states[state_name]
current_state.enter(previous_state_name, data)
# 发送信号
emit_signal("state_entered", state_name)
emit_signal("state_changed", previous_state_name, state_name)
print("StateMachine: 状态切换: " + previous_state_name + " -> " + state_name)
return true
# 内部:检查自动状态转换
func _check_transitions() -> void:
if current_state == null:
return
var current_name: String = current_state.state_name
var best_transition: StateTransition = null
var highest_priority: int = -999999
# 查找优先级最高的可执行转换
for transition: StateTransition in transitions:
if transition.can_transition(current_name) and transition.priority > highest_priority:
best_transition = transition
highest_priority = transition.priority
# 执行转换
if best_transition != null:
transition_to(best_transition.to_state)
状态自动加载器
# StateLoader.gd
# 状态自动加载器,用于扫描和加载状态脚本
# @author chenrui
# @date 2025-08-31
class_name StateLoader
extends RefCounted
# 状态脚本基础路径
const BASE_PATH: String = "res://scenes/entities/"
# 从子节点加载状态(NODE 模式)
# @param state_machine_node 状态机节点
# @return 状态字典 {state_name: State实例}
static func load_states_from_nodes(state_machine_node: Node) -> Dictionary:
var states: Dictionary = {}
for child: Node in state_machine_node.get_children():
if child is State:
var state: State = child as State
# 确定状态名称:优先使用检查器配置的 state_name,为空则使用节点名称
var state_name: String = state.state_name
if state_name == "":
state_name = state.name
# 如果 state_name 仍然为空,跳过此状态
if state_name == "":
print("StateLoader: 跳过未命名的状态节点: ", child.get_path())
continue
states[state_name] = state
print("StateLoader: 从节点加载状态 - ", state_name)
print("StateLoader: 成功从节点加载 " + str(states.size()) + " 个状态")
return states
# 自动加载状态(从 owner 节点自动推断路径)
# @param owner 状态机的拥有者节点
# @param custom_base_path 自定义基础路径(可选,如果为空则从 owner 路径推断)
# @return 状态字典 {state_name: State实例}
static func load_states_from_owner(owner: Node, custom_base_path: String = "") -> Dictionary:
var base_path: String = custom_base_path
# 如果没有设置路径,尝试从 owner 的文件路径推断
if base_path == "" and owner != null:
var owner_scene_path: String = owner.scene_file_path
if owner_scene_path != "":
# 从场景路径推断脚本路径
# 例如: res://scenes/entities/player/player.tscn -> res://scenes/entities/player/scripts/states/
var scene_dir: String = owner_scene_path.get_base_dir()
base_path = scene_dir + "/scripts/states/"
print("StateLoader: 自动推断状态脚本路径: ", base_path)
# 如果仍然没有路径,尝试使用默认规则
if base_path == "" and owner != null:
var object_name: String = owner.name.to_lower()
return load_states(object_name, base_path)
# 使用指定路径加载状态
if base_path != "":
# 从路径中提取对象名称(用于状态名称提取)
var object_name: String = ""
if owner != null:
object_name = owner.name.to_lower()
return load_states_from_path(object_name, base_path)
else:
print("StateLoader: 无法确定状态脚本路径,请设置 custom_base_path 或确保 owner 有 scene_file_path")
return {}
# 自动加载指定对象的所有状态
# @param object_name 对象名称(如 "player", "enemy")
# @param custom_base_path 自定义基础路径(可选)
# @return 状态字典 {state_name: State实例}
static func load_states(object_name: String, custom_base_path: String = "") -> Dictionary:
var states: Dictionary = {}
# 确定搜索路径
var search_paths: Array[String] = []
# 如果提供了自定义路径,优先使用
if custom_base_path != "":
search_paths.append(custom_base_path + "/states/")
else:
# 默认规则:从entities路径加载
search_paths.append(BASE_PATH + object_name + "/scripts/states/")
# 查找存在的路径
var path: String = ""
for search_path in search_paths:
if DirAccess.dir_exists_absolute(search_path):
path = search_path
break
if path == "":
print("StateLoader: 状态目录不存在,尝试的路径: ", search_paths)
return states
return load_states_from_path(object_name, path)
# 从指定路径加载状态
# @param object_name 对象名称
# @param states_path 状态目录路径
# @return 状态字典 {state_name: State实例}
static func load_states_from_path(object_name: String, states_path: String) -> Dictionary:
var states: Dictionary = {}
print("StateLoader: 从路径加载状态 - ", states_path)
var dir: DirAccess = DirAccess.open(states_path)
if dir == null:
print("StateLoader: 无法打开目录: " + states_path)
return states
dir.list_dir_begin()
var file_name: String = dir.get_next()
while file_name != "":
# 只处理 .gd 文件
if file_name.ends_with(".gd") and file_name.ends_with("_state.gd"):
var state_name: String = _extract_state_name(file_name, object_name)
if state_name != "":
var state_instance: State = _load_state_script(states_path + file_name, state_name)
if state_instance != null:
states[state_name] = state_instance
print("StateLoader: 从路径加载状态 - " + state_name)
file_name = dir.get_next()
dir.list_dir_end()
print("StateLoader: 成功从路径加载 " + str(states.size()) + " 个状态")
return states
# 从文件名提取状态名称
# @param file_name 文件名
# @param object_name 对象名称
# @return 状态名称
static func _extract_state_name(file_name: String, object_name: String) -> String:
# 文件名格式: {object}_{state}_state.gd
# 例如: player_idle_state.gd -> idle
var pattern: String = object_name + "_(.+)_state\\.gd$"
var regex: RegEx = RegEx.new()
regex.compile(pattern)
var result: RegExMatch = regex.search(file_name)
if result:
return result.get_string(1)
return ""
# 加载状态脚本并创建实例
# @param script_path 脚本路径
# @param state_name 状态名称
# @return 状态实例
static func _load_state_script(script_path: String, state_name: String) -> State:
var script: Script = load(script_path)
if script == null:
print("StateLoader: 无法加载脚本: " + script_path)
return null
var instance: Node = script.new()
# 检查实例是否有State类的基本方法
if not instance.has_method("enter") or not instance.has_method("exit"):
print("StateLoader: 脚本不是State的子类: " + script_path)
return null
# 设置状态名称到 state_name 属性
if instance is State:
var state: State = instance as State
state.state_name = state_name
instance.name = state_name # 同时设置节点名称以便识别
return instance as State
PS: 代码可能会随着我学习的深入而修改,最新的代码可以在Gitee上找到。
本节小结
在本节中,我们首先完成了角色攻击功能的实现,然后发现了当前代码结构存在的问题,最后引入了有限状态机这个重要的设计模式来解决这些问题。
主要收获
- 攻击系统实现:成功实现了角色的攻击动画和攻击逻辑,包括攻击触发、计时和状态管理
- 问题识别:发现了当前代码结构在扩展性、可维护性方面的不足
- 设计模式学习:引入了有限状态机这一重要的设计模式,理解了其核心思想和架构设计
- 代码架构:学习了状态机的三大核心组件:状态机、状态类和状态转换条件类
关键知识点回顾
- 攻击系统:通过计时器管理攻击持续时间,确保攻击动作的完整性
- 有限状态机:一种设计模式,将对象的行为分解为有限个状态,每个状态独立管理自己的逻辑
- 状态转换:通过状态转换条件类管理状态之间的转换规则,支持优先级机制
- 代码组织:通过状态机模式,实现代码的解耦和模块化,提高可维护性和可扩展性
下一步计划
下一节我们将在我们的项目中实际使用有限状态机,并且用状态机改造我们玩家的脚本。这将让我们的代码结构更加清晰,也为后续添加更多功能(如受击、死亡、翻滚等)打下坚实的基础。
敬请期待!
820

被折叠的 条评论
为什么被折叠?



