使用说明 · 从零到能写业务

状态机 · 使用说明

这份文档只回答一个问题:我该用哪一种、怎么写?(轻量管流程、重量级管 AI,两套都在这一页)

读完你能做到:3 分钟写出游戏流程状态机 · 3 分钟给 Boss 写一个状态 · 知道返回栈状态机怎么用 · 清楚"谁来每帧驱动"

〇两种形态:先选对(30 秒)

同一个模块里给了两种状态机 —— 不是"新旧",是两种用法。

轻量(Lightweight)重量级(Heavyweight)
用来管游戏流程、界面栈、玩法阶段复杂 AI:Boss、小怪、技能阶段
状态是什么实现 RevILightState 的类(一般继承 RevLightStateBase)继承 RevHeavyFsmState<状态枚举, 宿主接口> 的类
类型安全泛型 RevLightStateMachine<T>(T 是基类/接口)状态用枚举索引:RevHeavyFsm<BossState, IBossObj>
特色能力可异步切换、返回栈版本(Push/Pop)切换历史、异步进出(PrepareEnterAsync)、FixedUpdate/LateUpdate
典型代码量一个状态类 10 行一个状态类 + 一个行为接口
一句话选法 流程/界面 → 轻量;敌人/角色行为 → 重量级。 拿不准就用轻量(它更简单,也能覆盖大部分玩法阶段)。

一轻量状态机:3 分钟写游戏流程

目标:登录 → 大厅 → 战斗,三个状态互相切换。

// ① 状态:继承 RevLightStateBase,重写关心的回调(三个都是可选的)
public class LoginState : RevLightStateBase
{
    public override void OnEnter()  { /* 打开登录界面、连服务器 */ }
    public override void OnUpdate(float dt) { /* 需要每帧做的事 */ }
    public override void OnExit()   { /* 关界面、清理 */ }
}
public class LobbyState  : RevLightStateBase { }
public class BattleState : RevLightStateBase { }

// ② 建机器 + 注册状态(T 写你要共用的基类/接口)
var flow = new RevLightStateMachine<RevLightStateBase>();
flow.Register(new LoginState());
flow.Register(new LobbyState());
flow.Register(new BattleState());

// ③ 切状态(走 OnExit → OnEnter)
flow.ChangeTo(flow.CurrentState is LoginState ? GetLobby() : GetBattle());

// ④ 每帧驱动(谁来调见第五节)
flow.Update(Time.deltaTime);

// ⑤ 想监听切换(埋点、日志、音效)
flow.StateChanged += (from, to) => RevLog.Info($"流程:{from?.GetType().Name} → {to?.GetType().Name}", "Flow");

// ⑥ 常用查询
bool inBattle = flow.Is<BattleState>();
float t = flow.StateTime;              // 当前状态已经持续了多久(做超时很有用)
异步切换(要等加载完才进下一个状态) await flow.ChangeToAsync(GetBattle()) —— 切换过程中 IsTransitioning 为 true、PendingState 是目标状态; 想取消就 flow.CancelAsyncTransition()。

二重量级状态机:3 分钟给 Boss 写状态

关键设计:状态类只面向"行为接口"编程,不认识具体 MonoBehaviour —— 所以状态逻辑能单测。

// ① 定义宿主的行为接口:Boss 有哪些能力,由业务说(继承 RevIHeavyFsmOwner 标记接口)
public interface IBossObj : RevIHeavyFsmOwner
{
    float Health { get; }
    void MoveTo(Vector3 pos);
    void PlayAnim(string name);
}

// ② 状态枚举:Boss 有哪些状态
public enum BossState { Idle, Chase, Attack, Die }

// ③ 状态类:泛型参数 = (状态枚举, 宿主接口)
public class BossChaseState : RevHeavyFsmState<BossState, IBossObj>
{
    public override void OnEnter()
    {
        Owner.PlayAnim("run");                    // Owner 就是宿主(IBossObj)
    }
    public override void OnUpdate(float dt)
    {
        Owner.MoveTo(PlayerPos());
        if (NearPlayer()) Fsm.ChangeState(BossState.Attack, "进入攻击距离");
    }
    public override void OnExit() { Owner.PlayAnim("idle"); }
}

// ④ 组装 + 驱动(宿主类里:Boss 自己实现 IBossObj)
_fsm = new RevHeavyFsm<BossState, IBossObj>(this);
_fsm.AddState(BossState.Idle,   new BossIdleState());
_fsm.AddState(BossState.Chase,  new BossChaseState());
_fsm.AddState(BossState.Attack, new BossAttackState());
_fsm.ChangeState(BossState.Idle, "初始化");

void Update()      { _fsm.UpdateState(Time.deltaTime); }        // 每帧驱动
void FixedUpdate() { _fsm.FixedUpdateState(Time.fixedDeltaTime); }
void OnDestroy()   { _fsm.Dispose(); }
异步进出(播放动画、等技能前摇) 重写 PrepareEnterAsync(token) / PrepareExitAsync(token) 返回一个 RevTask, 用 ChangeStateAsync(...) 切换时框架会等你完成。
为什么值得这么绕一层接口 状态类里不出现 BossController、不出现 GetComponent —— 于是"Boss 在血量低于 30% 时该进狂暴"这种规则,可以用一个假的宿主实现来跑单元测试。

