有限状态机(FSM)的使用

有限状态机的使用

有限状态机在游戏制作中十分常见,它既可以作为玩家角色的控制框架,纯代码控制动画的播放,免去动画间的“连连看”;也可以制作简单的AI,甚至还可以搭配其它AI决策方式做出更复杂易用的AI控制……本文仅是个人对有限状态机的理解,与大家一同交流有限状态机的使用。

有限状态机的介绍

有限状态机(finite-state machine,缩写:FSM),本身是一种数学计算模型,用于有限几个「状态」的动作与它们之间的转换。大概长这样:

image

此物在Unity中亦有记载——那就是动画控制器,它也是一种有限状态机,只不过各个状态都是动画片段,它们之间的转化的条件是参数。

image

一个状态机中,只能同时处于一个状态,而且,一个状态中不能用相同条件转移到不同状态,因为这样违背了「同时处于一个状态」这点,例如下面这样:

image

「状态」并不是具体的,只要你有办法定义,它可以是别的任何东西;而状态转换的条件更是可以小到变量、大到函数。

有限状态机有个非常重要的特点:下一个状态只能从当前状态转换。这就使得控制的逻辑变得清晰。游戏开发中,我们就可以将角色的一个行为作为一种「状态」,一些条件判断作为转换的依据。

代码实现有限状态机

状态

首先我们定义有限状态机中的「状态」,如前文所言,「状态」可以是很多东西,但通常都少不了以下内容:

  • 进入该状态时会执行一次的逻辑
  • 处于该状态时会不断执行的逻辑
  • 退出该状态(转移到其它状态)时会执行一次的逻辑

故而,我们可以这样将它们以接口的方式定义:

public interface IFSMState
{
    /// <summary>
    /// 进入该状态时执行的
    /// </summary>
    void Enter();

    /// <summary>
    /// 相当于用Unity生命周期中的Update,用于逻辑更新
    /// </summary>
    void LogicalUpdate();

    /// <summary>
    /// 状态结束时(即转移出时)执行的
    /// </summary>
    void Exit();
}

只要继承了这个接口,就可以作为一种「状态」。什么?你说你的角色还会用到FixedUpdateOnAnimatorIK等其它的「不断更新」的函数,该如何在「状态」中增加这些逻辑?

其实我们所写的虽为接口,但并不能直接作为根本,我是说具体状态并非是直接继承这个接口实现的,考虑到实际中,所谓处于该状态时会不断执行的逻辑可能不止一种,所以我们要用一个继承了这个接口的类作为基类状态(在「示例」部分会展示这一点)。

我们并不需要对转换条件单独写一个类,转换条件可以直接写在诸如 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();
    }
}

也许你心中还有一些疑问,看我猜的准不准:

  1. 为什么状态机是作为普通的类,而不是继承MonoBehavior
     
    合情合理的问题 (我自己也用过继承MonoBehavior的状态机,毕竟 FSM.OnUpdate() 想要不断执行,也要在Unity生命周期函数中的 Update 里调用。那还不如直接继承 MonoBehavior,这样直接在 Update 中调用 curState.LogicalUpdate()。而不这么做是因为:如果一个物体挂载了这样一个继承了 MonoBehavior 的状态机,那它就只能是一个状态机了。

    image

    大家应该都知道,Unity中的动画状态机是分层级,这使得角色的各个部位可以执行不同的动画。例如,下半身播放行走动画,上半身播放射击动画,从而做到边射击边移动。考虑到可能需要一个脚本中使用多个状态机,故而将它作为普通的类。
     

  2. 状态有很多持续执行的逻辑,但并不是都适合在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

我们实现以下这样的行为切换规则用以实践有限状态机:玩家在站立时,可切换到下蹲或跳跃(落地后站立);在下蹲后会一直蹲着,触发主动站起来;蹲着时不能跳跃,且可以选择挥拳;当玩家挥拳时可以选择停止,且如果不是蹲着就不能挥拳。

这可以用两个状态机表示,一个控制大动作间的切换,一个负责手臂动作的切换:

image

首先我们定义一个挂载在角色身上用于控制的 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 而且动画机的 CrossFadePlay 应当用于层级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();
    }
}

这些动画名字当然是根据动画机里的:

image

最终效果符合预期:

  • FSM_0

    image
  • FSM_1

    image

其它应用

目前我们主要讨论的是纯粹使用有限状态机在角色控制上的应用,其实它也很容易与其它决策方式进行融合。以 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 结合的话就简单很多了,结构如下:

image

非常小巧的有限状态机,但能将这种意外的中断从HTN中分离出来。类似的构思其实也不少,像首个使用了 GOAP 作为敌人AI的游戏《F.E.A.R》,他们是用 GOAP规划出合适的行为序列,再交给有限状态机去执行行为。

结尾

有限状态机是比较基础的行为决策方式,但又不限于行为决策,像游戏进程的控制,开始游戏,暂停游戏,退出游戏,重来游戏……也可以视为一个个状态并用状态机管理。只要能将问题抽象成状态间的转换,都可以尝试用有限状态机解决,会使得逻辑更加清晰。更多用法还得从实践中去学习啦!

posted @ 2024-11-17 15:31  狐王驾虎  阅读(211)  评论(0编辑  收藏  举报