八、游戏对象类型

在前面的章节中,你已经看到了如何创建一个包含一些不同游戏对象的游戏世界,比如一门大炮和一个球。你已经看到了如何让游戏对象相互作用。例如,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.

假设你想在画师游戏中能够同时射出三个球。如果你像现在这样创建对象,你将创建两个变量,ball2ball3,并复制两次用于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类中,这些方法是游戏循环方法(handleInputupdatedrawreset)。

你可以很容易地将游戏循环方法转换成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级与BallCannon级的区别在于油漆罐的位置不同。这就是为什么在构造颜料罐时要将坐标值作为参数传递。该值表示油漆罐的所需 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方法来帮助您做到这一点。让我们首先来看看创建一个随机速度。为了简洁起见,在名为calculateRandomVelocityPaintCan类中用一个单独的方法来实现。当你想初始化罐子的速度时,你可以调用这个方法。这里你使用成员变量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中存储的值相加,以获得minVelocityminVelocity+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.randomMath.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类中,您调用不同游戏对象上的handleInputupdatedraw方法。例如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 示例的屏幕截图,现在有三个掉落的油漆罐。

9781430265382_Fig08-01.jpg

图 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关键字来返回变量的类型。这里你用它来检查xy是否有一个已定义的类型。如果是这种情况,可以将作为参数传递的值赋给成员变量。否则,将值指定为 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方法,将两个向量相加并返回一个新向量。为此,您可以重用copyaddTo方法:

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)

在本例中,positionanotherPosition在第三条指令中保持不变。创建一个新的 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对象。如果没有定义参数xy,则使用默认值。您可以利用这种情况,因为依赖默认值可以简化代码编写。举个例子,这是在屏幕上绘制背景图像的指令:

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);

虽然参数的默认值在创建紧凑的代码时非常有用,但是请确保您总是为您的方法提供文档,指定如果方法的调用方没有提供所有参数时将使用哪些默认值。

你学到了什么

在本章中,您学习了:

  • 如何使用原型机制定义类
  • 如何创建一个类型/类的多个实例
  • 如何增加游戏的随机性以增加可玩性