有限狀態機的使用
有限狀態機在遊戲製作中十分常見,它既可以作為玩家角色的控制框架,純程式碼控制動畫的播放,免去動畫間的“連連看”;也可以製作簡單的AI,甚至還可以搭配其它AI決策方式做出更復雜易用的AI控制……本文僅是個人對有限狀態機的理解,與大家一同交流有限狀態機的使用。
有限狀態機的介紹
有限狀態機(finite-state machine,縮寫:FSM),本身是一種數學計算模型,用於有限幾個「狀態」的動作與它們之間的轉換。大概長這樣:
此物在Unity中亦有記載——那就是動畫控制器,它也是一種有限狀態機,只不過各個狀態都是動畫片段,它們之間的轉化的條件是引數。
一個狀態機中,只能同時處於一個狀態,而下一個狀態只能從當前狀態轉換。同時,一個狀態中不能用相同條件轉移到不同狀態,因為這樣違背了「同時處於一個狀態」
這點,例如下面這樣:
「狀態」並不是具體的,只要你有辦法定義,它可以是別的任何東西;而狀態轉換的條件更是可以小到變數、大到函式。
有限狀態機有個非常重要的特點:,這就使得控制的邏輯變得清晰。遊戲開發中,我們就可以將角色的一個行為作為一種「狀態」,一些條件判斷作為轉換的依據。
程式碼實現有限狀態機
狀態
首先我們定義有限狀態機中的「狀態」,如前文所言,「狀態」可以是很多東西,但通常都少不了以下內容:
- 進入該狀態時會執行一次的邏輯
- 處於該狀態時會不斷執行的邏輯
- 退出該狀態(轉移到其它狀態)時會執行一次的邏輯
故而,我們可以這樣將它們以介面的方式定義:
public interface IFSMState
{
/// <summary>
/// 進入該狀態時執行的
/// </summary>
void Enter();
/// <summary>
/// 相當於用Unity生命週期中的Update,用於邏輯更新
/// </summary>
void LogicalUpdate();
/// <summary>
/// 狀態結束時(即轉移出時)執行的
/// </summary>
void Exit();
}
只要繼承了這個介面,就可以作為一種「狀態」。什麼?你說你的角色還會用到FixedUpdate
、 OnAnimatorIK
等其它的「不斷更新」的函式,該如何在「狀態」中增加這些邏輯?
其實我們所寫的雖為介面,但並不能直接作為根本,我是說具體狀態並非是直接繼承這個介面實現的,考慮到實際中,所謂處於該狀態時會不斷執行的邏輯
可能不止一種,所以我們要用一個繼承了這個介面的類作為基類狀態(在「示例」部分會展示這一點)。
我們並不需要對轉換條件單獨寫一個類,轉換條件可以直接寫在諸如 LogicalUpdate
這類函式中,自行判斷切換(示例中有體現)。
狀態機
狀態機的設計需要考慮以下問題:
- 能方便地增加與查詢各個狀態
- 能方便的切換狀態
- 能很好地執行狀態的邏輯(即狀態進入、退出、持續執行的那些邏輯)
對於第一個問題,我們可以使用字典儲存狀態,這樣就方便增加與查詢。但該用什麼作為字典的鍵值呢?首先,我們知道狀態機中的各個狀態是沒有重複的(兩個相同的狀態也沒什麼意義好吧),或許可以給各個狀態起個名字用作鍵值,當然也可以自定義列舉變數。但這些都要額外多些變數,莫不如就用狀態本身的型別(System.Type),故而我們可以這麼寫:
using System.Collections.Generic;
public class FSM<T> where T : IFSMState
{
//狀態表
public Dictionary<System.Type, T> StateTable{ get; protected set; }
public FSM()
{
StateTable = new Dictionary<System.Type, T>();
}
//新增狀態
public void AddState(T state)
{
StateTable.Add(state.GetType(), state);
}
}
接著,該看看如何切換了。已知狀態機時刻只能處以一個狀態,那麼我們就定義一個「當前狀態」,切換便是這個變數的變化:
using System.Collections.Generic;
public class FSM<T> where T : IFSMState
{
public Dictionary<System.Type, T> StateTable{ get; protected set; } //狀態表
protected T curState; //當前狀態
public FSM()
{
StateTable = new Dictionary<System.Type, T>();
curState = default;
}
public void AddState(T state)
{
StateTable.Add(state.GetType(), state);
}
public void ChangeState(System.Type nextState)
{
curState = StateTable[nextState];
}
}
假設有個狀態類叫 Player_Run
且已經新增到狀態表裡了,那麼要從當前狀態切換到 Player_Run
,就直接這樣呼叫即可:
MyFSM.ChangeState(typeof(Player_Run));
最後,我們的狀態機還必須具備處理當前狀態邏輯的能力。
首先是比較特殊的進入、退出邏輯,它們都是在特殊時刻執行一次。這並不難,在狀態機切換狀態時處理下即可——在切換時,當前狀態觸發「退出」邏輯、新的狀態觸發「進入」邏輯:
public void ChangeState(System.Type nextState)
{
curState.Exit();
curState = StateTable[nextState];
//因為此時curState變成了新的狀態,故觸發Enter邏輯
//即為 新狀態進入
curState.Enter();
}
接下來便是那些需要「不斷執行」的邏輯了,其實就是一個包裝,我們只需呼叫狀態機的OnUpdate
就能讓「當前狀態」的對應邏輯呼叫了。
public void OnUpdate()
{
curState.LogicalUpdate();
}
總結上述內容,一個完整的狀態機類如下所示:
using System.Collections.Generic;
public class FSM<T> where T : IFSMState
{
public Dictionary<System.Type, T> StateTable{ get; protected set; } //狀態表
protected T curState; //當前狀態
public FSM()
{
StateTable = new Dictionary<System.Type, T>();
curState = default;
}
public void AddState(T state)
{
StateTable.Add(state.GetType(), state);
}
//設定狀態機的第一個狀態時使用,因為一開始的curState還是空的
//故不需要 curState.Exit()
public void SwitchOn(System.Type startState)
{
curState = StateTable[startState];
curState.Enter();
}
public void ChangeState(System.Type nextState)
{
curState.Exit();
curState = StateTable[nextState];
curState.Enter();
}
public void OnUpdate()
{
curState.LogicalUpdate();
}
}
也許你心中還有一些疑問,看我猜的準不準:
-
為什麼狀態機是作為普通的類,而不是繼承
MonoBehavior
?
合情合理的問題(我自己也用過繼承,畢竟MonoBehavior
的狀態機FSM.OnUpdate()
想要不斷執行,也要在Unity生命週期函式中的Update
裡呼叫。那還不如直接繼承MonoBehavior
,這樣直接在Update
中呼叫curState.LogicalUpdate()
。而不這麼做是因為:如果一個物體掛載了這樣一個繼承了MonoBehavior
的狀態機,那它就只能是一個狀態機了。大家應該都知道,
Unity
中的動畫狀態機是分層級,這使得角色的各個部位可以執行不同的動畫。例如,下半身播放行走動畫,上半身播放射擊動畫,從而做到邊射擊邊移動。考慮到可能需要一個指令碼中使用多個狀態機,故而將它作為普通的類。
-
狀態有很多持續執行的邏輯,但並不是都適合在
Update
中呼叫怎麼辦?
這個也和之前設計「狀態」時的做法一樣,我們實現的這個FSM
也並非直接使用,最妥當的做法還是根據「狀態」進行繼承擴充,例如,我的狀態設計 動畫IK,有些需要在生命週期中的OnAnimatorIK
呼叫的邏輯,我們就可以這樣繼承:public class IK_FSM<T>: FSM<T> where T : IFSMState, IAnimIKState { public void OnAnimatorMove() { curState.AnimatorMove(); } public void OnAnimatorIK(int layerIndex) { curState.AnimatorIKUpdate(layerIndex); } }
示例
專案連結:https://gitee.com/OwlCat/some-projects-in-tutorials/tree/master/FSM
我們實現以下這樣的行為切換規則用以實踐有限狀態機:玩家在站立時,可切換到下蹲或跳躍(落地後站立);在下蹲後會一直蹲著,觸發主動站起來;蹲著時不能跳躍,且可以選擇揮拳;當玩家揮拳時可以選擇停止,且如果不是蹲著就不能揮拳。
這可以用兩個狀態機表示,一個控制大動作間的切換,一個負責手臂動作的切換:
首先我們定義一個掛載在角色身上用於控制的 PlayerController
指令碼,它包含一個控制動畫的動畫機,以及先前提到的兩個有限狀態機;還有幾個屬性讀取按鍵狀態,控制狀態的轉換條件的觸發:
using UnityEngine;
public class PlayerController : MonoBehaviour
{
public Animator animator; //動畫機
public PlayerFSM FSM_0; //大動作的狀態機
public PlayerFSM FSM_1; //單獨控制手臂動作的狀態機
//按下S鍵準備下蹲
public bool IsTryDown => Input.GetKey(KeyCode.S);
//按下W鍵準備起立
public bool IsTryUp => Input.GetKey(KeyCode.W);
//按下空格鍵準備跳躍
public bool IsTryJump => Input.GetKey(KeyCode.Space);
//按下A鍵準備拳擊
public bool IsTryPunch => Input.GetKey(KeyCode.A);
//按下D鍵停止拳擊
public bool IsTryStopPunch => Input.GetKey(KeyCode.D);
private void OnEnable()
{
FSM_0 = new PlayerFSM();
FSM_1 = new PlayerFSM();
}
private void Start()
{
}
private void Update()
{
FSM_0.OnUpdate();
FSM_1.OnUpdate();
}
}
接著,定義玩家狀態基類,如前所述它將繼承 IFSMState
介面,而由於每個狀態都有對應的動畫要播放,故而我們可以為每個狀態都配備一個動畫名字或動畫雜湊,以便進入到該狀態時,用動畫機播放。這其實有點像程式碼控制了Unity動畫控制器,只不過附帶了些額外邏輯。這是比較常見的做法,使得我們省去了動畫機中各個動畫切換間的連線。
using UnityEngine;
public class PlayerState : IFSMState
{
protected readonly int animHash; //動畫片段的雜湊
protected PlayerController agent;
//傳入agent主要是為了獲取其中的狀態機,animName是狀態播放的動畫的名字
public PlayerState(PlayerController agent, string animName)
{
this.agent = agent;
animHash = Animator.StringToHash(animName);
}
//預設一進入狀態就播放對應動畫
public virtual void Enter()
{
//animator.CrossFade函式可以實現動畫切換時的混合效果
agent.animator.CrossFade(animHash, 0.1f);
}
public virtual void Exit()
{
;
}
public virtual void LogicalUpdate()
{
;
}
}
然後是玩家狀態機,完成目前的任務並不需要額外函式,但考慮到手臂的狀態切換條件與大動作有關,所以我們將 curState
即「當前狀態」用屬性的方式公開,方便讀取狀態機的當前狀態:
public class PlayerFSM : FSM<PlayerState>
{
public PlayerState CurState => curState;
}
一切準備就緒,可以實現具體狀態了:
Player_Idle
視為「站立」Player_Jumping
視為「跳躍」Player_Down
視為「下蹲」Player_Down_Idle
視為「蹲著」Player_Up
視為「起立」Player_DoNothing
視為「無事」Player_Punch
視為「揮拳」
先來看看「站立」,根據需求,站立可以轉換成兩種狀態——蹲下與跳躍:
public class Player_Idle : PlayerState
{
public Player_Idle(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void LogicalUpdate()
{
if(agent.IsTryDown)
{
agent.FSM_0.ChangeState(typeof(Player_Down));
}
else if(agent.IsTryJump)
{
agent.FSM_0.ChangeState(typeof(Player_Jumping));
}
}
}
再來看看「蹲下」,下蹲只可以轉換成「蹲著」,而且理應是蹲下動畫播放完成後就變為「蹲著」:
public class Player_Down : PlayerState
{
public Player_Down(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void LogicalUpdate()
{
var curInfo = agent.animator.GetCurrentAnimatorStateInfo(0);
if(curInfo.normalizedTime > 0.98f && curInfo.shortNameHash == animHash)
{
agent.FSM_0.ChangeState(typeof(Player_Down_Idle));
}
}
}
注意,由於是使用 CrossFade
混合過渡動畫,所以只是判斷當前播放進度歸一化時間還不夠,還需確認當前動畫名字或雜湊是否與需要轉換到的動畫匹配。
因為沒有其它邏輯,所以其餘的狀態都與這兩個相差不大:
public class Player_Down_Idle : PlayerState
{
public Player_Down_Idle(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void LogicalUpdate()
{
if(agent.IsTryUp)
{
agent.FSM_0.ChangeState(typeof(Player_Up));
}
}
}
public class Player_Jumping : PlayerState
{
public Player_Jumping(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void LogicalUpdate()
{
var curInfo = agent.animator.GetCurrentAnimatorStateInfo(0);
if(curInfo.normalizedTime > 0.98f && curInfo.shortNameHash == animHash)
{
agent.FSM_0.ChangeState(typeof(Player_Idle));
}
}
}
public class Player_Up : PlayerState
{
public Player_Up(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void LogicalUpdate()
{
var curInfo = agent.animator.GetCurrentAnimatorStateInfo(0);
if(curInfo.normalizedTime > 0.98f && curInfo.shortNameHash == animHash)
{
agent.FSM_0.ChangeState(typeof(Player_Idle));
}
}
}
接下來便是第二個狀態機了,也一樣簡單,只不過要注意,此時控制的應當是 FSM_1
而且動畫機的 CrossFade
或 Play
應當用於層級1而非預設的層級0:
public class Player_DoNothing : PlayerState
{
public Player_DoNothing(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void Enter()
{
//用於層級1,不用CrossFade是因為DoNothing是個空動畫片段,無需過渡
agent.animator.Play(animHash, 1);
}
public override void LogicalUpdate()
{
//讀取了FSM_0的狀態並進行判斷,如果「蹲著」且試圖揮拳才進入「揮拳」
if(agent.FSM_0.CurState is Player_Down_Idle && agent.IsTryPunch)
{
agent.FSM_1.ChangeState(typeof(Player_Punch));
}
}
}
public class Player_Punch : PlayerState
{
public Player_Punch(PlayerController agent, string animName) : base(agent, animName)
{
}
public override void Enter()
{
agent.animator.CrossFade(animHash, 0.1f, 1);
}
public override void LogicalUpdate()
{
if(agent.FSM_0.CurState is not Player_Down_Idle || agent.IsTryStopPunch)
{
agent.FSM_1.ChangeState(typeof(Player_DoNothing));
}
}
}
最後,在 PlayerController
中為兩個狀態機,新增各自狀態:
using UnityEngine;
public class PlayerController : MonoBehaviour
{
public Animator animator; //動畫機
public PlayerFSM FSM_0; //第一層狀態機
public PlayerFSM FSM_1; //第二層狀態機
public bool IsTryDown => Input.GetKey(KeyCode.S);
public bool IsTryUp => Input.GetKey(KeyCode.W);
public bool IsTryJump => Input.GetKey(KeyCode.Space);
public bool IsTryPunch => Input.GetKey(KeyCode.A);
public bool IsTryStopPunch => Input.GetKey(KeyCode.D);
private void OnEnable()
{
FSM_0 = new PlayerFSM();
FSM_0.AddState(new Player_Idle(this, "Idle"));
FSM_0.AddState(new Player_Down(this, "Down"));
FSM_0.AddState(new Player_Down_Idle(this, "Down_Idle"));
FSM_0.AddState(new Player_Up(this, "Up"));
FSM_0.AddState(new Player_Jumping(this, "Jumping"));
FSM_1 = new PlayerFSM();
FSM_1.AddState(new Player_DoNothing(this, "DoNothing"));
FSM_1.AddState(new Player_Punch(this, "Punching"));
}
private void Start()
{
FSM_0.SwitchOn(typeof(Player_Idle));
FSM_1.SwitchOn(typeof(Player_DoNothing));
}
private void Update()
{
FSM_0.OnUpdate();
FSM_1.OnUpdate();
}
}
這些動畫名字當然是根據動畫機裡的:
最終效果符合預期:
-
FSM_0
-
FSM_1
其它應用
目前我們主要討論的是純粹使用有限狀態機在角色控制上的應用,其實它也很容易與其它決策方式進行融合。以 HTN(分層任務網路)
為例,HTN
可以為角色AI規劃出未來的行為序列並逐一執行,但在實際執行時,也常會因外部原因而中斷。
例如,HTN
規劃出了一個小兵的行動為:前往兵器庫,拾取武器,返回城牆,巡邏。但鑑於小兵是比較低階的怪,如果受到攻擊,無論他在執行上述哪一部,都應當打斷並重新規劃。這樣就必須在每次執行前的條件中新增“沒有受傷”:
public class Enemy_Patrol : EnemyTask
{
……
protected override bool MetCondition_OnPlan(Dictionary<string, object> worldState)
{
//沒檢查到敵人且沒受傷時方可巡邏
return !manager.CheckEnemy() && !(bool)worldState[isHurtStr];
}
protected override bool MetCondition_OnRun()
{
//同上
return !manager.CheckEnemy() && !HTNWorld.GetWorldState<bool>(isHurtStr);
}
……
}
而一想到很多的行為其實在受到攻擊時都應當被打斷,這樣新增額外條件判斷屬實繁瑣。當然,這時純粹用HTN
決策時的問題,我們而將有限狀態機與 HTN
結合的話就簡單很多了,結構如下:
非常小巧的有限狀態機,但能將這種意外的中斷從HTN
中分離出來。類似的構思其實也不少,像首個使用了 GOAP
作為敵人AI的遊戲《F.E.A.R》,他們是用 GOAP
規劃出合適的行為序列,再交給有限狀態機去執行行為。
結尾
有限狀態機是比較基礎的行為決策方式,但又不限於行為決策,像遊戲程序的控制,開始遊戲,暫停遊戲,退出遊戲,重來遊戲……也可以視為一個個狀態並用狀態機管理。只要能將問題抽象成狀態間的轉換,都可以嘗試用有限狀態機解決,會使得邏輯更加清晰。更多用法還得從實踐中去學習啦!