简介:
在本系列文章中,HTML5 专家 David Geary 将告诉您如何一步一个脚印地实现 HTML5 的
2D 视频游戏。本期文章将介绍 Canvas 图形和 HTML5 动画。您将了解如何绘制游戏的图形,以及如何让它们运动起来。您还将学习利用
HTML5 实现动画的最佳方式,如何滚动背景,以及如何实现视差,从而模拟三维效果。
图形和动画是任何视频游戏最根本的方面,所以在本文中,我将从 Canvas2D
API 的简要介绍开始,对 Snail Bait 的中央动画的实现进行讨论。在本文中,您将学习如何:
- 将图像和图形基元绘制到画布上
- 创建流畅的、无闪烁的动画
- 实现游戏循环
- 以帧数每秒为单位监视动画的速度
- 滚动游戏的背景
- 使用视差来模拟三维效果
- 实现基于时间的运动
本文中所讨论的代码的最终结果如图 1 所示:
图 1. 滚动背景并监视帧速率
背景和平台水平滚动。这些平台在前景中,所以它们的移动明显快于背景,这样会形成一个温和的视差效果。在游戏开始时,背景由右至左滚动。在结束某个级别时,背景和平台开始逆转方向。
在开发的这个阶段,跑步者不动。此外,游戏还没有经过碰撞检测,所以当跑步者的下面没有平台时,她会漂浮在半空中。
最后,游戏画布的上方和左侧的图标会显示剩余生命的数量(如 本系列第一篇文章中的图 1 所示)。目前,该游戏会在这个位置上显示当前动画速度(以帧数每秒为单位)。
即时模式图形
Canvas 是一个即时模式 图形系统,这意味着它会即时绘制您指定的内容,然后即时忘记。可伸缩矢量图形
(Scalable Vector Graphics, SVG) 等其他图形系统实现了保留模式 图形,这意味着它们会保存一个将要绘制的对象的列表。由于不会因保存显示列表而产生开销,所以
Canvas 的速度比 SVG 更快一些;但是,如果您想保存一个用户可以操作的对象列表,则必须自己在 Canvas
中实现该功能。
创建了这样的游戏,就会更容易理解相关的代码。(请参阅 下载,获得本期的 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 中的所有滚动都是利用这一个方法调用来实现的。 |
基于路径的图形
与 Apple 的 Cocoa 和 Adobe 的 Illustrator
类似,Canvas API 也是基于路径的,这意味着您可以先创建一条路径,然后描画或填充这条路径,在画布上绘制图形基元。strokeRect()
和 fillRect() 方法分别是描画或填充矩形的便捷方法。
除平台之外,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); } |
多用途的 drawImage() 方法
您可以使用 Canvas 2D 上下文的 drawImage() 方法在画布内的任何地方绘制一个完整的图像,或图像内的任何矩形区域,有选择地沿着路线缩放图像。除了图像外,您还可以利用
drawImage() 绘制另一个画布或一个 video 元素当前帧的内容。这只是其中一个方法,但drawImage()
还有助于便利地实现有趣的或者难以实现的应用程序(如视频编辑软件)。
清单 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 坐标系
默认情况下,坐标系的原点位于画布的左上角。您可以使用 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)
|
最佳实践
对于时间要求苛刻的动画,永远不要使用 setTimeout() 或 setInterval()。
毫无疑问,清单 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 在无处不在的浏览器中实现跨平台运行的应用程序。规范发展迅速,不断采用新技术,同时改进现有的功能。
Polyfill:面向未来的编程
过去,大多数跨平台软件都在最低的共同点实现。Polyfill 彻底颠覆了这一概念,它让您能够访问高级特性(如果它们可用),并在必要时回退到一个能力较低的实现。
然而,新技术要实行规范,往往是通过特定浏览器现有的功能来实现的。浏览器厂商通常为这样的功能添加了前缀,使它们不会干扰其他浏览器的实现;例如,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);
};
}
)
();
|
Polyfill:定义
单词 polyfill 是 polymorphism(多态)和 backfill(回填)的混合。类似于多态,polyfill
在运行时选择适当的代码,并且它们回填 (backfill) 了缺失的功能。
清单 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. 背景图像
因为背景的左右边缘是完全相同的,所以背景可以无缝地滚动,如图 4 所示:
图 4. 完全相同的边缘实现平滑的过渡(左:右边缘;右:左边缘)
Snail Bait 通过绘制两次背景,使背景无休止地滚动,如图 5
所示。 最初,如图 5 的顶部截屏所示,左侧的背景图像完全在屏幕上,而右侧的背景图像则完全在屏幕外。随着时间的推移,背景开始滚动,如图
5 的中部和底部截屏所示:
图 5. 从右侧滚动到左侧:半透明区域代表在屏幕外的图像部分
清单 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 过渡和插话功能用于游戏循环。下次再见。
参考资料
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:在此处查看规范。
|