原文:Pro HTML5 Games
协议:CC BY-NC-SA 4.0
零、简介
欢迎来到职业 HTML5 游戏。
在写这本书的时候,我想创建一个资源,我希望有人在我开始学习游戏编程的时候给过我这个资源。
不像其他有你永远不会用到的抽象例子的书,这本书将直接向你展示如何使用 HTML5 制作完整的游戏。
我特别选择了物理引擎游戏和即时战略游戏作为例子,因为在这两者之间,这些类型包含了构建当今流行的大多数游戏类型所需的所有元素。
随着您的学习,您将学习在 HTML5 中创建游戏所需的所有基本元素,然后了解这些元素如何组合在一起形成专业外观的游戏。
在这本书结束时,我希望你会带着信心和资源离开,开始在 HTML5 中制作你自己的令人惊奇的游戏。
这本书是给谁的
Pro HTML5 Games 面向已经有一些 HTML 和 JavaScript 编程经验的程序员,他们现在想学习利用 HTML5 的能力来构建看起来很棒的游戏,但不知道从哪里开始。
有用其他语言(比如 Flash)制作游戏的经验,并且想转向 HTML5 的读者也会在这本书里找到很多有用的信息。
如果你对自己的游戏编程技能没有信心,不要担心。这本书涵盖了构建这些游戏所需的所有要素,因此您可以跟随并学习用 HTML5 设计大型专业游戏。如果你跟不上,这本书还会提供补充学习的资源和参考资料。
有专门的 HTML5 基础章节,Box2D 引擎,寻路和转向,战斗和有效的敌人人工智能,和多人使用节点。JS with WebSockets,不管你有多少游戏编程经验,都应该从这本书里学到很多。
这本书的结构
Pro HTML5 Games 在 12 个章节的过程中,带你完成构建两个完整游戏的过程。
在前四章中,你将构建弗鲁特战争,这是一款基于 Box2D 引擎的物理游戏,类似于非常受欢迎的愤怒的小鸟。
第一章讨论了构建游戏所需的 HTML5 的基本元素,比如在画布上绘图和制作动画,播放音频,以及使用 sprite sheets。
第二章介绍了如何构建一个基本的游戏框架,包括闪屏、游戏菜单、资源加载器和带视差滚动的基本关卡。
第三章详细介绍了 Box2D 物理引擎,并展示了如何用 Box2D 来模拟一个游戏世界。
第四章展示了如何将游戏框架与 Box2D 引擎整合,加入声音,加入音乐,创建一个完整的工作物理游戏。
书中的第二款游戏是一款 RTS 游戏,既有单人战役模式,也有多人模式。您将在接下来的六章中构建单人战役。
第五章讲述了如何构建一个基本的游戏框架,包括启动画面、游戏菜单、资源加载器和使用鼠标平移的基本关卡。
第六章在游戏中加入不同的实体,如车辆、飞机和建筑。
第七章展示了如何结合寻路和转向步骤在游戏中加入智能单位运动。
第八章增加了一些元素,如经济和基于触发的系统,允许编写事件脚本。
第九章涵盖了在游戏中实现武器和战斗系统。
第十章通过展示如何使用目前开发的框架创建几个具有挑战性的单人游戏关卡来总结单人游戏。
最后,在最后两章中,你将看到如何构建 RTS 游戏的多人游戏组件。
第十一章讨论了使用 WebSocket API 和 Node.js 以及创建多人游戏大厅的基础知识。
第十二章介绍了使用锁步网络模型实现多人游戏的框架,以及在保持游戏同步的同时补偿网络延迟。
下载代码
本书中给出的例子的代码可以在 press 网站www.apress.com上找到。您可以在该书的信息页面的源代码/下载选项卡上找到链接。该选项卡位于页面相关标题部分的下方。
联系作者
如果您有任何问题或反馈,您可以通过作者网站上的专门页面联系作者,地址为www.adityaravishankar.com/pro-html5-games/。也可以通过发电子邮件到 prohtml5games@adityaravishankar.com 的找到他。
一、HTML5 和 JavaScript 基础
HTML5 ,HTML 标准的最新版本,为我们提供了许多改进交互性和媒体支持的新功能。这些新特性(如画布、音频和视频)使得无需 Flash 等第三方插件就能为浏览器制作相当丰富的交互式应用成为可能。
HTML5 规范目前正在制定中,浏览器仍在实现它的一些新功能。然而,大多数现代浏览器(Google Chrome、Mozilla Firefox、Internet Explorer 9+、Safari 和 Opera)已经支持我们构建一些非常棒的游戏所需的元素。
开始在 HTML5 中开发游戏所需要的只是一个好的文本编辑器来编写代码(我在 Mac 上使用 TextMate—macromates.com/
)和一个现代的、兼容 HTML5 的浏览器(我使用谷歌 Chrome—www.google.com/chrome
)。
HTML5 文件的结构与以前版本的 HTML 中的文件非常相似,只是在文件的开头有一个简单得多的 DOCTYPE 标记。清单 1-1 提供了一个非常基本的 HTML5 文件的框架,我们将用它作为本章剩余部分的起点。
执行这段代码需要将其保存为 HTML 文件,然后在 web 浏览器中打开该文件。如果你做的一切都正确,这个文件应该弹出消息“Hello World!”
清单 1-1。 基本 HTML5 文件骨架
<!DOCTYPE html>
<html>
<head>
<meta http-equiv = "Content-type" content = "text/html; charset = utf-8">
<title > Sample HTML5 File</title>
<script type = "text/javascript" charset = "utf-8">
// This function will be called once the page loads completely
function pageLoaded(){
alert('Hello World!');
}
</script>
</head>
<body onload = "pageLoaded();">
</body>
</html>
[外链图片转存中…(img-4z9ZlZiJ-1723738149454)] 注意我们使用主体的 onload 事件来调用我们的函数,这样我们就可以在开始使用它之前确定我们的页面已经被完全加载了。当我们开始操作像 canvas 和 image 这样的元素时,这将变得很重要。试图在浏览器完成加载之前访问这些元素会导致 JavaScript 错误。
在我们开始开发游戏之前,我们需要检查一些我们将用来创建游戏的基本构件。我们需要的最重要的是
- 画布元素,用于呈现形状和图像
- 音频元素,添加声音和背景音乐
- 图像元素,加载我们的游戏作品并显示在画布上
- 浏览器定时器功能和游戏循环来处理动画
画布元素
我们游戏中最重要的元素是新的画布元素。根据 HTML5 标准规范,“canvas 元素为脚本提供了一个依赖于分辨率的位图画布,可用于实时渲染图形、游戏图形或其他可视图像。”你可以在 www . whatwg . org/specs/we b-apps/current-work/multipage/the-canvas-element . html 找到完整的规范。
画布允许我们绘制线条、圆形和矩形等基本形状,以及图像和文本,并且已经针对快速绘制进行了优化。浏览器已经开始启用 2D 画布内容的 GPU 加速渲染,因此基于画布的游戏和动画运行速度很快。
使用 canvas 元素相当简单。将< canvas >标签放在我们之前创建的 HTML5 文件的主体中,如清单 1-2 中的所示。
清单 1-2。 创建画布元素
<canvas width = "640" height = "480" id = "testcanvas" style = "border:black 1px solid;">
Your browser does not support HTML5 Canvas. Please shift to another browser.
</canvas>
清单 1-2 中的代码创建了一个 640 像素宽、480 像素高的画布。画布本身显示为空白区域(带有我们在样式中指定的黑色边框)。我们现在可以开始使用 JavaScript 在这个矩形内绘制。
[外链图片转存中…(img-S2WbO0vR-1723738149455)] 注意不支持 canvas 的浏览器会忽略< canvas >标签,渲染< canvas >标签内的任何东西。您可以使用此功能在旧浏览器上向用户显示替代的后备内容或一条消息,引导他们使用更现代的浏览器。
我们使用画布的主要渲染上下文在画布上进行绘制。我们可以使用 canvas 对象中的 getContext()方法来访问这个上下文。getContext()方法接受一个参数:我们需要的上下文类型。我们将在游戏中使用 2d 背景。
清单 1-3 展示了页面加载后,我们如何访问画布及其上下文。
清单 1-3。 访问画布上下文
<script type = "text/javascript" charset = "utf-8">
function pageLoaded(){
// Get a handle to the canvas object
var canvas = document.getElementById('testcanvas');
// Get the 2d context for this canvas
var context = canvas.getContext('2d');
// Our drawing code here. . .
}
</script>
[外链图片转存中…(img-UwN2bhWR-1723738149455)] 注意所有的浏览器都支持 2D 图形所需的 2d 环境。浏览器也用它们自己的专有名称实现其他上下文,比如用于 3D 图形的 experimental-webgl。
这个上下文对象为我们提供了大量的方法,我们可以使用这些方法在屏幕上绘制我们的游戏元素。这包括以下方法:
- 绘制矩形
- 绘制复杂的路径(直线、圆弧等)
- 绘图文本
- 自定义绘图样式(颜色、alpha、纹理等)
- 绘制图像
- 变换和旋转
我们将在下面的小节中更详细地研究这些方法。
绘制矩形
画布使用一个坐标系统,原点(0,0)在左上角,x 向右增加,y 向下增加,如图图 1-1 所示。
图 1-1。画布坐标系
我们可以使用上下文的矩形方法在画布上绘制一个矩形:
- fillRect(x,y,width,height):绘制一个实心矩形
- strokeRect(x,y,width,height):绘制矩形轮廓
- clearRect(x,y,width,height):清除指定的矩形区域并使其完全透明
清单 1-4。 在画布内绘制矩形
// FILLED RECTANGLES
// Draw a solid square with width and height of 100 pixels at (200,10)
context.fillRect (200,10,100,100);
// Draw a solid square with width of 90 pixels and height of 30 pixels at (50,70)
context.fillRect (50,70,90,30);
// STROKED RECTANGLES
// Draw a rectangular outline of width and height 50 pixels at (110,10)
context.strokeRect(110,10,50,50);
// Draw a rectangular outline of width and height 50 pixels at (30,10)
context.strokeRect(30,10,50,50);
// CLEARING RECTANGLES
// Clear a rectangle of width of 30 pixels and height 20 pixels at (210,20)
context.clearRect(210,20,30,20);
// Clear a rectangle of width 30 and height 20 pixels at (260,20)
context.clearRect(260,20,30,20);
清单 1-4 中的代码会在画布的左上角绘制多个矩形,如图图 1-2 所示。
图 1-2。在画布内绘制矩形
绘制复杂路径
当简单的盒子不够用时,context 有几种方法可以让我们画出复杂的形状:
- beginPath() :开始记录一个新形状
- closePath() :通过从当前绘制点到起点绘制一条线来关闭路径
- fill()、stroke() :填充或绘制记录形状的轮廓
- moveTo (x,y):将绘图点移动到 x,y
- lineTo (x,y):从当前绘制点到 x,y 绘制一条直线
- 圆弧 (x,y,半径,起始角度,终止角度,逆时针):在 x,y 处画一个指定半径的圆弧
使用这些方法,绘制复杂路径包括以下步骤:
- 使用 beginPath()开始记录新形状。
- 使用 moveTo()、lineTo()和 arc()创建形状。
- 或者,使用 closePath()关闭形状。
- 使用 stroke()或 fill()绘制轮廓或填充形状。使用 fill()会自动关闭任何打开的路径。
清单 1-5 将创建如图图 1-3 所示的三角形、弧线和形状。
清单 1-5。 在画布内绘制复杂的形状
// Drawing complex shapes
// Filled triangle
context.beginPath();
context.moveTo(10,120); // Start drawing at 10,120
context.lineTo(10,180);
context.lineTo(110,150);
context.fill(); // close the shape and fill it out
// Stroked triangle
context.beginPath();
context.moveTo(140,160); // Start drawing at 140,160
context.lineTo(140,220);
context.lineTo(40,190);
context.closePath();
context.stroke();
// A more complex set of lines. . .
context.beginPath();
context.moveTo(160,160); // Start drawing at 160,160
context.lineTo(170,220);
context.lineTo(240,210);
context.lineTo(260,170);
context.lineTo(190,140);
context.closePath();
context.stroke();
// Drawing arcs
// Drawing a semicircle
context.beginPath();
// Draw an arc at (400,50) with radius 40 from 0 to 180 degrees,anticlockwise
context.arc(100,300,40,0,Math.PI,true); //(PI radians = 180 degrees)
context.stroke();
// Drawing a full circle
context.beginPath();
// Draw an arc at (500,50) with radius 30 from 0 to 360 degrees,anticlockwise
context.arc(100,300,30,0,2*Math.PI,true); //(2*PI radians = 360 degrees)
context.fill();
// Drawing a three-quarter arc
context.beginPath();
// Draw an arc at (400,100) with radius 25 from 0 to 270 degrees,clockwise
context.arc(200,300,25,0,3/2*Math.PI,false); //(3/2*PI radians = 270 degrees) context.stroke();
清单 1-4 中的代码将创建图 1-3 中所示的三角形、圆弧和形状。
图 1-3。在画布内绘制复杂的形状
绘图文本
上下文还为我们提供了两种在画布上绘制文本的方法:
- strokeText(text,x,y):在(x,y)处绘制文本轮廓
- fillText(text,x,y):在(x,y)处填充文本
与其他 HTML 元素中的文本不同,canvas 中的文本没有 CSS 布局选项,如换行、填充和边距。然而,文本输出可以通过设置上下文字体属性以及笔画和填充样式来修改,如清单 1-6 所示。设置 font 属性时,可以使用任何有效的 CSS 字体属性。
清单 1-6。 在画布内绘制文本
// Drawing text
context.fillText('This is some text. . .',330,40);
// Modifying the font
context.font = '10 pt Arial';
context.fillText('This is in 10 pt Arial. . .',330,60);
// Drawing stroked text
context.font = '16 pt Arial';
context.strokeText('This is stroked in 16 pt Arial. . .',330,80);
清单 1-6 中的代码将绘制出图 1-4 中所示的文本。
图 1-4。在画布内绘制文本
自定义绘图样式(颜色和纹理)
到目前为止,我们绘制的所有东西都是黑色的,但这只是因为画布默认的绘制颜色是黑色。我们有其他选择。我们可以在画布上设计和定制线条、形状和文本。我们可以使用不同的颜色、线条样式、透明度,甚至填充形状内部的纹理
如果我们想将颜色应用于形状,有两个重要的属性可以使用:
- fillStyle:设置所有未来填充操作的默认颜色
- strokeStyle:设置所有未来描边操作的默认颜色
这两个属性都可以将有效的 CSS 颜色作为值。这包括 rgb()和 rgba()值以及颜色常数值。比如 context.fillStyle = " red 将为所有将来的填充操作(fillRect、fillText 和 fill)将填充颜色定义为红色。
清单 1-7 中的代码将绘制彩色矩形,如图图 1-5 所示。
清单 1-7。 用颜色和透明度绘制
// Set fill color to red
context.fillStyle = "red";
// Draw a red filled rectangle
context.fillRect (310,160,100,50);
// Set stroke color to green
context.strokeStyle = "green";
// Draw a green stroked rectangle
context.strokeRect (310,240,100,50);
// Set fill color to red using rgb()
context.fillStyle = "rgb(255,0,0)";
// Draw a red filled rectangle
context.fillRect (420,160,100,50);
// Set fill color to green with an alpha of 0.5
context.fillStyle = "rgba(0,255,0,0.6)";
// Draw a semi transparent green filled rectangle
context.fillRect (450,180,100,50);
图 1-5。用颜色和透明度绘图
绘图图像
虽然我们仅仅使用我们到目前为止已经介绍过的绘图方法就可以取得相当大的成就,但是我们仍然需要探索如何使用图像。学习如何绘制图像将使您能够绘制游戏背景、角色精灵和爆炸等效果,使您的游戏栩栩如生。
我们可以使用 drawImage()方法在画布上绘制图像和精灵。上下文为我们提供了这种方法的三种不同版本:
- drawImage(image,x,y):在画布上的(x,y)处绘制图像
- drawImage(image,x,y,width,height):将图像缩放到指定的宽度和高度,然后在(x,y)处绘制
- drawImage(image,sourceX,sourceY,sourceWidth,sourceHeight,x,y,Width,Height):从图像中剪切一个矩形(sourceX,sourceY,sourceWidth,sourceHeight),将其缩放到指定的宽度和高度,并在画布上的(x,y)处绘制它
在开始绘制图像之前,我们需要将图像加载到浏览器中。现在,我们将在 HTML 文件中的< canvas >标签后添加一个< img >标签:
<img src = "spaceship.png" id = "spaceship">
一旦图像被加载,我们就可以使用清单 1-8 中的代码来绘制它。
清单 1-8。 绘制图像
// Get a handle to the image object
var image = document.getElementById('spaceship');
// Draw the image at (0,350)
context.drawImage(image,0,350);
// Scaling the image to half the original size
context.drawImage(image,0,400,100,25);
// Drawing part of the image
context.drawImage(image,0,0,60,50,0,420,60,50);
清单 1-8 中的代码将绘制出图 1-6 所示的图像。
图 1-6。绘制图像
变换和旋转
context 对象有几种方法来转换用于绘制元素的坐标系。这些方法是
- translate(x,y):将画布及其原点移动到不同的点(x,y)
- 旋转(角度):围绕当前原点顺时针旋转画布一个角度(弧度)
- scale(x,y):以 x 和 y 的倍数缩放绘制的对象
这些方法的一个常见用途是在绘制对象或精灵时旋转它们。我们可以通过以下方式做到这一点
- 将画布原点平移到对象的位置
- 将画布旋转所需的角度
- 绘制对象
- 将画布恢复到原始状态
让我们在绘制之前先看看旋转的物体,如清单 1-9 所示。
清单 1-9。 先旋转物体再绘制它们
//Translate origin to location of object
context.translate(250, 370);
//Rotate about the new origin by 60 degrees
context.rotate(Math.PI/3);
context.drawImage(image,0,0,60,50,-30,-25,60,50);
//Restore to original state by rotating and translating back
context.rotate(−Math.PI/3);
context.translate(−240, -370);
//Translate origin to location of object
context.translate(300, 370);
//Rotate about the new origin
context.rotate(3*Math.PI/4);
context.drawImage(image,0,0,60,50,-30,-25,60,50);
//Restore to original state by rotating and translating back
context.rotate(−3*Math.PI/4);
context.translate(−300, -370);
清单 1-9 中的代码将绘制出图 1-7 中所示的两幅旋转后的船只图像。
图 1-7。旋转图像
注意除了旋转和平移回来,你还可以在开始转换之前首先使用 save()方法恢复画布状态,然后在转换结束时调用 restore()方法。
音频元素
使用 HTML5 音频元素是将音频文件嵌入网页的新标准方式。在这个元素出现之前,大多数页面使用嵌入式插件(如 Flash)播放音频文件。
音频元素可以在 HTML 中使用< audio >标签创建,也可以在 JavaScript 中使用音频对象创建。清单 1-10 中给出了一个例子。
***清单 1-10。***html 5<音频>标签
<audio src = "music.mp3" controls = "controls">
Your browser does not support HTML5 Audio. Please shift to a newer browser.
</audio>
注意不支持音频的浏览器会忽略<音频>标签,渲染<音频>标签内的任何东西。您可以使用此功能在旧浏览器上向用户显示替代的后备内容或一条消息,引导他们使用更现代的浏览器。
包含在清单 1-10 中的控件属性使浏览器显示一个简单的特定于浏览器的界面来播放音频文件(比如播放/暂停按钮和音量控件)。
音频元素还有其他几个属性,如下所示:
- 预加载:指定是否应该预加载音频
- 自动播放:指定是否在对象加载后立即开始播放音频
- 循环:指定音频结束后是否继续回放
目前浏览器支持三种流行的文件格式:MP3(MPEG 音频层 3)、WAV(波形音频)和 OGG (Ogg Vorbis)。需要注意的一点是,并非所有的浏览器都支持所有的音频格式。例如,由于许可问题,Firefox 不能播放 MP3 文件,但可以播放 OGG 文件。另一方面,Safari 支持 MP3,但不支持 OGG。表 1-1 显示了最流行的浏览器支持的格式。
表 1-1 。不同浏览器支持的音频格式
解决这一限制的方法是为浏览器提供可供选择的播放格式。音频元素允许在< audio >标签中有多个源元素,浏览器自动使用第一个识别的格式(见清单 1-11 )。
清单 1-11。<多音频>标签
<audio controls = "controls">
<source src = "music.ogg" type = "audio/ogg" />
<source src = "music.mp3" type = "audio/mpeg" />
Your browser does not support HTML5 Audio. Please shift to a newer browser.
</audio>
也可以通过使用 JavaScript 中的 Audio 对象来动态加载音频。Audio 对象允许我们根据需要加载、播放和暂停声音文件,这就是游戏将使用的内容(参见清单 1-12 )。
清单 1-12。 动态加载音频文件
<script>
//Create a new Audio object
var sound = new Audio();
// Select the source of the sound
sound.src = "music.ogg";
// Play the sound
sound.play();
</script>
同样,与< audio > HTML 标签一样,我们需要一种方法来检测浏览器支持哪种格式并加载适当的格式。Audio 对象为我们提供了一个名为 canPlayType() 的方法,该方法返回值"“、” maybe “或” probably "来表示对特定编解码器的支持。我们可以用它来创建一个简单的检查并加载适当的音频格式,如清单 1-13 所示。
清单 1-13。 测试音频支持
<script>
var audio = document.createElement('audio');
var mp3Support,oggSupport;
if (audio.canPlayType) {
// Currently canPlayType() returns: "", "maybe", or "probably"
mp3Support = "" ! = myAudio.canPlayType('audio/mpeg');
oggSupport = "" ! = myAudio.canPlayType('audio/ogg; codecs = "vorbis"');
} else {
//The audio tag is not supported
mp3Support = false;
oggSupport = false;
}
// Check for ogg, then mp3, and finally set soundFileExtn to undefined
var soundFileExtn = oggSupport?".ogg":mp3Support?".mp3":undefined;
if(soundFileExtn) {
var sound = new Audio();
// Load sound file with the detected extension
sound.src = "bounce" + soundFileExtn;
sound.play();
}
</script>
当文件准备好播放时,音频对象触发一个名为 canplaythrough 的事件。我们可以使用这个事件来跟踪声音文件的加载时间。清单 1-14 显示了一个例子。
清单 1-14。 等待音频文件加载
<script>
if(soundFileExtn) {
var sound = new Audio();
sound .addEventListener('canplaythrough', function(){
alert('loaded');
sound.play();
});
// Load sound file with the detected extension
sound.src = "bounce" + soundFileExtn;
}
</script>
我们可以用这个来设计一个音频预加载器,在开始游戏之前加载所有的游戏资源。我们将在接下来的几章中更详细地探讨这个观点。
图像元素
image 元素允许我们在 HTML 文件中显示图像。最简单的方法是使用< image >标签并指定一个 src 属性,如前面的清单 1-15 所示。
清单 1-15。<图像>标记
<img src = 'spaceship.png' id = 'spaceship' >
您还可以使用 JavaScript 通过实例化一个新的图像对象并设置它的 src 属性来动态加载图像,如清单 1-16 所示。
清单 1-16。 动态加载图像
var image = new Image();
image.src = 'spaceship.png';
您可以使用这两种方法中的任何一种来获取用于在画布上绘制的图像。
图像加载
游戏通常被编程为在开始之前等待所有图像加载。程序员经常做的一件事是显示进度条或状态指示器,显示图像加载的百分比。Image 对象为我们提供了一个 onload 事件,一旦浏览器加载完图像文件,该事件就会被触发。使用这个事件,我们可以跟踪图像加载的时间,如清单 1-17 中的例子所示。
清单 1-17。 等待图像加载
image.onload = function() {
alert('Image finished loading');
};
使用 onload 事件,我们可以创建一个简单的图像加载器来跟踪目前已经加载的图像(见清单 1-18 )。
清单 1-18。 简单的图像加载器
var imageLoader = {
loaded:true,
loadedImages:0,
totalImages:0,
load:function(url){
this.totalImages++;
this.loaded = false;
var image = new Image();
image.src = url;
image.onload = function(){
imageLoader.loadedImages++;
if(imageLoader.loadedImages === imageLoader.totalImages){
imageLoader.loaded = true;
}
}
return image;
}
}
这个图像加载器可以被调用来加载大量的图像(比方说在一个循环中)。使用 imageLoader.loaded 可以检查是否加载了所有图像,使用 loadedimg/totalImages 可以绘制百分比/进度条。
精灵表
当你的游戏有很多图像时,另一个问题是如何优化服务器加载这些图像的方式。游戏可能需要几十到几百张图片。即使是简单的即时战略(RTS)游戏也需要不同单位、建筑、地图、背景和效果的图像。对于单位和建筑,您可能需要多个版本的图像来表示不同的方向和状态;对于动画,您可能需要动画的每一帧都有一个图像。
在我早期的 RTS 游戏项目中,我为每个动画帧使用单独的图像,为每个单位和建筑使用单独的状态,最终有超过 1000 个图像。由于大多数浏览器一次只能同时发出几个请求,下载所有这些图像需要很长时间,服务器上的 HTTP 请求会过载。当我在本地测试代码时,这不是问题,但是当代码上传到服务器时,这就有点麻烦了。人们最终要等 5 到 10 分钟(有时更久)才能加载游戏,然后才能真正开始玩游戏。这就是雪碧床单的用武之地。
Sprite sheets 将一个对象的所有 Sprite(图像)存储在一个大的图像文件中。当显示图像时,我们计算想要显示的 sprite 的偏移量,并使用 drawImage()方法的能力来绘制图像的一部分。我们在本章中使用的 spaceship.png 图像是一个精灵表的例子。
查看清单 1-19 和清单 1-20 ,您可以看到绘制单独加载的图像和绘制加载到 sprite 表中的图像的例子。
清单 1-19。 绘制一幅图像单独载入
//First: (Load individual images and store in a big array)
// Three arguments: the element, and destination (x,y) coordinates.
var image = imageArray[imageNumber];
context.drawImage(image,x,y);
清单 1-20。 绘制加载到 Sprite 表中的图像
// First: (Load single sprite sheet image)
// Nine arguments: the element, source (x,y) coordinates,
// source width and height (for cropping),
// destination (x,y) coordinates, and
// destination width and height (resize).
context.drawImage (this.spriteImage, this.imageWidth*(imageNumber), 0, this.imageWidth, this.imageHeight, x, y, this.imageWidth, this.imageHeight);
以下是使用 sprite 工作表的一些优点:
- 更少的 HTTP 请求:一个有 80 张图片的单元(也就是 80 个请求)现在可以在一个 HTTP 请求中下载。
- 更好的压缩效果:将图像存储在单个文件中意味着文件头信息不会重复,而且合并后的文件大小比单个文件的总和要小得多。
- 更快的加载时间:随着 HTTP 请求和文件大小的显著降低,游戏的带宽使用率和加载时间也随之下降,这意味着用户不必为游戏加载等待很长时间。
动画:定时器和游戏循环
动画就是画一个物体,擦除它,然后在新的位置再画一次。最常见的处理方法是保存一个每秒被调用几次的绘图函数。在一些游戏中,还有一个独立的控制/动画功能,用于更新游戏中实体的移动,调用频率低于绘图例程。清单 1-21 显示了一个典型的例子。
清单 1-21。 典型动画和绘图循环
function animationLoop(){
// Iterate through all the items in the game
//And move them
}
function drawingLoop(){
//1\. Clear the canvas
//2. Iterate through all the items
//3\. And draw each item
}
现在我们需要找出一种方法,定期重复调用 drawingLoop()。实现这一点的最简单方法是使用两个计时器方法 setInterval()和 setTimeout()。setInterval(functionName,timeInterval)告诉浏览器以固定的时间间隔重复调用给定的函数,直到调用 clearInterval()函数。当我们需要停止动画时(当游戏暂停,或者已经结束),我们使用 clearInterval()。清单 1-22 显示了一个例子。
清单 1-22。 用 setInterval 调用绘图循环
// Call drawingLoop() every 20 milliseconds
var gameLoop = setInterval(drawingLoop,20);
// Stop calling drawingLoop() and clear the gameLoop variable
clearInterval(gameLoop);
setTimeout(functionName,timeInterval)告诉浏览器在给定的时间间隔后调用一次给定的函数,如清单 1-23 中的示例所示。
清单 1-23。 用 setTimeout 调用绘图循环
function drawingLoop(){
//1\. call the drawingLoop method once after 20 milliseconds
var gameLoop = setTimeout(drawingLoop,20);
//2\. Clear the canvas
//3\. Iterate through all the items
//4\. And draw them
}
当我们需要停止动画时(当游戏暂停,或者已经结束),我们可以使用 clearTimeout():
// Stop calling drawingLoop() and clear the gameLoop variable
clearTimeout(gameLoop);
requestimationframe〔??〕
虽然使用 setInterval()或 setTimeout()作为动画帧的方法确实有效,但浏览器供应商已经提出了一种专门用于处理动画的新 API。使用此 API 而不是 setInterval()的一些优点是浏览器可以执行以下操作:
- 将动画代码优化为单个回流和重绘周期,从而产生更流畅的动画
- 当选项卡不可见时暂停动画,从而减少 CPU 和 GPU 的使用
- 在不支持更高帧速率的机器上自动限制帧速率,或者在能够处理帧速率的机器上提高帧速率
不同的浏览器厂商对 API 中的方法都有自己专有的名称(比如微软的 msrequestAnimationFrame 和 Mozilla 的 mozRequestAnimationFrame)。然而,有一段简单的代码(见清单 1-24 )作为跨浏览器的 polyfill,为您提供了两种方法:requestAnimationFrame()和 cancelAnimationFrame()。
清单 1-24。 简单的 requestAnimationFrame Polyfill
(function() {
var lastTime = 0;
var vendors = ['ms', 'moz', 'webkit', 'o'];
for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
window.cancelAnimationFrame =
window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame'];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function(callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function() { callback(currTime + timeToCall); },
timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function(id) {
clearTimeout(id);
};
}());
注意既然我们不能保证帧速率(浏览器决定它调用我们的绘制循环的速度),我们需要确保动画对象在屏幕上以相同的速度移动,而与实际的帧速率无关。我们通过计算自上一个绘制周期以来的时间,并使用该计算来插入正在被动画化的对象的位置,来做到这一点。
一旦这个 polyfill 就位,就可以从 drawingLoop()方法中调用 requestAnimationFrame()方法,类似于 setTimeout()(见清单 1-25 )。
清单 1-25。 用 requestAnimationFrame 调用绘图循环
function drawingLoop(nowTime){
//1\. call the drawingLoop method whenever the browser is ready to draw again var gameLoop = requestAnimationFrame(drawingLoop);
//2\. Clear the canvas
//3\. Iterate through all the items
//4\. Optionally use nowTime and the last nowTime to interpolate frames
//5\. And draw them
}
当我们需要停止动画时(当游戏暂停,或者已经结束),我们可以使用 cancelAnimationFrame():
// Stop calling drawingLoop()and clear the gameLoop variable
cancelAnimationFrame(gameLoop);
本节已经介绍了给游戏添加动画的主要方法。在接下来的章节中,我们将会看到这些动画循环的实际实现。
摘要
在这一章中,我们看了构建游戏所需的 HTML5 的基本元素。我们讲述了如何使用 canvas 元素来绘制形状、编写文本和操作图像。我们研究了如何使用 audio 元素在不同的浏览器上加载和播放声音。我们还简要介绍了动画、预加载对象和使用精灵表的基础知识。
我们在这里讨论的主题只是一个起点,并不详尽。本章旨在快速复习 HTML5。当我们在接下来的章节中构建游戏时,我们将会更详细地讨论这些主题,包括完整的实现。
如果你跟不上,想要更详细地解释 JavaScript 和 HTML5 的基础知识,我会推荐你阅读 JavaScript 和 HTML5 的入门书籍,比如特里·麦克纳威的《绝对初学者的 JavaScript》和珍妮·迈耶的《HTML5 基本指南》。
现在我们已经有了基本的方法,让我们开始构建我们的第一个游戏。
二、创造一个基本的游戏世界
支持游戏的智能手机和手持设备的出现,重新激起了人们对简单益智和基于物理的游戏的兴趣,这些游戏可以在短时间内玩。这些游戏大多概念简单,关卡小,简单易学。这种类型中最受欢迎和最著名的游戏之一是愤怒的小鸟(由 Rovio Entertainment 开发),这是一款益智/策略游戏,玩家使用弹弓向敌人的猪射击小鸟。尽管前提相当简单,但这款游戏已经在全球超过 10 亿台设备上下载和安装。游戏使用物理引擎来逼真地模拟游戏世界中物体的抛掷、碰撞和破碎。
在接下来的三章中,我们将构建我们自己的基于物理学的益智游戏,拥有完整的可玩关卡。我们的游戏,弗鲁特战争,会有水果做主角,垃圾食品做敌人,关卡内还有一些易碎的结构。
我们将实现您在自己的游戏中需要的所有基本组件——闪屏、加载屏幕和预加载器、菜单屏幕、视差滚动、声音、使用 Box2D 物理引擎的真实物理以及记分牌。一旦你有了这个基本框架,你应该能够在你自己的益智游戏中重用这些想法。
所以让我们开始吧。
基本 HTML 布局
我们需要做的第一件事是创建基本的游戏布局。这将由几层组成:
- 闪屏:游戏页面加载时显示
- 游戏开始屏幕:允许玩家开始游戏或修改设置的菜单
- 加载/进度屏幕:每当游戏加载素材(如图像和声音文件)时显示
- 游戏画布:实际游戏层
- 记分牌:游戏画布上的一个覆盖物,显示一些按钮和分数
- *结束画面:*每一关结束时显示的画面
这些层中的每一层都将是一个 div 元素或一个 canvas 元素,我们将根据需要显示或隐藏它们。我们将使用 jQuery(jquery.com/
)来帮助我们完成这些操作任务。代码将被放置在图像和 JavaScript 代码的单独文件夹中。
创建闪屏和主菜单
我们从一个类似于第一章的框架 HTML 文件开始,并为我们的容器添加标记,如清单 2-1 所示。
清单 2-1。 【基本骨架】(index.html)加上图层
<!DOCTYPE html>
<html>
<head>
<meta http-equiv= "Content-type" content= "text/html; charset= utf-8">
<title > Froot Wars</title>
<script src= "js/jquery.min.js" type= "text/javascript" charset= "utf-8"> </script>
<script src= "js/game.js" type= "text/javascript" charset= "utf-8"> </script>
<link rel= "stylesheet" href= "styles.css" type= "text/css" media= "screen" charset= "utf-8">
</head>
<body>
<div id= "gamecontainer">
<canvas id= "gamecanvas" width= "640" height= "480" class= "gamelayer">
</canvas>
<div id= "scorescreen" class= "gamelayer">
<img id= "togglemusic" src= "img/sound.png">
<img src= "img/prev.png">
<span id= "score" > Score: 0</span>
</div>
<div id= "gamestartscreen" class= "gamelayer">
<img src= "img/play.png" alt= "Play Game"> <br>
<img src= "img/settings.png" alt= "Settings">
</div>
<div id= "levelselectscreen" class= "gamelayer">
</div>
<div id= "loadingscreen" class= "gamelayer">
<div id= "loadingmessage"> </div>
</div>
<div id= "endingscreen" class= "gamelayer">
<div>
<p id= "endingmessage" > The Level Is Over Message</p>
<p id= "playcurrentlevel"> <img src= "img/prev.png" > Replay Current Level</p>
<p id= "playnextlevel"> <img src= "img/next.png" > Play Next Level </p>
<p id= "showLevelScreen"> <img src= "img/return.png" > Return to Level Screen</p>
</div>
</div>
</div>
</body>
</html>
如你所见,我们定义了一个主 gamecontainer div 元素,它包含了每个游戏层 : gamestartscreen、levelselectscreen、loadingscreen、scorescreen、endingscreen,最后是 gamecanvas。
此外,我们还将在一个名为 styles.css 的外部文件中为这些层添加 CSS 样式,我们将从为游戏容器和开始菜单屏幕添加样式开始,如清单 2-2 所示。
清单 2-2。 容器和开始屏幕的 CSS 样式(styles.css)
#gamecontainer {
width:640px;
height:480px;
background: url(img/splashscreen.png);
border: 1px solid black;
}
.gamelayer {
width:640px;
height:480px;
position:absolute;
display:none;
}
/* Game Starting Menu Screen */
#gamestartscreen {
padding-top:250px;
text-align:center;
}
#gamestartscreen img {
margin:10px;
cursor:pointer;
}
到目前为止,我们已经在这个 CSS 样式表中完成了以下工作:
- 用 640 像素乘 480 像素的尺寸定义我们的游戏容器和所有游戏层。
- 确保所有游戏层都使用绝对定位(它们被放置在彼此之上)来定位,这样我们就可以根据需要显示/隐藏和叠加层。默认情况下,这些层都是隐藏的。
- 将我们的游戏闪屏图像设置为主容器背景,这样当页面加载时玩家首先看到的就是它。
- 为我们的游戏开始屏幕(开始菜单)添加一些样式,该屏幕有开始新游戏和更改游戏设置等选项。
注意所有的图片和源代码都可以在 Apress 网站的源代码/下载区获得(www.apress.com)。如果你想继续,你可以将所有的素材文件复制到一个新的文件夹中,然后自己构建游戏。
如果我们在浏览器中打开我们到目前为止创建的 HTML 文件,我们会看到游戏闪屏被黑色边框包围,如图 2-1 所示。
图 2-1。游戏启动画面
我们需要添加一些 JavaScript 代码来开始显示主菜单、加载屏幕和游戏。为了保持代码的整洁和易于维护,我们将把所有游戏相关的 JavaScript 代码保存在一个单独的文件(js/game.js)中。
我们从定义一个包含大部分游戏代码的游戏对象开始。我们首先需要一个 init()函数,它将在浏览器加载 HTML 文档后被调用。
清单 2-3。 一个基本的游戏对象(js/game.js)
var game = {
// Start initializing objects, preloading assets and display start screen
init: function(){
// Hide all game layers and display the start screen
$('.gamelayer').hide();
$('#gamestartscreen').show();
//Get handler for game canvas and context
game.canvas = $('#gamecanvas')[0];
game.context = game.canvas.getContext('2d');
},
}
清单 2-3 中的代码用 init()函数定义了一个名为 game 的 JavaScript 对象。现在,这个 init()函数只是隐藏所有游戏层,并使用 jQuery hide()和 show()函数显示游戏开始屏幕。它还保存了指向游戏画布和上下文的指针,因此我们可以使用 game.context 和 game.canvas 更容易地引用它们。
在确认页面已经完全加载之前试图操作 image 和 div 元素将导致不可预知的行为(包括 JavaScript 错误)。通过在 game.js 的顶部添加一小段 JavaScript 代码,我们可以在窗口加载后安全地调用这个 game.init()方法(如清单 2-4 所示)。
清单 2-4。 调用 game.init()方法安全地使用 load()事件
$(window).load(function() {
game.init();
});
当我们运行我们的 HTML 代码时,浏览器最初显示闪屏,然后在闪屏顶部显示游戏开始屏幕,如图图 2-2 所示。
图 2-2。游戏开始画面和菜单选项
级别选择
到目前为止,我们一直在等待游戏 HTML 文件完全加载,然后显示一个带有两个选项的主菜单。当用户点击播放按钮时,理想情况下,我们会显示一个级别选择屏幕,显示可用级别的列表。
在我们这样做之前,我们需要创建一个对象来处理级别。这个对象将包含级别数据和一些用于处理级别初始化的简单函数。我们将在 game.js 中创建这个 levels 对象,并将它放在 game 对象之后,如清单 2-5 所示。
清单 2-5。 简单关卡对象与关卡数据和功能
var levels = {
// Level data
data:[
{ // First level
foreground:'desert-foreground',
background:'clouds-background',
entities:[]
},
{ // Second level
foreground:'desert-foreground',
background:'clouds-background',
entities:[]
}
],
// Initialize level selection screen
init:function(){
var html = "";
for (var i = 0; i < levels.data.length; i++) {
var level = levels.data[i];
html + = ' < input type = "button" value = "' + (i + 1) + '" > ';
};
$('#levelselectscreen').html(html);
// Set the button click event handlers to load level
$('#levelselectscreen input').click(function(){
levels.load(this.value-1);
$('#levelselectscreen').hide();
});
},
// Load all data and images for a specific level
load:function(number){
}
}
levels 对象有一个数据数组,其中包含每个级别的信息。目前,我们存储的唯一级别信息是背景和前景图像。然而,我们会在每个关卡中加入英雄角色、反派角色和可破坏实体的信息。这将允许我们通过向数组中添加新的项目来非常快速地添加新的级别。
levels 对象包含的下一个东西是 init()函数,它遍历级别数据并为每个级别动态生成按钮。级别按钮 click 事件处理程序被设置为调用每个级别的 load()方法,然后隐藏级别选择屏幕。
我们将从 game.init()方法内部调用 levels.init()来生成关卡选择屏幕按钮。game.init()方法现在看起来如清单 2-6 所示。
清单 2-6。 初始化关卡来自 game.init()
init: function(){
// Initialize objects
levels.init();
// Hide all game layers and display the start screen
$('.gamelayer').hide();
$('#gamestartscreen').show();
//Get handler for game canvas and context
game.canvas = $('#gamecanvas')[0];
game.context = game.canvas.getContext('2d');
},
我们还需要为 styles.css 中的按钮添加一些 CSS 样式,如清单 2-7 所示。
清单 2-7。 级别选择屏幕的 CSS 样式
/* Level Selection Screen */
#levelselectscreen {
padding-top:150px;
padding-left:50px;
}
#levelselectscreen input {
margin:20px;
cursor:pointer;
background:url(img/level.png) no-repeat;
color:yellow;
font-size: 20px;
width:64px;
height:64px;
border:0;
}
我们需要做的下一件事是在游戏对象内部创建一个简单的 game.showLevelScreen()方法,它隐藏主菜单屏幕并显示等级选择屏幕,如清单 2-8 所示。
清单 2-8。 游戏对象内部的 showLevelScreen 方法
showLevelScreen:function(){
$('.gamelayer').hide();
$('#levelselectscreen').show('slow');
},
该方法首先隐藏所有其他游戏层,然后显示 levelselectscreen 层,使用慢速动画。
我们需要做的最后一件事是当用户单击播放按钮时调用 game.showLevelScreen()方法。我们通过从播放图像的 onclick 事件中调用方法来实现这一点:
<img src = "img/play.png" alt = "Play Game" onclick = "game.showLevelScreen()">
现在,当我们启动游戏并点击播放按钮时,游戏会检测关卡的数量,隐藏主菜单,并显示每个关卡的按钮,如图图 2-3 所示。
图 2-3。级别选择屏幕
目前,我们只显示了几个级别。然而,随着我们添加更多的级别,代码将自动检测级别并添加正确数量的按钮(由于 CSS,格式正确)。当用户单击这些按钮时,浏览器将调用我们尚未实现的 levels.load()按钮。
加载图像
在我们实现关卡本身之前,我们需要放置图像加载器和加载屏幕。这将允许我们以编程方式加载一个关卡的图像,并在所有资源加载完毕后开始游戏。
我们将设计一个简单的加载屏幕,其中包含一个动画 GIF 和一个进度条图像,上面的一些文本显示了到目前为止加载的图像数量。首先,我们需要将清单 2-9 中的 CSS 添加到 styles.css 中。
清单 2-9。 CSS 为加载屏幕
/* Loading Screen */
#loadingscreen {
background:rgba(100,100,100,0.3);
}
#loadingmessage {
margin-top:400px;
text-align:center;
height:48px;
color:white;
background:url(img/loader.gif) no-repeat center;
font:12px Arial;
}
这个 CSS 在游戏背景上添加了暗淡的灰色,让用户知道游戏当前正在处理一些东西,还没有准备好接收任何用户输入。它还以白色文本显示加载消息。
下一步是基于第一章中的代码创建一个 JavaScript 素材加载器。加载器将实际加载素材,然后更新 loadingscreen div.element。我们将在 game.js 中定义一个加载器对象,如清单 2-10 所示。
清单 2-10。 图像/声音资源加载器
var loader = {
loaded:true,
loadedCount:0, // Assets that have been loaded so far
totalCount:0, // Total number of assets that need to be loaded
init:function(){
// check for sound support
var mp3Support,oggSupport;
var audio = document.createElement('audio');
if (audio.canPlayType) {
// Currently canPlayType() returns: "", "maybe" or "probably"
mp3Support = "" != audio.canPlayType('audio/mpeg');
oggSupport = "" != audio.canPlayType('audio/ogg; codecs = "vorbis"');
} else {
//The audio tag is not supported
mp3Support = false;
oggSupport = false;
}
// Check for ogg, then mp3, and finally set soundFileExtn to undefined
loader.soundFileExtn = oggSupport?".ogg":mp3Support?".mp3":undefined;
},
loadImage:function(url){
this.totalCount++;
this.loaded = false;
$('#loadingscreen').show();
var image = new Image();
image.src = url;
image.onload = loader.itemLoaded;
return image;
},
soundFileExtn:".ogg",
loadSound:function(url){
this.totalCount++;
this.loaded = false;
$('#loadingscreen').show();
var audio = new Audio();
audio.src = url + loader.soundFileExtn;
audio.addEventListener("canplaythrough", loader.itemLoaded, false);
return audio;
},
itemLoaded:function(){
loader.loadedCount++;
$('#loadingmessage').html('Loaded ' + loader.loadedCount + ' of ' + loader.totalCount);
if (loader.loadedCount === loader.totalCount){
// Loader has loaded completely..
loader.loaded = true;
// Hide the loading screen
$('#loadingscreen').hide();
//and call the loader.onload method if it exists
if(loader.onload){
loader.onload();
loader.onload = undefined;
}
}
}
}
清单 2-10 中的素材加载器拥有我们在第一章中讨论过的相同元素,但是它是以一种更加模块化的方式构建的。它有以下组件:
- init()方法检测支持的音频文件格式并保存它。
- 加载图像和音频文件的两种方法—loadImage()和 loadSound()。这两种方法都会增加 totalCount 变量,并在调用时显示加载屏幕。
- 每次素材完成加载时调用的 itemLoaded()方法。此方法更新加载的计数和加载消息。一旦加载了所有的素材,加载屏幕就会隐藏,并调用一个可选的 loader.onload()方法(如果定义了的话)。这让我们可以分配一个回调函数,以便在图像加载后调用。
注意使用回调方法可以让我们在图像加载时等待,并在所有图像加载完毕后开始游戏。
在可以使用加载程序之前,我们需要从 game.init()内部调用 loader.init()方法,以便在游戏初始化时加载程序被初始化。game.init()方法现在看起来如清单 2-11 所示。
清单 2-11。 从 game.init() 初始化加载程序
init: function(){
// Initialize objects
levels.init();
loader.init();
// Hide all game layers and display the start screen
$('.gamelayer').hide();
$('#gamestartscreen').show();
//Get handler for game canvas and context
game.canvas = $('#gamecanvas')[0];
game.context = game.canvas.getContext('2d');
},
我们将通过调用两个加载方法之一来使用加载器—loadImage()或 loadSound() 。当这些加载方法中的任何一个被调用时,屏幕将显示如图图 2-4 所示的加载屏幕,直到所有的图像和声音被加载。
图 2-4。加载屏幕
注意通过为每个 div 设置不同的背景属性样式,你可以为每个屏幕选择不同的图像。
装载水平
现在我们已经有了一个图像加载器,我们可以开始加载关卡了。现在,让我们通过在 levels 对象中定义 load()方法来加载游戏背景、前景和弹弓图像,如清单 2-12 所示。
清单 2-12。 基本骨架为加载()方法内的关卡对象
// Load all data and images for a specific level
load:function(number){
// declare a new currentLevel object
game.currentLevel = {number:number,hero:[]};
game.score= 0;
$('#score').html('Score: ' + game.score);
var level = levels.data[number];
//load the background, foreground, and slingshot images
game.currentLevel.backgroundImage = loader.loadImage("img/" + level.background + ".png");
game.currentLevel.foregroundImage = loader.loadImage("img/" + level.foreground + ".png");
game.slingshotImage = loader.loadImage("img/slingshot.png");
game.slingshotFrontImage = loader.loadImage("img/slingshot-front.png");
//Call game.start() once the assets have loaded
if(loader.loaded){
game.start()
} else {
loader.onload = game.start;
}
}
load()函数创建一个 currentLevel 对象来存储加载的级别数据。到目前为止,我们只加载了三个图像。我们最终将使用这种方法来加载构建游戏所需的英雄、反派和积木。
最后要注意的是,一旦图像被加载,我们就调用 game.start()方法,要么立即调用它,要么设置 onload 回调。这个 start()方法是实际游戏将被绘制的地方。
制作游戏动画
正如在第一章中所讨论的,为了使我们的游戏动画化,我们将使用 requestAnimationFrame 每秒多次调用我们的绘图和动画代码。在我们可以使用 requestAnimationFrame 之前,我们需要将第一章中的 requestAnimation polyfill 函数放在 game.js 的顶部,这样我们就可以在我们的游戏代码中使用它,如清单 2-13 中的所示。
清单 2-13。 请求动画帧聚合填充
// Set up requestAnimationFrame and cancelAnimationFrame for use in the game code
(function() {
var lastTime = 0;
var vendors = ['ms', 'moz', 'webkit', 'o'];
for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
window.cancelAnimationFrame =
window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame'];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function(callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function() { callback(currTime + timeToCall); },
timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function(id) {
clearTimeout(id);
};
}());
接下来,我们使用 game.start()方法来设置动画循环,然后在 game.animate()方法中绘制关卡。代码如清单 2-14 所示。
清单 2-14。 游戏对象内部的 start()和 animate()函数
// Game mode
mode:"intro",
// X & Y Coordinates of the slingshot
slingshotX:140,
slingshotY:280,
start:function(){
$('.gamelayer').hide();
// Display the game canvas and score
$('#gamecanvas').show();
$('#scorescreen').show();
game.mode = "intro";
game.offsetLeft = 0;
game.ended = false;
game.animationFrame = window.requestAnimationFrame(game.animate,game.canvas);
},
handlePanning:function(){
game.offsetLeft++; // Temporary placeholder – keep panning to the right
},
animate:function(){
// Animate the background
game.handlePanning();
// Animate the characters
// Draw the background with parallax scrolling
game.context.drawImage(game.currentLevel.backgroundImage,game.offsetLeft/4,0,640,480,0,0,640,480);
game.context.drawImage(game.currentLevel.foregroundImage,game.offsetLeft,0,640,480,0,0,640,480);
// Draw the slingshot
game.context.drawImage(game.slingshotImage,game.slingshotX-game.offsetLeft,game.slingshotY);
game.context.drawImage(game.slingshotFrontImage,game.slingshotX-game.offsetLeft,game.slingshotY);
if (!game.ended){
game.animationFrame = window.requestAnimationFrame(game.animate,game.canvas);
}
}
同样,前面的代码包含两个方法,game.start()和 game.animate()。start()方法执行以下操作:
- 初始化一些我们在游戏中需要的变量——offset left 和 mode。offsetLeft 将用于围绕整个关卡平移游戏视图,mode 将用于存储游戏的当前状态(intro,wait for firing,fireing,fired)。
- 隐藏所有其他层并显示画布层和乐谱层,乐谱层是屏幕顶部的一个窄条,包含。
- 使用 window.requestAnimationFrame 设置游戏动画间隔以调用 animate()函数。
更大的方法 animate()将完成游戏中所有的动画和绘图。该方法从临时占位符开始,用于动画背景和字符。我们将在稍后实现这些。然后,我们使用 offsetLeft 变量来偏移图像的 x 轴,从而绘制背景和前景图像。最后,我们检查是否设置了 game.ended 标志,如果没有,使用 requestAnimationFrame 再次调用 animate()。我们可以稍后使用 game.ended 标志来决定何时停止动画循环。
需要注意的一点是,背景图像和前景图像相对于向左滚动的速度不同:背景图像移动的距离仅为前景图像移动距离的四分之一。这两层移动速度的差异会给我们一种错觉,当我们开始在关卡周围移动时,云离我们更远了。
最后,我们在前景中画出弹弓。
注意视差滚动是一种通过移动背景图像比前景图像慢来创造深度错觉的技术。这项技术利用了这样一个事实,即远处的物体看起来总是比近处的物体移动得慢。
在我们尝试这段代码之前,我们需要在 styles.css 中添加一些 CSS 样式来实现我们的乐谱屏幕面板,如清单 2-15 所示。
清单 2-15。 CSS 为乐谱屏幕面板
/* Score Screen */
#scorescreen {
height:60px;
font: 32px Comic Sans MS;
text-shadow: 0 0 2px #000;
color:white;
}
#scorescreen img{
opacity:0.6;
top:10px;
position:relative;
padding-left:10px;
cursor:pointer;
}
#scorescreen #score {
position:absolute;
top:5px;
right:20px;
}
与其他层不同,scorescreen 层只是我们游戏顶部的一个窄带。我们还增加了一些透明度,以确保图像(用于停止音乐和重新开始关卡)不会干扰游戏的其他部分。
当我们运行这段代码并尝试开始一个关卡时,我们应该会看到一个在右上角带有分数栏的基础关卡,如图图 2-5 所示。
图 2-5。一个基本水平与分数
我们粗略的平移实现目前会导致屏幕慢慢向右平移,直到图像不再可见。不要担心,我们将很快致力于更好的实现。
正如你所看到的,背景中的云比前景移动得慢。我们可能会添加更多的层,并以不同的速度移动它们,以建立更多的效果,但这两个图像很好地说明了这种效果。
现在我们已经有了一个基本的关卡,我们将添加处理鼠标输入的能力,并实现游戏状态的平移。
处理鼠标输入
JavaScript 有几个事件可以用来捕获鼠标输入——mousedown、mouseup 和 mousemove。为了简单起见,我们将使用 jQuery 在 game.js 中创建一个单独的鼠标对象来处理所有的鼠标事件,如清单 2-16 所示。
清单 2-16。 处理鼠标事件
var mouse = {
x:0,
y:0,
down:false,
init:function(){
$('#gamecanvas').mousemove(mouse.mousemovehandler);
$('#gamecanvas').mousedown(mouse.mousedownhandler);
$('#gamecanvas').mouseup(mouse.mouseuphandler);
$('#gamecanvas').mouseout(mouse.mouseuphandler);
},
mousemovehandler:function(ev){
var offset = $('#gamecanvas').offset();
mouse.x = ev.pageX - offset.left;
mouse.y = ev.pageY - offset.top;
if (mouse.down) {
mouse.dragging = true;
}
},
mousedownhandler:function(ev){
mouse.down = true;
mouse.downX = mouse.x;
mouse.downY = mouse.y;
ev.originalEvent.preventDefault();
},
mouseuphandler:function(ev){
mouse.down = false;
mouse.dragging = false;
}
}
这个鼠标对象有一个 init()方法,该方法为鼠标移动、按下或释放鼠标按钮以及鼠标离开画布区域设置事件处理程序。下面是我们使用的三种处理程序方法:
- mousemovehandler():使用 jQuery 的 offset()方法和事件对象的 pageX 和 pageY 属性计算鼠标相对于画布左上角的 x 和 y 坐标,并存储它们。它还检查鼠标移动时鼠标按钮是否被按下,如果是,则将拖动变量设置为 true。
- mousedownhandler():将 mouse.down 变量设置为 true,并存储按下鼠标按钮的位置。此外,它还包含一行额外的代码,用于防止单击按钮的默认浏览器行为。
- mouseuphandler():将向下和拖动变量设置为 false。如果鼠标离开画布区域,我们调用这个相同的方法。
现在我们已经有了这些方法,我们可以根据需要添加更多的代码来与游戏元素进行交互。我们还可以从游戏中的任何地方访问 mouse.x、mouse.y、mouse.dragging 和 mouse.down 属性。和之前所有的 init()方法一样,我们从 game.init()调用这个方法,所以它现在看起来如清单 2-17 所示。
清单 2-17。 从 game.init()初始化鼠标
init: function(){
// Initialize objects
levels.init();
loader.init();
mouse.init();
// Hide all game layers and display the start screen
$('.gamelayer').hide();
$('#gamestartscreen').show();
//Get handler for game canvas and context
game.canvas = $('#gamecanvas')[0];
game.context = game.canvas.getContext('2d');
},
有了这些功能,现在让我们实现一些基本的游戏状态和平移。
定义我们的游戏状态
还记得我们之前在创建 game.start()时简单提到的 game.mode 变量吗?好吧,这就是它出现的原因。我们将在这个变量中存储游戏的当前状态。我们期望游戏经历的一些模式或状态如下:
- 介绍:关卡已经载入,游戏将会在关卡周围移动一次,向玩家展示关卡中的所有东西。
- 加载下一个英雄:游戏检查是否有另一个英雄加载到弹弓上,如果有,加载这个英雄。如果我们用完了英雄或者所有的反派都被消灭了,关卡就结束了。
- 等待开火:游戏回到弹弓区域,等待玩家发射“英雄”此时,我们正在等待用户点击英雄。用户也可以选择用鼠标拖动画布屏幕来在该级别周围平移。
- 开火:这发生在用户点击英雄之后,释放鼠标按钮之前。此时,我们正在等待用户拖动鼠标来决定射击英雄的角度和高度。
- 触发:这发生在用户释放鼠标按钮之后。此时,我们启动 hero,让物理引擎处理一切,而用户只是观看。游戏将平移,以便用户可以尽可能地遵循英雄的路径。
我们可以根据需要实现更多的状态。关于这些不同的状态,需要注意的一点是,一次只能有一种状态,从一种状态转换到另一种状态有明确的条件,以及在每种状态下可能发生的情况。这种构造在计算机科学中被普遍称为有限状态机 。我们将使用这些状态为我们的平移代码创建一些简单的条件,如清单 2-18 所示。所有这些代码都放在 start()方法之后的游戏对象中。
清单 2-18。 使用游戏模式实现平移
// Maximum panning speed per frame in pixels
maxSpeed:3,
// Minimum and Maximum panning offset
minOffset:0,
maxOffset:300,
// Current panning offset
offsetLeft:0,
// The game score
score:0,
//Pan the screen to center on newCenter
panTo:function(newCenter){
if (Math.abs(newCenter-game.offsetLeft-game.canvas.width/4) > 0
&& game.offsetLeft < = game.maxOffset && game.offsetLeft > = game.minOffset){
var deltaX = Math.round((newCenter-game.offsetLeft-game.canvas.width/4)/2);
if (deltaX && Math.abs(deltaX) > game.maxSpeed){
deltaX = game.maxSpeed*Math.abs(deltaX)/(deltaX);
}
game.offsetLeft + = deltaX;
} else {
return true;
}
if (game.offsetLeft < game.minOffset){
game.offsetLeft = game.minOffset;
return true;
} else if (game.offsetLeft > game.maxOffset){
game.offsetLeft = game.maxOffset;
return true;
}
return false;
},
handlePanning:function(){
if(game.mode=="intro"){
if(game.panTo(700)){
game.mode = "load-next-hero";
}
}
if(game.mode=="wait-for-firing"){
if (mouse.dragging){
game.panTo(mouse.x + game.offsetLeft)
} else {
game.panTo(game.slingshotX);
}
}
if (game.mode=="load-next-hero"){
// TODO:
// Check if any villains are alive, if not, end the level (success)
// Check if there are any more heroes left to load, if not end the level (failure)
// Load the hero and set mode to wait-for-firing
game.mode = "wait-for-firing";
}
if(game.mode == "firing"){
game.panTo(game.slingshotX);
}
if (game.mode == "fired"){
// TODO:
// Pan to wherever the hero currently is
}
},
我们首先创建一个名为 panTo() 的方法,该方法缓慢地将屏幕平移到给定的 x 坐标,如果坐标在屏幕的中心附近,或者如果屏幕已经平移到最左边或最右边,则返回 true。它还使用 maxSpeed 来限制平移速度,以便平移不会变得太快。我们还改进了 handlePanning()方法,因此它实现了我们之前描述的一些游戏状态。我们还没有实现 load-current-hero、firing 和 fired 状态。
如果我们运行目前的代码,我们会看到当关卡开始时,屏幕向右移动,直到到达最右边,panTo()返回 true(见图 2-6 )。然后游戏模式从“开始”变为“等待开火”,屏幕慢慢回到开始位置,等待用户输入。我们也可以拖动鼠标到屏幕的左边或右边来查看关卡。
图 2-6。最终结果:在关卡周围平移
摘要
在这一章中,我们开始为我们的游戏开发基本框架。
我们从定义和实现闪屏和游戏菜单开始。然后我们创建了一个简单的关卡系统和一个素材加载器来动态加载每个关卡使用的图像。我们设置了游戏画布和动画循环,并实现了视差滚动,以产生深度错觉。我们使用游戏状态来简化我们的游戏流程,并以一种有趣的方式在我们的关卡中移动。最后,我们捕获并使用鼠标事件来让用户在关卡周围平移。
在这一点上,我们有一个基本的游戏世界,我们可以与之互动,所以我们准备添加各种游戏实体和游戏物理。
在下一章中,我们将学习 Box2D 物理引擎的基础知识,并用它来为我们的游戏建立物理模型。我们将学习如何使用来自物理引擎的数据来激活我们的角色。然后,我们将把这个引擎与我们现有的框架集成起来,这样游戏实体就可以在我们的游戏世界中逼真地移动,之后我们就可以真正开始玩游戏了。
三、物理引擎基础知识
物理引擎是一个程序,它通过为游戏中的所有对象交互和碰撞创建数学模型来提供游戏世界的近似模拟。它考虑了重力、弹性、摩擦和碰撞物体之间的动量守恒,从而使物体以可信的方式运动。对于我们的游戏,我们将使用一个现有的非常流行的物理引擎,叫做 Box2D。
Box2D 引擎是一个免费的开源物理引擎,最初由 Erin Catto 用 C++编写。它已经被用在很多流行的基于物理的游戏中,包括蜡笔物理豪华版、罗兰多和愤怒的小鸟。该引擎后来被移植到其他几种语言,包括 Java、ActionScript、C#和 JavaScript。我们将使用 Box2D 的 JavaScript 端口,称为 Box2dWeb。你可以在 http://code.google.com/p/box2dweb/找到最新的 Box2dWeb 源代码和文档。
在我们开始将引擎集成到我们自己的游戏中之前,让我们回顾一下使用 Box2D 创建和模拟世界的一些基本组件。
Box2D 基础知识
Box2D 使用一些基本对象来定义和模拟游戏世界。这些物体中最重要的如下:
- 世界:包含所有世界对象并模拟游戏物理的主 Box2D 对象。
- 身体:可能由一个或多个形状组成的刚体,通过固定装置附着在身体上。
- 形状:一个二维形状,如圆形或多边形,它们是 Box2D 中使用的基本形状。
- Fixture :用于将一个图形附加到一个物体上进行碰撞检测。夹具保存附加的非几何数据,如摩擦、碰撞和过滤器。
- 关节:用于以不同的方式将两个物体约束在一起。例如,旋转关节约束两个实体共享一个公共点,同时它们可以围绕该点自由旋转。
在我们的游戏中使用 Box2D 时,首先需要定义游戏世界。然后,我们使用夹具添加几何体及其相应的形状。一旦这样做了,我们就在这个世界里走来走去,让 Box2D 移动身体。最后,我们在每一步之后画出身体。大部分繁重的工作由 Box2D 世界对象来完成。
现在,当我们使用 Box2D 创建一个简单的世界时,让我们更详细地看看这些步骤。
设置 Box2D
我们将从一个简单的 HTML 文件开始,就像前面的章节一样(box2d.html)。我们需要做的第一件事是在 HTML 文件的 head 部分包含对 Box2dWeb 库(Box2dWeb-2.1.a.3.min.js)的引用(参见清单 3-1 )。
清单 3-1。 基本 HTML5 文件为 Box2D(box2d.html)
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>Box2d Test</title>
<script src="Box2dWeb-2.1.a.3.min.js" type="text/javascript" charset="utf-8"></script>
<script src="box2d.js" type="text/javascript" charset="utf-8"></script>
</head>
<body onload="init();">
<canvas id="canvas" width="640" height="480" style="border:1px solid black;">Your browser does not support HTML5 Canvas</canvas>
</body>
</html>
正如你在清单 3-1 中看到的,box2d.html 文件只包含一个我们将要绘制的画布元素。我们引用两个 JavaScript 文件:Box2dWeb 库文件和第二个文件,我们将使用它来存储所有的 JavaScript 代码(box2d.js)。一旦 HTML 文件被完全加载,它将调用一个 init()函数,我们将用它来初始化 Box2D 世界并开始制作动画。
引用 Box2dWeb JavaScript 文件可以让我们在 JavaScript 代码中访问 Box2D 对象。这个对象包含了我们需要的所有对象,包括世界(Box2D。Dynamics.b2World)和车身(Box2D。Dynamics.b2Body)。
将常用的对象定义为变量是很方便的,这样在引用它们的时候可以节省一些打字的工作量。我们将在 JavaScript 文件(box2d.js)中做的第一件事是声明这些变量(见清单 3-2)。
清单 3-2。 将常用对象定义为变量
// Declare all the commonly used objects as variables for convenience
var b2Vec2 = Box2D.Common.Math.b2Vec2;
var b2BodyDef = Box2D.Dynamics.b2BodyDef;
var b2Body = Box2D.Dynamics.b2Body;
var b2FixtureDef = Box2D.Dynamics.b2FixtureDef;
var b2Fixture = Box2D.Dynamics.b2Fixture;
var b2World = Box2D.Dynamics.b2World;
var b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;
var b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;
var b2DebugDraw = Box2D.Dynamics.b2DebugDraw;
var b2RevoluteJointDef = Box2D.Dynamics.Joints.b2RevoluteJointDef;
一旦我们将这些变量定义为快捷方式,我们就可以访问 Box2D。Dynamics.b2World,方法是使用 b2World 变量。现在,让我们开始定义我们的世界。
定义世界
盒子 2D。Dynamics.b2World 对象是 Box2D 的心脏。它包含了添加和删除对象的方法,以增量方式模拟物理的方法,甚至还有一个在画布上绘制世界的选项。在开始使用 Box2D 之前,我们需要创建 b2World 对象。我们在 JavaScript 文件(box2d.js)中创建的 init()函数中实现了这一点,如清单 3-3 所示。
清单 3-3。 创建 B2 世界对象
var world;
var scale = 30; //30 pixels on our canvas correspond to 1 meter in the Box2d world
function init(){
// Set up the Box2d world that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
}
init()函数首先定义 b2World,并向其构造函数传递以下两个参数:
- gravity: 使用一个 b2Vec2 对象定义为一个向量,它有两个参数,x 和 y 分量。我们把世界重力设定为向下方向每平方秒 9.8 米。设置自定义重力的能力让我们可以模拟具有不同重力场的环境,例如月球或重力非常低或非常高的幻想世界。我们也可以将 gravity 设置为 0,只对不需要重力的游戏(基于空间的游戏或赛车游戏之类的自上而下视图游戏)使用 Box2D 的碰撞检测功能。
- b2World 使用 allowSleep: 来决定在模拟计算中是否包括静止的对象。允许将静止的对象排除在计算之外可以减少不必要的计算,从而有助于提高性能。即使一个物体在睡觉,如果有运动的物体与之碰撞,它也会醒来。
我们在代码中做的另一件事是定义一个 scale 变量,我们将使用它在 Box2D 单位(米)和游戏单位(像素)之间进行转换。
注 Box2D 所有计算都使用公制。它最适用于 0.1 米到 10 米大的物体。因为我们在画布上绘图时使用像素,所以我们需要在像素和米之间进行转换。常用的比例是 30 像素比 1 米。
现在我们有了一个基本的世界,我们需要开始给它添加身体。我们将创建的第一个实体是我们世界底部的静态地板。
添加我们的第一个身体:地板
在 Box2D 中创建任何几何体包括以下步骤:
- 在 b2BodyDef 对象中声明一个体定义。b2BodyDef 对象包含诸如主体位置(x 和 y 坐标)和主体类型(静态或动态)的细节。静态物体不受重力和与其他物体碰撞的影响。
- 在 b2FixtureDef 对象中声明一个 fixture 定义。这用于将形状附加到几何体上。夹具定义还包含附加信息,如密度、摩擦系数和附着形状的恢复系数。
- 设置夹具定义的形状。Box2D 中使用的两种形状是多边形(b2PolygonShape)和圆形(b2CircleShape)。
- 将 Body 定义对象传递给世界的 createBody()方法,并获取一个 body 对象。
- 将 fixture 定义传递给 body 对象的 createFixture()方法,并将形状附加到 body。
现在我们知道了这些基本步骤,我们将创建我们在这个世界里的第一个身体:地板。我们将通过在前面创建的 init()函数的正下方创建一个 createFloor()方法来实现这一点。这显示在清单 3-4 中。
清单 3-4。 创建楼层
function createFloor(){
//A body definition holds all the data needed to construct a rigid body.
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_staticBody;
bodyDef.position.x = 640/2/scale;
bodyDef.position.y = 450/scale;
// A fixture is used to attach a shape to a body for collision detection.
// A fixture definition is used to create a fixture.
var fixtureDef = new b2FixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution = 0.2;
fixtureDef.shape = new b2PolygonShape;
fixtureDef.shape.SetAsBox(320/scale,10/scale); //640 pixels wide and 20 pixels tall
var body = world.CreateBody(bodyDef);
var fixture = body.CreateFixture(fixtureDef);
}
我们做的第一件事是定义一个 bodyDef 对象。我们将其类型设置为 static (b2Body.b2_staticBody ),因为我们希望我们的地板保持在同一位置,不受重力或与其他物体碰撞的影响。然后,我们将身体的位置设置在画布底部附近(x = 320 像素,y = 450 像素),并使用 scale 变量将 Box2D 的像素转换为米。
注意与画布不同,矩形的位置基于左上角,Box2D 主体的位置基于对象的原点。对于使用 SetAsBox()创建的盒子,原点位于盒子的中心。
接下来我们要做的是定义 fixture 定义(fixtureDef)。夹具定义包含密度、摩擦系数以及其附着形状的恢复系数等值。密度用于计算身体的重量,摩擦系数用于确保身体真实地滑动,恢复用于使身体弹跳。
注意恢复系数越高,物体变得越“有弹性”。接近 0 的值意味着物体不会反弹,并将在碰撞中失去大部分动量(称为非弹性碰撞)。接近 1 的值意味着物体保留了大部分动量,并会像它来时一样快地反弹回来(称为弹性碰撞)。
然后,我们将夹具的形状设置为 B2 多边形对象。b2PolygonShape 对象有一个名为 SetAsBox()的辅助方法,该方法将多边形设置为一个以父体原点为中心的长方体。SetAsBox()方法将盒子的半宽和半高(范围)作为参数。同样,我们使用 scale 变量来定义一个 640 像素宽、20 像素高的方框。
最后,我们通过将 bodyDef 传递给 world 来创建身体。CreateBody()并通过将 fixtureDef 传递给 Body 来创建 fixture。CreateFixture()。
我们需要做的另一件事是从我们之前声明的 init()函数内部调用这个新创建的方法,以便在调用 init()函数时创建这个主体。init()函数现在看起来像清单 3-5 中的。
清单 3-5。 从 init()调用 createFloor()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
}
现在我们已经为世界添加了第一个身体,我们需要学习如何绘制世界,以便我们可以看到我们迄今为止所创造的东西。
绘制世界:设置调试图形
Box2D 主要用于处理物理计算的引擎,而我们自己处理绘制世界上所有的物体。然而,Box2D 世界对象为我们提供了一个简单的 DrawDebugData()方法,我们可以用它在给定的画布上绘制世界。
DrawDebugData()方法绘制了世界内部物体的一个非常简单的表示,最适合用来帮助我们在创建世界时可视化世界。
在使用 DrawDebugData()之前,我们需要通过定义一个 b2DebugDraw()对象并将其传递给世界来设置调试绘图。SetDebugDraw()方法。我们在一个 setupDebugDraw()方法中这样做,我们将把它放在 box2d.js 中的 createFloor()方法之下(见清单 3-6 )。
清单 3-6。 设置调试图纸
var context;
function setupDebugDraw(){
context = document.getElementById('canvas').getContext('2d');
var debugDraw = new b2DebugDraw();
// Use this canvas context for drawing the debugging screen
debugDraw.SetSprite(context);
// Set the scale
debugDraw.SetDrawScale(scale);
// Fill boxes with an alpha transparency of 0.3
debugDraw.SetFillAlpha(0.3);
// Draw lines with a thickness of 1
debugDraw.SetLineThickness(1.0);
// Display all shapes and joints
debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
// Start using debug draw in our world
world.SetDebugDraw(debugDraw);
}
我们首先定义画布上下文的句柄。然后,我们创建一个新的 b2DebugDraw 对象,并使用它的 set 方法设置一些属性:
- SetSprite():用于为绘图提供画布上下文。
- SetDrawScale():用于设置 Box2D 单位和像素之间转换的比例。
- SetFillAlpha()和 SetLineThickness():用于设置绘制样式。
- SetFlags():用于选择要绘制哪些 Box2D 实体。我们选择了用于绘制所有形状和关节的标志,并使用逻辑 or 操作符来组合这两个标志。我们可以让 Box2D 绘制的一些其他实体是质心(e_centerOfMassBit)和轴对齐的边界框(e_aabbBit)。
最后,我们将 debugDraw 对象传递给世界。SetDebugDraw()方法。创建函数后,我们需要从 init()函数内部调用它。init()函数现在看起来像清单 3-7 中的。
清单 3-7。 从 init()调用 setupDebugDraw()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
setupDebugDraw();
}
现在调试图已经设置好了,我们可以使用这个世界了。DrawDebugData()方法将 Box2D 世界的当前状态绘制到画布上。
让世界充满活力
使用 Box2D 制作世界动画包括以下步骤,我们在动画循环中重复这些步骤:
- 告诉 Box2D 以一个小的时间步长(通常为 1/60 秒)运行模拟。我们通过利用世界来做到这一点。Step()函数。
- 使用任意一个世界,在新的位置绘制所有的物体。DrawDebugData()或我们自己的绘图函数。
- 清除我们使用世界施加的任何力。ClearForces()。
我们可以在自己的 animate()函数中实现这些步骤,这个函数是在 init()之后的 box2d.js 中创建的,如清单 3-8 所示。
清单 3-8。 设置一个 Box2D 动画循环
var timeStep = 1/60;
//As per the Box2d manual, the suggested iteration count for Box2D is 8 for velocity and 3 for position.
var velocityIterations = 8;
var positionIterations = 3;
function animate(){
world.Step(timeStep,velocityIterations,positionIterations);
world.ClearForces();
world.DrawDebugData();
setTimeout(animate, timeStep);
}
我们首先调用 world.step()并向其传递三个参数:时间步长、速度迭代和位置迭代。
Box2D 使用一种叫做积分器的计算算法。积分器在离散的时间点模拟物理方程。时间步长是我们希望 Box2D 模拟的时间量。我们将这个值设置为 1/60 秒。
除了积分器,Box2D 还使用了一个更大的代码,叫做约束解算器。约束求解器解决模拟中的所有约束,一次一个。为了得到一个好的解决方案,我们需要多次迭代所有的约束。约束求解器中有两个阶段:速度阶段和位置阶段。每个阶段都有自己的迭代次数,我们将这两个值分别设置为 8 和 3。
注意一般来说,游戏的物理引擎在至少 60Hz 或 1/60 秒的时间步长下工作良好。根据 Erin Catto 的原始 C++ * Box2D v2.2.0 用户手册*(可在box2d.org/manual.pdf
获得),最好保持时间步长不变,不要随着帧速率而变化,因为可变的时间步长会产生可变的结果,这使得调试变得困难。
同样根据 Box2d C++手册,Box2d 的建议迭代次数是速度 8 次,位置 3 次。您可以根据自己的喜好调整这些数字,但请记住,这需要在速度和准确性之间进行权衡。使用较少的迭代次数可以提高性能,但精度会受到影响。同样,使用更多迭代会降低性能,但会提高模拟的质量。
在逐步完成模拟后,我们调用 world。ClearForces()清除应用于实体的任何力。我们称之为世界。DrawDebugData()在画布上绘制世界。
最后,我们使用 setTimeout()在下一个时间步超时后再次调用动画循环。我们现在使用 setTimeout(),因为使用 Box2d 更简单。Step()函数具有恒定的帧速率。在下一章中,我们将看看如何使用 requestAnimationFrame()和一个可变的帧速率来将 Box2D 集成到我们的游戏中。
现在动画循环已经完成,我们可以通过调用 init()函数中的这些新方法来查看我们到目前为止已经创建的世界。更新后的 init()函数现在看起来像清单 3-9 中的。
清单 3-9。 更新了 init()函数
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
setupDebugDraw();
animate();
}
当我们在浏览器中打开 box2d.html 时,我们应该会看到我们的世界被绘制成地板,如图图 3-1 所示。
图 3-1。我们的第一款 Box2D 车身:地板
这看起来还不太像。地板是一个静止的物体,漂浮在画布的底部。然而,现在我们已经设置好了创建我们的基本世界并将其显示在屏幕上的一切,我们可以开始向我们的世界添加更多的 Box2D 元素。
更多 Box2D 元素
Box2D 允许我们向我们的世界添加不同类型的元素,包括以下内容:
- 矩形、圆形或多边形的简单几何体
- 组合多种形状的复杂几何体
- 连接多个实体的关节,如旋转关节
- 联系允许我们处理冲突事件的侦听器
现在,我们将依次更详细地了解这些元素。
创建矩形体
我们可以创建一个矩形体,就像创建地板一样——通过定义一个 b2PolygonShape 并使用它的 SetAsBox()方法。我们将在一个名为 createRectangularBody()的新方法中完成这项工作,我们将把它添加到 box2d.js 中(参见清单 3-10 )。
清单 3-10。 创建一个矩形体
function createRectangularBody(){
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_dynamicBody;
bodyDef.position.x = 40/scale;
bodyDef.position.y = 100/scale;
var fixtureDef = new b2FixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution = 0.3;
fixtureDef.shape = new b2PolygonShape;
fixtureDef.shape.SetAsBox(30/scale,50/scale);
var body = world.CreateBody(bodyDef);
var fixture = body.CreateFixture(fixtureDef);
}
我们创建一个 body 定义,并将其放置在画布顶部附近,x = 40 像素,y = 100 像素。这次的一个区别是,我们将 body 类型定义为 dynamic (b2Body.b2_dynamicBody)。这意味着身体会受到重力和碰撞的影响。然后,我们用一个多边形定义夹具,这个多边形被设置为一个 60 像素宽、100 像素高的盒子。最后,我们将身体加入我们的世界。
我们需要在 init()函数中添加一个对 createRectangularBody()的调用,以便在页面加载时调用它。init()函数现在看起来像清单 3-11 中的。
清单 3-11。 从 init()调用 createRectangularBody()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
// Create some bodies with simple shapes
createRectangularBody();
setupDebugDraw();
animate();
}
当我们在浏览器中运行代码时,我们应该会看到我们刚刚创建的新主体,如图 3-2 所示。
图 3-2。我们的第一个动态物体:一个跳动的矩形
由于这个物体是动态的,它会因为重力而向下坠落,直到撞到地板,然后从地板上弹开。每次弹跳后,身体上升到一个较低的高度,直到最后落在地板上。如果我们愿意,我们可以改变恢复系数来决定物体的弹性。
注意一旦身体静止,Box2D 会改变身体的颜色,使其变暗。这就是 Box2D 告诉我们物体被认为处于睡眠状态的方式。如果另一个物体与它碰撞,Box2D 将唤醒一个物体。
创建圆形几何体
我们将创建的下一个几何体是一个简单的圆形几何体。我们可以通过将 shape 属性设置为 b2CircleShape 对象来定义圆形。我们将在一个名为 createCircularBody()的新方法中这样做,我们将把它添加到 box2d.js 中,如清单 3-12 所示。
清单 3-12。 创建圆形
function createCircularBody(){
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_dynamicBody;
bodyDef.position.x = 130/scale;
bodyDef.position.y = 100/scale;
var fixtureDef = new b2FixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution = 0.7;
fixtureDef.shape = new b2CircleShape(30/scale);
var body = world.CreateBody(bodyDef);
var fixture = body.CreateFixture(fixtureDef);
}
b2CircleShape 构造函数接受一个参数,即圆的半径。代码的其余部分(定义几何体、定义夹具和创建几何体)与矩形几何体的代码非常相似。
我们所做的一个更改是将恢复值增加到 0.7,这比我们之前用于矩形几何体的值要高得多。我们需要从 init()函数内部调用 createCircularBody()。init()函数现在看起来像清单 3-13 中的。
清单 3-13。 从 init()调用 createCircularBody()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
// Create some bodies with simple shapes
createRectangularBody();
createCircularBody();
setupDebugDraw();
animate();
}
一旦我们这样做并运行代码,我们应该看到我们刚刚创建的新的圆形物体(如图 3-3 所示)。
图 3-3。更有弹性的圆形车身
你会注意到,圆形物体比矩形物体弹跳得高得多,并且需要更长的时间才能静止下来。这是因为较大的恢复系数。当你创建自己的游戏时,你可以尝试这些值,直到它们适合你的游戏。
创建多边形几何体
我们将创建的最后一个简单形状是多边形。Box2D 允许我们通过定义每个点的坐标来创建任何我们想要的多边形。唯一的限制是多边形必须是凸多边形。
要创建一个多边形,我们首先需要用它的每个点的坐标创建一个 b2Vec2 对象的数组,然后我们需要把这个数组传递给这个形状。SetAsArray()方法。我们将在一个名为 createSimplePolygonBody()的新方法中做这件事,我们将把它添加到 box2d.js 中(见清单 3-14 )。
清单 3-14。 用点定义多边形形状
function createSimplePolygonBody(){
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_dynamicBody;
bodyDef.position.x = 230/scale;
bodyDef.position.y = 50/scale;
var fixtureDef = new b2FixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution = 0.2;
fixtureDef.shape = new b2PolygonShape;
// Create an array of b2Vec2 points in clockwise direction
var points = [
new b2Vec2(0,0),
new b2Vec2(40/scale,50/scale),
new b2Vec2(50/scale,100/scale),
new b2Vec2(-50/scale,100/scale),
new b2Vec2(-40/scale,50/scale),
];
// Use SetAsArray to define the shape using the points array
fixtureDef.shape.SetAsArray(points,points.length);
var body = world.CreateBody(bodyDef);
var fixture = body.CreateFixture(fixtureDef);
}
我们定义了一个点数组,其中包含了 b2Vec2 对象中每个多边形点的坐标。以下是一些需要注意的事项:
- 所有坐标都是相对于物体原点的。第一个点(0,0)从几何体的原点开始,并将放置在几何体位置(230,50)。
- 我们不需要封闭多边形。Box2D 将为我们处理这些。
- 所有点必须以顺时针方向定义。
提示如果我们以逆时针方向定义坐标,Box2D 将无法正确处理碰撞。如果你发现物体互相穿过,检查你是否已经定义了顺时针方向的点。
然后,我们调用 SetAsArray()方法,并向它传递两个参数:points 数组和点数。代码的其余部分与我们之前讨论的形状保持一致。
现在我们需要从 init()函数中调用 createSimplePolygonBody()。init()函数现在看起来像清单 3-15 中的。
清单 3-15。 从 init()调用 createSimplePolygonBody()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
// Create some bodies with simple shapes
createRectangularBody();
createCircularBody();
createSimplePolygonBody();
setupDebugDraw();
animate();
}
如果我们运行这段代码,我们应该会看到新的多边形物体(见图 3-4 )。
图 3-4。多边形物体
我们现在已经创建了三个简单的实体,具有不同的形状和属性。这些简单的形状通常足以在我们的游戏中建模各种各样的对象(水果、轮胎、板条箱等等)。然而,有时这些形状是不够的。有时候,我们需要创建更复杂的对象来组合多个形状。
创建具有多种形状的复杂几何体
到目前为止,我们一直在创造具有单一形状的简单物体。然而,如前所述,Box2D 允许我们创建包含多种形状的几何体。
要创建一个复杂的形状,我们需要做的就是将多个固定装置(每个都有自己的形状)连接到同一个物体上。让我们试着把我们刚刚学过的两种形状组合成一个整体:一个圆形和一个多边形。我们将在一个名为 createComplexPolygonBody()的新方法中完成这项工作,我们将把它添加到 box2d.js 中(见清单 3-16 )。
清单 3-16。 创建具有两种形状的几何体
function createComplexBody(){
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_dynamicBody;
bodyDef.position.x = 350/scale;
bodyDef.position.y = 50/scale;
var body = world.CreateBody(bodyDef);
//Create first fixture and attach a circular shape to the body
var fixtureDef = new b2FixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution = 0.7;
fixtureDef.shape = new b2CircleShape(40/scale);
body.CreateFixture(fixtureDef);
// Create second fixture and attach a polygon shape to the body
fixtureDef.shape = new b2PolygonShape;
var points = [
new b2Vec2(0,0),
new b2Vec2(40/scale,50/scale),
new b2Vec2(50/scale,100/scale),
new b2Vec2(-50/scale,100/scale),
new b2Vec2(-40/scale,50/scale),
];
fixtureDef.shape.SetAsArray(points,points.length);
body.CreateFixture(fixtureDef);
}
我们首先创建一个几何体,然后创建两个不同的装置,第一个用于圆形,第二个用于多边形。然后,我们使用 CreateFixture()方法将这两个设备连接到主体。Box2D 会自动创建一个包含这两种形状的刚体。
既然我们已经创建了 createComplexBody(),我们需要从 init()函数内部调用它。init()函数现在看起来像清单 3-17 中的。
清单 3-17。 从 init()调用 createComplexBody()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
// Create some bodies with simple shapes
createRectangularBody();
createCircularBody();
createSimplePolygonBody();
// Create a body combining two shapes
createComplexBody();
setupDebugDraw();
animate();
}
当我们运行这段代码时,我们应该会看到新的复合体,如图 3-5 所示。
图 3-5。具有两种形状的复合体
你会注意到这两个形状就像一个整体。这是因为 Box2D 将这些多种形状视为单个刚体。这种组合形状的能力允许我们模拟任何我们想要的对象,比如树和桌子。
它还允许我们避开创建凹多边形的限制,因为任何凹多边形都可以分解成多个凸多边形。
用关节连接物体
现在我们知道了如何在 Box2D 中创建不同类型的实体,我们将简要地看一下如何创建关节。
关节用于将身体约束到世界或约束到彼此。Box2D 支持许多不同类型的关节,包括滑轮、齿轮、距离、旋转和焊接关节。
其中一些关节限制运动(例如,距离关节和焊接关节),而其他关节允许有趣的运动类型(例如,滑轮关节和旋转关节)。一些关节甚至提供可以用于以特定速度驱动关节的马达。我们将看看 Box2D 提供的一个更简单的关节:旋转关节。
旋转关节迫使两个实体共享一个公共锚点,通常称为铰接点。这意味着物体在这一点上相互连接,并且可以围绕这一点旋转。
我们可以通过定义一个 b2RevoluteJointDef 对象来创建一个旋转关节,然后将其传递给世界。CreateJoint()方法。这在我们添加到 box2d.js 中的 createRevoluteJoint()方法中进行了说明(参见清单 3-18 )。
清单 3-18。 创建旋转关节
function createRevoluteJoint(){
//Define the first body
var bodyDef1 = new b2BodyDef;
bodyDef1.type = b2Body.b2_dynamicBody;
bodyDef1.position.x = 480/scale;
bodyDef1.position.y = 50/scale;
var body1 = world.CreateBody(bodyDef1);
//Create first fixture and attach a rectangular shape to the body
var fixtureDef1 = new b2FixtureDef;
fixtureDef1.density = 1.0;
fixtureDef1.friction = 0.5;
fixtureDef1.restitution = 0.5;
fixtureDef1.shape = new b2PolygonShape;
fixtureDef1.shape.SetAsBox(50/scale,10/scale);
body1.CreateFixture(fixtureDef1);
// Define the second body
var bodyDef2 = new b2BodyDef;
bodyDef2.type = b2Body.b2_dynamicBody;
bodyDef2.position.x = 470/scale;
bodyDef2.position.y = 50/scale;
var body2 = world.CreateBody(bodyDef2);
//Create second fixture and attach a polygon shape to the body
var fixtureDef2 = new b2FixtureDef;
fixtureDef2.density = 1.0;
fixtureDef2.friction = 0.5;
fixtureDef2.restitution = 0.5;
fixtureDef2.shape = new b2PolygonShape;
var points = [
new b2Vec2(0,0),
new b2Vec2(40/scale,50/scale),
new b2Vec2(50/scale,100/scale),
new b2Vec2(-50/scale,100/scale),
new b2Vec2(-40/scale,50/scale),
];
fixtureDef2.shape.SetAsArray(points,points.length);
body2.CreateFixture(fixtureDef2);
// Create a joint between body1 and body2
var jointDef = new b2RevoluteJointDef;
var jointCenter = new b2Vec2(470/scale,50/scale);
jointDef.Initialize(body1, body2, jointCenter);
world.CreateJoint(jointDef);
}
在这段代码中,我们首先定义了两个物体,一个矩形(body1)和一个多边形(body2),它们相互叠放,然后我们将它们添加到世界中。
然后,我们创建一个 b2RevolutionJointDef 对象,并通过向 initialize()方法传递三个参数来初始化它:两个身体(身体 1 和身体 2)和关节中心,关节中心是关节旋转所围绕的点。
最后,我们称之为世界。CreateJoint()将关节添加到世界中。
我们需要从 init()函数中调用 createRevoluteJoint()。init()函数现在看起来像清单 3-19 中的。
清单 3-19。 从 init()调用 createRevoluteJoint()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
// Create some bodies with simple shapes
createRectangularBody();
createCircularBody();
createSimplePolygonBody();
// Create a body combining two shapes
createComplexBody();
// Join two bodies using a revolute joint
createRevoluteJoint();
setupDebugDraw();
animate();
}
当我们运行我们的代码时,我们应该看到我们的旋转关节在工作。你可以在图 3-6 中看到这一点。
图 3-6。运转中的旋转关节
正如你所看到的,矩形物体围绕它的定位点旋转,就像风车叶片一样。这与我们之前创建的复杂几何体非常不同,在复杂几何体中,形状就像一个单独的几何体。
Box2D 中的每个关节都可以以不同的方式组合,以创建有趣的运动和效果,如滑轮、布娃娃和钟摆。你可以在 Box2D 参考 API 中读到更多关于这些其他类型的关节,你可以在www.box2dflash.org/docs/2.1a/reference/
找到。注意,这是针对我们的 JavaScript 版本所基于的 Box2D 的 Flash 版本。在为 JavaScript 版本开发时,我们仍然可以参考这个 Flash 版本中的方法签名和文档,因为 Box2D 的 JavaScript 版本是通过直接转换 Flash 版本开发的,两者之间的方法签名保持相同。
跟踪碰撞和损坏
在前面的几个例子中,你可能注意到了一件事,一些物体相互碰撞,来回弹跳。如果能够记录这些碰撞和它们造成的冲击量,并模拟身体受损,那就太好了。
在我们追踪一个物体的损坏之前,我们需要能够将一个生命或健康与它联系起来。Box2D 为我们提供了一些方法,允许我们为任何实体、夹具或关节设置自定义属性。我们可以通过调用 SetUserData()方法将任何 JavaScript 对象指定为主体的自定义属性,并在以后通过调用 GetUserData()方法检索该属性。
让我们创造另一个身体,它有自己的健康,不像以前的任何身体。我们将在一个名为 createSpecialBody()的方法中完成这项工作,我们将把这个方法添加到 box2d.js 中(参见清单 3-20 )。
清单 3-20。 创造出具有自身属性的特殊机体
var specialBody;
function createSpecialBody(){
var bodyDef = new b2BodyDef;
bodyDef.type = b2Body.b2_dynamicBody;
bodyDef.position.x = 450/scale;
bodyDef.position.y = 0/scale;
specialBody = world.CreateBody(bodyDef);
specialBody.SetUserData({name:"special",life:250})
//Create a fixture to attach a circular shape to the body
var fixtureDef = new b2FixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution = 0.5;
fixtureDef.shape = new b2CircleShape(30/scale);
var fixture = specialBody.CreateFixture(fixtureDef);
}
创建这个物体的代码类似于我们前面看到的创建圆形物体的代码。唯一的区别是,一旦创建了主体,我们就调用它的 SetUserData()方法,并向它传递一个带有两个自定义属性 name 和 life 的对象参数。
我们可以给这个对象添加任意多的属性。另外,请注意,我们将对主体的引用保存在一个名为 specialBody 的变量中,该变量是在函数外部定义的。这样,我们可以在函数之外引用这个物体。
如果我们从 init()函数中调用 createSpecialBody(),我们不会看到任何异常—只是另一个跳动的圆。我们仍然希望能够追踪发生在这个物体上的碰撞。这就是联系听众的用武之地。
联系听众
Box2D 为我们提供了名为 contact listeners 的对象,让我们为几个与联系人相关的事件定义事件处理程序。为此,我们必须首先定义一个 b2ContactListener 对象,并覆盖一个或多个我们想要监视的事件。b2ContactListener 有四个我们可以根据需要使用的事件:
- BeginContact():当两个 fixtures 开始接触时调用。
- EndContact():当两个设备停止接触时调用。
- PostSolve():让我们在求解器完成后检查一个联系人。这对检查脉冲很有用。
- PreSolve():让我们在接触到达求解器之前检查它。
一旦我们覆盖了我们需要的方法,我们就需要向外界传递联系侦听器。SetContactListener()方法。由于我们想要跟踪碰撞造成的损害,我们将监听 PostSolve()事件,该事件为我们提供了碰撞期间传递的冲量(参见清单 3-21 )。
清单 3-21。 实现联络监听
function listenForContact(){
var listener = new Box2D.Dynamics.b2ContactListener;
listener.PostSolve = function(contact,impulse){
var body1 = contact.GetFixtureA().GetBody();
var body2 = contact.GetFixtureB().GetBody();
// If either of the bodies is the special body, reduce its life
if (body1 == specialBody || body2 == specialBody){
var impulseAlongNormal = impulse.normalImpulses[0];
specialBody.GetUserData().life -= impulseAlongNormal;
console.log("The special body was in a collision with impulse", impulseAlongNormal,"and its life has now become ",specialBody.GetUserData().life);
}
};
world.SetContactListener(listener);
}
如您所见,我们创建了一个 b2ContactListener 对象,并用我们自己的处理程序覆盖了它的 PostSolve()方法。PostSolve()方法为我们提供了两个参数:contact,它包含碰撞中涉及的夹具的详细信息,以及 impulse,它包含碰撞期间的法向和切向脉冲。
在 PostSolve()中,我们首先提取碰撞中涉及的两个物体,并检查我们的特殊物体是否是其中之一。如果是,我们提取两个身体之间沿着法线的冲量,从身体中减去生命点。我们还将这个事件记录到控制台,以便跟踪每个冲突。
显然,这是一种相当简单的处理对象损坏的方式,但是它做了我们需要它做的事情。碰撞中的冲力越大,碰撞次数越高,身体失去健康的速度就越快。
注意在 Box2D 世界中发生的每一次碰撞都会调用 PostSolve()方法,不管碰撞有多小。甚至当一个物体在另一个物体上滚动时,它也会被调用。要知道这个方法会被调用很多。
接下来,我们从 init()调用 createSimpleBody()和 listenForContact()。init()函数现在看起来像清单 3-22 中的。
清单 3-22。 从 init()调用 createSpecialBody()和 listenForContact()
function init(){
// Set up the box2d World that will do most of the physics calculation
var gravity = new b2Vec2(0,9.8); //declare gravity as 9.8 m/s² downward
var allowSleep = true; //Allow objects that are at rest to fall asleep and be excluded from calculations
world = new b2World(gravity,allowSleep);
createFloor();
// Create some bodies with simple shapes
createRectangularBody();
createCircularBody();
createSimplePolygonBody();
// Create a body combining two shapes
createComplexBody();
// Join two bodies using a revolute joint
createRevoluteJoint();
// Create a body with special user data
createSpecialBody();
// Create contact listeners and track events
listenForContact();
setupDebugDraw();
animate();
}
如果我们现在运行我们的代码,我们应该会看到这个圆圈来回跳动,每次碰撞后浏览器控制台都会显示一条消息,告诉我们身体的健康下降了多少,如图 3-7 所示。
图 3-7。观看与联系人监听器的冲突
能够追踪我们特殊身体的生命固然很好,但如果我们能在它耗尽生命时做点什么就更好了。
现在我们可以访问 specialBody 和 life 属性,我们可以在每次迭代后检查身体寿命是否达到 0,如果是,则使用 world 将其从世界中删除。DestroyBody()方法。最容易进行这种检查的地方是 animate()方法。animate()函数现在看起来像清单 3-23 中的。
清单 3-23。 毁灭身体
function animate(){
world.Step(timeStep,velocityIterations,positionIterations);
world.ClearForces();
world.DrawDebugData();
//Kill Special Body if Dead
if (specialBody && specialBody.GetUserData().life<=0){
world.DestroyBody(specialBody);
specialBody = undefined;
console.log("The special body was destroyed");
}
setTimeout(animate, timeStep);
}
一旦我们打完电话。Step()并绘制世界,我们检查 specialBody 是否还被定义,它的寿命是否已经到了 0。一旦生命确实达到 0,我们使用 DestroyBody()从世界中移除身体,然后将 specialBody 设置为 undefined。
这一次当我们运行代码时,这个特殊的身体随着它的寿命下降而来回跳动,直到它最终消失。一条消息出现在控制台上,告诉我们尸体被销毁了。
注意我们可以使用类似的原理,通过遍历对象数组来跟踪游戏中的所有物体和元素。我们摧毁一具尸体的地方是我们在游戏中添加爆炸声音或视觉效果并可能更新分数的完美地方。
画我们自己的角色
到目前为止,我们已经玩了很多 Box2D 特性。然而,我们只使用了默认的 DrawDebugData()方法。虽然这种方法在测试代码的时候很好,但是我们真的不能写出一个像这样令人惊奇的游戏。我们需要知道如何使用我们在第一章中学习的所有绘制方法来绘制我们自己的角色。
每个 b2Body 对象都有两个方法,GetPosition()和 GetAngle(),它们为我们提供 Box2D 世界中物体的坐标和旋转。使用我们在本章中定义的 scale 变量和我们在第一章中探索的 canvas translate()和 rotate()方法,我们可以在 Box2D 为我们计算的位置上绘制我们的角色或精灵。
为了说明这一点,我们可以在一个 drawSpecialBody()方法中绘制一个特殊的主体,我们将把它添加到 box2d.js 中(见清单 3-24 )。
清单 3-24。 描绘自己的性格
function drawSpecialBody(){
// Get body position and angle
var position = specialBody.GetPosition();
var angle = specialBody.GetAngle();
// Translate and rotate axis to body position and angle
context.translate(position.x*scale,position.y*scale);
context.rotate(angle);
// Draw a filled circular face
context.fillStyle = "rgb(200,150,250);";
context.beginPath();
context.arc(0,0,30,0,2*Math.PI,false);
context.fill();
// Draw two rectangular eyes
context.fillStyle = "rgb(255,255,255);";
context.fillRect(-15,-15,10,5);
context.fillRect(5,-15,10,5);
// Draw an upward or downward arc for a smile depending on life
context.strokeStyle = "rgb(255,255,255);";
context.beginPath();
if (specialBody.GetUserData().life>100){
context.arc(0,0,10,Math.PI,2*Math.PI,true);
} else {
context.arc(0,10,10,Math.PI,2*Math.PI,false);
}
context.stroke();
// Translate and rotate axis back to original position and angle
context.rotate(-angle);
context.translate(-position.x*scale,-position.y*scale);
}
我们首先将画布平移到身体的位置,并将画布旋转到身体的角度。这与我们在第一章中看到的代码非常相似。
然后,我们绘制一个完整的圆形的脸,两只长方形的眼睛,和一个微笑,使用弧线。只是为了好玩,当肉体生命降到 100 以下的时候,我们把笑容换成了一张悲伤的脸。
最后,我们撤销旋转和平移。
在我们看到这个方法运行之前,我们需要从 animate()内部调用它。完成的 animate()方法现在看起来像清单 3-25 中的。
清单 3-25。 【禀完了】方法
function animate(){
world.Step(timeStep,velocityIterations,positionIterations);
world.ClearForces();
world.DrawDebugData();
// Custom Drawing
if (specialBody){
drawSpecialBody();
}
//Kill Special Body if Dead
if (specialBody && specialBody.GetUserData().life<=0){
world.DestroyBody(specialBody);
specialBody = undefined;
console.log("The special body was destroyed");
}
setTimeout(animate, timeStep);
}
我们在这里所做的是检查 specialBody 是否仍被定义,如果是,则调用 drawSpecialBody()。一旦身体死亡,特殊的身体将变得不确定,我们将停止试图画它。您会注意到,我们是在 DrawDebugData()完成之后进行绘制的,所以我们最终会在调试绘图的顶部进行绘制。
当我们运行这个完成的代码时,我们看到新版本的 specialBody 带有一个笑脸,过一会儿它会变得悲伤,然后最终消失(见图 3-8 )。
图 3-8。描绘我们自己的性格
我们刚刚使用 Box2D 引擎制作了我们自己角色的动画。这可能看起来不多,但我们现在已经拥有了使用 Box2D 构建游戏所需的所有构件。
当你创建自己的游戏时,你将不仅仅是在玩盒子和圆圈。你将仍然使用简单的形状,这些形状在外观上与你的游戏元素相似,这样它们看起来会逼真地移动。但是,您将自己绘制所有字符,而不是使用 debug drawing。
摘要
在本章中,我们参加了 Box2D 引擎的速成班。我们在 Box2D 中创建了一个世界,并在其中绘制了不同种类的物体。我们制作了简单的圆形和矩形、多边形以及组合了多种形状的复杂物体,并使用关节来组合形状。
我们通过让 Box2D 处理物理计算并使用 DrawDebugData()绘制世界来逼真地制作世界动画。我们使用联系监听器来跟踪碰撞,并慢慢地破坏和摧毁世界上的物体。最后我们画出了自己被 Box2D 感动的角色。
我们涵盖了我们将在游戏中使用的 Box2D 的大部分元素。如果你想更深入地研究 Box2D API,你可以看看在www.box2dflash.org/docs/
的 API 参考。您还可以在同一网站上阅读 Box2D 指南。
在下一章中,我们将结合我们目前所学的一切,将 Box2D 整合到我们的游戏中。我们将创建一个框架来处理 Box2D 中的游戏实体的创建。然后我们将使用图像和精灵在我们在第二章中构建的视差滚动背景上绘制我们的角色。之后,我们将花一些时间通过添加音效来完善我们的游戏,然后将所有东西连接在一起,创建一个完整的基于物理的益智游戏。