HTML5-游戏高级教程-全-
HTML5 游戏高级教程(全)
零、简介
欢迎来到职业 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>
注意我们使用主体的 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 在这个矩形内绘制。
注意不支持 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>
注意所有的浏览器都支持 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 中的游戏实体的创建。然后我们将使用图像和精灵在我们在第二章中构建的视差滚动背景上绘制我们的角色。之后,我们将花一些时间通过添加音效来完善我们的游戏,然后将所有东西连接在一起,创建一个完整的基于物理的益智游戏。
四、集成物理引擎
在第二章中,我们为我们的游戏《弗鲁特战争》开发了基本框架,在第三章中,我们看了如何在 Box2D 中模拟一个游戏世界。现在是时候把所有的碎片放在一起完成我们的游戏了。
在本章中,我们将从第二章结束时我们停止的地方继续。我们将在关卡中添加实体,使用 Box2D 来模拟这些实体,然后在游戏中制作这些实体的动画。我们将使用这些实体来创建几个工作级别,我们将添加鼠标交互性,以便我们可以玩游戏。一旦我们有了一个可用的游戏,我们将添加声音、背景音乐和一些其他的收尾工作来结束我们的游戏。
现在让我们开始吧。我们将使用第二章中的代码作为起点。
定义实体
到目前为止,我们的游戏关卡包含背景和前景图像的数据以及实体的空数组。这个实体数组最终将包含我们游戏中的所有实体:英雄、恶棍、地面和用于创建环境的积木。然后,我们将使用这个数组要求 Box2D 创建相应的 Box2D 形状。
典型的实体看起来像清单 4-1 中的例子。
清单 4-1。 典型实体
{type:"block", name:"wood", x:520,y:375,angle:90},
{type:"villain", name:"burger",x:520,y:200,calories:590},
type 属性可以包含像“英雄”、“恶棍”、“地面”和“街区”这样的值。我们将使用该属性来决定在创建和绘制操作期间如何处理实体。
x、y 和 angle 属性用于设置实体的起始位置和方向。我们还可以在实体中存储其他自定义属性(如卡路里,这是消灭一个恶棍所获得的分数)。
name 属性告诉我们使用哪个精灵来绘制实体。我们将用于实体的所有图像都存储在图像/实体文件夹中。
name 属性也将用于引用实体定义。这些定义将包括设备数据,如密度和恢复,可破坏对象的健康数据,以及在英雄和恶棍的情况下,甚至形状的细节。典型的实体定义看起来像清单 4-2 中的例子。
清单 4-2。 典型实体定义
"burger":{
shape:"circle",
fullHealth:40,
radius:25,
density:1,
friction:0.5,
restitution:0.4,
},
"wood":{
fullHealth:500,
density:0.7,
friction:0.4,
restitution:0.4,
},
既然我们已经决定了如何存储实体,我们还需要一种方法来创建它们。我们将首先在 game.js 中创建一个实体对象,它将处理游戏中所有与实体相关的操作。该对象将包含所有实体定义以及创建和绘制实体的方法(见清单 4-3 )。
清单 4-3。 实体对象同实体定义
var entities = {
definitions:{
"glass":{
fullHealth:100,
density:2.4,
friction:0.4,
restitution:0.15,
},
"wood":{
fullHealth:500,
density:0.7,
friction:0.4,
restitution:0.4,
},
"dirt":{
density:3.0,
friction:1.5,
restitution:0.2,
},
"burger":{
shape:"circle",
fullHealth:40,
radius:25,
density:1,
friction:0.5,
restitution:0.4,
},
"sodacan":{
shape:"rectangle",
fullHealth:80,
width:40,
height:60,
density:1,
friction:0.5,
restitution:0.7,
},
"fries":{
shape:"rectangle",
fullHealth:50,
width:40,
height:50,
density:1,
friction:0.5,
restitution:0.6,
},
"apple":{
shape:"circle",
radius:25,
density:1.5,
friction:0.5,
restitution:0.4,
},
"orange":{
shape:"circle",
radius:25,
density:1.5,
friction:0.5,
restitution:0.4,
},
"strawberry":{
shape:"circle",
radius:15,
density:2.0,
friction:0.5,
restitution:0.4,
}
},
// take the entity, create a Box2D body, and add it to the world
create:function(entity){
},
// take the entity, its position, and its angle and draw it on the game canvas
draw:function(entity,position,angle){
}
}
entities 对象包含一个数组,其中包含所有材质类型的定义(玻璃、木头和泥土)以及游戏中所有英雄和恶棍的定义(橘子、苹果和汉堡)。
其中一些属性的值(如大小、恢复和满生命值)是根据感觉决定的,通过不断调整它们来使游戏尽可能有趣。这些属性的正确值将随您制作的每个游戏而变化。
我们还为需要实现的 create()和 draw()函数准备了占位符。然而,在我们实现这些之前,我们需要将 Box2D 添加到我们的代码中。
添加 Box2D
我们需要做的第一件事是在对 game.js 的引用之前,在 index.html 的部分添加一个对 Box2dWeb-2.1.a.3.min.js 的引用,文件的部分现在看起来将类似于清单 4-4 。
清单 4-4。 添加 Box2D 到 index.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/Box2dWeb-2.1.a.3.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>
我们要做的另一件事是将所有常用的 Box2D 对象的引用添加到 game.js 的开头(参见清单 4-5 )。
清单 4-5。 给常用的 Box2D 对象添加引用
// 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;
现在我们已经设置了引用,我们可以开始在我们的游戏代码中使用 Box2D 了。我们将在 game.js 中创建一个单独的 box2d 对象来存储所有与 Box2D 相关的方法(参见清单 4-6 )。
清单 4-6。创建一个 box2d 对象
var box2d = {
scale:30,
init:function(){
// 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
box2d.world = new b2World(gravity,allowSleep);
},
createRectangle:function(entity,definition){
var bodyDef = new b2BodyDef;
if(entity.isStatic){
bodyDef.type = b2Body.b2_staticBody;
} else {
bodyDef.type = b2Body.b2_dynamicBody;
}
bodyDef.position.x = entity.x/box2d.scale;
bodyDef.position.y = entity.y/box2d.scale;
if (entity.angle) {
bodyDef.angle = Math.PI*entity.angle/180;
}
var fixtureDef = new b2FixtureDef;
fixtureDef.density = definition.density;
fixtureDef.friction = definition.friction;
fixtureDef.restitution = definition.restitution;
fixtureDef.shape = new b2PolygonShape;
fixtureDef.shape.SetAsBox(entity.width/2/box2d.scale,entity.height/2/box2d.scale);
var body = box2d.world.CreateBody(bodyDef);
body.SetUserData(entity);
var fixture = body.CreateFixture(fixtureDef);
return body;
},
createCircle:function(entity,definition){
var bodyDef = new b2BodyDef;
if(entity.isStatic){
bodyDef.type = b2Body.b2_staticBody;
} else {
bodyDef.type = b2Body.b2_dynamicBody;
}
bodyDef.position.x = entity.x/box2d.scale;
bodyDef.position.y = entity.y/box2d.scale;
if (entity.angle) {
bodyDef.angle = Math.PI*entity.angle/180;
}
var fixtureDef = new b2FixtureDef;
fixtureDef.density = definition.density;
fixtureDef.friction = definition.friction;
fixtureDef.restitution = definition.restitution;
fixtureDef.shape = new b2CircleShape(entity.radius/box2d.scale);
var body = box2d.world.CreateBody(bodyDef);
body.SetUserData(entity);
var fixture = body.CreateFixture(fixtureDef);
return body;
},
}
box2d 对象连接一个 init()方法,该方法初始化一个新的 b2World 对象,就像我们在第三章中所做的一样。该对象还包含两个辅助方法,createRectangle()和 createCircle()。这两种方法都接受两个参数,即我们前面描述的实体和定义对象。实体对象包含详细信息,如其位置、角度以及实体是否是静态的。定义对象包含有关设备的详细信息,例如恢复和密度。使用这些参数,这些方法创建 Box2D 实体和装置,并将它们添加到 Box2D 世界中。
需要注意的一点是,这两种方法都使用 box2d.scale 转换位置和大小,并将角度从度转换为弧度,然后才能被 box2d 使用。
这些方法做的另一件事是使用 SetUserData()方法将实体对象附加到主体。这使我们能够使用 GetUserData()方法检索 Box2D 主体的任何实体相关数据。
创建实体
现在我们已经设置了 Box2D,我们将在前面定义的实体对象中实现 entities.create()方法。该方法将一个实体对象作为参数,并将其添加到世界中(见清单 4-7 )。
清单 4-7。 定义 entities.create()方法
// take the entity, create a Box2D body, and add it to the world
create:function(entity){
var definition = entities.definitions[entity.name];
if(!definition){
console.log ("Undefined entity name",entity.name);
return;
}
switch(entity.type){
case "block": // simple rectangles
entity.health = definition.fullHealth;
entity.fullHealth = definition.fullHealth;
entity.shape = "rectangle";
entity.sprite = loader.loadImage("img/"+entity.name+".png");
box2d.createRectangle(entity,definition);
break;
case "ground": // simple rectangles
// No need for health. These are indestructible
entity.shape = "rectangle";
// No need for sprites. These won't be drawn at all
box2d.createRectangle(entity,definition);
break;
case "hero": // simple circles
case "villain": // can be circles or rectangles
entity.health = definition.fullHealth;
entity.fullHealth = definition.fullHealth;
entity.sprite = loader.loadImage("img/"+entity.name+".png");
entity.shape = definition.shape;
if(definition.shape == "circle"){
entity.radius = definition.radius;
box2d.createCircle(entity,definition);
} else if(definition.shape == "rectangle"){
entity.width = definition.width;
entity.height = definition.height;
box2d.createRectangle(entity,definition);
}
break;
default:
console.log("Undefined entity type",entity.type);
break;
}
},
在这个方法中,我们使用实体类型来决定如何处理实体对象及其属性:
- Block :对于 Block 实体,我们根据实体定义设置实体 health 和 fullHealth 属性,并将 shape 属性设置为“rectangle”。然后,我们加载 sprite,并调用 box2d.createRectangle()方法。
- Ground :对于地面实体,我们将实体对象的 shape 属性设置为“rectangle”并调用 box2d.createRectangle()方法。我们不加载精灵,因为我们将使用水平前景图像的地面,不会单独绘制地面。
- 英雄和反派:对于英雄和反派实体,我们根据实体定义设置实体健康、完整健康和形状属性。然后,我们根据实体的形状设置半径或高度和宽度属性。最后,我们根据形状调用 box2d.createRectangle()或 box2d.createCircle()。
现在我们有了创建实体的方法,让我们添加一些实体到我们的级别。
将实体添加到级别
我们要做的第一件事是在 levels.data 数组中添加一些实体,如清单 4-8 所示。
清单 4-8。 向 levels.data 数组添加实体
data:[
{ // First level
foreground:'desert-foreground',
background:'clouds-background',
entities:[
{type:"ground", name:"dirt", x:500,y:440,width:1000,height:20,isStatic:true},
{type:"ground", name:"wood", x:180,y:390,width:40,height:80,isStatic:true},
{type:"block", name:"wood", x:520,y:375,angle:90,width:100,height:25},
{type:"block", name:"glass", x:520,y:275,angle:90,width:100,height:25},
{type:"villain", name:"burger",x:520,y:200,calories:590},
{type:"block", name:"wood", x:620,y:375,angle:90,width:100,height:25},
{type:"block", name:"glass", x:620,y:275,angle:90,width:100,height:25},
{type:"villain", name:"fries", x:620,y:200,calories:420},
{type:"hero", name:"orange",x:90,y:410},
{type:"hero", name:"apple",x:150,y:410},
]
},
{ // Second level
foreground:'desert-foreground',
background:'clouds-background',
entities:[
{type:"ground", name:"dirt", x:500,y:440,width:1000,height:20,isStatic:true},
{type:"ground", name:"wood", x:180,y:390,width:40,height:80,isStatic:true},
{type:"block", name:"wood", x:820,y:375,angle:90,width:100,height:25},
{type:"block", name:"wood", x:720,y:375,angle:90,width:100,height:25},
{type:"block", name:"wood", x:620,y:375,angle:90,width:100,height:25},
{type:"block", name:"glass", x:670,y:310,width:100,height:25},
{type:"block", name:"glass", x:770,y:310,width:100,height:25},
{type:"block", name:"glass", x:670,y:248,angle:90,width:100,height:25},
{type:"block", name:"glass", x:770,y:248,angle:90,width:100,height:25},
{type:"block", name:"wood", x:720,y:180,width:100,height:25},
{type:"villain", name:"burger",x:715,y:160,calories:590},
{type:"villain", name:"fries",x:670,y:400,calories:420},
{type:"villain", name:"sodacan",x:765,y:395,calories:150},
{type:"hero", name:"strawberry",x:40,y:420},
{type:"hero", name:"orange",x:90,y:410},
{type:"hero", name:"apple",x:150,y:410},
]
}
],
第一层包含两个背景地面实体,一个用于地板,另一个用于弹弓。这些实体应该是静态对象,不是由我们绘制的。
该级别还包含四个矩形块实体(玻璃和木材)。这些是我们使用它们的角度、x 和 y 属性定位的可析构元素。
最后,关卡包含两个英雄实体(橘子和苹果)和两个反派实体(汉堡和薯条)。注意反派有一个额外的属性叫做卡路里,当他们被消灭时我们会用它来增加玩家分数。
第二层也有类似的设计,只是多了几个实体。
现在我们已经为每个级别定义了实体,我们需要在加载级别时加载这些实体。为此,我们将修改 levels 对象的 load()方法(参见清单 4-9 )。
清单 4-9。 修改 levels.load()来加载实体
// Load all data and images for a specific level
load:function(number){
//Initialize Box2D world whenever a level is loaded
box2d.init();
// declare a new current level object
game.currentLevel = {number:number,hero:[]};
game.score=0;
$('#score').html('Score: '+game.score);
game.currentHero = undefined;
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");
// Load all the entities
for (var i = level.entities.length - 1; i >= 0; i--){
var entity = level.entities[i];
entities.create(entity);
};
//Call game.start() once the assets have loaded
if(loader.loaded){
game.start()
} else {
loader.onload = game.start;
}
}
我们做的第一个更改是在方法的最开始添加了对 box2d.init()的调用。另一个变化是增加了一个 for 循环,在这个循环中,我们遍历一个级别的所有实体,并为每个实体调用 entities.create()。现在,当我们加载一个关卡时,Box2D 将被初始化,所有的实体都将被加载到 Box2D 世界中。
我们仍然看不到我们添加的几何体。让我们用第三章中介绍的 Box2D 调试画图方法,看看我们创造了什么。
设置 Box2D 调试图形
我们要做的第一件事是在 HTML 文件中创建另一个 canvas 元素,并把它放在标签的末尾之前:
<canvas id="debugcanvas" width="1000" height="480" style="border:1px solid black;"></canvas>
这个画布比我们的游戏画布要大,所以我们不用平移就能看到整个关卡。我们将使用这个画布和调试绘图只设计和测试我们的水平。一旦游戏完成,我们可以删除所有的调试绘图的痕迹。
我们需要做的下一件事是在初始化 Box2D 时设置调试图形。我们将通过修改 box2d.init()方法来做到这一点,使它看起来如清单 4-10 所示。
清单 4-10。 修改 box2d.init()来设置调试绘制
init:function(){
// 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
box2d.world = new b2World(gravity,allowSleep);
// Set up debug draw
var debugContext = document.getElementById('debugcanvas').getContext('2d');
var debugDraw = new b2DebugDraw();
debugDraw.SetSprite(debugContext);
debugDraw.SetDrawScale(box2d.scale);
debugDraw.SetFillAlpha(0.3);
debugDraw.SetLineThickness(1.0);
debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
box2d.world.SetDebugDraw(debugDraw);
},
这个新添加的代码与第三章中的代码相同。在我们可以看到 debug draw 的结果之前,我们需要调用 world 对象的 DrawDebugData()方法。我们将在游戏对象中使用一个名为 drawAllBodies()的新方法来实现,如清单 4-11 所示。我们将从游戏对象的 animate()方法中调用这个方法。
清单 4-11。 修改 animate()和创建 drawAllBodies()
animate:function(){
// Animate the background
game.handlePanning();
// TODO: 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);
// Draw all the bodies
game.drawAllBodies();
// Draw the front of the slingshot
game.context.drawImage(game.slingshotFrontImage,game.slingshotX-game.offsetLeft,game.slingshotY);
if (!game.ended){
game.animationFrame = window.requestAnimationFrame(game.animate,game.canvas);
}
},
drawAllBodies:function(){
box2d.world.DrawDebugData();
// TODO: Iterate through all the bodies and draw them on the game canvas
}
现在,我们已经创建了一个简单的 drawAllBodies()方法,该方法调用 box2d.world.DrawDebugData()。我们最终需要添加代码来遍历 Box2D 世界中的所有身体,并将它们绘制在游戏画布上。我们从游戏对象的 animate()方法内部调用这个新方法。
如果我们现在运行我们的代码并加载第一级,我们应该看到带有所有实体的调试画布,如图图 4-1 所示。
图 4-1。在调试画布上绘制的第一级
调试画布视图以圆形和矩形向我们展示了所有的游戏实体。我们还可以看到不同颜色的地面和弹弓块。我们可以使用这个视图来快速测试我们的级别,并确保所有的实体都被正确定位。现在我们可以看到关卡中的一切看起来都没问题,是时候将所有的实体绘制到游戏画布上了。
绘制实体
为了绘制实体,我们将在实体对象中定义一个名为 draw()的方法。这个对象将把实体、它的位置和它的角度作为参数,并把它画在游戏画布上(见清单 4-12 )。
清单 4-12。entities . draw()方法
// take the entity, its position, and its angle and draw it on the game canvas
draw:function(entity,position,angle){
game.context.translate(position.x*box2d.scale-game.offsetLeft,position.y*box2d.scale);
game.context.rotate(angle);
switch (entity.type){
case "block":
game.context.drawImage(entity.sprite,0,0,entity.sprite.width,entity.sprite.height,
-entity.width/2-1,-entity.height/2-1,entity.width+2,entity.height+2);
break;
case "villain":
case "hero":
if (entity.shape=="circle"){
game.context.drawImage(entity.sprite,0,0,entity.sprite.width,entity.sprite.height,
-entity.radius-1,-entity.radius-1,entity.radius*2+2,entity.radius*2+2);
} else if (entity.shape=="rectangle"){
game.context.drawImage(entity.sprite,0,0,entity.sprite.width,entity.sprite.height,
-entity.width/2-1,-entity.height/2-1,entity.width+2,entity.height+2);
}
break;
case "ground":
// do nothing... We will draw objects like the ground & slingshot separately
break;
}
game.context.rotate(-angle);
game.context.translate(-position.x*box2d.scale+game.offsetLeft,-position.y*box2d.scale);
}
该方法首先将上下文平移和旋转到实体的位置和角度。然后,它根据实体类型和形状在画布上绘制对象。最后,它旋转并将上下文转换回原始位置。
需要注意的一点是,当使用 drawImage()时,代码在每个方向上将图像拉伸到比 sprite 定义大一个像素的大小。这是为了掩盖 Box2D 对象之间的小间隙。
注意 Box2D 在所有多边形周围创建一个“皮肤”。该蒙皮用于堆叠场景,以保持多边形略微分离。这允许连续碰撞对核心多边形起作用。在绘制 Box2D 对象时,我们需要通过绘制略大于其实际尺寸的物体来补偿这种额外的皮肤;否则,堆叠的对象之间会有无法解释的间隙。
现在我们已经定义了一个 entities.draw()方法,我们需要为游戏世界中的每个实体调用这个方法。通过使用 world 对象的 GetBodyList()方法,我们可以遍历游戏世界中的每一个实体。我们现在将修改游戏对象的 drawAllBodies()方法来做这件事,如清单 4-13 所示。
清单 4-13。 遍历所有物体并绘制
drawAllBodies:function(){
box2d.world.DrawDebugData();
// Iterate through all the bodies and draw them on the game canvas
for (var body = box2d.world.GetBodyList(); body; body = body.GetNext()) {
var entity = body.GetUserData();
if(entity){
entities.draw(entity,body.GetPosition(),body.GetAngle())
}
}
}
for 循环使用 world 初始化 body。GetBodyList(),返回世界上第一个身体。body 对象的 GetNext()方法返回列表中的下一个 body,直到它到达列表的末尾,这时我们退出 for 循环。在循环中,我们检查主体是否有附属实体;如果是这样,我们调用 entities.draw(),将实体对象、位置和角度传递给它。
如果我们现在运行游戏并加载第一关,我们应该会看到所有的实体都被绘制在画布上,如图 4-2 所示。
图 4-2。在画布上绘制游戏实体
一旦关卡加载完毕,游戏会向右平移,这样我们就可以清楚地看到坏人,然后游戏会平移回弹弓。我们可以看到所有的实体都被正确地绘制在与调试画布相同的位置上。我们在 draw()方法中添加的额外像素确保所有堆叠的对象彼此紧密相邻。请注意,画布在绘制图像时保留了图像的透明度,这就是为什么我们可以透过玻璃块看到背景。
现在我们已经画出了 Box2D 世界中的所有元素,我们需要给 Box2D 世界添加动画。
动画 Box2D 世界
和上一章一样,我们可以通过调用 world 对象的 Step()方法并把时间步长间隔作为参数传递给它来制作 Box2D 世界的动画。然而,这就是事情变得有点棘手的地方。
按照 Box2D 手册的建议,理想情况下,我们应该使用固定的时间步长来获得最佳结果,因为可变的时间步长很难调试。同样根据手册,Box2D 使用 1/60 秒的时间步长效果最好,你应该使用不超过 1/30 秒的时间步长。如果时间步长变得非常大,Box2D 开始出现碰撞问题,物体开始相互穿过。
requestAnimationFrame API 可以在不同的浏览器和机器上改变调用 animate()方法的频率。解决这个问题的一种方法是测量从最后一次调用 animate()以来经过的时间,并将这个差值作为时间步长传递给 Box2D。
但是,如果我们在浏览器上切换标签页,然后返回到游戏标签页,浏览器调用 animate()方法的频率就会降低,这个时间步长可能会变得远大于 1/30 秒的上限。为了避免因时间步长过大而导致的问题,如果时间步长大于 1/30 秒,我们需要主动限制时间步长。
有了这些信息,我们将首先在 box2d 对象中定义一个 step()方法,该方法将时间间隔作为参数,并调用世界对象的 Step()方法(见清单 4-14 )。
清单 4-14。box2d . step()方法
step:function(timeStep){
// velocity iterations = 8
// position iterations = 3
if(timeStep >2/60){
timeStep = 2/60
}
box2d.world.Step(timeStep,8,3);
},
step()方法以秒为单位获取时间步长,并将其传递给世界。Step()方法。如果时间步长太大,我们将其限制在 1/30 秒。对于速度和位置迭代,我们使用 Box2D 手册推荐的值 8 和 3。我们会在计算完时间步长后,从 game.animate()方法中调用这个方法。game.animate()方法现在看起来像清单 4-15 中的。
清单 4-15。 从 game.animate()中调用 box2d.step()
animate:function(){
// Animate the background
game.handlePanning();
// Animate the characters
var currentTime = new Date().getTime();
var timeStep;
if (game.lastUpdateTime){
timeStep = (currentTime - game.lastUpdateTime)/1000;
box2d.step(timeStep);
}
game.lastUpdateTime = currentTime;
// 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.drawAllBodies();
// Draw the front of the slingshot
game.context.drawImage(game.slingshotFrontImage,game.slingshotX-game.offsetLeft,game.slingshotY);
if (!game.ended){
game.animationFrame = window.requestAnimationFrame(game.animate,game.canvas);
}
},
我们将 timeStep 计算为 lastUpdateTime 和 currentTime 之差,然后调用 box2d.step()方法。然后,我们将当前时间保存到 game.lastUpdateTime 变量中。
第一次调用 animate()时,game.lastUpdateTime 会未定义,所以我们不会计算 timeStep 或者调用 box2d.step()。
装载英雄
现在动画和引擎已经就绪,是时候实现更多的游戏状态了(也就是游戏模式)。我们将实现的第一个状态是 load-next-hero 状态。当处于这种状态时,游戏需要统计游戏中的英雄和反派还剩多少,检查还剩多少,并据此行动,如下:
- 如果所有反派都走了,游戏切换到状态层面——成功。
- 如果所有的英雄都走了,游戏切换到状态等级——失败。
- 如果还有英雄剩余,游戏会将第一个英雄放在弹弓上,然后切换到等待射击状态。
我们将通过创建一个名为 game.countHeroesAndVillains 恶棍()的方法并修改 game.handlePanning()方法来实现这一点,如清单 4-16 所示。
清单 4-16。 搬运负载——下一个——英雄状态
countHeroesAndVillains:function(){
game.heroes = [];
game.villains = [];
for (var body = box2d.world.GetBodyList(); body; body = body.GetNext()) {
var entity = body.GetUserData();
if(entity){
if(entity.type == "hero"){
game.heroes.push(body);
} else if (entity.type =="villain"){
game.villains.push(body);
}
}
}
},
handlePanning:function(){
if (game.mode=="intro"){
if(game.panTo(700)){
game.mode = "load-next-hero";
}
}
if (game.mode=="wait-for-firing"){
game.panTo(game.slingshotX);
}
if (game.mode == "firing"){
game.panTo(game.slingshotX);
}
if (game.mode == "fired"){
// TODO:
// Pan to wherever the hero currently is
}
if (game.mode == "load-next-hero"){
game.countHeroesAndVillains();
// Check if any villains are alive, if not, end the level (success)
if (game.villains.length == 0){
game.mode = "level-success";
return;
}
// Check if there are any more heroes left to load, if not end the level (failure)
if (game.heroes.length == 0){
game.mode = "level-failure"
return;
}
// Load the hero and set mode to wait-for-firing
if(!game.currentHero){
game.currentHero = game.heroes[game.heroes.length-1];
game.currentHero.SetPosition({x:180/box2d.scale,y:200/box2d.scale});
game.currentHero.SetLinearVelocity({x:0,y:0});
game.currentHero.SetAngularVelocity(0);
game.currentHero.SetAwake(true);
} else {
// Wait for hero to stop bouncing and fall asleep and then switch to wait-for-firing
game.panTo(game.slingshotX);
if(!game.currentHero.IsAwake()){
game.mode = "wait-for-firing";
}
}
}
},
countHeroesAndVillains()方法遍历世界上的所有实体,并将英雄存储在 game.heroes 数组中,将恶棍存储在 game.villains 数组中。
在 handlePanning()方法内部,当 game.mode 为 load-next-hero 时,我们首先调用 countHeroesandVillains()。然后,我们检查反派或英雄的数量是否为 0,如果是,将 game.mode 分别设置为成功级或失败级。如果没有,我们将游戏中的最后一个英雄保存到 game.currentHero 变量中,并将其位置设置为弹弓上方的空中点。我们把它的角速度和线速度设为 0。我们也唤醒身体以防它睡着。
当身体落到弹弓上时,它会一直弹跳,直到最后停下来,再次睡着。一旦身体回到睡眠状态,我们就将游戏模式设置为等待开火。如果我们运行游戏,开始第一关,我们会看到第一个英雄在弹弓上弹起,来休息,如图图 4-3 所示。
图 4-3。第一个英雄被装上弹弓,等待发射
现在我们已经准备好发射英雄,我们需要处理从弹弓发射英雄。
解雇英雄
我们将使用三种状态来实现解雇英雄:
- 等待开火:游戏在弹弓上方平移,当指针在英雄上方时等待鼠标被点击拖动。当这种情况发生时,它转换到激发状态。
- 射击:游戏用鼠标移动英雄,直到松开鼠标键。当这种情况发生时,它会根据与弹弓的距离以一种冲动推动英雄,并转换到发射状态。
- 开火:游戏平移跟随英雄,直到它停止或超出关卡边界。然后游戏将英雄从游戏世界中移除,并回到加载下一个英雄的状态。
我们将首先在游戏对象内部实现一个名为 mouseOnCurrentHero()的方法来测试鼠标指针是否定位在当前英雄上(参见清单 4-17 )。
清单 4-17。mouseOnCurrentHero()方法
mouseOnCurrentHero:function(){
if(!game.currentHero){
return false;
}
var position = game.currentHero.GetPosition();
var distanceSquared = Math.pow(position.x*box2d.scale - mouse.x-game.offsetLeft,2) + Math.pow(position.y*box2d.scale-mouse.y,2);
var radiusSquared = Math.pow(game.currentHero.GetUserData().radius,2);
return (distanceSquared<= radiusSquared);
},
此方法计算当前英雄中心和鼠标位置之间的距离,并将其与当前英雄的半径进行比较,以检查鼠标是否位于英雄上方。如果距离小于半径,鼠标指针将定位在英雄上。
我们可以使用这个简单的检查,因为我们所有的英雄都是圆形的。如果你想实现不同形状的英雄,你可能需要一个更复杂的方法。
现在我们已经有了这个方法,我们可以在 handlePanning()方法中实现三个状态,如清单 4-18 所示。
清单 4-18。 处理 handlePanning()方法内的点火状态
if (game.mode=="wait-for-firing"){
if (mouse.dragging){
if (game.mouseOnCurrentHero()){
game.mode = "firing";
} else {
game.panTo(mouse.x + game.offsetLeft)
}
} else {
game.panTo(game.slingshotX);
}
}
if (game.mode == "firing"){
if(mouse.down){
game.panTo(game.slingshotX);
game.currentHero.SetPosition({x:(mouse.x+game.offsetLeft)/box2d.scale,y:mouse.y/box2d.scale});
} else {
game.mode = "fired";
var impulseScaleFactor = 0.75;
var impulse = new b2Vec2((game.slingshotX+35-mouse.x-game.offsetLeft)*impulseScaleFactor,(game.slingshotY+25-mouse.y)*impulseScaleFactor);
game.currentHero.ApplyImpulse(impulse,game.currentHero.GetWorldCenter());
}
}
if (game.mode == "fired"){
//pan to wherever the current hero is...
var heroX = game.currentHero.GetPosition().x*box2d.scale;
game.panTo(heroX);
//and wait till he stops moving or is out of bounds
if(!game.currentHero.IsAwake() || heroX<0 || heroX >game.currentLevel.foregroundImage.width ){
// then delete the old hero
box2d.world.DestroyBody(game.currentHero);
game.currentHero = undefined;
// and load next hero
game.mode = "load-next-hero";
}
}
当状态为等待射击,鼠标被拖动时,如果鼠标指针位于英雄上,我们将状态更改为射击;如果鼠标指针不在英雄身上,我们就把屏幕向光标移动。如果鼠标没有被拖动,我们将镜头转向弹弓。
当状态为开火,鼠标键按下时,我们将英雄的位置设置为鼠标位置,并向弹弓方向平移。当释放鼠标按钮时,我们将状态设置为 fired,并使用 b2Body 对象的 ApplyImpulse()方法将脉冲应用于英雄。该方法以 b2Vec2 对象的形式将脉冲作为参数。我们将脉冲向量的 x 和 y 值设置为英雄距离弹弓顶部的 x 和 y 距离的倍数。(脉冲比例因子是我通过试验不同值得出的一个数字。)
当状态被触发时,我们将屏幕移向英雄,等待英雄休息或落在游戏边界之外。当出现这种情况时,我们使用 DestroyBody()方法将英雄从世界中移除,并将状态改回 load-next-hero。
当我们运行这个完成的代码并加载关卡时,我们应该能够向方块开火并击倒它们,如图 4-4 所示。
图 4-4。向街区开火并击倒他们
一旦英雄停止滚动或超出关卡边界,他将被从游戏中移除,下一个英雄将被装载到弹弓上。此时,一旦所有的英雄都走了,游戏就停止等待。因此,我们需要做的最后一件事是实现结束级别。
结束关卡
一旦关卡结束,我们将停止游戏动画循环,并显示一个关卡结束画面。该屏幕将为用户提供重放当前级别、前进到下一级别或返回到级别选择屏幕的选项。
我们需要做的第一件事是向 endingscreen div 元素添加 onclick 事件处理程序,并将其放在 index.html 文件中其他游戏层之后。最终的标记将类似于清单 4-19 中的。
清单 4-19。 结束屏幕 div 元素
<div id="endingscreen" class="gamelayer">
<div>
<p id="endingmessage">The Level Is Over Message</p>
<p id="playcurrentlevel" onclick="game.restartLevel();"><img src="img/prev.png"> Replay Current Level</p>
<p id="playnextlevel" onclick="game.startNextLevel();"><img src="img/next.png"> Play Next Level </p>
<p id="returntolevelscreen"onclick="game.showLevelScreen();"><img src="img/return.png"> Return to Level Screen</p>
</div>
</div>
我们还需要将相应的 CSS 添加到 styles.css 中,如清单 4-20 所示。
清单 4-20。 CSS 为 endingscreen div 元素
/* Ending Screen */
endingscreen {
text-align:center;
}
#endingscreen div {
height:430px;
padding-top:50px;
border:1px;
background:rgba(1,1,1,0.5);
text-align:left;
padding-left:100px;
}
#endingscreen p {
font: 20px Comic Sans MS;
text-shadow: 0 0 2px #000;
color:white;
}
#endingscreen p img{
top:10px;
position:relative;
cursor:pointer;
}
#endingscreen #endingmessage {
font: 32px Comic Sans MS;
text-shadow: 0 0 2px #000;
color:white;
}
既然结束屏幕已经准备好了,我们将在游戏对象内部实现一个名为 showEndingScreen()的方法,该方法将显示 endingscreen div 元素(参见清单 4-21 )。
清单 4-21。game . showendingscreen()方法
showEndingScreen:function(){
if (game.mode=="level-success"){
if(game.currentLevel.number<levels.data.length-1){
$('#endingmessage').html('Level Complete. Well Done!!!');
$("#playnextlevel").show();
} else {
$('#endingmessage').html('All Levels Complete. Well Done!!!');
$("#playnextlevel").hide();
}
} else if (game.mode=="level-failure"){
$('#endingmessage').html('Failed. Play Again?');
$("#playnextlevel").hide();
}
$('#endingscreen').show();
},
showEndingScreen()方法根据 game.mode 的值显示不同的消息。如果玩家成功了,并且当前关卡不是游戏的最后一关,则显示玩下一关的选项。如果玩家不成功或者当前关卡是最后一关,该选项将被隐藏。
我们现在将在游戏对象的 handlePanning()方法中处理关卡成功和关卡失败,方法是将清单 4-22 中所示的代码添加到该方法的底部。
清单 4-22。 实现一级收尾状态
if(game.mode=="level-success" || game.mode=="level-failure"){
if(game.panTo(0)){
game.ended = true;
game.showEndingScreen();
}
}
当 game.mode 为关卡成功或关卡失败时,游戏首先平移回左侧,然后将 game.ended 属性设置为 true,最后显示如图图 4-5 所示的结束屏幕。
图 4-5。关卡结束画面
当然,由于我们还没有实现碰撞伤害,反派不能死,我们永远不能赢。因此,我们接下来要实现的是碰撞伤害。
碰撞损坏
我们需要做的第一件事是通过使用一个联系监听器并覆盖它的 PostSolve()方法来跟踪冲突,就像我们在第三章中所做的一样。我们将在 box2d 对象的 init()方法中创建这个监听器之后,立即将其添加到世界中,如清单 4-23 所示。
清单 4-23。 使用联系监听器处理碰撞
init:function(){
// 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
box2d.world = new b2World(gravity,allowSleep);
var debugContext = document.getElementById('debugcanvas').getContext('2d');
var debugDraw = new b2DebugDraw();
debugDraw.SetSprite(debugContext);
debugDraw.SetDrawScale(box2d.scale);
debugDraw.SetFillAlpha(0.3);
debugDraw.SetLineThickness(1.0);
debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
box2d.world.SetDebugDraw(debugDraw);
var listener = new Box2D.Dynamics.b2ContactListener;
listener.PostSolve = function(contact,impulse){
var body1 = contact.GetFixtureA().GetBody();
var body2 = contact.GetFixtureB().GetBody();
var entity1 = body1.GetUserData();
var entity2 = body2.GetUserData();
var impulseAlongNormal = Math.abs(impulse.normalImpulses[0]);
// This listener is called a little too often. Filter out very tiny impulses.
// After trying different values, 5 seems to work well
if(impulseAlongNormal>5){
// If objects have a health, reduce health by the impulse value
if (entity1.health){
entity1.health -= impulseAlongNormal;
}
if (entity2.health){
entity2.health -= impulseAlongNormal;
}
}
};
box2d.world.SetContactListener(listener);
},
在 PostSolve()方法中,如果碰撞中涉及的任何一个物体具有健康属性,我们将健康减少法线方向上的冲量值。因为 PostSolve()方法是为每个小碰撞调用的,所以我们忽略任何 impulseAlongNormal 小于阈值的碰撞。
接下来,我们将在游戏对象的 drawAllBodies()方法中添加一些代码,以检查身体的健康属性是否小于零,或者身体是否超出了级别界限。如果任何一个是真的,我们将从世界上移除身体。drawAllBodies()方法现在看起来像清单 4-24 中的。
清单 4-24。 清除世间的死尸
drawAllBodies:function(){
box2d.world.DrawDebugData();
// Iterate through all the bodies and draw them on the game canvas
for (var body = box2d.world.GetBodyList(); body; body = body.GetNext()) {
var entity = body.GetUserData();
if(entity){
var entityX = body.GetPosition().x*box2d.scale;
if(entityX<0|| entityX>game.currentLevel.foregroundImage.width||(entity.health && entity.health <0)){
box2d.world.DestroyBody(body);
if (entity.type=="villain"){
game.score += entity.calories;
$('#score').html('Score: '+game.score);
}
} else {
entities.draw(entity,body.GetPosition(),body.GetAngle())
}
}
}
}
如果代码发现身体超出了级别界限或者实体失去了所有健康,我们使用 world 对象的 DestroyBody()方法来移除身体。此外,如果实体是一个恶棍,我们会将实体的卡路里值添加到游戏分数中。
当我们运行游戏时,反派确实被消灭了,分数也增加了,如图图 4-6 所示。
图 4-6。坏人被消灭后,分数会增加
现在我们有了一个工作级别,让我们添加一些收尾工作。我们要做的第一件事是在英雄被射击时画一个弹弓乐队。
画弹弓乐队
弹弓带将会是一条从弹弓末端到英雄最末端的粗棕色线。我们将仅在游戏处于射击模式时绘制乐队。我们将在游戏对象内部的 drawSlingshotBand()方法中实现这一点,如清单 4-25 所示。
清单 4-25。 绘制弹弓乐队
drawSlingshotBand:function(){
game.context.strokeStyle = "rgb(68,31,11)"; // Darker brown color
game.context.lineWidth = 6; // Draw a thick line
// Use angle hero has been dragged and radius to calculate coordinates of edge of hero wrt. hero center
var radius = game.currentHero.GetUserData().radius;
var heroX = game.currentHero.GetPosition().x*box2d.scale;
var heroY = game.currentHero.GetPosition().y*box2d.scale;
var angle = Math.atan2(game.slingshotY+25-heroY,game.slingshotX+50-heroX);
var heroFarEdgeX = heroX - radius * Math.cos(angle);
var heroFarEdgeY = heroY - radius * Math.sin(angle);
game.context.beginPath();
// Start line from top of slingshot (the back side)
game.context.moveTo(game.slingshotX+50-game.offsetLeft, game.slingshotY+25);
// Draw line to center of hero
game.context.lineTo(heroX-game.offsetLeft,heroY);
game.context.stroke();
// Draw the hero on the back band
entities.draw(game.currentHero.GetUserData(),game.currentHero.GetPosition(),game.currentHero.GetAngle());
game.context.beginPath();
// Move to edge of hero farthest from slingshot top
game.context.moveTo(heroFarEdgeX-game.offsetLeft,heroFarEdgeY);
// Draw line back to top of slingshot (the front side)
game.context.lineTo(game.slingshotX-game.offsetLeft +10,game.slingshotY+30)
game.context.stroke();
},
我们首先使用 strokeStyle 属性将绘图颜色设置为深棕色。接下来,我们使用 lineWidth 属性将线条绘制宽度设置为 6 像素。然后我们从弹弓的后面到英雄画一条带子,在带子的上面画英雄,最后,从弹弓的前面到离弹弓最远的英雄的边缘画一条带子。
我们将在绘制完所有其他物体后从 game.animate()方法中调用这个方法。最终的 game.animate()方法将类似于清单 4-26 中的。
清单 4-26。 从 animate()中调用 drawSlingshotBand()方法
animate:function(){
// Animate the background
game.handlePanning();
// Animate the characters
var currentTime = new Date().getTime();
var timeStep;
if (game.lastUpdateTime){
timeStep = (currentTime - game.lastUpdateTime)/1000;
if(timeStep >2/60){
timeStep = 2/60
}
box2d.step(timeStep);
}
game.lastUpdateTime = currentTime;
// 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);
// Draw all the bodies
game.drawAllBodies();
// Draw the band when we are firing a hero
if(game.mode == "firing"){
game.drawSlingshotBand();
}
// Draw the front of the slingshot
game.context.drawImage(game.slingshotFrontImage,game.slingshotX-game.offsetLeft,game.slingshotY);
if (!game.ended){
game.animationFrame = window.requestAnimationFrame(game.animate,game.canvas);
}
},
当我们运行这段代码时,我们应该会看到英雄周围有一条棕色的带子,如图图 4-7 所示。
图 4-7。画弹弓带
这不是一个完整的解决方案。乐队在某些极端的角度看起来可能有点不自然。你可以考虑改进这种方法,在带子上叠加一些额外的图像来掩盖这些边缘效果。目前,这个简单的实现就足够了。
现在我们已经完成了关卡的插图,让我们来实现改变和重启关卡的按钮。
改变级别
我们已经实现了一种方法来遍历级别,使用级别选择屏幕。现在,我们将实现按钮,重新开始一个级别,并进行到下一个级别。
我们首先在游戏对象中实现 restartLevel()和 startNextLevel()方法,如清单 4-27 所示。
清单 4-27。 实现 restartLevel()和 startNextLevel()
restartLevel:function(){
window.cancelAnimationFrame(game.animationFrame);
game.lastUpdateTime = undefined;
levels.load(game.currentLevel.number);
},
startNextLevel:function(){
window.cancelAnimationFrame(game.animationFrame);
game.lastUpdateTime = undefined;
levels.load(game.currentLevel.number+1);
},
这些方法相当简单。两者都取消任何现有的 animationFrame 循环,重置 game.lastUpdateTime 变量,最后用适当的级别号调用 levels.load()方法。我们需要从 scorescreen 和 endingscreen 层中相应图像的 onclick 事件中调用这些方法,如清单 4-28 所示。
清单 4-28。 设置 onclick 事件以改变等级
<div id="scorescreen" class="gamelayer">
<img id="togglemusic" src="img/sound.png">
<img src="img/prev.png" onclick="game.restartLevel();">
<span id="score">Score: 0</span>
</div>
<div id="endingscreen" class="gamelayer">
<div>
<p id="endingmessage">The Level Is Over Message</p>
<p id="playcurrentlevel" onclick="game.restartLevel();"><img src="img/prev.png"> Replay Current Level</p>
<p id="playnextlevel" onclick="game.startNextLevel();"><img src="img/next.png"> Play Next Level </p>
<p id="returntolevelscreen"onclick="game.showLevelScreen();"><img src="img/return.png"> Return to Level Screen</p>
</div>
</div>
如果我们运行游戏,我们现在应该能够使用提供的按钮重新开始一个级别或进行到下一个级别。
我们现在有了一个完整关卡的工作游戏。我们也有一个简单的方法来建立新的水平。然而,还缺少最后一个元素:声音。
添加声音
添加声音使游戏更具沉浸感。我们将开始添加一些音效,比如当弹弓被释放时,当英雄或坏人弹起时,以及当其中一个方块被破坏时。我们还将添加一些背景音乐,以及如果我们想关闭它的能力。
每种效果的声音文件都可以在音频文件夹中找到(MP3 和 OGG 格式)。
我们将从在游戏对象的 init()方法中加载这些声音文件开始,如清单 4-29 所示。
清单 4-29。 加载声音和背景音乐
init: function(){
// Initialize objects
levels.init();
loader.init();
mouse.init();
// Load All Sound Effects and Background Music
//"Kindergarten" by Gurdonark
//http://ccmixter.org/files/gurdonark/26491is licensed under a Creative Commons license
game.backgroundMusic = loader.loadSound('audio/gurdonark-kindergarten');
game.slingshotReleasedSound = loader.loadSound("audio/released");
game.bounceSound = loader.loadSound('audio/bounce');
game.breakSound = {
"glass":loader.loadSound('audio/glassbreak'),
"wood":loader.loadSound('audio/woodbreak')
};
// Hide all game layers and display the start screen
$('.gamelayer').hide();
$('#gamestartscreen').show();
//Get handler for game canvas and context
game.canvas = document.getElementById("gamecanvas");
game.context = game.canvas.getContext('2d');
},
该代码使用 loader.loadSound()方法加载不同的声音文件,并保存它们供以后参考。我们将断音存储在一个关联数组中,这样我们可以方便地为更多实体添加声音,并通过名称引用它们。背景音乐是由 Gurdonark 创作的名为“幼儿园”的优秀创作通用许可曲调。
提示你可以在位于
www.ccmixter.com
的 ccMixter 网站上为你自己的游戏找到一些令人惊叹的免费音乐。
添加中断和弹跳声音
现在我们已经加载了这些声音,我们需要将这些声音效果与实体相关联,并在正确的时间播放它们。我们将修改 entities.create()方法,并在实体定义中设置中断和反弹声音,如清单 4-30 所示。
清单 4-30。 在创作时给实体分配声音
create:function(entity){
var definition = entities.definitions[entity.name];
if(!definition){
console.log ("Undefined entity name",entity.name);
return;
}
switch(entity.type){
case "block": // simple rectangles
entity.health = definition.fullHealth;
entity.fullHealth = definition.fullHealth;
entity.shape = "rectangle";
entity.sprite = loader.loadImage("img/"+entity.name+".png");
entity.breakSound = game.breakSound[entity.name];
box2d.createRectangle(entity,definition);
break;
case "ground": // simple rectangles
// No need for health. These are indestructible
entity.shape = "rectangle";
// No need for sprites. These won't be drawn at all
box2d.createRectangle(entity,definition);
break;
case "hero": // simple circles
case "villain": // can be circles or rectangles
entity.health = definition.fullHealth;
entity.fullHealth = definition.fullHealth;
entity.sprite = loader.loadImage("img/"+entity.name+".png");
entity.shape = definition.shape;
entity.bounceSound = game.bounceSound;
if(definition.shape == "circle"){
entity.radius = definition.radius;
box2d.createCircle(entity,definition);
} else if(definition.shape == "rectangle"){
entity.width = definition.width;
entity.height = definition.height;
box2d.createRectangle(entity,definition);
}
break;
default:
console.log("Undefined entity type",entity.type);
break;
}
},
像这样在创作过程中给实体附加声音的好处是,每个实体都可以有自己定制的“中断”声音和“反弹”声音。现在,我们需要做的就是在事件实际发生时播放声音。首先,在我们之前定义的 b2ContactListener 对象中,每当我们检测到碰撞时,我们就播放反弹声,如清单 4-31 所示。
清单 4-31。 碰撞时发出的弹跳声
var listener = new Box2D.Dynamics.b2ContactListener;
listener.PostSolve = function(contact,impulse){
var body1 = contact.GetFixtureA().GetBody();
var body2 = contact.GetFixtureB().GetBody();
var entity1 = body1.GetUserData();
var entity2 = body2.GetUserData();
var impulseAlongNormal = Math.abs(impulse.normalImpulses[0]);
// This listener is called a little too often. Filter out very tiny impulses.
// After trying different values, 5 seems to work well
if(impulseAlongNormal>5){
// If objects have a health, reduce health by the impulse value
if (entity1.health){
entity1.health -= impulseAlongNormal;
}
if (entity2.health){
entity2.health -= impulseAlongNormal;
}
// If objects have a bounce sound, play the sound
if (entity1.bounceSound){
entity1.bounceSound.play();
}
if (entity2.bounceSound){
entity2.bounceSound.play();
}
}
};
在碰撞过程中,我们检查实体是否定义了 bounceSound 属性,如果是,我们播放声音。如果我们为任何实体定义反弹声音,这段代码将自动播放它。接下来,我们在游戏对象的 drawAllBodies()方法中,在任何对象被破坏的时候播放中断声音(见清单 4-32 )。
清单 4-32。 播放物体被破坏时的破裂声
drawAllBodies:function(){
box2d.world.DrawDebugData();
// Iterate through all the bodies and draw them on the game canvas
for (var body = box2d.world.GetBodyList(); body; body = body.GetNext()) {
var entity = body.GetUserData();
if(entity){
var entityX = body.GetPosition().x*box2d.scale;
if(entityX<0|| entityX>game.currentLevel.foregroundImage.width||(entity.health && entity.health <0)){
box2d.world.DestroyBody(body);
if (entity.type=="villain"){
game.score += entity.calories;
$('#score').html('Score: '+game.score);
}
if (entity.breakSound){
entity.breakSound.play();
}
} else {
entities.draw(entity,body.GetPosition(),body.GetAngle())
}
}
}
},
同样,我们检查被销毁的实体是否有 breakSound 属性,如果有,我们播放声音。到目前为止,我们已经为玻璃和木块定义了中断声音,但是我们可以很容易地扩展代码来为其他实体添加声音。
最后,当 game.mode 在 handlePanning()方法中从 fired 变为 fired 时,我们会播放 slingshotReleasedSound(参见清单 4-33 )。
清单 4-33。 打弹弓时释放声音英雄被发射
if (game.mode == "firing"){
if(mouse.down){
game.panTo(game.slingshotX);
game.currentHero.SetPosition({x:(mouse.x+game.offsetLeft)/box2d.scale,y:mouse.y/box2d.scale});
} else {
game.mode = "fired";
game.slingshotReleasedSound.play();
var impulseScaleFactor = 0.75;
// Coordinates of center of slingshot (where the band is tied to slingshot)
var slingshotCenterX = game.slingshotX + 35;
var slingshotCenterY = game.slingshotY+25;
var impulse = new b2Vec2((slingshotCenterX -mouse.x-game.offsetLeft)*impulseScaleFactor,(slingshotCenterY-mouse.y)*impulseScaleFactor);
game.currentHero.ApplyImpulse(impulse, game.currentHero.GetWorldCenter());
}
}
现在,当你运行游戏时,你应该听到当英雄被解雇时,当它撞到什么东西时,或者当积木被破坏时的声音效果。我们最后要添加的是背景音乐。
添加背景音乐
我们已经在 game.init()方法中加载了背景音乐文件和其他声音文件。现在我们需要创建一些方法来开始、停止和切换背景音乐。我们将把这些方法添加到游戏对象中,如清单 4-34 所示。
清单 4-34。 控制背景音乐的方法
startBackgroundMusic:function(){
var toggleImage = $("#togglemusic")[0];
game.backgroundMusic.play();
toggleImage.src="img/sound.png";
},
stopBackgroundMusic:function(){
var toggleImage = $("#togglemusic")[0];
toggleImage.src="img/nosound.png";
game.backgroundMusic.pause();
game.backgroundMusic.currentTime = 0; // Go to the beginning of the song
},
toggleBackgroundMusic:function(){
var toggleImage = $("#togglemusic")[0];
if(game.backgroundMusic.paused){
game.backgroundMusic.play();
toggleImage.src="img/sound.png";
} else {
game.backgroundMusic.pause();
$("#togglemusic")[0].src="img/nosound.png";
}
},
startBackgroundMusic()方法首先调用 BackgroundMusic 对象的 play()方法,然后设置“切换音乐”按钮图像以显示有声音传出的扬声器。
stopBackgroundMusic()方法设置切换音乐按钮图像以显示没有声音的扬声器。然后,它调用 backgroundMusic 对象的 pause()方法,并通过将其 currentTime 属性设置为 0,将音频设置回歌曲的开头。
最后,toggleBackgroundMusic()方法检查音乐当前是否暂停,调用 pause()或 play()方法,然后适当地设置切换图像。
现在我们已经有了这些方法,我们需要调用它们。我们会在游戏从 game.start()方法内部启动时调用 startBackgroundMusic()方法,如清单 4-35 所示。
清单 4-35。 启动背景音乐
start:function(){
$('.gamelayer').hide();
// Display the game canvas and score
$('#gamecanvas').show();
$('#scorescreen').show();
game.startBackgroundMusic();
game.mode = "intro";
game.offsetLeft = 0;
game.ended = false;
game.animationFrame = window.requestAnimationFrame(game.animate,game.canvas);
},
接下来,每当关卡结束时,我们将调用 stopBackgroundMusic()方法,将它添加到 showEndingScreen()方法中,如清单 4-36 所示。
清单 4-36。 停止背景音乐
showEndingScreen:function(){
game.stopBackgroundMusic();
if (game.mode=="level-success"){
if(game.currentLevel.number<levels.data.length-1){
$('#endingmessage').html('Level Complete. Well Done!!!');
$("#playnextlevel").show();
} else {
$('#endingmessage').html('All Levels Complete. Well Done!!!');
$("#playnextlevel").hide();
}
} else if (game.mode=="level-failure"){
$('#endingmessage').html('Failed. Play Again?');
$("#playnextlevel").hide();
}
$('#endingscreen').show();
},
最后,我们将从 scorescreen 层内部的 toggle music 按钮的 onclick 事件中调用 toggleBackgroundMusic()方法,如清单 4-37 所示。
清单 4-37。 切换背景音乐
<div id="scorescreen" class="gamelayer">
<img id="togglemusic" src="img/sound.png" onclick="game.toggleBackgroundMusic()">
<img src="img/prev.png" onclick="game.restartLevel();">
<span id="score">Score: 0</span>
</div>
现在,当我们运行游戏时,背景音乐会在每一关开始时播放。如果我们点击切换按钮,音乐暂停,按钮变为无声图标,如图图 4-8 所示。
图 4-8。游戏结束,背景音乐关闭
有了这最后的改变,我们的游戏终于完成了。我们现在可以选择一个关卡,在听音效和背景音乐的同时,通过投掷英雄果实来攻击邪恶的垃圾食品来玩游戏。花些时间享受这个游戏,想出你自己的关卡创意。
摘要
在过去的三章中,我们创建了第一个基于物理引擎的 HTML5 游戏。我们从第二章开始,创建了一个基本的游戏框架,包括菜单、关卡系统和资源加载器,并设置了游戏动画。然后我们在第三章中讲述了 Box2D 的基础知识。最后,在这一章中,我们将 Box2D 集成到我们现有的游戏框架中,并通过添加菜单选项、音效和音乐来包装我们的游戏。
当然,这款游戏的功能性还有很大的拓展空间。一些显而易见的下一步将是为不同的实体添加动画,添加更多的关卡,调整游戏物理参数,并添加更多具有不同特征的英雄和恶棍。
然而,这款游戏拥有人们对一款好的 HTML5 游戏的所有基本元素。你可以使用这个游戏中的代码作为任何你自己的基于物理引擎的游戏的起点,并把它带到你想去的任何地方。
现在我们已经完成了我们的第一个游戏,我们将在接下来的几章中进行一个稍微更具挑战性的项目:构建一个完整的多人实时策略游戏。所以我们继续吧。
五、创建 RTS 游戏世界
即时战略(RTS)游戏结合了快节奏的战术战斗、资源管理和经济建设。
一个典型的 RTS 游戏包括一个有不同单位、建筑和地形的世界地图,以及一个控制和操作这些元素的界面。玩家使用界面来处理任务,例如收集资源、建造建筑物和创建军队,然后管理军队以实现为每个级别定义的一组目标。
虽然这些游戏有着广泛的历史,但 RTS 类型在很大程度上是由韦斯特伍德工作室和暴雪娱乐公司在 20 世纪 90 年代发布的游戏普及的。韦斯特伍德的沙丘 II 和命令&征服系列被认为是帮助定义该类型的经典。凭借其引人入胜的故事线和令人上瘾的多人游戏,暴雪的星际争霸继续将 RTS 游戏提升为电子竞技,在世界各地举办职业竞技比赛。
HTML5 现在可以将这种风格带到浏览器中,这在以前是不可能的。事实上,在过去的一年中,我最著名的游戏编程相关的成就之一就是在 HTML5 中单独重新创建了最初的命令&征服。虽然在网上引起了很多议论,但这个项目毫无疑问地证明了 HTML5 已经为下一代游戏做好了准备。
在接下来的几章中,我们将使用我们在前面章节中学到的知识,并在此基础上创建我们自己的 RTS 游戏。我们将定义一个有建筑、单位和主线故事的游戏世界,来创造一个引人入胜的单人战役。然后,我们将使用 HTML5 websockets 为我们的游戏添加实时多人支持。
这款游戏的大部分艺术作品由丹尼尔·库克(www.lostgarden.com)提供,他最初为一款未发布的 RTS 游戏《??》设计了这幅艺术作品。我们将重用他优雅地分享的作品,但将创造我们自己的游戏概念。我们的游戏最后的殖民地,将讲述一小群幸存者在一个刚刚被攻击的星球上的故事。我们将在接下来的几章中更详细地探索故事和游戏。
在开发这个游戏时,我们将尽可能保持代码的通用性和可定制性,以便您以后可以重用这些代码来构建您自己的想法。
那么,我们开始吧。
基本 HTML 布局
像之前的游戏一样,我们的 RTS 游戏将由几层组成。以下是我们将定义的最初几层:
- 闪屏和主菜单:游戏加载时显示,允许玩家选择战役或多人模式
- 载入画面:游戏载入素材时显示
- 任务画面:任务开始前显示,带有任务说明
- 游戏界面画面 :游戏主画面,包括地图区和控制游戏的仪表盘
我们将在后面的章节中根据需要定义更多的屏幕。我们将在一个图像文件夹中组织所有的艺术作品。与上一个游戏不同,我们将 JavaScript 代码分解成 js 文件夹中的几个文件(如 buildings.js、vehicles.js、levels.js 和 common.js ),以使代码更易于维护。
创建闪屏和主菜单
我们将从创建一个 HTML 文件并为我们的容器添加标记开始,如清单 5-1 所示。
清单 5-1。 【基本骨架】(index.html)添加图层
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>Last Colony</title>
<script src="js/common.js" type="text/javascript" charset="utf-8"></script>
<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>
<script src="js/mouse.js" type="text/javascript" charset="utf-8"></script>
<script src="js/singleplayer.js" type="text/javascript" charset="utf-8"></script>
<script src="js/maps.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">
<div id="gamestartscreen" class="gamelayer">
<span id="singleplayer" onclick = "singleplayer.start();">Campaign</span><br>
<span id="multiplayer" onclick = "multiplayer.start();">Multiplayer</span><br>
</div>
<div id="loadingscreen" class="gamelayer">
<div id="loadingmessage"></div>
</div>
</div>
</body>
</html>
代码首先引用我们将使用的外部 JavaScript 和 CSS 文件。我们将在游戏过程中创建和实现所有这些 JavaScript 文件(除了 jQuery 代码)。我们还定义了一个 gamecontainer div,它包含了我们的前两个游戏层:gamestartscreen 和 loadingscreen。
接下来我们要做的是在 styles.css 中定义游戏容器的初始样式,如清单 5-2 所示。
清单 5-2。 游戏容器和图层的初始样式表(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;
}
在这段代码中,我们设置了游戏容器和层的宽度和高度,并分配了一个背景闪屏,就像我们在之前的游戏中所做的那样。
当我们在浏览器中加载 index.html 时,我们应该会看到新的闪屏,如图 5-1 所示。
图 5-1。初始游戏启动画面
现在闪屏已经就绪,我们可以实现主菜单屏幕和游戏加载屏幕了。
我们将从设置 requestAnimationFrame 和素材加载器开始,使用与我们在上一个游戏中相同的代码。我们将把这段代码放在一个名为 common.js 的单独文件中,如清单 5-3 所示。
清单 5-3。 设置 requestAnimationFrame 和图像加载器(common.js)
// Setup requestAnimationFrame and cancelAnimationFrame for use in the game code
(function() {
var lastTime = 0;
var vendors = ['ms', ';', '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);
};
}());
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.loaded = true;
$('#loadingscreen').hide();
if(loader.onload){
loader.onload();
loader.onload = undefined;
}
}
}
}
接下来,我们将在 game.js 中定义我们的游戏对象,如清单 5-4 所示。
清单 5-4。 定义游戏对象的 init()方法(game.js)
$(window).load(function() {
game.init();
});
var game = {
// Start preloading assets
init: function(){
loader.init();
$('.gamelayer').hide();
$('#gamestartscreen').show();
},
}
在这段代码中,我们使用 init()方法创建了一个游戏对象,该方法首先初始化我们的素材加载器,然后使用 jQuery 显示游戏开始屏幕。一旦窗口完全加载,我们还使用窗口加载处理程序来调用 game.init()。
最后,我们需要在 styles.css 中追加游戏开始屏幕和加载屏幕的 CSS,如清单 5-5 所示。
清单 5-5。 游戏启动画面和加载画面的样式(styles.css)
/* Game Starting Menu Screen */
#gamestartscreen {
padding-top:320px;
text-align:left;
padding-left:50px;
width:590px;
height:160px;
}
#gamestartscreen span {
margin:20px;
font-family: 'Courier New', Courier, monospace;
font-size: 48px;
cursor:pointer;
color:white ;
text-shadow: -2px 0 purple, 0 2px purple, 2px 0 purple, 0 -2px purple;
}
#gamestartscreen span:hover {
color:yellow;
}
/* Loading Screen */
#loadingscreen {
background:rgba(100,100,100,0.7);
z-index:10;
}
#loadingmessage {
margin-top:400px;
text-align:center;
height:48px;
color:white;
background:url(img/loader.gif) no-repeat center;
font:12px Arial;
}
当我们在浏览器中打开游戏时,应该会看到带有主菜单的开始屏幕,如图图 5-2 所示。
图 5-2。带有主菜单的启动屏幕
菜单目前提供了战役选项,是我们基于故事的单人游戏模式,多人游戏是我们的玩家对战模式。你可能已经注意到在清单 5-1 中,这两个选项的 onclick 处理程序分别调用了 singleplayer.start() 和 multiplayer.start()方法。现在,单击战役选项不会做任何事情,因为我们还没有实现单人游戏对象。然而,在此之前,我们需要创建第一个关卡。
创建我们的第一关
对于我们的游戏,有许多可行的方法来定义地图或关卡。一种方法是将关于地图地形的所有信息存储为元数据,然后在运行时通过在浏览器上组装地形的所有必要图像来绘制地图。
另一种方法稍微简单一点,就是将基本地图存储为一幅大图,并使用我们自己的关卡设计工具绘制地形。然后,我们只需要存储地图图像的位置以及元数据,如游戏实体和任务目标。这是我们将在游戏中使用的方法。
使用 Tiled(www.mapeditor.org)等通用平铺地图编辑软件可以很快设计出地图图像。Tiled 是一个优秀的免费工具,可用于多种操作系统,包括 Windows、Mac 和 Linux。一旦你启动应用,你可以加载地形的精灵表作为一个图块集,然后用它来绘制地图,就像你在使用一个绘画应用一样(见图 5-3 )。
图 5-3。使用平铺绘制地图
绘制地图后,可以将其导出为多种不同的文件格式,如 PNG 图像或 JSON 元数据。你不需要使用这个工具来跟随这本书,因为我们需要的所有地图已经生成。然而,如果你正在考虑开发自己的游戏,我强烈建议你探索一下这个工具的特性。
注意平铺编辑器的 JSON 格式包含对 sprite 表的引用和它使用的所有平铺块的偏移量。这意味着您可以使用 JSON 文件来创建在运行时组装的地图(而不是我们正在创建的预组装地图)。
一旦我们设计了第一个地图图像,我们将需要创建描述关卡的基本元数据。我们将在 maps.js 中这样做,如清单 5-6 所示。
清单 5-6。 定义基层元数据(maps.js)
var maps = {
"singleplayer":[
{
"name":"Introduction",
"briefing": "In this level you will learn how to pan across the map.\n\nDon't worry! We will be implementing more features soon.",
/* Map Details */
"mapImage":"img/level-one-debug-grid.png",
"startX":4,
"startY":4,
},
]
};
我们定义了一个包含单人数组的地图对象。此数组当前仅包含一张地图的详细信息。这个数组将最终包含所有按时间顺序排列的单人战役地图。当单人战役开始时,单人玩家对象将加载该数组中的第一张地图,然后随着玩家完成每一关而在列表中继续向下移动。
我们为关卡存储的详细信息包括关卡名称和任务简介,我们将在开始关卡之前显示。然后,我们存储地图图像和起始地图坐标(startX,startY)。
地图图像被分解成 20 像素宽 20 像素高的方格(基于我们使用的图块的大小)。现在,我们使用的是一个“调试”版本的关卡,它在地图上绘制了这个网格。这将使我们在构建游戏时更容易定位关卡中的元素。
当我们使用网格坐标开始关卡时,起始地图坐标让我们决定屏幕在地图上的位置。
现在我们已经定义了一个简单的地图,我们将设置单人游戏对象并显示任务简报屏幕。
加载任务简报屏幕
我们要做的第一件事是将任务屏幕的 HTML 代码添加到 HTML 文件(index.html)的主体中。HTML 的主体现在将看起来像清单 5-7 中的。
清单 5-7。 添加任务画面
<body>
<div id="gamecontainer">
<div id="gamestartscreen" class="gamelayer">
<span id="singleplayer" onclick = "singleplayer.start();">Campaign</span><br>
<span id="multiplayer" onclick = "multiplayer.start();">Multiplayer</span><br>
</div>
<div id="missionscreen" class="gamelayer">
<input type="button" id="entermission" onclick = "singleplayer.play();">
<input type="button" id="exitmission" onclick = "singleplayer.exit();">
<div id="missonbriefing"></div>
</div>
<div id="loadingscreen" class="gamelayer">
<div id="loadingmessage"></div>
</div>
</div>
</body>
missionscreen div 包含两个按钮;它们用于进入任务和退出任务屏幕。它还包含一个 missionbriefing div,我们将使用它来显示简报消息。
现在我们已经有了 HTML 标记,我们需要将任务屏幕的 CSS 样式添加到 styles.css 中,如清单 5-8 所示。
清单 5-8。 任务画面的 CSS 样式
/* Mission Briefing Screen */
#missionscreen {
background: url(img/missionscreen.png) no-repeat;
}
#missionscreen #entermission {
position:absolute;
top:79px;
left:6px;
width:246px;
height:68px;
border-width:0px;
background-image: url(img/buttons.png);
background-position: 0px 0px;
}
#missionscreen #entermission:disabled, #missionscreen #entermission:active {
background-image: url(img/buttons.png);
background-position: -251px 0px;
}
#missionscreen #exitmission {
position:absolute;
top:79px;
left:380px;
width:98px;
height:68px;
border-width:0px;
background-image: url(img/buttons.png);
background-position: 0px -76px;
}
#missionscreen #exitmission:disabled,#missionscreen #exitmission:active{
background-image: url(img/buttons.png);
background-position: -103px -76px;
}
#missionscreen #missonbriefing {
position:absolute;
padding:10px;
top:160px;
left:20px;
width:410px;
height:300px;
color:rgb(130,150,162);
font-size: 13px;
font-family: 'Courier New', Courier, monospace;
}
我们为任务简报屏幕定义了一个新的背景,看起来像一个未来的控制台。然后我们定位按钮和 div 元素以适应背景。我们为按钮的启用和禁用状态保留不同的图像,但是将所有这些精灵存储在一个图像文件(buttons.png)中。
既然任务简报层已经就绪,我们将在 singleplayer.js 中用 start()和 exit()方法实现 singleplayer 对象,如清单 5-9 所示。
清单 5-9。 实现基本 Singleplayer 对象(singleplayer.js)
var singleplayer = {
// Begin single player campaign
start:function(){
// Hide the starting menu layer
$('.gamelayer').hide();
// Begin with the first level
singleplayer.currentLevel = 0;
game.type = "singleplayer";
game.team = "blue";
// Finally start the level
singleplayer.startCurrentLevel();
},
exit:function(){
// Show the starting menu layer
$('.gamelayer').hide();
$('#gamestartscreen').show();
},
currentLevel:0,
startCurrentLevel:function(){
// Load all the items for the level
var level = maps.singleplayer[singleplayer.currentLevel];
// Don't allow player to enter mission until all assets for the level are loaded
$("#entermission").attr("disabled", true);
// Load all the assets for the level
game.currentMapImage = loader.loadImage(level.mapImage);
game.currentLevel = level;
// Enable the enter mission button once all assets are loaded
if (loader.loaded){
$("#entermission").removeAttr("disabled");
} else {
loader.onload = function(){
$("#entermission").removeAttr("disabled");
}
}
// Load the mission screen with the current briefing
$('#missonbriefing').html(level.briefing.replace(/\n/g,'<br><br>'));
$("#missionscreen").show();
},
};
我们用三个方法定义了一个 singleplayer 对象:start()、exit()和 startCurrentLevel()。
start()方法首先隐藏所有游戏层,并将 singleplayer.currentLevel 设置为 0,这是指我们之前定义的 maps.singleplayer 数组中的第一级。然后将 game.type 和 game.team 变量分别设置为 singleplayer 和 blue。一旦游戏开始运行,我们将使用这些值。最后,它调用 singleplayer.startCurrentLevel()方法,我们将在每次想要加载关卡时调用该方法。
exit()方法隐藏了所有的游戏层,并把我们带回到主菜单。
startCurrentLevel()方法首先创建一个包含我们级别的元数据的级别对象。
然后,它会暂时禁用屏幕上的“输入任务”按钮,并开始加载关卡资源。目前,我们加载的唯一素材是地图图像。一旦素材被加载,输入任务按钮被激活,玩家可以点击它并进入游戏。
最后,该方法将级别简报放在 missionbriefing div 内,并显示 missionscreen div。
注意我们用< br >标签替换回车,这样它们就会出现在 HTML 中。这样,如果我们愿意,我们可以很容易地将任务简报分成多个段落。
当我们在浏览器中加载游戏并点击战役选项时,应该会看到第一关的任务简报画面,如图图 5-4 所示。
图 5-4。我们第一关的任务简报画面
在后台加载素材的同时显示任务简报屏幕的好处是玩家可以在等待所有素材加载的同时花时间阅读任务简报。
点击退出按钮应该会把我们带回主菜单。我们仍然不能进入任务,直到我们实现了实际的游戏界面和游戏动画和绘图循环。
实现游戏界面
我们要做的第一件事是将游戏界面屏幕的 HTML 标记添加到我们的 HTML 文件(index.html)的主体中。身体现在将看起来像清单 5-10 中的。
清单 5-10。【index.html】添加游戏界面层(??)
<body>
<div id="gamecontainer">
<div id="gamestartscreen" class="gamelayer">
<span id="singleplayer" onclick = "singleplayer.start();">Campaign</span><br>
<span id="multiplayer" onclick = "multiplayer.start();">Multiplayer</span><br>
</div>
<div id="missionscreen" class="gamelayer">
<input type="button" id="entermission" onclick = "singleplayer.play();">
<input type="button" id="exitmission" onclick = "singleplayer.exit();">
<div id="missonbriefing">Welcome to your first mission.
</div>
</div>
<div id="gameinterfacescreen" class="gamelayer">
<div id="gamemessages"></div>
<div id="callerpicture"></div>
<div id="cash"></div>
<div id="sidebarbuttons">
</div>
<canvas id="gamebackgroundcanvas" height="400" width="480"></canvas>
<canvas id="gameforegroundcanvas" height="400" width="480"></canvas>
</div>
<div id="loadingscreen" class="gamelayer">
<div id="loadingmessage"></div>
</div>
</div>
</body>
我们的游戏界面层由几个不同的区域组成。
- 游戏区:这是玩家可以看到地图并与游戏中的建筑、单位和其他实体互动的地方。这是使用两个 canvas 元素实现的:gamebackgroundcanvas 用于地图,gameforegroundcanvas 用于关卡内部的实体(比如建筑和单位)。
- 游戏消息:这是玩家可以看到系统通知或故事驱动消息的地方。
- 来电图片:玩家将在这里看到发送故事驱动消息的人的个人资料图片。
- 现金:玩家可以在这里看到他们的现金储备。
- 侧边栏按钮:玩家将在这里看到他们可以用来在游戏中创建单位和建筑的按钮。
现在 HTML 已经就绪,我们将把游戏界面的 CSS 添加到 styles.css 中,如清单 5-11 所示。
清单 5-11。 CSS 为游戏界面画面
/* Game Interface Screen */
#gameinterfacescreen {
background: url(img/maininterface.png) no-repeat;
}
#gameinterfacescreen #gamemessages{
position:absolute;
padding-left:10px;
top:5px;
left:5px;
width:450px;
height:60px;
color:rgb(130,150,162);
overflow:hidden;
font-size: 13px;
font-family: 'Courier New', Courier, monospace;
}
#gameinterfacescreen #gamemessages span {
color:white;
}
#gameinterfacescreen #callerpicture {
position:absolute;
left:498px;
top:154px;
width:126px;
height:88px;
overflow:none;
}
#gameinterfacescreen #cash {
width:120px;
height:22px;
position:absolute;
left:498px;
top:256px;
color:rgb(130,150,162);
overflow:hidden;
font-size: 13px;
font-family: 'Courier New', Courier, monospace;
text-align:right;
}
#gameinterfacescreen canvas{
position:absolute;
top:79px;
left:0px;
}
#gameinterfacescreen #foregroundcanvas{
z-index:1;
}
#gameinterfacescreen #backgroundcanvas{
z-index:0;
}
我们首先为 gameinterfacescreen div 定义一个单独的背景,然后将各种其他 div 放置在界面区域上方的适当位置。通过为 foregroundcanvas 设置较高的 z-index 值,这两个游戏画布元素与 foregroundcanvas 位于 backgroundcanvas 之上的位置相同。具有较高 z 索引值的元素显示在具有较低 z 索引值的元素上方。
接下来,我们将修改 game.js 中的游戏对象,以初始化画布元素并定义动画和绘制循环。修改后的游戏对象现在看起来像清单 5-12 中的。
清单 5-12。 给游戏对象添加动画和绘制循环(game.js)
var game = {
// Start preloading assets
init: function(){
loader.init();
$('.gamelayer').hide();
$('#gamestartscreen').show();
game.backgroundCanvas = document.getElementById('gamebackgroundcanvas');
game.backgroundContext = game.backgroundCanvas.getContext('2d');
game.foregroundCanvas = document.getElementById('gameforegroundcanvas');
game.foregroundContext = game.foregroundCanvas.getContext('2d');
game.canvasWidth = game.backgroundCanvas.width;
game.canvasHeight = game.backgroundCanvas.height;
},
start:function(){
$('.gamelayer').hide();
$('#gameinterfacescreen').show();
game.running = true;
game.refreshBackground= true;
game.drawingLoop();
},
// The map is broken into square tiles of this size (20 pixels x 20 pixels)
gridSize:20,
// Store whether or not the background moved and needs to be redrawn
backgroundChanged:true,
// A control loop that runs at a fixed period of time
animationTimeout:100, // 100 milliseconds or 10 times a second
offsetX:0, // X & Y panning offsets for the map
offsetY:0,
animationLoop:function(){
// Animate each of the elements within the game
},
drawingLoop:function(){
// Handle Panning the Map
// Since drawing the background map is a fairly large operation,
// we only redraw the background if it changes (due to panning)
if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage,game.offsetX,game.offsetY, game.canvasWidth,game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
game.refreshBackground = false;
}
// Call the drawing loop for the next frame using request animation frame
if (game.running){
requestAnimationFrame(game.drawingLoop);
}
},
}
我们修改了 init()方法,将画布元素、它们的 2D 上下文对象以及它们的宽度和高度保存到变量中。
我们定义一个 start()方法,隐藏其他层,显示游戏界面画面。然后将 game.running 和 game.backgroundChanged 变量设置为 true 以备后用。最后,我们第一次调用 drawingLoop()方法。
我们还定义了两个不同的方法,分别叫做 animationLoop() 和 drawingLoop()。
animationLoop()方法处理所有与控制和动画相关的逻辑,并且需要以固定的时间间隔运行(在 animationTimeout 中定义)。对于相当流畅的游戏来说,100 毫秒的动画超时通常就足够了。现在 animationLoop()方法是空的,占位符用于处理地图平移和动画游戏元素。
我们将代码分成两个不同的定时器循环的原因是因为动画代码将包含诸如寻路、处理命令和改变精灵的动画状态之类的逻辑,这些不需要像绘图代码那样频繁地执行。
动画代码也将控制单位的实际移动。通过保持这个代码独立于绘图代码,我们可以确保在每个动画周期后,单位将移动相同的数量。当我们处理多人游戏并且需要游戏状态在不同的机器上同步时,这将变得非常重要。如果我们不小心,浏览器和机器之间的微小计算差异会导致意想不到的结果,例如子弹在一个浏览器中击中敌人,但在另一个浏览器中错过敌人。
如果代码运行时间超过 100 毫秒,则在当前循环完成之前,不会执行下一个间隔循环。这可能会导致游戏在较慢的机器上出现轻微的跳跃和口吃。一些游戏使用基于时间增量的代码,通过推断单位运动来解决这个问题。然而,由于我们将开发多人游戏,推断可能会有点棘手。现在,我们假设玩家有一台能够流畅运行游戏的机器。
drawingLoop()方法处理所有游戏元素在两个游戏画布对象上的实际绘制。该方法是使用 requestAnimationFrame()调用的,可以在浏览器允许的情况下尽可能频繁地运行。
drawingLoop()方法做的第一件事就是检查背景是否已经改变,需要重画。如果是这样,它将使用平移偏移量(offsetX,offsetY)和画布尺寸绘制地图图像(在加载地图时存储在 currentMapImage 中)。然后它重置 backgroundChanged 标志。我们使用这种优化,这样我们就不需要在每次刷新后重新绘制整个背景。最后,只要游戏还在运行,drawingLoop()方法就使用 requestAnimationFrame()调用自己。
既然游戏动画状态已经就绪,我们需要在 singleplayer.js 中实现 singleplayer.play()方法,如清单 5-13 所示。
清单 5-13。single player . play()方法(singleplayer.js)
play:function(){
game.animationLoop();
game.animationInterval = setInterval(game.animationLoop,game.animationTimeout);
game.start();
},
这个方法相当简单。它第一次调用 game.animationLoop()方法,然后每隔 100 毫秒使用 setInterval()方法调用该方法(在 game.animationTimeout 中设置)。最后,它调用 game.start()方法。gameAnimationLoop()方法目前是空的,但是我们将在下一章向游戏中添加实体时开始使用它。
如果我们运行我们目前拥有的游戏代码,我们应该能够在任务简报屏幕上点击输入任务按钮,然后看到加载了地图的游戏界面屏幕,如图图 5-5 所示。
图 5-5。加载了第一张地图的游戏界面
你可能会注意到,游戏从地图的左上角开始。要使用我们在 map.js 中提供的初始地图偏移设置,我们需要在开始关卡时加载偏移值。我们将通过修改 singleplayer.js 中的 startCurrentLevel()方法来实现这一点,如清单 5-14 所示。
清单 5-14。 在 startCurrentLevel()(single player . js)内设置地图偏移
startCurrentLevel:function(){
// Load all the items for the level
var level = maps.singleplayer[singleplayer.currentLevel];
// Don't allow player to enter mission until all assets for the level are loaded
$("#entermission").attr("disabled", true);
// Load all the assets for the level
game.currentMapImage = loader.loadImage(level.mapImage);
game.currentLevel = level;
game.offsetX = level.startX * game.gridSize;
game.offsetY = level.startY * game.gridSize;
// Enable the enter mission button once all assets are loaded
if (loader.loaded){
$("#entermission").removeAttr("disabled");
} else {
loader.onload = function(){
$("#entermission").removeAttr("disabled");
}
}
// Load the mission screen with the current briefing
$('#missonbriefing').html(level.briefing.replace('\n','<br><br>'));
$("#missionscreen").show();
},
我们只添加了两行来设置基于 level.startX 和 level.startY 的 game.offsetX 和 game.offsetY。现在我们已经完成了地图的加载,我们将使用鼠标实现地图的平移。
实现地图平移
我们要做的第一件事是通过在 mouse.js 中创建一个鼠标对象来设置鼠标输入(见清单 5-15 )。
清单 5-15。 设置鼠标对象
var mouse = {
// x,y coordinates of mouse relative to top left corner of canvas
x:0,
y:0,
// x,y coordinates of mouse relative to top left corner of game map
gameX:0,
gameY:0,
// game grid x,y coordinates of mouse
gridX:0,
gridY:0,
// whether or not the left mouse button is currently pressed
buttonPressed:false,
// whether or not the player is dragging and selecting with the left mouse button pressed
dragSelect:false,
// whether or not the mouse is inside the canvas region
insideCanvas:false,
click:function(ev,rightClick){
// Player clicked inside the canvas
},
draw:function(){
if(this.dragSelect){
var x = Math.min(this.gameX,this.dragX);
var y = Math.min(this.gameY,this.dragY);
var width = Math.abs(this.gameX-this.dragX)
var height = Math.abs(this.gameY-this.dragY)
game.foregroundContext.strokeStyle = 'white';
game.foregroundContext.strokeRect(x-game.offsetX,y-game.offsetY, width, height);
}
},
calculateGameCoordinates:function(){
mouse.gameX = mouse.x + game.offsetX ;
mouse.gameY = mouse.y + game.offsetY;
mouse.gridX = Math.floor((mouse.gameX) / game.gridSize);
mouse.gridY = Math.floor((mouse.gameY) / game.gridSize);
},
init:function(){
var $mouseCanvas = $("#gameforegroundcanvas");
$mouseCanvas.mousemove(function(ev) {
var offset = $mouseCanvas.offset();
mouse.x = ev.pageX - offset.left;
mouse.y = ev.pageY - offset.top;
mouse.calculateGameCoordinates();
if (mouse.buttonPressed){
if ((Math.abs(mouse.dragX - mouse.gameX) > 4 || Math.abs(mouse.dragY - mouse.gameY) > 4)){
mouse.dragSelect = true
}
} else {
mouse.dragSelect = false;
}
});
$mouseCanvas.click(function(ev) {
mouse.click(ev,false);
mouse.dragSelect = false;
return false;
});
$mouseCanvas.mousedown(function(ev) {
if(ev.which == 1){
mouse.buttonPressed = true;
mouse.dragX = mouse.gameX;
mouse.dragY = mouse.gameY;
ev.preventDefault();
}
return false;
});
$mouseCanvas.bind('contextmenu',function(ev){
mouse.click(ev,true);
return false;
});
$mouseCanvas.mouseup(function(ev) {
var shiftPressed = ev.shiftKey;
if(ev.which==1){
//Left key was released
mouse.buttonPressed = false;
mouse.dragSelect = false;
}
return false;
});
$mouseCanvas.mouseleave(function(ev) {
mouse.insideCanvas = false;
});
$mouseCanvas.mouseenter(function(ev) {
mouse.buttonPressed = false;
mouse.insideCanvas = true;
});
}
}
这个物体内部发生了很多事情。首先,我们声明一个鼠标对象,并通过定义变量来存储相对于画布(x,y)、相对于地图(gameX,gameY)和地图网格(gridX,gridY)的鼠标坐标。我们还定义了几个变量来存储鼠标状态(buttonPressed、dragSelect 和 insideCanvas)。
接下来,我们将 click()方法定义为一个占位符,每当鼠标在画布区域内单击时都会调用它。我们将在后面实现这个方法。
接下来我们定义一个 draw()方法,该方法检查鼠标是否被拖过画布,如果是,从所选区域的左上角到右下角绘制一个白色矩形。在计算矩形的坐标时,我们减去了地图偏移量,这样它就相对于游戏地图绘制了,即使地图被平移,它也不会移动。
我们还定义了一个名为 calculateGameCoordinates()的方法,将鼠标的 x 和 y 坐标转换为游戏坐标。
最后,我们定义 init()方法,这是鼠标对象的核心。这个方法为前景画布设置所有必要的鼠标事件监听器:
- mousemove:每当鼠标移动时,我们计算不同的鼠标坐标并存储它们。我们还检查鼠标按钮是否被按下,鼠标是否被拖动了至少 4 个像素,如果是,设置 dragSelect 选项。4 像素的阈值防止游戏将每次点击与拖动选择操作混淆。
- click:每当单击操作完成时,我们调用 mouse.click()方法并清除 dragSelect 标志。
- mousedown:如果鼠标左键被按下,我们设置 buttonPressed 标志并将坐标保存到 dragX 和 dragY 中。此外,我们还防止了默认的鼠标点击行为(比如按下鼠标右键时的浏览器上下文菜单)。
- contextmenu:我们调用 mouse.click()方法并将 rightClick 参数作为 true 传递。
- mouseup:如果鼠标左键被释放,我们清除 dragSelect 和 buttonPressed 标志。
- mouseleave:当鼠标离开画布区域时,我们将 insideCanvas 标志设置为 false。
- mouseenter:每当鼠标重新进入画布区域时,我们将 insideCanvas 标志设置为 true 并清除 buttonPressed 标志。
现在我们已经设置了鼠标对象,我们将修改 game.js 中的游戏对象来使用鼠标。我们需要做的第一件事是从 game.init()方法内部调用 mouse.init()方法。更新后的 game.init()方法将类似于清单 5-16 中的。
清单 5-16。 从 game.init() (game.js)内部调用 mouse.init()
init: function(){
loader.init();
mouse.init();
$('.gamelayer').hide();
$('#gamestartscreen').show();
game.backgroundCanvas = document.getElementById('gamebackgroundcanvas');
game.backgroundContext = game.backgroundCanvas.getContext('2d');
game.foregroundCanvas = document.getElementById('gameforegroundcanvas');
game.foregroundContext = game.foregroundCanvas.getContext('2d');
game.canvasWidth = game.backgroundCanvas.width;
game.canvasHeight = game.backgroundCanvas.height;
},
接下来我们将在游戏对象中定义一个 handlePanning()方法。(参见清单 5-17 。)
清单 5-17。 定义游戏对象(game.js)内部的 handlePanning()方法
// A control loop that runs at a fixed period of time
animationTimeout:100, // 100 milliseconds or 10 times a second
offsetX:0, // X & Y panning offsets for the map
offsetY:0,
panningThreshold:60, // Distance from edge of canvas at which panning starts
panningSpeed:10, // Pixels to pan every drawing loop
handlePanning:function(){
// do not pan if mouse leaves the canvas
if (!mouse.insideCanvas){
return;
}
if(mouse.x<=game.panningThreshold){
if (game.offsetX>=game.panningSpeed){
game.refreshBackground = true;
game.offsetX -= game.panningSpeed;
}
} else if (mouse.x>= game.canvasWidth - game.panningThreshold){
if (game.offsetX + game.canvasWidth + game.panningSpeed <= game.currentMapImage.width){
game.refreshBackground = true;
game.offsetX += game.panningSpeed;
}
}
if(mouse.y<=game.panningThreshold){
if (game.offsetY>=game.panningSpeed){
game.refreshBackground = true;
game.offsetY -= game.panningSpeed;
}
} else if (mouse.y>= game.canvasHeight - game.panningThreshold){
if (game.offsetY + game.canvasHeight + game.panningSpeed <= game.currentMapImage.height){
game.refreshBackground = true;
game.offsetY += game.panningSpeed;
}
}
if (game.refreshBackground){
// Update mouse game coordinates based on game offsets
mouse.calculateGameCoordinates();
}
},
我们首先定义两个新的变量,panningThreshold 和 panningSpeed,它们存储鼠标光标需要多接近画布边缘才能进行平移,以及平移应该多快。handlePanning()方法本身检查鼠标是否在画布的任何边缘附近,以及是否还有任何地图可以在那个方向平移。如果有,我们通过平移阈值调整该方向上的偏移,并设置背景改变标志。最后,如果地图平移,我们刷新鼠标游戏坐标,因为它们会随着地图的平移而改变。
我们将对游戏对象进行的最后一个更改是从 game.drawingLoop()内部调用 handlePanning()和 mouse.draw()方法。最终的 drawingLoop()方法将类似于清单 5-18 中的。
清单 5-18。 从 game.drawingLoop() (game.js)内部调用 mouse.draw()
drawingLoop:function(){
// Handle Panning the Map
game.handlePanning();
// Since drawing the background map is a fairly large operation,
// we only redraw the background if it changes (due to panning)
if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage, game.offsetX, game.offsetY, game.canvasWidth, game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
game.refreshBackground = false;
}
// Clear the foreground canvas
game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);
// Start drawing the foreground elements
// Draw the mouse
mouse.draw()
// Call the drawing loop for the next frame using request animation frame
if (game.running){
requestAnimationFrame(game.drawingLoop);
}
},
我们首先调用刚刚定义的 game.handlePanning()方法。接下来,我们通过重置画布宽度来清除前景画布。然后,我们留下一个占位符,用于绘制前景元素,如稍后将实现的建筑物和单位。最后,我们在绘图循环结束之前调用 mouse.draw()方法。
此时,如果我们运行游戏,我们应该能够通过在画布边缘附近移动鼠标来平移地图,这样我们就可以浏览整个地图,如图图 5-6 所示。
图 5-6。平移地图
摘要
在这一章,我们开始为我们的 RTS 游戏开发基本框架。
就像在第二章中,我们实现了闪屏和开始菜单。然后,我们通过结合地图图像和一些基本级别的元数据创建了第一个级别。
我们实现了一个单人游戏对象,它加载地图数据并显示任务简报屏幕。然后,我们创建了游戏界面屏幕,并为游戏设置了动画和绘图循环,这样我们就可以在画布上加载和查看初始地图。最后,我们捕获并使用鼠标事件来让用户在关卡周围平移。
虽然我们有很多游戏世界的基本元素,但我们仍然缺少实际的实体来进行交互,例如建筑物和车辆。
在下一章,我们将开始添加这些不同的实体到我们的水平。我们将使用精灵表和动画状态在屏幕上绘制它们。然后,我们将建立一个框架来选择这些实体,这样我们就可以与它们进行交互。
六、向我们的世界添加实体
在前一章中,我们为 RTS 游戏搭建了基本框架。我们加载了一个关卡,并使用鼠标进行平移。
在这一章中,我们将通过在我们的游戏世界中添加实体来建立它。我们将建立一个通用框架,允许我们轻松地添加实体,如建筑物和单位到一个级别。最后,我们将增加玩家使用鼠标选择这些实体的能力。
我们开始吧。我们将使用第五章中的代码作为起点。
定义实体
这些是我们将添加到游戏中的游戏实体:
-
建筑 : 我们的游戏将会有四种类型的建筑。
-
基础:用于建造其他建筑的主要结构
-
星港:用于传送地面车辆和飞机
-
收割机:用于从油田中提取资源
-
地面炮塔:用来防御地面车辆的防御建筑
-
交通工具:我们的游戏将会有四种交通工具。
-
运输工具:一种用来运送物资和人员的非武装车辆
-
收割者:部署在油田收割者建筑内的移动单位
-
侦察坦克:一种轻型快速移动的坦克,用于侦察
-
重型坦克:速度较慢的坦克,拥有更重的装甲和武器
-
飞行器 : 我们的游戏将会有两种类型的飞行器。
-
斩波器:一种缓慢移动的飞行器,可以攻击陆地和空中
-
幽灵:一种快速移动的喷气式飞机,只能在空中攻击
-
地形:除了已经整合到我们地图中的地形,我们将定义两种额外的地形。
-
油田:可以通过部署收割机提取现金的矿产资源来源
-
岩石:有趣的岩层
我们将实体类型存储在单独的 JavaScript 文件中,以使代码更易于维护。我们要做的第一件事是在 HTML 文件的 head 部分添加对新 JavaScript 文件的引用。修改后的 head 部分现在看起来像清单 6-1 中的。
清单 6-1。 添加对实体的引用(index.html)
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>Last Colony</title>
<script src="js/common.js" type="text/javascript" charset="utf-8"></script>
<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>
<script src="js/mouse.js" type="text/javascript" charset="utf-8"></script>
<script src="js/singleplayer.js" type="text/javascript" charset="utf-8"></script>
<script src="js/maps.js" type="text/javascript" charset="utf-8"></script>
<!-- Definitions for game entities -->
<script src="js/buildings.js" type="text/javascript" charset="utf-8"></script>
<script src="js/vehicles.js" type="text/javascript" charset="utf-8"></script>
<script src="js/aircraft.js" type="text/javascript" charset="utf-8"></script>
<script src="js/terrain.js" type="text/javascript" charset="utf-8"></script>
<link rel="stylesheet" href="styles.css" type="text/css" media="screen" charset="utf-8">
</head>
有了这些代码,我们现在就可以开始为游戏定义我们的第一组实体,建筑物。
定义我们的第一个实体:主基地
我们将定义的第一个建筑是主基地。与游戏中其他可以由玩家建造的建筑不同,主基地总是在关卡开始前就已经建造好了。只要玩家有足够的资源,基地允许玩家传送到其他建筑中。
基础将由单个 sprite 表图像组成,该图像包含基础的不同动画状态(见图 6-1 )。
图 6-1。雪碧片为基底
如您所见,该表单由蓝色和绿色团队的两行不同的框架组成。在这种情况下,精灵由一个默认动画(四帧)、一个损坏的基础(一帧)和最后一个基础建造建筑物时的动画(三帧)组成。我们将为游戏中的所有实体使用相似的精灵表和通用的加载和绘制机制。
我们要做的第一件事是在 buildings.js 中定义一个 buildings 对象,如清单 6-2 所示。
清单 6-2。 用第一个建筑类型(buildings.js)定义建筑对象
var buildings = {
list:{
"base":{
name:"base",
// Properties for drawing the object
pixelWidth:60,
pixelHeight:60,
baseWidth:40,
baseHeight:40,
pixelOffsetX:0,
pixelOffsetY:20,
// Properties for describing structure for pathfinding
buildableGrid:[
[1,1],
[1,1]
],
passableGrid:[
[1,1],
[1,1]
],
sight:3,
hitPoints:500,
cost:5000,
spriteImages:[
{name:"healthy",count:4},
{name:"damaged",count:1},
{name:"contructing",count:3},
],
},
},
defaults:{
type:"buildings",
animationIndex:0,
direction:0,
orders:{ type:"stand" },
action:"stand",
selected:false,
selectable:true,
// Default function for animating a building
animate:function(){
},
// Default function for drawing a building
draw:function(){
}
},
load:loadItem,
add:addItem,
}
buildings 对象有四个重要的项目。
- 列表属性将包含我们所有建筑的定义。现在,我们定义基础建筑以及稍后需要的属性。这些属性包括绘制对象的属性(如 pixelWidth)、寻路属性(buildableGrid)、生命值和开销等常规属性,最后是精灵图像列表。
- defaults 属性包含所有建筑通用的属性和定义。这包括所有建筑物通常使用的 animate()和 draw()方法的占位符。我们将在后面实现这些方法。
- load()方法指向所有实体的一个公共方法,称为 loadItem(),我们仍然需要定义它。该方法将加载给定实体的 sprite 表和定义。
- add()方法指向我们需要定义的所有实体的另一个公共方法 addItem()。这个方法将创建一个给定实体的新实例来添加到游戏中。
现在我们已经有了一个基本的构建定义,我们将在 common.js 中定义 loadItem()和 addItem()方法,这样它们就可以被所有的实体使用(见清单 6-3 )。
清单 6-3。 定义 loadItem()和 addItem()方法(common.js)
/* The default load() method used by all our game entities*/
function loadItem(name){
var item = this.list[name];
// if the item sprite array has already been loaded then no need to do it again
if(item.spriteArray){
return;
}
item.spriteSheet = loader.loadImage('img/'+this.defaults.type+'/'+name+'.png');
item.spriteArray = [];
item.spriteCount = 0;
for (var i=0; i < item.spriteImages.length; i++){
var constructImageCount = item.spriteImages[i].count;
var constructDirectionCount = item.spriteImages[i].directions;
if (constructDirectionCount){
for (var j=0; j < constructDirectionCount; j++) {
var constructImageName = item.spriteImages[i].name +"-"+j;
item.spriteArray[constructImageName] = {
name:constructImageName,
count:constructImageCount,
offset:item.spriteCount
};
item.spriteCount += constructImageCount;
};
} else {
var constructImageName = item.spriteImages[i].name;
item.spriteArray[constructImageName] = {
name:constructImageName,
count:constructImageCount,
offset:item.spriteCount
};
item.spriteCount += constructImageCount;
}
}
}
/* The default add() method used by all our game entities*/
function addItem(details){
var item = {};
var name = details.name;
$.extend(item,this.defaults);
$.extend(item,this.list[name]);
item.life = item.hitPoints;
$.extend(item,details);
return item;
}
loadItem()方法使用图像加载器将 sprite 工作表图像加载到 sprite sheet 属性中。然后,它遍历 spriteImages 定义并创建一个 spriteArray 对象,该对象存储每个 sprite 动画的起始偏移量。
您会注意到,在创建数组时,代码会检查 count 和 directions 属性是否存在。这允许我们定义多方向的精灵,在绘制像炮塔和车辆这样的实体时会用到。
addItem()方法首先应用实体类型的默认值(例如,buildings),然后使用特定实体的属性(例如,base)扩展它,设置项目的生命周期,最后应用传递到 details 参数中的任何附加属性。
这种创建对象的有趣方式为我们提供了自己的多重继承实现,允许我们在三个不同的级别定义和覆盖属性:建筑属性、基本属性和特定于项目的细节(比如位置和团队颜色)。
既然我们已经定义了我们的第一个实体,我们需要一个简单的方法将实体添加到一个级别中。
向级别添加实体
我们要做的第一件事是修改我们的映射定义,以包含一个需要加载的实体类型列表和一个在开始之前要添加到级别的项目列表。我们将修改我们在 maps.js 中创建的第一个地图,如清单 6-4 所示。
清单 6-4。 加载和添加地图内部实体(maps.js)
var maps = {
"singleplayer":[
{
"name":"Entities",
"briefing": "In this level you will add new entities to the map.\nYou will also select them using the mouse",
/* Map Details */
"mapImage":"img/level-one-debug-grid.png",
"startX":4,
"startY":4,
/* Entities to be loaded */
"requirements":{
"buildings":["base"],
"vehicles":[],
"aircraft":[],
"terrain":[]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50}
]
}
]
}
该地图与第五章中的地图非常相似。我们添加了两个新的部分:需求和项目。
requirements 属性包含建筑物、车辆、飞机和地形,以便为该级别预加载。现在,我们只装载基地类型的建筑。
条目数组包含了我们想要添加到这个级别的实体的详细信息。我们提供的细节包括项目类型和名称、x 和 y 坐标以及团队的颜色。这些是我们唯一定义一个实体所需要的最基本的属性。
我们增加了三个随机位置和队伍的基地建筑。items 数组中的最后一个建筑还包含一个附加属性:life。由于我们之前定义 addItem()方法的方式,这个 life 属性将覆盖基础的 life 默认值。这样,我们也将有一个受损建筑的例子。
接下来我们将修改 singleplayer.js 中的 singleplayer.startCurrentLevel()方法,以便在游戏开始时加载和添加实体(参见清单 6-5 )。
清单 6-5。 在 startCurrentLevel()方法(singleplayer.js)中加载和添加实体
startCurrentLevel:function(){
// Load all the items for the level
var level = maps.singleplayer[singleplayer.currentLevel];
// Don't allow player to enter mission until all assets for the level are loaded
$("#entermission").attr("disabled", true);
// Load all the assets for the level
game.currentMapImage = loader.loadImage(level.mapImage);
game.currentLevel = level;
game.offsetX = level.startX * game.gridSize;
game.offsetY = level.startY * game.gridSize;
// Load level Requirements
game.resetArrays();
for (var type in level.requirements){
var requirementArray = level.requirements[type];
for (var i=0; i < requirementArray.length; i++) {
var name = requirementArray[i];
if (window[type]){
window[type].load(name);
} else {
console.log('Could not load type :',type);
}
};
}
for (var i = level.items.length - 1; i >= 0; i--){
var itemDetails = level.items[i];
game.add(itemDetails);
};
// Enable the enter mission button once all assets are loaded
if (loader.loaded){
$("#entermission").removeAttr("disabled");
} else {
loader.onload = function(){
$("#entermission").removeAttr("disabled");
}
}
// Load the mission screen with the current briefing
$('#missonbriefing').html(level.briefing.replace(/\n/g,'<br><br>'));
$("#missionscreen").show();
},
我们在新添加的代码中做了三件事。我们首先通过调用 game.resetArrays()方法初始化游戏数组。然后我们遍历需求对象,并为每个实体调用适当的 load()方法。load()方法将依次调用加载器在后台异步加载实体的所有图像,并在所有图像加载完毕后启用 entermission 按钮。
最后,我们遍历 items 数组并将详细信息传递给 game.add()方法。
接下来我们将在 game.js 中为游戏对象添加 resetArrays()、add()和 remove()方法(参见清单 6-6 )。
清单 6-6。 向游戏对象(game.js)添加 resetArrays()、add()和 remove()
resetArrays:function(){
game.counter = 1;
game.items = [];
game.sortedItems = [];
game.buildings = [];
game.vehicles = [];
game.aircraft = [];
game.terrain = [];
game.triggeredEvents = [];
game.selectedItems = [];
game.sortedItems = [];
},
add:function(itemDetails) {
// Set a unique id for the item
if (!itemDetails.uid){
itemDetails.uid = game.counter++;
}
var item = window[itemDetails.type].add(itemDetails);
// Add the item to the items array
game.items.push(item);
// Add the item to the type specific array
game[item.type].push(item);
return item;
},
remove:function(item){
// Unselect item if it is selected
item.selected = false;
for (var i = game.selectedItems.length - 1; i >= 0; i--){
if(game.selectedItems[i].uid == item.uid){
game.selectedItems.splice(i,1);
break;
}
};
// Remove item from the items array
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].uid == item.uid){
game.items.splice(i,1);
break;
}
};
// Remove items from the type specific array
for (var i = game[item.type].length - 1; i >= 0; i--){
if(game[item.type][i].uid == item.uid){
game[item.type].splice(i,1);
break;
}
};
},
resetArrays()方法仅仅初始化所有游戏特定的数组和计数器变量。
add()方法使用计数器为项目生成唯一标识符(UID ),调用适当实体的 add()方法,最后将项目保存在适当的游戏数组中。对于基础建筑物,该方法将首先调用 buildings.add(),然后将新建筑物添加到 game.items 和 game.buildings 数组中。
remove()方法从 selectedItems、Items 和特定于实体的数组中移除指定的项。这样,任何时候从游戏中移除一个物品(例如,当它被破坏时),它会自动从选择和物品数组中移除。
既然我们已经设置了定义实体和向层添加实体的代码,我们就可以开始在屏幕上绘制它们了。
绘制实体
为了绘制实体,我们需要在实体对象中实现 animate()和 draw()方法,然后从游戏 animationLoop()和 drawingLoop()方法中调用这些方法。
我们首先在 buildings.js 中的 buildings 对象内部实现 draw()和 animate()方法。buildings 对象的默认 draw()和 animate()方法现在看起来将类似于清单 6-7 。
清单 6-7。实现默认的 draw()和 animate()方法(buildings.js)
animate:function(){
// Consider an item healthy if it has more than 40% life
if (this.life>this.hitPoints*0.4){
this.lifeCode = "healthy";
} else if (this.life <= 0){
this.lifeCode = "dead";
game.remove(this);
return;
} else {
this.lifeCode = "damaged";
}
switch (this.action){
case "stand":
this.imageList = this.spriteArray[this.lifeCode];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
case "construct":
this.imageList = this.spriteArray["contructing"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
// Once constructing is complete go back to standing
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "stand";
}
break;
}
},
// Default function for drawing a building
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
// All sprite sheets will have blue in the first row and green in the second row
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
game.foregroundContext.drawImage(this.spriteSheet,
this.imageOffset*this.pixelWidth,colorOffset, this.pixelWidth, this.pixelHeight,
x,y,this.pixelWidth,this.pixelHeight);
}
在 animate()方法中,我们首先根据项目的健康状况和生命值设置项目的 lifeCode 属性。任何时候一个物品的生命值降到 0 以下,我们会将生命代码设置为死亡,并将其从游戏中移除。
接下来,我们基于项目的 action 属性实现行为。现在,我们只实现 stand 和 construct 操作。
对于站立动作,我们选择“健康的”或“损坏的”精灵动画,并增加 animationIndex 属性。如果 animationIndex 超过了 sprite 中的帧数,我们将该值回滚到 0。这样,动画会一次又一次地在精灵的每一帧中旋转。
对于构造动作,我们显示构造精灵,并在完成后滚动到 stand 动作。
draw()方法相对简单一些。我们通过转换网格 x 和 y 坐标来计算建筑物的绝对 x 和 y 像素坐标。然后我们计算正确的图像偏移量(基于 animationIndex)和图像颜色行(基于 team)。最后,我们使用 foregroundContext.drawImage()方法在前景画布上绘制适当的图像。
既然 draw()和 animate()方法已经就绪,我们需要从游戏对象中调用它们。我们将修改 game.js 内部的 game.animationLoop()和 game.drawingLoop()方法,如清单 6-8 所示。
清单 6-8。 从游戏循环(game.js)中调用 draw()和 animate()
animationLoop:function(){
// Animate each of the elements within the game
for (var i = game.items.length - 1; i >= 0; i--){
game.items[i].animate();
};
// Sort game items into a sortedItems array based on their x,y coordinates
game.sortedItems = $.extend([],game.items);
game.sortedItems.sort(function(a,b){
return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
});
},
drawingLoop:function(){
// Handle Panning the Map
game.handlePanning();
// Since drawing the background map is a fairly large operation,
// we only redraw the background if it changes (due to panning)
if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage,game.offsetX,game.offsetY,game.canvasWidth, game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
game.refreshBackground = false;
}
// Clear the foreground canvas
game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);
// Start drawing the foreground elements
for (var i = game.sortedItems.length - 1; i >= 0; i--){
game.sortedItems[i].draw();
};
// Draw the mouse
mouse.draw();
// Call the drawing loop for the next frame using request animation frame
if (game.running){
requestAnimationFrame(game.drawingLoop);
}
},
在 animationLoop()方法中,我们首先遍历所有游戏项目,并调用它们的 animate()方法。然后,我们按 y 值和 x 值对所有项目进行排序,并将它们存储在 game.sortedItems 数组中。
drawingLoop()方法中的新代码只是遍历 sortedItems 数组,并调用每一项的 draw()方法。我们使用 sortedItems 数组,以便根据项目的 y 坐标从后向前依次绘制项目。这是深度排序的一个简单实现,它确保靠近玩家的项目掩盖了后面的项目,产生了深度的错觉。
经过最后的修改,我们现在可以在屏幕上看到我们的第一个游戏实体了。如果我们在浏览器中打开游戏,加载第一关,我们应该会看到我们在地图中定义的三个基地建筑一个挨着一个画出来(见图 6-2 )。
图 6-2。三个基地建筑
正如你所看到的,第一个“蓝色”团队基地使用“健康”动画显示了闪烁的蓝光。
第二个“绿色”团队基础绘制在第一个基础之上,并部分遮挡第一个基础。这是我们深度排序步骤的结果,让玩家清楚地看到二垒在一垒前面。
最后,生命值更低的三垒看起来受损。这是因为每当建筑物的寿命低于其最大生命值的 40%时,我们会自动使用“受损”动画。
现在我们已经有了在游戏中展示建筑的框架,让我们添加剩下的建筑,从星港开始。
添加 Starport
星港可以用来购买地面和空中单位。星港精灵表单有一些基地没有的有趣的动画:一个传送动画序列,我们将在第一次创建建筑时使用,一个打开和关闭动画序列,我们将在运输新单位时使用。
我们要做的第一件事是将 starport 定义添加到 buildings.js 中的基本定义下面的 buildings 列表中(见清单 6-9 )。
清单 6-9。 定义为星港大厦(buildings.js)
"starport":{
name:"starport",
pixelWidth:40,
pixelHeight:60,
baseWidth:40,
baseHeight:55,
pixelOffsetX:1,
pixelOffsetY:5,
buildableGrid:[
[1,1],
[1,1],
[1,1]
],
passableGrid:[
[1,1],
[0,0],
[0,0]
],
sight:3,
cost:2000,
hitPoints:300,
spriteImages:[
{name:"teleport",count:9},
{name:"closing",count:18},
{name:"healthy",count:4},
{name:"damaged",count:1},
],
},
除了两个新的精灵集,starport 的定义与基本定义非常相似。接下来,我们将需要考虑动画的打开,关闭和瞬间移动的动画状态。我们将通过修改 buildings.js 中建筑物的默认 animate()方法来实现这一点,如清单 6-10 所示。
清单 6-10。 修改 animate()来处理瞬移、开启和关闭
animate:function(){
// Consider an item healthy if it has more than 40% life
if (this.life>this.hitPoints*0.4){
this.lifeCode = "healthy";
} else if (this.life <= 0){
this.lifeCode = "dead";
game.remove(this);
return;
} else {
this.lifeCode = "damaged";
}
switch (this.action){
case "stand":
this.imageList = this.spriteArray[this.lifeCode];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
case "construct":
this.imageList = this.spriteArray["contructing"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
// Once contructing is complete go back to standing
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "Stand";
}
break;
case "teleport":
this.imageList = this.spriteArray["teleport"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
// Once teleporting is complete, move to either guard or stand mode
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
if (this.canAttack){
this.action = "guard";
} else {
this.action = "stand";
}
}
break;
case "close":
this.imageList = this.spriteArray["closing"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
// Once closing is complete go back to standing
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "stand";
}
break;
case "open":
this.imageList = this.spriteArray["closing"];
// Opening is just the closing sprites running backwards
this.imageOffset = this.imageList.offset + this.imageList.count - this.animationIndex;
this.animationIndex++;
// Once opening is complete, go back to close
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "close";
}
break;
}
},
像构造动画状态一样,传送、关闭和打开动画状态一旦结束就不再重复。瞬间移动动画翻转到站立动画状态(或可以攻击的建筑如炮塔的守卫动画状态)。打开动画(仅仅是向后运行的关闭动画状态)翻转到关闭动画状态,然后翻转到站立动画状态。
这样,我们可以用一个传送或打开动画状态来初始化 starport,知道一旦当前动画完成,它最终会移回到站立动画状态。
现在,我们可以通过修改 maps.js 中的需求和项目来将 starport 添加到地图中,如清单 6-11 中的所示。
清单 6-11。 在地图上添加星港
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport"],
"vehicles":[],
"aircraft":[],
"terrain":[]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
{"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
]
当我们在浏览器中打开游戏开始关卡时,应该会看到三个新的星港建筑,如图图 6-3 所示。
图 6-3。三座星港建筑
第一个绿色团队星港打开,然后关闭。第二个蓝队星港首先发光并出现,然后切换到站立模式,而最后一个蓝队星港只是在站立模式下等待。
现在星港已经被添加了,我们要看的下一个建筑是矿车。
添加收割机
收割机是一个独特的实体,因为它既是建筑又是交通工具。与游戏中的其他建筑不同,收割机是通过在油田部署一辆收割机进入建筑而创建的(见图 6-4 )。
图 6-4。收割机展开成建筑形态
我们要做的第一件事是将收割机定义添加到 buildings.js 中 starport 定义下的 buildings 列表中(见清单 6-12 )。
清单 6-12。 收割机建筑定义(buildings.js)
"harvester":{
name:"harvester",
pixelWidth:40,
pixelHeight:60,
baseWidth:40,
baseHeight:20,
pixelOffsetX:-2,
pixelOffsetY:40,
buildableGrid:[
[1,1]
],
passableGrid:[
[1,1]
],
sight:3,
cost:5000,
hitPoints:300,
spriteImages:[
{name:"deploy",count:17},
{name:"healthy",count:3},
{name:"damaged",count:1},
],
},
接下来,我们需要考虑正在部署的动画状态。我们将通过向 buildings.js 中的默认 animate()方法添加部署案例来实现这一点,如清单 6-13 中的所示。
清单 6-13。 处理部署动画状态(buildings.js)
case "deploy":
this.imageList = this.spriteArray["deploy"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
// Once deploying is complete, go back to stand
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "stand";
}
break;
展开状态,就像我们之前定义的传送状态一样,一旦完成就会自动进入站立动画状态。
现在,我们可以通过修改 maps.js 中的需求和项目来将收割机添加到地图中,如清单 6-14 中的所示。
清单 6-14。 向地图添加收割机
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport","harvester"],
"vehicles":[],
"aircraft":[],
"terrain":[]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
{"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
{"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
{"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},
]
当我们在浏览器中打开游戏开始关卡时,应该会看到两个新的收割机建筑,如图图 6-5 所示。
图 6-5。两座收割机建筑
蓝色的收割者处于默认的站立模式,而绿色的收割者在部署模式下会变形为一个建筑,然后切换到站立模式。
现在已经添加了矿车,最后一个建筑我们来看看地面炮塔。
添加地面炮塔
地面炮塔是一种防御建筑,只攻击地面威胁。
这是唯一使用基于方向的精灵的建筑。此外,与其他建筑不同,它有一个默认的守卫动画状态,在动画和绘图时会考虑炮塔的方向。
方向属性的取值范围为 0-7,顺时针方向递增,0 指向北方,7 指向西北方向,如图图 6-6 所示。
图 6-6。炮塔的方向精灵范围从 0 到 7
我们要做的第一件事是将炮塔定义添加到 buildings.js 中收割机定义下面的建筑物列表中(见清单 6-15 )。
清单 6-15。 收割机建筑定义(buildings.js)
"ground-turret":{
name:"ground-turret",
canAttack:true,
canAttackLand:true,
canAttackAir:false,
weaponType:"cannon-ball",
action:"guard", // Default action is guard unlike other buildings
direction:0, // Face upward (0) by default
directions:8, // Total of 8 turret directions allowed (0-7)
orders:{type:"guard"},
pixelWidth:38,
pixelHeight:32,
baseWidth:20,
baseHeight:18,
cost:1500,
pixelOffsetX:9,
pixelOffsetY:12,
buildableGrid:[
[1]
],
passableGrid:[
[1]
],
sight:5,
hitPoints:200,
spriteImages:[
{name:"teleport",count:9},
{name:"healthy",count:1,directions:8},
{name:"damaged",count:1},
],
}
炮塔有一些额外的属性,表明它是否可以用来攻击敌人,炮塔指向的方向,以及它使用的武器类型。当我们在游戏中实现战斗时,我们将使用这些属性。
健康精灵有一个附加的 directions 属性,itemLoad()方法使用该属性为每个方向生成精灵。
接下来,我们将把 guard case 添加到 buildings.js 中的 animate()方法,如清单 6-16 所示。
清单 6-16。 处理门卫动画状态(buildings.js)
case "guard":
if (this.lifeCode == "damaged"){
// The damaged turret has no directions
this.imageList = this.spriteArray[this.lifeCode];
} else {
// The healthy turret has 8 directions
this.imageList = this.spriteArray[this.lifeCode+"-"+this.direction];
}
this.imageOffset = this.imageList.offset;
break;
与前面的动画状态不同,保护状态不使用 animationIndex,而是使用炮塔方向来拾取适当的图像偏移。
现在,我们可以通过修改 maps.js 中的要求和项目来将炮塔添加到地图中,如清单 6-17 所示。
清单 6-17。 给地图添加地面炮塔
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport","harvester","ground-turret"],
"vehicles":[],
"aircraft":[],
"terrain":[]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
{"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
{"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
{"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},
{"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue", "direction":3},
{"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green", "direction":1},
{"type":"buildings","name":"ground-turret","x":16,"y":10,"team":"blue", "action":"teleport"},
]
我们为前两个炮塔指定了起始方向属性,并将第三个炮塔的动作属性设置为传送。当我们在浏览器中打开游戏,开始关卡时,应该会看到三个新的炮塔,如图图 6-7 所示。
图 6-7。三座地面炮塔建筑
前两个炮塔是守卫模式,面向两个不同的方向,第三个是瞬移到面向默认方向,瞬移到后切换到守卫模式。
至此,我们已经实现了我们需要的所有构建。现在是时候开始给我们的游戏增加一些交通工具了。
添加车辆
我们游戏中的所有交通工具,包括运输工具,都会有一个简单的精灵表,上面的交通工具指向八个方向,类似于地面炮塔,如图图 6-8 所示。
图 6-8。运输子画面
我们将通过在 vehicles.js 中定义一个新的 vehicles 对象来为我们的车辆设置代码,如清单 6-18 中的所示。
清单 6-18。 定义车辆对象(vehicles.js)
var vehicles = {
list:{
"transport":{
name:"transport",
pixelWidth:31,
pixelHeight:30,
pixelOffsetX:15,
pixelOffsetY:15,
radius:15,
speed:15,
sight:3,
cost:400,
hitPoints:100,
turnSpeed:2,
spriteImages:[
{name:"stand",count:1,directions:8}
],
},
"harvester":{
name:"harvester",
pixelWidth:21,
pixelHeight:20,
pixelOffsetX:10,
pixelOffsetY:10,
radius:10,
speed:10,
sight:3,
cost:1600,
hitPoints:50,
turnSpeed:2,
spriteImages:[
{name:"stand",count:1,directions:8}
],
},
"scout-tank":{
name:"scout-tank",
canAttack:true,
canAttackLand:true,
canAttackAir:false,
weaponType:"bullet",
pixelWidth:21,
pixelHeight:21,
pixelOffsetX:10,
pixelOffsetY:10,
radius:11,
speed:20,
sight:4,
cost:500,
hitPoints:50,
turnSpeed:4,
spriteImages:[
{name:"stand",count:1,directions:8}
],
},
"heavy-tank":{
name:"heavy-tank",
canAttack:true,
canAttackLand:true,
canAttackAir:false,
weaponType:"cannon-ball",
pixelWidth:30,
pixelHeight:30,
pixelOffsetX:15,
pixelOffsetY:15,
radius:13,
speed:15,
sight:5,
cost:1200,
hitPoints:50,
turnSpeed:4,
spriteImages:[
{name:"stand",count:1,directions:8}
],
}
},
defaults:{
type:"vehicles",
animationIndex:0,
direction:0,
action:"stand",
orders:{type:"stand"},
selected:false,
selectable:true,
directions:8,
animate:function(){
// Consider an item healthy if it has more than 40% life
if (this.life>this.hitPoints*0.4){
this.lifeCode = "healthy";
} else if (this.life <= 0){
this.lifeCode = "dead";
game.remove(this);
return;
} else {
this.lifeCode = "damaged";
}
switch (this.action){
case "stand":
var direction = this.direction;
this.imageList = this.spriteArray["stand-"+direction];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
}
},
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
}
},
load:loadItem,
add:addItem,
}
我们的车辆对象的结构与建筑物对象非常相似。我们有一个列表属性,定义了四种车辆类型:运输车、矿车、侦察兵坦克和重型坦克。
所有车辆精灵都有 directions 属性和 animate()中的默认 stand 动画实现,它使用车辆的方向来选择要绘制的精灵。我们使用 animationIndex 来处理一个 sprite 中的多个图像,以便在需要时添加带有动画的车辆。
车辆还具有速度、视野和成本等属性。运输和采矿车没有任何武器,而这两种坦克有类似于我们之前定义的地面炮塔建筑的武器属性。我们将在后面的章节中使用所有这些属性来实现移动和战斗。
现在,我们可以通过修改 maps.js 中的 requirements 和 items 属性将这些车辆添加到地图中,如清单 6-19 中的所示。
清单 6-19。 向地图添加车辆
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport","harvester","ground-turret"],
"vehicles":["transport","harvester","scout-tank","heavy-tank"],
"aircraft":[],
"terrain":[]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
{"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
{"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
{"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},
{"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue", "direction":3},
{"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green", "direction":1},
{"type":"buildings","name":"ground-turret" ,"x":16,"y":10, "team":"blue", "action":"teleport"},
{"type":"vehicles","name":"transport","x":26,"y":10,"team":"blue","direction":2},
{"type":"vehicles","name":"harvester","x":26,"y":12,"team":"blue","direction":3},
{"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue", "direction":4},
{"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue", "direction":5},
{"type":"vehicles","name":"transport","x":28,"y":10,"team":"green", "direction":7},
{"type":"vehicles","name":"harvester","x":28,"y":12,"team":"green", "direction":6},
{"type":"vehicles","name":"scout-tank","x":28,"y":14,"team":"green", "direction":1},
{"type":"vehicles","name":"heavy-tank","x":28,"y":16,"team":"green", "direction":0},
]
当我们在浏览器中打开游戏并开始关卡时,应该会看到车辆,如图图 6-9 所示。
图 6-9。向关卡添加车辆
根据我们在将车辆添加到项目列表时设置的属性,车辆指向不同的方向。随着交通工具的实现,是时候把飞机加入到我们的游戏中了。
添加飞机
我们游戏中的飞机有一个类似于车辆的精灵表,除了一个区别:阴影。飞机精灵表有第三排阴影。此外,斩波器子画面在每个方向都有多个图像,如图图 6-10 所示。
图 6-10。带阴影的斩波器子画面
我们将通过在 aircraft.js 中定义一个新的飞行器对象来为我们的飞行器设置代码,如清单 6-20 中的所示。
清单 6-20。 定义飞机对象(aircraft.js)
var aircraft = {
list:{
"chopper":{
name:"chopper",
cost:900,
pixelWidth:40,
pixelHeight:40,
pixelOffsetX:20,
pixelOffsetY:20,
weaponType:"heatseeker",
radius:18,
sight:6,
canAttack:true,
canAttackLand:true,
canAttackAir:true,
hitPoints:50,
speed:25,
turnSpeed:4,
pixelShadowHeight:40,
spriteImages:[
{name:"fly",count:4,directions:8}
],
},
"wraith":{
name:"wraith",
cost:600,
pixelWidth:30,
pixelHeight:30,
canAttack:true,
canAttackLand:false,
canAttackAir:true,
weaponType:"fireball",
pixelOffsetX:15,
pixelOffsetY:15,
radius:15,
sight:8,
speed:40,
turnSpeed:4,
hitPoints:50,
pixelShadowHeight:40,
spriteImages:[
{name:"fly",count:1,directions:8}
],
}
},
defaults:{
type:"aircraft",
animationIndex:0,
direction:0,
directions:8,
action:"fly",
selected:false,
selectable:true,
orders:{type:"float"},
animate:function(){
// Consider an item healthy if it has more than 40% life
if (this.life>this.hitPoints*0.4){
this.lifeCode = "healthy";
} else if (this.life <= 0){
this.lifeCode = "dead";
game.remove(this);
return;
} else {
this.lifeCode = "damaged";
}
switch (this.action){
case "fly":
var direction = this.direction;
this.imageList = this.spriteArray["fly-"+ direction];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
}
},
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight;
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth,this.pixelHeight);
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, shadowOffset, this.pixelWidth, this.pixelHeight, x, y+this.pixelShadowHeight, this.pixelWidth, this.pixelHeight);
}
},
load:loadItem,
add:addItem,
}
我们的飞机对象的结构类似于车辆对象。我们有一个列表属性,其中定义了两种飞机类型:直升机和幽灵。
所有的飞机精灵都有方向属性。animate()中的默认飞行动画实现使用飞机的方向来选择要绘制的精灵。对于斩波器,我们还使用 animationIndex 来处理每个方向的多个图像。
一个很大的区别是 draw()方法的实现方式。我们在飞机的位置画一个阴影,在飞机的位置上面画实际的飞机 pixelShadowHeight 像素。这样,飞机看起来就像是漂浮在地面上,阴影在它下面的地面上。
现在,我们可以通过修改 maps.js 中的 requirements 和 items 属性将这些飞机添加到地图中,如清单 6-21 所示。
清单 6-21。 向地图添加飞机
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport","harvester","ground-turret"],
"vehicles":["transport","harvester","scout-tank","heavy-tank"],
"aircraft":["chopper","wraith"],
"terrain":[]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
{"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
{"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
{"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},
{"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue", "direction":3},
{"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green", "direction":1},
{"type":"buildings","name":"ground-turret","x":16,"y":10,"team":"blue", "action":"teleport"},
{"type":"vehicles","name":"transport","x":26,"y":10,"team":"blue","direction":2},
{"type":"vehicles","name":"harvester","x":26,"y":12,"team":"blue","direction":3},
{"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue", "direction":4},
{"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue", "direction":5},
{"type":"vehicles","name":"transport","x":28,"y":10,"team":"green", "direction":7},
{"type":"vehicles","name":"harvester","x":28,"y":12,"team":"green", "direction":6},
{"type":"vehicles","name":"scout-tank","x":28,"y":14,"team":"green", "direction":1},
{"type":"vehicles","name":"heavy-tank","x":28,"y":16,"team":"green", "direction":0},
{"type":"aircraft","name":"chopper","x":20,"y":22,"team":"blue", "direction":2}, {"type":"aircraft","name":"wraith","x":23,"y":22,"team":"green", "direction":3},
]
当我们在浏览器中打开游戏,开始关卡的时候,应该会看到飞行器悬停在地面上方,如图图 6-11 所示。
图 6-11。漂浮在地面上方的飞行器
阴影有助于创造飞机漂浮在地面上的错觉,也标记出它们在地面上的准确位置。由于动画的缘故,斩波器刀片及其在地面上的阴影似乎在旋转。
随着飞机的实现,我们现在将添加地形到我们的游戏中。
添加地形
除了油田之外,我们游戏中的地形实体都是静态物体,只是为了装饰。油田是一个特殊的实体,在它上面,采矿车可以部署到采矿建筑中。油田精灵表包括两个版本:一个默认版本和一个“提示”版本,上面显示一个模糊的收割机作为对玩家的提示。
我们将通过在 terrain.js 中定义一个新的地形对象来为我们的地形设置代码,如清单 6-22 所示。
清单 6-22。 定义地形对象(terrain.js)
var terrain = {
list:{
"oilfield":{
name:"oilfield",
pixelWidth:40,
pixelHeight:60,
baseWidth:40,
baseHeight:20,
pixelOffsetX:0,
pixelOffsetY:40,
buildableGrid:[
[1,1]
],
passableGrid:[
[1,1]
],
spriteImages:[
{name:"hint",count:1},
{name:"default",count:1},
],
},
"bigrocks":{
name:"bigrocks",
pixelWidth:40,
pixelHeight:70,
baseWidth:40,
baseHeight:40,
pixelOffsetX:0,
pixelOffsetY:30,
buildableGrid:[
[1,1],
[0,1]
],
passableGrid:[
[1,1],
[0,1]
],
spriteImages:[
{name:"default",count:1},
],
},
"smallrocks":{
name:"smallrocks",
pixelWidth:20,
pixelHeight:35,
baseWidth:20,
baseHeight:20,
pixelOffsetX:0,
pixelOffsetY:15,
buildableGrid:[
[1]
],
passableGrid:[
[1]
],
spriteImages:[
{name:"default",count:1},
],
},
},
defaults:{
type:"terrain",
animationIndex:0,
action:"default",
selected:false,
selectable:false,
animate:function(){
switch (this.action){
case "default":
this.imageList = this.spriteArray["default"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
case "hint":
this.imageList = this.spriteArray["hint"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
}
},
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
var colorOffset = 0; // No team based colors
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
}
},
load:loadItem,
add:addItem,
}
我们的地形对象的结构类似于建筑物对象。我们有一个定义地形类型的列表属性:油田、大岩石和小岩石。我们在 animate()方法中实现了默认和提示动画状态。我们还实现了一个更简单的 draw()方法,它不使用基于团队的颜色。
现在,我们可以通过修改 maps.js 中的需求和项目将这些地形添加到地图中,如清单 6-23 所示。
清单 6-23。 给地图添加地形
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport","harvester","ground-turret"],
"vehicles":["transport","harvester","scout-tank","heavy-tank"],
"aircraft":["chopper","wraith"],
"terrain":["oilfield","bigrocks","smallrocks"]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
{"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
{"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
{"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
{"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},
{"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue","direction":3},
{"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green","direction":1},
{"type":"buildings","name":"ground-turret","x":16,"y":10,"team":"blue","action":"teleport"},
{"type":"vehicles","name":"transport","x":26,"y":10,"team":"blue", "direction":2},
{"type":"vehicles","name":"harvester","x":26,"y":12,"team":"blue", "direction":3},
{"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue","direction":4},
{"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue","direction":5},
{"type":"vehicles","name":"transport","x":28,"y":10,"team":"green", "direction":7},
{"type":"vehicles","name":"harvester","x":28,"y":12,"team":"green", "direction":6},
{"type":"vehicles","name":"scout-tank","x":28,"y":14,"team":"green","direction":1},
{"type":"vehicles","name":"heavy-tank","x":28,"y":16,"team":"green","direction":0},
{"type":"aircraft","name":"chopper","x":20,"y":22,"team":"blue", "direction":2},
{"type":"aircraft","name":"wraith","x":23,"y":22,"team":"green", "direction":3},
{"type":"terrain","name":"oilfield","x":5,"y":7},
{"type":"terrain","name":"oilfield","x":8,"y":7,"action":"hint"},
{"type":"terrain","name":"bigrocks","x":5,"y":3},
{"type":"terrain","name":"smallrocks","x":8,"y":3},
]
我们添加了两个油田,其中一个的 action 属性设置为 hint。当我们在浏览器中打开游戏并开始关卡时,应该会看到岩石和油田,如图图 6-12 所示。
图 6-12。添加岩石和油田
右边有提示的油田有一个微弱发光的矿车图像,让玩家知道矿车可以部署在那里。这个油田的提示版本可以用在我们战役的早期阶段,当玩家刚刚接触到收割的想法时。
这样,我们实现了游戏中所有重要的实体。当然,在这一点上我们所能做的就是看着他们。接下来我们要做的是通过选择它们来与它们互动。
选择游戏实体
我们将允许玩家通过点击或者拖拽选择框来选择实体。
我们将通过修改 mouse.js 中的鼠标对象来启用点击选择,如清单 6-24 所示。
清单 6-24。 通过点击启用选择(mouse.js)
click:function(ev,rightClick){
// Player clicked inside the canvas
var clickedItem = this.itemUnderMouse();
var shiftPressed = ev.shiftKey;
if (!rightClick){ // Player left clicked
if (clickedItem){
// Pressing shift adds to existing selection. If shift is not pressed, clear existing selection
if(!shiftPressed){
game.clearSelection();
}
game.selectItem(clickedItem,shiftPressed);
}
} else { // Player right clicked
// Handle actions like attacking and movement of selected units
}
},
itemUnderMouse:function(){
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if (item.type=="buildings" || item.type=="terrain"){
if(item.lifeCode != "dead"
&& item.x<= (mouse.gameX)/game.gridSize
&& item.x >= (mouse.gameX - item.baseWidth)/game.gridSize
&& item.y<= mouse.gameY/game.gridSize
&& item.y >= (mouse.gameY - item.baseHeight)/game.gridSize
){
return item;
}
} else if (item.type=="aircraft"){
if (item.lifeCode != "dead" &&
Math.pow(item.x-mouse.gameX/game.gridSize,2) + Math.pow(item.y-(mouse.gameY+item.pixelShadowHeight)/game.gridSize,2) < Math.pow((item.radius)/game.gridSize,2)){
return item;
}
}else {
if (item.lifeCode != "dead" && Math.pow(item.x-mouse.gameX/game.gridSize,2) + Math.pow(item.y-mouse.gameY/game.gridSize,2) < Math.pow((item.radius)/game.gridSize,2)){
return item;
}
}
}
},
mouse.click()方法首先使用 itemUnderMouse()方法检查在点击过程中鼠标下是否有项目。如果鼠标下有一个项目,单击了左键,我们调用 game.selectItem()方法。除非在单击时按下了 Shift 键,否则在选择新项目之前会调用 game.clearSelection()方法。这样,用户可以通过在选择时按住 Shift 键来选择多个项目。
itemUnderMouse()方法遍历列表中的所有项目,并使用针对不同项目类型的不同标准返回鼠标 gameX 和 gameY 坐标下的第一个项目。
- 在建筑物和地形的情况下,我们检查项目的底部是否在鼠标下面。这样,玩家可以点击建筑的底部来选择它,但在选择建筑后面的车辆时不会有问题。
- 对于车辆,我们检查鼠标是否在车辆中心的半径范围内。
- 对于飞机,我们使用 pixelShadowHeight 属性检查鼠标是否在飞机中心的半径范围内,而不是阴影。
接下来,我们将通过修改鼠标对象的 init()方法中的 mouseup 事件处理程序来处理拖动选择(参见清单 6-25 )。
清单 6-25。 在 mouseup 事件处理程序(mouse.js)中实现拖动选择
$mouseCanvas.mouseup(function(ev) {
var shiftPressed = ev.shiftKey;
if(ev.which==1){
//Left key was released
if (mouse.dragSelect){
if (!shiftPressed){
// Shift key was not pressed
game.clearSelection();
}
var x1 = Math.min(mouse.gameX,mouse.dragX)/game.gridSize;
var y1 = Math.min(mouse.gameY,mouse.dragY)/game.gridSize;
var x2 = Math.max(mouse.gameX,mouse.dragX)/game.gridSize;
var y2 = Math.max(mouse.gameY,mouse.dragY)/game.gridSize;
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if (item.type != "buildings" && item.selectable && item.team==game.team && x1<= item.x && x2 >= item.x){
if ((item.type == "vehicles" && y1<= item.y && y2 >= item.y)
|| (item.type == "aircraft" && (y1 <= item.y-item.pixelShadowHeight/game.gridSize) && (y2 >= item.y-item.pixelShadowHeight/game.gridSize))){
game.selectItem(item,shiftPressed);
}
}
};
}
mouse.buttonPressed = false;
mouse.dragSelect = false;
}
return false;
});
在 mouseup 事件中,我们检查鼠标是否被拖动过,如果是,遍历每个游戏项目,检查它是否在被拖动的矩形的边界内。然后,我们选择适当的项目。
最重要的是,我们只允许拖拽选择我们自己的车辆和飞机,而不是敌人或者我们自己的建筑。这是因为拖拽选择通常被用来选择一组单位来移动它们或者用它们快速攻击,而选择敌人单位或者我们自己的建筑并不能真正帮助玩家。
接下来,我们将在 game.js 内部的游戏对象中添加一些与选择相关的代码,如清单 6-26 所示。
清单 6-26。 给游戏对象添加选择相关代码(game.js)
/* Selection Related Code */
selectionBorderColor:"rgba(255,255,0,0.5)",
selectionFillColor:"rgba(255,215,0,0.2)",
healthBarBorderColor:"rgba(0,0,0,0.8)",
healthBarHealthyFillColor:"rgba(0,255,0,0.5)",
healthBarDamagedFillColor:"rgba(255,0,0,0.5)",
lifeBarHeight:5,
clearSelection:function(){
while(game.selectedItems.length>0){
game.selectedItems.pop().selected = false;
}
},
selectItem:function(item,shiftPressed){
// Pressing shift and clicking on a selected item will deselect it
if (shiftPressed && item.selected){
// deselect item
item.selected = false;
for (var i = game.selectedItems.length - 1; i >= 0; i--){
if(game.selectedItems[i].uid == item.uid){
game.selectedItems.splice(i,1);
break;
}
};
return;
}
if (item.selectable && !item.selected){
item.selected = true;
game.selectedItems.push(item);
}
},
我们首先定义一些与颜色和生活条相关的常见的基于选择的属性。然后我们定义用于选择的两种方法。
- clearSelection()方法遍历 game.selectedItems 数组,清除每个项目的 selected 标志,并从数组中删除该项目。
- selectItem()方法根据是否按下了 Shift 键,将可选项目添加到 selectedItems()数组中,或者将其从数组中移除。通过这种方式,玩家可以在按住 Shift 键的情况下通过单击来取消选定的项目。
至此,我们已经拥有了在游戏中选择物品所需的所有代码。然而,我们仍然需要一种方法来突出显示选定的项目,以便我们可以在视觉上识别它们。这是我们接下来要实现的。
突出显示选定的实体
当玩家选择一个项目时,我们将使用该项目的 selected 属性来检测它,并在该项目周围绘制一个封闭的选择边界。我们还将添加一个指示器来显示该物品的寿命。
为此,我们将为每个实体定义两个默认方法 drawSelection()和 drawLifeBar(),并修改 draw()方法来调用它们。
首先,我们将在 buildings 对象中实现这些方法(参见清单 6-27 )。
清单 6-27。 为建筑物(buildings.js)实现 drawSelection()和 drawLifeBar()
drawLifeBar:function(){
var x = this.drawingX+ this.pixelOffsetX;
var y = this.drawingY - 2*game.lifeBarHeight;
game.foregroundContext.fillStyle = (this.lifeCode == "healthy") ? game.healthBarHealthyFillColor: game.healthBarDamagedFillColor;
game.foregroundContext.fillRect(x,y,this.baseWidth*this.life/this.hitPoints,game.lifeBarHeight)
game.foregroundContext.strokeStyle = game.healthBarBorderColor;
game.foregroundContext.lineWidth = 1;
game.foregroundContext.strokeRect(x,y,this.baseWidth,game.lifeBarHeight)
},
drawSelection:function(){
var x = this.drawingX + this.pixelOffsetX;
var y = this.drawingY + this.pixelOffsetY;
game.foregroundContext.strokeStyle = game.selectionBorderColor;
game.foregroundContext.lineWidth = 1;
game.foregroundContext.fillStyle = game.selectionFillColor;
game.foregroundContext.fillRect(x-1,y-1,this.baseWidth+2,this.baseHeight+2);
game.foregroundContext.strokeRect(x-1,y-1,this.baseWidth+2,this.baseHeight+2);
},
// Default function for drawing a building
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
// All sprite sheets will have blue in the first row and green in the second row
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
}
drawLifeBar()方法仅仅是根据建筑物的寿命用绿色或红色在建筑物上方画一个条形。酒吧的长度与建筑的寿命成正比。drawSelection()方法在建筑物底部周围绘制一个黄色矩形。最后,如果项目是从 draw()方法中选择的,我们将调用这两个方法。
接下来,我们将为 vehicles 对象实现这些方法(参见清单 6-28 )。
清单 6-28。 为车辆(vehicles.js)实现 drawSelection()和 drawLifeBar()
drawLifeBar:function(){
var x = this.drawingX;
var y = this.drawingY - 2*game.lifeBarHeight;
game.foregroundContext.fillStyle = (this.lifeCode == "healthy")?game.
healthBarHealthyFillColor:game.healthBarDamagedFillColor;
game.foregroundContext.fillRect(x,y,this.pixelWidth*this.life/this.hitPoints,game.lifeBarHeight)
game.foregroundContext.strokeStyle = game.healthBarBorderColor;
game.foregroundContext.lineWidth = 1;
game.foregroundContext.strokeRect(x,y,this.pixelWidth,game.lifeBarHeight)
},
drawSelection:function(){
var x = this.drawingX + this.pixelOffsetX;
var y = this.drawingY + this.pixelOffsetY;
game.foregroundContext.strokeStyle = game.selectionBorderColor;
game.foregroundContext.lineWidth = 1;
game.foregroundContext.beginPath();
game.foregroundContext.arc(x,y,this.radius,0,Math.PI*2,false);
game.foregroundContext.fillStyle = game.selectionFillColor;
game.foregroundContext.fill();
game.foregroundContext.stroke();
},
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
game.foregroundContext.drawImage(this.spriteSheet,
this.imageOffset*this.pixelWidth,colorOffset,
this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
}
这一次,drawSelection()方法在选定的车辆下绘制了一个黄色的浅填充圆。像以前一样,drawLifeBar()方法在车辆上方绘制一个生命条。
最后,我们将为飞机对象实现这些方法(见清单 6-29 )。
清单 6-29。 为飞机(aircraft.js)实现 drawSelection()和 drawLifeBar()
drawLifeBar:function(){
var x = this.drawingX;
var y = this.drawingY - 2*game.lifeBarHeight;
game.foregroundContext.fillStyle = (this.lifeCode ==
"healthy")?game.healthBarHealthyFillColor:game.healthBarDamagedFillColor;
game.foregroundContext.fillRect(x,y,this.pixelWidth*this.life/this.hitPoints,game.lifeBarHeight)
game.foregroundContext.strokeStyle = game.healthBarBorderColor;
game.foregroundContext.lineWidth = 1;
game.foregroundContext.strokeRect(x,y,this.pixelWidth,game.lifeBarHeight)
},
drawSelection:function(){
var x = this.drawingX + this.pixelOffsetX;
var y = this.drawingY + this.pixelOffsetY;
game.foregroundContext.strokeStyle = game.selectionBorderColor;
game.foregroundContext.lineWidth = 2;
game.foregroundContext.beginPath();
game.foregroundContext.arc(x,y,this.radius,0,Math.PI*2,false);
game.foregroundContext.stroke();
game.foregroundContext.fillStyle = game.selectionFillColor;
game.foregroundContext.fill();
game.foregroundContext.beginPath();
game.foregroundContext.arc(x,y+this.pixelShadowHeight,4,0,Math.PI*2,false);
game.foregroundContext.stroke();
game.foregroundContext.beginPath();
game.foregroundContext.moveTo(x,y);
game.foregroundContext.lineTo(x,y+this.pixelShadowHeight);
game.foregroundContext.stroke();
},
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,
colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,
shadowOffset, this.pixelWidth, this.pixelHeight, x, y+this.pixelShadowHeight, this.pixelWidth,this.pixelHeight);
}
这一次,drawLifeBar()方法在绘制生活栏时调整阴影高度。drawSelection()方法在飞机周围画一个黄色的圆,从飞机到阴影画一条直线,最后在阴影的中心画一个小圆。
通过最后的更改,我们已经为所有实体实现了绘图选择。我们不需要选择地形,因为它不能在游戏中被选择。
如果我们在浏览器中运行游戏,我们现在应该能够通过点击或者拖动鼠标到多个单位上来选择项目。这些被选中的项目应高亮显示,如图图 6-13 所示。
图 6-13。选中的项目高亮显示
请注意,受损建筑上方的生命栏清楚地向我们展示了它的受损程度。您可以在按住 Shift 键的同时点按项目,从而在选择中添加或减去项目。我们现在已经在游戏中完全实现了实体选择。
摘要
我们在这一章中涉及了很多内容。从上一章的空白开始,我们开发了一个通用框架,通过为这些实体实现 draw()和 animate()方法来在游戏中动画和绘制项目。
在绘制项目之前,我们进行了深度排序,这样靠近屏幕的项目会遮住较远的项目。使用这个框架,我们在游戏中添加了建筑、车辆、飞机和地形。
最后,我们实现了使用鼠标选择这些实体并突出显示这些选定实体的能力。
在下一章,我们将实现发送命令到这些实体,从最重要的一个开始:运动。我们也将着眼于使用寻路和转向算法,使单位智能导航周围的建筑物和其他障碍。
所以,我们继续吧。
七、智能单元运动
在前一章中,我们建立了一个在游戏中制作动画和绘制实体的框架,然后添加了不同类型的建筑、车辆、飞机和地形。最后,我们添加了选择这些实体的功能。
在这一章中,我们将添加一个框架来给选定的单位命令,并让实体遵循命令。然后我们将实现这些命令中最基本的:通过使用寻路和转向算法的组合来智能地移动我们的单位。
现在让我们开始吧。我们将使用第六章中的代码作为起点。
指挥单位
我们将使用现在已经成为大多数现代 RTS 游戏标准的惯例来指挥单位。我们将用左键选择单位,用右键命令它们。
右键单击地图上的可导航点将命令选定的单位移动到该点。右击一个敌人单位或建筑会命令所有选中的可以攻击的单位去攻击敌人。右击一个友军单位会告诉所有选择的单位跟随它并保护它。最后,右键点击一个油田并选择一辆矿车,会告诉矿车移动到油田并在上面部署。
我们需要做的第一件事是修改 mouse.js 中鼠标对象的 click()方法来处理右击事件,如清单 7-1 所示。
清单 7-1。 修改 click()处理右键命令(mouse.js)
click:function(ev,rightClick){
// Player clicked inside the canvas
var clickedItem = this.itemUnderMouse();
var shiftPressed = ev.shiftKey;
if (!rightClick){ // Player left clicked
if (clickedItem){
// Pressing shift adds to existing selection. If shift is not pressed, clear existing selection
if(!shiftPressed){
game.clearSelection();
}
game.selectItem(clickedItem,shiftPressed);
}
} else { // Player right-clicked
// Handle actions like attacking and movement of selected units
var uids = [];
if (clickedItem){ // Player right-clicked on something
if (clickedItem.type != "terrain"){
if (clickedItem.team != game.team){ // Player right-clicked on an enemy item
// Identify selected items from players team that can attack
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && item.canAttack){
uids.push(item.uid);
}
};
// then command them to attack the clicked item
if (uids.length>0){
game.sendCommand(uids,{type:"attack",toUid:clickedItem.uid});
}
} else { // Player right-clicked on a friendly item
//identify selected items from players team that can move
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
uids.push(item.uid);
}
};
// then command them to guard the clicked item
if (uids.length>0){
game.sendCommand(uids,{type:"guard", toUid:clickedItem.uid});
}
}
} else if (clickedItem.name == "oilfield"){ // Player right licked on an oilfield
// identify the first selected harvester from players team (since only one can deploy at a time)
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" && item.name == "harvester")){
uids.push(item.uid);
break;
}
};
// then command it to deploy on the oilfield
if (uids.length>0){
game.sendCommand(uids,{type:"deploy",toUid:clickedItem.uid});
}
}
} else { // Player clicked on the ground
//identify selected items from players team that can move
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
uids.push(item.uid);
}
};
// then command them to move to the clicked location
if (uids.length>0){
game.sendCommand(uids, {type:"move", to:{x:mouse.gameX/game.gridSize, y:mouse.gameY/game.gridSize}});
}
}
}
},
当玩家在游戏地图内右击时,我们首先检查鼠标是否在一个物体上。
如果玩家没有点击某个对象,我们调用 game.sendCommand()方法向所有被选中的友军车辆和飞机发送移动命令。
如果玩家点击了一个物体,我们同样会向相应的单位发送攻击、守卫或部署命令。我们还在订单中将被点击项目的 UID 作为一个名为 toUid 的参数进行传递。
有了右键逻辑,我们现在必须实现发送和接收游戏命令的方法。
发送和接收命令
我们可以通过在前面修改的 click()方法中修改所选项目的 orders 属性来实现发送命令。然而,我们将使用一个稍微复杂一些的实现。
任何生成命令的点击动作都会调用 game.sendCommand()方法。sendCommand()方法会将调用传递给单人游戏或多人游戏对象。然后,这些对象会将命令细节发送回 game.processCommand()方法。在 game.processCommand()方法中,我们将更新所有适当对象的顺序。我们首先将这些方法添加到 game.js 中的游戏对象,如清单 7-2 所示。
清单 7-2。 实现 sendCommand()和 processCommand() (game.js)
// Send command to either singleplayer or multiplayer object
sendCommand:function(uids,details){
if (game.type=="singleplayer"){
singleplayer.sendCommand(uids,details);
} else {
multiplayer.sendCommand(uids,details);
}
},
getItemByUid:function(uid){
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].uid == uid){
return game.items[i];
}
};
},
// Receive command from singleplayer or multiplayer object and send it to units
processCommand:function(uids,details){
// In case the target "to" object is in terms of uid, fetch the target object
var toObject;
if (details.toUid){
toObject = game.getItemByUid(details.toUid);
if(!toObject || toObject.lifeCode=="dead"){
// To object no longer exists. Invalid command
return;
}
}
for (var i in uids){
var uid = uids[i];
var item = game.getItemByUid(uid);
//if uid is a valid item, set the order for the item
if(item){
item.orders = $.extend([],details);
if(toObject) {
item.orders.to = toObject;
}
}
};
},
sendCommand()方法根据游戏类型将调用传递给单人游戏或多人游戏对象的 sendCommand()方法。使用这一抽象层允许我们对单人游戏和多人游戏使用相同的代码,同时以不同的方式处理命令。
虽然单机版的 sendCommand()只会立即回调 processCommand(),但多人版会将命令发送到服务器,然后服务器会同时将命令转发给所有玩家。
我们还实现了 getItemByUid()方法,该方法查找条目 Uid 并返回实体对象。
由于游戏的多人版本,我们将 uid 而不是实际的游戏对象传递给 sendCommand()方法。一个典型的 item 对象包含许多动画和绘制对象的细节,如方法、sprite 表图像和所有的 item 属性。虽然需要绘制项目,但是将这些额外的数据传输到服务器并取回是浪费带宽,而且是完全不必要的,特别是因为整个对象可以用一个整数(UID)来代替。
processCommand()方法首先查找任何 toUid 属性并获取结果项。如果不存在具有该 UID 的项目,它会认为该命令无效并忽略该命令。然后,该方法查找 uids 数组中传递的商品,并将它们的 orders 对象设置为参数中提供的订单详细信息的副本。
接下来我们要做的是在 singleplayer.js 中实现 singlePlayer 对象的 sendCommand()方法,如清单 7-3 所示。
清单 7-3。 实现单人 sendCommand()方法(singleplayer.js)
sendCommand:function(uids,details){
game.processCommand(uids,details);
}
如您所见,sendCommand()的实现相当简单。我们只是将呼叫转发给 game.processCommand()。然而,如果我们愿意,我们也可以使用这个方法来添加保存游戏命令的功能,以及关于当前运行的动画周期的细节,以实现重放保存的游戏的能力。
现在我们已经建立了一个指挥单位和设置他们的命令的机制,我们需要建立一个单位处理和执行这些命令的方法。
处理订单
我们处理订单的实现将相当简单。我们将为每个需要它的实体实现一个名为 processOrders()的方法,并从游戏动画循环内部为所有游戏项目调用 processOrders()方法。
我们将从修改 game.js 内游戏对象的 animationLoop()方法开始,如清单 7-4 所示。
清单 7-4。 从动画循环(game.js)内部调用 processOrders()
animationLoop:function(){
// Process orders for any item that handles it
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].processOrders){
game.items[i].processOrders();
}
};
// Animate each of the elements within the game
for (var i = game.items.length - 1; i >= 0; i--){
game.items[i].animate();
};
// Sort game items into a sortedItems array based on their x,y coordinates
game.sortedItems = $.extend([],game.items);
game.sortedItems.sort(function(a,b){
return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
});
},
该代码遍历每个游戏项目,并调用该项目的 processOrders()方法(如果存在)。现在,我们可以为游戏实体逐个实现 processOrders()方法,并观察这些实体开始服从我们的命令。
让我们从实现飞机的运动开始。
实现飞机运动
与陆地交通工具不同,移动飞机相当简单,因为飞机不受地形、建筑物或其他交通工具的影响。当一架飞机接到移动命令时,它只会转向目的地,然后直线前进。一旦飞机接近目的地,它将回到漂浮状态。
我们将把它实现为 aircraft.js 中飞机的默认 processOrders()方法,如清单 7-5 中的所示。
清单 7-5。 运动中飞机对象的默认 processOrders()方法(aircraft.js)
processOrders:function(){
this.lastMovementX = 0;
this.lastMovementY = 0;
switch (this.orders.type){
case "move":
// Move towards destination until distance from destination is less than aircraft radius
var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
this.orders = {type:"float"};
} else {
this.moveTo(this.orders.to);
}
break;
}
},
我们首先重置两个与运动相关的变量,我们稍后会用到它们。然后,我们检查 case 语句中的订单类型。
如果订单类型是 move,我们调用 moveTo()方法,直到飞机到目的地的距离(存储在 To 参数中)小于飞机的半径。一旦飞机到达目的地,我们就把顺序改回浮动。
目前,我们只执行了一个订单。每当飞机收到一个它不知道如何处理的命令时,它将继续在当前位置浮动。随着时间的推移,我们将执行更多的订单。
我们要做的下一件事是实现一个默认的 moveTo()方法,这两个飞行器都将使用它(见清单 7-6 )。
清单 7-6。 飞机对象的默认 moveTo()方法(aircraft.js)
moveTo:function(destination){
// Find out where we need to turn to get to destination
var newDirection = findAngle(destination,this,this.directions);
// Calculate difference between new direction and current direction
var difference = angleDiff(this.direction,newDirection,this.directions);
// Calculate amount that aircraft can turn per animation cycle
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
} else {
// Calculate distance that aircraft can move per animation cycle
var movement = this.speed*game.speedAdjustmentFactor;
// Calculate x and y components of the movement
var angleRadians = -(Math.round(this.direction)/this.directions)*2*Math.PI ;
this.lastMovementX = - (movement*Math.sin(angleRadians));
this.lastMovementY = - (movement*Math.cos(angleRadians));
this.x = (this.x +this.lastMovementX);
this.y = (this.y +this.lastMovementY);
}
},
我们首先使用 findAngle()方法计算从飞机到目的地的角度,并使用 angleDiff()方法计算当前方向和新方向之间的差异。newDirection 变量的值在 0 到 7 之间(以反映飞机可以采取的方向),而 difference 变量的值在-4 到 4 之间,负号表示逆时针转弯比顺时针转弯短。
然后,我们根据飞机的转弯速度属性计算飞机可以转弯的量,并通过比较角度差和转弯量来查看该项目是否需要更多的转弯。
如果飞机仍然需要转弯,我们将 turnAmount 值加到它的方向上,同时保持差值变量的符号。我们使用 wrapDirection()方法来确保最终的飞机方向仍然在 0 到 7 之间。
如果飞机已经转向目的地,我们根据它的速度计算移动距离。然后,我们计算运动的 x 和 y 分量,并将其添加到飞机的 x 和 y 坐标中。
当然,既然飞机方向可以采用非整数值,我们需要修改飞机对象的默认 animate()方法,以确保它在选择精灵之前舍入方向(见清单 7-7 )。
清单 7-7。 修改 animate()处理非整数方向值(aircraft.js)
animate:function(){
// Consider an item healthy if it has more than 40% life
if (this.life>this.hitPoints*0.4){
this.lifeCode = "healthy";
} else if (this.life <= 0){
this.lifeCode = "dead";
game.remove(this);
return;
} else {
this.lifeCode = "damaged";
}
switch (this.action){
case "fly":
var direction = wrapDirection(Math.round(this.direction),this.directions);
this.imageList = this.spriteArray["fly-"+ direction];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
}
},
我们首先舍入飞机方向,然后调用 wrapDirection()来确保方向位于 0 和 7 之间。方法的其余部分保持不变。
接下来,我们将把 findAngle()、angleDiff()和 wrapDirection()方法添加到 common.js 中,如清单 7-8 所示。
清单 7-8。 实现 findAngle()、angleDiff()和 wrapDirection() (common.js)
/* Common functions for turning and movement */
// Finds the angle between two objects in terms of a direction (where 0 <= angle < directions)
function findAngle(object,unit,directions){
var dy = (object.y) - (unit.y);
var dx = (object.x) - (unit.x);
//Convert Arctan to value between (0 - directions)
var angle = wrapDirection(directions/2-(Math.atan2(dx,dy)*directions/(2*Math.PI)),directions);
return angle;
}
// returns the smallest difference (value ranging between -directions/2 to +directions/2) between two angles (where 0 <= angle < directions)
function angleDiff(angle1,angle2,directions){
if (angle1>=directions/2){
angle1 = angle1-directions;
}
if (angle2>=directions/2){
angle2 = angle2-directions;
}
diff = angle2-angle1;
if (diff<-directions/2){
diff += directions;
}
if (diff>directions/2){
diff -= directions;
}
return diff;
}
// Wrap value of direction so that it lies between 0 and directions-1
function wrapDirection(direction,directions){
if (direction<0){
direction += directions;
}
if (direction >= directions){
direction -= directions;
}
return direction;
}
我们需要做的最后一个改变是在 game.js 的游戏对象中定义两个与运动相关的属性(参见清单 7-9 )。
清单 7-9。 给游戏对象添加动作相关属性(game.js)
//Movement related properties
speedAdjustmentFactor:1/64,
turnSpeedAdjustmentFactor:1/8,
这两个因素用于将实体的速度和转弯速度值转换为游戏中的移动和转弯单位。
我们现在准备开始在游戏中移动我们的飞行器,但是在此之前,让我们通过从地图上移除所有不必要的物品来简化我们的关卡。新的 maps.js 将看起来像清单 7-10 中的。
清单 7-10。 从地图中删除不必要的项目(maps.js)
var maps = {
"singleplayer":[
{
"name":"Entities",
"briefing": "In this level you will start commanding units and moving them around the map.",
/* Map Details */
"mapImage":"img/level-one-debug-grid.png",
"startX":2,
"startY":3,
/* Entities to be loaded */
"requirements":{
"buildings":["base","starport","harvester","ground-turret"],
"vehicles":["transport","harvester","scout-tank","heavy-tank"],
"aircraft":["chopper","wraith"],
"terrain":["oilfield","bigrocks","smallrocks"]
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":14, "team":"blue"},
{"type":"buildings","name":"harvester","x":20,"y":10, "team":"blue"},
{"type":"buildings","name":"ground-turret","x":24,"y":7, "team":"blue","direction":3},
{"type":"vehicles","name":"transport","x":24,"y":10, "team":"blue","direction":2},
{"type":"vehicles","name":"harvester","x":16,"y":12, "team":"blue","direction":3},
{"type":"vehicles","name":"scout-tank","x":24,"y":14, "team":"blue","direction":4},
{"type":"vehicles","name":"heavy-tank","x":24,"y":16, "team":"blue","direction":5},
{"type":"aircraft","name":"chopper","x":7,"y":9, "team":"blue","direction":2},
{"type":"aircraft","name":"wraith","x":11,"y":9, "team":"blue","direction":3},
{"type":"terrain","name":"oilfield","x":3,"y":5, "action":"hint"},
{"type":"terrain","name":"bigrocks","x":19,"y":6},
{"type":"terrain","name":"smallrocks","x":8,"y":3}
]
}
]
}
当你在浏览器中运行游戏时,你应该能够选择两架飞机并在新地图上移动它们,如图 7-1 所示。
图 7-1。在新地图周围移动飞机
当你选择一架飞机,在地图上某处右击,飞机应该会转向,向目的地移动。你会注意到幽灵的飞机比直升机移动得更快,因为我们在幽灵实体的属性中指定了一个更高的速度值。
你可能也会注意到右键点击一个建筑或者一个友军单位并没有任何作用。这是因为右击一个友方物品会生成守卫命令,我们还没有实现这个命令。
为我们的飞机实现运动相当简单,因为我们创造性地假设飞机可以通过调整高度来避开建筑物、车辆和其他飞机。
然而,当涉及到车辆时,我们不能再这样做了。当我们在建筑物和地形等障碍物周围行驶时,我们需要担心找到车辆和目的地之间的最短路径。这就是寻路的用武之地。
寻路
寻路,或称寻路,是寻找两点间最短路径的过程。典型地,它包括使用各种算法来遍历节点图,从一个顶点开始并探索相邻的节点,直到到达目的节点。
基于图的寻路最常用的两种算法是 Dijkstra 算法及其变体 A*(读作“A star”)算法。
A使用额外的距离启发式算法,帮助它比 Dijkstra 更快地找到路径。由于其性能和准确性,被广泛应用于游戏中。你可以在en.wikipedia.org/wiki/A
了解更多算法。我们也将在游戏中使用*来表示车辆路径。
我们将使用 Andrea Giammarchi 的 A的一个优秀的 MIT 授权的 JavaScript 实现。代码已经针对 JavaScript 进行了优化,即使在大型图形上,它的性能也相当不错。在devpro.it/javascript_id_137.html
,你可以看到最新的代码,也可以玩现场演示。我们将在 index.html 的 head 部分添加一个对 A实现(存储在 astar.js 中)的引用,如清单 7-11 所示。
清单 7-11。 添加引用 A*实现(index.html)
<!-- A* Implementation by Andrea Giammarchi -->
<script src="js/astar.js" type="text/javascript" charset="utf-8"></script>
虽然实现相当复杂,但相对容易使用。代码让我们可以访问 Astar()方法,该方法接受四个参数:我们要使用的地图、起始坐标、结束坐标,以及可选的要使用的启发式名称。
该方法返回一个包含最短路径所有中间步骤的数组,或者在没有可能路径的情况下返回一个空数组。
现在我们已经有了 A*算法,我们需要为它提供一个用于寻路的图或网格。
定义我们的寻路网格
我们已经把地图分成了 20 像素乘 20 像素的方格。我们将把寻路网格存储为一个二维数组,对于可通行和不可通行的正方形,其值分别为 0 和 1。
在我们创建这个数组之前,我们需要修改我们的地图来定义地图上所有不可通行的区域。我们将通过在 maps.js 中的第一层添加一些新的属性来做到这一点,如清单 7-12 中的所示。
清单 7-12。 为关卡添加寻路属性(maps.js)
/* Map coordinates that are obstructed by terrain*/
"mapGridWidth":60,
"mapGridHeight":40,
"mapObstructedTerrain":[
[49,8], [50,8], [51,8], [51,9], [52,9], [53,9], [53,10], [53,11], [53,12], [53,13], [53,14],
[53,15], [53,16], [52,16], [52,17], [52,18], [52,19], [51,19], [50,19], [50,18], [50,17], [49,17],
[49,18], [48,18], [47,18], [47,17], [47,16], [48,16], [49,16], [49,15], [49,14], [48,14], [48,13],
[48,12], [49,12], [49,11], [50,11], [50,10], [49,10], [49,9], [44,0], [45,0], [45,1], [45,2],
[46,2], [47,2], [47,3], [48,3], [48,4], [48,5], [49,5], [49,6], [49,7], [50,7], [51,7], [51,6],
[51,5], [51,4], [52,4], [53,4], [53,3], [54,3], [55,3], [55,2], [56,2], [56,1], [56,0], [55,0],
[43,19], [44,19], [45,19], [46,19], [47,19], [48,19], [48,20], [48,21], [47,21], [46,21], [45,21],
[44,21], [43,21], [43,20], [41,22], [42,22], [43,22], [44,22], [45,22], [46,22], [47,22], [48,22],
[49,22], [50,22], [50,23], [50,24], [49,24], [48,24], [47,24], [47,25], [47,26], [47,27], [47,28],
[47,29], [47,30], [46,30], [45,30], [44,30], [43,30], [43,29], [43,28], [43,27], [43,26], [43,25],
[43,24], [42,24], [41,24], [41,23], [48,39], [49,39], [50,39], [51,39], [52,39], [53,39], [54,39],
[55,39], [56,39], [57,39], [58,39], [59,39], [59,38], [59,37], [59,36], [59,35], [59,34], [59,33],
[59,32], [59,31], [59,30], [59,29], [0,0], [1,0], [2,0], [1,1], [2,1], [10,3], [11,3], [12,3],
[12,2], [13,2], [14,2], [14,3], [14,4], [15,4], [15,5], [15,6], [14,6], [13,6], [13,5], [12,5],
[11,5], [10,5], [10,4], [3,9], [4,9], [5,9], [5,10], [6,10], [7,10], [8,10], [9,10], [9,11],
[10,11], [11,11], [11,10], [12,10], [13,10], [13,11], [13,12], [12,12], [11,12], [10,12], [9,12],
[8,12], [7,12], [7,13], [7,14], [6,14], [5,14], [5,13], [5,12], [5,11], [4,11], [3,11], [3,10],
[33,33], [34,33], [35,33], [35,34], [35,35], [34,35], [33,35], [33,34], [27,39], [27,38], [27,37],
[28,37], [28,36], [28,35], [28,34], [28,33], [28,32], [28,31], [28,30], [28,29], [29,29], [29,28],
[29,27], [29,26], [29,25], [29,24], [29,23], [30,23], [31,23], [32,23], [32,22], [32,21], [31,21],
[30,21], [30,22], [29,22], [28,22], [27,22], [26,22], [26,21], [25,21], [24,21], [24,22], [24,23],
[25,23], [26,23], [26,24], [25,24], [25,25], [24,25], [24,26], [24,27], [25,27], [25,28], [25,29],
[24,29], [23,29], [23,30], [23,31], [24,31], [25,31], [25,32], [25,33], [24,33], [23,33], [23,34],
[23,35], [24,35], [24,36], [24,37], [23,37], [22,37], [22,38], [22,39], [23,39], [24,39], [25,39],
[26,0], [26,1], [25,1], [25,2], [25,3], [26,3], [27,3], [27,2], [28,2], [29,2], [29,3], [30,3],
[31,3], [31,2], [31,1], [32,1], [32,0], [33,0], [32,8], [33,8], [34,8], [34,9], [34,10], [33,10],
[32,10], [32,9], [8,29], [9,29], [9,30], [17,32], [18,32], [19,32], [19,33], [18,33], [17,33]
, [18,34], [19,34], [3,27], [4,27], [4,26], [3,26], [2,26], [3,25], [4,25], [9,20], [10,20], [11,20],
[11,21], [10,21], [10,19], [19,7], [15,7], [29,12], [30,13], [20,14], [21,14], [34,13], [35,13],
[36,13], [36,14], [35,14], [34,14], [35,15], [36,15], [16,18], [17,18], [18,18], [16,19], [17,19],
[18,19], [17,20], [18,20], [11,19], [58,0], [59,0], [58,1], [59,1], [59,2], [58,3], [59,3], [58,4],
[59,4], [59,5], [58,6], [59,6], [58,7], [59,7], [59,8], [58,9], [59,9], [58,10], [59,10], [59,11],
[52,6], [53,6], [54,6], [52,7], [53,7], [54,7], [53,8], [54,8], [44,17], [46,32], [55,32], [54,28],
[26,34], [34,34], [4,10], [6,11], [6,12], [6,13], [7,11], [8,11], [12,11], [27,0], [27,1], [26,2],
[28,1], [28,0], [29,0], [29,1], [30,2], [30,1], [30,0], [31,0], [33,9], [46,0], [47,0], [48,0],
[49,0], [50,0], [51,0], [52,0], [53,0], [54,0], [55,1], [54,1], [53,1], [52,1], [51,1], [50,1],
[49,1], [48,1], [47,1], [46,1], [48,2], [49,2], [50,2], [51,2], [52,2], [53,2], [54,2], [52,3],
[51,3], [50,3], [49,3], [49,4], [50,4], [50,5], [50,6], [50,9], [51,10], [52,10], [51,11], [52,11],
[50,12], [51,12], [52,12], [49,13], [50,13], [51,13], [52,13], [50,14], [51,14], [52,14], [50,15],
[51,15], [52,15], [50,16], [51,16], [51,17], [48,17], [51,18], [44,20], [45,20], [46,20], [47,20],
[42,23], [43,23], [44,23], [45,23], [46,23], [47,23], [48,23], [49,23], [44,24], [45,24], [46,24],
[44,25], [45,25], [46,25], [44,26], [45,26], [46,26], [44,27], [45,27], [46,27], [44,28], [45,28],
[46,28], [44,29], [45,29], [46,29], [11,4], [12,4], [13,4], [13,3], [14,5], [25,22], [31,22],
[27,23], [28,23], [27,24], [28,24], [26,25], [27,25], [28,25], [25,26], [26,26], [27,26], [28,26],
[26,27], [27,27], [28,27], [26,28], [27,28], [28,28], [26,29], [27,29], [24,30], [25,30], [26,30],
[27,30], [26,31], [27,31], [26,32], [27,32], [26,33], [27,33], [24,34], [25,34], [27,34], [25,35],
[26,35], [27,35], [25,36], [26,36], [27,36], [25,37], [26,37], [23,38], [24,38], [25,38], [26,38],
[26,39], [2,25], [9,19], [36,31]
],
我们首先定义了两个名为 mapGridWidth 和 mapGridHeight 的属性,最后定义了一个非常大而且看起来很吓人的数组,名为 mapObstructedTerrain。这个数组仅仅包含了地图中每个不可通过的网格的 x 和 y 坐标。这包括有树、山、水、火山口和熔岩的地区。
注如果你打算给你的游戏增加很多关卡,你应该花时间设计一个关卡编辑器,自动为你生成这个数组,而不是试图手工创建。
现在我们已经有了这些属性,我们需要在加载关卡时从这些数据中生成一个地形网格。我们将在 singleplayer.js 中的 singleplayer 对象的 startCurrentLevel()方法中完成这项工作(参见清单 7-13 )。
清单 7-13。 开始关卡时创建地形网格(singleplayer.js)
startCurrentLevel:function(){
// Load all the items for the level
var level = maps.singleplayer[singleplayer.currentLevel];
// Don't allow player to enter mission until all assets for the level are loaded
$("#entermission").attr("disabled", true);
// Load all the assets for the level
game.currentMapImage = loader.loadImage(level.mapImage);
game.currentLevel = level;
game.offsetX = level.startX * game.gridSize;
game.offsetY = level.startY * game.gridSize;
// Load level Requirements
game.resetArrays();
for (var type in level.requirements){
var requirementArray = level.requirements[type];
for (var i=0; i < requirementArray.length; i++) {
var name = requirementArray[i];
if (window[type]){
window[type].load(name);
} else {
console.log('Could not load type :',type);
}
};
}
for (var i = level.items.length - 1; i >= 0; i--){
var itemDetails = level.items[i];
game.add(itemDetails);
};
// Create a grid that stores all obstructed tiles as 1 and unobstructed as 0
game.currentMapTerrainGrid = [];
for (var y=0; y < level.mapGridHeight; y++) {
game.currentMapTerrainGrid[y] = [];
for (var x=0; x< level.mapGridWidth; x++) {
game.currentMapTerrainGrid[y][x] = 0;
}
};
for (var i = level.mapObstructedTerrain.length - 1; i >= 0; i--){
var obstruction = level.mapObstructedTerrain[i];
game.currentMapTerrainGrid[obstruction[1]][obstruction[0]] = 1;
};
game.currentMapPassableGrid = undefined;
// Enable the enter mission button once all assets are loaded
if (loader.loaded){
$("#entermission").removeAttr("disabled");
} else {
loader.onload = function(){
$("#entermission").removeAttr("disabled");
}
}
// Load the mission screen with the current briefing
$('#missonbriefing').html(level.briefing.replace(/\n/g,'<br><br>'));
$("#missionscreen").show();
},
我们在游戏对象中初始化一个名为 currentMapTerrainGrid 的数组,并使用 mapGridWidth 和 mapGridHeight 将它设置为地图的尺寸。然后,我们将所有被遮挡的方块设为 1,将未被遮挡的方块设为 0。
如果我们在地图上突出显示当前主栅格中被遮挡的方块,它看起来会像图 7-2 。
图 7-2。当前主栅格中定义的障碍栅格方块
虽然 currentMapTerrainGrid 在地图地形中标出了所有的障碍物,但它仍然不包括地图上的建筑物和地形实体。
我们将在游戏对象中保留另一个名为 currentMapPassableGrid 的数组,该数组将结合建筑和地形实体以及我们之前定义的 currentMapTerrainGrid 数组。每次在游戏中添加或删除建筑或地形时,都需要重新创建这个数组。我们将在游戏对象中的 rebuildPassableGrid()方法中实现这一点(参见清单 7-14 )。
清单 7-14。 游戏对象(game.js)中的 rebuildPassableGrid()方法
rebuildPassableGrid:function(){
game.currentMapPassableGrid = $.extend(true,[],game.currentMapTerrainGrid);
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if(item.type == "buildings" || item.type == "terrain"){
for (var y = item.passableGrid.length - 1; y >= 0; y--){
for (var x = item.passableGrid[y].length - 1; x >= 0; x--){
if(item.passableGrid[y][x]){
game.currentMapPassableGrid[item.y+y][item.x+x] = 1;
}
};
};
}
};
},
我们首先将 currentMapTerrainGrid 数组复制到 currentMapPassableGrid 中。然后,我们遍历所有游戏项目,并使用我们为所有建筑物和地形定义的 passableGrid 属性来标记出不可通过的网格方块。如果我们基于 currentMapPassableGrid 在地图上高亮显示被遮挡的方块,它看起来会像图 7-3 。
图 7-3。在 currentMapPassableGrid 中定义的障碍网格方块
由于我们为每个建筑定义 passableGrid 的方式,允许部分建筑是可通行的是可能的(例如,星门的下部)。
我们需要确保在游戏中添加或移除建筑时,game.currentMapPassableGrid 会被重置。我们通过在游戏对象的 add()和 remove()方法中添加一个额外的条件来做到这一点,如清单 7-15 所示。
清单 7-15。 清除 currentMapPassableGrid 里面的 add()和 remove() (game.js)
add:function(itemDetails) {
// Set a unique id for the item
if (!itemDetails.uid){
itemDetails.uid = game.counter++;
}
var item = window[itemDetails.type].add(itemDetails);
// Add the item to the items array
game.items.push(item);
// Add the item to the type specific array
game[item.type].push(item);
if(item.type == "buildings" || item.type == "terrain"){
game.currentMapPassableGrid = undefined;
}
return item;
},
remove:function(item){
// Unselect item if it is selected
item.selected = false;
for (var i = game.selectedItems.length - 1; i >= 0; i--){
if(game.selectedItems[i].uid == item.uid){
game.selectedItems.splice(i,1);
break;
}
};
// Remove item from the items array
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].uid == item.uid){
game.items.splice(i,1);
break;
}
};
// Remove items from the type specific array
for (var i = game[item.type].length - 1; i >= 0; i--){
if(game[item.type][i].uid == item.uid){
game[item.type].splice(i,1);
break;
}
};
if(item.type == "buildings" || item.type == "terrain"){
game.currentMapPassableGrid = undefined;
}
},
在这两种方法中,我们检查被添加或删除的项目是建筑物类型还是地形类型,如果是,重置 currentMapPassableGrid 变量。
现在我们已经为 A*算法定义了运动网格,我们准备好实现车辆运动。
实现车辆移动
我们将从在 vehicles.js 中为 vehicles 对象添加一个默认的 processOrders()方法开始,如清单 7-16 中的所示。
清单 7-16。 默认为车辆的 processOrders()方法(vehicles.js)
processOrders:function(){
this.lastMovementX = 0;
this.lastMovementY = 0;
switch (this.orders.type){
case "move":
// Move towards destination until distance from destination is less than vehicle radius
var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
this.orders = {type:"stand"};
return;
} else {
// Try to move to the destination
var moving = this.moveTo(this.orders.to);
if(!moving){
// Pathfinding couldn't find a path so stop
this.orders = {type:"stand"};
return;
}
}
break;
}
},
该方法非常类似于我们为飞机定义的 processOrders()方法。一个微妙的区别是,我们检查 moveTo()方法是否返回 true 值,表明它能够向目的地移动,并在它不能移动时重置订单。我们这样做是因为寻路算法可能找不到有效的路径,moveTo()将返回一个指示这一点的值。
接下来,我们将为车辆实现默认的 moveTo()方法,如清单 7-17 所示。
清单 7-17。 默认为车辆的 moveTo()方法(vehicles.js)
moveTo:function(destination){
if(!game.currentMapPassableGrid){
game.rebuildPassableGrid();
}
// First find path to destination
var start = [Math.floor(this.x),Math.floor(this.y)];
var end = [Math.floor(destination.x),Math.floor(destination.y)];
var grid = $.extend(true,[],game.currentMapPassableGrid);
// Allow destination to be "movable" so that algorithm can find a path
if(destination.type == "buildings"||destination.type == "terrain"){
grid[Math.floor(destination.y)][Math.floor(destination.x)] = 0;
}
var newDirection;
// if vehicle is outside map bounds, just go straight towards goal
if (start[1]<0 || start[1]>=game.currentLevel.mapGridHeight || start[0]<0 || start[0]>= game.currentLevel.mapGridWidth){
this.orders.path = [this,destination];
newDirection = findAngle(destination,this,this.directions);
} else {
//Use A* algorithm to try and find a path to the destination
this.orders.path = AStar(grid,start,end,'Euclidean');
if (this.orders.path.length>1){
var nextStep = {x:this.orders.path[1].x+0.5,y:this.orders.path[1].y+0.5};
newDirection = findAngle(nextStep,this,this.directions);
} else if(start[0]==end[0] && start[1] == end[1]){
// Reached destination grid;
this.orders.path = [this,destination];
newDirection = findAngle(destination,this,this.directions);
} else {
// There is no path
return false;
}
}
// Calculate turn amount for new direction
var difference = angleDiff(this.direction,newDirection,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
// Move forward, but keep turning as needed
var movement = this.speed*game.speedAdjustmentFactor;
var angleRadians = -(Math.round(this.direction)/this.directions)*2*Math.PI;
this.lastMovementX = - (movement*Math.sin(angleRadians));
this.lastMovementY = - (movement*Math.cos(angleRadians));
this.x = (this.x +this.lastMovementX);
this.y = (this.y +this.lastMovementY);
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction + turnAmount*Math.abs(difference)/difference, this.directions);
}
return true;
},
我们首先检查 game.currentMapPassableGrid 是否已定义,如果未定义,则调用 game.rebuildPassableGrid()。然后,我们通过截断车辆和目的地位置来定义路径的开始和结束值。
接下来,我们将 game.currentMapPassableGrid 复制到一个网格变量中,并将目的地网格正方形定义为可通行,以防目的地是建筑物或地形。这种黑客让 A*算法找到一条通往建筑物的路径,即使目的地无法通行。
下一步是计算路径和新方向。我们首先检查车辆是否在地图边界之外,如果是,通过使用车辆和目的地定义路径的开始和结束位置,并使用 findAngle()方法计算 newDirection,直接驶向目的地。我们这样做是因为如果我们传递给 AStar()方法的起始坐标在网格之外,它就会失败。
如果车辆在地图范围内,我们调用 AStar()方法,同时向它传递 start、end 和 grid 值。我们指定了欧几里得的启发式方法,它允许对角线移动,似乎对我们的游戏很有效。
如果 AStar()方法返回一条至少有两个元素的路径,我们通过找到从车辆到下一个网格中间的角度来计算 newDirection。
如果路径不包含至少两个元素,我们检查这是否是因为我们已经到达目的地网格方块,如果是,则朝着最终目的地前进。如果不是,我们假设这是因为 AStar()找不到路径并返回 false。
最后,我们使用 newDirection 和 turnSpeed 和 Speed 值来向前移动车辆并使其转向 newDirection。与我们的飞机不同,车辆不应该原地转向,我们通过使运动和转向同时发生来实现这一点。
实现了寻路方法的核心之后,我们需要对车辆对象进行最后一次修改。我们将修改默认的 animate()方法来考虑方向的非整数值,如清单 7-18 所示。
清单 7-18。 修改 animate()处理非整数方向值(vehicles.js)
animate:function(){
// Consider an item healthy if it has more than 40% life
if (this.life>this.hitPoints*0.4){
this.lifeCode = "healthy";
} else if (this.life <= 0){
this.lifeCode = "dead";
game.remove(this);
return;
} else {
this.lifeCode = "damaged";
}
switch (this.action){
case "stand":
var direction = wrapDirection(Math.round(this.direction),this.directions);
this.imageList = this.spriteArray["stand-"+direction];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
break;
}
},
如果你现在运行这个游戏,你应该能够通过右击地图上的一个点来选择车辆并在地图上移动它们。车辆将沿着避开所有地形和建筑障碍物的路径行驶。图 7-4 显示了寻路算法返回的典型路径。
图 7-4。使用寻路算法的典型运动路径
你会注意到的一件事是,当车辆避开无法通行的地形时,它们仍然会驶过其他车辆。
解决这个问题的一个简单方法是将所有被车辆占据的方格标记为不可通行。然而,这种简单的方法可能会阻塞地图的很大一部分,因为车辆经常穿过多个网格方块。这种方法的另一个缺点是,如果我们试图移动一堆车辆通过一条狭窄的通道,第一辆车将阻塞通道,导致后面的车辆试图寻找一条更长的替代路线,或者更糟的是,假设没有可能的路径而放弃。
一个更好的替代方案是实现一个转向步骤,该步骤检查与其他物体的碰撞并修改车辆的方向,同时仍然尽可能地保持原始路径。
碰撞检测和转向
转向和寻路一样,是一个相当庞大的人工智能课题。在游戏中应用转向行为的想法由来已久,但它是在 20 世纪 80 年代中后期由克雷格·雷诺兹(Craig Reynolds)的工作推广开来的。他的论文“自主角色的操纵行为”和他的 Java 演示仍然被认为是开发游戏中操纵机制的基本起点。你可以阅读更多关于他的研究,并在 http://www.red3d.com/cwr/steer/观看各种转向机制的演示。
我们将为我们的游戏使用一个相当简单的实现。我们将首先检查沿当前方向移动车辆是否会导致与任何物体的碰撞。如果有碰撞的物体,我们将从任何碰撞的物体对我们的车辆产生排斥力,并对寻路路径中的下一个网格方块产生温和的吸引力。
然后,我们将所有这些力结合起来作为矢量,来看车辆需要向哪个方向移动以避开碰撞。我们将把车辆转向这个方向,直到车辆不再与任何物体碰撞,此时车辆将返回到基本寻路模式。
我们将根据碰撞物体的距离来区分硬碰撞和软碰撞。即将发生软碰撞的车辆在转弯时仍可能移动;然而,一辆即将发生硬碰撞的车辆根本不会向前行驶,只会转向。
我们将首先为 vehicles.js 中的 vehicle 对象实现一个默认的 checkCollisionsObject()方法,如清单 7-19 所示。
清单 7-19。 默认的 checkCollisionObjects()方法(vehicles.js)
// Make a list of collisions that the vehicle will have if it goes along present path
checkCollisionObjects:function(grid){
// Calculate new position on present path
var movement = this.speed*game.speedAdjustmentFactor;
var angleRadians = -(Math.round(this.direction)/this.directions)*2*Math.PI;
var newX = this.x - (movement*Math.sin(angleRadians));
var newY = this.y - (movement*Math.cos(angleRadians));
// List of objects that will collide after next movement step
var collisionObjects = [];
var x1 = Math.max(0,Math.floor(newX)-3);
var x2 = Math.min(game.currentLevel.mapGridWidth-1,Math.floor(newX)+3);
var y1 = Math.max(0,Math.floor(newY)-3);
var y2 = Math.min(game.currentLevel.mapGridHeight-1,Math.floor(newY)+3);
// Test grid upto 3 squares away
for (var j=x1; j <= x2;j++){
for(var i=y1; i<= y2 ;i++){
if(grid[i][j]==1){ // grid square is obsutructed
if (Math.pow(j+0.5-newX,2)+Math.pow(i+0.5-newY,2) < Math.pow(this.radius/game.gridSize+0.1,2)){
// Distance of obstructed grid from vehicle is less than hard collision threshold
collisionObjects.push({collisionType:"hard", with:{type:"wall",x:j+0.5,y:i+0.5}});
} else if (Math.pow(j+0.5-newX,2)+Math.pow(i+0.5-newY,2) < Math.pow(this.radius/game.gridSize+0.7,2)){
// Distance of obstructed grid from vehicle is less than soft collision threshold
collisionObjects.push({collisionType:"soft", with:{type:"wall",x:j+0.5,y:i+0.5}});
}
}
};
};
for (var i = game.vehicles.length - 1; i >= 0; i--){
var vehicle = game.vehicles[i];
// Test vehicles that are less than 3 squares away for collisions
if (vehicle != this && Math.abs(vehicle.x-this.x)<3 && Math.abs(vehicle.y-this.y)<3){
if (Math.pow(vehicle.x-newX,2) + Math.pow(vehicle.y-newY,2) < Math.pow((this.radius+vehicle.radius)/game.gridSize,2)){
// Distance between vehicles is less than hard collision threshold (sum of vehicle radii)
collisionObjects.push({collisionType:"hard",with:vehicle});
} else if (Math.pow(vehicle.x-newX,2) + Math.pow(vehicle.y-newY,2) < Math.pow((this.radius*1.5+vehicle.radius)/game.gridSize,2)){
// Distance between vehicles is less than soft collision threshold (1.5 times vehicle radius + colliding vehicle radius)
collisionObjects.push({collisionType:"soft",with:vehicle});
}
}
};
return collisionObjects;
},
如果车辆沿着其当前方向移动,我们首先计算车辆的新位置。然后,我们通过将中心之间的距离与基于车辆半径的特定阈值进行比较,来检查附近是否有任何不可通过的网格方块可能与新位置的车辆发生碰撞。如果碰撞正在发生,我们将它们标记为“硬”碰撞,如果它们即将发生碰撞,我们将它们标记为“软”碰撞。然后,所有碰撞都添加到 collisionObjects 数组中。
然后,我们对车辆阵列重复这一过程,通过使用它们的半径之和作为阈值距离来测试附近的所有车辆的可能碰撞。
现在我们有了一个碰撞对象的列表,我们将修改我们之前定义的默认 moveTo()方法来处理碰撞(见清单 7-20 )。
清单 7-20。 处理碰撞内部默认 moveTo()方法(vehicles.js)
moveTo:function(destination){
if(!game.currentMapPassableGrid){
game.rebuildPassableGrid();
}
// First find path to destination
var start = [Math.floor(this.x),Math.floor(this.y)];
var end = [Math.floor(destination.x),Math.floor(destination.y)];
var grid = $.extend(true,[],game.currentMapPassableGrid);
// Allow destination to be "movable" so that algorithm can find a path
if(destination.type == "buildings"||destination.type == "terrain"){
grid[Math.floor(destination.y)][Math.floor(destination.x)] = 0;
}
var newDirection;
// if vehicle is outside map bounds, just go straight towards goal
if (start[1]<0 || start[1]>=game.currentLevel.mapGridHeight || start[0]<0 || start[0]>= game.currentLevel.mapGridWidth){
this.orders.path = [this,destination];
newDirection = findAngle(destination,this,this.directions);
} else {
//Use A* algorithm to try and find a path to the destination
this.orders.path = AStar(grid,start,end,'Euclidean');
if (this.orders.path.length>1){
var nextStep = {x:this.orders.path[1].x+0.5,y:this.orders.path[1].y+0.5};
newDirection = findAngle(nextStep,this,this.directions);
} else if(start[0]==end[0] && start[1] == end[1]){
// Reached destination grid square
this.orders.path = [this,destination];
newDirection = findAngle(destination,this,this.directions);
} else {
// There is no path
return false;
}
}
// check if moving along current direction might cause collision..
// If so, change newDirection
var collisionObjects = this.checkCollisionObjects(grid);
this.hardCollision = false;
if (collisionObjects.length>0){
this.colliding = true;
// Create a force vector object that adds up repulsion from all colliding objects
var forceVector = {x:0,y:0}
// By default, the next step has a mild attraction force
collisionObjects.push({collisionType:"attraction", with:{x:this.orders.path[1].x+0.5,y:this.orders.path[1].y+0.5}});
for (var i = collisionObjects.length - 1; i >= 0; i--){
var collObject = collisionObjects[i];
var objectAngle = findAngle(collObject.with,this,this.directions);
var objectAngleRadians = -(objectAngle/this.directions)* 2*Math.PI;
var forceMagnitude;
switch(collObject.collisionType){
case "hard":
forceMagnitude = 2;
this.hardCollision = true;
break;
case "soft":
forceMagnitude = 1;
break;
case "attraction":
forceMagnitude = -0.25;
break;
}
forceVector.x += (forceMagnitude*Math.sin(objectAngleRadians));
forceVector.y += (forceMagnitude*Math.cos(objectAngleRadians));
};
// Find a new direction based on the force vector
newDirection = findAngle(forceVector,{x:0,y:0},this.directions);
} else {
this.colliding = false;
}
// Calculate turn amount for new direction
var difference = angleDiff(this.direction,newDirection,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
// Either turn or move forward based on collision type
if (this.hardCollision){
// In case of hard collision, do not move forward, just turn towards new direction
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+ turnAmount*Math.abs(difference)/difference, this.directions);
}
} else {
// Otherwise, move forward, but keep turning as needed
var movement = this.speed*game.speedAdjustmentFactor;
var angleRadians = -(Math.round(this.direction)/this.directions)* 2*Math.PI ;
this.lastMovementX = - (movement*Math.sin(angleRadians));
this.lastMovementY = - (movement*Math.cos(angleRadians));
this.x = (this.x +this.lastMovementX);
this.y = (this.y +this.lastMovementY);
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+ turnAmount*Math.abs(difference)/difference, this.directions);
}
}
return true;
},
在初始寻路步骤之后,我们调用 checkCollisionObjects()方法,并获得车辆将与之碰撞的对象列表。
然后,我们遍历这个对象列表,根据碰撞是“软”还是“硬”,为每个对象定义一个大小为 1 或 2 的排斥力我们还定义了一个对下一个寻路方格的吸引力。最后,我们将所有这些力加到一个 forceVector 对象中,并使用它来计算使车辆离所有力最远的方向,并将它赋给新的 direction 变量。
这意味着,只要没有碰撞物体,车辆就会朝着其路径中定义的下一个网格方块前进。当车辆感觉到碰撞时,它的主要动机将是通过采取规避动作来避免碰撞。一旦避免了碰撞威胁,车辆将返回到其最初的路径跟踪行为。
我们增加了一个额外的检查,以防止车辆向前移动,如果移动将导致严重碰撞。因此,车辆将完全停止,而不是实际上与另一个物体相撞。
如果你现在运行游戏并试图移动一辆车,你会发现它会绕过其他车辆以避免与它们相撞。
您可能会注意到的一个问题是,如果您试图将多辆车移动到同一个地点,第一辆车会停在正确的位置,而其他车会不停地兜圈子,徒劳地试图到达被占用的站点。我们将需要通过增加一些智能来解决这个问题,让车辆处理试图移动到堵塞点的方式。
理想的行为是,如果目的地被阻挡,则在离目的地不远的地方停下来,如果车辆碰撞了很长时间而没有到达目的地,则在更远的地方停下来。
我们将通过修改默认的 processOrders()方法来实现这一点,如清单 7-21 所示。
清单 7-21。 修改 processOrders()处理停止(vehicles.js)
processOrders:function(){
this.lastMovementX = 0;
this.lastMovementY = 0;
switch (this.orders.type){
case "move":
// Move towards destination until distance from destination is less than vehicle radius
var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
//Stop when within one radius of the destination
this.orders = {type:"stand"};
return;
} else if (distanceFromDestinationSquared <Math.pow(this.radius*3/game.gridSize,2)) {
//Stop when within 3 radius of the destination if colliding with something
this.orders = {type:"stand"};
return;
} else {
if (this.colliding && (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.radius*5/game.gridSize,2)) {
// Count collsions within 5 radius distance of goal
if (!this.orders.collisionCount){
this.orders.collisionCount = 1
} else {
this.orders.collisionCount ++;
}
// Stop if more than 30 collisions occur
if (this.orders.collisionCount > 30) {
this.orders = {type:"stand"};
return;
}
}
var moving = this.moveTo(this.orders.to);
// Pathfinding couldn't find a path so stop
if(!moving){
this.orders = {type:"stand"};
return;
}
}
break;
}
},
如果车辆在目的地的 1 个半径范围内,我们首先尝试在目的地停车。如果车辆发生碰撞,并且在目的地的 3 个半径范围内,我们也会停下来。最后,如果车辆在距离目的地 5 个半径的范围内碰撞超过 30 次,我们就停下来。最后一个条件处理车辆在拥挤的区域颠簸了一段时间而没有找到到达目的地的方法的情况。
如果你现在运行游戏,并试图移动多辆车在一起,你会看到他们智能地停在他们的目的地附近,即使在拥挤的地区。
在这一点上,我们有一个相当好的智能单元运动的寻路和转向解决方案。这个系统可以进一步开发,以提高性能和增加其他智能行为,如排队,成群结队,领导跟随,这取决于你的游戏要求。当你在自己的游戏中实现单位运动时,你一定要进一步研究这个话题,从克雷格·雷诺兹(www.red3d.com/cwr/steer/)的作品开始。
现在我们已经有了车辆运动,让我们花点时间来实现另一个与运动相关的命令:部署收割机。
部署收割机
我们将收割机设计为可展开的车辆,当部署在油田上时,它可以打开进入收割机建筑。我们已经设置了代码,当玩家右键单击油田时,将部署命令传递给收割机。现在我们将在 vehicles.js 中的 vehicle 对象的 processOrders()方法中实现 deploy case,如清单 7-22 所示。
清单 7-22。process orders()(vehicles . js)内部部署案例的实现
case "deploy":
// If oilfield has been used already, then cancel order
if(this.orders.to.lifeCode == "dead"){
this.orders = {type:"stand"};
return;
}
// Move to middle of oil field
var target = {x:this.orders.to.x+1,y:this.orders.to.y+0.5,type:"terrain"};
var distanceFromTargetSquared = (Math.pow(target.x-this.x,2) + Math.pow(target.y-this.y,2));
if (distanceFromTargetSquared<Math.pow(this.radius*2/game.gridSize,2)) {
// After reaching oil field, turn harvester to point towards left (direction 6)
var difference = angleDiff(this.direction,6,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
} else {
// Once it is pointing to the left, remove the harvester and oil field and deploy a harvester building
game.remove(this.orders.to);
this.orders.to.lifeCode="dead";
game.remove(this);
this.lifeCode="dead";
game.add({type:"buildings", name:"harvester", x:this.orders.to.x, y:this.orders.to.y, action:"deploy", team:this.team});
}
} else {
var moving = this.moveTo(target);
// Pathfinding couldn't find a path so stop
if(!moving){
this.orders = {type:"stand"};
}
}
break;
我们首先使用 moveTo()方法将收割机移动到油田的中间。一旦收割机到达油田,我们使用 angleDiff()方法将收割机转向左侧(方向 6)。最后,我们从游戏中移除矿车和油田物品,并在油田位置添加一个矿车建筑,动作设置为部署。
如果我们运行我们的游戏,选择矿车,然后右键单击一个油田,我们应该看到矿车移动到油田并部署到一个建筑中,如图图 7-5 所示。
图 7-5。收割机部署到收割机建筑中
矿车移动到油田,转入位置,然后似乎扩展成一个矿车建筑。如您所见,有了移动框架,处理不同的订单变得非常容易。
在我们结束单位运动之前,我们将解决最后一件事。你可能已经注意到了单位的移动,特别是像幽灵这样的快速单位,看起来有点起伏不定。我们将努力使这个单位运动平稳。
更平滑的单位运动
我们的游戏动画循环目前以稳定的每秒 10 帧的速度运行。尽管我们的绘制循环运行得更快(通常每秒 30 到 60 帧),但在这些额外的循环中它没有新的信息要绘制,所以实际上它也以每秒 10 帧的速度绘制。这导致了我们看到的起伏不定的运动。
使动画看起来更平滑的一个简单方法是在动画帧之间插入车辆运动。我们可以计算自上次动画循环以来的时间,并使用它来创建插值因子,该因子用于在中间绘制循环期间定位单元。这个小小的调整将使单位看起来以更高的帧速率移动,即使它们实际上仅以每秒 10 帧的速度移动。
我们将首先修改游戏对象的 animationLoop()方法来保存最后的动画时间,并修改 drawingLoop()方法来根据当前绘制时间和最后的动画时间计算插值因子。animationLoop()和 drawingLoop()的最终版本将类似于清单 7-23 。
清单 7-23。 计算一个运动插值因子(game.js)
animationLoop:function(){
// Process orders for any item that handles it
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].processOrders){
game.items[i].processOrders();
}
};
// Animate each of the elements within the game
for (var i = game.items.length - 1; i >= 0; i--){
game.items[i].animate();
};
// Sort game items into a sortedItems array based on their x,y coordinates
game.sortedItems = $.extend([],game.items);
game.sortedItems.sort(function(a,b){
return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
});
//Save the time that the last animation loop completed
game.lastAnimationTime = (new Date()).getTime();
},
drawingLoop:function(){
// Handle Panning the Map
game.handlePanning();
// Check the time since the game was animated and calculate a linear interpolation factor (-1 to 0)
// since drawing will happen more often than animation
game.lastDrawTime = (new Date()).getTime();
if (game.lastAnimationTime){
game.drawingInterpolationFactor = (game.lastDrawTime-game.lastAnimationTime)/game.animationTimeout - 1;
if (game.drawingInterpolationFactor>0){ // No point interpolating beyond the next animation loop ...
game.drawingInterpolationFactor = 0;
}
} else {
game.drawingInterpolationFactor = -1;
}
// Since drawing the background map is a fairly large operation,
// we only redraw the background if it changes (due to panning)
if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage, game.offsetX, game.offsetY,game.
canvasWidth, game.canvasHeight, 0, 0, game.canvasWidth, game.canvasHeight);
game.refreshBackground = false;
}
// Clear the foreground canvas
game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);
// Start drawing the foreground elements
for (var i = game.sortedItems.length - 1; i >= 0; i--){
game.sortedItems[i].draw();
};
// Draw the mouse
mouse.draw();
// Call the drawing loop for the next frame using request animation frame
if (game.running){
requestAnimationFrame(game.drawingLoop);
}
},
我们在 animationLoop()方法的末尾将当前时间保存到 game.lastAnimationTime 中。然后,我们使用这个变量和当前时间来计算 game . drawingininterpolationfactor 变量,它是一个介于-1 和 0 之间的数字。值-1 表示我们在先前的位置绘制单元,而值 0 表示我们在当前位置绘制单元。-1 和 0 之间的任何值都意味着我们在两点之间的中间位置绘制单位。我们将该值限制在 0,以防止任何外推的发生(即,将单元绘制到它已经被动画化的点之外)。
注意在多人第一人称射击游戏中,使用外推和客户端预测等技术来定位单位更为常见,以补偿由于高延迟造成的滞后。
现在我们已经计算了插值因子,我们将使用它和单位 lastMovementX 和 lastMovementY 值在绘制时定位元素。首先,我们将修改 aircraft.js 中 aircraft 对象的默认 draw()方法,如清单 7-24 所示。
清单 7-24。 绘制飞机时插补运动(aircraft.js)
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight +
this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.
pixelWidth,colorOffset,this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.
pixelWidth,shadowOffset,this.pixelWidth,this.pixelHeight,x,y+this.pixelShadowHeight,this.
pixelWidth,this.pixelHeight);
}
我们所做的唯一改变是在 x 和 y 坐标计算中加入了与外推相关的项。接下来,我们将对 vehicles.js 中车辆的默认 draw()方法进行同样的更改(参见清单 7-25 )。
清单 7-25。 绘制车辆时插补运动(vehicles.js)
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY + this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,colorOffset,
this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
}
如果我们运行游戏并四处移动单位,移动现在应该比以前更平滑了。有了这最后一个变化,我们现在可以认为单位运动结束了。
摘要
在这一章中,我们为我们的游戏实现了智能单位运动。
我们从开发一个框架开始,给选定的单位命令,然后让实体执行命令。
我们通过将飞机直接移向目的地来实现移动命令,通过将 A*用于寻路,将排斥力用于转向来实现车辆的移动命令。然后,我们使用我们开发的移动代码实现了矿车的部署命令。
最后,我们通过在绘图代码中集成一个插值步骤来平滑单元移动。
在下一章,我们将实现更多的游戏规则:建造和放置建筑,从星际港口传送车辆和飞机,以及收获金钱。所以,我们继续吧。
八、添加更多游戏元素
在前一章中,我们开发了一个结合寻路和转向的单元移动框架。我们使用这个框架来实现车辆的移动和部署命令。最后,我们通过在中间绘制周期中插入移动步骤,使我们的单元移动看起来更平滑。
我们现在有一个游戏,玩家可以选择单位并命令他们在地图上移动。
在这一章中,我们将在这段代码的基础上添加更多的游戏元素。我们将从实现一种经济开始,玩家可以通过收割来赚钱,然后把钱花在建造建筑和单位上。
然后,我们将构建一个框架来创建游戏级别内的脚本事件,我们可以使用它来控制游戏故事线。我们还将添加向用户显示消息和通知的功能。然后,我们将使用这些元素来处理一个级别内的任务的完成。
我们开始吧。我们将使用第七章中的代码作为起点。
实现基本经济
我们的游戏将会有一个相当简单的经济系统。玩家开始每个任务时会有一笔初始资金。然后,他们可以通过在油田部署收割机来赚取更多。玩家将能够在侧边栏中看到他们的现金余额。一旦玩家有了足够的钱,他们就可以使用工具条来购买建筑和单位。
我们要做的第一件事是修改游戏,以便在关卡开始时向玩家提供金钱。
设定启动资金
我们将从移除物品数组中的一些额外物品开始,并在 maps.js 中的第一张地图中指定两位玩家的起始现金,如清单 8-1 中的所示。
清单 8-1。 设置关卡的起始现金金额(maps.js)
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"vehicles","name":"harvester","x":16,"y":12,"team":"blue","direction":3, "uid":-1},
{"type":"terrain","name":"oilfield","x":3,"y":5,"action":"hint"},
{"type":"terrain","name":"bigrocks","x":19,"y":6},
{"type":"terrain","name":"smallrocks","x":8,"y":3}
],
/* Economy Related*/
"cash":{
"blue":5000,
"green":1000
},
我们把所有不必要的项目从项目清单上删除了。我们还添加了一个现金对象,将蓝队的起始现金设置为 5000,绿队的起始现金设置为 1000。
您可能已经注意到,我们已经为收割机指定了一个 UID。我们将在本章后面处理触发器和脚本事件时使用它。
注意当我们为一个项目指定 UID 时,我们使用负值,这样我们可以确保 UID 永远不会与自动生成的 UID 冲突,后者总是正值。
接下来,我们需要在 singleplayer 对象的 startCurrentLevel()方法中加载这些现金值,如清单 8-2 所示。
清单 8-2。 开始关卡时加载现金金额(singleplayer.js)
startCurrentLevel:function(){
// Load all the items for the level
var level = maps.singleplayer[singleplayer.currentLevel];
// Don't allow player to enter mission until all assets for the level are loaded
$("#entermission").attr("disabled", true);
// Load all the assets for the level
game.currentMapImage = loader.loadImage(level.mapImage);
game.currentLevel = level;
game.offsetX = level.startX * game.gridSize;
game.offsetY = level.startY * game.gridSize;
// Load level Requirements
game.resetArrays();
for (var type in level.requirements){
var requirementArray = level.requirements[type];
for (var i=0; i < requirementArray.length; i++) {
var name = requirementArray[i];
if (window[type]){
window[type].load(name);
} else {
console.log('Could not load type :',type);
}
};
};
for (var i = level.items.length - 1; i >= 0; i--){
var itemDetails = level.items[i];
game.add(itemDetails);
};
// Create a grid that stores all obstructed tiles as 1 and unobstructed as 0
game.currentMapTerrainGrid = [];
for (var y=0; y < level.mapGridHeight; y++) {
game.currentMapTerrainGrid[y] = [];
for (var x=0; x< level.mapGridWidth; x++) {
game.currentMapTerrainGrid[y][x] = 0;
}
};
for (var i = level.mapObstructedTerrain.length - 1; i >= 0; i--){
var obstruction = level.mapObstructedTerrain[i];
game.currentMapTerrainGrid[obstruction[1]][obstruction[0]] = 1;
};
game.currentMapPassableGrid = undefined;
// Load Starting Cash For Game
game.cash = $.extend([],level.cash);
// Enable the enter mission button once all assets are loaded
if (loader.loaded){
$("#entermission").removeAttr("disabled");
} else {
loader.onload = function(){
$("#entermission").removeAttr("disabled");
}
}
// Load the mission screen with the current briefing
$('#missonbriefing').html(level.briefing.replace(/\n/g,'<br><br>'));
$("#missionscreen").show();
},
此时,游戏应该加载关卡加载时双方玩家的起始现金金额。然而,在我们看到现金价值之前,我们需要实现侧栏。
实现侧栏
我们将在 sidebar.js 的侧边栏对象中实现侧边栏功能,如清单 8-3 所示。
清单 8-3。 创建侧栏对象(sidebar.js)
var sidebar = {
animate:function(){
// Display the current cash balance value
$('#cash').html(game.cash[game.team]);
},
}
目前,该对象只有 animate()方法,它更新侧栏现金值。我们将从游戏对象的 animationLoop()方法中调用这个方法,如清单 8-4 所示。
清单 8-4。 从 game.animationLoop() (game.js)调用 sidebar.animate()
animationLoop:function(){
// Animate the Sidebar
sidebar.animate();
// Process orders for any item that handles it
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].processOrders){
game.items[i].processOrders();
}
};
// Animate each of the elements within the game
for (var i = game.items.length - 1; i >= 0; i--){
game.items[i].animate();
};
// Sort game items into a sortedItems array based on their x,y coordinates
game.sortedItems = $.extend([],game.items);
game.sortedItems.sort(function(a,b){
return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
});
game.lastAnimationTime = (new Date()).getTime();
},
接下来,我们将在 index.html 的部分添加对 sidebar.js 的引用,如清单 8-5 所示。
清单 8-5。 添加对 sidebar.js 的引用(index.html)
<script src="js/sidebar.js" type="text/javascript" charset="utf-8"></script>
如果我们运行代码到目前为止,我们应该在侧边栏区域看到玩家的现金余额,如图图 8-1 所示。
图 8-1。侧栏显示现金余额
现在我们有了一个有现金余额的基本侧边栏,我们将为玩家实现一种通过收获获得更多钱的方法。
产生金钱
在前一章中,我们已经实现了部署收割机的能力。为了在收割时开始赚钱,我们将修改部署动画状态,并在 buildings.js 中的默认 animate()方法中实现一个新的收割动画状态,如清单 8-6 中的所示。
清单 8-6。 在 animate() (buildings.js)内部实现新的收割动画状态
case "deploy":
this.imageList = this.spriteArray["deploy"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
// Once deploying is complete, go to harvest now
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "harvest";
}
break;
case "harvest":
this.imageList = this.spriteArray[this.lifeCode];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
if (this.lifeCode == "healthy"){
// Harvesters mine 2 credits of cash per animation cycle
game.cash[this.team] += 2;
}
}
break;
收获箱与立箱相似。然而,每当动画运行一个完整的周期,我们会在玩家的现金余额中增加两个信用点。只有在矿车建筑没有损坏的情况下,我们才会这么做。
我们还修改了 deploy 状态,使其转入 harvest 状态,而不是 stand 状态。这样一旦部署了收割机,就会自动开始赚钱。
如果我们开始游戏,将矿车部署到油田,应该会看到现金余额慢慢增加,如图图 8-2 所示。
图 8-2。部署收割机慢慢挣钱
我们现在有一个基本的游戏经济设置。我们准备实现购买建筑物和单位。
购买建筑物和单元
在我们的游戏中,基地建筑被用来建造建筑,星港被用来建造车辆和飞机。玩家可以通过选择他们想要建造的建筑来购买物品,然后点击工具条上相应的购买按钮。
我们首先将这些购买按钮添加到我们的侧边栏。
添加侧边栏按钮
我们首先将按钮的 HTML 标记添加到 index.html 内部的 gameinterfacescreen div 中,如清单 8-7 所示。
清单 8-7。 添加侧边栏购买按钮到游戏界面屏幕(index.html)
<div id="gameinterfacescreen" class="gamelayer">
<div id="gamemessages"></div>
<div id="callerpicture"></div>
<div id="cash"></div>
<div id="sidebarbuttons">
<input type="button" id="starportbutton" title = "Starport">
<input type="button" id="turretbutton" title = "Turret">
<input type="button" id="placeholder1" disabled>
<input type="button" id="scouttankbutton" title = "Scout Tank">
<input type="button" id="heavytankbutton" title = "Heavy Tank">
<input type="button" id="harvesterbutton" title = "Harvester">
<input type="button" id="chopperbutton" title = "Copter">
<input type="button" id="wraithbutton" title = "Wraith">
<input type="button" id="placeholder2" disabled>
</div>
<canvas id="gamebackgroundcanvas" height="400" width="480"></canvas>
<canvas id="gameforegroundcanvas" height="400" width="480"></canvas>
</div>
接下来我们将为这些按钮添加合适的 CSS 样式到 styles.css 中,如清单 8-8 所示。
清单 8-8。 侧边栏按钮的 CSS 样式(styles.css)
/* Sidebar Buttons */
#gameinterfacescreen #sidebarbuttons {
position:absolute;
left:500px;
top:305px;
width:152px;
height:148px;
overflow:none;
}
#gameinterfacescreen #sidebarbuttons input[type="button"] {
width:43px;
height:35px;
border-width:0px;
padding:0px;
background-image: url(img/buttons.png);
}
/* Grayed out state for buttons*/
#starportbutton:active, #starportbutton:disabled {
background-position: -2px -305px;
}
#placeholder1:active, #placeholder1:disabled {
background-position: -52px -305px;
}
#turretbutton:active, #turretbutton:disabled {
background-position: -100px -305px;
}
#scouttankbutton:active, #scouttankbutton:disabled {
background-position: -2px -346px;
}
#heavytankbutton:active, #heavytankbutton:disabled {
background-position: -52px -346px;
}
#harvesterbutton:active, #harvesterbutton:disabled {
background-position: -102px -346px;
}
#chopperbutton:active, #chopperbutton:disabled {
background-position: -2px -387px;
}
#placeholder2:active, #placeholder2:disabled {
background-position: -52px -387px;
}
#wraithbutton:active, #wraithbutton:disabled {
background-position: -102px -387px;
}
/* Regular state for buttons*/
#starportbutton {
background-position: -167px -305px;
}
#placeholder1 {
background-position: -216px -305px;
}
#turretbutton {
background-position: -264px -305px;
}
#scouttankbutton {
background-position: -167px -346px;
}
#heavytankbutton {
background-position: -216px -346px;
}
#harvesterbutton {
background-position: -264px -346px;
}
#chopperbutton {
background-position: -167px -387px;
}
#placeholder2 {
background-position: -216px -387px;
}
#wraithbutton {
background-position: -264px -387px;
}
HTML 标记将按钮添加到侧边栏,而 CSS 样式使用 buttons.png 文件定义这些按钮的图像。
如果我们在浏览器中运行游戏,我们应该会在工具条中看到购买按钮,如图图 8-3 所示。
图 8-3。侧边栏中的购买按钮
此时,所有的按钮看起来都是激活的;但是,单击按钮不会做任何事情。根据是否允许玩家构建物品,需要启用或禁用按钮。
启用和禁用侧栏按钮
我们要做的下一件事是确保侧边栏按钮只有在选择了合适的建筑并且玩家有足够的钱来建造物品时才被激活。我们将通过向 sidebar.js 添加一个 enableSidebarButtons()方法并从 animate()方法内部调用它来实现,如清单 8-9 所示。
清单 8-9。 启用和禁用侧边栏按钮(sidebar.js)
var sidebar = {
enableSidebarButtons:function(){
// Buttons only enabled when appropriate building is selected
$("#gameinterfacescreen #sidebarbuttons input[type='button'] ").attr("disabled", true);
// If no building selected, then no point checking below
if (game.selectedItems.length==0){
return;
}
var baseSelected = false;
var starportSelected = false;
// Check if base or starport is selected
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
// Check If player selected a healthy,inactive building (damaged buildings can't produce)
if (item.type == "buildings" && item.team == game.team && item.lifeCode == "healthy" && item.action=="stand"){
if(item.name == "base"){
baseSelected = true;
} else if (item.name == "starport"){
starportSelected = true;
}
}
};
var cashBalance = game.cash[game.team];
/* Enable building buttons if base is selected,building has been loaded in requirements, not in deploy building mode and player has enough money*/
if (baseSelected && !game.deployBuilding){
if(game.currentLevel.requirements.buildings.indexOf('starport')>-1 && cashBalance>=buildings.list["starport"].cost){
$("#starportbutton").removeAttr("disabled");
}
if(game.currentLevel.requirements.buildings.indexOf('ground-turret')>-1 && cashBalance>=buildings.list["ground-turret"].cost){
$("#turretbutton").removeAttr("disabled");
}
}
/* Enable unit buttons if starport is selected, unit has been loaded in requirements, and player has enough money*/
if (starportSelected){
if(game.currentLevel.requirements.vehicles.indexOf('scout-tank')>-1 && cashBalance>=vehicles.list["scout-tank"].cost){
$("#scouttankbutton").removeAttr("disabled");
}
if(game.currentLevel.requirements.vehicles.indexOf('heavy-tank')>-1 && cashBalance>=vehicles.list["heavy-tank"].cost){
$("#heavytankbutton").removeAttr("disabled");
}
if(game.currentLevel.requirements.vehicles.indexOf('harvester')>-1 && cashBalance>=vehicles.list["harvester"].cost){
$("#harvesterbutton").removeAttr("disabled");
}
if(game.currentLevel.requirements.aircraft.indexOf('chopper')>-1 && cashBalance>=aircraft.list["chopper"].cost){
$("#chopperbutton").removeAttr("disabled");
}
if(game.currentLevel.requirements.aircraft.indexOf('wraith')>-1 && cashBalance>=aircraft.list["wraith"].cost){
$("#wraithbutton").removeAttr("disabled");
}
}
},
animate:function(){
// Display the current cash balance value
$('#cash').html(game.cash[game.team]);
// Enable or disable buttons as appropriate
this.enableSidebarButtons();
},
}
在 enableSidebarButton()方法中,我们首先默认禁用所有按钮。然后,我们检查是否选择了有效的 base 或 starport。一个有效的基地或星港属于玩家,是健康的,并且当前处于站立模式,这意味着它当前没有建造任何其他东西。
如果基地已经被选中,建筑类型已经载入关卡要求,并且玩家有足够的现金购买建筑,我们就会启用建筑按钮。如果选择了一个有效的星港,我们对车辆和飞机做同样的事情。
如果我们现在运行游戏,一旦我们选择了一个基地或星港,侧边栏按钮就会被激活,如图 8-4 所示。
图 8-4。侧边栏建筑建造按钮通过选择基础启用
如图所示,建筑物按钮已启用,而车辆和飞机按钮被禁用,因为选择了基础。我们同样可以通过选择星港来激活车辆和飞机建造按钮。
现在是时候在星港建造车辆和飞机了。
在星港建造车辆和飞机
我们要做的第一件事是通过添加清单 8-10 中的代码来修改侧边栏对象以处理按钮的点击事件。
清单 8-10。 设置侧栏按钮的点击事件(Sidebar . js)
init:function(){
$("#scouttankbutton").click(function(){
sidebar.constructAtStarport({type:"vehicles","name":"scout-tank"});
});
$("#heavytankbutton").click(function(){
sidebar.constructAtStarport({type:"vehicles","name":"heavy-tank"});
});
$("#harvesterbutton").click(function(){
sidebar.constructAtStarport({type:"vehicles","name":"harvester"});
});
$("#chopperbutton").click(function(){
sidebar.constructAtStarport({type:"aircraft","name":"chopper"});
});
$("#wraithbutton").click(function(){
sidebar.constructAtStarport({type:"aircraft","name":"wraith"});
});
},
constructAtStarport:function(unitDetails){
var starport;
// Find the first eligible starport among selected items
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if (item.type == "buildings" && item.name == "starport"
&& item.team == game.team && item.lifeCode == "healthy" && item.action=="stand"){
starport = item;
break;
}
};
if (starport){
game.sendCommand([starport.uid],{type:"construct-unit",details:unitDetails});
}
},
我们首先声明一个 init()方法,为每个车辆和飞机按钮设置 click 事件,以使用适当的单元细节调用 constructAtStarport()方法。
在 constructAtStarport()方法中,我们获得了所选项目中第一个合格的 Starport。然后,我们使用 game.sendCommand()方法向 starport 发送一个构造单元命令,其中包含要构造的单元的详细信息。
接下来,我们将在游戏初始化时从 game.init()方法内部调用 sidebar.init()方法,如清单 8-11 所示。
清单 8-11。 从 game.init() (game.js)内部初始化侧边栏
// Start preloading assets
init: function(){
loader.init();
mouse.init();
sidebar.init();
$('.gamelayer').hide();
$('#gamestartscreen').show();
game.backgroundCanvas = document.getElementById('gamebackgroundcanvas');
game.backgroundContext = game.backgroundCanvas.getContext('2d');
game.foregroundCanvas = document.getElementById('gameforegroundcanvas');
game.foregroundContext = game.foregroundCanvas.getContext('2d');
game.canvasWidth = game.backgroundCanvas.width;
game.canvasHeight = game.backgroundCanvas.height;
},
接下来,我们将为 starport 建筑创建一个 processOrder()方法,它实现了构造单元订单。我们将在 starport 定义中添加这个方法,如清单 8-12 所示。
清单 8-12。 在 Starport 定义(buildings.js)内实现 processOrder()
"starport":{
name:"starport",
pixelWidth:40,
pixelHeight:60,
baseWidth:40,
baseHeight:55,
pixelOffsetX:1,
pixelOffsetY:5,
buildableGrid:[
[1,1],
[1,1],
[1,1]
],
passableGrid:[
[1,1],
[0,0],
[0,0]
],
sight:3,
cost:2000,
hitPoints:300,
spriteImages:[
{name:"teleport",count:9},
{name:"closing",count:18},
{name:"healthy",count:4},
{name:"damaged",count:1},
],
processOrders:function(){
switch (this.orders.type){
case "construct-unit":
if(this.lifeCode != "healthy"){
return;
}
// First make sure there is no unit standing on top of the building
var unitOnTop = false;
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if (item.type == "vehicles" || item.type == "aircraft"){
if (item.x > this.x && item.x < this.x+2 && item.y> this.y && item.y<this.y+3){
unitOnTop = true;
break;
}
}
};
var cost = window[this.orders.details.type].list[this.orders.details.name].cost;
if (unitOnTop){
if (this.team == game.team){
game.showMessage("system","Warning! Cannot teleport unit while landing bay is occupied.");
}
} else if(game.cash[this.team]<cost){
if (this.team == game.team){
game.showMessage("system","Warning! Insufficient Funds. Need "+cost+ " credits.");
}
} else {
this.action="open";
this.animationIndex = 0;
// Position new unit above center of starport
var itemDetails = this.orders.details;
itemDetails.x = this.x+0.5*this.pixelWidth/game.gridSize;
itemDetails.y = this.y+0.5*this.pixelHeight/game.gridSize;
// Teleport in unit and subtract the cost from player cash
itemDetails.action="teleport";
itemDetails.team = this.team;
game.cash[this.team] -= cost;
this.constructUnit = $.extend(true,[],itemDetails);
}
this.orders = {type:"stand"};
break;
}
}
},
我们首先检查是否有任何单位已经被放置在星港上方,如果是,我们使用 game.showMessage()方法通知玩家当登陆舱被占用时不能传送单位。接下来,我们检查我们是否有足够的资金,如果没有,通知用户。
最后,我们实现了单元的实际购买。我们首先将建筑物的动画动作设置为打开。然后,我们为该项设置位置、动作和团队属性。我们将新单位的详细信息保存在 constructUnit 变量中,最后从玩家的现金余额中减去该物品的费用。
你可能已经注意到我们为新建造的单位设置了一个传送动作。我们需要在车辆和飞机上实现这一点。
接下来,我们将修改 buildings 对象的 animate()方法中的开放动画状态,以将该单位添加到游戏中,如清单 8-13 所示。
清单 8-13。 星港开启后添加单位(buildings.js)
case "open":
this.imageList = this.spriteArray["closing"];
// Opening is just the closing sprites running backwards
this.imageOffset = this.imageList.offset + this.imageList.count - this.animationIndex;
this.animationIndex++;
// Once opening is complete, go back to close
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
this.action = "close";
// If constructUnit has been set, add the new unit to the game
if(this.constructUnit){
game.add(this.constructUnit);
this.constructUnit = undefined;
}
}
break;
一旦打开动画完成,我们检查是否设置了 constructUnit 属性,如果设置了,我们在取消设置变量之前将单位添加到游戏中。
接下来我们将在游戏对象中实现一个 showMessage()方法,如清单 8-14 所示。
清单 8-14。 游戏对象的 showMessage()方法
// Functions for communicating with player
characters: {
"system":{
"name":"System",
"image":"img/system.png"
}
},
showMessage:function(from,message){
var character = game.characters[from];
if (character){
from = character.name;
if (character.image){
$('#callerpicture').html('<img src="'+character.image+'"/>');
// hide the profile picture after six seconds
setTimeout(function(){
$('#callerpicture').html("");
},6000)
}
}
// Append message to messages pane and scroll to the bottom
var existingMessage = $('#gamemessages').html();
var newMessage = existingMessage+'<span>'+from+': </span>'+message+'<br>';
$('#gamemessages').html(newMessage);
$('#gamemessages').animate({scrollTop:$('#gamemessages').prop('scrollHeight')});
}
我们首先定义一个 characters 对象,它包含系统角色的名称和图像。在 showMessage()方法中,我们检查 from 参数是否有字符图像,如果有,则显示该图像四秒钟。接下来,我们将消息追加到 gamemessages div,并滚动到 div 的底部。
无论何时调用 showMessage()方法,都会在消息窗口显示消息,在侧边栏显示图片,如图图 8-5 所示。
图 8-5。使用 showMessage()显示系统警告
当我们推进游戏故事线时,我们可以使用这种机制来显示来自各种游戏角色的玩家对话。这将使单人战役更受剧情驱动,并使游戏更具吸引力。
最后,我们将修改车辆和飞机物体来实现新的传送动作。
我们将从在 vehicles 对象的 animate()方法中的 stand 动作的正下方添加一个传送动作的案例开始,如清单 8-15 所示。
清单 8-15。 在 animate() (vehicles.js)内添加瞬移动作的案例
case "teleport":
var direction = wrapDirection(Math.round(this.direction),this.directions);
this.imageList = this.spriteArray["stand-"+direction];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
if (!this.brightness){
this.brightness = 1;
}
this.brightness -= 0.05;
if(this.brightness <= 0){
this.brightness = undefined;
this.action = "stand";
}
break;
我们首先设置 imageOffset 和 animationIndex,就像我们为默认的 stand 动作所做的那样。然后,我们将一个亮度变量设置为 1,并逐渐将其减少到 0,此时我们将动作状态切换回 stand。
接下来,我们将修改 vehicles 对象的默认 draw()方法来使用 brightness 属性,如清单 8-16 所示。
清单 8-16。 修改 draw()方法处理瞬移亮度(vehicles.js)
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY + this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,colorOffset,
this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
// Draw glow while teleporting in
if(this.brightness){
game.foregroundContext.beginPath();
game.foregroundContext.arc(x+ this.pixelOffsetX, y+this.pixelOffsetY, this.radius, 0 , Math.PI*2,false);
game.foregroundContext.fillStyle = 'rgba(255,255,255,'+this.brightness+')';
game.foregroundContext.fill();
}
}
在新添加的代码中,我们检查车辆是否设置了亮度属性,如果是,我们在车辆顶部绘制一个填充的白色圆圈,并根据亮度填充 alpha 值。由于 brightness 属性的值从 1 下降到 0,圆将逐渐从亮白色变为完全透明。
接下来,我们将在飞行器对象的 animate()方法中的 fly 动作下面添加一个传送动作的例子,如清单 8-17 所示。
清单 8-17。 在 animate() (aircraft.js)内部添加瞬移动作案例
case "teleport":
var direction = wrapDirection(Math.round(this.direction),this.directions);
this.imageList = this.spriteArray["fly-"+direction];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
this.animationIndex = 0;
}
if (!this.brightness){
this.brightness = 1;
}
this.brightness -= 0.05;
if(this.brightness <= 0){
this.brightness = undefined;
this.action = "fly";
}
break;
与我们对车辆所做的类似,我们设置一个亮度属性,逐渐将其降低到 0,然后将动作状态设置为飞行。
最后,我们将修改飞机对象的默认 draw()方法来使用亮度属性,就像我们对车辆所做的一样,如清单 8-18 所示。
清单 8-18。 修改 draw()方法处理瞬移亮度(aircraft.js)
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight + this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
this.drawingX = x;
this.drawingY = y;
if (this.selected){
this.drawSelection();
this.drawLifeBar();
}
var colorIndex = (this.team == "blue")?0:1;
var colorOffset = colorIndex*this.pixelHeight;
var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,colorOffset, this.pixelWidth, this.pixelHeight, x,y,this.pixelWidth,this.pixelHeight);
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,shadowOffset,this.pixelWidth, this.pixelHeight, x, y+this.pixelShadowHeight, this.pixelWidth,this.pixelHeight);
// Draw glow while teleporting in
if(this.brightness){
game.foregroundContext.beginPath();
game.foregroundContext.arc(x+ this.pixelOffsetX,y+this.pixelOffsetY,this.radius,0,Math.PI*2,false);
game.foregroundContext.fillStyle = 'rgba(255,255,255,'+this.brightness+')';
game.foregroundContext.fill();
}
}
如果你在浏览器中运行游戏,你现在应该能够选择星港并建造一辆车或飞机,如图 8-6 所示。
图 8-6。飞行器瞬移到星际港口
飞机传送到星港的正上方,在一个白色发光的圆圈内。你会注意到,当飞机被传送进来时,侧边栏按钮被禁用。此外,现金余额因飞机成本而减少。当玩家买不起一个单位时,它的按钮会自动失效。当星港有另一个单位在它上面盘旋时,试图建造一个单位将会导致如图 8-5 所示的系统警告。
现在我们已经完成了建造车辆和飞机,是时候在基地建造建筑了。
在基地建造建筑
我们将从在侧边栏对象的 init()方法中为两个建筑构造按钮设置 click 事件开始,如清单 8-19 中的所示。
清单 8-19。 设置建筑按钮的点击事件(sidebar.js)
init:function(){
// Initialize unit construction buttons
$("#scouttankbutton").click(function(){
sidebar.constructAtStarport({type:"vehicles","name":"scout-tank"});
});
$("#heavytankbutton").click(function(){
sidebar.constructAtStarport({type:"vehicles","name":"heavy-tank"});
});
$("#harvesterbutton").click(function(){
sidebar.constructAtStarport({type:"vehicles","name":"harvester"});
});
$("#chopperbutton").click(function(){
sidebar.constructAtStarport({type:"aircraft","name":"chopper"});
});
$("#wraithbutton").click(function(){
sidebar.constructAtStarport({type:"aircraft","name":"wraith"});
});
//Initialize building construction buttons
$("#starportbutton").click(function(){
game.deployBuilding = "starport";
});
$("#turretbutton").click(function(){
game.deployBuilding = "ground-turret";
});
},
当单击两个 building-construction 按钮中的任何一个时,我们将 sidebar.deployBuilding 属性设置为要构建的建筑物的名称。
接下来,我们将修改侧边栏 animate()方法来处理建筑物的部署,如清单 8-20 所示。
清单 8-20。 修改 animate()方法处理建筑部署(sidebar.js)
animate:function(){
// Display the current cash balance value
$('#cash').html(game.cash[game.team]);
// Enable or disable buttons as appropriate
this.enableSidebarButtons();
if (game.deployBuilding){
// Create the buildable grid to see where building can be placed
game.rebuildBuildableGrid();
// Compare with buildable grid to see where we need to place the building
var placementGrid = buildings.list[game.deployBuilding].buildableGrid;
game.placementGrid = $.extend(true,[],placementGrid);
game.canDeployBuilding = true;
for (var i = game.placementGrid.length - 1; i >= 0; i--){
for (var j = game.placementGrid[i].length - 1; j >= 0; j--){
if(game.placementGrid[i][j] &&
(mouse.gridY+i>= game.currentLevel.mapGridHeight || mouse.gridX+j>=game.currentLevel.mapGridWidth || game.currentMapBuildableGrid[mouse.gridY+i][mouse.gridX+j]==1)){
game.canDeployBuilding = false;
game.placementGrid[i][j] = 0;
}
};
};
}
},
如果已经设置了 game.deployBuilding 变量,我们调用 game.rebuildBuildableGrid()方法创建 game.currentMapBuildableGrid 数组,然后使用正在部署的建筑物的 BuildableGrid 属性设置 game.placementGrid 变量。
然后,我们遍历放置网格,检查是否有可能在当前鼠标位置部署建筑。如果要在其上放置建筑物的任何方块在地图边界之外,或者在 currentMapBuildableGrid 数组中被标记为不可建造,我们将 placementGrid 数组上相应的方块标记为不可建造,并将 canDeployBuilding 标志设置为 false。
接下来我们将在游戏对象内部实现 rebuildBuildableGrid()方法,如清单 8-21 所示。
清单 8-21。 在 rebuildBuildableGrid()方法中创建 buildableGrid(game . js)
rebuildBuildableGrid:function(){
game.currentMapBuildableGrid = $.extend(true,[],game.currentMapTerrainGrid);
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if(item.type == "buildings" || item.type == "terrain"){
for (var y = item.buildableGrid.length - 1; y >= 0; y--){
for (var x = item.buildableGrid[y].length - 1; x >= 0; x--){
if(item.buildableGrid[y][x]){
game.currentMapBuildableGrid[item.y+y][item.x+x] = 1;
}
};
};
} else if (item.type == "vehicles"){
// Mark all squares under or near the vehicle as unbuildable
var radius = item.radius/game.gridSize;
var x1 = Math.max(Math.floor(item.x - radius),0);
var x2 = Math.min(Math.floor(item.x + radius),game.currentLevel.mapGridWidth-1);
var y1 = Math.max(Math.floor(item.y - radius),0);
var y2 = Math.min(Math.floor(item.y + radius),game.currentLevel.mapGridHeight-1);
for (var x=x1; x <= x2; x++) {
for (var y=y1; y <= y2; y++) {
game.currentMapBuildableGrid[y][x] = 1;
};
};
}
};
},
我们首先将 currentMapBuildableGrid 初始化为 currentMapTerrainGrid。然后,我们将建筑或地形实体下的所有方块标记为不可建筑,就像我们在创建可通行数组时所做的那样。最后,我们将车辆旁边的所有方格标记为不可建造。
接下来我们将修改鼠标对象的 draw()方法来标记建筑将要部署的网格位置,如清单 8-22 所示。
清单 8-22。 绘制鼠标光标下的建筑部署网格(Mouse . js)
draw:function(){
if(this.dragSelect){
var x = Math.min(this.gameX,this.dragX);
var y = Math.min(this.gameY,this.dragY);
var width = Math.abs(this.gameX-this.dragX)
var height = Math.abs(this.gameY-this.dragY)
game.foregroundContext.strokeStyle = 'white';
game.foregroundContext.strokeRect(x-game.offsetX,y- game.offsetY, width, height);
}
if (game.deployBuilding && game.placementGrid){
var buildingType = buildings.list[game.deployBuilding];
var x = (this.gridX*game.gridSize)-game.offsetX;
var y = (this.gridY*game.gridSize)-game.offsetY;
for (var i = game.placementGrid.length - 1; i >= 0; i--){
for (var j = game.placementGrid[i].length - 1; j >= 0; j--){
if(game.placementGrid[i][j]){
game.foregroundContext.fillStyle = "rgba(0,0,255,0.3)";
} else {
game.foregroundContext.fillStyle = "rgba(255,0,0,0.3)";
}
game.foregroundContext.fillRect(x+j*game.gridSize, y+i*game.gridSize, game.gridSize, game.gridSize);
};
};
}
},
我们首先检查是否已经设置了 deployBuilding 和 placementGrid 变量,如果已经设置了,我们根据是否可以在该网格位置放置建筑物来绘制蓝色或红色方块。
如果你现在运行游戏,选择主基地,并尝试创建一个建筑,你应该会看到建筑在鼠标位置展开网格,如图图 8-7 所示。
图 8-7。构建部署网格,用红色标记不可构建的方块
现在我们可以启动建筑物部署模式,我们将通过单击鼠标左键来放置建筑物,或者通过单击鼠标右键来取消模式。我们将从修改鼠标对象的 click()方法开始,如清单 8-23 所示。
清单 8-23。 修改 mouse.click() 完成或取消部署模式(mouse.js)
click:function(ev,rightClick){
// Player clicked inside the canvas
var clickedItem = this.itemUnderMouse();
var shiftPressed = ev.shiftKey;
if (!rightClick){ // Player left clicked
// If the game is in deployBuilding mode, left clicking will deploy the building
if (game.deployBuilding){
if(game.canDeployBuilding){
sidebar.finishDeployingBuilding();
} else {
game.showMessage("system","Warning! Cannot deploy building here.");
}
return;
}
if (clickedItem){
// Pressing shift adds to existing selection. If shift is not pressed, clear existing selection
if(!shiftPressed){
game.clearSelection();
}
game.selectItem(clickedItem,shiftPressed);
}
} else { // Player right clicked
// If the game is in deployBuilding mode, right clicking will cancel deployBuilding mode
if (game.deployBuilding){
sidebar.cancelDeployingBuilding();
return;
}
// Handle actions like attacking and movement of selected units
var uids = [];
if (clickedItem){ // Player right clicked on something... Specific action
if (clickedItem.type != "terrain"){
if (clickedItem.team != game.team){ // Player right clicked on an enemy item
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
// if selected item is from players team and can attack
if(item.team == game.team && item.canAttack){
uids.push(item.uid);
}
};
if (uids.length>0){
game.sendCommand(uids,{type:"attack",toUid:clickedItem.uid});
}
} else { // Player right clicked on a friendly item
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
uids.push(item.uid);
}
};
if (uids.length>0){
game.sendCommand(uids,{type:"guard",toUid:clickedItem.uid});
}
}
} else if (clickedItem.name == "oilfield"){
// Oilfield means harvesters go and deploy there
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
// pick the first selected harvester since only one can deploy at a time
if(item.team == game.team && (item.type == "vehicles" && item.name == "harvester")){
uids.push(item.uid);
break;
}
};
if (uids.length>0){
game.sendCommand(uids,{type:"deploy",toUid:clickedItem.uid});
}
}
} else { // Just try to move there
// Get all UIDs that can be commanded to move
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
uids.push(item.uid);
}
};
if (uids.length>0){
game.sendCommand(uids,{type:"move", to:{x:mouse.gameX/game.gridSize, y:mouse.gameY/game.gridSize}});
}
}
}
},
如果玩家在部署模式下单击鼠标左键,我们检查 canDeployBuilding 变量并调用 sidebar . finishdeployingbuilding(),如果我们可以部署建筑物,我们使用 game.showMessage()显示警告消息。
如果播放器在部署模式下右键单击,我们调用 sidebar . canceldeployingbuilding()方法。
接下来我们将在侧边栏对象中实现这两个新方法,finishDeployBuilding() 和 cancelDeployBuilding() ,如清单 8-24 所示。
清单 8-24。 完成部署构建()和取消部署构建()(sidebar.js)
cancelDeployingBuilding:function(){
game.deployBuilding = undefined;
},
finishDeployingBuilding:function(){
var buildingName= game.deployBuilding;
var base;
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if (item.type == "buildings" && item.name == "base" && item.team == game.team && item.lifeCode == "healthy" && item.action=="stand"){
base = item;
break;
}
};
if (base){
var buildingDetails = {type:"buildings",name:buildingName,x:mouse.gridX,y:mouse.gridY};
game.sendCommand([base.uid],{type:"construct-building",details:buildingDetails});
}
// Clear deployBuilding flag
game.deployBuilding = undefined;
}
cancelDeployingBuilding()方法只是清除 deployBuilding 变量。finishDeployingBuilding()方法首先选择基地,然后使用 game.sendCommand()方法向其发送构建命令。
接下来,我们将为实现构造-构建顺序的基础构建创建一个 processOrder()方法。我们将在基本定义中添加这个方法,如清单 8-25 所示。
清单 8-25。 实现基定义(buildings.js)内的 processOrder()
processOrders:function(){
switch (this.orders.type){
case "construct-building":
this.action="construct";
this.animationIndex = 0;
var itemDetails = this.orders.details;
// Teleport in building and subtract the cost from player cash
itemDetails.team = this.team;
itemDetails.action = "teleport";
var item = game.add(itemDetails);
game.cash[this.team] -= item.cost;
this.orders = {type:"stand"};
break;
}
}
我们首先将基本实体的动作状态设置为构造。接下来,我们把这个建筑添加到游戏中,它的动作状态是传送。最后,我们从现金余额中减去建筑成本,并将基本实体的 orders 属性设置回 stand。
如果你现在运行游戏,并试图通过左键点击地图上的一个有效位置来部署建筑,建筑应该会被传送到那个位置,如图 8-8 所示。
图 8-8。部署好的建筑被传送进来
你会注意到现金余额因建筑成本而减少。当玩家买不起建筑时,它的按钮会自动失效。此外,如果您尝试在无效的位置部署建筑物,您将会看到一条系统警告消息,告诉您不能在该位置部署建筑物。
我们现在可以在游戏中建造单位和建筑了。我们将在本章中实现的最后一件事是根据触发的事件结束级别。
结束一关
每当玩家成功完成一个关卡的目标,我们会显示一个消息框通知他们,然后载入下一个关卡。如果玩家任务失败,我们会给玩家重新玩当前关卡或者离开单人战役的选择。
我们将通过在游戏中实现一个触发事件系统来检查成功和失败的标准。在后面的章节中,我们将使用相同的事件系统来编写基于故事的事件。
我们要做的第一件事是实现一个消息对话框。
实现消息对话框
消息框将是一个模式对话框,只有一个确定按钮或同时有确定和取消按钮。
我们首先将消息框屏幕的 HTML 标记添加到 index.html 的主体,如清单 8-26 所示。
清单 8-26。 在正文标签内为消息框添加 HTML 标记(index.html)
<body>
<div id="gamecontainer">
<div id="gamestartscreen" class="gamelayer">
<span id="singleplayer" onclick = "singleplayer.start();">Campaign</span><br>
<span id="multiplayer" onclick = "multiplayer.start();">Multiplayer</span><br>
</div>
<div id="missionscreen" class="gamelayer">
<input type="button" id="entermission" onclick = "singleplayer.play();">
<input type="button" id="exitmission" onclick = "singleplayer.exit();">
<div id="missonbriefing">Welcome to your first mission.
</div>
</div>
<div id="gameinterfacescreen" class="gamelayer">
<div id="gamemessages"></div>
<div id="callerpicture"></div>
<div id="cash"></div>
<div id="sidebarbuttons">
<input type="button" id="starportbutton" title = "Starport">
<input type="button" id="turretbutton" title = "Turret">
<input type="button" id="placeholder1" disabled>
<input type="button" id="scouttankbutton" title = "Scout Tank">
<input type="button" id="heavytankbutton" title = "Heavy Tank">
<input type="button" id="harvesterbutton" title = "Harvester">
<input type="button" id="chopperbutton" title = "Copter">
<input type="button" id="wraithbutton" title = "Wraith">
<input type="button" id="placeholder2" disabled>
</div>
<canvas id="gamebackgroundcanvas" height="400" width="480"></canvas>
<canvas id="gameforegroundcanvas" height="400" width="480"></canvas>
</div>
<div id="messageboxscreen" class="gamelayer">
<div id="messagebox">
<span id="messageboxtext"></span>
<input type="button" id="messageboxok" onclick="game.messageBoxOK();">
<input type="button" id="messageboxcancel" onclick="game.messageBoxCancel();">
</div>
</div>
<div id="loadingscreen" class="gamelayer">
<div id="loadingmessage"></div>
</div>
</div>
</body>
接下来,我们将把消息框的样式添加到 styles.css 中,如清单 8-27 所示。
清单 8-27。 消息框样式(styles.css)
/* Message Box Screen */
#messageboxscreen {
background:rgba(0,0,0,0.7);
z-index:20;
}
#messagebox {
position:absolute;
top:170px;
left:140px;
width:296px;
height:178px;
color:white;
background:url(img/messagebox.png) no-repeat center;
color:rgb(130,150,162);
overflow:hidden;
font-size: 13px;
font-family: 'Courier New', Courier, monospace;
}
#messagebox span {
position:absolute;
top:30px;
left:50px;
width:200px;
height:100px;
}
#messagebox input[type="button"]{
background-image: url(img/buttons.png);
position:absolute;
border-width:0px;
padding:0px;
}
#messageboxok{
background-position: -2px -150px;
top:126px;
left:11px;
width:74px;
height:26px;
}
#messageboxok:active,#messageboxok:disabled{
background-position: -2px -186px;
}
#messageboxcancel{
background-position: -86px -150px;
left:197px;
top:129px;
width:73px;
height:24px;
}
#messageboxcancel:active,#messageboxcancel:disabled{
background-position: -86px -184px;
}
最后,我们给游戏对象添加一些方法,如清单 8-28 所示。
清单 8-28。 给游戏对象添加消息框方法(game.js)
/* Message Box related code*/
messageBoxOkCallback:undefined,
messageBoxCancelCallback:undefined,
showMessageBox:function(message,onOK,onCancel){
// Set message box text
$('#messageboxtext').html(message);
// Set message box ok and cancel handlers and enable buttons
if(!onOK){
game.messageBoxOkCallback = undefined;
} else {
game.messageBoxOkCallback = onOK;
}
if(!onCancel){
game.messageBoxCancelCallback = undefined;
$("#messageboxcancel").hide();
} else {
game.messageBoxCancelCallback = onCancel;
$("#messageboxcancel").show();
}
// Display the message box and wait for user to click a button
$('#messageboxscreen').show();
},
messageBoxOK:function(){
$('#messageboxscreen').hide();
if(game.messageBoxOkCallback){
game.messageBoxOkCallback()
}
},
messageBoxCancel:function(){
$('#messageboxscreen').hide();
if(game.messageBoxCancelCallback){
game.messageBoxCancelCallback();
}
},
showMessageBox()方法首先在 messageboxtext 元素中设置消息。接下来,它将 onOK 和 onCancel 回调方法参数保存到 messageBoxOkCallback 和 messageBoxCancelCallback 变量中。它根据是否传递了取消回调方法参数来显示或隐藏取消按钮。最后,它展示了 messageboxscreen 层。
messageBoxOK()和 messageBoxCancel()方法隐藏 messageboxscreen 层,然后调用它们各自的回调方法(如果已设置)。
在没有指定任何回调方法的情况下调用 showMessageBox()时,它会在一个只有 OK 按钮的黑屏上显示消息框,如图图 8-9 所示。
图 8-9。消息框中显示的示例消息
现在消息框的代码已经就绪,我们将实现我们的游戏触发器。
实现触发器
我们的游戏将使用两种类型的触发器。
- 定时触发器将在指定时间后执行操作。他们也可以定期重复。
- 条件触发器将在指定的条件为真时执行操作。
我们将从在 maps 对象的级别中添加一个触发器数组开始,如清单 8-29 所示。
清单 8-29。 向关卡中添加触发器(maps.js)
/* Conditional and Timed Trigger Events */
"triggers":[
/* Timed Events*/
{"type":"timed","time":1000,
"action":function(){
game.showMessage("system","You have 20 seconds left.\nGet the harvester near the oil field.");
}
},
{"type":"timed","time":21000,
"action":function(){
singleplayer.endLevel(false);
}
},
/* Conditional Event */
{"type":"conditional",
"condition":function(){
var transport = game.getItemByUid(-1);
return (transport.x <10 && transport.y <10);
},
"action":function(){
singleplayer.endLevel(true);
}
}
],
所有的触发器都有一个类型和一个动作方法。我们在数组中定义了三个触发器。
第一个触发器是时间设置为 1 秒的定时触发器。在它的动作参数中,我们调用 game.showMessage()并告诉玩家他有 20 秒的时间将收割机移动到油田附近。
第二个触发器计时 20 秒后,调用 singleplayer.endLevel()方法,参数为 false,表示任务失败。
最终触发器是条件触发器。当运输工具位于地图的左上角象限内且 x 和 y 坐标小于 10°时,condition 方法返回 true。当这个条件被触发时,action 方法调用 singleplayer.endLevel()方法,参数 true 表示任务成功完成。
接下来我们将在 singleplayer 对象中实现 endLevel()方法,如清单 8-30 所示。
清单 8-30。 实现 singleplayer endLevel()方法(singleplayer.js)
endLevel:function(success){
clearInterval(game.animationInterval);
game.end();
if (success){
var moreLevels = (singleplayer.currentLevel < maps.singleplayer.length-1);
if (moreLevels){
game.showMessageBox("Mission Accomplished.",function(){
$('.gamelayer').hide();
singleplayer.currentLevel++;
singleplayer.startCurrentLevel();
});
} else {
game.showMessageBox("Mission Accomplished.<br><br>This was the last mission in the campaign.<br><br>Thank You for playing.",function(){
$('.gamelayer').hide();
$('#gamestartscreen').show();
});
}
} else {
game.showMessageBox("Mission Failed.<br><br>Try again?",function(){
$('.gamelayer').hide();
singleplayer.startCurrentLevel();
}, function(){
$('.gamelayer').hide();
$('#gamestartscreen').show();
});
}
}
我们首先清除调用 game.animationLoop()方法的 game.animationInterval 计时器。接下来我们调用 game.end()方法。
如果关卡成功完成,我们将检查地图中是否有更多的关卡。如果是这样,我们会在消息框中通知玩家任务成功,然后当玩家点击 OK 按钮时开始下一关。如果没有更多的关卡,我们会通知玩家,但是当玩家点击“确定”时,我们会返回到游戏开始菜单。
如果关卡没有成功完成,我们会询问玩家是否想再试一次。如果玩家点击确定,我们重新开始当前的水平。如果玩家点击取消,我们返回游戏开始菜单。
接下来我们将为游戏对象添加一些与触发器相关的方法,如清单 8-31 所示。
清单 8-31。 给游戏对象添加触发相关方法(game.js)
// Methods for handling triggered events within the game
initTrigger:function(trigger){
if(trigger.type == "timed"){
trigger.timeout = setTimeout (function(){
game.runTrigger(trigger);
},trigger.time)
} else if(trigger.type == "conditional"){
trigger.interval = setInterval (function(){
game.runTrigger(trigger);
},1000)
}
},
runTrigger:function(trigger){
if(trigger.type == "timed"){
// Re initialize the trigger based on repeat settings
if (trigger.repeat){
game.initTrigger(trigger);
}
// Call the trigger action
trigger.action(trigger);
} else if (trigger.type == "conditional"){
//Check if the condition has been satisfied
if(trigger.condition()){
// Clear the trigger
game.clearTrigger(trigger);
// Call the trigger action
trigger.action(trigger);
}
}
},
clearTrigger:function(trigger){
if(trigger.type == "timed"){
clearTimeout(trigger.timeout);
} else if (trigger.type == "conditional"){
clearInterval(trigger.interval);
}
},
end:function(){
// Clear Any Game Triggers
if (game.currentLevel.triggers){
for (var i = game.currentLevel.triggers.length - 1; i >= 0; i--){
game.clearTrigger(game.currentLevel.triggers[i]);
};
}
game.running = false;
}
我们实现的第一个方法是 initTrigger()。我们检查触发器是定时的还是有条件的。对于定时触发器,我们在 time 参数中指定的超时之后调用 runTrigger()方法。对于条件触发器,我们每秒调用一次 runTrigger()方法。
在 runTrigger()方法中,我们检查触发器是定时的还是有条件的。对于指定了重复参数的定时触发器,我们再次调用 initTrigger()。然后,我们执行触发操作。对于条件触发器,我们检查条件是否为真。如果是,我们清除触发器并执行操作。
clearTimeout()方法只是清除触发器的超时或间隔。
最后,end()方法清除某个级别的所有触发器,并将 game.running 变量设置为 false。
我们要做的最后一个改变是游戏对象的 start()方法,如清单 8-32 所示。
清单 8-32。 初始化 start()方法(game.js)内的触发器
start:function(){
$('.gamelayer').hide();
$('#gameinterfacescreen').show();
game.running = true;
game.refreshBackground = true;
game.drawingLoop();
$('#gamemessages').html("");
// Initialize All Game Triggers
for (var i = game.currentLevel.triggers.length - 1; i >= 0; i--){
game.initTrigger(game.currentLevel.triggers[i]);
};
},
当我们开始关卡时,我们初始化 gamemessages 容器。接下来,我们遍历当前级别的触发器数组,并为每个触发器调用 initTrigger()。
如果我们现在运行游戏,我们应该会收到一条消息,要求我们在 20 秒内将收割机带到油田附近。如果我们没有及时这样做,我们会看到一个消息框,指示任务失败,如图图 8-10 所示。
图 8-10。任务失败时显示的消息
如果我们点击“确定”按钮,关卡将重新开始,我们将返回到任务简报界面。如果我们点击取消按钮,我们将回到主菜单。
如果我们将收割机移向油田,并在 20 秒结束前到达那里,我们将看到一个消息框,指示任务已完成,如图图 8-11 所示。
图 8-11。任务完成时显示的消息
由于这是我们战役中唯一的任务,我们将会看到战役结束的消息框。当我们单击“确定”时,我们将返回到主菜单。
摘要
我们在这一章完成了很多。我们从创建一个基础经济开始,在那里我们可以通过收割来赚取现金。然后我们实现了使用工具条上的按钮来购买星港的单位和基地的建筑的能力。
我们开发了一个消息系统和一个消息对话框来和玩家交流。然后,我们为基于触发器的动作构建了一个系统,处理定时和条件触发器。最后,我们使用这些触发器来创建一个简单的任务目标和任务成功或失败的标准。尽管这是一个相当简单的任务,但我们现在已经有了建造更复杂关卡的基础设施。
在下一章,我们将处理游戏的另一个重要组成部分:战斗。我们将对单位和炮塔实现不同的基于攻击的命令状态。我们将使用触发器和命令状态的组合来使单位在战斗中表现得聪明。最后,我们将着眼于实现战争迷雾,使单位看不到或攻击未探索的领域。
九、添加武器和战役
在过去的几章中,我们建立了游戏的基本框架;增加了车辆、飞机和建筑物等实体;执行单位移动;并使用侧边栏创建了一个简单的经济。我们现在有一个游戏,我们可以开始水平,赚钱,购买建筑物和单位,并移动这些单位来实现简单的目标。
在这一章中,我们将实现车辆、飞机和炮塔的武器。我们将增加处理基于战斗的命令的能力,例如攻击、守卫、巡逻和狩猎,让单位以智能的方式战斗。最后,我们将实现一个限制地图可见性的战争迷雾,允许有趣的策略,如偷袭和伏击。
我们开始吧。我们将使用第八章中的代码作为起点。
实现战斗系统
我们的游戏将有一个相当简单的战斗系统。所有单位和炮塔都有自己的武器和子弹类型。当攻击敌人时,单位将首先进入射程,转向目标,然后向他们发射子弹。一旦单位发射了一颗子弹,它会等到它的武器重新加载后再发射。
子弹本身会是一个独立的游戏实体,有自己的动画逻辑。发射时,子弹会飞向目标,一旦到达目的地就会爆炸。
我们要做的第一件事是给我们的游戏添加子弹。
添加项目符号
我们将从在 bullets.js 中定义一个新的 bullets 对象开始,如清单 9-1 所示。
清单 9-1。 定义子弹对象(bullets.js)
var bullets = {
list:{
"fireball":{
name:"fireball",
speed:60,
reloadTime:30,
range:8,
damage:10,
spriteImages:[
{name:"fly",count:1,directions:8},
{name:"explode",count:7}
],
},
"heatseeker":{
name:"heatseeker",
reloadTime:40,
speed:25,
range:9,
damage:20,
turnSpeed:2,
spriteImages:[
{name:"fly",count:1,directions:8},
{name:"explode",count:7}
],
},
"cannon-ball":{
name:"cannon-ball",
reloadTime:40,
speed:25,
damage:10,
range:6,
spriteImages:[
{name:"fly",count:1,directions:8},
{name:"explode",count:7}
],
},
"bullet":{
name:"bullet",
damage:5,
speed:50,
range:5,
reloadTime:20,
spriteImages:[
{name:"fly",count:1,directions:8},
{name:"explode",count:3}
],
},
},
defaults:{
type:"bullets",
distanceTravelled:0,
animationIndex:0,
direction:0,
directions:8,
pixelWidth:10,
pixelHeight:11,
pixelOffsetX:5,
pixelOffsetY:5,
radius:6,
action:"fly",
selected:false,
selectable:false,
orders:{type:"fire"},
moveTo:function(destination){
// Weapons like the heatseeker can turn slowly toward target while moving
if (this.turnSpeed){
// Find out where we need to turn to get to destination
var newDirection = findFiringAngle(destination,this,this.directions);
// Calculate difference between new direction and current direction
var difference = angleDiff(this.direction,newDirection,this.directions);
// Calculate amount that bullet can turn per animation cycle
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
}
}
var movement = this.speed*game.speedAdjustmentFactor;
this.distanceTravelled += movement;
var angleRadians = −((this.direction)/this.directions)*2*Math.PI ;
this.lastMovementX = − (movement*Math.sin(angleRadians));
this.lastMovementY = − (movement*Math.cos(angleRadians));
this.x = (this.x +this.lastMovementX);
this.y = (this.y +this.lastMovementY);
},
reachedTarget:function(){
var item = this.target;
if (item.type=="buildings"){
return (item.x<= this.x && item.x >= this.x - item.baseWidth/game.gridSize &&
item.y<= this.y && item.y >= this.y - item.baseHeight/game.gridSize);
} else if (item.type=="aircraft"){
return (Math.pow(item.x-this.x,2)+Math.pow(item.y-(this.y+item.pixelShadowHeight/
game.gridSize),2) < Math.pow((item.radius)/game.gridSize,2));
} else {
return (Math.pow(item.x-this.x,2)+Math.pow(item.y-this.y,2) < Math.pow((item.radius)/game.gridSize,2));
}
},
processOrders:function(){
this.lastMovementX = 0;
this.lastMovementY = 0;
switch (this.orders.type){
case "fire":
// Move toward destination and stop when close by or if travelled past range
var reachedTarget = false;
if (this.distanceTravelled>this.range
|| (reachedTarget = this.reachedTarget())) {
if(reachedTarget){
this.target.life -= this.damage;
this.orders = {type:"explode"};
this.action = "explode";
this.animationIndex = 0;
} else {
// Bullet fizzles out without hitting target
game.remove(this);
}
} else {
this.moveTo(this.target);
}
break;
}
},
animate:function(){
switch (this.action){
case "fly":
var direction = wrapDirection(Math.round(this.direction),this.directions);
this.imageList = this.spriteArray["fly-"+ direction];
this.imageOffset = this.imageList.offset;
break;
case "explode":
this.imageList = this.spriteArray["explode"];
this.imageOffset = this.imageList.offset + this.animationIndex;
this.animationIndex++;
if (this.animationIndex>=this.imageList.count){
// Bullet explodes completely and then disappears
game.remove(this);
}
break;
}
},
draw:function(){
var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY + this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
var colorOffset = 0;
game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,
colorOffset, this.pixelWidth,this.pixelHeight, x,y,this.pixelWidth,this.pixelHeight);
}
},
load:loadItem,
add:addItem,
}
子弹对象遵循与所有其他游戏实体相同的模式。我们从定义四种子弹类型开始:火球、热探测器、炮弹和子弹。每个项目符号都有一组共同的属性。
- 速度:子弹行进的速度
- reloadTime:在子弹再次发射之前,发射后动画循环的次数
- 伤害:子弹爆炸时对目标造成的伤害
- 射程:子弹失去动量前的最大飞行距离
项目符号还定义了两个动画序列:飞行和爆炸。飞行状态有八个方向,类似于车辆和飞机。分解状态只有方向,但有多个帧。
然后我们定义一个默认的 moveTo()方法,它类似于 aircraft moveTo()方法。在这个方法中,我们首先检查子弹是否可以转动,如果可以,使用 findFiringAngle()方法计算子弹朝向目标中心的角度,轻轻地将子弹转向目标。接下来,我们沿着当前方向向前移动项目符号,并更新项目符号的 distanceTravelled 属性。
接下来我们定义一个 reachedTarget()方法来检查子弹是否已经到达目标。我们检查子弹的坐标是否在建筑物的基准区域内,是否在车辆和飞机的项目半径内。如果是这样,我们返回 true 值。
在 processOrders()方法中,我们实现了 fire order。我们检查子弹是否到达了目标或者超出了射程。如果没有,我们继续将子弹移向目标。
如果子弹超出射程而没有击中目标,我们将它从游戏中移除。如果子弹到达目标,我们首先将子弹的顺序和动画状态设置为爆炸,并按伤害量减少其目标的生命。
在 animate()方法中,一旦分解动画序列完成,我们就移除子弹。
现在我们已经定义了 bullets 对象,我们将在 index.html 的部分添加对 bullets.js 的引用,如清单 9-2 所示。
清单 9-2。 【添加对项目符号对象的引用】(index.html)
<script src="js/bullets.js" type="text/javascript" charset="utf-8"></script>
我们还将在 common.js 中定义 findFiringAngle()方法,如清单 9-3 所示。
清单 9-3。 定义 findFiringAngle()方法(common.js)
function findFiringAngle(target,source,directions){
var dy = (target.y) - (source.y);
var dx = (target.x) - (source.x);
if(target.type=="buildings"){
dy += target.baseWidth/2/game.gridSize;
dx += target.baseHeight/2/game.gridSize;
} else if(target.type == "aircraft"){
dy -= target.pixelShadowHeight/game.gridSize;
}
if(source.type=="buildings"){
dy -= source.baseWidth/2/game.gridSize;
dx -= source.baseHeight/2/game.gridSize;
} else if(source.type == "aircraft"){
dy += source.pixelShadowHeight/game.gridSize;
}
//Convert Arctan to value between (0 – 7)
var angle = wrapDirection(directions/2-(Math.atan2(dx,dy)*directions/(2*Math.PI)),directions);
return angle;
}
findFiringAngle()方法类似于 findAngle()方法,除了我们调整 dy 和 dx 变量的值以指向源和目标的中心。对于建筑物,我们使用 baseWidth 和 baseHeight 属性调整 dx 和 dy,对于飞机,我们通过 pixelShadowHeight 属性调整 dy。这样子弹就可以瞄准目标的中心。
我们还将修改 common.js 中的 loadItem()方法,以便在项目加载时加载项目符号,如清单 9-4 所示。
清单 9-4。 加载物品时加载子弹(common.js)
/* The default load() method used by all our game entities*/
function loadItem(name){
var item = this.list[name];
// if the item sprite array has already been loaded then no need to do it again
if(item.spriteArray){
return;
}
item.spriteSheet = loader.loadImage('img/'+this.defaults.type+'/'+name+'.png');
item.spriteArray = [];
item.spriteCount = 0;
for (var i=0; i < item.spriteImages.length; i++){
var constructImageCount = item.spriteImages[i].count;
var constructDirectionCount = item.spriteImages[i].directions;
if (constructDirectionCount){
for (var j=0; j < constructDirectionCount; j++) {
var constructImageName = item.spriteImages[i].name +"-"+j;
item.spriteArray[constructImageName] = {
name:constructImageName,
count:constructImageCount,
offset:item.spriteCount
};
item.spriteCount += constructImageCount;
};
} else {
var constructImageName = item.spriteImages[i].name;
item.spriteArray[constructImageName] = {
name:constructImageName,
count:constructImageCount,
offset:item.spriteCount
};
item.spriteCount += constructImageCount;
}
};
// Load the weapon if item has one
if(item.weaponType){
bullets.load(item.weaponType);
}
}
当加载一个项目时,我们检查它是否定义了 weaponType 属性,如果是,使用 bullets.load()方法加载武器的项目符号。所有有攻击能力的实体都有一个武器类型属性。
我们要做的下一个改变是修改游戏对象的 drawingLoop()方法,以在游戏中所有其他项目的顶部绘制子弹和爆炸。更新后的 drawingLoop()方法将类似于清单 9-5 中的。
清单 9-5。 修改 drawingLoop()在其他项目上方绘制项目符号(game.js)
drawingLoop:function(){
// Handle Panning the Map
game.handlePanning();
// Check the time since the game was animated and calculate a linear interpolation factor (−1 to 0)
// since drawing will happen more often than animation
game.lastDrawTime = (new Date()).getTime();
if (game.lastAnimationTime){
game.drawingInterpolationFactor = (game.lastDrawTime -game.lastAnimationTime)/game.animationTimeout - 1;
if (game.drawingInterpolationFactor>0){ // No point interpolating beyond the next animation loop...
game.drawingInterpolationFactor = 0;
}
} else {
game.drawingInterpolationFactor = −1;
}
// Since drawing the background map is a fairly large operation,
// we only redraw the background if it changes (due to panning)
if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage,game.offsetX,game.offsetY, game.canvasWidth, game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
game.refreshBackground = false;
}
// Clear the foreground canvas
game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);
// Start drawing the foreground elements
for (var i = game.sortedItems.length - 1; i >= 0; i--){
if (game.sortedItems[i].type != "bullets"){
game.sortedItems[i].draw();
}
};
// Draw the bullets on top of all the other elements
for (var i = game.bullets.length - 1; i >= 0; i--){
game.bullets[i].draw();
};
// Draw the mouse
mouse.draw()
// Call the drawing loop for the next frame using request animation frame
if (game.running){
requestAnimationFrame(game.drawingLoop);
}
},
我们先画出所有不是子弹的项目,最后画出子弹。这样,子弹和爆炸在游戏中总是清晰可见的。
最后,我们将修改游戏对象的 resetArrays()方法来重置 game.bullets[]数组,如清单 9-6 所示。
清单 9-6。 重置 resetArrays()(game.js)内的子弹数组
resetArrays:function(){
game.counter = 1;
game.items = [];
game.sortedItems = [];
game.buildings = [];
game.vehicles = [];
game.aircraft = [];
game.terrain = [];
game.triggeredEvents = [];
game.selectedItems = [];
game.sortedItems = [];
game.bullets = [];
},
现在我们已经实现了子弹对象,是时候为炮塔、车辆和飞机实现基于战斗的命令了。
基于战斗的炮塔订单
地面炮塔可以向任何地面威胁发射炮弹。当处于守卫或攻击模式时,他们将搜索视线内的有效目标,将炮塔对准目标,并发射子弹,直到目标被摧毁或超出射程。
我们将通过修改 buildings.js 中的地面炮塔对象来实现 processOrders()方法,如清单 9-7 所示。
清单 9-7。 修改地堡对象实现攻击(buildings.js)
isValidTarget:isValidTarget,
findTargetsInSight:findTargetsInSight,
processOrders:function(){
if(this.reloadTimeLeft){
this.reloadTimeLeft--;
}
// damaged turret cannot attack
if(this.lifeCode != "healthy"){
return;
}
switch (this.orders.type){
case "guard":
var targets = this.findTargetsInSight();
if(targets.length>0){
this.orders = {type:"attack",to:targets[0]};
}
break;
case "attack":
if(!this.orders.to ||
this.orders.to.lifeCode == "dead" ||
!this.isValidTarget(this.orders.to) ||
(Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))>Math.pow(this.sight,2)
){
var targets = this.findTargetsInSight();
if(targets.length>0){
this.orders.to = targets[0];
} else {
this.orders = {type:"guard"};
}
}
if (this.orders.to){
var newDirection = findFiringAngle(this.orders.to,this,this.directions);
var difference = angleDiff(this.direction,newDirection,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
return;
} else {
this.direction = newDirection;
if(!this.reloadTimeLeft){
this.reloadTimeLeft = bullets.list[this.weaponType].reloadTime;
var angleRadians = −(Math.round(this.direction)/this.directions)*2*Math.PI ;
var bulletX = this.x+0.5- (1*Math.sin(angleRadians));
var bulletY = this.y+0.5- (1*Math.cos(angleRadians));
var bullet = game.add({name:this.weaponType,type:"bullets", x:bulletX,
y:bulletY, direction:this.direction, target:this.orders.to});
}
}
}
break;
}
}
我们首先在地面炮塔对象内部分配两个名为 isValidTarget()和 findTargetInSight()的方法。我们需要定义这些方法。然后我们定义 processOrders()方法。
在 processOrders()方法中,如果 reloadTimeLeft 属性已定义并且大于 0,我们将减小该属性的值。如果转台生命码不健康(它已损坏或失效),我们什么也不做并退出。
接下来,我们定义守卫命令和攻击命令的行为。在守卫模式下,我们使用 findTargetsInSight()方法来查找目标,如果找到了,就攻击它。在攻击模式下,如果炮塔的当前目标是未定义的、死亡的或看不见的,我们使用 findTargetsInSight()找到新的有效目标,并设置攻击它的顺序。如果我们找不到一个有效的目标,我们回到守卫模式。
如果炮塔有一个有效的目标,我们把它转向目标。一旦炮塔面向目标并且 reloadTimeLeft 为 0,我们通过使用 game.add()方法将子弹添加到游戏中来发射子弹,并将炮塔的 reloadTimeLeft 属性重置为子弹的重新加载时间。
接下来,我们将修改默认 animate()方法中的守卫动画案例来处理方向,如清单 9-8 所示。
清单 9-8。 修改内护箱 animate() (buildings.js)
case "guard":
if (this.lifeCode == "damaged"){
// The damaged turret has no directions
this.imageList = this.spriteArray[this.lifeCode];
} else {
// The healthy turret has 8 directions
var direction = wrapDirection(Math.round(this.direction),this.directions);
this.imageList = this.spriteArray[this.lifeCode+"-"+ direction];
}
this.imageOffset = this.imageList.offset;
break;
接下来,我们将在 common.js 中添加两个名为 isValidTarget()和 findTargetInSight()的方法,如清单 9-9 所示。
清单 9-9。 添加 isValidTarget()和 findTargetInSight()方法(common.js)
// Common Functions related to combat
function isValidTarget(item){
return item.team != this.team &&
(this.canAttackLand && (item.type == "buildings" || item.type == "vehicles")||
(this.canAttackAir && (item.type == "aircraft")));
}
function findTargetsInSight(increment){
if(!increment){
increment=0;
}
var targets = [];
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if (this.isValidTarget(item)){
if(Math.pow(item.x-this.x,2) + Math.pow(item.y-this.y,2)<Math.pow(this.sight+increment,2)){
targets.push(item);
}
}
};
// Sort targets based on distance from attacker
var attacker = this;
targets.sort(function(a,b){
return (Math.pow(a.x-attacker.x,2) + Math.pow(a.y-attacker.y,2))-(Math.pow(b.x-attacker.x,2) + Math.pow(b.y-attacker.y,2));
});
return targets;
}
isValidTarget()方法如果目标物品来自对方队伍,则返回 true,可以攻击。
findTargetsInSight()方法检查 game.items()数组中的所有项目,查看它们是否是有效的目标并在范围内,如果是,它将它们添加到 targets 数组中。然后,它根据每个目标与攻击者的距离对目标数组进行排序。该方法还接受一个可选的 increment 参数,该参数允许我们找到超出项目范围的目标。这两种常用的方法将被炮塔、车辆和飞机使用。
在我们看到代码的结果之前,我们将通过修改触发器和项目数组从最后一级更新我们的映射,如清单 9-10 所示。
清单 9-10。 更新地图条目和触发器(maps.js)
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"vehicles","name":"harvester","x":16,"y":12,"team":"blue","direction":3},
{"type":"terrain","name":"oilfield","x":3,"y":5,"action":"hint"},
{"type":"terrain","name":"bigrocks","x":19,"y":6},
{"type":"terrain","name":"smallrocks","x":8,"y":3},
{"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue","direction":4},
{"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue","direction":5},
{"type":"aircraft","name":"chopper","x":20,"y":12,"team":"blue","direction":2},
{"type":"aircraft","name":"wraith","x":23,"y":12,"team":"blue","direction":3},
{"type":"buildings","name":"ground-turret","x":15,"y":23,"team":"green"},
{"type":"buildings","name":"ground-turret","x":20,"y":23,"team":"green"},
{"type":"vehicles","name":"scout-tank","x":16,"y":26,"team":"green","direction":4},
{"type":"vehicles","name":"heavy-tank","x":18,"y":26,"team":"green","direction":6},
{"type":"aircraft","name":"chopper","x":20,"y":27,"team":"green","direction":2},
{"type":"aircraft","name":"wraith","x":22,"y":28,"team":"green","direction":3},
{"type":"buildings","name":"base","x":19,"y":28,"team":"green"},
{"type":"buildings","name":"starport","x":15,"y":28,"team":"green"},
],
/* Conditional and Timed Trigger Events */
"triggers":[
],
我们移除了在上一章中定义的触发器,因此关卡不会在 30 秒后结束。现在,如果我们在浏览器中运行游戏,移动一辆车辆靠近敌人的炮塔,炮塔应该会开始攻击车辆,如图图 9-1 所示。
图 9-1。炮塔向射程内的车辆开火
子弹击中车辆时会爆炸,缩短车辆寿命。一旦车辆失去所有生命,它就会从游戏中消失。如果目标超出射程,炮塔停止向目标射击,并移动到下一个目标。
接下来,我们将实现基于战斗的飞机订单。
基于战斗的飞机订单
我们将为飞机定义几个基本的基于战斗的命令状态。
- 攻击:在目标范围内移动并射击它。
- 漂浮:呆在一个地方,攻击任何靠近的敌人。
- 守卫:跟随一个友军单位,向任何靠近的敌人射击。
- 狩猎:积极寻找地图上任何地方的敌人并攻击他们。
- 巡逻:在两点之间移动,向任何进入射程的敌人射击。
- 哨兵:呆在一个地方,比在漂浮模式下攻击敌人更有侵略性。
我们将通过修改 aircraft 对象中默认的 processOrders()方法来实现这些状态,如清单 9-11 中的所示。
清单 9-11。 对战机实现战斗命令(aircraft.js)
isValidTarget:isValidTarget,
findTargetsInSight:findTargetsInSight,
processOrders:function(){
this.lastMovementX = 0;
this.lastMovementY = 0;
if(this.reloadTimeLeft){
this.reloadTimeLeft--;
}
switch (this.orders.type){
case "float":
var targets = this.findTargetsInSight();
if(targets.length>0){
this.orders = {type:"attack",to:targets[0]};
}
break;
case "sentry":
var targets = this.findTargetsInSight(2);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
}
break;
case "hunt":
var targets = this.findTargetsInSight(100);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
}
break;
case "move":
// Move toward destination until distance from destination is less than aircraft radius
var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
this.orders = {type:"float"};
} else {
this.moveTo(this.orders.to);
}
break;
case "attack":
if(this.orders.to.lifeCode == "dead" || !this.isValidTarget(this.orders.to)){
if (this.orders.nextOrder){
this.orders = this.orders.nextOrder;
} else {
this.orders = {type:"float"};
}
return;
}
if ((Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.sight,2)) {
//Turn toward target and then start attacking when within range of the target
var newDirection = findFiringAngle(this.orders.to,this,this.directions);
var difference = angleDiff(this.direction,newDirection,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+ turnAmount*Math.abs(difference)/difference, this.directions);
return;
} else {
this.direction = newDirection;
if(!this.reloadTimeLeft){
this.reloadTimeLeft = bullets.list[this.weaponType].reloadTime;
var angleRadians = −(Math.round(this.direction)/this.directions)*2*Math.PI ;
var bulletX = this.x- (this.radius*Math.sin(angleRadians)/game.gridSize);
var bulletY = this.y- (this.radius*Math.cos(angleRadians)/game.gridSize)-this.pixelShadowHeight/game.gridSize;
var bullet = game.add({name:this.weaponType, type:"bullets",x:bulletX, y:bulletY, direction:newDirection, target:this.orders.to});
}
}
} else {
var moving = this.moveTo(this.orders.to);
}
break;
case "patrol":
var targets = this.findTargetsInSight(1);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
return;
}
if ((Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.radius/game.gridSize,2)) {
var to = this.orders.to;
this.orders.to = this.orders.from;
this.orders.from = to;
} else {
this.moveTo(this.orders.to);
}
break;
case "guard":
if(this.orders.to.lifeCode == "dead"){
if (this.orders.nextOrder){
this.orders = this.orders.nextOrder;
} else {
this.orders = {type:"float"};
}
return;
}
if ((Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.sight-2,2)) {
var targets = this.findTargetsInSight(1);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
return;
}
} else {
this.moveTo(this.orders.to);
}
break;
}
},
我们首先分配 isValidTarget()和 findTargetInSight()方法。然后,我们定义 processOrders()方法中的所有状态。
在 processOrders()方法中,我们减少了 reloadTimeLeft 属性的值,就像我们对炮塔所做的那样。然后,我们为每个订单状态定义案例。
如果订单类型是 float,我们使用 findTargetsInSight()来检查目标是否在附近,如果是,就攻击它。当订单类型是 sentry 时,我们做同样的事情,除了我们传递一个范围增量参数 2,以便飞机攻击稍微超出其典型范围的单位。
除了范围增量参数为 100 之外,寻线情况非常相似,这应该理想地覆盖整个地图。这意味着飞机将攻击地图上的任何敌人单位或车辆,从最近的开始。
对于攻击情况,我们首先检查目标是否还活着。如果没有,我们要么将 orders 设置为 orders.nextOrder(如果它已定义),要么返回浮点模式。
接下来我们检查目标是否在射程内,如果不在,我们就向目标靠近。接下来,我们确保飞机指向目标。最后,我们等到 reloadTimeLeft 变量为 0,然后向目标射出一颗子弹。
巡逻案件是移动和岗哨案件的结合。我们将飞机移动到 to 属性中定义的位置,一旦飞机到达该位置,就掉头向 from 位置移动。如果目标进入射程,我们将下一个攻击顺序设置为当前顺序。这样,如果飞机在巡逻时看到敌人,它会先攻击敌人,然后在敌人被消灭后再回去巡逻。
最后,在守卫模式下,我们将飞机移动到它所守卫的单位的视线范围内,并攻击任何靠近的敌人。
如果你运行我们到目前为止的代码,你应该可以看到不同的飞机互相攻击,如图图 9-2 所示。
图 9-2。互相攻击的飞机
选择飞机后右击鼠标可以命令飞机攻击敌人或守卫朋友。直升机可以攻击地面和空中单位,而幽灵只能攻击空中单位。
我们通常会使用哨兵,猎人和巡逻的命令来给电脑人工智能一点优势,并使游戏对玩家更具挑战性。玩家将无法访问这些订单。
接下来,我们将实现基于战斗的车辆订单。
基于战斗的车辆订单
车辆的基于战斗的命令状态将非常类似于飞机的命令状态。
- 攻击:在目标范围内移动并射击它。
- 站立:呆在一个地方,攻击任何靠近的敌人。
- 守卫:跟随一个友军单位,向任何靠近的敌人射击。
- 狩猎:积极寻找地图上任何地方的敌人并攻击他们。
- 巡逻:在两点之间移动,向任何进入射程的敌人射击。
- 哨兵:呆在一个地方,比站着的时候攻击敌人更有侵略性。
我们将通过修改 vehicles 对象中默认的 processOrders()方法来实现这些状态,如清单 9-12 所示。
清单 9-12。 执行战斗命令的车辆(vehicles.js)
isValidTarget:isValidTarget,
findTargetsInSight:findTargetsInSight,
processOrders:function(){
this.lastMovementX = 0;
this.lastMovementY = 0;
if(this.reloadTimeLeft){
this.reloadTimeLeft--;
}
var target;
switch (this.orders.type){
case "move":
// Move toward destination until distance from destination is less than vehicle radius
var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
//Stop when within one radius of the destination
this.orders = {type:"stand"};
return;
} else if (distanceFromDestinationSquared <Math.pow(this.radius*3/game.gridSize,2)) {
//Stop when within 3 radius of the destination if colliding with something
this.orders = {type:"stand"};
return;
} else {
if (this.colliding && (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.radius*5/game.gridSize,2)) {
// Count collsions within 5 radius distance of goal
if (!this.orders.collisionCount){
this.orders.collisionCount = 1
} else {
this.orders.collisionCount ++;
}
// Stop if more than 30 collisions occur
if (this.orders.collisionCount > 30) {
this.orders = {type:"stand"};
return;
}
}
var moving = this.moveTo(this.orders.to);
// Pathfinding couldn't find a path so stop
if(!moving){
this.orders = {type:"stand"};
return;
}
}
break;
case "deploy":
// If oilfield has been used already, then cancel order
if(this.orders.to.lifeCode == "dead"){
this.orders = {type:"stand"};
return;
}
// Move to middle of oil field
var target = {x:this.orders.to.x+1,y:this.orders.to.y+0.5,type:"terrain"};
var distanceFromTargetSquared = (Math.pow(target.x-this.x,2) + Math.pow(target.y-this.y,2));
if (distanceFromTargetSquared<Math.pow(this.radius*2/game.gridSize,2)) {
// After reaching oil field, turn harvester to point toward left (direction 6)
var difference = angleDiff(this.direction,6,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
} else {
// Once it is pointing to the left, remove the harvester and oil field and deploy a harvester building
game.remove(this.orders.to);
this.orders.to.lifeCode="dead";
game.remove(this);
this.lifeCode="dead";
game.add({type:"buildings", name:"harvester", x:this.orders.to.x, y:this.orders.to.y, action:"deploy", team:this.team});
}
} else {
var moving = this.moveTo(target);
// Pathfinding couldn't find a path so stop
if(!moving){
this.orders = {type:"stand"};
}
}
break;
case "stand":
var targets = this.findTargetsInSight();
if(targets.length>0){
this.orders = {type:"attack",to:targets[0]};
}
break;
case "sentry":
var targets = this.findTargetsInSight(2);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
}
break;
case "hunt":
var targets = this.findTargetsInSight(100);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
}
break;
case "attack":
if(this.orders.to.lifeCode == "dead" || !this.isValidTarget(this.orders.to)){
if (this.orders.nextOrder){
this.orders = this.orders.nextOrder;
} else {
this.orders = {type:"stand"};
}
return;
}
if ((Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.sight,2)) {
//Turn toward target and then start attacking when within range of the target
var newDirection = findFiringAngle(this.orders.to,this,this.directions);
var difference = angleDiff(this.direction,newDirection,this.directions);
var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
if (Math.abs(difference)>turnAmount){
this.direction = wrapDirection(this.direction + turnAmount*Math.abs(difference)/difference, this.directions);
return;
} else {
this.direction = newDirection;
if(!this.reloadTimeLeft){
this.reloadTimeLeft = bullets.list[this.weaponType].reloadTime;
var angleRadians = −(Math.round(this.direction)/this.directions)*2*Math.PI ;
var bulletX = this.x- (this.radius*Math.sin(angleRadians)/game.gridSize);
var bulletY = this.y- (this.radius*Math.cos(angleRadians)/game.gridSize);
var bullet = game.add({name:this.weaponType,type:"bullets",x:bulletX,y:bulletY, direction:newDirection, target:this.orders.to});
}
}
} else {
var moving = this.moveTo(this.orders.to);
// Pathfinding couldn't find a path so stop
if(!moving){
this.orders = {type:"stand"};
return;
}
}
break;
case "patrol":
var targets = this.findTargetsInSight(1);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
return;
}
if ((Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.radius*4/game.gridSize,2)) {
var to = this.orders.to;
this.orders.to = this.orders.from;
this.orders.from = to;
} else {
this.moveTo(this.orders.to);
}
break;
case "guard":
if(this.orders.to.lifeCode == "dead"){
if (this.orders.nextOrder){
this.orders = this.orders.nextOrder;
} else {
this.orders = {type:"stand"};
}
return;
}
if ((Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.sight-2,2)) {
var targets = this.findTargetsInSight(1);
if(targets.length>0){
this.orders = {type:"attack",to:targets[0],nextOrder:this.orders};
return;
}
} else {
this.moveTo(this.orders.to);
}
break;
}
},
状态的实现几乎与飞机相同。如果我们现在运行代码,我们应该能够攻击车辆,如图 9-3 所示。
图 9-3。用车辆攻击
我们现在可以用车辆、飞机或炮塔攻击。
你可能已经注意到,当你靠近时,对方的单位会攻击,但他们仍然很容易被击败。现在战斗系统已经就位,我们将探索如何让敌人变得更聪明,让游戏更有挑战性。
构建智能敌人
建造智能敌人 AI 的主要目标是确保玩游戏的人发现它相当具有挑战性,并且有完成关卡的乐趣。关于 RTS 游戏,尤其是单人战役,需要意识到的一个重要的事情是,敌人 AI 不需要是特级大师级别的棋手。事实是,我们可以只使用战斗命令状态和条件脚本事件的组合来为玩家提供非常引人注目的体验。
通常,人工智能的“智能”行为方式会随着每个等级的不同而不同。
在一个简单的关卡中,没有生产设施,只有地面单位,唯一可能的行为就是开到敌人单位前并攻击他们。巡逻和哨兵命令的结合通常足以达到这个目的。我们也可以通过在特定时间或特定事件发生时(例如,当玩家到达特定地点或建造特定建筑时)攻击玩家来增加关卡的趣味性。
在一个更复杂的层面上,我们可以通过使用定时触发和狩猎命令,在特定的时间间隔内制造和派出一波又一波的敌人来挑战敌人。
我们可以通过在地图上添加更多的项目和触发器来看到这些想法在起作用,如清单 9-13 中的所示。
清单 9-13。 增加触发器和物品使关卡更具挑战性(maps.js)
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
{"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
{"type":"vehicles","name":"harvester","x":16,"y":12,"team":"blue","direction":3},
{"type":"terrain","name":"oilfield","x":3,"y":5,"action":"hint"},
{"type":"terrain","name":"bigrocks","x":19,"y":6},
{"type":"terrain","name":"smallrocks","x":8,"y":3},
{"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue","direction":4},
{"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue","direction":5},
{"type":"aircraft","name":"chopper","x":20,"y":12,"team":"blue","direction":2},
{"type":"aircraft","name":"wraith","x":23,"y":12,"team":"blue","direction":3},
{"type":"buildings","name":"ground-turret","x":15,"y":23,"team":"green"},
{"type":"buildings","name":"ground-turret","x":20,"y":23,"team":"green"},
{"type":"vehicles","name":"scout-tank","x":16,"y":26,"team":"green","direction":4,"orders":{"type":"sentry"}},
{"type":"vehicles","name":"heavy-tank","x":18,"y":26,"team":"green","direction":6,"orders":{"type":"sentry"}},
{"type":"aircraft","name":"chopper","x":20,"y":27,"team":"green","direction":2,"orders":{"type":"hunt"}},
{"type":"aircraft","name":"wraith","x":22,"y":28,"team":"green","direction":3,"orders":{"type":"hunt"}},
{"type":"buildings","name":"base","x":19,"y":28,"team":"green"},
{"type":"buildings","name":"starport","x":15,"y":28,"team":"green","uid":-1},
],
/* Economy Related*/
"cash":{
"blue":5000,
"green":5000
},
/* Conditional and Timed Trigger Events */
"triggers":[
/* Timed Events*/
{"type":"timed","time":1000,
"action":function(){
game.sendCommand([−1],{type:"construct-unit",details:{type:"aircraft",name:"wraith",orders:
{"type":"patrol","from":{"x":22,"y":30},"to":{"x":15,"y":21}}}});
}
},
{"type":"timed","time":5000,
"action":function(){
game.sendCommand([−1],{type:"construct-unit", details:{type:"aircraft",name:"chopper",
orders:{"type":"patrol","from":{"x":15,"y":30},"to":{"x":22,"y":21}}}});
}
},
{"type":"timed","time":10000,
"action":function(){
game.sendCommand([−1],{type:"construct-unit",details:{type:"vehicles",name:"heavy-tank",
orders:{"type":"patrol","from":{"x":15,"y":30},"to":{"x":22,"y":21}}}});
}
},
{"type":"timed","time":15000,
"action":function(){
game.sendCommand([−1],{type:"construct-unit",details:{type:"vehicles",name:"scout-tank",
orders:{"type":"patrol","from":{"x":22,"y":30},"to":{"x":15,"y":21}}}});
}
},
{"type":"timed","time":60000,
"action":function(){
game.showMessage("AI","Now every enemy unit is going to attack you in a wave.");
var units = [];
for (var i=0; i < game.items.length; i++) {
var item = game.items[i];
if (item.team == "green" && (item.type == "vehicles"|| item.type == "aircraft")){
units.push(item.uid);
}
};
game.sendCommand(units,{type:"hunt"});
}
},
],
我们做的第一件事是在游戏开始时命令敌人的直升机和幽灵去打猎。接下来,我们分配一个 UID 1 给敌人的星港,并设置一些定时触发器来每隔几秒建造不同类型的巡逻单位。
最后,60 秒后,我们命令所有敌方单位进行狩猎,并使用 showMessage()方法通知玩家。
如果我们现在运行代码,我们可以预期人工智能会很好地保护自己,并在 60 秒结束时非常积极地攻击,如图 9-4 所示。
图 9-4。电脑 AI 大举进攻玩家
显然,这是一个相当做作的例子。没有人会想玩一个游戏,在玩的第一分钟就被如此残忍地攻击。然而,正如这个例子所说明的,我们可以通过调整这些触发器和顺序来使游戏变得简单或具有挑战性。
提示你可以根据难度设置实现不同的触发器和起始物品,这样玩家就可以根据所选的设置玩同一战役的简单版本或挑战版本。
现在我们已经实现了战斗系统,并探索了使游戏 AI 具有挑战性的方法,我们在这一章最后要看的是加入战争迷雾。
增加了战争的迷雾
战争之雾通常是一层黑色的裹尸布,覆盖了地图上所有未被探索的区域。当玩家单位在地图上移动时,单位可以看到的任何地方的雾都会被清除。
这为游戏引入了探索和阴谋的元素。隐藏在雾中的能力允许使用诸如隐藏基地、伏击和偷袭等策略。
有些 RTS 游戏会在探索某个区域后永久清除雾气,而其他游戏则只会清除玩家单位视线范围内的雾气,并在玩家单位离开该区域后将雾气带回来。对于我们的游戏,我们将使用第二个实现。
定义雾对象
我们将从在 fog.js 中定义一个新的 fog 对象开始,如清单 9-14 所示。
清单 9-14。 实现雾对象(fog.js)
var fog = {
grid:[],
canvas:document.createElement('canvas'),
initLevel:function(){
// Set fog canvas to the size of the map
this.canvas.width = game.currentLevel.mapGridWidth*game.gridSize;
this.canvas.height = game.currentLevel.mapGridHeight*game.gridSize;
this.context = this.canvas.getContext('2d');
// Set the fog grid for the player to array with all values set to 1
this.defaultFogGrid = [];
for (var i=0; i < game.currentLevel.mapGridHeight; i++) {
this.defaultFogGrid[i] = [];
for (var j=0; j < game.currentLevel.mapGridWidth; j++) {
this.defaultFogGrid[i][j] = 1;
};
};
},
isPointOverFog:function(x,y){
// If the point is outside the map bounds consider it fogged
if(y<0 || y/game.gridSize >= game.currentLevel.mapGridHeight || x<0 || x/game.gridSize >= game.currentLevel.mapGridWidth ){
return true;
}
// If not, return value based on the player's fog grid
return this.grid[game.team][Math.floor(y/game.gridSize)][Math.floor(x/game.gridSize)] == 1;
},
animate:function(){
// Fill fog with semi solid black color over the map
this.context.drawImage(game.currentMapImage,0,0)
this.context.fillStyle = 'rgba(0,0,0,0.8)';
this.context.fillRect(0,0,this.canvas.width,this.canvas.height);
// Initialize the players fog grid
this.grid[game.team] = $.extend(true,[],this.defaultFogGrid);
// Clear all areas of the fog where a player item has vision
fog.context.globalCompositeOperation = "destination-out";
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
var team = game.team;
if (item.team == team && !item.keepFogged){
var x = Math.floor(item.x );
var y = Math.floor(item.y );
var x0 = Math.max(0,x-item.sight+1);
var y0 = Math.max(0,y-item.sight+1);
var x1 = Math.min(game.currentLevel.mapGridWidth-1, x+item.sight-1+ (item.type=="buildings"?item.baseWidth/game.gridSize:0));
var y1 = Math.min(game.currentLevel.mapGridHeight-1, y+item.sight-1+ (item.type=="buildings"?item.baseHeight/game.gridSize:0));
for (var j=x0; j <= x1; j++) {
for (var k=y0; k <= y1; k++) {
if ((j>x0 && j<x1) || (k>y0 && k<y1)){
if(this.grid[team][k][j]){
this.context.fillStyle = 'rgba(100,0,0,0.9)';
this.context.beginPath();
this.context.arc(j*game.gridSize+12, k*game.gridSize+12, 16, 0, 2*Math.PI, false);
this.context.fill();
this.context.fillStyle = 'rgba(100,0,0,0.7)';
this.context.beginPath();
this.context.arc(j*game.gridSize+12, k*game.gridSize+12,18, 0, 2*Math.PI, false);
this.context.fill();
this.context.fillStyle = 'rgba(100,0,0,0.5)';
this.context.beginPath();
this.context.arc(j*game.gridSize+12, k*game.gridSize+12, 24, 0, 2*Math.PI, false);
this.context.fill();
}
this.grid[team][k][j] = 0;
}
};
};
}
};
fog.context.globalCompositeOperation = "source-over";
},
draw:function(){
game.foregroundContext.drawImage(this.canvas,game.offsetX, game.offsetY, game.canvasWidth, game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
}
}
我们首先在雾对象中定义一个画布。initLevel()方法将 canvas 对象的大小调整为当前地图的大小,并定义一个 fogGrid 数组,该数组与所有元素都设置为 1 的地图具有相同的维度。
在 animate()方法中,我们首先用半透明的黑色图层将雾初始化为地图背景。这样,地图上模糊的区域会显示为变暗的背景地形。
然后,我们遍历游戏中的每个物品,并根据玩家物品的视线属性清除其周围的雾数组和雾画布。我们不会为敌对玩家的物品或 keepFogged 属性设置为 true 的物品清除迷雾。
最后,draw()方法使用我们在 game.backgroundContext 上下文上绘制地图时使用的相同偏移量,在 game.foregroundContext 上下文上绘制雾画布。
拨开迷雾
现在我们已经定义了 fog 对象,我们将开始在 index.html 的 head 部分添加一个对 fog.js 的引用,如清单 9-15 所示。
清单 9-15。 【添加引用到雾对象(index.html)
<script src="js/fog.js" type="text/javascript" charset="utf-8"></script>
接下来,我们需要在关卡加载后初始化雾。我们将通过调用 singleplayer 对象的 play()方法中的 fog.initLevel()方法来实现这一点,如清单 9-16 所示。
清单 9-16。 为关卡初始化雾对象(singleplayer.js)
play:function(){
fog.initLevel();
game.animationLoop();
game.animationInterval = setInterval(game.animationLoop,game.animationTimeout);
game.start();
},
接下来我们需要修改游戏对象的 animationLoop()和 drawingLoop()方法,分别调用 fog.animate()和 fog.draw(),如清单 9-17 所示。
清单 9-17。 调用 fog.animate()和 fog.draw() (game.js)
animationLoop:function(){
// Animate the Sidebar
sidebar.animate();
// Process orders for any item that handles it
for (var i = game.items.length - 1; i >= 0; i--){
if(game.items[i].processOrders){
game.items[i].processOrders();
}
};
// Animate each of the elements within the game
for (var i = game.items.length - 1; i >= 0; i--){
game.items[i].animate();
};
// Sort game items into a sortedItems array based on their x,y coordinates
game.sortedItems = $.extend([],game.items);
game.sortedItems.sort(function(a,b){
return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
});
fog.animate();
game.lastAnimationTime = (new Date()).getTime();
},
drawingLoop:function(){
// Handle Panning the Map
game.handlePanning();
// Check the time since the game was animated and calculate a linear interpolation factor (−1 to 0)
// since drawing will happen more often than animation
game.lastDrawTime = (new Date()).getTime();
if (game.lastAnimationTime){
game.drawingInterpolationFactor = (game.lastDrawTime-game.lastAnimationTime)/game.animationTimeout - 1;
if (game.drawingInterpolationFactor>0){ // No point interpolating beyond the next animation loop...
game.drawingInterpolationFactor = 0;
}
} else {
game.drawingInterpolationFactor = −1;
}
// Since drawing the background map is a fairly large operation,
// we only redraw the background if it changes (due to panning)
if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage,game.offsetX, game.offsetY, game.canvasWidth, game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
game.refreshBackground = false;
}
// Clear the foreground canvas
game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);
// Start drawing the foreground elements
for (var i = game.sortedItems.length - 1; i >= 0; i--){
if (game.sortedItems[i].type != "bullets"){
game.sortedItems[i].draw();
}
};
// Draw the bullets on top of all the other elements
for (var i = game.bullets.length - 1; i >= 0; i--){
game.bullets[i].draw();
};
fog.draw();
// Draw the mouse
mouse.draw()
// Call the drawing loop for the next frame using request animation frame
if (game.running){
requestAnimationFrame(game.drawingLoop);
}
},
如果我们现在运行代码,我们应该会看到整个地图笼罩在战争的迷雾中,如图图 9-5 所示。
图 9-5。笼罩在战争迷雾中的地图
你会看到友方单位和建筑周围的雾气被揭开了。此外,雾区显示原始地形,但不显示其下的任何单位。
当我们不知道对方军队的规模和位置时,同样的敌人攻击会令人感到更加恐怖。在我们结束这一章之前,我们将做一些补充,从使模糊的区域不可建造开始。
使得有雾的区域不可建造
我们要做的第一个改变是通过使雾区不可建造来防止在雾区部署建筑。我们将修改侧边栏对象的 animate()方法,如清单 9-18 所示。
清单 9-18。 使模糊区域不可构建(sidebar.js)
animate:function(){
// Display the current cash balance value
$('#cash').html(game.cash[game.team]);
// Enable or disable buttons as appropriate
this.enableSidebarButtons();
if (game.deployBuilding){
// Create the buildable grid to see where building can be placed
game.rebuildBuildableGrid();
// Compare with buildable grid to see where we need to place the building
var placementGrid = buildings.list[game.deployBuilding].buildableGrid;
game.placementGrid = $.extend(true,[],placementGrid);
game.canDeployBuilding = true;
for (var i = game.placementGrid.length - 1; i >= 0; i--){
for (var j = game.placementGrid[i].length - 1; j >= 0; j--){
if(game.placementGrid[i][j] &&
(mouse.gridY+i>= game.currentLevel.mapGridHeight || mouse.gridX+j>= game.currentLevel.mapGridWidth
|| game.currentMapBuildableGrid[mouse.gridY+i][mouse.gridX+j]==1 || fog.grid[game.team][mouse.gridY+i][mouse.gridX+j]==1 )){
game.canDeployBuilding = false;
game.placementGrid[i][j] = 0;
}
};
};
}
},
在创建 placementGrid 数组时,我们添加了一个额外的条件来测试雾化网格,这样就不能再构建雾化的网格方块了。如果我们运行游戏并试图在一个有雾的区域建造,我们应该会看到一个警告,如图 9-6 所示。
图 9-6。不能在有雾的地区部署建筑
正如你所看到的,建筑部署网格在有雾的区域变成红色,表示玩家不能在那里建筑。如果你仍然试图点击一个模糊的区域,你会得到一个系统警告。
接下来,我们将确保玩家不能选择或探测到在雾中的建筑或单位。我们通过修改鼠标对象的 pointUnderFog()方法来做到这一点,如清单 9-19 所示。
清单 9-19。 【雾下藏物】(mouse.js)
itemUnderMouse:function(){
if(fog.isPointOverFog(mouse.gameX,mouse.gameY)){
return;
}
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if (item.type=="buildings" || item.type=="terrain"){
if(item.lifeCode != "dead"
&& item.x<= (mouse.gameX)/game.gridSize
&& item.x >= (mouse.gameX - item.baseWidth)/game.gridSize
&& item.y<= mouse.gameY/game.gridSize
&& item.y >= (mouse.gameY - item.baseHeight)/game.gridSize
){
return item;
}
} else if (item.type=="aircraft"){
if (item.lifeCode != "dead" &&
Math.pow(item.x-mouse.gameX/game.gridSize,2)+Math.pow(item.y-(mouse.gameY+item.pixelShadowHeight)/game.gridSize,2) < Math.pow((item.radius)/game.gridSize,2)){
return item;
}
}else {
if (item.lifeCode != "dead" && Math.pow(item.x-mouse.gameX/game.gridSize,2) + Math.pow(item.y-mouse.gameY/game.gridSize,2) < Math.pow((item.radius)/game.gridSize,2)){
return item;
}
}
}
},
我们检查鼠标下的点是否模糊,如果是,不返回任何东西。随着这最后的改变,我们现在在游戏中有一个工作的战争迷雾。
摘要
在这一章中,我们为我们的游戏实现了一个战斗系统。我们从用不同类型的项目符号定义一个项目符号对象开始。然后,我们在炮塔、飞机和车辆上添加了几个基于战斗的订单状态。我们使用这些命令和上一章定义的触发系统来创造一个相当有挑战性的敌人。最后,我们实现了战争迷雾。
我们的游戏现在拥有了 RTS 的大部分基本元素。在下一章,我们将通过添加声音来完善我们的游戏框架。然后,我们将使用这个框架来构建一些有趣的关卡,并结束我们的单人战役。
十、完成单人战役
我们的游戏框架现在几乎拥有了构建一个非常好的单人战役所需的一切:关卡系统,各种单位和建筑,使用寻路的智能移动,经济,最后是战斗。
现在是时候添加最后的润色,结束我们的单人战役了。我们将首先在游戏中加入爆炸和声音等音效。然后,我们将通过组合和使用我们在过去几章中开发的各种元素来构建几个级别。你会看到这些积木是如何组合成一个完整的游戏的。
我们开始吧。我们将从第九章结束时我们停止的地方继续。
添加声音
RTS 游戏比其他类型的游戏有更多的事情同时发生,比如我们在前几章开发的物理游戏。如果我们不小心,有可能用如此多的音频输入淹没玩家,以至于它成为一种干扰,并从他们的沉浸感中带走。对于我们的游戏来说,我们将专注于让玩家意识到游戏中重要事件的声音。
- 确认命令:任何时候玩家选择一个单位并给它一个命令,我们都会让这个单位确认它收到了命令。
- 消息:每当玩家收到系统警告或故事线驱动的通知时,我们会用声音提醒玩家。
- 战斗:我们将在战斗中添加声音,这样玩家就能立即知道他们在地图上的某个地方受到了攻击。
设置声音
我们将从在 sounds.js 中创建一个 sounds 对象开始,如清单 10-1 所示。
清单 10-1。 创建一个声音对象(sounds.js)
var sounds = {
list:{
"bullet":["bullet1","bullet2"],
"heatseeker":["heatseeker1","heatseeker2"],
"fireball":["laser1","laser2"],
"cannon-ball":["cannon1","cannon2"],
"message-received":["message"],
"acknowledge-attacking":["engaging"],
"acknowledge-moving":["yup","roger1","roger2"],
},
loaded:{},
init: function(){
for(var soundName in this.list){
var sound = {};
sound.audioObjects = [];
for (var i=0; i < this.list[soundName].length; i++) {
sound.audioObjects.push(loader.loadSound('audio/' + this.list[soundName][i]));
};
this.loaded [soundName] = sound;
}
},
play:function(soundName){
var sound = sounds.loaded[soundName];
if(sound && sound.audioObjects && sound.audioObjects.length>0){
if(!sound.counter || sound.counter>= sound.audioObjects.length){
sound.counter = 0;
}
var audioObject = sound.audioObjects[sound.counter];
sound.counter++;
audioObject.play();
}
}
};
在 sound 对象中,我们首先声明一个列表,它将一个声音名称映射到一个或多个声音文件。例如,项目符号声音映射到两个文件:项目符号 1 和项目符号 2。您会注意到我们没有指定文件扩展名(。ogg 或. mp3)。我们让 loader 对象为浏览器选择合适的音频文件扩展名。
接下来,我们声明一个 init()方法,它遍历声音列表,使用 loader.loadSound()方法加载每个音频文件,然后为每个声音名称创建一个 audioObjects 数组。然后,我们将这个声音对象添加到加载的对象中。
最后,我们声明一个 play()方法,它从加载的数组中查找合适的声音对象,然后使用 play()方法播放音频对象。您会注意到,我们为每个声音对象使用了一个计数器,以确保我们遍历给定声音名称的声音,以便每次调用 play()时播放不同的声音。这使得我们可以为一个事件播放不同版本的声音,而不是每次都听到相同的单调声音。
接下来,我们将在 index.html 的部分添加对 sounds.js 的引用,如清单 10-2 所示。
清单 10-2。 泛指 sounds . js(index.html)
<script src="js/sounds.js" type="text/javascript" charset="utf-8"></script>
最后,我们将在游戏初始化时加载所有这些声音,方法是从游戏对象的 init()方法内部调用 init()方法,如清单 10-3 所示。
清单 10-3。 初始化 game.init()方法(game.js)内部的 sounds 对象
init:function(){
loader.init();
mouse.init();
sidebar.init();
sounds.init();
$('.gamelayer').hide();
$('#gamestartscreen').show();
game.backgroundCanvas = document.getElementById('gamebackgroundcanvas');
game.backgroundContext = game.backgroundCanvas.getContext('2d');
game.foregroundCanvas = document.getElementById('gameforegroundcanvas');
game.foregroundContext = game.foregroundCanvas.getContext('2d');
game.canvasWidth = game.backgroundCanvas.width;
game.canvasHeight = game.backgroundCanvas.height;
},
现在 sounds 对象已经就绪,我们可以开始为每个事件添加声音,从确认命令开始。
确认命令
我们允许玩家给单位几种类型的命令:攻击,移动,部署和守卫。每当一个单位被发送攻击命令,我们将播放确认攻击的声音。当单位被发送任何其他命令,如移动或守卫,我们将播放确认移动的声音。
我们将通过从鼠标对象的 click()方法内部调用 sounds.play()来实现,如清单 10-4 所示。
清单 10-4。 确认 click()方法(mouse.js)内的命令
click:function(ev,rightClick){
// Player clicked inside the canvas
var clickedItem = this.itemUnderMouse();
var shiftPressed = ev.shiftKey;
if (!rightClick){ // Player left clicked
// If the game is in deployBuilding mode, left clicking will deploy the building
if (game.deployBuilding){
if(game.canDeployBuilding){
sidebar.finishDeployingBuilding();
} else {
game.showMessage("system","Warning! Cannot deploy building here.");
}
return;
}
if (clickedItem){
// Pressing shift adds to existing selection. If shift is not pressed, clear existing selection
if(!shiftPressed){
game.clearSelection();
}
game.selectItem(clickedItem,shiftPressed);
}
} else { // Player right clicked
// If the game is in deployBuilding mode, right clicking will cancel deployBuilding mode
if (game.deployBuilding){
sidebar.cancelDeployingBuilding();
return;
}
// Handle actions like attacking and movement of selected units
var uids = [];
if (clickedItem){ // Player right clicked on something
if (clickedItem.type != "terrain"){
if (clickedItem.team != game.team){ // Player right clicked on an enemy item
// identify selected items from players team that can attack
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && item.canAttack){
uids.push(item.uid);
}
};
// then command them to attack the clicked item
if (uids.length>0){
game.sendCommand(uids,{type:"attack", toUid:clickedItem.uid});
sounds.play("acknowledge-attacking");
}
} else { // Player right clicked on a friendly item
//identify selected items from players team that can move
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
uids.push(item.uid);
}
};
// then command them to guard the clicked item
if (uids.length>0){
game.sendCommand(uids,{type:"guard", toUid:clickedItem.uid});
sounds.play("acknowledge-moving");
}
}
} else if (clickedItem.name == "oilfield"){ // Player right clicked on an oilfield
// identify the first selected harvester from players team (since only one can deploy at a time)
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
// pick the first selected harvester since only one can deploy at a time
if(item.team == game.team && (item.type == "vehicles" && item.name == "harvester")){
uids.push(item.uid);
break;
}
};
// then command it to deploy on the oilfield
if (uids.length>0){
game.sendCommand(uids,{type:"deploy", toUid:clickedItem.uid});
sounds.play("acknowledge-moving");
}
}
} else { // Player right clicked on the ground
//identify selected items from players team that can move
for (var i = game.selectedItems.length - 1; i >= 0; i--){
var item = game.selectedItems[i];
if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
uids.push(item.uid);
}
};
// then command them to move to the clicked location
if (uids.length>0){
game.sendCommand(uids,{type:"move", to:{x:mouse.gameX/game.gridSize, y:mouse.gameY/game.gridSize}});
sounds.play("acknowledge-moving");
}
}
}
},
每当我们发送一个游戏命令时,我们用适当的声音名称调用 sounds.play()方法。
需要指出的一个有趣的事情是,我们是在命令发出的时候播放声音,而不是在命令被接收和处理的时候。虽然这在单人战役中影响很小,但在多人游戏中却很重要。
通常,网络延迟和其他问题会导致在发送命令和所有玩家实际收到命令之间有长达几百毫秒的延迟。通过在点击鼠标时立即播放声音,我们给玩家一种命令已经被立即执行的错觉,并使滞后的影响不那么明显。
注意一些游戏除了声音之外还使用动画序列来向玩家表明该单元正在处理命令。像第一人称射击游戏这样的游戏经常试图预测单位的移动,并在收到服务器确认之前开始移动单位。
如果你现在打开并运行游戏,你应该会听到部队在开始移动或攻击前确认命令。接下来,让我们添加消息声音。
信息
每当玩家看到一条消息时,我们会播放一个简短的哔哔声来通知他们。我们将从游戏对象的 showMessage()方法中播放消息接收的声音,如清单 10-5 所示。
清单 10-5。 消息通知声音里面的 showMessage()方法(game.js)
showMessage:function(from,message){
sounds.play('message-received');
var character = game.characters[from];
if (character){
from = character.name;
if (character.image){
$('#callerpicture').html('<img src="'+character.image+'"/>');
// hide the profile picture after six seconds
setTimeout(function(){
$('#callerpicture').html("");
},6000)
}
}
// Append message to messages pane and scroll to the bottom
var existingMessage = $('#gamemessages').html();
var newMessage = existingMessage+'<span>'+from+': </span>'+message+'<br>';
$('#gamemessages').html(newMessage);
$('#gamemessages').animate( {scrollTop:$('#gamemessages').prop('scrollHeight')});
},
如果你现在玩游戏,每当显示新消息时,你应该会听到哔哔声。
我们将实现的最后一组声音是用于战斗的。
战斗
您可能已经注意到,我们在声音列表中声明了四种不同的声音类型:子弹、热探测器、炮弹和火球。这四种声音对应于我们在前一章中声明的四种子弹类型。每当我们发射子弹时,我们都会播放相应子弹的声音。
我们可以通过修改 game.js 中的 add()方法很容易地做到这一点,每当添加一个项目符号时就播放适当的声音,如清单 10-6 所示。
清单 10-6。 加子弹时播放声音(game.js)
add:function(itemDetails) {
// Set a unique id for the item
if (!itemDetails.uid){
itemDetails.uid = game.counter++;
}
var item = window[itemDetails.type].add(itemDetails);
// Add the item to the items array
game.items.push(item);
// Add the item to the type specific array
game[item.type].push(item);
if(item.type == "buildings" || item.type == "terrain"){
game.currentMapPassableGrid = undefined;
}
if (item.type == "bullets"){
sounds.play(item.name);
}
return item;
},
如果你现在玩这个游戏,你应该会听到不同武器发射时的不同声音。
如果我们愿意,我们可以不断地给游戏添加更多的声音,比如爆炸、建筑噪音、对话,甚至背景音乐。该过程将保持不变。然而,我们目前实现的声音已经足够了。
现在我们的游戏已经有声音了,是时候开始为我们的单人战役建立实际的关卡了。
构建单人战役
我们将在游戏战役中建立三个关卡。每一关都将越来越难,同时建立在前几关的基础上。这些关卡将展示你在 RTS 游戏中能找到的典型关卡类型。
救援队
在我们的游戏中,入门将是一个相对简单的任务,这样玩家就可以轻松地在地图上移动和攻击敌人的单位。
玩家需要在一张布满容易被击败的敌人的地图上导航,然后护送一队运输车辆回到玩家的出发地点。在任务简报之后,我们将使用由定时和条件触发器触发的角色对话来推进故事情节。
我们将从 maps.js 中一个全新的地图对象开始,如清单 10-7 所示。
清单 10-7。 创建第一关(maps.js)
var maps = {
"singleplayer":[
{
"name":"Rescue",
"briefing": "In the months since the great war, mankind has fallen into chaos. Billions are dead with cities in ruins.\nSmall groups of survivors band together to try and survive as best as they can.\nWe are trying to reach out to all the survivors in this sector before we join back with the main colony.",
/* Map Details */
"mapImage":"img/level-one.png",
"startX":36,
"startY":0,
/* Map coordinates that are obstructed by terrain*/
"mapGridWidth":60,
"mapGridHeight":40,
"mapObstructedTerrain":[
[49,8], [50,8], [51,8], [51,9], [52,9], [53,9], [53,10], [53,11], [53,12], [53,13],
[53,14], [53,15], [53,16], [52,16], [52,17], [52,18], [52,19], [51,19], [50,19], [50,18], [50,17],
[49,17], [49,18], [48,18], [47,18], [47,17], [47,16], [48,16], [49,16], [49,15], [49,14], [48,14],
[48,13], [48,12], [49,12], [49,11], [50,11], [50,10], [49,10], [49,9], [44,0], [45,0], [45,1],
[45,2], [46,2], [47,2], [47,3], [48,3], [48,4], [48,5], [49,5], [49,6], [49,7], [50,7], [51,7],
[51,6], [51,5], [51,4], [52,4], [53,4], [53,3], [54,3], [55,3], [55,2], [56,2], [56,1], [56,0],
[55,0], [43,19], [44,19], [45,19], [46,19], [47,19], [48,19], [48,20], [48,21], [47,21], [46,21],
[45,21], [44,21], [43,21], [43,20], [41,22], [42,22], [43,22], [44,22], [45,22], [46,22], [47,22],
[48,22], [49,22], [50,22], [50,23], [50,24], [49,24], [48,24], [47,24], [47,25], [47,26], [47,27],
[47,28], [47,29], [47,30], [46,30], [45,30], [44,30], [43,30], [43,29], [43,28], [43,27], [43,26],
[43,25], [43,24], [42,24], [41,24], [41,23], [48,39], [49,39], [50,39], [51,39], [52,39], [53,39],
[54,39], [55,39], [56,39], [57,39], [58,39], [59,39], [59,38], [59,37], [59,36], [59,35], [59,34],
[59,33], [59,32], [59,31], [59,30], [59,29], [0,0], [1,0], [2,0], [1,1], [2,1], [10,3], [11,3],
[12,3], [12,2], [13,2], [14,2], [14,3], [14,4], [15,4], [15,5], [15,6], [14,6], [13,6], [13,5],
[12,5], [11,5], [10,5], [10,4], [3,9], [4,9], [5,9], [5,10], [6,10], [7,10], [8,10], [9,10], [9,11],
[10,11], [11,11], [11,10], [12,10], [13,10], [13,11], [13,12], [12,12], [11,12], [10,12], [9,12],
[8,12], [7,12], [7,13], [7,14], [6,14], [5,14], [5,13], [5,12], [5,11], [4,11], [3,11], [3,10],
[33,33], [34,33], [35,33], [35,34], [35,35], [34,35], [33,35], [33,34], [27,39], [27,38], [27,37],
[28,37], [28,36], [28,35], [28,34], [28,33], [28,32], [28,31], [28,30], [28,29], [29,29], [29,28],
[29,27], [29,26], [29,25], [29,24], [29,23], [30,23], [31,23], [32,23], [32,22], [32,21], [31,21],
[30,21], [30,22], [29,22], [28,22], [27,22], [26,22], [26,21], [25,21], [24,21], [24,22], [24,23],
[25,23], [26,23], [26,24], [25,24], [25,25], [24,25], [24,26], [24,27], [25,27], [25,28], [25,29],
[24,29], [23,29], [23,30], [23,31], [24,31], [25,31], [25,32], [25,33], [24,33], [23,33], [23,34],
[23,35], [24,35], [24,36], [24,37], [23,37], [22,37], [22,38], [22,39], [23,39], [24,39], [25,39],
[26,0], [26,1], [25,1], [25,2], [25,3], [26,3], [27,3], [27,2], [28,2], [29,2], [29,3], [30,3],
[31,3], [31,2], [31,1], [32,1], [32,0], [33,0], [32,8], [33,8], [34,8], [34,9], [34,10], [33,10],
[32,10], [32,9], [8,29], [9,29], [9,30], [17,32], [18,32], [19,32], [19,33], [18,33], [17,33],
[18,34], [19,34], [3,27], [4,27], [4,26], [3,26], [2,26], [3,25], [4,25], [9,20], [10,20], [11,20],
[11,21], [10,21], [10,19], [19,7], [15,7], [29,12], [30,13], [20,14], [21,14], [34,13], [35,13],
[36,13], [36,14], [35,14], [34,14], [35,15], [36,15], [16,18], [17,18], [18,18], [16,19], [17,19],
[18,19], [17,20], [18,20], [11,19], [58,0], [59,0], [58,1], [59,1], [59,2], [58,3], [59,3], [58,4],
[59,4], [59,5], [58,6], [59,6], [58,7], [59,7], [59,8], [58,9], [59,9], [58,10], [59,10], [59,11],
[52,6], [53,6], [54,6], [52,7], [53,7], [54,7], [53,8], [54,8], [44,17], [46,32], [55,32], [54,28],
[26,34], [34,34], [4,10], [6,11], [6,12], [6,13], [7,11], [8,11], [12,11], [27,0], [27,1], [26,2],
[28,1], [28,0], [29,0], [29,1], [30,2], [30,1], [30,0], [31,0], [33,9], [46,0], [47,0], [48,0],
[49,0], [50,0], [51,0], [52,0], [53,0], [54,0], [55,1], [54,1], [53,1], [52,1], [51,1], [50,1],
[49,1], [48,1], [47,1], [46,1], [48,2], [49,2], [50,2], [51,2], [52,2], [53,2], [54,2], [52,3],
[51,3], [50,3], [49,3], [49,4], [50,4], [50,5], [50,6], [50,9], [51,10], [52,10], [51,11], [52,11],
[50,12], [51,12], [52,12], [49,13], [50,13], [51,13], [52,13], [50,14], [51,14], [52,14], [50,15],
[51,15], [52,15], [50,16], [51,16], [51,17], [48,17], [51,18], [44,20], [45,20], [46,20], [47,20],
[42,23], [43,23], [44,23], [45,23], [46,23], [47,23], [48,23], [49,23], [44,24], [45,24], [46,24],
[44,25], [45,25], [46,25], [44,26], [45,26], [46,26], [44,27], [45,27], [46,27], [44,28], [45,28],
[46,28], [44,29], [45,29], [46,29], [11,4], [12,4], [13,4], [13,3], [14,5], [25,22], [31,22],
[27,23], [28,23], [27,24], [28,24], [26,25], [27,25], [28,25], [25,26], [26,26], [27,26], [28,26],
[26,27], [27,27], [28,27], [26,28], [27,28], [28,28], [26,29], [27,29], [24,30], [25,30], [26,30],
[27,30], [26,31], [27,31], [26,32], [27,32], [26,33], [27,33], [24,34], [25,34], [27,34], [25,35],
[26,35], [27,35], [25,36], [26,36], [27,36], [25,37], [26,37], [23,38], [24,38], [25,38], [26,38],
[26,39], [2,25], [9,19], [36,31]
],
/* Entities to be loaded */
"requirements":{
"buildings":["base"],
"vehicles":["transport","scout-tank","heavy-tank"],
"aircraft":[],
"terrain":[]
},
/* Economy Related*/
"cash":{
"blue":0,
"green":0
},
/* Entities to be added */
"items":[
/* Slightly damaged base */
{"type":"buildings","name":"base","x":55,"y":6,"team":"blue","life":100},
{"type":"vehicles","name":"heavy-tank","x":57,"y":12,"direction":4,"team":"blue","uid":-1},
/* Two transport vehicles waiting just outside the visible map */
{"type":"vehicles","name":"transport","x":-3,"y":2,"direction":2,"team":"blue","uid":-3,"selectable":false},
{"type":"vehicles","name":"transport","x":-3,"y":4,"direction":2,"team":"blue","uid":-4,"selectable":false},
/* Two damaged enemy scout-tanks patroling the area*/
{"type":"vehicles","name":"scout-tank","x":40,"y":20,"direction":4,"team":"green","uid":-2,"life":20,"orders":{"type":"patrol","from":{"x":34,"y":20},"to":{"x":42,"y":25}}},
{"type":"vehicles","name":"scout-tank","x":14,"y":0,"direction":4,"team":"green","uid":-5,"life":20,"orders":{"type":"patrol","from":{"x":14,"y":0},"to":{"x":14,"y":14}}},
],
/* Conditional and Timed Trigger Events */
"triggers":[
{"type":"timed","time":3000,
"action":function(){
game.showMessage("op", "Commander!! We haven't heard from the last convoy in over two hours. They should have arrived by now.");
}
},
{"type":"timed","time":10000,
"action":function(){
game.showMessage("op", "They were last seen in the North West Sector. Could you investigate?");
}
},
{"type":"conditional",
"condition":function(){
return(isItemDead(−1)||isItemDead(−3)||isItemDead(−4));
},
"action":function(){
singleplayer.endLevel(false);
}
},
],
}
]
}
该级别的第一部分由我们在早期级别中看到的相同的基本元数据组成。我们从任务简报开始,它给玩家一点地图背景。然后,我们将地图图像设置为地图的最终版本,而不是我们迄今为止用来构建游戏的调试版本。我们还将地图起始位置设置为地图的右上角。最后,我们设置地图大小和 mapObstructedTerrain 属性。
接下来,我们在 requirements 数组中加载一些基本项目,并将两个玩家的起始现金余额设置为 0。
在关卡的物品阵列中,我们添加了一个受损的基地,一辆玩家控制的重型坦克,两辆巡逻该区域的敌方侦查坦克,以及两辆运输船。我们为它们中的每一个设置了 uid,这样我们就可以从触发器中引用它们。
因为这是第一关,我们设定了侦察兵坦克的寿命,这样玩家会发现摧毁它们很容易。运输工具被放置在稍微超出地图左上角边界的地方,这样玩家在适当的时候才能看到它们。
在触发器数组中,我们定义了前几个触发器。前两个定时触发器向玩家显示来自运营商的消息,要求他们找到失踪的运输工具。第三个是一个条件触发器,如果运输工具或者重型坦克被 isItemDead()方法摧毁,这个触发器就会以失败结束任务。
接下来,我们将在游戏对象中的角色对象中添加一些新角色,如清单 10-8 所示。
清单 10-8。 添加新角色(game.js)
characters: {
"system":{
"name":"System",
"image":"img/system.png"
},
"op":{
"name":"Operator",
"image":"img/girl1.png"
},
"pilot":{
"name":"Pilot",
"image":"img/girl2.png"
},
"driver":{
"name":"Driver",
"image":"img/man1.png"
}
},
注意这些新的角色形象是知识共享——在
opengameart.org
找到的授权作品。
我们还将在 common.js 中定义 isItemDead()方法,如清单 10-9 所示。
清单 10-9。isItemDead()方法(common.js)
function isItemDead(uid){
var item = game.getItemByUid(uid);
return (!item || item.lifeCode == "dead");
}
如果在 game.items 数组中找不到某个物品,或者该物品的 lifeCode 属性被设置为 dead,则认为该物品已经死亡。
如果你运行游戏到现在,你应该会看到操作者通过让你调查情况的方式给你第一个任务任务,如图图 10-1 所示。
图 10-1。第一次任务任务
你应该能够选择坦克并移动它,随着你探索地图,战争的迷雾慢慢散去。
现在,我们将通过在第一张地图上添加更多的触发器来引入敌人和车队,如清单 10-10 所示。
清单 10-10。 介绍敌人和车队(maps.js)
{"type":"conditional",
"condition":function(){
// Check if first enemy is dead
return isItemDead(−2);
},
"action":function(){
game.showMessage("op", "The rebels have been getting very aggressive lately. I hope the convoy is safe. Find them and escort them back to the base.");
}
},
{"type":"conditional",
"condition":function(){
var hero = game.getItemByUid(−1);
return(hero && hero.x<30 && hero.y<30);
},
"action":function(){
game.showMessage("driver", "Can anyone hear us? Our convoy has been pinned down by rebel tanks. We need help.");
}
},
在第一个条件触发中,我们显示了一条来自操作员的消息,讨论一旦第一辆敌方侦查坦克被摧毁后的叛军。在第二个条件触发器中,当我们进入地图的左上角时,我们显示来自车队司机的消息。
如果我们现在运行游戏,我们应该会看到操作员在与叛军的第一次战斗后催促我们快点,以及当我们接近车队位置时车队司机呼救,如图图 10-2 所示。
图 10-2。车队司机求助
最后,我们将添加几个触发器来实现营救车队和完成任务,如清单 10-11 所示。
清单 10-11。 营救车队完成任务(maps.js)
{"type":"conditional",
"condition":function(){
var hero = game.getItemByUid(−1);
return(hero && hero.x<10 && hero.y<10);
},
"action":function(){
var hero = game.getItemByUid(−1);
game.showMessage("driver", "Thank you. We thought we would never get out of here alive.");
game.sendCommand([−3,-4],{type:"guard",to:hero});
}
},
{"type":"conditional",
"condition":function(){
var transport1 = game.getItemByUid(−3);
var transport2 = game.getItemByUid(−4);
return(transport1 && transport2 && transport1.x>52 && transport2.x>52 && transport2.y<18 && transport1.y<18);
},
"action":function(){
singleplayer.endLevel(true);
}
},
在第一个条件触发中,当英雄坦克到达地图的左上角时,我们显示了来自驾驶员的另一条消息。然后我们命令两艘运输舰守卫坦克,这意味着无论坦克去哪里,他们都会跟着它。
在第二个条件触发中,一旦两个运输机到达基地所在的地图右上角,我们就结束游戏。
如果你现在运行游戏,应该会看到车队司机感谢你救了车队,然后跟着你回基地,如图图 10-3 所示。
图 10-3。营救车队
回程应该是平安无事的,因为这一关的所有敌人都死了。一旦两艘运输舰到达基地,任务就结束了。你刚刚完成了单人战役中的第一个任务。
现在是时候进入下一关了,突击。
攻击
我们游戏中的第二关会比第一关更有挑战性。这一次我们将向玩家介绍微操单位来攻击敌人的想法,而不必担心管理资源或单位的生产。
玩家将获得源源不断的援军,他们需要用这些援军来定位并占领地图上的一个小型敌人基地。敌人的基地会不断派出攻击单位,使任务更具挑战性。
我们将在地图对象的单人游戏数组中创建一个新的关卡对象,如清单 10-12 所示。这个新的关卡会在第一次任务完成后自动加载。
清单 10-12。 创建第二关(maps.js)
{
"name":"Assault",
"briefing": "Thanks to the supplies from the convoy, we now have the base up and running.\n The rebels nearby are proving to be a problem. We need to take them out. \n First set up the base defences. Then find and destroy all rebels in the area.\n The colony will be sending us reinforcements to help us out.",
/* Map Details */
"mapImage":"img/level-one.png",
"startX":36,
"startY":0,
/* Map coordinates that are obstructed by terrain*/
"mapGridWidth":60,
"mapGridHeight":40,
"mapObstructedTerrain":[
[49,8], [50,8], [51,8], [51,9], [52,9], [53,9], [53,10], [53,11], [53,12], [53,13],
[53,14], [53,15], [53,16], [52,16], [52,17], [52,18], [52,19], [51,19], [50,19], [50,18], [50,17],
[49,17], [49,18], [48,18], [47,18], [47,17], [47,16], [48,16], [49,16], [49,15], [49,14], [48,14],
[48,13], [48,12], [49,12], [49,11], [50,11], [50,10], [49,10], [49,9], [44,0], [45,0], [45,1],
[45,2], [46,2], [47,2], [47,3], [48,3], [48,4], [48,5], [49,5], [49,6], [49,7], [50,7], [51,7],
[51,6], [51,5], [51,4], [52,4], [53,4], [53,3], [54,3], [55,3], [55,2], [56,2], [56,1], [56,0],
[55,0], [43,19], [44,19], [45,19], [46,19], [47,19], [48,19], [48,20], [48,21], [47,21], [46,21],
[45,21], [44,21], [43,21], [43,20], [41,22], [42,22], [43,22], [44,22], [45,22], [46,22], [47,22],
[48,22], [49,22], [50,22], [50,23], [50,24], [49,24], [48,24], [47,24], [47,25], [47,26], [47,27],
[47,28], [47,29], [47,30], [46,30], [45,30], [44,30], [43,30], [43,29], [43,28], [43,27], [43,26],
[43,25], [43,24], [42,24], [41,24], [41,23], [48,39], [49,39], [50,39], [51,39], [52,39], [53,39],
[54,39], [55,39], [56,39], [57,39], [58,39], [59,39], [59,38], [59,37], [59,36], [59,35], [59,34],
[59,33], [59,32], [59,31], [59,30], [59,29], [0,0], [1,0], [2,0], [1,1], [2,1], [10,3], [11,3],
[12,3], [12,2], [13,2], [14,2], [14,3], [14,4], [15,4], [15,5], [15,6], [14,6], [13,6], [13,5],
[12,5], [11,5], [10,5], [10,4], [3,9], [4,9], [5,9], [5,10], [6,10], [7,10], [8,10], [9,10], [9,11],
[10,11], [11,11], [11,10], [12,10], [13,10], [13,11], [13,12], [12,12], [11,12], [10,12], [9,12],
[8,12], [7,12], [7,13], [7,14], [6,14], [5,14], [5,13], [5,12], [5,11], [4,11], [3,11], [3,10],
[33,33], [34,33], [35,33], [35,34], [35,35], [34,35], [33,35], [33,34], [27,39], [27,38], [27,37],
[28,37], [28,36], [28,35], [28,34], [28,33], [28,32], [28,31], [28,30], [28,29], [29,29], [29,28],
[29,27], [29,26], [29,25], [29,24], [29,23], [30,23], [31,23], [32,23], [32,22], [32,21], [31,21],
[30,21], [30,22], [29,22], [28,22], [27,22], [26,22], [26,21], [25,21], [24,21], [24,22], [24,23],
[25,23], [26,23], [26,24], [25,24], [25,25], [24,25], [24,26], [24,27], [25,27], [25,28], [25,29],
[24,29], [23,29], [23,30], [23,31], [24,31], [25,31], [25,32], [25,33], [24,33], [23,33], [23,34],
[23,35], [24,35], [24,36], [24,37], [23,37], [22,37], [22,38], [22,39], [23,39], [24,39], [25,39],
[26,0], [26,1], [25,1], [25,2], [25,3], [26,3], [27,3], [27,2], [28,2], [29,2], [29,3], [30,3],
[31,3], [31,2], [31,1], [32,1], [32,0], [33,0], [32,8], [33,8], [34,8], [34,9], [34,10], [33,10],
[32,10], [32,9], [8,29], [9,29], [9,30], [17,32], [18,32], [19,32], [19,33], [18,33], [17,33],
[18,34], [19,34], [3,27], [4,27], [4,26], [3,26], [2,26], [3,25], [4,25], [9,20], [10,20], [11,20],
[11,21], [10,21], [10,19], [19,7], [15,7], [29,12], [30,13], [20,14], [21,14], [34,13], [35,13],
[36,13], [36,14], [35,14], [34,14], [35,15], [36,15], [16,18], [17,18], [18,18], [16,19], [17,19],
[18,19], [17,20], [18,20], [11,19], [58,0], [59,0], [58,1], [59,1], [59,2], [58,3], [59,3], [58,4],
[59,4], [59,5], [58,6], [59,6], [58,7], [59,7], [59,8], [58,9], [59,9], [58,10], [59,10], [59,11],
[52,6], [53,6], [54,6], [52,7], [53,7], [54,7], [53,8], [54,8], [44,17], [46,32], [55,32], [54,28],
[26,34], [34,34], [4,10], [6,11], [6,12], [6,13], [7,11], [8,11], [12,11], [27,0], [27,1], [26,2],
[28,1], [28,0], [29,0], [29,1], [30,2], [30,1], [30,0], [31,0], [33,9], [46,0], [47,0], [48,0],
[49,0], [50,0], [51,0], [52,0], [53,0], [54,0], [55,1], [54,1], [53,1], [52,1], [51,1], [50,1],
[49,1], [48,1], [47,1], [46,1], [48,2], [49,2], [50,2], [51,2], [52,2], [53,2], [54,2], [52,3],
[51,3], [50,3], [49,3], [49,4], [50,4], [50,5], [50,6], [50,9], [51,10], [52,10], [51,11], [52,11],
[50,12], [51,12], [52,12], [49,13], [50,13], [51,13], [52,13], [50,14], [51,14], [52,14], [50,15],
[51,15], [52,15], [50,16], [51,16], [51,17], [48,17], [51,18], [44,20], [45,20], [46,20], [47,20],
[42,23], [43,23], [44,23], [45,23], [46,23], [47,23], [48,23], [49,23], [44,24], [45,24], [46,24],
[44,25], [45,25], [46,25], [44,26], [45,26], [46,26], [44,27], [45,27], [46,27], [44,28], [45,28],
[46,28], [44,29], [45,29], [46,29], [11,4], [12,4], [13,4], [13,3], [14,5], [25,22], [31,22]
, [27,23], [28,23], [27,24], [28,24], [26,25], [27,25], [28,25], [25,26], [26,26], [27,26], [28,26],
[26,27], [27,27], [28,27], [26,28], [27,28], [28,28], [26,29], [27,29], [24,30], [25,30], [26,30],
[27,30], [26,31], [27,31], [26,32], [27,32], [26,33], [27,33], [24,34], [25,34], [27,34], [25,35],
[26,35], [27,35], [25,36], [26,36], [27,36], [25,37], [26,37], [23,38], [24,38], [25,38], [26,38],
[26,39], [2,25], [9,19], [36,31]
],
/* Entities to be loaded */
"requirements":{
"buildings":["base","ground-turret","starport","harvester"],
"vehicles":["transport","scout-tank","heavy-tank"],
"aircraft":["chopper"],
"terrain":[]
},
/* Economy Related*/
"cash":{
"blue":0,
"green":0
},
/* Entities to be added */
"items":[
{"type":"buildings","name":"base","x":55,"y":6,"team":"blue","uid":-1},
{"type":"buildings","name":"ground-turret","x":53,"y":17,"team":"blue"},
{"type":"vehicles","name":"heavy-tank","x":55,"y":16,"direction":4,"team":"blue","uid":-2,"orders":{"type":"sentry"}},
/* The first wave of attacks*/
{"type":"vehicles","name":"scout-tank","x":55,"y":36,"direction":4,"team":"green","orders":{"type":"hunt"}},
{"type":"vehicles","name":"scout-tank","x":53,"y":36,"direction":4,"team":"green","orders":{"type":"hunt"}},
/* Enemies patroling the area */
{"type":"vehicles","name":"scout-tank","x":5,"y":5,"direction":4,"team":"green","orders":{"type":"patrol","from":{"x":5,"y":5},"to":{"x":20,"y":20}}},
{"type":"vehicles","name":"scout-tank","x":5,"y":15,"direction":4,"team":"green","orders":{"type":"patrol","from":{"x":5,"y":15},"to":{"x":20,"y":30}}},
{"type":"vehicles","name":"scout-tank","x":25,"y":5,"direction":4,"team":"green","orders":{"type":"patrol","from":{"x":25,"y":5},"to":{"x":25,"y":20}}},
{"type":"vehicles","name":"scout-tank","x":35,"y":5,"direction":4,"team":"green","orders":{"type":"patrol","from":{"x":35,"y":5},"to":{"x":35,"y":30}}},
/* The Evil Rebel Base*/
{"type":"buildings","name":"base","x":5,"y":36,"team":"green","uid":-11},
{"type":"buildings","name":"starport","x":1,"y":30,"team":"green","uid":-12},
{"type":"buildings","name":"starport","x":4,"y":32,"team":"green","uid":-13},
{"type":"buildings","name":"harvester","x":1,"y":38,"team":"green","action":"deploy"},
{"type":"buildings","name":"ground-turret","x":5,"y":28, "team":"green"},
{"type":"buildings","name":"ground-turret","x":7,"y":33, "team":"green"},
{"type":"buildings","name":"ground-turret","x":8,"y":37, "team":"green"},
],
/* Conditional and Timed Trigger Events */
"triggers":[
{"type":"timed","time":8000,
"action":function(){
// Send in reinforcements to defend the base from the first wave
game.showMessage("op", "Commander!! Reinforcements have arrived from the colony.");
var hero = game.getItemByUid(−2);
game.add ({"type":"vehicles","name":"scout-tank","x":61,"y":22, "team":"blue","orders":{"type":"guard","to":hero}});
game.add ({"type":"vehicles","name":"scout-tank","x":61,"y":21, "team":"blue","orders":{"type":"guard","to":hero}});
}
},
{"type":"timed","time":25000,
"action":function(){
// Supply extra cash
game.cash["blue"] = 1500;
game.showMessage("op", "Commander!! We have enough resources for another ground turret. Set up the turret to keep the base safe from any more attacks.");
}
},
]
},
该级别的第一部分具有与前一级别几乎相同的元数据。我们正在重用第一级的地图。唯一改变的是任务简报。
接下来,我们加载需求数组中的所有基本项目,并将两个玩家的起始现金余额设置为 0。
这一次,我们在关卡的条目数组中添加了更多的条目。我们从基地开始,一个玩家将控制的重型坦克,和一个保护基地的地面炮塔。
接下来,我们添加了两辆被设置为狩猎模式的敌方侦查坦克,这样它们会在游戏一开始就攻击我们的基地。然后我们增加了几辆巡逻坦克。
最后,我们添加了一个有两个星际机场和一个精炼厂的敌人基地。它也被一些地面炮塔和附近巡逻的侦察坦克保护得很好。
触发器数组包含两个定时触发器,它们在游戏的前几秒内被触发。在第一次触发时,我们在游戏中增加了两辆友军侦察兵坦克,并通知玩家援军已经到达。
在第二个触发器中,我们给玩家 1500 信用点,并告诉他们有足够的资源来建造一个地面炮塔。放置这个炮塔将是玩家在这个游戏中执行的唯一与侧边栏相关的任务。
如果你运行游戏,你会发现第二关的开始比第一关要精彩得多。你会看到基地在最初的几秒钟内受到攻击,援军在紧要关头赶来救你,如图图 10-4 所示。
图 10-4。被援军救了
一旦攻击停止,操作员会通知你你有足够的资源来建造更多的炮塔。
现在我们将添加更多的触发器来增加攻击单位和增援部队的数量,如清单 10-13 所示。
清单 10-13。 添加敌波和援军(maps.js)
// Construct a couple of bad guys to hunt the player every time enemy has enough money
{"type":"timed","time":60000,"repeat":true,
"action":function(){
if(game.cash["green"]>1000){
game.sendCommand([−12,-13],{type:"construct-unit", details:{type:"vehicles",name:"scout-tank", oders:{"type":"hunt"}}});
}
}
},
// Send in some reinforcements every three minutes
{"type":"timed","time":180000,"repeat":true,
"action":function(){
game.showMessage("op", "Commander!! More Reinforcments have arrived.");
game.add ({"type":"vehicles","name":"scout-tank","x":61,"y":22, "team":"blue","orders":{"type":"move","to":{"x":55,"y":21}}});
game.add ({"type":"vehicles","name":"heavy-tank","x":61,"y":23, "team":"blue","orders":{"type":"move","to":{"x":56,"y":23}}});
}
},
在第一次定时触发中,我们每 60 秒检查一次绿色玩家是否有足够的钱,如果绿色玩家有足够的钱,我们在狩猎模式下建造几辆侦察坦克。在第二次定时触发中,我们每 180 秒向英雄发送两个增援单位。
不像第一关,敌人有更多的单位和炮塔防御。对敌人基地进行正面攻击是行不通的,因为玩家很可能会失去所有的单位。玩家也不能等太久,因为敌人每隔几分钟就会派出一波又一波的敌人。
一个玩家的最佳策略将是进行小规模攻击,一点一点地削弱对手,然后撤退到基地寻求增援,直到准备好进行最后的攻击。
最后,我们将添加触发器为玩家提供空中支援并完成任务,如清单 10-14 所示。
清单 10-14。 增加空中支援并完成任务(maps.js)
// Send in air support after 10 minutes
{"type":"timed","time":600000,
"action":function(){
game.showMessage("pilot", "Close Air Support en route. Will try to do what I can.");
game.add ({"type":"aircraft","name":"chopper","x":61, "y":22,"selectable":false, "team":"blue", "orders":{"type":"hunt"}});
}
},
/* Lose if our base gets destroyed */
{"type":"conditional",
"condition":function(){
return isItemDead(−1);
},
"action":function(){
singleplayer.endLevel(false);
}
},
/* Win if enemy base gets at least half destroyed */
{"type":"conditional",
"condition":function(){
var enemyBase = game.getItemByUid(−11);
return(!enemyBase || (enemyBase.life<=enemyBase.hitPoints/2));
},
"action":function(){
singleplayer.endLevel(true);
}
},
在第一次定时触发中,我们在游戏开始 10 分钟后释放了一个狩猎模式的友方直升机。接下来的两个条件触发器设置任务成功完成或失败的条件。
如果我们现在运行游戏,我们应该看到直升机驾驶员在一段时间后进来帮助我们,如图图 10-5 所示。
图 10-5。飞行员驾驶直升机进行空中支援
如果你完成任务有困难,额外的空中支援会有帮助。我们再次介绍一个新角色,飞行员,他将和我们一起完成下一个任务。在直升机的帮助下,我们可以占领敌人的基地并完成任务,这样我们就可以继续最后的任务。
现在是时候建立我们的最终任务:围攻。
被围困
我们游戏的最后一关将是最具挑战性的。玩家需要不断建造单位来击退几波敌人的单位。
这一次,玩家将接管上次任务后占领的敌人基地。玩家将被提供一些初始用品来帮助开始。之后,玩家将需要抵挡几波单位,并保护装满难民的运输车辆,直到殖民地援军到来帮助他们。
我们将在地图对象的单人游戏数组中创建一个新的关卡对象,如清单 10-15 所示。一旦第二次任务完成,这个新关卡将会自动加载。
清单 10-15。 创建第三关(maps.js)
{
"name":"Under Siege",
"briefing": "Thanks to the attack led by you, we now have control of the rebel base. We can expect the rebels to try to retaliate.\n The colony is sending in aircraft to help us evacuate back to the main camp. All we need to do is hang tight until the choppers get here. \n Luckily, we have some supplies and ammunition to defend ourselves with until they get here. \n Protect the transports at all costs.",
/* Map Details */
"mapImage":"img/level-one.png",
"startX":0,
"startY":20,
/* Map coordinates that are obstructed by terrain*/
"mapGridWidth":60,
"mapGridHeight":40,
"mapObstructedTerrain":[
[49,8], [50,8], [51,8], [51,9], [52,9], [53,9], [53,10], [53,11], [53,12], [53,13],
[53,14], [53,15], [53,16], [52,16], [52,17], [52,18], [52,19], [51,19], [50,19], [50,18], [50,17],
[49,17], [49,18], [48,18], [47,18], [47,17], [47,16], [48,16], [49,16], [49,15], [49,14], [48,14],
[48,13], [48,12], [49,12], [49,11], [50,11], [50,10], [49,10], [49,9], [44,0], [45,0], [45,1],
[45,2], [46,2], [47,2], [47,3], [48,3], [48,4], [48,5], [49,5], [49,6], [49,7], [50,7], [51,7],
[51,6], [51,5], [51,4], [52,4], [53,4], [53,3], [54,3], [55,3], [55,2], [56,2], [56,1], [56,0],
[55,0], [43,19], [44,19], [45,19], [46,19], [47,19], [48,19], [48,20], [48,21], [47,21], [46,21],
[45,21], [44,21], [43,21], [43,20], [41,22], [42,22], [43,22], [44,22], [45,22], [46,22], [47,22],
[48,22], [49,22], [50,22], [50,23], [50,24], [49,24], [48,24], [47,24], [47,25], [47,26], [47,27],
[47,28], [47,29], [47,30], [46,30], [45,30], [44,30], [43,30], [43,29], [43,28], [43,27], [43,26]
, [43,25], [43,24], [42,24], [41,24], [41,23], [48,39], [49,39], [50,39], [51,39], [52,39], [53,39],
[54,39], [55,39], [56,39], [57,39], [58,39], [59,39], [59,38], [59,37], [59,36], [59,35], [59,34],
[59,33], [59,32], [59,31], [59,30], [59,29], [0,0], [1,0], [2,0], [1,1], [2,1], [10,3], [11,3],
[12,3], [12,2], [13,2], [14,2], [14,3], [14,4], [15,4], [15,5], [15,6], [14,6], [13,6], [13,5],
[12,5], [11,5], [10,5], [10,4], [3,9], [4,9], [5,9], [5,10], [6,10], [7,10], [8,10], [9,10], [9,11],
[10,11], [11,11], [11,10], [12,10], [13,10], [13,11], [13,12], [12,12], [11,12], [10,12], [9,12],
[8,12], [7,12], [7,13], [7,14], [6,14], [5,14], [5,13], [5,12], [5,11], [4,11], [3,11], [3,10],
[33,33], [34,33], [35,33], [35,34], [35,35], [34,35], [33,35], [33,34], [27,39], [27,38], [27,37],
[28,37], [28,36], [28,35], [28,34], [28,33], [28,32], [28,31], [28,30], [28,29], [29,29], [29,28],
[29,27], [29,26], [29,25], [29,24], [29,23], [30,23], [31,23], [32,23], [32,22], [32,21], [31,21],
[30,21], [30,22], [29,22], [28,22], [27,22], [26,22], [26,21], [25,21], [24,21], [24,22], [24,23],
[25,23], [26,23], [26,24], [25,24], [25,25], [24,25], [24,26], [24,27], [25,27], [25,28], [25,29],
[24,29], [23,29], [23,30], [23,31], [24,31], [25,31], [25,32], [25,33], [24,33], [23,33], [23,34],
[23,35], [24,35], [24,36], [24,37], [23,37], [22,37], [22,38], [22,39], [23,39], [24,39], [25,39],
[26,0], [26,1], [25,1], [25,2], [25,3], [26,3], [27,3], [27,2], [28,2], [29,2], [29,3], [30,3],
[31,3], [31,2], [31,1], [32,1], [32,0], [33,0], [32,8], [33,8], [34,8], [34,9], [34,10], [33,10],
[32,10], [32,9], [8,29], [9,29], [9,30], [17,32], [18,32], [19,32], [19,33], [18,33], [17,33],
[18,34], [19,34], [3,27], [4,27], [4,26], [3,26], [2,26], [3,25], [4,25], [9,20], [10,20], [11,20],
[11,21], [10,21], [10,19], [19,7], [15,7], [29,12], [30,13], [20,14], [21,14], [34,13], [35,13],
[36,13], [36,14], [35,14], [34,14], [35,15], [36,15], [16,18], [17,18], [18,18], [16,19], [17,19],
[18,19], [17,20], [18,20], [11,19], [58,0], [59,0], [58,1], [59,1], [59,2], [58,3], [59,3], [58,4],
[59,4], [59,5], [58,6], [59,6], [58,7], [59,7], [59,8], [58,9], [59,9], [58,10], [59,10], [59,11],
[52,6], [53,6], [54,6], [52,7], [53,7], [54,7], [53,8], [54,8], [44,17], [46,32], [55,32], [54,28],
[26,34], [34,34], [4,10], [6,11], [6,12], [6,13], [7,11], [8,11], [12,11], [27,0], [27,1], [26,2],
[28,1], [28,0], [29,0], [29,1], [30,2], [30,1], [30,0], [31,0], [33,9], [46,0], [47,0], [48,0],
[49,0], [50,0], [51,0], [52,0], [53,0], [54,0], [55,1], [54,1], [53,1], [52,1], [51,1], [50,1],
[49,1], [48,1], [47,1], [46,1], [48,2], [49,2], [50,2], [51,2], [52,2], [53,2], [54,2], [52,3],
[51,3], [50,3], [49,3], [49,4], [50,4], [50,5], [50,6], [50,9], [51,10], [52,10], [51,11], [52,11],
[50,12], [51,12], [52,12], [49,13], [50,13], [51,13], [52,13], [50,14], [51,14], [52,14], [50,15],
[51,15], [52,15], [50,16], [51,16], [51,17], [48,17], [51,18], [44,20], [45,20], [46,20], [47,20],
[42,23], [43,23], [44,23], [45,23], [46,23], [47,23], [48,23], [49,23], [44,24], [45,24], [46,24],
[44,25], [45,25], [46,25], [44,26], [45,26], [46,26], [44,27], [45,27], [46,27], [44,28], [45,28],
[46,28], [44,29], [45,29], [46,29], [11,4], [12,4], [13,4], [13,3], [14,5], [25,22], [31,22],
[27,23], [28,23], [27,24], [28,24], [26,25], [27,25], [28,25], [25,26], [26,26], [27,26], [28,26],
[26,27], [27,27], [28,27], [26,28], [27,28], [28,28], [26,29], [27,29], [24,30], [25,30], [26,30],
[27,30], [26,31], [27,31], [26,32], [27,32], [26,33], [27,33], [24,34], [25,34], [27,34], [25,35],
[26,35], [27,35], [25,36], [26,36], [27,36], [25,37], [26,37], [23,38], [24,38], [25,38], [26,38],
[26,39], [2,25], [9,19], [36,31]
],
/* Entities to be loaded */
"requirements":{
"buildings":["base","ground-turret","starport","harvester"],
"vehicles":["transport","scout-tank","heavy-tank"],
"aircraft":["chopper","wraith"],
"terrain":[]
},
/* Economy Related*/
"cash":{
"blue":0,
"green":0
},
/* Entities to be added */
"items":[
/* The Rebel Base now in our hands */
{"type":"buildings","name":"base","x":5,"y":36,"team":"blue","uid":-11},
{"type":"buildings","name":"starport","x":1,"y":28,"team":"blue","uid":-12},
{"type":"buildings","name":"starport","x":4,"y":32,"team":"blue","uid":-13},
{"type":"buildings","name":"harvester","x":1,"y":38,"team":"blue","action":"deploy"},
{"type":"buildings","name":"ground-turret","x":7,"y":28,"team":"blue"},
{"type":"buildings","name":"ground-turret","x":8,"y":32,"team":"blue"},
{"type":"buildings","name":"ground-turret","x":11,"y":37,"team":"blue"},
/* The transports that need to be protected*/
{"type":"vehicles","name":"transport","x":2,"y":33,"team":"blue","direction":2, "selectable":false, "uid":-1},
{"type":"vehicles","name":"transport","x":1,"y":34,"team":"blue","direction":2, "selectable":false,"uid":-2},
{"type":"vehicles","name":"transport","x":2,"y":35,"team":"blue","direction":2, "selectable":false,"uid":-3},
{"type":"vehicles","name":"transport","x":1,"y":36,"team":"blue","direction":2, "selectable":false,"uid":-4},
/* The chopper pilot from the last mission */
{"type":"aircraft","name":"chopper","x":15,"y":40,"team":"blue","selectable":false,"uid":-5,"orders":{"type":"patrol","from":{"x":15,"y":40},"to":{"x":0,"y":25}}},
/* The first wave of attacks*/
{"type":"vehicles","name":"scout-tank","x":15,"y":16,"direction":4,"team":"green","orders":{"type":"hunt"}},
{"type":"vehicles","name":"scout-tank","x":17,"y":16,"direction":4,"team":"green","orders":{"type":"hunt"}},
/* Secret Rebel bases*/
{"type":"buildings","name":"starport","x":35,"y":37,"team":"green","uid":-23},
{"type":"buildings","name":"starport","x":33,"y":37,"team":"green","uid":-24},
{"type":"buildings","name":"harvester","x":28,"y":39,"team":"green","action":"deploy"},
{"type":"buildings","name":"harvester","x":30,"y":39,"team":"green","action":"deploy"},
{"type":"buildings","name":"starport","x":3,"y":0,"team":"green","uid":-21},
{"type":"buildings","name":"starport","x":6,"y":0,"team":"green","uid":-22},
{"type":"buildings","name":"harvester","x":0,"y":2,"team":"green","action":"deploy"},
{"type":"buildings","name":"harvester","x":0,"y":4,"team":"green","action":"deploy"},
],
/* Conditional and Timed Trigger Events */
"triggers":[
/* Lose if even one transport gets destroyed */
{"type":"conditional",
"condition":function(){
return isItemDead(−1)||isItemDead(−2)||isItemDead(−3)||isItemDead(−4);
},
"action":function(){
singleplayer.endLevel(false);
}
},
{"type":"timed","time":5000,
"action":function(){
game.showMessage("op", "Commander!! The rebels have started attacking. We need to protect the base at any cost.");
}
},
],
}
同样,我们正在重用级别 1 的地图。该级别的第一部分与之前的级别具有几乎相同的元数据。唯一改变的是任务简报。
接下来,我们加载需求数组中的所有基本项目,并将两个玩家的起始现金余额设置为 0。
这一次,我们在地图上添加了更多的项目。首先,我们重建整个敌人基地,但是是为了玩家团队。接下来,我们添加几辆我们将在这次任务中保护的运输车辆,并添加上次任务中巡逻模式的直升机来保护基地。接下来,我们增加一些敌人单位进行第一波攻击。最后,我们为将要攻击玩家的两个秘密敌人基地定义了几个星际机场和精炼厂。
在触发器中,我们定义了一个条件触发器,在其中一架运输机死亡的情况下结束任务。第二个定时触发器只显示来自操作员的信息。
如果我们运行游戏并开始第三关,我们应该会看到叛军攻击基地,巡逻的直升机击退他们,如图图 10-6 所示。
图 10-6。巡逻直升机保卫基地
既然第一波攻击已经被击退,我们将通过在地图上添加一些创造性的触发器,在任务中建立一个带有小电影故事线的小戏剧,如清单 10-16 所示。
清单 10-16。 增加一点剧情到关卡(maps.js)
{"type":"timed","time":20000,
"action":function(){
game.add({"type":"vehicles","name":"transport","x":57,"y":3,"team":"blue","direction":4, "selectable":false,"uid":-6});
game.sendCommand([−5],{"type":"guard","toUid":-6})
game.showMessage("driver", "Commander!! The colony has sent some extra supplies. We are coming in from the North East sector through rebel territory. We could use a little protection.");
}
},
//Have the pilot offer to assist and get some villains in to make it interesting
{"type":"timed","time":28000,
"action":function(){
game.showMessage("pilot", "I'm on my way.");
game.add({"type":"vehicles","name":"scout-tank","x":57,"y":28,"team":"green","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"wraith","x":55,"y":33,"team":"green", "orders":{"type":"sentry"}});
game.add({"type":"aircraft","name":"wraith","x":53,"y":33,"team":"green", "orders":{"type":"sentry"}});
game.add({"type":"vehicles","name":"scout-tank","x":35,"y":25,"life":20,"direction":4,"team":"green", "orders":{"type":"patrol","from":{"x":35,"y":25},"to":{"x":35,"y":30}}});
}
},
// Start moving the transport,
{"type":"timed","time":48000,
"action":function(){
game.showMessage("driver", "Thanks! Appreciate the backup. All right. Off we go.");
game.sendCommand([−6],{"type":"move","to":{"x":0,"y":39,}});
}
},
// Pilot asks for help when attacked
{"type":"conditional",
"condition":function(){
var pilot = game.getItemByUid(−5);
return pilot.life<pilot.hitPoints;
},
"action":function(){
game.showMessage("pilot", "We are under attack! Need assistance. This doesn't look good.");
}
},
// Extra supplies from new transport
{"type":"conditional",
"condition":function(){
var driver = game.getItemByUid(−6);
return driver && driver.x < 2 && driver.y>37;
},
"action":function(){
game.showMessage("driver", "The rebels came out of nowhere. There was nothing we could do. She saved our lives. Hope these supplies were worth it.");
game.cash["blue"] += 1200;
}
},
在第一个定时触发中,来自第一个任务的驾驶员站在地图的右上角请求帮助。然后我们命令飞行员守卫运输机。
在第二次定时触发时,飞行员宣布她已经上路了。我们也在地图上增加了几个敌人单位。
在第三次定时触发中,它将在飞行员到达运输机的位置时被触发,我们命令运输机开始向基地移动。
在第四个条件触发中——如果飞行员的直升机受到攻击就会被触发——飞行员发出求救信息。
在最后的条件触发中,如果运输工具到达目的地就会被触发,司机会谈论他的经历,玩家的现金资源会增加。
如果你现在运行这个游戏,你应该会看到一个相当有趣的场景。你会看到飞行员在司机呼救的时候出去帮助他。然后飞行员在被几架敌机伏击前保护运输机。
运输机在敌人的炮火下继续向基地驶去。一旦驾驶员到达基地,驾驶员向玩家提供一些补给并描述体验,如图图 10-7 所示。
图 10-7。司机描述回来后的痛苦经历
在整个体验结束时,玩家现在有一些额外的现金来开始建造单位。
尽管我们游戏中的故事有点仓促,但正如你所见,这种触发机制可以用来讲述一个相当有趣的故事。显然,游戏框架可以扩展到使用视频、音频或动画 gif 的组合,使体验更加身临其境。
现在让我们添加一个触发器来设置敌人单位的波动,如清单 10-17 所示。
清单 10-17。 添加敌波(maps.js)
// Send in waves of enemies every 150 seconds
{"type":"timed","time":150000,"repeat":true,
"action":function(){
// Count aircraft and tanks already available to bad guys
var wraithCount = 0;
var chopperCount = 0;
var scoutTankCount = 0;
var heavyTankCount = 0;
for (var i = game.items.length - 1; i >= 0; i--){
var item = game.items[i];
if(item.team=="green"){
switch(item.name){
case "chopper":
chopperCount++;
break;
case "wraith":
wraithCount++;
break;
case "scout-tank":
scoutTankCount++;
break;
case "heavy-tank":
heavyTankCount++;
break;
}
}
};
// Make sure enemy has atleast two wraiths and two heavy tanks, and use the remaining starports to build choppers and scouts
if(wraithCount==0){
// No wraiths alive. Ask both starports to make wraiths
game.sendCommand([−23,-24],{type:"construct-unit",details:{type:"aircraft",name:"wraith","orders":{"type":"hunt"}}});
} else if (wraithCount==1){
// One wraith alive. Ask starports to make one wraith and one chopper
game.sendCommand([−23],{type:"construct-unit",details:{type:"aircraft",name:"wraith","orders":{"type":"hunt"}}});
game.sendCommand([−24],{type:"construct-unit",details:{type:"aircraft",name:"chopper","orders":{"type":"hunt"}}});
} else {
// Two wraiths alive. Ask both starports to make choppers
game.sendCommand([−23,-24],{type:"construct-unit",details:{type:"aircraft",name:"chopper","orders":{"type":"hunt"}}});
}
if(heavyTankCount==0){
// No heavy-tanks alive. Ask both starports to make heavy-tanks
game.sendCommand([−21,-22],{type:"construct-unit",details:{type:"vehicles",name:"heavy-tank","orders":{"type":"hunt"}}});
} else if (heavyTankCount==1){
// One heavy-tank alive. Ask starports to make one heavy-tank and one scout-tank
game.sendCommand([−21],{type:"construct-unit",details:{type:"vehicles",name:"heavy-tank","orders":{"type":"hunt"}}});
game.sendCommand([−22],{type:"construct-unit",details:{type:"vehicles",name:"scout-tank","orders":{"type":"hunt"}}});
} else {
// Two heavy-tanks alive. Ask both starports to make scout-tanks
game.sendCommand([−21,-22],{type:"construct-unit",details:{type:"vehicles",name:"scout-tank","orders":{"type":"hunt"}}});
}
// Ask any units on the field to attack
var uids = [];
for (var i=0; i < game.items.length; i++) {
var item = game.items[i];
if(item.team == "green" && item.canAttack){
uids.push(item.uid);
}
};
game.sendCommand(uids,{"type":"hunt"});
}
},
与之前的关卡不同,敌方电波触发稍微智能一点。定时触发器每 150 秒运行一次。它首先计算每种类型的敌人单位的数量。然后,它会根据哪些单元可用来决定要构建哪些单元。在这个简单的例子中,我们首先确保绿队至少有两个幽灵来控制天空和两个重型坦克来控制地面,如果没有,我们就在星际机场建造它们。我们在任何剩余的星际港口建造直升机和侦查坦克。最后,我们命令所有会攻击的绿队单位进入狩猎模式。
如果现在运行游戏,敌人每隔几分钟就会发出电波。这一次,敌人单位的组成将随着每次攻击而变化。如果你没有适当地计划你的防御,你可以期待被敌人压倒。
显然,这个 AI 可以通过根据玩家的构成调整敌人的构成或选择目标进行智能攻击来进一步改进。然而,正如您所看到的,即使是这一组简单的指令也为我们提供了一个相当具有挑战性的敌人。
现在我们在关卡中有了一个具有挑战性的敌人,我们将实现结束任务的触发器,如清单 10-18 所示。
清单 10-18。 实现结局(maps.js)
//After 8 minutes, start waiting for the end
{"type":"timed","time":480000,
"action":function(){
game.showMessage("op", "Commander! The colony air fleet is just a few minutes away.");
}
},
//After 10 minutes send in reinforcements
{"type":"timed","time":600000,
"action":function(){
game.showMessage("op", "Commander! The colony air fleet is approaching");
game.add({"type":"aircraft","name":"wraith","x":-1,"y":30, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"chopper","x":-1,"y":31, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"wraith","x":-1,"y":32, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"chopper","x":-1,"y":33, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"wraith","x":-1,"y":34, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"chopper","x":-1,"y":35, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"wraith","x":-1,"y":36, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"chopper","x":-1,"y":37, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"wraith","x":-1,"y":38, "team":"blue","orders":{"type":"hunt"}});
game.add({"type":"aircraft","name":"chopper","x":-1,"y":39, "team":"blue","orders":{"type":"hunt"}});
}
},
// And a minute after, end the level
{"type":"timed","time":660000,
"action":function(){
singleplayer.endLevel(true);
}
},
第一个触发器,在游戏开始 8 分钟后,操作员宣布殖民地的机群几乎已经到达。在第二次触发中,两分钟后,我们在搜索模式下增加了一整队友军飞机。最后,舰队到达后一分钟,我们结束了水平。
很明显,这次任务的唯一目标是在舰队到达之前生存和保护运输工具。如果你现在玩任务,存活那么久,你应该会看到大舰队飞过来消灭敌人,如图图 10-8 所示。
图 10-8。殖民地的空军舰队飞来拯救世界
一旦舰队飞来,在完成单人战役的最后一关之前,我们有额外的一分钟来欣赏他们攻击和摧毁敌人。
摘要
在这一章中,我们终于完成了我们的 RTS 游戏的整个单人战役。我们从给游戏添加声音开始。然后,我们使用我们在过去几章中构建的框架来开发活动的几个级别。我们研究了通过创造性地使用触发事件来使关卡具有挑战性和趣味性的方法。我们还学会了如何在游戏中编织一个完整的故事。
至此,你已经有了一个完整的、可运行的单人 RTS 游戏,你既可以扩展它,也可以根据自己的想法使用它。从这里开始,一个很好的方法是尝试为这个活动开发你自己的有趣水平。
在此之后,如果你准备好了更具挑战性的东西,你应该尝试建立自己的游戏。你可以使用这些代码,通过修改图片和调整设置,相当快地设计出新的游戏创意。如果对你的原型的反馈是令人鼓舞的,那么你可以投入更多的时间和精力来构建一个完整的游戏。
当然,虽然和电脑对战很有趣,但是挑战你的朋友会更有趣。在接下来的两章中,我们将看看 HTML5 WebSocket API,以及我们如何使用它来构建一个多人游戏,以便您可以通过网络与您的朋友进行游戏。所以,一旦你花了一些时间享受单人游戏,继续下一章,这样我们就可以开始多人游戏了。
十一、使用 WebSockets 的多人游戏
不管我们制作的单人游戏有多有挑战性,它总是缺少与另一个人竞争的挑战。多人游戏允许玩家相互竞争或者为了一个共同的目标合作。
现在我们已经有了一个可行的单人游戏,我们将看看如何通过使用 HTML5 WebSocket API 为我们的 RTS 游戏添加多人支持。
在我们开始向游戏中添加多人游戏之前,让我们先来看看使用 Node.js 的 WebSocket API 的一些网络基础知识。
通过 Node.js 使用 WebSocket API
我们多人游戏的核心是新的 HTML5 WebSocket API。在 WebSockets 出现之前,浏览器与服务器交互的唯一方式是通过稳定的请求流轮询和长时间轮询服务器。这些方法虽然有效,但有很高的网络延迟和高带宽使用率,使它们不适合实时多人游戏。
随着 WebSocket API 的出现,这一切都改变了。该 API 通过单个 TCP 套接字定义了一个双向、全双工通信通道,为我们提供了浏览器和服务器之间高效、低延迟的连接。
简而言之,我们现在可以在浏览器和服务器之间创建一个单一的、持久的连接,并以比以前更快的速度来回发送数据。你可以在 www.websocket.org/了解更多关于 WebSockets 的好处。让我们看一个使用 WebSockets 在浏览器和服务器之间进行通信的简单例子。
浏览器上的 WebSocket
使用 WebSockets 与服务器通信包括以下步骤:
- 通过提供服务器 URL 来实例化 WebSocket 对象
- 根据需要实现 onopen、onclose 和 onerror 事件处理程序
- 实现 onmessage 事件处理程序,以便在从服务器收到消息时处理操作
- 使用 send()方法向服务器发送消息
- 使用 close()方法关闭与服务器的连接
我们可以在一个新的 HTML 文件中创建一个简单的 WebSocket 客户端,如清单 11-1 所示。我们将把这个新文件放在 websocketdemo 文件夹中,使它与我们的游戏代码分开。
清单 11-1。 一个简单的 WebSocket 客户端(websocketclient.html)
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>WebSocket Client</title>
<script type="text/javascript" charset="utf-8">
var websocket;
var serverUrl = "ws://localhost:8080/";
function displayMessage(message){
document.getElementById("displaydiv").innerHTML += message +"<br>";
}
// Initialize the WebSocket object and setup Event Handlers
function initWebSocket(){
// Check if browser has an implementation of WebSocket (older Mozilla browsers used MozWebSocket)
var WebSocketObject = window.WebSocket || window.MozWebSocket;
if(WebSocketObject){
// Create the WebSocket object
websocket = new WebSocketObject(serverUrl);
// Setup the event handlers
websocket.onopen = function(){
displayMessage("WebSocket Connection Opened");
document.getElementById("sendmessage").disabled = false;
};
websocket.onclose = function(){
displayMessage("WebSocket Connection Closed");
document.getElementById("sendmessage").disabled = true;
};
websocket.onerror = function(){
displayMessage("Connection Error Occured");
};
websocket.onmessage = function(message){
displayMessage("Received Message: <i>"+message.data+"</i>");
};
} else {
displayMessage("Your Browser does not support WebSockets");
}
}
// Send a message to the server using the WebSocket
function sendMessage(){
// readyState can be CONNECTING,OPEN,CLOSING,CLOSED
if (websocket.readyState = websocket.OPEN){
var message = document.getElementById("message").value;
displayMessage("Sending Message: <i>"+message+"</i>");
websocket.send(message);
} else {
displayMessage("Cannot send message. The WebSocket connection isn't open");
}
}
</script>
</head>
<body onload="initWebSocket();">
<label for="message">Message</label><input type="text" value="Simple Message" size="40" id="message">
<input type="button" value="Send" id="sendmessage" onclick="sendMessage()" disabled="true">
<div id="displaydiv" style="border:1px solid black;width:600px; height:400px;font-size:14px;"></div>
</body>
</html>
HTML 文件的 body 标记包含一些基本元素:一个消息输入框、一个发送消息的按钮和一个显示所有消息的 div。
在脚本标记中,我们首先使用 WebSocket 协议(ws://)声明一个指向 WebSocket 服务器的服务器 URL。
然后我们声明一个简单的 displayMessage()方法,它将给定的消息附加到 displaydiv div 元素。
接下来,我们声明 initWebSocket()方法,该方法初始化 WebSocket 连接并设置事件处理程序。
在这个方法中,我们首先检查 WebSocket 或 MozWebSocket 对象是否存在,以验证浏览器是否支持 WebSockets,并将其保存到 WebSocket object。这是因为旧版本的 Mozilla 浏览器在转换到 WebSocket 之前将它们的实现命名为 MozWebSocket。
然后,我们通过调用 WebSocket object 的构造函数并将其保存到 WebSocket 变量来初始化 websocket 对象。
最后,我们为 onopen、onclose、onerror 和 onmessage 事件处理程序定义处理程序,向用户显示适当的消息。我们还在连接打开时启用 sendmessage 按钮,在连接关闭时禁用它。
在 sendMessage()方法中,我们使用 readyState 属性检查连接是否打开,然后使用 send()方法将消息输入框的内容发送到服务器。
我们的浏览器客户端需要一个可以使用 WebSocket 协议进行通信的服务器。已经有几个 WebSocket 服务器实现可用于大多数流行语言,如 Java 的 jweb socket(jwebsocket.org/
)和 Jetty(jetty.codehaus.org/jetty/
),Node.js 的 socket . io(github.com/LearnBoost/Socket.IO-node
)和 web socket-Node(github.com/Worlize/WebSocket-Node
)以及 C++的 web socket++(github.com/zaphoyd/websocketpp
)。
在本书中,我们将对 Node.js 使用 WebSocket-Node。我们将首先设置 Node.js 并创建一个 HTTP 服务器,然后为其添加 WebSocket 支持。
在 Node.js 中创建 HTTP 服务器
node . js(nodejs.org/
)是一个服务器端平台,由构建在谷歌 JavaScript V8 引擎之上的几个库组成。Node.js 最初由 Ryan Dahl 于 2009 年创建,旨在轻松构建快速、可伸缩的网络应用。程序是用 JavaScript 编写的,使用事件驱动的、非阻塞的 I/O 模型,它是轻量级的、高效的。Node.js 在相对较短的时间内获得了很大的知名度,并被许多公司使用,包括 LinkedIn、微软和雅虎。
在开始编写 Node.js 代码之前,您需要在计算机上安装 Node.js。Node.js 的实现可用于大多数操作系统,如 Windows、Mac OS X、Linux 和 SunOS,在您的特定操作系统上设置 Node.js 的详细说明可在github.com/joyent/node/wiki/Installation
获得。对于 Windows 和 Mac OS X,最简单的安装方法是运行现成的安装文件,可在 http://nodejs.org/download/下载。
正确设置 Node.js 后,您将能够从命令行运行 Node.js 程序,方法是调用 Node 可执行文件并将程序名作为参数传递。
设置 Node.js 后,我们可以在一个新的 JavaScript 文件中创建一个简单的 HTTP web 服务器,如清单 11-2 所示。我们将把这个文件放在 websocketdemo 文件夹中。
清单 11-2。 一个简单的 HTTP Web 服务器中的 Node.js (websocketserver.js)
// Create an HTTP Server
var http = require('http');
// Create a simple web server that returns the same response for any request
var server = http.createServer(function(request,response){
console.log('Received HTTP request for url ', request.url);
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end("This is a simple node.js HTTP server.");
});
// Listen on port 8080
server.listen(8080,function(){
console.log('Server has started listening on port 8080');
});
使用 Node.js HTTP 库,在 Node.js 中构建简单 web 服务器的代码少得惊人。你可以在 http://nodejs.org/api/http.html 找到关于这个库的详细文档。
我们首先使用 require()方法引用 http 库,并将其保存到 HTTP 变量中。然后,我们通过调用 createServer()方法创建一个 HTTP 服务器,并向它传递一个处理所有 HTTP 请求的方法。在我们的例子中,对于任何 HTTP 请求,我们都向服务器发回相同的文本响应。最后,我们告诉服务器开始监听端口 8080。
如果您从命令行运行 websocketserver.js 中的代码,并尝试从浏览器访问 web 服务器的 URL(localhost:8080
),您应该会看到如图图 11-1 所示的输出。
图 11-1。node . js 中的一个简单的 HTTP 服务器
我们已经启动并运行了 HTTP 服务器。无论我们在 URL 中的服务器名称后传递什么路径,这个服务器都将返回相同的页面。此服务器也不支持 WebSockets。
接下来,我们将通过使用 WebSocket-Node 包向该服务器添加 WebSocket 支持。
创建 WebSocket 服务器
您需要做的第一件事是使用 npm 命令安装 WebSocket-Node 包。详细的安装说明和示例代码可在github.com/Worlize/WebSocket-Node
获得。
如果 Node.js 设置正确,您应该能够通过从命令行运行以下命令来设置 WebSocket:
npm install websocket
提示如果你之前已经安装了 Node。JS 和 WebSocket-Node,您应该通过运行 npm update 命令来确保您使用的是最新版本。
一旦安装了 WebSocket 包,我们将通过修改 websocketserver.js 来添加 WebSocket 支持,如清单 11-3 所示。
清单 11-3。 实现一个简单的 WebSocket 服务器(websocketserver.js)
// Create an HTTP Server
var http = require('http');
// Create a simple web server that returns the same response for any request
var server = http.createServer(function(request,response){
console.log('Received HTTP request for url', request.url);
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end("This is a simple node.js HTTP server.");
});
// Listen on port 8080
server.listen(8080,function(){
console.log('Server has started listening on port 8080');
});
// Attach WebSocket Server to HTTP Server
var WebSocketServer = require('websocket').server;
var wsServer = new WebSocketServer({
httpServer:server
});
// Logic to determine whether a specified connection is allowed.
function connectionIsAllowed(request){
// Check criteria such as request.origin, request.remoteAddress
return true;
}
// Handle WebSocket Connection Requests
wsServer.on('request',function(request){
// Reject requests based on certain criteria
if(!connectionIsAllowed(request)){
request.reject();
console.log('WebSocket Connection from' + request.remoteAddress + 'rejected.');
return;
}
// Accept Connection
var websocket = request.accept();
console.log('WebSocket Connection from' + request.remoteAddress + 'accepted.');
websocket.send ('Hi there. You are now connected to the WebSocket Server');
websocket.on('message', function(message) {
if (message.type === 'utf8') {
console.log('Received Message:' + message.utf8Data);
websocket.send('Server received your message:'+ message.utf8Data);
}
});
websocket.on('close', function(reasonCode, description) {
console.log('WebSocket Connection from' + request.remoteAddress + 'closed.');
});
});
我们创建 HTTP 服务器的代码的第一部分保持不变。在新添加的代码中,我们首先使用 require()方法保存对 WebSocket 服务器的引用。然后,我们创建一个新的 WebSocketServer 对象,传递我们之前作为配置选项创建的 HTTP 服务器。您可以在github . com/Worlize/web socket-Node/wiki/Documentation
上了解不同的 WebSocketServer 配置选项以及 WebSocketServer API 的详细信息。
接下来,我们实现服务器请求事件的处理程序。我们首先检查连接请求是否应该被拒绝,如果应该,调用请求的 reject()方法。
我们使用一个名为 connectionIsAllowed()的方法来过滤需要拒绝的连接。现在我们批准所有连接;但是,这种方法可以使用连接请求的 IP 地址和来源等信息来智能地过滤请求。
如果允许连接,我们使用 accept()方法接受请求,并将结果 WebSocket 连接保存到 websocket 变量中。这个 websocket 变量是我们前面在客户机 HTML 文件中创建的 websocket 变量的服务器端等价物。
一旦我们创建了连接,我们就使用 websocket 对象的 send()方法向客户端发送一条欢迎消息,通知它连接已经建立。
Next we implement the handler for the message event of the websocket object. Every time a message arrives, we send back a message to the client saying the server just received the message and then log the message to the console.
注意web socket API 允许多种消息数据类型,如 UTF8 文本、二进制和 blob 数据。与浏览器不同,服务器端的消息对象根据数据类型使用不同的属性(如 utf8Data、binaryData)存储消息数据。
Finally, we implement the handler for the close event, where we just log the fact that the connection was closed.
如果从命令行运行 websocketserver.js 代码,并在浏览器中打开 websocketclient.html,应该会看到客户端和服务器之间的交互,如图图 11-2 所示。
图 11-2。客户端和服务器之间的交互
一旦建立了 WebSocket 连接,浏览器就会收到来自服务器的欢迎消息。客户端上的发送按钮也会被启用。如果您键入一条消息并单击发送,服务器会在控制台中显示该消息,并向客户端发回一个响应。最后,如果关闭服务器,客户机会显示一条消息,说明连接已经关闭。
我们现在有了一个在客户机和服务器之间来回传输纯文本消息的工作示例。
注使用二进制数据代替纯文本可以减少消息大小并优化带宽使用。然而,我们将继续使用 UTF8 文本,甚至在我们的游戏实现中,以保持代码简单。
现在我们已经了解了 WebSocket 通信的基础,是时候在我们的游戏中加入多人游戏了。我们将从第十章结尾的地方继续。
我们要做的第一件事是建造一个多人游戏大厅。
建造多人游戏大厅
我们的游戏大厅将显示游戏室列表。玩家可以从游戏大厅屏幕加入或离开这些房间。一旦两个玩家加入一个房间,多人游戏就开始了,两个玩家可以互相竞争。
定义多人游戏大厅画面
我们将从在 index.html 的 gamecontainer div 中添加多人游戏大厅屏幕的 HTML 代码开始,如清单 11-4 所示。
清单 11-4。 多人游戏大厅屏幕的 HTML 代码(index.html)
<div id="multiplayerlobbyscreen" class="gamelayer">
<select id="multiplayergameslist" size="10">
</select>
<input type="button" id="multiplayerjoin" onclick="multiplayer.join();">
<input type="button" id="multiplayercancel" onclick="multiplayer.cancel();">
</div>
该层包含一个 select 元素,用于显示游戏房间列表以及两个按钮。我们还将把大厅屏幕的 CSS 代码添加到 styles.css 中,如清单 11-5 所示。
清单 11-5。 多人游戏大厅屏幕的 CSS 代码(styles.css)
/* Multiplayer Lobby Screen */
#multiplayerlobbyscreen {
background:url(img/multiplayerlobbyscreen.png) no-repeat center;
}
#multiplayerlobbyscreen input[type="button"]{
background-image: url(img/buttons.png);
position:absolute;
border-width:0px;
padding:0px;
}
#multiplayerjoin{
background-position: -2px -212px;
top:400px;
left:21px;
width:74px;
height:26px;
}
#multiplayerjoin:active,#multiplayerjoin:disabled{
background-position: -2px -247px;
}
#multiplayercancel{
background-position: -86px -150px;
left:545px;
top:400px;
width:73px;
height:24px;
}
#multiplayercancel:active,#multiplayercancel:disabled{
background-position: -86px -184px;
}
#multiplayergameslist {
padding:20px;
position:absolute;
width:392px;
height:270px;
top:98px;
left:124px;
background:rgba(0,0,0,0.7);
border:none;
color:gray;
font-size: 15px;
font-family: 'Courier New', Courier, monospace;
}
#multiplayergameslist:focus {
outline:none;
}
#multiplayergameslist option.running{
color:gray;
}
#multiplayergameslist option.waiting{
color:green;
}
#multiplayergameslist option.empty{
color:lightblue;
}
现在大厅屏幕已经就绪,我们将构建代码将浏览器连接到服务器并填充游戏列表。
推广游戏列表
我们将从在 multiplayer.js 中定义一个新的多人对象开始,如清单 11-6 所示。
清单 11-6。 定义多人对象(multiplayer.js)
var multiplayer = {
// Open multiplayer game lobby
websocket_url:"ws://localhost:8080/",
websocket:undefined,
start:function(){
game.type = "multiplayer";
var WebSocketObject = window.WebSocket || window.MozWebSocket;
if (!WebSocketObject){
game.showMessageBox("Your browser does not support WebSocket. Multiplayer will not work.");
return;
}
this.websocket = new WebSocketObject(this.websocket_url);
this.websocket.onmessage = multiplayer.handleWebSocketMessage;
// Display multiplayer lobby screen after connecting
this.websocket.onopen = function(){
// Hide the starting menu layer
$('.gamelayer').hide();
$('#multiplayerlobbyscreen').show();
}
},
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
}
},
statusMessages:{
'starting':'Game Starting',
'running':'Game in Progress',
'waiting':'Awaiting second player',
'empty':'Open'
},
updateRoomStatus:function(status){
var $list = $("#multiplayergameslist");
$list.empty(); // remove old options
for (var i=0; i < status.length; i++) {
var key = "Game "+(i+1)+". "+this.statusMessages[status[i]];
$list.append($("<option></option>").attr("disabled",status[i]== "running"||status[i]== "starting").attr("value", (i+1)).text(key).addClass(status[i]).attr("selected", (i+1)== multiplayer.roomId));
};
},
};
在多人游戏对象中,我们首先定义一个 start()方法,该方法试图初始化一个到服务器的 WebSocket 连接,并将其保存在 websocket 变量中。然后,我们将 websocket 对象的 onmessage 事件处理程序设置为调用 handleWebSocketMessage()方法,并使用 onopen 事件处理程序在连接打开后显示大厅屏幕。
接下来,我们定义 handleWebSocketMessage()方法来处理消息数据。我们将使用 JSON.parse()和 JSON.stringify()在对象和字符串之间进行转换,从而在服务器和浏览器之间传递完整的对象,而不是像前面的 WebSocket 示例那样传递字符串。我们首先将消息数据解析为 messageObject 变量,然后使用解析对象的 type 属性来决定如何处理消息。
如果 type 属性设置为 room_list,我们将调用 updateRoomStatus()方法并向其传递 Status 属性。
最后,我们定义了一个 updateRoomStatus()方法,该方法接受一组状态消息并填充 multiplayergameslist select 元素。我们禁用任何状态为 starting 或 running 的选项,并将选项的 CSS 类设置为 status。
接下来,我们需要在 index.html 的头部添加一个对 multiplayer.js 的引用,如清单 11-7 所示。
清单 11-7。 添加对 multiplayer . js(index.html)的引用
<script src="js/multiplayer.js" type="text/javascript" charset="utf-8"></script>
最后,我们将在一个名为 server.js 的新文件中定义我们的多人 WebSocket 服务器,如清单 11-8 所示。
清单 11-8。 定义多人服务器(server.js)
var WebSocketServer = require('websocket').server;
var http = require('http');
// Create a simple web server that returns the same response for any request
var server = http.createServer(function(request,response){
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end("This is the node.js HTTP server.");
});
server.listen(8080,function(){
console.log('Server has started listening on port 8080');
});
var wsServer = new WebSocketServer({
httpServer:server,
autoAcceptConnections: false
});
// Logic to determine whether a specified connection is allowed.
function connectionIsAllowed(request){
// Check criteria such as request.origin, request.remoteAddress
return true;
}
// Initialize a set of rooms
var gameRooms = [];
for (var i=0; i < 10; i++) {
gameRooms.push({status:"empty",players:[],roomId:i+1});
};
var players = [];
wsServer.on('request',function(request){
if(!connectionIsAllowed(request)){
request.reject();
console.log('Connection from' + request.remoteAddress + 'rejected.');
return;
}
var connection = request.accept();
console.log('Connection from' + request.remoteAddress + 'accepted.');
// Add the player to the players array
var player = {
connection:connection
}
players.push(player);
// Send a fresh game room status list the first time player connects
sendRoomList(connection);
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
// Handle different message types
}
}
});
connection.on('close', function(reasonCode, description) {
console.log('Connection from' + request.remoteAddress + 'disconnected.');
for (var i = players.length - 1; i >= 0; i--){
if (players[i]==player){
players.splice(i,1);
}
};
});
});
function sendRoomList(connection){
var status = [];
for (var i=0; i < gameRooms.length; i++) {
status.push(gameRooms[i].status);
};
var clientMessage = {type:"room_list",status:status};
connection.send(JSON.stringify(clientMessage));
}
我们从定义 HTTP 服务器和 WebSocketServer 开始,就像我们在前面的 websocketdemo 示例中所做的那样。
接下来,我们定义一个 rooms 数组,并用十个 room 对象填充它,这些对象的 status 属性设置为 empty。
最后,我们实现了连接请求事件处理程序。我们首先为连接创建一个 player 对象,并将其添加到 players 数组中。然后,我们为该连接调用 sendRoomList()方法。
接下来,我们为连接实现消息事件处理程序,以解析消息数据并基于类型属性做出响应,就像我们在客户端所做的那样。我们尚未处理任何消息类型。
接下来,我们实现 close 事件处理程序,一旦连接关闭,我们就从 players 数组中删除播放器。
最后,我们创建一个 sendRoomList()方法,该方法在 room_list 类型的消息对象中发送一个状态数组。这与我们将在客户端解析的消息相同。
如果我们运行新创建的 server.js,然后在浏览器中打开我们的游戏,我们应该能够点击多人游戏菜单选项并到达多人游戏大厅屏幕,如图图 11-3 所示。
图 11-3。多人游戏大厅屏幕
在后台,客户机创建一个到服务器的套接字连接,服务器向客户机发回一个 room_list 消息,然后用它来填充列表。
你应该可以选择任何游戏室,但不能加入或离开这些房间。我们现在将实现加入和离开游戏室。
加入和离开游戏室
我们将从在多人游戏对象中实现 join()和 cancel()方法开始,如清单 11-9 所示。
清单 11-9。 实现 join()和 cancel() (multiplayer.js)
join:function(){
var selectedRoom = document.getElementById('multiplayergameslist').value;
if(selectedRoom){
multiplayer.sendWebSocketMessage({type:"join_room",roomId:selectedRoom});
document.getElementById('multiplayergameslist').disabled = true;
document.getElementById('multiplayerjoin').disabled = true;
} else {
game.showMessageBox("Please select a game room to join.");
}
},
cancel:function(){
// Leave any existing game room
if(multiplayer.roomId){
multiplayer.sendWebSocketMessage({type:"leave_room",roomId:multiplayer.roomId});
document.getElementById('multiplayergameslist').disabled = false;
document.getElementById('multiplayerjoin').disabled = false;
delete multiplayer.roomId;
delete multiplayer.color;
return;
} else {
// Not in a room, so leave the multiplayer screen itself
multiplayer.closeAndExit();
}
},
closeAndExit:function(){
// clear handlers and close connection
multiplayer.websocket.onopen = null;
multiplayer.websocket.onclose = null;
multiplayer.websocket.onerror = null;
multiplayer.websocket.close();
document.getElementById('multiplayergameslist').disabled = false;
document.getElementById('multiplayerjoin').disabled = false;
// Show the starting menu layer
$('.gamelayer').hide();
$('#gamestartscreen').show();
},
sendWebSocketMessage:function(messageObject){
this.websocket.send(JSON.stringify(messageObject));
},
在 join()方法中,我们检查是否选择了一个房间,如果选择了,就用 roomId 属性向服务器发送一个 join_room WebSocket 消息。然后我们禁用加入按钮和游戏列表。如果没有选择房间,我们要求玩家先选择一个房间。
在 cancel()方法中,我们首先使用 multiplayer.roomId 属性检查玩家是否在一个房间中。如果是这样,我们向服务器发送一个 leave_room WebSocket 消息,删除 roomId 和 color 属性,并启用 Join 按钮和游戏列表选择元素。如果没有,我们使用 closeAndExit()方法关闭套接字连接并返回到游戏开始屏幕。
在 closeAndExit()方法中,我们首先清除 websocket 对象的事件处理程序并关闭连接。然后,我们启用加入按钮和游戏列表,并返回到游戏开始屏幕。
最后,我们定义了一个 sendWebSocketMessage(),它将 messageObject 转换为一个字符串并将其发送到服务器。
接下来,我们将通过修改 server.js 中的消息事件处理程序来修改服务器以处理 join_room 和 leave_room 消息类型,如清单 11-10 所示。
清单 11-10。 处理加入房间和离开房间消息(server.js)
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
case "join_room":
var room = joinRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
case "leave_room":
leaveRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
}
}
});
当 join_room 消息进来时,我们首先调用 joinRoom()方法,然后使用 sendRoomListToEveryone()方法将房间列表发送给所有玩家。同样,当一个 leave_room 消息进来时,我们首先调用 leaveRoom()方法,然后调用 sendRoomListToEveryone()方法。
接下来,我们将在 server.js 中定义这三个新方法,如清单 11-11 所示。
清单 11-11。join room()、leaveRoom()和 sendRoomListToEveryone()方法(server.js)
function sendRoomListToEveryone(){
// Notify all connected players of the room status changes
var status = [];
for (var i=0; i < gameRooms.length; i++) {
status.push(gameRooms[i].status);
};
var clientMessage = {type:"room_list",status:status};
var clientMessageString = JSON.stringify(clientMessage);
for (var i=0; i < players.length; i++) {
players[i].connection.send(clientMessageString);
};
}
function joinRoom(player,roomId){
var room = gameRooms[roomId-1];
console.log("Adding player to room",roomId);
// Add the player to the room
room.players.push(player);
player.room = room;
// Update room status
if(room.players.length == 1){
room.status = "waiting";
player.color = "blue";
} else if (room.players.length == 2){
room.status = "starting";
player.color = "green";
}
// Confirm to player that he was added
var confirmationMessageString = JSON.stringify({type:"joined_room", roomId:roomId, color:player.color});
player.connection.send(confirmationMessageString);
return room;
}
function leaveRoom(player,roomId){
var room = gameRooms[roomId-1];
console.log("Removing player from room",roomId);
for (var i = room.players.length - 1; i >= 0; i--){
if(room.players[i]==player){
room.players.splice(i,1);
}
};
delete player.room;
// Update room status
if(room.players.length == 0){
room.status = "empty";
} else if (room.players.length == 1){
room.status = "waiting";
}
}
在 sendRoomListToEveryone()方法中,我们遍历 players 数组中的所有玩家,并向他们发送一条包含房间列表的 room_list 消息。
在 joinRoom()方法中,我们首先使用 roomId 获取房间对象,并将玩家添加到房间对象的玩家数组中。然后,我们根据房间里有多少玩家,将房间的状态设置为等待或开始。我们还根据玩家是第一个还是第二个加入房间的玩家,将玩家的颜色设置为蓝色或绿色。最后,我们向玩家发送一个 joined_room 消息,其中包含房间 ID 和玩家颜色的详细信息。
在 leaveRoom()方法中,我们首先使用 roomId 获取房间对象,并从房间对象的 players 数组中移除播放器。然后,我们根据房间中有多少玩家,将房间对象的状态设置为空或等待。
我们要做的下一个改变是在 multiplayer.js 中处理 joined_room 确认消息,如清单 11-12 中的所示。
清单 11-12。 处理加入 _ 房间消息(multiplayer.js)
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
case "joined_room":
multiplayer.roomId = messageObject.roomId;
multiplayer.color = messageObject.color;
break;
}
},
当 joined_room 消息进来时,我们将 roomId 和 color 属性保存在多人游戏对象中。
最后,我们将通过修改服务器上的 close 事件处理程序来确保如果玩家断开连接,玩家将被移出游戏室,如清单 11-13 所示。
清单 11-13。 处理玩家断线(server.js)
connection.on('close', function(reasonCode, description) {
console.log('Connection from' + request.remoteAddress + 'disconnected.');
for (var i = players.length - 1; i >= 0; i--){
if (players[i]==player){
players.splice(i,1);
}
};
// If the player is in a room, remove him from room and notify everyone
if(player.room){
var status = player.room.status;
var roomId = player.room.roomId;
// If the game was running, end the game as well
leaveRoom(player,roomId);
sendRoomListToEveryone();
}
});
在新添加的代码中,我们检查断开连接的玩家是否在房间中,如果是,我们使用 leaveRoom()方法将玩家从房间中移除,然后使用 sendRoomListToEveryone()方法通知每个人。
如果我们重启服务器并在多个浏览器窗口中运行游戏,我们应该能够在一个窗口中加入一个房间,并在两个窗口中看到状态变化,如图图 11-4 所示。
图 11-4。加入房间时,房间状态会在两个浏览器上更新
您会注意到,加入房间后,“加入”按钮和列表会被禁用。如果您在两个浏览器上加入同一个房间,房间状态将更改为“开始”,其他人无法加入房间。
如果您单击“取消”,您将离开房间,加入按钮将重新启用。如果您再次单击取消,您将返回到主菜单。如果通过关闭浏览器窗口断开与服务器的连接,您将被移出房间。
我们现在有一个工作游戏大厅,玩家可以加入和离开游戏室。接下来,一旦玩家加入游戏室,我们将开始多人游戏。
开始多人游戏
我们的多人游戏将在两名玩家加入游戏室后开始。我们需要告诉两个客户端加载相同的级别。一旦关卡在两个浏览器上都加载了,我们就开始游戏。我们需要做的第一件事是定义一个新的多人游戏关卡。
定义多人游戏关卡
多人游戏关卡与单人游戏关卡相似,将包含一些额外的信息,如每个玩家的起始位置和每个团队的起始物品。我们将从在地图对象中定义一个简单的关卡开始,如清单 11-14 所示。
清单 11-14。 一个多人关卡里面的多人阵列(maps.js)
"multiplayer":[
{
/* Map Details */
"mapImage":"img/level-one.png",
/* Map coordinates that are obstructed by terrain*/
"mapGridWidth":60,
"mapGridHeight":40,
"mapObstructedTerrain":[
[49,8], [50,8], [51,8], [51,9], [52,9], [53,9], [53,10], [53,11], [53,12], [53,13],
[53,14], [53,15], [53,16], [52,16], [52,17], [52,18], [52,19], [51,19], [50,19], [50,18], [50,17],
[49,17], [49,18], [48,18], [47,18], [47,17], [47,16], [48,16], [49,16], [49,15], [49,14], [48,14],
[48,13], [48,12], [49,12], [49,11], [50,11], [50,10], [49,10], [49,9], [44,0], [45,0], [45,1],
[45,2], [46,2], [47,2], [47,3], [48,3], [48,4], [48,5], [49,5], [49,6], [49,7], [50,7], [51,7],
[51,6], [51,5], [51,4], [52,4], [53,4], [53,3], [54,3], [55,3], [55,2], [56,2], [56,1], [56,0],
[55,0], [43,19], [44,19], [45,19], [46,19], [47,19], [48,19], [48,20], [48,21], [47,21], [46,21],
[45,21], [44,21], [43,21], [43,20], [41,22], [42,22], [43,22], [44,22], [45,22], [46,22], [47,22],
[48,22], [49,22], [50,22], [50,23], [50,24], [49,24], [48,24], [47,24], [47,25], [47,26], [47,27],
[47,28], [47,29], [47,30], [46,30], [45,30], [44,30], [43,30], [43,29], [43,28], [43,27], [43,26],
[43,25], [43,24], [42,24], [41,24], [41,23], [48,39], [49,39], [50,39], [51,39], [52,39], [53,39],
[54,39], [55,39], [56,39], [57,39], [58,39], [59,39], [59,38], [59,37], [59,36], [59,35], [59,34],
[59,33], [59,32], [59,31], [59,30], [59,29], [0,0], [1,0], [2,0], [1,1], [2,1], [10,3], [11,3],
[12,3], [12,2], [13,2], [14,2], [14,3], [14,4], [15,4], [15,5], [15,6], [14,6], [13,6], [13,5],
[12,5], [11,5], [10,5], [10,4], [3,9], [4,9], [5,9], [5,10], [6,10], [7,10], [8,10], [9,10], [9,11],
[10,11], [11,11], [11,10], [12,10], [13,10], [13,11], [13,12], [12,12], [11,12], [10,12], [9,12],
[8,12], [7,12], [7,13], [7,14], [6,14], [5,14], [5,13], [5,12], [5,11], [4,11], [3,11], [3,10],
[33,33], [34,33], [35,33], [35,34], [35,35], [34,35], [33,35], [33,34], [27,39], [27,38], [27,37],
[28,37], [28,36], [28,35], [28,34], [28,33], [28,32], [28,31], [28,30], [28,29], [29,29], [29,28],
[29,27], [29,26], [29,25], [29,24], [29,23], [30,23], [31,23], [32,23], [32,22], [32,21], [31,21],
[30,21], [30,22], [29,22], [28,22], [27,22], [26,22], [26,21], [25,21], [24,21], [24,22], [24,23],
[25,23], [26,23], [26,24], [25,24], [25,25], [24,25], [24,26], [24,27], [25,27], [25,28], [25,29],
[24,29], [23,29], [23,30], [23,31], [24,31], [25,31], [25,32], [25,33], [24,33], [23,33], [23,34],
[23,35], [24,35], [24,36], [24,37], [23,37], [22,37], [22,38], [22,39], [23,39], [24,39], [25,39],
[26,0], [26,1], [25,1], [25,2], [25,3], [26,3], [27,3], [27,2], [28,2], [29,2], [29,3], [30,3],
[31,3], [31,2], [31,1], [32,1], [32,0], [33,0], [32,8], [33,8], [34,8], [34,9], [34,10], [33,10],
[32,10], [32,9], [8,29], [9,29], [9,30], [17,32], [18,32], [19,32], [19,33], [18,33], [17,33],
[18,34], [19,34], [3,27], [4,27], [4,26], [3,26], [2,26], [3,25], [4,25], [9,20], [10,20], [11,20],
[11,21], [10,21], [10,19], [19,7], [15,7], [29,12], [30,13], [20,14], [21,14], [34,13], [35,13],
[36,13], [36,14], [35,14], [34,14], [35,15], [36,15], [16,18], [17,18], [18,18], [16,19], [17,19],
[18,19], [17,20], [18,20], [11,19], [58,0], [59,0], [58,1], [59,1], [59,2], [58,3], [59,3], [58,4],
[59,4], [59,5], [58,6], [59,6], [58,7], [59,7], [59,8], [58,9], [59,9], [58,10], [59,10], [59,11],
[52,6], [53,6], [54,6], [52,7], [53,7], [54,7], [53,8], [54,8], [44,17], [46,32], [55,32], [54,28],
[26,34], [34,34], [4,10], [6,11], [6,12], [6,13], [7,11], [8,11], [12,11], [27,0], [27,1], [26,2],
[28,1], [28,0], [29,0], [29,1], [30,2], [30,1], [30,0], [31,0], [33,9], [46,0], [47,0], [48,0],
[49,0], [50,0], [51,0], [52,0], [53,0], [54,0], [55,1], [54,1], [53,1], [52,1], [51,1], [50,1],
[49,1], [48,1], [47,1], [46,1], [48,2], [49,2], [50,2], [51,2], [52,2], [53,2], [54,2], [52,3],
[51,3], [50,3], [49,3], [49,4], [50,4], [50,5], [50,6], [50,9], [51,10], [52,10], [51,11], [52,11],
[50,12], [51,12], [52,12], [49,13], [50,13], [51,13], [52,13], [50,14], [51,14], [52,14], [50,15],
[51,15], [52,15], [50,16], [51,16], [51,17], [48,17], [51,18], [44,20], [45,20], [46,20], [47,20],
[42,23], [43,23], [44,23], [45,23], [46,23], [47,23], [48,23], [49,23], [44,24], [45,24], [46,24],
[44,25], [45,25], [46,25], [44,26], [45,26], [46,26], [44,27], [45,27], [46,27], [44,28], [45,28],
[46,28], [44,29], [45,29], [46,29], [11,4], [12,4], [13,4], [13,3], [14,5], [25,22], [31,22],
[27,23], [28,23], [27,24], [28,24], [26,25], [27,25], [28,25], [25,26], [26,26], [27,26], [28,26],
[26,27], [27,27], [28,27], [26,28], [27,28], [28,28], [26,29], [27,29], [24,30], [25,30], [26,30],
[27,30], [26,31], [27,31], [26,32], [27,32], [26,33], [27,33], [24,34], [25,34], [27,34], [25,35],
[26,35], [27,35], [25,36], [26,36], [27,36], [25,37], [26,37], [23,38], [24,38], [25,38], [26,38],
[26,39], [2,25], [9,19], [36,31]
],
/* Entities to be loaded */
"requirements":{
"buildings":["base","harvester","starport","ground-turret"],
"vehicles":["transport","scout-tank","heavy-tank","harvester"],
"aircraft":["wraith","chopper"],
"terrain":["oilfield"]
},
/* Economy Related*/
"cash":{
"blue":1000,
"green":1000
},
/* Entities to be added */
"items":[
{"type":"terrain","name":"oilfield","x":16,"y":4,"action":"hint"},
{"type":"terrain","name":"oilfield","x":34,"y":12,"action":"hint"},
{"type":"terrain","name":"oilfield","x":1,"y":30,"action":"hint"},
{"type":"terrain","name":"oilfield","x":38,"y":38,"action":"hint"},
],
/* Entities for each starting team */
"teamStartingItems":[
{"type":"buildings","name":"base","x":0,"y":0},
{"type":"vehicles","name":"harvester","x":2,"y":0},
{"type":"vehicles","name":"heavy-tank","x":2,"y":1},
{"type":"vehicles","name":"scout-tank","x":3,"y":0},
{"type":"vehicles","name":"scout-tank","x":3,"y":1},
],
"spawnLocations":[
{ "x":48, "y":36,"startX":36,"startY":20},
{ "x":3, "y":36,"startX":0,"startY":20},
{ "x":36, "y":3,"startX":32,"startY":0},
{ "x":3, "y":3,"startX":0,"startY":0},
],
/* Conditional and Timed Trigger Events */
"triggers":[
]
}
]
我们在多人游戏中引入的两个新元素是 teamStartingItems 和 spawnLocations 数组。
teamStartingItems 数组包含了每个团队在关卡开始时将拥有的物品列表。x 和 y 坐标将相对于团队产生的位置。
spawnLocations 数组包含地图上每个玩家团队可以开始的几个点。每个对象包含该位置的 x 和 y 坐标,以及该位置的起始平移偏移量。
现在我们已经定义了多人游戏关卡,我们需要在两个玩家加入游戏室后加载关卡。
载入多人游戏关卡
当两个玩家加入一个房间时,我们会告诉他们初始化关卡,并等待他们确认关卡已经初始化。一旦发生这种情况,我们需要告诉他们开始游戏。
我们将从向 server.js 添加一些新方法来处理游戏的初始化和启动开始,如清单 11-15 所示。
清单 11-15。 初始化并开始游戏(server.js)
function initGame(room){
console.log("Both players Joined. Initializing game for Room "+room.roomId);
// Number of players who have loaded the level
room.playersReady = 0;
// Load the first multiplayer level for both players
// This logic can change later to let the players pick a level
var currentLevel = 0;
// Randomly select two spawn locations between 0 and 3 for both players.
var spawns = [0,1,2,3];
var spawnLocations = {"blue":spawns.splice(Math.floor(Math.random()*spawns.length),1), "green":spawns.splice(Math.floor(Math.random()*spawns.length),1)};
sendRoomWebSocketMessage(room,{type:"init_level", spawnLocations:spawnLocations, level:currentLevel});
}
function startGame(room){
console.log("Both players are ready. Starting game in room",room.roomId);
room.status = "running";
sendRoomListToEveryone();
// Notify players to start the game
sendRoomWebSocketMessage(room,{type:"start_game"});
}
function sendRoomWebSocketMessage(room,messageObject){
var messageString = JSON.stringify(messageObject);
for (var i = room.players.length - 1; i >= 0; i--){
room.players[i].connection.send(messageString);
};
}
在 initGame()方法中,我们初始化 playersReady 变量,为两个玩家选择两个随机的种子位置,并使用 sendRoomWebSocketMessage()方法在 init_level 消息中将位置发送给两个玩家。
在 startGame()方法中,我们将房间状态设置为 running,更新每个玩家的房间列表,最后使用 sendRoomWebSocketMessage()方法向两个玩家发送 start_game 消息。
最后,在 sendRoomWebSocketMessage()方法中,我们遍历房间中的玩家,并向他们每个人发送给定的消息。
接下来,我们将修改 server.js 中的消息事件处理程序来初始化并启动游戏,如清单 11-16 所示。
清单 11-16。 修改消息事件处理程序(server.js)
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
case "join_room":
var room = joinRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
if(room.players.length == 2){
initGame(room);
}
break;
case "leave_room":
leaveRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
case "initialized_level":
player.room.playersReady++;
if (player.room.playersReady==2){
startGame(player.room);
}
break;
}
}
});
当一个玩家加入一个房间并且玩家数量达到两个时,我们调用 initGame()方法。当我们收到来自玩家的 initialized_level 消息时,我们递增 playersReady 变量。一旦计数达到 2,我们就调用 startGame()方法。
接下来我们将向多人游戏对象添加两个新方法来初始化多人游戏关卡并开始游戏,如清单 11-17 所示。
清单 11-17。 初始化并开始多人游戏(multiplayer.js)
currentLevel:0,
initMultiplayerLevel:function(messageObject){
$('.gamelayer').hide();
var spawnLocations = messageObject.spawnLocations;
// Initialize multiplayer related variables
multiplayer.commands = [[]];
multiplayer.lastReceivedTick = 0;
multiplayer.currentTick = 0;
game.team = multiplayer.color;
// Load all the items for the level
multiplayer.currentLevel = messageObject.level;
var level = maps.multiplayer[multiplayer.currentLevel];
// Load all the assets for the level
game.currentMapImage = loader.loadImage(level.mapImage);
game.currentLevel = level;
// Setup offset based on spawn location sent by server
// Load level Requirements
game.resetArrays();
for (var type in level.requirements){
var requirementArray = level.requirements[type];
for (var i=0; i < requirementArray.length; i++) {
var name = requirementArray[i];
if (window[type]){
window[type].load(name);
} else {
console.log('Could not load type :',type);
}
};
}
for (var i = level.items.length - 1; i >= 0; i--){
var itemDetails = level.items[i];
game.add(itemDetails);
};
// Add starting items for both teams at their respective spawn locations
for (team in spawnLocations){
var spawnIndex = spawnLocations[team];
for (var i=0; i < level.teamStartingItems.length; i++) {
var itemDetails = $.extend(true,{},level.teamStartingItems[i]);
itemDetails.x += level.spawnLocations[spawnIndex].x+itemDetails.x;
itemDetails.y += level.spawnLocations[spawnIndex].y+itemDetails.y;
itemDetails.team = team;
game.add(itemDetails);
};
if (team==game.team){
game.offsetX = level.spawnLocations[spawnIndex].startX*game.gridSize;
game.offsetY = level.spawnLocations[spawnIndex].startY*game.gridSize;
}
}
// Create a grid that stores all obstructed tiles as 1 and unobstructed as 0
game.currentMapTerrainGrid = [];
for (var y=0; y < level.mapGridHeight; y++) {
game.currentMapTerrainGrid[y] = [];
for (var x=0; x< level.mapGridWidth; x++) {
game.currentMapTerrainGrid[y][x] = 0;
}
};
for (var i = level.mapObstructedTerrain.length - 1; i >= 0; i--){
var obstruction = level.mapObstructedTerrain[i];
game.currentMapTerrainGrid[obstruction[1]][obstruction[0]] = 1;
};
game.currentMapPassableGrid = undefined;
// Load Starting Cash For Game
game.cash = $.extend([],level.cash);
// Enable the enter mission button once all assets are loaded
if (loader.loaded){
multiplayer.sendWebSocketMessage({type:"initialized_level"});
} else {
loader.onload = function(){
multiplayer.sendWebSocketMessage({type:"initialized_level"});
}
}
},
startGame:function(){
fog.initLevel();
game.animationLoop();
game.start();
},
在 initMultiplayerLevel()方法中,我们首先初始化一些与多人游戏相关的变量,这些变量我们以后会用到。然后我们初始化 game.team 和 multiplayer.currentLevel 变量。然后我们加载多人地图,就像我们加载单人战役一样。
接下来,我们将两个玩家的所有起始物品放在他们各自的产卵位置,并根据他们的产卵位置为每个玩家设置偏移位置。
然后,我们像对单人游戏那样加载地形网格,最后,一旦地图加载完毕,我们就将 initialized_level 消息发送回服务器,让服务器知道客户端已经完成了关卡的加载。
在 startGame()方法中,我们初始化雾,调用 animationLoop()一次,最后调用 game.start()。
接下来,我们将修改 multiplayer.js 中的 handleWebSocketMessage()方法来调用这些新创建的方法,如清单 11-18 所示。
清单 11-18。 修改消息处理程序初始化并开始游戏(multiplayer.js)
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
case "joined_room":
multiplayer.roomId = messageObject.roomId;
multiplayer.color = messageObject.color;
break;
case "init_level":
multiplayer.initMultiplayerLevel(messageObject);
break;
case "start_game":
multiplayer.startGame();
break;
}
},
当我们收到 init_level 消息时,我们只调用 initMultiplayerLevel()方法,当我们收到 start_game 消息时,我们只调用 startGame()方法。
如果我们重启服务器,在两个浏览器窗口中运行游戏,我们应该能够从两个浏览器加入同一个房间,并在两个浏览器中查看游戏加载,如图图 11-5 所示。
图 11-5。多人游戏载入两个浏览器窗口
一旦两个玩家加入房间,服务器会自动分配给两个玩家不同的颜色和产卵位置。当游戏载入时,两个玩家都被放置在各自的产卵位置,并有相同的开始队伍:两辆侦察坦克,一辆重型坦克和一辆收割机。
我们可以滚动地图,甚至选择单位;然而,我们仍然不能通过给这些单位命令来玩游戏。这是我们将在下一章实现的。
摘要
在这一章中,我们研究了在一个简单的客户端-服务器架构中使用 WebSocket API 和 Node.js。首先,我们安装了 Node.js 和 WebSocket-Node 包,并使用它构建了一个简单的 WebSocket 服务器。然后,我们构建了一个简单的基于 WebSocket 的浏览器客户端,并在浏览器和服务器之间来回发送消息。
我们使用相同的架构实现了一个多人游戏大厅,玩家可以加入和离开房间。我们设计了一个多人游戏关卡,有产卵地点和出发队伍。最后,我们在两个不同的浏览器上加载并启动了相同的关卡,同时将两个玩家放在不同的种子位置。
在下一章,我们将通过在浏览器和服务器之间传递命令来实现真正的多人游戏。我们将使用触发器来实现游戏的输赢。最后,我们将添加一些收尾工作,结束我们游戏的多人部分。
十二、多人游戏
在前一章中,我们看到了如何使用 WebSocket API 和 Node.js 服务器来实现简单的客户机-服务器网络架构。我们用它建立了一个简单的游戏大厅,这样两个玩家现在可以加入一个服务器上的游戏室,开始一个多人对战游戏。
在这一章中,我们将继续我们在第十一章结束时离开的地方,并使用锁步网络模型实现一个实际多人游戏的框架。我们将看看如何处理典型的游戏网络问题,如延迟和游戏同步。然后,我们将使用我们在前面章节中设计的 sendCommand()体系结构来确保玩家的命令在两种浏览器上都能执行,从而使游戏保持同步。然后我们将像在第十章中那样使用触发器来实现游戏中的输赢。最后,我们将为我们的游戏实现一个聊天系统。
我们开始吧。
锁步网络模型
到目前为止,我们使用 Node.js 服务器来传递简单的消息,例如游戏大厅状态和加入或离开房间。这些信息相互独立,一个玩家的信息不会影响另一个玩家。然而,当涉及到游戏时,这种交流会变得稍微复杂一些。
构建多人游戏最重要的挑战之一是确保所有玩家同步。这意味着每次游戏中发生变化时(例如,一个玩家发出移动或攻击命令),该变化会传达给其他玩家,以便他们也可以做出相同的变化。
更重要的是,动作或变化同时发生在两个玩家的机器上。如果在执行这些改变时有延迟,单位位置的细微差异将最终积累,导致游戏状态之间明显的差异。
举例来说,一个单位在到达敌人的位置时只晚了半秒钟,可能会躲过敌人的攻击并在一个浏览器中存活下来,而同一个单位可能会在另一个玩家的浏览器中被摧毁。当这样的事情发生时,两个玩家正在玩两个完全不同的游戏,而不是同一个游戏。
为了确保两个玩家完全同步,我们将实现一个被称为锁步网络模型的架构。两个玩家将以相同的游戏状态开始。当玩家给一个单位下命令时,我们会把命令发送到服务器,而不是立即执行。然后,服务器将向连接的玩家发送相同的命令,并指示何时执行该命令。一旦玩家收到命令,他们将同时执行,确保游戏保持同步。
服务器将通过运行自己的游戏计时器来实现这一行为,其速度为每秒 10 个时钟周期。当玩家向服务器发送命令时,服务器将记录收到命令时的时钟滴答声。然后,服务器将命令发送给玩家,同时指定执行命令的游戏记号。玩家将依次跟踪当前的游戏节拍,并在正确的节拍执行命令。
需要记住的一点是,由于服务器需要同时执行所有玩家的命令,所以它需要等待所有玩家的命令到达,然后才能前进到下一个游戏节拍,这就是为什么它被称为锁步。
由于网络延迟会导致通信延迟,消息有时需要数百毫秒才能在客户端和服务器之间传输,因此这一过程变得更加复杂。我们的网络模型需要测量并考虑这种延迟,以确保游戏流畅。
我们将从修改游戏代码开始,在玩家第一次连接到服务器时测量每个玩家的网络延迟。
测量网络延迟
出于我们的目的,我们将延迟定义为消息从服务器传输到客户端所花费的时间。我们将通过在服务器和客户端之间来回发送几条消息来测量这种延迟,然后取每次传输所用时间的平均值。
我们将从定义两种测量 server.js 内部延迟的新方法开始,如清单 12-1 所示。
清单 12-1。 测量网络延迟的方法(server.js)
function measureLatency(player){
var connection = player.connection;
var measurement = {start:Date.now()};
player.latencyTrips.push(measurement);
var clientMessage = {type:"latency_ping"};
connection.send(JSON.stringify(clientMessage));
}
function finishMeasuringLatency(player,clientMessage){
var measurement = player.latencyTrips[player.latencyTrips.length-1];
measurement.end = Date.now();
measurement.roundTrip = measurement.end - measurement.start;
player.averageLatency = 0;
for (var i=0; i < player.latencyTrips.length; i++) {
player.averageLatency += measurement.roundTrip/2;
};
player.averageLatency = player.averageLatency/player.latencyTrips.length;
player.tickLag = Math.round(player.averageLatency * 2/100)+1;
console.log("Measuring Latency for player. Attempt", player.latencyTrips.length, "- Average Latency:",player.averageLatency, "Tick Lag:", player.tickLag);
}
在 measureLatency()方法中,我们首先创建一个新的 measurement 对象,将 start 属性设置为当前时间,并将该对象添加到 player.latencyTrips 数组中。然后,我们向播放器发送 latency_ping 类型的消息。播放器将通过发回类型为 latency_pong 的消息来响应此消息。
在 finishMeasuringLatency()方法中,我们从 player.latencyTrips 数组中获取最后一次测量值,并将其 end 属性设置为当前时间,将其 roundTrip 属性设置为结束时间和开始时间之间的差值。
然后,我们计算玩家的平均等待时间,方法是将所有往返值相加,然后将总和除以往返次数。
最后,我们使用 averageLatency 来计算玩家的 tickLag 属性。这是发送命令后,玩家可以安全地预期已经收到命令的滴答数。启发式算法使用的值是典型延迟的 200 %,最小值为一个游戏节拍。
如果你愿意,你可以尝试这种启发式方法,并对它进行精确的微调;但是,出于游戏流畅的目的,高值更安全。人们发现,只要延迟一致,玩家就能够习惯网络延迟并自动进行调整。任何时候滞后变化太大,玩家往往会感到沮丧。
接下来我们将修改多人游戏对象的 handleWebSocketMessage()方法来响应服务器的 latency_ping 消息,如清单 12-2 所示。
清单 12-2。 响应 latency_ping 用 latency_pong (multiplayer.js)
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
case "joined_room":
multiplayer.roomId = messageObject.roomId;
multiplayer.color = messageObject.color;
break;
case "init_level":
multiplayer.initMultiplayerLevel(messageObject);
break;
case "start_game":
multiplayer.startGame();
break;
case "latency_ping":
multiplayer.sendWebSocketMessage({type:"latency_pong"});
break;
}
},
当浏览器从服务器收到 latency_ping 消息时,它会立即向服务器发回 latency_pong 消息。
最后,我们将修改服务器上 websocket 对象的请求事件处理程序,以便在播放器连接时开始测量延迟,并在播放器发回 latency_pong 响应时结束测量延迟,如清单 12-3 所示。
清单 12-3。 开始和结束延迟测量(server.js)
wsServer.on('request',function(request){
if(!connectionIsAllowed(request)){
request.reject();
console.log('Connection from ' + request.remoteAddress + ' rejected.');
return;
}
var connection = request.accept();
console.log('Connection from ' + request.remoteAddress + ' accepted.');
// Add the player to the players array
var player = {
connection:connection,
latencyTrips:[]
}
players.push(player);
// Send a fresh game room status list the first time player connects
sendRoomList(connection);
// Measure latency for player
measureLatency(player);
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
case "join_room":
var room = joinRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
if(room.players.length == 2){
initGame(room);
}
break;
case "leave_room":
leaveRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
case "initialized_level":
player.room.playersReady++;
if (player.room.playersReady==2){
startGame(player.room);
}
break;
case "latency_pong":
finishMeasuringLatency(player,clientMessage);
// Measure latency at least thrice
if(player.latencyTrips.length<3){
measureLatency(player);
}
break;
}
}
});
connection.on('close', function(reasonCode, description) {
console.log('Connection from ' + request.remoteAddress + ' disconnected.');
for (var i = players.length - 1; i >= 0; i--){
if (players[i]==player){
players.splice(i,1);
}
};
// If the player is in a room, remove him from room and notify everyone
if(player.room){
var status = player.room.status;
var roomId = player.room.roomId;
leaveRoom(player,roomId);
sendRoomListToEveryone();
}
});
});
我们首先向 player 对象添加 latencyTrips 数组,并在播放器连接后调用 measureLatency()。
然后我们修改消息处理程序来处理 latency_pong 类型的消息。当播放器用 latency_pong 消息响应 latency_ping 消息时,我们调用我们前面定义的 finishMeasuringLatency()方法。然后,我们检查是否至少有三个延迟测量值,如果没有,再次调用 measureLatency()方法。
现在,如果您启动服务器并运行游戏,服务器将进行三次尝试来测量延迟。使用浏览器的开发者控制台可以看到 Websocket 通信,如图图 12-1 所示。
图 12-1。观察开发人员控制台中的 websocket 通信
现在我们已经测量了玩家的延迟,是时候执行发送命令了。
发送命令
一旦游戏开始,我们将在服务器和客户端上维护一个带有游戏刻度号的游戏时钟。当一个玩家向服务器发送一个命令时,我们将把这个命令发送回客户端,并附上指令,以便在稍后使用 tickLag 计算的时间执行这个命令。
我们将从修改多人游戏对象的 handleWebSocketMessage()方法来接收 game_tick 消息中的命令开始,如清单 12-4 所示。
清单 12-4。 在 game_tick 消息中接收命令(multiplayer.js)
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
case "joined_room":
multiplayer.roomId = messageObject.roomId;
multiplayer.color = messageObject.color;
break;
case "init_level":
multiplayer.initMultiplayerLevel(messageObject);
break;
case "start_game":
multiplayer.startGame();
break;
case "latency_ping":
multiplayer.sendWebSocketMessage({type:"latency_pong"});
break;
case "game_tick":
multiplayer.lastReceivedTick = messageObject.tick;
multiplayer.commands[messageObject.tick] = messageObject.commands;
break;
}
},
当我们从服务器接收到一个 game_tick 消息,其中包含命令列表和命令需要执行的 tick 号时,我们将命令保存在 multiplayer.commands 数组中,然后更新 lastReceivedTick 变量。
接下来我们将实现游戏循环并处理发送命令,如清单 12-5 所示。
清单 12-5。 客户端发送命令(multiplayer.js)
startGame:function(){
fog.initLevel();
game.animationLoop();
multiplayer.animationInterval = setInterval(multiplayer.tickLoop, game.animationTimeout);
game.start();
},
sendCommand:function(uids,details){
multiplayer.sentCommandForTick = true;
multiplayer.sendWebSocketMessage({type:"command",uids:uids, details:details,currentTick:multiplayer.currentTick});
},
tickLoop:function(){
// if the commands for that tick have been received
// execute the commands and move on to the next tick
// otherwise wait for server to catch up
if(multiplayer.currentTick <= multiplayer.lastReceivedTick){
var commands = multiplayer.commands[multiplayer.currentTick];
if(commands){
for (var i=0; i < commands.length; i++) {
game.processCommand(commands[i].uids,commands[i].details);
};
}
game.animationLoop();
// In case no command was sent for this current tick, send an empty command to the server
// So that the server knows that everything is working smoothly
if (!multiplayer.sentCommandForTick){
multiplayer.sendCommand();
}
multiplayer.currentTick++;
multiplayer.sentCommandForTick = false;
}
},
首先,在 startGame()方法中,我们设置了一个间隔,在游戏开始时每隔 100 毫秒调用一次 tickLoop()方法。
接下来,在 sendCommand()方法中,我们向服务器发送命令类型的消息,其中包含命令的详细信息以及命令的 uid。
命令消息还包含当前的游戏滴答。这样,命令消息就像一个心跳,让服务器知道客户端当前在玩什么游戏。我们还将 sendCommandForTick 标志设置为 true。
在 tickLoop()方法中,我们检查是否收到了当前 tick 的命令。如果没有,我们将等待来自服务器的命令。
如果我们收到了 tick 的命令,我们将使用 game.processCommand()方法处理所有收到的命令。然后我们调用 game.animationLoop()方法。
如果到目前为止我们还没有发出任何命令,我们也向服务器发送一个空命令。
最后,我们增加游戏刻度数并清除 sentCommandForTick 标志。
现在客户机已经修改为发送和接收命令,我们将修改服务器来处理这些命令。
我们将从修改服务器上的消息事件处理程序来处理命令类型的消息开始,如清单 12-6 所示。
清单 12-6。 处理命令类型的消息(server.js)
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
case "join_room":
var room = joinRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
if(room.players.length == 2){
initGame(room);
}
break;
case "leave_room":
leaveRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
case "initialized_level":
player.room.playersReady++;
if (player.room.playersReady==2){
startGame(player.room);
}
break;
case "latency_pong":
finishMeasuringLatency(player,clientMessage);
// Measure latency at least thrice
if(player.latencyTrips.length<3){
measureLatency(player);
}
break;
case "command":
if (player.room && player.room.status=="running"){
if(clientMessage.uids){
player.room.commands.push({uids:clientMessage.uids, details:clientMessage.details});
}
player.room.lastTickConfirmed[player.color] = clientMessage.currentTick + player.tickLag;
}
break;
}
}
});
当服务器收到命令类型的消息时,我们检查消息是否有 uid。如果是这样,我们将命令存储在房间的命令数组中。否则,该消息只是一个心跳消息,没有需要保存的命令。然后,我们更新播放器的 lastTickConfirmed 属性。
接下来,我们将修改 server.js 中的 startGame()方法,如清单 12-7 所示。
清单 12-7。 修改 startGame()方法(server.js)
function startGame(room){
console.log("Both players are ready. Starting game in room",room.roomId);
room.status = "running";
sendRoomListToEveryone();
// Notify players to start the game
sendRoomWebSocketMessage(room,{type:"start_game"});
room.commands = [];
room.lastTickConfirmed = {"blue":0,"green":0};
room.currentTick = 0;
// Calculate tick lag for room as the max of both player's tick lags
var roomTickLag = Math.max(room.players[0].tickLag,room.players[1].tickLag);
room.interval = setInterval(function(){
// Confirm that both players have send in commands for up to present tick
if(room.lastTickConfirmed["blue"] >= room.currentTick && room.lastTickConfirmed["green"] >= room.currentTick){
// Commands should be executed after the tick lag
sendRoomWebSocketMessage(room,{type:"game_tick", tick:room.currentTick+roomTickLag, commands:room.commands});
room.currentTick++;
room.commands = [];
} else {
// One of the players is causing the game to lag. Handle appropriately
if(room.lastTickConfirmed["blue"] < room.currentTick){
console.log ("Room",room.roomId,"Blue is lagging on Tick:",room.currentTick,"by", room.currentTick-room.lastTickConfirmed["blue"]);
}
if(room.lastTickConfirmed["green"] < room.currentTick){
console.log ("Room",room.roomId,"Green is lagging on Tick:", room.currentTick, "by", room.currentTick-room.lastTickConfirmed["green"]);
}
}
},100);
}
当游戏开始时,我们初始化房间的命令数组、currentTick 和 lastTickConfirmed 对象。然后,我们计算房间的刻度滞后,作为两个玩家的最大刻度滞后,并将其保存在 roomTickLag 变量中。
然后,我们使用 setInterval()启动游戏的计时器循环。在这个循环中,我们首先检查两个玩家是否通过发送当前游戏滴答的命令赶上了服务器。
如果是这样,我们向玩家发送一个带有命令列表的 game_tick 消息,并要求他们在当前 tick 之后执行命令 roomTickLag ticks。这样,两个玩家将同时执行命令,即使消息需要一点时间到达玩家。
然后,我们清除服务器上的命令数组,并增加房间的 currentTick 变量。
如果服务器没有收到来自两个客户端的对当前节拍的确认,我们将向控制台记录一条消息,并且不增加节拍。您可以修改这段代码来检查服务器是否已经等待了很长时间,如果是这样,就向玩家发送一个通知,告诉他们服务器正在经历延迟。
如果你启动服务器并在两个不同的浏览器上运行游戏,你应该能够指挥单位并进行你的第一次多人对战,如图图 12-2 所示。
图 12-2。多人对战中的指挥单位
我们游戏的多人游戏部分现在可以运行了。现在两种浏览器都在同一台机器上。您可以将服务器代码移动到单独的 Node.js 机器上,并修改 multiplayer 对象以指向这个新服务器,而不是 localhost。如果你想迁移到公共服务器,你可以找到几个提供 Node.js 支持的主机提供商,比如 Nodester(nodester.com
)和 node jitsu(nodejitsu.com/
)。
现在我们已经实现了发送命令,我们将实现结束多人游戏。
结束多人游戏
多人游戏有两种结束方式。第一个是如果一个玩家通过满足等级要求来击败另一个玩家。另一种情况是玩家关闭浏览器或者与服务器断开连接。
当玩家被击败时结束游戏
我们将使用触发事件来结束游戏,就像我们在第十章中所做的一样。这给了我们设计不同类型的多人游戏关卡的灵活性,比如夺旗或死亡竞赛。我们只是被我们的想象力所限制。
现在,当一方被完全摧毁时,我们将使关卡结束。我们将从在多人游戏地图中创建一个简单的触发事件开始,如清单 12-8 所示。
清单 12-8。 结束多人关卡的触发器(maps.js)
/* Conditional and Timed Trigger Events */
"triggers":[
/* Lose if not even one item is left */
{"type":"conditional",
"condition":function(){
for (var i=0; i < game.items.length; i++) {
if(game.items[i].team == game.team){
return false;
}
};
return true;
},
"action":function(){
multiplayer.loseGame();
}
},
]
在条件触发器中,我们检查 game.items 数组是否包含至少一个属于玩家的项目。如果玩家没有剩余的物品,我们调用 loseGame()方法。
接下来我们将添加 loseGame()和 endGame()方法到多人游戏对象中,如清单 12-9 所示。
清单 12-9。 添加 loseGame()和 endgame()方法(multiplayer.js)
// Tell the server that the player has lost
loseGame:function(){
multiplayer.sendWebSocketMessage({type:"lose_game"});
},
endGame:function(reason){
game.running = false
clearInterval(multiplayer.animationInterval);
// Show reason for game ending, and on OK, exit multiplayer screen
game.showMessageBox(reason,multiplayer.closeAndExit);
}
在 loseGame()方法中,我们向服务器发送一条 lose_game 类型的消息,让它知道玩家输掉了游戏。
在 endGame()方法中,我们清除 game.running 标志和 multiplayer.animationInterval 间隔。然后我们显示一个消息框,说明结束游戏的原因,最后在单击消息框上的 OK 按钮后调用 multiplayer.closeAndExit()方法。
接下来,我们将在 server.js 中定义一个新的 endGame()方法,如清单 12-10 所示。
清单 12-10。 服务器残局()方法(server.js)
function endGame(room,reason){
clearInterval(room.interval);
room.status = "empty";
sendRoomWebSocketMessage(room,{type:"end_game",reason:reason})
for (var i = room.players.length - 1; i >= 0; i--){
leaveRoom(room.players[i],room.roomId);
};
sendRoomListToEveryone();
}
我们从清除游戏循环的间隔开始。然后,我们将 end_game 消息发送给房间中的所有玩家,原因作为参数提供。然后,我们将房间设置为空,并使用 leaveRoom()方法将所有玩家从房间中移除。最后,我们将更新后的房间列表发送给所有连接的玩家。
接下来,我们将修改服务器上的消息事件处理程序来处理 lose_game 类型的消息,如清单 12-11 所示。
清单 12-11。 处理 lose_game 类型的消息(server.js)
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
case "join_room":
var room = joinRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
if(room.players.length == 2){
initGame(room);
}
break;
case "leave_room":
leaveRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
case "initialized_level":
player.room.playersReady++;
if (player.room.playersReady==2){
startGame(player.room);
}
break;
case "latency_pong":
finishMeasuringLatency(player,clientMessage);
// Measure latency at least thrice
if(player.latencyTrips.length<3){
measureLatency(player);
}
break;
case "command":
if (player.room && player.room.status=="running"){
if(clientMessage.uids){
player.room.commands.push({uids:clientMessage.uids, details:clientMessage.details});
}
player.room.lastTickConfirmed[player.color] = clientMessage.currentTick + player.tickLag;
}
break;
case "lose_game":
endGame(player.room, "The "+ player.color +" team has been defeated.");
break;
}
}
});
当我们收到一个玩家发来的 lose_game 消息时,我们调用 endGame()方法,给出结束游戏的原因。
最后,我们将修改多人游戏对象的 handleWebSocketMessage()方法来接收 end_game 类型的消息,如清单 12-12 所示。
清单 12-12。 接收 end_game (multiplayer.js)类型的消息
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
case "joined_room":
multiplayer.roomId = messageObject.roomId;
multiplayer.color = messageObject.color;
break;
case "init_level":
multiplayer.initMultiplayerLevel(messageObject);
break;
case "start_game":
multiplayer.startGame();
break;
case "latency_ping":
multiplayer.sendWebSocketMessage({type:"latency_pong"});
break;
case "game_tick":
multiplayer.lastReceivedTick = messageObject.tick;
multiplayer.commands[messageObject.tick] = messageObject.commands;
break;
case "end_game":
multiplayer.endGame(messageObject.reason);
break;
}
},
当客户端收到 end_game 消息时,我们调用 multiplayer.endGame()并在消息中提供原因。
如果你启动服务器并运行游戏,当一个玩家摧毁所有其他玩家的单位和建筑时,你会看到一个消息框,如图 12-3 所示。
图 12-3。当一方击败另一方时,游戏结束
如果你点击 ok 按钮,你应该会回到主游戏菜单。您会注意到,当游戏结束时,大厅会自动显示房间为空,以便下一组玩家可以加入房间。
当玩家关闭浏览器或与服务器断开连接时,我们也会结束游戏。
当玩家断线时结束游戏
每当玩家在玩游戏时断开与服务器的连接,都会触发服务器上的 websocket close 事件。我们将通过修改服务器上的 close 事件处理程序来处理这个断开,如清单 12-13 所示。
清单 12-13。 处理玩家断线(server.js)
connection.on('close', function(reasonCode, description) {
console.log('Connection from ' + request.remoteAddress + ' disconnected.');
for (var i = players.length - 1; i >= 0; i--){
if (players[i]==player){
players.splice(i,1);
}
};
// If the player is in a room, remove him from room and notify everyone
if(player.room){
var status = player.room.status;
var roomId = player.room.roomId;
// If the game was running, end the game as well
if(status=="running"){
endGame(player.room, "The "+ player.color +" player has disconnected.");
} else {
leaveRoom(player,roomId);
}
sendRoomListToEveryone();
}
});
如果玩家在一个房间里,我们会将玩家从房间中移除,并将更新后的房间列表发送给每个人。如果游戏正在运行,我们也调用 endgame()方法,原因是玩家已经断开连接。
如果你启动服务器并开始一个多人游戏,当任何一个玩家断开连接时,你应该会看到一个断开消息,如图 12-4 所示。
图 12-4。玩家断线时显示的消息
单击“确定”按钮将带您回到主菜单屏幕。同样,大厅会自动显示房间为空,以便下一组玩家可以加入房间。
我们要处理的最后一件事是,如果出现连接错误并且连接丢失,就结束游戏。
失去连接时结束游戏
每当客户端与服务器断开连接或发生错误时,都会触发客户端上的错误或关闭事件。我们将通过在多人游戏对象的 start()方法中实现这些事件处理程序来处理这个问题,如清单 12-14 所示。
清单 12-14。 处理连接错误(multiplayer.js)
start:function(){
game.type = "multiplayer";
var WebSocketObject = window.WebSocket || window.MozWebSocket;
if (!WebSocketObject){
game.showMessageBox("Your browser does not support WebSocket. Multiplayer will not work.");
return;
}
this.websocket = new WebSocketObject(this.websocket_url);
this.websocket.onmessage = multiplayer.handleWebSocketMessage;
// Display multiplayer lobby screen after connecting
this.websocket.onopen = function(){
// Hide the starting menu layer
$('.gamelayer').hide();
$('#multiplayerlobbyscreen').show();
}
this.websocket.onclose = function(){
multiplayer.endGame("Error connecting to server.");
}
this.websocket.onerror = function(){
multiplayer.endGame("Error connecting to server.");
}
},
对于这两个事件,我们调用 endGame()方法并显示一条错误消息。如果你现在运行游戏,关闭服务器重新创建服务器断开连接,你应该会看到一个错误信息,如图图 12-5 所示。
图 12-5。出现连接错误时显示的消息
如果玩家在大厅或玩游戏时出现连接问题,浏览器将显示此错误消息,然后返回游戏主屏幕。
一个更健壮的实现将包括尝试在超时时间内重新连接到服务器,然后继续游戏。我们可以通过将带有唯一玩家 ID 的重新连接消息传递给服务器并在服务器端适当地处理该消息来实现这一点。然而,我们将坚持在我们的游戏中使用这个更简单的实现。
在我们结束游戏的多人部分之前,我们将实现游戏中的最后一个功能:玩家聊天。
实现玩家聊天
我们将从在 index.html 的 gameinterfacescreen 层中为聊天消息定义一个输入框开始,如清单 12-15 所示。
清单 12-15。 添加聊天消息输入框(index.html)
<div id="gameinterfacescreen" class="gamelayer">
<div id="gamemessages"></div>
<div id="callerpicture"></div>
<div id="cash"></div>
<div id="sidebarbuttons">
<input type="button" id="starportbutton" title = "Starport">
<input type="button" id="turretbutton" title = "Turret">
<input type="button" id="placeholder1" disabled>
<input type="button" id="scouttankbutton" title = "Scout Tank">
<input type="button" id="heavytankbutton" title = "Heavy Tank">
<input type="button" id="harvesterbutton" title = "Harvester">
<input type="button" id="chopperbutton" title = "Copter">
<input type="button" id="wraithbutton" title = "Wraith">
<input type="button" id="placeholder2" disabled>
</div>
<canvas id="gamebackgroundcanvas" height="400" width="480"></canvas>
<canvas id="gameforegroundcanvas" height="400" width="480"></canvas>
<input type="text" id="chatmessage"></input>
</div>
接下来,我们将为 styles.css 中的聊天消息输入添加一些额外的样式,如清单 12-16 所示。
清单 12-16。 聊天消息输入框样式(styles.css)
#chatmessage{
position:absolute;
top:460px;
width:479px;
background:rgba(0,255,0,0.1);
color:green;
border:1px solid green;
display:none;
}
#chatmessage:focus {
outline:none;
}
接下来我们将为 multiplayer.js 中的 keydown 事件添加一个事件处理程序,如清单 12-17 中的所示。
清单 12-17。【Handing Keydown 事件】处理聊天消息输入(multiplayer.js)
$(window).keydown(function(e){
// Chatting only allowed in multiplayer when game is running
if(game.type != "multiplayer" || !game.running){
return;
}
var keyPressed = e.which;
if (e.which == 13){ // Enter key pressed
var isVisible = $('#chatmessage').is(':visible');
if (isVisible){
// if chat box is visible, pressing enter sends the message and hides the chat box
if ($('#chatmessage').val()!= ''){
multiplayer.sendWebSocketMessage({type:"chat",message:$('#chatmessage').val()});
$('#chatmessage').val('');
}
$('#chatmessage').hide();
} else {
// if chat box is not visible, pressing enter shows the chat box
$('#chatmessage').show();
$('#chatmessage').focus();
}
e.preventDefault();
} else if (e.which==27){ // Escape key pressed
// Pressing escape hides the chat box
$('#chatmessage').hide();
$('#chatmessage').val('');
e.preventDefault();
}
});
每当一个键被按下,我们首先确认游戏是一个多人游戏,它正在运行,如果不是就退出。如果按下的键是回车键(键代码 13),我们首先检查 chatmessage 输入框是否可见。如果它是可见的,我们就把消息框的内容放在聊天类型的消息中发送给服务器。然后我们清除输入框的内容并隐藏它。如果输入框不可见,我们将显示聊天输入框并将焦点设置到它上面。如果按下的键是 Escape(键代码 27),我们清除输入框的内容并隐藏它。接下来,我们将修改服务器上的消息事件处理程序来处理 chat 类型的消息,如清单 12-18 所示。
清单 12-18。 处理聊天类型的消息(server.js)
// On Message event handler for a connection
connection.on('message', function(message) {
if (message.type === 'utf8') {
var clientMessage = JSON.parse(message.utf8Data);
switch (clientMessage.type){
case "join_room":
var room = joinRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
if(room.players.length == 2){
initGame(room);
}
break;
case "leave_room":
leaveRoom(player,clientMessage.roomId);
sendRoomListToEveryone();
break;
case "initialized_level":
player.room.playersReady++;
if (player.room.playersReady==2){
startGame(player.room);
}
break;
case "latency_pong":
finishMeasuringLatency(player,clientMessage);
// Measure latency at least thrice
if(player.latencyTrips.length<3){
measureLatency(player);
}
break;
case "command":
if (player.room && player.room.status=="running"){
if(clientMessage.uids){
player.room.commands.push({uids:clientMessage.uids, details:clientMessage.details});
}
player.room.lastTickConfirmed[player.color] = clientMessage.currentTick + player.tickLag;
}
break;
case "lose_game":
endGame(player.room, "The "+ player.color +" team has been defeated.");
break;
case "chat":
if (player.room && player.room.status=="running"){
var cleanedMessage = clientMessage.message.replace(/[<>]/g,"");
sendRoomWebSocketMessage(player.room,{type:"chat", from:player.color, message:cleanedMessage});
}
break;
}
}
});
当我们从一个玩家那里收到一条聊天类型的消息时,我们向房间中的所有玩家发回一条聊天类型的消息,将 from 属性设置为玩家的颜色,将 message 属性设置为我们刚刚收到的消息。
理想情况下,您应该验证聊天消息,这样玩家就不能在聊天消息中发送恶意的 HTML 和脚本标签。现在,我们使用一个简单的正则表达式在发送消息之前从消息中去掉所有的 HTML 标签。
最后,我们将修改多人游戏对象的 handleWebSocketMessage()方法来接收聊天类型的消息,如清单 12-19 所示。
清单 12-19。 接收聊天类型的消息(multiplayer.js)
handleWebSocketMessage:function(message){
var messageObject = JSON.parse(message.data);
switch (messageObject.type){
case "room_list":
multiplayer.updateRoomStatus(messageObject.status);
break;
case "joined_room":
multiplayer.roomId = messageObject.roomId;
multiplayer.color = messageObject.color;
break;
case "init_level":
multiplayer.initMultiplayerLevel(messageObject);
break;
case "start_game":
multiplayer.startGame();
break;
case "latency_ping":
multiplayer.sendWebSocketMessage({type:"latency_pong"});
break;
case "game_tick":
multiplayer.lastReceivedTick = messageObject.tick;
multiplayer.commands[messageObject.tick] = messageObject.commands;
break;
case "end_game":
multiplayer.endGame(messageObject.reason);
break;
case "chat":
game.showMessage(messageObject.from,messageObject.message);
break;
}
},
如果你现在启动服务器玩多人游戏,你应该可以从一个玩家向另一个玩家发送聊天消息,如图图 12-6 所示。
图 12-6。多人游戏中玩家之间的聊天
我们现在有一个多人游戏的工作玩家聊天。有了这最后的改变,我们可以认为我们的多人游戏结束了。
摘要
在这本书的过程中,我们已经走了很长的路。我们从查看构建游戏所需的 HTML5 的基本元素开始,例如在画布上绘图和制作动画、播放音频以及使用 sprite 表。
然后,我们使用这些基础知识构建了一个基于 Box2D 物理引擎的游戏,名为 Froot Wars 。在这个过程中,我们着眼于创建闪屏、素材加载器和可定制的级别。然后,我们检查了 Box2D 引擎的构建模块,并将 Box2D 与游戏集成在一起,以创建逼真的物理效果。然后我们添加了音效和背景音乐来制作一个非常精美的游戏。
之后我们构建了一个完整的即时战略游戏,名为失落的殖民地。在前几章的基础上,我们首先创造了一个单人游戏世界,有很大的可平移关卡和不同类型的实体。我们增加了使用寻路和转向的智能运动,使用状态和触发器的战斗,甚至游戏经济。然后,我们看到了如何使用这个框架来讲述一个令人信服的单人战役故事。
最后,在最后两章中,我们使用 Node.js 和 WebSockets 为我们的游戏添加了多人支持。我们从 WebSocket 通信的基础开始,用它来创建一个多人游戏大厅。
然后,我们使用锁步网络模型实现了一个多人游戏的框架,在保持游戏同步的同时也补偿了网络延迟。我们使用触发事件处理连接错误和游戏完成。最后,我们建立了一个聊天系统,在玩家之间发送消息。
如果你一直在跟随,你现在应该有知识、资源和信心在 HTML5 中构建你自己的令人惊奇的游戏。
我写这本书的目的是揭开在 HTML5 中构建复杂游戏的神秘面纱,并为您提供自己构建这类游戏所需的一切。
如果你有问题或反馈,你可以通过我网站上这本书的专用页面联系我,地址是www.adityaravishankar.com/pro-html5-games/。我很想听听你是如何将这本书作为你自己项目的起点的。
我祝你在游戏编程之旅中一切顺利。