架构解析 · 含动画演示

状态机 · 架构解析

覆盖:Assets\Revolution\Runtime\RevStateMachine\(Lightweight\ + Heavyweight\)

本篇只讲"为什么":为什么这么设计、为什么这么写代码、每个决策换来了什么、代价是什么、边界画在哪。
想学怎么用 → 看同目录的《使用说明》,那里是手把手,本篇不重复。
面向小白:用大白话讲"不这么做会怎样",不复述代码。

源设计起源 · 为什么要做这个模块

一句话 游戏里到处是"行为模式"(待机 / 追击 / 攻击、大厅 / 背包 / 弹窗、加载 / 战斗)。状态机把每种模式收成一个类:进出有明确回调、切换只有一个入口,省得一堆分支互相打架。

本篇只讲为什么;怎么用见同目录《使用说明》。

0三句话速览(完整用法见《使用说明》)

第一步:选哪一套?

你的业务用哪套
UI 流程、玩法阶段、输入模式、新手引导轻量级 RevLightStackStateMachine<T>(要逐级返回)或 RevLightStateMachine<T>(单状态)
要"加载完才进 / 收尾动画播完才走"轻量级 RevLightStateMachine<T>.ChangeToAsync(await 异步准备)
怪物 AI / Boss AI、技能流程、步骤多的重业务重量级 RevHeavyFsm<TStateEnum, TOwner>(状态枚举 + 行为接口)

第二步:三行跑起来

// ① 轻量级(UI 栈:主界面 → 背包 → 弹窗,逐级返回)
var sm = new RevLightStackStateMachine<RevLightStateBase>();
sm.Push(new LobbyState());
void Update() => sm.Update(Time.deltaTime);

// ② 轻量级(单状态 + 异步:等资源加载完再切)
await sm.ChangeToAsync(new BattleState());

// ③ 重量级(Boss AI:枚举 + 行为接口)
var fsm = new RevHeavyFsm<BossStateType, IBossObj>(this);
fsm.AddState(BossStateType.Idle, new BossIdleState(fsm));
fsm.ChangeState(BossStateType.Idle, reason: "初始化");
void Update() => fsm.UpdateState(Time.deltaTime);

第三步:记住三个铁律

  1. 回调里不许再切状态 —— 会抛异常(DEBUG)或报错拒绝(Release),这是防重入;
  2. 同步的 ChangeTo / ChangeState 不等异步准备 —— 要等就用 ChangeToAsync / ChangeStateAsync;
  3. await 完要确认真的切过去了 —— 被更新的请求取代时状态不变,看 CurrentState / CurrentStateType。

1状态机解决什么问题(为什么必须是"两套")

1.1 没有状态机时会怎样

if (血量 < 30%) { 逃跑(); }
else if (看见玩家 && 距离 < 3) { 攻击(); }
else if (看见玩家) { 追击(); }
else if (巡逻点未到) { 巡逻(); }
else { 待机(); }
    ↓ 加一个"被眩晕"、再加一个"技能吟唱"、再加一个"Boss 阶段切换"…
    ↓ 分支互相打架、上一帧的状态这一帧忘了、进出时的清理散在各处

状态机的思路:把每个"行为模式"变成一个类,进出有明确回调,切换有唯一入口:

进入状态 → OnEnter(订阅 / 开特效 / 重置计时)
每帧      → OnUpdate(只跑当前状态)
退出状态 → OnExit(退订 / 清理)—— 保证"清理一定发生"

1.2 为什么不是一套,而是两套

因为两类业务的要求正好相反:

维度轻量业务(UI / 流程)重量业务(怪物 / Boss AI)
状态标识一个类就够了需要枚举(做条件判断、查表、存档)
数据来源状态自己持有通过行为接口操作宿主(AI 本体)
驱动只有 UpdateUpdate + FixedUpdate + LateUpdate 三路
诊断几乎不需要需要切换历史 + 原因("AI 为什么突然切走了")
对开销的容忍零容忍(随写随用)可以接受注册表 / 字典查询
异步偶尔(加载、动画)常见(Boss 登场、阶段切换的加载)

一套硬凑的结果必然是:轻的嫌重(为了 UI 弹窗写一个枚举 + 行为接口 + 注册表),重的嫌轻(没有类型安全的枚举条件、没有三路驱动、没有历史)。

所以本框架给两套,共享同一套设计哲学(栈 / 事务 / 防重入这一脉),只是"重量"不同:

RevStateMachine\
├── Lightweight\        ← 轻:泛型类即状态,接口按能力拆(同步 / 栈 / 异步)
│   ├── Interfaces\     RevILightState / RevILightStackState / RevILightAsyncState
│   ├── Base\           RevLightStateBase / RevLightAsyncStateBase
│   └── Machines\       RevLightStateMachine / RevLightStackStateMachine
└── Heavyweight\        ← 重:枚举 + 行为接口,三路驱动 + 原因 + 历史
    ├── RevIHeavyFsmOwner.cs
    ├── RevHeavyFsmState.cs
    └── RevHeavyFsm.cs
轻量级不引用 UnityEngine(只用框架自己的 RevTask / RevCancellationTokenSource),可以脱离引擎做单元测试; 重量级用到 Time.time(历史时间戳)和 Debug(日志出口),所以依赖引擎。

2当前实现 vs 早期版本

2.1 总览

