在游戏服务端、客户端引擎、AI、技能系统、网络连接、关卡流程中,状态管理几乎无处不在。很多系统最初都只是几个 if、几个 bool、一个 switch,后来需求不断叠加,逻辑复杂导致代码混乱。
状态机解决的核心问题很朴素:当一个对象在任意时刻只能处于有限个状态之一,并且外部事件会驱动它从一个状态转移到另一个状态时,我们需要一种集中、明确、可验证的方式来表达这些规则。
1. 复杂业务逻辑为什么容易失控
复杂业务逻辑失控,通常不是因为一开始设计得太差,而是因为系统在演化过程中不断被"局部修补"。
以角色动作系统为例,最初只有:
- 待机 Idle
- 移动 Move
- 攻击 Attack
代码可能很简单:
cpp
if (input.move) {
PlayMove();
} else if (input.attack) {
PlayAttack();
} else {
PlayIdle();
}
后来加入跳跃、受击、眩晕、死亡、技能打断、动画锁、网络同步、服务端校验、客户端预测,逻辑就会开始膨胀:
cpp
if (!isDead && !isStunned && canMove && !isAttacking && input.move) {
...
}
问题不在于 if 本身,而在于业务规则被分散到了多个地方:
- 输入系统判断一次
- 动画系统判断一次
- 技能系统判断一次
- 网络同步判断一次
- AI 系统判断一次
- 服务器权威逻辑再判断一次
结果是:每个系统都以为自己知道角色当前"是什么状态",但它们的理解并不一致。即在没有统一状态机的情况下,角色的"当前状态"不是由一个权威模块统一维护的,而是散落在输入系统、动画系统、技能系统、物理系统、AI 系统、网络同步系统等多个子系统中。每个子系统都会根据自己关心的数据,局部判断角色当前能做什么、正在做什么、应该播放什么、是否可以响应某个事件。
状态机解决的核心问题是:把"当前是什么状态""什么事件可以发生""发生后变成什么状态""伴随什么动作和副作用"集中表达出来。
游戏开发特别容易遇到状态管理问题,因为游戏对象往往同时受到多种驱动:
- 玩家输入
- AI 决策
- 动画事件
- 物理碰撞
- 技能系统
- 网络包
- 时间流逝
- 战斗结算
- 服务器修正
如果没有明确的状态模型,最终会得到一堆看似能跑、实际很脆弱的隐式规则。
2. 从一个反例开始
假设我们要实现一个角色动作系统,包含:
- Idle:待机
- Move:移动
- Jump:跳跃
- Attack:攻击
- Stun:眩晕
- Dead:死亡
一个常见的早期实现可能是这样:
cpp
struct Character {
bool isMoving = false;
bool isJumping = false;
bool isAttacking = false;
bool isStunned = false;
bool isDead = false;
int hp = 100;
bool onGround = true;
bool hasWeapon = true;
float attackCooldown = 0.0f;
void Update(const Input& input, float dt) {
attackCooldown -= dt;
if (hp <= 0) {
isDead = true;
isMoving = false;
isJumping = false;
isAttacking = false;
isStunned = false;
PlayAnimation("Dead");
return;
}
if (isStunned) {
if (StunFinished()) {
isStunned = false;
PlayAnimation("Idle");
}
return;
}
if (input.attack && hasWeapon && attackCooldown <= 0.0f && !isJumping) {
isAttacking = true;
isMoving = false;
PlayAnimation("Attack");
DoAttack();
attackCooldown = 1.0f;
return;
}
if (input.jump && onGround && !isAttacking) {
isJumping = true;
isMoving = false;
onGround = false;
PlayAnimation("Jump");
return;
}
if (input.moveX != 0 || input.moveY != 0) {
if (!isAttacking && !isJumping) {
isMoving = true;
PlayAnimation("Move");
}
} else {
isMoving = false;
if (!isAttacking && !isJumping) {
PlayAnimation("Idle");
}
}
}
void OnHit(bool heavyHit) {
if (isDead) {
return;
}
if (heavyHit) {
isStunned = true;
isMoving = false;
isJumping = false;
isAttacking = false;
PlayAnimation("Stun");
}
}
void OnLand() {
if (!isDead) {
isJumping = false;
onGround = true;
PlayAnimation(isMoving ? "Move" : "Idle");
}
}
};
这段代码看起来并不离谱。很多线上项目的早期版本都类似。但问题会很快显现。
2.1 状态不唯一
多个 bool 表示互斥状态,天然会产生矛盾组合:
cpp
isJumping == true
isStunned == true
isAttacking == true
这到底表示什么?空中攻击时被眩晕?眩晕期间攻击?跳跃攻击被打断?
如果这些组合不是设计者明确允许的状态,那它们就是非法状态。问题在于,代码结构无法阻止它们出现。
2.2 非法状态难以避免
多个系统都能修改状态标记:
cpp
isMoving = false;
isAttacking = true;
isStunned = false;
只要某个地方漏改一个字段,就可能形成非法组合。状态一致性依赖程序员记忆,而不是依赖结构约束。
2.3 条件判断分散
攻击是否合法,可能出现在多个地方:
cpp
if (!isDead && !isStunned && hasWeapon && attackCooldown <= 0.0f)
后来策划说:"跳跃中也允许攻击,但落地攻击不允许被移动打断。"
于是你要在输入逻辑、技能逻辑、动画回调、服务器校验里都改一遍。只要漏一个地方,就会出现客户端能播动画、服务器不认账,或者服务器允许但客户端表现异常。
2.4 状态转移规则不清晰
从 Attack 能否进入 Jump?从 Stun 能否进入 Dead?从 Dead 能否响应 hit?
这些规则并没有被集中表达。你只能从分散的 if 中推理。
这对多人协作非常不友好。新同事想知道"角色被眩晕时收到死亡事件会怎样",只能全文搜索 isStunned、isDead、hp <= 0。
2.5 副作用难以管理
状态变化通常伴随副作用:
- 播放动画
- 发送战斗事件
- 写日志
- 同步网络状态
- 触发技能效果
- 清理输入缓存
- 修改移动组件
- 取消当前技能
- 广播角色死亡
如果这些副作用直接塞在各个 if 分支里,后续就很难回答:
- 状态到底有没有切换成功?
- 动画播放失败是否影响状态?
- 网络发送失败是否需要回滚?
- 服务端和客户端副作用是否一致?
- 事件重放时会不会重复扣血或重复发奖励?
2.6 需求变更容易引入 bug
例如新增一个需求:
"角色在攻击前摇阶段可以被眩晕打断,但攻击后摇阶段不能移动,只能进入 Idle。"
如果系统没有明确状态边界,最终很可能继续加字段:
cpp
bool isAttackStartup = false;
bool isAttackActive = false;
bool isAttackRecovery = false;
bool canBeInterrupted = true;
bool lockMovement = false;
状态数量没有减少,只是以更隐蔽的方式散落在字段里。
3. 状态机的基本概念
状态机不是简单地"把状态写成枚举"。真正有价值的是把状态、事件、转移、动作、守卫条件、副作用这些概念分离清楚。
下面仍以角色动作系统为例。
3.1 状态 State
状态表示对象在某一时刻所处的稳定语义。
例如:
cpp
enum class CharacterState {
Idle,
Move,
Jump,
Attack,
Stun,
Dead
};
状态应该回答的是:"角色现在处于哪一种行为模式?"
- Idle:无移动输入,处于地面待机
- Move:处于地面移动
- Jump:处于空中跳跃
- Attack:正在执行攻击流程
- Stun:被控制,暂时不能行动
- Dead:死亡,不再响应普通行为事件
一个好的状态应该是互斥的 。角色不应该同时是 Idle 和 Move,也不应该同时是 Attack 和 Dead。
3.2 事件 Event
事件表示外部或内部发生了什么,它驱动状态机尝试转移。
例如:
cpp
enum class CharacterEvent {
Move,
Stop,
Jump,
Land,
Attack,
Hit,
Stun,
Recover,
Die
};
事件不是状态。
Jump 作为事件表示"玩家按下跳跃键"或"AI 请求跳跃";Jump 作为状态表示"角色正在跳跃中"。
事件是瞬时的,状态是持续的。
3.3 转移 Transition
转移表示:
在某个当前状态下,收到某个事件,如果满足条件,则进入某个下一个状态。
例如:
| 当前状态 | 事件 | 下一个状态 |
|---|---|---|
| Idle | Move | Move |
| Move | Stop | Idle |
| Idle | Jump | Jump |
| Jump | Land | Idle |
| Attack | Stun | Stun |
| Stun | Recover | Idle |
| 任意非 Dead | Die | Dead |
转移规则是状态机的核心资产。它应该被集中表达,而不是散落在各个系统里。
3.4 动作 Action
动作是状态转移过程中执行的逻辑,通常分为:
- 离开旧状态时的动作
- 转移过程中的动作
- 进入新状态时的动作
例如从 Idle 收到 Attack 进入 Attack:
- 校验是否有武器
- 设置攻击开始时间
- 播放攻击动画
- 触发技能系统
- 写入战斗日志
- 广播状态变化
这些可以统称为 Action,但工程上建议继续拆分:状态机只决定"发生了什么转移",具体业务副作用交给外部系统或回调处理。
3.5 守卫条件 Guard
守卫条件表示状态转移前必须满足的条件。
例如:
Idle + Attack -> Attack需要hasWeapon == trueMove + Jump -> Jump需要onGround == trueAttack + Attack -> Attack可能需要comboWindowOpen == true任意状态 + Die -> Dead需要hp <= 0
Guard 的作用是让转移规则不只是"当前状态 + 事件",还可以表达业务约束。
3.6 当前状态 Current State
当前状态就是状态机当前持有的状态值:
cpp
CharacterState currentState = CharacterState::Idle;
真实项目中,当前状态通常还需要附带时间戳、状态版本号、进入状态的原因、上一个状态等调试信息。
3.7 初始状态 Initial State
初始状态是对象创建或重置后进入的状态。
角色生成后通常是:
cpp
InitialState = Idle;
但如果是死亡后复活,也可能从 Dead 重新进入 Idle,这属于另一条明确的业务转移,不应偷偷改字段。
3.8 终止状态 Final State
终止状态表示一旦进入,普通事件不再改变状态。
在角色动作状态机中,Dead 可以被视为终止状态。进入 Dead 后,Move、Attack、Jump 都应该非法或被忽略。
但是否绝对终止取决于业务。如果游戏有复活机制,那么 Dead 不是整个角色生命周期的终点,而只是动作状态机中的一个强约束状态。复活应通过明确事件表达,例如:
cpp
Dead + Revive -> Idle
4. 具体业务示例:角色动作状态机
本文选择「角色动作状态机」作为完整示例。这个示例足够贴近游戏开发,又不会像完整技能系统或 AI 行为树那样扩散过快。
4.1 状态列表
| 状态 | 含义 |
|---|---|
| Idle | 地面待机,无移动输入 |
| Move | 地面移动中 |
| Jump | 空中跳跃或下落中 |
| Attack | 攻击动作执行中 |
| Stun | 被眩晕,无法执行普通输入 |
| Dead | 死亡状态 |
4.2 事件列表
| 事件 | 来源 |
|---|---|
| move | 玩家输入、AI 请求移动 |
| stop | 玩家松开移动键、AI 停止移动 |
| jump | 玩家输入、AI 请求跳跃 |
| land | 物理系统检测落地 |
| attack | 玩家输入、AI 请求攻击 |
| hit | 战斗系统通知受击 |
| stun | 战斗系统或技能系统通知眩晕 |
| recover | 眩晕计时结束 |
| die | 血量归零或死亡结算 |
4.3 状态转移表
下面是一份简化但具备工程语义的转移表。
| 当前状态 | 事件 | 守卫条件 | 下一个状态 | 转移时执行的动作 |
|---|---|---|---|---|
| Idle | move | canMove == true |
Move | 播放移动动画;启用移动组件 |
| Move | stop | 无 | Idle | 播放待机动画;停止移动 |
| Idle | jump | onGround && canJump |
Jump | 播放跳跃动画;施加跳跃速度 |
| Move | jump | onGround && canJump |
Jump | 播放跳跃动画;保留水平速度 |
| Jump | land | onGround == true |
Idle | 播放落地或待机动画;清理空中状态 |
| Jump | move | 无 | Jump | 更新空中移动输入,不切换主状态 |
| Idle | attack | hasWeapon && cooldownReady |
Attack | 播放攻击动画;启动技能逻辑 |
| Move | attack | hasWeapon && cooldownReady |
Attack | 停止移动;播放攻击动画;启动技能逻辑 |
| Jump | attack | hasWeapon && cooldownReady && allowAirAttack |
Attack | 播放空中攻击动画;启动空中攻击逻辑 |
| Attack | stop | 无 | Attack | 幂等或忽略;攻击期间不响应停止移动 |
| Attack | move | canCancelAttackToMove |
Move | 取消攻击后摇;播放移动动画 |
| Attack | stun | canBeInterrupted |
Stun | 打断攻击;播放眩晕动画;通知技能系统取消 |
| Attack | hit | hitCausesStun |
Stun | 结算受击;打断攻击 |
| Stun | recover | stunTimeExpired |
Idle | 播放恢复动画;恢复输入 |
| Idle | stun | 无 | Stun | 播放眩晕动画;禁止输入 |
| Move | stun | 无 | Stun | 停止移动;播放眩晕动画 |
| Jump | stun | 无 | Stun | 清理空中控制或进入空中受击表现 |
| 任意非 Dead | die | hp <= 0 |
Dead | 停止所有动作;播放死亡动画;广播死亡事件 |
| Dead | move / jump / attack / hit / stun | 无 | Dead | 忽略或记录非法事件 |
| Dead | die | 无 | Dead | 幂等处理,避免重复死亡结算 |
这张表的价值不只是"能看懂"。更重要的是,它可以成为代码结构、测试用例、调试工具和策划配置的基础。
5. 如何识别状态、事件和转移
状态机设计最容易犯的错误,是把所有东西都塞进状态。状态太少会表达不清,状态太多又会爆炸。关键在于识别边界。
5.1 如何判断一个字段应该是状态,而不是普通属性
可以用几个问题判断。
第一,这个字段是否决定了对象能响应哪些事件?
例如:
cpp
isStunned
如果眩晕时不能移动、不能攻击、不能跳跃,那么它很可能应该是状态,或者是一个更高优先级的并行控制状态。
第二,这个字段是否与其他状态互斥?
cpp
isDead
isStunned
isAttacking
如果它们不能同时为真,继续用多个 bool 就很危险。
第三,这个字段是否有明确的进入和退出动作?
例如进入 Attack 要播放动画、锁移动、启动技能;退出 Attack 要清理技能上下文。这说明它有状态语义。
第四,它是否需要被日志、回放、网络同步、调试面板观察?
如果需要,那它不应该只是散落在代码中的临时标记。
但不是所有字段都应该变成状态。
例如:
cpp
hp
mana
moveSpeed
attackCooldown
onGround
hasWeapon
这些通常是属性或 Guard 输入,而不是主状态。它们影响状态转移,但不一定构成状态本身。
5.2 如何区分状态和事件
一个简单原则:
状态回答"现在是什么";事件回答"发生了什么"。
例如:
Jump作为事件:玩家按下跳跃键Jump作为状态:角色正在空中Attack作为事件:请求发起攻击Attack作为状态:攻击动作执行中Die作为事件:血量归零触发死亡Dead作为状态:已经死亡
如果一个概念只在瞬间发生,应优先建模为事件 。如果一个概念会持续一段时间,并影响后续行为,应优先建模为状态。
5.3 如何识别非法状态
非法状态通常来自多个维度混在一起。
例如:
cpp
isDead = true
isMoving = true
如果死亡后不能移动,这就是非法状态。
cpp
state = Attack
isStunned = true
如果攻击和眩晕互斥,这也是非法状态。
识别非法状态的关键,是问:
"这个组合在策划语义上是否存在?"
如果不存在,就应该让它无法表示。最直接的方法是把互斥概念合并到一个 enum class 中,而不是用多个 bool。
5.4 如何减少状态数量,避免状态爆炸
状态爆炸常见于把多个正交维度强行笛卡尔积组合。
例如:
- 地面 / 空中
- 待机 / 移动 / 攻击
- 正常 / 眩晕 / 死亡
- 持刀 / 持枪 / 空手
- 普通 / 隐身 / 无敌
如果全部组合成单一枚举,会得到:
cpp
GroundIdle
GroundMove
GroundAttack
AirIdle
AirMove
AirAttack
GroundStun
AirStun
GroundDead
AirDead
...
这很快不可维护。
避免状态爆炸的常见办法:
-
区分主状态和属性
hasWeapon、isInvisible、moveSpeed通常不应该进入动作主状态。 -
使用层级状态机
例如
Alive下有Idle、Move、Jump、Attack、Stun,Dead是另一个顶层状态。 -
使用并行状态机
动作状态、武器状态、网络状态、Buff 状态可以独立演化。
-
将短期阶段交给子状态机
攻击可以内部再拆成
Startup、Active、Recovery,但不一定暴露给角色主状态机。 -
不为纯表现差异创建状态
"持剑 Idle 动画"和"持枪 Idle 动画"通常是动画选择问题,不一定是状态机问题。
5.5 如何处理复合状态
游戏里经常有复合语义:
- 移动中攻击
- 空中受击
- 眩晕中死亡
- 攻击中被打断
- 网络重连中继续表现本地预测
不要急着把它们都做成一个巨大的枚举。
移动中攻击
如果攻击会打断移动,可以建模为:
text
Move + attack -> Attack
并在转移动作中停止移动。
如果攻击允许边移动边释放,动作状态和移动状态可能应该拆成两个并行状态机:
text
LocomotionState: Idle / Move / Jump
ActionState: None / Attack / Cast / Stun
空中受击
如果空中受击有独立动画和物理规则,可以考虑:
text
Jump + hit -> AirHit
但如果只是进入统一眩晕,并保留空中物理,则可以让 Stun 携带上下文:
cpp
struct StunContext {
bool wasAirborne;
};
状态数量不一定要增加。
眩晕中死亡
死亡通常是高优先级转移:
text
任意非 Dead + die -> Dead
也就是说,Die 事件可以从几乎所有状态打断到 Dead。
这比在每个状态里手写 if (hp <= 0) 更清晰。
6. 代码实现
下面给出一份简洁的 C++ 示例。它使用:
enum class定义状态和事件- 转移表集中表达规则
- Guard 处理转移前校验
- Action 处理转移动作
onEnter/onExit处理状态进入和离开- 明确处理非法转移和幂等事件
- 使用副作用接口与动画、技能、日志、事件系统解耦
6.1 基础类型定义
cpp
#include <algorithm>
#include <functional>
#include <iostream>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
// 角色状态枚举
enum class CharacterState {
Idle, // 站立
Move, // 移动
Jump, // 跳跃
Attack, // 攻击
Stun, // 眩晕
Dead // 死亡
};
// 角色事件枚举
enum class CharacterEvent {
Move, // 移动
Stop, // 停止
Jump, // 跳跃
Land, // 落地
Attack, // 攻击
Hit, // 受击
Stun, // 眩晕
Recover, // 恢复
Die // 死亡
};
// 状态枚举值转字符串 -- 用于日志和事件输出
std::string_view ToString(CharacterState state) {
switch (state) {
case CharacterState::Idle: return "Idle";
case CharacterState::Move: return "Move";
case CharacterState::Jump: return "Jump";
case CharacterState::Attack: return "Attack";
case CharacterState::Stun: return "Stun";
case CharacterState::Dead: return "Dead";
}
return "Unknown";
}
// 事件枚举值转字符串 -- 用于日志和事件输出
std::string_view ToString(CharacterEvent event) {
switch (event) {
case CharacterEvent::Move: return "Move";
case CharacterEvent::Stop: return "Stop";
case CharacterEvent::Jump: return "Jump";
case CharacterEvent::Land: return "Land";
case CharacterEvent::Attack: return "Attack";
case CharacterEvent::Hit: return "Hit";
case CharacterEvent::Stun: return "Stun";
case CharacterEvent::Recover: return "Recover";
case CharacterEvent::Die: return "Die";
}
return "Unknown";
}
6.2 角色上下文和副作用接口
状态机不应该直接依赖动画系统、技能系统、网络系统的复杂实现。可以通过接口或事件总线隔离。
cpp
// 角色上下文 -- 存储角色的运行时属性数据,作为状态机 guard 和 action 的输入。
// 这些字段由外部系统(物理检测、血量系统、冷却管理、计时器等)更新,
// 状态机只读取它们做转移决策,不直接修改(除 action 中对特定字段如 onGround 的写入)。
struct CharacterContext {
int entityId = 0; // 实体ID
// === 运行时属性 ===
int hp = 100; // 当前血量
bool onGround = true; // 是否在地面上(由物理系统更新)
bool canMove = true; // 是否允许移动(Buff/Debuff 系统控制)
bool canJump = true; // 是否允许跳跃
bool hasWeapon = true; // 是否持有武器
bool cooldownReady = true; // 技能冷却是否就绪
bool allowAirAttack = false; // 是否允许空中攻击
bool canCancelAttackToMove = false; // 攻击前摇是否可取消并移动
bool canBeInterrupted = true; // 当前动作是否可被硬控打断
bool hitCausesStun = true; // 受击是否导致眩晕
bool stunTimeExpired = false; // 眩晕持续时间是否已到(由计时器更新)
// === 辅助字段 ===
// 状态版本号 -- 每次状态切换时递增,可用于调试、回放验证、网络同步等
int stateVersion = 0;
};
// 副作用接口 -- 将状态机的转移逻辑与外部表现(动画/音效/物理/输入)解耦。
// 状态机通过此接口调用外部操作,不依赖具体引擎实现,
// 方便单元测试时 mock,也方便在不同引擎间移植。
class ICharacterSideEffects {
public:
virtual ~ICharacterSideEffects() = default;
// 播放动画
virtual void PlayAnimation(int entityId, std::string_view animation) = 0;
// 停止移动
virtual void StopMovement(int entityId) = 0;
// 施加跳跃冲量
virtual void ApplyJumpImpulse(int entityId) = 0;
// 开始攻击(触发技能系统)
virtual void StartAttack(int entityId) = 0;
// 取消攻击(打断当前技能)
virtual void CancelAttack(int entityId) = 0;
// 禁用玩家输入
virtual void DisableInput(int entityId) = 0;
// 启用玩家输入
virtual void EnableInput(int entityId) = 0;
// 发布状态变更事件 -- 向 UI/音效/网络等子系统广播
virtual void PublishStateChanged(
int entityId,
CharacterState from,
CharacterState to,
CharacterEvent event
) = 0;
// 日志输出
virtual void Log(std::string_view message) = 0;
};
// 控制台副作用实现 -- 将副作用以文本形式输出到 stdout,用于开发测试阶段。
// 生产环境中可替换为对接真实引擎(Unity/Unreal)的实现。
class ConsoleSideEffects final : public ICharacterSideEffects {
public:
void PlayAnimation(int entityId, std::string_view animation) override {
std::cout << "[Anim] entity=" << entityId << " animation=" << animation << "\n";
}
void StopMovement(int entityId) override {
std::cout << "[Move] entity=" << entityId << " stop movement\n";
}
void ApplyJumpImpulse(int entityId) override {
std::cout << "[Physics] entity=" << entityId << " apply jump impulse\n";
}
void StartAttack(int entityId) override {
std::cout << "[Skill] entity=" << entityId << " start attack\n";
}
void CancelAttack(int entityId) override {
std::cout << "[Skill] entity=" << entityId << " cancel attack\n";
}
void DisableInput(int entityId) override {
std::cout << "[Input] entity=" << entityId << " disable input\n";
}
void EnableInput(int entityId) override {
std::cout << "[Input] entity=" << entityId << " enable input\n";
}
void PublishStateChanged(
int entityId,
CharacterState from,
CharacterState to,
CharacterEvent event
) override {
std::cout << "[Event] entity=" << entityId
<< " " << ToString(from)
<< " --" << ToString(event)
<< "--> " << ToString(to) << "\n";
}
void Log(std::string_view message) override {
std::cout << "[Log] " << message << "\n";
}
};
这里的重点不是 ConsoleSideEffects,而是状态机本身只面向 ICharacterSideEffects。真实项目里,这些接口后面可以接动画系统、技能系统、日志系统、网络同步系统或事件队列。
6.3 转移表定义
cpp
// 状态转移表 -- 定义一条有限状态机(FSM)的转移规则
struct Transition {
CharacterState from; // 起始状态:当前角色必须处于此状态,该转移才会被检查
CharacterEvent event; // 触发事件:接收到此事件时尝试匹配该转移
CharacterState to; // 目标状态:转移成功后的新状态
// 守卫条件 -- 在转移执行前进行评估。
// 只有当返回 true 时才允许执行转移;返回 false 则拒绝转移并记录日志。
// nullptr 等同于无条件通过。
std::function<bool(const CharacterContext&)> guard;
// 动作 -- 转移成功时在状态切换的 OnExit 与 OnEnter 之间执行。
// 用于修改上下文数据(如标记离地、扣血)和触发外部副作用(如技能播放、物理推动)。
// nullptr 表示此转移无额外动作。
std::function<void(CharacterContext&, ICharacterSideEffects&)> action;
// 转移名称 -- 用于日志输出和通过名称进行查找(如 AnyToDead 的兜底转移)。
std::string_view name;
};
// 无条件通过守卫 -- 用于那些不需要额外条件的转移(如 Idle->Stun, Move->Stun 等)。
// 提取为独立函数以避免每个转移都创建重复的 lambda 闭包。
static bool Always(const CharacterContext&) {
return true;
}
// 角色有限状态机 -- 核心控制器。
// 职责:
// 1. 持有当前状态 current_
// 2. 维护所有合法转移规则 transitions_
// 3. 通过 Dispatch() 接收事件,执行状态转移的核心流程:
// 查找匹配转移 → 检查守卫 → 退出旧状态 → 执行动作 → 进入新状态 → 发布事件
// 4. 对非法转移、守卫拒绝、终态事件做防御性处理与日志记录
//
// 设计要点:
// - 状态转移规则集中定义在 BuildTransitions() 中,通过数据驱动而非硬编码 if-else
// - 副作用通过 ICharacterSideEffects 接口注入,实现逻辑与表现的解耦
// - 终态 (Dead) 只处理 idempotent Die 事件,其余一概拒绝
// - 当当前状态无精确匹配时,FindTransition 会回退到 AnyToDead 兜底转移
class CharacterStateMachine {
public:
// 构造时即构建全部转移表,转移表在整个生命周期中不变
CharacterStateMachine(CharacterContext& context, ICharacterSideEffects& effects)
: ctx_(context), effects_(effects) {
transitions_ = BuildTransitions();
}
// 查询当前状态
CharacterState CurrentState() const {
return current_;
}
// 事件分发入口 -- 状态机的核心方法。
// 处理流程:
// 1. 终态检查:若已处于 Dead,仅处理重复 Die(幂等),其余事件忽略
// 2. 查找匹配转移:先精确匹配 (from + event),若无则回退到 AnyToDead
// 3. 守卫检查:调用 guard 函数,不通过则记录日志并返回 false
// 4. 执行转移:OnExit → action → 更新状态 → OnEnter → 发布状态变更事件
//
// 返回值:
// true -- 事件被成功处理(含幂等 Die)
// false -- 非法转移或守卫被拒绝
bool Dispatch(CharacterEvent event) {
// 已处于终态:仅处理幂等 Die 事件
if (IsFinalState(current_)) {
return HandleFinalStateEvent(event);
}
// 查找匹配的转移规则(含 AnyToDead 兜底)
const Transition* transition = FindTransition(current_, event);
// 无匹配转移:记录非法转移日志
if (!transition) {
return HandleIllegalTransition(event);
}
// 守卫条件不满足:拒绝本次转移
if (transition->guard && !transition->guard(ctx_)) {
return HandleGuardRejected(*transition, event);
}
// 所有检查通过,执行转移
ApplyTransition(*transition, event);
return true;
}
private:
CharacterContext& ctx_; // 角色上下文引用,包含 HP、地面状态等运行时数据
ICharacterSideEffects& effects_; // 副作用接口,解耦动画/物理/输入等表现层逻辑
CharacterState current_ = CharacterState::Idle; // 当前状态,初始为 Idle
std::vector<Transition> transitions_; // 转移表,由 BuildTransitions() 一次性构建
private:
// 判断是否为终态 -- 当前仅有 Dead 为终态。
// 终态下状态机不再响应任何转移(除了幂等的 Die 事件)。
static bool IsFinalState(CharacterState state) {
return state == CharacterState::Dead;
}
// 查找匹配的转移规则。
// 查找策略(按优先级):
// 1. 精确匹配:from == 当前状态 && event == 触发事件
// 线性扫描 transitions_ 返回第一个命中(规则表顺序即优先级)
// 2. 兜底匹配(高优先级通用转移):当事件为 Die 且当前状态非 Dead 时,
// 按名称匹配 "AnyToDead" 转移 -- 无论角色处于何种状态,血量耗尽都应死亡
// 3. 无匹配返回 nullptr,由调用方 HandleIllegalTransition 处理
const Transition* FindTransition(CharacterState from, CharacterEvent event) const {
// 第一优先级:精确匹配 (from + event)
auto it = std::find_if(
transitions_.begin(),
transitions_.end(),
[from, event](const Transition& t) {
return t.from == from && t.event == event;
}
);
if (it != transitions_.end()) {
return &(*it);
}
// 第二优先级:跨状态兜底转移 -- 任意非 Dead 状态收到 Die 都进入 Dead。
// 业务语义:角色无论处于 Idle/Move/Jump/Attack/Stun 中的哪个状态,
// 只要血量归零收到 Die 事件就应该立刻死亡。
if (event == CharacterEvent::Die && from != CharacterState::Dead) {
auto dieIt = std::find_if(
transitions_.begin(),
transitions_.end(),
[event](const Transition& t) {
return t.name == "AnyToDead" && t.event == event;
}
);
if (dieIt != transitions_.end()) {
return &(*dieIt);
}
}
// 无匹配转移规则
return nullptr;
}
// 执行状态转移 -- 状态机的核心执行逻辑。
// 严格按以下顺序执行,保证状态切换的原子性和可观测性:
// 1. OnExit(旧状态) -- 清理旧状态的副作用(停止移动、取消攻击等)
// 2. action() -- 转移专属副作用(施加重力、扣血、播放技能等)
// 3. 更新 current_ -- 状态切换
// 4. stateVersion++ -- 递增版本号,便于调试/回放/网络同步
// 5. OnEnter(新状态) -- 应用新状态的持续性效果(播放动画、启用/禁用输入)
// 6. PublishStateChanged -- 向外部系统广播状态变更事件
void ApplyTransition(const Transition& transition, CharacterEvent event) {
CharacterState oldState = current_;
CharacterState newState = transition.to;
// 1. 退出旧状态:清理与该状态相关的持续性副作用
OnExit(oldState, event);
// 2. 执行转移专属动作(仅当 action 非空)
if (transition.action) {
transition.action(ctx_, effects_);
}
// 3-4. 更新当前状态并递增版本号
current_ = newState;
++ctx_.stateVersion;
// 5. 进入新状态:应用新状态的持续性效果
OnEnter(newState, event);
// 6. 发布状态变更事件 -- 外部系统(UI、音效、网络同步)通过此事件感知状态切换
effects_.PublishStateChanged(ctx_.entityId, oldState, newState, event);
}
// 退出状态时的清理逻辑 -- 撤销该状态的持续性副作用。
// 设计要点:只清理该状态特有的副作用,不处理跨状态通用的逻辑。
//
// Move 退出时:除非是因为继续移动(Move->Move 自循环),否则停止移动
// Attack 退出时:如果是被 Stun/Hit/Die 打断,需要取消攻击(播放打断动画等)
void OnExit(CharacterState state, CharacterEvent event) {
switch (state) {
case CharacterState::Move:
// 离开 Move 状态时停止移动,但 Move->Move(方向变更等)除外
if (event != CharacterEvent::Move) {
effects_.StopMovement(ctx_.entityId);
}
break;
case CharacterState::Attack:
// 攻击被硬控/受伤/死亡打断时,取消攻击
if (event == CharacterEvent::Stun || event == CharacterEvent::Hit || event == CharacterEvent::Die) {
effects_.CancelAttack(ctx_.entityId);
}
break;
default:
break;
}
}
// 进入状态时的初始化逻辑 -- 应用该状态的持续性效果。
// 每个状态进入时主要做两件事:播放对应的动画、设置输入可用性。
//
// Idle: 播放 Idle 动画 + 允许玩家输入
// Move: 播放 Move 动画(输入在 Idle 进入后已开启,无需重复操作)
// Jump: 播放 Jump 动画
// Attack: 播放 Attack 动画
// Stun: 播放 Stun 动画 + 禁用输入(玩家无法操作)
// Dead: 播放 Dead 动画 + 禁用输入(角色已死亡)
void OnEnter(CharacterState state, CharacterEvent) {
switch (state) {
case CharacterState::Idle:
effects_.PlayAnimation(ctx_.entityId, "Idle");
effects_.EnableInput(ctx_.entityId);
break;
case CharacterState::Move:
effects_.PlayAnimation(ctx_.entityId, "Move");
break;
case CharacterState::Jump:
effects_.PlayAnimation(ctx_.entityId, "Jump");
break;
case CharacterState::Attack:
effects_.PlayAnimation(ctx_.entityId, "Attack");
break;
case CharacterState::Stun:
effects_.PlayAnimation(ctx_.entityId, "Stun");
effects_.DisableInput(ctx_.entityId);
break;
case CharacterState::Dead:
effects_.PlayAnimation(ctx_.entityId, "Dead");
effects_.DisableInput(ctx_.entityId);
break;
}
}
// 处理发往终态的事件。
// 终态 (Dead) 的设计原则:
// - 幂等性:重复的 Die 事件视为合法(客户端可能因网络延迟重复发送)
// - 防御性:其他一切事件均拒绝 -- 死人不能移动、跳跃、攻击等
//
// 返回值:
// true -- 幂等 Die 事件(已死亡再收到 Die 无影响)
// false -- 其他事件(死人不响应任何操作)
bool HandleFinalStateEvent(CharacterEvent event) {
// 已死亡状态下重复收到 Die 事件:记录日志但视为成功(幂等)
if (current_ == CharacterState::Dead && event == CharacterEvent::Die) {
effects_.Log("Ignore idempotent Die event in Dead state.");
return true;
}
// 死人对其他一切事件均不响应
effects_.Log("Ignore event because character is already in final state.");
return false;
}
// 处理非法转移 -- 当前状态下该事件无匹配的转移规则。
// 记录详细日志便于调试,返回 false 告知调用方事件未被处理。
bool HandleIllegalTransition(CharacterEvent event) {
std::string message = "Illegal transition: state=";
message += ToString(current_);
message += ", event=";
message += ToString(event);
effects_.Log(message);
return false;
}
// 处理守卫被拒绝 -- 转移规则存在但 guard 条件不满足。
// 例如:Idle->Attack 需要 hasWeapon && cooldownReady,条件不满足时进入此分支。
// 记录转移名称、当前状态、触发事件以便排查业务逻辑。
bool HandleGuardRejected(const Transition& transition, CharacterEvent event) {
std::string message = "Guard rejected: transition=";
message += transition.name;
message += ", state=";
message += ToString(current_);
message += ", event=";
message += ToString(event);
effects_.Log(message);
return false;
}
// 构建转移表 -- 集中定义所有合法的状态转移规则。
//
// 每条转移 = { from, event, to, guard, action, name }
// 规则在 vector 中的顺序即匹配优先级:先定义的规则先被 FindTransition 命中。
//
// 转移表按照功能分为以下几组:
//
// === 移动相关 ===
// Idle --Move--> Move (guard: canMove)
// Move --Stop--> Idle
//
// === 跳跃相关 ===
// Idle --Jump--> Jump (guard: onGround && canJump; action: 标记离地 + 施加重力)
// Move --Jump--> Jump (同上)
// Jump --Land--> Idle (guard: onGround, 由物理系统在落地时设置)
//
// === 攻击相关 ===
// Idle --Attack--> Attack (guard: hasWeapon && cooldownReady)
// Move --Attack--> Attack (同上; action: 先停止移动再开始攻击)
// Jump --Attack--> Attack (guard: 额外需要 allowAirAttack; 空中攻击)
// Attack --Move--> Move (guard: canCancelAttackToMove; 攻击前摇可取消)
//
// === 受击/眩晕相关 ===
// Attack --Stun--> Stun (guard: canBeInterrupted; action: 取消攻击)
// Attack --Hit--> Stun (guard: hitCausesStun; action: 取消攻击)
// Idle --Stun--> Stun (无条件)
// Move --Stun--> Stun (无条件; action: 停止移动)
// Jump --Stun--> Stun (无条件)
// Stun --Recover--> Idle (guard: stunTimeExpired)
//
// === 死亡相关 ===
// Idle --Die--> Dead (guard: hp <= 0; name: "AnyToDead")
// 注:此转移名为 "AnyToDead",在 FindTransition 中作为兜底匹配,
// 任意非 Dead 状态收到 Die 都会命中此规则。
static std::vector<Transition> BuildTransitions() {
using S = CharacterState;
using E = CharacterEvent;
return {
// ===== 移动 =====
// Idle --Move--> Move: 站立状态开始移动,需要 canMove 条件
{
S::Idle,
E::Move,
S::Move,
[](const CharacterContext& c) {
return c.canMove;
},
[](CharacterContext&, ICharacterSideEffects&) {},
"IdleToMove"
},
// Move --Stop--> Idle: 移动中停止,回到站立
{
S::Move,
E::Stop,
S::Idle,
Always,
[](CharacterContext&, ICharacterSideEffects&) {},
"MoveToIdle"
},
// ===== 跳跃 =====
// Idle --Jump--> Jump: 站立起跳,需要在地面且可跳跃
{
S::Idle,
E::Jump,
S::Jump,
[](const CharacterContext& c) {
return c.onGround && c.canJump;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
c.onGround = false; // 标记离地
fx.ApplyJumpImpulse(c.entityId); // 施加跳跃冲量
},
"IdleToJump"
},
// Move --Jump--> Jump: 移动中起跳(跑跳),条件与站立起跳相同
{
S::Move,
E::Jump,
S::Jump,
[](const CharacterContext& c) {
return c.onGround && c.canJump;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
c.onGround = false;
fx.ApplyJumpImpulse(c.entityId);
},
"MoveToJump"
},
// Jump --Land--> Idle: 落地回到站立,由物理系统检测到 onGround 后触发
{
S::Jump,
E::Land,
S::Idle,
[](const CharacterContext& c) {
return c.onGround;
},
[](CharacterContext&, ICharacterSideEffects&) {},
"JumpToIdleOnLand"
},
// ===== 攻击 =====
// Idle --Attack--> Attack: 站立攻击,需要武器就绪且冷却完成
{
S::Idle,
E::Attack,
S::Attack,
[](const CharacterContext& c) {
return c.hasWeapon && c.cooldownReady;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.StartAttack(c.entityId);
},
"IdleToAttack"
},
// Move --Attack--> Attack: 移动中攻击(跑攻),先停止移动再出招
{
S::Move,
E::Attack,
S::Attack,
[](const CharacterContext& c) {
return c.hasWeapon && c.cooldownReady;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.StopMovement(c.entityId); // 先停步
fx.StartAttack(c.entityId); // 再出招
},
"MoveToAttack"
},
// Jump --Attack--> Attack: 空中攻击,额外需要 allowAirAttack 条件
{
S::Jump,
E::Attack,
S::Attack,
[](const CharacterContext& c) {
return c.hasWeapon && c.cooldownReady && c.allowAirAttack;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.StartAttack(c.entityId);
},
"JumpToAirAttack"
},
// Attack --Move--> Move: 攻击前摇中取消攻击并移动,需要 canCancelAttackToMove
{
S::Attack,
E::Move,
S::Move,
[](const CharacterContext& c) {
return c.canCancelAttackToMove;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.CancelAttack(c.entityId); // 取消攻击后再移动
},
"AttackCancelToMove"
},
// ===== 受击 / 眩晕 =====
// Attack --Stun--> Stun: 攻击中被眩晕打断,需要 canBeInterrupted
{
S::Attack,
E::Stun,
S::Stun,
[](const CharacterContext& c) {
return c.canBeInterrupted;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.CancelAttack(c.entityId); // 打断当前攻击
},
"AttackToStun"
},
// Attack --Hit--> Stun: 攻击中受击进入眩晕,需要 hitCausesStun
{
S::Attack,
E::Hit,
S::Stun,
[](const CharacterContext& c) {
return c.hitCausesStun;
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.CancelAttack(c.entityId); // 受伤打断攻击
},
"AttackHitToStun"
},
// Idle --Stun--> Stun: 站立状态被眩晕(无前置条件)
{
S::Idle,
E::Stun,
S::Stun,
Always,
[](CharacterContext&, ICharacterSideEffects&) {},
"IdleToStun"
},
// Move --Stun--> Stun: 移动中被眩晕,需要先停止移动
{
S::Move,
E::Stun,
S::Stun,
Always,
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.StopMovement(c.entityId);
},
"MoveToStun"
},
// Jump --Stun--> Stun: 跳跃中被眩晕(空中被打)
{
S::Jump,
E::Stun,
S::Stun,
Always,
[](CharacterContext&, ICharacterSideEffects&) {},
"JumpToStun"
},
// Stun --Recover--> Idle: 眩晕恢复,需要 stunTimeExpired
{
S::Stun,
E::Recover,
S::Idle,
[](const CharacterContext& c) {
return c.stunTimeExpired;
},
[](CharacterContext&, ICharacterSideEffects&) {},
"StunToIdle"
},
// ===== 死亡 =====
// 通用死亡转移(名称为 "AnyToDead",作为 FindTransition 的兜底匹配)。
// 规则上登记 from=Idle 仅为占位 -- 实际 FindTransition 通过名称查找,
// 任意非 Dead 状态收到 Die 事件且 hp <= 0 时都会命中此规则。
{
S::Idle,
E::Die,
S::Dead,
[](const CharacterContext& c) {
return c.hp <= 0; // 血量归零才允许死亡
},
[](CharacterContext& c, ICharacterSideEffects& fx) {
fx.StopMovement(c.entityId); // 死亡时停止一切移动
},
"AnyToDead"
}
};
}
};
6.4 使用示例
cpp
int main() {
CharacterContext ctx;
ctx.entityId = 1001;
ConsoleSideEffects effects;
CharacterStateMachine fsm(ctx, effects);
fsm.Dispatch(CharacterEvent::Move); // Idle -> Move
fsm.Dispatch(CharacterEvent::Attack); // Move -> Attack
fsm.Dispatch(CharacterEvent::Jump); // 非法:Attack 下没有 Jump 转移
fsm.Dispatch(CharacterEvent::Stun); // Attack -> Stun
ctx.stunTimeExpired = true;
fsm.Dispatch(CharacterEvent::Recover); // Stun -> Idle
ctx.hp = 0;
fsm.Dispatch(CharacterEvent::Die); // Idle -> Dead
fsm.Dispatch(CharacterEvent::Move); // Dead 状态下忽略
fsm.Dispatch(CharacterEvent::Die); // 幂等处理
return 0;
}
这份代码不是一个完整引擎级实现,但它体现了几个关键工程点:
- 状态定义集中。
- 事件定义集中。
- 转移规则集中。
- Guard 明确表达转移前校验。
- Action 和状态进入/退出逻辑有边界。
- 非法转移有统一处理。
- 幂等事件有明确策略。
- 副作用通过接口隔离,不直接耦合具体系统。
6.5 这份代码还可以如何演进
真实项目中可以继续改进:
- 使用
std::unordered_map<StateEventKey, Transition>替代线性查找。 - 给转移规则增加优先级,例如
Die高于其他事件。 - 让
Transition支持from = Any。 - 把 Guard 和 Action 注册为函数对象或脚本绑定。
- 增加事件队列,避免状态转移过程中重入。
- 增加状态持续时间,例如
timeInState。 - 增加调试快照,例如
lastEvent、previousState、transitionReason。 - 将副作用拆成命令列表,而不是转移时立即执行。
例如,状态机可以返回副作用命令:
cpp
struct EffectCommand {
enum class Type {
PlayAnimation,
StopMovement,
StartAttack,
PublishEvent,
WriteLog
};
Type type;
std::string payload;
};
这样状态机本身变得更纯粹,测试时只需要断言:
text
Idle + Attack -> Attack
Effects = [PlayAnimation("Attack"), StartAttack]
这比 mock 一堆系统更稳定。
7. 工程实践问题
7.1 非法状态转移如何处理
非法转移不能简单忽略,至少要分场景处理。
常见策略有三类。
第一类:严格错误。
适用于服务端权威逻辑、支付流程、关卡结算、不可恢复的状态一致性问题。
text
Attack + Jump -> 非法
如果这是协议层非法行为,服务端可以拒绝请求、记录安全日志,甚至断开异常客户端。
第二类:业务忽略。
例如死亡状态下收到移动输入:
text
Dead + move -> ignore
这不一定是错误,客户端输入系统可能还在发包,服务端忽略即可。
第三类:幂等成功。
例如:
text
Dead + die -> Dead
死亡事件可能因为网络重传、战斗结算重复通知而到达多次。此时应返回成功但不重复执行死亡奖励、掉落、广播等副作用。
工程上建议非法转移结果至少包含:
cpp
enum class DispatchResult {
Transitioned,
Ignored,
GuardRejected,
Illegal,
Idempotent
};
不要只返回 bool,否则调试信息太少。
7.2 幂等事件如何处理
幂等事件是指重复到达时不应该造成重复副作用的事件。
典型例子:
DieDisconnectCancelStopRecoverFinishLoading
例如角色死亡:
text
Alive + die -> Dead
Dead + die -> Dead
第一次 die 应该播放死亡动画、广播死亡、结算掉落。第二次 die 不应该重复掉落,也不应该重复发死亡事件。
因此状态机需要区分:
text
状态保持不变
和:
text
重复执行转移动作
幂等事件通常应该有专门路径,而不是走普通转移逻辑。
7.3 状态转移前的校验应该放在哪里
校验分三层。
第一层是输入合法性校验。
例如事件是否合法、实体是否存在、请求来源是否可信。这一层通常在系统边界处理。
第二层是状态转移 Guard。
例如:
text
Idle + attack -> Attack
Guard: hasWeapon && cooldownReady
这类校验属于状态机转移规则。
第三层是业务深层校验。
例如技能系统判断蓝量、技能等级、目标距离、视线阻挡、服务器冷却、Buff 限制。这些可以由 Guard 调用领域服务完成,但状态机不应直接塞入大量细节。
不建议把所有业务校验都放进状态机类。状态机应知道"能不能转移"的规则边界,但不应该知道"技能伤害公式如何计算"。
7.4 状态转移后的副作用应该如何管理
副作用是状态机工程化中最容易失控的部分。
常见副作用包括:
- 播放动画
- 发送网络包
- 写日志
- 推送事件
- 修改组件
- 扣资源
- 触发技能
- 创建子弹
- 掉落奖励
问题在于副作用可能失败、可能重复、可能重入、可能触发新的事件。
建议原则:
- 状态变化先于非关键表现副作用。
- 关键业务副作用要可幂等。
- 状态机最好输出副作用命令,而不是直接操作所有系统。
- 副作用执行失败要有明确策略。
- 转移过程中不要直接递归调用状态机,避免重入。
例如:
text
Dispatch(Event::Attack)
-> TransitionResult { from=Idle, to=Attack, effects=[StartAttack, PlayAnimation] }
-> 外层系统按顺序执行 effects
这样状态机更容易测试,副作用也更容易跟踪。
7.5 状态机如何与动画系统协作
动画系统通常不应该成为状态机的唯一真相。
错误做法:
text
当前播放 Attack 动画,所以角色状态是 Attack。
动画可能被混合、打断、重定向、延迟加载,还可能因为客户端表现与服务端权威不一致而出现偏差。
更合理的关系是:
text
状态机决定语义状态,动画系统根据状态播放表现。
状态机进入 Attack 后发出命令:
text
PlayAnimation("Attack")
动画系统可以在关键帧回调:
text
AttackHitFrame
AttackEnd
这些回调再转化为事件送回状态机或技能系统:
text
attack_finished
combo_window_open
需要注意:动画事件是输入,不是状态机本身。
7.6 状态机如何与技能系统协作
技能释放流程本身也可以是状态机:
text
Ready -> Casting -> Channeling -> Cooldown
角色动作状态机和技能状态机之间要有明确边界。
例如角色动作状态机负责:
- 能不能进入
Attack - 攻击是否打断移动
- 攻击是否能被眩晕打断
- 攻击状态如何退出
技能系统负责:
- 技能冷却
- 消耗资源
- 目标选择
- 伤害结算
- 弹道生成
- Buff 应用
不要让角色动作状态机直接计算技能伤害,也不要让技能系统随意改角色动作状态。二者通过事件和命令协作。
7.7 状态机如何与 AI 系统协作
AI 系统通常产生意图,而不是直接修改动作状态。
例如怪物 AI 状态机可能是:
text
Patrol -> Chase -> Attack -> Flee -> Return
当 AI 处于 Attack 状态时,它可以向角色动作状态机发送:
text
CharacterEvent::Attack
但动作状态机仍然有权拒绝:
- 角色正在眩晕
- 技能冷却未好
- 目标不合法
- 已经死亡
AI 决策和动作执行应该分层。AI 负责"想做什么",动作状态机负责"现在能不能做"。
7.8 状态机如何与网络同步协作
多人游戏中,服务端通常是权威状态机。
客户端可以有预测状态机,用于降低输入延迟:
text
客户端预测:Idle + attack -> Attack
服务端确认:Attack 成功
或服务端拒绝:冷却未好,回滚到 Idle
状态同步至少要包含:
- 当前状态
- 状态版本号
- 进入状态时间
- 触发事件
- 必要上下文,例如攻击技能 ID、眩晕来源
避免只同步动画名。动画是表现,状态是语义。
网络乱序下,状态版本号很重要:
cpp
if (incomingStateVersion < localStateVersion) {
// 丢弃过期状态
}
服务端还要处理重复包和非法事件。状态机的幂等处理和非法转移处理在这里非常关键。
7.9 多线程或异步事件下如何保证状态一致性
状态机本质上偏向串行模型:当前状态 + 一个事件 -> 新状态。
如果多个线程同时投递事件,例如:
- 网络线程收到
Die - 物理线程发出
Land - AI 线程发出
Attack - 主线程处理输入
Move
必须避免它们同时修改状态机。
常见做法:
- 单线程拥有状态机,其他线程只投递事件队列。
- 每帧固定阶段统一消费事件。
- 给事件排序和优先级,例如
Die高于Move。 - 使用状态版本号防止过期事件。
- 转移过程中禁止重入。
例如:
text
Frame N:
收集事件: [Move, Attack, Hit, Die]
排序: [Die, Hit, Attack, Move]
消费事件
不要在状态机回调中直接跨线程修改状态。否则调试会非常困难。
7.10 状态是否应该持久化
取决于状态语义。
应该持久化的状态:
- 角色死亡
- 副本流程
- 任务流程
- 网络连接阶段
- 技能冷却结束时间
- 需要断线重连恢复的状态
不一定持久化的状态:
- 临时动画状态
- 本地表现状态
- 可从其他数据推导出来的短暂状态
- 一帧内的过渡状态
持久化时不要只保存枚举值,还要保存必要上下文:
cpp
struct PersistedCharacterState {
CharacterState state;
int stateVersion;
int64_t enterStateTimeMs;
std::optional<int> sourceSkillId;
};
否则恢复后可能不知道眩晕还剩多久、攻击来自哪个技能、冷却何时结束。
7.11 日志和调试信息如何设计
状态机日志应该能回答:
- 谁在什么时间触发了什么事件?
- 当时状态是什么?
- Guard 为什么通过或拒绝?
- 转移到了什么状态?
- 执行了哪些副作用?
- 是否发生非法转移?
- 是否被忽略或幂等处理?
推荐日志结构:
text
entity=1001
fsm=CharacterAction
version=42
from=Move
event=Attack
to=Attack
result=Transitioned
guard=hasWeapon && cooldownReady
reason=PlayerInput
调试面板可以显示:
- 当前状态
- 上一个状态
- 最近 N 次事件
- 最近 N 次非法转移
- 当前状态持续时间
- 状态版本号
- Guard 拒绝原因
这对定位线上问题非常有效。
7.12 如何做单元测试
状态机天然适合测试。测试重点不是动画播没播,而是状态转移规则是否正确。
典型测试:
text
Given state = Idle
And hasWeapon = true
And cooldownReady = true
When event = Attack
Then state = Attack
And effects contains StartAttack
还要测试非法路径:
text
Given state = Attack
When event = Jump
Then result = Illegal
And state remains Attack
幂等路径:
text
Given state = Dead
When event = Die
Then result = Idempotent
And death reward is not emitted again
Guard 拒绝:
text
Given state = Idle
And cooldownReady = false
When event = Attack
Then result = GuardRejected
And state remains Idle
建议把转移表和测试用例对应起来。每一条重要转移都应该至少有一个正向测试;每类非法转移至少有覆盖。
8. 状态机设计中的关键原则
8.1 状态必须互斥且语义清晰
如果状态不互斥,就不要用一个主状态机强行表达。
例如:
text
移动
攻击
中毒
隐身
联网中
这些不是同一个维度。移动和攻击可能互斥,也可能并行;中毒和隐身更像 Buff;联网中是网络状态。
把不同维度混在一个状态枚举里,会导致状态爆炸。
好的状态命名应该表达稳定语义:
cpp
Idle
Move
Jump
Attack
Stun
Dead
差的命名通常混杂事件、条件或实现细节:
cpp
TryAttack
NeedMove
CanJump
AnimAttack01
8.2 转移规则要集中表达
状态机的最大价值之一,是让规则可见。
如果你只有一个状态枚举,但转移仍然散落在各处:
cpp
state = CharacterState::Attack;
那只是"状态变量",不是一个良好的状态机。
应尽量让所有状态变化经过统一入口:
cpp
fsm.Dispatch(CharacterEvent::Attack);
不要允许业务代码随意写:
cpp
character.state = CharacterState::Dead;
这会绕过 Guard、Action、日志和副作用管理。
8.3 状态变化和副作用要分层
状态机应该负责决定:
text
from + event + guard -> to
副作用系统负责执行:
text
PlayAnimation
SendNetworkMessage
StartSkill
WriteLog
二者不要完全混在一起。
状态机当然可以触发副作用,但要通过接口、事件或命令隔离,不要让状态机直接知道所有系统的实现细节。
8.4 不要让状态机知道太多业务细节
一个常见错误是把状态机写成上帝类:
cpp
class CharacterStateMachine {
AnimationSystem* animation;
SkillSystem* skill;
InventorySystem* inventory;
NetworkSystem* network;
PhysicsSystem* physics;
QuestSystem* quest;
DropSystem* drop;
};
这样状态机反而成了最难维护的耦合中心。
更好的方式是:
- Guard 查询必要上下文或领域服务。
- Action 发出命令。
- 外部系统订阅状态变化事件。
- 复杂业务由对应系统处理。
8.5 优先让非法状态无法表示
这是 C++ 工程中特别重要的一点。
多个 bool 很容易表示非法组合:
cpp
bool isIdle;
bool isMoving;
bool isDead;
一个 enum class 至少能保证同一时刻只有一个主状态:
cpp
enum class CharacterState {
Idle,
Move,
Dead
};
如果状态有上下文,可以用类型表达:
cpp
struct AttackStateData {
int skillId;
int comboIndex;
};
struct StunStateData {
int sourceId;
float remainingTime;
};
必要时可以用 std::variant 表达互斥状态数据:
cpp
using CharacterStateData = std::variant<
IdleStateData,
MoveStateData,
AttackStateData,
StunStateData,
DeadStateData
>;
这比一个巨大的结构体里塞一堆可选字段更安全。
8.6 状态机应当便于测试和调试
如果一个状态机无法在不启动引擎、不连接服务器、不加载完整场景的情况下测试,那么它过度耦合了。
可测试的状态机通常具备:
- 明确输入事件
- 明确当前状态
- 明确输出结果
- 副作用可替换或可记录
- 时间可注入
- 随机数可注入
- 不直接依赖全局单例
9. 常见错误设计与反例
9.1 用多个 bool 表示互斥状态
错误示例:
cpp
bool isIdle;
bool isMoving;
bool isAttacking;
bool isDead;
问题是非法组合太多。
更好的方式:
cpp
enum class CharacterState {
Idle,
Move,
Attack,
Dead
};
如果确实存在多个正交维度,就拆成多个状态机或普通属性,不要用 bool 拼一个隐式状态机。
9.2 状态枚举很多,但转移规则仍然散落各处
有些代码看起来用了状态机:
cpp
enum class State {
Idle,
Move,
Attack
};
但项目里到处都是:
cpp
state = State::Attack;
这仍然不可控。
状态变化必须经过统一接口:
cpp
fsm.Dispatch(Event::Attack);
否则 Guard、副作用、日志、调试都无法统一。
9.3 在状态切换中直接塞入大量业务副作用
错误示例:
cpp
void ChangeState(State newState) {
state = newState;
if (newState == State::Dead) {
PlayAnimation();
DropItems();
UpdateQuest();
SendMail();
SaveDatabase();
BroadcastNetwork();
RemoveFromScene();
}
}
问题:
- 状态机知道太多系统
- 副作用失败难处理
- 重复事件容易重复执行
- 单元测试困难
- 无法复用
更好的方式是输出领域事件:
text
CharacterDied
StateChanged
由对应系统订阅处理。
9.4 没有非法转移处理
错误做法:
cpp
if (transitionNotFound) {
return;
}
这会吞掉 bug。
至少应该区分:
- 非法事件
- Guard 拒绝
- 幂等忽略
- 终止状态忽略
并记录足够日志。
9.5 状态和事件命名混乱
错误命名:
cpp
State::StartAttack
Event::Attacking
StartAttack 更像事件,Attacking 更像状态。
建议:
cpp
State::Attack
Event::AttackRequested
或者:
cpp
State::Casting
Event::Cast
Event::CastFinished
命名越清晰,状态机越容易维护。
9.6 为了使用状态机而过度设计
不是所有条件分支都需要状态机。
例如:
cpp
if (hp <= 0) {
return DamageResult::Dead;
}
return DamageResult::Alive;
这种简单判断没必要引入状态机。
状态机适合:
- 状态有限且互斥
- 事件驱动明显
- 转移规则复杂
- 非法转移需要处理
- 需要调试、测试、可视化
- 状态需要持久化或同步
如果只是两个分支,一个函数足够。
9.7 状态数量失控导致状态爆炸
错误示例:
cpp
IdleWithSword
IdleWithGun
MoveWithSword
MoveWithGun
JumpWithSword
JumpWithGun
StunWithSword
StunWithGun
武器类型通常是属性,不应进入动作主状态。
更合理:
cpp
ActionState: Idle / Move / Jump / Stun
WeaponState: Sword / Gun / None
或者让动画系统根据动作状态和武器属性选择动画。
10. 不同类型的状态机
10.1 有限状态机 FSM
有限状态机是最常见的状态机形式:
text
有限状态集合 + 有限事件集合 + 转移规则
适合:
- 角色动作
- 技能流程
- 网络连接
- 关卡流程
- 简单 AI 行为
优点:
- 简单直接
- 易测试
- 易日志化
- 易可视化
缺点:
- 状态维度多时容易爆炸
- 难以表达层级共享逻辑
- 对复杂 AI 决策不够灵活
10.2 层级状态机 Hierarchical State Machine
层级状态机允许状态有父子关系。
例如:
text
Alive
Idle
Move
Jump
Attack
Stun
Dead
Alive 下的所有子状态都共享:
text
Die -> Dead
这样不用在每个状态里重复写死亡转移。
层级状态机适合:
- 多个状态共享通用转移
- 状态有明显父子语义
- 需要统一处理进入/退出逻辑
例如技能状态:
text
Casting
Startup
Active
Recovery
Cooldown
Interrupted
10.3 并行状态机 Parallel State Machine
并行状态机用于多个正交维度同时变化。
例如角色可以拆成:
text
LocomotionState: Idle / Move / Jump
ActionState: None / Attack / Cast
ControlState: Normal / Stun / Knockback
LifeState: Alive / Dead
这比把所有组合塞到一个枚举里更可维护。
但并行状态机也有代价:多个状态机之间需要协调优先级和冲突规则。
例如:
text
LifeState = Dead
应该压制其他所有状态机的普通行为。
10.4 行为树 Behavior Tree 与状态机的关系
在游戏 AI 中,行为树和状态机经常一起出现,但它们解决的问题不同。
状态机适合表达:
text
当前行为阶段是什么,以及事件如何切换阶段
行为树适合表达:
text
在当前世界状态下,AI 应该选择什么行为
例如怪物 AI:
- 行为树判断是否看到玩家、血量是否低、是否可攻击
- 行为树选择 Chase、Attack、Flee
- 角色动作状态机执行 Move、Attack、Stun、Dead
行为树更适合复杂决策,状态机更适合阶段控制和生命周期管理。
不要用状态机硬写复杂 AI 决策:
text
Patrol
Chase
Attack
ChaseButLowHp
AttackButNoMana
FleeButBlocked
ReturnButPlayerNear
这种会迅速爆炸。
也不要用行为树替代所有状态机。技能释放、网络连接、副本流程这类严格阶段流,状态机往往更合适。
11. 与其他方案的对比
11.1 状态机 vs if/else 或 switch
if/else 和 switch 不是坏东西。简单逻辑用它们最直接。
适合 if/else 的场景:
- 分支很少
- 没有长期状态
- 不需要处理非法转移
- 不需要调试状态图
- 不需要持久化和同步
适合状态机的场景:
- 状态会持续
- 事件驱动明显
- 转移规则多
- 非法转移有业务意义
- 需要日志、测试、回放、同步
状态机不是为了消灭 if,而是为了避免业务规则被无组织地散落。
11.2 状态机 vs 策略模式
策略模式用于替换算法:
text
同一个问题,不同算法实现
例如寻路策略:
- A*
- Dijkstra
- NavMesh
- Flow Field
状态机用于表达阶段和转移:
text
Idle -> Move -> Attack
二者可以组合。每个状态可以持有一种策略,或者 AI 状态选择不同策略。
如果你的问题是"根据类型选择不同算法",策略模式更合适。如果问题是"对象在不同阶段响应不同事件",状态机更合适。
11.3 状态机 vs 行为树
行为树适合 AI 决策,尤其是条件组合复杂、行为可复用、需要策划编辑时。
状态机适合严格状态生命周期,尤其是状态互斥、转移规则清晰、非法转移重要时。
AI 中常见组合是:
text
行为树负责决策
状态机负责执行阶段
例如行为树选择攻击,技能状态机负责:
text
Ready -> Casting -> Channeling -> Cooldown
11.4 状态机 vs 工作流引擎
工作流引擎适合长生命周期、可持久化、可人工干预的流程:
- 审批流程
- 订单流程
- 任务流程
- 后台运营流程
状态机适合更轻量、更实时的运行时逻辑:
- 角色动作
- 技能释放
- 网络连接
- 副本阶段
工作流引擎通常有:
- 持久化
- 重试
- 补偿
- 人工审批
- 可视化配置
- 版本管理
游戏运行时状态机通常追求低延迟、低开销、可预测。
11.5 状态机 vs 脚本驱动逻辑
脚本驱动适合频繁变化的内容逻辑:
- 技能配置
- Boss 行为
- 任务流程
- 关卡事件
但脚本不应让状态变化失控。脚本可以发事件:
text
RequestAttack
RequestPhaseChange
底层状态机仍然负责验证转移是否合法。
比较稳妥的方式是:
text
C++ 状态机提供安全边界
脚本提供可配置策略和内容
不要让脚本随意改 C++ 核心状态字段。
12. 什么时候需要状态机框架
12.1 简单 enum + switch 什么时候足够
如果状态很少,转移很简单,直接写 enum + switch 完全可以。
例如网络连接:
cpp
enum class NetState {
Disconnected,
Connecting,
Connected
};
如果只有几条转移:
text
Disconnected + Connect -> Connecting
Connecting + Success -> Connected
Connecting + Fail -> Disconnected
Connected + Disconnect -> Disconnected
一个简单 switch 就够了。不要为了"架构感"引入复杂框架。
12.2 转移表驱动什么时候更合适
当你需要:
- 集中查看转移规则
- 自动生成测试
- 打印状态图
- 支持配置化
- 统一 Guard 和 Action
- 更容易审查非法转移
转移表就比分散 switch 更合适。
角色动作状态机、技能状态机、关卡流程都适合转移表驱动。
12.3 何时需要层级状态机
当多个状态共享大量逻辑时,可以考虑层级状态机。
例如:
text
Alive 下任意状态收到 Die 都进入 Dead
Controllable 下任意状态收到 Stun 都进入 Stun
Attack 下分 Startup / Active / Recovery
如果你发现自己在很多状态里重复写同样的转移,说明层级状态机可能更合适。