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

前言

学习笔记源自 Bob Nystrom 的《Game Programming Patterns》(游戏编程模式)


关于本书

《Game Programming Patterns》是由游戏开发者 Bob Nystrom 撰写的一本经典书籍,旨在帮助游戏开发者写出更清晰、更灵活、更易维护的代码。书中介绍了 18 种适用于游戏开发的设计模式,涵盖架构设计、解耦、性能优化等多个方面。

笔记说明

本仓库是我学习过程中的个人笔记,包含:

  • 各模式的核心理念与动机
  • 关键代码示例(C++)
  • 与游戏开发场景的具体结合
  • 个人理解和思考

注意:本笔记仅供个人学习参考,建议配合原书阅读以获得最佳效果。

一、再探设计模式

Revisiting Design Patterns

设计模式的经典概念在游戏开发中同样适用,但用法和侧重点与传统软件工程不同。本部分重新审视最常用的几个设计模式在游戏场景中的应用。

命令模式 (Command)

将一个请求封装为一个对象,从而使你可以用不同的请求对客户进行参数化、对请求排队或记录请求日志,以及支持可撤销的操作。

— GoF 定义

动机

命令模式是我最喜欢的模式之一。我写过的大部分程序——游戏也好,别的什么也罢——最终都会在某处用到它。用对地方的时候,它能把一团糟的代码理得干干净净。

对于这样一个了不起的模式,不出所料,“四人帮”(GoF)给了一个深奥难懂的定义:

将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

我觉得没人会反对——这个句子写得真不怎么样。首先,它试图建立的比喻本身就是乱的。在软件这个词语可以指代任何东西的诡异世界之外,“客户”(client)指的是——和你做生意的人。据我所知,人是没法被“参数化“的。

然后,句子的后半部分只是列举了一堆可能会用到的场景。除非你的用例恰好在这个列表里,否则它对你毫无帮助。

我对命令模式的精辟总结是:

命令就是“具现化的方法调用“(a reified method call)。

“Reify“这个词来自拉丁语的“res”(意为“事物“),加上英语后缀“–fy“(“使成为”)。所以它基本上就是“事物化“(thingify)的意思——老实说,“thingify“这个词用起来有趣多了。

当然,“精辟“往往意味着“过于简练,难以理解”,所以这可能也没好到哪去。让我展开说说。如果你没听过“具现化“这个词,它的意思就是“使其成为真实存在的“。另一个说法是让它成为“一等公民“(first-class)。

某些语言中的反射系统允许你在运行时以命令式的方式操作程序中的类型。你可以获取一个代表某个类的对象,然后拿它来探索这个类型能做些什么。换句话说,反射就是一个具现化的类型系统(a reified type system)。

这两个术语的核心都是:把某个概念变成一块数据——一个对象——你可以把它存在变量里、传给函数等等。所以,说命令模式是“具现化的方法调用“,意思就是:把方法调用包装成一个对象

这听起来很像“回调“(callback)、“一等公民函数”(first-class function)、“函数指针”(function pointer)、“闭包”(closure)或“偏函数“(partially applied function)——取决于你从哪种语言的角度来看——它们确实属于同一类东西。四人帮后来也说过:

命令模式是回调的一种面向对象替代方案。

这个总结比他们选的那个定义好多了。

但所有这些都还是抽象而模糊的。我本来喜欢用具体的例子来开篇,结果搞砸了。作为补偿,接下来全是命令模式大显身手的实际例子。


配置输入

在每个游戏中,都有一段代码负责读取原始用户输入——按钮按下、键盘事件、鼠标点击等等。它把每个输入翻译成游戏中有意义的行为:

一个极简的实现长这样:

void InputHandler::handleInput() {
  if (isPressed(BUTTON_X)) jump();
  else if (isPressed(BUTTON_Y)) fireGun();
  else if (isPressed(BUTTON_A)) swapWeapon();
  else if (isPressed(BUTTON_B)) lurchIneffectively();
}

专业建议:别老按 B。

这个函数通常在游戏循环中每帧调用一次,我想你明白它在干什么。如果我们愿意把用户输入和游戏行为硬编码在一起,这段代码没问题。但很多游戏允许玩家自定义按键映射。

要支持这个功能,我们需要把对 jump()fireGun() 的直接调用转换成可以替换的东西。“替换“听起来很像赋值给一个变量,所以我们需要一个对象来表示一个游戏行为。接下来登场的是:命令模式。

我们定义一个基类来表示一个可触发的游戏命令:

class Command {
 public:
  virtual ~Command() {}
  virtual void execute() = 0;
};

如果你看到一个接口只有一个不返回任何东西的方法,那它十有八九就是命令模式。

然后为不同的游戏行为创建子类:

class JumpCommand : public Command {
 public:
  virtual void execute() { jump(); }
};

class FireCommand : public Command {
 public:
  virtual void execute() { fireGun(); }
};

// 你懂的……

在输入处理器中,为每个按键存储一个指向命令的指针:

class InputHandler {
 public:
  void handleInput();
  // 绑定命令的方法……

 private:
  Command* buttonX_;
  Command* buttonY_;
  Command* buttonA_;
  Command* buttonB_;
};

现在输入处理就委托给这些命令了:

void InputHandler::handleInput() {
  if (isPressed(BUTTON_X)) buttonX_->execute();
  else if (isPressed(BUTTON_Y)) buttonY_->execute();
  else if (isPressed(BUTTON_A)) buttonA_->execute();
  else if (isPressed(BUTTON_B)) buttonB_->execute();
}

注意到我们没有检查 NULL 了吗?这假设每个按键都绑定了某个命令。

如果我们想支持“什么都不做“的按键,又不想显式检查 NULL,可以定义一个 execute() 方法什么都不做的命令类。然后把按键处理器指向这个对象,而不是设为 NULL。这个模式叫做空对象模式

以前每个输入直接调用函数,现在有了一层间接引用:

这就是命令模式的精髓。如果你已经看出它的好处了,那这章剩下的部分就当是 bonus 吧。


给演员的指令

我们刚才定义的类在前面的例子里可以正常工作,但限制很大。问题在于,它们假设存在一些顶层函数(如 jump()fireGun() 等),这些函数能隐式地找到玩家角色,然后像操纵木偶一样让他动起来。

这种假设的耦合限制很大。JumpCommand只能让玩家角色跳跃。我们来放松这个限制。不让函数自己去寻找被命令的对象,而是传入我们要指挥的对象:

class Command {
 public:
  virtual ~Command() {}
  virtual void execute(GameActor& actor) = 0;
};

这里的 GameActor 是我们的“游戏对象“类,代表游戏世界中的一个角色。我们把它传给 execute(),这样派生命令就可以在我们选择的角色上调用方法了:

class JumpCommand : public Command {
 public:
  virtual void execute(GameActor& actor) {
    actor.jump();
  }
};

现在,我们可以用这一个类让游戏中的任意角色跳来跳去。在输入处理器和命令之间,我们还缺一块——负责把命令应用到正确对象上的代码。首先,我们修改 handleInput(),让它返回命令:

Command* InputHandler::handleInput() {
  if (isPressed(BUTTON_X)) return buttonX_;
  if (isPressed(BUTTON_Y)) return buttonY_;
  if (isPressed(BUTTON_A)) return buttonA_;
  if (isPressed(BUTTON_B)) return buttonB_;

  // 什么都没按,什么都不做
  return NULL;
}

它不能立即执行命令,因为还不知道要传给哪个角色。这正是命令作为“具现化调用“的优势所在——我们可以延迟调用的执行。

然后,我们需要一段代码来接收命令,并将其应用到代表玩家的角色上:

Command* command = inputHandler.handleInput();
if (command) {
  command->execute(actor);
}

假设 actor 是玩家角色的引用,这段代码就能根据用户输入正确地驱动他。这样我们又回到了第一个例子的行为。但通过在命令和执行命令的角色之间加一层间接引用,我们获得了一个灵巧的能力:现在只需改变执行命令时传入的角色,就可以让玩家控制游戏中的任意角色了。

在实际开发中,这个特性不常用,但有一个类似的用例经常出现。到目前为止,我们只考虑了玩家控制的角色,那游戏中其他的角色呢?它们由游戏 AI 驱动。我们可以用同样的命令模式作为 AI 引擎和角色之间的接口——AI 代码只需要生成 Command 对象。

选择命令的 AI 和执行命令的角色代码之间的解耦,给了我们很大的灵活性。我们可以为不同的角色使用不同的 AI 模块,或者混合搭配不同的 AI 以获得不同的行为。想要更有攻击性的对手?只要插入一个更具攻击性的 AI 来生成命令就行了。事实上,我们甚至可以把 AI 装在玩家角色上——这在需要游戏自动运行的演示模式中非常有用。

通过把控制角色的命令变成一等公民对象,我们摆脱了直接方法调用的紧耦合。相反,你可以把它想象成一个命令队列或命令流:

关于队列能带来哪些更多好处,请看事件队列

我为什么觉得有必要给你画一张“流“的图?而且它为什么看起来像根管子?

一些代码(输入处理器或 AI)产生命令并将其放入流中;另一些代码(调度器或角色自身)消费并执行命令。通过在中间加入一个队列,我们解耦了生产者和消费者。

如果把这些命令序列化,我们就可以通过网络传输这个命令流。我们可以获取玩家的输入,通过网络发送到另一台机器,然后重放它。这是构建网络多人游戏的重要一环。


撤销与重做

最后一个例子是这个模式最广为人知的用途。如果一个命令对象可以一件事,那它只需再进一步就可以撤销这件事。撤销功能用在一些策略游戏中,允许玩家回滚不满意的操作。在人们用来创作游戏的工具中,它是必备功能。让游戏设计师恨你最直接的办法,就是给他们一个无法撤销手误操作的关卡编辑器。

——我可能是在说自己的经历。

没有命令模式,实现撤销出奇地难。有了它,简直小菜一碟。假设我们在做一个单人回合制游戏,希望允许玩家撤销操作,这样他们可以更专注于策略而不是靠猜。

碰巧,我们已经用命令模式抽象了输入处理,所以玩家的每个操作都已经封装在命令中了。例如,移动单位的代码可能是这样的:

class MoveUnitCommand : public Command {
 public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    x_(x),
    y_(y)
  {}

  virtual void execute() {
    unit_->moveTo(x_, y_);
  }

 private:
  Unit* unit_;
  int x_, y_;
};

注意这和之前的命令稍有不同。在上一个例子中,我们想把命令和它修改的角色解耦。而在这个例子中,我们恰恰是想把命令绑定到被移动的单位上。这个命令的实例不是可以在多种上下文中使用的通用“移动某物“操作;它是游戏回合序列中一个具体的移动动作。

这揭示了命令模式实现中的一种变化。在一些情况下(比如前两个例子),命令是一个可重用的对象,代表一个可执行的操作。我们之前的输入处理器持有一个命令对象,在对应按键按下时调用它的 execute() 方法。

而这里的命令更加具体。它们代表在特定时间点可以做的一件事情。这意味着输入处理代码每次在玩家选择移动时都要创建一个实例。像这样:

Command* handleInput() {
  Unit* unit = getSelectedUnit();

  if (isPressed(BUTTON_UP)) {
    // 将单位向上移动一格
    int destY = unit->y() - 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  if (isPressed(BUTTON_DOWN)) {
    // 将单位向下移动一格
    int destY = unit->y() + 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  // 其他移动……

  return NULL;
}

当然,在像 C++ 这样没有垃圾回收的语言中,这也意味着执行命令的代码要负责释放内存。

命令是一次性的这个事实马上就要派上用场了。为了让命令可撤销,我们在每个命令类中再定义一个需要实现的方法:

class Command {
 public:
  virtual ~Command() {}
  virtual void execute() = 0;
  virtual void undo() = 0;
};

undo() 方法逆转了对应的 execute() 方法所改变的游戏状态。这是增加了撤销功能后的移动命令:

class MoveUnitCommand : public Command {
 public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    xBefore_(0),
    yBefore_(0),
    x_(x),
    y_(y)
  {}

  virtual void execute() {
    // 记住移动前的位置,以便之后恢复
    xBefore_ = unit_->x();
    yBefore_ = unit_->y();
    unit_->moveTo(x_, y_);
  }

  virtual void undo() {
    unit_->moveTo(xBefore_, yBefore_);
  }

 private:
  Unit* unit_;
  int xBefore_, yBefore_;
  int x_, y_;
};

注意我们给类增加了一些状态。当单位移动时,它会忘记之前的位置。如果要撤销移动,我们需要自己记住单位之前的位置——这就是 xBefore_yBefore_ 的作用。

这看起来像是备忘录模式的用武之地,但我发现它效果并不好。因为命令往往只修改对象状态的一小部分,把其他所有数据都做快照是浪费内存。手动只存储你改变的那部分数据更经济。

持久化数据结构是另一个选择。使用它们,每次修改一个对象都会返回一个新对象,原对象保持不变。通过巧妙实现,这些新对象与旧对象共享数据,所以比克隆整个对象廉价得多。

使用持久化数据结构,每个命令存储一个命令执行前的对象的引用,撤销只需切换回旧对象。

要让玩家撤销移动,我们保留他们执行的最后一条命令。当他们按下 Ctrl+Z 时,调用那条命令的 undo() 方法。(如果他们已经撤销了,就变成了“重做“,我们再次执行命令。)

支持多级撤销也并不复杂。不是只记住最后一条命令,而是维护一个命令列表和一个指向“当前“命令的引用。当玩家执行一条命令时,将其追加到列表末尾,并将“当前“指针指向它:

当玩家选择“撤销“时,撤销当前命令,并将当前指针向后移。当选择“重做“时,将当前指针向前移,然后执行该命令。如果玩家在撤销之后选择了新的操作,就清除当前指针之后的所有命令。

第一次在关卡编辑器中实现这个功能时,我感觉自己简直就是个天才。我惊讶于它是如此简洁而有效。你只需要约束自己:确保每个数据修改都通过命令完成。一旦做到了,剩下的就很简单了。

游戏里不常见“重做“,但重放很常见。一种简单的重放实现是记录游戏每帧的状态然后回放,但那会消耗太多内存。

相反,很多游戏记录每个实体每帧执行的命令。为了重放游戏,引擎只需正常运行,执行之前存储的命令即可。


用类还是用函数?

之前我提到过,命令与一等公民函数或闭包类似,但这里展示的每个例子都是用类完成的。如果你更熟悉函数式编程,你可能会疑惑:函数去哪儿了?

我用类写这些例子,是因为 C++ 对一等公民函数的支持非常有限。函数指针没有状态,仿函数(functors)很奇怪而且仍然需要定义类,C++11 的 lambda 需要大量人工辅助才能用起来。

并不是说你在其他语言中不能用函数来实现命令模式。如果你使用的语言支持闭包,尽管用!在某种程度上,命令模式就是为那些没有闭包的语言模拟闭包。

(我说某种程度上,是因为即使是在支持闭包的语言中,为命令建立真正的类或结构体也是有价值的。如果你的命令有多个操作——比如可撤销的命令——把它们全部塞进同一个函数中并不优雅。)

定义一个带有字段的真实类,能帮助读者理解命令包含了哪些数据。闭包是自动打包状态的完美方案,但它们太过自动化了,以至于很难看清楚到底打包了哪些状态。

举个例子,如果我们用 JavaScript 来写游戏,可以用这种方式实现单位移动命令:

function makeMoveUnitCommand(unit, x, y) {
  // 这个函数就是命令对象:
  return function () {
    unit.moveTo(x, y);
  };
}

通过一对闭包来实现撤销:

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function () {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function () {
      unit.moveTo(xBefore, yBefore);
    },
  };
}

如果你习惯了函数式编程风格,这种做法很自然。如果没有,我希望这章能帮你有所了解。对我而言,命令模式很好地展示了函数式范式在处理很多问题上的高效性。


参见

  • 你最终可能会有很多不同的命令类。为了更容易地实现这些类,定义一个具体的基类,其中包含一些用来定义行为的高层方法往往很有帮助。这就把命令的主体 execute() 引向了子类沙箱模式

  • 在上面的例子中,我们明确指定了哪个角色会处理命令。在某些情况下——特别是当对象模型有层次结构时——可以不这么简单粗暴。对象可以自己响应命令,也可以将命令转交给其从属对象。如果这样做,你就实现了一个职责链模式

  • 有些命令是无状态的纯行为——比如第一个例子中的 JumpCommand。这种情况下,有多个实例就是在浪费内存,因为所有实例都是等价的。可以用享元模式来解决。

    你也可以把它实现为单例,但真正的朋友不会让你用单例。


实践 Demo

命令模式 - 棋盘移动


笔记与思考

享元模式 (Flyweight)

运用共享技术有效地支持大量细粒度的对象。

— GoF 定义

动机

迷雾散去,一片壮丽的原始森林映入眼帘。古老的铁杉树不计其数,高耸入云,在你头顶形成一座绿色的穹顶。树叶交织成的彩绘玻璃将阳光打碎成一束束金色的雾状光柱。巨干之间,你能辨认出浩渺的森林延伸到远方的尽头。

这就是我们游戏开发者梦寐以求的超凡场景。而这样的场景,往往得益于一个名字质朴到不能再质朴的模式:不起眼的享元(Flyweight)。


只见树木,不见森林

我可以用几句话描述一片广袤的林地。然而,在实时游戏中真正实现它又是另一回事了。当你有一整片森林的树木塞满屏幕时,图形程序员看到的只有数以百万计的多边形——它们必须在每六十分之一秒内被塞进 GPU。

我们说的是数千棵树,每棵都有包含数千个多边形的精细几何体。即使你有足够的内存来描述这片森林,为了渲染它,这些数据还得穿过总线从 CPU 传到 GPU。

每棵树都关联着一大堆数据:

  • 定义树干、树枝和绿叶形状的多边形网格
  • 树皮和树叶的纹理
  • 在森林中的位置和朝向
  • 大小、色调等调整参数,让每棵树看起来不同

如果写成代码,大致是这样:

class Tree {
 private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

数据量很大,尤其是网格和纹理。一整片森林的这类对象,一帧之内全部扔给 GPU 根本吃不消。幸运的是,处理这种问题有一个久经考验的窍门。

关键观察是:虽然森林里可能有成千上万棵树,但它们看起来大都差不多。它们很可能会使用同一个网格和纹理。也就是说,这些对象中的大部分字段在所有实例之间是相同的

我们可以通过将对象拆分为两部分来明确地建模这一点。首先,提取所有树共有的数据,并将其移到一个单独的类中:

class TreeModel {
 private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};

游戏中只需要一个这样的对象,因为没理由把相同的网格和纹理在内存里存上千份。然后,世界中每一棵树的实例持有一个指向共享 TreeModel引用Tree 里剩下的就是实例特有的状态:

class Tree {
 private:
  TreeModel* model_;

  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

你可以这样想象:

这用来在主存中存储数据是挺好的,但对渲染没帮助。在森林出现在屏幕上之前,数据还得传输到 GPU。我们需要以显卡能理解的方式来表达这种资源共享。


一千个实例

为了最小化推送到 GPU 的数据量,我们希望把共享数据——TreeModel——发送一次。然后,单独地,推送每一棵树的实例独有数据——位置、颜色、大小。最后,告诉 GPU:“用那一个模型渲染所有这些实例。”

幸运的是,当今的图形 API 和显卡恰好支持这种操作。具体细节很繁琐,超出了本书范围,但 Direct3D 和 OpenGL 都能做一种叫做实例化渲染的事情。

在这两种 API 中,你提供两股数据流。第一股是会被多次渲染的公共数据块——在我们这个森林例子中就是网格和纹理。第二股是实例列表及其参数,用来在每次绘制时变换第一股数据。只需一次绘制调用,一整片森林就生长出来了。


享元模式

现在我们有了一个具体例子做铺垫,我可以带你过一遍通用模式了。享元(Flyweight)——顾名思义——用于那些需要变得更“轻“的对象,通常是因为你有太多了

在实例化渲染的例子中,与其说是它们占用了太多内存,不如说是把每棵树逐一推过总线到 GPU 花了太多时间,但基本思路是一样的。

该模式通过将对象的数据分离为两种来解决这个问题。第一种数据是跟特定实例无关、可以在所有实例间共享的东西。GoF 称之为内在状态(intrinsic state),但我更喜欢把它想成“上下文无关“的东西。在树的例子中,这就是几何体和纹理。

其余数据是外在状态(extrinsic state),即每个实例特有的东西。在这里就是每棵树的位置、大小和颜色。就和上方的示例代码一样,该模式通过在对象出现的每个地方共享内在状态的一份拷贝来节省内存。

从目前看到的来说,这看起来像是基本的资源共享,几乎不配被称为一个模式。这部分是因为在这个例子中,我们能为共享状态找出一个清晰的独立身份TreeModel

我发现,在共享对象没有明确定义的独立身份时,这个模式才显得不那么明显(也因此更加巧妙)。在那些情况下,感觉更像是某个对象同时神奇地出现在多个地方。我给你看另一个例子。


落地生根

这些树生长的地面也需要在我们的游戏中呈现。可能有草地、泥土、山丘、湖泊、河流,以及你能想象到的任何其他地形。我们让地面基于瓦片(tile):游戏世界的表面是一个由微小瓦片组成的巨大网格。每个瓦片被一种地形类型覆盖。

每种地形类型有一系列影响游戏玩法的属性:

  • 移动消耗(movement cost):决定玩家通过它的速度
  • 是否为水域(water):能否让船只通过
  • 用于渲染的纹理

因为我们游戏程序员对效率有偏执,我们绝不会把所有这些状态存在世界中的每个瓦片里。相反,一种常见做法是用枚举表示地形类型:

enum Terrain {
  TERRAIN_GRASS,
  TERRAIN_HILL,
  TERRAIN_RIVER
  // 其他地形……
};

然后世界维护一个巨大的网格:

class World {
 private:
  Terrain tiles_[WIDTH][HEIGHT];
};

要获取某个瓦片的有用数据,我们这样做:

int World::getMovementCost(int x, int y) {
  switch (tiles_[x][y]) {
    case TERRAIN_GRASS: return 1;
    case TERRAIN_HILL:  return 3;
    case TERRAIN_RIVER: return 2;
    // 其他地形……
  }
}

bool World::isWater(int x, int y) {
  switch (tiles_[x][y]) {
    case TERRAIN_GRASS: return false;
    case TERRAIN_HILL:  return false;
    case TERRAIN_RIVER: return true;
    // 其他地形……
  }
}

你懂的。这能工作,但我觉得很丑。移动消耗和是否为水域在我看来是地形相关的数据,但这里它们被嵌进了代码里。更糟的是,一种地形类型的数据散落在多个方法中。如果能把所有这些封装在一起就好了。毕竟,这就是对象设计的初衷。

如果能有一个真正的地形就好了,像这样:

class Terrain {
 public:
  Terrain(int movementCost, bool isWater, Texture texture)
  : movementCost_(movementCost),
    isWater_(isWater),
    texture_(texture)
  {}

  int getMovementCost() const { return movementCost_; }
  bool isWater() const { return isWater_; }
  const Texture& getTexture() const { return texture_; }

 private:
  int movementCost_;
  bool isWater_;
  Texture texture_;
};

但我们不想为世界中的每个瓦片都承担一份这个对象的开销。仔细看这个类,你会发现里面没有任何内容是跟瓦片的位置相关的。用享元的术语来说,地形的所有状态都是“内在的“或“上下文无关的“。

因此,每种地形类型完全没有理由有多于一个的实例。地面上的每一块草地都和其他草地一模一样。我们不把世界存成枚举或 Terrain 对象的网格,而是存成指向 Terrain 对象的指针的网格:

class World {
 private:
  Terrain* tiles_[WIDTH][HEIGHT];   // 存指针,不是对象本身
  // 其他东西……
};

使用相同地形的每个瓦片都会指向同一个地形实例。

由于地形实例被多处使用,如果你动态分配它们,生命周期的管理会稍微复杂一些。所以,我们直接把它们存在 World 里面:

class World {
 public:
  World()
  : grassTerrain_(1, false, GRASS_TEXTURE),
    hillTerrain_(3, false, HILL_TEXTURE),
    riverTerrain_(2, true, RIVER_TEXTURE)
  {}

 private:
  Terrain grassTerrain_;
  Terrain hillTerrain_;
  Terrain riverTerrain_;
  // 其他东西……
};

然后我们可以用它们来铺地面,像这样:

void World::generateTerrain() {
  // 先用草地铺满地面
  for (int x = 0; x < WIDTH; x++) {
    for (int y = 0; y < HEIGHT; y++) {
      // 撒一些山丘
      if (random(10) == 0) {
        tiles_[x][y] = &hillTerrain_;
      } else {
        tiles_[x][y] = &grassTerrain_;
      }
    }
  }

  // 铺一条河
  int x = random(WIDTH);
  for (int y = 0; y < HEIGHT; y++) {
    tiles_[x][y] = &riverTerrain_;
  }
}

现在,我们不再通过 World 上的方法去访问地形属性,而是直接暴露 Terrain 对象:

const Terrain& World::getTile(int x, int y) const {
  return *tiles_[x][y];
}

这样一来,World 不再与各种地形细节耦合。如果你想获取瓦片的某个属性,直接从对象上拿:

int cost = world.getTile(2, 3).getMovementCost();

我们又回到了与真实对象打交道的愉悦 API,而且几乎没有额外开销——一个指针通常并不比一个枚举大。


性能怎么样?

我说“几乎“是因为,关注性能的人有权想知道这和用枚举相比表现如何。通过指针引用地形意味着一次间接查找。要获取地形数据(比如移动消耗),你需要先沿着网格中的指针找到地形对象,再从那里找到移动消耗。像这样追逐指针可能导致缓存未命中,从而拖慢速度。

一如既往,优化的金科玉律是先做性能分析。现代计算机硬件太复杂了,性能问题早已不再是纯逻辑推理的游戏。我在本章的测试中,使用享元相比使用枚举没有任何性能损失。享元实际上明显更快。但这完全取决于内存中其他内容的布局方式。

有把握的是:不应该一棍子打死享元对象。它们让你享有面向对象风格的好处,却不用承受成千上万个对象的开销。如果你发现自己正在创建一个枚举并且到处对它做 switch,考虑用这个模式替代它。如果你担心性能,至少先做性能分析,再决定是否把代码改回更难维护的写法。


参见

  • 在瓦片例子中,我们直接为每种地形类型创建了一个实例并存放在 World 里。这样查找和复用共享实例很方便。但在很多情况下,你可能不想提前创建所有的享元。 如果你无法预测实际需要哪些,最好按需创建。为了享受共享的好处,当你请求一个时,先看看是否已经创建过一个完全相同的。如果是,直接返回已有实例。 这通常意味着你需要用一个接口来封装构造过程——先寻找已存在的对象。像这样隐藏构造函数是工厂方法模式的一个例子。

  • 为了返回之前创建过的享元,你需要追踪已经实例化的对象池。顾名思义,对象池模式可能是个存储它们的好地方。

  • 当你在使用状态模式时,经常会遇到那些没有任何特定于“状态所属机器“字段的“状态“对象。状态的身份和方法就足以有用。在这种情况下,你可以应用本模式,在多个状态机中同时复用同一个状态实例,毫无问题。


实践 Demo

享元模式 - 地形瓦片地图


笔记与思考

  • 核心概念:把大量相似对象中不变的部分(内在状态)提取出来共享一份,可变部分(外在状态)各自持有。GoF 叫“运用共享技术有效支持大量细粒度对象“。
  • 两种形态
    • 混合型(树的例子):既有内在状态(TreeModel)也有外在状态(position、color),拆分边界清晰
    • 纯共享型(地形瓦片):对象完全没有外在状态,所有字段都是内在的——此时享元表现为“同一个对象同时存在于多个位置“
  • 跟枚举+switch 的对比:枚举省内存但把数据散落在代码各处,享元用差不多的内存(指针≈枚举大小)换来良好的封装的 API
  • 何时用它:当你发现自己在写一个枚举然后到处 switch 它的时候,考虑用享元替代。性能焦虑先 profile 再改
  • 相关模式:享元的按需创建需要工厂方法;已创建实例的缓存可以用对象池;状态模式里的无状态“状态对象“天然适合享元
  • 额外收获:地形分布的随机阈值本质是“按权重采样“(战利品表),公式就是累积概率:阈值ₙ = p₁ + p₂ + ... + pₙ
  目标分布: 累加 = 阈值:
  草地 60% → 0.60
  山丘 18% → 0.60 + 0.18 = 0.78
  沙地 10% → 0.78 + 0.10 = 0.88
  河流 6% → 0.88 + 0.06 = 0.94
  山峰 6% → 0.94 + 0.06 = 1.00

观察者模式 (Observer)

定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

— GoF 定义

动机

你随便往电脑上扔块石头,都能砸中一个用 MVC 架构构建的应用,而 MVC 的底层就是观察者模式。

译注:MVC(Model-View-Controller)是 70 年代由 Smalltalk 程序员发明的。Lisp 程序员大概会说他们在 60 年代就想出来了,只是懒得写下来而已。

观察者模式无处不在——Java 把它放进了核心库(java.util.Observer),C# 直接把它做进了语言event 关键字)。它是 GoF 所有模式中知名度最高、使用最广的模式之一。但游戏开发圈有时候会奇怪地闭门造车,所以这对你来说可能全是新鲜事。需要我重新说明一下。


成就解锁

假设我们要给游戏加一个成就系统,包含几十种不同的徽章,玩家在完成特定里程碑时可以获得——比如说 “击杀 100 只猴妖”、“从桥上摔下去”、“只用一条死黄鼠狼通关一整关”。

译注:下图为 “黄鼠狼挥舞者” 徽章的示意图。作者声明:画这张图的时候绝对没有双关的意思。

![]:(images/observer-weasel-wielder.png)

这实现起来挺棘手的,因为我们的成就种类太多,触发方式五花八门。如果不够小心,成就系统的藤蔓就会缠绕到代码库的每一个黑暗角落。没错,“从桥上摔下去“确实跟物理引擎有关——但我们真的想在碰撞检测算法的线性代数中间看到一行 unlockFallOffBridge() 吗?

译注:这是个反问句。任何有自尊的物理程序员都不会让我们用“游戏玩法“这种俗物去玷污他们优美的数学。

我们想要的和往常一样:把跟游戏某个方面相关的所有代码放在同一个地方。挑战在于——成就被各种不同的游戏行为触发。怎么才能让成就代码跟所有这些行为通信,又不跟它们耦合在一起?

这就是观察者模式要解决的问题。它允许一段代码宣布“有趣的事情发生了“,而不用关心谁收到了通知

举个例子,我们有一段物理代码负责处理重力、跟踪哪些物体安稳地躺在平地上、哪些正在坠入注定的深渊。要实现“从桥上摔下“的徽章,我们可以直接把成就代码塞进物理代码里,但那会很乱。相反,我们只需要这样做:

void Physics::updateEntity(Entity& entity) {
  bool wasOnSurface = entity.isOnSurface();
  entity.accelerate(GRAVITY);
  entity.update();
  if (wasOnSurface && !entity.isOnSurface()) {
    notify(entity, EVENT_START_FALL);     // "嘿,我不知道有没有人关心,但这家伙刚才掉下去了。你们自己看着办吧。"
  }
}

成就系统把自己注册进来,这样每当物理代码发送通知时,成就系统就会收到。然后它可以检查坠落的物体是不是我们那位不太优雅的英雄,以及他坠落前站的地方是不是一座桥。如果是,就解锁对应的成就——全程物理代码毫不知情。

译注:物理引擎确实需要决定发送哪些通知,所以并不是完全解耦。但在架构设计中,我们的目标通常是让系统更好,而不是完美

事实上,我们可以修改成就列表,甚至把整个成就系统拆掉,都不用碰物理引擎一行代码。它依然会发送通知,浑然不知已经没有人在听了。

译注:当然,如果我们永久移除成就系统,而且没有其他东西在听物理引擎的通知了,那通知代码也应该删掉。但在游戏的演进过程中,有这种灵活性总是好的。


工作原理

如果你还不知道怎么实现,从前面的描述大概也能猜出来了。不过为了让你轻松点,我快速走一遍。

观察者

我们先从那个爱管闲事的类开始——它想知道另一个对象什么时候做了什么有趣的事。这些八卦的对象由这个接口定义:

class Observer {
 public:
  virtual ~Observer() {}
  virtual void onNotify(const Entity& entity, Event event) = 0;
};

任何实现了这个接口的具体类都变成了一个观察者。在我们的例子中,这就是成就系统:

class Achievements : public Observer {
 public:
  virtual void onNotify(const Entity& entity, Event event) {
    switch (event) {
      case EVENT_ENTITY_FELL:
        if (entity.isHero() && heroIsOnBridge_) {
          unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
        }
        break;
      // 处理其他事件,更新 heroIsOnBridge_……
    }
  }

 private:
  void unlock(Achievement achievement) {
    // 如果还没解锁就解锁……
  }
  bool heroIsOnBridge_;
};

译注onNotify() 的参数完全由你决定。这就是为什么它叫观察者模式而不是“你可以直接贴进游戏的现成代码“。典型参数是发送通知的对象,以及一个通用的“数据“参数用来塞其他细节。如果你用的语言支持泛型或模板,你可能会用上,但根据具体用途定制也完全没问题。

被观察者

通知方法是被观察的对象调用的。GoF 管这个对象叫“被观察者“(Subject)。它有两个职责。

第一,它持有一个观察者列表——这些观察者正耐心地等待着它的消息:

class Subject {
 private:
  Observer* observers_[MAX_OBSERVERS];
  int numObservers_;
};

译注:真实代码中你会用动态大小的容器而不是一个死板的数组。这里我用最基础的写法,是为了照顾那些从其他语言来、不熟悉 C++ 标准库的读者。

关键点在于,被观察者暴露了一个公开的 API 来修改这个列表:

class Subject {
 public:
  void addObserver(Observer* observer) {
    // 加到数组里……
  }
  void removeObserver(Observer* observer) {
    // 从数组中移除……
  }
  // 其他东西……
};

这让外部代码可以控制谁收到通知。被观察者与观察者通信,但并不耦合于它们。在我们的例子中,物理代码里不会出现一行提到成就的代码。然而它依然能跟成就系统对话。这就是这个模式的巧妙之处。

同样重要的是,被观察者持有的是观察者列表而不是单个观察者。这保证了观察者之间不会隐式地互相耦合。举个例子,假设音频引擎也要监听坠落事件来播放合适的音效。如果被观察者只支持一个观察者,那当音频引擎注册时,就会顶掉成就系统。这意味着两个系统会互相干扰——而且方式特别恶劣。支持列表就确保了每个观察者之间各自独立。

译注:注意,这段代码假设观察者不会在它们的 onNotify() 方法内部修改列表。更健壮的实现会预防或优雅地处理这种并发修改。

被观察者的另一个职责是发送通知

class Subject {
 protected:
  void notify(const Entity& entity, Event event) {
    for (int i = 0; i < numObservers_; i++) {
      observers_[i]->onNotify(entity, event);
    }
  }
  // 其他东西……
};

译注:在实际代码中,我会避免使用继承。我会让 Physics 拥有一个 Subject 实例。被观察的将不是物理引擎本身,而是一个独立的“坠落事件“对象。观察者可以用类似 physics.entityFell().addObserver(this) 的方式注册自己。

可被观察的物理引擎

现在,我们只需要把这一切接入物理引擎,让它能发送通知,而成就系统能把自己接进接收端。我们沿用原书《设计模式》的做法——继承 Subject

class Physics : public Subject {
 public:
  void updateEntity(Entity& entity);
};

这样,我们把 Subject 中的 notify() 设为 protected,派生的物理引擎类可以调用它来发送通知,但外部代码不能。而 addObserver()removeObserver()public 的,所以任何能拿到物理系统引用的东西都可以观察它。

现在,当物理引擎做了值得关注的事情时,它调用 notify()——就像前面动机例子那样。然后遍历观察者列表,挨个通知。

挺简单的,对吧?就一个类,维护一个指向某个接口实例的指针列表。很难相信如此直白的东西竟然是无数程序和应用程序框架的通信骨架。

但观察者模式并非没有批评者。当我问其他游戏程序员对这个模式怎么看时,他们提出了一些抱怨。让我们看看能做些什么来应对——如果有什么可做的话。


“它太慢了”

我经常听到这个说法,而且通常来自那些其实不了解这个模式细节的程序员。他们有一个默认假设:任何闻起来像“设计模式“的东西一定包含成堆的类、层层间接引用,以及各种创造性浪费 CPU 周期的方式。

观察者模式尤其背了坏名声,因为它经常跟一些不光彩的角色混在一起——叫“事件“、“消息”、甚至“数据绑定“。其中有些系统确实可以很慢(而且往往是故意为之,有充分理由的)。它们涉及为每次通知做队列或动态分配之类的操作。

但现在你已经看过这个模式的实际实现了,你知道事实并非如此。发送通知就是遍历一个列表、调用几个虚方法。当然,它略微慢于静态分派调用,但在除了最性能关键的代码之外的任何地方,这个代价都可以忽略不计。

而且我发现这个模式最适合用在热路径之外的代码中,所以动态分派的成本通常可以承受。除此之外,几乎没有额外开销。我们不为消息分配对象,没有队列,就是同步方法调用上的一层间接引用。

它太了?

事实上,你得小心一点——因为这个模式是同步的。被观察者直接调用观察者,意味着在所有观察者的通知方法返回之前,被观察者不会恢复自己的工作。一个慢的观察者会阻塞被观察者。

这听起来很吓人,但实际上并不是世界末日。只是你需要知道这一点。UI 程序员——他们做这种基于事件的编程已经很多年了——对这事有一条老生常谈的座右铭:“别卡 UI 线程”。如果你响应事件时要同步处理,就尽快完成并交还控制权,以免 UI 锁死。如果你有慢活要干,就推到另一个线程或工作队列里。

不过,把观察者跟多线程和显式锁混在一起确实要小心。如果一个观察者试图获取被观察者持有的锁,你可能会死锁游戏。在高度线程化的引擎中,也许用事件队列做异步通信更合适。


“它做了太多动态分配”

程序员部落中有大群人——包括许多游戏开发者——已经迁移到带垃圾回收的语言上了,动态分配不再像过去那么恐怖。但对于像游戏这样性能关键的软件,内存分配仍然重要——即使在托管语言中。动态分配需要时间,回收内存也需要时间——即使它是自动的。

在前面那个例子代码中,我用了一个固定数组,因为我尽量保持简单。在实际实现中,观察者列表几乎总是一个动态分配的集合,随着观察者的增删而扩展和收缩。这种内存活动吓到了一些人。

当然,首先要注意的是,它只在注册观察者时才分配内存。发送通知完全不涉及内存分配——就是一个方法调用。如果你在游戏开始时注册观察者,然后就不怎么动它们,分配量是极小的。

但如果这仍然是个问题呢?我来讲一种实现增删观察者而完全没有动态分配的方法。

链表式观察者

在我们目前看到的代码中,Subject 拥有一个指向每个观察者的指针列表。Observer 类本身没有指向这个列表的引用,它只是一个纯虚接口。接口优于具体、有状态的类,所以这通常是件好事。

但如果我们愿意Observer 里放一点状态,就可以通过把被观察者的列表穿过观察者自己来解决分配问题。不把独立的指针集合放在被观察者里,而是让观察者对象成为链表中的节点:

为了实现这一点,我们首先删掉 Subject 里的数组,换成一个指向链表头的指针:

class Subject {
  Subject() : head_(NULL) {}
  // 方法……
 private:
  Observer* head_;
};

然后给 Observer 加上一个指向链表中下一个节点的指针:

class Observer {
  friend class Subject;

 public:
  Observer() : next_(NULL) {}
  // 其他东西……
 private:
  Observer* next_;
};

我们这里还把 Subject 声明为友元类。被观察者拥有增删观察者的 API,但它要管理的链表现在在 Observer 类自身内部。最简单的办法就是让它成为友元来操作这个链表。

注册新观察者就是把它接入链表。我们选简单的做法——插入到头部:

void Subject::addObserver(Observer* observer) {
  observer->next_ = head_;
  head_ = observer;
}

另一个选择是插到链表尾部。那样会增加一点复杂度——Subject 要么遍历整个链表找尾部,要么维护一个始终指向最后节点的 tail_ 指针。

插到头部更简单,但有一个副作用。当遍历链表向每个观察者发送通知时,最近注册的观察者会最先收到通知。所以如果你按 A、B、C 的顺序注册,它们会按 C、B、A 的顺序收到通知。

理论上,这都不重要。良好观察者纪律的一条原则是:同一个被观察者的两个观察者之间不应该有顺序依赖。如果顺序确实重要,说明这两个观察者之间有某种隐式耦合,早晚会咬你。

最终只剩一件事——发送通知。就是遍历链表:

void Subject::notify(const Entity& entity, Event event) {
  Observer* observer = head_;
  while (observer != NULL) {
    observer->onNotify(entity, event);
    observer = observer->next_;
  }
}

不算太差,对吧?被观察者可以有任意多的观察者,完全没有动态内存。注册和注销跟简单数组一样快。不过我们牺牲了一个小功能。

因为我们在用观察者对象本身作为链表节点,这意味着它只能属于一个被观察者的观察者列表。换句话说,一个观察者一次只能观察一个被观察者。而在更传统的实现中——每个被观察者有自己独立的列表——一个观察者可以同时出现在多个列表中。

也许你能接受这个限制。我发现一个被观察者对应多个观察者的情况比反过来更常见。如果这对你确实是个问题,还有一个更复杂但不需动态分配的方案。太长塞不进这一章,但我勾勒一下骨架,细节你来填……

节点对象池

跟前面一样,每个被观察者都有一个链表。但链表节点不再是观察者对象本身。而是单独的小“链表节点“对象——包含指向观察者的指针,以及指向链表中下一个节点的指针。

因为多个节点可以指向同一个观察者,所以一个观察者可以同时出现在多个被观察者的列表中。我们又回到了可以同时观察多个被观察者的状态。

避免动态分配的方式很简单:因为这些节点大小和类型都一样,你可以预分配一个对象池。这样你就有一堆固定数量的链表节点可用,按需使用和回收,完全不需要碰真正的内存分配器。


剩余的问题

我觉得我们已经驱散了三个用来吓退人们使用这个模式的妖魔。我们看到它简单、快、而且可以对内存管理友好。但这是否意味着你应该一直用观察者模式?

这就不同了。和所有的设计模式一样,观察者模式不是万能药。即使实现得正确且高效,它也不一定是正确的解决方案。设计模式之所以名声不好,就是因为有人把好模式用在了错的问题上,结果越搞越糟。

还剩两个挑战:一个是技术层面的,另一个更像是可维护性层面的。我们先做技术的,因为那些总是最简单的。

销毁被观察者和观察者

我们走过的示例代码是可靠的,但它回避了一个重要问题:当你删除一个被观察者或观察者时会发生什么?如果你随意地对某个观察者调用 delete,被观察者可能还持有指向它的指针。现在就是一个指向已释放内存的悬空指针。当被观察者试图发送通知时——这么说吧,你不会有好果子吃。

销毁被观察者则更简单一些,因为在大多数实现中,观察者并没有指向被观察者的引用。但即便如此,把被观察者的比特位送进内存管理器的回收站,也可能出问题。那些观察者可能仍然期待将来会收到通知,它们不知道再也不会发生了。严格来说它们根本不是观察者了——它们只是自己以为自己还是。

有几种处理方式。最简单的就是像我这样——把问题甩给别人。观察者有责任在自己被删除时,从所有被观察者那里注销自己。通常情况下,观察者确实知道自己正在观察哪些被观察者,所以只需要在析构函数里加一个 removeObserver() 调用。

如果你不想在某个被观察者挂掉之后让观察者悬着,也很容易解决。让被观察者在销毁前发最后一个“临终“通知。这样,任何观察者都能收到,然后采取它认为合适的行动。

人——即便是我们这些在机器陪伴下待了足够长时间、沾染了些许精确本性的人——在可靠性这件事上是出了名的不靠谱。这就是为什么我们发明了计算机:它们不会犯我们经常犯的错误。

更安全的答案是让观察者在被销毁时自动从所有被观察者那里注销。如果你在基础的观察者类里一次性实现这个逻辑,使用它的人就不需要记得自己去做。但这确实增加了一些复杂度。这意味着每个观察者需要维护它正在观察的被观察者的列表。你最终会得到双向的指针。

别担心,我有 GC

你们这些用带垃圾回收的时髦语言的酷小孩,现在大概挺得意的,觉得自己根本不用操心这个,因为你从来不用显式地 delete 任何东西。再想想!

想象一下:你有个 UI 界面显示了一堆玩家角色的数据,比如血量之类的。玩家打开界面时,你实例化一个新对象。关掉时,你忘了这个对象,让 GC 清理它。

每次角色脸上挨一拳(或者其他地方,随你),它就发送一个通知。UI 界面观察到了,就更新那根小小的血量条。很好。现在——玩家关掉了界面,但你没有注销观察者,会发生什么?

UI 界面不再可见了,但它不会被 GC 回收——因为角色的观察者列表里仍然持有对它的引用。每次加载界面,都会把一个新的实例加到那个越来越长的列表里。

玩家玩游戏的全过程中——跑来跑去、打架——角色一直在发送通知,这些通知被所有那些界面接收。它们不在屏幕上,但它们收到通知,浪费 CPU 周期去更新不可见的 UI 元素。如果它们还做其他事——比如播放音效——你还会得到明显错误的行为。

这个问题在通知系统中太常见了,以至于有了一个名字:失效监听者问题(lapsed listener problem)。由于被观察者持有对监听者的引用,你最终会得到长久驻留在内存中的僵尸 UI 对象。教训是:在注销这件事上要自律

到底发生了什么?

观察者模式的另一个更深层的问题,是它的目的本身的直接后果。我们用它是为了帮助解耦两段代码。它让被观察者间接地跟某个观察者通信,而不用静态绑定它。

这在你想推理被观察者的行为时是一个真正的胜利——任何旁听者都会是一个恼人的干扰。如果你正在捣鼓物理引擎,你真的不想让编辑器——或你的大脑——被一堆成就系统的东西塞满。

但从另一方面来说,如果你的程序不工作、bug 跨越了某个观察者链条,推理这个通信流就会困难得多。如果是显式耦合,只需要查一下被调用的方法就行。你的 IDE 能轻而易举做到,因为耦合是静态的。

但如果耦合通过观察者列表发生,想要知道谁会收到通知的唯一办法就是看运行时列表里恰好有哪些观察者。你没法静态推理程序的通信结构,只能推理它的命令式、动态行为。

我对如何处理这个问题有个简单的准则:如果你经常需要同时思考通信的两端来理解程序的某个部分,就不要用观察者模式来表达那层关联。选更显式的方案。

当你黑进某大型程序时,你往往有一块一块的东西是你集中工作的。我们有很多术语来描述这个——“关注点分离”、“内聚与耦合”、“模块化”——但本质就是:这些放一起,那些不放一起。

观察者模式是让这些基本不相关的块相互通话的好方法,同时又不会让它们融合成一大块。但在一整块专注于某个功能或方面的代码内部,它就没那么有用了。

这就是为什么它适合我们的例子:成就和物理几乎是毫不相关的领域,很可能由不同的人来实现。我们希望它们之间的通信尽可能少,这样开发其中任何一个都不需要了解太多另一个。


今天的观察者

《设计模式》出版于 1994 年。那时面向对象编程是热门范式。地球上的每个程序员都想 “30 天学会 OOP”,中层管理者按他们创建的类数量来发薪。工程师以继承层次的深度衡量自己的本事。

观察者模式在那股思潮中流行起来,所以它重类、重继承也是意料之中。但现在的主流程序员对函数式编程更适应了。为了收一条通知就要实现一整个接口,不符合今天的审美。

它感觉笨重又僵硬。它就是笨重又僵硬。比如说,你没法让一个类对不同被观察者使用不同的通知方法。

更现代的做法是让“观察者“只是一个方法或函数的引用。在有头等函数的语言中——尤其是有闭包的语言——这是更常见的观察者写法。

举个例子,C# 把 “事件“做进了语言里。你注册的观察者是一个 “委托”(delegate),即方法引用。JavaScript 的事件系统中,观察者可以是实现 EventListener 协议的对象,但也可以只是函数——人们几乎总是用后者。

如果我今天设计一个观察者系统,我会让它基于函数而不是基于类。即使在 C++ 中,我也会倾向于一个让你注册成员函数指针作为观察者的系统,而不是某个 Observer 接口的实例。


明天的观察者

事件系统和其他类似观察者的模式在今天已经极其普遍了。它们是一条被踩得很熟的路。但如果你拿它们写几个大型应用,你就会开始注意到一件事:你的观察者里的代码有很多看起来是一样的。通常是这样:

  1. 收到通知,得知某个状态变了。
  2. 命令式地修改某块 UI 来反映新状态。

全都是“哦,英雄血量现在是 7?让我把血量条的宽度设成 70 像素。“一段时间之后,这变得相当乏味。计算机科学家和软件工程师们一直在试图消除这种乏味——已经尝试了很久。他们的尝试有过各种不同的名字:“数据流编程”、“函数式响应式编程“等等。

虽然有一些成功案例——通常是在有限领域,如音频处理或芯片设计——但圣杯至今仍未找到。与此同时,一种不那么激进的做法开始流行。近年来许多应用框架使用了“数据绑定“。

跟更激进的模型不同,数据绑定并不试图完全消灭命令式代码,也不试图围绕一个巨大的声明式数据流图来架构你的整个应用。它做的只是自动化那些繁琐的工作:修改 UI 元素或计算属性来反映某个值的变化。

和其他声明式系统一样,数据绑定可能太慢、太复杂,不适合塞进游戏引擎的核心。但如果我没在 UI 等游戏不那么关键的区域看到它开始渗透,我会很惊讶。

与此同时,老好观察者模式依然会在那里等着我们。当然,它不如那些能同时把“函数式“和“响应式“塞进名字的热门技术那么酷,但它极其简单,而且切实有效。对我来说,这往往是一个解决方案最重要的两个标准。

译注:这也解释了为什么我认为把模式文档化是重要的。当术语开始模糊的时候,我们就失去了清晰和简洁交流的能力。你说“观察者“,有人听到的是“事件“或“消息“,因为要么没人费心把区别写下来,要么他们刚好没读到。这也是我写这本书的初衷——事件和消息我也有专门的章节:事件队列


参见

  • 本章讨论的“事件“和“消息“的更详细版本,参考事件队列一章。

实践 Demo

观察者模式 - 英雄讨伐成就系统


笔记与思考

  • 模式骨架:三要素——Subject(维护 observers[] + notify() 遍历调用)、Observer(实现 onNotify(event) 业务逻辑)、业务代码(在关键节点调用 notify()
  • 三步流程:注册(addObserver)→ 通知(notify 遍历列表)→ 注销(removeObserver)。通常注册在初始化时集中完成,运行时极少变动
  • JS 版 vs C++ 版:JS 是鸭子类型——有 onNotify 方法就能当观察者,不需要显式 implements;C++/Java 需要显式继承 Observer 接口。函数式语言里甚至可以传一个函数当观察者
  • Vue 开发者的视角reactive() + watch() 底层就是观察者模式——reactive = Subject 的依赖收集,watch 的回调 = Observer 的 onNotify。日常用 Vue 开发时接触的“响应式“本质上就是这个模式
  • 游戏中的典型场景:成就系统(物理引擎发事件→成就检查条件)、UI 更新(血量变化→血量条刷新)、音效(攻击事件→播放音频)、AI(环境事件→AI 决策),全部解耦
  • 三个坑:① 观察者销毁前必须注销,否则 Subject 持有悬空指针(或 GC 语言里的失效监听者问题);② notify() 是同步的,慢观察者会阻塞被观察者;③ 调试困难——通信链是动态的,IDE 没法帮你跳转
  • 使用准则:跨系统/跨模块通信用观察者(如物理→成就);同一模块内部紧密逻辑用显式调用(如 player.takeDamage() 里直接调 checkDeath()),别滥用

原型模式 (Prototype)

用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

— GoF 定义

动机

我第一次听到“原型“这个词是在《设计模式》里。今天似乎每个人都在说这个词,但结果发现他们说的不是那个设计模式。我们这里会覆盖它,但我还会向你展示“原型“这个词及其背后的概念出现在其他更有趣的地方。但首先,让我们回顾一下原始的模式。

译注:我说“原始“可不是随随便便的。《设计模式》引用了 Ivan Sutherland 在 1963 年的传奇项目 Sketchpad 作为这个模式最早的实例之一。当其他人都在听 Dylan 和 Beatles 的时候,Sutherland 正忙于——你知道的——发明 CAD、交互式图形和面向对象编程的基本概念。看看演示视频,准备好被震撼吧。


原型设计模式

假设我们正在做一个类似 Gauntlet 风格的游戏。各种生物和恶魔蜂拥而至英雄周围,都想分一块他的肉。这些不受欢迎的晚餐同伴通过“生成器“进入竞技场,每种敌人有一种不同的生成器。

为了这个例子的方便,假设我们为游戏中的每种怪物都有不同的类——GhostDemonSorcerer 等:

class Monster {
  // 诸如此类……
};

class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

生成器构造特定类型怪物的实例。为了支持游戏中的每种怪物,我们可以用暴力方式——为每种怪物类做一个生成器类,导致一个并行的类层级:

译注:我挖了一本落满灰尘的 UML 书来画这张图。空心三角箭头 表示“继承自“。

实现起来是这样:

class Spawner {
 public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};

class GhostSpawner : public Spawner {
 public:
  virtual Monster* spawnMonster() {
    return new Ghost();
  }
};

class DemonSpawner : public Spawner {
 public:
  virtual Monster* spawnMonster() {
    return new Demon();
  }
};

// 你懂的……

除非你按代码行数算工资,否则这显然不是一种好玩的写法。大量的类、大量的模板代码、大量的冗余、大量的重复、大量的自我重复……

原型模式提供了一种解决方案。核心思想是:一个对象可以生成与自己相似的其他对象。如果你有一只幽灵,你可以用这只幽灵造出更多幽灵。如果你有一只恶魔,你可以造出其他恶魔。任何怪物都可以被当作一个原型怪物,用来生成它自己的其他版本。

为实现这一点,我们给基类 Monster 加一个抽象的 clone() 方法:

class Monster {
 public:
  virtual ~Monster() {}
  virtual Monster* clone() = 0;
  // 其他东西……
};

每个怪物子类提供一个实现,返回一个在类和状态上都与自己相同的新对象:

class Ghost : public Monster {
 public:
  Ghost(int health, int speed)
  : health_(health), speed_(speed) {}

  virtual Monster* clone() {
    return new Ghost(health_, speed_);
  }

 private:
  int health_;
  int speed_;
};

一旦所有怪物都支持了 clone(),我们就不再需要为每种怪物类单独写生成器类了。取而代之的是一个统一的生成器:

class Spawner {
 public:
  Spawner(Monster* prototype) : prototype_(prototype) {}

  Monster* spawnMonster() {
    return prototype_->clone();
  }

 private:
  Monster* prototype_;
};

它内部持有一个怪物——一个隐藏的怪物,唯一用途就是被生成器当作模板,像盖章一样造出更多同类怪物。有点像永远不出蜂巢的蜂后。

要创建一个幽灵生成器,我们先创建一个原型幽灵实例,然后用它来创建一个持有该原型的生成器:

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

这个模式的一个巧妙之处在于:它不只是克隆原型的,它克隆的还有其状态。这意味着我们可以通过创建合适的原型幽灵,来制造快速幽灵、弱鸡幽灵或慢速幽灵的生成器。

我觉得这个模式既优雅又令人惊讶。我想象不出自己是怎么想出它的,但既然知道了,我也想象不出不去知道它。

效果如何?

嗯,我们不需要为每种怪物创建独立的生成器类了,这是好事。但我们确实需要在每个怪物类中实现 clone()。这代码量跟写生成器差不多。

而且,当你试图实现一个正确的 clone() 时,还有一些棘手的语义坑。它是深拷贝还是浅拷贝?换句话说,如果一只恶魔拿着一把草叉,克隆恶魔会克隆草叉吗?

而且,不仅在这个刻意设计的例子中它看起来并没有省多少代码,还因为这个例子本就是刻意设计的。我们不得不假设每种怪物都有独立的类。如今,这绝对不是大多数游戏引擎的做法。我们大多数人都是通过血的教训才学会:像这样的大类层级很难管理。这就是为什么我们改用组件模式类型对象模式来建模不同种类的实体,而不是把每种都供奉成自己的一个类。

生成函数

即使每种怪物确实有不同的类,也还有其他办法来驯服这只猫。不需要为每种怪物创建独立的生成器,我们可以创建生成函数

Monster* spawnGhost() {
  return new Ghost();
}

这比为构造某种类型怪物的类来写一整套类要少很多模板代码。然后统一的生成器类只需存储一个函数指针:

typedef Monster* (*SpawnCallback)();

class Spawner {
 public:
  Spawner(SpawnCallback spawn) : spawn_(spawn) {}

  Monster* spawnMonster() {
    return spawn_();
  }

 private:
  SpawnCallback spawn_;
};

要创建一个幽灵生成器,你这样做:

Spawner* ghostSpawner = new Spawner(spawnGhost);

模板

如今,大多数 C++ 开发者对模板都不陌生。我们的生成器类需要构造某个类型的实例,但我们不想硬编码某个特定的怪物类。那么自然的解决方案是把它做成一个类型参数——这正是模板允许我们做的:

class Spawner {
 public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};

template <class T>
class SpawnerFor : public Spawner {
 public:
  virtual Monster* spawnMonster() { return new T(); }
};

译注:我不确定是 C++ 程序员学会了喜欢模板,还是模板直接把一些人吓得彻底离开了 C++。不管怎样,今天我看到的所有用 C++ 的人都在用模板。

使用起来像这样:

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

译注:这里的基类 Spawner 是为了让不关心生成器具体创建哪种怪物的代码可以统一使用它,通过指向 Monster 的指针来工作。如果我们只有 SpawnerFor<T> 类,那这个模板的所有实例化之间就没有一个共同的超类型,任何处理任意怪物类型的生成器的代码本身也需要带模板参数。

一等公民类型

前两种方案解决了“需要有一个以类型为参数的 Spawner 类“的问题。在 C++ 中,类型通常不是一等公民,所以需要一些花招。如果你使用的是动态类型语言——如 JavaScript、Python 或 Ruby——类本身就是可以传来传去的普通对象,解决起来就直接多了。

创建生成器时,只需传入它应该构造的怪物类——那个代表怪物类的运行时实际对象。简单得像派一样。

有了所有这些方案,老实说我没发现哪个场景下我认为原型设计模式是最佳答案。也许你的体验会不同,但现在我们把它放一边,来谈点别的:原型作为一种语言范式

译注:某种程度上,类型对象模式是缺乏一等公民类型的另一种变通方案。不过,即使在一等公民类型的语言中,这个模式依然有用——因为它让你自己定义什么是“类型“。你可能需要与语言内置类不同的语义。


原型语言范式

很多人认为“面向对象编程“就是“类“的同义词。关于 OOP 的定义,感觉像是不同宗教派别的信条,但一个相对没有争议的理解是:OOP 让你定义将数据和代码捆绑在一起的“对象“。相比 C 这样的结构化语言和 Scheme 这样的函数式语言,OOP 的决定性特征是它把状态和行为紧密绑定在一起。

你可能认为类是做到这一点的唯一方法,但包括 Dave Ungar 和 Randall Smith 在内的少数几个人不同意。他们在 80 年代创建了一种叫做 Self 的语言。它跟任何语言一样 OOP,但它没有类。

Self

从纯粹意义上说,Self 比基于类的语言更加面向对象。我们认为 OOP 是状态和行为的结合,但基于类的语言实际上在两者之间有一条分界线。

想想你最喜欢的基于类的语言的语义。要访问对象上的某个状态,你查实例的内存。状态包含在实例中。

但要调用方法,你得先找实例的类,然后在那里找方法。行为包含在中。获取方法总是有那一层间接引用,这意味着字段和方法是不同的。

译注:例如,在 C++ 中调用虚方法,你要先找实例中指向虚表的指针,然后在虚表中查找方法。

Self 消除了这种区别。要查找任何东西,你只在对象上找。一个实例可以同时包含状态和行为。你可以有一个对象,它拥有一个完全独一无二的方法。

译注:没有人是一座孤岛,但对象是。

如果 Self 只是做到这样,那就很难用了。基于类的语言中的继承,虽然有缺点,但提供了一个有用的机制来复用多态代码和避免重复。要在没有类的情况下实现类似的功能,Self 有委托

要查找某个对象上的字段或调用方法,我们首先在对象本身中查找。如果有,就完事了。如果没有,我们看对象的父对象。这只是指向另一个对象的引用。当我们在第一个对象上找不到属性时,我们尝试它的父对象,然后父对象的父对象,依此类推。换句话说,失败的查找被委托给了对象的父对象。

译注:我这里简化了。Self 实际上支持多个父对象。父对象只是被特殊标记的字段,这意味着你可以做像继承父对象或在运行时改变父对象之类的事情,导致所谓的动态继承

父对象让我们可以在多个对象之间复用行为(以及状态!),所以我们覆盖了类的一部分功能。类的另一个关键功能是给我们一种创建实例的方法。当你需要一个新的“玩意儿“时,你可以直接 new Thingamabob(),或者你喜欢的语言的其他语法。类就是它自身实例的工厂。

没有类,我们怎么制造新东西呢?特别是,如何制造一堆有共同点的新东西?就像设计模式一样,在 Self 中做这件事的方法是克隆

在 Self 中,就好像每个对象都自动支持原型设计模式。任何对象都可以被克隆。要制造一堆相似的对象,你:

  1. 把一个对象敲打成你想要的形状。你可以直接克隆系统内建的基础 Object,然后往里塞字段和方法。
  2. 克隆它来制作你想要的任意多个……呃……克隆。

这给了我们原型设计模式的优雅,而不用忍受自己实现 clone() 的繁琐——它是系统自带的。

译注:我意识到从头构建一门语言并不是最高效的学习方式,但能说什么呢?我有点另类。如果你好奇,这门语言叫 Finch。

这是一个如此美丽、巧妙、精简的系统,以至于我了解了它之后,就开始创建一门基于原型的语言来获得更多实践经验。

结果如何?

我对使用纯基于原型的语言感到超级兴奋,但一旦我的语言跑起来,我发现一个不愉快的事实:用它编程并没有那么有趣。

译注:我后来从可靠渠道听说很多 Self 程序员也得出了同样的结论。不过这个项目远非失败。Self 太动态了,需要各种虚拟机创新才能跑得足够快。他们为即时编译、垃圾回收和优化方法分派而发明的技术——经常由同一批人实现——正是让世界上许多动态类型语言快得足以用于大规模流行应用的那些技术。

当然,语言本身实现起来很简单,但那是因为它把复杂度推给了用户。当我开始尝试使用它时,我发现自己怀念类提供的结构。我最终试图在库的层面上重新实现它,因为语言本身没有。

也许这是因为我之前的经验都是基于类的语言,所以我的思想被那种范式污染了。但我直觉上认为,大多数人就是喜欢定义良好的“事物种类“。

除了基于类的语言取得了巨大成功之外,看看有多少游戏有明确的角色类别,以及精确的敌人、物品和技能清单,每种都井井有条地贴了标签。你很少看到游戏中每个怪物都是一个独特的“雪花“——比如“介于巨魔和哥布林之间,还带点蛇的味道“。

译注:而且,实际用原型风格写的代码到底有多少也很能说明问题。我找过。

虽然原型是一个非常酷的范式,我也希望更多人了解它,但我很高兴我们大多数人实际上并不每天用它编程。我所看到的完全拥抱原型的代码有一种奇特的模糊性,让人觉得难以把握。

那么 JavaScript 呢?

好吧,如果基于原型的语言这么不友好,那怎么解释 JavaScript?这是一个每天有数百万人使用的基于原型的语言。地球上运行着比任何其他语言都多的 JavaScript 代码。

JavaScript 的创造者 Brendan Eich 直接从 Self 中汲取了灵感,JavaScript 的许多语义都是基于原型的。每个对象可以有一组任意的属性,包括字段和“方法“(其实只是作为字段存储的函数)。一个对象还可以有另一个对象,称为它的“原型“,在字段访问失败时委托给它。

译注:作为语言设计者,原型的一个吸引人之处在于它比类更容易实现。Eich 充分利用了这一点:JavaScript 的第一个版本是在十天内创建的。

但是,尽管如此,我相信 JavaScript 在实践中与基于类的语言的共同点比与原型语言的共同点更多。一个提示 JavaScript 已经远离 Self 的证据是:基于原型语言的核心操作——克隆——已经无处可寻。

JavaScript 中没有克隆对象的方法。最接近的是 Object.create(),它允许你创建一个委托给现有对象的新对象。即便如此,这也直到 ECMAScript 5 才加入——在 JavaScript 面世十四年之后。让我带你看看 JavaScript 中定义类型和创建对象的典型方式,而不是克隆。你从一个构造函数开始:

function Weapon(range, damage) {
  this.range = range;
  this.damage = damage;
}

这会创建一个新对象并初始化它的字段。你这样调用它:

var sword = new Weapon(10, 16);

这里的 new 会执行 Weapon() 函数体,同时 this 被绑定到一个新的空对象。函数体会给这个对象添加一堆字段,然后这个已经被填值的对象会自动返回。

new 还为你做了一件事。当它创建那个空白对象时,它会将其连接到委托给一个原型对象。你可以直接用 Weapon.prototype 访问到那个对象。

状态是在构造函数体中添加的,但要定义行为,你通常是在原型对象上添加方法。像这样:

Weapon.prototype.attack = function (target) {
  if (distanceTo(target) > this.range) {
    console.log("Out of range!");
  } else {
    target.health -= this.damage;
  }
};

这给武器原型添加了一个 attack 属性,其值是一个函数。由于每个由 new Weapon() 返回的对象都委托给了 Weapon.prototype,你可以直接调用 sword.attack(),它会调用那个函数。看起来像这样:

来回顾一下:

  • 创建对象的方式是通过一个“new“操作,你用一个代表类型的对象——即构造函数——来调用它。
  • 状态存储在实例本身。
  • 行为经过一层间接引用——委托给原型——存储在一个代表某个类型所有对象共用方法集的对象上。

你可以说我疯了,但这听起来很像我之前对类的描述。你可以在 JavaScript 中写原型风格的代码(没有克隆),但语言的语法和惯用法鼓励的是基于类的方式。

就我个人而言,我认为这是件好事。就像我说的,我发现加倍押注原型只会让代码更难用,所以我喜欢 JavaScript 把核心语义包装在更“类“似的东西里。


数据建模中的原型

好了,我一直在谈论原型我不喜欢的东西,这让这一章变得很沉闷。我觉得这本书更多的是喜剧而不是悲剧,所以我们用一个我确实认为原型——更具体地说委托——有用的领域来收尾吧。

如果你点算一下游戏中代码所占的字节数 vs 数据所占的字节数,你会发现数据占比自编程诞生以来一直在稳步增长。早期的游戏几乎程序化生成一切,以塞进软盘和旧游戏卡带。在今天的许多游戏中,代码只是一个驱动游戏的“引擎“,而游戏本身完全定义在数据中。

这很棒,但把大堆内容推入数据文件并不能神奇地解决大型项目的组织挑战。如果有什么的话,它只会更难。我们之所以使用编程语言,是因为它们有管理复杂度的工具。

我们不会把一段代码复制粘贴到十个地方,而是把它移到一个可以通过名称调用的函数里。我们不会把方法复制到一堆类中,而是把它放到一个单独的类中,由那些类继承或混入。

当你的游戏数据达到一定规模时,你确实会开始想要类似的功能。数据建模是一个很深的话题,我无法在这本当之无愧地论述,但我确实想抛出一个你可以在自己的游戏中考虑的特性:使用原型和委托来复用数据。

译注:我指的是完全原创的标题,绝不受任何先前存在的自上而下多玩家地下城闯关街机游戏的启发。请别告我。

假设我们在我之前提到的无耻 Gauntlet 仿制品中定义数据模型。游戏设计师需要以某种文件格式指定怪物和物品的属性。

一种常见的方法是使用 JSON。数据实体基本上是映射(map)、或属性包、或者其他几十种术语之一——程序员最喜欢干的事就是给已经有名字的东西再发明一个新名字。

译注:我们重新发明它们的次数太多了,以至于 Steve Yegge 称之为“万能设计模式“。

于是一个游戏中哥布林可能被定义成这样:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

这非常直观,即使是最讨厌文本的设计师也能处理。于是你加入了哥布林家族树上几个旁系分支:

{
  "name": "goblin wizard",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "spells": ["fire ball", "lightning bolt"]
}

{
  "name": "goblin archer",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "attacks": ["short bow"]
}

现在,如果这是代码,我们的审美警钟就要响了。这些实体之间有很多重复,训练有素的程序员痛恨重复。它浪费空间,写起来更花时间。你得仔细看才能知道这些数据是不是一样的。这还是维护的噩梦。如果我们决定让游戏中所有哥布林更强,我们得记得更新它们三个的血量。糟糕糟糕糟糕。

如果这是代码,我们会为“哥布林“创建一个抽象,然后在三种哥布林类型中复用。但愚蠢的 JSON 对此一无所知。所以我们让它变聪明一点。

我们声明:如果对象有一个名为 "prototype" 的字段,那么它定义了另一个对象的名称,当前对象会委托给这个对象。任何在第一个对象上不存在的属性,都会回退到在原型上查找。

译注:这让“prototype“变成了元数据而不是数据。哥布林有疣状的绿色皮肤和黄色的牙齿。它们没有原型。原型是代表哥布林的数据对象的属性,而不是哥布林本身的属性。

有了这个,我们可以简化哥布林大军的 JSON:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

{
  "name": "goblin wizard",
  "prototype": "goblin grunt",
  "spells": ["fire ball", "lightning bolt"]
}

{
  "name": "goblin archer",
  "prototype": "goblin grunt",
  "attacks": ["short bow"]
}

由于弓箭手和法师以步兵为原型,我们不需要重复血量、抗性和弱点。我们给数据模型添加的逻辑超级简单——基本的单委托——但我们已经消除了大量重复。

这里一个有趣的点是:我们没有为三个具体哥布林类型设置第四个“基础哥布林“抽象原型来委托。相反,我们选了一个最简单的哥布林来委托给它。

这在基于原型的系统中感觉很自然——任何对象都可以被用作克隆来创建新的改良对象——我认为在这里同样自然。它特别适合游戏中的数据建模——游戏中经常有世界中一次性的特殊实体。想想 Boss 和独特物品。它们往往是对游戏中更常见物体的改良,原型委托是一种很好的定义方式。神奇的断头之剑,其实就是一把长剑加上一些加成,可以直接这样表达:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

在你的游戏引擎的数据建模系统中增加一点点能力,就可以让设计师更容易为游戏世界中的武器和怪物添加各种小变化——而这种丰富性正是让玩家着迷的东西。


实践 Demo

原型模式


笔记与思考

1. 语言范式:类语言 vs 原型语言

语言底层模型
Java纯类语言,一切必须在类中
C++类语言,支持多范式
Python类语言,type / __mro__ 体系,但类本身是一等公民
JavaScript原型语言(Self 后代),ES6 class 是语法糖
GDScript类语言(Godot 4 class_name

JS 虽底层是原型链,但实践中已经“类化“——社区惯用法(new + prototype.method)、ES6 class 语法、以及最关键的:JS 没有 clone 操作,而克隆是原型范式的核心。Brendan Eich 从 Self 借了机制,但生态把它推向了“看起来像类语言“的方向。

2. 原型模式真正的用武之地

GoF 原型模式(clone() + 生成器基类)在现代游戏开发中已经边缘化。原因:

  • 每种怪物独立类的做法已被组件模式和类型对象模式取代
  • 自己写 clone() 涉及深/浅拷贝的语义坑
  • 模板、生成函数、一等公民类型(Python/JS 可直接传类)都能更简洁地解决同一问题

真正有价值的是数据建模中的委托——游戏中大量实体定义存在重复(10 种哥布林共享血量、抗性),JSON 不支持继承,用 "prototype" 字符串做引用是最轻量的复用方案。

核心原则:不需要造干净抽象的“基类“——直接选一个最普通的实体当原型即可。 这比类层级更自然,因为游戏中传奇武器本来就是“普通长剑 + 点东西“。

3. Demo 关键领悟

Demo 做了这样一个过程:

  1. MONSTERS 对象模拟从 JSON 加载的原始数据——纯数据,没有原型链
  2. parsePrototype(name) 按需解析,解构出 prototype 字段 + 自身属性,递归合并原型链
  3. parsePrototypeIter(循环版)做等价比对,验证正确性

过程中经历了一个重要的弯路:想用 Object.create 预构建 JS 原生原型链来“更优雅地“解决,但这是错误方向——JSON 本身就是纯数据,"prototype": "goblin grunt" 只是字符串,不是对象引用。手写那 6 行递归就是最简洁的解法。宁愿保持数据干净、解析函数简单,不要为“用上语言特性“而增加预处理层。

4. 递归 vs 循环

递归版(parsePrototype)代码最直观,对于原型链深度 2-3 层的场景完全安全。循环版(parsePrototypeIter)用 while 沿链收集各层,再从远到近 Object.assign 合并,避免递归栈溢出风险,逻辑等价。生产环境中数据嵌套不可控时,循环版更稳妥。

单例模式 (Singleton)

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

— GoF 定义

动机

这个章节不同寻常。其他章节展示如何使用某个设计模式。这个章节展示如何避免使用某个设计模式。

尽管它的意图是好的,GoF 描述的单例模式通常弊大于利。他们强调应该谨慎使用这个模式,但在游戏业界的口口相传中,这一提示经常被无视了。

像其他模式一样,在不合适的地方使用单例模式就好像用夹板处理子弹伤口。由于它被滥用得太严重了,这章的大部分都在讲如何回避单例模式。但首先,让我们看看模式本身。

译注:当业界从 C 语言迁移到面向对象的语言,他们遇到的首个问题是“如何访问实例?“他们知道有要调用的方法,但是找不到实例提供这个方法。单例(换言之,全局化)是一条简单的解决方案。


单例模式

GoF 这样描述单例模式:

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

我们从“并且“那里将句子分为两部分,分别进行考虑。

保证一个类只有一个实例

有时候,如果类存在多个实例就不能正确运行。通常发生在类与保存全局状态的外部系统互动时。

考虑封装文件系统的 API 类。因为文件操作需要一段时间完成,所以类使用异步操作。这就意味着可以同时运行多个操作,必须让它们相互协调。如果一个操作创建文件,另一个操作删除同一文件,封装器类需要同时考虑,保证它们没有相互妨碍。

为了实现这点,对封装器类的调用必须访问之前的每个操作。如果用户可以自由地创建类的实例,这个实例就无法知道另一实例之前的操作。而单例模式提供的构建类的方式,在编译时保证类只有单一实例。

提供了访问该实例的全局访问点

游戏中的不同系统都会使用文件系统封装类:日志、内容加载、游戏状态保存,等等。如果这些系统不能创建文件系统封装类的实例,它们如何访问该实例呢?

单例为这点也提供了解决方案。除了创建单一实例以外,它也提供了一种获得它的全局方法。使用这种范式,无论何处何人都可以访问实例。综合起来,经典的实现方案如下:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    // 惰性初始化
    if (instance_ == NULL) instance_ = new FileSystem();
    return *instance_;
  }

private:
  FileSystem() {}

  static FileSystem* instance_;
};

静态的 instance_ 成员保存了一个类的实例,私有的构造器保证了它是唯一的。公开的静态方法 instance() 让任何地方的代码都能访问实例。在首次被请求时,它同样负责惰性实例化该单例。

现代的实现方案:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    static FileSystem *instance = new FileSystem();
    return *instance;
  }

private:
  FileSystem() {}
};

C++11 标准保证了本地静态变量只会初始化一次(即使多线程),因此这段代码是线程安全的,而前一个例子不是。

译注:当然,单例类本身的线程安全是个不同的问题!这里只保证了它的初始化没问题。


为什么我们使用它

看起来已有成效。文件系统封装类在任何需要的地方都可用,而无需笨重地到处传递。类本身巧妙地保证了我们不会实例化多个实例而搞砸。它还具有很多其他的优良性质:

  • 如果没人用,就不必创建实例。 节省内存和 CPU 时间总是好事。由于单例只在第一次被请求时实例化,如果游戏永远不请求,那么它不会被实例化。

  • 它在运行时实例化。 通常的替代方案是使用含有静态成员变量的类。我喜欢简单的解决方案,因此我尽可能使用静态类而不是单例,但是静态成员有个限制:自动初始化。编译器会在调用 main() 之前初始化静态变量。这意味着它们无法使用只有在程序运行起来之后才能获得的信息(例如从文件中加载的配置)。这也意味着它们的相互依赖是不可靠的——编译器可不保证以什么样的顺序初始化静态变量。惰性初始化解决了以上两个问题。

  • 可继承单例。 这是个很有用但通常被忽视的能力。假设我们需要跨平台的文件系统封装类:

class FileSystem
{
public:
  virtual ~FileSystem() {}
  virtual char* readFile(char* path) = 0;
  virtual void  writeFile(char* path, char* contents) = 0;
};

class PS3FileSystem : public FileSystem { /* ... */ };
class WiiFileSystem : public FileSystem { /* ... */ };

// 单例:通过编译器宏选择平台实现
FileSystem& FileSystem::instance()
{
  #if PLATFORM == PLAYSTATION3
    static FileSystem *instance = new PS3FileSystem();
  #elif PLATFORM == WII
    static FileSystem *instance = new WiiFileSystem();
  #endif
  return *instance;
}

通过一个简单的编译器转换,我们把文件系统包装类绑定到合适的具体类型上。整个代码库都可以使用 FileSystem::instance() 访问到文件系统,而无需和任何平台相关的代码耦合。


为什么我们后悔使用它

短期来看,单例模式是相对良性的。就像其他设计决策一样,我们需要从长期考虑。这里是一旦我们将一些不必要的单例写进代码,会给自己带来的麻烦:

它是一个全局变量

当游戏还是由几个家伙在车库中完成时,榨干硬件性能比象牙塔里的软件工程原则更重要。但随着游戏变得越来越大,越来越复杂,架构和管理开始变成瓶颈——阻碍我们发布游戏的,除了硬件限制,还有生产力限制。

我们学到了全局变量有害的诸多原因:

  • 理解代码更加困难。 假设我们在查找别人所写函数中的漏洞。如果函数没有碰到任何全局状态,脑子只需围着函数转。现在考虑函数中间是对 SomeClass::getSomeGlobalData() 的调用。为了查明发生了什么,得追踪整个代码库来看看什么修改了全局变量。

  • 促进了耦合的发生。 新加入团队的程序员也许不熟悉你们精心设计的松散耦合架构。但 AudioPlayer 是全局可见的,一个 #include 就能打乱整个架构。如果不用全局实例实现音频播放器,这种阻碍给他发送了一个明确的信号:这两个模块不该访问。通过控制对实例的访问,你控制了耦合。

  • 对并行不友好。 现代代码至少应考虑在多线程环境下工作。当我们将某些东西转为全局变量时,我们创建了一块每个线程都能看到并访问的内存,却不知道其他线程是否正在使用那块内存。这带来了死锁、竞争状态,以及其他很难解决的线程同步问题。

因为单例确实是全局状态——它只是被封装在一个类中。

它能在你只有一个问题的时候解决两个

GoF 描述中的“并且“这个词有点奇怪。这个模式解决了一个问题还是两个?保证实例是唯一存在的是很有用的,但是谁告诉我们要让每个人都能访问到它?同样,全局访问很方便,但是必须禁止存在多个实例吗?

便利的访问,几乎是使用单例模式的全部原因。想想日志类。大部分模块都能从记录诊断日志中获益。但是,如果将 Log 类的实例传给每个需要这个方法的函数,那就模糊了代码的意图。

明显的解决方案是让 Log 类成为单例。每个函数都能从类那里获得一个实例。但当我们这样做时,我们无意地制造了一个奇怪的小约束——突然之间,我们不再能创建多个日志记录者了。

起初,这不是一个问题。然后,每个团队的成员都使用日志记录各自的诊断信息,大量的日志倾泻在文件里。我们想将日志分散到多个文件中来解决这点。但是我们做不到——Log 类不再允许我们创建多个实例,而且每一行调用 Log::instance().write(...) 的地方都需要修改。

惰性初始化从你那里剥夺了控制权

在拥有虚拟内存和软性性能需求的 PC 里,惰性初始化是一个小技巧。游戏则是另一种状况。初始化系统需要消耗时间:分配内存,加载资源,等等。如果初始化音频系统消耗了几百个毫秒,我们需要控制它何时发生。如果在第一次声音播放时惰性初始化它自己,这可能发生在游戏的高潮部分,导致可见的掉帧和断续的游戏体验。

同样,游戏通常需要严格管理在堆上分配的内存来避免碎片。

因此,大多数游戏不使用惰性初始化,而是使用静态实例:

class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }
private:
  FileSystem() {}
  static FileSystem instance_;
};