维度早期版本(Interface\ + 状态基类\ + 状态机管理器\ + 示例用法\)新版(Runtime\RevStateMachine\)
代码规模(核心)3 文件 / 729 行(非空 646、注释 234)10 文件 / 1380 行(非空 1233、注释 621 ≈ 五成)
示例代码3 文件 / 1605 行(非空 1336)—— 示例比框架本身大一倍多0(改为本文档 + 每个文件的头部设计说明)
套数1 套 + 1 个分层变体(GenericStateMachine / HierarchicalStateMachine)2 套(轻量 7 文件 601 非空行 / 重量 3 文件 632 非空行)
创建状态Activator.CreateInstance(typeof(TState), new object[]{ this }) 反射AddState(BossStateType.Idle, new BossIdleState(fsm)) 显式 new
分层状态机有(HierarchicalStateMachine + ParentState / ChildStateMachine + 2 份 GetRootStateMachine)删除(要嵌套就组合多台状态机)
日志每次切换打 2 条 LogSystem.Info("退出状态 X" / "进入状态 Y"),每个方法都有诊断日志 [Conditional("UNITY_EDITOR")],正式包连字符串拼接都没有
异常处理每个 public 方法都 try/catch 后 LogSystem.Error(继续跑)只在状态回调边界隔离:DEBUG 抛、Release 记 Error
驱动UpdateState() / FixedUpdateState() / LateUpdateState()(不带 deltaTime)三路都带 deltaTime(OnUpdate(float) / OnFixedUpdate(float) / OnLateUpdate(float))
异步切换无有(事务式:await 双方 Prepare 才交割,可跨帧、可被取代)
重置Reset() 是坏的(见 2.5)Reset(initialState, reason):退出当前 + 清事务 + 清历史 + 保留注册表
历史Queue<TStateType>(只有枚举,最多 10 条)RevHeavyFsmTransition{From,To,Reason,Time} + GetHistory()
状态回调EnterState / QuitState / UpdateState(前两个 abstract,必须实现)OnEnter / OnExit / OnUpdate(virtual 空实现,只写关心的)
宿主访问AIObj 属性 + GetAIObj() / GetAIObj<T>() / IsAIObjOfType<T>()Owner 强类型属性(泛型已保证类型,无需运行时转型工具)
移除当前状态直接 QuitState() + null(状态机进入"没有当前状态"的中间态)拒绝(先切走再移除,必然有一个明确的前状态)
栈语义无轻量栈机:Push/Pop + OnSuspend/OnResume
说明:核心行数看起来"变多"了(646 → 1233 非空行),但结构完全不同 —— 早期版本 1336 行示例被"文档 + 文件头设计说明(621 行注释)"替代, 且新版是两套状态机:异步切换、事件、原因、取消令牌、栈语义、异常双轨,这些早期版本一个都没有。

2.2 用反射创建状态实例

// 早期版本 StateMachine.AddState<TState>
TState stateInstance = Activator.CreateInstance(typeof(TState), new object[] { this }) as TState;
// 新版:显式实例化 —— 构造参数看得见,注册顺序一目了然
fsm.AddState(BossStateType.Idle, new BossIdleState(fsm));

2.3 每个方法都 try/catch + 每次切换都打两条日志

// 早期版本 StateMachine.ChangeState(节选)
public virtual void ChangeState(TStateType stateType)
{
    try
    {
        if (!IsEnabled) { LogSystem.Warning("StateMachine未启用,无法改变状态"); return; }
        if (!stateDic.ContainsKey(stateType)) { LogSystem.Error($"状态 {stateType} 不存在"); return; }
        if (nowState != null && ...Equals(nowState.StateType, stateType))
        { LogSystem.Warning($"当前已经是状态 {stateType},无需转换"); return; }   // ★ 同状态也刷日志

        if (nowState != null) { PrevStateType = nowState.StateType; nowState.QuitState();
                                LogSystem.Info($"退出状态 {PrevStateType}"); }
        nowState = stateDic[stateType];
        CurrentStateType = nowState.StateType;
        nowState.EnterState();
        LogSystem.Info($"进入状态 {CurrentStateType}");                        // ★ 每次切换 2 条 Info
        AddToHistory(CurrentStateType);
    }
    catch (Exception e) { LogSystem.Error($"状态转换失败 - {e.Message}\n{e.StackTrace}"); }
}
两个后果 ① 日志洪水:一场战斗 50 个怪、每个怪每秒切一次状态 → 每秒上百条 Info,真机上日志本身就是卡顿源;
② 异常被吞:catch 之后状态机继续跑 —— 如果状态自己的 EnterState() 抛了异常, 那么"当前状态已经改了、但进入逻辑只做了一半",这种半截状态比崩溃更难查。

新版的处理是分边界:

// ① 诊断日志:正式包里被编译器整段移除([Conditional("UNITY_EDITOR")])
[Conditional("UNITY_EDITOR")]
public static void Debug(string message) => Write("[RevHeavyFsm][诊断] " + message, isError: false);

// ② 状态回调边界的异常隔离:DEBUG 抛(尽早发现)、Release 记 Error(不让一个状态拖垮整台机器)
private static void Guard(Action callback, string where)
{
#if DEBUG
    callback();
#else
    try { callback(); } catch (Exception e) { RevHeavyFsmLog.Error($"{where} 抛异常:{e}"); }
#endif
}
这套"DEBUG 抛、Release 隔离"的哲学和本框架的 UI 系统、对象池一致:开发期让错误炸出来,正式期让它别扩散。

2当前实现 vs 早期版本(续)

2.4 分层状态机:删掉,改成"组合多台"

StateMachine<TStateType, TFSMObj>
└── HierarchicalStateMachine<TStateType, TFSMObj>   // ParentState
BaseState<TStateType, TFSMObj>
├── ParentState / ChildStateMachine / HasChildStateMachine / SetChildStateMachine
└── HierarchicalState<TStateType, TFSMObj>          // 分层的"父状态"

而且两份 GetRootStateMachine()(状态机里一份、状态基类里一份,逻辑几乎一样),以及这个:

// 早期版本 BaseState.HierarchicalState
public override TStateType StateType => throw new NotImplementedException();   // ★ 一用就崩
分层状态机带来的实际业务用到的比例
父状态跳转时自动通知 / 驱动子状态机低(AI 的"技能吟唱"很少需要"父状态也跟着切")
子状态机继承父机上下文低(业务通常直接传引用)
多层级联 Dispose / Root 查找 / 谁驱动谁复杂度是全量的,但收益是零星的

新版彻底删掉,需要嵌套时组合多台状态机:

