剖析游戏结构
======

本文从技术角度分析了一般电子游戏的结构和工作流程，就此介绍主循环是如何运行的。它有助于初学者了解在现代游戏开发领域构建游戏时需要什么，以及如何将 JavaScript 这样的 Web 标准工具作为自己的工具使用。游戏开发经验丰富但不熟悉 Web 开发的开发者也能从中受益。

呈现、接收、解释、计算、重复
--------------

所有电子游戏的目标都是向用户**呈现**一个场景，**接收**他们的输入，将这些输入信号**解释**为动作，并**计算**出由这些动作产生的新场景。游戏不断地循环遍历，一遍又一遍，直到遇到某些终止条件（比如赢、输或者退出睡觉）。毫不奇怪，这种模式与游戏引擎的编程方式相呼应。

具体情况取决于游戏本身。

一些游戏通过**用户输入**来驱动这个循环。想象一下，你正在开发一种“大家来找茬”类型的游戏。这些游戏向用户**呈现**两张图片，游戏**接收**点击（或触摸）；将用户输入**解释**为成功，失败，暂停，菜单交互等。最后，游戏根据用户的输入**计算并更新**游戏场景。这种游戏是由用户的输入驱动，也就是说，它会在用户进行输入之后冻结画面，等待玩家进行新的输入。这是一种基于回合的游戏类型，它不需要每帧持续更新画面，只有当玩家作出反应时才会。

另一些游戏需要尽可能控制每一个细微的时间片（动画）。与上述原理有点小区别：每个动画帧都将循环遍历，并在之后第一个可用的轮次捕获玩家输入的任何变化。这种每帧一次的模型是通过一个叫**主循环**的东西实现的。如果你的游戏循环是基于时间的，则必须保证对主循环精准的模拟。

但它也可能不需要逐帧控制。你的游戏循环可能类似找不同的例子，并以输入事件作为基础。它可能需要输入和模拟时间片。它甚至可以基于其他的东西来循环。

现代 JavaScript - 正如下一节中所描述的 - 它可以轻松开发出一个高效的，逐帧执行的主循环，这很值得庆幸。当然，你的游戏只会按照你所做的那样优化。如果某些东西看起来应该被添加到一个更罕见的事件里，那么将它从主循环中剥离出来通常是个好主意（但并非总是如此）。

在 JavaScript 中构建一个主循环
---------------------

JavaScript 能很好的处理事件和回调函数。现代浏览器努力在需要的时候调用方法，并在间隙中闲下来（或做其他任务）。将你的代码附加到适合它们的时刻是一个很好的主意。考虑一下你的函数是需要在严格的时间周期内，还是每一帧，或者仅仅是在发生了其他情况之后执行。当你的函数需要被调用时，要更具体地使用浏览器，这样浏览器就可以在调用时进行优化。而且，这可能会让你的工作更轻松。

有些代码需要逐帧运行，所以应将其附加到浏览器的重绘周期中，没有比这更好的了！在 Web 中，通常 `window.requestAnimationFrame()` 方法是大多数良好的逐帧循环的基础。在调用该方法时必须传入一个回调函数，这个回调函数将在下一次重新绘制之前执行。下面是一个简单的主循环的例子：


```js
window.main = () => {
  window.requestAnimationFrame(main);

  // 无论你的主循环需要做什么
};

main(); // 开始循环
```


**备注：** 在这里讨论的每个 `main()` 方法中，在执行循环内容之前，我们会递归调用一个新的 `requestAnimationFrame`，这不是随意的，它被认为是最佳实践。如果你的当前帧错过了它的垂直同步窗口的周期，你也在下一个帧通过 `requestAnimationFrame` 尽早的调用 `main()`，从而确保浏览器能够及时地执行。

上面的代码块有两个语句。第一个语句创建一个名为 `main()` 中的全局变量的函数。这个函数做一些工作，也告诉浏览器在下一帧通过 `window.requestAnimationFrame()` 调用本身。第二个语句调用第一个语句中定义的 `main()` 函数。因为 `main()`中在第二个语句中被调用一次，而每次调用都将自身又放置到下一个帧的执行队列中，因此 `main()` 的调用与你的帧率同步。

