由于临近年关工做繁忙,已经有一段时间没有更新博客了。到了元旦终于有时间来写点东西,既是积累也是分享。如题目所示,本文要来聊一聊在游戏开发中常常会涉及到的话题——游戏AI。设计游戏AI的目标之一是要找到一种便于使用并容易拓展的的方案,常见的一些游戏AI方案包括了有限状态机(FSM)、分层有限状态机(HFSM)、面向目标的动做规划(GOAP)以及分层任务网络(HTN)和行为树(BT)等等。下面咱们就来聊一聊比较有表明性的游戏AI方案——状态机。设计模式
有限状态自动机 (Finite State Machine,FSM)是表示有限多个状态以及在这些状态(State)之间转移(Transition)和动做(Action)的数学模型。有限状态机的模型体现了两点:网络
状态首先是离散的:某一时刻只能处于某种状态之下,且须要知足某种条件才能从一种状态转移到另外一种状态。ide
而后状态总数是有限的。模块化
从它的定义,咱们能够看到有限状态机的几个重要概念:函数
状态(State):表示对象的某种形态,在当前形态下可能会拥有不一样的行为和属性。this
转移(Transition):表示状态变动,而且必须知足确使转移发生的条件来执行。spa
动做(Action):表示在给定时刻要进行的活动。设计
事件(Event):事件一般会引发状态的变迁,促使状态机从一种状态切换到另外一种状态。code
而状态机即是用来控制对象状态的管理器。在知足了某种条件或者说在某个特定的事件被触发以后,对象的状态便会经过转换来变成另一种状态,而对象在不一样的状态之下也有可能会有不一样的行为和属性。
固然,有限状态机的应用范围很广,可是显然游戏开发是有限状态机最为成功的应用领域之一。除了游戏AI的实现能够依靠有限状态机以外,游戏逻辑以及动做切换均可以借助有限状态机来实现。所以游戏中的每一个角色或者器件或者逻辑都有可能内嵌一个状态机。对象
若是咱们仔细观察一个有限状态机的话,能够发现它在逻辑结构上是没有层次的,若是和行为树来作对比的话能够发现这一点十分明显。在行为树中,节点是有层次(Hierarchical)的,子节点由其父节点来控制。例如行为树中有一种节点叫作“序列(Sequence)节点”,它的做用是顺序执行全部子节点(若是某个子节点失败返回失败,不然返回成功)。而将行为树的这个优点应用到有限状态机上,分层有限状态机HFSM便诞生了。
那么引入了分层以后的HFSM到底带来了什么好处呢?
最大的好处即是在必定程度上规范了状态机的状态转换,从而有效地减小了状态之间的转换。
举一个简单的小例子:例如RTS游戏中的士兵。若是逻辑没有层次上的划分,那么咱们对士兵所定义的若干状态,例如前进、寻敌、攻击、防护、逃跑等等,就须要在这些状态之间定义转移,由于它们是平级的,所以咱们须要考虑每一组状态的关系,并维护一大堆没有侧重点的转移。
若是在逻辑上是分层的,咱们就能够将士兵的这些状态进行一个分类,把几个低级的状态归并到一个高级的状态中,而且状态的转移只发生在同级的状态中。
例如高级状态包括战斗、撤退,而战斗状态中又包括了寻敌、攻击等几个小状态;撤退状态中又包括了防护、逃跑这几个小状态。
总而言之,分层状态机HFSM从某种程度上规范了状态机的状态转移,并且状态内的子状态不须要关心外部状态的跳转,这样也作到了无关状态间的隔离。
那么到底如何实现一个有限状态机呢?主要有两种方式来实现,即集中管理控制以及模块化管理。具体来讲,这两种方式的实现以下:
使用switch语句:全部的状态之间的转移逻辑全都写在一个部分,须要根据不一样的分支来判断转移条件是否符合。
使用状态模式(State Pattern):一种常见的设计模式。在状态模式中,咱们为每一个状态建立与之对应的类,这样就将状态转移的逻辑从臃肿的switch语句中分散到了各个类中。
了解了有限状态机大致上能够分为这两种实现方式,那么接下来咱们就具体来看一看这两种方式是如何实现的。
在实现有限状态机时,使用switch语句是最简单同时也是最直接的一种方式。这种方式的基本思路是为状态机中的每一种状态都设置一个case分支,专门用来对该状态进行控制。
上图是一个具体的使用有限状态机实现游戏AI的场景,描述的是一个游戏单位的AI,下面咱们就使用switch语句来实现图中的状态机。
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语句实现的有限状态机的确能够很好的运行。不过咱们还能够发现这种方式在实现状态之间的转换时,1.检查转换条件以及2.进行状态转换的代码都是混杂在当前的状态分支中来完成的,这样就会致使代码的可读性下降甚至会增长往后的维护成本。
这是由于在每一个具体的状态下,都须要检查多个具体的转换条件,对符合条件的还须要转移到新的具体的状态,这样的代码是难以维护的,由于它们须要在具体的状况下处理具体的事物。即使咱们将检查转换条件和进行状态转换的代码分别封装成两个专门的函数FuncA(检查转换条件)和FuncB(进行状态转换),switch语句中各个具体状态的代码可能会更加清晰。可是随着逻辑复杂度的增长,FuncA和FuncB这两个函数自己的复杂度可能也会增长,甚至最后变得臃肿不堪。
当控制一个对象状态转换的条件表达式过于复杂时,把状态的判断逻辑转移到一系列类当中,能够把复杂的逻辑判断简单化。所以,使用状态模式来实现状态机虽然不如直接使用switch语句来的直接,可是对于状态更易维护也更易拓展。下面咱们就来看一看状态模式中的角色:
1. 上下文环境(Context):它定义了客户程序须要的接口并维护一个具体状态的实例,将与状态相关的操做(1.检查转换条件;2.进行状态转换)交给当前的具体状态对象来处理。
2. 抽象状态(State):定义一个接口以封装使用上下文环境的的一个特定状态相关的行为。
3. 具体状态(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语句那种代码臃肿、可读性维护性差的问题,可是状态模式并不是没有本身的缺点。能够看出状态模式的使用必然会增长类和对象的个数,若是使用不当将致使程序结构和代码的混乱。
在游戏开发中使用状态机显然不失为一种不错的选择,首先它的概念并不复杂,其次它的实现也十分简单而直接。但它的缺点却也十分明显,例如难以复用,由于它每每须要根据具体的状况来作出反应,固然当状态机的模型复杂到必定的程度以后,也会带来实现和维护上的困难。如何选择,可能就是一个仁者见仁智者见智的问题了。