MeteorCat / 有限状态机(FSM)

Created Sun, 10 Nov 2024 20:16:38 +0800 Modified Wed, 29 Oct 2025 23:25:05 +0800
3845 Words

有限状态机作用

按照游戏玩家对象设计基本上有以下状态:

  • Idle(bool): 待机
  • Move(bool): 移动
  • Run(bool): 冲刺
  • Attack(bool): 攻击
  • Jump(bool): 跳跃

按照简单代码处理, 如果玩家先 移动 再按下 冲刺 的情况, 这时候玩家应该需要做到的是 冲刺 覆盖掉 移动 状态, 而不是 移动冲刺 同时 bool=true.

可以自己编写代码测试或者自己脑子模拟下以下情景:

  • 假设玩家初期带有 移动 状态, 那么玩家移动状态追加 isMove 即可
  • 但是后来玩家又多出个 冲刺 状态, 因为行为是和 移动 互斥必须编写代码判断处于状态中
  • 再后来玩家有多个 飞行(fly) 状态, 又要覆盖编写 if|switch 状态判断切换逻辑

这里如果按照比较初级的游戏逻辑来处理判断代码:

// 判断玩家是否可以移动, 不允许玩家处于奔跑和飞行状态
if (!isRun && !isFly) {
    // 允许移动并切换, 其他UI逻辑代码略
    isMove = true;
}

// 判断玩家是否可以奔跑, 不允许玩家处于飞行状态
if (!isFly) {
    // 允许奔跑
    isRun = true;
}

// 其他诸如追加传送和闪现的追加行为状态判断

可以看到这里面的状态复杂度及其可怕, 特别判断自身状态情况完全耦合在一起纠缠不清; 而如果要定义这种抢占状态(唯一状态)并发的情况, 衍生出利用 有限状态机(FSM) 处理并发行为状态.

Unityanimator 组件就是标准有限状态机实现

有限状态机实现

首先定义状态机接口定义状态机的行为:

/// <summary>
/// 这里就是简单的 Unity 状态接口, 用于状态机触发行为
/// </summary>
/// ReSharper disable once InconsistentNaming
public interface IFSMState
{
    void OnEnter(); // 进入状态机回调
    void OnUpdate(); // 逻辑帧更新回调
    void OnFixedUpdate(); // 物理帧更新回调
    void OnExit(); // 退出状态机回调
}

这个接口就是所有状态机需要实现处理的总抽象, 之后就是状态机的运行时:

/// <summary>
/// 状态机运行时, 负责保存实现状态机对象
/// </summary>
/// ReSharper disable once InconsistentNaming
public class FSMRuntime : IFSMState
{
    #region 状态机可用

    /// <summary>
    /// 状态机是否可用
    /// </summary>
    private bool mIsActive;


    /// <summary>
    /// 设置可用
    /// </summary>
    /// <param name="isActive">可用状态</param>
    /// <returns>FSMRuntime</returns>
    public FSMRuntime SetActive(bool isActive)
    {
        mIsActive = isActive;
        return this;
    }

    /// <summary>
    /// 确认状态机是否可用
    /// </summary>
    /// <returns>bool</returns>
    public bool IsActive()
    {
        return mIsActive;
    }

    #endregion


    #region 状态进入回调

    /// <summary>
    /// 状态进入回调成员
    /// </summary>
    private Action mOnEnter;

    /// <summary>
    /// 设置回调
    /// </summary>
    /// <param name="onEnter">事件回调</param>
    /// <returns>FSMRuntime</returns>
    public FSMRuntime OnEnter(Action onEnter)
    {
        mOnEnter = onEnter;
        return this;
    }

    /// <summary>
    /// 唤起事件回调
    /// </summary>
    public void OnEnter()
    {
        mOnEnter?.Invoke();
    }

    #endregion


    #region 状态逻辑帧更新回调

    /// <summary>
    /// 逻辑帧回调成员
    /// </summary>
    private Action mOnUpdate;

    /// <summary>
    /// 设置回调
    /// </summary>
    /// <param name="onUpdate">设置回调对象</param>
    /// <returns>FSMRuntime</returns>
    public FSMRuntime OnUpdate(Action onUpdate)
    {
        mOnUpdate = onUpdate;
        return this;
    }


    /// <summary>
    /// 逻辑帧回调唤醒
    /// </summary>
    public void OnUpdate()
    {
        mOnUpdate?.Invoke();
    }

    #endregion


    #region 状态物理帧更新回调

    /// <summary>
    /// 物理帧事件对象
    /// </summary>
    private Action mOnFixedUpdate;