// Boss:一台管"阶段",一台管"当前行为"—— 两台各自干净,随时能看到彼此
private RevHeavyFsm<BossPhaseType, IBossObj> _phaseFsm;   // 一阶段 / 二阶段 / 斩杀
private RevHeavyFsm<BossActType,   IBossObj> _actFsm;     // 待机 / 追击 / 技能
判断标准很简单:当"父状态"需要知道"子状态在干什么"时,组合比继承更好写(两台机器的数据都能直接访问),而分层状态机恰恰把这件事做复杂了。

2.5 Reset() 是个坏掉的 API

// 早期版本 StateMachine.Reset
public void Reset(TStateType initialStateType)
{
    try
    {
        ClearStates();                     // ★ 这个方法里 stateDic.Clear()
        ChangeState(initialStateType);     // ★ 于是必然命中 "状态不存在" 分支
        LogSystem.Info($"重置状态机到初始状态 {initialStateType}");
    }
    catch (Exception e) { LogSystem.Error($"重置失败 - {e.Message}\n{e.StackTrace}"); }
}
所以早期版本的 Reset 永远只会"清空 + 打一条错误日志":状态机停在没有当前状态的位置, 还会顺手打出一条"重置状态机到初始状态 X"的 Info 让人以为成功了。这是"读代码时看不错、跑起来才知道"的典型。
// 新版:语义定清楚 —— 取消在途事务、退出当前状态、清空历史(保留已注册的状态),再切到初始状态
public void Reset(TStateEnum initialState, string reason = "Reset")
{
    AbortPendingAsync();
    ExitCurrentState();
    _history.Clear();
    _stateTime = 0f;
    ChangeState(initialState, reason);
}

2.6 UpdateState() 不带 deltaTime

// 早期版本
public void UpdateState()      { ... nowState.UpdateState(); }
public void FixedUpdateState() { ... nowState.FixedUpdateState(); }
public void LateUpdateState()  { ... nowState.LateUpdateState(); }

新版三路都带参数(OnUpdate(float deltaTime) / OnFixedUpdate(float fixedDeltaTime) / OnLateUpdate(float deltaTime)), 这也是参考实现 ②③ 两套泛型状态机的做法。

2.7 状态历史只有枚举

早期版本 Queue<TStateType> + maxHistorySize = 10,只能回答"切过哪些状态"。新版记录原因和时刻:

RevHeavyFsmTransition<TStateEnum> { From, To, Reason, Time }
// ToString() → "Idle → Combat(看见玩家)"

fsm.ChangeState(BossStateType.CastSkill, "进入射程,技能冷却完毕");
foreach (var t in fsm.GetHistory()) Debug.Log(t);

为什么值得为它加结构体?因为AI 排错时最常问的两个问题是"它为什么切走了"和"什么时候切的", 而这两条在早期版本里都答不出来。参考实现 2024 年才在自己的状态机上补上"切换原因"(见《05-业务场景实战》),这里直接内建。

2.8 命名与"反射式工具方法"

早期版本新版为什么
EnterState / QuitState / UpdateStateOnEnter / OnExit / OnUpdate与参考实现命名、与 C# 事件惯例一致(OnXxx = "我被调用")
abstract 三个回调(必须实现)virtual 空实现只 override 关心的;增删回调不再破坏所有子类
GetAIObj() / GetAIObj<T>() / IsAIObjOfType<T>()Owner(强类型属性)泛型参数已经保证了类型,运行时转型工具是多余的
GetStateMachine<TMachine>()Machine(强类型属性)同上
GetOtherState<TState>(type)GetState<TState>(type)短一点;功能一样(跨状态读数据)
CanChangeStateCanChangeTo和 ChangeTo 对齐

3运用了参考实现的哪些设计哲学

3.1 四套 → 两套的映射

参考实现类型与位置本框架的落地
① StateMachinePlugins/Shared/Common/StateMachine.cs,非泛型栈式,8+ 业务在用轻量栈机 RevLightStackStateMachine<T>:栈语义 + 泛型类型安全 + Push/Pop/Change/Clear
② StateMachine<T>tweenplayer UnityExtensions,泛型单状态轻量单机 RevLightStateMachine<T>:单状态 + StateTime + 防重入
③ StackStateMachine<T>tweenplayer UnityExtensions,泛型栈同上栈机(OnSuspend / OnResume 用的是③的命名)
④ FSM<T>HLOD 包,枚举 + 事务式重量级 RevHeavyFsm<TStateEnum, TOwner>:枚举 + 事务式异步切换
⑤ 实战模式《05-业务场景实战》事件解耦(StateChanged / StatePushed / StatePopped)、PendingState(tarState)、切换原因

3.2 栈语义是灵魂

参考实现"核心结论"第一条就是这条:状态不是简单"切换",而是"压栈 / 弹栈"。被压住的状态不销毁、只暂停:

大厅输入 →(进战斗 Push 摇杆)→ 大厅输入收到 OnSuspend:禁用大厅点击,但不销毁
战斗结束 →(Pop)        → 大厅输入收到 OnResume:恢复点击

如果没有栈语义,业务只能"销毁大厅输入 → 打完再重建",重建意味着重新初始化、重新拉配置、状态丢失。

3.3 事务式切换:从"轮询就绪"到"await RevTask"

参考实现④的原做法是让状态注册就绪检查:

旧状态 IsReadyToExit(资源卸完了吗)+ 新状态 IsReadyToEnter(资源加载好了吗)
    ↓ 两者都返回 true 才真正切换(可以横跨多帧)

本框架保留"双方就绪才交割"的语义,但把"就绪"换成本框架的 RevTask —— 状态直接把准备过程写成 async:

public override async RevTask PrepareEnterAsync(RevCancellationToken token)
{
    await RevResManager.LoadAsync<GameObject>("Boss", token);   // 加载完才算"就绪"
    await RevTask.Delay(500);                                // 想再等也能等
}
微动画一次交割里,四件事严格按这个顺序发生
① 旧状态 OnExit ② 换当前状态 + StateTime 清零 ③ 新状态 OnEnter ④ 记历史 + 广播 StateChanged
顺序固定带来的好处:新状态在 OnEnter 里读 StateTime 一定是 0;订阅者在 StateChanged 里读 CurrentState 一定已经是新状态。
对比项参考实现④本框架
表达方式每个状态注册 6 个委托(Entering / IsReadyToEnter / Entered × 退出侧)一个 async RevTask 方法
可读性就绪逻辑被拆成回调 + 轮询两个地方加载 → 等待 → 就绪,从上往下读
取消无内建内建 RevCancellationToken:被取代 / 主动取消时 Cancel(),业务可中断在途加载
每帧成本每帧轮询两个 IsReady*只在 await 完成时接管(由 RevTaskScheduler 驱动)

