跳转至

第九章:游戏中的有限状态机

本章导言

在游戏开发中,我们经常会遇到这样的问题:一个角色在奔跑时能否立即跳跃?敌人巡逻中该如何切换到追击?菜单界面如何在游戏中与主逻辑协同工作?这些看似平常的行为切换,其实都可以归纳为“状态”的变化。

有限状态机(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_jumpingis_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_postarget_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_pointsfind_next_point函数进行执行,这样一开始敌方坦克就知道巡逻路径了。另外,在代码中要记得保留原来的on_die函数以及on_get_damage函数。

修改游戏主场景

本节是一个实验版本的游戏,只是为了实现一个简单的FSM,所以只有一个敌方坦克,没有其它敌人和管理机制。

打开game场景,删除原有的EnemyManager和PickupManager。也删除Path节点和下面的所有路径。删除Enemies节点下的所有敌方炮台。然后把EnemyTankFSM场景放到Enemies节点下。使用移动工具将它放到地图的道路上。要记得设置EnemyTankFSM的Points属性,将它设置为Points节点。

在根节点下新增一个Node2D节点,名为Points,然后在Points节点下新增四个Marker2D节点,分别用来表示四个巡逻点。这四个点的位置需要放到地图上的四个路口上。具体如下图所示。 alt text

然后将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变量。

func _physics_process(delta: float) -> void:
    if current_state:
        current_state.physics_update(delta)
_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是当前巡逻点的索引。

func _ready() -> void:
    get_points()

func enter():
    find_next_point()
代码解释:

  • _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
这里定义了三个节点引用变量,分别表示tanker,探测组件和武器组件。target_pos是目标巡逻点的位置。

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
要注意它是继承自CharacterBody2D,而不是Node2D,因此需要在代码中加上extends CharacterBody2D。

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场景如下: alt text

主场景测试

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

alt text

当然你会发现一些问题,例如,敌方坦克在地图中移动时,会碰到障碍物而无法移动。这是因为我们还没有给它加上自动避障的功能。我们会在后续章节介绍如何实现自动避障的功能。

状态模式架构分析

通过上述架构,我们实现了一个解耦的状态机:每个状态拥有自己独立的脚本和逻辑,彼此之间通过信号和状态机调度解关联。这种设计有多项优点:开发者可以在 Godot 编辑器的场景树中直观地看到所有可用状态节点,方便调试和管理;每个状态的代码局部化在自己的脚本内,使得代码段简短且专注于单一功能,阅读和维护起来更容易;不同对象如果需要共享某些状态逻辑,我们可以将同一个状态脚本实例化为它们的子节点,从而达到代码复用的目的(例如多个敌人AI都可以使用通用的“巡逻”状态脚本)。此外,利用节点的机制,我们还能方便地启用/禁用整个状态节点,或利用Godot的场景继承系统来进一步组织复杂的状态机。

然而,基于节点的状态模式实现也有一定的权衡和成本。相比单脚本方案,这种方式会产生更多的脚本文件和类,在初期搭建时编写的代码量也更大,每个状态可能存在一些重复的通用代码(例如都有类似的输入检测逻辑框架)。同时,由于把原本集中在一个脚本的角色数据拆分到多个状态脚本中,开发过程中需要在状态之间传递共享数据或访问宿主节点的属性,处理不当的话会增加一些复杂性。尽管如此,对于中大型项目或复杂AI,状态模式带来的清晰结构和扩展性往往是值得的。

何时使用哪种实现?这取决于项目规模和需求。在状态较少且行为简单时,使用单一状态变量配合条件判断的方式实现FSM通常更加直接高效。如果状态数量增多、逻辑复杂,或者你希望重用状态代码,那么使用节点和脚本分离的状态模式会让代码组织更清晰、拓展更容易。开发者可以权衡利弊,在实际项目中选择合适的FSM实现方式。无论采用哪种方案,有限状态机的核心思想都是一致的:将系统按状态划分,明确各状态下的行为和转换,从而让游戏逻辑更加可控、易读和易维护。

本章小结

本章我们深入探讨了有限状态机(Finite State Machine, FSM)在游戏开发中的原理与实践应用。

我们首先了解了 FSM 的基本概念:它是一种将系统行为划分为若干状态,并通过特定事件驱动状态切换的逻辑模型。通过结合流程图,我们清楚地看到了“状态”与“转换”之间的关系,这为后续的代码实现打下了认知基础。

接着,我们分析了 FSM 在游戏中的常见应用场景:包括角色控制、敌人 AI、UI 流程、任务系统等。我们也提醒开发者,虽然 FSM 强大且清晰,但也不能滥用,对于极其简单或不需要状态划分的逻辑,不必强行引入状态机。

然后,我们分别介绍了两种常见的实现方式:

  • 使用状态变量:通过枚举值加条件分支的方式实现简单 FSM,适合小型项目和逻辑简单的对象。
  • 使用状态模式:借助节点和脚本的分离,把每个状态封装成独立节点,结合信号实现模块化切换。虽然初期代码组织复杂度更高,但适合中大型项目、可重用性强、维护更清晰。

通过对坦克敌人 AI 的两个版本改造实例,我们完整展示了 FSM 从原理到实践的演进过程。你应当能感受到,良好的状态管理可以显著提升游戏对象行为的清晰度和可扩展性。