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

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

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

实现角色攻击功能

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

制作攻击动画

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

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

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

完成所有方向的攻击动画

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

  • attack_down:向下攻击动画
  • attack_side:横向攻击动画(左右共用)
  • attack_up:向上攻击动画

配置输入映射

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

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

定义攻击规则

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

  1. 触发条件:当角色处于待机状态或移动状态时,按下J键都可以触发攻击动画
  2. 攻击持续时间:玩家的攻击需要持续0.3秒(与动画时长匹配)
  3. 状态限制:在攻击的0.3秒内,玩家无法移动,也无法切换到待机状态

这些规则确保了攻击动作的完整性和游戏体验的流畅性。

实现攻击逻辑

为了满足上面的需求,我们先定义几个成员变量:

GDScript 复制代码
# 攻击持续时长,与动画时长匹配,代表角色攻击需要的时间
var attack_duration: float = 0.3

# 攻击计时器,用来跟踪攻击用了多久
var attack_timer: float = 0.0

# 是否正在攻击
var is_attacking: bool = false

1. 攻击触发逻辑

接着我们在_physics_process中添加攻击判断。攻击开始后,我们需要重置攻击计时器,并且将攻击状态改成true

GDScript 复制代码
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,这样我们就可以在动画播放结束后,继续移动了:

GDScript 复制代码
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中:

yaml 复制代码
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)是一种强大的设计模式,它能够优雅地解决我们上面遇到的问题。

什么是有限状态机?

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

状态机的核心思想

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

状态机的优势

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

  1. 消除大量if-else:每个状态的逻辑独立管理,不再需要复杂的条件判断
  2. 提高代码可维护性:修改一个状态不会影响其他状态
  3. 增强代码可扩展性:添加新状态只需要创建新的状态类
  4. 提升代码可读性:代码结构清晰,逻辑一目了然

有限状态机的架构设计

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

  1. 状态机(StateMachine):负责维护当前状态,控制状态的切换,调用状态的逻辑
  2. 状态(State):每个状态都有自己的逻辑,负责处理自己的状态行为
  3. 状态转换条件(StateTransition):负责维护状态转换的规则和条件

类示意图如下:

核心组件说明

状态机(StateMachine)类

  • 维护当前状态(current_state)、状态集合(states)、状态转换条件(transitions)等
  • 对外提供添加、删除状态、添加状态转换条件等方法
  • 每帧都会:
    1. 调用当前状态的updatephysics_update)方法
    2. 调用自动状态转换方法,检查是否需要转换状态
  • 每当需要转换状态时,状态机会调用当前状态的exit()方法,调用新状态的enter()方法,并且将状态切换为新状态

状态类(State)

  • 该类是状态的抽象基类,所有具体状态都应继承此类
  • 每个状态都有自己的逻辑,负责处理自己的状态行为
  • 状态类需要实现以下方法:
    • enter(params):状态进入时调用
    • exit():状态退出时调用
    • physics_update(delta):状态物理更新时调用
    • update(delta):状态逻辑更新时调用
    • handle_input(event):处理输入事件
    • name:状态名称,用于调试

状态转换条件类(StateTransition)

  • 该类是状态转换条件类,定义状态间的转换规则
  • 每当我们有一个状态转换的条件,我们就需要实例一个状态转换条件类
  • 支持优先级设置,当多个转换条件同时满足时,选择优先级最高的

具体实现代码

下面我们来看看具体的实现代码:

状态基类

GDScript 复制代码
# State.gd
# 状态基类,所有具体状态都应继承此类
# @author chenrui
# @date 2025-08-31
class_name State
extends RefCounted

# 状态机引用
var state_machine: StateMachine
# 状态名称
var name: String


# 进入状态时调用
# @param _previous_state 前一个状态名称
func enter(_previous_state: String = "") -> 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_owner() -> Node:
	return state_machine.owner if state_machine else null