这解决了惰性初始化问题,但也失去了单例相对于裸全局变量的一些优势,例如多态、运行时构造与显式释放。这里实际上是一个简单的静态类。如果你需要的是静态类,为什么不直接使用静态函数呢?调用 Foo::bar()Foo::instance().bar() 更简单,也更明确地表明你在处理静态内存。


那该如何是好

如果我现在达到了目标,你在下次遇到问题使用单例模式之前就会三思而后行。但是你还是有问题需要解决。你应该使用什么工具呢?

看看你是不是真正地需要类

我在游戏中看到的很多单例类都是“管理器“——那些类存在的意义就是照顾其他对象。MonsterManager、ParticleManager、SoundManager、ManagerManager……

看看这个设计:

class Bullet {
  int getX() const; int getY() const;
  void setX(int x); void setY(int y);
};
class BulletManager {
  Bullet* create(int x, int y);
  bool isOnScreen(Bullet& bullet);
  void move(Bullet& bullet);
};

答案:你根本不需要 BulletManager。把行为放回对象自身:

class Bullet {
public:
  Bullet(int x, int y) : x_(x), y_(y) {}
  bool isOnScreen() { /* ... */ }
  void move() { x_ += 5; }
};

糟糕设计的单例通常会“帮助“另一个类增加代码。如果可以,把所有的行为都移到单例帮助的类中。毕竟,OOP 就是让对象管理好自己。

