最近在閱讀《遊戲人工智能編程案例精粹(修訂版)》,本文是書中第二章的一篇筆記。git
有限狀態機(英語:Finite-state machine, 縮寫:FSM),是一個被數學家用來解決問題的嚴格形式化的設備,在遊戲業中也常見有限狀態機的身影。程序員
對於遊戲程序員來講,能夠用下面這個定義來了解:github
一個有限狀態機是一個設備(device),或是一個設備模型(a model of a device)。具備有限數量的狀態,它能夠在任何給定的時間根據輸入進行操做,是的從一個狀態變換到另外一個狀態,或者是促使一個輸出或者一種行爲的發生。一個有限狀態機在任何瞬間只能處在一種狀態。
——《遊戲人工智能編程案例精粹(修訂版)》 Mat Buckland
有限狀態機就是要把一個對象的行爲分解成易於處理的「塊」或者狀態。拿某個開關來講,咱們能夠把它分紅兩個狀態:開或關。其中開開關這個操做,就是一次狀態轉移,使開關的狀態從「關」變換到「開」,反之亦然。編程
拿遊戲來舉例,一個 FPS 遊戲中的敵人 AI 狀態能夠分紅:巡邏、偵查(聽到了玩家)、追逐(玩家出如今 AI 視野)、攻擊(玩家進入 AI 攻擊範圍)、死亡等,這些有限的狀態都互相獨立,且要知足某種條件才能從一個狀態轉移到另一個狀態。設計模式
有限狀態機由三部分組成:ide
下圖是隻有三種狀態的 AI 的有限狀態機圖示:函數
實現有限狀態機以前,要先了解它的優勢:動畫
有限狀態機的缺點是:this
這是第一種實現有限狀態機的方法,用一系列 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),它提供了一種優雅的方式來實現狀態驅動行爲。這種實現編碼簡單,容易擴展,也能夠容易地爲狀態增長進入和退出的動做。下文會給出更完整的實現。
這項目是關於使用有限狀態機建立一個 AI 的實際例子。遊戲環境是一個古老西部風格的開採金礦的小鎮,稱做 West World。一開始只有一個挖金礦工 Bob,後期會加入他的妻子。任何的狀態改變或者輸出都會出如今控制檯窗口中。West World 中有四個位置:金礦,能夠存金塊的銀行,能夠解除乾渴的酒吧,還有家。礦工 Bob 會挖礦、睡覺、喝酒等,但這些都由 Bob 的當前狀態決定。
項目在這裏:programming-game-ai-by-example-in-unity/WestWorld/
當你看到礦工改變了位置時,就表明礦工改變了狀態,其餘的事情都是狀態中發生的事情。
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 類是從 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 項目 }
金礦工人有四種狀態:
當前狀態 | 條件 | 狀態轉移 |
---|---|---|
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 基類,這裏用泛型實現。
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)。
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 類中,可使得設計更爲簡潔。
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 是礦工 Bob 的妻子,她會清理小木屋和上廁所(老喝咖啡)。其中 VisitBathroom 狀態是用狀態翻轉實現的,即上完廁所要回到以前的狀態。
項目地址:programming-game-ai-by-example-in-unity/WestWorldWithWoman/
好的遊戲實現趨向於事件驅動。即當一件事情發生了(發射了武器,主角發出了聲音等等),事件會被廣播給遊戲中相關的對象。
整合事件(觀察者模式)的狀態機能夠實現更靈活的需求,例如:一個足球運動員從隊友旁邊經過時,傳球者能夠發送一個(延時)消息,通知隊友應該何時到相應位置來接球;一個士兵正在開槍攻擊敵人,突然一個隊友中了流彈,這時候隊友能夠發送一個(即時)消息,通知士兵馬上救援隊友。
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/
這裏實現的場景是:
有時候咱們可能會用到多個狀態機來並行工做,例如一個 AI 有多個狀態,其中包括攻擊狀態,而攻擊狀態又有不一樣攻擊類型(瞄準和射擊),像一個狀態機包含另外一個狀態機這種層次化的狀態機。固然也有其餘不一樣的使用場景,咱們不能受限於本身的想象力。
本文根據《遊戲人工智能編程案例精粹(修訂版)》進行了 Unity 版本的實現,我對有限狀態機也有了更清晰的認識。閱讀這本書的同時也會把 Unity 實現放到下面的倉庫地址中,下篇文章可能會總結行爲樹的知識,若是沒看到請督促我~
項目地址:programming-game-ai-by-example-in-unity