    /// <summary>
    /// 设置物理帧回调
    /// </summary>
    /// <param name="onFixedUpdate">物理帧回调</param>
    /// <returns>FSMRuntime</returns>
    public FSMRuntime OnFixedUpdate(Action onFixedUpdate)
    {
        mOnFixedUpdate = onFixedUpdate;
        return this;
    }

    /// <summary>
    /// 唤醒物理帧回调
    /// </summary>
    public void OnFixedUpdate()
    {
        mOnFixedUpdate?.Invoke();
    }

    #endregion


    #region 状态退出回调

    
    /// <summary>
    /// 状态退出属性
    /// </summary>
    private Action mOnExit;

    
    /// <summary>
    /// 设置退出回调
    /// </summary>
    /// <param name="onExit">退出回调</param>
    /// <returns>FSMRuntime</returns>
    public FSMRuntime OnExit(Action onExit)
    {
        mOnExit = onExit;
        return this;
    }
    
    /// <summary>
    /// 唤起退出事件
    /// </summary>
    public void OnExit()
    {
        mOnExit?.Invoke();
    }

    #endregion
}

之后就是定义状态类型枚举, 后续玩家角色带有更多状态可以对该枚举进行扩展:

/***
 * 把之前提到的角色状态
 */
public enum RoleAction {
    Idle, // 待机
    Move, // 移动
    Run, // 冲刺
    Fly, // 飞行
    Jump, // 跳跃
    Attack // 攻击
    // 其他需要扩展的状态
}

这里的行为值最好可以编码成 int 状态, 方便和服务器进行行为联调的时候直接传值做共享状态同步, 而 C# 支持将 enum 序列成 int 值的转化.

最后就是负责构建 FSM 的管理器, 用于生成状态机集合:

/// <summary>
/// 有限状态机处理器
/// </summary>
/// <typeparam name="T">标识的对象</typeparam>
/// ReSharper disable once InconsistentNaming
public class FSMFactory<T>
{
    #region 状态调用

    /// <summary>
    /// 当前状态机标识
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public T CurrentStateId { protected set; get; }

    /// <summary>
    /// 当前状态机对象
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public IFSMState CurrentState { protected set; get; }


    /// <summary>
    /// 上个状态机对象标识
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public T PreviousStateId { protected set; get; }


    /// <summary>
    /// 当前状态机逻辑帧, 主要逻辑帧并不是固定时间帧所以需要追加编号
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public long FrameCountOfCurrentState { protected set; get; } = 1;
    
    #endregion
    
    
    #region 状态属性与操作

    /// <summary>
    /// 内部集成的状态机列表
    /// </summary>
    // ReSharper disable once CollectionNeverQueried.Local
    // ReSharper disable once FieldCanBeMadeReadOnly.Local
    private Dictionary<T, IFSMState> mStates = new Dictionary<T, IFSMState>();


    /// <summary>
    /// 追加状态
    /// </summary>
    /// <param name="key">状态标识</param>
    /// <param name="state">状态对象</param>
    public void AddState(T key, IFSMState state)
    {
        mStates.Add(key, state);
    }


    /// <summary>
    /// 获取保存的状态机运行时, 如果不存在就追加
    /// </summary>
    /// <param name="key"></param>
    /// <returns></returns>
    public FSMRuntime GetAndSaveState(T key)
    {
        if (mStates.TryGetValue(key, out var s))
        {
            return s as FSMRuntime;
        }

        var state = new FSMRuntime();
        mStates.Add(key, state);
        return state;
    }

    /// <summary>
    /// 获取状态机运行时
    /// </summary>
    /// <param name="key"></param>
    /// <param name="state"></param>
    /// <returns></returns>
    public bool GetState(T key, out IFSMState state)
    {
        return mStates.TryGetValue(key, out state);
    }


    /// <summary>
    /// 修改目前的状态
    /// </summary>
    /// <param name="key">状态机标识</param>
    public void SetCurrentState(T key)
    {
        if (key.Equals(CurrentStateId)) return;
        if (!mStates.TryGetValue(key, out var state)) return;
        if (CurrentState == null || !state.IsActive()) return;
        
        
        // 退出目前的状态机
        CurrentState.OnExit();
        PreviousStateId = CurrentStateId;
                
        // 切换目前状态机
        CurrentStateId = key;
        CurrentState = state;
        FrameCountOfCurrentState = 1;
        CurrentState.OnEnter();
    }


    /// <summary>
    /// 启动状态机
    /// </summary>
    /// <param name="key">状态机标识</param>
    public void StartState(T key)
    {
        if (!mStates.TryGetValue(key, out var state)) return;
        
        // 启动当前状态机
        PreviousStateId = key;
        CurrentState = state;
        FrameCountOfCurrentState = 0;
        state.OnEnter();
    }

    /// <summary>
    /// 清理所有状态机
    /// </summary>
    public void Clear()
    {
        CurrentState = null;
        CurrentStateId = default;
        mStates.Clear();
    }

