内容


HTML5 2D 游戏开发

图形和动画

将事物绘制到画布上并让其运动起来

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: HTML5 2D 游戏开发

敬请期待该系列的后续内容。

此内容是该系列的一部分:HTML5 2D 游戏开发

敬请期待该系列的后续内容。

图形和动画是任何视频游戏最根本的方面,所以在本文中,我将从 Canvas2D API 的简要介绍开始,对 Snail Bait 的中央动画的实现进行讨论。在本文中,您将学习如何:

  • 将图像和图形基元绘制到画布上
  • 创建流畅的、无闪烁的动画
  • 实现游戏循环
  • 以帧数每秒为单位监视动画的速度
  • 滚动游戏的背景
  • 使用视差来模拟三维效果
  • 实现基于时间的运动

本文中所讨论的代码的最终结果如图 1 所示:

图 1. 滚动背景并监视帧速率
滚动 Snail Bait 的背景并监视帧速率
滚动 Snail Bait 的背景并监视帧速率

背景和平台水平滚动。这些平台在前景中,所以它们的移动明显快于背景,这样会形成一个温和的视差效果。在游戏开始时,背景由右至左滚动。在结束某个级别时,背景和平台开始逆转方向。

在开发的这个阶段,跑步者不动。此外,游戏还没有经过碰撞检测,所以当跑步者的下面没有平台时,她会漂浮在半空中。

最后,游戏画布的上方和左侧的图标会显示剩余生命的数量(如 本系列第一篇文章中的图 1 所示)。目前,该游戏会在这个位置上显示当前动画速度(以帧数每秒为单位)。

在继续后面的操作之前,您可能想尝试创建一个这类游戏,因为它就在 图 1 里;如果您创建了这样的游戏,就会更容易理解相关的代码。(请参阅 下载,获得本期的 Snail Bait 实现。)

HTML5 Canvas 概述

Canvas 2D 上下文提供了一个广泛的图形 API,让您可以在平台视频游戏中实现文本编辑器中的一切。在我撰写这篇文章的时候,该 API 包含了超过 30 个方法,但 Snail Bait 只使用了其中的极少数,如表 1 所示:

表 1. Snail Bait 使用的 Canvas 2D 上下文方法
方法描述
drawImage() 您可以在画布的某个特定位置上绘制全部或部分图像,也可以绘制另一个画布或来自 video 元素的一个帧。
save() 在堆栈上保存上下文属性。
restore() 将上下文属性移出堆栈,并将它们应用于上下文。
strokeRect() 绘制一个未填充的矩形。
fillRect() 填充一个矩形。
translate() 平移坐标系。这是一个很强大的方法,在许多不同场景中都很有用。Snail Bait 中的所有滚动都是利用这一个方法调用来实现的。

除平台之外,Snail Bait 中的所有内容都是一个图像。背景、跑步者以及所有好人和坏人都是游戏使用 drawImage() 方法绘制的图像。

最终,Snail Bait 将使用 spritesheet(单个图像包含游戏的所有图形),但现在,我对背景和跑步者分别使用不同的图像。我使用 清单 1 所示的函数绘制跑步者:

清单 1. 绘制跑步者
function drawRunner() {
   context.drawImage(runnerImage,                                        // image
                     STARTING_RUNNER_LEFT,                               // canvas left
                     calculatePlatformTop(runnerTrack) - RUNNER_HEIGHT); // canvas top
}

drawRunner() 函数将三个参数传递给了 drawImage():一个图像、左侧坐标和顶部坐标,将在画布的这个位置上绘制图像。左侧坐标是一个常数,而顶部坐标由跑步者所驻留的平台决定。

我以类似的方式绘制背景,如清单 2 所示:

清单 2. 绘制背景
function drawBackground() {
   context.drawImage(background, 0, 0);
}

清单 2 中的 drawBackground() 函数在画布的 (0,0) 绘制背景图像。稍后,我会在本文中修改该函数,以便滚动背景。

绘制平台(它们不是图像)需要更广泛地使用 Canvas API,如清单 3 所示:

清单 3. 绘制平台
var platformData = [
    // Screen 1.......................................................
    {
       left:      10,
       width:     230,
       height:    PLATFORM_HEIGHT,
       fillStyle: 'rgb(150,190,255)',
       opacity:   1.0,
       track:     1,
       pulsate:   false,
    },
    ...
 ],
 ...

