做游戏状态管理,绕不开有限状态机这套东西。角色不是待机就是走路,开怪之后切攻击,被打就进硬直,这套逻辑如果靠一堆bool变量堆出来,测到后期自己都想把代码删了重写。我这次折腾的是一个支持消息调度系统的泛型状态机,核心解决两个问题:一是状态切换要有清晰的转移控制,二是状态之间、外部系统与状态之间要有一条干净的消息通道,而不是所有地方都互相持有引用、互相调方法。
这个小框架我是在一个ARPG项目里实际落地过的,场景里一个角色要同时处理待机、移动、攻击、受击、死亡,外加技能事件、受击反馈、动画回调,用这套状态机整理完之后,逻辑分层清楚了很多。适合谁?建议用过Animator但没完全依赖Animator、或者自己写过一版简单状态机想再进一步的同学直接参考,代码思路可以直接抄,改一改就能融进自己的项目。
下面我从设计思路讲起,到核心代码,再到实际踩过的坑,全程按我实操时的顺序来。
1. 先聊清楚:我为什么不用if塞布尔的常规写法
1.1 bool地狱撑不过三个技能
任何一个Unity项目,只要角色行为超过三种,你都会碰到类似代码:
if (_isRunning && !_isAttacking && _canMove && !_isHurt) { // 移动逻辑 }这种条件判断的写法,初期跑得动。等加上了受击、死亡、QTE、技能霸体之后,if层数越来越深,状态组合数呈指数膨胀。最典型的问题是:攻击中不能移动,所以加了_isAttacking;受击时不能移动也不能攻击,于是判断里又要塞一个_isHurt;等加了冰冻、眩晕、嘲讽,每个控制状态都往条件里塞一个bool。到了排bug的时候,你根本说不清“为什么这个角色没动”——因为可能被任何一个bool挡掉了。
状态机的核心价值就在这里:把“行为”从“条件”里抽出来,变成一个个独立的状态对象,每个状态只管自己的生命周期。角色当前是什么状态,就只执行那个状态的逻辑。开发者不需要在全局条件判断里猜,只需要看“当前状态是什么”就够了。
1.2 我最早用Switch写状态机,踩了三个坑
一开始我图省事,用了最经典的枚举加Switch写法:
switch (_state) { case State.Idle: // do idle break; case State.Run: // do run break; case State.Attack: // do attack break; }这个写法玩小Demo没问题,但真正用起来有三个硬伤。
第一个坑:状态切换权限完全失控。任何代码都能直接改_state字段,可能在Update里刚切成Attack,下一秒被另一段逻辑又切回Idle,两个系统打架根本查不到是谁先改的。
第二个坑:Switch分支必然膨胀。状态一多,一个方法几百行,每个case里塞着进入逻辑、退出逻辑、更新逻辑,代码全搅在一起。想给每个状态单独建文件都不行,因为逻辑已经被Switch绑死了。
第三个坑:没有切换回调。状态进入时要播动画、要重置速度、要设置碰撞体,这些逻辑没地方放。只能每次Switch之后手动跟着调一遍,漏一次就出一个诡异bug。
1.3 泛型状态机的核心差别在哪里
用泛型的核心原因是类型安全。不同游戏角色的状态枚举不一样:小兵有巡逻、追击,Boss有多阶段变身,主角有连招、翻滚。如果给每个角色各写一套状态机,代码大量重复;如果都用字符串状态名,拼错了编译器不报警,运行时才炸。泛型加TState枚举约束,把状态类型限定在编译期确定,代码复用和类型安全都拿到了。
从C# 7.3开始支持where TState : struct, Enum这种泛型约束,Unity 2019.3以上的版本都内置了对应版本的C#编译器,所以不用考虑兼容性,直接用就行。
2. 架构设计:状态机与消息队列怎么拆分工
2.1 三大核心类型
我的框架由三个核心类型组成,各司其职:
| 类型 | 职责 | 关键点 |
|---|---|---|
Fsm<TState> | 状态机本体 | 管理状态注册、切换、更新、消息分发 |
FsmState<TState> | 抽象状态基类 | 提供OnEnter / OnExit / OnUpdate / OnMessage生命周期 |
FsmMessage | 消息结构体 | 承载消息ID、载荷、时间戳、紧急标记 |
状态机本体是个纯C#类,不依赖MonoBehaviour。这样做有两个好处:一是可以在编辑器里做单元测试,不跑游戏也能验证状态切换逻辑;二是状态机可以被任意载体持有,无论是角色、敌人、UI界面还是AI流程,都能复用同一套状态机框架。
状态基类设计成这样:
public abstract class FsmState<TState> where TState : struct, Enum { protected TState StateType { get; } protected FsmState(TState type) { StateType = type; } public virtual void OnEnter(Fsm<TState> fsm, object args) { } public virtual void OnExit(Fsm<TState> fsm) { } public virtual void OnUpdate(Fsm<TState> fsm, float deltaTime) { } public virtual void OnMessage(Fsm<TState> fsm, FsmMessage msg) { } }2.2 消息调度系统为什么要装在状态机内部
直接说结论:不是所有游戏消息都要走状态机,但状态机内部必须有处理“延迟响应”和“状态相关消息”的能力。
举例:角色A攻击角色B,B受伤要播放受击动画并短暂无法操作。这个“暂时无法操作”其实就是切换进Hurt状态;但A的攻击如果带击退效果,B的受击方向数据如果不在消息里传过去,你还得另找引用去拿攻击者的位置,非常脏。消息调度系统解决的就是这类跨状态的数据传递和时序控制。
消息处理流程是:
- 外部系统构造
FsmMessage并调用SendMessage - 状态机把消息按优先级放入队列
- 每帧更新末尾,状态机统一分发队列中的消息
- 当前状态通过
OnMessage接收并响应
发送方不需要知道接收者是谁,当前状态只需要实现处理逻辑,状态机负责把消息路由给当前状态。这就是消息调度相比直接方法调用的核心价值。
2.3 为什么不直接上事件总线
Unity里很多人用C#事件或第三方EventBus。事件总线的问题在于:事件发出后,所有订阅者都能收到,很难控制“只有当前状态响应”。比如受击消息如果走全局事件总线,UI要监听、音效要监听、连击系统要监听,角色状态反而要遍历监听者列表,事件作用域不可控。
状态机内置消息系统则天然隔离作用域:
- 消息只路由给当前状态,不会泄漏给无关系统
- 状态切换时,可以按策略清空过期消息
- 外部系统如果确实需要知道“当前状态切换到了什么”,可以额外注册状态变化回调,但消息本身不广播
消息总线是全局广播,状态机消息是定向投递,两种模式各有适用场景。我把消息总线留给游戏内的全局事件(如通关、计分、成就),把状态机消息留给状态流转相关的数据传递。边界清晰,两边都不乱。
3. 泛型状态机的核心实现与关键细节
3.1 状态注册与状态表构建
状态机的构造函数接收状态集合。我做了个FsmBuilder<TState>帮助类,让状态注册可读性更强:
public class FsmBuilder<TState> where TState : struct, Enum { private readonly Dictionary<TState, FsmState<TState>> _states = new(); private readonly HashSet<(TState from, TState to)> _transitions = new(); public FsmBuilder<TState> AddState(FsmState<TState> state) { _states[state.StateType] = state; return this; } public FsmBuilder<TState> AddTransition(TState from, TState to) { _transitions.Add((from, to)); return this; } public Fsm<TState> Build(TState initialState) { return new Fsm<TState>(initialState, _states, _transitions); } }这里用HashSet<(TState from, TState to)>做转移表,限制哪些状态允许互相切换。为什么需要转移表?最直接的原因是防止状态闪烁:比如Idle和Move频繁互切,动画一卡一顿;加了转移表之后,可以强制“Idle只能通过Run命令进入Move”,而不是任意状态下都能被某个系统改过去。
3.2 状态切换为什么必须原子化
我踩过一个很典型的坑:状态A的OnExit里触发了一个动画事件,动画事件的监听方又调用了SwitchTo,导致这次切换还没结束就嵌套进了下一个切换,状态栈直接乱套。解决办法是给切换过程加重入锁,同一时间只允许一个切换流程执行:
public bool SwitchTo(TState type, object args = null) { if (_isSwitching) { _pendingSwitch = (type, args); return false; } _isSwitching = true; try { if (!_transitions.Contains((CurrentType, type))) return false; _current?.OnExit(this); _current = _states[type]; _current.OnEnter(this, args); } finally { _isSwitching = false; } return true; }但我很快发现重入锁只能防止嵌套,解决不了“切换请求被丢弃”的问题。状态切换时如果收到新的合法请求,直接return false太粗暴,正确做法是把请求缓存到_pendingSwitch,在当前切换完成后的帧末统一执行。这样既不丢请求,也不打断当前切换:
internal void ResolvePendingSwitch() { if (_pendingSwitch is { } pending) { _pendingSwitch = null; SwitchTo(pending.type, pending.args); } }需要强调的是:OnExit和OnEnter里面绝对不要再直接调用SwitchTo。这是硬约束,我见过太多次因为破坏这个约束导致的状态错乱。如果确实需要在进入时立刻切换(比如收刀状态直接进跑步),也应该把目标状态塞给状态机的延迟切换队列。
3.3 消息队列怎么实现优先级
消息队列需要支持优先级。我的实现用了双队列:高优先级(urgent)和普通队列。受击、眩晕、打断这类消息必须插队;动画事件、角色语音这类普通消息排队即可:
private readonly Queue<FsmMessage> _urgentQueue = new(); private readonly Queue<FsmMessage> _normalQueue = new(); public void SendMessage(int msgId, object payload = null, bool urgent = false) { var msg = new FsmMessage { MsgId = msgId, Payload = payload, Timestamp = Time.time, Urgent = urgent }; if (urgent) _urgentQueue.Enqueue(msg); else _normalQueue.Enqueue(msg); }消息结构体定义:
public struct FsmMessage { public int MsgId; public object Payload; public float Timestamp; public bool Urgent; }为什么用int做消息ID而不是直接用枚举?因为状态机是泛型的,消息类型如果也用枚举,就需要再引入第二个泛型参数,调用链会变得很啰嗦。用int加常量定义最简单,又能兼容各种外部系统(比如Lua侧配置就能直接下发消息ID)。如果你喜欢强类型,也可以定义一个FsmMsgId的静态只读类,里面放常量:
public static class FsmMsgId { public const int TakeDamage = 1; public const int AnimEvent = 2; public const int SkillCancel = 3; }3.4 为什么让消息先入队而不是直接调用
有人会问:既然消息最终要交给当前状态处理,为什么不直接调用_current.OnMessage(this, msg),还绕一圈入队?
直接调用的最大问题是时序不可控。同一个物理帧里,OnTriggerEnter回了击退消息、Animator事件回了攻击命中消息、按键输入回了技能消息,这些消息如果直接执行,执行顺序取决于代码调用顺序,改一行代码就可能让顺序彻底变掉。入队之后统一在帧末按优先级分发,开发者就能明确知道“这一帧所有消息,按紧急优先、其次按发送顺序处理”,排查问题容易得多。
状态机的Update方法设计成:
public void Update(float deltaTime) { _current?.OnUpdate(this, deltaTime); ProcessDelayedMessages(); DispatchMessages(); ResolvePendingSwitch(); }顺序是:先更新当前状态逻辑,再处理延迟消息,再分发消息,最后解析待切换请求。为什么消息分发在状态更新之后?因为状态更新过程中可能会SendMessage,后分发才能保证当帧消息齐全;为什么待切换请求在最后?因为消息处理过程中可能触发SwitchTo,但此时不能马上切,要等所有消息消费完再说。
3.5 消息的生命周期与清空策略
消息从发送到消费有三个阶段:
- OnSend:入队等待
- OnDispatch:帧末统一消费,当前状态通过OnMessage接收
- 状态切换时,队列里还有未处理的消息——需要配置处理策略
我定义了策略枚举:
public enum MessagePolicy { DiscardOnSwitch, CarryToNext }默认是DiscardOnSwitch。原因很简单:上一帧发出来的“播放攻击音效”消息,到切换到Hurt状态时还执行,音效播放了但角色已经在硬直,体验很怪。消息有时效性,状态都切了,旧状态的消息就别再消费了。
但有例外。比如全局性的“进入结算界面”这类消息,即使状态切到Dead也要保证送达。这种情况给消息加一个persistent标记,在清空策略中放过。
4. 实战演示:一套可复用的角色状态机
4.1 定义状态与转移表
以一个ARPG角色为例,我定义了五个状态:
public enum PlayerState { Idle, Move, Attack, Hurt, Dead }转移关系配置:
- Idle与Move互相切换
- Idle和Move可以进Attack(抬手动作)
- 处于Attack时如果收到受击,可以进Hurt(受击打断攻击)
- Hurt结束后回到Idle
- 任意状态可以进Dead(死亡优先)
- 死亡后不允许再进其他状态
用Builder配置:
private Fsm<PlayerState> BuildFsm() { var builder = new FsmBuilder<PlayerState>(); builder.AddState(new IdleState()); builder.AddState(new MoveState(_config.moveSpeed)); builder.AddState(new AttackState(_config.attackList)); builder.AddState(new HurtState()); builder.AddState(new DeadState()); builder.AddTransition(PlayerState.Idle, PlayerState.Move); builder.AddTransition(PlayerState.Move, PlayerState.Idle); builder.AddTransition(PlayerState.Idle, PlayerState.Attack); builder.AddTransition(PlayerState.Move, PlayerState.Attack); builder.AddTransition(PlayerState.Attack, PlayerState.Hurt); builder.AddTransition(PlayerState.Hurt, PlayerState.Idle); builder.AddTransition(PlayerState.Idle, PlayerState.Dead); builder.AddTransition(PlayerState.Move, PlayerState.Dead); builder.AddTransition(PlayerState.Attack, PlayerState.Dead); builder.AddTransition(PlayerState.Hurt, PlayerState.Dead); return builder.Build(PlayerState.Idle); }转移表把状态切换的合法性收敛到了一处。昵称,如果策划后续说“移动中可以受击但不能被打断攻击”,那只需要调整Attack到Hurt这一行,而不必去状态类里改逻辑。
4.2 核心代码结构:MonoBehaviour怎么和状态机对接
状态机是纯C#,但最终要挂在场景里。我写了一个很薄的MonoBehaviour壳:
public class PlayerFsmRunner : MonoBehaviour { private Fsm<PlayerState> _fsm; [SerializeField] private PlayerConfig _config; private void Awake() { _fsm = BuildFsm(); } private void Update() { _fsm.Update(Time.deltaTime); } public void Send(int msgId, object payload = null, bool urgent = false) { _fsm.SendMessage(msgId, payload, urgent); } }这是一个关键的设计取舍:MonoBehaviour只负责生命周期驱动和对外的消息入口,不参与状态逻辑。所有的行为判断、状态转折都下沉到各个State类里。这样做测试起来很舒服——在纯C#环境下new一个Fsm出来,调用Update和SendMessage,跑完断言当前状态是否符合预期,根本不需要进入Play Mode。
4.3 状态类实现范例:以攻击状态为例
AttackState要处理的事情很多:进入时播攻击动画、设置攻击判定、处理攻击被打断、动画事件触发判定框。我把它拆成清晰的几个部分:
public class AttackState : FsmState<PlayerState> { private readonly AttackConfig _config; public AttackState(AttackConfig config) : base(PlayerState.Attack) { _config = config; } public override void OnEnter(Fsm<PlayerState> fsm, object args) { var animator = fsm.GetContext<Animator>(); animator.CrossFade("Attack", 0.1f); fsm.SendMessage(FsmMsgId.AnimTrigger, "Attack"); } public override void OnMessage(Fsm<PlayerState> fsm, FsmMessage msg) { if (msg.MsgId == FsmMsgId.AnimEvent) { var animEvent = (string)msg.Payload; if (animEvent == "HitFrame") { EnableHitbox(_config.hitRadius); } else if (animEvent == "EndAttack") { fsm.SwitchTo(PlayerState.Idle); } } } }注意这里状态和MonoBehaviour解耦的方式:通过fsm.GetContext<T>()获取共享的上下文组件。状态机内部维护一个上下文容器,在Build时注入,而不是让每个状态自己去找GameObject引用。这样同一套状态类既能用在角色身上,也能用在敌人身上,只要传入不同的上下文实现即可。
4.4 动画事件怎么接入消息调度
Unity的Animation Event如果直接挂在Animator上,必须绑定到具体的MonoBehaviour方法。我把它们转换成消息:
public class AnimEventBridge : MonoBehaviour { private PlayerFsmRunner _runner; public void OnAnimEvent(string eventName) { _runner.Send(FsmMsgId.AnimEvent, eventName); } }动画事件和逻辑彻底解耦。动画师在动画里叫“HitFrame”,代码只处理“HitFrame”这个字符串对应的效果;如果策划调整动画节奏,动画师改动画事件帧,完全不必动代码。EventBus做不到这个效果,因为事件总线需要注册订阅,而这里消息直接路由到当前状态,只要当前状态不是Attack,这个动画事件消息就自然被丢弃,不会误触其他逻辑。
4.5 受击消息的完整链路
受击是状态机消息调度的典型受益场景。外部系统(比如伤害检测)发送TakeDamage消息:
void OnTriggerEnter(Collider other) { if (other.TryGetComponent<DamageSource>(out var source)) { _runner.Send(FsmMsgId.TakeDamage, new DamageInfo { Value = source.Value, Direction = source.transform.forward }, urgent: true); } }当前状态是Attack时,AttackState的OnMessage里有响应:
public override void OnMessage(Fsm<PlayerState> fsm, FsmMessage msg) { if (msg.MsgId == FsmMsgId.TakeDamage && _config.canBeInterrupted) { fsm.SwitchTo(PlayerState.Hurt); } }当前状态是Dead时,DeadState不处理TakeDamage,消息被丢弃,死亡的角色不会再次受击进Hurt。这就是消息路由的价值——发送方完全不需要关心接收方是谁、是否在正确状态,状态机自动分流。
4.6 帧末状态切换的实际体验
我把受击的切换放到消息分发过程中执行,玩家实际体验是“命中瞬间”就进了受击状态,没有额外延迟。原因就是urgent: true的设计:紧急消息优先分发,切换请求会作为延迟切换在帧末解析。整个链路在一帧内完成,玩家看到受击动画、镜头震动和角色位移都在同一帧开始,肉眼看不出延迟。
5. 踩坑实录:状态机落地时的常见问题
5.1 问题:状态切换时消息被吞了
玩家按攻击键没反应,但按键事件确实发出了。排查后发现,攻击消息是在状态切换执行的瞬间发送的,此时切换队列还没清空,消息被插入之后,切换完成时状态已经从“可攻击”变成了“不可攻击”,消息被丢弃。
解法有两个,我最终选了双管齐下。一是输入类消息统一urgent: true,确保在普通消息之前消费;二是在SwitchTo完成时,如果存在未被消费的紧急消息,主动调用一次当前状态的OnMessage,把最后一刻挤进来的消息处理掉。
5.2 问题:泛型状态机和Unity序列化不兼容
Fsm是泛型类,Unity的Inspector窗口看不到它的字段,因为Unity序列化器对泛型不友好。这在开发调试期很痛苦,看不到当前状态就不知道角色在做什么。
我的方案是加一个调试可视化组件,用MonoBehaviour持有状态枚举引用:
public class FsmDebugView : MonoBehaviour { [SerializeField] private PlayerState _currentState; private Fsm<PlayerState> _fsm; public void Bind(Fsm<PlayerState> fsm) { _fsm = fsm; } private void Update() { _currentState = _fsm.CurrentType; } }这纯粹是为了开发时看状态,不参与任何逻辑。还有一种更强的方案是写自定义Inspector,直接序列化泛型字段,但工作量大且收益有限,我建议先用调试组件。
5.3 问题:状态越写越胖
Hurt状态如果同时要处理受击反馈、镜头震动、角色后退、无敌帧,会膨胀成一个上千行的巨型状态。状态机不应该是“什么都干”的上帝类,它只负责流程控制,表现逻辑应该外置。
我的拆分方式:用消息把不同的受击表现拆开。HurtState只负责进入硬直和退出硬直;受击反馈(镜头震动、粒子特效)是独立的系统,它们不注册状态机消息,而是注册全局事件总线。这样状态机保持轻量,表现系统保持独立,互不干扰。
5.4 问题:状态机和Animator需要硬耦合吗
不需要,也是不应该的。我的落地方式是让业务状态机和表现层的Animator通过消息桥接:
- 业务状态机切换状态,通过AnimTrigger消息通知Animator切动画
- Animator的动画事件,通过AnimEventBridge转成消息发回状态机
两者各自维护自己的状态,但通过消息保持同步。好处是如果动画美术改了动画切片名,代码不用改;如果要做性能优化,可以只更新Animator相关状态,业务状态机不参与。这个结构化设计让多人协作时互不阻塞。
6. 额外扩展:延迟消息与对象池的升级方案
6.1 延迟消息队列
实际项目中某些效果需要延迟触发,比如受击硬直结束后的0.2秒无敌帧、技能释放后的蓄力后摇。我给消息系统加了一个带时间戳的延迟队列:
private readonly Queue<(FsmMessage msg, float fireTime)> _delayed = new(); public void SendDelayed(int msgId, float delay, object payload = null) { var msg = new FsmMessage { MsgId = msgId, Payload = payload, Timestamp = Time.time + delay, Urgent = false }; _delayed.Enqueue((msg, Time.time + delay)); }每帧更新时检查队首消息是否到期,到时再转入普通队列。这个功能非常实用,技能系统和AI行为都频繁依赖延迟调度。如果后续项目规模变大,可以直接替换为完整的协程或Task系统,但接在状态机消息队列里有一个额外好处:随状态切换可清理的延迟消息,不会因为角色状态已经改变还触发旧逻辑。
6.2 状态对象池
状态对象本身是无状态的(或尽量保持无状态),所以可以复用。很多项目每个状态只有一份实例,切换时调用OnEnter/OnExit。我强烈建议不要为每次切换都new状态对象,GC压力会在几十个角色同时战斗时暴露出来。
我的做法是在Fsm内部为每个状态单例化,Builder AddState时创建一次,之后切换永远复用同一实例。状态之间需要传递的数据放在OnEnter的args参数里,而不是状态字段里。这样状态本身就是线程安全的、可预测的,也不用在切换时做复杂的重置。
6.3 状态机状态的可视化调试
最后分享一个开发效率倍增器:在屏幕上实时显示当前状态、最近处理的消息、切换历史。我用一个简单的OnGUI窗口:
private void OnGUI() { GUILayout.Label($"Current: {_fsm.CurrentType}"); _fsm.BufferLastMessages(20) .ForEach(msg => GUILayout.Label($"Msg: {msg.MsgId}")); }这个调试UI看起来简陋,但排查问题速度快得惊人。以前我在Animator的StateMachine里查半天不知道哪段动画状态错乱,现在一眼就能看到状态卡在哪个状态,哪个消息没被处理。等确定问题后,再把这个组件放到宏定义里,只在开发模式启用,发布时自动剔除。
实际写完这套状态机之后,我最明显的感受是:状态机的价值一半在切换逻辑的清晰,另一半在消息调度带来的解耦。普通的Switch状态机只能管流程,管不了数据和时序,加入消息系统之后才算完整。我个人实际操作中的体会是:不要把消息调度设计得太复杂,优先级双队列加延迟队列对绝大多数游戏已经够用;过度设计成任务编排系统反而会让状态机失去“有限状态”的可预测性。
最后再给大家一个小技巧:把状态机的“转移表”单独做成配置,哪怕一开始所有状态都允许互切,也要先有这个结构。因为后期每一次加新状态,转移表会提醒你检查新状态和旧状态的所有关系,这比在代码里满处找Switch强太多了。如果你正在被角色行为逻辑折磨,建议直接动手写一个泛型状态机,小步迭代,很快你这套代码就会成为整个项目里最稳定的一块。