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

该文章已生成可运行项目,

从零开始的 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. 代码架构:学习了状态机的三大核心组件:状态机、状态类和状态转换条件类

关键知识点回顾

  • 攻击系统:通过计时器管理攻击持续时间,确保攻击动作的完整性
  • 有限状态机:一种设计模式,将对象的行为分解为有限个状态,每个状态独立管理自己的逻辑
  • 状态转换:通过状态转换条件类管理状态之间的转换规则,支持优先级机制
  • 代码组织:通过状态机模式,实现代码的解耦和模块化,提高可维护性和可扩展性

下一步计划

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

敬请期待!

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

圆周率巴巴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值