第九章:游戏中的有限状态机
本章导言
在游戏开发中,我们经常会遇到这样的问题:一个角色在奔跑时能否立即跳跃?敌人巡逻中该如何切换到追击?菜单界面如何在游戏中与主逻辑协同工作?这些看似平常的行为切换,其实都可以归纳为“状态”的变化。
有限状态机(Finite State Machine,简称FSM)正是解决这类问题的经典方法。它通过将对象的行为划分为若干“状态”,并定义各状态之间的转换规则,让游戏逻辑变得更加清晰、可控、可维护。你可以把FSM想象成一个行为的交通指挥员,每次只允许系统处于某一个状态,并明确指出何时、因何、往哪一个状态切换。
本章将带你深入理解有限状态机的原理,并结合具体的游戏开发案例,手把手实现两种常见的FSM构建方式:
- 一种是基于状态变量的简单实现,适合状态不多、逻辑较轻的对象;
- 另一种是基于节点与脚本模块化的状态模式实现,更适合中大型项目中的角色AI与流程控制。
我们将以“坦克巡逻与追击”为例,完整构建FSM逻辑,帮助你掌握如何在 Godot 引擎中优雅地实现复杂行为的切换。通过本章学习,你将不仅理解状态机的基础理论,更能将它应用在实际游戏项目中,包括角色控制、敌人AI、任务系统与UI流程等。
1 什么是有限状态机
1.1 基本概念
在游戏开发中,我们经常需要让角色、敌人、任务或界面根据不同的情况做出不同反应。例如:玩家站着不动时播放待机动画,按下跳跃键后角色跳起,落地后又回到待机。如何优雅地管理这种“行为切换”?这正是有限状态机(Finite State Machine, FSM)擅长的事情。
有限状态机是一种行为建模方式,它将系统的运行过程划分为若干个有限的状态(State),每次只能处于其中之一。系统根据外部输入或内部事件触发状态转换(Transition),从一个状态切换到另一个状态,并在状态之间执行特定的动作(Action)。
FSM的核心要素:
| 组件 | 英文 | 含义说明 | 示例 |
|---|---|---|---|
| 状态 | State | 系统当前的行为模式 | 待机、奔跑、受击 |
| 转换 | Transition | 状态切换的路径 | 待机 → 奔跑 |
| 事件/条件 | Event/Condition | 触发转换的“开关” | 按下方向键、生命值归零 |
| 动作 | Action | 在进入、退出或处于状态时执行的逻辑 | 播放动画、扣除体力 |
1.2 直观理解
为了直观理解 FSM,我们来看两个例子。
示例 A:游戏角色控制。假设一个基础的角色控制器,它有三个动作状态,分别是待机(Idle)、奔跑(Run)、跳跃(Jump)。状态之间的转换是由特定条件或输入触发的。我们可以用一个状态图来表示它们之间的关系:
stateDiagram-v2
[*] --> Idle: 默认进入
Idle --> Run: 按下方向键
Run --> Idle: 松开方向键
Idle --> Jump: 按下跳跃键
Run --> Jump: 按下跳跃键
Jump --> Idle: 落地
示例 B:学生的一天。FSM 不仅存在于代码中,也存在于我们的日常逻辑中。
-
学习状态: 执行“听课”、“笔记”动作。转换条件:下课铃响 → 休息状态。
-
休息状态: 执行“放松”、“饮水”动作。转换条件:体育课开始 → 锻炼状态。
-
锻炼状态: 执行“跑步”、“流汗”动作。转换条件:锻炼结束 → 学习状态。
1.3 核心应用场景
有限状态机(FSM)的应用广度决定了它是开发者必须掌握的“武器”,其核心价值在于将复杂的逻辑链条拆解为互斥且清晰的独立单元:
-
在角色动作控制方面,FSM 是处理动作衔接的行业标准。它将“待机”、“奔跑”、“跳跃”等动作定义为互斥状态,通过监听玩家按键或环境变化(如碰撞地表)来触发转换。这种机制能确保角色在每一时刻只能执行一种逻辑、播放一段动画,从根本上规避了逻辑重叠导致的异常表现。
-
在敌人 AI 行为设计中,FSM 为 NPC 赋予了基础智能。开发者可以将守卫的行为拆分为巡逻、警觉、追击和返回等模块,使 AI 能够根据感知系统的输出(如视觉传感器发现玩家)在不同行为模式间自然切换,从而实现逻辑的高度解耦与模块化。
-
在游戏流程管理层面,FSM 常用于控制游戏的宏观运行阶段。从主菜单到加载页面,再到游戏进行中与暂停界面,每个阶段都被视为一个独立状态。利用 FSM 可以集中管理不同阶段的功能开关,例如在暂停状态下统一锁定玩家输入并停滞物理世界更新,确保流程运行的稳定性。
-
在任务系统追踪中,FSM 负责管理任务从“未接取”到“进行中”再到“已交付”的全生命周期。通过将每个任务阶段设定为固定状态,开发者可以轻松实现任务目标的自动追踪,并确保 NPC 的对话内容与当前任务进度保持实时同步,防止出现剧情逻辑的跳跃或混乱。
1.4 为什么需要 FSM?
许多初学者在刚开始开发游戏逻辑时,往往选择使用大量的条件语句和布尔变量(如 is_jumping、is_running)来控制角色行为。乍一看这种方式也能实现效果,但当状态数量一多,问题就会接踵而至:
-
状态管理混乱:每种行为都需要一个或多个布尔变量控制,不同变量之间容易冲突。例如角色在跳跃过程中还能不能攻击?变量之间的配合越来越难维护。
-
逻辑判断冗长且重复:你需要在每一帧里检查大量条件,判断当前角色能不能做某件事,而这些判断可能在不同的地方被重复书写。
-
难以扩展新状态:比如你想添加一个“滑翔”状态,你不仅要新增变量,还要到每一处逻辑中加入新的条件判断,极易遗漏。
-
调试困难:一旦出现角色行为错乱(比如“又在跳又在蹲”),你很难快速定位到底是哪个布尔变量组合出了问题。
这种方式在状态非常少、逻辑极其简单的情况下勉强可以使用。但一旦你的角色或系统行为稍微复杂一点,就很容易陷入“布尔地狱”和“条件混战”。此时 FSM 能帮你把复杂的状态逻辑梳理得清清楚楚。
相比之下,有限状态机(FSM)通过将每个状态和状态之间的转移规则明确地定义出来,解决了上述问题。FSM的优点主要体现在以下几个方面:
- 清晰简洁:FSM模型通常使系统行为逻辑更加简单且易于理解,因为状态和转换是有限且明确的。开发者和读者可以直观看出系统可能的状态及切换情况。
- 模块化设计:通过FSM可以把复杂的行为拆分到独立的状态模块中,各状态的代码井井有条,互不干扰。这使得大型复杂系统的开发和管理更加轻松。
- 易于调试:由于状态转换路径明确,开发者可以逐步跟踪系统从一个状态到另一个状态的过程,更容易发现和修复问题。出现错误时,可以根据当前状态迅速定位相关代码。
- 行为可预测:每个状态的功能是预先定义好的,系统对相同事件的反应在相同状态下是一致的。这种确定性让游戏中的角色或对象行为更加可预测,减少意外情况。
- 扩展性好:引入新状态通常不会影响已有状态的内部实现。例如,可以在角色FSM中新增一个“滑翔”状态,而无需改动其他状态的代码逻辑。这使得添加新功能或行为相对容易,系统具有良好的可扩展性。
虽然有限状态机是实现逻辑解耦与行为预测的利器,但它并非万能,开发者需根据系统特性权衡其局限性。由于 FSM 具有离散且排他的结构,在处理物理模拟或动画混合等连续性数据系统时往往缺乏灵活性;同时,当面对需要多个状态叠加(如“移动+中毒+隐身”)的复杂情况时,纯粹的 FSM 会陷入“状态爆炸”的困境,使维护难度指数级上升。针对这些痛点,若系统涉及复杂的逻辑嵌套,可引入层次状态机(HFSM)来精简转换路径;若行为切换条件极其模糊或依赖大量动态权重,行为树(Behavior Tree)等高级架构将更具扩展性。总而言之,FSM 仅在具备离散行为阶段、明确转换规则及状态排他性的场景下才能发挥最大优势。
1.5 两种实现方式对比
在 Godot 中实现FSM有两种主要方式。第一种是使用枚举值作为状态变量配合条件判断。第二种是为每个状态创建独立的节点(状态模式)。
| 对比维度 | 状态变量 (Simple FSM) | 状态模式 (State Pattern) |
|---|---|---|
| 实现机制 | 基于 enum (枚举) 和 match/if 分支 |
基于 面向对象 的节点/脚本拆分 |
| 代码结构 | 所有状态逻辑集中在单一脚本中 | 每个状态是一个独立的 .gd 脚本和节点 |
| 适用场景 | 状态少(<3个)、原型开发、简单逻辑 | 复杂 AI、动作游戏角色、中大型项目 |
| 可维护性 | 差。脚本会迅速膨胀,形成“巨型类” | 优。逻辑高度解耦,每个文件只负责一件事 |
| 扩展性 | 低。新增状态需修改多处判断逻辑 | 高。只需添加新节点并修改切换信号 |
| 复用性 | 难。逻辑与具体对象高度绑定 | 易。状态脚本可跨对象复用(如通用的巡逻状态) |
| 上手门槛 | 极低。符合初学者直觉 | 中等。需要理解类继承、信号与节点树结构 |
选择“状态变量”还是“状态模式”,主要取决于逻辑的复杂程度与复用需求:前者适用于快速原型开发或状态极简且无需复用的场景;而当状态数量增加(通常超过5个)、逻辑变得臃肿难以调试,或者需要在多个不同对象间共享行为模块时,升级为高度解耦、易于扩展的“状态模式”则是确保中大型项目稳定运行的必然选择。
下面我们会基于《Battle Tank》游戏,分别使用这两种实现方式。读者可以清晰的对比理解它们的差异和优缺点。
2 初阶:使用状态变量
对于状态较少、逻辑简单的对象,最直接的实现方式是在单一脚本中使用状态变量。通过定义枚举(Enum)来穷举所有状态,并在每帧根据变量值执行对应的逻辑分支。
2.1 核心逻辑演示
以下是玩家角色在“待机、奔跑、跳跃”三状态下的基础实现框架:
extends CharacterBody2D
enum PlayerState { IDLE, RUN, JUMP }
var state = PlayerState.IDLE # 当前状态
func _physics_process(delta):
# 根据当前状态执行不同的游戏逻辑
match state:
PlayerState.IDLE:
# 待机状态逻辑(例如保持静止,播放待机动画)
PlayerState.RUN:
# 奔跑状态逻辑(例如根据输入移动角色,播放跑步动画)
PlayerState.JUMP:
# 跳跃状态逻辑(例如应用向上速度,播放跳跃动画)
# 根据条件触发状态转换
if state == PlayerState.IDLE and Input.is_action_just_pressed("jump"):
state = PlayerState.JUMP # 从待机切换到跳跃
elif state == PlayerState.JUMP and is_on_floor():
state = PlayerState.IDLE # 着地后切回待机
在这个示例中,我们使用state变量来追踪角色当前状态,并在每帧根据状态执行相应逻辑。同时,通过判断输入和角色状态来更新 state 实现状态转换。这样一来,所有可能的状态变化条件都集中在同一个位置处理,我们只需关注和维护一个状态变量,逻辑清晰明了
2.2 用状态变量重构敌方坦克 AI
在之前的坦克游戏项目中,敌人坦克只会固定巡逻或在原地开火,不具备对玩家的感知与反应。本节我们用状态变量 FSM 对它进行升级,目标是让它具有以下智能行为:
- 默认情况下在地图中巡逻;
- 当探测到玩家坦克时,切换到追击状态,朝玩家移动并攻击;
- 如果玩家逃离探测范围,则恢复巡逻行为。
我们为敌人定义两个状态:巡逻 Patrol 和 追击 Chase。每一帧中只执行当前状态的行为逻辑,根据感知结果在两者之间切换。
修改敌方坦克
-
首先我们准备来修改敌方坦克场景,我们会实现两个不同版本的FSM,所以先将原有的场景文件enemy_tank.tscn以及代码都复制一份,新场景重命名为enemy_tank_sfsm.tscn。同时将对应的代码进行Detach,然后新附加代码文件enemy_tank_sfsm.gd。
-
坦克的移动不再依赖路径,所以将根节点类型修改为CharacterBody2D。这样可以利用其内置的
move_and_slide()函数移动和碰撞。修改方法是点击右键选择Change Type,然后选择CharacterBody2D。 -
敌方坦克需要设置碰撞层,打开右侧的检查器面板,将Collision Layer设置为7,Collision Mask设置为5,6,7。也就是说它会和地图障碍、玩家坦克和其它坦克进行物理碰撞检测,不会重叠在一起。
-
修改探测组件的功能,打开探测组件的代码,新增代码如下:
var player_pos = null
func find_player():
player_pos = get_player_pos()
if player_pos:
return true
else:
return false
find_player函数将调用原有的get_player_pos函数,来得到玩家坦克的位置,如果player_pos不为空,说明玩家坦克在敌方坦克的探测范围内,返回true,否则返回false。
修改敌方坦克的代码
打开enemy_tank_sfsm.gd,除了常规的节点引用变量之外,需要在代码新增修改如下:
extends CharacterBody2D
enum FSMState {Patrol, Chase} # 定义枚举值,包括巡逻和追击状态
var direction := Vector2.RIGHT # 坦克移动的方向
var points_array :Array # 坦克移动时需要经过的巡逻点
var target_pos : Vector2 # 目标点坐标
var target_index : int = 0 # 索引编号
var player_pos = null # 玩家坦克的位置
var cur_state = FSMState.Patrol # 坦克当前的状态
@export var points: Node2D # 用来收纳巡逻路径点
func get_points():
for point in points.get_children():
points_array.append(point.global_position)
func find_next_point():
if target_index >= points_array.size():
target_index = 0
target_pos = points_array[target_index]
target_index += 1
get_points函数负责遍历points节点下的所有子节点,将子节点的全局位置添加到points_array数组中find_next_point函数负责从points_array数组获取一个巡逻点,并更新target_pos和target_index变量。如果target_index大于等于points_array的大小,说明巡逻路线已经到达最后一个点,将target_index重置为0,target_pos更新为points_array的第一个点。
func update_direction():
direction = global_position.direction_to(target_pos)
var angle_rad = direction.angle()
rotation = angle_rad
func move_to_target():
velocity = transform.x * speed
move_and_slide()
update_direction函数用来更新坦克的移动方向,direction_to是一个内置函数,它会返回一个向量,表示从当前位置到目标位置的方向,angle函数会返回这个向量的角度,然后通过rotation属性来设置坦克的旋转角度。move_to_target函数负责将坦克移动到目标巡逻点。transform.x表示了坦克的x轴方向,也就是正向朝向,speed是坦克的移动速度,move_and_slide函数会根据坦克的旋转角度和速度来移动坦克。
func near_point():
if global_position.distance_to(target_pos) < 5:
return true
else:
return false
func find_player():
player_pos = detect_component.get_player_pos()
if player_pos:
return true
else:
return false
near_point函数用来判断是否到达目标巡逻点,distance_to是一个内置函数,它会返回两个向量之间的距离。如果距离小于5个像素单位,返回true,否则返回false。find_player函数用来检测是否有玩家坦克在探测范围内,它利用了探测组件的函数来获取玩家坦克的位置,玩家坦克位置不为空,返回true,否则返回false。
func update_patrol():
update_direction()
move_to_target()
if near_point():
find_next_point()
if find_player():
cur_state = FSMState.Chase
update_patrol函数负责坦克牌巡逻状态时的行为逻辑,它会更新坦克的移动方向,然后移动到目标巡逻点,如果到达目标巡逻点,就寻找下一个巡逻点,如果在巡逻过程中,发现玩家坦克,就切换到追击状态。
func attack_player():
weapon_component.target(player_pos)
weapon_component.shoot(player_pos)
func update_chase():
if find_player():
target_pos = player_pos
update_direction()
move_to_target()
attack_player()
else:
cur_state = FSMState.Patrol
update_chase函数负责坦克处于追击状态时的行为逻辑。如果发现玩家坦克,就将玩家位置更新为目标位置,同时更新坦克的移动方向,移动到目标位置,然后攻击玩家坦克,如果没有发现玩家坦克,就切换到巡逻状态。attack_player函数是调用武器组件的函数对玩家进行瞄准和射击。
func FSM_update():
match cur_state:
FSMState.Patrol:
update_patrol()
FSMState.Chase:
update_chase()
func _physics_process(delta: float) -> void:
FSM_update()
trail_compoment.start()
FSM_update函数是FSM的主循环,使用match关键字来判断当前状态,然后根据状态来执行相应的行为逻辑。- 使用
_physics_process函数来控制坦克的行为,更新逻辑就只有两个函数,一个是FSM_update函数,另一个是trail_compoment.start,它会启动轨迹组件的更新,这样就可以实现坦克的轨迹效果。
func _ready() -> void:
hurt_box_component.get_damage.connect(health_component.get_damage)
health_component.died.connect(on_died)
hurt_box_component.get_damage.connect(on_get_damage)
get_points()
find_next_point()
_ready函数中把get_points和find_next_point函数进行执行,这样一开始敌方坦克就知道巡逻路径了。另外,在代码中要记得保留原来的on_die函数以及on_get_damage函数。
修改游戏主场景
本节是一个实验版本的游戏,只是为了实现一个简单的FSM,所以只有一个敌方坦克,没有其它敌人和管理机制。
打开game场景,删除原有的EnemyManager和PickupManager。也删除Path节点和下面的所有路径。删除Enemies节点下的所有敌方炮台。然后把EnemyTankFSM场景放到Enemies节点下。使用移动工具将它放到地图的道路上。要记得设置EnemyTankFSM的Points属性,将它设置为Points节点。
在根节点下新增一个Node2D节点,名为Points,然后在Points节点下新增四个Marker2D节点,分别用来表示四个巡逻点。这四个点的位置需要放到地图上的四个路口上。具体如下图所示。