function drawPlatforms() {
   var data, top;

   context.save(); // Save the current state of the context

   context.translate(-platformOffset, 0); // Translate the coord system for all platforms
   
   for (var i=0; i < platformData.length; ++i) {
      data = platformData[i];
      top = calculatePlatformTop(data.track);

      context.lineWidth   = PLATFORM_STROKE_WIDTH;
      context.strokeStyle = PLATFORM_STROKE_STYLE;
      context.fillStyle   = data.fillStyle;
      context.globalAlpha = data.opacity;

      context.strokeRect(data.left, top, data.width, data.height);
      context.fillRect  (data.left, top, data.width, data.height);
   }

   context.restore(); // Restore context state saved above
}

清单 3 中的 JavaScript 定义一个名称为 platformData 的数组。该数组中的每个对象代表着描述一个独立平台的元数据。

drawPlatforms() 函数使用 Canvas 上下文的 strokeRect()fillRect() 方法来绘制平台矩形。这些矩形的特征存储在 platformData 数组内的对象中,用于设置上下文的填充风格和 globalAlpha 属性,该属性设置您之后在画布上绘制的任何图形的不透明度。

调用 context.translate() 将画布的坐标系(如图 2 所示)在水平方向平移指定数量的像素。该平移和属性设置是临时的,因为这些操作是在 context.save()context.restore() 调用之间执行的。

图 2. 默认的 Canvas 坐标系
默认的 Canvas 坐标系
默认的 Canvas 坐标系

默认情况下,坐标系的原点位于画布的左上角。您可以使用 context.translate() 移动坐标系的原点。

我会在 滚动背景 中讨论如何使用 context.translate() 滚动背景。但现在,您几乎已经知道了实现 Snail Bait 需要了解的与 HTML5 Canvas 有关的一切内容。在本系列的其余部分中,我将侧重于 HTML5 游戏开发的其他方面,从动画开始。

HTML5 动画

从根本上讲,实现动画很简单:您反复绘制一个图像序列,看起来就象对象在以某种方式运动。这意味着您必须实现一个定期绘制图像的循环。

传统上,会使用 setTimeout() 或如清单 4 所示的 setInterval() 在 JavaScript 中实现动画循环:

清单 4. 使用 setInterval() 实现动画
setInterval( function (e) { // Don't do this for time-critical animations
   animate();               // A function that draws the current animation frame
}, 1000 / 60);              // Approximately 60 frames/second (fps)

毫无疑问,清单 4 中的代码通过反复调用一个绘制下一个动画帧的 animate() 函数来生成一个动画;然而,您可能会得到不满意的结果,因为 setInterval()setTimeout() 完全不知道如何制作动画。(注:您必须实现 animate() 函数;它不属于 Canvas API。)

清单 4 中,我将时间间隔设置为 1000/60 毫秒,这相当于大约每秒 60 帧。这个数字是我对最佳帧速率的最佳估值,它可能不是一个很好的值,但是,因为 setInterval()setTimeout() 完全不了解动画,所以由我指定帧速率。浏览器肯定比我更了解何时绘制下一个动画帧,因此,如果改为由浏览器指定帧速率,会产生更好的结果。

使用 setTimeoutsetInterval() 甚至有一个更严重的缺陷。虽然您传递以毫秒为单位指定的这些方法的时间间隔,但这些方法没有精确到毫秒;事实上,根据 HTML 规范,这些方法(为了节约资源)慷慨地拉长您指定的时间间隔。

为了避免这些缺陷,对于时间要求苛刻的动画,不应使用 setTimeout()setInterval();而是应该使用 requestAnimationFrame()

requestAnimationFrame()

Timing control for script-based animations 规范(请参阅 参考资料)中,W3C 在 window 对象上定义了一个名称为 requestAnimationFrame() 的方法。与 setTimeout()setInterval() 不同,requestAnimationFrame() 是专门用于实现动画的。因此,它不会具有与 setTimeout()setInterval() 有关的任何缺点。而且它简单易用,如 清单 5 所示:

清单 5. 使用 requestAnimationFrame() 实现动画
function animate(time) {           // Animation loop
   draw(time);                     // A function that draws the current animation frame
   requestAnimationFrame(animate); // Keep the animation going
};

requestAnimationFrame(animate);    // Start the animation

您可以将 requestAnimationFrame() 作为一个参考传递给回调函数,当浏览器准备好绘制下一个动画帧时,它就会调用这个回调函数。为了维持动画,回调函数还会调用 requestAnimationFrame()

正如您在 清单 5 中所见,浏览器会将一个 time 参数传递给您的回调函数。您可能会疑惑该 time 参数究竟有何意义。它是当前时间,还是浏览器绘制下一个动画帧的时间?

