--- title: 剖析游戏结构 slug: Games/Anatomy tags: - Games - JavaScript - Main Loop - requestAnimationFrame translation_of: Games/Anatomy ---
{{IncludeSubnav("/en-US/docs/Games")}}
本文从技术角度分析了一般电子游戏的结构和工作流程,就此介绍主循环是如何运行的。它有助于初学者了解在现代游戏开发领域构建游戏时需要什么,以及如何将JavaScript这样的Web标准工具作为自己的工具使用。游戏开发经验丰富但不熟悉Web开发的开发者也能从中受益。
所有电子游戏的目标都是向用户(们)呈现一个场景,接收他们的输入,将这些输入信号解释为动作,并计算出由这些动作产生的新场景。游戏不断地循环遍历,一遍又一遍,直到遇到某些终止条件(比如赢,输或者退出睡觉)。毫不奇怪,这种模式与游戏引擎的编程方式相呼应。
具体情况取决于游戏本身。
一些游戏通过用户输入来驱动这个循环。想象一下,你正在开发一种“大家来找茬”类型的游戏。这些游戏向用户呈现两张图片,游戏接收点击(或触摸);将用户输入解释为成功,失败,暂停,菜单交互等。最后,游戏根据用户的输入计算并更新游戏场景。这种游戏是由用户的输入驱动,也就是说,它会在用户进行输入之后冻结画面,等待玩家进行新的输入。这是一种基于回合的游戏类型,它不需要每帧持续更新画面,只有当玩家作出反应时才会。
另一些游戏需要尽可能控制每一个细微的时间片(动画)。与上述原理有点小区别:每个动画帧都将循环遍历,并在之后第一个可用的轮次捕获玩家输入的任何变化。这种 每帧一次 的模型是通过一个叫主循环的东西实现的。如果您的游戏循环是基于时间的,则必须保证对主循环精准的模拟。
但它也可能不需要逐帧控制。您的游戏循环可能类似找不同的例子,并以输入事件作为基础。它可能需要输入和模拟时间片。它甚至可以基于其他的东西来循环。
现代JavaScript - 正如下一节中所描述的 - 它可以轻松开发出一个高效的,逐帧执行的主循环,这很值得庆幸。当然,您的游戏只会按照您所做的那样优化。如果某些东西看起来应该被添加到一个更罕见的事件里,那么将它从主循环中剥离出来通常是个好主意(但并非总是如此)。
JavaScript能很好的处理事件和回调函数。现代浏览器努力在需要的时候调用方法,并在间隙中闲下来(或做其他任务)。将您的代码附加到适合它们的时刻是一个很好的主意。考虑一下你的函数是需要在严格的时间周期内,还是每一帧,或者仅仅是在发生了其他情况之后执行。当您的函数需要被调用时,要更具体地使用浏览器,这样浏览器就可以在调用时进行优化。而且,这可能会让你的工作更轻松。
有些代码需要逐帧运行,所以应将其附加到浏览器的重绘周期中,没有比这更好的了!在Web 中,通常 window.requestAnimationFrame()
方法是大多数良好的逐帧循环的基础。在调用该方法时必须传入一个回调函数,这个回调函数将在下一次重新绘制之前执行。下面是一个简单的主循环的例子。:
window.main = function(){ window.requestAnimationFrame(main); //无论你的主循环需要做什么 }; main(); //开始循环
注意:在这里讨论的每个 main()
方法中,在执行循环内容之前,我们会递归调用一个新的requestAnimationFrame
,这不是随意的,它被认为是最佳实践。如果你的当前帧错过了它的垂直同步窗口的周期,你也在下一个帧通过requestAnimationFrame
尽早的调用main()
,从而确保浏览器能够及时地执行。
上面的代码块有两个语句。第一个语句创建一个名为main()
中的全局变量的函数。这个函数做一些工作,也告诉浏览器在下一帧通过window.requestAnimationFrame()
调用本身。第二个语句调用第一个语句中定义的main()
函数。因为main()
中在第二个语句中被调用一次,而每次调用都将自身又放置到下一个帧的执行队列中,因此main()
的调用与您的帧率同步。
当然,这个循环并不完美。不过在讨论如何改善之前,让我们先讨论一下它已经做好了什么。
将主循环的时机安排在浏览器每次的绘制显示中,允许您能像浏览器想要绘制的那样频繁的运行您的循环。你能够控制每一帧动画,并且main()
是循环中唯一执行的函数,所以这很简单。主视角射击游戏(或类似的游戏)每一帧都会出现一个新的场景。没有比这种方法更平滑并绘制及时的了。
但是不要马上认为动画必需要帧帧控制。通过CSS动画或浏览器中的其他工具,可以很容易实现简单的动画,甚至GPU加速。有很多这样的东西,它们会让你的工作更轻松。
在前面的主循环中有两个明显的问题:main()
污染了 window
对象
(所有全局变量存储的对象),并且示例代码没有给我们留下一个停止循环的方法,除非整个浏览器选项卡被关闭或刷新。对于第一个问题,如果您希望主循环只运行,并且不需要被简单(直接)访问它,您可以将它作为一个立即调用的函数表达式(IIFE)创建。
/* * */ (function(){ function main(){ window.requestAnimationFrame(main); //你的主循环内容。 } main(); //开始循环 })();
当浏览器遇到这个IIFE时,它将定义您的主循环,并立即将其加入下一个帧的更新队列中。main
(或main()
方法)不会被附加到任何对象,在应用程序的其他地方仍是一个有效的未使用的名称,仍可以自由地定义为其他的东西。
注意:在实践中,更常见的终止下一个requestAnimationFrame()
方式是使用if语句,而不是调用cancelAnimationFrame()
。
对于第二个问题,要终止循环,您需要调用
window.cancelAnimationFrame()
来取消main()
的调用。该方法需要传入你最后一次调用requestAnimationFrame()
时返回的ID。让我们假设您的游戏的函数和变量是建立在您称为MyGame的名称空间上。扩展我们的最后一个例子,主循环现在看起来是这样的:
/* * 让我们假设MyGame是之前定义的。 */ ;(function(){ function main(){ MyGame.stopMain = window.requestAnimationFrame(main); //你的主循环内容 } main(); //开始循环 })();
现在,我们在MyGame名称空间中声明了一个变量stopMain
,其值为主循环最后调用requestAnimationFrame()
时返回的ID。任何时候,我们可以通过告诉浏览器取消与ID相对应的请求来停止主循环。
window.cancelAnimationFrame(MyGame.stopMain);
在JavaScript的中编写主循环的关键在于,考虑到任何会驱动你行为的事件,并注意不同的系统是如何相互作用的。您可能拥有多个由多个不同类型的事件驱动的组件。这看起来像是不必要的复杂性,但它可能就是一个很好的优化(当然,不一定是这样的)。问题是,您并不是在编写一个典型的主循环。在Java脚本中,您使用的是浏览器的主循环,并且您正在尝试这样做。
基本上,在JavaScript的中,浏览器有它自己的主循环,而你的代码存在于循环某些阶段。上面描述的主循环,试图避免脱离浏览器的控制。这种主循环附着于window.requestAnimationFrame()
方法,该方法将在浏览器的下一帧中执行,具体取决于浏览器如何与将其自己的主循环关联起来。W3C规范并没有真正定义什么时候浏览器必须执行requestAnimationFrame回调。这有一个好处,浏览器厂商可以自由地实现他们认为最好的解决方案,并随着时间的推移进行调整。
现代版的Firefox和Google Chrome(可能还有其他版本)试图在框架的时间片的开始时将请求AnimationFrame回调与它们的主线程进行连接。因此,浏览器的主线程看起来就像下面这样:
您可以考虑开发实时应用程序,因为有时间做工作。所有上述步骤都必须在每16毫秒内进行一次,以保持60赫兹的显示效果。浏览器会尽可能早地调用您的代码,从而给它最大的计算时间。您的主线程通常会启动一些甚至不在主线程上的工作负载(如WebGL的中的光栅化或着色器)。在浏览器使用其主线程管理垃圾收集,其他任务或处理异步事件时,可以在Web Worker或GPU上执行长时间的计算。
当我们讨论预算时,许多网络浏览器都有一个称为高分辨率时间的工具.{{ domxref("Date") }} 对象不再是计时事件的识别方法,因为它非常不精确,可以由系统时钟进行修改。另一方面,高分辨率的时间计算自navigationStart(当上一个文档被卸载时)的毫秒数。这个值以小数的精度返回,精确到千分之一毫秒。它被称为{{ domxref("DOMHighResTimeStamp") }},但是,无论出于什么目的和目的,都认为它是一个浮点数。
注意:系统(硬件或软件)不能达到微秒精度,可以提供毫秒精度的最小值然而,如果他们能够做到这一点,他们就应该提供0.001的准确性。
这个值本身并不太有用,因为它与一个相当无趣的事件相关,但它可以从另一个时间戳中减去,以便准确准确地确定这两个点之间的时间间隔。要获得这些时间戳中的一个,您可以调用window.performance.now()并将结果存储为一个变量。
var tNow = window.performance.now();
回到主循环的主题。您将经常想知道何时调用主函数。因为这是常见的,window.requestAnimationFrame()总是提供一个DOMHighResTimeStamp执行时回调函数作为参数。这将导致我们之前的主循环的另一个增强。
/* * 以分号开始,以上例子中的代码行都是这样的 * 依靠自动分号插入(ASI)。浏览器可能会意外 * 认为这个整个例子从上一行继续。领先分号 * 标记我们的新行的开始,如果前一个不是空或终止。 * * 我们还假设MyGame是以前定义的。 */ ;(function(){ function main(tFrame){ MyGame.stopMain = window.requestAnimationFrame(main); //你的主循环内容 // tFrame,来自"function main(tFrame)",现在是由rAF提供的DOMHighResTimeStamp。 } main(); //开始循环 })();
其他一些优化是可能的,这取决于你的游戏想要完成什么。你的游戏类型显然会有所不同,但它甚至可能比这更微妙。您可以在画布上单独绘制每个像素,也可以将DOM元素(包括具有透明背景的多个WebGL的画布)放入复杂的层次结构中。每条路径都将导致不同的机会和约束。
您将需要对您的主循环作出艰难的决定:如何模拟准确的时间进度。如果你想要控制每一帧,那么你需要确定你的游戏更新和绘制的频率,您甚至可能希望以不同的速率进行更新和绘制。您还需要考虑如果用户的系统无法跟上工作负载,那么您还需考虑如何优雅降级,以保证性能。让我们首先假定你会在每次绘制时,同时处理用户的输入,并更新游戏状态。我们稍后再细讲。
注意:改变主循环如何处理时间是非常困难的,在进行主循环之前,请仔细考虑您的需求。
如果你的游戏可以达到你所支持的任何硬件的最大刷新率,那么你的工作就变得相当容易了。 你可以简单地进行更新,渲染,然后在垂直同步之前什么都不用做。
/* * 以分号开始,以上例子中的代码行都是这样的 * 依靠自动分号插入(ASI)。浏览器可能会意外 * 认为这个整个例子从上一行继续。领先分号 * 标记我们的新行的开始,如果前一个不是空或终止。 * * 我们还假设MyGame是以前定义的。 */ ;(function(){ function main(tFrame){ MyGame.stopMain = window.requestAnimationFrame(main); update(Frame); //调用update方法。在我们的例子中,我们给它rAF的时间戳。 render(); } main(); //开始循环 })();
如果无法达到最大刷新率,可以调整画面质量设置以保持你的时间预算。这个概念最有名的例子是ID Software的RAGE游戏 ,这个游戏取消了用户的控制权,以使其计算时间保持在大约16ms(或大约60fps)。如果计算时间过长,则提交的解析度就降低,纹理和其他资源将无法加载或绘制等。这个(非网络)案例研究做了一些假设和折衷:
存在其他解决问题的方法。
一种常见的技术是以恒定的频率更新模拟,然后绘制尽可能多的(或尽可能少的)实际帧。更新方法可以继续循环,而不用考虑用户看到的内容。绘图方法可以查看最后的更新以及发生的时间。由于绘制知道何时表示,以及上次更新的模拟时间,它可以预测为用户绘制一个合理的框架。这是否比官方更新循环更频繁(甚至更不频繁)无关紧要。更新方法设置检查点,并且像系统允许的那样频繁地,渲染方法画出周围的时间。在Web标准中分离更新方法有很多种方法:
requestAnimationFrame
并更新 {{ domxref("window.setInterval") }}或{{domxref("window.setTimeout")}}。
requestAnimationFrame
和更新一个setInterval
或setTimeout
一个Web工作者。
requestAnimationFrame
并使用它来戳一个包含更新方法的Web Worker,其中包含要计算的刻度数(如果有的话)。
requestAnimationFrame
被调用并且不会污染主线程,加上你不依赖于老式的方法。再次,这比以前的两个选项更复杂一些,并且开始每个更新将被阻止,直到浏览器决定启动rAF回调。这些方法中的每一种都有类似的权衡:
单独的更新和绘图方法可能如下面的示例。为了演示,该示例基于第三个项目符号,只是不使用Web Workers进行可读性(而且我们诚实地说可写性)。
注意:这个例子,具体来说,需要进行技术审查。
/* * 以分号开始,以上例子中的代码行都是这样的 * 依靠自动分号插入(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){ var timeSinceTick = tFrame - MyGame.lastTick; numTicks = Math.floor(timeSinceTick / MyGame.tickLength); } queueUpdates(numTicks); render(tFrame); MyGame.lastRender = tFrame; } function queueUpdates(numTicks){ for(var i = 0; i <numTicks; i ++){ MyGame.lastTick = 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,声音,网络同步,以及你游戏可能需要。