3.4 防重入:回调里再切状态 = 程序错误

参考实现②③都在 DEBUG 下对"回调里切状态"做了拦截。本框架把它做成两档:

✗ 在 OnExit / OnEnter 里切状态 轻量级:直接抛 InvalidOperationException;重量级:DEBUG 抛 / Release 记 Error 并拒绝 → 请延迟到下一帧
// 轻量级:直接抛(Release 也抛 —— 轻量业务"错了就该立刻发现")
throw new InvalidOperationException("[RevLightStateMachine] 不能在 OnEnter/OnExit 回调里切换状态…");

// 重量级:DEBUG 抛 / Release 记 Error 并拒绝(真机上一个 AI 的 bug 不该崩掉整场战斗)
#if DEBUG
    throw new InvalidOperationException(...);
#else
    RevHeavyFsmLog.Error($"不能在状态回调里切换状态({CurrentStateType} → {target} 已拒绝)。请延迟到下一帧。");
    return true;
#endif
"防重入"为什么重要:在 OnExit 里切状态,会让"退出 / 进入"发生两次,回调顺序变成 Exit(A) → Exit(B) → Enter(C) → Enter(B);一旦状态机带栈或带事务,就是数据结构的错乱。 拦住的成本是一行判断,不拦的成本是一个难查的时序 bug。

3运用了参考实现的哪些设计哲学(续)

3.5 StateTime:状态计时

参考实现②③都有"进入本状态多久了",切换时自动清零 —— 这是做 AI 最常用的一个信号:

protected override void OnUpdate(float dt)
{
    if (StateTime > 3f) ChangeTo(BossStateType.Idle, "待机超时");   // 不用自己记时间
}

3.6 tarState:知道"本来要去哪"

参考实现①的 StateMachine 里有个 tarState(目标状态)持久保留,实战用法很巧 —— LoadingState 在退出时读它,判断"如果目标不是战斗,就兜底释放战斗资源"。

位置属性含义
轻量单机PendingState异步切换的目标(等待交割期间)
重量级PendingStateType同上(枚举)
轻量栈机TargetState最近一次 Change 的目标(持久保留,和参考实现①一致)

3.7 事件解耦

参考实现⑤的实战模式:状态机切换后广播,其它系统订阅响应,而不是状态机主动去找它们。

fsm.StateChanged += (from, to, reason) => Debug.Log($"{from} → {to}({reason})");
sm.StateChanged  += (prev, next)      => _hud.Refresh(next);
sm.StatePushed   += state => Debug.Log($"压栈:{state.GetType().Name}");
sm.StatePopped   += state => Debug.Log($"弹栈:{state.GetType().Name}");

3.8 异常隔离:DEBUG 与 Release 双轨

环境行为为什么
DEBUG异常直接抛尽早发现,别让它被吞成"半截状态"
Release捕获 + RevHeavyFsmLog.Error真机上某个状态的 bug 不拖垮整台状态机(AI 停在上一状态继续跑,至少不连累其它怪)

3.9 我和参考实现不一样的地方

差异参考实现做法新版做法为什么
异步表达④ 用"就绪检查 + 委托注册"用框架自带 RevTask(async/await)一个方法读完,且天然带取消令牌;不再自造轮询
状态标识①非泛型(枚举/字符串)、②③泛型类、④枚举轻量用泛型类、重量用枚举各取所需:轻的免注册、重的要条件判断
层级参考实现单套里不做层级(四套各管一段)重量级也不做分层,要嵌套就组合多台与参考实现一致;早期实现的分层被证明性价比太低(见 2.4)
取消机制无RevCancellationTokenSource(每次异步切换一个)"更新的请求胜出"要能中断在途加载,否则白干活
事件部分版本靠业务自己订阅内建 StateChanged / StatePushed / StatePopped解耦是常态需求,成本极低
日志与调试用 #if ENABLE_EDITOR_STATISTIC 之类开关[Conditional("UNITY_EDITOR")] + 可替换日志出口正式包零开销,且能接框架统一的日志系统

4轻量级怎么用

4.1 文件与职责

Lightweight\
├── Interfaces\   RevILightState.cs          进入/退出/每帧(最小契约)
│                 RevILightStackState.cs     + 被压住/重新露出(栈语义)
│                 RevILightAsyncState.cs     + 进入前/退出前的异步准备
├── Base\         RevLightStateBase.cs       五个回调全空
│                 RevLightAsyncStateBase.cs  再加两个 Prepare(默认直接完成)
└── Machines\     RevLightStateMachine.cs        单状态机(含事务式异步切换)
                  RevLightStackStateMachine.cs   栈状态机(Push/Pop/Change/Clear)

接口按能力拆开的好处:业务状态只实现自己用到的能力,不为用不到的方法写空实现。

4.2 单状态机 RevLightStateMachine<T>

public sealed class RevLightStateMachine<T> where T : class, RevILightState
{
    event Action<T, T> StateChanged;    // 切换后广播(旧, 新)
    T      CurrentState { get; }        // 当前状态
    bool   IsTransitioning { get; }     // 有异步切换在途
    T      PendingState { get; }        // 异步切换的目标(tarState)
    float  StateTime { get; }           // 进入当前状态的秒数

    void    ChangeTo(T target);         // 同步、立即交割
    RevTask ChangeToAsync(T target);    // 事务式:await 双方 Prepare 才交割
    void    CancelAsyncTransition();    // 取消在途异步切换(当前状态不变)
    void    Update(float deltaTime);    // ★ 每帧必须调
    bool    Is<TState>();