将类限制为单一的实例(而不提供全局访问)

这是单例模式解决的一半问题。我们希望有种方式能保证只有一个实例而无需提供全局访问点:

class FileSystem
{
public:
  FileSystem() {
    assert(!instantiated_);
    instantiated_ = true;
  }
  ~FileSystem() { instantiated_ = false; }
private:
  static bool instantiated_;
};

这个类允许任何人构建它,但在运行时检查并阻止多重实例化。它的缺点:检查只在运行时进行,不像单例模式在编译时保证。

提供方便的访问(而不限制实例数量)

便利的访问才是我们伸手拿单例的主要原因。但便利的代价是耦合。以下是几种替代方案:

1. 传递参数。 最简单的方案,通常也是最好的——把需要的对象作为参数传给函数。渲染函数通常接受一个 context 参数来获取图形设备。

2. 从基类获取。 如果架构有浅而宽的继承层级(GameObject → 各种实体),可以把共享对象挂在基类上,通过 protected 方法提供给子类:

class GameObject {
protected:
  Log& getLog() { return log_; }
private:
  static Log& log_;
};
class Enemy : public GameObject {
  void doSomething() { getLog().write("I can log!"); }
};

这就是子类沙箱模式的思路。

3. 从已经全局的对象获取。 大多数代码库仍有少量全局对象(如单一的 GameWorld 对象)。我们可以让其他系统“搭车“在它上面,而不是各自成为单例:

