JavaScript 游戏构建指南(三)
十一、组织游戏对象
在前面的章节中,你已经看到了如何使用类来对属于同一类的变量进行分组。本章着眼于不同类型的游戏对象之间的相似性,以及如何用 JavaScript 表达这些相似性。
游戏对象之间的相似性
如果你看看画师游戏中不同的游戏对象,你会发现它们有很多共同点。例如,球、大炮和颜料罐都使用三个精灵,分别代表三种不同的颜色。此外,游戏中的大多数物体都有位置和速度。此外,所有游戏对象都需要一个方法来绘制它们,一些游戏对象有一个处理输入的方法,一些游戏对象有一个update方法、 等等。现在,这些类有相似之处并不是一个问题。浏览器或游戏玩家不会对此抱怨。但是,很遗憾,你必须一直复制代码。举个例子,Ball和PaintCan类都有width和height属性:
Object.defineProperty(Ball.prototype, "width",
{
get: function () {
return this.currentColor.width;
}
});
Object.defineProperty(Ball.prototype, "height",
{
get: function () {
return this.currentColor.height;
}
});
代码是完全一样的,但是你必须为两个类复制它。并且每次你想添加一个不同种类的游戏对象,你可能需要再次复制这些属性。在这种情况下,幸运的是,这些属性并不复杂,但是在应用中,您还复制了许多其他内容。例如,Painter 游戏中的大多数游戏对象类都定义了以下成员变量:
this.currentColor = *some sprite*
;
this.velocity = Vector2.zero;
this.position = Vector2.zero;
this.origin = Vector2.zero;
this.rotation = 0;
各种游戏对象的draw方法看起来也很相似。例如,下面是Ball和PaintCan类的draw方法:
Ball.prototype.draw = function () {
if (!this.shooting)
return;
Canvas2D.drawImage(this.currentColor, this.position, this.rotation, 1,
this.origin);
};
PaintCan.prototype.draw = function () {
Canvas2D.drawImage(this.currentColor, this.position, this.rotation, 1,
this.origin);
};
同样,代码在不同的类中是非常相似的,你每次创建一个新的游戏对象时都要复制它。一般来说,最好避免复制大量代码。为什么会这样?因为如果在某个时候你意识到那部分代码中有错误,你必须在你复制它的地方改正它。在像 Painter 这样的小游戏中,这不是什么大问题。但是当你开发一个拥有数百个不同游戏对象类的商业游戏时,这就变成了一个严重的维护问题。此外,你并不总是知道一个小游戏会走多远。如果您不小心,您可能会复制大量代码(以及与之相关的错误)。随着游戏的成熟,留意在哪里优化代码是一个好主意,即使这意味着一些额外的工作来找到这些重复并巩固它们。对于这种特殊的情况,你需要考虑不同种类的游戏对象是如何相似的,以及你是否可以将这些相似性组合在一起,就像你在前面的章节中对成员变量进行分组一样。
从概念上讲,很容易说出球、颜料罐和大炮之间的相似之处:它们都是游戏对象。基本上都可以画在某个位置;它们都有一个速度(即使是大炮,但它的速度为零);它们都有红色、绿色或蓝色。此外,它们中的大多数处理某种类型的输入并被更新。
遗产
使用 JavaScript 中的原型,可以将这些相似之处组合在一个泛型类中,然后定义其他类,这些类是这个泛型类的特殊版本。在面向对象的行话中,这被称为继承 ,这是一个非常强大的语言特性。在 JavaScript 中,继承是通过原型机制 实现的。考虑以下示例:
function Vehicle() {
this.numberOfWheels = 4;
this.brand = "";
}
Vehicle.prototype.what = function() {
return "nrOfWheels = " + this.numberOfWheels + ", brand = " + this.brand;
};
这里有一个非常简单的表示车辆的类的例子(你可以想象这对交通模拟游戏很有用)。简单来说,一辆车由多个车轮和一个品牌来定义。Vehicle类 也有一个名为what的方法,返回车辆的描述。如果您想创建一个在表格中显示车辆列表的网站,这可能会很有用。您可以按如下方式使用该类:
var v = new Vehicle();
v.brand = "volkswagen";
console.log(v.what()); // outputs "nrOfWheels = 4, brand = volkswagen"
有不同类型的交通工具,如汽车、自行车、摩托车等等。对于其中一些类型,您可能希望存储附加信息。例如,对于一辆汽车,存储它是否是敞篷车可能是有用的;对于摩托车,它有多少个气缸;等等。您可以使用 JavaScript 中基于原型的继承机制来实现这一点。下面是一个名为Car的类的例子:
function Car(brand) {
Vehicle.call(this);
this.brand = brand;
this.convertible = false;
}
Car.prototype = Object.create(Vehicle.prototype);
在这个类声明中有一些新的东西。在底部,你给Car的prototype对象赋值。你可以通过使用Object.create方法来做到这一点。在这种情况下,您复制了Vehicle的prototype对象,并将该副本存储在Car的prototype对象中。换句话说,Car现在拥有与Vehicle相同的功能,包括what方法:
var c = new Car("mercedes");
console.log(c.what()); // outputs "nrOfWheels = 4, brand = mercedes"
在Car的构造函数中有下面一行:
Vehicle.call(this);
这里发生的是使用调用Car构造函数时创建的同一个对象调用Vehicle构造函数*。本质上,你是在告诉解释器,你当前操作的Car对象(this)实际上也是一个 Vehicle 对象。所以你可以看到继承的两个重要方面:*
- 对象之间有关系(一个
Car对象也是一个Vehicle)。 - 从另一个类继承的类复制其功能(
Car对象与Vehicle对象具有相同的成员变量、属性和方法)。
因为Car继承自Vehicle,所以你也说Car是Vehicle的子类或者派生类,或者说Vehicle是Car的超类,或者父类,或者基类。类之间的继承关系应用广泛;而在一个好的类设计中,可以解释为“是一种。”在这个例子中,关系很清楚:汽车是一种交通工具。反过来也不总是对的。交通工具并不总是汽车。Vehicle可能还有其他子类,例如:
function Motorbike(brand) {
Vehicle.call(this);
this.numberOfWheels = 2;
this.brand = brand;
this.cylinders = 4;
}
Motorbike.prototype = Object.create(Vehicle.prototype);
摩托车也是一种交通工具。Motorbike类从Vehicle继承而来,并添加了自己的自定义成员变量来指示气缸数。图 11-1 说明了类的层次结构。对于这个层次结构的更扩展版本,参见图 11-4 。
图 11-1 。Vehicle及其子类的继承图
游戏对象和继承
“是一种”关系也适用于画家游戏中的游戏对象。球是一种游戏对象,颜料罐和大炮也是。你可以通过定义一个名为ThreeColorGameObject的类名,让你的游戏对象类从这个类名中继承,从而在程序中明确这种继承关系。然后你可以把所有定义三色游戏对象的东西放在那个类中,球、大炮和油漆罐将是那个类的特殊版本。
让我们更详细地看看这个ThreeColorGameObject 级。您将游戏中不同类型的游戏对象通常使用的成员变量放入这个类中。您可以如下定义该类的基本框架:
function ThreeColorGameObject() {
this.currentColor = undefined;
this.velocity = Vector2.zero;
this.position = Vector2.zero;
this.origin = Vector2.zero;
this.rotation = 0;
this.visible = true;
}
从ThreeColorGameObject继承的每个类都有一个速度,一个位置,一个原点,一个旋转,等等。这很好,因为现在你只在一个地方定义这些成员变量,它们可以在任何继承自ThreeColorGameObject的类中使用。
这个构造函数中仍然缺少的一点是处理三种不同颜色的方法。在 Painter 的例子中,每个游戏对象类型都有三个不同的精灵,每个精灵代表一种不同的颜色。当你定义ThreeColorGameObject类时,你还不知道使用哪个精灵,因为它们将取决于游戏对象的最终类型(大炮使用精灵而不是球或油漆桶)。为了解决这个问题,让我们如下扩展构造函数:
function ThreeColorGameObject(sprColorRed, sprColorGreen, sprColorBlue) {
this.colorRed = sprColorRed;
this.colorGreen = sprColorGreen;
this.colorBlue = sprColorBlue;
this.currentColor = this.colorRed;
this.velocity = Vector2.zero;
this.position = Vector2.zero;
this.origin = Vector2.zero;
this.rotation = 0;
this.visible = true;
}
无论何时继承这个类,都可以定义成员变量colorRed、colorGreen和colorBlue的值。
现在您需要定义基本的游戏循环方法。绘制游戏对象的方法很简单。您可能已经注意到这个类中添加了一个成员变量visible。您可以使用这个成员变量来切换游戏对象的可见性。在draw方法中,只有当游戏对象应该可见时,才在屏幕上绘制精灵:
ThreeColorGameObject.prototype.draw = function () {
if (!this.visible)
return;
Canvas2D.drawImage(this.currentColor, this.position, this.rotation, 1,
this.origin);
};
该类的update方法包含一条更新游戏对象当前位置的指令:
ThreeColorGameObject.prototype.update = function (delta) {
this.position.addTo(this.velocity.multiply(delta));
};
最后,添加一些方便的属性来获取和设置颜色,并检索对象的尺寸。例如,这是用于读取和写入对象颜色的属性:
Object.defineProperty(ThreeColorGameObject.prototype, "color",
{
get: function () {
if (this.currentColor === this.colorRed)
return Color.red;
else if (this.currentColor === this.colorGreen)
return Color.green;
else
return Color.blue;
},
set: function (value) {
if (value === Color.red)
this.currentColor = this.colorRed;
else if (value === Color.green)
this.currentColor = this.colorGreen;
else if (value === Color.blue)
this.currentColor = this.colorBlue;
}
});
如你所见,这里使用了彩色的 sprite 成员变量。任何从ThreeColorGameObject继承的类现在也有这个属性。这为您节省了大量的代码复制!关于完整的ThreeColorGameObject类,参见属于本章的 Painter9 示例。
Cannon 作为 ThreeColorGameObject 的子类
现在你已经为彩色游戏对象创建了一个非常基本的类,你可以通过从这个类继承来为你游戏中的实际游戏对象重用这个基本行为。我们先来看一下Cannon类。因为您已经定义了基本的ThreeColorGameObject类,所以您可以创建Cannon类作为该类的子类,如下所示:
function Cannon() {
// to do...
}
Cannon.prototype = Object.create(ThreeColorGameObject.prototype);
通过复制ThreeColorGameObject.prototype对象来创建Cannon.prototype对象。但是,您仍然需要在构造函数方法中编写代码。
因为Cannon继承自ThreeColorGameObject,所以需要调用ThreeColorGameObject类的构造函数。此构造函数需要三个参数。因为您正在创建一个Cannon对象,所以您想要将彩色的加农炮精灵传递给该构造函数。幸运的是,你可以通过call方法传递这些精灵,如下所示:
ThreeColorGameObject.call(this, sprites.cannon_red, sprites.cannon_green,
sprites.cannon_blue);
第二,你设置大炮的位置和原点,就像你在最初的Cannon类中所做的那样:
this.position = new Vector2(72, 405);
this.origin = new Vector2(34, 34);
剩下的工作(分配三个颜色精灵和初始化其他成员变量)已经在ThreeColorGameObject构造函数中完成了!注意,在子类中设置成员变量之前,首先调用超类的构造函数是很重要的。否则,当调用ThreeColorGameObject构造函数时,您为加农炮选择的位置和原点值将被重置为零。
现在已经定义了新版本的Cannon类,您可以开始向该类添加属性和方法,就像您之前所做的一样。例如,下面是handleInput方法:
Cannon.prototype.handleInput = function (delta) {
if (Keyboard.down(Keys.R))
this.currentColor = this.colorRed;
else if (Keyboard.down(Keys.G))
this.currentColor = this.colorGreen;
else if (Keyboard.down(Keys.B))
this.currentColor = this.colorBlue;
var opposite = Mouse.position.y - this.position.y;
var adjacent = Mouse.position.x - this.position.x;
this.rotation = Math.atan2(opposite, adjacent);
};
如您所见,您可以毫无问题地访问成员变量,如currentColor和rotation。因为Cannon继承自ThreeColorGameObject,所以它包含相同的成员变量、属性和方法。
重写超类的方法
除了添加新的方法和属性,你还可以选择用替换Cannon类中的方法。例如,ThreeColorGameObject有如下的draw方法:
ThreeColorGameObject.prototype.draw = function () {
if (!this.visible)
return;
Canvas2D.drawImage(this.currentColor, this.position,
this.rotation, 1, this.origin);
};
对于加农炮来说,这种方法并不完全如你所愿。你想画大炮的颜色,但你也想画炮管。替换一个方法非常容易。您只需将该方法重新定义为Cannon原型的一部分:
Cannon.prototype.draw = function () {
if (!this.visible)
return;
var colorPosition = this.position.subtract(this.size.divideBy(2));
Canvas2D.drawImage(sprites.cannon_barrel, this.position, this.rotation, 1,
this.origin);
Canvas2D.drawImage(this.currentColor, colorPosition);
};
用面向对象的行话来说,当你替换子类中从超类继承的方法时,你说你覆盖了该方法。在这种情况下,您覆盖了来自ThreeColorGameObject的draw方法。类似地,如果您愿意,您可以覆盖一个属性,或者甚至通过让它们引用undefined来删除属性和方法。一旦创建了一个Cannon对象,您就拥有了 JavaScript 提供的修改该对象的全部灵活性。
注意即使你在这个例子中覆盖了一个方法,JavaScript 也不像 Java 或 C#等其他语言那样使用override关键字。
如果你看一看属于本章的 Painter9 示例中的Cannon.js文件,你可以看到Cannon类的定义比以前的版本小得多,也更容易阅读,因为所有通用的游戏对象成员都放在了ThreeColorGameObject类中。将代码组织在不同的类和子类中有助于减少代码复制,并使设计更加简洁。但是,有一个警告:你的类结构(哪个类从哪个类继承)必须正确。请记住,只有当类之间存在“是一种”关系时,类才应该从其他类继承。为了说明这一点,假设您想在屏幕顶部添加一个指示器,显示球当前的颜色。您可以为此创建一个类,并让它从Cannon类继承,因为它需要以类似的方式处理输入:
function ColorIndicator() {
Cannon.call(this, ...);
// etc.
}
然而,这是一个非常糟糕的想法。颜色指示器当然不是一种大炮,这样设计您的类会让其他开发人员非常不清楚这些类的用途。此外,颜色指示器还会旋转,这没有任何意义。类继承图应该有逻辑性并且容易理解。每当你写一个继承自另一个类的类时,问问你自己这个类是否真的是你继承的类的一种。如果不是,那么你必须重新考虑你的设计。
球课
您以与Cannon类非常相似的方式定义新的Ball类。就像在Cannon类中一样,你继承了ThreeColorGameObject类。唯一不同的是,你必须添加一个额外的成员变量来指示球当前是否正在射门:
function Ball() {
ThreeColorGameObject.call(this, sprites.ball_red, sprites.ball_green,
sprites.ball_blue);
this.shooting = false;
this.reset();
}
Ball.prototype = Object.create(ThreeColorGameObject.prototype);
当一个Ball实例被创建时,你需要调用ThreeColorGameObject构造函数,就像你对Cannon类所做的那样。在这种情况下,您将球精灵作为参数传递。另外,你需要给shooting变量一个初始值false,你通过调用reset方法来重置球。
Ball类清楚地说明了当你从另一个类继承时会发生什么。每个Ball实例由从ThreeColorGameObject继承的部分和在Ball类中定义的部分组成。图 11-2 显示了没有使用继承的Ball对象的内存的样子。图 11-3 也显示了一个Ball实例,但是使用了本章介绍的继承机制。
图 11-2 。Ball类(无继承)的实例使用的内存概述
图 11-3 。Ball类的一个实例(从ThreeColorGameObject继承而来)
你可能会对这两个图形和它们呈现的结构感到有点困惑。稍后,本章将更详细地讨论内存结构。现在,假设由多个成员变量组成的复杂对象(如Cannon或Ball实例)的存储方式不同于简单的数字或布尔。这意味着什么,以及你应该如何在你的代码中正确地处理它,在这一章的结尾有所涉及。
ThreeColorGameObject类中的update方法只包含一行代码,它根据游戏对象的速度、经过的时间和当前位置来计算游戏对象的新位置:
this.position.addTo(this.velocity.multiply(delta));
球应该做得更多。球的速度应该更新,以纳入阻力和重力;球的颜色需要的话要更新;而如果球飞出了屏幕,就要复位到原来的位置。您可以简单地从先前版本的Ball类中复制update方法,这样它就可以替换ThreeColorGameObject的update方法。一个稍微好一点的方法是在Ball类中定义update方法,但是重用ThreeColorGameObject中最初的update方法。这可以通过使用call方法来完成,方式非常类似于您使用它来调用超类的构造函数。下面是Ball.update方法的新版本:
Ball.prototype.update = function (delta) {
ThreeColorGameObject.prototype.update.call(this, delta);
if (this.shooting) {
this.velocity.x *= 0.99;
this.velocity.y += 6;
}
else {
this.color = Game.gameWorld.cannon.color;
this.position = Game.gameWorld.cannon.ballPosition
.subtractFrom(this.center);
}
if (Game.gameWorld.isOutsideWorld(this.position))
this.reset();
};
看这个方法的第一条指令。您正在访问ThreeColorGameObject的prototype对象,它包含一个update函数。你在传递this对象的同时调用这个update函数,所以Ball对象被更新,但是根据ThreeColorGameObject中定义的update方法。最后,您将delta参数传递给该调用。好的一面是,这种方法允许您将更新过程的不同部分(在本例中)分开。任何具有位置和速度的游戏对象都需要在游戏循环的每次迭代中根据其速度更新其位置。您在ThreeColorGameObject的update方法中定义了这个行为,这样您就可以为从ThreeColorGameObject继承的任何类重用它!
多态性
因为有了继承机制,你不必总是知道一个变量指向什么类型的对象。考虑下面的声明和初始化:
var someKindOfGameObject = new Cannon();
在代码的其他地方,你这样做:
someKindOfGameObject.update(delta);
现在假设您更改了声明和初始化,如下所示:
var someKindOfGameObject = new Ball();
需要把调用改成update方法吗?不,你不需要,因为游戏循环方法被调用的方式是在ThreeColorGameObject类中定义的。当你在someKindOfGameObject变量上调用update方法时,它实际引用的是哪个游戏对象并不重要。唯一重要的是定义了update方法,并且它只需要一个参数:自最后一次update调用以来经过的时间。因为解释器会跟踪它是哪个对象,所以会自动调用正确版本的update方法。
这种效应被称为多态性,有时会非常方便。多态性允许您更好地分离代码。假设一家游戏公司想要发布其游戏的扩展。例如,它可能想引入一些新的敌人,或者玩家可以学习的技能。公司可以将这些扩展作为泛型Enemy和Skill类的子类来提供。实际的游戏代码将会使用这些对象,而不需要知道它在处理哪种特殊技能或敌人。它只是调用泛型类中定义的方法。
类的层次结构
在这一章中,你已经看到了几个从基本游戏对象类继承的类的例子。只有当这两个类之间的关系可以描述为“是一种”时,一个类才应该从另一个类继承比如:a Ball是ThreeColorGameObject的一种。事实上,等级制度并没有到此为止。你可以写另一个继承自Ball类的类,比如BouncingBall,它可以是一个标准球的特殊版本,可以从油漆罐上反弹,而不仅仅是与它们碰撞。你还可以创建另一个继承自BouncingBall的类BouncingElasticBall,它是一个球,当它在油漆桶上反弹时会根据它的弹性变形。每次从一个类继承时,都可以免费从基类中获得数据(编码在成员变量中)和行为(编码在方法和属性中)。
商业游戏有一个不同游戏对象的等级体系,有许多不同的级别。回到本章开始的交通模拟例子,你可以想象一个非常复杂的各种不同车辆的层次结构。图 11-4 显示了这样一个层次结构的例子。该图使用箭头来指示类之间的继承关系。
图 11-4 。交通模拟游戏中复杂的游戏对象层次
在继承树的最底层是一个GameObject类。这个类只包含非常基本的信息,比如游戏对象的位置或速度。对于每个子类,可以添加与特定类及其子类相关的新成员(变量、方法或属性)。例如,变量numberOfWheels通常属于Vehicle类,而不属于MovingGameObject(因为船没有轮子)。变量flightAltitude属于Airplane类,变量bellIsWorking属于Bicycle类。
当你决定你的类的结构时,你必须做出许多决定。没有单一的最佳等级;而且,根据应用的不同,一种层次结构可能比另一种更有用。例如,这个例子首先根据物体用来移动自身的媒介来划分MovingGameObject类:土地、空气或水。之后,这些类又分为不同的子类:机动化或非机动化。你可以反过来做这件事。对于某些类,它们在层次结构中的位置并不完全清楚:你说摩托车是一种特殊类型的自行车(有马达的那种)吗?还是一种特殊的机动车辆(只有两个轮子的那种)?
重要的是,类本身之间的关系是清晰的。帆船是船,但船并不总是帆船。自行车是一种交通工具,但不是每一种交通工具都是自行车。
值与参考值
在你读完这一章之前,让我们看看对象和变量是如何在内存中被处理的。当处理基本类型如数字或布尔时,变量与内存中的位置直接相关。比如看下面的声明和初始化:
var i = 12;
该指令执行后,存储器看起来如图图 11-5 所示。
图 11-5 。数字变量的内存使用
现在您可以创建一个新变量j并将变量i的值存储在该变量中:
var j = i;
图 11-6 显示了执行该指令后内存的样子。
图 11-6 。声明和初始化两个数字变量后的内存使用情况
如果你给j变量赋另一个值,例如通过执行指令j = 24,产生的内存使用如图图 11-7 所示。
图 11-7 。更改j变量值后的内存使用
现在让我们看看当您使用更复杂类型的变量时会发生什么,比如Cannon类。考虑以下代码:
var cannon1 = new Cannon();
var cannon2 = cannon1;
看一下前面使用数字类型的例子,您会期望现在内存中有两个Cannon对象:一个存储在变量cannon1中,另一个存储在cannon2中。然而,事实并非如此!其实cannon1和cannon2?? 都是指同一个物体。第一条指令后(创建Cannon对象),内存如图图 11-8 所示。
图 11-8 。内存中的一个Cannon对象
在这里,您可以看到基本类型(如数字和布尔值)与更复杂的类型(如Cannon类)在内存中的表示方式有很大的不同。在 JavaScript 中,所有非原始类型的对象,比如数字、布尔值和字符,都存储为引用而不是值。这意味着像cannon1这样的变量并不直接包含Cannon对象,但是它包含了对它的引用。图 11-8 通过将cannon1表示为一个包含指向一个对象的箭头的块来表示它是一个引用。如果你现在声明了cannon2变量并将cannon1的值赋给它,你可以在图 11-9 中看到新的情况。
图 11-9 。指向同一个对象的两个变量
结果是,如果你改变加农炮的颜色如下
cannon2.color = Color.red;
那么表达式cannon1.color将是Color.red,因为cannon1和cannon2指的是同一个对象!这对对象在方法中的传递方式也有影响。例如,ThreeColorGameObject的构造函数方法期望三个精灵作为参数。因为精灵不是 JavaScript 中的基本类型,所以您实际上是在传递对这些精灵的引用。理论上,这意味着您可以在ThreeColorGameObject构造函数中修改精灵。将基本类型(比如数字)作为参数传递给方法是通过值发生的*,所以改变方法中的值没有影响。考虑下面的函数*
function square(f) {
f = f * f;
}
现在是以下指令:
var someNumber = 10;
square(someNumber);
执行完这些指令后,someNumber的值仍然是 10(而不是 100)。这是为什么?因为当调用square函数时,number 参数通过值传递给*。变量f是方法中的一个局部变量,最初包含变量someNumber的值。在该方法中,局部变量f被更改为包含f * f,但这不会更改someNumber变量,因为它是内存中的另一个位置。因为非原始对象是通过引用传递的,所以下面的示例将导致对象的更改值作为参数传递:*
function square(obj) {
obj.f = obj.f * obj.f;
}
var myObject = { f : 10 };
square(myObject);
// myObject.f now contains the value 100.
每当 JavaScript 脚本运行时,内存中都有大量的引用和值。例如,如果您查看图 11-2 和 11-3 ,您会看到Ball对象既包含值,也包含对其他对象的引用(例如Vector2对象或Image对象)。
空的和未定义的
每当您在 JavaScript 中声明一个变量时,最初它的值被设置为undefined :
var someVariable;
console.log(someVariable); // will print 'undefined'.
在 JavaScript 中,你也可以指出一个变量被定义了,但是当前没有引用任何对象。这是通过使用null关键字完成的:
var anotherCannon = null;
因为你还没有创建一个对象(使用new关键字),内存看起来像图 11-10 中描述的那样。
图 11-10 。一个指向null的变量
因此,指示一个变量还没有指向任何东西是通过给它赋值null来完成的。甚至可以在 JavaScript 程序中检查变量是否指向一个对象,就像这样:
if (anotherCannon === null)
anotherCannon = new Cannon();
在这个例子中,你检查变量是否等于null(没有指向一个对象)。如果是这样,你使用new关键字创建一个Cannon实例,之后内存中的情况再次改变(见图 11-11 )。
图 11-11 。记忆中的最后情境
由您决定何时使用null和undefined。不是所有的程序员都用同样的方式做这件事。我们建议你用undefined来表示一个变量不存在,用null来表示这个变量存在但还没有引用任何对象。
你学到了什么
在本章中,您学习了:
- 如何使用继承来构建层次结构中的相关类
- 如何重写子类中的方法来为该类提供特定的行为
- 如何从超类中调用方法,比如构造函数方法
null和undefined的含义
十二、完成画家游戏
在本章中,您将通过添加一些额外的功能(如动作效果、声音和音乐)以及维护和显示分数来完成画师游戏。最后,您将更详细地了解字符和字符串。
添加运动效果
为了使游戏更具视觉吸引力,您可以在颜料罐的移动中引入漂亮的旋转效果,以模拟风和摩擦对下落运动的影响。属于本章的 Painter10 程序是游戏的最终版本,在易拉罐中添加了这种运动效果。添加这样的效果并不复杂。由于您在上一章所做的工作,只需要在PaintCan类的update方法中添加一行代码。因为PaintCan是ThreeColorGameObject的子类,它已经有了一个rotation成员变量,在屏幕上绘制 sprite 时会自动考虑到这个变量!
为了达到运动效果,你使用了Math.sin的方法。通过让该值依赖于罐的当前位置,可以根据该位置得到不同的值。然后使用这个值在精灵上应用一个旋转。这是您添加到PaintCan.update方法中的代码行:
this.rotation = Math.sin(this.position.y / 50) * 0.05;
该指令使用颜料罐位置的 y 坐标来获得不同的旋转值。此外,你把它除以 50,得到一个很好的慢速运动;将结果乘以 0.05,以降低正弦的幅度,使旋转看起来更真实。如果您愿意,可以尝试不同的值,看看它们如何影响颜料罐的行为。
创建精灵
即使你不是艺术家,自己制作简单的精灵也会有所帮助。它能让你快速制作出游戏的原型——也许会发现你内心也有一个艺术家。要创建精灵,你首先需要好的工具。大多数艺术家使用像 Adobe Photoshop 这样的绘画程序或像 Adobe Illustrator 这样的矢量绘图程序,但其他人使用像 Microsoft Paint 或更广泛和免费的 GIMP 这样的简单工具。每个工具都需要练习。浏览一些教程,并确保对许多不同的特性有所了解。通常,你想要的东西可以用一种简单的方式实现。
最好是,为你的游戏对象创建非常大的图像,然后将它们缩小到所需的尺寸。这样做的好处是,你可以在以后的游戏中更改所需的尺寸,并且可以消除由于图像由像素表示而产生的锯齿效应。缩放图像时,抗锯齿技术会混合颜色,使图像保持平滑。如果您保持图像中游戏对象的外部透明,那么,当您缩放时,边界像素将自动变为部分透明。只有当你想创建经典的像素样式时,你才应该按照实际需要的大小来创建精灵。
最后,在网上四处看看。有很多精灵可以免费使用。确保检查许可条款,这样你使用的精灵包对于你正在构建的东西是合法的。然后你可以把它们作为你自己精灵的基础。但是最后,要意识到当你和一个有经验的艺术家一起工作时,你的游戏质量会显著提高。
添加声音和音乐
另一种让游戏更有趣的方法是添加一些声音。这个游戏同时使用了背景音乐和音效。为了使 JavaScript 中的声音处理变得更简单,您添加了一个Sound类,允许您回放和循环声音。下面是该类的构造函数:
function Sound(sound, looping) {
this.looping = typeof looping !== 'undefined' ? looping : false;
this.snd = new Audio();
if (this.snd.canPlayType("audio/ogg")) {
this.snd.src = sound + ".ogg";
} else if (this.snd.canPlayType("audio/mpeg")) {
this.snd.src = sound + ".mp3";
} else // we cannot play audio in this browser
this.snd = null;
}
因为不是所有的浏览器都能够播放所有不同类型的音乐,所以您添加了一个if指令,根据浏览器可以播放的类型来加载不同的声音类型。类似于创建Image对象(用于表示精灵),您创建一个Audio对象,并将其源初始化为需要加载的声音文件。除了声音文件之外,您还添加了一个looping变量来指示声音是否应该循环。一般来说,背景音乐要循环播放;声音效果(如发射彩球)不应该。
除了构造函数之外,还要添加一个名为play的方法。在这个方法中,加载声音,并将名为autoplay的属性设置为 true。这样做的结果是,声音将在加载后立即开始播放。如果声音不需要循环,就完成了,可以从方法返回。如果您确实需要循环播放声音,您需要在声音播放完毕后重新加载并再次播放声音。Audio类型允许你给所谓的事件附加功能。当事件发生时,执行您附加的函数。例如音频已经开始播放的事件,或者音频已经结束播放的事件。
这本书很少使用事件和事件处理。但是,许多 JavaScript 概念依赖于它们。例如,键盘按键和鼠标动作都会产生你应该在游戏中处理的事件。在这种情况下,您希望在音频播放完毕后执行一项功能。下面是完整的play方法:
Sound.prototype.play = function () {
if (this.snd === null)
return;
this.snd.load();
this.snd.autoplay = true;
if (!this.looping)
return;
this.snd.addEventListener('ended', function () {
this.load();
this.autoplay = true;
}, false);
};
最后,添加一个属性来更改正在播放的声音的音量。这特别有用,因为通常你希望音效比背景音乐更响亮。在一些游戏中,这些音量可以被玩家改变(在本书的后面,你会看到如何去做)。每当你在游戏中引入声音时,确保总是提供音量或者至少静音控制。没有静音功能的游戏将会遭到用户通过评论的愤怒!下面是volume属性,很简单:
Object.defineProperty(Sound.prototype, "volume",
{
get: function () {
return this.snd.volume;
},
set: function (value) {
this.snd.volume = value;
}
});
在Painter.js(加载所有资源的文件)中,你加载声音并将它们存储在一个变量中,就像你对精灵所做的那样:
var sounds = {};
下面是如何使用刚刚创建的Sound类加载相关的声音:
var loadSound = function (sound, looping) {
return new Sound("../../assets/Painter/sounds/" + sound, looping);
};
sounds.music = loadSound("snd_music");
sounds.collect_points = loadSound("snd_collect_points");
sounds.shoot_paint = loadSound("snd_shoot_paint");
现在在游戏过程中播放声音非常容易。例如,当游戏初始化时,您开始以低音量播放背景音乐,如下所示:
sounds.music.volume = 0.3;
sounds.music.play();
你也想玩音效。比如球员投篮,他们就想听到!所以,当他们开始投篮时,你播放这个音效。这在Ball类的handleInput方法中处理:
Ball.prototype.handleInput = function (delta) {
if (Mouse.leftPressed && !this.shooting) {
this.shooting = true;
this.velocity = Mouse.position.subtract(this.position)
.multiplyWith(1.2);
sounds.shoot_paint.play();
}
};
同样,当正确颜色的颜料罐从屏幕上掉落时,您也可以播放声音。
保持分数
分数往往是激励玩家继续玩下去的非常有效的方法。高分在这方面特别有效,因为它们给游戏引入了竞争因素:你想比 AAA 或 XYZ 更好(许多早期街机游戏只允许高分列表中的每个名字有三个字符,导致名字非常有想象力)。高分是如此激励人心,以至于第三方系统的存在将它们纳入游戏。这些系统让用户与世界上成千上万的其他玩家进行比较。在画师游戏中,保持简单,在存储当前分数的PainterGameWorld类中添加一个成员变量score:
function PainterGameWorld() {
this.cannon = new Cannon();
this.ball = new Ball();
this.can1 = new PaintCan(450, Color.red);
this.can2 = new PaintCan(575, Color.green);
this.can3 = new PaintCan(700, Color.blue);
this.score = 0;
this.lives = 5;
}
玩家从零分开始。每次油漆罐落在屏幕外,分数就会更新。如果有一罐颜色正确的罐子从屏幕上掉了下来,就加 10 分。如果罐子不是正确的颜色,玩家失去一条生命。
分数是一场比赛所谓的经济的一部分。游戏的经济基本上描述了游戏中不同的成本和优点,以及它们如何相互作用。当你制作自己的游戏时,考虑它的经济性总是有用的。东西有什么成本,作为玩家执行不同的动作有什么收获?这两件事是相互平衡的吗?
您在PaintCan类中更新分数,在这里您可以检查罐子是否落在屏幕之外。如果是这样,你检查它是否有正确的颜色,并相应地更新分数和玩家生存的数量。然后您将PaintCan对象移动到顶部,以便它可以再次落下:
if (Game.gameWorld.isOutsideWorld(this.position)) {
if (this.color === this.targetColor) {
Game.gameWorld.score += 10;
sounds.collect_points.play();
}
else
Game.gameWorld.lives -= 1;
this.moveToTop();
}
最后,每当一个颜色正确的罐子从屏幕上掉下来,你就播放一个声音。
更完整的 Canvas2D 类
除了在屏幕上画精灵,你还想在屏幕上画当前的分数(否则维护它就没多大意义了)。到目前为止,您只在画布上绘制了图像。HTML5 canvas 元素还允许在其上绘制文本。为了绘制文本,您扩展了Canvas2D_Singleton类。
当您修改 canvas drawing 类时,您还想做些别的事情。既然您已经将所有变量组织到对象中,这些对象可以使用类来创建,可以从其他类继承,现在是考虑应该在哪里更改哪些信息的好时机。例如,您可能只想更改Canvas2D_Singleton类中的canvas和canvasContext变量。例如,您不需要在Cannon类中访问这些变量。在Cannon类中,您只想使用通过 canvas drawing 类中的方法提供的高级行为。
不幸的是,JavaScript 没有办法直接控制对变量的访问。一个邪恶的程序员可以在他们程序的某个地方写下下面一行代码:
Canvas2D.canvas = null;
执行完这行代码,屏幕上什么也画不出来!当然,没有一个正常的程序员会故意写这样的东西,但是让你的类的用户尽可能清楚他们应该改变什么数据,什么数据是类内部的,不应该被修改,这是一个好主意。一种方法是在任何内部变量的名字上加一些东西。这本书给所有的内部变量加上了下划线,这些变量不应该在它们所属的类之外被改变。例如,下面是遵循此规则的Canvas2D_Singleton类的修改后的构造函数:
function Canvas2D_Singleton() {
this._canvas = null;
this._canvasContext = null;
}
您还向该类添加了一个新方法drawText,该方法可用于在屏幕上的特定位置绘制文本。drawText方法与drawImage方法非常相似。在这两种情况下,您都使用 canvas 上下文在绘制文本之前执行转换。这允许您在画布上的任意位置绘制文本。此外,您可以更改文本的颜色和文本对齐方式(左对齐、居中或右对齐)。查看属于本章的 Painter10 示例,以了解该方法的主体。
现在使用这种方法在屏幕上绘制文本很容易。例如,这会在屏幕的左上角绘制一些绿色文本:
Canvas2D.drawText("Hello, how are you doing?", Vector2.zero, Color.green);
字符和字符串
在包括 JavaScript 在内的大多数编程语言中,一个字符序列被称为字符串。就像数字或布尔值一样,字符串是 JavaScript 中的基本类型。字符串也是不可变的。这意味着字符串一旦创建,就不能更改。当然,仍然有可能用另一根弦替换这根弦。例如:
var name = "Patrick";
name = "Arjan";
在 JavaScript 中,字符串由单引号或双引号字符分隔。如果字符串以双引号开始,它应该以双引号结束。所以,这是不允许的:
var country = 'The Netherlands";
当你将一个字符串赋给一个变量时,这个字符串被称为常量。除了字符串值,常量值还可以是数字、布尔值、undefined或null,如图 12-1 中的语法图所示。
图 12-1 。常量值的语法图
使用单引号和双引号
当使用字符串值并将它们与其他变量组合时,您必须小心使用哪种类型的引号(如果有的话)。如果您忘记了引号,您就不再是在编写文本或字符,而是 JavaScript 程序的一部分!有很大的区别
- 字符串
"hello"和变量名hello - 字符串
'123'和值123 - 字符串值
'+'和运算符+
特殊字符
特殊字符,仅仅因为它们是特殊的,并不总是容易用引号之间的单个字符来表示。因此,一些特殊的符号有特殊的符号使用反斜杠符号,如下:
'\n'为行尾符号'\t'为制表符号
这就引入了一个新问题:如何表示反斜杠字符本身。反斜杠字符用双反斜杠表示。以类似的方式,反斜杠符号用于表示单引号和双引号本身的字符:
'\\'为反斜杠符号'\''或"'"为单引号字符"\""或'"'为双引号字符
如您所见,您可以在由双引号分隔的字符串中使用不带反斜杠的单引号,反之亦然。图 12-2 中给出了表示所有这些符号的语法图。
图 12-2 。符号语法图
字符串操作
在 Painter 游戏中,您将字符串值与drawText方法结合使用,在屏幕上的某个地方以所需的字体绘制某种颜色的文本。在这种情况下,你需要在屏幕的左上角写下当前的分数。分数保存在一个名为score的成员变量中。该变量在PaintCan的update方法中增加或减少。鉴于文本的一部分(乐谱)一直在变化,你如何构建应该打印在屏幕上的文本?这个解决方案叫做字符串串联,意思是一段接一段的粘贴文本。在 JavaScript(以及许多其他编程语言)中,这是使用加号来完成的。例如,表达式"Hi, my name is " + "Arjan"产生字符串"Hi, my name is Arjan"。在本例中,您连接了两段文本。也可以将一段文本和一个数字连接起来。例如,表达式"Score: " + 200产生字符串"Score: 200"。你可以用一个变量来代替常量。因此,如果变量score包含值 175,那么表达式"Score: " + score的计算结果为"Score: 175"。通过编写这个表达式作为drawText方法的参数,您总是在屏幕上绘制当前的分数。对drawText方法的最后一次调用变成了
Canvas2D.drawText("Score: " + this.score, new Vector2(20, 22), Color.white);
注意:连接只有在处理文本时才有意义。例如,不可能“连接”两个数字:表达式1 + 2的结果是3,而不是12。当然,您可以将表示为文本的数字*:"1" + "2"连接成"12"。通过使用单引号或双引号来区分文本和数字。*
其实在表情"Score: " + 200里偷偷做的就是一个型转换或者投。在连接到另一个字符串之前,数值200被自动转换为字符串"200"。
如果你想把一个字符串值转换成一个数值,事情会变得有点复杂。对于解释器来说,这不是一个容易执行的操作,因为不是所有的字符串都可以转换成数值。为此,JavaScript 有一些有用的内置函数。例如,这是将字符串转换为整数的方法:
var x = parseInt("10");
如果作为参数传递的字符串不是整数,则parseInt函数的结果是该数字的整数部分:
var y = parseInt("3.14"); // y will contain the value 3
为了解析带小数的数字,JavaScript 有parseFloat函数:
y = parseFloat("3.14"); // y will contain the value 3.14
如果字符串不包含有效的数字,那么尝试使用这两个函数之一解析它的结果是常数NaN(不是数字;另见图 12-1。
最后几句话
祝贺您,您已经完成了您的第一个游戏!图 12-3 包含了最终比赛的截图。在开发这个游戏的过程中,你学到了很多重要的概念。在下一个游戏中,你将继续你已经完成的工作。同时,别忘了玩游戏!你会注意到几分钟后变得非常困难,因为油漆罐下降的速度越来越快。
图 12-3 。画师最终版本截图
谁玩游戏?
你可能认为游戏主要是年轻男性玩的,但这完全不是事实。很大一部分人玩游戏。2013 年,美国有 1.83 亿活跃游戏玩家,超过总人口的一半(包括婴儿)。他们在许多不同的设备上玩游戏。36%的人在智能手机上玩游戏,25%的人在无线设备上玩游戏(资料来源:娱乐软件协会(ESA),2013 年)。
如果你开发一款游戏,你最好先想想你想要它的受众。小孩子的游戏不同于中年妇女的游戏。游戏应该有不同种类的游戏,不同的视觉风格和不同的目标。
虽然主机游戏往往发生在大型 3D 世界,但网站和移动设备上的休闲游戏通常是 2D,并且大小有限。此外,主机游戏被设计成可以(并且需要)玩几个小时,而休闲游戏通常被设计成只玩几分钟。也有许多类型的严肃游戏,这是用来训练专业人员的游戏,如消防员、市长和医生。
意识到你喜欢的游戏不一定是你的目标受众喜欢的游戏。
你学到了什么
在本章中,您学习了:
- 如何在游戏中加入音乐和音效
- 如何维护和显示分数
- 如何使用字符串来表示和处理文本
十三、适应不同的设备
这一章讲述了如何让游戏适应不同的设备。到目前为止,您一直在开发只能在有键盘和鼠标的设备上工作的示例,比如笔记本电脑或台式机。JavaScript 程序的一个好处是它们也可以在智能手机和平板电脑上运行,这是一个正在蓬勃发展的市场。让你的游戏运行在这样的平台上会为你的游戏带来很多额外的玩家。为了在智能手机或平板电脑上玩游戏,你需要处理触摸输入,就像处理键盘和鼠标输入一样。
另一件你需要注意的事情是你想要制作游戏的设备上的各种屏幕尺寸。本章向您展示如何创建自动适应任何屏幕大小的游戏,无论是巨大的 24 英寸桌面显示器还是微小的智能手机屏幕。
允许画布改变大小
为了允许自动调整屏幕尺寸,你需要做的第一件事是放置canvas元素,使得画布自动缩放到页面的大小。一旦完成,你可以检索画布的大小,将其与游戏屏幕的实际大小进行比较,并执行缩放操作。在 Painter 中,这是您在 HTML 页面上放置canvas元素的方式:
<div id="gameArea">
<canvas id="mycanvas" width="800" height="480"> </canvas>
</div>
如您所见,您定义了一个名为gameArea的div元素。在这个div元素中是一个单独的canvas元素。为了让canvas元素自动缩放,你需要将它的宽度和高度设置为浏览器窗口宽度和高度的 100%。这样,当浏览器窗口改变大小时,canvas元素也会随之改变大小。此外,你要尽可能使你显示的页面整洁(没有空白)。为了做到这一点,您使用了样式表。样式表是定义网页元素外观的好方法。不同的 HTML 页面可以使用相同的样式表来确保统一的设计。这是示例游戏的样式表:
html, body {
margin: 0;
}
#gameArea {
position: absolute;
}
#mycanvas {
position: absolute;
width: 100%;
height: 100%;
}
深入讨论样式表的可能性不在本书的范围内,但是如果你想了解更多,你可以阅读 Simon Collision 的CSS Web 开发入门 (Apress,2006)或者 Jon Ducket 的 HTML 和 CSS(Wiley,2011)。
在这个特殊的例子中,您要做几件事情。首先,定义html和body元素没有边距。这样,如果你愿意,画布可以完全填满屏幕。然后,定义被称为gameArea和mycanvas的东西在页面上的绝对位置。这样,不管 HTML 页面中还有什么其他元素,这些元素总是被放置在它们想要的位置。最后,您指出mycanvas的宽度和高度是屏幕的 100%。结果,画布现在填满了浏览器的整个显示,并自动缩放到不同的分辨率。
注意让画布填满整个浏览器并不一定是所有设置的最佳解决方案。在某些情况下,用户可能很难在不意外点击浏览器之外的情况下与浏览器窗口边缘的元素进行交互。在这种情况下,您可以考虑增加边距。
设置本地游戏大小
既然画布会自动缩放到浏览器窗口的大小,您就不能再将画布分辨率用作原生游戏分辨率。如果你这样做了,你将不得不在调整画布大小时重新计算所有游戏对象的位置。更好的方法是定义一个本地游戏大小。然后,在绘制对象时,缩放它们的位置和大小,使其与实际的画布大小相匹配。
第一步是能够指出本地游戏的大小应该是多少。当您调用Game.start方法时,您可以这样做。 你扩展了这个方法,让它接受两个额外的参数来定义游戏的宽度和高度,然后从 HTML 页面调用它:
Game.start('gameArea', 'mycanvas', 1440, 1080);
Jewel Jam 游戏的原生分辨率较大(1440 × 1080),所以如果你想让游戏在所有智能手机和平板电脑上正常运行,缩放肯定是必要的。您还可以传递包含画布的div元素的名称。原因是你改变了这个div的边距,这样游戏就可以很好地显示在屏幕中间,以防屏幕比例与游戏大小比例不同(见图 13-1 )。
图 13-1 。你必须确保游戏屏幕总是很好地显示在浏览器窗口的中间!
在Game.start方法中,你将游戏的大小存储在一个成员变量_size中。您还定义了一个只读属性size 来访问成员变量。通过这种方式,你向Game类的用户表明,他们不应该在游戏运行时改变游戏的原生尺寸。您将游戏区域和画布的标识符传递给Canvas2D.initialize方法,因为您必须在属于Canvas2D的方法中完成缩放和定位精灵的跑腿工作。这就是完整的Game.start方法:
Game_Singleton.prototype.start = function (divName, canvasName, x, y) {
this._size = new Vector2(x, y);
Canvas2D.initialize(divName, canvasName);
this.loadAssets();
this.assetLoadingLoop();
};
在Canvas2D中,你存储了一个对div元素的引用,这样你就可以在以后使用它来改变边距。检索该元素的方式与检索canvas元素的方式相同:
this._canvas = document.getElementById(canvasName);
this._div = document.getElementById(divName);
您需要计算每次调整浏览器窗口大小时要应用的缩放因子。您可以通过附加事件处理函数来实现这一点,如下所示:
window.onresize = Canvas2D_Singleton.prototype.resize;
现在,每当调整窗口大小时,就会调用resize方法 。最后,您显式调用resize方法:
this.resize();
通过调用这个方法,在游戏开始时根据浏览器窗口大小计算出合适的比例。
调整游戏大小
每当调整窗口大小时,就调用resize方法。当这种情况发生时,你需要计算两件事:
- 绘制精灵所需的比例
- 游戏区域的边距,以便在浏览器窗口的中间很好地绘制游戏
在开始计算这些东西之前,将canvas和div元素存储在两个局部变量中。这样做可以节省一些编写工作,因为您需要在这个方法中经常访问这些元素:
var gameCanvas = Canvas2D._canvas;
var gameArea = Canvas2D._div;
现在你来计算一下原生游戏大小的比例:
var widthToHeight = Game.size.x / Game.size.y;
下一步是计算浏览器窗口比率。首先通过访问innerWidth和innerHeight变量来获取浏览器窗口的大小。一旦你有了这些,你就可以计算浏览器窗口比例:
var newWidth = window.innerWidth;
var newHeight = window.innerHeight;
var newWidthToHeight = newWidth / newHeight;
你不希望游戏屏幕看起来被压扁或拉长,所以你必须确保当窗口大小改变时,比例不会改变。当新的比例大于原生游戏尺寸比例时,这意味着浏览器窗口太宽。因此,您需要重新计算宽度以修正比率,如下所示:
if (newWidthToHeight > widthToHeight) {
newWidth = newHeight * widthToHeight;
}
另一种情况是新比率较小,意味着浏览器窗口太高。在这种情况下,您需要重新计算高度:
newHeight = newWidth / widthToHeight;
现在你已经计算出了正确的宽度和高度,你首先改变gameArea div使其具有这个高度和宽度。只需通过编辑元素的样式,即可实现如下操作:
gameArea.style.width = newWidth + 'px';
gameArea.style.height = newHeight + 'px';
为了在屏幕中间显示gameArea元素,您还需要定义边距,如下:
gameArea.style.marginTop = (window.innerHeight - newHeight) / 2 + 'px';
gameArea.style.marginLeft = (window.innerWidth - newWidth) / 2 + 'px';
gameArea.style.marginBottom = (window.innerHeight - newHeight) / 2 + 'px';
gameArea.style.marginRight = (window.innerWidth - newWidth) / 2 + 'px';
将新的宽度和高度与实际的宽度和高度之差除以 2,并将这些值设置为边距。例如,如果期望的游戏屏幕宽度是 800,但是窗口实际上是 900 像素宽,那么您可以在每边创建 50 像素的边距,以便将元素绘制在屏幕的中间。最后,更改画布的大小,使其具有所需的宽度和高度(以及正确的比例):
gameCanvas.width = newWidth;
gameCanvas.height = newHeight;
绘制精灵的比例现在可以很容易地计算出来了。您可以定义一个属性来完成这项工作:
Object.defineProperty(Canvas2D_Singleton.prototype, "scale",
{
get: function () {
return new Vector2(this._canvas.width / Game.size.x,
this._canvas.height / Game.size.y);
}
});
当您绘制图像时,您只需在执行绘制操作之前应用此比例。这是通过向Canvas2D.drawImage方法添加几行代码来实现的——一行用于检索标尺,一行用于应用标尺:
var canvasScale = this.scale;
this._canvasContext.scale(canvasScale.x, canvasScale.y);
在Canvas2D.drawText方法中做同样的事情。完整的代码,请看属于本章的 JewelJam1 例子。
重新设计鼠标输入处理
做完所有这些之后,您已经在div和canvas元素周围移动了相当多的距离。在画师游戏中,你假设画布总是画在屏幕的左上角。在这里,情况不再如此。如果你想检测鼠标在屏幕上的点击,当计算鼠标在游戏屏幕上的本地位置时,你需要考虑画布在屏幕上的位置。此外,因为您应用了缩放,所以您还需要相应地缩放鼠标位置。
这是重新审视输入处理类Mouse和Keyboard设计方式的好机会。你从未正确实现的一件事是处理键盘或鼠标按钮的上下相对于键盘或鼠标按钮按下。此外,在Mouse类中,你只考虑了左键点击;而在Keyboard类中,同一时间只能按下一个键。您可以通过 JavaScript 的原型系统使用面向对象编程的力量来设计更好的解决方案。
第一步是创建一个简单的类,可以存储按钮的状态(不管是按键还是鼠标按钮)。我们姑且称这个类为ButtonState。ButtonState类非常简单,只有两个(boolean)成员变量:一个指示按钮是否按下,另一个指示按钮是否被按下。下面是完整的类:
function ButtonState() {
this.down = false;
this.pressed = false;
}
现在您使用Mouse类中的 ButtonState实例来表示鼠标左键、中键和右键的状态。这是Mouse的新构造者的样子:
function Mouse_Singleton() {
this._position = Vector2.zero;
this._left = new ButtonState();
this._middle = new ButtonState();
this._right = new ButtonState();
document.onmousemove = handleMouseMove;
document.onmousedown = handleMouseDown;
document.onmouseup = handleMouseUp;
}
如您所见,按钮的成员变量位于与每个位置相关联的成员变量旁边。您可以添加只读属性来访问这些变量。例如,如果您想检查鼠标中键是否按下,您可以使用下面一行简单的代码来完成:
if (Mouse.middle.down)
// do something
在事件处理函数中,您正在更改成员变量的值。在handleMouseMove事件处理函数中,您必须计算鼠标位置。这也是您确保鼠标位置根据应用于画布的缩放和偏移进行缩放和移动的地方。下面是完整的handleMouseMove功能:
function handleMouseMove(evt) {
var canvasScale = Canvas2D.scale;
var canvasOffset = Canvas2D.offset;
var mx = (evt.pageX - canvasOffset.x) / canvasScale.x;
var my = (evt.pageY - canvasOffset.y) / canvasScale.y;
Mouse._position = new Vector2(mx, my);
}
现在,无论何时移动鼠标,都会正确计算鼠标位置。接下来您需要做的是处理鼠标按钮按下和按下事件。鼠标按钮只有在当前按下时才会被按下,而不是在之前的游戏循环迭代中。对于鼠标左键,您可以这样表达:
if (evt.which === 1) {
if (!Mouse._left.down)
Mouse._left.pressed = true;
Mouse._left.down = true;
}
evt.which值表示您是在处理鼠标左键(1)、中键(2)还是右键(3)。请看一下 JewelJam1 示例,了解完整的handleMouseDown事件处理函数。在handleMouseUp事件处理程序中,您再次将down变量设置为false。下面是完整的函数:
function handleMouseUp(evt) {
handleMouseMove(evt);
if (evt.which === 1)
Mouse._left.down = false;
else if (evt.which === 2)
Mouse._middle.down = false;
else if (evt.which === 3)
Mouse._right.down = false;
}
您可以看到在这里也调用了handleMouseMove函数。这样做是为了确保当您按下鼠标按钮时,鼠标位置可用。如果您省略了这一行,玩家在没有移动鼠标的情况下开始游戏并按下了鼠标按钮,游戏将试图在没有位置信息的情况下处理鼠标按钮的按下。这就是为什么你在handleMouseDown和handleMouseUp函数中都调用了handleMouseMove函数(尽管后者可能不是必需的)。
最后,在每个游戏循环迭代结束时,Mouse对象被重置。这里你唯一需要做的事情就是将pressed变量再次设置为:】
Mouse_Singleton.prototype.reset = function () {
this._left.pressed = false;
this._middle.pressed = false;
this._right.pressed = false;
};
通过在每次游戏循环迭代后将pressed变量设置为false,可以确保鼠标或按键只被处理一次。
数组
你也可以重新设计键盘输入处理,现在你已经有了这个ButtonState类——但是在你这么做之前,让我们引入你需要的另一个概念,叫做数组。数组基本上是一个编号列表。看看下面的例子:
var emptyArray = [];
var intArray = [4, 8, 15, 16, 23, 42];
这里你可以看到数组变量的两个声明和初始化。第一个声明是一个空数组(没有元素)。第二个变量intArray,引用一个长度为 6 的数组。您可以通过索引来访问数组中的元素,其中数组中的第一个元素的索引为 0:
var v = intArray[0]; // contains the value 4
var v2 = intArray[4]; // contains the value 23
您使用方括号来访问数组中的元素。您也可以使用相同的方括号来修改数组中的值:
intArray[1] = 13; // intArray now is [4, 13, 15, 16, 23, 42]
还可以向数组中添加一个元素:
intArray.push(-3); // intArray now is [4, 13, 15, 16, 23, 42, -3]
最后,每个数组都有一个length变量,您可以访问它来检索长度:
var l = intArray.length; // contains the value 7
您可以结合使用数组和for循环来做有趣的事情。这里有一个例子:
for (var i = 0; i < intArray.length; i++) {
intArray[i] += 10;
}
这将遍历数组中的所有元素,每个元素加 10。所以,这段代码执行后,intArray指的是[14, 23, 25, 26, 33, 52, 7]。
除了以您刚才看到的方式初始化数组之外,还有另一种创建数组的方式,如下所示:
var anotherArray = new Array(3);
此示例创建一个大小为 3 的数组,然后您可以填充该数组:
anotherArray[0] = "hello";
您甚至可以用 JavaScript 创建多维数组。例如:
var tictactoe = new Array(3);
tictactoe[0] = ['x', 'o', ' '];
tictactoe[1] = [' ', 'x', 'o'];
tictactoe[2] = [' ', 'o', 'x'];
所以,数组的元素也可以是数组。这些类型的网格结构在表示像国际象棋这样的游戏中的游戏场时特别有用。因为数组是作为引用存储的(就像类的实例一样),所以使用这样的二维数组表示网格在计算上不是很高效。用一维数组表示网格有一个简单的方法,如下:
var rows = 10, cols = 15;
var myGrid = new Array(rows * cols);
现在可以按如下方式访问第i行和第j列的元素:
var elem = myGrid[i * cols + j];
图 13-2 显示了表达式语法图的另一部分,带有指定数组的语法。下一章将更详细地讨论在游戏中使用数组来表示结构和网格。
图 13-2 。表达式的部分语法图
使用数组处理键盘输入
让我们看看如何使用数组更有效地处理按键。因为现在有了ButtonState类,所以也可以用它来处理键盘输入,并为每个按键状态存储一个ButtonState实例。所以,在Keyboard_Singleton的构造函数中,创建一个包含所有可能的关键状态的数组,如下:
this._keyStates = [];
for (var i = 0; i < 256; ++i)
this._keyStates.push(new ButtonState());
现在,只要检测到按键按下,您就可以设置按键的按下状态,如下所示:
function handleKeyDown(evt) {
var code = evt.keyCode;
if (code < 0 || code > 255)
return;
if (!Keyboard._keyStates[code].down)
Keyboard._keyStates[code].pressed = true;
Keyboard._keyStates[code].down = true;
}
如您所见,您可以像处理鼠标按钮一样处理按键。添加到Keyboard_Singleton中的以下两种方法使得检测按键是否被按下变得非常容易:
Keyboard_Singleton.prototype.pressed = function (key) {
return this._keyStates[key].pressed;
};
Keyboard_Singleton.prototype.down = function (key) {
return this._keyStates[key].down;
};
在 JewelJam 游戏中,你不需要键盘,因为所有的互动都是通过鼠标或触摸屏进行的。后面的例子再次使用了键盘。这也意味着您基本上可以忽略任何发生的键盘事件。如果你没有在游戏循环方法中加入任何键盘操作,游戏会完全忽略玩家按下的任何键。
触摸屏输入
因为智能手机和平板电脑也有网络浏览器,所以可以在这些设备上运行 JavaScript 应用。实际上,JavaScript 是目前唯一一种无需重写代码就能开发出运行在台式机、笔记本电脑、手机和平板电脑上的应用的跨平台方法。例如,您可以将最终的画师示例放在服务器上,然后在平板电脑上访问网页。但是,你不能玩这个游戏,因为你没有处理过触摸输入。在本节中,您将为游戏添加触摸输入功能。幸运的是,这在 JavaScript 中非常容易做到!
在开始编写 JavaScript 代码之前,您需要注意 HTML 页面中的一些事情。原因是平板电脑和智能手机设备定义了网页上的默认触摸行为,例如滚动或缩放网页的能力。这些互动会干扰玩游戏,所以你要把它们关掉。这可以在 HTML 文件的标题中完成,方法是在标题中添加以下标记:
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
HTML 中的 视区 定义了视图中的页面部分。例如,当您导航到一个页面时,它会自动放大一部分,这是通过修改视口的大小来实现的。在meta标签中,你可以定义视口的属性。这个例子告诉浏览器三件事:视窗的宽度与设备的宽度相同,第一次查看页面时页面的缩放比例为 1,用户不能缩放页面。结果,所有改变 HTML 页面的定位或缩放的触摸交互都被关闭。
现在,您可以开始编写 JavaScript 代码来处理触摸事件。JavaScript 中触摸输入的工作方式是,每次用户触摸屏幕时,都会生成一个touchstart事件 ,并为该触摸分配一个唯一的标识符。当用户用另一个手指触摸屏幕时,另一个带有新的唯一标识符的touchstart事件发生。当用户在屏幕上移动手指时,会产生一个touchmove事件,并且可以从该事件中获得正在移动的手指的唯一标识符。类似地,当用户停止触摸屏幕时,生成一个touchend事件,并且可以再次从该事件中检索已经停止触摸屏幕的手指的标识符。
为了处理触摸,创建一个类Touch_Singleton和相关的单个实例Touch,类似于您创建Keyboard和Mouse对象的方式。在类的构造函数中,创建一个数组,在其中存储每次触摸和相关信息。此外,您还可以存储每次触摸是否是按压。触摸按压意味着在先前的游戏循环迭代中手指没有触摸屏幕,但是在当前的迭代中手指触摸了屏幕。此外,您必须附加事件处理函数。下面是完整的构造函数方法:
function Touch_Singleton() {
this._touches = [];
this._touchPresses = [];
document.addEventListener('touchstart', handleTouchStart, false);
document.addEventListener('touchend', handleTouchEnd, false);
document.addEventListener('touchcancel', handleTouchEnd, false);
document.addEventListener('touchleave', handleTouchEnd, false);
document.body.addEventListener('touchmove', handleTouchMove, false);
}
对于浏览器来说,处理触摸相对较新(相对于键盘和鼠标输入)。因此,并非所有浏览器都使用相同的术语。这就是为什么您需要为不同种类的touchend事件添加事件监听器。然而,最终,您处理三种类型的事件:touchstart、touchmove和touchend、。它们都有自己的事件处理函数。
当用户开始触摸屏幕时,调用handleTouchStart功能。 在这个函数中,您只需将触摸事件存储在_touches数组中,以便以后需要时可以检索数据。然而,用户可能会同时用多个手指触摸屏幕。因此,该事件包含一系列新的触摸。你可以用变量changedTouches来访问这个数组。对于该数组中的每个元素,将触摸事件数据添加到_touches数组中。因为这些都是新的内容,所以您还需要将true值添加到_touchPresses变量中。这是完整的方法:
function handleTouchStart(evt) {
evt.preventDefault();
var touches = evt.changedTouches;
for (var i = 0; i < touches.length; i++) {
Touch._touches.push(touches[i]);
Touch._touchPresses.push(true);
}
}
注意,这个函数中的第一条指令是evt.preventDefault();。该方法调用确保触摸事件不用于默认行为(如滚动)。您还将该指令放在其他触摸事件处理程序函数中。在handleTouchEnd中,你需要从你的类中的两个数组中移除触摸。为此,您必须搜索整个数组,以找到属于特定 touch ID 的数据。为了使这变得简单一点,您在您的类中添加了一个方法getTouchIndexFromId,它会为您找到数组中的索引。稍后,您可以调用此方法来查找触摸在数组中的存储位置,然后移除该元素。
在该方法中,使用一个for循环遍历数组中的所有元素。当您找到与您正在寻找的触摸数据的标识符相匹配的触摸时,您返回该数据。如果找不到触摸数据,则返回值-1。下面是完整的方法:
Touch_Singleton.prototype.getTouchIndexFromId = function (id) {
for (var i = 0, l = this._touches.length; i < l; ++i) {
if (this._touches[i].identifier === id)
return i;
}
return -1;
};
在handleTouchEnd函数中, 用一个for循环遍历changedTouches变量。对于每一次触摸,您找到相应的索引并从两个数组(_touches和_touchPresses)中移除触摸。从数组中移除一个元素是通过splice方法完成的。该方法采用一个索引和一个数字:索引指示应该从哪里移除元素,第二个参数指示应该移除多少个元素。这里有一个例子:
var myArray = [2, 56, 12, 4];
myArray.splice(0,1); // myArray now contains [56, 12, 4]
myArray.splice(1,2); // myArray now contains [56]
以下是完整的handleTouchEnd功能:
function handleTouchEnd(evt) {
evt.preventDefault();
var touches = evt.changedTouches;
for (var i = 0; i < touches.length; ++i) {
var id = Touch.getTouchIndexFromId(touches[i].identifier);
Touch._touches.splice(id, 1);
Touch._touchPresses.splice(id, 1);
}
}
最后,在handleTouchMove、、中,您必须为已经改变的触摸更新触摸信息。这意味着你必须替换数组中的一个元素。splice方法接受第三个参数,在这个参数中您可以指定一个替换元素。看看属于这一章的代码,看看handleTouchMove事件处理函数的实现。
使得处理触摸输入更容易
您已经看到了 JavaScript 是如何处理触摸输入的。你可以在Touch_Singleton类中再添加一些方法,让它在游戏中更容易使用。首先是一个简单的方法来检索一个给定索引触摸的位置:
Touch_Singleton.prototype.getPosition = function (index) {
var canvasScale = Canvas2D.scale;
var canvasOffset = Canvas2D.offset;
var mx = (this._touches[index].pageX - canvasOffset.x) / canvasScale.x;
var my = (this._touches[index].pageY - canvasOffset.y) / canvasScale.y;
return new Vector2(mx, my);
};
请注意,您必须应用与鼠标位置相同的位置和比例校正。触摸事件中的pageX和pageY变量提供了玩家触摸屏幕的坐标。
检查你是否触摸了屏幕的某个区域也很有用。为此,您可以在 JewelJam1 示例中添加一个名为Rectangle的类。这是一个非常简单的类,类似于Vector2,用于表示矩形。还可以添加几个简单的方法来检查两个矩形是否相交(对碰撞检查有用)以及矩形是否包含某个位置。查看Rectangle.js文件,了解如何构造这个类。
您可以使用矩形来定义屏幕的各个部分。containsTouch方法检查作为参数提供给方法的矩形中是否有触摸
Touch_Singleton.prototype.containsTouch = function (rect) {
for (var i = 0, l = this._touches.length; i < l; ++i) {
if (rect.contains(this.getPosition(i)))
return true;
}
return false;
};
在方法体中有一个for循环,它为数组中的每次触摸检查它的位置是否在矩形内。您重用了之前定义的getPosition方法。根据结果,该方法返回true或false。还将以下属性添加到Touch :
Object.defineProperty(Touch_Singleton.prototype, "isTouchDevice",
{
get: function () {
return ('ontouchstart' in window) || (navigator.msMaxTouchPoints > 0);
}
});
这是一个非常简单的属性,用于检查您当前是否在使用触摸设备。并非所有的浏览器都以相同的方式检测到这一点,所以这是将其封装在方法或属性中的一个很好的理由。这样,您只需处理一次浏览器之间的这些差异。之后,访问Touch.isTouchDevice属性,就完成了。
向画师添加触摸输入
现在你已经有了这个漂亮的Touch物体,如果不在画师游戏中加入触感,那就太可惜了。属于本章的示例代码有一个带触摸输入的 Painter 版本。查看示例代码,特别是添加了触摸输入的Cannon.js文件。在触控界面中,你可以点击炮管中的彩球来切换到不同的颜色。在大炮外面敲击瞄准大炮。之后松开触球。
为了将触摸输入处理和鼠标输入处理分开,您可以如下重写handleInput方法:
Cannon.prototype.handleInput = function (delta) {
if (Touch.isTouchDevice)
this.handleInputTouch(delta);
else
this.handleInputMouse(delta);
};
现在你写两个不同的方法来处理触摸和鼠标输入。这就是handleInputTouch方法的样子:
Cannon.prototype.handleInputTouch = function (delta) {
var rect = this.colorSelectRectangle;
if (Touch.containsTouchPress(rect)) {
if (this.currentColor === this.colorRed)
this.currentColor = this.colorGreen;
else if (this.currentColor === this.colorGreen)
this.currentColor = this.colorBlue;
else
this.currentColor = this.colorRed;
} else if (Touch.touching && !Touch.containsTouch(rect)) {
var opposite = Touch.position.y - this.position.y;
var adjacent = Touch.position.x - this.position.x;
this.rotation = Math.atan2(opposite, adjacent);
}
};
首先,检索代表加农炮部分的矩形,您可以触摸它来选择不同的颜色。添加一个简单的属性来计算这个矩形。然后检查矩形是否包含触摸按压。如果是这种情况,你改变颜色。通过使用一个if指令,你可以很容易地在三种颜色之间循环。
如果玩家正在触摸屏幕的某个地方,但不是在矩形内,你改变大炮的目标指向那个位置。还可以看看Ball类,看看它是如何处理触摸输入的!最后,另一件好事是根据应用是在平板电脑上运行还是在台式计算机上运行来加载不同的精灵:
if (Touch.isTouchDevice)
sprites.gameover = loadSprite("spr_gameover_tap.png");
else
sprites.gameover = loadSprite("spr_gameover_click.png");
当游戏在触摸设备上运行时,覆盖图有文本“点击继续”;否则显示“点击继续”。
注意许多现代笔记本电脑都包含触摸屏和键盘,如何自动确定玩家想要使用触摸还是键盘输入并不总是显而易见的。玩家可能希望在一些游戏中使用触摸屏,而在另一些游戏中使用键盘。一个可能的解决方案是同时接受两个输入。另一种解决方案是让玩家通过菜单设置来自己选择输入法。
你学到了什么
在本章中,您学习了:
- 如何根据不同的设备自动调整游戏屏幕的尺寸
- 如何根据游戏画面的尺寸自动校正鼠标位置
- 什么是数组以及如何使用数组
- 如何使用触摸界面来控制游戏中发生的事情
十四、结构中的游戏对象
在前一章中,你看到了如何使用数组来表示事物的列表。例如,您使用一个数组来跟踪玩家触摸触摸屏的位置,或者玩家当前在键盘上按了多少个键。
数组也可以用在很多其他情况下。您可以使用它们来存储更复杂的对象,如画家游戏中的球或颜料罐。一般来说,为游戏对象提供一些结构是有用的,而不是简单地将它们都声明为游戏世界中的成员变量。许多游戏将它们的游戏对象放在某种游戏层级中。例如,你可以有一个游戏对象Car,它由其他游戏对象组成,比如轮子、传动系统、马达、窗户、座椅等等。这些对象中的每一个又可以由更小的游戏对象组成,等等。
在某些情况下,游戏对象必须遵守游戏世界中的某种结构。很多桌游或者益智游戏都有这个要求。这些游戏强加了一套规则,将棋子绑定到棋盘上的特定位置或配置。例如,在国际象棋游戏中,棋子只能(有意义地)放在棋盘上的白格子和黑格子上。你不能把你的皇后放在两个方格中间。在电脑游戏中,这种限制更容易实施:你只需要确保你放置游戏对象的位置是有效的。在这一章中,你将看到如何将层次和结构融入到电脑游戏中。
网格中的游戏对象
通常,棋盘游戏和益智游戏是基于将物体放置在某种网格中。这种游戏有很多例子:国际象棋、俄罗斯方块、井字游戏、数独游戏、糖果粉碎游戏等等。通常这些游戏的目标是以某种方式修改网格的配置以获得分数。在俄罗斯方块中,必须构建完全填充的行;在数独游戏中,数字属性必须适用于行、列和子网格。游戏 JewelJam 也采用了网格结构。问题是,你如何在你的游戏中表现这些网格状的结构?
首先让我们来看一个简单的例子,你想画一个背景精灵,在这个基础上,画一个十行五列的网格,网格中的每个位置都填充了一个精灵。做这件事的程序叫做 JewelJam2 ,你可以在属于本章的示例代码文件夹中找到它。
创建精灵网格
前一章展示了如何创建数组。让我们在下一个例子中使用这个特性来创建一个二维游戏场。程序 JewelJam2 包含了创建一个游戏世界的指令,这个游戏世界由一个可以被操纵的精灵网格组成。在这个特殊的例子中,你没有在网格中存储实际的精灵,而是一个代表它们的整数。这样,你可以根据这个数字选择画哪个精灵,甚至可以用网格中的数字进行计算。当你开始游戏时,你加载了三个宝石精灵和背景精灵:
Game.loadAssets = function () {
var loadSprite = function (sprite) {
return Game.loadSprite("../assets/sprites/" + sprite);
};
sprites.background = loadSprite("spr_background.jpg");
sprites.single_jewel1 = loadSprite("spr_single_jewel1.png");
sprites.single_jewel2 = loadSprite("spr_single_jewel2.png");
sprites.single_jewel3 = loadSprite("spr_single_jewel3.png");
};
在JewelJamGameWorld类中,创建了一个表示二维游戏场的数组。在上一章中,您看到了如何使用一维数组来表示二维网格:
var myGrid = new Array(rows * cols);
var someElement = myGrid[i * cols + j];
因此,让我们在JewelJamGameWorld类中创建这样一个网格:
this.rows = 10;
this.columns = 5;
this.grid = new Array(this.rows * this.columns);
为了在访问该网格时使事情变得简单一些,定义以下两种方法来获取和设置网格中的值:
JewelJamGameWorld.prototype.setGridValue = function (x, y, value) {
var index = y * this.columns + x;
this.grid[index] = value;
};
JewelJamGameWorld.prototype.getGridValue = function (x, y) {
var index = y * this.columns + x;
return this.grid[index];
};
正如你所看到的,你只需应用上一章解释的技巧。最初,你用你装载的三个宝石精灵中的一个随机填充网格。您可以这样做:
for (var i = 0; i < this.rows * this.columns; i++) {
var randomval = Math.floor(Math.random() * 3) + 1;
if (randomval === 1)
this.grid[i] = sprites.single_jewel1;
else if (randomval === 2)
this.grid[i] = sprites.single_jewel2;
else
this.grid[i] = sprites.single_jewel3;
}
for循环体中的第一条指令从集合{1,2,3}中生成一个随机数。在这条指令中,使用Math.random得到一个介于 0 和 1 之间的值,将该值乘以 3(得到一个介于 0 和 3 之间的值),然后将其向下舍入并加 1,得到一个介于 1 和 3 之间的值。根据随机数的值,在if指令中选择不同的精灵。在网格数组中存储一个对 sprite 的引用。
JavaScript 中有一种很好的方法可以缩短这段代码,因为 JavaScript 允许您通过使用类似数组的语法来访问对象的成员变量。例如,假设您定义了以下对象:
var person = {
name : "Arjan",
gender : "male",
married : true
};
您可以按常规方式访问成员变量,如下所示:
person.name = "John";
有一个指令是等价的。看起来是这样的:
person["name"] = "John";
因此,当访问对象的成员变量时,可以使用常规语法,或者可以将成员作为用字符串索引的数组来访问。你会问,这为什么有用?嗯,字符串可以连接起来,所以你可以写一些聪明的代码,根据随机生成的数字选择不同的精灵。这里是和以前一样的for循环,但是现在你使用这个特性来编写更短的代码——这很容易适应四个或更多的宝石类型!
for (var i = 0; i < this.rows * this.columns; i++) {
var randomval = Math.floor(Math.random() * 3) + 1;
this.grid[i] = sprites["single_jewel" + randomval];
}
绘制网格
现在你有了一个随机选择的宝石精灵网格,你可以在屏幕上画网格了。这里的挑战是,你需要计算每个宝石应该画的位置。该位置取决于您要绘制的宝石的行和列索引。因此,您使用一个嵌套的for循环遍历所有的行和列,然后在每个行和列的索引处绘制宝石。要取回宝石,您可以使用之前定义的getGridValue方法。下面是完整的draw方法:
JewelJamGameWorld.prototype.draw = function (delta) {
Canvas2D.drawImage(sprites.background);
for (var row = 0; row < this.rows; row++) {
for (var col = 0; col < this.columns; col++) {
var position = new Vector2(85 + col * 85, 150 + row * 85);
Canvas2D.drawImage(this.getGridValue(col, row), position);
}
}
};
在这段代码中,您可以看到使用网格的优势。通过使用索引,你可以非常方便地计算出精灵的位置。整个网格应该以(85,150)的偏移量绘制,所以您将 85 加到本地position变量的x-坐标,150 加到y-坐标。要计算精灵的实际位置,将指数乘以 85 (精灵的宽度和高度)得到最终位置。偏移值可以存储在脚本开始时的配置变量中。这样,如果不同的级别使用不同的背景精灵,您只需要更新该变量,而不必通过绘制代码来更新偏移量。后来,你看到了另一种处理方式。图 14-1 显示了 JewelJam2 示例的截图。
图 14-1 。JewelJam2 示例程序的屏幕截图
网格操作
因为你已经在网格中组织了游戏世界的一部分,你现在可以聪明地使用for循环来将行为添加到网格中。在本例中,您添加了一个将每一行下移一行的特性。这意味着最后一行消失了,您需要为第一行生成新的(随机)值。让我们添加一个名为moveRowDown的方法来做这件事。“下移”一行是什么意思?基本上,您只需将索引为y的行中的值复制到索引为y + 1的行中。让我们把它放在一个for循环中:
for (var y = 1; y < this.rows - 1; y++) {
for (var x = 0; x < this.columns; x++) {
this.setGridValue(x, y + 1, this.getGridValue(x, y));
}
}
外部的for循环从第 0 行开始迭代,直到this.rows - 1。这意味着最后一行不会向下移动。而这就是你想要的,因为最后一行下面没有行!内部的for循环遍历列(从 0 到this.columns,并将位置(x、y)处的值复制到位置(x、y + 1)。在这个内部for循环完成后,行y的内容被复制到行y + 1。然而,如果您尝试运行这个for循环,您会注意到它没有您想要的行为:第一行的内容被复制到它下面的所有行!这怎么可能?
这是一个为什么思考循环如何工作很重要的例子。这种情况下的问题是你忘记了循环是顺序的。让我们看看发生了什么。第一次进入循环时,将第 0 行的内容复制到第 1 行。第二次进入循环时,将第 1 行的内容复制到第 2 行。但是,第 1 行已经被第 0 行的内容替换了,所以您正在将第 0 行的内容复制(间接)到第 2 行!
你如何解决这个问题?实际上,你只需要对算法做一个简单的改变。不是从第 0 行开始,一直到最后一行,而是从最后一行开始,一直向上,直到第一行。修改后的算法如下所示:
for (var y = this.rows - 2; y >=0; y--) {
for (var x = 0; x < this.columns; x++) {
this.setGridValue(x, y + 1, this.getGridValue(x, y));
}
}
在这种情况下,从索引 8 处的行开始,将其内容复制到索引 9 处的行。之后,将第 7 行复制到第 8 行,依此类推。与以前版本的算法不同,这种方法是可行的,因为您是从底部向上工作的,并且只对您不再需要考虑的行进行修改:一旦您将值从第 7 行复制到第 8 行,在算法的剩余部分中就不会再看到第 8 行。
当你在你的程序中使用循环时,你会遇到那些刚刚描述的错误。当发生这种情况时,最好的办法是在纸上画一个图,看看发生了什么,并写下循环正在做什么,一次又一次地迭代。调试器也很有帮助,因为它允许您在任何地方停止代码并检查变量的值。
向下移动所有行后,剩下唯一要做的就是为第一行生成新的随机值。这可以通过一条for指令来完成,该指令为行中的每一项检索一个随机数:
for (x = 0; x < this.columns; x++) {
var randomval = Math.floor(Math.random() * 3) + 1;
this.setGridValue(x, 0, sprites["single_jewel" + randomval]);
}
网格带来更多可能性
为了熟悉多维数组是如何工作的,您可以尝试自己编写一些其他的网格操作。例如,您能否编写一个方法void removeRow(int i)来删除给定索引处的一行,并为顶行创建新值?能不能写一个方法,对行执行一个循环操作(所有行下移,最后一行变成第一行)?向上移动行怎么样?还是动柱?可以在这样的网格上创建许多不同的操作。这些操作对许多不同的游戏都很有用。例如,从网格中删除一行是在俄罗斯方块游戏中经常使用的操作。像宝石迷阵这样的游戏需要能够从一行或一列中移除一些项目并再次填充网格的操作。
除了可以在网格上执行的操作之外,您还必须考虑网格包含的项目。在本例中,您使用了一个包含子画面引用的二维网格。对于更复杂的游戏,用一个由个游戏对象组成的网格来代替是很有用的,这样你就可以向网格中的对象添加更多的行为和交互。
游戏对象的层次结构
这一节向你展示如何创建一个游戏对象的层次结构。首先定义一个非常基本的GameObject类,然后添加支持将游戏对象放入层次结构的代码。
游戏对象的剖析
大多数游戏都有相当复杂的游戏对象结构。首先,可能有一个由各种运动物体层组成的背景(山脉、空气、树木等等)。然后是玩家可以与之互动的四处移动的物体。这些物体可能是玩家的敌人,所以它们需要一定程度的智能;它们也可以是更静态的,如电源、树、门或梯子。
有时物体甚至没有精灵形状的物理外观。例如,玩家的当前得分也可以是一个游戏对象,但不是有一个小精灵与之相关联,而是有一个字体在某处显示当前得分。或者想象一个游戏,其中一个看不见的敌人必须被击败,它的位置只能通过它对周围环境的影响来看。其他游戏对象甚至更复杂:由其他游戏对象组成的游戏对象。
假设你有一个代表房子的游戏对象。它可能由许多其他游戏对象组成,如门、楼梯、窗户和厨房(厨房本身又由不同的游戏对象组成)。
在益智游戏的情况下,代表游戏场地的网格也可以被认为是由其他游戏对象的网格组成的游戏对象。给定这些不同类型的游戏对象以及它们之间的关系,你可以说游戏对象通常形成了层次的一部分。这个层级可以是完全扁平化的,就像第一个示例游戏,画师;但是在接下来的章节中解释的宝石果酱游戏有一个复杂的游戏对象层次结构。
许多游戏使用游戏对象的层次结构。特别是在 3D 游戏中,由于三维环境的复杂性,这样的层次非常重要。3D 游戏中的对象通常不是由精灵来表示,而是由一个或多个 3D 模型来表示。层次结构的优点是这些对象可以组合在一起,这样,如果你拿起一个花瓶,里面有一个写有魔法文字的卷轴,卷轴会随着花瓶一起移动。这种层次也被称为场景图 ,因为它们将场景(环境)呈现为一个类似图形的结构。
在画师游戏中,游戏对象的基本类型由ThreeColorGameObject类表示。很明显,不是所有的游戏对象都有三种可能的颜色,一个当前位置和一个当前速度。到目前为止,这是你表示游戏对象的方式,仅仅是因为这对于你正在处理的基本例子来说已经足够了。如果你想开发更大、更复杂的游戏,你必须放弃一个游戏对象是三色精灵的基本前提。但是,什么是游戏对象呢?从某种意义上说,游戏对象可以是你想要的任何东西。因此,您可以定义以下类来表示游戏对象:
function GameObject() {
}
好吧,这可能有点过了。现在,让我们假设任何游戏对象都有一个位置和一个速度,但是游戏对象如何出现(如果它出现)是你还没有处理的事情。此外,您希望能够设置一个可见性标志,以便您可以选择不绘制某些游戏对象。因此,让我们用这三个成员变量创建一个通用的GameObject类:
function GameObject() {
this.position = Vector2.zero;
this.velocity = Vector2.zero;
this._visible = true;
}
如果你想要一个由精灵代表的游戏对象,你可以从这个基类继承并添加必要的成员变量。
您还添加了主要的游戏循环方法:handleInput、update和draw。因为您还不知道游戏对象应该如何处理输入,以及它应该如何在屏幕上绘制,所以您将这两个方法留空。在update方法中,就像在ThreeColorGameObject类中一样,根据游戏对象的速度和经过的时间来更新它的当前位置:
GameObject.prototype.update = function (delta) {
this.position.addTo(this.velocity.multiply(delta));
};
游戏对象之间的关系
如果你想在游戏对象之间建立某种层次,你需要识别哪个游戏对象是哪个游戏对象的一部分。就层次而言,这意味着你需要建立一个游戏对象可以有一个父游戏对象。对于游戏对象本身来说,知道父母是谁是非常有用的。因此,GameObject类需要一个引用游戏对象父对象的成员变量:
this.parent = null;
例如,想象一个名为playingField的物体,它包含了游戏场上的所有宝石。然后playingField对象可以被认为是这些宝石的父。但并非所有游戏对象都有父对象。例如,根对象没有父对象。你怎么能表明一个游戏对象没有父对象呢?您需要将parent成员变量的值设置为“nothing”——用 JavaScript 编程术语来说,就是null。
既然您已经向 game-object 类添加了一个父类,那么您必须处理一些管理上的麻烦,以确保游戏对象之间的父子关系得到正确维护;但是你过会儿回到那个。因为游戏对象的等级制度,你需要对一些事情做出决定。
本地与全球立场
如你所知,每个游戏对象都有一个包含其位置的变量。直到现在,每个游戏对象都被直接定位在游戏世界中。尽管这种方法很好,但它可能不是理想的解决方案。考虑运动场游戏对象。为了将游戏区域与背景精灵对齐,您希望将它放置在位置(85,150)。然而,所有的子对象(网格中的宝石)可能也有相同的位置偏移量(85,150)。事实上,在前面的示例中,您必须将这个偏移量应用于网格中的所有项目:
var position = new Vector2(85 + col * 85, 150 + row * 85);
Canvas2D.drawImage(this.getGridValue(col, row), position);
尽管将该偏移应用于所有游戏对象(游戏场对象的子对象)有点麻烦,但这是可行的。一旦子对象变得更加复杂,并且子对象本身也需要正确定位,问题就变得更加严重。如果你改变比赛场地的位置会发生什么?你必须更新挂在它下面的所有游戏物体的位置。有一个更好的方法来做到这一点:你必须区分本地和世界位置。游戏对象的世界位置是其在游戏世界中的绝对 x -和y-坐标。游戏对象的局部位置是其相对于父游戏对象位置的位置。那么,你需要在每个游戏对象中存储这两个位置吗?否:您只需要存储本地位置。您可以通过将游戏对象的本地位置添加到父对象的世界位置来计算世界位置。如果没有父位置,那么本地位置与世界位置相同。您可以向GameObject类添加一个属性来为您完成这项工作:
Object.defineProperty(GameObject.prototype, "worldPosition",
{
get: function () {
if (this.parent !== null)
return this.parent.worldPosition.addTo(this.position);
else
return this.position.copy();
}
});
使用该属性,您现在可以获得游戏对象的本地位置(存储在position成员变量中)和世界位置(通过worldPosition属性访问)。如您所见,您通过将本地位置添加到父对象的世界位置来计算世界位置。反过来,父对象的世界位置是通过获取其本地位置并将其添加到其父对象的世界位置来计算的。这一直持续到你到达一个没有父对象的游戏对象,在这种情况下,世界位置是本地位置的副本。例如,宝石的世界位置是通过将根对象的(本地)位置、游戏场对象的本地位置加上它自己的本地位置相加来计算的。这正是当您访问它的worldPosition属性时得到的行为。在worldPosition属性本身中调用worldPosition属性可能看起来很奇怪,但这是完全有效的 JavaScript 代码。事实上,您正在使用一种叫做递归的编程技术(稍后您将了解更多)。
游戏对象的层
当你想要绘制一个游戏对象时,你可以使用worldPosition属性作为一种便捷的方式来找出在屏幕上的何处绘制游戏对象。唯一的问题是你不知道游戏对象在层级中的绘制顺序。看着宝石果酱游戏,你明明希望背景先画,游戏场才画;否则,玩家只会看到背景。
如果你能以某种方式作为游戏对象的一部分指出它应该在什么时候被绘制,那就太好了。一种方法是引入层。您可以为每个游戏对象分配一个层,分配给它的层决定了何时应该绘制该对象。您可以使用整数以非常简单的方式表示这些层。较低的图层编号表示将较早绘制对象。因此,您可以将第 0 层指定给背景精灵游戏对象,将第 1 层指定给运动场游戏对象,确保在运动场之前绘制背景。直接在属于GameObject类的成员变量中存储层:
this.layer = 0;
使用层的一个小缺点是无法保证同一层中对象的绘制顺序。所以,如果你希望一个对象总是在另一个之后被绘制,那么这个对象必须在一个更高的层中。
关于GameObject类的完整视图,请参见 JewelJam3 示例中的代码。当然,简单地给GameObject类添加一个layer成员变量是不够的:你必须用这个信息做一些事情。下一节看几个不同的游戏对象子类。其中之一是GameObjectList类,它由多个其他游戏对象组成。在这个类中,您将看到如何使用layer变量以正确的顺序绘制对象。
不同种类的游戏对象
本节介绍几个有用的游戏对象,它们都被定义为GameObject的子类。首先定义一个简单的基于精灵的游戏对象。然后你移动到游戏对象的列表和网格。
精灵游戏对象
最常见的游戏对象之一是一个有位置和速度的精灵。因为 position 和 velocity 是两个已经在GameObject类中可用的成员变量,所以您可以从这个类继承,然后添加一个成员变量来存储 sprite 和一个成员变量来存储 sprite 的原点。在这个类的构造函数中,你必须将 sprite 作为参数传递,因为你在继承,你必须调用基类的构造函数,这样对象的GameObject部分也被构造。此构造函数需要一个表示层的参数。最后,您必须替换/覆盖draw方法。这个方法在GameObject中是空的,因为你决定游戏对象不一定有一个附属的精灵。在被覆盖的draw方法中,在屏幕上绘制精灵,并使用worldPosition属性计算精灵在屏幕上的实际位置。下面是SpriteGameObject类的简化版本:
function SpriteGameObject(sprite, layer) {
GameObject.call(this, layer);
this.sprite = sprite;
this.origin = Vector2.zero;
}
SpriteGameObject.prototype = Object.create(GameObject.prototype);
SpriteGameObject.prototype.draw = function () {
if (!this.visible)
return;
Canvas2D.drawImage(this.sprite, this.worldPosition, 0, 1, this.origin);
};
请看一下 JewelJam3 示例代码中该类的完整版本。该版本增加了一些有用的属性,例如用于检索 sprite 游戏对象宽度的属性。
游戏对象列表
下一种类型的游戏对象由其他游戏对象的列表组成。这是一个非常有用的类型,因为它允许你创建游戏对象的层次结构。例如,根游戏对象需要是其他游戏对象的列表,因为它包含背景精灵游戏对象以及游戏场。要表示一个包含其他游戏对象列表的游戏对象,可以使用一个名为GameObjectList的类。这个类继承自GameObject类,所以游戏对象列表本身也是一个游戏对象。这样,你可以把它当作一个普通的游戏对象,给它一个位置,一个速度,一个绘图层,或者一个父游戏对象。此外,列表中的游戏对象本身可以是其他游戏对象的列表。这个GameObjectList类的设计允许你定义游戏对象的层次结构。要管理游戏对象列表,您需要添加一个包含(子)游戏对象的数组成员变量。这里是GameObjectList的完整构造器:
function GameObjectList(layer) {
GameObject.call(this, layer);
this._gameObjects = [];
}
GameObjectList类的目标之一是处理列表中的游戏对象。这意味着,如果你调用一个GameObjectList实例的draw方法,这个实例将绘制列表中的所有游戏对象。如果调用了handleInput方法或update方法,需要遵循相同的程序。下面是GameObjectList : 中定义的update方法
GameObjectList.prototype.update = function (delta) {
for (var i = 0, l = this._gameObjects.length; i < l; ++i)
this._gameObjects[i].update(delta);
};
所以,GameObjectList本身并不定义任何行为;它只是管理它所包含的游戏对象的行为。对于update方法,你不关心游戏对象自我更新的顺序。对于draw方法,你确实关心,因为你想先画出层数最少的游戏对象。最健壮的方法是在每次调用draw方法的开始对游戏对象列表进行排序。之后,你可以使用一个for循环,根据游戏对象在列表中的顺序,一个接一个地绘制它们。draw方法的主体看起来像这样:
if (!this.visible)
return;
// sort the list of game objects
...
for (var i = 0, l = this._gameObjects.length; i < l; ++i)
this._gameObjects[i].draw();
因为排序可能相当复杂,所以不是在绘制游戏对象时进行排序(每秒必须进行 60 次),而是在将游戏对象添加到列表中时进行排序。这样,你只需要在必要的时候对游戏对象列表进行排序。在 JavaScript 中对数组进行排序非常容易。数组有一个你可以调用的sort函数。比如:
var myArray = ["named", "must", "your", "fear", "be", "before", "banish", "it", "you", "can"];
myArray.sort();
/* myArray now refers to ["banish", "be", "before", "can", "fear", "it", "must", "named", "you", "your"]; */
默认情况下,sort函数按字母顺序对数组进行排序。然而,如果你有一个数组包含比字符串更复杂的东西,比如游戏对象,会发生什么呢?在这种情况下,您可以为sort提供一个排序函数作为参数。这个函数应该指示数组中任意两个对象的顺序。你可以自己写这个函数。例如,下面是对sort函数的调用,该函数根据游戏对象所在的层对其进行排序:
this._gameObjects.sort(function (a, b) {
return a.layer - b.layer;
});
当排序函数返回正数时,a比b要“大”,应该放在b之后,反之亦然。您可以编写一个名为add的方法,将一个游戏对象添加到列表中,然后对列表进行排序。该方法还将游戏对象列表指定为您添加的游戏对象的父对象。下面是完整的方法:
GameObjectList.prototype.add = function (gameobject) {
this._gameObjects.push(gameobject);
gameobject.parent = this;
this._gameObjects.sort(function (a, b) {
return a.layer - b.layer;
});
};
因为您确保游戏对象被添加到正确的位置,draw方法只包含一个for循环:
GameObjectList.prototype.draw = function () {
if (!this.visible)
return;
for (var i = 0, l = this._gameObjects.length; i < l; ++i)
this._gameObjects[i].draw();
};
这样,你的绘制操作保持非常高效,因为你不用每次都对游戏对象列表进行排序!不过,这样做有一个小小的缺点。考虑下面的代码片段:
var obj1 = new SpriteGameObject(spr, 1);
var obj2 = new SpriteGameObject(spr, 2);
var objects = new GameObjectList(0);
objects.add(obj1);
objects.add(obj2);
obj2.layer = 0;
这个片段创建了两个 sprite 游戏对象,并将它们添加到游戏对象列表中。add方法调用确保它们被添加到正确的位置(在这种情况下,添加的顺序恰好与层排序一致)。然而,在那之后你改变了对象obj2的层索引,但是游戏对象的列表没有改变,这意味着obj1仍然会在obj2之前绘制。因此,有可能破坏系统。在这种情况下,强烈推荐清晰的文档来指导开发人员不要做这种讨厌的事情!例如,你可以在add方法的定义上添加一个警告注释,告诉用户只考虑对象的当前图层值。另一个选择是给layer变量声明添加一个注释,说明当图层改变时,绘制顺序不会自动更新。处理这个问题的一个更好、更可靠的方法是添加一个属性,通过该属性可以更改层,该属性会自动对对象所属父对象的绘制顺序进行排序。
为了完整起见,GameObjectList类还包含一些其他有用的方法。方法从列表中删除所有的游戏对象。方法从列表中删除一个对象;因为对象不再是列表的一部分,它的父对象被设置为null。
现在,您可以从自己创建的分层绘制机制以及层次结构中获益。为了让你的代码更清晰,你可以定义几个不同的层作为一个变量(完整代码见JewelJam.js):
var ID = {};
...
ID.layer_background = 1;
ID.layer_objects = 20;
现在看一下下面的代码片段:
function JewelJamGameWorld(layer) {
GameObjectList.call(this, layer);
this.add(new SpriteGameObject(sprites.background, ID.layer_background));
var rows = 10, columns = 5;
var grid = new JewelGrid(rows, columns, ID.layer_objects);
grid.position = new Vector2(85, 150);
grid.cellWidth = 85;
grid.cellHeight = 85;
this.add(grid);
for (var i = 0; i < rows * columns; i++) {
grid.add(new Jewel());
}
}
JewelJamGameWorld.prototype = Object.create(GameObjectList.prototype);
这是重新创建宝石果酱游戏层次结构所需的部分代码。JewelJameGameWorld类继承自GameObjectList。因此,您可以使用add方法将游戏对象添加到游戏世界中!
首先添加一个代表背景的 sprite 游戏对象。你将图层ID.layer_background分配给这个对象。然后,你在ID.layer_objects层创建一个JewelGrid(稍后会详细讨论)。最后,用Jewel对象填充这个网格。这样,您就创建了一个相关游戏对象的层次结构,这些对象是按照正确的顺序自动绘制的!此外,因为您还处理了其他游戏循环方法的调用,所以在创建层次结构时,您不必再考虑这一点。
游戏对象的网格
正如您创建了一个类GameObjectList来表示游戏对象列表一样,您也可以创建一个类GameObjectGrid来表示游戏对象的网格。然而,这两个类别在概念上有很大的不同。首先,GameObjectList类没有说明它所包含的游戏对象的位置。另一方面,GameObjectGrid将所有游戏对象关联到一个网格,这反过来意味着它们都在网格上有一个位置。但是每个游戏对象也有一个position成员变量。
位置看起来是一式两份存储的,但事实上游戏对象在世界中的位置不一定总是与它们在网格中的位置相同。由网格指示的位置可以被认为是游戏对象的锚位置 (它们所属的位置)。然而,游戏对象的实际位置可以不同。通过将锚点位置与实际的游戏对象位置结合使用,您可以获得很好的运动效果,其中游戏对象在网格上平滑移动,同时仍然属于特定的网格位置。这种效果被大量使用的一个游戏的例子是俄罗斯方块:玩家可以将方块移动到网格上的不同位置,但是因为网格锚点位置与实际的游戏对象位置不同,所以方块移动很平稳。如果您运行 JewelJam3 示例,如果您使用鼠标或手指(在带有触摸屏的设备上)向左或向右拖动其中一行,您也可以看到这种效果的演示。
因为可以用常规数组表示二维结构,GameObjectGrid类是GameObjectList的子类。您需要做一些额外的事情来使GameObjectGrid类按照您想要的方式运行。首先,您需要能够计算锚点位置,这意味着您需要知道网格中单个元素(单元格)的大小。因此,还要添加两个成员变量来存储网格中单个单元格的大小。此外,您可以在成员变量中存储所需的行数和列数。当创建一个GameObjectGrid实例时,这些值必须作为参数传递给构造函数。这是完整的构造函数方法:
function GameObjectGrid(rows, columns, layer) {
GameObjectList.call(this, layer);
this.cellWidth = 0;
this.cellHeight = 0;
this._rows = rows;
this._columns = columns;
}
GameObjectGrid.prototype = Object.create(GameObjectList.prototype);
此外,向该类添加两个属性,以便可以读取行数和列数:
Object.defineProperty(GameObjectGrid.prototype, "rows", {
get: function () {
return this._rows;
}
});
Object.defineProperty(GameObjectGrid.prototype, "columns", {
get: function () {
return this._columns;
}
});
因为你继承了GameObjectList,所以你已经有了一个添加游戏对象的方法。然而,在这门课上,你需要做一些稍微不同的事情。因为游戏对象被放置在一个(平面)网格中,绘制顺序不再重要。当你添加一个游戏对象时,你不想对数组进行排序。此外,您希望将游戏对象的位置设置为它在网格中的期望位置。要做到这一点,您可以重写来自GameObjectList的add方法,如下所示:
GameObjectGrid.prototype.add = function (gameobject) {
var row = Math.floor(this._gameObjects.length / this._columns);
var col = this._gameObjects.length % this._columns;
this._gameObjects.push(gameobject);
gameobject.parent = this;
gameobject.position = new Vector2(col * this.cellWidth, row * this.cellHeight);
};
正如您在这个示例中看到的,您从数组中的目标位置计算行和列的索引。然后,将游戏对象放入数组,设置其父对象,并使用计算出的行和列索引来确定其位置。为了方便起见,添加另一个方法,允许您在网格中的特定行和列索引处添加游戏对象:
GameObjectGrid.prototype.addAt = function (gameobject, col, row) {
this._gameObjects[row * this._columns + col] = gameobject;
gameobject.parent = this;
gameobject.position = new Vector2(col * this.cellWidth, row * this.cellHeight);
};
宝石网格
对于 Jewel Jam 游戏,您希望在网格上执行一些基本操作,包括将一行中的元素向左或向右移动。比如,当玩家向左拖动网格中的第三行时,除了最左边的元素之外,所有的元素都要向左移动,最左边的元素就变成了最右边的元素。因为这种操作并不是每个使用网格的游戏都需要的,所以让我们创建一个继承自GameObjectGrid的类JewelGrid,然后将您需要的操作添加到该类中。下面是JewelGrid类的构造方法:
function JewelGrid(rows, columns, layer) {
GameObjectGrid.call(this, rows, columns, layer);
this.dragging = false;
this._dragRow = 0;
this._draggingLastX = 0;
this._touchIndex = 0;
}
JewelGrid.prototype = Object.create(GameObjectGrid.prototype);
它包括一些成员变量,您需要这些变量来存储与用户正在进行的拖动相关的信息。稍后当您学习如何获得这种拖动行为时,您会看到更多的细节。
通过将第一个元素存储在临时对象中,将其他对象向左移动一列,最后将存储在临时对象中的元素放在最后一列,可以将一行中的列向左移动。您可以添加一个方法shiftRowLeft来做这件事。因为该方法只应用于一行,所以必须将行索引作为参数传递。完整的方法如下:
JewelGrid.prototype.shiftRowLeft = function (selectedRow) {
var firstObj = this.at(0, selectedRow);
var positionOffset = firstObj.position.x;
for (var x = 0; x < this._columns - 1; x++) {
this._gameObjects[selectedRow * this._columns + x]
= this._gameObjects[selectedRow * this._columns + x + 1];
}
this._gameObjects[selectedRow * this._columns + (this._columns - 1)] = firstObj;
firstObj.position = new Vector2(this._columns * this.cellWidth + positionOffset,
selectedRow * this.cellHeight);
};
除了将最左边的元素移动到最右边的列并移动所有其他元素之外,您还可以更改从最左边的对象更改为最右边的对象的对象的位置。通过在执行移位操作之前将第一个元素的任何现有位置偏移存储在局部变量中,然后将该偏移添加到新位置,可以考虑第一个元素的任何现有位置偏移。这种位置变化的结果是一个很好的运动效果,稍后你会看到。方法shiftRowRight与此方法类似;参见 JewelJam3 的示例代码。
您还想添加一个方法,为您提供任意游戏对象在网格中的锚点位置。这个方法以后会有用的。作为参数,这个方法需要一个游戏对象,它返回一个包含锚点位置的Vector2对象。下面是完整的方法:
GameObjectGrid.prototype.getAnchorPosition = function (gameobject) {
var l = this._gameObjects.length;
for (var i = 0; i < l; i++)
if (this._gameObjects[i] === gameobject) {
var row = Math.floor(i / this.columns);
var col = i % this.columns;
return new Vector2(col * this.cellWidth, row * this.cellHeight);
}
return Vector2.zero;
};
这个方法使用一个for指令来寻找作为参数传递的游戏对象。一旦找到这个对象,就可以根据网格中的行和列索引以及单元格大小来计算它的锚位置。如果没有找到对象,则返回零向量(Vector2.Zero)。因为这种方法对几乎所有的网格都有用,所以将它添加到GameObjectGrid类中。
在网格上平稳移动
为了让对象在网格上平滑移动,可以使用属于GameObject类的velocity和position成员变量。您使用从GameObjectGrid实例中检索的锚点位置来计算属于该位置的游戏对象的速度。其效果是,当游戏对象不完全在锚点位置时,它会自动开始向该位置移动。
为此,您引入了另一个名为Jewel的类,它表示网格中的一个游戏对象(在本例中,是一种宝石)。这个游戏对象是SpriteGameObject的子类。在该类的构造函数中,随机选择三个宝石精灵中的一个,如下所示:
function Jewel(layer) {
var randomval = Math.floor(Math.random() * 3) + 1;
var spr = sprites["single_jewel" + randomval];
SpriteGameObject.call(this, spr, layer);
}
在这个游戏对象中唯一需要改变的是update方法,因为绘制精灵已经在基类中被正确处理了。update方法需要做什么?首先你需要调用update方法的原始版本,这样物体的位置总是根据它的速度更新:
SpriteGameObject.prototype.update.call(this, delta);
然后你需要找出这个游戏对象的锚点位置。您可以通过从父实例(通常应该是一个JewelGrid实例)调用getAnchorPosition来实现这一点:
var anchor = this.parent.getAnchorPosition(this);
最后,修改游戏对象的速度,使其向锚点位置移动:
this.velocity = anchor.subtractFrom(this.position).multiplyWith(15);
如您所见,您通过获取目标位置(即锚点位置)和当前位置之间的差值来计算速度。要获得更快的运动效果,请将该值乘以 15。当游戏对象的位置被更新时,该速度被添加到位置向量,结果游戏对象向目标位置移动。对于完整的Jewel类,参见宝石 3 示例。
拖动网格中的行
在这一章中你要做的最后一件事是添加拖拽行为到网格中,这样玩家就可以左右移动行。您可以分两步定义拖动行为。首先,根据玩家用鼠标或手指拖动的位置,确定行中元素的新位置。然后,根据玩家拖动行的距离,向左或向右移动元素。
您可以为鼠标和触摸输入定义这种拖动行为。因此,您将handleInput方法分成两部分,每一部分都在一个特定的输入类型方法中定义:
JewelGrid.prototype.handleInput = function (delta) {
if (Touch.isTouchDevice)
this.handleInputTouch(delta);
else
this.handleInputMouse(delta);
};
因为拖动行为是特定于宝石果酱游戏的,所以您在JewelGrid中处理输入。让我们先来看看鼠标拖动行为。您需要检测到玩家已经开始在网格中拖动。只有当鼠标左键被按下并且玩家没有拖动时,这才是可能的。如果是这种情况,您需要确定玩家是在网格内拖动还是在网格外拖动。在后一种情况下,您不需要做任何事情。在前一种情况下,您需要存储一些与播放器拖动位置相关的信息。以下是完整的代码:
if (Mouse.left.down && !this.dragging) {
var rect = new Rectangle(this.worldPosition.x, this.worldPosition.y, this.columns * this.cellHeight, this.rows * this.cellWidth);
if (Mouse.containsMouseDown(rect)) {
this.dragging = true;
this._dragRow = Math.floor((Mouse.position.y - this.worldPosition.y) / this.cellHeight);
this._draggingLastX = Mouse.position.x - this.worldPosition.x;
}
}
您使用dragging变量来跟踪玩家是否在拖动。如果玩家已经开始拖动,您计算玩家正在拖动哪一行,并将其存储在_dragRow成员变量中。最后,您计算鼠标当前拖动的网格中的本地 x 位置。当你根据玩家拖动的多少重新放置所有的宝石时,这将是有用的。
接下来检查第二种情况,玩家没有拖动。如果是这种情况,您将dragging变量设置为false:
if (!Mouse.left.down) {
this.dragging = false;
}
现在,您已经执行了准备步骤来确定玩家是否在拖动,如果玩家确实在拖动,您需要采取行动。第一步是根据玩家向左或向右拖动了多少来重新定位宝石。计算鼠标的新位置:
var newpos = Mouse.position.x - this.worldPosition.x;
然后通过将新位置和最后拖动位置之间的差值加到每个宝石的x-坐标上,重新定位该行中的每个宝石:
for (var i = 0; i < this.columns; i++) {
var currObj = this.at(i, this._dragRow);
currObj.position.x += (newpos - this._draggingLastX);
}
现在您检查是否需要向左或向右移动一行。首先检查最左边的对象是否被向左拖动了超过单元格宽度的一半。拖动时检查newpos是否小于最后一个x-位置,可以判断玩家是否向左拖动。如果是这种情况,则将该行向左移动:
var firstObj = this.at(0, this._dragRow);
if (firstObj.position.x < -this.cellWidth / 2 && newpos - this._draggingLastX < 0)
this.shiftRowLeft(this._dragRow);
类似地,检查最右边的对象是否向右拖动了超过一半的单元格宽度。如果是这种情况,您将该行向右移动:
var lastObj = this.at(this.columns - 1, this._dragRow);
if (lastObj.position.x > (this.columns - 1) * this.cellWidth + this.cellWidth / 2 &&
newpos - this._draggingLastX > 0)
this.shiftRowRight(this._dragRow);
最后,更新最后一个拖动位置,使其包含新计算的位置。这样,您可以在下一次调用update时执行相同的拖动和移动操作:
this._draggingLastX = newpos;
处理触摸拖动的方式非常类似于鼠标拖动。你需要做一些额外的行政工作。首先,您需要跟踪当前在网格中拖动的手指。当玩家开始拖动时,将属于该手指的触摸指数存储在成员变量中:
this._touchIndex = Touch.getIndexInRect(rect);
然后,您可以使用存储的触摸指数来检索手指的位置:
pos = Touch.getPosition(this._touchIndex);
然后你做同样的事情来处理鼠标拖动。有关处理触摸和鼠标拖动的完整代码,请参见 JewelJam3 示例中的JewelGrid类。
创建游戏对象
既然您已经定义了所有这些不同类型的游戏对象,您可以将它们创建为游戏世界的一部分。您已经简要了解了如何做到这一点。首先你添加一个背景图片:
this.add(new SpriteGameObject(sprites.background, ID.layer_background));
然后创建一个网格来包含宝石:
var rows = 10, columns = 5;
var grid = new JewelGrid(rows, columns, ID.layer_objects);
grid.position = new Vector2(85, 150);
grid.cellWidth = 85;
grid.cellHeight = 85;
this.add(grid);
最后,使用一个for循环用Jewel对象填充网格:
for (var i = 0; i < rows * columns; i++) {
grid.add(new Jewel());
}
由于您创建的游戏对象的层次结构,游戏循环调用会自动传播到层次结构中的所有游戏对象。由于这种层次结构,您可以做的另一件好事是修改父对象的位置,之后子对象会相应地自动移动。例如,尝试将grid对象放在另一个位置:
grid.position = new Vector2(300, 100);
这一变化的结果如图 14-2 中的所示。正如您所看到的,所有的子对象都被移动了,行拖动机制和以前一样工作。在这里,你可以看到在这样一个层次中放置游戏对象的真正力量:你可以很好地控制对象在屏幕上的放置方式。你甚至可以完全疯狂,给网格一个速度,让它在屏幕上移动!
图 14-2 。将比赛场地移动到另一个位置
你学到了什么
在本章中,您学习了:
- 如何在场景图中组织游戏对象
- 如何创建游戏对象的结构化集合,比如网格和列表
- 本地和全球位置之间的差异
- 如何利用场景图绘制和更新游戏对象,使场景图成为游戏不可分割的一部分
- 如何定义鼠标和触摸输入的拖动行为