    // 注册(可选):登记"类型 → 实例",之后按类型切换,不必自己持有字段
    void    Register<TState>(TState state);     // 实例:长期复用
    void    Register<TState>(Func<T> factory);  // 工厂:每次取新实例(需构造参数时)
    bool    HasState<TState>();
    void    ChangeTo<TState>();                 // 按类型切(未注册直接抛异常)
    RevTask ChangeToAsync<TState>();
}
注册是可选的:键是「类型」而不是枚举 —— 不需要维护"枚举 ↔ 类型"两套东西(要枚举 + 条件判断的场景请用重量级状态机)。 注册的实例会被复用,于是 ChangeTo 判"是否已在该状态"(引用相等)能生效,天然避开"每次 new 就每次 Exit/Enter"的坑; 注册与直接传实例可以混用,未注册却按类型切会立刻抛异常。
动画 1轻量级单状态机:同步切(立即) vs 异步切(等准备完)
LobbyState激活 ⇄ 等待交割
BattleState待命 → 准备中 → 激活

① ChangeTo(LobbyState):只有 LobbyState.OnUpdate 被驱动。

② await ChangeToAsync(BattleState):PrepareEnterAsync 进行中(进度条=准备进度)—— LobbyState 仍是激活状态,继续被驱动。

③ 双方就绪 → 交割:LobbyState.OnExit → BattleState.OnEnter → StateChanged 广播。

④ ChangeTo(LobbyState):同步立即交割,不等任何异步准备。

第 ② 段是"事务式切换"的全部价值:准备期间旧状态照常运行(玩家不会看到卡死); 一旦被更新的请求取代,这次切换作废、状态不变。

两种切换的差别(务必分清)

对比项ChangeToChangeToAsync
交割时机立刻await 旧状态 PrepareExitAsync + 新状态 PrepareEnterAsync 之后
遇到异步状态跳过 Prepare(直接切)等它准备完
有在途异步切换时取消它(同步请求永远赢)取消它(更新的请求胜出)
返回值voidRevTask(可 await)
适用输入响应、UI 按钮加载完再进、动画播完再走

4.3 栈状态机 RevLightStackStateMachine<T>

public sealed class RevLightStackStateMachine<T> where T : class, RevILightStackState
{
    event Action<T> StatePushed, StatePopped;
    int   Count { get; }        // 栈深度
    T     Current { get; }      // 栈顶(激活状态)
    T     Under { get; }        // 栈顶下面一层(返回预览 / 面包屑)
    T     TargetState { get; }  // 最近一次 Change 的目标(tarState)
    float StateTime { get; }

    void Push(T state);         // 旧栈顶 OnSuspend → 新状态 OnEnter
    T    Pop();                 // 栈顶 OnExit → 新栈顶 OnResume
    void Change(T state);       // 替换栈顶(栈深不变)
    void Clear();               // 逐层 OnExit(不触发 OnResume)
    void Update(float dt);      // ★ 只驱动栈顶
    bool Is<TState>();
    // 栈由 Stack<T> 承载:只有 LIFO;不提供"按下标访问任意层"(那需要随机访问能力)

    // 注册(可选):登记"类型 → 实例",之后按类型压栈 / 替换栈顶
    void Register<TState>(TState state);        // 实例:复用(但同一实例不能连着压两次)
    void Register<TState>(Func<T> factory);     // 工厂:每次新实例(可把同一状态压多层)
    bool HasState<TState>();
    void Push<TState>();                        // 按类型压栈(未注册直接抛异常)
    void Change<TState>();                      // 按类型替换栈顶
}
动画 2轻量级栈状态机:Push / Pop 与 OnSuspend / OnResume 严格配对
LobbyState主界面 栈顶 · 激活OnSuspendOnResume
BagState背包 栈顶 · 激活OnSuspendOnResume
SettingsState设置弹窗 栈顶 · 激活

① 初始:Push(LobbyState) —— 只有栈顶被 Update 驱动。

② Push(BagState):LobbyState 收到 OnSuspend(禁用点击,但不销毁)。

③ Push(SettingsState):BagState 也收到 OnSuspend。

④ Pop():SettingsState.OnExit → BagState 收到 OnResume(恢复表现)。

⑤ Pop():BagState.OnExit → LobbyState 收到 OnResume(回到主界面,状态没丢)。

这就是栈语义的价值:被压住的状态不销毁、只暂停 —— 返回时不需要重建 (重建意味着重新初始化、重新拉配置、状态丢失)。
栈机是同步的:Push/Pop 是结构性变化,异步交割在栈上语义不清晰(参考实现也没有"栈 + 异步"的组合)。 要异步请用单机的 ChangeToAsync 或重量级状态机。

4轻量级怎么用(续)

4.4 异步准备:RevILightAsyncState

public interface RevILightAsyncState
{
    RevTask PrepareEnterAsync(RevCancellationToken token);   // 进入前的准备
    RevTask PrepareExitAsync(RevCancellationToken token);    // 退出前的准备
}

不想写一堆空实现就继承 RevLightAsyncStateBase(两个 Prepare 默认 RevTask.Completed):

public sealed class BattleLoadingState : RevLightAsyncStateBase
{
    public override async RevTask PrepareEnterAsync(RevCancellationToken token)
    {
        // ★ token 在这次切换被"更新的请求"取代时会被 Cancel —— 用来中断无用的加载
        await RevResManager.LoadAsync<GameObject>("UI/BattleHud", token);
        await RevTask.Delay(300);        // 读条至少显示 300ms,别闪一下
    }

    public override async RevTask PrepareExitAsync(RevCancellationToken token)
    {
        await RevTask.Delay(200);        // 退场动画播完再走
    }
}

4.5 异步切换的三条规则

① 只保留最新目标:在途时再发起一次 → 旧请求立刻作废(token.Cancel()),只有最新的会交割。

ChangeToAsync(Battle)
    └─ PrepareEnterAsync(Battle) 进行中…
ChangeToAsync(Lobby)              ← 玩家反悔,点了返回
    ├─ Battle 的那次:Cancel + 作废(不会交割)
    └─ Lobby 的那次:正常走