当然，这个循环并不完美。不过在讨论如何改善之前，让我们先讨论一下它已经做好了什么。

将主循环的时机安排在浏览器每次的绘制显示中，允许你能像浏览器想要绘制的那样频繁的运行你的循环。你能够控制每一帧动画，并且 `main()` 是循环中唯一执行的函数，所以这很简单。主视角射击游戏（或类似的游戏）每一帧都会出现一个新的场景。没有比这种方法更平滑并绘制及时的了。

但是不要马上认为动画必需要帧帧控制。通过 CSS 动画或浏览器中的其他工具，可以很容易实现简单的动画，甚至 GPU 加速。有很多这样的东西，它们会让你的工作更轻松。

在 JavaScript 中构建一个更好的主循环
------------------------

在前面的主循环中有两个明显的问题：`main()` 污染了 `window` 对象（所有全局变量存储的对象），并且示例代码没有给我们留下一个停止循环的方法，除非整个浏览器选项卡被关闭或刷新。对于第一个问题，如果你希望主循环只运行，并且不需要被简单（直接）访问它，你可以将它作为一个立即调用的函数表达式（IIFE）创建。


```js
/\*
\* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入（ASI）。 
\* 浏览器可能会意外地认为整个示例从上一行继续。
\* 如果前一行不为空或终止，则前面的分号标志着新行的开始。
\*/

;(() => {
  function main(){
    window.requestAnimationFrame(main);

    // 你的主循环内容。
  }

  main(); // 开始循环
})();
```


当浏览器遇到这个 IIFE 时，它将定义你的主循环，并立即将其加入下一个帧的更新队列中。`main`（或 `main()` 方法）不会被附加到任何对象，在应用程序的其他地方仍是一个有效的未使用的名称，仍可以自由地定义为其他的东西。

**备注：** 在实践中，更常见的终止下一个 `requestAnimationFrame()` 方式是使用 if 语句，而不是调用`cancelAnimationFrame()`。

对于第二个问题，要终止循环，你需要调用 `window.cancelAnimationFrame()` 来取消 `main()` 的调用。该方法需要传入你最后一次调用 `requestAnimationFrame()` 时返回的 ID。让我们假设你的游戏的函数和变量是建立在你称为 MyGame 的名称空间上。扩展我们的最后一个例子，主循环现在看起来是这样的：


```js
/\*
\* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入（ASI）。 
\* 浏览器可能会意外地认为整个示例从上一行继续。
\* 如果前一行不为空或终止，则前面的分号标志着新行的开始。
\*
\* 让我们假设 MyGame 是之前定义的。
\*/

;(() => {
  function main(){
    MyGame.stopMain = window.requestAnimationFrame(main);

    // 你的主循环内容
  }

  main(); // 开始循环
})();
```


现在，我们在 MyGame 名称空间中声明了一个变量`stopMain`，其值为主循环最后调用 `requestAnimationFrame()` 时返回的 ID。任何时候，我们可以通过告诉浏览器取消与 ID 相对应的请求来停止主循环。


```js
window.cancelAnimationFrame(MyGame.stopMain);
```


在 JavaScript 的中编写主循环的关键在于，考虑到任何会驱动你行为的事件，并注意不同的系统是如何相互作用的。你可能拥有多个由多个不同类型的事件驱动的组件。这看起来像是不必要的复杂性，但它可能就是一个很好的优化（当然，不一定是这样的）。问题是，你并不是在编写一个典型的主循环。在 JavaScript 中，你使用的是浏览器的主循环，并且你正在尝试这样做。

用 JavaScript 构建一个更优化的主循环
------------------------

基本上，在 JavaScript 的中，浏览器有它自己的主循环，而你的代码存在于循环某些阶段。上面描述的主循环，试图避免脱离浏览器的控制。这种主循环附着于 `window.requestAnimationFrame()` 方法，该方法将在浏览器的下一帧中执行，具体取决于浏览器如何与将其自己的主循环关联起来。W3C 的 requestAnimationFrame 规范并没有真正定义什么时候浏览器必须执行 requestAnimationFrame 回调。这有一个好处，浏览器厂商可以自由地实现他们认为最好的解决方案，并随着时间的推移进行调整。

