Unity笔记-34-FSM(有限状态机)
前言
模仿Unity动画状态机写一个简易的状态机框架,用于状态管理
思路分析
状态
对于每一个状态,它都有以下属性:
- 状态名称
- 当前是否在运行
- 状态转换的目标状态与转化条件
一二两点很容易
//状态名称public string StateName { get; set; }/// <summary>/// 标记当前状态是否正在运行/// </summary>public bool IsRun { get; set; }
第三点,状态转换的目标与转化条件是一个键值对,转化目标使用状态名称标识即可,转化条件显然是一个方法用于判断满足与否,因此使用委托。那么使用字典就十分合适了。
//存储“箭头”状态转化的字典public Dictionary<string,Func<bool>> TransitionStatesDic { get; set; }
转化的对应状态对应其转化条件进行存储即可。
Fucn<bool>
为返回值为bool
类型的无参委托。
至此,基本属性已经定义完毕。
我们首先考虑状态转化目标的注册与移除,我们就需要两个方法去做这件事情。
注册状态转化目标
/// <summary>/// 注册状态转化/// </summary>/// <param name="stateName">状态名称</param>/// <param name="condition">条件</param>public void RegisterTransitionState(string stateName,Func<bool> condition){if (!TransitionStatesDic.ContainsKey(stateName)){//添加TransitionStatesDic.Add(stateName,condition);}else{//更新TransitionStatesDic[stateName] = condition;}}
参数显然就是状态名称与委托方法。
首先判断字典中是否已经包含该状态,如果已经包含,则更新转化条件。否则,将其添加到字典中。
移除状态转化目标
移除就更简单了。
/// <summary>/// 移除状态转化/// </summary>/// <param name="stateName">状态名称</param>public void UnRegisterTransitionState(string stateName){if (TransitionStatesDic.ContainsKey(stateName)){TransitionStatesDic.Remove(stateName);}}
仅仅这些当然还不够
对于一个状态,它应该拥有进入事件(Enter),更新事件(Update),退出事件(Exit)
对应状态的三种情况所调用的方法。
我这里的思路是使用委托。
/// <summary>/// 状态进入事件/// </summary>public event Action<object[]> OnStateEnter;/// <summary>/// 状态更新事件/// </summary>public event Action<object[]> OnStateUpdate;/// <summary>/// 状态退出事件/// </summary>public event Action<object[]> OnStateExit;
通过委托去绑定想要绑定的方法,再统一调用。这样更加方便
再给出调用接口即可
这里的虚方法后面会讲。参数就是委托定义好的参数就行。
这里需要重点说明的是,更新事件
进入事件与退出事件之需要调用一次即可
而更新事件需要再状态运行中每间隔一定事件调用。但是状态类本身不继承MonoBehaviour
因此我创建了一个工具类MonoHelper
,让他去继承MonoBehaviour
并统一调用当下所有正在运行的状态更新事件
而将更新事件添加到工具类的时机则是进入状态后立刻添加
这里首先讲解工具类MonoHelper
为了能够调用所有的状态的更新事件,我工具类内部创建了了一个内部类StateUpdateModule
状态更新模型
他的代码如下:
class StateUpdateModule{/// <summary>/// 状态更新事件/// </summary>public Action<object[]> stateUpdateEvent;/// <summary>/// 触发状态更新事件的参数 /// </summary>public object[] stateUpdateParameters;public StateUpdateModule(Action<object[]> e,object[] paras){this.stateUpdateEvent = e;this.stateUpdateParameters = paras;}}
对于这个类,它有用属性:状态更新事件的委托,委托对应的输入参数。再添加每个状态的更新事件时,会创建这样一个类并进行存储。存储方法仍然是字典
/// <summary>/// 状态更新模块数组/// </summary>private Dictionary<string, StateUpdateModule> stateUpdateModuleDic;
状态名称——状态更新模型
因此我们之需要再添加好这些类之后,统一的调用他们即可。
统一的调用需要注意一个问题,正常思路是,遍历字典去调用模型的每一个更新事件即可。但是在遍历字典的过程中,仍然可能会有新的状态更新事件被加入,而这会导致错误。因此每次添加/移除事件后,要将字典转化为数组,再遍历数组即可,为了方便获取,将工具类做成单例
至于执行间隔,通过创建一个协程去处理即可。
(另外,Unity不推荐使用多线程或者说不太支持,因此才需要一个工具类,用多线程固然可以,但是会产生不可预料的错误)
工具类的完整代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;public class MonoHelper : MonoBehaviour
{class StateUpdateModule{/// <summary>/// 状态更新事件/// </summary>public Action<object[]> stateUpdateEvent;/// <summary>/// 触发状态更新事件的参数 /// </summary>public object[] stateUpdateParameters;public StateUpdateModule(Action<object[]> e,object[] paras){this.stateUpdateEvent = e;this.stateUpdateParameters = paras;}}/// <summary>/// 单例脚本/// </summary>public static MonoHelper Instance;public void Awake(){Instance = this;stateUpdateModuleDic = new Dictionary<string, StateUpdateModule>();}[Header("更新事件的执行间隔")]public float invokeInterval = -1;/// <summary>/// 状态更新模块数组/// </summary>private Dictionary<string, StateUpdateModule> stateUpdateModuleDic;private StateUpdateModule[] stateModuleArray;/// <summary>/// 字典数据转化为数组数据,防止集合遍历时变更导致错误/// </summary>private void DicToArray(){stateModuleArray = new StateUpdateModule[stateUpdateModuleDic.Count];int index = 0;foreach(var item in stateUpdateModuleDic){stateModuleArray[index++] = item.Value;}}/// <summary>/// 添加更新事件/// </summary>/// <param name="stateName">状态名称</param>/// <param name="updateEvent">状态更新事件</param>/// <param name="updateEventParameters">更新事件参数</param>public void AddUpdateEvent(string stateName,Action<object[]> updateEvent,object[] updateEventParameters){if (stateUpdateModuleDic.ContainsKey(stateName)){//更新Update事件与参数stateUpdateModuleDic[stateName] = new StateUpdateModule(updateEvent, updateEventParameters);}else{//添加Update事件与参数stateUpdateModuleDic.Add(stateName, new StateUpdateModule(updateEvent, updateEventParameters));}//添加完毕从集合更新数组DicToArray();}/// <summary>/// 移除事件/// </summary>/// <param name="stateName">状态名称</param>public void RemoveUpdateEvent(string stateName){if (stateUpdateModuleDic.ContainsKey(stateName)){stateUpdateModuleDic.Remove(stateName);//移除完毕从集合更新数组DicToArray();}}private IEnumerator Start(){while (true){if (invokeInterval <= 0)yield return 0;//等待一帧elseyield return new WaitForSeconds(invokeInterval);//等待一个时间间隔//foreach (KeyValuePair<string, StateUpdateModule> st in stateUpdateModuleDic)//{// st.Value.stateUpdateEvent(st.Value.stateUpdateParameters);//}//遍历数组for (int i = 0; i < stateModuleArray.Length; i++){if (stateModuleArray[i].stateUpdateEvent != null)stateModuleArray[i].stateUpdateEvent(stateModuleArray[i].stateUpdateParameters);}}}
}
状态的进入与退出事件接口
/// <summary>/// 进入事件接口/// </summary>/// <param name="enterParameters">进入事件参数</param>/// <param name="updateParameters">更新事件</param>public virtual void EnterState(object[] enterParameters,object[] updateParameters){if (OnStateEnter != null){//执行进入状态的事件OnStateEnter(enterParameters);}//绑定当前状态的更新事件以便后期执行MonoHelper.Instance.AddUpdateEvent(StateName, OnStateUpdate, updateParameters);}/// <summary>/// 退出事件接口/// </summary>/// <param name="parameters"></param>public virtual void ExitState(object[] parameters){//解除当前状态的更新事件绑定MonoHelper.Instance.RemoveUpdateEvent(StateName);if (OnStateExit != null){//执行离开事件OnStateExit(parameters);}}
到这里我们仍然有问题没有解决,那就是状态的转化控制
状态机
我们只是创建了状态,还没有状态机。
因此再创建一个状态机类继承状态类,状态机本身也是一个状态,这个状态去管理一些子状态罢了。
对于状态机,它应当有以下属性
- 存储所有子状态的变量
- 默认的子状态
- 当前的子状态
第一点,存储所有子状态,仍然是字典
/// <summary>/// 被管理的状态/// </summary>private Dictionary<string, State> managedStates;
第二点,默认的子状态,在进入该状态机时,第一个默认进入的子状态
/// <summary>/// 默认状态/// </summary>private State defaultState;
第三点,当前的子状态,当前正在运行的子状态
/// <summary>/// 当前状态/// </summary>private State currentState;
显然我们需要有子状态的添加与移除方法
在添加状态时,我们需要考虑:
- 如果状态机字典里已经包含该子状态,则跳过
- 如果当前状态机还没子状态,则将新加入的第一个子状态设置为默认子状态
移除状态时,我们需要考虑:
- 如果状态机字典里包含该子状态,并且该状态不在运行当中,则允许移除
- 移除后,发现移除的走状态如果时默认状态,则重新设置新的默认子状态,如果已经没有子状态了,则将默认子状态设置为空。
代码如下
/// <summary>/// 添加状态/// </summary>/// <param name="crtState"></param>public void AddState(State crtState){//检查当前字典中是否已经包含该状态if (managedStates.ContainsKey(crtState.StateName)){Debug.LogWarning("该状态已经存在,无需再次添加");return;}//添加状态managedStates.Add(crtState.StateName, crtState);//如果当前字典中数量为1,则表示加入的状态为第一个状态,设置为默认状态 if (managedStates.Count == 1){defaultState = crtState;}}/// <summary>/// 移除状态/// </summary>/// <param name="stateName"></param>public void RemoveState(string stateName){//如果该状态存在于状态机内,并且不是当前状态,则可以进行移除if (managedStates.ContainsKey(stateName) && managedStates[stateName] != currentState) {State crtState = managedStates[stateName];managedStates.Remove(stateName); if (crtState == defaultState) {defaultState = null;ChooseNewDefaultState();}}}
状态机也有其自身的进入,更新,退出事件。
在执行状态机的进入事件时:首先调用自身的进入事件,其次调用默认子状态(判断不为空)的进入事件,并将默认子状态设置为当前状态
在执行状态机的退出事件时:首先调用当前子状态(判断不为空)的退出事件,再调用状态机自身的退出事件。
因此这里需要重写事件方法
代码如下
/// <summary>/// 状态机进入/// </summary>/// <param name="enterParameters"></param>/// <param name="updateParameters"></param>public override void EnterState(object[] enterParameters, object[] updateParameters){//先执行当前状态机的进入事件base.EnterState(enterParameters, updateParameters);//再执行子状态的进入事件//判断是否存在默认状态if (defaultState == null) return;//此时当前状态就是默认状态currentState = defaultState;//执行当前状态的进入事件,进入默认的子状态currentState.EnterState(enterParameters, updateParameters);}/// <summary>/// 状态机离开/// </summary>/// <param name="parameters"></param>public override void ExitState(object[] parameters){if (currentState != null){//当前子状态先退出currentState.ExitState(parameters);}//状态机再退出base.ExitState(parameters);}
此外,状态与状态机自身需要有基本的事件,比如
状态进入时要将IsRun
设置为true
,退出时设置为False
状态机更新事件中必须要不断检测当前子状态是否满足他自身转化到其他状态的条件
这些基础的事件,我们需要在最初的时候直接绑定其对应的事件委托。
状态类基本事件
public virtual void StateBaseEventBind(){OnStateEnter += objects => { IsRun = true; };OnStateExit += objects => { IsRun = false; };}
状态机类重写方法
public override void StateBaseEventBind(){base.StateBaseEventBind();OnStateUpdate += CheckCurrentStateTransition;}
这些包括上述所有的变量初始化都在构造函数中执行
对于一个状态,他有一个方法,不断遍历自身存储的状态转化字典,并判断是否能够转化,如果能,则返回对于的状态名称,再有状态机的更新事件调用,如果满足了条件,则状态机执行状态转化方法,当然其实对于状态检测的方法也可以转移到状态机中
(状态类)/// <summary>/// 检测状态转化条件/// </summary>/// <returns></returns>public string CheckTransition(){foreach (KeyValuePair<string,Func<bool>> item in TransitionStatesDic){//执行判断条件if (item.Value()){//条件满足return item.Key;}}return null;}
(状态机类)/// <summary>/// 检测当前状态是否满足过渡条件/// </summary>private void CheckCurrentStateTransition(object[] objs){if (currentState == null) return;//查看当前子状态的条件情况string targetState = currentState.CheckTransition();//当前切换条件满足了if (targetState != null){TransitionToState(targetState);}}/// <summary>/// 切换状态/// </summary>/// <param name="targetStateName"></param>private void TransitionToState(string targetStateName){//要过渡的状态是否存在于当前状态机if (managedStates.ContainsKey(targetStateName)){//当前状态离开currentState.ExitState(null);//切换当前状态currentState = managedStates[targetStateName];currentState.EnterState(null,null);}}