令人惊讶的是,这个时间并没有固定的定义。您惟一可以肯定的是,对于任何给定的浏览器,它试着代表着同样的事情;因此,您可以使用它来计算两帧之间的时间间隔,我会在 以 fps 计算动画速率 中说明这一点。

一个 requestAnimationFrame() polyfill

从许多方面来看,HTML5 是程序员的乌托邦。没有专用的 API,开发人员使用 HTML5 在无处不在的浏览器中实现跨平台运行的应用程序。规范发展迅速,不断采用新技术,同时改进现有的功能。

然而,新技术要实行规范,往往是通过特定浏览器现有的功能来实现的。浏览器厂商通常为这样的功能添加了前缀,使它们不会干扰其他浏览器的实现;例如,requestAnimationFrame() 最初被 Mozilla 实现为 mozRequestAnimationFrame()。然后 WebKit 实现了它,将其函数命名为 webkitRequestAnimationFrame()。最后,W3C 将它标准化为 requestAnimationFrame()

供应商提供了对前缀实现以及标准实现的不同支持,这使得新功能的使用变得非常棘手,所以 HTML5 社区发明了一种被称为 polyfill 的东西。Polyfill 针对特定功性确定浏览器的支持级别,如果浏览器已经实现了该功能,您就可以直接访问它,否则,浏览器会向您提供一个暂时尽量模仿标准功能的实现。

Polyfill 易于使用,但实现起来可能比较复杂。清单 6 演示了 requestAnimationFrame() 的一个 polyfill 的实现:

Listing 6. requestNextAnimationFrame() polyfill
// Reprinted from Core HTML5 Canvas

window.requestNextAnimationFrame =
   (function () {
      var originalWebkitRequestAnimationFrame = undefined,
          wrapper = undefined,
          callback = undefined,
          geckoVersion = 0,
          userAgent = navigator.userAgent,
          index = 0,
          self = this;

      // Workaround for Chrome 10 bug where Chrome
      // does not pass the time to the animation function
      
      if (window.webkitRequestAnimationFrame) {
         // Define the wrapper

         wrapper = function (time) {
           if (time === undefined) {
              time = +new Date();
           }
           self.callback(time);
         };

         // Make the switch
          
         originalWebkitRequestAnimationFrame = window.webkitRequestAnimationFrame;    

         window.webkitRequestAnimationFrame = function (callback, element) {
            self.callback = callback;

            // Browser calls the wrapper and wrapper calls the callback
            
            originalWebkitRequestAnimationFrame(wrapper, element);
         }
      }

      // Workaround for Gecko 2.0, which has a bug in
      // mozRequestAnimationFrame() that restricts animations
      // to 30-40 fps.

      if (window.mozRequestAnimationFrame) {
         // Check the Gecko version. Gecko is used by browsers
         // other than Firefox. Gecko 2.0 corresponds to
         // Firefox 4.0.
         
         index = userAgent.indexOf('rv:');

         if (userAgent.indexOf('Gecko') != -1) {
            geckoVersion = userAgent.substr(index + 3, 3);

            if (geckoVersion === '2.0') {
               // Forces the return statement to fall through
               // to the setTimeout() function.

               window.mozRequestAnimationFrame = undefined;
            }
         }
      }
      
      return window.requestAnimationFrame   ||
         window.webkitRequestAnimationFrame ||
         window.mozRequestAnimationFrame    ||
         window.oRequestAnimationFrame      ||
         window.msRequestAnimationFrame     ||

         function (callback, element) {
            var start,
                finish;


            window.setTimeout( function () {
               start = +new Date();
               callback(start);
               finish = +new Date();

               self.timeout = 1000 / 60 - (finish - start);

            }, self.timeout);
         };
      }
   )
();

清单 6 中实现的 polyfill 为 window 对象添加了一个名为 requestNextAnimationFrame() 的函数。函数名称中包含的 Next 使其能够区别于底层的 requestAnimationFrame() 函数。

该 polyfill 分配给 requestNextAnimationFrame() 的函数要么是 requestAnimationFrame()(如果浏览器支持它),要么是一个厂商前缀实现。如果浏览器对这两种方式均不支持,那么该函数会使用 setTimeout() 作为临时实现,以便尽可能地模仿 requestAnimationFrame()

几乎所有 polyfill 复杂性都涉及解决两个错误并在 return 语句前构成代码。第一个错误涉及 Chrome 10,它为时间传递一个 undefined 值。第二个错误涉及 Firefox 4.0,它将帧速率限制为每秒 35-40 帧。

