Game development

Anatomy of a video game

现在,接受,解释,计算,重复

每个视频游戏的目标是向用户(们)提供一个情况,接受他们的输入,将这些信号解释为动作,并计算出这些行为产生的新情况。游戏不断地循环着,不断地循环,直到有一些最终的条件发生(比如赢,输,或者退出去睡觉)。毫不奇怪,这种模式与游戏引擎的编程方式相对应。

具体情况取决于游戏。

一些游戏通过用户输入来驱动这个循环想象一下,你正在开发一种“发现这两种相似的图片之间的区别” - 类型游戏这些游戏向用户呈现两张图片;他们接受点击(或触摸);他们将输入解释为成功,失败,暂停,菜单交互等;最后,他们计算由输入产生的更新场景游戏循环由用户输入进行,并休眠,直到他们提供这更多的是一种基于回合的方法,它不要求每帧都有一个恒定的更新,只有当玩家作出反应时。

其他游戏需要控制每一个最小可能的时间片与上面相同的原则也适用于轻微的扭曲:每一个动画帧都在进行循环,用户输入的任何变化都会在第一个可用的回合中被捕获。这个曾经的帧模型是在一个叫做主循环的东西中实现的。如果你的游戏以时间为基础,那么这将是你的模拟会坚持的权威。

但它可能不需要每帧控制。您的游戏循环可能类似于找到之间的差异示例,并将其作为输入事件的基础。它可能需要输入和模拟时间。它甚至可以基于其他的东西进行循环。

现代JavaScript--正如下一节中所描述的 - 令人欣慰的是,它可以很容易地开发出一个高效的,可执行的每帧主循环当然,你的游戏只会像你所做的那样优化。如果某些东西看起来应该被附加到一个更罕见的事件中,那么将它从主循环中打破通常是个好主意(但并非总是如此)。

在JavaScript的中构建一个主循环

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

有些代码需要逐帧运行,所以为什么要把这个函数附加到浏览器的重绘计划之外的任何东西上呢?在网络上,window.requestAnimationFrame()将大多数的基础好的每帧主要循环回调函数在调用时必须传递给它这个回调函数将在下一次重新绘制之前的适当时间执行下面是一个简单的主循环的例子。:

window.main = function(){
  window.requestAnimationFrame(main);
  
  //无论你的主循环需要做什么
};

主要(); //开始循环。

注意:。在这里讨论的每个主()方法中,在执行循环内容之前,我们会安排一个新的requestAnimationFrame这不是偶然的,它被认为是最佳实践如果你的当前帧错过了它的垂直同步窗口,那么你可以及时地调用下一个requestAnimationFrame,以确保浏览器能够及时地接收到它。

上面的代码块有两个语句。第一个语句创建一个名为main()中的全局变量的函数。这个函数做一些工作,也告诉浏览器调用本身下帧window.requestAnimationFrame()。第二个语句调用主()函数,该函数是在第一个语句中定义的。因为main()中在第二个语句中被调用一次,并且它的每次调用都将自己放置到要执行下一个框架的队列中,main()中与您的帧率同步。

当然,这个循环并不完美。在讨论改变它的方法之前,让我们先讨论一下它已经做了什么。

将主循环的时间安排在浏览器对显示进行绘制时,允许您像浏览器想要绘制的那样频繁地运行您的循环。你可以控制每一帧动画。它也是非常简单的,因为main()中是唯一被循环的函数。第一人称射击(或类似的游戏)每一帧都会出现一个新的场景。你不可能比这更平滑和更有反应。

但是不要马上认为动画需要帧帧控制。简单的动画可以很容易地执行,甚至是GPU加速,使用CSS动画和浏览器中包含的其他工具。有很多这样的东西,它们会让你的生活更轻松。

在Java脚本中构建一个更好的主循环

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

/ *
*从分号开始,在这个例子的任何一行代码都依赖于自动的分号插入(ASI)。
*浏览器可能会意外地认为这整个示例都是从上一行延续的。
*如果前一个没有空或终止,那么前面的分号标志着我们的新行的开始。
* /

;(function(){
  function main(){
    window.requestAnimationFrame(main);
    
    //你的主循环内容。
  }
  
  主要(); //开始循环
})();

当浏览器遇到这个IIFE时,它将定义您的主循环,并立即为下一个框架排队。它不会被附加到任何对象和主(或主()方法中)在应用程序的其余部分中是一个有效的未使用的名称,可以自由地定义为其他的东西。

注意:在实践中,使用如 - 语句()来防止下一个requestAnimationFrame()更常见,而不是调用cancelAnimationFrame()。

对于第二个问题,停止主循环,您需要取消调用 window.cancelAnimationFrame() main()  。您将需要通过requestAnimationFrame()在它最后一次调用时传递的ID令牌()让我们假设您的游戏的函数和变量是建立在您称为MyGame的名称空间上的扩展我们的最后一个例子,主循环现在看起来是这样的。:

/ * 
*从分号开始,在这个例子的任何一行代码都依赖于自动的分号插入(ASI)。  
*浏览器可能会意外地认为这整个示例都是从上一行延续的。  
*如果前一个没有空或终止,那么前面的分号标志着我们的新行的开始
* 
*让我们假设MyGame是之前定义的。
* /

;(function(){
  function main(){
    MyGame.stopMain = window.requestAnimationFrame(main);
    
    //你的主循环内容。
  }
  
  主要(); //开始循环
})();

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

