有限状态机(FSM)的使用

news/2024/11/17 15:33:46/文章来源:https://www.cnblogs.com/OwlCat/p/18550607

有限状态机的使用

有限状态机在游戏制作中十分常见,它既可以作为玩家角色的控制框架,纯代码控制动画的播放,免去动画间的“连连看”;也可以制作简单的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规划出合适的行为序列,再交给有限状态机去执行行为。

结尾

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/835364.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【学习篇】patran设置阻尼

在数学和力学甚至机械专业中,质量-弹簧-阻尼系统是基础、经典的模型。其数学模型表示为: $$ m\ddot{x} +c\dot{x} +kx = 0 \qquad(1) $$ 有限元模型比较简单,就是两个质量点+弹簧假设上述式子的初值为 $$ \begin{cases} u(0)=0.2 \ \dot{u}(0)=0 \end{cases} $$ 各项系数为变…

mysql 查询每个订单总价和购买商品的总类数

数据表 CREATE TABLE goods ( order_id INT NOT NULL, goods_id INT NOT NULL, price DECIMAL(10, 2) NOT NULL ); 插入数据 INSERT INTO goods (order_id, goods_id, price) VALUES (1, 1, 3.5), (1, 2, 4.0), (2, 4, 6.0); 数据查询 SELECT order_id, SUM(price) AS total_pr…

【视频讲解】Python深度神经网络DNNs-K-Means(K-均值)聚类方法在MNIST等数据可视化对比分析

全文链接:https://tecdat.cn/?p=38289 原文出处:拓端数据部落公众号 分析师:Cucu Sun 近年来,由于诸如自动编码器等深度神经网络(DNN)的高表示能力,深度聚类方法发展迅速。其核心思想是表示学习和聚类可以相互促进:好的表示会带来好的聚类效果,而好的聚类为表示学习提…

时间

JDK7 时间 全世界的时间, 有一个统一的计算标准. 格林尼治时间/格林威治时间 (Greenwich Mean Time) 简称 GMT. 计算核心: 地球自转一天是 24 小时, 太阳直射时为正午 12 点. 后来发现计算误差较大, 现在格林威治时间已经不再作为标准时间来使用了. 到了 2012 年 1 月, 取消了用…

贴代码框架PasteForm特性介绍之markdown和richtext

简介 PasteForm是贴代码推出的 “新一代CRUD” ,基于ABPvNext,目的是通过对Dto的特性的标注,从而实现管理端的统一UI,借助于配套的PasteBuilder代码生成器,你可以快速的为自己的项目构建后台管理端!目前管理端只有Html+js版本的,后续将支持小程序,Vue等 案例源码 案例源…

MATLAB用CNN-LSTM神经网络的语音情感分类深度学习研究

全文链接:https://tecdat.cn/?p=38258 原文出处:拓端数据部落公众号 在语音处理领域,对语音情感的分类是一个重要的研究方向。本文将介绍如何通过结合二维卷积神经网络(2 - D CNN)和长短期记忆网络(LSTM)构建一个用于语音分类任务的网络,特别是针对语音情感识别这一应…

2024长城靶场训练

仿射密码 首先题目描述 使用仿射函数y=3x+9加密得到的密文为JYYHWVPIDCOZ,请尝试对其解密。flag为flag{大写明文}。 1、使用在线网站直接破解或手工计算破解,获得flag。(参数a=3,b=9,对应仿射函数y=3x+9) 仿射密码加密_仿射密码解密手工计算使用解密函数为D(x) = a^-1(x …

学期2024-2025-1 学号20241421 《计算机基础与程序设计》第8周学习总结

作业信息 |这个作业属于哪个课程|https://edu.cnblogs.com/campus/besti/2024-2025-1-CFAP| |这个作业要求在哪里|https://www.cnblogs.com/rocedu/p/9577842.html#WEEK08| |这个作业的目标|功能设计与面向对象设计,面向对象设计过程,面向对象语言三要素,汇编、编译、解释、…

QObject,QMainWindpw,QWidget,QDialog介绍

QObject QObject 的角色和特点 在 Qt 框架中,QObject 是整个对象模型的核心基类,它为 Qt 对象树 和 信号-槽机制 提供了基础支持。很多 Qt 的类(包括 QWidget、QDialog、QMainWindow)都直接或间接继承自 QObject。 QObject 的核心功能对象树管理(Object Tree)QObject 提供…

2024-2025-1 20241329 《计算机基础与程序设计》第八周学习总结

作业信息 作业归属课程:https://edu.cnblogs.com/campus/besti/2024-2025-1-CFAP 作业要求:https://www.cnblogs.com/rocedu/p/9577842.html#WEEK08 作业目标:功能设计与面向对象设计;面向对象设计过程;面向对象语言三要素;汇编、编译、解释、执行 作业正文:https://www…

Alpha冲刺(4/14)——2024.11.15

目录一、团队成员分工与进度二、成员任务问题及处理方式三、冲刺会议内容记录会议内容四、GitHub签入记录及项目运行截图GitHub签入记录五、项目开发进展及燃尽图项目开发进展燃尽图六、团队成员贡献表 一、团队成员分工与进度成员 完成的任务 完成的任务时长 剩余时间施靖杰 完…