状态机设计

在游戏服务端、客户端引擎、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 中推理。

这对多人协作非常不友好。新同事想知道"角色被眩晕时收到死亡事件会怎样",只能全文搜索 isStunnedisDeadhp <= 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:死亡,不再响应普通行为事件

一个好的状态应该是互斥的 。角色不应该同时是 IdleMove,也不应该同时是 AttackDead

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 == true
  • Move + Jump -> Jump 需要 onGround == true
  • Attack + 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 后,MoveAttackJump 都应该非法或被忽略。

但是否绝对终止取决于业务。如果游戏有复活机制,那么 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
...

这很快不可维护。

避免状态爆炸的常见办法:

  1. 区分主状态和属性

    hasWeaponisInvisiblemoveSpeed 通常不应该进入动作主状态。

  2. 使用层级状态机

    例如 Alive 下有 IdleMoveJumpAttackStunDead 是另一个顶层状态。

  3. 使用并行状态机

    动作状态、武器状态、网络状态、Buff 状态可以独立演化。

  4. 将短期阶段交给子状态机

    攻击可以内部再拆成 StartupActiveRecovery,但不一定暴露给角色主状态机。

  5. 不为纯表现差异创建状态

    "持剑 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;
}

这份代码不是一个完整引擎级实现,但它体现了几个关键工程点:

  1. 状态定义集中。
  2. 事件定义集中。
  3. 转移规则集中。
  4. Guard 明确表达转移前校验。
  5. Action 和状态进入/退出逻辑有边界。
  6. 非法转移有统一处理。
  7. 幂等事件有明确策略。
  8. 副作用通过接口隔离,不直接耦合具体系统。

6.5 这份代码还可以如何演进

真实项目中可以继续改进:

  • 使用 std::unordered_map<StateEventKey, Transition> 替代线性查找。
  • 给转移规则增加优先级,例如 Die 高于其他事件。
  • Transition 支持 from = Any
  • 把 Guard 和 Action 注册为函数对象或脚本绑定。
  • 增加事件队列,避免状态转移过程中重入。
  • 增加状态持续时间,例如 timeInState
  • 增加调试快照,例如 lastEventpreviousStatetransitionReason
  • 将副作用拆成命令列表,而不是转移时立即执行。

例如,状态机可以返回副作用命令:

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 幂等事件如何处理

幂等事件是指重复到达时不应该造成重复副作用的事件。

典型例子:

  • Die
  • Disconnect
  • Cancel
  • Stop
  • Recover
  • FinishLoading

例如角色死亡:

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 状态转移后的副作用应该如何管理

副作用是状态机工程化中最容易失控的部分。

常见副作用包括:

  • 播放动画
  • 发送网络包
  • 写日志
  • 推送事件
  • 修改组件
  • 扣资源
  • 触发技能
  • 创建子弹
  • 掉落奖励

问题在于副作用可能失败、可能重复、可能重入、可能触发新的事件。

建议原则:

  1. 状态变化先于非关键表现副作用。
  2. 关键业务副作用要可幂等。
  3. 状态机最好输出副作用命令,而不是直接操作所有系统。
  4. 副作用执行失败要有明确策略。
  5. 转移过程中不要直接递归调用状态机,避免重入。

例如:

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

必须避免它们同时修改状态机。

常见做法:

  1. 单线程拥有状态机,其他线程只投递事件队列。
  2. 每帧固定阶段统一消费事件。
  3. 给事件排序和优先级,例如 Die 高于 Move
  4. 使用状态版本号防止过期事件。
  5. 转移过程中禁止重入。

例如:

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/elseswitch 不是坏东西。简单逻辑用它们最直接。

适合 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

如果你发现自己在很多状态里重复写同样的转移,说明层级状态机可能更合适。