然后将Player移到四个巡逻点附近,运行game场景。你会看到敌方坦克会沿着四个巡逻点巡逻,如果你的坦克离它比较近,它会攻击玩家坦克并脱离巡逻进行追击。你可以尝试加速远离,看看敌方坦克会怎么做。
使用状态变量实现FSM的方法相对简单直接,对于状态数量不多的角色或对象非常有效。通过集中管理状态切换条件,添加新状态或修改转换逻辑也很方便。不过,这种实现也有一些局限。为了解决这些问题,我们可以考虑使用更模块化的状态模式来实现FSM。
3 高阶:状态模式
当角色或敌人拥有多个复杂状态,且希望实现模块化、解耦与复用,传统的“状态变量 + 分支判断”方法就不够灵活了。此时,我们可以采用更具扩展性的状态模式(State Pattern)。其核心思想是:为每一个状态创建独立的脚本类,将状态的行为封装在各自模块中,由一个状态机节点来管理当前状态的切换与更新逻辑。
3.1 状态模式的实现方式
状态模式是一种常用于管理复杂对象行为的设计结构,适合状态较多、逻辑分支复杂、需要模块化的场景。状态模式通过将每个状态封装为一个独立节点和脚本,实现代码的职责分离与复用。
常见的实现结构如下:
-
状态机节点(StateMachine):它挂载在主对象(例如敌人坦克)上,负责统一管理所有状态的切换逻辑。状态机记录当前状态,并在每帧调用当前状态的
physics_update()函数,同时监听状态节点发出的transitioned信号,执行切换逻辑。 -
状态基类(State.gd):这是一个抽象的基类脚本,定义了状态的统一接口,如
enter()(进入状态)、exit()(退出状态)和physics_update()(每帧更新)。它通常还会定义一个signal transitioned信号,用于通知状态切换。所有具体状态类都继承自该基类,实现多态调用。 -
具体状态类(如 EnemyPatrol、EnemyAttack):每个状态对应一个脚本和一个节点,挂载为状态机的子节点,封装特定的行为逻辑。状态之间不相互依赖,仅通过信号与状态机通信,保持低耦合结构。
状态类常常需要调用宿主节点(如坦克或敌人角色)的方法,例如 move()、update_direction() 等。推荐做法是在状态类中使用 @export var owner: Node2D 来显式引用宿主节点,并在编辑器中进行设置绑定。
在命名规范方面,所有状态类建议统一命名风格,如 EnemyPatrol, EnemyAttack, EnemyDead 等。每个状态脚本尽量保持职责单一,避免过多逻辑混杂。每个状态只负责内部逻辑,并通过 transitioned.emit(self, "NewStateName") 向状态机发送切换请求。状态机统一判断和切换,避免状态类之间出现交叉控制逻辑。
3.2 坦克游戏中的状态模式实现
之前我们使用状态变量来实现敌方坦克的巡逻和追逐行为,下面我们来使用状态模式进行实现。
创建状态基类
首先我们来创建一个状态类State。在文件系统中Enemy目录下,点击右键,新建一个代码文件,命名为State.gd。
extends Node
class_name State
signal transitioned
func enter():
pass
func exit():
pass
func physics_update(_delta:float):
pass
- 使用
class_name State来标识这是一个状态类 - 定义了信号
transitioned,用来通知状态机状态切换 enter函数用来进入状态时要做的事情exit函数用来退出状态时时要做的事情physics_update函数是用来处理在状态中时要做的事情- 这些函数中都只有
pass,意味着需要通过继承子类来实现这些函数的具体逻辑。
创建状态机类
然后我们来创建一个状态机。新建一个场景,根节点设置为Node,重命名为StateMachine。在具体使用的时候,我们会把所有的状态放在这个StateMachine节点下面。附加代码如下:
extends Node
@export var initial_state: State # 指定初始状态
var current_state: State # 存储当前状态
var states: Dictionary # 存储所有状态
func _ready() -> void:
for child in get_children():
if child is State:
states[child.name] = child
child.transitioned.connect(on_child_transition)
if initial_state:
initial_state.enter()
current_state = initial_state
_ready函数中首先通过get_children函数获取所有子节点- 判断当子节点类型为
State时,则将它们加入到states字典中,并为每个子节点连接transitioned信号 - 如果
initial_state变量不为空,则在初始状态中调用enter函数进入该状态,并把current_state设置为初始状态。
func on_child_transition(state, new_state_name):
if state != current_state:
return
var new_state = states.get(new_state_name)
if not new_state:
return
if current_state:
current_state.exit()
new_state.enter()
current_state = new_state
on_child_transition函数负责状态切换的逻辑,函数有两个参数,state是发出transitioned信号的状态自身,new_state_name是新状态的名称。- 首先判断当前状态是否和发出
transitioned信号的状态相同,如果不同则直接跳出函数。这是为了确保只有当前状态才能发出信号。 - 然后通过
states字典获取新状态。如果新状态不存在则直接跳出。 - 然后判断当前状态是否为空,如果不为空则退出当前状态
- 最后使用
enter函数进入新状态,并更新current_state变量。
_physics_process函数中,判断当前状态是否为空,如果不为空则调用当前状态的physics_update函数更新状态。
创建状态子类
下面我们来创建一个新的场景,根节点设置为State,重命名为EnemyPatrol,它会负责巡逻状态,附加代码如下:
extends State
@export var enemy: Node2D
@export var detect_component :Node2D
var points_array :Array
var target_pos : Vector2
var target_index : int = 0
extends State关键字表示它继承了状态类- 定义两个节点引用,分别是
enemy变量,表示巡逻的坦克,detect_component变量表示探测组件。 points_array变量是用来存储巡逻点的数组target_pos是目标巡逻点的位置target_index是当前巡逻点的索引。
_ready函数中会调用get_points函数获取巡逻点- 重写
enter函数,它会调用find_next_point函数找到下一个巡逻点。
func get_points():
for point in enemy.points.get_children():
points_array.append(point.global_position)
func find_next_point():
if target_index >= points_array.size():
target_index = 0
target_pos = points_array[target_index]
target_index += 1
get_points函数是为了获取巡逻点find_next_point函数是为了找到下一个巡逻点,它们的逻辑和之前版本一样。
func physics_update(delta: float) -> void:
enemy.update_direction(target_pos)
enemy.move()
if near_point():
find_next_point()
if detect_component.find_player():
transitioned.emit(self,"EnemyAttack")
physics_update函数是状态更新函数,它会让坦克移动到目标巡逻点,如果接近巡逻点,会找到下一个巡逻点,如果检测到玩家,它会发出transitioned信号,切换到EnemyAttack状态。near_point函数和之前版本的逻辑是一样的。
类似的,我们再创建一个新的场景,根节点设置为State,重命名为EnemyAttack,它会负责攻击状态,附加代码如下:
extends State
@export var enemy: Node2D
@export var detect_component :Node2D
@export var weapon_component :Node2D
var target_pos : Vector2
func physics_update(delta: float) -> void:
if detect_component.find_player():
target_pos = detect_component.get_player_pos()
enemy.update_direction(target_pos)
enemy.move()
weapon_component.target(target_pos)
weapon_component.shoot(target_pos)
else:
transitioned.emit(self,"EnemyPatrol")
physics_update函数是状态更新函数,如果检测到玩家,它会让目标巡逻点设置为玩家位置,让坦克进行追击,然后坦克炮管指向玩家,并向玩家射击。- 如果没有检测到玩家,它会发出
transitioned信号,切换回到EnemyPatrol状态。
修改EnemyTank场景
下面我们来修改EnemyTank场景,步骤如下:
- 打开enemy_tank.tscn,将StateMachine场景放置到根节点之下,
- 然后再将刚刚创建的EnemyPatrol和EnemyAttack场景放置到StateMachine之下。
- 同时要在编辑器中设置对应的引用节点变量,将StateMachine的initial_state设置为EnemyPatrol。将EnemyPatrol节点的enemy和detect_component设置为相应节点,
然后保存好场景文件。EnemyTank场景的代码修改如下:
extends CharacterBody2D
# 节点引用变量
@export var points: Node2D
@export var speed: float = 100
@onready var weapon_component: Node2D = $WeaponComponent
@onready var hurt_box_component: Area2D = $HurtBoxComponent
@onready var health_component: Node = $HealthComponent
@onready var detect_component: Area2D = $DetectComponent
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var trail_compoment: Node2D = $TrailCompoment
func _ready() -> void:
# 关联信号
hurt_box_component.get_damage.connect(health_component.get_damage)
health_component.died.connect(on_died)
hurt_box_component.get_damage.connect(on_get_damage)
# 启动轨迹组件的更新
func _physics_process(delta: float) -> void:
trail_compoment.start()
func on_died():
Gamemanager.entity_died.emit(global_position,get_groups())
queue_free()
func on_get_damage(damage):
animation_player.play("flash")
on_died函数是负责敌人死亡的响应函数,它会发出entity_died信号,销毁自己。on_get_damage函数是敌人受到伤害的响应函数,它会播放flash动画。
func update_direction(target_pos: Vector2):
var direction = global_position.direction_to(target_pos)
var angle_rad = direction.angle()
rotation = angle_rad
func move():
velocity = transform.x * speed
move_and_slide()
update_direction函数负责更新坦克的移动方向,move函数负责移动坦克。
完成后的EnemyTank场景如下:

