Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

状态模式 (State)

允许一个对象在其内部状态发生变化时改变自己的行为,该对象看起来好像修改了它的类型。

— GoF 定义

动机

忏悔时间:我有些越界,将太多的东西打包到了这章中。它表面上关于状态模式,但我无法只讨论它和游戏,而不涉及更加基础的有限状态机(FSMs)。但是一旦讲了那个,我发现也想要介绍层次状态机下推自动机

译注:这些状态机术语来自人工智能的早期时代。在五十年代到六十年代,很多 AI 研究关注于语言处理。很多现在用于分析程序语言的技术在当时是发明出来分析人类语言的。


感同身受

假设我们在完成一个卷轴平台游戏,现在的工作是实现玩家在游戏世界中操作的女英雄。这就意味着她需要对玩家的输入做出响应。按 B 键她应该跳跃。简单实现如下:

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    yVelocity_ = JUMP_VELOCITY;
    setGraphics(IMAGE_JUMP);
  }
}

看到漏洞了吗?

没有东西阻止“空中跳跃“——当角色在空中时狂按 B,她就会浮空。简单修复是增加 isJumping_ 布尔字段:

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    if (!isJumping_) {
      isJumping_ = true;
      // 跳跃……
    }
  }
}

接下来,按下方向键时卧倒、松开时站起来:

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    // 如果没在跳跃,就跳起来……
  }
  else if (input == PRESS_DOWN) {
    if (!isJumping_) {
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN) {
    setGraphics(IMAGE_STAND);
  }
}

这次看到漏洞了吗?玩家可以:按 ↓ 卧倒 → 按 B 跳起 → 在空中松开 ↓ → 贴图变成站立。再增加 isDucking_ 字段……

接下来加“跳斩“(跳跃中按 ↓):

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    if (!isJumping_ && !isDucking_) { /* 跳跃…… */ }
  }
  else if (input == PRESS_DOWN) {
    if (!isJumping_) {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    } else {
      isJumping_ = false;
      setGraphics(IMAGE_DIVE);
    }
  }
  else if (input == RELEASE_DOWN) {
    if (isDucking_) { /* 站立…… */ }
  }
}

漏洞:跳斩中按 B 可以再次跳跃(空中跳)——又一个字段……

译注:那些你崇拜的、看上去永远能写出完美代码的程序员并不是超人。相反,他们有哪种代码易于出错的直觉,然后避开。复杂分支和可变状态是两种易错代码,上面的例子覆盖了两者。

显然这个方法不对。每次改动代码就破坏些东西。还没加入行走就要崩了。


有限状态机前来救援

在经历了挫败之后,把桌子扫空,只留下纸笔,开始画流程图。给英雄每件能做的事情都画了一个盒子:站立、跳跃、俯卧、跳斩。当角色在能响应按键的状态时,从那个盒子画出箭头,标记上按键,连接到新状态。

祝贺,你刚刚建好了一个有限状态机(FSM)。要点是:

  • 你拥有状态机所有可能状态的集合。 站立、跳跃、俯卧、速降。
  • 状态机同时只能在一个状态。 英雄不可能同时处于跳跃和站立。
  • 一连串的输入或事件被发送给状态机。 按键按下和松开。
  • 每个状态有一系列的转移,每个转移与输入和另一状态相关。如果输入在当前状态没有定义转移,输入就被忽视。

这就是核心:状态、输入、转移。可以用流程图表示出来。但编译器不认识流程图,怎么实现?

译注:对 FSMs 我最喜欢的类比是那种老式文字冒险游戏,比如 Zork。你有个由屋子组成的世界,屋子彼此通过出口相连。每个屋子都是一个状态,你所在的屋子是当前状态,每个屋子的出口是转移,导航指令是输入。


枚举和分支

问题在于不合法地捆绑了一堆布尔量——isJumping_isDucking_ 不会同时为真。这提示你真正需要的其实是 enum

enum State {
  STATE_STANDING,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};

先对状态做分支(而不是先判输入),让处理状态的代码聚在一起:

void Heroine::handleInput(Input input) {
  switch (state_) {
    case STATE_STANDING:
      if (input == PRESS_B) {
        state_ = STATE_JUMPING;
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
      } else if (input == PRESS_DOWN) {
        state_ = STATE_DUCKING;
        setGraphics(IMAGE_DUCK);
      }
      break;

    case STATE_JUMPING:
      if (input == PRESS_DOWN) {
        state_ = STATE_DIVING;
        setGraphics(IMAGE_DIVE);
      }
      break;

    case STATE_DUCKING:
      if (input == RELEASE_DOWN) {
        state_ = STATE_STANDING;
        setGraphics(IMAGE_STAND);
      }
      break;
  }
}