class Game {
public:
  static Game& instance() { return instance_; }
  Log&         getLog()         { return *log_; }
  FileSystem&  getFileSystem()  { return *fileSystem_; }
  AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
};

这样只有 Game 是全局的,其他系统通过它访问。但如果一个类只需要播放声音,它仍需知道 Game 的存在。

4. 从服务定位器获取。 定义一个类,它唯一的目的就是提供全局访问点。这就是服务定位器模式。


单例模式还剩下什么

问题仍然存在:哪里才应该使用真正的单例模式?老实说,我在游戏中从未使用过完整的 GoF 实现。为了保证只有一个实例,我通常直接使用静态类。如果那不行,我会在运行时用静态 flag 检查只有一个实例被构造。

本书中还有其他相关章节:


实践 Demo

待补充


笔记与思考

单例解决了两个问题,分别是:

  • 确保只有一个实例
  • 提供一个全局访问点 但是往往只需要解决一个问题,例如确保只有一个实例。 强行使用单例会带来更多的问题,例如:
  • 耦合度增加
  • 难以测试
  • 难以扩展 在游戏开发中,通常会使用静态类而不是单例,因为静态类在编译时就确定了只有一个实例,而单例在运行时才确定。

状态模式 (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
  • 状态可穷举、转换关系明确

不适合:

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

二、序列化模式

Sequencing Patterns

游戏的核心是“循环“——每一帧都在重复执行特定的操作序列。本部分探讨如何优雅地组织这些时间上的顺序逻辑。

双缓冲模式 (Double Buffer)

用序列的操作模拟瞬间或者同时发生的事情。

— 意图

动机

电脑具有强大的序列化处理能力。它的力量来自于将大的任务分解为小的步骤,这样可以一步接一步的完成。但是,通常用户需要看到事情发生在瞬间或者让多个任务同时进行。

译注:使用线程和多核架构让这种说法不那么正确了,但哪怕使用多核,也只有一些操作可以同步运行。

一个典型的例子,也是每个游戏引擎都得掌控的问题,渲染。当游戏渲染玩家所见的世界时,它同时需要处理一堆东西——远处的山,起伏的丘陵,树木,每个都在各自的循环中处理。如果在用户观察时增量做这些,连续世界的幻觉就会被打破。场景必须快速流畅地更新,显示一系列完整的帧,每帧都是立即出现的。

双缓冲解决了这个问题,但是为了理解其原理,让我们首先的复习下计算机是如何显示图形的。

计算机图形系统是如何工作的(概述)

在电脑屏幕上显示图像是一次绘制一个像素点。它从左到右扫描每行像素点,然后移动至下一行。当抵达了右下角,它退回左上角重新开始。它做得飞快——每秒六十次——因此我们的眼睛无法察觉。对我们来说,这是一整张静态的彩色像素——一张图像。

译注:这个解释是“简化过的“。如果你是底层软件开发人员,跳过下一节吧。你对这章的其余部分已经了解得够多了。如果你不是,这部分的目标是给你足够的背景知识,理解等下要讨论的设计模式。

你可以将整个过程想象为软管向屏幕喷洒像素。独特的像素从软管的后面流入,然后在屏幕上喷洒,每次对一个像素涂一点颜色。所以软管怎么知道哪种颜色要喷到哪里?

在大多数电脑上,答案是从帧缓冲中获知这些信息。帧缓冲是内存中的色素数组,RAM 中每两个字节代表表示一个像素点的颜色。当软管向屏幕喷洒时,它从这个数组中读取颜色值,每次一个字节。

译注:在字节值和颜色之间的映射通常由系统的像素格式色深来指定。在今日多数游戏主机上,每个像素都有32位,红绿蓝三个各占八位,剩下的八位保留作其他用途。

最终,为了让游戏显示在屏幕中,我们需要做的就是写入这个数组。我们疯狂摆弄的图形算法最终都到了这里:设置帧缓冲中的字节值。但这里有个小问题。

早先,我说过计算机是顺序处理的。如果机器在运行一块渲染代码,我们不指望它同时还能做些别的什么事。这通常是没啥问题,但是有些事确实在程序运行时发生。其中一件是,当游戏运行时,视频输出正在不断从帧缓冲中读取数据。这可能会为我们带来问题。

假设我们要在屏幕上显示一张笑脸。程序在帧缓冲上开始循环,为像素点涂色。我们没有意识到的是,在写入的同时,视频驱动正在读取它。当它扫描过已写的像素时,笑脸开始浮现,但是之后它进入了未写的部分,就将没有写的像素绘制到了屏幕上。结果就是撕裂,你在屏幕上看到了绘制到一半的图像,这是可怕的视觉漏洞。

译注:显卡设备读取的缓冲帧正是我们绘制像素的那块(Fig. 1)。显卡最终追上了渲染器,然后越过它,读取了还没有写入的像素(Fig. 2)。我们完成了绘制,但驱动没有收到那些新像素。结果(Fig. 4)是用户只看到了一半的绘制结果。我称它为“哭脸“,笑脸看上去下半部是撕裂的。

这就是我们需要这个设计模式的原因。程序一次渲染一个像素,但是显示需要一次全部看到——在这帧中啥也没有,下一帧笑脸全部出现。双缓冲解决了这个问题。我会用类比来解释。

表演1,场景1

想象玩家正在观看我们的表演。在场景一结束而场景二开始时,我们需要改变舞台设置。如果让场务在场景结束后进去拖动东西,场景的连贯性就被打破了。我们可以减弱灯光(这是剧院实际上的做法),但是观众还是知道有什么在进行,而我们想在场景间毫无跳跃地转换。

通过消耗一些地皮,我们想到了一个聪明的解决方案:建两个舞台,观众两个都能看到。每个有它自己的一组灯光。我们称这些舞台为舞台A和舞台B。场景一在舞台A上。同时场务在处于黑暗之中的舞台B布置场景二。当场景一完成后,将切断场景A的灯光,打开场景B的灯光。观众看向新舞台,场景二立即开始。

同时,场务到了黑咕隆咚的舞台A,收拾了场景一然后布置场景。一旦场景二结束,将灯光转回舞台A。我们在整场表演中进行这样的活动,使用黑暗的舞台作为布置下一场景的工作区域。每一次场景转换,只是在两个舞台间切换灯光。观众获得了连续的体验,场景转换时没有感到任何中断。他们从来没有见到场务。

译注:使用单面镜以及其他的巧妙布置,你可以真正地在同一位置布置两个舞台。随着灯光切换,观众看到了不同的舞台,无需看向不同的地方。如何这样布置舞台就留给读者做练习吧。

重新回到图形

这就是双缓冲的工作原理,这就是你看到的几乎每个游戏背后的渲染系统。不只用一个帧缓冲,我们用两个。其中一个代表现在的帧,即类比中的舞台A,也就是说是显卡读取的那一个。GPU 可以想什么时候扫就什么时候扫。

译注:但不是所有的游戏主机都是这么做的。更老的简单主机中,内存有限,需要小心地同步绘制和渲染。那很需要技巧。

同时,我们的渲染代码正在写入另一个帧缓冲。即黑暗中的舞台B。当渲染代码完成了场景的绘制,它将通过交换缓存来切换灯光。这告诉图形硬件开始从第二块缓存中读取而不是第一块。只要在刷新之前交换,就不会有任何撕裂出现,整个场景都会一下子出现。

这时可以使用以前的帧缓冲了。我们可以将下一帧渲染在它上面了。超棒!


模式

定义缓冲类封装了缓冲:一段可改变的状态。这个缓冲被增量地修改,但我们想要外部的代码将修改视为单一的原子操作。为了实现这点,类保存了两个缓冲的实例:下一缓冲当前缓冲

当信息缓冲区中读取,它总是读取当前的缓冲区。当信息需要写缓存,它总是在下一缓冲区上操作。当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换,这样新缓冲区就是公共可见的了。旧的缓冲区成为下一个重用的缓冲区。


何时使用

这是那种你需要它时自然会想起的模式。如果你有一个系统需要双缓冲,它可能有可见的错误(撕裂之类的)或者行为不正确。但是,“当你需要时自然会想起“没提提供太多有效信息。更加特殊地,以下情况都满足时,使用这个模式就很恰当:

  • 我们需要维护一些被增量修改的状态。
  • 在修改到一半的时候,状态可能会被外部请求。
  • 我们想要防止请求状态的外部代码知道内部的工作方式。
  • 我们想要读取状态,而且不想等着修改完成。

记住

不像其他较大的架构模式,双缓冲模式位于底层。正因如此,它对代码库的其他部分影响较小——大多数游戏甚至不会感到有区别。尽管这里还是有几个警告。

交换本身需要时间

在状态被修改后,双缓冲需要一个swap步骤。这个操作必须是原子的——在交换时,没有代码可以接触到任何一个状态。通常,这就是修改一个指针那么快,但是如果交换消耗的时间长于修改状态的时间,那可是毫无助益。

我们得保存两个缓冲区

这个模式的另一个结果是增加了内存的使用。正如其名,这个模式需要你在内存中一直保留两个状态的拷贝。在内存受限的设备上,你可能要付出惨痛的代价。如果你不能接受使用两份内存,你需要使用别的方法保证状态在修改时不会被请求。


示例代码

我们知道了理论,现在看看它在实践中如何应用。我们编写了一个非常基础的图形系统,允许我们在缓冲帧上描绘像素。在大多数主机和电脑上,显卡驱动提供了这种底层的图形系统,但是在这里手动实现有助于理解发生了什么。首先是缓冲区本身:

class Framebuffer
{
public:
  Framebuffer() { clear(); }

  void clear()
  {
    for (int i = 0; i < WIDTH * HEIGHT; i++)
    {
      pixels_[i] = WHITE;
    }
  }

  void draw(int x, int y)
  {
    pixels_[(WIDTH * y) + x] = BLACK;
  }

  const char* getPixels()
  {
    return pixels_;
  }

private:
  static const int WIDTH = 160;
  static const int HEIGHT = 120;

  char pixels_[WIDTH * HEIGHT];
};

它有将整个缓存设置成默认的颜色的操作,也将其中一个像素设置为特定颜色的操作。它也有函数 getPixels(),读取保存像素数据的数组。虽然在这个例子中没有出现,但在实际中,显卡驱动会频繁调用这个函数,将缓存中的数据输送到屏幕上。

我们将整个缓冲区封装在 Scene 类中。渲染某物需要做的是在这块缓冲区上调用一系列 draw()

class Scene
{
public:
  void draw()
  {
    buffer_.clear();

    buffer_.draw(1, 1);
    buffer_.draw(4, 1);
    buffer_.draw(1, 3);
    buffer_.draw(2, 4);
    buffer_.draw(3, 4);
    buffer_.draw(4, 3);
  }

  Framebuffer& getBuffer() { return buffer_; }

private:
  Framebuffer buffer_;
};

特别地,它画出来这幅旷世杰作:

每一帧,游戏告诉场景去绘制。场景清空缓冲区然后一个接一个绘制一大堆像素。它也提供了 getBuffer() 获得缓冲区,这样显卡可以接触到它。

这看起来直截了当,但是如果就这样做,我们会遇到麻烦。显卡驱动可以在任何时间调用 getBuffer(),甚至在这个时候:

buffer_.draw(1, 1);
buffer_.draw(4, 1);
// <- 图形驱动从这里读取像素!
buffer_.draw(1, 3);
buffer_.draw(2, 4);
buffer_.draw(3, 4);
buffer_.draw(4, 3);

当上面的情况发生时,用户就会看到脸的眼睛,但是这一帧中嘴却消失了。下一帧,又可能在某些别的地方发生冲突。最终结果是糟糕的闪烁图形。我们会用双缓冲修复这点:

class Scene
{
public:
  Scene()
  : current_(&buffers_[0]),
    next_(&buffers_[1])
  {}

  void draw()
  {
    next_->clear();

    next_->draw(1, 1);
    // ...
    next_->draw(4, 3);

    swap();
  }

  Framebuffer& getBuffer() { return *current_; }

private:
  void swap()
  {
    // 只需交换指针
    Framebuffer* temp = current_;
    current_ = next_;
    next_ = temp;
  }

  Framebuffer  buffers_[2];
  Framebuffer* current_;
  Framebuffer* next_;
};

现在 Scene 有存储在 buffers_ 数组中的两个缓冲区。我们并不从数组中直接引用它们。而是通过两个成员,next_current_,指向这个数组。当绘制时,我们绘制在 next_ 指向的缓冲区上。当显卡驱动需要获得像素信息时,它总是通过 current_ 获取另一个缓冲区。

通过这种方式,显卡驱动永远看不到我们正在施工的缓冲区。解决方案的的最后一部分就是在场景完成绘制一帧的时候调用 swap()。它通过交换 next_current_ 的引用完成这一点。下一次显卡驱动调用 getBuffer(),它会获得我们刚刚完成渲染的新缓冲区,然后将刚刚描绘好的缓冲区放在屏幕上。没有撕裂,也没有不美观的问题。

不仅是图形

双缓冲解决的核心问题是状态有可能在被修改的同时被请求。这通常有两种原因。图形的例子覆盖了第一种原因——另一线程的代码或者另一个中断的代码直接访问了状态。

但是,还有一个同样常见的原因:负责修改的代码试图访问同样正在修改状态。这可能发生在很多地方,特别是实体的物理部分和 AI 部分,实体在相互交互。双缓冲在那里也十分有用。

人工不智能

假设我们正在构建一个关于趣味喜剧的游戏的行为系统。这个游戏包括一堆跑来跑去寻欢作乐的角色。这里是我们的基础角色:

class Actor
{
public:
  Actor() : slapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;

  void reset()      { slapped_ = false; }
  void slap()       { slapped_ = true; }
  bool wasSlapped() { return slapped_; }

private:
  bool slapped_;
};

每一帧,游戏要在角色身上调用 update(),让角色做些事情。特别地,从玩家的角度,所有的角色都应该看上去同时更新

这是更新方法模式的例子。

角色也可以相互交互,这里的“交互“,我指“可以互相扇对方巴掌“。当更新时,角色可以在另一个角色身上调用 slap() 来扇它一巴掌,然后调用 wasSlapped() 看看自己是不是被扇了。

角色需要一个可以交互的舞台,让我们来布置一下:

class Stage
{
public:
  void add(Actor* actor, int index)
  {
    actors_[index] = actor;
  }

  void update()
  {
    for (int i = 0; i < NUM_ACTORS; i++)
    {
      actors_[i]->update();
      actors_[i]->reset();
    }
  }

private:
  static const int NUM_ACTORS = 3;

  Actor* actors_[NUM_ACTORS];
};

Stage 允许我们向其中增加角色,然后使用简单的 update() 调用来更新每个角色。在用户看来,角色是同时移动的,但是实际上,它们是依次更新的。

这里需要注意的另一点是,每个角色的“被扇“状态在更新后就立刻被清除。这样才能保证一个角色对一巴掌只反应一次。

作为一切的开始,让我们定义一个具体的角色子类。这里的喜剧演员很简单。他只面向一个角色。当他被扇时——无论是谁扇的他——他的反应是扇他面前的人一巴掌。

class Comedian : public Actor
{
public:
  void face(Actor* actor) { facing_ = actor; }

  virtual void update()
  {
    if (wasSlapped()) facing_->slap();
  }

private:
  Actor* facing_;
};

现在我们把一些喜剧演员丢到舞台上看看发生了什么。我们设置三个演员,第一个面朝第二个,第二个面朝第三个,第三个面对第一个,形成一个环:

Stage stage;

Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();

harry->face(baldy);
baldy->face(chump);
chump->face(harry);

stage.add(harry, 0);
stage.add(baldy, 1);
stage.add(chump, 2);

最终舞台布置如下图。箭头代表角色的朝向,然后数字代表角色在舞台数组中的索引。

我们扇哈利一巴掌,为表演拉开序幕,看看之后会发生什么:

harry->slap();
stage.update();

记住 Stage 中的 update() 函数轮流更新每个角色,因此如果检视整个代码,我们会发现事件这样发生:

Stage updates actor 0 (Harry)
  Harry was slapped, so he slaps Baldy
Stage updates actor 1 (Baldy)
  Baldy was slapped, so he slaps Chump
Stage updates actor 2 (Chump)
  Chump was slapped, so he slaps Harry
Stage update ends

在单独的一帧中,初始给哈利的一巴掌传给了所有的喜剧演员。现在,让事物复杂起来,让我们重新排列舞台数组中角色的排序,但是继续保持面向对方的方式。

我们不动舞台的其余部分,只是将添加角色到舞台的代码块改为如下:

stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);