状态转换条件基类

GDScript 复制代码
# 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

状态机

GDScript 复制代码
# StateMachine.gd
# 核心状态机管理器,负责状态的切换、更新和生命周期管理
# @author chenrui
# @date 2025-08-31
class_name StateMachine
extends Node

# 信号定义
signal state_changed(previous_state: String, new_state: String)
signal state_entered(state_name: String)
signal state_exited(state_name: String)

# 当前状态
var current_state: State
# 所有注册的状态
var states: Dictionary = {}
# 状态转换规则
var transitions: Array[StateTransition] = []
# 是否启用状态机
var enabled: bool = true
# 自动加载基础路径(用户可自定义)
var auto_load_base_path: String = ""


func _init(custom_name: String = "StateMachine") -> void:
	name = custom_name

# 初始化
func _ready() -> void:
	# 设置为单线程处理模式,确保状态切换的原子性
	set_process_mode(Node.PROCESS_MODE_INHERIT)


# 每帧更新
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 = 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(initial_state: String) -> void:
	if initial_state in states:
		_change_state(initial_state)
	else:
		print("StateMachine: 初始状态不存在: " + initial_state)


# 手动切换到指定状态
# @param state_name 目标状态名称
# @param force 是否强制切换(忽略转换规则)
func transition_to(state_name: String, force: bool = false) -> bool:
	if not force:
		# 检查是否有有效的转换规则
		var can_transition: bool = false
		var current_name: String = current_state.name if current_state else ""

		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:
			print("StateMachine: 无有效转换规则从 " + current_name + " 到 " + state_name)
			return false

	return _change_state(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.name if current_state else ""


# 检查是否处于指定状态
# @param state_name 状态名称
# @return 是否处于指定状态
func is_in_state(state_name: String) -> bool:
	return current_state != null and current_state.name == state_name


# 暂停状态机
func pause() -> void:
	enabled = false


# 恢复状态机
func resume() -> void:
	enabled = true


# 内部:执行状态切换
# @param state_name 目标状态名称
# @return 是否切换成功
func _change_state(state_name: String) -> 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.name
		current_state.exit(state_name)
		emit_signal("state_exited", previous_state_name)

	# 进入新状态
	current_state = states[state_name]
	current_state.enter(previous_state_name)

	# 发送信号
	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.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, true)

本节小结

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

主要收获

  1. 攻击系统实现:成功实现了角色的攻击动画和攻击逻辑,包括攻击触发、计时和状态管理
  2. 问题识别:发现了当前代码结构在扩展性、可维护性方面的不足
  3. 设计模式学习:引入了有限状态机这一重要的设计模式,理解了其核心思想和架构设计
  4. 代码架构:学习了状态机的三大核心组件:状态机、状态类和状态转换条件类

关键知识点回顾

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

下一步计划

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

敬请期待!

相关推荐
应用市场4 天前
Godot C++开发指南:正确获取节点的Forward/Up/Right方向向量
c++·游戏引擎·godot
云缘若仙4 天前
Godot游戏开发——C# (一)
c#·godot
陈尕六7 天前
从零开始的 Godot 之旅 — EP8:角色移动和动画切换
godot·游戏开发
宇宙无敌QT拼图糕手7 天前
godot4.4 如何让游戏画面没有透视【正交相机】
godot
Setsuna_F_Seiei8 天前
CocosCreator 游戏开发 - 利用 AssetsBundle 技术对小游戏包体积进行优化
前端·cocos creator·游戏开发
gopyer8 天前
Go语言2D游戏开发入门004:零基础打造射击游戏《太空大战》3
golang·go·游戏开发
芝麻粒儿9 天前
天龙八部TLBB系列 - 客户端技术整体性分析
游戏开发·天龙八部·网单·引擎脚本
UWA12 天前
有什么指标可以判断手机是否降频
人工智能·智能手机·性能优化·memory·游戏开发