    #endregion


    #region 回调唤醒

    
    /// <summary>
    /// 逻辑帧更新
    /// </summary>
    public void OnUpdate()
    {
        CurrentState?.OnUpdate();
        FrameCountOfCurrentState++;
    }

    /// <summary>
    /// 物理帧更新
    /// </summary>
    public void OnFixedUpdate()
    {
        CurrentState?.OnFixedUpdate();
    }

    #endregion
}

可以看到只有 OnUpdate 采用 FrameCountOfCurrentState 做帧数的值递增操作, 这是因为主要物理帧在底层上保证了值的递增序列化, 所以可以当作帧递增 永远 保持递增处理.

这里另外处理个抽象类用于快速继承实现:

/// <summary>
/// 抽象出来的状态机类
/// </summary>
/// <typeparam name="TStateId">状态机标识</typeparam>
/// <typeparam name="TTarget">状态机对象</typeparam>
/// ReSharper disable once InconsistentNaming
public abstract class AbstractFSMState<TStateId, TTarget> : IFSMState
{
    /// <summary>
    /// 状态机管理器
    /// </summary>
    public FSMFactory<TStateId> Factory { protected set; get; }

    /// <summary>
    /// 状态管理对象
    /// </summary>
    public TTarget Target { protected set; get; }
    
    
    /// <summary>
    /// 初始化状态机管理器和目标对象
    /// </summary>
    /// <param name="factory"></param>
    /// <param name="target"></param>
    // ReSharper disable once PublicConstructorInAbstractClass
    public AbstractFSMState(FSMFactory<TStateId> factory, TTarget target)
    {
        Factory = factory;
        Target = target;
    }


    /// <summary>
    /// 继承类实现启动状态
    /// </summary>
    /// <returns></returns>
    protected virtual bool IsActive() => true;

    /// <summary>
    /// 继承类实现进入状态机状态
    /// </summary>
    public virtual void OnEnter()
    {
    }

    /// <summary>
    /// 继承类实现逻辑帧更新
    /// </summary>
    public virtual void OnUpdate()
    {
        
    }

    /// <summary>
    /// 继承类实现物理帧更新
    /// </summary>
    public virtual void OnFixedUpdate()
    {
        
    }


    /// <summary>
    /// 继承类实现退出状态机
    /// </summary>
    public virtual void OnExit()
    {
        
    }
    


    /// <summary>
    /// 状态机是否可用
    /// </summary>
    /// <returns>是否可用</returns>
    bool IFSMState.IsActive()
    {
        return IsActive();
    }


    /// <summary>
    /// 状态机启动
    /// </summary>
    void IFSMState.OnEnter()
    {
        Debug.Log("#FSM Enter " + GetType().Name);
        OnEnter();
    }

    /// <summary>
    /// 逻辑帧更新
    /// </summary>
    void IFSMState.OnUpdate()
    {
        OnUpdate();
    }

    /// <summary>
    /// 物理帧更新
    /// </summary>
    void IFSMState.OnFixedUpdate()
    {
        OnFixedUpdate();
    }

    /// <summary>
    /// 状态机退出
    /// </summary>
    void IFSMState.OnExit()
    {
        Debug.Log("#FSM Exit " + GetType().Name);
        OnExit();
    }
}

最后就是测试编写之前所说的玩家行为状态管理器:

/// <summary>
/// 角色状态机
/// </summary>
// ReSharper disable once InconsistentNaming
public class RoleFSM : MonoBehaviour
{
    /// <summary>
    /// 角色行为
    /// </summary>
    public enum RoleAction
    {
        Idle, // 待机
        Move, // 移动
        Run, // 冲刺
        Fly, // 飞行
        Jump, // 跳跃

        Attack // 攻击
        // 其他需要扩展的状态
    }

    /// <summary>
    /// 状态管理器
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public FSMFactory<RoleAction> Factory { protected set; get; } = new FSMFactory<RoleAction>();


    /// <summary>
    /// 待机状态处理器
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public class IdleActionState : AbstractFSMState<RoleAction, RoleFSM>
    {
        /// <summary>
        /// 衍生到父类继承
        /// </summary>
        /// <param name="factory"></param>
        /// <param name="target"></param>
        public IdleActionState(FSMFactory<RoleAction> factory, RoleFSM target) : base(factory, target)
        {
        }

        /// <summary>
        /// 重写进入时候的状态
        /// </summary>
        public override void OnEnter()
        {
            Debug.Log("玩家切换待机模式: 开始播放待机动画");
        }
    }

