JavaScript-游戏构建指南二
JavaScript 游戏构建指南(二)
原文:
协议:
七、基本游戏对象
在这一章中,你开始更多地组织画师游戏的源代码。这是必要的,因为一个游戏的源代码包含许多行代码。在前面的例子中,您开始对不同对象上的变量进行分组(例如
Canvas2D
或
cannon
)。在本章中,您将继续通过使用更多的对象和将代码拆分到不同的文件来构建您的代码。
使用单独的 JavaScript 文件
您可能已经注意到的一件事是,您的 JavaScript 文件变得相当大。拥有一个包含所有代码的大文件并不理想,因为这样很难找到程序的某些部分。将代码拆分到多个源文件中更有意义。一个好的方法是将不同的 JavaScript 对象分割到不同的 JavaScript 文件中,这样每个 JavaScript 文件都包含其中一个对象的代码。Painter3 示例程序包含前面章节中介绍的对象,但是每个对象都在自己的 JavaScript 文件中描述。现在浏览代码和理解程序的结构变得容易多了。您甚至可以将这些文件放在单独的目录中,以指示哪些对象属于同一个目录。例如,您可以将
Keyboard
和
Mouse
JavaScript 文件放在一个名为
input
的目录中。这样,很明显这两个文件都包含与处理输入相关的代码。
在浏览器中加载这些独立的 JavaScript 文件有点棘手。在前面的示例中,您已经看到了加载 JavaScript 文件的以下代码行:
<script src="FlyingSpriteWithSound.js"></script>
您可能希望通过在 HTML 文件中添加更多的这些
script
元素来加载 Painter3 程序中使用的 JavaScript 文件,如下所示:
<script src="input/Keyboard.js"></script>
<script src="input/Mouse.js"></script>
<script src="Canvas2D.js"></script>
<script src="system/Keys.js"></script>
<script src="Painter.js"></script>
<script src="Cannon.js"></script>
不幸的是,如果你试图采用这种添加越来越多
script
元素的方法,你会遇到麻烦。因为 JavaScript 文件是从某个地方的服务器上检索的,所以没有办法确定哪个 JavaScript 文件将首先完成加载。假设浏览器可以加载的第一个文件是
Painter.js
。浏览器无法解释该文件中的代码,因为代码
引用了其他文件
中的代码(如
Canvas2D
对象)。这也适用于其他文件。因此,为了实现这一点,您需要确保文件以某种顺序加载,使得
尊重文件
之间现有的依赖关系。换句话说:如果文件 A 需要文件 B,你需要在文件 A 之前加载文件 B。
在 JavaScript 中,可以修改 HTML 页面;因此,理论上,您可以向 HTML 页面添加一个额外的 script 元素,然后开始加载另一个 JavaScript 文件。通过巧妙使用事件处理程序,您可以想象编写 JavaScript 代码,以预定义的顺序加载其他 JavaScript 文件。您也可以使用其他人已经编写的代码,而不是自己编写所有这些代码。这是结构化代码的另一个优点:它使代码对其他应用更有用。
对于这本书,我选择使用一个名为
LABjs
(
http://labjs.com/
)的动态脚本加载工具。这是一个非常简单的脚本,可以让您动态地按照预定义的顺序加载其他 JavaScript 文件。下面是使用 LABjs 按照正确的顺序加载所有 JavaScript 文件的代码:
<script src="../LAB.min.js"></script>
<script>
$LAB.script('input/Keyboard.js').wait()
.script('input/Mouse.js').wait()
.script('Canvas2D.js').wait()
.script('system/Keys.js').wait()
.script('Painter.js').wait()
.script('Cannon.js').wait(function () {
Game.start('mycanvas');
});
</script>
如您所见,使用 LABjs 非常简单。您只需调用一系列的
script
和
wait
方法。最后一个
wait
方法调用获取一个要执行的函数作为参数。在这个函数中,你开始游戏。通过改变
script
方法调用的顺序,您可以改变脚本加载的顺序。当您开发游戏或其他更大的 JavaScript 应用时,使用这样的脚本非常有用,因为它使得开发和维护代码更加容易。有关显示加载不同 JavaScript 文件的完整示例,请参见 Painter3。这里的另一个改进是我将画布元素的名称作为参数传递给了
start
方法。这样,JavaScript 游戏代码可以使用任何画布名称。
你可能不希望游戏的最终(发布)版本使用这样的方法,因为浏览器将不得不加载许多 JavaScript 文件。此时,最好使用另一个程序将所有 JavaScript 文件合并成一个大文件,这样加载速度会更快。此外,通常的做法是对代码结构进行一些优化,使脚本文件尽可能小。这个过程叫做 缩小 。第三十章更详细地讨论了这一点。
以错误的方式加载游戏素材
之前,我谈到了浏览器以任意顺序加载文件,因为这些文件必须从服务器中检索。同样的规则也适用于加载游戏资源,比如精灵和声音。这是你到目前为止用来加载游戏素材的方法:
var sprite = new Image();
sprite.src = "someImageFile.png";
var anotherSprite = new Image();
anotherSprite.src = "anotherImageFile.png";
// and so on
看起来很简单。对于每个你想要加载的精灵,你创建一个
Image
对象,并给它的
src
变量赋值。将
src
变量设置为某个值并不意味着图像会立即加载。它只是告诉浏览器开始从服务器检索图像。根据互联网连接的速度,这可能需要一段时间。如果您尝试过早绘制图像,浏览器将会因为访问错误(尝试绘制尚未加载的图像)而停止脚本。为了避免这个问题,在前面的例子中精灵是这样加载的:
sprites.background = new Image();
sprites.background.src = spriteFolder + "spr_background.jpg";
sprites.cannon_barrel = new Image();
sprites.cannon_barrel.src = spriteFolder + "spr_cannon_barrel.png";
sprites.cannon_red = new Image();
sprites.cannon_red.src = spriteFolder + "spr_cannon_red.png";
sprites.cannon_green = new Image();
sprites.cannon_green.src = spriteFolder + "spr_cannon_green.png";
sprites.cannon_blue = new Image();
sprites.cannon_blue.src = spriteFolder + "spr_cannon_blue.png";
cannon.initialize();
window.setTimeout(Game.mainLoop, 500);
注意最后一行代码,粗体。在设置了所有
Image
对象的
src
变量之后,您告诉浏览器在执行主循环之前等待 500 毫秒。这样,浏览器应该有足够的时间来加载精灵。但是网速太慢怎么办?那么 500 毫秒可能不够。或者网速真的很快怎么办?那么你让玩家不必要地等待。为了解决这个问题,您需要程序在执行主循环之前等待所有图像都已加载。您将看到如何使用事件处理函数正确地做到这一点。但在此之前,让我们再多谈一点关于方法和函数的内容。
方法和功能
您已经看到并使用了相当多不同种类的方法和函数。例如,
Canvas2D.drawImage
方法和
cannon.update
方法之间有一个明显的区别:后者没有任何参数,而前者有(精灵、它的位置、它的旋转和它的原点)。此外,一些函数/方法可以有一个对象的
结果值,该值可以在执行方法调用的指令中使用——例如,通过将结果存储在一个变量中:
var n = Math.random();
这里,您调用被定义为
Math
对象的一部分的
random
函数,并将它的结果存储在变量
n
中。显然,
random
提供了一个可以存储的结果值。另一方面,
Canvas2D.drawImage
方法不提供可以存储在变量中的结果。当然,这个方法确实有某种效果,因为它在屏幕上绘制了一个精灵,这也可以被认为是方法调用的结果。然而,当我谈论一个方法的
结果
时,我并不是说这个方法对一个对象有某种影响。我的意思是
方法调用
返回一个可以存储在变量中的值。这也称为方法或函数的
返回值
。在数学中,函数有结果是很常见的。数学函数
将
x
值作为参数,并返回其平方值作为结果。如果你愿意,你可以用 JavaScript 写这个数学函数:
var square = function(x) {
return x*x;
}
如果你看看这个方法的头,你会看到它有一个名为
x
的参数。因为函数返回值,所以可以将该值存储在变量中:
var sx = square(10);
该指令执行后,变量
sx
将包含值 100。在函数体中,您可以使用关键字
return
来指示函数返回的实际值。在
square
的情况下,函数返回表达式
x*x
的结果。注意,执行
return
指令也会终止函数中其余指令的执行。放置在和
return
指令之后的任何指令都不会被执行。例如,考虑以下函数:
var someFunction = function() {
return 12;
var tmp = 45;
}
在这个例子中,第二条指令(
var tmp = 45;
)永远不会被执行,因为它之前的指令结束了函数。这是
return
指令的一个非常方便的特性,您可以将它用于您的优势:
var squareRoot = function(x) {
if (x < 0)
return 0;
// Calculate the square root, we are now sure that x >=0.
}
在这个例子中,您使用
return
指令来防止方法用户的错误输入。您不能计算负数的平方根,所以在您进行任何计算或引发任何恼人的、潜在的难以调试的错误之前,您需要处理
x
为负的情况。
没有返回值的方法的一个例子是
cannon.handleInput
。因为这个方法没有返回值,所以不需要在方法体中使用
return
关键字,尽管这样做有时还是有用的。例如,假设您只想在鼠标位于屏幕左侧时更改大炮的颜色。您可以通过以下方式实现这一点:
cannon.handleInput = function () {
if (Mouse.position.x > 10)
return;
if (Keyboard.keyDown === Keys.R)
cannon.currentColor = sprites.cannon_red;
else if (Keyboard.keyDown === Keys.G)
// etc.
};
在这个方法中,首先检查鼠标的 x 位置是否大于 10。如果是这种情况,就执行
return
指令。此后的任何指令将不再执行。
请注意,无论何时调用没有返回值的方法,它都没有可以存储在变量中的结果。例如:
var what = cannon.handleInput();
因为
cannon.handleInput
没有返回值,所以在这条指令执行后,变量
what
将具有值
undefined
。
如果一个方法或函数有一个返回值,这个值不一定要存储在变量中。你也可以直接在
if
指令中使用它,就像你在
cannon.handleInput
方法中所做的一样:
if (Math.random() > 0.5)
// do something
这里,
Math.random
方法返回一个数字,如果该数字大于 0.5,则执行
if
指令的主体。有值的东西和没有值的东西之间的区别是你以前见过的:这和你在
指令
(没有值)和
表达式
(有值)之间看到的区别是一样的。所以,这意味着
Math.random()
是一个
表达式
,而
cannon.handleInput();
是一个
指令
。这两者之间的第二个区别是,表达式从不以分号结尾,而指令总是以分号结尾,除非指令是一个块。
声明与参数
变量的声明与写在方法头中的参数有很多共同之处。事实上,这些参数也是声明,但是有一些不同:
- 变量在方法体中声明;参数在方法头的括号中声明。
- 变量通过使用赋值指令来获取值;调用方法时,参数会自动获取一个值。
- 变量声明以单词
var
开头;参数声明不会。 - 变量声明以分号结束;参数声明不会。
以正确的方式加载游戏素材
为了让 sprite 加载更容易一点,让我们给
Game
对象添加一个方法
loadSprite
:
Game.loadSprite = function(imageName) {
var image = new Image();
image.src = imageName;
return image;
}
加载不同精灵的代码现在变得更短了:
var sprFolder = "../../assets/Painter/sprites/";
sprites.background = Game.loadSprite(sprFolder + "spr_background.jpg");
sprites.cannon_barrel = Game.loadSprite(sprFolder + "spr_cannon_barrel.png");
sprites.cannon_red = Game.loadSprite(sprFolder + "spr_cannon_red.png");
sprites.cannon_green = Game.loadSprite(sprFolder + "spr_cannon_green.png");
sprites.cannon_blue = Game.loadSprite(sprFolder + "spr_cannon_blue.png");
然而,处理加载精灵所花费的时间的问题还没有解决。为了解决这个问题,你需要做的第一件事就是记录你加载了多少精灵。这可以通过向
Game
对象添加一个名为
spritesStillLoading
的变量来实现:
var Game = {
spritesStillLoading : 0
};
最初,该变量被设置为值 0。每次加载一个 sprite,变量就增加 1。从逻辑上来说,您可以在
loadSprite
方法中这样做:
Game.loadSprite = function(imageName) {
var image = new Image();
image.src = imageName;
Game.spritesStillLoading += 1;
return image;
}
所以现在,每次你加载一个精灵,
spritesStillLoading
变量就会增加。接下来,每当一个精灵完成加载时,你想要
减少
这个变量。这可以通过使用事件处理函数来完成。您将这个函数分配给
image
对象中的变量
onload
。在函数体中,变量递减。下面是添加事件处理程序的
loadSprite
方法的版本:
Game.loadSprite = function (imageName) {
var image = new Image();
image.src = imageName;
Game.spritesStillLoading += 1;
image.onload = function () {
Game.spritesStillLoading -= 1;
};
return image;
};
现在
spritesStillLoading
变量准确地表示了还有多少精灵需要被加载。您可以使用该信息等待主循环的开始,直到该变量包含值 0。为此,您需要创建两个循环方法:一个素材加载循环和一个主游戏循环。在素材加载循环中,您只需检查是否还有必须加载的精灵。如果是这种情况,您再次调用素材加载循环。如果所有的精灵都已经被加载,你调用主循环方法。下面是素材加载循环方法:
Game.assetLoadingLoop = function () {
if (Game.spritesStillLoading > 0)
window.setTimeout(Game.assetLoadingLoop, 1000 / 60);
else {
Game.initialize();
Game.mainLoop();
}
};
您使用
if
指令来检查仍在加载的精灵数量是否大于 0。如果是这种情况,您在短暂的延迟后再次调用
assetLoadingLoop
方法。一旦所有的精灵都被加载,就会执行
if
指令的
else
部分。在这一部分中,您调用了
Game
对象中的
initialize
方法,然后调用了
mainLoop
方法。在
initialize
方法中,所有的游戏对象都被初始化(在本例中,只有
cannon
对象)。在加载完所有精灵后进行初始化是很有用的,因为精灵数据在初始化对象时可能会很有用。例如,如果你想计算一个覆盖图的位置,使它画在屏幕的中心,你需要知道精灵的宽度和高度。该信息仅在精灵完成加载时可用。有关完整的概述,请参见 Painter3 示例,该示例阐释了新的 sprite 加载代码。
编写一个更有效的游戏循环
到目前为止,您已经使用了
window.setTimeout
方法来创建一个运行的游戏循环。虽然这段代码很好,但不幸的是它不是很高效。大多数浏览器提供了一种更有效的方式来实现这一点,这种方式专门针对交互式绘图应用,如游戏。问题是,并不是所有的浏览器和版本都使用相同的方法名。最常用的浏览器的新版本都使用了
window.requestAnimationFrame
方法。不过火狐老版本用的是
window.mozRequestAnimationFrame
,Safari 老版本和 Chrome 用的是
window.webkitRequestAnimationFrame
。您希望尽可能少地处理特定于浏览器的代码,所以让我们想出一种方法,使用更快的方法来运行游戏循环,而不必了解不同浏览器制作和版本使用的不同方法。因为大多数浏览器已经使用了
window.requestAnimationFrame
方法,您可以如下扩展该方法的定义:
window.requestAnimationFrame = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
};
您在这里使用
||
操作符来确定
window.requestAnimationFrame
名称所指的方法。如果没有定义第一个选项(例如,如果您正在使用一个较旧的浏览器),您将检查任何较旧的名称。如果没有优化的游戏循环方法可用,你让
requestAnimationFrame
指向一个调用
window.setTimeout
方法的函数。在 JavaScript 中,
window
变量是一个全局名称空间的容器。这意味着您可以调用前面有或没有
window
变量的
requestAnimationFrame
方法。电话:
window.requestAnimationFrame(callbackFunction);
相当于
requestAnimationFrame(callbackFunction);
Painter3 示例使用优化的游戏循环方法来运行素材加载循环和游戏循环,如下所示:
Game.assetLoadingLoop = function () {
if (Game.spritesStillLoading > 0)
window.requestAnimationFrame(Game.assetLoadingLoop);
else {
Game.initialize();
Game.mainLoop();
}
};
同样,下面是
Game
对象中的
mainLoop
方法:
Game.mainLoop = function () {
Game.handleInput();
Game.update();
Game.draw();
Mouse.reset();
window.requestAnimationFrame(Game.mainLoop);
};
浏览器以不同的方式处理事情是 JavaScript 开发人员面临的一个主要挑战,尽管在浏览器如何执行 JavaScript 代码的标准化方面已经取得了很大进展。当您开发 JavaScript 游戏时,您可能会在某个时候在浏览器中遇到这些差异。因此,在发布游戏之前,一定要在最常用的浏览器上测试你的游戏!
将通用代码与游戏专用代码分开
以前,你没有区分可以用于许多不同游戏的代码和特定于一个游戏的代码。您编写的一些代码,比如
Game.mainLoop
方法,可能对其他 JavaScript 游戏也有用。这同样适用于您编写的所有加载精灵的代码。既然您已经看到了在不同脚本文件上分割代码的方法,那么您可以利用这一点。通过将通用代码从特定于 Painter 的代码中分离出来,以后重用该通用代码将会更加容易。如果你想重用加载精灵的代码,你只需将包含代码的源文件包含到你的新游戏应用中,而不是在画师游戏代码中寻找你需要的。这样做的另一个原因是为了让你以后能更快地创建类似的游戏。你可能会发现自己正在开发一个与你之前开发的游戏相似的游戏。通过以这种方式组织代码,您可以更快地开发出新游戏,同时也不会重新发明轮子。
Painter4 示例创建了一个单独的
Game.js
文件,其中包含了
Game
对象和许多属于该对象的有用方法。专用于 Painter 的零件已被移到
Painter.js
文件中。在这个文件中,有一个加载精灵的方法和一个初始化游戏的方法。此外,一个名为
PainterGameWorld.js
的新文件处理游戏中的各种对象。在以前版本的 Painter 中,这个游戏世界只包含一个背景图像和一门大炮。在下一节中,您将向这个游戏世界添加一个
球
。然后,画家游戏世界由一个对象定义,该对象确保所有游戏对象都被更新和绘制。这是
painterGameWorld
对象定义的一部分:
var painterGameWorld = {
};
painterGameWorld.handleInput = function (delta) {
ball.handleInput(delta);
cannon.handleInput(delta);
};
painterGameWorld.update = function (delta) {
ball.update(delta);
cannon.update(delta);
};
painterGameWorld.draw = function () {
Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
{ x : 0, y : 0 });
ball.draw();
cannon.draw();
};
当你初始化游戏时,你初始化游戏对象,你告诉
Game
对象管理游戏世界的对象是
painterGameWorld
,如下:
Game.initialize = function () {
cannon.initialize();
ball.initialize();
Game.gameWorld = painterGameWorld;
};
在
Game.mainLoop
方法中,你现在只需要确保在
gameWorld
变量(它指的是
painterGameWorld
)上调用正确的方法:
Game.mainLoop = function () {
Game.gameWorld.handleInput();
Game.gameWorld.update();
Canvas2D.clear();
Game.gameWorld.draw();
Mouse.reset();
requestAnimationFrame(Game.mainLoop);
};
因此,你已经很好地分离了一般的游戏代码(在
Game.js
中)和特定于游戏的代码,包括加载精灵和初始化游戏(
Painter.js
)以及更新和绘制画师游戏对象(
PainterGameWorld.js
)。任何其他特定于 Painter 的游戏对象都是在各自的脚本文件中定义的(比如
Cannon.js
)。
给游戏世界添加一个球
在前面的章节中,您看到了如何通过加载单独的 JavaScript 文件、将通用游戏代码与特定于画师的代码分开、正确加载资源以及创建更高效的游戏循环来使您的 JavaScript 游戏应用更加灵活和高效。在本节中,您将通过添加一个由
cannon
对象射出的球来扩展画师游戏。为此,您添加一个
ball
对象。
您以与
cannon
对象非常相似的方式设置了
ball
对象。在 Painter4 的例子中,你看到了一个 Painter 游戏的版本,它给游戏世界增加了一个球(见图 7-1 )。通过点击游戏屏幕上的任何地方,球都可以从大炮中射出。而且,球会随着大炮一起变色。您可以在
Ball.js
文件中找到描述
ball
对象的代码。就像
cannon
对象一样,
ball
由许多变量组成,比如位置、球的当前颜色和球精灵的来源。因为球在移动,你还需要存储它的
速度
。这个速度是一个矢量,定义了球的位置如何随时间变化。例如,如果球的速度为(0,1),那么每秒钟,球的 y 位置增加 1 个像素(意味着球落下)。最后,球可以有两种状态:要么是因为从大炮中射出而在空中飞行,要么是等待被射出(所以它不动)。为此,您向
ball
对象添加一个额外的布尔变量
shooting
。这是
ball
对象结构的完整定义:
var ball = {
};
ball.initialize = function() {
ball.position = { x : 65, y : 390 };
ball.velocity = { x : 0, y : 0 };
ball.origin = { x : 0, y : 0 };
ball.currentColor = sprites.ball_red;
ball.shooting = false;
};
图 7-1 。 包含炮管和飞行球 的画师 4 例子的截图
在你在本书中开发的游戏中,大多数物体都有位置和速度。因为这本书只涉及 2D 游戏,所以位置和速度都是由一个
x
和一个
y
变量组成的变量。当你更新这些游戏对象时,你需要根据速度向量和经过的时间来计算新的位置。在本章的后面,你会看到如何做到这一点。
为了能够使用
ball
对象,你需要更多的精灵。在
Game.``loadAssets
方法中,你加载红色、绿色和蓝色的球精灵。根据加农炮的颜色,你可以在以后改变球的颜色。这是扩展的
loadAssets
方法:
Game.loadAssets = function () {
var loadSprite = function (sprite) {
return Game.loadSprite("../../assets/Painter/sprites/" + sprite);
};
sprites.background = loadSprite("spr_background.jpg");
sprites.cannon_barrel = loadSprite("spr_cannon_barrel.png");
sprites.cannon_red = loadSprite("spr_cannon_red.png");
sprites.cannon_green = loadSprite("spr_cannon_green.png");
sprites.cannon_blue = loadSprite("spr_cannon_blue.png");
sprites.ball_red = loadSprite("spr_ball_red.png");
sprites.ball_green = loadSprite("spr_ball_green.png");
sprites.ball_blue = loadSprite("spr_ball_blue.png");
};
在这里,您可以看到在 JavaScript 中使 sprite 加载调用更具可读性的一种好方法。您声明了一个引用函数的局部变量
loadSprite
。该函数将 sprite 图像名称作为参数,并调用
Game.loadSprite
方法。作为该方法的一个参数,您可以传递 sprite 的文件夹路径和 sprite 的名称。最后,该函数返回
Game.loadSprite
方法的结果。
创造球
让我们回到
ball
对象。在那个对象的
initialize
方法中,你必须给成员变量赋值,就像在
cannon
对象中一样。当游戏开始时,球不应该移动。因此,你把球的速度设为零。此外,您最初将球设置到零位置。这样,它就藏在大炮后面,所以当球不动的时候,你看不见它。您最初将球的颜色设置为红色,并将
shooting
成员变量设置为
false
。下面是完整的方法:
ball.initialize = function() {
ball.position = { x : 0, y : 0 };
ball.velocity = { x : 0, y : 0 };
ball.origin = { x : 0, y : 0 };
ball.currentColor = sprites.ball_red;
ball.shooting = false;
};
在
initialize
方法旁边,您还添加了一个
reset
方法,用于重置球的位置及其射门状态:
ball.reset = function () {
ball.position = { x : 0, y : 0 };
ball.shooting = false;
};
当球从大炮中射出后飞出屏幕时,您可以通过调用此方法来重置它。此外,您向
ball
对象添加了一个
draw
方法。如果球没有投篮,你不想让球员看到它。因此,只有当球正在射击时,才绘制球精灵:
ball.draw = function () {
if (!ball.shooting)
return;
Canvas2D.drawImage(ball.currentColor, ball.position, ball.rotation,
ball.origin);
};
你可以在这个方法的主体中看到,只有在球不投篮的时候,你才使用
return
关键字来画球。在
painterGameWorld
对象中,你必须调用球上的游戏循环方法。例如,这是
painterGameWorld
中的
draw
方法,由此调用
ball.draw
方法:
painterGameWorld.draw = function () {
Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
{ x : 0, y : 0 });
ball.draw();
cannon.draw();
};
请注意游戏对象的绘制顺序:首先是背景图像,然后是球,然后是大炮。
投篮
玩家可以在游戏画面中点击鼠标左键射出一个彩球。球的速度和移动方向由玩家点击的位置决定。球应该向那个位置的方向移动;玩家离大炮越远,球的速度就越高。这是用户控制球的速度的直观方式。无论何时你设计一个游戏,都要仔细考虑用户的指令是如何被接收的,以及最自然或有效的处理方式是什么。
为了处理输入,您向
ball
对象添加了一个
handleInput
方法。在这个方法中,你可以通过使用
Mouse
对象来检查玩家是否点击了左键:
if (Mouse.leftPressed)
// do something...
然而,因为在任何时候都只能有一个球在空中,所以只有当球还没有在空中时,你才想做些什么。这意味着你必须检查球的投篮状态。如果球已经射出了,你就不必处理鼠标点击。所以,你用一个额外的条件来扩展你的
if
指令,这个额外的条件是球当前不在空中:
if (Mouse.leftPressed && !ball.shooting)
// do something...
如您所见,您将两个逻辑操作符(
&&
和
!
)结合使用。由于逻辑
而非
(
!
)运算符,只有当
shooting
变量的值为
false
时,
if
指令中的整个条件才会计算为
true
:换句话说,球当前没有射门。
在
if
指令中,您需要做几件事情。你知道玩家点击了某个地方,球必须从大炮中射出。您需要做的第一件事是将变量
shooting
设置为正确的值,因为球的状态需要更改为“当前正在射门”:
ball.shooting = true;
因为球现在正在移动,你需要给它一个 速度 。这个速度是玩家点击位置方向的向量。你可以通过鼠标位置减去球的位置来计算这个方向。因为速度有一个 x 分量和一个 y 分量,所以需要对两个维度都这样做:
ball.velocity.x = (Mouse.position.x - ball.position.x);
ball.velocity.y = (Mouse.position.y - ball.position.y);
以这种方式计算速度也给出了期望的效果,即当用户点击离大炮更远时,速度更大,因为这样鼠标位置和球位置之间的差异也更大。然而,如果你现在玩这个游戏,球会移动得有点慢。因此,你将这个速度乘以一个常数值,这个常数值给出了球在这个游戏中可用的速度:
ball.velocity.x = (Mouse.position.x - ball.position.x) * 1.2;
ball.velocity.y = (Mouse.position.y - ball.position.y) * 1.2;
我在测试了不同数值的游戏玩法后,选择了常量值 1.2。每款游戏都有许多这样的 游戏参数 ,你需要在游戏测试时调整这些参数以确定它们的最佳值。为这些参数找到正确的值对于一个玩得好的平衡游戏来说是至关重要的,你需要确保你选择的值不会使游戏过于容易或困难。例如,如果选择 0.3 而不是 1.2 这个常量值,球的移动速度会慢得多。这将使比赛变得更加困难,甚至可能使比赛无法进行,因为球可能永远也不能到达最远的地方。
如果您将
handleInput
方法添加到
ball
中,它不会被自动调用。您需要在
painterGameWorld
对象中显式地这样做。因此,您向该对象的
handleInput
方法添加了一条额外的指令:
painterGameWorld.handleInput = function () {
ball.handleInput();
cannon.handleInput();
};
更新球
在对象中将相关的变量和方法组合在一起的一个很大的优点是,您可以保持每个对象相对较小和清晰。您可以设计或多或少反映游戏中各种游戏对象的对象。在这种情况下,你有一个大炮和球的对象。目标是每个游戏对象处理与该对象相关的玩家输入。你也想让游戏对象自己更新和绘制。这就是你给
ball
增加了一个
update
方法和一个
draw
方法的原因,所以你可以在
painterGameWorld
的游戏循环方法中调用这些方法。
在
ball.update
里面,你需要定义球的行为。根据球当前是否正在射门,这种行为是不同的。这是完整的方法:
ball.update = function (delta) {
if (ball.shooting) {
ball.velocity.x = ball.velocity.x * 0.99;
ball.velocity.y = ball.velocity.y + 6;
ball.position.x = ball.position.x + ball.velocity.x * delta;
ball.position.y = ball.position.y + ball.velocity.y * delta;
}
else {
if (cannon.currentColor === sprites.cannon_red)
ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
ball.currentColor = sprites.ball_green;
else
ball.currentColor = sprites.ball_blue;
ball.position = cannon.ballPosition();
ball.position.x = ball.position.x - ball.currentColor.width / 2;
ball.position.y = ball.position.y - ball.currentColor.height / 2;
}
if (painterGameWorld.isOutsideWorld(ball.position))
ball.reset();
};
正如您在这个方法的头文件中看到的,它有一个名为
delta
的参数。这个参数是必要的,因为为了计算球的新位置,你需要知道从上一次调用
update
到现在已经过了多长时间。这个参数在一些游戏对象的
handleInput
方法中也很有用——例如,如果你想知道玩家移动鼠标的速度,那么你需要知道已经过了多长时间。Painter4 示例扩展了每个具有游戏循环方法(
handleInput
、
update
、
draw
)的对象,以便将自上次更新以来经过的时间作为参数传递。
但是 delta 的值在哪里计算呢?你是怎么计算的?在本例中,您在
Game.mainLoop
方法中这样做:
Game.mainLoop = function () {
var delta = 1 / 60;
Game.gameWorld.handleInput(delta);
Game.gameWorld.update(delta);
Canvas2D.clear();
Game.gameWorld.draw();
Mouse.reset();
requestAnimationFrame(Game.mainLoop);
};
因为您希望游戏循环每秒执行 60 次,所以您按如下方式计算
delta
值:
var delta = 1 / 60;
这种在游戏循环中计算过去时间的方式被称为 固定时间步长 。如果你有一台非常慢的计算机,每秒钟不能执行 60 次游戏循环,你仍然告诉你的游戏对象,从上次到现在只过了 1/60 秒,尽管这可能不是真的。因此, 游戏时间 不同于 真实时间 。另一种方法是通过访问系统时间来计算 实际经过时间 。以下是您的操作方法:
var d = new Date();
var n = d.getTime();
变量
n
现在包含自 1970 年 1 月 1 日以来的毫秒数(!).每次你运行游戏循环,你可以存储这个时间,减去你上次运行游戏循环所存储的时间。那会给你已经过去的真实时间。在这种情况下,没有固定的时间步长,因为经过的时间取决于所使用的计算机/设备的速度、操作系统中进程的优先级、玩家是否同时在执行其他任务等等。所以这种在游戏中处理时间的方法叫做
变时步
。
可变时间步长在需要高帧速率的游戏中特别有用:例如,在第一人称射击游戏中,相机运动可能非常快,因为相机是由玩家直接控制的。在这些情况下,可变的时间步长与尽可能频繁地调用游戏循环方法相结合,可以产生更流畅的动作和更愉快的游戏体验。可变时间步长的缺点是,即使玩家暂时在做一些不同的事情(比如在游戏中打开菜单或保存游戏),时间也会继续。一般来说,如果玩家在浏览物品时发现他们的角色在游戏世界中被杀了,他们不会很高兴。所以,作为一个游戏开发者,你需要在使用可变时间步长时解决这些问题。
使用可变时间步长可能干扰游戏可玩性的另一个例子是玩家临时切换到另一个应用(或浏览器中的标签)。这种情况经常发生,尤其是当你开发在浏览器中运行的游戏时。这也是你在本书中使用固定时间步长的主要原因之一。当播放器切换到另一个选项卡时,非活动选项卡中的 JavaScript 代码执行会自动暂停,直到播放器返回。当使用固定时间步长时,当玩家重新激活标签时,游戏简单地从暂停的地方继续,因为游戏对象不关心已经过去的实际时间,只关心固定的增量值。
让我们回到
ball.update
方法。如果您查看方法的主体,您可以看到第一部分由一条
if
指令组成。
if
的条件是
ball.shooting
变量应该有值
true
。因此,如果球当前正在移动,则执行
if
指令的主体。这个主体同样由四个指令组成。前两条指令更新速度,后两条更新位置。第一条指令更新速度的 x 方向。您将速度乘以值 0.99,其效果是速度缓慢降低。这样做是为了模拟空气摩擦。第二条指令在每次更新中增加 y 速度。这样做是为了模拟
重力
对球的影响。总的来说,x 和 y 方向上的速度变化导致了似是而非的球行为。当然,在现实世界中,重力不是 6。但是话说回来,你的现实世界也不是由像素组成的。游戏世界中的物理并不总是准确地代表现实世界中的物理。当你想在你的游戏中加入某种形式的物理(无论是非常简单还是非常复杂),最重要的部分不是物理是真实的,而是
游戏是可玩的
。这就是为什么在战略游戏中,飞机会像士兵在地面上行走一样快。如果游戏对这两个物体使用真实的速度,这将导致游戏无法进行。
球的当前位置通过向其 x 和 y 分量添加速度来更新。以下是执行此操作的说明:
ball.position.x = ball.position.x + ball.velocity.x * delta;
ball.position.y = ball.position.y + ball.velocity.y * delta;
如您所见,这是使用
delta
变量的地方。您根据速度和自上次更新以来经过的时间来计算球的新位置。您将每个速度维度乘以
delta
变量中的值,并将结果添加到球的当前位置。这样,如果您决定使用更高或更低的帧速率,游戏对象移动的速度将不会改变。
在过去,计算机速度非常慢,以至于不存在固定时间步长的概念。游戏开发人员假设每个人都将在同样慢的机器上玩游戏,所以他们尽可能频繁地调用游戏循环方法,并简单地用一个恒定的速度因子更新对象的位置。结果,当电脑变得更快时,这些游戏变得越来越难玩了!玩家不喜欢这样。因此,在计算速度和位置时,一定要考虑经过的时间。
如果球目前没有投篮,你可以改变它的颜色。在这种情况下,您可以通过检索加农炮的当前颜色并相应地更改球的颜色来实现。这样,你就能确定球的颜色总是和大炮的颜色相匹配。您需要一个
if
指令来处理不同的情况,如下所示:
if (cannon.currentColor === sprites.cannon_red)
ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
ball.currentColor = sprites.ball_green;
else
ball.currentColor = sprites.ball_blue;
您还可以更新球的位置:
ball.position = cannon.ballPosition();
ball.position.x = ball.position.x - ball.currentColor.width / 2;
ball.position.y = ball.position.y - ball.currentColor.height / 2;
你为什么改变立场?当球不在空中时,玩家可以通过旋转炮管来修改它的射击位置。因此,您需要在这里计算正确的球位置,以确保它与炮管的当前方向匹配。为了做到这一点,您向
cannon
添加了一个名为
ballPosition
的新方法,在该方法中,您根据桶的方向计算球的位置。使用正弦和余弦函数,可以如下计算新位置:
cannon.ballPosition = function() {
var opp = Math.sin(cannon.rotation) * sprites.cannon_barrel.width * 0.6;
var adj = Math.cos(cannon.rotation) * sprites.cannon_barrel.width * 0.6;
return { x : cannon.position.x + adj, y : cannon.position.y + opp };
};
正如你所看到的,你将对面和相邻的边乘以值 0.6,这样球就被画到了旋转桶的一半以上。该方法返回一个新的复合对象,该对象具有包含球的所需 x 和 y 位置的
x
和
y
变量。
在你获得了想要的球的位置后,你从中减去球精灵的宽度和高度的一半。这样,球就被很好地画在了炮管的中间。
ball.update
方法的第二部分也是一个
if
指令:
if (painterGameWorld.isOutsideWorld(ball.position))
ball.reset();
方法的这一部分处理当球在游戏世界之外时发生的事件。为了计算这是否为真,您将一个名为
isOutsideWorld
的方法添加到
painterGameWorld
中。这个方法的目标是检查一个给定的位置是否在游戏世界之外。你用一些简单的规则来定义游戏世界的边界。记住屏幕的左上方是原点。如果一个物体的 x 位置小于零或者大于屏幕的宽度,那么这个物体就在游戏世界之外。如果一个物体的 y 位置大于屏幕的高度,那么它也在游戏世界之外。注意,如果一个物体的 y 位置小于零,我不会说它在游戏世界之外。为什么不呢?我选择这样做是为了让玩家可以在空中投篮,让球在再次落下之前暂时停留在屏幕上方。你经常会在平台游戏中看到类似的效果,角色可以跳起来,部分消失在屏幕之外,而不是从屏幕底部掉下来(这通常意味着角色的即时死亡)。
如果您查看这个方法的头部,您会看到它需要一个参数,一个位置:
painterGameWorld.isOutsideWorld = function (position)
如果你想检查一个位置是否在屏幕之外,你需要知道屏幕的宽度和高度。在诸如 Painter 这样的 HTML5 游戏中,这对应于
画布
的大小。Painter4 将名为
size
的变量添加到
Game
中。当调用
Game.start
方法时,所需的屏幕尺寸作为参数传递。下面是扩展的
Game.start
方法:
Game.start = function (canvasName, x, y) {
Canvas2D.initialize(canvasName);
Game.size = { x : x, y : y };
Keyboard.initialize();
Mouse.initialize();
Game.loadAssets();
Game.assetLoadingLoop();
};
在
isOutsideWorld
方法中,您使用
Game.size
变量来确定一个位置是否在游戏世界之外。该方法的主体由一条使用关键字
return
计算布尔值的指令组成。逻辑
或
操作用于涵盖位置在游戏世界之外的不同情况:
return position.x < 0 || position.x > Game.size.x || position.y > Game.size.y;
如你所见,你不介意 y 坐标小于零。这允许你把球放在屏幕上方,然后再掉回来。
让我们回到
ball.update
方法。第二条
if
指令在其条件中调用
isOutsideWorld
方法;如果这个方法返回值
true
,那么
ball.reset
方法被执行。或者,用更简单的话来说:如果球飞出了屏幕,它就被放在大炮旁边,准备好被玩家再次射击。在这里,您可以看到在方法中对指令进行分组的另一个优点:像
isOutsideWorld
这样的方法可以在程序的不同部分中被
重用
,这节省了开发时间,并产生了更短、可读性更好的程序。例如,
isOutsideWorld
可能在游戏后期对颜料罐也有用,用来测试它们是否从屏幕上掉了下来。
最后,确保在
painterGameWorld.update
方法中调用
ball.update
方法:
painterGameWorld.update = function (delta) {
ball.update(delta);
cannon.update(delta);
};
当您运行 Painter4 示例时,您可以看到现在可以瞄准加农炮,选择颜色,并发射一个球。在下一章中,你将在这个游戏中加入颜料罐。但是为了做到这一点,我必须引入一个新的 JavaScript 编程概念: 原型 。
你学到了什么
在本章中,您学习了:
- 如何在不同的源文件中分离代码
- 如何让游戏循环更高效
- 不同种类的方法/函数(有/没有参数,有/没有返回值)
- 固定时间步长和可变时间步长的区别
- 如何在游戏世界中添加一个飞行球
八、游戏对象类型
在前面的章节中,你已经看到了如何创建一个包含一些不同游戏对象的游戏世界,比如一门大炮和一个球。你已经看到了如何让游戏对象相互作用。例如,
ball
对象根据大炮的颜色更新其颜色。在这一章中,您将向游戏世界添加掉落的油漆罐。但是,在这样做之前,您必须重新检查如何在 JavaScript 中创建和管理对象。我引入类的概念是为了创建多种特定类型的游戏对象。然后,将类的概念应用到 Painter 游戏应用的其他部分。此外,你学习如何在游戏中融入随机性。
创建多个相同类型的对象
到目前为止,在 Painter 中每个游戏对象只需要一个实例。只有一门大炮和一个球。这同样适用于 JavaScript 代码中的所有其他对象。有一个单独的
Game
对象,一个单独的
Keyboard
对象,一个单独的
Mouse
对象,等等。您可以通过声明一个引用空对象或复合对象的变量并向其添加有用的方法来创建这些对象。例如,下面是如何创建
ball
对象:
var ball = {
};
ball.initialize = function() {
ball.position = { x : 0, y : 0 };
// etc.
};
ball.handleInput = function (delta) {
if (Mouse.leftPressed && !ball.shooting) {
// do something
}
};
// etc.
假设你想在画师游戏中能够同时射出三个球。如果你像现在这样创建对象,你将创建两个变量,
ball2
和
ball3
,并复制两次用于
ball
对象的代码。出于几个原因,这不是一个很好的解决方案。首先,复制代码意味着你必须处理版本管理问题。举个例子,如果你在
update
方法代码中发现了一个 bug 怎么办?你必须确保将改进后的代码复制到其他
ball
对象中。如果你忘记了一个副本,当你认为你解决了它的时候,这个 bug 仍然存在。另一个问题是,这种方法不能很好地扩展。如果你想延长游戏,让玩家可以同时射 20 个球,会发生什么?你复制代码 20 次吗?还要注意,JavaScript 文件越大,浏览器下载和解释它们的时间就越长。所以,如果你不想让你的玩家等待脚本加载太久,最好避免复制代码。最后,重复的代码看起来很难看,弄乱了您的源代码文件,并且很难找到您需要的代码的其他部分,导致过多的滚动和编码效率的总体降低。
幸运的是,这个问题有一个非常好的解决方案。这是一个叫做 原型 的 JavaScript 编程结构。原型允许你为一个对象定义一种蓝图,包括它包含的变量和方法。一旦定义了这个原型,您就可以使用这个原型用一行代码创建对象了!你已经用过类似的东西了。看看这行代码:
var image = new Image();
在这里,您创建一个
image
对象,它使用
Image
原型 来构造自己。
定义原型很容易。看看这个例子:
function Dog() {
}
Dog.prototype.bark = function () {
console.log("woof!");
};
这就创建了一个名为
Dog
的函数。当这个函数与关键字
new
一起被调用时,一个对象被创建。JavaScript 中的每个函数都有一个
原型
,它包含了通过调用函数和
new
关键字创建的对象的信息。这个例子定义了一个名为
bark
的方法,它是
Dog
原型的一部分。这个词不仅仅是为了美观。使用它,您表明您正在向
Dog
的原型添加东西。每当你创建一个
Dog
对象时,只有属于其原型的东西才是对象的一部分。下面是如何创建一个新的
Dog
对象:
var lucy = new Dog();
因为
lucy
是根据
Dog
函数中的原型创建的,
lucy
对象包含一个名为
bark
的方法:
lucy.bark(); // outputs "woof!" to the console
好的一面是,你现在可以创建许多会叫的狗,但是你只需要定义一次
bark
方法:
var max = new Dog();
var zoe = new Dog();
var buster = new Dog();
max.bark();
zoe.bark();
buster.bark();
当然,这本书的目标不是向你展示如何成为一个养狗人,而是如何创造游戏。而且对于游戏来说,原型概念是非常强大的。它允许你 将游戏中使用的实际物体与它们应该如何被构造 分开。
作为练习,让我们应用原型原理来创建一个
ball
对象。为此,您需要定义一个函数。我们称这个函数为
Ball
,我们将
initialize
方法 添加到原型中:
function Ball() {
}
Ball.prototype.initialize = function() {
// ball object initialization here
};
在
initialize
方法中,您必须定义作为您创建的每个
ball
对象的一部分的变量。问题是,你还没有创建一个对象——你只有一个函数和一个包含
initialize
方法的原型。那么在
initialize
方法的主体中,如何引用这个方法所属的对象呢?在 JavaScript 中,
this
关键字用于此目的。在一个方法中,
this
总是指该方法所属的对象。使用该关键字,您可以填充
initialize
方法的主体:
Ball.prototype.initialize = function() {
this.position = { x : 0, y : 0 };
this.velocity = { x : 0, y : 0 };
this.origin = { x : 0, y : 0 };
this.currentColor = sprites.ball_red;
this.shooting = false;
};
现在,您可以创建任意数量的球并初始化它们:
var ball = new Ball();
var anotherBall = new Ball();
ball.initialize();
anotherBall.initialize();
每次创建新球时,原型中的任何方法都会添加到对象中。当对
ball
对象调用
initialize
方法时,
this
指的是
ball
。在
anotherBall
上调用时,
this
是指
anotherBall
。
你实际上可以把你写的代码缩短一点。当
Ball
本身已经是一个被调用的函数时,为什么还要添加一个
initialize
方法呢?您可以简单地在该函数中执行初始化,如下所示:
function Ball() {
this.position = { x : 0, y : 0 };
this.velocity = { x : 0, y : 0 };
this.origin = { x : 0, y : 0 };
this.currentColor = sprites.ball_red;
this.shooting = false;
}
现在当你创建球时,它们在创建时被初始化:
var ball = new Ball();
var anotherBall = new Ball();
因为
Ball
是一个函数,如果你想的话,你甚至可以传递参数:
function Ball(pos) {
this.position = pos;
this.velocity = { x : 0, y : 0 };
this.origin = { x : 0, y : 0 };
this.currentColor = sprites.ball_red;
this.shooting = false;
}
var ball = new Ball({ x : 0, y : 0});
var anotherBall = new Ball({ x : 100, y : 100});
因为
Ball
函数负责初始化(或
构造
)对象,所以这个函数也被称为
构造函数
。构造函数和原型中定义的方法一起被称为
类
。当一个对象是根据一个类创建的时候,你也说这个对象把那个类作为
类型
。在前面的例子中,
ball
对象有一个类型
Ball
,因为它是使用
Ball
构造函数及其原型创建的。一个类是一个对象的蓝图,因此它描述了两件事:
- 包含在对象中的数据。对于球,这些数据包括位置、速度、原点、当前颜色和一个指示球是否正在射门的变量。通常,这些数据在构造函数中初始化。
- 操纵
数据的方法。在
Ball
类中,这些方法是游戏循环方法(handleInput
、update
、draw
和reset
)。
你可以很容易地将游戏循环方法转换成
Ball
原型中的方法,只需用
this
替换
ball
。比如这里的
handleInput
法 :
Ball.prototype.handleInput = function (delta) {
if (Mouse.leftPressed && !this.shooting) {
this.shooting = true;
this.velocity.x = (Mouse.position.x - this.position.x) * 1.2;
this.velocity.y = (Mouse.position.y - this.position.y) * 1.2;
}
};
查看属于本章的 Painter5 示例中的
Ball.js
文件。您可以看到
Ball
类及其所有方法。请注意,我没有给球添加任何功能;我只是应用原型原理来定义球的
蓝图。
类和对象的概念非常强大。它构成了 面向对象编程范例 的基础。JavaScript 是一种非常灵活的语言,因为它不强迫你使用类。如果你想的话,你可以只使用函数来编写脚本(这就是你到目前为止所做的)。但是因为类是一个如此强大的编程概念,并且在(游戏)行业中被广泛使用,所以本书尽可能地利用了它们。通过学习如何正确使用类,你可以设计出更好的软件,用 任何 编程语言。
注意 在编写游戏时,你经常要在做一件事需要多长时间和多久做一次之间做出权衡。在 Painter 的例子中,如果你只打算创建一个或两个球,那么就不值得为这些球创建一个类。然而,通常情况下,事情会慢慢扩大。在你意识到之前,你正在复制和粘贴几十行代码,因为你没有创建一个更简单的方法来完成它。当您设计类时,考虑适当设计的长期收益,即使这需要短期的牺牲,例如必须做一些额外的编程工作以使类设计更加通用。
构建游戏对象作为游戏世界的一部分
现在你已经看到了如何创建类,你需要重新思考你的游戏对象是在哪里构造的。直到现在,游戏对象被声明为全局变量,因此,它们在任何地方都是可访问的。例如,这是创建
cannon
对象的方法:
var cannon = {
};
cannon.initialize = function() {
cannon.position = { x : 72, y : 405 };
cannon.colorPosition = { x : 55, y : 388 };
cannon.origin = { x : 34, y : 34 };
cannon.currentColor = sprites.cannon_red;
cannon.rotation = 0;
};
在 Painter5 的例子中,这是
Cannon
类的构造函数:
function Cannon() {
this.position = { x : 72, y : 405 };
this.colorPosition = { x : 55, y : 388 };
this.origin = { x : 34, y : 34 };
this.currentColor = sprites.cannon_red;
this.rotation = 0;
}
在球的
update
方法中,您需要检索大炮的当前颜色,以便更新球的颜色。这是你在上一章中是如何做到的:
if (cannon.currentColor === sprites.cannon_red)
ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
ball.currentColor = sprites.ball_green;
else
ball.currentColor = sprites.ball_blue;
当使用 JavaScript 原型方法定义一个类时,您必须用
this
替换
ball
(因为没有对象的命名实例)。所以前面的代码被翻译成
if (cannon.currentColor === sprites.cannon_red)
this.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
this.currentColor = sprites.ball_green;
else
this.currentColor = sprites.ball_blue;
但是如果加农炮也是用一个类构造的,你如何引用
cannon
对象呢?这就引出了两个问题:
- 游戏对象是在代码的什么地方构造的?
- 如果这些游戏对象不是全局变量,你如何引用它们?
从逻辑上来说,游戏对象应该在游戏世界构建的时候就被构建。这就是为什么 Painter5 示例在
PainterGameWorld
类中创建游戏对象(之前是
painterGameWorld
对象)。下面是该类的部分构造函数:
function PainterGameWorld() {
this.cannon = new Cannon();
this.ball = new Ball();
// create more game objects if needed
}
所以,这回答了第一个问题,却引出了另一个问题。如果在创建游戏世界的时候创建了游戏对象,那么在哪里调用
PainterGameWorld
的构造函数来创建游戏世界呢?如果您打开
Game.js
文件,您会看到使用原型方法定义了另一个类:
Game_Singleton
。这是它的构造函数:
function Game_Singleton() {
this.size = undefined;
this.spritesStillLoading = 0;
this.gameWorld = undefined;
}
正如你所看到的,这个类能够构造在前一章中使用的
Game
对象。
Game_Singleton
类 有一个
initialize
方法,在那里创建游戏世界对象:
Game_Singleton.prototype.initialize = function () {
this.gameWorld = new PainterGameWorld();
};
好了,你已经发现了游戏世界的构建。但是
Game_Singleton
对象的实例是在哪里构造的呢?你需要这个实例来访问游戏世界,这反过来会让你访问游戏对象。如果您查看
Game.js
文件的最后一行,您会看到这条指令:
var Game = new Game_Singleton();
最后一个实际的变量声明!所以通过变量
Game
,可以接入游戏世界;通过这个对象,你可以访问游戏世界中的游戏对象。例如,这是您到达
cannon
对象的方式:
Game.gameWorld.cannon
你可能会问,为什么这么复杂?为什么不像以前那样简单地将每个游戏对象声明为全局变量呢?有几个原因。首先,通过在不同的地方声明许多全局变量,您的代码变得更加难以重用。假设您想在另一个也使用球和大炮的应用中使用 Painter 的部分代码。现在,您必须仔细检查代码,找到声明全局变量的位置,并确保它们对您的应用有用。最好在一个地方声明这些变量(比如
PainterGameWorld
类),这样更容易找到这些声明。
使用许多全局变量的第二个问题是,您丢弃了变量之间存在的任何结构或关系。在画家游戏中,很明显大炮和球 是游戏世界 的一部分。如果通过让游戏对象成为游戏世界对象的一部分来明确表达这种关系,代码会变得更容易理解。
一般来说,尽可能避免全局变量是个好主意。在画师游戏中,主要的全局变量是
Game
。这个变量由一个包含游戏世界的树形结构组成,游戏世界又包含游戏对象,游戏对象又包含其他变量(如位置或精灵)。
使用新的结构,其中
Game
对象是其他对象的树结构,您现在可以访问
cannon
对象来检索球的所需颜色,如下所示:
if (Game.gameWorld.cannon.currentColor === sprites.cannon_red)
this.currentColor = sprites.ball_red;
else if (Game.gameWorld.cannon.currentColor === sprites.cannon_green)
this.currentColor = sprites.ball_green;
else
this.currentColor = sprites.ball_blue;
有时候在纸上画出游戏对象的树结构,或者创建一个图表,你可以在以后用适当的名字放置引用,这是很有用的。随着你开发的游戏变得越来越复杂,这样一个树提供了一个有用的概述,什么对象属于哪里,它让你在处理代码时不必在心里重新创建这个树。
编写具有多个实例的类
现在,您可以构造多个相同类型的对象,让我们在 Painter 游戏中添加几个颜料罐。这些颜料罐应该被赋予随机的颜色,它们应该从屏幕的顶部落下。一旦它们从屏幕底部掉出,你给它们分配一种新的颜色,然后把它们移回顶部。对于玩家来说,似乎每次都有不同的颜料罐落下。实际上,您只需要三个重复使用的油漆桶对象。在
PaintCan
类中,你定义一个画框是什么,它的行为是什么。然后,您可以创建该类的多个实例。在
PainterGameWorld
类中,您将这些实例存储在三个不同的成员变量中,这些变量在
PainterGameWorld
构造函数中声明并初始化:
function PainterGameWorld() {
this.cannon = new Cannon();
this.ball = new Ball();
this.can1 = new PaintCan(450);
this.can2 = new PaintCan(575);
this.can3 = new PaintCan(700);
}
PaintCan
级与
Ball
和
Cannon
级的区别在于油漆罐的位置不同。这就是为什么在构造颜料罐时要将坐标值作为参数传递。该值表示油漆罐的所需 x 位置。y 位置不必提供,因为它将根据每个颜料罐的 y 速度来计算。为了让事情更有趣,你让罐子以不同的随机速度落下。(如何做到这一点将在本章后面解释。)为了计算这个速度,你想知道一个油漆罐应该具有的最小速度,这样它才不会掉得太慢。为此,您添加一个包含值的成员变量
minVelocity
。因此,这是
PaintCan
类的构造函数:
function PaintCan(xPosition) {
this.currentColor = sprites.can_red;
this.velocity = new Vector2();
this.position = new Vector2(xPosition, -200);
this.origin = new Vector2();
this.reset();
}
就像大炮和球一样,油漆罐也有一定的颜色。默认情况下,选择红色油漆罐精灵。最初,你设置油漆罐的 y 位置,这样它就被绘制在屏幕顶部的外面,这样在游戏的后期,你就可以看到它落下。在
PainterGameWorld
构造函数中,您调用这个构造函数三次来创建三个
PaintCan
对象,每个对象都有不同的 x 位置。
因为颜料罐不处理任何输入(只有球和大炮会这样做),所以这个类不需要一个
handleInput
方法。然而,油漆罐确实需要更新。你想做的事情之一就是让颜料罐在随机的时刻以随机的速度落下。但是你怎么能这样做呢?
处理游戏中的随机性
油漆罐行为最重要的部分之一是它的某些方面应该是 不可预测的 。你不希望每个罐子都以可预测的速度或时间落下。你想增加一个 随机性 的因素,这样玩家每次开始一个新游戏,游戏都会不一样。当然,你也需要控制这种随机性。你不希望一个罐子花三个小时从顶部落到底部,而另一个罐子只花一毫秒。速度应该是随机的,但在 可玩的速度范围 内。
随机性实际上是什么意思?通常,游戏和其他应用中的随机事件或值由一个
随机数生成器
管理。在 JavaScript 中,有一个属于
Math
对象的
random
方法。你可能想知道:计算机如何生成一个完全随机的数字?现实中随机性存在吗?随机性不就是一种你还不能完全预测并因此称之为“随机”的行为表现吗?好吧,我们不要太哲学了。在游戏世界和电脑程序中,你
可以
精确预测将要发生什么,因为电脑只能做你告诉它做的事情。因此,严格地说,计算机不能产生完全随机的数字。假装可以产生随机数的一种方法是从一个预定义的非常大的数字表中选择一个数字。因为你不是真的产生随机数,这被称为一个
伪随机数发生器
。大多数随机数生成器可以生成一个范围内的数,例如 0 或 1 之间的数,但它们通常也可以生成任意数或另一个范围内的数。范围内的每个数字都有相等的机会被生成。在统计学中,这样的分布称为
均匀分布
。
假设当你开始一个游戏时,你开始通过在桌子上走来产生“随机”数字。因为数字表不会改变,所以每次玩游戏时,都会生成相同的随机数序列。为了避免这个问题,你可以在开始的时候指出你想从表格中的 不同的 位置开始。您在表格中开始的位置也被称为随机数发生器的 种子 。通常,每次启动程序时,种子的值都是不同的,比如当前系统时间。
你如何使用随机数发生器在你的游戏世界中创造随机性?假设你想在用户进门的 75%的时候制造一个敌人。在这种情况下,您会生成一个介于 0 和 1 之间的随机数。如果数字小于或等于 0.75,你就产生了一个敌人;否则你不会。由于均匀分布,这将准确地导致您所需要的行为。以下 JavaScript 代码说明了这一点:
var spawnEnemyProbability = Math.random();
if (spawnEnemyProbability >=0.75)
// spawn an enemy
else
// do something else
如果你想计算一个介于 0.5 和 1 之间的随机速度,你生成一个介于 0 和 1 之间的随机数,将这个数除以 2,然后加上 0.5:
var newSpeed = Math.random()/2 * 0.5;
在理解“真正的”随机性方面,人类并不比计算机强多少。这就是为什么你的 MP3 播放器在随机播放模式下有时会一遍又一遍地播放同样的歌曲。您认为自然出现的条纹是非随机的,而实际上它们是随机的。这意味着程序员有时不得不创建一个在人类看来是随机的函数——即使它不是真正随机的。
在游戏中,你必须非常小心地处理随机性。一个错误设计的产生随机单位的机制可能会让某些玩家更频繁地产生某种类型的单位,给他们一个不公平的优势。此外,当你设计游戏时,确保随机事件不会对结果产生太大影响。例如,不要让玩家在完成 80 级高挑战平台游戏后掷骰子,让掷骰子的结果决定玩家是否死亡。
计算随机速度和颜色和
每当一个罐子落下时,你想要为它创建一个随机的速度和颜色。您可以使用
Math.random
方法来帮助您做到这一点。让我们首先来看看创建一个随机速度。为了简洁起见,在名为
calculateRandomVelocity
的
PaintCan
类中用一个单独的方法来实现。当你想初始化罐子的速度时,你可以调用这个方法。这里你使用成员变量
minVelocity
来定义颜料罐下落时的最小速度。这个变量在
reset
方法中被赋予一个初始值,这个方法是从构造函数中调用的
PaintCan.prototype.reset = function () {
this.moveToTop();
this.minVelocity = 30;
};
当计算随机速度时,使用这个最小速度值,在
calculateRandomVelocity
方法中:
PaintCan.prototype.calculateRandomVelocity = function () {
return { x : 0, y : Math.random() * 30 + this.minVelocity };
};
该方法只包含一条指令,该指令返回一个表示速度的对象。x 方向的速度为零,因为罐子不是水平移动的——它们只会落下。y 速度是使用随机数生成器计算的。你将这个随机值乘以 30,并将成员变量
minVelocity
中存储的值相加,以获得
minVelocity
和
minVelocity+30
之间的正 y 速度。
要计算随机颜色,您也可以使用随机数发生器,但您希望在几个离散选项(红色、绿色或蓝色)中进行选择。问题是
Math.random
返回一个介于零和一之间的实数。你想要的是生成一个 0、1 或 2 的随机
整数
。然后你可以使用一个
if
指令来处理不同的情况。幸运的是,
Math.floor
法可以帮上忙。
Math.floor
返回小于作为参数传递的值的最大整数。例如:
var a = Math.floor(12.34); // a will contain the value 12
var b = Math.floor(199.9999); // b will contain the value 199
var c = Math.floor(-3.44); // c will contain the value -4
这个例子结合了
Math.random
和
Math.floor
来生成一个随机数 0、1 或 2:
var randomval = Math.floor(Math.random() * 3);
使用这种方法,您可以计算一个随机值,然后使用一个
if
指令来选择油漆罐的颜色。这个任务是通过
calculateRandomColor
方法完成的。下面是该方法的样子:
PaintCan.prototype.calculateRandomColor = function () {
var randomval = Math.floor(Math.random() * 3);
if (randomval == 0)
return sprites.can_red;
else if (randomval == 1)
return sprites.can_green;
else
return sprites.can_blue;
};
现在您已经编写了这两种生成随机值的方法,您可以在定义油漆罐的行为时使用它们。
更新油漆罐
PaintCan
类中的
update
方法至少应该做以下事情:
- 设置一个随机创建的速度和颜色,如果罐头目前还没有下降
- 通过添加速度来更新罐位置
- 检查罐子是否完全掉落,并在那种情况下重置它
对于第一个任务,您可以使用一个
if
指令来检查罐子当前是否没有移动(速度等于零)。此外,您希望引入一点不可预测性,以确定罐头何时出现。为了达到这种效果,只有当某个生成的随机数小于阈值 0.01 时,才能指定随机速度和颜色。由于均匀分布,大约 100 个随机数中只有 1 个小于 0.01。因此,
if
指令的主体有时会被执行,甚至当一个罐子的速度为零时。在
if
指令的主体中,你使用了之前定义的两种方法来生成随机速度和随机颜色:
if (this.velocity.y === 0 && Math.random() < 0.01) {
this.velocity = this.calculateRandomVelocity();
this.currentColor = this.calculateRandomColor();
}
您还需要通过添加当前速度来更新罐子位置,再次考虑游戏时间,就像您处理球一样:
this.position.x = this.position.x + this.velocity.x * delta;
this.position.y = this.position.y + this.velocity.y * delta;
现在您已经初始化了 can 并更新了它的位置,您需要处理特殊情况。对于油漆罐,你得检查它是否已经掉落在游戏世界之外。如果是这样,就需要重新设置。好的是你已经写了一个方法来检查某个位置是否在游戏世界之外:在
PainterGameWorld
类中的
isOutsideWorld
方法。您现在可以再次使用该方法来检查罐子的位置是否在游戏世界之外。如果是这种情况,您需要重新设置罐子,使其再次位于屏幕外部的顶部。完整的
if
指令变成了
if (Game.gameWorld.isOutsideWorld(this.position))
this.moveToTop();
最后,为了让游戏更有挑战性,每次更新循环时,稍微提高罐子的最小速度:
this.minVelocity = this.minVelocity + 0.01;
因为最小速度缓慢增加,游戏随着时间的推移变得更加困难。
在屏幕上画罐子
为了在屏幕上绘制油漆桶,您向
PaintCan
类添加一个
draw
方法,该方法简单地在期望的位置绘制油漆桶精灵。在
PainterGameWorld
类中,您调用不同游戏对象上的
handleInput
、
update
和
draw
方法。例如
PainterGameWorld
中的
draw
方法如下:
PainterGameWorld.prototype.draw = function () {
Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
{ x : 0, y : 0 });
this.ball.draw();
this.cannon.draw();
this.can1.draw();
this.can2.draw();
this.can3.draw();
};
Painter5 示例的所有代码都可以在本章的示例文件夹中找到。图 8-1 显示了油漆工人 5 示例的屏幕截图,现在有三个掉落的油漆罐。
图 8-1 。画家 5 示例的屏幕截图,其中有一门大炮、一个球和三个掉落的颜料罐
将位置和速度表示为矢量
您已经看到,类是一个有价值的概念,因为它们定义了对象的结构,以及通过方法修改这些对象的行为。当您需要多个相似的对象(例如三个油漆桶)时,这尤其有用。类非常有用的另一个领域是定义基本的数据结构和操作这些结构的方法。你已经见过的一个常见结构是一个表示二维位置或速度向量的对象:
var position = { x : 0, y : 0 };
var anotherPosition = { x : 35, y : 40 };
不幸的是,下面的指令是不允许的:
var sum = position + anotherPosition;
原因是加法运算符不是为这样的复合对象定义的。当然,您可以定义一个方法来完成这项工作。但是其他一些方法也是有用的。例如,如果你能减去这些向量,乘以它们,计算它们的长度,等等,那就太好了。为了做到这一点,让我们创建一个
Vector2
类。首先定义构造函数:
function Vector2(x, y) {
this.x = x;
this.y = y;
}
您现在可以创建一个对象,如下所示:
var position = new Vector2(0,0);
如果你能初始化一个向量而不需要一直传递两个参数,那就太好了。一种方法是检查
x
和/或
y
是否未定义。如果是这种情况,只需将成员变量初始化为 0,如下:
function Vector2(x, y) {
if (typeof x === 'undefined')
this.x = 0;
else
this.x = x;
if (typeof y === 'undefined')
this.y = 0;
else
this.y = y;
}
在 JavaScript 中使用
typeof
关键字来返回变量的类型。这里你用它来检查
x
和
y
是否有一个已定义的类型。如果是这种情况,可以将作为参数传递的值赋给成员变量。否则,将值指定为 0。JavaScript 知道写下这种
if
指令的一个更简短的版本。这是相同方法的样子,只是缩短了:
function Vector2(x, y) {
this.x = typeof x !== 'undefined' ? x : 0;
this.y = typeof y !== 'undefined' ? y : 0;
}
这段代码做的事情与带有完整的
if
指令的版本完全一样,但是它要短得多。问号前面是条件。然后,在问号后面,有两个值的选项,用冒号隔开。当使用这个较短的版本时,请确保您的代码仍然可读。本书仅使用较短的版本来检查参数是否已定义。这有好处;例如,您可以用各种方式创建
Vector2
对象:
var position = new Vector2(); // create a vector (0, 0)
var anotherPosition = new Vector2(35, 40); // create a vector (35, 40)
var yetAnotherPosition = new Vector2(-1); // create a vector (-1, 0)
现在你可以给
Vector2
类添加一些有用的方法,这样用向量进行计算就变得更容易了。例如,下面的方法制作一个 vector 的副本:
Vector2.prototype.copy = function () {
return new Vector2(this.x, this.y);
};
如果你想从不同的游戏对象中复制位置或速度,这很方便。此外,比较矢量也很有用。
equals
方法为你做了这个:
Vector2.prototype.equals = function (obj) {
return this.x === obj.x && this.y === obj.y;
};
您还可以定义一些基本操作,如向量的加、减、乘和除。首先,让我们定义一个向现有向量添加向量的方法:
Vector2.prototype.addTo = function (v) {
this.x = this.x + v.x;
this.y = this.y + v.y;
return this;
};
您可以按如下方式使用此方法:
var position = new Vector2(10, 10); // create a vector (10, 10)
var anotherPosition = new Vector2(20, 20); // create a vector (20, 20)
position.addTo(anotherPosition); // now represents the vector (30, 30)
addTo
方法的最后一条指令返回
this
。原因是你可以做所谓的
操作符链接
。因为
addTo
方法返回一个向量作为结果,所以您可以对该结果调用方法。例如:
var position = new Vector2(10, 10); // create a vector (10, 10)
var anotherPosition = new Vector2(20, 20); // create a vector (20, 20)
position.addTo(anotherPosition).addTo(anotherPosition);
// position now represents the vector (50, 50)
根据传递给
addTo
方法的参数的类型,您可以做一些不同的事情。如果参数是一个数字,你只需把这个数字加到向量的每个元素上。如果它是一个向量,你用已经描述过的方法执行运算。一种方法是使用您之前见过的
typeof
操作符,如下所示:
Vector2.prototype.addTo = function (v) {
if (typeof v === 'Vector2') {
this.x = this.x + v.x;
this.y = this.y + v.y;
}
else if (typeof v === 'Number') {
this.x = this.x + v;
this.y = this.y + v;
}
return this;
};
您使用一个
if
指令来确定被传递的参数的类型,并相应地执行加法操作。另一种确定类型的方法是使用
constructor
变量,它是 JavaScript 中每个对象的一部分(就像
prototype
是每个函数的一部分一样)。这是
addTo
方法的一个版本,它使用了
constructor
变量,而不是
typeof
运算符:
Vector2.prototype.addTo = function (v) {
if (v.constructor === Vector2) {
this.x = this.x + v.x;
this.y = this.y + v.y;
}
else if (v.constructor === Number) {
this.x = this.x + v;
this.y = this.y + v;
}
return this;
};
addTo
方法将一个向量添加到一个现有的向量中。您还可以定义一个
add
方法,将两个向量相加并返回一个
新向量
。为此,您可以重用
copy
和
addTo
方法:
Vector2.prototype.add = function (v) {
var result = this.copy();
return result.addTo(v);
};
您现在可以执行以下操作:
var position = new Vector2(10, 10); // create a vector (10, 10)
var anotherPosition = new Vector2(20, 20); // create a vector (20, 20)
var sum = position.add(anotherPosition); // creates a new vector (30, 30)
在本例中,
position
和
anotherPosition
在第三条指令中保持不变。创建一个新的 vector 对象,它包含操作数向量中值的总和。
看看 Painter6 示例中的
Vector2.js
文件,在这里可以看到
Vector2
类的完整定义。它定义了这个类中最常见的向量运算,包括本节讨论的加法方法。因此,在画师游戏中使用矢量要容易得多。
在所有游戏对象中使用
Vector2
类型来表示位置和速度。例如,这是
Ball
类的新构造函数:
function Ball() {
this.position = new Vector2();
this.velocity = new Vector2();
this.origin = new Vector2();
this.currentColor = sprites.ball_red;
this.shooting = false;
}
感谢
Vector2
类中的方法,您可以根据球的速度直观地更新球的位置,只需一行代码:
this.position.addTo(this.velocity.multiply(delta));
参数的默认值
在完成本章之前,让我们再看一下
Vector2
构造函数是如何定义的:
function Vector2(x, y) {
this.x = typeof x !== 'undefined' ? x : 0;
this.y = typeof y !== 'undefined' ? y : 0;
}
由于方法体内部的赋值指令,即使您在调用构造函数方法时没有传递任何参数,您仍将创建一个有效的
Vector2
对象。如果没有定义参数
x
和
y
,则使用
默认值
。您可以利用这种情况,因为依赖默认值可以简化代码编写。举个例子,这是在屏幕上绘制背景图像的指令:
Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0, { x : 0, y : 0 });
通过让
drawImage
方法 自动为位置、旋转和原点参数提供默认值,可以使这个方法调用更加简洁:
Canvas2D_Singleton.prototype.drawImage = function (sprite, position,
rotation, origin) {
position = typeof position !== 'undefined' ? position : Vector2.zero;
rotation = typeof rotation !== 'undefined' ? rotation : 0;
origin = typeof origin !== 'undefined' ? origin : Vector2.zero;
// remaining drawing code here
...
}
然后绘制背景,如下所示:
Canvas2D.drawImage(sprites.background);
虽然参数的默认值在创建紧凑的代码时非常有用,但是请确保您总是为您的方法提供文档,指定如果方法的调用方没有提供所有参数时将使用哪些默认值。
你学到了什么
在本章中,您学习了:
- 如何使用原型机制定义类
- 如何创建一个类型/类的多个实例
- 如何增加游戏的随机性以增加可玩性
九、颜色和碰撞
到目前为止,您已经实现了 Painter 游戏的很大一部分。您已经看到了如何使用原型机制定义游戏对象类。通过使用这些类,您可以更好地控制游戏对象的结构以及如何创建特定类型的游戏对象。您将这些类定义分隔在不同的文件中。这样,当你在未来的游戏中需要一个具有相同行为的大炮或球时,你可以简单地复制这些文件并在你的游戏中创建这些游戏对象的实例。
当您更仔细地查看类的定义时,您会发现类定义了对象的内部结构(它由哪些变量组成)以及以某种方式操作该对象的方法。这些方法可以帮助更精确地定义一个对象的可能性和局限性。例如,如果有人想重用
Ball
类,他们不需要很多关于球是如何构造的详细信息。简单地创建一个实例并调用 game-loop 方法就足以在游戏中添加一个飞行球。一般来说,当你设计一个程序时,无论是游戏还是完全不同的应用,清楚地定义某个类的对象的可能性是很重要的。方法是做到这一点的一种方式。本章向你展示了另一种定义对象可能性的方法:通过定义
属性
。本章还介绍了一种表示颜色的类型,并展示了如何处理球和颜料罐之间的碰撞(如果发生这种情况,颜料罐需要改变颜色)。
表现颜色的不同方式
在 Painter 的早期版本中,您已经相当实际地处理了颜色。例如,在
Cannon
类中,您通过使用
currentColor
变量跟踪当前颜色,该变量最初指向红色的 cannon sprite:
this.currentColor = sprites.cannon_red;
你在
Ball
类中做了类似的事情,除了你让同名的变量指向彩色的球精灵。虽然这样做很好,但当球的颜色需要根据大炮的颜色而改变时,这就有点不方便了:
if (Game.gameWorld.cannon.currentColor === sprites.cannon_red)
this.currentColor = sprites.ball_red;
else if (Game.gameWorld.cannon.currentColor === sprites.cannon_green)
this.currentColor = sprites.ball_green;
else
this.currentColor = sprites.ball_blue;
在这个
if
指令中,你需要处理所有三种不同的颜色;此外,现在
Ball
类需要了解
Cannon
类使用的精灵。如果可以更统一地定义颜色,并在所有游戏对象类中使用该定义来表示不同的颜色,不是更好吗?当然会!现在开始在游戏中统一颜色使用的另一个原因是,如果你决定增加游戏中可能的颜色数量(到 4、6、10 或更多),当前的方法将需要更长的编程时间。
属于本章的 Painter7 示例添加了一个
Color.js
JavaScript 文件。要定义不同的颜色,可以使用与定义不同键类似的方法。这个文件定义了一个名为
Color
的变量。
Color
变量包含许多子变量,每个子变量代表一种不同的颜色。您可以如下定义颜色:
var Color = {
red : 1,
blue : 2,
yellow : 3,
green : 4,
// and so on
}
然而,这种方法并不是一个好的解决方案。用数字来表示颜色并不是一个坏主意,但是你不应该自己编一个没有人知道的编号方案。在 HTML 中已经有了一个定义颜色的标准,它使用以十六进制形式表示的整数,您也可以使用这个标准。好处是这种方法被广泛使用,被广泛理解,被工具广泛支持(比如 Adobe 的 Kuler,比如 at
http://kuler.adobe.com
)。
在 HTML 标准中,您可以通过使用十六进制表示来定义网页中元素的颜色。例如:
<body style="background: #0000FF">
That's a very nice background.
</body>
在这种情况下,您指定主体的背景颜色应该是颜色 蓝色 。十六进制表示让您定义红、绿、蓝(RGB)值中的颜色,其中 00 表示没有颜色分量, FF 表示颜色分量最大。
#
符号不是数字的一部分,它只是向浏览器表明后面是十六进制数而不是十进制数。所以,
#0000FF
十六进制数代表蓝色,
#00FF00
是绿色,
#FF0000
是红色。当然,颜色分量的任何混合或渐变都可以用类似的方式来定义。
#808080
为灰色,
#800080
为紫色,
#FF00FF
为洋红色。
下面是
Color
变量的一部分:
var Color = {
aliceBlue: "#F0F8FF",
antiqueWhite: "#FAEBD7",
aqua: "#00FFFF",
aquamarine: "#7FFFD4",
azure: "#F0FFFF",
beige: "#F5F5DC",
bisque: "#FFE4C4",
black: "#000000",
blanchedAlmond: "#FFEBCD",
blue: "#0000FF",
blueViolet: "#8A2BE2",
brown: "#A52A2A",
// and so on
}
有关更完整的颜色列表,请参见
Color.js
文件。您现在可以开始在您的类中使用这些颜色定义。
对象的受控数据访问
三个游戏对象类代表一个特定颜色的对象:
Cannon
、
Ball
和
PaintCan
。为了简单起见,让我们从如何修改
Cannon
类来使用上一节中的颜色定义开始。到目前为止,
Cannon
构造函数看起来是这样的:
function Cannon() {
this.position = new Vector2(72, 405);
this.colorPosition = new Vector2(55, 388);
this.origin = new Vector2(34, 34);
this.currentColor = sprites.cannon_red;
this.rotation = 0;
}
您可以做的是添加另一个成员变量,给出加农炮的当前颜色。因此,新的
Cannon
构造函数如下所示:
function Cannon() {
this.position = new Vector2(72, 405);
this.colorPosition = new Vector2(55, 388);
this.origin = new Vector2(34, 34);
this.currentColor = sprites.cannon_red;
this.color = Color.red;
this.rotation = 0;
}
然而,这并不理想。您现在存储了冗余数据,因为颜色信息由两个变量表示。此外,当加农炮的颜色改变时,如果您忘记改变两个变量中的一个,您可能会以这种方式引入错误。
另一种方法是不存储对当前 sprite 的引用。这将是构造函数:
function Cannon() {
this.position = new Vector2(72, 405);
this.colorPosition = new Vector2(55, 388);
this.origin = new Vector2(34, 34);
this.color = Color.red;
this.rotation = 0;
}
这也不是一种理想的方法,因为每次调用
draw
方法时,您都需要查找正确的 sprite。
一个解决方案是定义两个方法,允许
Cannon
类的用户检索和设置颜色信息。然后,您可以保持构造函数不变,但添加方法来读取和写入颜色值。例如,您可以将以下两个方法添加到
Cannon
原型中:
Cannon.prototype.getColor = function () {
if (this.currentColor === sprites.cannon_red)
return Color.red;
else if (this.currentColor === sprites.cannon_green)
return Color.green;
else
return Color.blue;
};
Cannon.prototype.setColor = function (value) {
if (value === Color.red)
this.currentColor = sprites.cannon_red;
else if (value === Color.green)
this.currentColor = sprites.cannon_green;
else if (value === Color.blue)
this.currentColor = sprites.cannon_blue;
};
现在
Cannon
类的用户不需要知道在内部,您使用一个 sprite 来确定大炮的当前颜色。用户可以简单地传递颜色定义来读取或写入加农炮的颜色:
myCannon.setColor(Color.blue);
var cannonColor = myCannon.getColor();
有时,程序员称这类方法为 getter 和 setter 。在许多面向对象的编程语言中,方法是访问对象内部数据的唯一方式,因此对于每个需要在类外部访问的成员变量,程序员都添加了一个 getter 和一个 setter。JavaScript 提供了一个对面向对象编程语言来说相对较新的特性: 属性 。属性是 getter 和 setter 的替代品。它定义了从对象中检索数据时会发生什么,以及向对象内部的数据赋值时会发生什么。
只读属性
按照基于原型的编程范式,您希望能够向类添加属性。JavaScript 有一个叫做
defineProperty
的简便方法可以让你做到这一点。这个方法是对象的一部分,俗称
Object
。
Object
还有几个其他有用的方法,您稍后会了解到。
defineProperty
方法需要三个参数:
- 应添加属性的原型(例如,
Cannon.prototype
) - 属性的名称(例如,
color
) - 一个最多包含两个变量的对象:
get
和set
get
和
set
变量都应该指向一个函数,该函数应该在属性被读取或写入时被执行。然而,可以只定义一个
get
或
set
零件。如果属性只读取信息而不能更改信息,这将非常有用。如果一个属性只读取信息,它被称为
只读属性
。下面是一个简单的例子,它是您添加到
Cannon
类中的只读属性:
Object.defineProperty(Cannon.prototype, "center",
{
get: function () {
return new Vector2(this.currentColor.width / 2,
this.currentColor.height / 2);
}
});
如您所见,您向
defineProperty
方法提供了三个参数:原型、名称和对象。这个楼盘的名字叫
center
。它的目标是提供代表大炮的精灵的中心。因为不可能改变中心的值,所以这个属性只有一个
get
部分。这反映在作为第三个参数传递的对象中,该对象包含一个指向函数的变量
get
。以下是使用该属性的方法:
var cannonCenter = cannon.center;
很简单,不是吗?同样,您可以添加一个提供加农炮高度的属性,如下所示:
Object.defineProperty(Cannon.prototype, "height",
{
get: function () {
return this.currentColor.height;
}
});
你甚至可以定义一个属性
ballPosition
来计算球应该在的位置:
Object.defineProperty(Cannon.prototype, "ballPosition",
{
get: function () {
var opposite = Math.sin(this.rotation) *
sprites.cannon_barrel.width * 0.6;
var adjacent = Math.cos(this.rotation) *
sprites.cannon_barrel.width * 0.6;
return new Vector2(this.position.x + adjacent,
this.position.y + opposite);
}
});
就像处理方法一样,使用
this
关键字来引用属性所属的对象。属于本章的 Painter7 示例将属性添加到不同的类中。例如,
Ball
类也包含一个
center
属性。结合您添加到
Vector2
中的便捷的方法,您现在可以在一行代码中根据大炮的旋转来计算球的新位置:
this.position = Game.gameWorld.cannon.ballPosition.subtractFrom(this.center);
定义类、方法和属性的好处是你的代码变得更短,更容易理解。例如,您还可以将以下属性添加到
Vector2
:
Object.defineProperty(Vector2, "zero",
{
get: function () {
return new Vector2();
}
});
现在你有一个非常简单的方法来创建一个二维向量,如下:
var position = Vector2.zero;
从现在开始,我使用属性和方法来定义对象的行为和数据访问。通过在类中定义有用的属性和方法,游戏代码通常会变得更短,更容易阅读。例如,在您拥有包含有用方法和属性的类之前,您必须这样计算球的位置:
this.position = Game.gameWorld.cannon.ballPosition();
this.position.x = this.position.x - this.currentColor.width / 2;
this.position.y = this.position.y - this.currentColor.height / 2;
正如您在本节前面所看到的,新的方法要短得多。这种效果在你在本书中开发的游戏代码中随处可见,我鼓励你接受类、方法和属性所提供的力量!
检索加农炮的颜色
在本章中,您定义了一个名为
Color
的新类型。因此,让我们结合使用该类型和属性来读取和写入大炮的颜色。根据
currentColor
变量指向的 sprite,您希望返回不同的颜色值。为了实现这一点,您需要在
Cannon
类中添加一个名为
color
的属性。在该属性的
get
部分,您使用一个
if
指令来找出返回哪种颜色。就像有返回值的方法一样,使用
return
关键字来指示属性应该返回什么值:
Object.defineProperty(Cannon.prototype, "color",
{
get: function () {
if (this.currentColor === sprites.cannon_red)
return Color.red;
else if (this.currentColor === sprites.cannon_green)
return Color.green;
else
return Color.blue;
}
});
现在,您可以使用该属性来访问加农炮的颜色。例如,您可以将它存储在一个变量中,如下所示:
var cannonColor = cannon.Color;
您还希望能够为加农炮颜色赋值。为此,您必须定义属性的
set
部分。在那个部分,您需要修改
currentColor
变量的值。当在另一个方法中使用该属性时,会提供此值。例如,它可能是这样的指令:
cannon.color = Color.Red;
同样,您使用一条
if
指令来确定
currentColor
变量的新值应该是什么。右边的赋值作为
参数
传递给
set
部件。完整的属性如下所示:
Object.defineProperty(Cannon.prototype, "color",
{
get: function () {;
if (this.currentColor === sprites.cannon_red)
return Color.red;
else if (this.currentColor === sprites.cannon_green)
return Color.green;
else
return Color.blue;
},
set: function (value) {
if (value === Color.red)
this.currentColor = sprites.cannon_red;
else if (value === Color.green)
this.currentColor = sprites.cannon_green;
else if (value === Color.blue)
this.currentColor = sprites.cannon_blue;
}
});
这是一个可以读写的属性示例。你添加一个
color
属性到所有的彩色游戏对象类型:
Cannon
、
Ball
和
PaintCan
。在
get
和
set
部分的代码中唯一的区别是用来表示颜色的精灵。例如,这是
Ball
类的
color
属性:
Object.defineProperty(Ball.prototype, "color",
{
get: function () {
if (this.currentColor === sprites.ball_red)
return Color.red;
else if (this.currentColor === sprites.ball_green)
return Color.green;
else
return Color.blue;
},
set: function (value) {
if (value === Color.red)
this.currentColor = sprites.ball_red;
else if (value === Color.green)
this.currentColor = sprites.ball_green;
else if (value === Color.blue)
this.currentColor = sprites.ball_blue;
}
});
因为您已经定义了这些属性,所以现在您可以根据大炮的颜色非常容易地更改球的颜色,只需一行代码:
this.color = Game.gameWorld.cannon.color;
查看 Painter7 示例,了解它如何以及在何处使用属性来使代码更易于阅读。对于一些程序员来说,属性乍一看可能很奇怪,因为它们被用来获取和设置方法。然而,属性确实有更直观的意义。它们是降低代码行复杂性的好方法。
处理球和罐子之间的碰撞
Painter7 示例通过处理球和罐子之间的碰撞来扩展游戏。如果两个对象发生冲突,你必须在两个对象之一的
update
方法中处理这个冲突。在这种情况下,您可以选择在
Ball
类或
PaintCan
类中处理冲突。Painter7 在
PaintCan
类中处理碰撞,因为如果你要在
Ball
类中处理碰撞,你需要重复同样的代码三次,每个油漆罐一次。通过在
PaintCan
类中处理碰撞,您可以自动获得这种行为,因为每个类都可以自己检查是否与球碰撞。
虽然可以用许多不同的方法进行冲突检查,但是这里使用一个非常简单的方法。如果两个对象中心之间的距离小于某个值,则可以定义这两个对象之间存在碰撞。游戏世界中任何时候球的中心位置是通过将球精灵的中心与球的位置相加来计算的。您可以用类似的方法计算油漆罐的中心。因为您添加了一些很好的属性来计算游戏对象的中心,所以让我们使用它们来计算球和油漆罐之间的距离,如下所示:
var ball = Game.gameWorld.ball;
var distance = ball.position.add(ball.center).subtractFrom(this.position)
.subtractFrom(this.center);
现在你已经计算了这个向量,你必须检查它在 x 和 y 方向的长度是否小于某个给定值。如果距离向量的 x 分量的绝对值小于中心的 x 值,则意味着球对象在罐的 x 范围内。同样的原理也适用于 y 方向。如果这对于 x 和 y 分量都成立,你可以说球和罐子碰撞了。您可以编写一条
if
指令来检查这种情况:
if (Math.abs(distance.x) < this.center.x &&
Math.abs(distance.y) < this.center.y) {
// handle the collision
}
您使用
Math.abs
方法来计算距离分量的绝对值。如果球和罐子有碰撞,你需要把罐子的颜色改成球的颜色。
接下来,你必须重新设置球,以便它可以再次被拍摄。以下两条指令正是这样做的:
this.color = ball.color;
ball.reset();
您可以尝试 Painter7 示例,查看球和颜料罐之间的碰撞是否得到了正确处理。
您可能已经注意到,这里使用的碰撞检测方法不是很精确。在第二十六章中,你会看到一种更好的方法来处理每像素级别的碰撞,尽管如果你不小心的话,这会让你的游戏运行得更慢。
注意 最后,像本节中所写的简单代码行在玩家体验中产生了很大的不同。当你构建你的游戏应用时,你会发现有时候对玩家来说最小的东西也要花最长的时间来编程,而最大的变化只用一两行就实现了!
你学到了什么
在本章中,您学习了:
- 如何向类中添加属性
- 如何处理游戏对象之间的基本碰撞
- 如何定义具有不同颜色的游戏对象
十、有限的生命
在这一章中,你通过给玩家有限数量的生命来使画家游戏更有趣。如果玩家错过太多颜料罐,他们就会死。这一章讨论了如何处理这个问题,以及如何向玩家显示当前的生命值。为了实现后者,您需要学习一些编程结构,以便多次重复一组指令。
维持生命的数量
为了在游戏中引入一些危险和努力工作的激励,您想要限制玩家可以允许从屏幕底部掉落的错误颜色的颜料罐的数量。Painter8 示例将这种行为添加到游戏中,并使用五个限制。
选择五个颜料罐的限制是作为游戏设计者和开发者必须做出的决定的许多例子之一。如果你只给玩家一条命,那么这个游戏就太难玩了。给玩家几百条命就消除了玩家玩好游戏的动机。确定这些参数通常是通过游戏测试和确定合理的参数值来实现的。除了自己测试游戏之外,你还可以请你的朋友或家人玩你的游戏,以了解这些参数的取值。
为了存储寿命限制,您向
PainterGameWorld
类添加了一个额外的成员变量:
this.lives = 5;
最初在
PainterGameWorld
类的构造函数中将这个值设置为 5。现在,只要颜料罐落在屏幕之外,您就可以更新该值。您在
PaintCan
类的
update
方法中执行这个检查。因此,您必须在该方法中添加一些指令来处理这种情况。你唯一需要做的事情是检查颜料罐的颜色是否与它通过屏幕底部时的目标颜色相同。如果是这种情况,你必须减少
PainterGameWorld
类中的
lives
计数器。
在这样做之前,您必须扩展
PaintCan
类,以便
PaintCan
对象知道当它们从屏幕底部掉出来时需要有一个目标颜色。当您在
PainterGameWorld
中创建
PaintCan
对象时,Painter8 将此目标颜色作为参数传递:
this.can1 = new PaintCan(450, Color.red);
this.can2 = new PaintCan(575, Color.green);
this.can3 = new PaintCan(700, Color.blue);
您将目标颜色存储在每个颜料罐的变量中,正如您在
PaintCan
的构造函数中看到的:
function PaintCan(xPosition, targetColor) {
this.currentColor = sprites.can_red;
this.velocity = Vector2.zero;
this.position = new Vector2(xPosition, -200);
this.origin = Vector2.zero;
this.targetColor = targetColor;
this.reset();
}
您现在可以扩展
PaintCan
的
update
方法,这样它就可以处理颜料罐落在屏幕底部之外的情况。如果发生这种情况,您需要将颜料罐移回屏幕顶部。如果油漆桶的当前颜色与目标颜色不匹配,则生命数减少一:
if (Game.gameWorld.isOutsideWorld(this.position)) {
if (this.color !== this.targetColor)
Game.gameWorld.lives = Game.gameWorld.lives - 1;
this.moveToTop();
}
在某些时候,你可能想要减少不止一个生命的数量。为了方便起见,你可以把惩罚变成一个变量:
var penalty = 1;
if (Game.gameWorld.isOutsideWorld(this.position)) {
if (this.color !== this.targetColor)
Game.gameWorld.lives = Game.gameWorld.lives - penalty;
this.moveToTop();
}
这样,如果你愿意,你可以引入更严厉的惩罚,或者动态惩罚(第一次失误要付出一条生命的代价,第二次失误要付出两条生命的代价,以此类推)。你也可以想象有时一种特殊的颜料会掉下来。如果球员用正确颜色的球射击罐子,油漆罐颜色不匹配的惩罚暂时为零。你能想出在画家游戏中处理点球的其他方法吗?
向玩家指示生命的数量
显然,玩家想知道他们做得怎么样。所以,你必须在屏幕上显示玩家还剩多少条命。在画师游戏中,你可以在屏幕的左上角显示一些气球。利用你所拥有的知识,你可以使用一个
if
指令来做这件事:
if (lives === 5) {
// Draw the balloon sprite 5 times in a row
} else if (lives === 4) {
// Draw the balloon sprite 4 times in a row
} else if (lives === 3)
// And so on...
这不是一个很好的解决方案。这导致了大量的代码,你不得不多次复制相同的指令。好在有更好的解决方案: 迭代 。
多次执行指令
JavaScript 中的迭代是多次重复指令的一种方式。看看下面的代码片段:
var val = 10;
while (val >=3)
val = val - 3;
第二条指令称为
while
循环。该指令由一种头(
while (val >=3)
)和一种体(
val = val - 3;
)组成,与
if
指令的结构非常相似。标题由单词
while
组成,后跟括号中的
条件
。身体本身就是一个指令。在这种情况下,指令从变量中减去 3。然而,它也可以是另一种指令,比如调用一个方法或访问一个属性。图 10-1 显示了
while
指令的语法图。
图 10-1 。
while
指令的语法图
当执行
while
指令时,其主体被多次执行。事实上,只要头文件中的
条件
产生
true
,主体就会被执行。在这个例子中,条件是
val
变量包含一个等于或大于 3 的值。一开始,变量包含值 10,所以它肯定大于 3。因此,
while
指令的主体被执行,之后变量
val
包含值 7。然后再次评估该条件。变量仍然大于 3,所以再次执行主体,之后变量
val
包含值 4。再次,值大于 3,所以再次执行主体,
val
将包含值 1。此时,条件被评估,但它不再是
true
。因此,重复的指令结束。因此,在这段代码执行之后,变量
val
包含值 1。事实上,您在这里编程的是使用
while
指令的整数除法。当然,在这种情况下,更简单的方法是使用下面一行代码来实现相同的结果:
var val = 10 % 3;
如果你想在屏幕上画出玩家的生命数,你可以使用一个
while
指令来非常有效地完成:
var i = 0;
while (i < numberOfLives) {
Canvas2D.drawImage(sprites.lives,
new Vector2(i * sprites.lives.width + 15, 60));
i = i + 1;
}
在这个
while
指令中,只要变量
i
包含一个小于
numberOfLives
的值(假设这个变量在其他地方被声明并初始化为某个值),就执行主体。每执行一次主体,你就在屏幕上画出 sprite,然后把
i
加 1。结果就是你在屏幕上画的精灵正好是
numberOfLives
的两倍!所以,你在这里使用变量
i
作为
计数器
。
注意
你从等于零的
i
开始,直到
i
达到与
numberOfLives
相同的值。这意味着
while
指令的主体针对
i
的值 0、1、2、3 和 4 执行。结果,身体被执行五次。
正如你所看到的,一条
while
指令的主体可能包含不止一条指令。如果主体包含不止一条指令,那么这些指令需要放在大括号中,就像使用
if
指令一样。
绘制精灵的位置取决于
i
的值。这样,你可以把每个精灵画得更靠右一点,这样它们就可以很好地排成一行。第一次执行 body 时,在 x 位置 15 绘制 sprite,因为
i
是 0。下一次迭代,你在 x 位置
sprites.lives.width + 15
绘制精灵,下一次迭代在
2 * sprites.lives.width + 15
绘制,以此类推。在这种情况下,你不仅要使用计数器来确定你执行指令的频率,还要
改变指令做什么
。这是像
while
这样的迭代指令的一个非常强大的特性。由于循环行为,
while
指令也被称为
while
循环
。图 10-2 显示了画家游戏的截图,其中生命的数量显示在屏幕的左上角。
图 10-2 。画家游戏向玩家展示剩余的生命数
递增计数器的更短符号
许多
while
指令,尤其是那些使用计数器的指令,都有一个包含变量递增指令的主体。这可以通过以下指令完成:
i = i + 1;
顺便说一下,特别是由于这种类型的指令,将赋值表示为“是”是不明智的。
i
的值当然永远不可能和
i + 1
相同,但是
i
的值就变成了
i
的旧值,加 1。这种类型的指令在程序中非常常见,因此存在一种特殊的、更短的符号来做完全相同的事情:
i++;
++
可以表示为“递增”因为这个操作符放在它所操作的变量之后,所以
++
操作符被称为
后缀操作符
。要使变量增加 1 以上,还有另一种表示法
i += 2;
这意味着和
i = i + 2;
其他基本算术运算也有类似的记法,例如:
i -= 12; // this is the same as i = i – 12
i *= 0.99; // this is the same as i = i * 0.99
i /=5; // this is the same as i = i / 5
i--; // this is the same as i = i – 1
这种符号非常有用,因为它允许您编写更短的代码。例如,下面的代码:
Game.gameWorld.lives = Game.gameWorld.lives – penalty;
…变成:
Game.gameWorld.lives -= penalty;
当你查看属于本章的示例代码时,你会发现这种更短的符号被用在许多不同的类中,以使代码更紧凑。
更紧凑的循环语法
许多
while
指令使用计数变量,因此具有以下结构:
var i;
i = *begin value*
;
while (i < *end value* ) {
// do something useful using i
i++;
}
因为这种指令很常见,所以有一个更简洁的符号:
var i;
for (i = *begin value* ; i < *end value* ; i++ ) {
// do something useful using i
}
这条指令的含义和前面的
while
指令完全一样。在这种情况下使用
for
指令的优点是,所有与计数器有关的东西都被很好地组合在指令头中。这减少了您忘记递增计数器的指令的机会(导致无限循环)。在“使用
i
做一些有用的事情”只包含一条指令的情况下,您可以省去大括号,这使得符号更加简洁。同样,你可以在
for
指令的头部移动变量
i
的声明。例如,看看下面的代码片段:
for (var i = 0; i < this.lives; i++) {
Canvas2D.drawImage(sprites.lives,
new Vector2(i * sprites.lives.width + 15, 60));
}
这是一个非常紧凑的指令,它递增计数器并在不同的位置绘制精灵。该指令相当于下面的
while
指令:
var i = 0;
while (i < this.lives) {
Canvas2D.drawImage(sprites.lives,
new Vector2(i * sprites.lives.width + 15, 60));
i = i + 1;
}
这是另一个例子:
for (var i = this.lives - 1; i >=0; i--)
Canvas2D.drawImage(sprites.lives,
new Vector2(i * sprites.lives.width + 15, 60));
这条
for
指令等同于哪条
while
指令?在这种情况下,增加或减少计数器在实践中有区别吗?图 10-3 包含了
for
指令的语法图。
图 10-3 。
for
指令的语法图
几个特例
在处理
while
和
for
循环时,您需要了解一些特殊情况。以下小节讨论了这些情况。
完全没有重复
有时
while
指令头中的条件在开始时已经是
false
。请看下面的代码片段:
var x = 1;
var y = 0;
while (x < y)
x++;
在这种情况下,
while
指令的主体不会被执行——一次也不会!因此,在这个例子中,变量
x
保留值 1。
无限重复
使用
while
指令(在较小程度上还有
for
指令)的危险之一是,如果你不小心,它们可能永远不会结束。你可以很容易地写出这样的指令:
while (1 + 1 === 2)
x = x + 1;
在这种情况下,
x
的值会无限增加。这是因为条件
1 + 1 === 2
总是产生
true
,不管在指令体中做了什么。这个例子很容易避免,但是由于编程错误,一个
while
指令经常在一个无限循环中结束。考虑以下示例:
var x = 1;
var n = 0;
while (n < 10)
x = x * 2;
n = n + 1;
这段代码的意图是将
x
的值翻十倍。但遗憾的是,程序员忘了把主体中的两条指令放在大括号之间。程序的布局暗示了这一意图,但是脚本解释器并不关心这一点。只有
x=x*2;
指令是重复的,所以
n
的值永远不会大于或等于十。在
while
指令之后,指令
n=n+1;
将被执行,但是程序永远不会到达那里。程序员真正的意思是
var x = 1;
var n = 0;
while (n < 10) {
x = x * 2;
n = n + 1;
}
如果你的电脑或移动设备因为忘记在你的
while
指令周围加上括号而陷入昏迷后,你不得不扔掉它,那将是一个遗憾。幸运的是,操作系统可以强制停止一个程序的执行,即使它还没有完成。甚至现在的浏览器也可以检测无限期运行的脚本,在这种情况下,浏览器可以停止脚本的执行。一旦这样做了,你就可以开始寻找程序挂起的原因。虽然这种问题在程序中偶尔会出现,但作为游戏程序员,你有责任确保一旦游戏公开发布,这种编程错误已经从游戏代码中删除。这就是为什么正确的测试如此重要。
一般来说,如果你写的程序在启动时似乎不做任何事情,或者如果它无限期挂起,检查一下
while
指令中发生了什么。一个非常常见的错误是忘记递增计数器变量,因此
while
指令的条件永远不会变成
false
,并且
while
循环会无限期地继续下去。许多其他编程错误可能会导致无限循环。事实上,无限循环是如此普遍,以至于加州库比蒂诺的一条街道以它们命名——苹果总部就坐落在这条街上!
嵌套重复
while
指令或
for
指令的主体是一条指令。这个指令可以是一个赋值,一个方法调用,一个由大括号分隔的指令块,或者另一个
while
或
for
循环。比如:
var x, y;
for (y=0; y<7; y++)
for (x=0; x<y; x++)
Canvas2D.drawImage(sprites.lives,
new Vector2(x * sprites.lives.width, y * sprites.lives.height));
在这个片段中,变量
y
从 0 计数到 7。对于
y
的每一个值,执行主体,它由一个
for
指令组成。第二个
for
指令使用计数器
x
,该计数器具有作为上限的
y
的值。因此,在外部
for
指令的每个进程中,内部
for
指令会持续更长时间。重复的指令在使用
x
和
y
计数器的值计算的位置绘制一个黄色气球精灵。这个循环的结果是许多气球被放置成三角形(见图 10-4 )。
图 10-4 。三角形的气球
该形状的第一条线包含零个气球。原因是此时
y
的值仍然为 0,这意味着内部的
for
指令执行了 0 次。
重新开始游戏
当玩家失去所有生命时,游戏就结束了。你如何处理这个?在画师游戏的情况下,你想在屏幕上显示一个游戏。玩家可以按下鼠标左键,然后重新开始游戏。为了将它添加到游戏中,当游戏开始时,你加载一个额外的精灵,它在屏幕上显示游戏:
sprites.gameover = loadSprite("spr_gameover_click.png");
现在你可以在每个游戏循环方法中使用一个
if
指令来决定你应该做什么。如果游戏结束了,你不希望大炮和球再处理输入;你只是想听听玩家是否按下了鼠标键。如果发生这种情况,你重置游戏。因此,
PainterGameWorld
类中的
handleInput
方法现在包含以下指令:
if (this.lives > 0) {
this.ball.handleInput(delta);
this.cannon.handleInput(delta);
}
else {
if (Mouse.leftPressed)
this.reset();
}
您将一个
reset
方法添加到
PainterGameWorld
类中,这样您就可以将游戏重置为其初始状态。这意味着重置所有的游戏对象。您还需要将生命数重置为 5。下面是
PainterGameWorld
中完整的
reset
方法:
PainterGameWorld.prototype.reset = function () {
this.lives = 5;
this.cannon.reset();
this.ball.reset();
this.can1.reset();
this.can2.reset();
this.can3.reset();
};
对于
update
方法,如果游戏没有结束,你只需要更新游戏对象。因此,你首先用一个
if
指令检查你是否需要更新游戏对象。如果不是(换句话说:生命的数量已经达到零),则从方法返回:
if (this.lives <= 0)
return;
this.ball.update(delta);
this.cannon.update(delta);
this.can1.update(delta);
this.can2.update(delta);
this.can3.update(delta);
最后,在
draw
方法中,你绘制游戏对象,如果玩家没有生命了,游戏就结束。这导致了以下结构:
PainterGameWorld.prototype.draw = function () {
// draw the game world
...
for (var i = 0; i < this.lives; i++) {
Canvas2D.drawImage(sprites.lives,
new Vector2(i * sprites.lives.width + 15, 60));
}
if (this.lives <= 0) {
Canvas2D.drawImage(sprites.gameover,
new Vector2(Game.size.x - sprites.gameover.width,
Game.size.y - sprites.gameover.height).divideBy(2));
}
};
你可以看到你使用了屏幕的尺寸和游戏的尺寸来很好地将它放置在屏幕的中央。图 10-5 显示了在游戏世界顶部绘制的游戏 Over overlay 的截图。
图 10-5 。游戏结束!
在图 10-5 中,请注意覆盖层上的游戏并没有完全隐藏其他物体和背景。原因是游戏 Over sprite 有一些 透明 像素。通常,精灵有透明的部分,所以精灵似乎是游戏世界的一部分。气球、球、颜料罐和炮管都是部分透明的,这就是它们无缝融入游戏世界的原因。当设计精灵时,你需要确保图像的透明度值设置正确。虽然正确地做这件事可能需要大量的工作,但现代的图像编辑工具,如 Adobe Photoshop,给了你许多定义图像透明度的方法。只要确保你保存的图像格式支持透明,如 PNG。
注意 你可以使用覆盖图的透明度来控制玩家看到的内容。在某些情况下,您可能希望事情变得模糊不清(例如时间敏感游戏中的“暂停”屏幕)或能够被看到(例如 Painter 中的屏幕上的游戏)。
你学到了什么
在本章中,您学习了:
- 如何存储和显示玩家当前拥有的生命数
- 如何使用
while
或for
指令重复一组指令 - 当玩家没有剩余生命时,如何重新开始游戏