七、基本游戏对象

在这一章中,你开始更多地组织画师游戏的源代码。这是必要的,因为一个游戏的源代码包含许多行代码。在前面的例子中,您开始对不同对象上的变量进行分组(例如Canvas2Dcannon)。在本章中,您将继续通过使用更多的对象和将代码拆分到不同的文件来构建您的代码。

使用单独的 JavaScript 文件

您可能已经注意到的一件事是,您的 JavaScript 文件变得相当大。拥有一个包含所有代码的大文件并不理想,因为这样很难找到程序的某些部分。将代码拆分到多个源文件中更有意义。一个好的方法是将不同的 JavaScript 对象分割到不同的 JavaScript 文件中,这样每个 JavaScript 文件都包含其中一个对象的代码。Painter3 示例程序包含前面章节中介绍的对象,但是每个对象都在自己的 JavaScript 文件中描述。现在浏览代码和理解程序的结构变得容易多了。您甚至可以将这些文件放在单独的目录中,以指示哪些对象属于同一个目录。例如,您可以将KeyboardMouse 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 非常简单。您只需调用一系列的scriptwait方法。最后一个wait方法调用获取一个要执行的函数作为参数。在这个函数中,你开始游戏。通过改变script方法调用的顺序,您可以改变脚本加载的顺序。当您开发游戏或其他更大的 JavaScript 应用时,使用这样的脚本非常有用,因为它使得开发和维护代码更加容易。有关显示加载不同 JavaScript 文件的完整示例,请参见 Painter3。这里的另一个改进是我将画布元素的名称作为参数传递给了start方法。这样,JavaScript 游戏代码可以使用任何画布名称。

你可能不希望游戏的最终(发布)版本使用这样的方法,因为浏览器将不得不加载许多 JavaScript 文件。此时,最好使用另一个程序将所有 JavaScript 文件合并成一个大文件,这样加载速度会更快。此外,通常的做法是对代码结构进行一些优化,使脚本文件尽可能小。这个过程叫做缩小第 30 章更详细地讨论了这一点。

以错误的方式加载游戏素材

之前,我谈到了浏览器以任意顺序加载文件,因为这些文件必须从服务器中检索。同样的规则也适用于加载游戏资源,比如精灵和声音。这是你到目前为止用来加载游戏素材的方法:

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方法不提供可以存储在变量中的结果。当然,这个方法确实有某种效果,因为它在屏幕上绘制了一个精灵,这也可以被认为是方法调用的结果。然而,当我谈论一个方法的结果时,我并不是说这个方法对一个对象有某种影响。我的意思是方法调用返回一个可以存储在变量中的值。这也称为方法或函数的返回值 。在数学中,函数有结果是很常见的。数学函数 imagex 值作为参数,并返回其平方值作为结果。如果你愿意,你可以用 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;
};

9781430265382_Fig07-01.jpg

图 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 示例扩展了每个具有游戏循环方法(handleInputupdatedraw)的对象,以便将自上次更新以来经过的时间作为参数传递。

但是 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 位置的xy变量。

在你获得了想要的球的位置后,你从中减去球精灵的宽度和高度的一半。这样,球就被很好地画在了炮管的中间。

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 编程概念:原型

你学到了什么

在本章中,您学习了:

  • 如何在不同的源文件中分离代码
  • 如何让游戏循环更高效
  • 不同种类的方法/函数(有/没有参数,有/没有返回值)
  • 固定时间步长和可变时间步长的区别
  • 如何在游戏世界中添加一个飞行球