让我们看看再次运行时会发生什么:

Stage updates actor 0 (Chump)
  Chump was not slapped, so he does nothing
Stage updates actor 1 (Baldy)
  Baldy was not slapped, so he does nothing
Stage updates actor 2 (Harry)
  Harry was slapped, so he slaps Baldy
Stage update ends

哦不。完全不一样了。问题很明显。更新角色时,我们修改了他们的“被扇“状态,这也是我们在更新时读取的状态。因此,在更新中早先的状态修改会影响之后同一状态的修改的步骤。

译注:如果你继续更新舞台,你会看到巴掌在角色间逐渐传递,每帧传递一个。在第一帧 Harry 扇了 Baldy。下一帧,Baldy 扇了 Chump,如此类推。

而最终的结果是,一个角色对被扇作出反应可能是在被扇的同一帧或者下一帧,这完全取决于两个角色在舞台上是如何排序的。这没能满足我让角色同时反应的需求——它们在同一帧中更新的顺序不该对结果有影响。

缓存巴掌

幸运的是,双缓冲模式可以帮忙。这次,不是保存两大块“缓冲“,我们缓冲更小粒度的事物:每个角色的“被扇“状态。

class Actor
{
public:
  Actor() : currentSlapped_(false) {}

  virtual ~Actor() {}
  virtual void update() = 0;

  void swap()
  {
    // 交换缓冲区
    currentSlapped_ = nextSlapped_;

    // 清空新的"下一个"缓冲区。
    nextSlapped_ = false;
  }

  void slap()       { nextSlapped_ = true; }
  bool wasSlapped() { return currentSlapped_; }

private:
  bool currentSlapped_;
  bool nextSlapped_;
};

不再使用一个 slapped_ 状态,每个演员现在使用两个。就像我们之前图形的例子一样,当前状态为读准备,下一状态为写准备。

reset() 函数被替换为 swap()。现在,就在清除交换状态前,它将下一状态拷贝到当前状态上,使其成为新的当前状态,这还需要在 Stage 中进行小小的改变:

void Stage::update()
{
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->update();
  }

  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->swap();
  }
}

update() 函数现在更新所有的角色,然后交换它们的状态。最终结果是,角色在实际被扇之后的那帧才能看到巴掌。这样一来,角色无论在舞台数组中如何排列,都会保持相同的行为。无论外部的代码如何调用,所有的角色在一帧内同时更新。


设计决策

双缓冲很直观,我们上面看到的例子也覆盖了大多数你需要的场景。使用这个模式之前,还需要做两个主要的设计决策。

缓冲区是如何被交换的?

交换操作是整个过程的最重要的一步,因为在其发生时,我们必须锁住两个缓冲区上的读取和修改。为了让性能最优,我们需要它进行得越快越好。

  • 交换缓冲区的指针或者引用: 这是我们图形例子中的做法,这也是大多数双缓冲图形通用的解决方法。

    • 速度快。 不管缓冲区有多大,交换都只需赋值一对指针。很难在速度和简易性上超越它。
    • 外部代码不能存储对缓存的永久指针。 这是主要限制。由于我们没有真正地移动数据,本质上做的是周期性地通知代码库的其他部分到别处去寻找缓存,就像前面的舞台类比一样。这就意味着代码库的其他部分不能存储指向缓冲区中数据的指针——它一段时间后可能就指向了错误的部分。这会严重误导那些期待缓冲帧永远在内存中的固定地址的显卡驱动。在这种情况下,我们不能这么做。
    • 缓冲区中的数据是两帧之前的数据,而不是上一帧的数据。 接下来的那帧绘制在帧缓冲区上,而不是在它们之间拷贝数据。你会注意到,当我们绘制第三帧时,缓冲区上的数据是第一帧的,而不是第二帧的。大多数情况下,这不是什么问题——我们通常在绘制之前清空整个帧。但如果想沿用某些缓存中已有的数据,就需要考虑数据其实比期望的更旧。旧帧中缓存数据的经典用法是模拟动态模糊。
  • 在缓冲区之间拷贝数据: 如果我们不能重定向到其他缓存,唯一的选项就是将下帧的数据实实在在的拷贝到现在这帧上。这是我们的扇巴掌喜剧的工作方法。这种情况下,使用这种方法是因为拷贝状态——一个简单的布尔标识——不比修改指向缓存的指针开销大。

    • 下一帧的数据和之前的数据相差一帧。 拷贝数据与在两块缓冲区间跳来跳去正相反。如果我们需要前一帧的数据,这样我们可以处理更新的数据。
    • 交换也许更花时间。 这个当然是最大的缺点。交换操作现在意味着在内存中拷贝整个缓冲区。如果缓冲区很大,比如一整个缓冲帧,这需要花费可观的时间。由于交换时没有东西可以读取或者写入任何一个缓冲区,这是一个巨大的限制。

缓冲的粒度如何?

这里的另一个问题是缓冲区本身是如何组织的——是单个数据块还是散布在对象集合中?图形例子是前一种,而角色例子是后一种。

大多数情况下,你缓存的方式自然而然会引导你找到答案,但是这里也有些灵活度。

  • 如果缓存是一整块:

    • 交换操作更简单。 由于只有一对缓存,一个简单的交换就完成了。如果可以改变指针来交换,那么不必在意缓冲区大小,只需几部操作就可以交换整个缓冲区。
  • 如果很多对象都持有一块数据:

    • 交换操作更慢。 为了交换,需要遍历整个对象集合,通知每个对象交换。 在喜剧的例子中,这没问题,因为反正需要清除被扇状态——每块缓存的数据每帧都需要接触。如果不需要接触较旧的帧,可以用通过在多个对象间分散状态来优化,获得使用整块缓存一样的性能。

      思路是将“当前“和“下一“指针概念,将它们改为对象相关的偏移量。就像这样:

      class Actor
      {
      public:
        static void init() { current_ = 0; }
        static void swap() { current_ = next(); }
      
        void slap()        { slapped_[next()] = true; }
        bool wasSlapped()  { return slapped_[current_]; }
      
      private:
        static int current_;
        static int next()  { return 1 - current_; }
      
        bool slapped_[2];
      };
      

      角色使用 current_ 在状态数组中查询,获得当前的被扇状态,下一状态总是数组中的另一索引,这样可以用 next() 来计算。交换状态只需改动 current_ 索引。聪明之处在于 swap() 现在是静态函数,它只需被调用一次,每个角色的状态都会被交换。


参见

  • 你可以在几乎每个图形 API 中找到双缓冲模式。举个例子,OpenGL 有 swapBuffers(),Direct3D 有 “swap chains”,Microsoft 的 XNA 框架有 endDraw() 方法。

实践 Demo

已由引擎/浏览器底层实现,无需手写 Demo


笔记与思考

游戏循环模式 (Game Loop)

将游戏的进行和玩家的输入解耦,和处理器速度解耦。

— 意图

动机

如果本书中有一个模式不可或缺,那非这个模式莫属了。游戏循环是“游戏编程模式“的精髓。几乎每个游戏都有,两两不同,而在非游戏的程序几乎没有使用。

为了看看它多有用,让我们快速缅怀一遍往事。在每个编写计算机程序的人都留着胡子的时代,程序像洗碗机一样工作。你输入一堆代码,按个按钮,等待,然后获得结果,完成。程序全都是批处理模式的——一旦工作完成,程序就停止了。

译注:Ada Lovelace 和 Rear Admiral Grace Hopper 是女程序员,并没有胡子。

你在今日仍然能看到这些程序,虽然感谢上天,我们不必在打孔纸上面编写它们了。终端脚本,命令行程序,甚至将 Markdown 翻译成这本书的 Python 脚本都是批处理程序。

采访 CPU

最终,程序员意识到将批处理代码留在计算办公室,等几个小时后拿到结果才能开始找程序漏洞的方式实在低效。他们想要立即的反馈。交互式程序诞生了。第一批交互式程序中就有游戏:

YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK
BUILDING . AROUND YOU IS A FOREST. A SMALL
STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.

> GO IN
YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.

这是 Colossal Cave Adventure,史上首个冒险游戏。

你可以和这个程序进行实时交互。它等待你的输入,然后进行响应。你再输入,这样一唱一和,就像相声一样。当轮到你时,它停在那里啥也不做。像这样:

while (true)
{
  char* command = readCommand();
  handleCommand(command);
}

译注:这程序会永久循环,所以没法退出游戏。真实的游戏会做些 while (!done) 进行检查,然后通过设置 done 为真来退出游戏。我省去了那些内容,保持简明。

事件循环

如果你剥开现代的图形 UI 的外皮,会惊讶地发现它们与老旧的冒险游戏差不多。文本处理器通常呆在那里什么也不做,直到你按了个键或者点了什么东西:

while (true)
{
  Event* event = waitForEvent();
  dispatchEvent(event);
}

这与冒险游戏主要的不同是,程序不是等待文本指令,而是等待用户输入事件——鼠标点击、按键按下之类的。其他部分还是和以前的老式文本冒险游戏一样,程序阻塞等待用户的输入,这是个问题。

不像其他大多数软件,游戏即使在没有玩家输入时也继续运行。如果你站在那里看着屏幕,游戏不会冻结。动画继续动着。视觉效果继续闪烁。如果运气不好的话,怪物会继续吞噬英雄。

译注:事件循环有“空转“事件,这样你可以无需用户输入间歇地做些事情。这对于闪烁的光标或者进度条已经足够了,但对于游戏就太原始了。

这是真实游戏循环的第一个关键部分:它处理用户输入,但是不等待它。循环总是继续旋转:

while (true)
{
  processInput();
  update();
  render();
}

我们之后会改善它,但是基本的部分都在这里了。processInput() 处理上次调用到现在的任何输入。然后 update() 让游戏模拟一步。运行 AI 和物理(通常是这种顺序)。最终,render() 绘制游戏,这样玩家可以看到发生了什么。

译注:就像你可以从名字中猜到的,update() 是使用更新方法模式的好地方。

时间之外的世界

如果这个循环没有因为输入而阻塞,这就带来了明显的问题,要运转多快呢?每次进行游戏循环都会推动一定的游戏状态的发展。在游戏世界的居民看来,他们手上的表就会滴答一下。

译注:运行游戏循环一次的常用术语就是“滴答“(tick)和“帧“(frame)。

同时,玩家的真实手表也在滴答着。如果我们用实际时间来测算游戏循环运行的速度,就得到了游戏的“帧率“(FPS)。如果游戏循环的更快,FPS 就更高,游戏运行得更流畅、更快。如果循环得过慢,游戏看上去就像是慢动作电影。

我们现在写的这个循环是能转多快转多快,两个因素决定了帧率。一个是每帧要做多少工作。复杂的物理,众多游戏对象,图形细节都让 CPU 和 GPU 繁忙,这决定了需要多久能完成一帧。

另一个是底层平台的速度。更快的芯片可以在同样的时间里执行更多的代码。多核,GPU 组,独立声卡,以及系统的调度都影响了在一次滴答中能够做多少东西。

每秒的帧数

在早期的视频游戏中,第二个因素是固定的。如果你为 NES 或者 Apple IIe 写游戏,你明确知道游戏运行在什么 CPU 上。你可以(也必须)为它特制代码。你只需担忧第一个因素:每次滴答要做多少工作。

早期的游戏被仔细地编码,一帧只做一定的工作,开发者可以让游戏以想要的速率运行。但是如果你想要在快些或者慢些的机器上运行同一游戏,游戏本身就会加速或减速。

这就是为什么老式计算机通常有 “turbo” 按钮。新的计算机运行得太快了,无法玩老游戏,因为游戏也会运行得过快。关闭 turbo 按钮,会减慢计算机的运行速度,就可以运行老游戏了。

现在,很少有开发者可以奢侈地知道游戏运行的硬件条件。游戏必须自动适应多种设备。

这就是游戏循环的另一个关键任务:不管潜在的硬件条件,以固定速度运行游戏。


模式

一个游戏循环在游玩中不断运行。每一次循环,它无阻塞地处理玩家输入更新游戏状态渲染游戏。它追踪时间的消耗并控制游戏的速度


何时使用

使用错误的模式比不使用模式更糟,所以这节通常告诫你不要过于热衷设计模式。设计模式的目标不是往代码库里尽可能的塞东西。

但是这个模式有所不同。我可以很自信的说你使用这个模式。如果你使用游戏引擎,你不需要自己编写,但是它还在那里。

对于我而言,这是“引擎“与“库“的不同之处。使用库时,你拥有游戏循环,调用库代码。使用引擎时,引擎拥有游戏循环,调用你的代码。