三对照表(两套 API 全列)

轻量:RevLightStateMachine<T>

我想…这么写
注册状态machine.Register(new LoginState())
切换(同步 / 异步)machine.ChangeTo(state) · await machine.ChangeToAsync(state)
每帧驱动machine.Update(deltaTime)
查询当前 / 判断类型 / 已持续多久machine.CurrentState · machine.Is<BattleState>() · machine.StateTime
切换中想知道目标machine.IsTransitioning · machine.PendingState · CancelAsyncTransition()
监听切换machine.StateChanged += (from, to) => { }
状态回调继承 RevLightStateBase:OnEnter / OnExit / OnUpdate / OnSuspend / OnResume

重量级:RevHeavyFsm<TStateEnum, TOwner>

我想…这么写
建机器(宿主传自己)new RevHeavyFsm<BossState, IBossObj>(this)
增 / 删 / 查状态fsm.AddState(BossState.Idle, new BossIdleState()) · RemoveState · HasState
切换fsm.ChangeState(BossState.Attack, "进入攻击距离")(返回 bool:被拒绝时是 false)
异步切换await fsm.ChangeStateAsync(...) · 取消:fsm.CancelAsyncTransition()
驱动三种帧fsm.UpdateState(dt) · FixedUpdateState · LateUpdateState
看重切历史 / 重置 / 清理fsm.GetHistory() · fsm.Reset(BossState.Idle) · fsm.Clear() · fsm.Dispose()
状态回调继承 RevHeavyFsmState<TEnum, TOwner>:OnEnter/OnExit/OnUpdate/OnFixedUpdate/OnLateUpdate/PrepareEnterAsync/PrepareExitAsync
状态里怎么拿到"宿主"和"状态机" 基类已经给了:Owner(你的行为接口实例)与 Fsm(当前状态机)。 不要再自己维护一份 static 引用 —— 那是早期实现最容易出错的写法。

四返回栈状态机(UI 栈 / 流程栈)

当"新状态压在旧状态上面,退出后回到上一个"时,用栈版本。

// 界面栈:设置界面压在背包上面,返回时回到背包
var uiStack = new RevLightStackStateMachine<RevLightStateBase>();

uiStack.Push(new BagState());        // 压入(原来那个被挂起:OnSuspend)
uiStack.Push(new SettingsState());   // 再压一个

var top = uiStack.Pop();               // 弹出 → 下面那个自动 OnResume 回来
var under = uiStack.Under;             // 只在"下面那个"上做点事(不弹它)
int n = uiStack.Count;                 // 栈里几个

uiStack.Change(new ShopState());         // 替换栈顶(Pop + Push 一步)
uiStack.Clear();                        // 全清(回主界面)

uiStack.StatePushed += s => RevLog.Info($"进入 {s.GetType().Name}", "UIStack");
uiStack.StatePopped += s => RevLog.Info($"退出 {s.GetType().Name}", "UIStack");
栈状态用 OnSuspend / OnResume 被压在下面时 OnSuspend()(暂停逻辑、停特效),弹回来时 OnResume()。 这与 UI 系统的 OnCovered(bool) 是同一个思路(界面被盖住/恢复)。

五谁负责每帧驱动(最常问)

状态机是纯逻辑:它不自己挂物体、不自己抢 Update —— 由你决定在哪驱动,这样它才能被单测。

你的宿主是什么推荐驱动方式
MonoBehaviour(角色、Boss)在它自己的 Update 里:_fsm.UpdateState(Time.deltaTime)
纯 C# 类(流程管理器、玩法模块)用公共 Mono 模块注册:RevMono.AddUpdate(dt => flow.Update(dt), owner: this)
只想"定时看一眼"(如从登录轮询到服务器就绪)RevTimer.Every(0.2f, () => …)(见《计时器系统》)
为什么不做成自动驱动 自动挂 Update 会带来两个问题:① 纯 C# 里跑不了断言;② 谁在驱动不明确(出了问题时找不到"到底谁在推它")。 现在这样:你看得见驱动点,测试里也能手动喂时间。

六新手最容易踩的 5 个坑

坑正确做法
① 往状态对象里塞"这一局的临时数据"状态对象是复用的;临时数据放宿主(Owner)或上下文里
② 忘了每帧调用 Update/UpdateState驱动点只能有一处(第五节);重复驱动会让状态走两遍
③ 在 OnUpdate 里频繁切状态切状态有开销(OnExit/OnEnter);用"条件 + 冷却/阈值"避免每帧抖动(抖动=状态在两个状态间跳)
④ 忽略 ChangeState 的返回值返回 false = 这次切换被拒绝(异步切换中/状态不存在)→ 需要时判断并打日志
⑤ 异步切换时又发起一次切换用 IsTransitioning/PendingState 判重,或先 CancelAsyncTransition()
最贵的一课:状态抖动 "进入攻击距离 → 攻击;一离开就回追击;于是每帧来回切" —— 这类 bug 症状是 动画抽搐、日志刷屏、性能掉。解决:阈值要带滞回(进 3 米、退 4 米),或加最短持续时间(StateTime 就是给你判这个的)。

七相关文档