GameFramework 解析:有限状态机(FSM)

什么是有限状态机

有限状态机的概念相信很多同学都清楚了,不清楚的可以参考一下书籍《游戏编程模式》中状态模式一节,里面讲得十分清楚。FSM 在游戏中常用于玩家控制、怪物 AI、UI 状态、游戏流程控制等。

有限状态机的实现

结构

有限状态机的实现我们可以把他分成 3 部分,上图中从上到下每一行就是一部分,分别是状态部分(FsmState),状态机部分(FsmBase、IFsm、Fsm)以及状态机管理器部分(IFsmManager、FsmManager)。

状态类 FsmState

  • FsmState 为有限状态机状态基类,所有用于有限状态机的状态都需要继承自此类,泛型参数 T 需要传入状态持有者类型。
  • OnInit、OnEnter、OnUpdate、OnLeave、OnDestroy 为状态的生命周期方法,其中 OnInit 和 OnDestroy 分别在状态创建和销毁时调用,只会调用一次,而 OnEnter、OnLeave 分别在进入状态和离开状态时调用,可能会调用多次,而 OnUpdate 则是在进入该状态后每帧调用。
  • ChangeState 用于切换到下一状态。ChangeState 实际是用该方法传入的 FSM 对象调用 FSM 类里的 ChangeState 方法,正式执行状态切换逻辑。

状态机类 Fsm

  • Fsm 对象通过 Create 方法创建,需要传入状态机拥有者类型、状态机名字、状态列表 3 个参数,Create 方法为静态方法,由 FsmManager 调用。参数状态列表将会保存在字段 m_States 中,并调用所有状态的 OnInit 方法。
  • 状态机通过 Start 方法启动,传入初始状态类型作为参数,方法内部会调用该状态的 OnEnter。
  • Update 方法会每帧调用当前状态的 Update 方法,且会计算当前状态机进行了的累计时间,可通过 CurrentStateTime 获取。
  • GetAllState 和 GetState 方法可以获取注册进这个状态机的状态对象。
  • 状态机内通常不同状态之间是需要有数据交互的,GetData,SetData,HasData,RemoveData 这四个接口则提供了不同状态间数据交互的功能,分别对应获取数据、设置数据、是否有数据、移除数据,数据以 key-value 形式存在于字典 m_Datas 中。
  • Shutdown 方法会回收 FSM 对象,此方法由 FsmManager 的 DestroyFsm 方法调用。

状态机管理器 FsmManager

  • 外部创建新的状态机统一通过 FsmManager 的 CreateFsm 接口创建,参数同 FSM 类中的静态方法 Create,此方法会调用 Fsm 类的 Create 创建 Fsm 对象,然后以 key-value 的形式储存在字段 m_Fsms 中,注意 m_Fsms 是 Dictionary 类型,以 TypeNamePair 为 Key,TypeNamePair 对象是结合状态机持有者类型和状态机名字字符串类型参数组成,为了保证 Key 的唯一性,对于同样类型的而不同实例的持有者,应该传入不同的状态机名字。
  • GetFsm、GetAllFsm、HasFsm,向外部提供某个状态机的查询、获取,需要传入持有者类型和状态机名字两个参数。
  • DestroyFsm 可销毁特定状态机,会调用对应 Fsm 对象的 Shutdown 方法,并在 FsmManager 的 m_Fsms 字段中移除该状态机。
internal override void Update(float elapseSeconds, float realElapseSeconds)
{
    m_TempFsms.Clear();
    if (m_Fsms.Count <= 0)
    {
        return;
    }
 
    foreach (KeyValuePair<TypeNamePair, FsmBase> fsm in m_Fsms)
    {
        m_TempFsms.Add(fsm.Value);
    }
 
    foreach (FsmBase fsm in m_TempFsms)
    {
        if (fsm.IsDestroyed)
        {
            continue;
        }
 
        fsm.Update(elapseSeconds, realElapseSeconds);
    }
}
  • Update 方法中会调用 m_Fsms 中的所有状态机的 Update 方法,值得注意的是这里并没有直接对 m_Fsms 进行 foreach,而是添加到一个临时的列表中再进行循环调用,这样可以防止在迭代过程中,外部销毁某个状态机而从 m_Fsms 移除状态机对象时,造成迭代器失效。

示例

假设我们现在需要用状态来实现玩家的控制,其中包括空闲和移动状态,处于空闲状态下的玩家当检测到方向键按下时,会切换到移动状态,且根据方向键向某个方向进行移动,移动过程持续一秒。
我们需要 3 个类去实现这一需求,其中 IdleState、MoveState 两个类分别对应空闲状态、移动状态,Player 则为状态机的持有者,也是状态机要控制的主体。