虽然 requestNextAnimationFrame() polyfill 的实现很有趣,但不必理解它;相反,您只需要了解如何使用它即可,我会在下一节说明这一点。

游戏循环

既然图形和动画的先决条件已经得到满足,那么现在是时候让 Snail Bait 动起来了。首先,我在游戏的 HTML 中让 requestNextAnimationFrame() 包含 JavaScript,如清单 7 所示:

清单 7. HTML
<html>
   ...

   <body>
      ...

      <script src='js/requestNextAnimationFrame.js'></script>
      <script src='game.js'></script>
   </body>
</html>

清单 8 显示了游戏的动画循环,一般将该循环称为游戏循环

清单 8. 游戏循环
var fps;

function animate(now) { 
   fps = calculateFps(now); 
   draw();
   requestNextAnimationFrame(animate);
} 
          
function startGame() {
   requestNextAnimationFrame(animate);
}

startGame() 函数由背景图像的 onload 事件处理器调用,该函数通过调用 requestNextAnimationFrame() polyfill 启动游戏。在绘制游戏的第一个动画帧时,浏览器会调用 animate() 函数。

animate() 函数根据当前时间计算动画的帧速率。(参见 requestAnimationFrame(),了解有关 time 值的更多信息。)在计算帧速率之后,animate() 会调用一个 draw() 函数来绘制下一个动画帧。然后,animate() 调用 requestNextAnimationFrame() 来保持动画。

以 fps 计算动画速率

清单 9 显示了 Snail Bait 如何计算其帧速率,以及如何更新在 图 1 中显示的帧速率值:

清单 9. 计算 fps 并更新 fps 元素
var lastAnimationFrameTime = 0,
    lastFpsUpdateTime = 0,
    fpsElement = document.getElementById('fps');

function calculateFps(now) {
   var fps = 1000 / (now - lastAnimationFrameTime);
   lastAnimationFrameTime = now;

   if (now - lastFpsUpdateTime > 1000) {
      lastFpsUpdateTime = now;
      fpsElement.innerHTML = fps.toFixed(0) + ' fps';
   }

   return fps; 
}

帧速率只是自上一个动画帧开始计算的时间量,所以您也可以认为它是 frame per second(帧每秒)而不是 frames per second(每秒的帧数),这使得它不太像是一个速率。您可以采用更严格的方法,在几个帧中保持平均帧速率,但我还没有发现这样做的必要性,事实上,自最后一个动画帧起所用的时间就正是我在 基于时间的运动 中所需要的。

清单 9 还演示了一个重要的动画技术:执行任务的速率不同于动画速率。如果我在每一个动画帧都更新帧/秒值,则无法读取速率,因为它总是在不断变化;我将该设置改为每秒更新一次。

设置好了游戏循环和帧速率之后,我现在就准备开始滚动背景了。

滚动背景

Snail Bait 的背景(如图 3 所示)在水平方向缓慢滚动:

图 3. 背景图像
snail bait 背景图像
snail bait 背景图像

因为背景的左右边缘是完全相同的,所以背景可以无缝地滚动,如图 4 所示:

图 4. 完全相同的边缘实现平滑的过渡(左:右边缘;右:左边缘)
背景图像完全相同的边缘

Snail Bait 通过绘制两次背景,使背景无休止地滚动,如图 5 所示。 最初,如图 5 的顶部截屏所示,左侧的背景图像完全在屏幕上,而右侧的背景图像则完全在屏幕外。随着时间的推移,背景开始滚动,如图 5 的中部和底部截屏所示:

图 5. 从右侧滚动到左侧:半透明区域代表在屏幕外的图像部分
从右到左滚动 Snail Bait 的背景
从右到左滚动 Snail Bait 的背景

清单 10 显示了与 图 5 有关联的代码。drawBackground() 函数绘制两次图像,试着在同一位置上进行绘制。明显的滚动由不断将画布坐标系统平移到左侧而显示的,使得背景看似滚动到了右侧。

(您如何理解平移到左侧,但滚动到右侧的明显矛盾:将画布想象为在一张很长的纸上的一个空图片帧。这张纸就是坐标系,将它向左侧平移,就像将它在帧[画布]下面向左侧滑动左侧一样,因此,画布看起来就移动到右侧。)

清单 10. 滚动背景
var backgroundOffset; // This is set before calling drawBackground()

function drawBackground() {
   context.translate(-backgroundOffset, 0);

   // Initially onscreen:
   context.drawImage(background, 0, 0);

   // Initially offscreen:
   context.drawImage(background, background.width, 0);

   context.translate(backgroundOffset, 0);
}