现代版的 Firefox 和 Google Chrome（可能还有其他版本）试图在框架的时间片的开始时尝试将 `requestAnimationFrame` 回调与它们的主线程进行连接。因此，浏览器的主线程看起来就像下面这样：

1. 启动一个新帧（而之前的帧由显示处理）。
2. 遍历 `requestAnimationFrame` 回调并调用它们。
3. 当上面的回调停止控制主线程时，执行垃圾收集和其他帧任务。
4. 睡眠（除非事件打断了浏览器的小睡），直到显示器准备好你的图像（VSync）并重复。

你可以考虑开发实时应用程序，因为有时间做工作。所有上述步骤都必须在每 16 毫秒内进行一次，以保持 60 赫兹的显示效果。浏览器会尽可能早地调用你的代码，从而给它最大的计算时间。你的主线程通常会启动一些甚至不在主线程上的工作负载（如 WebGL 的中的光栅化或着色器）。在浏览器使用其主线程管理垃圾收集，其他任务或处理异步事件时，可以在 Web Worker 或 GPU 上执行长时间的计算。

当我们讨论预算时，许多网络浏览器都有一个称为高分辨率时间的工具。`Date` 对象不再是计时事件的识别方法，因为它非常不精确，可以由系统时钟进行修改。另一方面，高分辨率的时间计算自 `navigationStart`（当上一个文档被卸载时）的毫秒数。这个值以小数的精度返回，精确到千分之一毫秒。它被称为`DOMHighResTimeStamp`，但是，无论出于什么目的和目的，都认为它是一个浮点数。

**备注：** 系统（硬件或软件）不能达到微秒精度，可以提供毫秒精度的最小值然而，如果他们能够做到这一点，他们就应该提供 0.001 毫秒的准确性。

这个值本身并不太有用，因为它与一个相当无趣的事件相关，但它可以从另一个时间戳中减去，以便准确准确地确定这两个点之间的时间间隔。要获得这些时间戳中的一个，你可以调用 `window.performance.now()` 并将结果存储为一个变量。


```js
var tNow = window.performance.now();
```


回到主循环的主题。你将经常想知道何时调用主函数。因为这是常见的，`window.requestAnimationFrame()` 总是提供一个 `DOMHighResTimeStamp` 执行时回调函数作为参数。这将导致我们之前的主循环的另一个增强。


```js
/\*
\* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入（ASI）。 
\* 浏览器可能会意外地认为整个示例从上一行继续。
\* 如果前一行不为空或终止，则前面的分号标志着新行的开始。
\*
\* 我们还假设 MyGame 是以前定义的。
\*/

;(() => {
  function main(tFrame){
    MyGame.stopMain = window.requestAnimationFrame(main);

    // 你的主循环内容
    // tFrame，来自"function main(tFrame)"，现在是由 rAF 提供的 DOMHighResTimeStamp。
  }

  main(); // 开始循环
})();
```


其他一些优化是可能的，这取决于你的游戏想要完成什么。你的游戏类型显然会有所不同，但它甚至可能比这更微妙。你可以在画布上单独绘制每个像素，也可以将 DOM 元素（包括具有透明背景的多个 WebGL 的画布）放入复杂的层次结构中。每条路径都将导致不同的机会和约束。

决定......时间
----------

你将需要对你的主循环作出艰难的决定：如何模拟准确的时间进度。如果你想要控制每一帧，那么你需要确定你的游戏更新和绘制的频率，你甚至可能希望以不同的速率进行更新和绘制。你还需要考虑如果用户的系统无法跟上工作负载，那么你还需考虑如何优雅降级，以保证性能。让我们首先假定你会在每次绘制时，同时处理用户的输入，并更新游戏状态。我们稍后再细讲。

**备注：** 改变主循环如何处理时间是非常困难的，在进行主循环之前，请仔细考虑你的需求。

### 大多数浏览器游戏应该是什么样的

如果你的游戏可以达到你所支持的任何硬件的最大刷新率，那么你的工作就变得相当容易了。你可以简单地进行更新，渲染，然后在垂直同步之前什么都不用做。


