这篇文章转载自 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引擎动向,学习进阶开发技能

每一个“在看”,都是我们前进的动力