比布尔标识版本好很多:单一状态字段,每个状态的代码集中在一处。英雄不再会处于不合法状态。

但问题规模可能膨胀。假设俯卧可以充能释放特殊攻击。需要 chargeTime_ 字段和 update() 里的计时逻辑。现在数据散落在 Heroineswitch 各处——只有俯卧状态才用得上的字段污染了整个类。


状态模式

面向对象在这里是更好的方案。GoF 状态模式:

允许一个对象在其内部状态发生变化时改变自己的行为,该对象看起来好像修改了它的类型。

一个状态接口

状态相关的行为(之前所有 switch 的地方)变成虚方法:

class HeroineState {
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine, Input input) {}
  virtual void update(Heroine& heroine) {}
};

为每个状态写个类

每个 case 分支变成一个类:

class DuckingState : public HeroineState {
public:
  DuckingState() : chargeTime_(0) {}

  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == RELEASE_DOWN) {
      // 改回站立状态……
      heroine.setGraphics(IMAGE_STAND);
    }
  }

  virtual void update(Heroine& heroine) {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE) {
      heroine.superBomb();
    }
  }

private:
  int chargeTime_;     // 只在俯卧时有意义,现在属于这个状态!
};

委托到状态

Heroine 持有当前状态指针,所有行为委托出去:

class Heroine {
public:
  virtual void handleInput(Input input) {
    state_->handleInput(*this, input);
  }
  virtual void update() {
    state_->update(*this);
  }
private:
  HeroineState* state_;
};

“改变状态“就是把 state_ 指向不同对象。

译注:这看上去有些像策略模式和类型对象模式。区别在于意图——策略是解耦行为,类型对象是共享类型引用,状态模式是通过改变委托对象来改变行为。


状态对象在哪里?

“新状态对象从哪来?“有两种方案:

静态状态

如果状态对象没有数据字段,就没有理由产生多个实例——所有实例完全一样。用一个静态实例即可,所有 FSM 共享:

class HeroineState {
public:
  static StandingState standing;
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;
};

// 切换到跳跃状态
heroine.state_ = &HeroineState::jumping;

实例化状态

俯卧状态有 chargeTime_ 字段,不能共享(多英雄时各自独立)。需要按需创建、用完销毁。为了避免在状态方法里 delete this,让 handleInput() 返回新状态:

void Heroine::handleInput(Input input) {
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL) {
    delete state_;
    state_ = state;
  }
}

// 站立状态创建俯卧实例
HeroineState* StandingState::handleInput(Heroine& heroine, Input input) {
  if (input == PRESS_DOWN) {
    return new DuckingState();
  }
  return NULL;
}

入口行为和出口行为

目前状态之间的贴图切换逻辑散落——是由前一个状态负责设置新状态的贴图。应该由新状态自己控制:

class StandingState : public HeroineState {
public:
  virtual void enter(Heroine& heroine) {
    heroine.setGraphics(IMAGE_STAND);
  }
};

状态转换时调用 newState->enter(*this)。现在状态真正封装了——不管你从哪个状态转来,入口行为保证一致性。同理可以扩展出口行为(离开前执行清理)。


有什么收获?

FSM 的最大优点也是最大缺点。它通过强约束结构理清杂乱的代码,但你只有一个固定状态集合、单一当前状态、硬编码转换。如果用于复杂 AI,会撞上模型的限制。幸好前人们找到了一些规避方法。


并发状态机

假设英雄可以携带武器。携带枪支时,她仍能做所有之前的事情(跑、跳、俯卧),但还要能开火。

如果硬塞进单一 FSM,每个现有状态都得加倍——站立、持枪站立、跳跃、持枪跳跃……状态数量组合爆炸

解决方案:两个独立的状态机——一个管“她在做什么“,一个管“她拿了什么“:

class Heroine {
private:
  HeroineState* state_;      // 动作状态
  HeroineState* equipment_;  // 装备状态
};

void Heroine::handleInput(Input input) {
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}

两个状态机独立响应输入、改变状态。偶尔需要交互(比如跳跃时不能开枪),用一个简单的 if 检查另一台机器的状态即可。


层次状态机

当多个状态有共同行为时(站立、行走、奔跑、滑行中按 B 都跳跃,按 ↓ 都俯卧),简单实现会导致大量代码重复。

如果是普通 OOP,用继承解决。同理,状态也可以用继承:

