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

从零开始的 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 做一些清理和调整,移除旧的、将被状态机替代的逻辑。

  1. 清空 _physics_process 函数 :其中的逻辑将由各个状态脚本接管。

    GDScript 复制代码
    func _physics_process(_delta: float) -> void:
    	pass
  2. 移除旧的攻击相关变量 :attack_duration、attack_timer、is_attacking 将被移入攻击状态(Attack State)中管理。

  3. 更新 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()

最终测试

一切准备就绪!现在运行游戏,你会发现角色的行为和之前完全一样,可以流畅地移动、站立和攻击。

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

本节小结

在本节中,我们将上一章设计的有限状态机架构成功应用到了项目中,彻底重构了玩家的控制逻辑。我们不仅完成了代码层面的迁移,更重要的是,我们建立了一套清晰、可扩展的角色行为管理框架。

核心实践回顾

  1. 状态机集成:将通用的状态机脚本和节点集成到玩家场景中,并完成了初始化配置。
  2. 状态实现 :为"待机"、"行走"和"攻击"创建了各自独立的状态脚本,将原本耦合在 player.gd 中的逻辑进行了解耦和拆分。
  3. 规则驱动的转换 :利用 StateTransition 定义了清晰的状态切换规则,实现了状态的自动、智能切换,彻底告别了复杂的 if-else 嵌套。
  4. 代码重构 :通过移除旧的逻辑和变量,让 player.gd 变得更加轻量,只负责维护通用的属性和方法,而将具体的状态行为全权委托给状态机。

通过这次实践,我们不仅让代码变得更易于维护和扩展,也为未来添加更多复杂功能(如受伤、死亡、翻滚等)打下了坚实的基础。

下一步

角色控制已经步入正轨,接下来,我们将把目光投向游戏世界的构建。在下一节中,我们将一同学习 Godot 强大的 TileMap(瓦片地图)系统,用它来搭建我们的第一个游戏场景。

相关推荐
SmalBox17 小时前
03-06-架构篇-资源依赖与分组架构
unity3d·游戏开发
鑫鑫哥adam21 小时前
一局游戏的时长,凭什么信客户端说的?
unity3d·游戏开发
Behavior1 天前
国内下载使用 Unity 6000 踩坑点总结
c#·unity3d·游戏开发
哈欠兽2 天前
Laya 1.x AS3 游戏开发 以玩家死亡为例子 了解如何通过事件调用函数
游戏开发
SmalBox2 天前
03-05-架构篇-操作异步体系(OperationSystem)
unity3d·游戏开发
SmalBox3 天前
03-04-架构篇-文件系统架构(IFileSystem)
unity3d·游戏开发
狂野小白兔6 天前
AI游戏制作04——Codex 搭配 Godot MCP 全流程教程
人工智能·游戏·godot
甲维斯11 天前
Opus5.5大考!做一个赛车游戏"秋名山车神"
人工智能·游戏开发
鑫鑫哥adam13 天前
一个 1x1 贴图,是怎么炸掉整个热更包的?
unity3d·游戏开发
Behavior14 天前
Unity 手游动态更换 App 图标 — Android 与 iOS 双端技术方案
c#·unity3d·游戏开发