有限状态机(FSM)的使用
有限状态机的使用
有限状态机在游戏制作中十分常见,它既可以作为玩家角色的控制框架,纯代码控制动画的播放,免去动画间的“连连看”;也可以制作简单的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
规划出合适的行为序列,再交给有限状态机去执行行为。
结尾
有限状态机是比较基础的行为决策方式,但又不限于行为决策,像游戏进程的控制,开始游戏,暂停游戏,退出游戏,重来游戏……也可以视为一个个状态并用状态机管理。只要能将问题抽象成状态间的转换,都可以尝试用有限状态机解决,会使得逻辑更加清晰。更多用法还得从实践中去学习啦!