十、有限的生命

在这一章中,你通过给玩家有限数量的生命来使画家游戏更有趣。如果玩家错过太多颜料罐,他们就会死。这一章讨论了如何处理这个问题,以及如何向玩家显示当前的生命值。为了实现后者,您需要学习一些编程结构,以便多次重复一组指令。

维持生命的数量

为了在游戏中引入一些危险和努力工作的激励,您想要限制玩家可以允许从屏幕底部掉落的错误颜色的颜料罐的数量。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();
}

您现在可以扩展PaintCanupdate方法,这样它就可以处理颜料罐落在屏幕底部之外的情况。如果发生这种情况,您需要将颜料罐移回屏幕顶部。如果油漆桶的当前颜色与目标颜色不匹配,则生命数减少一:

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指令的语法图。

9781430265382_Fig10-01.jpg

图 10-1while指令的语法图

当执行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 显示了画家游戏的截图,其中生命的数量显示在屏幕的左上角。

9781430265382_Fig10-02.jpg

图 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指令的语法图。

9781430265382_Fig10-03.jpg

图 10-3for指令的语法图

几个特例

在处理whilefor循环时,您需要了解一些特殊情况。以下小节讨论了这些情况。

完全没有重复

有时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指令的主体是一条指令。这个指令可以是一个赋值,一个方法调用,一个由大括号分隔的指令块,或者另一个whilefor循环。比如:

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指令会持续更长时间。重复的指令在使用xy计数器的值计算的位置绘制一个黄色气球精灵。这个循环的结果是许多气球被放置成三角形(见图 10-4 )。

9781430265382_Fig10-04.jpg

图 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 的截图。

9781430265382_Fig10-05.jpg

图 10-5 。游戏结束!

图 10-5 中,请注意覆盖层上的游戏并没有完全隐藏其他物体和背景。原因是游戏 Over sprite 有一些透明像素。通常,精灵有透明的部分,所以精灵似乎是游戏世界的一部分。气球、球、颜料罐和炮管都是部分透明的,这就是它们无缝融入游戏世界的原因。当设计精灵时,你需要确保图像的透明度值设置正确。虽然正确地做这件事可能需要大量的工作,但现代的图像编辑工具,如 Adobe Photoshop,给了你许多定义图像透明度的方法。只要确保你保存的图像格式支持透明,如 PNG。

注意你可以使用覆盖图的透明度来控制玩家看到的内容。在某些情况下,您可能希望事情变得模糊不清(例如时间敏感游戏中的“暂停”屏幕)或能够被看到(例如 Painter 中的屏幕上的游戏)。

你学到了什么

在本章中,您学习了:

  • 如何存储和显示玩家当前拥有的生命数
  • 如何使用whilefor指令重复一组指令
  • 当玩家没有剩余生命时,如何重新开始游戏