這篇文章轉載自 Unity 社區開發者狐王加護,記錄了作者對有限狀態機的理解與實現方式,包含示例項目與代碼。狐王加護在 Unity 中國開發者社區持續更新技術內容中, 點擊閱讀原文,前往 狐王加護的社區主頁 ,閱讀更多干貨文章~
有限狀態機在游戲制作中十分常見,它既可以作為玩家角色的控制框架,純代碼控制動畫的播放,免去動畫間的“連連看”;也可以制作簡單的 AI,甚至還可以搭配其它 AI 決策方式做出更復雜易用的 AI 控制……本文僅是個人對有限狀態機的理解,與大家一同交流有限狀態機的使用。
有限狀態機的介紹
有限狀態機(finite-state machine,縮寫:FSM),本身是一種數學計算模型,用于有限幾個「狀態」的動作與它們之間的轉換。大概長這樣:
此物在 Unity 中亦有記載——那就是動畫控制器,它也是一種有限狀態機,只不過各個狀態都是動畫片段,它們之間的轉化的條件是參數。
一個狀態機中,只能同時處于一個狀態。而且,一個狀態中不能用相同條件轉移到不同狀態,因為這樣違背了「同時處于一個狀態」這點,例如下面這樣:
「狀態」并不是具體的,只要你有辦法定義,它可以是別的任何東西;而狀態轉換的條件更是可以小到變量、大到函數。
有限狀態機有個非常重要的特點:下一個狀態只能從當前狀態轉換,這就使得控制的邏輯變得清晰。游戲開發中,我們就可以將角色的一個行為作為一種「狀態」,一些條件判斷作為轉換的依據。
代碼實現有限狀態機
狀態
首先我們定義有限狀態機中的「狀態」,如前文所言,「狀態」可以是很多東西,但通常都少不了以下內容:
進入該狀態時會執行一次的邏輯
處于該狀態時會不斷執行的邏輯
退出該狀態(轉移到其它狀態)時會執行一次的邏輯
故而,我們可以這樣將它們以接口的方式定義:
public interface IFSMState
{
///
/// 進入該狀態時執行的
///
void Enter();
///
/// 相當于用Unity生命周期中的Update,用于邏輯更新
///
void LogicalUpdate();
///
/// 狀態結束時(即轉移出時)執行的
///
void Exit();
}
只要繼承了這個接口,就可以作為一種「狀態」。什么?你說你的角色還會用到FixedUpdate、 OnAnimatorIK 等其它的「不斷更新」的函數,該如何在「狀態」中增加這些邏輯?
其實我們所寫的雖為接口,但并不能直接作為根本,我是說具體狀態并非是直接繼承這個接口實現的,考慮到實際中,所謂處于該狀態時會不斷執行的邏輯可能不止一種,所以我們要用一個繼承了這個接口的類作為基類狀態(在「示例」部分會展示這一點)。
我們并不需要對轉換條件單獨寫一個類,轉換條件可以直接寫在諸如 LogicalUpdate 這類函數中,自行判斷切換(示例中有體現)。
狀態機
狀態機的設計需要考慮以下問題:
能方便地增加與查找各個狀態
能方便的切換狀態
能很好地執行狀態的邏輯(即狀態進入、退出、持續執行的那些邏輯)
對于第一個問題,我們可以使用字典存儲狀態,這樣就方便增加與查找。但該用什么作為字典的鍵值呢?首先,我們知道狀態機中的各個狀態是沒有重復的(兩個相同的狀態也沒什么意義好吧),或許可以給各個狀態起個名字用作鍵值,當然也可以自定義枚舉變量。但這些都要額外多些變量,莫不如就用狀態本身的類型(System.Type),故而我們可以這么寫:
using System.Collections.Generic;
public class FSM where T : IFSMState
{
//狀態表
public Dictionary StateTable{ get; protected set; }
public FSM()
{
StateTable = new Dictionary ();
}
//添加狀態
public void AddState(T state)
{
StateTable.Add(state.GetType(), state);
}
}
接著,該看看如何切換了。已知狀態機時刻只能處以一個狀態,那么我們就定義一個「當前狀態」,切換便是這個變量的變化:
using System.Collections.Generic;
public class FSM where T : IFSMState
{
public Dictionary StateTable{ get; protected set; } //狀態表
protected T curState; //當前狀態
public FSM()
{
StateTable = new Dictionary ();
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 where T : IFSMState
{
public Dictionary StateTable{ get; protected set; } //狀態表
protected T curState; //當前狀態
public FSM()
{
StateTable = new Dictionary ();
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 : FSM 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
{ 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
worldState) { //沒檢查到敵人且沒受傷時方可巡邏 return !manager.CheckEnemy() && !(bool)worldState[isHurtStr]; } protected override bool MetCondition_OnRun() { //同上 return !manager.CheckEnemy() && !HTNWorld.GetWorldState
(isHurtStr); } …… }
而一想到很多的行為其實在受到攻擊時都應當被打斷,這樣添加額外條件判斷屬實繁瑣。當然,這時純粹用 HTN 決策時的問題,我們而將有限狀態機與 HTN 結合的話就簡單很多了,結構如下:
非常小巧的有限狀態機,但能將這種意外的中斷從 HTN 中分離出來。類似的構思其實也不少,像首個使用了 GOAP 作為敵人 AI 的游戲《F.E.A.R》,他們是用 GOAP 規劃出合適的行為序列,再交給有限狀態機去執行行為。
結尾
有限狀態機是比較基礎的行為決策方式,但又不限于行為決策,像游戲進程的控制,開始游戲,暫停游戲,退出游戲,重來游戲……也可以視為一個個狀態并用狀態機管理。只要能將問題抽象成狀態間的轉換,都可以嘗試用有限狀態機解決,會使得邏輯更加清晰。更多用法還得從實踐中去學習啦!
Unity 中文社區持續征集內容投稿,歡迎與 Unity 官方分享你的技術筆記、項目 demo、行業經驗、有趣案例,加入社區建設,繁榮內容生態,帶領百萬 Unity 中文開發者一同學習。
投稿方式:
方式一:在 Unity 中文社區首頁(https://developer.unity.cn/)創建個人賬號,點擊【寫文章】,發表文章;
方式二:聯系郵箱 learn-cn@unity.cn , 投稿技術內容。
Unity 官方微信
第一時間了解Unity引擎動向,學習進階開發技能
每一個“在看”,都是我們前進的動力
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.