② 交割前,当前(旧)状态仍是激活状态:继续被 Update 驱动 —— 玩家不会看到"卡在中间"(比如大厅还在,只是正在加载战斗)。

③ await 之后要确认:被取代 / 被 CancelAsyncTransition() 取消时,状态保持不变且任务正常完成(不抛异常):

await sm.ChangeToAsync(new BattleState());
if (sm.Is<BattleState>()) ShowBattleHud();      // 真的切过去了
else                      Debug.Log("切换被取代或取消,仍在大厅");

4.6 一个完整例子:UI 流程 + 战斗加载

public sealed class GameFlow : MonoBehaviour
{
    private RevLightStateMachine<RevLightStateBase> _sm;

    private void Awake()
    {
        _sm = new RevLightStateMachine<RevLightStateBase>();
        _sm.StateChanged += (prev, next) => Debug.Log($"[流程] {prev?.GetType().Name} → {next?.GetType().Name}");
        _sm.ChangeTo(new LoginState());
    }

    private void Update() => _sm.Update(Time.deltaTime);

    public async void OnClickEnterBattle()
    {
        _sm.ChangeTo(new LoadingState());            // 立刻切到"读条"(同步、不等准备)

        await _sm.ChangeToAsync(new BattleState());  // 等战斗资源就绪才交割
        if (!_sm.Is<BattleState>()) { Debug.Log("加载被取消,留在读条态"); return; }
        ShowBattleHud();
    }

    public void OnClickCancel() => _sm.CancelAsyncTransition();   // 玩家点取消 / 断线
}

5重量级怎么用

5.1 定义枚举与行为接口

// ① 状态枚举(做条件判断、查表、存档都方便)
public enum BossStateType { Idle, Patrol, Chase, Combat, Phase2, Dead }

// ② 宿主行为接口:状态只面向"行为"编程,不依赖具体 MonoBehaviour
public interface IBossObj : RevIHeavyFsmOwner
{
    float Health { get; }
    Transform Target { get; }
    bool  IsPlayerInAttackRange();
    void  MoveTowardsPlayer();
    void  PlayAnimation(string name);
}

RevIHeavyFsmOwner 是空标记接口(继承自早期实现 IFSMObj 的设计):框架不规定宿主该有什么,行为契约由业务定义 —— 好处是状态逻辑可以被普通单元测试构造(传一个假的 IBossObj 实现即可)。

5.2 写一个状态

public sealed class BossCombatState : RevHeavyFsmState<BossStateType, IBossObj>
{
    public override BossStateType StateType => BossStateType.Combat;

    public BossCombatState(RevHeavyFsm<BossStateType, IBossObj> machine) : base(machine) { }

    public override void OnEnter() => Owner.PlayAnimation("Combat_Ready");   // Owner 是强类型行为接口

    public override void OnUpdate(float deltaTime)
    {
        if (Owner.Health < 0.5f)            { ChangeToAsync(BossStateType.Phase2, "血量低于 50%").Forget(); return; }
        if (!Owner.IsPlayerInAttackRange()) { ChangeTo(BossStateType.Chase, "玩家跑出射程"); return; }
        if (StateTime > 5f)                { ChangeTo(BossStateType.Idle, "战斗超时"); return; }
    }

    public override void OnExit() => Owner.PlayAnimation("Idle");

    // ★ 事务式:等"登场演出 + 二阶段资源"就绪才允许交割
    public override async RevTask PrepareEnterAsync(RevCancellationToken token)
    {
        Owner.PlayAnimation("Phase2_Intro");
        await RevTask.Delay(1200);                                       // 等登场演出
        await RevResManager.LoadAsync<GameObject>("Boss/Phase2Fx", token);  // 等资源(可被取消)
    }
}
回调(全部 virtual,默认空实现)时机
OnEnter / OnExit交割时各一次
OnUpdate(float) / OnFixedUpdate(float) / OnLateUpdate(float)每帧 / 固定步长 / Late(仅当前状态被调)
PrepareExitAsync(token) / PrepareEnterAsync(token)交割前的异步准备(默认直接完成)

状态里能拿到:Machine、Owner、StateType、IsActive、StateTime、 ChangeTo / ChangeToAsync / CanChangeTo / GetState<TState>(type)。

5.3 组装与驱动

private void Awake()
{
    _fsm = new RevHeavyFsm<BossStateType, IBossObj>(this);

    // ★ 显式 new(不是反射创建):构造参数看得见,IL2CPP/AOT 安全
    _fsm.AddState(BossStateType.Idle,   new BossIdleState(_fsm));
    _fsm.AddState(BossStateType.Chase,  new BossChaseState(_fsm));
    _fsm.AddState(BossStateType.Combat, new BossCombatState(_fsm));
    _fsm.AddState(BossStateType.Phase2, new BossPhase2State(_fsm));
    _fsm.AddState(BossStateType.Dead,   new BossDeadState(_fsm));

    _fsm.StateChanged += (from, to, reason) => Debug.Log($"Boss: {from} → {to}({reason})");
    _fsm.ChangeState(BossStateType.Idle, reason: "初始化");
}

private void Update()      => _fsm.UpdateState(Time.deltaTime);          // ★ 三路都要驱动
private void FixedUpdate() => _fsm.FixedUpdateState(Time.fixedDeltaTime);
private void LateUpdate()  => _fsm.LateUpdateState(Time.deltaTime);
private void OnDestroy()   => _fsm.Dispose();

5重量级怎么用(续)

5.4 事务式切换(动画演示)

// 同步:必须马上切(追击、脱战这类"反应")
_fsm.ChangeState(BossStateType.Chase, "看见玩家");