```js
/\*
\* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入（ASI）。 
\* 浏览器可能会意外地认为整个示例从上一行继续。
\* 如果前一行不为空或终止，则前面的分号标志着新行的开始。
\*
\* 我们还假设 MyGame 是以前定义的。
\*/

;(function(){
  function main(tFrame){
    MyGame.stopMain = window.requestAnimationFrame(main);

    update(tFrame); // 调用 update 方法。在我们的例子中，我们给它 rAF 的时间戳。
    render();
  }

  main(); // 开始循环
})();
```


如果无法达到最大刷新率，可以调整画面质量设置以保持你的时间预算。这个概念最有名的例子是 id Software 的 RAGE 游戏，这个游戏取消了用户的控制权，以使其计算时间保持在大约 16ms（或大约 60fps）。如果计算时间过长，则提交的解析度就降低，纹理和其他资源将无法加载或绘制等。这个（非网络）案例研究做了一些假设和折衷：

* 每个动画帧都占用户输入。
* 没有帧需要外推（猜测），因为每个绘图都有自己的更新。
* 仿真系统基本上可以假定每次完全更新间隔约 16ms。
* 给用户控制质量设置将是一场噩梦。
* 不同的监视器以不同的速率输入：30FPS、75FPS、100FPS、120FPS、144FPS 等
* 无法跟上 60 FPS 的系统会失去视觉质量，以保持游戏运行的最佳速度（最终如果质量太低，则会彻底失败）。
### 处理可变刷新率需求的其他方法

存在其他解决问题的方法。

一种常见的技术是以恒定的频率更新模拟，然后绘制尽可能多的（或尽可能少的）实际帧。更新方法可以继续循环，而不用考虑用户看到的内容。绘图方法可以查看最后的更新以及发生的时间。由于绘制知道何时表示，以及上次更新的模拟时间，它可以预测为用户绘制一个合理的框架。这是否比官方更新循环更频繁（甚至更不频繁）无关紧要。更新方法设置检查点，并且像系统允许的那样频繁地，渲染方法画出周围的时间。在 Web 标准中分离更新方法有很多种方法：

* 绘制 `requestAnimationFrame` 并更新 `setInterval()` 或 `setTimeout()`。
    + 即使在未聚焦或最小化的情况下，使用处理器时间，也可能是主线程，并且可能是传统游戏循环的工件（但是很简单）。
* 绘制 `requestAnimationFrame` 并在 Web Worker 的 `setInterval` 或 `setTimeout` 中对其进行更新。
    + 这与上述相同，除了更新不会使主线程（主线程也没有）。这是一个更复杂的解决方案，并且对于简单更新可能会有太多的开销。
* 绘制 `requestAnimationFrame` 并使用它来戳一个包含更新方法的 Web Worker，其中包含要计算的刻度数（如果有的话）。
    + 这个睡眠直到 `requestAnimationFrame` 被调用并且不会污染主线程，加上你不依赖于老式的方法。再次，这比以前的两个选项更复杂一些，并且*开始*每个更新将被阻止，直到浏览器决定启动 rAF 回调。

这些方法中的每一种都有类似的权衡：

* 用户可以跳过渲染帧或根据其性能内插额外的帧。
* 你可以指望所有用户以相同的恒定频率更改打嗝的更新非美容变量。
* 程序比我们前面看到的基本循环要复杂得多。
* 用户输入完全被忽略，直到下次更新（即使用户具有快速设备）。
* 强制性内插具有性能损失。

单独的更新和绘图方法可能如下面的示例。为了演示，该示例基于第三个项目符号，只是不使用 Web Workers 进行可读性（而且我们诚实地说可写性）。

**警告：** 这个例子，具体来说，需要进行技术审查。


