趣说游戏AI开发:对状态机的褒扬和批判

2016年01月04日 13:42 0 点赞 0 评论 更新于 2025-11-21 13:33

作者:慕容小匹夫

0x00 前言

临近年关,工作繁忙,博客已有一段时间未更新。元旦之际,终于有时间撰写此文,既是知识积累,也是经验分享。如标题所示,本文将探讨游戏开发中常见的话题——游戏AI。

设计游戏AI的目标之一,是找到一种便于使用且易于拓展的方案。常见的游戏AI方案包括有限状态机(FSM)、分层有限状态机(HFSM)、面向目标的动作规划(GOAP)、分层任务网络(HTN)和行为树(BT)等。下面将着重介绍具有代表性的游戏AI方案——状态机。

0x01 有限状态机(FSM)

有限状态自动机(Finite State Machine,FSM)是一种数学模型,用于表示有限个状态以及这些状态(State)之间的转移(Transition)和动作(Action)。有限状态机的模型体现了两个关键特性:

  • 状态离散性:在某一时刻,对象只能处于一种特定状态,且需满足特定条件才能从一个状态转移到另一个状态。
  • 状态总数有限:状态机所包含的状态数量是有限的。

从其定义中,可以提炼出有限状态机的几个重要概念:

  • 状态(State):代表对象的某种形态,在该形态下对象可能具有不同的行为和属性。
  • 转移(Transition):表示状态的变更,必须满足特定条件才能触发。
  • 动作(Action):指在特定时刻要执行的活动。
  • 事件(Event):通常会引发状态的变迁,促使状态机从一个状态切换到另一个状态。

状态机是控制对象状态的管理器。当满足特定条件或特定事件被触发时,对象的状态会通过转换变为另一种状态,且对象在不同状态下可能表现出不同的行为和属性。

有限状态机应用广泛,游戏开发是其最为成功的应用领域之一。除了实现游戏AI,游戏逻辑和动作切换也可借助有限状态机。因此,游戏中的每个角色、器件或逻辑都可能内嵌一个状态机。

0x02 HFSM分层有限状态机

仔细观察有限状态机,会发现其逻辑结构缺乏层次,与行为树对比尤为明显。在行为树中,节点具有层次(Hierarchical)结构,子节点由父节点控制。例如,行为树中的“序列(Sequence)节点”,其作用是顺序执行所有子节点(若某个子节点失败则返回失败,否则返回成功)。将行为树的这一优势应用到有限状态机上,便诞生了分层有限状态机HFSM。

分层的好处

引入分层后,HFSM带来了显著的好处,最大的优势在于规范了状态机的状态转换,有效减少了状态之间的转换。

以RTS游戏中的士兵为例。若逻辑无层次划分,为士兵定义的前进、寻敌、攻击、防御、逃跑等状态为平级关系,需要在这些状态之间定义转移,需考虑每一组状态的关系,并维护大量无侧重点的转移。

若逻辑分层,可对士兵的状态进行分类,将低级状态归并到高级状态中,且状态转移仅发生在同级状态之间。例如,高级状态包括战斗和撤退,战斗状态包含寻敌、攻击等小状态;撤退状态包含防御、逃跑等小状态。

总之,分层状态机HFSM在一定程度上规范了状态机的状态转移,状态内的子状态无需关注外部状态的跳转,实现了无关状态间的隔离。

0x03 有限状态机的实现

实现有限状态机主要有两种方式:集中管理控制和模块化管理。具体实现如下:

  • 使用switch语句:将所有状态之间的转移逻辑集中写在一处,根据不同分支判断转移条件是否满足。
  • 使用状态模式(State Pattern):一种常见的设计模式,为每个状态创建对应的类,将状态转移逻辑从臃肿的switch语句分散到各个类中。

switch语句

使用switch语句实现有限状态机是最简单直接的方式。基本思路是为状态机的每个状态设置一个case分支,用于控制该状态。

以下是使用switch语句实现游戏单位AI状态机的示例:

switch (state) {
// 处理状态Waiting的分支
case State.Waiting:
// 执行等待
wait();
// 检查是否有可以攻击
if (canAttack()) {
// 当前状态转换为Attacking
changeState(State.Attacking);
}
// 若不可攻击,则检查是否有可以移动
else if (canMove()) {
// 当前状态转换为Moving
changeState(State.Moving);
}
break;
// 处理状态Moving的分支
case State.Moving:
// 执行动作move
move();
// 检查是否可以攻击敌人
if (canAttack()) {
// 当前状态转换为Attacking
changeState(State.Attacking);
}
// 若不可攻击,则检查是否可以等待
else if (canWait()) {
// 当前状态转换为Waiting
changeState(State.Waiting);
}
break;
// 处理状态Attacking的分支
case State.Attacking:
// 执行攻击attack
attack();
// 检查是否可以等待
if (canWait()) {
// 当前状态转换为Waiting
changeState(State.Waiting);
}
break;
}

通过该示例可知,使用switch语句实现的有限状态机能够正常运行。但这种方式在实现状态转换时,检查转换条件和进行状态转换的代码混杂在当前状态分支中,降低了代码的可读性,增加了维护成本。

在每个具体状态下,需检查多个转换条件,符合条件的还需转移到新状态,代码难以维护。即便将检查转换条件和进行状态转换的代码分别封装成函数FuncA和FuncB,随着逻辑复杂度的增加,这两个函数本身也可能变得臃肿。

状态模式

当控制对象状态转换的条件表达式过于复杂时,将状态判断逻辑转移到一系列类中,可简化复杂的逻辑判断。因此,使用状态模式实现状态机虽不如直接使用switch语句直接,但更易于维护和拓展。状态模式包含以下角色:

  • 上下文环境(Context):定义客户程序所需的接口,维护一个具体状态的实例,将与状态相关的操作(检查转换条件、进行状态转换)交给当前的具体状态对象处理。
  • 抽象状态(State):定义一个接口,封装与上下文环境的特定状态相关的行为。
  • 具体状态(Concrete State):实现抽象状态定义的接口。

以下是按照这三个角色实现上述状态机的代码:

Context类

public class Context {
private State state;

public Context(State state) {
this.state = state;
}

public void Do() {
state.CheckAndTran(this);
}
}

抽象状态类

public abstract class State {
public abstract void CheckAndTran(Context context);
}

具体状态类

public class WaitingState : State {
public override void CheckAndTran(Context context) {
// 执行等待动作
Wait();
// 检查是否可以攻击敌人
if (canAttack()) {
// 当前状态转换为Attacking
context.State = new AttackingState();
}
// 若不可攻击,则检查是否有可以移动
else if (canMove()) {
// 当前状态转换为Moving
context.State = new MovingState();
}
}
}

虽然状态模式缓解了switch语句代码臃肿、可读性和维护性差的问题,但也存在缺点。使用状态模式会增加类和对象的数量,使用不当可能导致程序结构和代码混乱。

0x04 褒扬和批判

在游戏开发中,使用状态机是不错的选择。其概念简单,实现直接。然而,状态机的缺点也很明显,如难以复用,需根据具体情况做出反应。当状态机模型复杂到一定程度,实现和维护也会变得困难。因此,是否选择状态机,需根据具体情况权衡。

作者信息

洞悉

洞悉

共发布了 3994 篇文章