你可能认为在做回合制游戏时不需要它。但是哪怕是那里,就算游戏状态到玩家回合才改变,视觉听觉状态仍会改变。哪怕游戏在“等待“你进行你的回合,动画和音乐也会继续运行。


记住

我们这里谈到的循环是游戏代码中最重要的部分。有人说程序会花费 90% 的时间在 10% 的代码上。游戏循环代码肯定在这 10% 中。你必须小心谨慎,时时注意效率。

译注:“真正的“工程师,比如机械或电子工程师,不把我们当回事,大概就是因为我们像这样使用统计学。

你也许需要与平台的事件循环相协调

如果你在操作系统的顶层或者有图形 UI 和内建事件循环的平台上构建游戏,那你就有了两个应用循环在同时运作。它们需要很好地协调。

有时候,你可以进行控制,只运行你的游戏循环。举个例子,如果舍弃了 Windows 的珍贵 API,main() 可以只用游戏循环。其中你可以调用 PeekMessage() 来处理和分发系统的事件。不像 GetMessage()PeekMessage() 不会阻塞等待用户输入,因此你的游戏循环会保持运作。

其他的平台不会让你这么轻松地摆脱事件循环。如果你使用网页浏览器作为平台,事件循环已被内建在浏览器的执行模型深处。这样,你得用事件循环作为游戏循环。你会调用 requestAnimationFrame() 之类的函数,它会回调你的代码,保持游戏继续运行。


示例代码

在如此长的介绍之后,游戏循环的代码实际上很直观。我们会浏览一堆变种,比较它们的好处和坏处。

游戏循环驱动了 AI,渲染和其他游戏系统,但这些不是模式的要点,所以我们会调用虚构的方法。在实现了 render()update() 之后,剩下的作为给读者的练习(挑战!)。

跑,能跑多快跑多快

我们已经见过了可能是最简单的游戏循环:

while (true)
{
  processInput();
  update();
  render();
}

它的问题是你不能控制游戏运行得有多快。在快速机器上,循环会运行得太快,玩家看不清发生了什么。在慢速机器上,游戏慢的跟在爬一样。如果游戏的一部分有大量内容或者做了很多 AI 或物理运算,游戏就会慢一些。

休息一下

我们看看增加一个简单的小修正如何。假设你想要你的游戏以 60FPS 运行。这样每帧大约 16 毫秒。只要你用少于这个的时长进行游戏所有的处理和渲染,就可以以稳定的帧率运行。你需要做的就是处理这一帧然后等待,直到处理下一帧的时候,就像这样:

代码看上去像这样:

1000 毫秒 / 帧率 = 毫秒每帧

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

  sleep(start + MS_PER_FRAME - getCurrentTime());
}

如果它很快地处理完一帧,这里的 sleep() 保证了游戏不会运行太。如果你的游戏运行太,这无济于事。如果需要超过 16ms 来更新并渲染一帧,休眠的时间就变成了负的。如果计算机能回退时间,很多事情就很容易了,但是它不能。

相反,游戏变慢了。可以通过每帧少做些工作来解决这个问题——减少物理效果和绚丽光影,或者把 AI 变笨。但是这影响了那些有快速机器的玩家的游玩体验。

一小步,一大步

让我们尝试一些更加复杂的东西。我们拥有的问题基本上是:

  1. 每次更新将游戏时间推动一个固定量。
  2. 这消耗一定量的真实时间来处理它。

如果第二步消耗的时间超过第一步,游戏就变慢了。如果它需要超过 16ms 来推动游戏时间 16ms,那它永远也跟不上。但是如果一步中推动游戏时间超过16ms,那我们可以减少更新频率,就可以跟得上了。

接着的思路是基于上帧到现在有多少真实时间流逝来选择前进的时间。这一帧花费的时间越长,游戏的间隔越大。它总能跟上真实时间,因为它走的步子越来越大。有人称之为变化的或者流动的时间间隔。它看上去像是:

double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}

每一帧,我们计算上次游戏更新到现在有多少真实时间过去了(即变量 elapsed)。当我们更新游戏状态时将其传入。然后游戏引擎让游戏世界推进一定的时间量。

假设有一颗子弹跨过屏幕。使用固定的时间间隔,在每一帧中,你根据它的速度移动它。使用变化的时间间隔,你根据过去的时间拉伸速度。随着时间间隔增加,子弹在每帧间移动得更远。无论是二十个快的小间隔还是四个慢的大间隔,子弹在真实时间里移动同样多的距离。这看上去成功了:

  • 游戏在不同的硬件上以固定的速度运行。
  • 使用高端机器的玩家获得了更流畅的游戏体验。

但悲剧的是,这里有一个严重的问题:游戏不再是确定的了,也不再稳定。这是我们给自己挖的一个坑:

译注:“确定的“代表每次你运行程序,如果给了它同样的输入,就获得同样的输出。可以想得到,在确定的程序中追踪漏洞更容易——一旦找到造成漏洞的输入,每次你都能重现之。计算机本身是确定的;它们机械地执行程序。在纷乱的真实世界搀合进来,非确定性就出现了。例如,网络,系统时钟,线程调度都依赖于超出程序控制的外部世界。

假设我们有个双人联网游戏,Fred 的游戏机是台性能猛兽,而 George 正在使用他祖母的老爷机。前面提到的子弹在他们的屏幕上飞行。在 Fred 的机器上,游戏跑得超级快,每个时间间隔都很小。比如,我们塞了 50 帧在子弹穿过屏幕的那一秒。可怜的 George 的机器只能塞进大约 5 帧。

这就意味着在 Fred 的机器上,物理引擎每秒更新 50 次位置,但是 George 的只更新 5 次。大多数游戏使用浮点数,它们有舍入误差。每次你将两个浮点数加在一起,获得的结果就会有点偏差。Fred 的机器做了 10 倍的操作,所以他的误差要比 George 的更大。同样的子弹最终在他们的机器上到了不同的位置

这是使用变化时间可引起的问题之一,还有更多问题呢。为了实时运行,游戏物理引擎做的是实际机制法则的近似。为了避免飞天遁地,物理引擎添加了阻尼。这个阻尼运算被小心地安排成以固定的时间间隔运行。改变了它,物理就不再稳定。

译注:“飞天遁地“在这里使用的是它的字面意思。当物理引擎卡住,对象获得了完全错误的速度,就会飞到天上或者掉入地底。

这种不稳定性太糟了,这个例子在这里的唯一原因是作为警示寓言,引领我们到更好的东西……

追逐时间

游戏中渲染通常不会被动态时间间隔影响到。由于渲染引擎表现的是时间上的一瞬间,它不会计算上次到现在过了多久。它只是将当前事物渲染在所在的地方。

译注:这或多或少是成立的。像动态模糊的东西会被时间间隔影响,但如果有一点延迟,玩家通常也不会注意到。

我们可以利用这点。以固定的时间间隔更新游戏,因为这让所有事情变得简单,物理和 AI 也更加稳定。但是我们允许灵活调整渲染的时刻,释放一些处理器时间。

它像这样运作:自上一次游戏循环过去了一定量的真实时间。需要为游戏的“当前时间“模拟推进相同长度的时间,以追上玩家的时间。我们使用一系列固定时间步长。可以这样可视化:

代码大致如下:

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;

  processInput();

  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
  }

  render();
}

这里有几个部分。在每帧的开始,根据过去了多少真实的时间,更新 lag。这个变量表明了游戏世界时钟比真实世界落后了多少,然后我们使用一个固定时间步长的内部循环进行追赶。一旦我们追上真实时间,我们就渲染然后开始新一轮循环。你可以将其画成这样:

注意这里的时间步长不是视觉上的帧率了。MS_PER_UPDATE 只是我们更新游戏的间隔。这个间隔越短,就需要越多的处理次数来追上真实时间。它越长,游戏抖动得越厉害。理想上,你想要它足够短,通常快过 60FPS,这样游戏在高速机器上会有高效的表现。

但是小心不要把它整得短了。你需要保证即使在最慢的机器上,这个时间步长也超过处理一次 update() 的时间。否则,你的游戏就跟不上现实时间了。

译注:我不会详谈这个,但你可以通过限定内层循环的最大次数来保证这一点。游戏会变慢,但是比完全卡死要好。

幸运的是,我们给自己了一些喘息的空间。技巧在于我们将渲染拉出了更新循环。这释放了一大块 CPU 时间。最终结果是游戏以固定时间步长模拟,该时间步长与硬件不相关。只是使用低端硬件的玩家看到的内容会有抖动。

卡在中间

我们还剩一个问题,就是剩下的延迟。以固定的时间步长更新游戏,在任意时刻渲染。这就意味着从玩家的角度看,游戏经常在两次更新之间时显示。

就像你看到的那样,我们以紧凑固定的时间步长进行更新。同时,我们在任何可能的时候渲染。它比更新发生得要少,而且也不稳定。两者都没问题。糟糕的是,我们不总能在正确的时间点渲染。看看第三次渲染时间。它发生在两次更新之间:

想象一颗子弹飞过屏幕。第一次更新时,它在左边。第二次更新将它移到了右边。这个游戏在两次更新之间的时间点渲染,所以玩家期望看到子弹在屏幕的中间。而现在的实现中,它还在左边。这意味着看上去移动发生了卡顿。

方便的是,我们实际知道渲染时距离两次更新的时间:它被存储在 lag 中。我们在 lag 比更新时间间隔小时,而不是 lag时,跳出循环进行渲染。lag 的剩余量?那就是到下一帧的时间。

当我们要渲染时,我们将它传入:

render(lag / MS_PER_UPDATE);

我们在这里除以 MS_PER_UPDATE归一化值。不管更新的时间步长是多少,传给 render() 的值总在 0(恰巧在前一帧)到 1.0(恰巧在下一帧)之间。这样,渲染引擎不必担心帧率。它只需处理 0 到 1 的值。

渲染器知道每个游戏对象以及它当前的速度。假设子弹在屏幕左边 20 像素的地方,正在以 400 像素每帧的速度向右移动。如果在两帧正中渲染,我们会给 render() 传 0.5。它绘制了半帧之前的图形,在 220 像素,啊哈,平滑的移动。

当然,也许这种推断是错误的。在我们计算下一帧时,也许会发现子弹碰撞到另一障碍,或者减速,又或者别的什么。我们只是在上一帧位置和我们认为的下一帧位置之间插值。但只有在完成物理和 AI 更新后,我们才能知道真正的位置。

所以推断有猜测的成分,有时候结果是错误的。但是,幸运地,这种修正通常不可感知。最起码,比你不使用推断导致的卡顿更不明显。


设计决策

虽然这章我讲了很多,但是有更多的东西我没讲。一旦你考虑显示刷新频率的同步,多线程,多 GPU,真正的游戏循环会变得更加复杂。即使在高层,这里还有一些问题需要你回答:

拥有游戏循环的是你,还是平台?

这个选择通常是已经由平台决定的。如果你在做浏览器中的游戏,很可能你不能编写自己的经典游戏循环。浏览器本身的事件驱动机制阻碍了这一点。类似地,如果你使用现存的游戏引擎,你很可能依赖于它的游戏循环而不是自己写一个。

  • 使用平台的事件循环:

    • 简单。 你不必担心编写和优化自己的游戏核心循环。
    • 平台友好。 你不必明确地给平台一段时间让它处理它自己的事件,不必缓存事件,不必管理任何平台输入模型和你的不匹配之处。
    • 你失去了对时间的控制。 平台会在它方便时调用代码。如果这不如你想要的那样平滑或者频繁,太糟了。更糟的是,大多数应用的事件循环并未为游戏设计,通常又慢又卡顿。
  • 使用游戏引擎的循环:

    • 不必自己编写。 编写游戏循环非常需要技巧。由于是每帧都要执行的核心代码,小小的漏洞或者性能问题就对游戏有巨大的影响。稳固的游戏循环是使用现有引擎的原因之一。
    • 不必自己编写。 当然,硬币的另一面是,如果引擎无法满足你真正的需求,你也没法获得控制权。
  • 自己写:

    • 完全的控制。 你可以做任何想做的事情。你可以为游戏的需求订制开发。
    • 你需要与平台交互。 应用框架和操作系统通常需要时间片去处理自己的事件和其他工作。如果你拥有应用的核心循环,平台就没有这些时间片了。你得显式定期检查,保证框架没有挂起或者混乱。

如何管理能量消耗?

在五年前这还不是问题。游戏运行在插到插座上的机器上或者专用的手持设备上。但是随着智能手机,笔记本以及移动游戏的发展,现在需要关注这个问题了。画面绚丽,但会耗干三十分钟前充的电,并将手机变成空间加热器的游戏,可不能让人开心。

现在,你需要考虑的不仅仅是让游戏看上去很棒,同时也要尽可能少地使用 CPU。你需要设置一个性能的上限:完成一帧之内所需的工作后,让 CPU 休眠。

  • 尽可能快地运行: 这是 PC 游戏的常态(即使越来越多的人在笔记本上运行游戏)。游戏循环永远不会显式告诉系统休眠。相反,空闲的循环被划在提升 FPS 或者图像显示效果上了。 这会给你最好的游戏体验。但是,也会尽可能多地使用电量。如果玩家在笔记本电脑上游玩,他们就得到了一个很好的加热器。

  • 固定帧率: 移动游戏更加注意游戏的体验质量,而不是最大化图像画质。很多这种游戏都会设置最大帧率(通常是 30 或 60FPS)。如果游戏循环在分配的时间片消耗完之前完成,剩余的时间它会休眠。 这给了玩家“足够好的“游戏体验,也让电池轻松了一点。

你如何控制游戏速度?

游戏循环有两个关键部分:不阻塞用户输入和自适应的帧时间步长。输入部分很直观。关键在于你如何处理时间。这里有数不尽的游戏可运行的平台,每个游戏都需要在其中一些平台上运行。如何适应平台的变化就是关键。

译注:创作游戏看来是人类的天性,因为每当我们建构可以计算的机器,首先做的就是在上面编游戏。PDP-1 是一个仅有 4096 字内存的 2kHz 机器,但是 Steve Russell 和他的朋友还是在上面创建了 Spacewar!。

  • 固定时间步长,没有同步: 见我们第一个样例中的代码。你只需尽可能快地运行游戏。

    • 简单。 这是主要的(好吧,唯一的)好处。
    • 游戏速度直接受到硬件和游戏复杂度影响。 主要的缺点是,如果有所变化,会直接影响游戏速度。游戏速度与游戏循环紧密相关。
  • 固定时间步长,有同步: 对复杂度控制的下一步是使用固定的时间间隔,但在循环的末尾增加同步点,保证游戏不会运行得过快。

    • 还是很简单。 这比过于简单以至于不可行的例子只多了一行代码。在多数游戏循环中,你可能需要做一些同步。你可能需要双缓冲图形并将缓冲块与更新显示的频率同步。
    • 电量友好。 这对移动游戏至关重要。你不想消耗不必要的电量。通过简单地休眠几个毫秒而不是试图每帧塞入更多的处理,你就节约了电量。
    • 游戏不会运行得太快。 这解决了固定循环速度的一半问题。
    • 游戏可能运行的太慢。 如果花了太多时间更新和渲染一帧,播放也会减缓。因为这种方案没有分离更新和渲染,它比更高级的方案更容易遇到这点。没法扔掉渲染帧来追上真实时间,游戏本身会变慢。
  • 动态时间步长: 我把这个方案放在这里作为问题的解决办法之一,附加警告:大多数我认识的游戏开发者反对它。不过记住为什么反对它是很有价值的。

    • 能适应并调整,避免运行得太快或者太慢。 如果游戏不能追上真实时间,它用越来越长的时间步长更新,直到追上。
    • 让游戏不确定而且不稳定。 这是真正的问题,当然。在物理和网络部分使用动态时间步长会遇见更多的困难。
  • 固定更新时间步长,动态渲染: 在示例代码中提到的最后一个选项是最复杂的,但是也是最有适应性的。它以固定时间步长更新,但是如果需要赶上玩家的时间,可以扔掉一些渲染帧。

    • 能适应并调整,避免运行得太快或者太慢。 只要能实时更新,游戏状态就不会落后于真实时间。如果玩家用高端的机器,它会回以更平滑的游戏体验。
    • 更复杂。 主要负面问题是需要在实现中写更多东西。你需要将更新的时间步长调整得尽可能小来适应高端机,同时不至于在低端机上太慢。

参见

  • 关于游戏循环的经典文章是 Glenn Fiedler 的 “Fix Your Timestep”。如果没有这篇文章,这章就不会是这个样子。
  • Witters 关于 game loops 的文章也值得阅读。
  • Unity 框架有一个复杂的游戏循环,细节在这里有详尽的解释。

实践 Demo

已由引擎/浏览器底层实现,无需手写 Demo


笔记与思考

更新方法模式 (Update Method)

通过每次处理一帧的行为模拟一系列独立对象。

— 意图

动机

玩家操作强大的女武神完成考验:从死亡巫王的栖骨之处偷走华丽的珠宝。她尝试接近巫王华丽的地宫门口,然后遇到了……啥也没遇到。没有诅咒雕像向她发射闪电,没有不死战士巡逻入口。她直捣黄龙,拿走了珠宝。游戏结束。你赢了。

好吧,这可不行。

地宫需要守卫——一些英雄可以杀死的敌人。首先,我们需要一个骷髅战士在门口前后移动巡逻。如果无视任何关于游戏编程的知识,让骷髅蹒跚着来回移动的最简单的代码大概是这样的:

译注:如果巫王想表现得更加智慧,它应创造一些仍有脑子的东西。

while (true)
{
  // 向右巡逻
  for (double x = 0; x < 100; x++)
  {
    skeleton.setX(x);
  }

  // 向左巡逻
  for (double x = 100; x > 0; x--)
  {
    skeleton.setX(x);
  }
}

这里的问题,当然,是骷髅来回打转,可玩家永远看不到。程序锁死在一个无限循环,那可不是有趣的游戏体验。我们事实上想要的是骷髅每帧移动一步

我们得移除这些循环,依赖外层游戏循环来迭代。这保证了在卫士来回巡逻时,游戏能响应玩家的输入并进行渲染。如下:

当然,游戏循环是本书的另一个章节。

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// 游戏主循环
while (true)
{
  if (patrollingLeft)
  {
    x--;
    if (x == 0) patrollingLeft = false;
  }
  else
  {
    x++;
    if (x == 100) patrollingLeft = true;
  }

  skeleton.setX(x);

  // 处理用户输入并渲染游戏……
}

在这里前后两个版本展示了代码是如何变得复杂的。左右巡逻需要两个简单的 for 循环。通过指定哪个循环在执行,我们追踪了骷髅在移向哪个方向。现在我们每帧跳出到外层的游戏循环,然后再跳回继续我们之前所做的,我们使用 patrollingLeft 显式地追踪了方向。