```js
/\*
\* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入（ASI）。 
\* 浏览器可能会意外地认为整个示例从上一行继续。
\* 如果前一行不为空或终止，则前面的分号标志着新行的开始。
\*
\* 我们还假设 MyGame 是以前定义的。
\*
\* MyGame.lastRender 跟踪最后提供的 requestAnimationFrame 时间戳。
\* MyGame.lastTick 跟踪最后更新时间。始终以 tickLength 递增。
\* MyGame.tickLength 是游戏状态更新的频率。这是 20 Hz(50ms)。
\*
\* timeSinceTick 是 requestAnimationFrame 回调和最后一次更新之间的时间。
\* numTicks 是这两个呈现帧之间应该发生的更新次数。
\*
\* render() 传入 tFrame, 因为 render 方法可能需要计算
\* tFrame 距离最近的更新已经过去了多久，通过外推的方式
\* 来获得场景数据。(对于快速设备，render 方法是纯表现性的)。
\* 用以绘制场景。
\*
\* update() 根据给定时间点计算游戏状态。通常需要用 tickLength
\* 作为循环参数，递增更新。来保证游戏状态的严谨。传入 DOMHighResTimeStamp
\* 格式的当前时间。（除非需要增加暂停功能，传入的时间应该总是
\* 最后更新时间 + 游戏的 tick 间隔。)
\*
\* setInitialState() 执行在运行 mainloop 之前需要的任何任务。
\* 它只是一个普通的示例函数，表示你在 mainloop 前做过的事。
\*/

;(function(){
  function main(tFrame){
    MyGame.stopMain = window.requestAnimationFrame(main);
    var nextTick = MyGame.lastTick + MyGame.tickLength;
    var numTicks = 0;

    // 如果 tFrame < nextTick，则需要更新 0 个 ticks（对于 numTicks，默认为 0）。
    // 如果 tFrame = nextTick，则需要更新 1 tick（等等）。
    // 备注：正如我们在总结中提到的那样，你应该跟踪 numTicks 的大小。
    // 如果它很大，那么你的游戏是睡着了，或者机器无法跟上。
    if (tFrame > nextTick) {
      const timeSinceTick = tFrame - MyGame.lastTick;
      numTicks = Math.floor(timeSinceTick / MyGame.tickLength);
    }

    queueUpdates(numTicks);
    render(tFrame);
    MyGame.lastRender = tFrame;
  }

  function queueUpdates(numTicks) {
    for (let i = 0; i < numTicks; i++) {
      MyGame.lastTick += MyGame.tickLength; // 现在 lastTick 是这个刻度。
      update(MyGame.lastTick);
    }
  }

  MyGame.lastTick = performance.now();
  MyGame.lastRender = MyGame.lastTick; // 假装第一次绘制是在第一次更新。
  MyGame.tickLength = 50; // 这将使你的模拟运行在 20Hz（50ms）

  setInitialState();
  main(performance.now()); // 开始循环
})();
```


另一个选择是简单地做一些事情不那么频繁。如果你的更新循环的一部分难以计算但对时间不敏感，则可以考虑缩小其频率，理想情况下，在延长的时间段内将其扩展成块。这是一个隐含的例子，在火炮博物馆的炮兵游戏中，他们调整垃圾发生率来优化垃圾收集。显然，清理资源不是时间敏感的（特别是如果整理比垃圾本身更具破坏性）。

这也可能适用于你自己的一些任务。那些是当可用资源成为关注点时的好候选人。

概要
--

我知道上述的任何一种，或许没有适合你的游戏。正确的决定完全取决于你愿意（和不愿意）做出的权衡。主要关心的是切换到另一个选项。幸运的是，我没有任何经验，但我听说这是一个令人难以置信的游戏的 Whack-a-Mole。

像 Web 这样的受管理平台，要记住的一件重要的事情是，你的循环可能会在相当长的一段时间内停止执行。当用户取消选择你的标签并且浏览器休眠（或减慢）其 `requestAnimationFrame` 回调间隔时，可能会发生这种情况。你有很多方法来处理这种情况，这可能取决于你的游戏是单人游戏还是多人游戏。一些选择是：

* 考虑差距“暂停”并跳过时间。
    + 你可能会看到这对大多数多人游戏来说都是有问题的。
* 你可以模拟差距赶上。
    + 这可能是长时间丢失和/或复杂更新的问题。
* 你可以从联机设备或服务器恢复游戏状态。
    + 除非联机设备/服务器的游戏状态已经过期，或者这是一个没有没服务器的纯单机游戏。

一旦你的主循环被开发出来，你已经决定了一套适合你的游戏的假设和权衡，现在只需要用你的决定来计算任何适用的物理、AI、声音、网络同步，以及其他任何你的游戏可能需要的。