Unity 中用有限狀態機來實現一個 AI

最近在閱讀《遊戲人工智能編程案例精粹(修訂版)》,本文是書中第二章的一篇筆記。

有限狀態機(英語:Finite-state machine, 縮寫:FSM),是一個被數學家用來解決問題的嚴格形式化的設備,在遊戲業中也常見有限狀態機的身影。

對於遊戲程序員來說,可以用下面這個定義來了解:

一個有限狀態機是一個設備(device),或是一個設備模型(a model of a device)。具有有限數量的狀態,它可以在任何給定的時間根據輸入進行操作,是的從一個狀態變換到另一個狀態,或者是促使一個輸出或者一種行爲的發生。一個有限狀態機在任何瞬間只能處在一種狀態。
——《遊戲人工智能編程案例精粹(修訂版)》 Mat Buckland

有限狀態機就是要把一個對象的行爲分解成易於處理的“塊”或者狀態。拿某個開關來說,我們可以把它分成兩個狀態:開或關。其中開開關這個操作,就是一次狀態轉移,使開關的狀態從“關”變換到“開”,反之亦然。

拿遊戲來舉例,一個 FPS 遊戲中的敵人 AI 狀態可以分成:巡邏、偵查(聽到了玩家)、追逐(玩家出現在 AI 視野)、攻擊(玩家進入 AI 攻擊範圍)、死亡等,這些有限的狀態都互相獨立,且要滿足某種條件才能從一個狀態轉移到另外一個狀態。

有限狀態機由三部分組成:

  • 存儲任務信息的一些狀態(states),例如一個 AI 可以有探索狀態、追蹤狀態、攻擊狀態等等。
  • 狀態之間的一些變換(transitions),轉移代表狀態的轉移,並且描述着狀態轉移的條件。例如聽到了主角的腳步聲,就轉移到追蹤狀態。
  • 需要跟隨每個狀態的一系列行爲(actions)。例如在探索狀態,要隨機移動和找東西。

下圖是隻有三種狀態的 AI 的有限狀態機圖示:

優缺點

實現有限狀態機之前,要先了解它的優點:

  1. 編程快速簡單:很多有限狀態機的實現都較簡單,本文會列出三種實現方法。
  2. 易於調試:因爲行爲被分成單一的狀態塊,因此要調試的時候,可以只跟蹤某個異常狀態的代碼。
  3. 很少的計算開銷:幾乎不佔用珍貴的處理器時間,因爲除了 if-this-then-that 這種思考處理之外,是不存在真正的“思考”的。
  4. 直覺性:人們總是自然地把事物思考爲處在一種或另一種狀態。人類並不是像有限狀態機一樣工作,但我們發現這種方式下考慮行爲是很有用的,或者說我們能更好更容易地進行 AI 狀態的分解和創建操作 AI 的規則,容易理解的概念也讓程序員之間能更好地交流其設計。
  5. 靈活性:遊戲 AI 的有限狀態機能很容易地由程序員進行調整,增添新的狀態和規則也很容易擴展一個 AI 的行爲。

有限狀態機的缺點是:

  1. 當狀態過多時,難以維護代碼。
  2. 《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/
West World

當你看到礦工改變了位置時,就代表礦工改變了狀態,其他的事情都是狀態中發生的事情。

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/

West World With Woman

消息功能

好的遊戲實現趨向於事件驅動。即當一件事情發生了(發射了武器,主角發出了聲音等等),事件會被廣播給遊戲中相關的對象。

整合事件(觀察者模式)的狀態機可以實現更靈活的需求,例如:一個足球運動員從隊友旁邊通過時,傳球者可以發送一個(延時)消息,通知隊友應該什麼時候到相應位置來接球;一個士兵正在開槍攻擊敵人,忽然一個隊友中了流彈,這時候隊友可以發送一個(即時)消息,通知士兵立刻救援隊友。

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/

West World With Messaging

這裏實現的場景是:

  1. 礦工 Bob 回家後發送 HiHoneyImHome 即時消息給他的妻子 Elsa,提醒她做飯。
  2. Elsa 收到消息後,停止手上的活兒,開始進入 CookStew 狀態做飯。
  3. Elsa 進入 CookStew 狀態後,把肉放到烤爐裏面,並且發送 StewReady 延時消息提醒自己在一段時間後拿出烤爐中的肉。
  4. Elsa 收到 StewReady 消息後,發送一個 StewReady 即時消息給 Bob 提醒他飯已經做好了。如果 Bob 這時不在家,命令行將顯示 Discharge 函數中的 Warning “消息未處理”。Bob 在家,就會開心地去吃飯。
  5. Bob 收到 StewReady 的消息,狀態轉移到 EatStew,開始吃飯。

總結

有時候我們可能會用到多個狀態機來並行工作,例如一個 AI 有多個狀態,其中包括攻擊狀態,而攻擊狀態又有不同攻擊類型(瞄準和射擊),像一個狀態機包含另一個狀態機這種層次化的狀態機。當然也有其他不同的使用場景,我們不能受限於自己的想象力。

本文根據《遊戲人工智能編程案例精粹(修訂版)》進行了 Unity 版本的實現,我對有限狀態機也有了更清晰的認識。閱讀這本書的同時也會把 Unity 實現放到下面的倉庫地址中,下篇文章可能會總結行爲樹的知識,如果沒看到請督促我~

項目地址:programming-game-ai-by-example-in-unity

引用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章