Unity 中用有限状态机来实现一个 AI
最近在阅读《游戏人工智能编程案例精粹(修订版)》,本文是书中第二章的一篇笔记。
有限状态机(英语:Finite-state machine, 缩写:FSM),是一个被数学家用来解决问题的严格形式化的设备,在游戏业中也常见有限状态机的身影。
对于游戏程序员来说,可以用下面这个定义来了解:
一个有限状态机是一个设备(device),或是一个设备模型(a model of a device)。具有有限数量的状态,它可以在任何给定的时间根据输入进行操作,是的从一个状态变换到另一个状态,或者是促使一个输出或者一种行为的发生。一个有限状态机在任何瞬间只能处在一种状态。
——《游戏人工智能编程案例精粹(修订版)》 Mat Buckland
有限状态机就是要把一个对象的行为分解成易于处理的“块”或者状态。拿某个开关来说,我们可以把它分成两个状态:开或关。其中开开关这个操作,就是一次状态转移,使开关的状态从“关”变换到“开”,反之亦然。
拿游戏来举例,一个 FPS 游戏中的敌人 AI 状态可以分成:巡逻、侦查(听到了玩家)、追逐(玩家出现在 AI 视野)、攻击(玩家进入 AI 攻击范围)、死亡等,这些有限的状态都互相独立,且要满足某种条件才能从一个状态转移到另外一个状态。
有限状态机由三部分组成:
- 存储任务信息的一些状态(states),例如一个 AI 可以有探索状态、追踪状态、攻击状态等等。
- 状态之间的一些变换(transitions),转移代表状态的转移,并且描述着状态转移的条件。例如听到了主角的脚步声,就转移到追踪状态。
- 需要跟随每个状态的一系列行为(actions)。例如在探索状态,要随机移动和找东西。
下图是只有三种状态的 AI 的有限状态机图示:
优缺点
实现有限状态机之前,要先了解它的优点:
- 编程快速简单:很多有限状态机的实现都较简单,本文会列出三种实现方法。
- 易于调试:因为行为被分成单一的状态块,因此要调试的时候,可以只跟踪某个异常状态的代码。
- 很少的计算开销:几乎不占用珍贵的处理器时间,因为除了 if-this-then-that 这种思考处理之外,是不存在真正的“思考”的。
- 直觉性:人们总是自然地把事物思考为处在一种或另一种状态。人类并不是像有限状态机一样工作,但我们发现这种方式下考虑行为是很有用的,或者说我们能更好更容易地进行 AI 状态的分解和创建操作 AI 的规则,容易理解的概念也让程序员之间能更好地交流其设计。
- 灵活性:游戏 AI 的有限状态机能很容易地由程序员进行调整,增添新的状态和规则也很容易扩展一个 AI 的行为。
有限状态机的缺点是:
- 当状态过多时,难以维护代码。
- 《AI Game Development》的作者 Alex J. Champandard 发表过一篇文章《10 Reasons the Age of Finite State Machines is Over》
if-then 实现
这是第一种实现有限状态机的方法,用一系列 if-then 语句或者 switch 语句来表达状态。
下面拿那个只有三个状态的僵尸 AI 举例:
public enum ZombieState { Chase, Attack, Die } public class Zombie : MonoBehaviour { private ZombieState currentState; private void Update() { switch (currentState) { case ZombieState.Chase: if (currentHealth <= 0) { ChangeState(ZombieState.Die); } // 玩家在攻击范围内则进入攻击状态 if (PlayerInAttackRange()) { ChangeState(ZombieState.Attack); } break; case ZombieState.Attack: if (currentHealth <= 0) { ChangeState(ZombieState.Die); } if (!PlayerInAttackRange()) { ChangeState(ZombieState.Chase); } break; case ZombieState.Die: Debug.Log("僵尸死亡"); break; } } }
这种写法能实现有限状态机,但当游戏对象复杂到一定程度时,case 就会变得特别多,使程序难以理解、调试。另外这种写法也不灵活,难以扩展超出它原始设定的范围。
此外,我们常需要在进入状态和退出状态时做些什么,例如僵尸在开始攻击时像猩猩一样锤几下胸口,玩家跑出攻击范围的时候,僵尸要“摇摇头”让自己清醒,好让自己打起精神继续追踪玩家。
状态变换表
一个用于组织状态和影响状态变换的更好的机制是一个状态变换表。
当前状态 | 条件 | 状态转移 |
---|---|---|
追踪 | 玩家进入攻击范围 | 攻击 |
追踪 | 僵尸生命值小于或等于0 | 死亡 |
攻击 | 玩家脱离攻击范围 | 追踪 |
攻击 | 僵尸生命值小于或等于0 | 死亡 |
这表格可以被僵尸 AI 不间断地查询。使得它能基于从游戏环境的变化来进行状态变换。每个状态可以模型化为一个分离的对象或者存在于 AI 外的函数。提供了一个清楚且灵活的结构。
我们只用告诉僵尸它有多少个状态,僵尸则会根据自己获得的信息(例如玩家是否在它的攻击范围内)来处理规则(转移状态)。
public class Zombie : MonoBehaviour { private ZombieState currentState; private void Update() { // 生命值小于等于0,进入死亡状态 if (currentHealth <= 0) { ChangeState(ZombieState.Die); return; } // 玩家在攻击范围内则进入攻击状态,反之进入追踪状态 if (PlayerInAttackRange()) { ChangeState(ZombieState.Attack); } else { ChangeState(ZombieState.Chase); } } }
内置规则
另一种方法就是将状态转移规则内置到状态内部。
在这里,每一个状态都是一个小模块,虽然每个模块都可以意识到其他模块的存在,但是每个模块都是一个独立的单位,而且不依赖任何外部的逻辑来决定自己是否要进行状态转移。
public class Zombie : MonoBehaviour { private State currentState; public int CurrentHealth { get; private set; } private void Update() { currentState.Execute(this); } public void ChangeState(State state) { currentState = state; } public bool PlayerInAttackRange() { // ...游戏逻辑 return result; } } public abstract class State { public abstract void Execute(Zombie zombie); } public class ChaseState : State { public override void Execute(Zombie zombie) { if (zombie.CurrentHealth <= 0) { zombie.ChangeState(new DieState()); } if (zombie.PlayerInAttackRange()) { zombie.ChangeState(new AttackState()); } } } public class AttackState : State { public override void Execute(Zombie zombie) { if (zombie.CurrentHealth <= 0) { zombie.ChangeState(new DieState()); } if (!zombie.PlayerInAttackRange()) { zombie.ChangeState(new ChaseState()); } } } public class DieState : State { public override void Execute(Zombie zombie) { Debug.Log("僵尸死亡"); } }
Update()
函数只需要根据 currentState
来执行代码,当 currentState
改变时,下一次 Update()
的调用也会进行状态转移。这三个状态都作为对象封装,并且都给出了影响状态转移的规则(条件)。
这个结构被称为状态设计模式(state design pattern),它提供了一种优雅的方式来实现状态驱动行为。这种实现编码简单,容易扩展,也可以容易地为状态增加进入和退出的动作。下文会给出更完整的实现。
West World 项目
这项目是关于使用有限状态机创建一个 AI 的实际例子。游戏环境是一个古老西部风格的开采金矿的小镇,称作 West World。一开始只有一个挖金矿工 Bob,后期会加入他的妻子。任何的状态改变或者输出都会出现在控制台窗口中。West World 中有四个位置:金矿,可以存金块的银行,可以解除干渴的酒吧,还有家。矿工 Bob 会挖矿、睡觉、喝酒等,但这些都由 Bob 的当前状态决定。
项目在这里:programming-game-ai-by-example-in-unity/WestWorld/
当你看到矿工改变了位置时,就代表矿工改变了状态,其他的事情都是状态中发生的事情。
Base Game Entity 类
public abstract class BaseGameEntity { /// <summary> /// 每个实体具有一个唯一的识别数字 /// </summary> private int m_ID; /// <summary> /// 这是下一个有效的ID,每次 BaseGameEntity 被实例化这个值就被更新 /// 这项目居民较少,采用预定义 id 的方式,可以忽视 /// </summary> public static int m_iNextValidID { get; private set; } protected BaseGameEntity(int id) { m_ID = id; } public int ID { get { return m_ID; } set { m_ID = value; m_iNextValidID = m_ID + 1; } } // 在 GameManager 的 Update() 函数中调用,相当于实体自己的 Update 函数 public abstract void EntityUpdate(); }
Miner 类
MIner 类是从 BaseGameEntity 类中继承的,包含很多成员变量,代码如下:
public class Miner : BaseGameEntity { /// <summary> /// 指向一个状态实例的指针 /// </summary> private State m_pCurrentState; /// <summary> /// 旷工当前所处的位置 /// </summary> private LocationType m_Location; /// <summary> /// 旷工的包中装了多少金块 /// </summary> private int m_iGoldCarried; /// <summary> /// 旷工在银行存了多少金块 /// </summary> private int m_iMoneyInBank; /// <summary> /// 口渴程度,值越高,旷工越口渴 /// </summary> private int m_iThirst; /// <summary> /// 疲倦程度,值越高,旷工越疲倦 /// </summary> private int m_iFatigue; public Miner(int id) : base(id) { m_Location = LocationType.Shack; m_iGoldCarried = 0; m_iMoneyInBank = 0; m_iThirst = 0; m_iFatigue = 0; m_pCurrentState = GoHomeAndSleepTilRested.Instance; } /// <summary> /// 等于 Update 函数,在 GameManager 内被调用,每调用一次就变得越口渴 /// </summary> public override void EntityUpdate() { m_iThirst += 1; m_pCurrentState.Execute(this); } // ...其他的代码看 Github 项目 }
Miner 状态
金矿工人有四种状态:
- EnterMineAndDigForNugget:如果矿工没在金矿,则改变位置。在金矿里了,就挖掘金块。
- VisitBankAndDepositGold:矿工会走到银行并且存储他携带的所有天然金矿。
- GoHomeAndSleepTilRested:矿工会回到他的小木屋睡觉知道他的疲劳值下降到可接受的程度。醒来继续去挖矿。
- QuenchThirst:去酒吧买一杯威士忌,不口渴了继续挖矿。
当前状态 | 条件 | 状态转移 |
---|---|---|
EnterMineAndDigForNugget | 挖矿挖到口袋装不下 | VisitBankAndDepositGold |
EnterMineAndDigForNugget | 口渴 | QuenchThirst |
VisitBankAndDepositGold | 觉得自己存够钱能安心了 | GoHomeAndSleepTilRested |
VisitBankAndDepositGold | 没存够钱 | EnterMineAndDigForNugget |
GoHomeAndSleepTilRested | 疲劳值下降到一定程度 | EnterMineAndDigForNugget |
QuenchThirst | 不口渴了 | EnterMineAndDigForNugget |
再谈状态设计模式
之前提到要为状态实现进入和退出这两个一个状态只执行一次的逻辑,这样可以增加有限状态机的灵活性。下面是威力加强版:
public abstract class State { /// <summary> /// 当状态被进入时执行这个函数 /// </summary> public abstract void Enter(Miner miner); /// <summary> /// 旷工更新状态函数 /// </summary> public abstract void Execute(Miner miner); /// <summary> /// 当状态退出时执行这个函数 /// </summary> public abstract void Exit(Miner miner); }
这两个增加的方法只有在矿工改变状态时才会被调用。我们也需要修改 ChangeState
方法的代码如下:
public void ChangeState(State state) { // 执行上一个状态的退出方法 m_pCurrentState.Exit(this); // 更新状态 m_pCurrentState = state; // 执行当前状态的进入方法 m_pCurrentState.Enter(this); }
另外,每个具体的状态都添加了单例模式,这样可以节省内存资源,不必重复分配和释放内存给改变的状态。以其中一个状态为例子:
public class EnterMineAndDigForNugget : State { public static EnterMineAndDigForNugget Instance { get; private set; } static EnterMineAndDigForNugget() { Instance = new EnterMineAndDigForNugget(); } public override void Enter(Miner miner) { if (miner.Location() != LocationType.Goldmine) { Debug.Log("矿工:走去金矿"); miner.ChangeLocation(LocationType.Goldmine); } } public override void Execute(Miner miner) { miner.AddToGoldCarried(1); miner.IncreaseFatigue(); Debug.Log("矿工:采到一个金块 | 身上有 " + miner.GoldCarried() + " 个金块"); // 口袋里金块满了就去银行存 if (miner.PocketsFull()) { miner.ChangeState(VisitBankAndDepositGold.Instance); } // 口渴了就去酒吧喝威士忌 if (miner.Thirsty()) { miner.ChangeState(QuenchThirst.Instance); } } public override void Exit(Miner miner) { Debug.Log("矿工:离开金矿"); } }
看到这里,大家应该都会很熟悉。这不就是 Unity 中动画控制器 Animator 的功能吗!
没错,Animator 也是一个状态机,有和我们之前实现十分相似的功能,例如:添加状态转移的条件,每个状态都有进入、执行、退出三个回调方法供使用。
我们可以创建 Behaviour 脚本,对 Animator 中每一个状态的进入、执行、退出等方法进行自定义,所以有些人直接拿 Animator 当状态机来使用,不过我们在下文还会为我们的状态机实现扩展更多的功能。
public class NewState : StateMachineBehaviour { // OnStateEnter is called when a transition starts and the state machine starts to evaluate this state //override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // //} // OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks //override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // //} // OnStateExit is called when a transition ends and the state machine finishes evaluating this state //override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // //} // ... }
使 State 基类可重用
由于上面四个状态是矿工独有的状态,如果要新建不同功能的角色,就有必要创建一个分离的 State 基类,这里用泛型实现。
public abstract class State<T> { /// <summary> /// 当状态被进入时执行这个函数 /// </summary> public abstract void Enter(T entity); /// <summary> /// 旷工更新状态函数 /// </summary> public abstract void Execute(T entity); /// <summary> /// 当状态退出时执行这个函数 /// </summary> public abstract void Exit(T entity); }
状态翻转(State Blip)
这个项目其实有点像模拟人生这个游戏,其中有一点有意思的是,当模拟人生的主角做某件事时忽然要上厕所,去完之后会继续做之前停止的事情。这种返回前一个状态的行为就是状态翻转(State Blip)。
private State<T> m_pCurrentState; private State<T> m_pPreviousState; private State<T> m_pGlobalState;
m_pGlobalState
是一个全局状态,也会在 Update()
函数中和 m_pCurrentState
一起调用。如果有紧急的行为中断状态,就把这行为(例如上厕所)放到全局状态中,等到全局状态为空再进入当前状态。
public void StateUpdate() { // 如果有一个全局状态存在,调用它的执行方法 if (m_pGlobalState != null) { m_pGlobalState.Execute(m_pOwner); } if (m_pCurrentState != null) { m_pCurrentState.Execute(m_pOwner); } }
StateMachine 类
通过把所有与状态相关的数据和方法封装到一个 StateMachine 类中,可以使得设计更为简洁。
public class StateMachine<T> { private T m_pOwner; private State<T> m_pCurrentState; private State<T> m_pPreviousState; private State<T> m_pGlobalState; public StateMachine(T owner) { m_pOwner = owner; } public void SetCurrentState(State<T> state) { m_pCurrentState = state; } public void SetPreviousState(State<T> state) { m_pPreviousState = state; } public void SetGlobalState(State<T> state) { m_pGlobalState = state; } public void StateMachineUpdate() { // 如果有一个全局状态存在,调用它的执行方法 if (m_pGlobalState != null) { m_pGlobalState.Execute(m_pOwner); } if (m_pCurrentState != null) { m_pCurrentState.Execute(m_pOwner); } } public void ChangeState(State<T> newState) { m_pPreviousState = m_pCurrentState; m_pCurrentState.Exit(m_pOwner); m_pCurrentState = newState; m_pCurrentState.Enter(m_pOwner); } /// <summary> /// 返回之前的状态 /// </summary> public void RevertToPreviousState() { ChangeState(m_pPreviousState); } public State<T> CurrentState() { return m_pCurrentState; } public State<T> PreviousState() { return m_pPreviousState; } public State<T> GlobalState() { return m_pGlobalState; } public bool IsInState(State<T> state) { return m_pCurrentState == state; } }
新人物 Elsa
第二个项目会演示之前的改进。Elsa 是矿工 Bob 的妻子,她会清理小木屋和上厕所(老喝咖啡)。其中 VisitBathroom 状态是用状态翻转实现的,即上完厕所要回到之前的状态。
项目地址:programming-game-ai-by-example-in-unity/WestWorldWithWoman/
消息功能
好的游戏实现趋向于事件驱动。即当一件事情发生了(发射了武器,主角发出了声音等等),事件会被广播给游戏中相关的对象。
整合事件(观察者模式)的状态机可以实现更灵活的需求,例如:一个足球运动员从队友旁边通过时,传球者可以发送一个(延时)消息,通知队友应该什么时候到相应位置来接球;一个士兵正在开枪攻击敌人,忽然一个队友中了流弹,这时候队友可以发送一个(即时)消息,通知士兵立刻救援队友。
Telegram 结构
public struct Telegram { public BaseGameEntity Sender { get; private set; } public BaseGameEntity Receiver { get; private set; } public MessageType Message { get; private set; } public float DispatchTime { get; private set; } public Dictionary<string, string> ExtraInfo { get; private set; } public Telegram(float time, BaseGameEntity sender, BaseGameEntity receiver, MessageType message, Dictionary<string, string> extraInfo = null) : this() { Sender = sender; Receiver = receiver; DispatchTime = time; Message = message; ExtraInfo = extraInfo; } }
这里用结构体来实现消息。要发送的消息可以作为枚举加在 MessageType
中,DispatchTime 是决定立刻发送还是延时发送的时间戳,ExtraInfo 能携带额外的信息。这里只用两种消息做例子。
public enum MessageType { /// <summary> /// 矿工让妻子知道他已经回到小屋了 /// </summary> HiHoneyImHome, /// <summary> /// 妻子通知矿工自己什么时候要将晚饭从烤箱中拿出来 /// 以及通知矿工食物已经放在桌子上了 /// </summary> StewReady, }
发送消息
下面是 MessageDispatcher 类,用来管理消息的发送。
/// <summary> /// 管理消息发送的类 /// 处理立刻被发送的消息,和打上时间戳的消息 /// </summary> public class MessageDispatcher { public static MessageDispatcher Instance { get; private set; } static MessageDispatcher() { Instance = new MessageDispatcher(); } private MessageDispatcher() { priorityQueue = new HashSet<Telegram>(); } /// <summary> /// 根据时间排序的优先级队列 /// </summary> private HashSet<Telegram> priorityQueue; /// <summary> /// 该方法被 DispatchMessage 或者 DispatchDelayedMessages 利用。 /// 该方法用最新创建的 telegram 调用接受实体的消息处理成员函数 receiver /// </summary> public void Discharge(BaseGameEntity receiver, Telegram telegram) { if (!receiver.HandleMessage(telegram)) { Debug.LogWarning("消息未处理"); } } /// <summary> /// 创建和管理消息 /// </summary> /// <param name="delay">时间的延迟(要立刻发送就用零或负值)</param> /// <param name="senderId">发送者 ID</param> /// <param name="receiverId">接受者 ID</param> /// <param name="message">消息本身</param> /// <param name="extraInfo">附加消息</param> public void DispatchMessage( float delay, int senderId, int receiverId, MessageType message, Dictionary<string, string> extraInfo) { // 获得消息发送者 BaseGameEntity sender = EntityManager.Instance.GetEntityFromId(senderId); // 获得消息接受者 BaseGameEntity receiver = EntityManager.Instance.GetEntityFromId(receiverId); if (receiver == null) { Debug.LogWarning("[MessageDispatcher] 找不到消息接收者"); return; } float currentTime = Time.time; if (delay <= 0) { Telegram telegram = new Telegram(0, sender, receiver, message, extraInfo); Debug.Log(string.Format( "消息发送时间: {0} ,发送者是:{1},接收者是:{2}。消息是 {3}", currentTime, sender.Name, receiver.Name, message.ToString())); Discharge(receiver, telegram); } else { Telegram delayedTelegram = new Telegram(currentTime + delay, sender, receiver, message, extraInfo); priorityQueue.Add(delayedTelegram); Debug.Log(string.Format( "延时消息发送时间: {0} ,发送者是:{1},接收者是:{2}。消息是 {3}", currentTime, sender.Name, receiver.Name, message.ToString())); } } /// <summary> /// 发送延时消息 /// 这个方法会放在游戏的主循环中,以正确地和及时地发送任何定时的消息 /// </summary> public void DisplayDelayedMessages() { float currentTime = Time.time; while (priorityQueue.Count > 0 && priorityQueue.First().DispatchTime < currentTime && priorityQueue.First().DispatchTime > 0) { Telegram telegram = priorityQueue.First(); BaseGameEntity receiver = telegram.Receiver; Debug.Log(string.Format("延时消息开始准备分发,接收者是 {0},消息是 {1}", receiver.Name, telegram.Message.ToString())); // 开始分发消息 Discharge(receiver, telegram); priorityQueue.Remove(telegram); } } }
DispatchMessage
函数会管理消息的发送,即时消息会直接由 Discharge
函数发送到接收者,延时消息会进入队列,通过 GameManager 游戏主循环,每一帧调用 DisplayDelayedMessages()
函数来轮询要发送的消息,当发现当前时间超过了消息的发送时间,就把消息发送给接收者。
处理消息
处理消息的话修改 BaseGameEntity 来增加处理消息的功能。
public abstract class BaseGameEntity { // ... 省略无关代码 public abstract bool HandleMessage(Telegram message); } public class Miner : BaseGameEntity { public override bool HandleMessage(Telegram message) { return m_stateMachine.HandleMessage(message); } }
StateMachine 代码也要改:
public class StateMachine<T> { public bool HandleMessage(Telegram message) { if (m_pCurrentState != null && m_pCurrentState.OnMessage(m_pOwner, message)) { return true; } // 如果当前状态没有代码适当的处理消息 // 它会发送到实体的全局状态的消息处理者 if (m_pCurrentState != null && m_pGlobalState.OnMessage(m_pOwner, message)) { return true; } return false; } }
State 基类也要修改:
public abstract class State<T> { /// <summary> /// 处理消息 /// </summary> /// <param name="entity">接受者</param> /// <param name="message">要处理的消息</param> /// <returns>消息是否成功被处理</returns> public abstract bool OnMessage(T entity, Telegram message); }
Discharge
函数发送消息给接收者,接收者将消息给他 StateMachine 的 HandleMessage
函数处理,消息最后通过 StateMachine 到达各种状态的 OnMessage
函数,开始根据消息的类型来做出处理(例如进行状态转移)。
具体实现请看项目代码:programming-game-ai-by-example-in-unity/WestWorldWithMessaging/
这里实现的场景是:
- 矿工 Bob 回家后发送 HiHoneyImHome 即时消息给他的妻子 Elsa,提醒她做饭。
- Elsa 收到消息后,停止手上的活儿,开始进入 CookStew 状态做饭。
- Elsa 进入 CookStew 状态后,把肉放到烤炉里面,并且发送 StewReady 延时消息提醒自己在一段时间后拿出烤炉中的肉。
- Elsa 收到 StewReady 消息后,发送一个 StewReady 即时消息给 Bob 提醒他饭已经做好了。如果 Bob 这时不在家,命令行将显示 Discharge 函数中的 Warning “消息未处理”。Bob 在家,就会开心地去吃饭。
- Bob 收到 StewReady 的消息,状态转移到 EatStew,开始吃饭。
总结
有时候我们可能会用到多个状态机来并行工作,例如一个 AI 有多个状态,其中包括攻击状态,而攻击状态又有不同攻击类型(瞄准和射击),像一个状态机包含另一个状态机这种层次化的状态机。当然也有其他不同的使用场景,我们不能受限于自己的想象力。
本文根据《游戏人工智能编程案例精粹(修订版)》进行了 Unity 版本的实现,我对有限状态机也有了更清晰的认识。阅读这本书的同时也会把 Unity 实现放到下面的仓库地址中,下篇文章可能会总结行为树的知识,如果没看到请督促我~
项目地址:programming-game-ai-by-example-in-unity