    /// <summary>
    /// 移动状态处理器
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public class MoveActionState : AbstractFSMState<RoleAction, RoleFSM>
    {
        /// <summary>
        /// 衍生到父类继承
        /// </summary>
        /// <param name="factory"></param>
        /// <param name="target"></param>
        public MoveActionState(FSMFactory<RoleAction> factory, RoleFSM target) : base(factory, target)
        {
        }

        /// <summary>
        /// 重写移动时候的状态
        /// </summary>
        public override void OnEnter()
        {
            Debug.Log("玩家切换移动模式: 开始播放玩家慢慢移动");
        }
    }


    /// <summary>
    /// 冲刺状态处理器
    /// </summary>
    /// ReSharper disable once MemberCanBePrivate.Global
    public class RunActionState : AbstractFSMState<RoleAction, RoleFSM>
    {
        /// <summary>
        /// 衍生到父类继承
        /// </summary>
        /// <param name="factory"></param>
        /// <param name="target"></param>
        public RunActionState(FSMFactory<RoleAction> factory, RoleFSM target) : base(factory, target)
        {
        }

        /// <summary>
        /// 重写冲刺时候的状态
        /// </summary>
        public override void OnEnter()
        {
            Debug.Log("玩家切换冲刺模式: 开始播放冲刺动画");
        }
    }


    /// <summary>
    /// 组件启动 
    /// </summary>
    private void Start()
    {
        // 追加状态机所有状态
        Factory.AddState(RoleAction.Idle, new IdleActionState(Factory, this));
        Factory.AddState(RoleAction.Move, new MoveActionState(Factory, this));
        Factory.AddState(RoleAction.Run, new RunActionState(Factory, this));


        // 初始化默认状态: 待机
        Factory.StartState(RoleAction.Idle);
    }


    /// <summary>
    /// 逻辑帧更新
    /// </summary>
    private void Update()
    {
        Factory.OnUpdate();
    }

    /// <summary>
    /// 物理帧更新
    /// </summary>
    private void FixedUpdate()
    {
        Factory.OnFixedUpdate();
    }

    /// <summary>
    /// 销毁组件
    /// </summary>
    private void OnDestroy()
    {
        Factory.Clear();
    }
}

具体实现可以查看 QFramework自定义扩展 章节复制黏贴下就可以直接使用, 这里实际上就是内部构建 FSMFactory 将更新移交给内部状态机运行时来运作.

为什么需要状态

之前就看过, 直接对于玩家状态设置布尔值也挺方便的, 根本不需要上面那么冗余复杂的组件就能实现一样效果; 其实这样也没错, 都把功能实现出来了没什么问题, 但是内部后续扩展性可能有很大问题.

比如上面说到的 待机 -> 移动 -> 冲刺 的状态切换仅仅用布尔值实现, 就需要考虑到动画|布尔值切换等判断:

  • 需要判断 IsIdle 待机状态可用且其他移动相关状态全部置为 false, IsMove|IsRun|IsFly 等相关布尔都要置为 false
  • 移动状态必带有人物|物体动画, 这时候需要防止动画冲突避免出现按下 IsMove 还在播放 IsIdle|IsRun 等动画
  • 如果玩家带有 IsDef 防御状态的时候还需要展开防护盾等粒子效果, 之后如果转化其他动作就需要重置取消掉相关效果动画

这样细想起来你就会发现如果单纯靠布尔值来处理单个角色状态是多么麻烦, 在 Update 逻辑帧更新当中要判断这么复杂的效果仔细想想就感觉很可怕了.

所以考虑到种种问题就派生出有限状态机处理, 以上面样例追加 攻击(Atk) 状态:

/// <summary>
/// 攻击状态处理器
/// </summary>
/// ReSharper disable once MemberCanBePrivate.Global
public class AtkActionState : AbstractFSMState<RoleAction, RoleFSM>
{
    /// <summary>
    /// 衍生到父类继承
    /// </summary>
    /// <param name="factory"></param>
    /// <param name="target"></param>
    public AtkActionState(FSMFactory<RoleAction> factory, RoleFSM target) : base(factory, target)
    {
    }

    /// <summary>
    /// 重写攻击时候的状态
    /// </summary>
    public override void OnEnter()
    {
        Debug.Log("玩家切换攻击模式: 开始播放攻击动画, 并且附带播放动画和粒子效果");
    }
    
    /// <summary>
    /// 重写攻击时候的状态
    /// </summary>
    public override void OnExit()
    {
        Debug.Log("玩家攻击模式结束: 开始取消粒子效果, 并且对攻击之后状态做收尾");
    }
}

之后后续调用该攻击状态直接注册 Factory.AddState(RoleAction.Atk, new AtkActionState(Factory, this)), 后续切换的时候唤醒状态机即可 Factory.StartState(RoleAction.Atk); 直接把本来复杂的逻辑抽离成类的片段, 有效摆脱了大量代码判断和处理.