在Unity中实现有限状态机
概要
本文将介绍Unity开发中的有限状态机,给出对应的实现代码。
背景
有限状态机借鉴了图灵机的思想,可以看作是最简单的图灵机。
它包含4要素:
- 现态
- 条件
- 动作
- 次态
有限状态机的基本实现
基础的有限状态机不复杂,无非是几个状态定义成类,提供OnEnter/OnExit/OnUpdate方法,这里直接根据需求给出对应的代码实现。
需求
- 按住左Ctrl蓄力
- 蓄力0.5s 内松开取消蓄力
- 蓄力2.0s 内松开播放技能动作1
- 蓄力2.0s 以上松开播放技能动作2
- 蓄力超过3.5s 播放技能动作2
- 点击空格跳跃
实现
抽象类,定义进入状态和退出状态的行为
public interface IState
{
public IState OnUpdate(Character t);
public void OnEnter(Character t);
public void OnExit(Character t);
}
状态机的关键在于控制状态的切换,这里直接在Character里进行相关逻辑控制。
public class Character : MonoBehaviour
{
public Animator animator;
protected IState state;
public virtual void Update()
{
if (null != state)
{
IState newState = state.OnUpdate(this);
//状态切换
//state switch
if (null != newState)
{
TransitionState(newState);
}
}
}
/// <summary>
/// 状态切换
/// Transition state
/// </summary>
/// <param name="newState"></param>
public void TransitionState(IState newState)
{
Debug.Log($"状态变更{newState}");
newState.OnEnter(this);
if (null != state)
state.OnExit(this);
state = newState;
}
}
演示一个技能类
public class UltimateSkillState : IState
{
private int _fullPathHash;
private int _skillId;
private float _dt = 0;
private int _prevHashId = 0;
public void OnEnter(Character t)
{
_dt = 0;
t.animator.SetInteger(AnimationTriggers.hashIdDefaultSkill, _skillId);
}
/// <summary>
/// 构造
/// </summary>
/// <param name="skillId">技能id</param>
/// <param name="animatorFullPath">动画全路径(如Base Layer.Jump)</param>
public UltimateSkillState(int skillId, string animatorFullPath)
{
_skillId = skillId;
_fullPathHash = Animator.StringToHash(animatorFullPath);
}
public void OnExit(Character t)
{
var _animatorStateInfo = t.animator.GetCurrentAnimatorStateInfo(0);
//恢复默认技能
t.animator.SetInteger(AnimationTriggers.hashIdDefaultSkill, 0);
}
public IState OnUpdate(Character t)
{
_dt += Time.deltaTime;
var _animatorStateInfo = t.animator.GetCurrentAnimatorStateInfo(0);
//技能播放完毕的判定依据是先播放指定动画,然后再播放到任意其他动作
if (_prevHashId == 0)
{
_prevHashId = t.animator.GetCurrentAnimatorStateInfo(0).fullPathHash == _fullPathHash ? _fullPathHash : 0;
}
else
{
if (t.animator.GetCurrentAnimatorStateInfo(0).fullPathHash != _prevHashId)
{
//技能播放完毕
return new IdleState();
}
}
return null;
}
}
最终效果
一些细节的分叉
静态状态与实例化状态
根据状态是否实例化,有限状态机又可以区分为静态状态和实例化状态
实例化状态
实例化状态可以考虑用对象池来进行优化。
静态状态
若全局只有一个对象,可以用静态状态,一般把这些状态直接放置在基类状态类中。
//找一个地方声明静态变量,可以放到基状态类中
static JumpState TheJumpState;
public IState OnUpdate(Character t)
{
if (Input.GetKeyDown(KeyCode.Space)) //space for jump
{
return TheJumpState;
}
}
一般来说我用的都是实例化状态。
npc会有多个,怪物也会有多个,主角一开始是一个但是随着联机模块的加入也会变成多个。
一般我是先写成实例化状态,有需要再用实例化状态对象池来进行优化。
有限状态机的封装实现—状态机类
在实际的开发中,我们可以像上文一样把状态机的控制逻辑写在控制对象里。
但是随着类的膨胀,为了让控制对象的逻辑更简单清晰(单一职责原则),我们往往会引入一个StateMachine类。
这个状态机类,我们尽量设计的充分一些,让它能处理更多的情况。
全局状态
有的时候,玩家除了基础状态外,还有一个状态需要被处理。
如玩家流血状态,可以和跑跳攻击等状态同时存在。
假设我们沿用上文的状态机,那就需要在每个状态类里处理这个逻辑,过于繁琐。
处理起来很简单,引入一个全局的状态即可。
public void Update()
{
if (globalState != null)
{
globalState.OnUpdate(owner);
}
if (currentState != null)
{
// 如果当前状态返回了一个新的状态,就切换到新的状态
if (currentState.OnUpdate(owner) != null)
{
ChangeState(currentState.OnUpdate(owner));
}
}
}
状态翻转
有的时候,玩家会面临被某个状态打断后,再回到原本状态的需求。
例如在《模拟人生》中,玩家可能会感到本能的迫切要求,不得不去洗手间方便。
实现起来很简单,我们只需要记录prevState,然后提供RevertToPreviousState函数即可。
/// <summary>
/// 状态翻转
/// </summary>
public void RevertToPreviousState()
{
ChangeState(previousState);
}
完整的状态机代码如下
using UnityEngine;
using AboveCloud;
/// <summary>
/// 有限状态机
/// </summary>
public class FiniteStateMachine<T>
{
/// <summary>
/// 当前状态
/// </summary>
public IState<T> currentState { get; private set; }
/// <summary>
/// 上一个状态
/// </summary>
public IState<T> previousState { get; private set; }
/// <summary>
/// 全局状态
/// </summary>
public IState<T> globalState { get; private set; }
/// <summary>
/// 状态机拥有者
/// </summary>
public T owner { get; private set; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="owner"></param> nm
public FiniteStateMachine(T owner)
{
this.owner = owner;
}
/// <summary>
/// 设置当前状态
/// </summary>
/// <param name="state"></param>
public void SetCurrentState(IState<T> state)
{
currentState = state;
}
/// <summary>
/// 设置全局状态
/// </summary>
/// <param name="state"></param>
public void SetGlobalState(IState<T> state)
{
globalState = state;
}
/// <summary>
/// 设置上一个状态
/// </summary>
/// <param name="state"></param>
public void SetPreviousState(IState<T> state)
{
previousState = state;
}
/// <summary>
/// 初始化状态机
/// </summary>
/// <param name="owner"></param>
private void Init(T owner)
{
this.owner = owner;
}
/// <summary>
/// 更新状态机
/// </summary>
public void Update()
{
if (globalState != null)
{
globalState.OnUpdate(owner);
}
if (currentState != null)
{
// 如果当前状态返回了一个新的状态,就切换到新的状态
if (currentState.OnUpdate(owner) != null)
{
ChangeState(currentState.OnUpdate(owner));
}
}
}
/// <summary>
/// 改变状态
/// </summary>
/// <param name="newState"></param>
public void ChangeState(IState<T> newState)
{
if (newState == null)
{
Debug.LogError("newState is null");
return;
}
previousState = currentState;
if (currentState != null)
{
Debug.LogFormat("Exit State: {0}", currentState.GetType().Name);
currentState.OnExit(owner);
}
currentState = newState;
if (currentState != null)
{
Debug.LogFormat("Enter State: {0}", currentState.GetType().Name);
currentState.OnEnter(owner);
}
}
}
这样的状态机已经能处理许多情况了。
但如果我们游戏里有许多的AI对象,这种实现就显得有点力不从心:
所有的条件过渡都定义在具体的状态类里,后期的扩展性会比较差。
举个例子,有些AI可以从状态A切换到状态B,有些AI只能从状态A切换到状态C,我该如何生成这些对象的AI?
开闭原则,尽量不要因为新增了一个对象,而要去修改老的状态。
合理的方案是把Transition对象化,开发者可以通过AddState来定义一个对象有哪些行为,通过AddTranslateState来定义对象响应哪些切换。
对于转换条件复杂的AI对象,我们希望可以通过组装的方式来实现状态机的生成。
有限状态机的衍生
并发状态机
在传统的有限状态机,一次只有一个状态。
但有时候我们需要处于一个状态的时候,能做另一个状态的行为。
如主角有跑RunState,跳的状态JumpState,现在我们需要让它可以在跑,跳的同时也能开枪。
如果严格按照上文的有限状态机进行开发,就需要继续开发FireRunState和FireJumpState,状态的数量将急剧膨胀。
有点像桥接模式的概念,这时候我们要做的是分离状态的维度。
Run/Jump是一个维度,开枪是另一个维度,分别处理即可。
public void Update()
{
if (currentState != null)
{
// 如果当前状态返回了一个新的状态,就切换到新的状态
if (currentState.OnUpdate(owner) != null)
{
ChangeState(currentState.OnUpdate(owner));
}
}
// 分离武器的维度,作为第二种状态
if (currentEquipState != null)
{
// 如果当前状态返回了一个新的状态,就切换到新的状态
if (currentEquipState.OnUpdate(owner) != null)
{
ChangeState(currentEquipState.OnUpdate(owner));
}
}
}
当状态处于不同的维度时,并发状态工作得很好。
如何实现代码简化/状态分层
往往在开发中,角色会有大量的状态。
而这些状态中不乏相似的状态。我们可能会在这些状态中重复不少代码。
比如,我们有站立,走路,跑步和滑动这几个状态。
他们都可以响应跳跃Jump动作。
如果继续沿用上文所述的状态机,响应jump的代码我们就得写四遍Jump的逻辑。
又比如有一些状态的优先级是比较高的,比如说机器人电量不足的时候我们希望它去充电。
如果只有一个层次的话,我们需要在每个状态里都处理这个高优先状态的跳转,产生大量冗余的复杂代码。
有几种方法可以实现状态代码的复用:
- 继承
- 状态栈
使用继承实现代码简化
使用继承实现分层比较简单,但是多重继承在复杂性上升后会面临不好维护的问题。
在BaseIdle这个基类实现移动的控制
public class BaseIdle : IState<Player>
{
public virtual void OnEnter(Player t)
{
}
public virtual void OnExit(Player t)
{
}
public virtual IState<Player> OnUpdate(Player player)
{
if (player.inputMoveDir != Vector3.zero)
{
return new MoveState();
}
return null;
}
}
IdleState继承BaseIdle,加入它新的逻辑。
// 这里演示Idle状态闲置5秒后可切换到IdleS状态
public class IdleState : BaseIdle
{
private float _idleDuration;
private static readonly float _idleDurationMax = 5;
public override void OnEnter(Player player)
{
_idleDuration = 0;
base.OnEnter(player);
}
public override void OnExit(Player player)
{
_idleDuration = 0;
base.OnEnter(player);
}
public override IState<Player> OnUpdate(Player player)
{
_idleDuration += Time.deltaTime;
if (_idleDuration >= _idleDurationMax)
{
return new IdleS_State();
}
return base.OnUpdate(player);
}
}
使用状态栈实现代码简化
我们可以实现状态栈来实现分层状态机HFSM,分层状态机通过将状态嵌套实现了状态的分层。篇幅较长,本篇略去。文章来源:https://www.toymoban.com/news/detail-753576.html
此外我们还可以用状态栈来实现下推自动机。文章来源地址https://www.toymoban.com/news/detail-753576.html
到了这里,关于在Unity中实现有限状态机的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!