源设计起源 · 为什么要做这个模块
- 没有它,逻辑会长成一团分支:血量、距离、眩晕、吟唱、Boss 阶段……条件越加越多:分支互相打架、上一帧的状态这一帧就忘了、进出时的清理散在各处。状态机把每种模式变成一个类,用 OnEnter / OnUpdate / OnExit 保证"清理一定会发生"。
- 一套不够,得两套:轻量业务(UI 流程)要"零容忍开销、随写随用";重量业务(怪物 / Boss AI)要枚举、行为接口、三路驱动、切换历史。硬凑一套的结果必然是"轻的嫌重、重的嫌轻",所以给两套、共享同一套设计哲学。
- 早期版本踩过的坑,这里逐条改掉:用反射创建状态(IL2CPP 不友好、构造签名靠隐式约定、失败只能运行时才发现);每个方法都 try/catch 且每次切换打两条日志(日志洪水,还留下"进入逻辑只做一半"的半截状态);Reset() 是个坏掉的 API;UpdateState() 不带 deltaTime(状态没法暂停、没法脱机测试)。
- 换来了什么:显式 new 注册状态;诊断日志在正式包整段移除;异常走"DEBUG 抛 / Release 隔离"双轨;切换历史带"原因 + 时刻"(AI 排错最常问的两句就是"为什么切走""什么时候切的");以及事务式异步切换(双方准备完才交割,可取消、可被取代)。
本篇只讲为什么;怎么用见同目录《使用说明》。
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);
第三步:记住三个铁律
- 回调里不许再切状态 —— 会抛异常(DEBUG)或报错拒绝(Release),这是防重入;
- 同步的
ChangeTo/ChangeState不等异步准备 —— 要等就用ChangeToAsync/ChangeStateAsync; 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 本体) |
| 驱动 | 只有 Update | Update + FixedUpdate + LateUpdate 三路 |
| 诊断 | 几乎不需要 | 需要切换历史 + 原因("AI 为什么突然切走了") |
| 对开销的容忍 | 零容忍(随写随用) | 可以接受注册表 / 字典查询 |
| 异步 | 偶尔(加载、动画) | 常见(Boss 登场、阶段切换的加载) |
一套硬凑的结果必然是:轻的嫌重(为了 UI 弹窗写一个枚举 + 行为接口 + 注册表),重的嫌轻(没有类型安全的枚举条件、没有三路驱动、没有历史)。
所以本框架给两套,共享同一套设计哲学(栈 / 事务 / 防重入这一脉),只是"重量"不同:
RevStateMachine\ ├── Lightweight\ ← 轻:泛型类即状态,接口按能力拆(同步 / 栈 / 异步) │ ├── Interfaces\ RevILightState / RevILightStackState / RevILightAsyncState │ ├── Base\ RevLightStateBase / RevLightAsyncStateBase │ └── Machines\ RevLightStateMachine / RevLightStackStateMachine └── Heavyweight\ ← 重:枚举 + 行为接口,三路驱动 + 原因 + 历史 ├── RevIHeavyFsmOwner.cs ├── RevHeavyFsmState.cs └── RevHeavyFsm.cs
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 |
2.2 用反射创建状态实例
// 早期版本 StateMachine.AddState<TState> TState stateInstance = Activator.CreateInstance(typeof(TState), new object[] { this }) as TState;
- IL2CPP / AOT 不友好:反射构造在裁剪、泛型实例化上有额外开销与坑(移动端尤其要小心);
- 隐式魔数约定:状态类必须恰好有一个"接受状态机"的公开构造,改签名就在运行时炸(编译期毫无提示);
- 失败只能运行时发现:反射失败 → 日志一行 Error → 状态字典里没有它 → 之后每次切它都报错。
// 新版:显式实例化 —— 构造参数看得见,注册顺序一目了然 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}"); } }
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 }
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(); }
- 状态逻辑没法暂停 / 慢动作(
Time.timeScale被别处改了就跟着变,且改不了); - 状态逻辑没法脱离引擎测试(构造一个状态就得有引擎的
Time); - 单位不明确:不知道那个数是"帧"还是"秒"。
新版三路都带参数(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 / UpdateState | OnEnter / OnExit / OnUpdate | 与参考实现命名、与 C# 事件惯例一致(OnXxx = "我被调用") |
abstract 三个回调(必须实现) | virtual 空实现 | 只 override 关心的;增删回调不再破坏所有子类 |
GetAIObj() / GetAIObj<T>() / IsAIObjOfType<T>() | Owner(强类型属性) | 泛型参数已经保证了类型,运行时转型工具是多余的 |
GetStateMachine<TMachine>() | Machine(强类型属性) | 同上 |
GetOtherState<TState>(type) | GetState<TState>(type) | 短一点;功能一样(跨状态读数据) |
CanChangeState | CanChangeTo | 和 ChangeTo 对齐 |
3运用了参考实现的哪些设计哲学
3.1 四套 → 两套的映射
| 参考实现 | 类型与位置 | 本框架的落地 |
|---|---|---|
① StateMachine | Plugins/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); // 想再等也能等 }
OnEnter 里读 StateTime 一定是 0;订阅者在 StateChanged 里读 CurrentState 一定已经是新状态。| 对比项 | 参考实现④ | 本框架 |
|---|---|---|
| 表达方式 | 每个状态注册 6 个委托(Entering / IsReadyToEnter / Entered × 退出侧) | 一个 async RevTask 方法 |
| 可读性 | 就绪逻辑被拆成回调 + 轮询两个地方 | 加载 → 等待 → 就绪,从上往下读 |
| 取消 | 无内建 | 内建 RevCancellationToken:被取代 / 主动取消时 Cancel(),业务可中断在途加载 |
| 每帧成本 | 每帧轮询两个 IsReady* | 只在 await 完成时接管(由 RevTaskScheduler 驱动) |
3.4 防重入:回调里再切状态 = 程序错误
参考实现②③都在 DEBUG 下对"回调里切状态"做了拦截。本框架把它做成两档:
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"的坑;
注册与直接传实例可以混用,未注册却按类型切会立刻抛异常。
① ChangeTo(LobbyState):只有 LobbyState.OnUpdate 被驱动。
② await ChangeToAsync(BattleState):PrepareEnterAsync 进行中(进度条=准备进度)—— LobbyState 仍是激活状态,继续被驱动。
③ 双方就绪 → 交割:LobbyState.OnExit → BattleState.OnEnter → StateChanged 广播。
④ ChangeTo(LobbyState):同步立即交割,不等任何异步准备。
两种切换的差别(务必分清)
| 对比项 | ChangeTo | ChangeToAsync |
|---|---|---|
| 交割时机 | 立刻 | await 旧状态 PrepareExitAsync + 新状态 PrepareEnterAsync 之后 |
| 遇到异步状态 | 跳过 Prepare(直接切) | 等它准备完 |
| 有在途异步切换时 | 取消它(同步请求永远赢) | 取消它(更新的请求胜出) |
| 返回值 | void | RevTask(可 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>(); // 按类型替换栈顶 }
① 初始: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("切换被取代/取消");
OnExit → OnEnter 的交割。
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)); // ✓
- 状态类必须把
machine传给基类构造(base(machine)),否则Machine/Owner拿不到; - 注册时会校验"这个状态属于本机"(
state.Machine != this直接报错),防止把 A 怪的状态注册到 B 怪上; StateType与注册键不一致时只告警,以注册键为准(写错枚举值时能立刻在日志里看到)。
7API 速查
7.1 轻量级 · 接口与基类
| 类型 | 成员 | 说明 |
|---|---|---|
RevILightState | OnEnter() / 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 便于慢动作调试 |
| 状态快照与回放 | 把"当前状态 + 历史"序列化出来做战斗回放 / 断线重连校验(枚举状态天然适合) |
| 栈机的异步切换 | 目前刻意不做(语义不清晰);若确有"加载完再压栈"的需求,先评估是否该改成单状态机 |
所以这套框架的两条主线是:一是把边界说清楚(进出回调、交割时机、事务就绪、被取代就作废、回调里不许重入); 二是把选择权留给业务(轻量 / 重量两套,同步 / 异步两个入口,接口按能力拆开)。