// 事务式:等双方准备完才交割(登场、阶段切换这类"演出")
await _fsm.ChangeStateAsync(BossStateType.Phase2, "血量低于 50%");
if (_fsm.CurrentStateType == BossStateType.Phase2) Debug.Log("二阶段已就位");
else                                              Debug.Log("切换被取代/取消");
动画 3a正常路径:双方准备完 → 才交割(Commit)
旧状态 PrepareExitAsync 等"收尾动画播完"
新状态 PrepareEnterAsync 等"资源加载完"
交割 ✓ 双方就绪 → Commit
两条进度条走完之前,当前状态一直是旧状态、一直在被驱动(AI 不会僵住); 只有双方都就绪,才会走 OnExit → OnEnter 的交割。
动画 3b被更新的请求取代:旧事务作废,绝不"半切换"
请求 A:切向 Phase2 ✗ 被取代 → Cancel&作废
请求 B:切向 Phase3 + 新请求抢占(最新目标胜出)
交割 ✓ 只有 B 会 Commit
请求 A 的 PrepareExitAsync 跑到一半时,请求 B 到来 —— A 的 token 被 Cancel(): 业务可以借它中断在途加载,A 也绝不会再交割(代次比对),状态不会出现"半切换"。

5重量级怎么用(续)

5.5 历史、重置、开关

_fsm.MaxHistorySize = 20;
foreach (var t in _fsm.GetHistory()) Debug.Log($"{t.Time:F2}s {t}");   // Idle → Combat(看见玩家)

_fsm.Reset(BossStateType.Idle, "复活");   // 退出当前 + 清事务/历史 + 保留注册表
_fsm.Clear();                             // 连注册表一起清(不广播 StateChanged)
_fsm.Dispose();                           // Clear + IsEnabled=false + 放掉宿主引用
_fsm.IsEnabled = false;                   // 临时停摆(切换与驱动都被忽略)

6必须知道的八个坑

6.1 在状态回调里切状态 → 被拦(防重入)

// ✗ 错误:OnEnter / OnExit 里切状态
public override void OnEnter() { ChangeTo(BossStateType.Chase); }

// ✓ 正确:交给 OnUpdate 的第一帧处理(切换后本来就会继续被驱动)
private bool _pendingSwitch;
public override void OnEnter() { _pendingSwitch = true; }
public override void OnUpdate(float dt)
{
    if (_pendingSwitch) { _pendingSwitch = false; ChangeTo(BossStateType.Chase, "进入即追击"); }
}

6.2 同步切换会跳过异步准备

如果目标状态实现了 RevILightAsyncState(或重量级 override 了 PrepareEnterAsync), 而你用的是同步的 ChangeTo / ChangeState,那么 Prepare 不会被执行:资源没加载、动画没等完就切进去了。

要准备过程就一定用异步入口(ChangeToAsync / ChangeStateAsync)。

6.3 异步切换"被取代"是静默的

await sm.ChangeToAsync(new BattleState());     // 进行中…
sm.ChangeToAsync(new LobbyState());            // 又发起了新的
// 第一个 await 会正常返回,但状态没切 —— 所以必须确认:
await sm.ChangeToAsync(new BattleState());
if (!sm.Is<BattleState>()) return;    // ★ 否则后面会对着错误的状态写逻辑

6.4 交割前旧状态还在跑,别在 PrepareExitAsync 里等"状态自己停下"

PrepareExitAsync 期间,旧状态仍被 OnUpdate 驱动(这是有意的:游戏不能僵住)。 所以"等旧状态的动画播完"得由你自己的标志位 / RevTask 表达,不要写"等 IsActive == false"(它只会在交割后才变成 false)。

6.5 忘了每帧驱动

状态机每帧要调什么
RevLightStateMachine<T>Update(deltaTime)(驱动当前状态)
RevLightStackStateMachine<T>Update(deltaTime)(只驱动栈顶)
RevHeavyFsm<TStateEnum, TOwner>UpdateState(dt) + FixedUpdateState(dt) + LateUpdateState(dt)

异步切换的恢复由 RevTaskScheduler(框架全局单例,自动创建)负责,但状态自己的 OnUpdate 仍然要靠你驱动 —— 两件事别混。

6.6 栈状态机不支持异步

Push / Pop / Change / Clear 都是同步的。需要"加载完再切屏"就换成单状态机 ChangeToAsync,或者用两台状态机(栈管 UI、单机管加载)。

6.7 RemoveState 不能移除当前状态

重量级会拒绝并告警(RevHeavyFsmLog.Warning)—— 先 ChangeState 到别的状态再移除。 早期实现在这里是"直接 QuitState() + null",状态机进入"没有当前状态"的中间态,之后 Update 全在空跑(而且日志上看不出问题)。

6.8 状态是显式 new 出来的,构造要自己传状态机

_fsm.AddState(BossStateType.Idle, new BossIdleState(_fsm));   // ✓

7API 速查

7.1 轻量级 · 接口与基类

类型成员说明
RevILightStateOnEnter() / OnExit() / OnUpdate(float)最小契约
RevILightStackState+ OnSuspend() / OnResume()栈语义
RevILightAsyncState+ PrepareEnterAsync(token) / PrepareExitAsync(token)异步准备(返回 RevTask)
RevLightStateBase五个回调全空同步状态继承它
RevLightAsyncStateBase再叠两个默认完成的 Prepare需要异步准备时继承它

7.2 轻量级 · 单状态机 RevLightStateMachine<T>

分类成员
切换ChangeTo(target)、ChangeToAsync(target)(返回 RevTask)、CancelAsyncTransition()
切换(注册后)ChangeTo<TState>()、ChangeToAsync<TState>()(按类型切,未注册抛异常)
注册(可选)Register<TState>(state)、Register<TState>(factory)、HasState<TState>()
状态读取CurrentState、PendingState、IsTransitioning、StateTime、Is<TState>()
驱动 / 事件Update(deltaTime)、event StateChanged(prev, next)

7.3 轻量级 · 栈状态机 RevLightStackStateMachine<T>

分类成员
结构Push(state)、Pop()、Change(state)、Clear()
结构(注册后)Push<TState>()、Change<TState>()(按类型操作,未注册抛异常)
注册(可选)Register<TState>(state)、Register<TState>(factory)、HasState<TState>()
状态读取Count、Current、Under、TargetState、StateTime、Is<TState>()
驱动 / 事件Update(deltaTime)、event StatePushed、event StatePopped

7.4 重量级 · 状态机 RevHeavyFsm<TStateEnum, TOwner>

