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

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

在上一章中,我们详细设计了有限状态机(FSM)的底层架构。理论终须实践,本节我们将正式在项目中应用这套状态机,并通过改造玩家(Player)场景,将枯燥的代码设计化为生动的角色行为。

新建目录

我们首先来调整一下项目结构,为接下来的状态机实现做好准备。

res://common/目录下创建state_machine/目录。

没错,我确实删除了原先的 script 根目录。自从在 EP8 中将玩家相关的脚本和资源整合到其场景目录下后,我愈发觉得一个笼统的 script 目录意义不大。Godot 与 Java 或 C# 不同,它不以脚本为核心,脚本本身也不具备严格的"包(package)"概念。因此,将所有脚本集中存放,反而会导致我们在场景和脚本目录间频繁切换,既浪费时间也不利于组件的迁移。所以,后续我们的项目结构可能会更多地以"场景"为核心,而非"文件类型"。(至少目前是这么想的)

state_machine目录中,我们依次创建state_machine.gdstate.gdstate_transition.gdstate_loader.gd这四个文件,并将上一节中提供的代码分别粘贴进去。

请特别注意它们的基类:state_machinestate 继承自 Node,而 state_transitionstate_loader 继承自 RefCounted

为什么基类不同?这是因为 StateMachineState 作为场景的一部分需要被挂载到节点树上,而 StateTransitionStateLoader 只是用于承载逻辑和数据的辅助类,无需成为场景节点,使用轻量级的 RefCounted 可以有效管理内存。

在新建 state_transition 脚本时,可以直接在Godot的创建脚本窗口选择其基类 RefCounted:

至此,我们通用的状态机模块就创建好了。

最新的代码请查阅Gitee。我有时可能更新了代码但未能及时同步博客,所以最好还是以Gitee上的为准。

改造玩家场景

基础的状态机框架搭建完毕,现在是时候改造我们的玩家场景,让它真正"动"起来了。

我们打开玩家场景,在根节点(CharacterBody2D)下添加一个 StateMachine 节点,并将其命名为StateMachine

在检查器中设置该 StateMachine 节点的属性:

  • Auto Load Mode 设置为 NODE,这样状态机会自动加载其子节点作为状态。
  • Initial State 设置为 idle,这是我们希望的初始状态。

你可能会好奇,为什么我们自己写的脚本类(StateMachine)能像内置节点一样被添加到场景中?答案就在于继承。因为我们的 StateMachine 类继承自 Node,而 Godot 中所有 Node 都可以被添加到场景树里。这意味着,我们可以通过继承 Godot 提供的任意节点类来创造功能丰富的自定义节点!

创建状态节点

我们之前为玩家设计了 idlewalkattack 三个动画,现在需要为它们分别创建对应的状态节点。

首先,我们来创建 idle 状态。在 StateMachine 节点下添加一个 State 节点,将其命名为 idle,并在检查器中将它的 State Name 属性也设置为 idle

接着,用同样的方法创建 walkattack 两个状态节点,并分别设置好它们的命名和 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_durationattack_timeris_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)则用于解决当多个转换规则同时满足时,应优先执行哪一个的问题(数值越大,优先级越高)。

我们以 idlewalk 的转换为例:

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 定义了从 idlewalk 的转换,优先级为 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(瓦片地图)系统,用它来搭建我们的第一个游戏场景。

相关推荐
祭璃妖1 天前
关于2D人物冒险游戏闪现逻辑的实现方法
游戏开发
专注VB编程开发20年5 天前
游戏开发入门,简单小游戏原理-关于2D渲染的一些小想法
小游戏·游戏开发
陈尕六7 天前
从零开始的 Godot 之旅 — EP9:有限状态机(一)
godot·游戏开发
应用市场11 天前
Godot C++开发指南:正确获取节点的Forward/Up/Right方向向量
c++·游戏引擎·godot
云缘若仙11 天前
Godot游戏开发——C# (一)
c#·godot
陈尕六14 天前
从零开始的 Godot 之旅 — EP8:角色移动和动画切换
godot·游戏开发