从零开始的 Godot 之旅 — EP9:有限状态机(一)

148 阅读18分钟

从零开始的 Godot 之旅 — EP9:有限状态机(一)

上一节中我们实现了角色待机和行走的动画,并且配合键盘输入的监听,让角色能在两个动画间切换。本节我们将继续完善角色功能,实现攻击系统,并引入有限状态机这个重要的设计模式来优化我们的代码结构。

实现角色攻击功能

在上一节的课后作业中,我们需要完成攻击动画的制作,并将攻击绑定到J键。现在我们先来实现这个功能,这将为我们后续学习状态机打下基础。

制作攻击动画

先来看下图片,可以发现攻击的动画只有4帧,而行走和待机有6帧。

alt text

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

alt text

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

alt text

完成所有方向的攻击动画

现在我们创建完了所有方向的攻击动画:

  • attack_down:向下攻击动画

  • attack_side:横向攻击动画(左右共用)

  • attack_up:向上攻击动画

alt text

配置输入映射

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

alt text

不要忘记在项目中配置输入映射,否则无法检测到按键输入。

定义攻击规则

在实现攻击功能之前,我们先确定几个规则:

  1. 触发条件:当角色处于待机状态或移动状态时,按下J键都可以触发攻击动画

  2. 攻击持续时间:玩家的攻击需要持续0.3秒(与动画时长匹配)

  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键,可以看到角色成功执行攻击动画:

alt text

代码结构的隐患

到现在我们已经完成了角色待机、移动、攻击等功能,看起来一切都很顺利。但是,后续我们还要实现受到攻击、死亡、翻滚、拾取等等功能。试想一下我们的代码要怎么写?

是不是全部都写在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()

......

问题分析

这样我们的代码就会变得非常混乱,难以维护。主要问题包括:

  1. 逻辑耦合:所有状态的逻辑都混在一起,难以分离

  2. 可维护性差:修改一个状态可能影响其他状态

  3. 可扩展性差:添加新状态需要修改大量代码

  4. 可读性差:大量的if-else嵌套让代码难以理解

所以我们需要一种更好的方式来管理我们的代码,这就是有限状态机要解决的问题。

有限状态机

有限状态机(Finite State Machine,简称FSM)是一种强大的设计模式,它能够优雅地解决我们上面遇到的问题。

什么是有限状态机?

有限状态机是一种设计模式,它将对象的状态分为有限个状态,每个状态处理自己的逻辑,并且可以按照预定义的规则互相转换,从而实现对象的行为。

状态机的核心思想

对于我们的玩家角色来说,它同一时间只会处于一种状态,处于每种状态时玩家会执行对应的动作,每种状态之间切换都有固定的规则。就如同下图所示:

alt text

状态机的优势

如果我们将每个状态的逻辑都写在对应的脚本中,并且让一个管理员(状态机)来管理状态的切换,那么我们就成功地:

  1. 消除大量if-else:每个状态的逻辑独立管理,不再需要复杂的条件判断

  2. 提高代码可维护性:修改一个状态不会影响其他状态

  3. 增强代码可扩展性:添加新状态只需要创建新的状态类

  4. 提升代码可读性:代码结构清晰,逻辑一目了然

有限状态机的架构设计

要想实现一个有限状态机,我们至少需要以下几个核心组件:

  1. 状态机(StateMachine):负责维护当前状态,控制状态的切换,调用状态的逻辑

  2. 状态(State):每个状态都有自己的逻辑,负责处理自己的状态行为

  3. 状态转换条件(StateTransition):负责维护状态转换的规则和条件

类示意图如下:

alt text

核心组件说明

状态机(StateMachine)类

  • 维护当前状态(current_state)、状态集合(states)、状态转换条件(transitions)等

  • 对外提供添加、删除状态、添加状态转换条件等方法

  • 每帧都会:

  1. 调用当前状态的updatephysics_update)方法

  2. 调用自动状态转换方法,检查是否需要转换状态

  • 每当需要转换状态时,状态机会调用当前状态的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上找到。

本节小结

在本节中,我们首先完成了角色攻击功能的实现,然后发现了当前代码结构存在的问题,最后引入了有限状态机这个重要的设计模式来解决这些问题。

主要收获

  1. 攻击系统实现:成功实现了角色的攻击动画和攻击逻辑,包括攻击触发、计时和状态管理

  2. 问题识别:发现了当前代码结构在扩展性、可维护性方面的不足

  3. 设计模式学习:引入了有限状态机这一重要的设计模式,理解了其核心思想和架构设计

  4. 代码架构:学习了状态机的三大核心组件:状态机、状态类和状态转换条件类

关键知识点回顾

  • 攻击系统:通过计时器管理攻击持续时间,确保攻击动作的完整性

  • 有限状态机:一种设计模式,将对象的行为分解为有限个状态,每个状态独立管理自己的逻辑

  • 状态转换:通过状态转换条件类管理状态之间的转换规则,支持优先级机制

  • 代码组织:通过状态机模式,实现代码的解耦和模块化,提高可维护性和可扩展性

下一步计划

下一节我们将在我们的项目中实际使用有限状态机,并且用状态机改造我们玩家的脚本。这将让我们的代码结构更加清晰,也为后续添加更多功能(如受击、死亡、翻滚等)打下坚实的基础。

敬请期待!