主场景测试
到现在为止,我们实现了两种有状态机加持的敌方坦克,我们可以在关卡中测试一下。将EnemyTank场景放置到关卡中,使用移动工具将它移动到道路上,然后点击开始游戏,坦克会自动巡逻,当玩家进入可视范围后,坦克会追击玩家。

当然你会发现一些问题,例如,敌方坦克在地图中移动时,会碰到障碍物而无法移动。这是因为我们还没有给它加上自动避障的功能。我们会在后续章节介绍如何实现自动避障的功能。
状态模式架构分析
通过上述架构,我们实现了一个解耦的状态机:每个状态拥有自己独立的脚本和逻辑,彼此之间通过信号和状态机调度解关联。这种设计有多项优点:开发者可以在 Godot 编辑器的场景树中直观地看到所有可用状态节点,方便调试和管理;每个状态的代码局部化在自己的脚本内,使得代码段简短且专注于单一功能,阅读和维护起来更容易;不同对象如果需要共享某些状态逻辑,我们可以将同一个状态脚本实例化为它们的子节点,从而达到代码复用的目的(例如多个敌人AI都可以使用通用的“巡逻”状态脚本)。此外,利用节点的机制,我们还能方便地启用/禁用整个状态节点,或利用Godot的场景继承系统来进一步组织复杂的状态机。
然而,基于节点的状态模式实现也有一定的权衡和成本。相比单脚本方案,这种方式会产生更多的脚本文件和类,在初期搭建时编写的代码量也更大,每个状态可能存在一些重复的通用代码(例如都有类似的输入检测逻辑框架)。同时,由于把原本集中在一个脚本的角色数据拆分到多个状态脚本中,开发过程中需要在状态之间传递共享数据或访问宿主节点的属性,处理不当的话会增加一些复杂性。尽管如此,对于中大型项目或复杂AI,状态模式带来的清晰结构和扩展性往往是值得的。
何时使用哪种实现?这取决于项目规模和需求。在状态较少且行为简单时,使用单一状态变量配合条件判断的方式实现FSM通常更加直接高效。如果状态数量增多、逻辑复杂,或者你希望重用状态代码,那么使用节点和脚本分离的状态模式会让代码组织更清晰、拓展更容易。开发者可以权衡利弊,在实际项目中选择合适的FSM实现方式。无论采用哪种方案,有限状态机的核心思想都是一致的:将系统按状态划分,明确各状态下的行为和转换,从而让游戏逻辑更加可控、易读和易维护。
本章小结
本章我们深入探讨了有限状态机(Finite State Machine, FSM)在游戏开发中的原理与实践应用。
我们首先了解了 FSM 的基本概念:它是一种将系统行为划分为若干状态,并通过特定事件驱动状态切换的逻辑模型。通过结合流程图,我们清楚地看到了“状态”与“转换”之间的关系,这为后续的代码实现打下了认知基础。
接着,我们分析了 FSM 在游戏中的常见应用场景:包括角色控制、敌人 AI、UI 流程、任务系统等。我们也提醒开发者,虽然 FSM 强大且清晰,但也不能滥用,对于极其简单或不需要状态划分的逻辑,不必强行引入状态机。
然后,我们分别介绍了两种常见的实现方式:
- 使用状态变量:通过枚举值加条件分支的方式实现简单 FSM,适合小型项目和逻辑简单的对象。
- 使用状态模式:借助节点和脚本的分离,把每个状态封装成独立节点,结合信号实现模块化切换。虽然初期代码组织复杂度更高,但适合中大型项目、可重用性强、维护更清晰。
通过对坦克敌人 AI 的两个版本改造实例,我们完整展示了 FSM 从原理到实践的演进过程。你应当能感受到,良好的状态管理可以显著提升游戏对象行为的清晰度和可扩展性。