分类成员
构造new RevHeavyFsm<TStateEnum, TOwner>(owner)
注册AddState(type, state)、RemoveState(type)、HasState(type)、GetState<TState>(type)、States
切换ChangeState(type, reason)、ChangeStateAsync(type, reason)(返回 RevTask)、CancelAsyncTransition()
状态读取Owner、CurrentState、CurrentStateType、PrevStateType、HasCurrentState、StateTime、IsTransitioning、PendingStateType、IsEnabled
驱动UpdateState(dt)、FixedUpdateState(dt)、LateUpdateState(dt)
历史 / 清理GetHistory()、MaxHistorySize、Reset(initialState, reason)、Clear()、Dispose()
事件event StateChanged(from, to, reason)

7.5 重量级 · 状态基类 RevHeavyFsmState<TStateEnum, TOwner>

分类成员
抽象 / 可重写StateType(abstract);OnEnter / OnExit / OnUpdate / OnFixedUpdate / OnLateUpdate;PrepareExitAsync / PrepareEnterAsync(virtual,默认直接完成)
读取Machine、Owner、IsActive、StateTime
便捷ChangeTo、ChangeToAsync、CanChangeTo、GetState<TState>

7.6 配套类型

类型说明
RevIHeavyFsmOwner宿主行为接口(空标记接口,业务继承它定义行为)
RevHeavyFsmTransition<TStateEnum>切换历史记录:From / To / Reason / Time
RevHeavyFsmLog日志出口:Sink(可接管)、Warning / Error / Debug(后两者 DEBUG 编译进、正式包移除)
RevTask / RevCancellationToken(Source)异步与取消(来自 Runtime\RevTask\)

8验证结果

验证项结果
Revolution.Runtime 程序集(Unity 真实引用 + UNITY_EDITOR,Debug 配置,临时把新文件加入 csproj 后整包编译)0 错误 0 警告
同上,Release 配置(走异常隔离的另一条分支、[Conditional] 生效)0 错误(2 条 CS0436 来自既有 RevTask.cs 的 netstandard2.0 兼容补丁,与状态机无关)
静态检查(IDE 语言服务,整个 RevStateMachine\)无 error / warning

编译器验证覆盖了什么:泛型约束(where TStateEnum : struct, Enum / where TOwner : class, RevIHeavyFsmOwner)、 async RevTask 的自定义 AsyncMethodBuilder 接入、#if DEBUG 的两条分支、[Conditional] 属性、 接口继承链(RevLightAsyncStateBase : RevLightStateBase, RevILightAsyncState)。

还没做的验证(诚实说明):没有像对象池那样写"工程外断言工程"逐条跑行为断言。下面这些行为目前是设计保证 + 代码审查,建议后续补断言:
  • 被取代的异步切换一定不交割(_version 代次比对);
  • token 在"被取代 / 主动取消"时确实被 Cancel();
  • MaxHistorySize 裁剪、Reset 后状态与历史的一致性;
  • 栈机的 OnSuspend / OnResume 配对(含 Clear 与逐层 Pop 的差别)。

9关键文件索引

文件职责
Runtime\RevStateMachine\Lightweight\Interfaces\RevILightState.cs轻量级最小契约:OnEnter / OnExit / OnUpdate
Runtime\RevStateMachine\Lightweight\Interfaces\RevILightStackState.cs栈语义:OnSuspend / OnResume
Runtime\RevStateMachine\Lightweight\Interfaces\RevILightAsyncState.cs异步准备:PrepareEnterAsync / PrepareExitAsync(返回 RevTask)
Runtime\RevStateMachine\Lightweight\Base\RevLightStateBase.cs五个回调的空实现基类
Runtime\RevStateMachine\Lightweight\Base\RevLightAsyncStateBase.cs再叠两个默认完成的 Prepare
Runtime\RevStateMachine\Lightweight\Machines\RevLightStateMachine.cs轻量单状态机:ChangeTo / ChangeToAsync / CancelAsyncTransition / StateTime / StateChanged
Runtime\RevStateMachine\Lightweight\Machines\RevLightStackStateMachine.cs轻量栈状态机:Push / Pop / Change / Clear / TargetState
Runtime\RevStateMachine\Heavyweight\RevIHeavyFsmOwner.cs宿主行为接口(空标记接口)
Runtime\RevStateMachine\Heavyweight\RevHeavyFsmState.cs重量级状态基类:七个回调 + 便捷方法
Runtime\RevStateMachine\Heavyweight\RevHeavyFsm.cs重量级状态机 + 切换历史 + 日志出口

对照资料

参考资料位置
异步与取消(事务式切换的底座)Runtime\RevTask\(RevTask / RevTaskScheduler / RevCancellationToken)
对象池(同为"当前实现 vs 早期版本"对照文档,可参照写法)Revolution.Document\对象池\对象池架构解析.md

10本期没做的(可后续扩展)

项说明
工程外断言像对象池那样把"可脱离引擎的核心"抽出来跑断言(异步取代、取消、栈配对、历史裁剪…)
状态机可视化 / 调试窗口编辑器窗口列出"当前状态 / 在途事务 / 历史 / 切换原因",AI 排错会快很多
分层(组合式)辅助封装现在"组合两台状态机"要业务自己协调;可给一个 RevHeavyFsmGroup 把"阶段机 + 行为机"的联动封装起来(保持"组合优于继承")
时间缩放 / 暂停现在 deltaTime 由调用方传,已经具备暂停能力;可以加一个统一的 RevHeavyFsm.TimeScale 便于慢动作调试
状态快照与回放把"当前状态 + 历史"序列化出来做战斗回放 / 断线重连校验(枚举状态天然适合)
栈机的异步切换目前刻意不做(语义不清晰);若确有"加载完再压栈"的需求,先评估是否该改成单状态机
一句话总结:状态机真正的难点不是"切状态",而是"切的前后各发生了什么"。
所以这套框架的两条主线是:一是把边界说清楚(进出回调、交割时机、事务就绪、被取代就作废、回调里不许重入); 二是把选择权留给业务(轻量 / 重量两套,同步 / 异步两个入口,接口按能力拆开)。