window.cancelAnimationFrame(MyGame.stopMain);

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

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

最终,在JavaScript的中,浏览器运行它自己的主循环,并且代码存在于某些阶段。上面的部分描述了主要的循环,它试图避免从浏览器中脱离控制。这些主要的方法附着于窗口。 requestAnimationFrame(),这对控制要求浏览器即将到来的框架。这取决于浏览器如何将这些请求与主循环关联起来.requestAnimationFrame的W3C规范并没有真正定义什么时候浏览器必须执行requestAnimationFrame回调。这可能是一个好处,因为它让浏览器厂商可以自由地体验他们认为最好的解决方案,并随着时间的推移进行调整。

现代版的Firefox和Google Chrome(可能还有其他版本)试图在框架的时间片的开始时将请求AnimationFrame回调与它们的主线程进行连接。因此,浏览器的主线程看起来就像下面这样:

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

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

当我们讨论预算时,许多网络浏览器都有一个称为高分辨率时间的工具.Date 对象不再是计时事件的识别方法,因为它非常不精确,可以由系统时钟进行修改。另一方面,高分辨率的时间计算自navigationStart(当上一个文档被卸载时)的毫秒数。这个值以小数的精度返回,精确到千分之一毫秒。它被称为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,from“function main(tFrame)”,现在是由rAF提供的DOMHighResTimeStamp。
  }
  
  主要(); //开始循环
})();

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

决定......时间

您将需要对您的主循环作出艰难的决定:。如何模拟准确的时间进度如果你要求每个帧控制,那么你需要确定你的游戏更新和绘制的频率您甚至可能希望以不同的速率进行更新和绘制。您还需要考虑如果用户的系统无法与工作负载保持一致,那么您的游戏将会如何地失败。让我们先假设您将处理用户输入,并在每次绘制时更新游戏状态。我们以后会再来。

注意:改变主循环处理时间的方式是一种调试噩梦,到处都是仔细考虑你的需求,在你的主循环之前。

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

如果你的游戏能达到你所支持的任何硬件的最大刷新速度,那么你的工作就相当容易了。您可以简单地更新,呈现,然后在垂直同步之前什么都不做。

/ *
*以分号开始,以上例子中的代码行都是这样的
*依靠自动分号插入(ASI)。浏览器可能会意外
*认为这个整个例子从上一行继续。领先分号
*标记我们的新行的开始,如果前一个不是空或终止。
*
*我们还假设MyGame是以前定义的。
* /

;(function(){
  function main(tFrame){
    MyGame.stopMain = window.requestAnimationFrame(main);
    
    更新(tFrame); //调用更新方法。在我们的例子中,我们给它rAF的时间戳。
    渲染();
  }
  
  主要(); //开始循环
})();

如何不能达到最大刷新速率,则可以根据您的时间预算调整质量设置。这个概念最有名的例子是id软件的游戏,RAGE 。为了使计算时间保持在大约16ms(大约60fps),这个游戏从用户中。删除了控制如果计算时间过长,分辨率就会降低,纹理和其他资产就无法加载或绘制,等等这个(非网)案例研究做出了一些假设和权衡:

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

处理可变刷新率需求的其他方法

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

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

  • 绘制requestAnimationFrame并更新 window.setIntervalwindow.setTimeout
    • 即使在未聚焦或最小化的情况下,使用处理器时间,也可能是主线程,并且可能是传统游戏循环的工件(但是很简单)。
  • 绘制requestAnimationFrame和更新一个setIntervalsetTimeout一个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方法会计算出来
*自从最近通过的更新刻度之后已经有多长时间了 
*外推(纯化妆品用于快速装置)。它画了场景。
*
* update()根据给定时间点计算游戏状态。应该永远
*以tickLength递增。它是游戏状态的权威。通过了
* DOMHighResTimeStamp代表它的时间(再次,总是 
*最后更新+ MyGame.tickLength,除非添加暂停功能等)
*
* setInitialState()执行在运行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;
  }

  函数queueUpdates(numTicks){
    for(var i = 0; i <numTicks; i ++){
      MyGame.lastTick = MyGame.lastTick + MyGame.tickLength; //现在lastTick是这个刻度。
      更新(MyGame.lastTick);
    }
  }

  MyGame.lastTick = performance.now();
  MyGame.lastRender = MyGame.lastTick; //假装第一次绘制是在第一次更新。
  MyGame.tickLength = 50; //这将使您的模拟运行在20Hz(50ms)
  
  setInitialState();
  主(performance.now()); //开始循环
})();

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

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

概要

我想清楚,上述任何一种,或者没有一种可能是最适合你的游戏。正确的决定完全取决于你愿意(和不愿意)做出的权衡。主要关心的是切换到另一个选项。幸运的是,我没有任何经验,但我听说这是一个令人难以置信的游戏的Whack-a-Mole。

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

  • 考虑差距“暂停”并跳过时间。
    • 你可能会看到这对大多数多人游戏来说都是有问题的。
  • 你可以模拟差距赶上。
    • 这可能是长时间丢失和/或复杂更新的问题。
  • 您可以从对等体或服务器恢复游戏状态。
    • 如果您的同龄人或服务器已过期,或者由于游戏是单人游戏,并且没有服务器,则不存在,这是无效的。

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

文档标签和贡献者