空闲状态类

using UnityEngine;
using GameFramework.Fsm;
using ProcedureOwner = GameFramework.Fsm.IFsm<Player>;
using UnityGameFramework.Runtime;
 
public class IdleState : FsmState<Player>
{
    //触发移动的指令列表
    private static KeyCode[] MOVE_COMMANDS = { KeyCode.LeftArrow, KeyCode.RightArrow, KeyCode.UpArrow, KeyCode.DownArrow };
 
    protected override void OnInit(ProcedureOwner fsm) {
        base.OnInit(fsm);
    }
 
    protected override void OnEnter(ProcedureOwner fsm) {
        base.OnEnter(fsm);
    }
 
    protected override void OnUpdate(ProcedureOwner fsm, float elapseSeconds, float realElapseSeconds) {
        base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
 
        foreach (var command in MOVE_COMMANDS)
        {
            //触发任何一个移动指令时
            if (Input.GetKeyDown(command))
            {
                //记录这个移动指令
                fsm.SetData<VarInt32>("MoveCommand", (int)command);
                //切换到移动状态
                ChangeState<MoveState>(fsm);
            }
        }
    }
 
    protected override void OnLeave(ProcedureOwner fsm, bool isShutdown) {
        base.OnLeave(fsm, isShutdown);
    }
 
    protected override void OnDestroy(ProcedureOwner fsm) {
        base.OnDestroy(fsm);
    }
}

移动状态类

using UnityEngine;
using GameFramework.Fsm;
using ProcedureOwner = GameFramework.Fsm.IFsm<Player>;
using UnityGameFramework.Runtime;
 
public class MoveState : FsmState<Player>
{
    private static readonly float EXIT_TIME = 1f;
    private float exitTimer;
    private KeyCode moveCommand;
 
    protected override void OnInit(ProcedureOwner fsm) {
        base.OnInit(fsm);
    }
 
    protected override void OnEnter(ProcedureOwner fsm) {
        base.OnEnter(fsm);
 
        //进入移动状态时,获取移动指令数据
        moveCommand = (KeyCode)(int)fsm.GetData<VarInt32>("MoveCommand");
    }
 
    protected override void OnUpdate(ProcedureOwner fsm, float elapseSeconds, float realElapseSeconds) {
        base.OnUpdate(fsm, elapseSeconds, realElapseSeconds);
 
        //计时器累计时间
        exitTimer += elapseSeconds;
        //switch(moveCommand)
        //{
        //根据移动方向指令向对应方向移动
        //}
 
        //达到指定时间后
        if (exitTimer > EXIT_TIME)
        {
            //切换回空闲状态
            ChangeState<IdleState>(fsm);
        }
    }
 
    protected override void OnLeave(ProcedureOwner fsm, bool isShutdown) {
        base.OnLeave(fsm, isShutdown);
 
        //推出移动状态时,把计时器清零
        exitTimer = 0;
        //清空移动指令
        moveCommand = KeyCode.None;
        fsm.RemoveData("MoveCommand");
    }
 
    protected override void OnDestroy(ProcedureOwner fsm) {
        base.OnDestroy(fsm);
    }
}

玩家类

using System.Collections.Generic;
using UnityEngine;
using GameFramework.Fsm;
using StarForce;
 
public class Player : MonoBehaviour
{
    //Player对象自增Id
    private static int SERIAL_ID = 0;
 
    private IFsm<Player> fsm;
 
    // Start is called before the first frame update
    void Start() {
        //创建状态列表
        List<FsmState<Player>> stateList = new List<FsmState<Player>>() { new IdleState(), new MoveState() };
        //创建状态机,注意,对于所有持有者为Player类型的状态机的名字参数不能重复,这里用自增ID避免重复
        fsm = GameEntry.Fsm.CreateFsm<Player>((SERIAL_ID++).ToString(), this, stateList);
        //以IdleState为初始状态,启动状态机
        fsm.Start<IdleState>();
    }
 
    // Update is called once per frame
    void Update() {
 
    }
 
    private void OnDestroy() {
        //销毁状态机
        GameEntry.Fsm.DestroyFsm(fsm);
    }
}

Inspector 面板

FSM 组件的 Inspector 面板可以实时看到所有正在运行的状态机,以及这些状态机当前处于的状态、运行时间。

最后

GameFramework 解析 系列目录:GameFramework 解析:开篇

个人原创,未经授权,谢绝转载!