做游戏AI最头疼的事,基本上从“状态多了之后不知道谁该切谁”开始。我花了不少时间折腾普通有限状态机,后来切到分层有限状态机(Hierarchical Finite State Machine,简称HFSM),角色行为逻辑才彻底通透。这篇东西我不会讲那种放之四海皆准的教科书理论,而是直接结合自己做游戏项目的实际经验,从设计思路、核心代码、实战装配到踩坑排查,完整拆一遍这个HFSM框架。不管你是在重构AI模块、做交互原型,还是课程项目里被状态转换折磨到怀疑人生,这篇都能给你一个可以直接抄作业的底子。
1. 为什么游戏角色需要分层状态机
1.1 普通状态机的痛:状态地狱
先说说我最初用普通FSM时的惨状。一个敌人角色的状态大概有这几类:待机、巡逻、追踪、攻击、后退、闪避、硬直、受伤、逃跑、死亡。听起来不算多,10个状态而已。但真正写起来就发现问题了:每个状态之间几乎都可能互相切换,攻击中被硬直要切到受伤状态,硬直结束可能回攻击也可能回待机,逃跑途中又被击中了也得切到硬直。这些关系摊平画出来,完全是一张蜘蛛网。
代码里最典型的表现就是状态切换全堆在角色主循环里,一大串if (state == ATTACK && animDone && distance < range)之类条件,改一个逻辑要翻半天。更麻烦的是,攻击状态和待机状态都要读“角色面向玩家”这个数据,代码就复制来复制去。还有一个隐藏问题:每个状态里都可能改角色的移动速度、动画参数、攻击协程,一旦切换之前忘了清理某项数据,下一个状态就会带着脏数据运行两秒钟,然后出现“角色站在原地播放跑步动画”这种鬼畜表现。
这种状态数量一多就失控的局面,在游戏开发里有个很形象的名字,叫“状态地狱”。
1.2 分层思想:从平铺到树形
HFSM解决状态地狱的思路很简单:把状态从一层平铺变成多层级嵌套。根层表示角色的“整体意图”,子层表示“该意图下的具体动作”。比如“战斗”是根层的意图,而“接近玩家”“发动攻击”“闪避”“受击硬直”都是这个意图下的具体子状态。它们不应该和待机、巡逻平起平坐,因为在战斗意图下才会有这一连串动作。
我一般拿公司组织结构来类比这个模型:老板定目标,中层拆方案,员工执行细活。老板不会亲自管员工今天敲哪行代码,中层也不会去管公司是不是该调整整体战略。“意图”和“细节”是分层的,每一层只处理自己层面的逻辑。HFSM的每个父状态就像中层,它可以有自己的多个子状态,但父状态本身不执行具体更新,它的职责是决定“当前意图下该让哪个子状态跑”。
这个分层带来的直接好处有三点:第一,状态转换的边数大幅减少,根层不会频繁被战斗子状态打扰;第二,同一意图下的临时状态(比如硬直、闪避)不需要和所有平级状态建立关系,只和本意图内的相邻状态切换;第三,角色被击倒后要回攻击,路径是“战斗意图下的硬直自动回到攻击”,而不是让根层去判断角色刚才在干什么。
2. 框架设计:细节先想明白再动手
2.1 状态的生命周期约定
写HFSM框架之前,最关键的是先约定状态的生命周期。我固定为四个方法:OnEnter、OnUpdate、OnExit、OnEvent。OnEnter在状态成为当前叶子时触发,OnExit在离开时触发,OnUpdate每帧驱动当前叶子状态,OnEvent用于响应外部事件。
为什么一定要区分这四个方法?因为它们各自承担不同责任。OnEnter里通常做进入动作:保存进入前状态、播放进入动画、记录进入时刻。OnExit里做对称清理:停掉协程、恢复初始速度、清除临时标记。很多新手写的状态机没有OnExit,导致切换后上一个状态设置的“无敌时间”还在生效,或者动画状态残留。OnEvent用来接收像“见到玩家”“被打中”“血量过低”这类外部事件,状态可以自己决定要不要处理,没处理就往上抛给父状态。
状态设计上我习惯遵守一条原则:状态对象本身不持有角色业务数据。角色位置、血量、目标对象应该放在一个独立的FSMContext上下文结构里。这样做的原因很简单,多个状态都要读写这些数据,如果把数据塞进状态对象,状态切换时还得搬家,非常麻烦。上下文就是状态机访问角色数据的“公共内存”。
2.2 路径栈:状态切换的骨架
HFSM运行时的核心数据结构,是一条“从根到叶子”的状态路径栈。比如角色当前在Combat下的Attack状态,路径就是[Root, Combat, Attack]。栈底是根,栈顶是当前真正在Update的叶子状态。
为什么要维护一整条路径而不是只存一个叶子指针?因为状态切换时,必须知道两个状态之间的公共祖先层级。假设当前在[Root, Combat, Stunned],要切到[Root, Combat, Attack],它们的公共前缀是[Root, Combat],那么只需要退出Stunned,再进入Attack,整个Combat都不用动。如果当前在[Root, Patrol],要切到[Root, Combat, Approach],公共前缀只有[Root],那就得退出Patrol,进入Combat,再进入Approach。
这个过程完全符合栈的规律:后进入的状态先退出。我用一个标准C++的std::vector存路径,从尾部弹出要退出的状态,再往尾部压入新状态就行。路径栈本身也是调试利器,打印出来就是Root -> Combat -> Attack,一眼就知道角色当前在干嘛。
2.3 切换规则与事件冒泡
HFSM里的状态切换调用,我设计成ChangeState(targetPath),参数是目标状态ID的数组。切换算法就两步:先计算当前路径和目标路径的公共前缀长度,然后把公共前缀之后的所有状态依次退出,再逐层进入目标路径中公共前缀之后的那些新状态。
事件处理则采用了一种“冒泡”策略。DispatchEvent(eventId)会先从栈顶叶子开始调OnEvent,如果返回false表示叶子没兴趣处理,就往栈底方向继续找父状态处理。这个设计极大提高了事件容错能力。比如“被打中”这个事件,在Combat子层每个状态都可以处理,但如果角色在普通巡逻时被打中,叶子Patrol不想处理,事件冒泡到根层,根层统一切到战斗或逃跑,逻辑非常干净。
一个特别容易犯错的点是:事件处理和状态切换不要混在同一层反复横跳。我有一次设计成“被击中”事件在根层监听,然后从根层切到Stunned,结果角色的整个战斗意图全部被卸载,硬直结束后再切什么状态都变得手足无措。正确的做法是把Stunned放在Combat的子层,被打中的事件冒泡到Combat这一层处理,切换也在这一层内部完成。
3. 核心代码骨架:一份可以抄进项目的C++实现
3.1 状态与上下文定义
先定义基础的数据结构。状态ID用简单的枚举,事件ID也用枚举。上下文保持最简,实际项目里可以往里加角色速度、动画控制器、目标实体等。
enum StateID { kRoot = 0, kIdle, kPatrol, kCombat, kFlee, kApproach, kAttack, kDodge, kStunned }; enum EventID { kEventSawPlayer = 0, kEventLostPlayer, kEventInAttackRange, kEventOutOfAttackRange, kEventGotHit, kEventHpLow, kEventPlayerDied }; struct FSMContext { float hp = 100.0f; float maxHp = 100.0f; float distanceToPlayer = 0.0f; float aggroRange = 10.0f; float attackRange = 2.5f; bool playerAlive = true; };状态基类需要持有一个状态ID,这样路径栈才能比对公共前缀。虚函数全给默认实现,子类按需覆写。
class HFSMState { public: virtual ~HFSMState() = default; virtual int GetStateId() const { return kRoot; } virtual void OnEnter(HFSM& fsm, const HFSMState* from) {} virtual void OnUpdate(HFSM& fsm, float dt) {} virtual void OnExit(HFSM& fsm, const HFSMState* to) {} virtual bool OnEvent(HFSM& fsm, int eventId) { return false; } };有人可能会问,为什么HFSMState要持有HFSM&引用而不是上下文指针?因为状态在切换时经常需要调用ChangeState,直接把状态机实例传进去,状态内部就能发起转换,省得我在外部到处处理回调。这算是我个人比较偏好的写法,简单直接。
3.2 路径切换算法
HFSM类是框架的核心,维护路径栈、状态表、上下文。状态表用unordered_map把状态ID映射到状态对象指针,初始化时一次性注册,运行时禁止动态创建。
class HFSM { public: void Init(FSMContext* ctx) { ctx_ = ctx; states_.clear(); RegisterState(new IdleState(kIdle)); RegisterState(new PatrolState(kPatrol)); // 注册其余状态... stack_.clear(); ChangeState({kRoot, kIdle}); } void RegisterState(HFSMState* state) { states_[state->GetStateId()] = state; } void ChangeState(const std::vector<int>& targetPath) { if (targetPath.empty() || !states_.count(targetPath.back())) { return; } int common = 0; while (common < (int)stack_.size() && common < (int)targetPath.size()) { if (stack_[common]->GetStateId() != targetPath[common]) break; ++common; } for (int i = (int)stack_.size() - 1; i >= common; --i) { HFSMState* fromState = i > 0 ? stack_[i - 1] : nullptr; stack_[i]->OnExit(*this, fromState); } stack_.resize(common); for (int i = common; i < (int)targetPath.size(); ++i) { HFSMState* target = states_[targetPath[i]]; HFSMState* fromState = stack_.empty() ? nullptr : stack_.back(); target->OnEnter(*this, fromState); stack_.push_back(target); } } void Update(float dt) { if (!stack_.empty()) { stack_.back()->OnUpdate(*this, dt); } } bool DispatchEvent(int eventId) { for (int i = (int)stack_.size() - 1; i >= 0; --i) { if (stack_[i]->OnEvent(*this, eventId)) { return true; } } return false; } FSMContext* GetContext() const { return ctx_; } const std::vector<HFSMState*>& GetPath() const { return stack_; } private: std::vector<HFSMState*> stack_; std::unordered_map<int, HFSMState*> states_; FSMContext* ctx_ = nullptr; };这里有一个细节我吃了不少亏:ChangeState的公共前缀比较不能只比较HFSMState*指针,因为同一个状态可能通过不同路径实例化?不是,在HFSM里同一个状态的ID是全局唯一的,所以按GetStateId()比较是安全的。按指针比较遇到重复注册时会失效,按ID比较稳。
OnExit的参数我传的是“上一个状态”,也就是路径里比它更靠近根的那个状态,用来告诉子状态“我是被谁叫出来的”。OnEnter的参数传的是from,也就是进入这个状态时栈顶那个状态。很多时候子状态需要知道“谁切到了我”,比如Patrol切到Combat,Combat进入时想拿到Patrol引用,看巡逻是否巡检到一半。这两个参数非常有用,省得你再去额外维护一套切换来源表。
3.3 代码说明与使用方式
整个框架的使用流程可以归纳为三步:初始化上下文、注册状态、调用ChangeState触发初始路径,然后每帧调用Update,外部事件来了调用DispatchEvent。状态内部可以通过fsm.ChangeState继续发起子层级切换。
这里要特别提醒:不要绕开ChangeState直接修改返回的路径栈。GetPath()只用于调试输出,别在外面push_back一个新的叶子状态,否则状态生命周期会彻底乱套。所有切换必须走ChangeState,由它统一处理退出和进入流程,这样才保证每个状态的OnExit都会被调用到。
4. 实战装配:一个会巡逻、会战斗、会逃跑的守卫NPC
4.1 状态定义与层级结构
用一个具体例子把框架串起来。假设我们做一个守卫NPC,日常在据点周围巡逻,发现玩家就近身攻击,血量低了会跑路。状态树结构如下:
| 层级路径 | 含义 | 说明 |
|---|---|---|
| Root -> Patrol | 巡逻意图 | 沿路点循环移动 |
| Root -> Combat -> Approach | 战斗意图-接近 | 朝玩家移动直到进入攻击范围 |
| Root -> Combat -> Attack | 战斗意图-攻击 | 攻击动作与伤害判定 |
| Root -> Combat -> Dodge | 战斗意图-闪避 | 玩家反击时的短暂回避 |
| Root -> Combat -> Stunned | 战斗意图-硬直 | 被击中后的受击动画与恢复 |
| Root -> Flee | 逃跑意图 | 血量低时远离玩家 |
Combat状态本身不需要OnUpdate逻辑,它存在的意义是给子状态提供一个“公共容器”。Approach、Attack这些子状态切换时,根本不影响根层。等于角色始终处于战斗意图下,只是战斗内的小流程在流转。这一点就是分层和普通状态机最大的区别。
4.2 切换逻辑与数值调参
核心切换逻辑我放在各个状态的事件处理里。拿PatrolState为例:
class PatrolState : public HFSMState { public: PatrolState() : HFSMState() { stateId_ = kPatrol; } bool OnEvent(HFSM& fsm, int eventId) override { if (eventId == kEventSawPlayer) { FSMContext* ctx = fsm.GetContext(); if (ctx->distanceToPlayer < ctx->aggroRange) { fsm.ChangeState({kRoot, kCombat, kApproach}); return true; } } return false; } };这里有个细节:OnEvent处理完之后一定要返回true,表示事件已被消费,不要再让父状态处理。如果返回false,事件会继续冒泡到根层,根层可能又切一次战斗,导致重复切换。我看到过很多代码在状态里处理了事件却不返回true,结果同一帧切了两次状态,看起来就是“角色抽搐了一下”。
攻击状态下,InAttackRange事件让角色发起攻击,GotHit事件则切到Combat下的Stunned。Stunned结束后需要回哪个状态?我的设计是在StunnedState::OnUpdate里根据一个计时器恢复:
class StunnedState : public HFSMState { public: void OnEnter(HFSM& fsm, const HFSMState* from) override { restoreTimer_ = 0.5f; // 0.5秒硬直 } void OnUpdate(HFSM& fsm, float dt) override { restoreTimer_ -= dt; if (restoreTimer_ <= 0.0f) { FSMContext* ctx = fsm.GetContext(); if (ctx->distanceToPlayer < ctx->attackRange) { fsm.ChangeState({kRoot, kCombat, kAttack}); } else { fsm.ChangeState({kRoot, kCombat, kApproach}); } } } };因为Stunned在Combat子层,硬直结束之后可以直接回到攻击意图下的具体子状态,不需要根层操心。这个逻辑如果用普通FSM,你得让Stunned去连接所有可能的下一个状态,或者记录之前的状态ID,麻烦得多。
4.3 调参经验与运行效果
数值调参是另一个坑。攻击范围attackRange不能设得和警戒范围aggroRange太接近,否则角色刚进入战斗又因为出了范围退回巡逻,来回反复横跳。我实际调参时会把警戒范围设置为攻击范围的三倍左右,并额外加一个“丢失目标8秒后才退回巡逻”的延迟。8秒这个数值很关键,太短会导致玩家躲在掩体后面两秒,角色就收获回巡逻,再探出头时又触发交战,频繁进出战斗;太长又会让角色像磁铁一样追着人不放,穿越大半个地图。
逃跑阈值我设成最大血量的20%,并且逃跑状态不是直线远离,而是向最近的安全据点方向移动。这个数值如果设高了,比如30%,小规模战斗还没怎么打就触发逃跑,玩家体验会非常挫败;设低了,角色可能在逃跑途中被击倒,给人“它怎么不跑”的感觉。合理的阈值还要结合玩家伤害曲线来定,我习惯在模拟环境里放一个测试玩家,连续殴打NPC十次,看每次战斗的掉血速度再定。
整套框架跑起来之后,路径日志大概是这样的:
[Root, Patrol] -> event:SawPlayer -> [Root, Combat, Approach] [Root, Combat, Approach] -> event:InAttackRange -> [Root, Combat, Attack] [Root, Combat, Attack] -> event:GotHit -> [Root, Combat, Stunned] [Root, Combat, Stunned] -> timer:restore -> [Root, Combat, Approach] [Root, Combat, Approach] -> event:HpLow -> [Root, Flee]从日志就能清晰看出,战斗意图内的切换和根层意图切换被完美分开。玩家打NPC时,根层只切了两次:进战斗、逃跑。剩下的接近、攻击、硬直都在战斗意图内部流转,互不干扰。
5. 实际踩坑与排查技巧
5.1 切换路径时的状态泄漏
我遇到最多的bug是“切回根状态时,子状态的数据没有清理干净”。比如Combat状态下角色持有当前攻击目标,如果根层因为玩家逃远直接切到Patrol,虽然Combat的OnExit会被调用,但它的子状态Approach、Attack的OnExit只会在某条路径退出时触发一次。如果通过ChangeState({kRoot, kPatrol})切换,公共前缀是Root,会先退出Attack,再退出Combat,子状态自己的OnExit确实会调。问题往往出在CombatState本身没存目标清理逻辑,攻击目标指针悬空,等下次再进战斗时直接访问野指针。
解决办法是强制约定:一个复合状态在OnExit时,除了它自己的资源,还要负责让它的子状态图“复位到初始状态”。我这里用路径栈方式,切走时栈已经自动弹出了,但复合状态内部的子状态初始路径需要自己记住。可以给复合状态的初始化增加一个initialSubState指针,在OnEnter时自动切到该子路径。
5.2 转换条件互相打架
状态切换不是越多越好,条件互相冲突时会出现“上一帧的事件改变了状态,下一帧另一个事件又把状态拉回去”。我遇到过最典型的情况:角色刚被击中进入Stunned,同时玩家死亡事件触发,事件冒泡顺序是从叶子向根,Stunned先处理PlayerDied,它可能直接切回Idle,然后硬直计时器又触发切回Attack,最后角色对着空气攻击。
解决思路是给事件排优先级。我在DispatchEvent里加了一层事件类型判断,像PlayerDied、HpLow这类全局性高优事件,优先从根层向下派发;而GotHit这种战斗内事件,只在当前意图内流动。实现上不复杂,外部调用时先检查高优事件,再派发普通事件。同时每个状态内部也可以设定“当前状态不可打断”的标记,比如攻击前摇期间硬直事件可以被拒绝。
5.3 日志与可视化调试
调试HFSM最有效的手段,就是打印完整路径和切换原因。我在ChangeState里挂了一个回调,每次切换输出oldPath -> newPath加一行说明。刚开始会觉得日志很吵,但排查横跳问题的时候,这行日志就是救命稻草。我见过最诡异的问题是角色在两种状态间每帧切换,日志刷屏,最终定位到两个状态的事件条件互相满足,一个切过来另一个立刻切回去。没有日志,这种bug靠肉眼根本看不出来。
更好的做法是在调试面板里实时画出状态树,高亮当前路径。我用简单的文本界面就能做到:根部、子状态两个缩进层级,当前路径上的节点变色。调试时眼睛扫一眼就知道意图层和动作层的实际状态,比读日志快很多。
5.4 性能与对象复用
HFSM本身对性能的影响非常小,路径栈的操作只是几个指针的入栈出栈。但我在早期版本里犯过一个错误:为了让每个状态能拿到不同参数,我在ChangeState里动态new状态对象,结果每帧切换几次就产生大量内存碎片,接口机帧数直接掉下去。
正确做法是初始化时把整套状态树一次性new好,存进unordered_map,之后切换永远只换指针。如果有“同一个状态类需要多个实例”的需求,比如多个NPC各有一个状态机,那就让每个NPC实例都持有一套自己的状态对象,或者干脆用对象池。一套状态对象对应一台状态机,别共享。
最后再说一件事。状态机框架再好用,也架不住往里硬塞不属于当前层面的逻辑。有人会把“战斗日志记录”塞进攻击状态、把“地图边界检测”塞进巡逻状态,短期看不出问题,功能叠加后层级结构越来越乱。我后来给自己定了一条规矩:写每个状态前先问两个问题,这个状态属于哪个意图之下,它要做的事是不是这个意图层面的细活。问完再动手写,HFSM才能真正帮你省心。