将事物绘制到画布上并让其运动起来
在本系列文章中,HTML5 专家 David Geary 将告诉您如何一步一个脚印地实现 HTML5 的 2D 视频游戏。本期文章将介绍 Canvas 图形和 HTML5 动画。您将了解如何绘制游戏的图形,以及如何让它们运动起来。您还将学习利用 HTML5 实现动画的最佳方式,如何滚动背景,以及如何实现视差,从而模拟三维效果。
图形和动画是任何视频游戏最根本的方面,所以在本文中,我将从 Canvas2D API 的简要介绍开始,对 Snail Bait 的中央动画的实现进行讨论。在本文中,您将学习如何:
- 将图像和图形基元绘制到画布上
- 创建流畅的、无闪烁的动画
- 实现游戏循环
- 以帧数每秒为单位监视动画的速度
- 滚动游戏的背景
- 使用视差来模拟三维效果
- 实现基于时间的运动
本文中所讨论的代码的最终结果如图 1 所示:
图 1. 滚动背景并监视帧速率
![滚动 Snail Bait 的背景并监视帧速率](https://i-blog.csdnimg.cn/blog_migrate/f4c1aa82c854c4831ecad2c7876ad751.png)
背景和平台水平滚动。这些平台在前景中,所以它们的移动明显快于背景,这样会形成一个温和的视差效果。在游戏开始时,背景由右至左滚动。在结束某个级别时,背景和平台开始逆转方向。
在开发的这个阶段,跑步者不动。此外,游戏还没有经过碰撞检测,所以当跑步者的下面没有平台时,她会漂浮在半空中。
最后,游戏画布的上方和左侧的图标会显示剩余生命的数量(如 本系列第一篇文章中的图 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 坐标系](https://i-blog.csdnimg.cn/blog_migrate/09e2feca11c4c6d266ef6379c278496a.png)
默认情况下,坐标系的原点位于画布的左上角。您可以使用 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()
完全不了解动画,所以由我指定帧速率。浏览器肯定比我更了解何时绘制下一个动画帧,因此,如果改为由浏览器指定帧速率,会产生更好的结果。
使用 setTimeout
和 setInterval()
甚至有一个更严重的缺陷。虽然您传递以毫秒为单位指定的这些方法的时间间隔,但这些方法没有精确到毫秒;事实上,根据 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 背景图像](https://i-blog.csdnimg.cn/blog_migrate/321a6af063c5265f3b3c5fe72b60c648.png)
因为背景的左右边缘是完全相同的,所以背景可以无缝地滚动,如图 4 所示:
图 4. 完全相同的边缘实现平滑的过渡(左:右边缘;右:左边缘)
![背景图像完全相同的边缘](https://i-blog.csdnimg.cn/blog_migrate/5d6c3be5ef6d6b6b1508cac61cc89f86.png)
Snail Bait 通过绘制两次背景,使背景无休止地滚动,如图 5 所示。最初,如图 5 的顶部截屏所示,左侧的背景图像完全在屏幕上,而右侧的背景图像则完全在屏幕外。随着时间的推移,背景开始滚动,如图 5 的中部和底部截屏所示:
图 5. 从右侧滚动到左侧:半透明区域代表在屏幕外的图像部分
![从右到左滚动 Snail Bait 的背景](https://i-blog.csdnimg.cn/blog_migrate/fc4bf17a91efe1048a41726defa49098.png)
清单 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. 视差:平台(近)滚动得比背景(远)更快
![视差](https://i-blog.csdnimg.cn/blog_migrate/f114d635e69a776c29b1f555ff302cc0.png)
清单 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 过渡和插话功能用于游戏循环。下次再见。
下载
描述 | 名字 | 大小 |
---|---|---|
样例代码 | j-html5-game2.zip | 737KB |
参考资料
学习
- Core HTML5 Canvas:(David Geary,Prentice Hall,2012):David Geary 的书广泛地涵盖了 Canvas API 与游戏开发展。另外,请查看 配套的网站和博客。
- Snail Bait:在任何支持 HTML5 的浏览器中在线玩 Snail Bait 游戏(最好使用 Chrome version 18+)。
- Mind-blowing apps with HTML5 Canvas:观看 David Geary 在 Strange Loop 2011 上的演示。
- HTML5 Game Development:观看 David Geary 在 Norwegian Developer's Conference (NDC) 2011 上的演示。
- Platform games:在 Wikipedia 上阅读有关平台游戏的资料。
- Side-scroller video games:在 Wikipedia 上阅读有关 side-scroller 视频游戏的资料。
- HTML5 基础:利用此 developerWorks 知识路径学习 HTML5 基础知识。
- Timing control for script-based animations:在此处查看规范。