但或多或少这能行,所以我们继续。一堆无脑的骨头不会对你的女武神提出太多挑战,我们下一个添加的是魔法雕像。它们一直会向她发射闪电球,这样可让她保持移动。

继续我们的“用最简单的方式编码“的风格,我们得到了:

// 骷髅的变量……
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;

// 游戏主循环:
while (true)
{
  // 骷髅的代码……

  if (++leftStatueFrames == 90)
  {
    leftStatueFrames = 0;
    leftStatue.shootLightning();
  }

  if (++rightStatueFrames == 80)
  {
    rightStatueFrames = 0;
    rightStatue.shootLightning();
  }

  // 处理用户输入,渲染游戏
}

你会发现这代码渐渐滑向失控。变量数目不断增长,代码都在游戏循环中,每段代码处理一个特殊的游戏实体。为了同时访问并运行它们,我们将它们的代码混杂在了一起。

译注:一旦能用“混杂“一词描述你的架构,你就有麻烦了。

你也许已经猜到了修复这个所用的简单模式了:每个游戏实体应该封装它自己的行为。这保持了游戏循环的整洁,便于添加和移除实体。

为了做到这点需要抽象层,我们通过定义抽象的 update() 方法来完成。游戏循环管理对象的集合,但是不知道对象的具体类型。它只知道这些对象可以被更新。这样,每个对象的行为与游戏循环分离,与其他对象分离。

每一帧,游戏循环遍历集合,在每个对象上调用 update()。这给了我们在每帧上更新一次行为的机会。在所有对象上每帧调用它,对象就能同时行动。

译注:死抠细节的人会在这点上揪着我不放,是的,它们没有真的同步。当一个对象更新时,其他的都不在更新中。我们等会儿再说这点。

游戏循环维护动态的对象集合,所以从关卡添加和移除对象是很容易的——只需要将它们从集合中添加和移除。不必再用硬编码,我们甚至可以用数据文件构成这个关卡,那正是我们的关卡设计者需要的。


模式

游戏世界管理对象集合。每个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象


何时使用

如果游戏循环模式是切片面包,那么更新方法模式就是它的奶油。很多玩家交互的游戏实体都以这样或那样的方式实现了这个模式。如果游戏有太空陆战队,火龙,火星人,鬼魂或者运动员,很有可能它使用了这个模式。

但是如果游戏更加抽象,移动部分不太像活动的角色而更加像棋盘上的棋子,这个模式通常就不适用了。在棋类游戏中,你不需要同时模拟所有的部分,你可能也不需要告诉棋子每帧都更新它们自己。

译注:你也许不需要每帧更新它们的行为,但即使是棋类游戏,你可能也需要每帧更新动画。这个设计模式也可以帮到你。

更新方法适应以下情况:

  • 你的游戏有很多对象或系统需要同时运行。
  • 每个对象的行为都与其他的大部分独立。
  • 对象需要跟着时间进行模拟。

记住

这个模式很简单,所以没有太多值得发现的惊喜。当然,每行代码还是有利有弊。

将代码划分到一帧帧中会让它更复杂

当你比较前面两块代码时,第二块看上去更加复杂。两者都只是让骷髅守卫来回移动,但与此同时,第二块代码将控制权交给了游戏循环的一帧帧中。

译注:这里说几乎是因为有时候鱼和熊掌可以兼得。你可以直接为对象编码而不进行返回,保持很多对象同时运行并与游戏循环保持协调。你需要的是允许你同时拥有多个“线程“执行的系统——如果你的语言支持轻量协同架构比如 generators、coroutines 或者 fibers,那你也许可以使用它们。字节码模式是另一个在应用层创建多个线程执行的方法。

当离开每帧时,你需要存储状态

在第一个示例代码中,我们不需要用任何变量表明守卫在向左还是向右移动。这显式的依赖于哪块代码正在运行。

当我们将其变为一次一帧的形式,我们需要创建 patrollingLeft 变量来追踪行走的方向。当从代码中返回时,就丢失了行走的方向,所以为了下帧继续,我们需要显式存储足够的信息。

译注:状态模式通常可以在这里帮忙。状态机在游戏中频繁出现的部分原因是(就像名字暗示的),它能在你离开时为你存储各种你需要的状态。

对象逐帧模拟,但并非真的同步

在这个模式中,游戏遍历对象集合,更新每一个对象。在 update() 调用中,大多数对象都能够接触到游戏世界的其他部分,包括现在正在更新的其他对象。这就意味着你更新对象的顺序至关重要

如果对象更新列表中,A 在 B 之前,当 A 更新时,它会看到 B 之前的状态。但是当 B 更新时,由于 A 已经在这帧更新了,它会看见 A 的新状态。哪怕按照玩家的视角,所有对象都是同时运转的,游戏的核心还是回合制的。只是完整的“回合“只有一帧那么长。

译注:如果,由于某些原因,你决定不让游戏按这样的顺序更新,你需要双缓冲模式。那么 AB 更新的顺序就没有关系了,因为双方都会看对方之前那帧的状态。

当关注游戏逻辑时,这通常是件好事。同时更新所有对象将把你带到一些不愉快的语义角落。序列更新解决了这点——每次更新都让游戏世界从一个合法状态增量更新到下一个,不会出现引发歧义而需要协调的部分。

译注:这对在线游戏也有用,因为你有了可以在网上发送的行动指令序列。

在更新时修改对象列表需小心

当你使用这个模式时,很多游戏行为在更新方法中纠缠在一起。这些行为通常包括增加和删除可更新对象。

举个例子,假设骷髅守卫被杀死时掉落物品。使用新对象,你通常可以将其增加到列表尾部,而不引起任何问题。你会继续遍历这张链表,最终找到新的那个,然后也更新了它。

但这确实表明新对象在它产生的那帧就有机会活动,甚至有可能在玩家看到它之前。如果你不想发生那种情况,简单的修复方法就是在游戏循环中缓存列表对象的数目,然后只更新那么多数目的对象就停止:

int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
  objects_[i]->update();
}

这里,objects_ 是可更新游戏对象的数组,而 numObjects_ 是数组的长度。当添加新对象时,这个数组长度变量就增加。在循环的一开始,我们在 numObjectsThisTurn 中存储数组的长度,这样这帧的遍历循环会停在新添加的对象之前。

一个更麻烦的问题是在遍历时移除对象。你击败了邪恶的野兽,现在它需要被移出对象列表。如果它正好位于你当前更新对象之前,你会意外地跳过一个对象:

for (int i = 0; i < numObjects_; i++)
{
  objects_[i]->update();
}

这个简单的循环通过增加索引值来遍历每个对象。下图的左侧展示了在我们更新英雄时,数组看上去是什么样的:

我们在更新她时,索引值 i 是 1。邪恶野兽被她杀了,因此需要从数组移除。英雄移到了位置 0,倒霉的乡下人移到了位置 1。在更新英雄之后,i 增加到了 2。就像你在右图看到的,倒霉的乡下人被跳过了,没有更新。

一种简单的解决方案是在更新时从后往前遍历列表。这种方式只会移动已经被更新的对象。

译注:一种解决方案是小心地移除对象,任何对象被移除时,更新索引。另一种是在遍历完列表后再移除对象。将对象标为“死亡“,但是把它放在那里。在更新时跳过任何死亡的对象。然后,在完成遍历后,遍历列表并删除尸体。如果在更新循环中有多个线程处理对象,那么你可能更喜欢推迟任何修改,避免更新时同步线程的开销。


示例代码

这个模式太直观了,代码几乎只是在重复说明要点。这不意味着这个模式没有用。它因为简单而有用:这是一个无需装饰的干净解决方案。

但是为了让事情更具体些,让我们看看一个基础的实现。我们会从代表骷髅和雕像的 Entity 类开始:

class Entity
{
public:
  Entity() : x_(0), y_(0) {}

  virtual ~Entity() {}
  virtual void update() = 0;

  double x() const { return x_; }
  double y() const { return y_; }

  void setX(double x) { x_ = x; }
  void setY(double y) { y_ = y; }

private:
  double x_;
  double y_;
};

我在这里只呈现了我们后面所需东西的最小集合。可以推断在真实代码中,会有很多图形和物理这样的其他东西。上面这部分代码最重要的部分是它有抽象的 update() 方法。

游戏管理实体的集合。在我们的示例中,我会把它放在一个代表游戏世界的类中。

class World
{
public:
  World() : numEntities_(0) {}

  void gameLoop();

private:
  Entity* entities_[MAX_ENTITIES];
  int numEntities_;
};

译注:在真实的世界程序中,你可能真的要使用集合类,我在这里使用数组来保持简单。

现在,万事俱备,游戏通过每帧更新每个实体来实现模式:

void World::gameLoop()
{
  while (true)
  {
    // 处理用户输入……

    // 更新每个实体
    for (int i = 0; i < numEntities_; i++)
    {
      entities_[i]->update();
    }

    // 物理和渲染……
  }
}

正如其名,这是游戏循环模式的一个例子。

子类化实体?!

有很多读者刚刚起了鸡皮疙瘩,因为我在 Entity 主类中使用继承来定义不同的行为。如果你在这里还没有看出问题,我会提供一些线索。

当游戏业界从 6502 汇编代码和 VBLANKs 转向面向对象的语言时,开发者陷入了对软件架构的狂热之中。其中之一就是使用继承。他们建立了遮天蔽日的高耸的拜占庭式对象层次。

最终证明这是个糟点子,没人可以不拆解它们来管理庞杂的对象层次。哪怕在 1994 年的 GoF 都知道这点,并写道:

多用“对象组合“,而非“类继承“。

译注:只在你我间聊聊,我认为这已经是一朝被蛇咬十年怕井绳了。我通常避免使用它,但教条地不用和教条地使用一样糟。你可以适度使用,不必完全禁用。

当游戏业界都明白了这一点,解决方案是使用组件模式。使用它,update() 是实体的组件而不是在 Entity 中。这让你避开了为了定义和重用行为而创建实体所需的复杂类继承层次。相反,你只需混合和组装组件。

译注:如果我真正在做游戏,我也许也会那么做。但是这章不是关于组件的,而是关于 update() 方法,最简单,最少牵连其他部分的介绍方法,就是把更新方法放在 Entity 中然后创建一些子类。

定义实体

好了,回到任务中。我们原先的动机是定义巡逻的骷髅守卫和释放闪电的魔法雕像。让我们从我们的骷髅朋友开始吧。为了定义它的巡逻行为,我们定义恰当地实现了 update() 的新实体:

class Skeleton : public Entity
{
public:
  Skeleton() : patrollingLeft_(false) {}

  virtual void update()
  {
    if (patrollingLeft_)
    {
      setX(x() - 1);
      if (x() == 0) patrollingLeft_ = false;
    }
    else
    {
      setX(x() + 1);
      if (x() == 100) patrollingLeft_ = true;
    }
  }

private:
  bool patrollingLeft_;
};

如你所见,几乎就是从早先的游戏循环中剪切代码,然后粘贴到 Skeletonupdate() 方法中。唯一的小小不同是 patrollingLeft_ 被定义为字段而不是本地变量。通过这种方式,它的值在 update() 两次调用间保持不变。

让我们对雕像如法炮制:

class Statue : public Entity
{
public:
  Statue(int delay) : frames_(0), delay_(delay) {}

  virtual void update()
  {
    if (++frames_ == delay_)
    {
      shootLightning();

      // 重置计时器
      frames_ = 0;
    }
  }

private:
  int frames_;
  int delay_;

  void shootLightning()
  {
    // 火光效果……
  }
};

又一次,大部分改动是将代码从游戏循环中移动到类中,然后重命名一些东西。但是,在这个例子中,我们真的让代码库变简单了。先前讨厌的命令式代码中,存在存储每个雕像的帧计数器和开火的速率的分散的本地变量。

现在那些都被移动到了 Statue 类中,你可以想创建多少就创建多少实例了,每个实例都有它自己的小计时器。这是这章背后的真实动机——现在为游戏世界增加新实体会更加简单,因为每个实体都带来了它需要的全部东西。

这个模式让我们分离了游戏世界的构建和实现。这同样能让我们灵活地使用分散的数据文件或关卡编辑器来构建游戏世界。

传递时间

这是模式的关键,但是我只对常用的部分进行了细化。到目前为止,我们假设每次对 update() 的调用都推动游戏世界前进一个固定的时间。

我更喜欢那样,但是很多游戏使用可变时间步长。在那种情况下,每次游戏循环推进的时间长度或长或短,具体取决于它需要多长时间处理和渲染前一帧。

游戏循环一章讨论了更多关于固定和可变时间步长的优劣。

这意味着每次 update() 调用都需要知道虚拟的时钟转动了多少,所以你经常可以看到传入消逝的时间。举个例子,我们可以让骷髅卫士像这样处理变化的时间步长:

void Skeleton::update(double elapsed)
{
  if (patrollingLeft_)
  {
    x -= elapsed;
    if (x <= 0)
    {
      patrollingLeft_ = false;
      x = -x;
    }
  }
  else
  {
    x += elapsed;
    if (x >= 100)
    {
      patrollingLeft_ = true;
      x = 100 - (x - 100);
    }
  }
}

现在,骷髅卫士移动的距离随着消逝时间的增长而增长。也可以看出,处理变化时间步长需要的额外复杂度。如果一次需要更新的时间步长过长,骷髅卫士也许就超过了其巡逻的范围,因此需要小心的处理。


设计决策

在这样简单的模式中,没有太多的调控之处,但是这里仍有两个你需要决策的地方:

更新方法在哪个类中?

最明显和最重要的决策就是决定将 update() 放在哪个类中。

  • 实体类中: 如果你已经有实体类了,这是最简单的选项,因为这不会带来额外的类。如果你需要的实体种类不多,这也许可行,但是业界已经逐渐远离这种做法了。 当类的种类很多时,一有新行为就建 Entity 子类来实现是痛苦的。当你最终发现你想要用单一继承的方法重用代码时,你就卡住了。

  • 组件类: 如果你已经使用了组件模式,你知道这个该怎么做。这让每个组件独立更新它自己。更新方法用了同样的方法解耦游戏中的实体,组件让你进一步解耦了单一实体中的各部分。渲染,物理,AI 都可以自顾自了。

  • 委托类: 还可将类的部分行为委托给其他的对象。状态模式可以这样做,你可以通过改变它委托的对象来改变它的行为。类型对象模式也这样做了,这样你可以在同“种“实体间分享行为。 如果你使用了这些模式,将 update() 放在委托类中是很自然的。在那种情况下,也许主类中仍有 update() 方法,但是它不是虚方法,可以简单地委托给委托对象。就像这样:

void Entity::update()
{
  // 转发给状态对象
  state_->update();
}

这样做允许你改变委托对象来定义新行为。就像使用组件,这给了你无须定义全新的子类就能改变行为的灵活性。

如何处理隐藏对象?

游戏中的对象,不管什么原因,可能暂时无需更新。它们可能是停用了,或者超出了屏幕,或者还没有解锁。如果状态中的这种对象很多,每帧遍历它们却什么都不做是在浪费 CPU 循环。

一种方法是管理单独的“活动“对象集合,它存储真正需要更新的对象。当一个对象停用时,从那个集合中移除它。当它启用时,再把它添加回来。用这种方式,你只需要迭代那些真正需要更新的东西。

  • 如果你使用单个包括了所有不活跃对象的集合:

    • 浪费时间。 对于不活跃对象,你要么检查一些“是否启用“的标识,要么调用一些啥都不做的方法。
    • 检查对象启用与否然后跳过它,不但消耗了 CPU 循环,还报废了你的数据缓存。CPU 通过从 RAM 上读取数据到缓存上来优化读取。这样做是基于刚刚读取内存之后的内存部分很可能等会儿也会被读取到这个假设。当你跳过对象,你可能越过了缓存的尾部,强迫它从缓慢的主存中再取一块。
  • 如果你使用单独的集合保存活动对象:

    • 使用了额外的内存管理第二个集合。当你需要所有实体时,通常又需要一个巨大的集合。在那种情况下,这集合是多余的。在速度比内存要求更高的时候(通常如此),这取舍仍是值得的。
    • 得保持集合同步。 当对象创建或完全销毁时(不是暂时停用),你得修改全部对象集合和活跃对象集合。

方法选择的度量标准是不活跃对象的可能数量。数量越多,用分离的活动集合就越有意义。


实践 Demo

待补充


笔记与思考

1. 模式本身就是游戏的基本约定

update() 方法不需要“实现“——它是一种架构约定。每个现代游戏引擎都有这个机制(Unity 的 Update()、Godot 的 _process()、Unreal 的 Tick())。这一章不是在教你怎么写,而是在告诉你为什么它是这样设计的

2. “混杂“是自然趋势,解耦是刻意行为

文章中女武神地宫的演化过程完美展示了这一点:骷髅巡逻 → 加雕像 → 加更多实体 → 所有逻辑堆在主循环里不可维护。把每个实体的行为放进自己的 update(),不是技术需要,是管理需要——让添加/移除实体变得简单,让代码库不会随着实体数量线性膨胀。

3. 逐帧切片的代价:状态必须显式存储

while(true) 里写 for 循环可以自然依赖“当前执行到哪一行“来判断方向。拆成 update() 后,每次调用完就返回了,执行位置丢失了。所以需要 patrollingLeft_ 这类字段来显式存储“我上次做到哪了“。这是逐帧编程的核心心智负担:你以为你在写连续流程,实际上你在写状态机。

4. 更新顺序 = 隐式回合制

虽然玩家感觉所有对象同步行动,但底层是 for 循环依次更新。A 先更新时看到 B 的旧状态,B 后更新时看到 A 的新状态。这个顺序差异在大多数游戏中不可见,但如果需要真正的“同步“,就得用双缓冲。文章用国际象棋黑白同时落子的类比很精准——序列更新天然避免了冲突。

5. update() 放哪里 = 架构选择

三种位置对应三种设计哲学:放实体类(简单粗暴,继承地狱)、放组件(灵活但分散,Unity/Godot 的做法)、放委托对象(状态模式/类型对象,运行时切换行为)。这不是对错问题,是你的项目规模决定了哪个更合适。

三、行为模式

Behavioral Patterns

当游戏对象的行为变得复杂,硬编码会让代码迅速腐烂。本部分介绍如何为对象赋予灵活、可扩展、可替换的行为。

字节码模式 (Bytecode)

子类沙箱模式 (Subclass Sandbox)

类型对象模式 (Type Object)

四、解耦模式

Decoupling Patterns

大型游戏项目中,模块之间互相纠缠是最大的维护噩梦。本部分专注于如何让代码各部件松耦合、独立演化。

组件模式 (Component)

事件队列模式 (Event Queue)

服务定位器模式 (Service Locator)

五、优化模式

Optimization Patterns

游戏必须跑满 60 帧。本部分介绍在不牺牲代码可读性的前提下,如何通过巧妙的设计提升运行时性能。

数据局部性模式 (Data Locality)

脏标记模式 (Dirty Flag)

对象池模式 (Object Pool)

空间分区模式 (Spatial Partition)