// 超状态:处理"在地上"的共用行为
class OnGroundState : public HeroineState {
public:
  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == PRESS_B) { /* 跳跃…… */ }
    else if (input == PRESS_DOWN) { /* 俯卧…… */ }
  }
};

// 子状态:只处理自己特有的输入,其余上交
class DuckingState : public OnGroundState {
public:
  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == RELEASE_DOWN) {
      // 站起来……
    } else {
      // 没处理 → 转交超状态
      OnGroundState::handleInput(heroine, input);
    }
  }
};

这就是层次状态机:状态可以有超状态(父状态)。事件来时,子状态优先处理,不处理则沿超状态链上滚。这就像虚方法重写一样自然。

不用 GoF 状态模式时,可以用状态栈实现——栈顶是当前状态,下面依次是超状态,处理事件时从栈顶往下走。


下推自动机

FSM 没有历史记忆——你知道当前状态,但不知道之前是什么状态。需要回到上一个状态时就很麻烦。

例子:英雄在任何状态下都可以开枪。开枪后要播放动画、生成子弹,然后回到之前的状态。但 vanilla FSM 不知道“之前的状态“是什么。

下推自动机用一个来解决问题(而不是单指针):

  1. Push ——新状态压栈,成为当前状态,旧状态保留在下面
  2. Pop ——栈顶状态弹出并丢弃,下面那个恢复为当前状态

这就完美解决了开枪问题:任何状态中按开火键 → push FiringState → 动画结束 → pop → 自动回到之前的状态。


它们到底多有用?

即使有这些扩展,状态机仍然有限。现代游戏 AI 的趋势是行为树和规划系统。如果你想做复杂 AI,本章只是开胃菜。

但这不意味着状态机没用。它们适合的场景:

  • 实体行为根据内部状态改变
  • 状态可以明确划分为少量互斥选项
  • 实体随时间响应一系列输入或事件

在游戏中,状态机最有名的是 AI,但也常用于:输入处理、菜单 导航、文本解析、网络协议等异步行为。


实践 Demo

状态模式


笔记与思考

1. 布尔标识是 Bug 温床

从 Demo 的 Heroine 演化过程可以清楚看到:每加一个动作就加一个 bool 字段 → 字段之间的非法组合越来越多 → 每次改代码都在修上一次引入的 Bug。“空中跳”、“俯卧中跳起后松键贴图错”、“跳斩中再跳”——这些问题不是逻辑错误,是状态模型的表达错误。当你发现一堆布尔字段中只能有一个为 true 时,你需要的不是更多 if,而是一个 enum

2. 三种实现层级对比

方案优点缺点适用
布尔标识最快上手状态组合爆炸,不可维护别用
枚举+switch单一状态字段,代码集中状态相关数据散落在主类3-5 个简单状态
GoF 状态模式数据和行为完全封装在状态类类数量多,对象生命周期需管理状态有独立数据/复杂行为

核心区别:枚举版把“所有状态的逻辑“写在一个 switch 里;类版把“每个状态的逻辑“独立成一个对象。 当状态需要自己的数据(如俯卧的充能计时),类版的优势就体现出来了。

3. JS 实现比 C++ 简洁得多

JS 不需要接口、虚函数、继承——每个状态就是一个普通对象,有 handleInput 方法就行(鸭子类型)。状态对象可以放在同一个 states 字面量里,切换只需 heroine.state = states.xxx,一行搞定。同名方法+不同对象引用,就是动态分派的最简洁形式。

4. 并发状态机:拆开独立维度

Demo 中动作(站立/跳跃/俯卧/跳斩)和装备(空手/持枪)是两个正交的维度。把它们拆成两个独立状态机,各管各的,避免组合爆炸(4×2=8 个状态 v.s. 4+2=6 个状态定义)。唯一需要交互时,加一个简单的 if 检查对方状态即可。这是一个通用原则:一旦发现状态数量是乘法关系,就该拆成多个 FSM。

5. 入口行为的意义

状态转换时,谁负责设置新状态的贴图?Demo 中没有用到入口行为,因为它足够简单。但在复杂场景中,多个不同转移都通向同一个状态时,入口行为消除了重复——不管从哪来,进入站立状态就自动设 IMAGE_STAND。同理,出口行为用于离开时清理。

6. FSM 的边界

FSM 的强约束(固定状态集合、单一当前状态、硬编码转移)既是它的力量也是它的天花板。它适合:

  • 输入处理、菜单导航、简单 AI
  • 状态可穷举、转换关系明确

不适合:

  • 复杂行为树、需要历史记忆的场景
  • 状态数量随组合增长的场景(此时上层次状态机或行为树)