setBackground() 函数在水平方向平移画布上下文 -backgroundOffset 像素。如果 backgroundOffset 是正数,那么背景会向右侧滚动;如果它是负数,那么背景会向左侧滚动。

在平移背景之后,drawBackground() 绘制了两次背景,然后将上下文平移回它在调用 drawBackground() 之前的位置。

一个看似琐碎的计算仍然保留:计算 backgroundOffset,这决定了为每个动画帧将画布的坐标系统平移多远。虽然该计算本身确实是琐碎的,但它具有重要的意义,所以我接下来将会讨论它。

基于时间的运动

动画的帧速率各不相同,但您不能让不同的帧速率影响您的动画运行速率。例如,无论动画的底层帧速率是多少,Snail Bait 都以 42 像素/秒的速度滚动背景。动画必须是基于时间的,这意味着速度以像素/秒指定,并且一定不能依赖于帧速率。

使用基于时间的运动来计算给定帧中移动某个对象的像素数,这很简单:用速度除以当前帧速率。速度(像素/秒)除以帧速率(帧/秒),结果是像素/帧,这意味着您在当前帧中需要将某个东西移动该数量的像素。

清单 11 显示了 Snail Bait 如何使用基于时间的运动来计算背景的位移:

清单 11. 设置背景位移
var BACKGROUND_VELOCITY = 42, // pixels / second
    bgVelocity = BACKGROUND_VELOCITY;

function setBackgroundOffset() {
   var offset = backgroundOffset + bgVelocity/fps; // Time-based motion

   if (offset > 0 && offset < background.width) {
      backgroundOffset = offset;
   }
   else {
      backgroundOffset = 0;
   }
}

setBackgroundOffset() 函数计算在当前帧中背景需移动的像素数,用背景的速度除以当前帧速率来计算它。然后将该值加到当前背景的位移。

为了持续滚动背景,setBackgroundOffset() 在该值小于 0 或大于背景宽度时将背景位移重置为 0

视差

如果您曾经坐在行驶中的汽车的乘客座位上,看着您的手刀穿过高速的电线杆,你就知道靠近自己的的东西的移动速度比距离远的东西更快。这就是所谓的 视差

Snail Bait 是一个 2D 游戏平台,但它使用温和的视差效果,使平台看起来仿佛比背景更接近您。该游戏通过滚动平台的速度明显快于后台而实现视差。

图 6 演示了 Snail Bait 如何实现该视差。上面的截屏显示了在一个特定时间点上的背景,而底部的截屏显示了一些动画帧后面的背景。从这两个截屏可以看出,在相同的时间长度中,平台的移动比背景远得多。

图 6. 视差:平台(近)滚动得比背景(远)更快
视差
视差

清单 12 显示了设置平台速度和位移的函数:

清单 12. 设置平台速度和位移
var PLATFORM_VELOCITY_MULTIPLIER = 4.35; 

function setPlatformVelocity() {
   // Platforms move 4.35 times as fast as the background
   platformVelocity = bgVelocity * PLATFORM_VELOCITY_MULTIPLIER; 
}

function setPlatformOffset() {
   platformOffset += platformVelocity/fps; // Time-based motion
}

回忆一下 清单 8,它列出了 Snail Bait 的游戏循环。该循环包括一个 animate() 函数,在需要绘制游戏的下一个动画帧时,浏览器会调用该函数。然后,该 animate() 函数调用一个 draw() 函数来绘制下一个动画帧。位于开发阶段中的 draw() 函数的代码如清单 13 所示:

清单 13. draw() 函数
function setOffsets() {
   setBackgroundOffset();
   setPlatformOffset();
}

function draw() {
   setPlatformVelocity();
   setOffsets();

   drawBackground();

   drawRunner();
   drawPlatforms();
}

draw() 函数设置了平台速度,并为背景和平台设置了位移。然后,它绘制背景、跑步者和平台。

结束语

在下期文章中,我会告诉您如何将 Snail Bait 代码封装在一个 JavaScript 对象中,以避免产生名称空间的冲突。我还将告诉您如何暂停游戏,包括如何在窗口失去焦点时自动暂停,以及如何在窗口重新获得焦点时通过倒计时重新启动游戏。您还可以了解如何用键盘控制该游戏的跑步者。接下来,我们将学习如何将 CSS 过渡和插话功能用于游戏循环。下次再见。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, Open source, Web development
ArticleID=844326
ArticleTitle=HTML5 2D 游戏开发: 图形和动画
publish-date=11072012