JavaScript 游戏构建指南(五)
二十一、存储和调用游戏数据
许多游戏由不同的关卡组成。尤其是在解谜、迷宫类的休闲游戏中,一个游戏可能有几百个关卡。到目前为止,你的游戏一直依靠随机性来保持游戏的趣味性。虽然随机性是实现可玩性的强大工具,但在很多情况下,游戏设计者希望对游戏的进展有更多的控制。这种控制一般通过设计级来实现。每个关卡都有自己的游戏世界,玩家必须在其中实现某种目标。
使用到目前为止您所看到的工具,您可以想象,对于游戏中的每个关卡,您都可以编写一个特定的类,在其中用游戏对象填充特定的关卡,并添加您想要的行为。这种方法有一些缺点。最大的缺点是你把游戏逻辑(游戏玩法、获胜条件等等)和游戏内容混在了一起。这意味着每次你想给游戏添加另一个关卡时,你都必须编写一个新的类,这导致了当一个游戏被加载到浏览器中时,需要检索大量的类。此外,如果一个游戏设计师想给你开发的游戏增加一个关卡,他需要深入了解你的代码是如何工作的。设计者在编写代码时犯的任何错误都会导致你的游戏出现错误或崩溃。
更好的方法是将关卡信息与实际的游戏代码分开存储。当游戏加载时,关卡信息被检索。理想情况下,信息需要以非程序员能够理解和使用的简单格式存储。这样,关卡可以由某人来设计,而不需要那个人知道游戏如何将数据转换成可玩的游戏关卡。JavaScript 是一种非常适合表示结构化信息的语言。这很容易,主要是因为可以在 JavaScript 中定义对象文字。看看下面的例子:
var ticTacToeSaveGame = {
scorePlayerX : 2,
scorePlayerO : 1,
currentStatus : ["x x",
"oox",
"o "],
turn : "x"
}
这个变量描述了两个玩家的井字游戏的游戏状态。第一个玩家赢了两局,第二个玩家只赢了一局。游戏的当前状态存储在一个字符串数组中。最后,放置 x 标记的玩家轮到他们了。通过编辑这个变量,你可以很容易地改变分数或游戏的当前状态。您甚至可以通过向currentStatus变量添加列和行来决定让棋盘变得更大。所有这些都可以在不知道井字游戏实际工作原理的情况下完成。游戏设计者可以编辑以这种方式构建的数据,游戏可以从这样的变量中读取数据,并让玩家继续他们的游戏。此外,由于 JavaScript 对象文字,这种格式相对容易理解,即使对于很少或没有编程经验的人也是如此。最后,随着游戏变大,开发游戏的团队也会变大。通过将关卡设计和之前介绍的东西(比如精灵表)分开,你可以让擅长游戏设计和图形设计的非程序员帮助你更有效地创建令人敬畏的游戏。
你可以用类似的方式处理企鹅配对中的不同等级。在这一章中,你将看到如何在你的游戏中建立这样一个关卡加载方案。你要看的另一件事是在不同的会话中存储和调用游戏的状态。诸如 Painter 和 Jewel Jam 之类的游戏不会保留玩家以前玩该游戏时的任何信息,这在那些游戏中并不重要。然而,在企鹅配对的情况下,这很重要,因为你不希望玩家每次启动游戏都要从头开始。如果玩家完成了一个关卡,浏览器应该会在玩家下一次启动游戏时记住,这样玩家就可以从他们停止的地方继续。
层次的结构
我们先来看看企鹅配对游戏里一个关卡里能有什么样的东西。首先,有某种背景图像。让我们假设这个背景在您加载关卡时是固定的,所以没有必要在文本文件中存储任何关于它的信息。
在这一层有许多不同的动物,如企鹅、海豹和鲨鱼。还有冰山,企鹅可以移动的背景块,还有其他一些东西。您希望将所有这些信息存储在一个结构化变量中。一种可能是存储每个对象的位置和类型,但这将使变量变得复杂。另一种可能是将关卡分成小块,也叫瓦片 。每个方块都有特定的类型(可能是企鹅、运动场瓷砖、透明瓷砖、企鹅等等)。一个方块可以用一个字符来表示,你可以将关卡的结构存储在一个变量中,就像你对井字游戏所做的一样,例如:
var myLevel = {
tiles : ["#.......#",
"#...r...#",
"#.......#",
"#. .#",
"#. .#",
"#. .#",
"#.......#",
"#...r...#",
"#.......#"]
};
在这个级别定义中,定义了许多不同的块。冰山(墙)瓷砖由#符号定义,企鹅由r字符定义,背景瓷砖由.字符定义,空瓷砖由空格字符定义。现在您可以编写一个方法,使用这些信息来创建图块并将它们存储在某个地方(可能在一个GameObjectGrid实例中)。这意味着你需要不同类型的瓷砖:企鹅可以站在上面的普通瓷砖,透明的背景瓷砖,以及企鹅可以碰撞的墙壁(冰山)瓷砖。
瓷砖类
首先,让我们写一个基本的Tile类。 这个类是SpriteGameObject类的子类。现在,你不考虑更复杂的项目,如企鹅,海豹和鲨鱼。您只需查看背景(透明)瓷砖、普通瓷砖和墙壁(冰山)瓷砖。让我们引入一个变量来表示这些不同种类的瓷砖:
var TileType = {
normal: 0,
background: 1,
wall: 2
};
Tile类是SpriteGameObject的基本扩展。在构造函数中,您声明一个成员变量type来存储一个实例所代表的图块类型:
function Tile(sprite, layer) {
SpriteGameObject.call(this, sprite, layer);
this.type = TileType.normal;
}
为了适应透明拼贴,只有当拼贴不是背景拼贴时,才覆盖draw方法来绘制 sprite:
Tile.prototype.draw = function () {
if (this.type === TileType.background)
return;
SpriteGameObject.prototype.draw.call(this);
};
当您加载关卡时,您为每个角色创建一个图块,并将其存储在一个网格结构中,如GameObjectGrid。
其他级别信息
除了瓷砖,您还需要在levelData变量中存储一些其他的东西:
- 级别是否被锁定
- 关卡是否已经被玩家解决
- 关卡的提示
- 要制作的对数
- 提示箭头的位置和方向
因此,您可以在变量中定义一个完整级别,如下所示:
var myLevel = {
locked : true,
solved : false,
hint : "Don't let the penguins fall in the water!",
nrPairs : 1,
hint_arrow_x : 3,
hint_arrow_y : 1,
hint_arrow_direction : 2,
tiles : ["#.......#",
"#...r...#",
"#.......#",
"#. .#",
"#. .#",
"#. .#",
"#.......#",
"#...r...#",
"#.......#"]
};
您需要为每个级别定义这样一个变量。将所有这些级别存储在一个数组中是有意义的。因为级别信息需要随处可用,所以您将级别信息存储在一个全局变量中。一般来说,如果可能的话,应该避免使用全局变量,原因如下:
- 全局名称空间将被大量的全局变量弄得混乱不堪,这可能会降低脚本的执行速度。
- 如果两个不同的 JavaScript 文件碰巧使用相同的全局变量,就会发生冲突。
- 您源代码变得不容易阅读,因为很难了解哪些数据在哪里使用。
在这种情况下,您使用一个全局变量,因为级别数据需要随处可访问。然而,你可以做一些事情来确保你使用的是一个全局变量。你要做的一件事是用大写字母写变量名,以强调它不同于其他普通变量。您还可以显式地将变量附加到全局域(在 JavaScript 中称为window)。下面是变量初始化:
window.LEVELS = [];
现在您唯一需要做的就是用级别信息填充这个变量。对于每一级,使用push方法 : 向数组添加一个条目
window.LEVELS.push({
locked : false,
solved : false,
hint : "Click on a penguin and select the arrow to let
it move towards the other penguin.",
nrPairs : 1,
hint_arrow_x : 4,
hint_arrow_y : 3,
hint_arrow_direction : 3,
tiles : ["#########",
"#.......#",
"#...r...#",
"#.......#",
"#.......#",
"#.......#",
"#...r...#",
"#.......#",
"#########"]
});
这个例子是第一个层次。如你所见,第一关的锁定状态被设置为false,所以玩家被允许玩这一关。所有其他级别的锁定状态被设置为true。当玩家完成一个级别,你更新这个状态。等级在levels.js文件中定义。这是一个 JavaScript 文件,但它位于PenguinPairs4示例的assets文件夹中,因为这些数据与其说是代码,不如说是素材。此外,这样设计者可以在assets文件夹中工作,改变精灵和等级数据,而不必查看游戏运行代码。
播放状态
在前一章中,你看到了如何创建多个游戏状态,比如标题屏幕、关卡选择菜单和选项菜单。在本节中,您添加了一个播放状态。游戏状态基本上由一系列关卡组成,每个关卡都有自己的游戏世界。对于标题屏幕和选项菜单这样的状态,您可以创建一个GameObjectList的子类。然而,在这里这没有太大的意义,因为游戏状态需要在游戏世界之间切换。因此,你不会从GameObjectList继承。但是你确实想定义游戏循环方法,比如update和draw。您可以通过引入一个新的类IGameLoopObject,稍微改变软件设计来适应这一点。这个类唯一做的事情是提供游戏循环的任何对象部分应该拥有的方法的定义。下面是完整的类:
function IGameLoopObject() {
}
IGameLoopObject.prototype.handleInput = function (delta) {};
IGameLoopObject.prototype.update = function (delta) {};
IGameLoopObject.prototype.draw = function () {};
IGameLoopObject.prototype.reset = function () {};
这个类被称为IGameLoopObject,而不是例如GameLoopObject,因为在软件设计中,这样的类通常被称为接口。接口非常有用,因为它们为程序员提供了当一个类实现那个接口(换句话说,从接口类继承)时可以预期的方法(或属性)种类的信息。相当多的编程语言都有一个特殊的编程结构,可以让你创建这些接口。JavaScript 不是这种情况,但是您仍然可以使用这个概念来获得相同的结果。
接口构成了所有拥有游戏循环方法的对象的基础。您可以更改示例中的现有类来遵循这种方法。例如,GameObject类现在也继承自IGameLoopObject:
function GameObject(layer, id) {
IGameLoopObject.call(this);
// initialize the game object...
}
GameObject.prototype = Object.create(IGameLoopObject.prototype);
看看PenguinPairs4例子中的类,看看IGameLoopObject类是如何集成到程序设计中的。如您所见,该示例添加了一个PlayingState类,它也继承自IGameLoopObject:
function PlayingState() {
IGameLoopObject.call(this);
// initialize the playing state...
}
PlayingState.prototype = Object.create(IGameLoopObject.prototype);
在播放状态下创建关卡
在本节中,您将从存储在全局windows.LEVELS变量中的数据创建游戏中的关卡。为了表示一个级别,您创建了一个继承自GameObjectList的Level类。对于每个需要创建的级别,您创建一个Level实例,并根据全局LEVELS变量中的数据填充它。在PlayingState构造函数中,您初始化一个数组,在其中存储所有这些实例。您还可以存储玩家当前正在玩的关卡:
this.currentLevelIndex = -1;
this.levels = [];
然后您调用一个方法loadLevels,它负责从级别数据创建Level实例:
this.loadLevels();
在loadLevels方法中,您放置了一个for循环,在其中您创建了Level实例。在Level构造函数中,你将关卡数据转换成实际的游戏对象,这些对象是每个关卡的一部分:
PlayingState.prototype.loadLevels = function () {
for (var currLevel = 0; currLevel < window.LEVELS.length; currLevel++)
this.levels.push(new Level(currLevel));
};
创建Level实例
在Level构造函数中,你必须创建属于那个级别的不同游戏对象。作为第一步,您检索级别数据并将其存储在一个名为levelData : 的变量中
function Level(levelIndex) {
GameObjectList.call(this);
var levelData = window.LEVELS[levelIndex];
this.levelIndex = levelIndex;
// to do: fill this level with game objects according to the level data
}
你还需要跟踪企鹅和海豹等动物。您在一个单独的数组中这样做,以便以后可以快速查找它们。这同样适用于某些级别的鲨鱼:
this.animals = [];
this.sharks = [];
现在你可以开始创建游戏对象来填充游戏世界。首先向游戏世界添加一个背景图像:
this.add(new SpriteGameObject(sprites.background_level, ID.layer_background));
然后你读水平的宽度和高度。您可以通过检索tiles数组的长度以及该数组中单个字符串的长度来确定它们:
var width = levelData.tiles[0].length;
var height = levelData.tiles.length;
然后创建一个GameObjectList实例来包含游戏区域,就像在宝石果酱游戏中一样。你把这个游戏场放在屏幕的正中央:
var playingField = new GameObjectList(ID.layer_objects);
playingField.position = new Vector2((Game.size.x - width * 73) / 2, 100);
this.add(playingField);
现在您需要从levelData变量中检索图块信息。您重用了GameObjectGrid类来表示瓷砖网格。要读取所有的瓷砖,你使用一个嵌套的for指令。看看下面几行代码:
var tileField = new GameObjectGrid(height, width, ID.layer_objects, ID.tiles);
tileField.cellHeight = 72;
tileField.cellWidth = 73;
for (var row = 0; row < height; row++) {
for (var col = 0; col < width; col++) {
// handle the tile 'levelData.tiles[row][col]' here
}
}
首先创建一个GameObjectGrid实例,并将网格中一个单元格的宽度和高度设置为给定的大小。然后开始读取包含瓷砖信息的字符。
现在,根据您从表达式levelData.tiles[row][col]中获得的角色,您需要创建不同种类的游戏对象并将它们添加到网格中。你可以使用一个if指令来实现:
if (levelData.tiles[row][col] === '.')
// create an empty tile
else if (levelData.tiles[row][col] === ' ')
// create a background tile
else if (levelData.tiles[row][col] === 'r')
// create a penguin tile
//... and so on
原则上,这种代码是可行的。但是每次都要写一个复杂的条件。很容易犯错误,比如变量名拼写错误或者忘记加括号。还有另一种选择,可以让你以稍微干净的方式写这个。JavaScript 提供了一种特殊的处理案例的指令:switch。
注意当以基于文本的格式定义等级时,你必须决定每个字符代表哪种对象。这些决定影响了关卡设计者和开发者的工作,前者必须在关卡数据文件中输入字符,后者必须编写代码来解释关卡数据。这显示了文档是多么重要,即使是在活动开发期间。有一个“备忘单”是很好的,这样当你写这段代码时,你就不必记住所有关卡设计的想法。如果你和设计师一起工作,这样的备忘单也很有用,可以确保你们在同一页上。
使用switch处理备选方案
switch指令允许您指定替代方案,以及每个替代方案应执行的指令。例如,前面有多个替代项的if指令可以重写为一个switch指令如下:
switch(levelData.tiles[row][col]) {
case '.': // create an empty tile
break;
case ' ': // create a background tile
break;
case 'r': // create a penguin tile
break;
}
switch指令有一些便利的属性,这使得它在处理不同的选择时非常有用。请看下面的代码示例:
if (x === 1)
one();
else if (x === 2) {
two();
alsoTwo();
} else if (x === 3 || x === 4)
threeOrFour();
else
more();
您可以用如下的switch指令重写它:
switch(x) {
case 1: one();
break;
case 2: two();
alsoTwo();
break;
case 3:
case 4: threeOrFour();
break;
default: more();
break;
}
当执行switch指令时,计算括号之间的表达式。然后执行字case和特定值之后的指令。如果没有对应于该值的案例,则执行default关键字之后的指令。不同情况背后的值需要是常量值(数字、字符、双引号中的字符串或声明为常量的变量)。
break指令
如果不小心,switch指令不仅会执行相关案例后面的指令,还会执行其他案例后面的指令。您可以通过在每个案例后放置特殊的break指令来防止这种情况。break指令的基本意思是,“停止执行你当前所在的switch、while或for指令。”如果在前面的例子中没有break指令,那么在x === 2的情况下,将调用方法two和alsoTwo,以及方法threeOrFour和more。
在某些情况下,这种行为是有用的,这样,在某种意义上,不同的案例可以相互交流。但是,当这样做时,您必须小心,因为这可能导致错误——例如,如果程序员忘记将break指令放在某个地方,这将导致非常奇怪的行为。当您使用switch指令时,请确保案例始终由break指令分隔。唯一的例外是当你在一组指令前写多个case标签时,就像你在例 3 和例 4 中所做的那样。switch指令的语法是指令语法图的一部分。图 21-1 显示了属于switch指令的那部分图表。
图 21-1 。switch指令的语法图
装载不同种类的瓷砖
您可以使用switch指令来加载所有不同的图块和游戏对象。对于levelData.tiles变量中的每个字符,您需要执行不同的任务。例如,当字符“.”时被读取,您需要创建一个正常的运动场瓷砖。下面的指令就是这样做的:
t = new Tile(sprites.field, ID.layer_objects);
t.sheetIndex = row + col % 2;
tileField.addAt(t, col, row);
break;
用于图块的精灵是由两个不同精灵组成的条带。通过使用公式row + col % 2切换工作表索引,您会得到一个交替的棋盘图案,正如您通过运行属于本章的示例程序所看到的。另一个例子是添加透明背景拼贴:
t = new Tile(sprites.wall, ID.layer_objects);
t.type = TileType.background;
tileField.addAt(t, col, row);
break;
尽管背景精灵是不可见的,你仍然可以加载一个属于这个图块的精灵。为什么会这样?因为Tile类继承自SpriteGameObject类,后者需要一个 sprite。当然,另一个选择是修改SpriteGameObject类,这样它就可以处理一个名为null的精灵。然而,在这种情况下,您遵循提供精灵的简单解决方案,即使玩家永远不会看到它。当你必须安置一只企鹅时,需要做两件事:
- 放置普通瓷砖。
- 放置一只企鹅。
因为企鹅需要在棋盘上走来走去,而你需要与它们互动,所以你创建了一个类Animal来表示一种动物,比如企鹅或海豹。在本节的后面,您将看到这个类的样子。为了跟踪游戏中的动物,您维护了一个数组作为Level类的成员变量,正如您之前看到的:
this.animals = [];
在switch指令中,你创建一个普通的瓷砖和一只企鹅,如下所示:
t = new Tile(sprites.field, ID.layer_objects);
t.sheetIndex = row + col % 2;
tileField.addAt(t, col, row);
var animalSprite = sprites.penguin;
if (levelData.tiles[row][col] === levelData.tiles[row][col].toUpperCase())
animalSprite = sprites.penguin_boxed;
var p = new Animal(levelData.tiles[row][col], animalSprite, ID.layer_objects_1);
p.position = t.position.copy();
p.initialPosition = t.position.copy();
playingField.add(p);
this.animals.push(p);
break;
你也在做一些其他的事情。例如,你希望普通动物和被装箱的动物(不能移动的动物)有所不同。您可以通过使用大写或小写字符来进行区分。JavaScript 知道一个方法toUpperCase将一个字符转换成它的大写变体。您在一个if指令的条件中使用该方法来确定应该使用的素材的名称。创建了Animal对象后,你将它的位置设置为你创建的图块的位置,这样它就被正确放置了。您还将名为initialPosition的变量设置为相同的值。你这样做是为了当玩家卡住并按下重试按钮时,你可以知道每个动物在关卡中的原始位置。
在Animal构造函数中,您将字符作为参数传递。这样做是为了在构造函数中决定应该选择哪个元素。你也可以检查角色,看看你是否在和一只被装箱的动物打交道。装箱状态存储在Animal类的布尔成员变量中:
this.boxed = (color === color.toUpperCase());
在下一条指令中,你使用一个叫做indexOf的方法,根据作为参数传递的字符,聪明地计算出你想要显示的动物。方法检查字符串中一个字符的第一个索引。例如:
"game".indexOf('a') // returns 1
"over".indexOf('x') // returns -1
"penguin pairs".indexOf('n') // returns 2
以下是如何使用该方法计算动物的床单指数:
this.sheetIndex = "brgyopmx".indexOf(color.toLowerCase());
您将字符转换为小写,这样指令对正常企鹅和盒装企鹅都有效。为了完成Animal类,您添加了一些方便的方法来检查您是否正在处理一个特殊情况,比如一只五彩企鹅、一个空盒子或一只海豹。有关完整的Animal类,请参见属于本章的示例程序PenguinPairs4。
最后,在企鹅配对游戏中还有鲨鱼。鲨鱼是相对简单的动物,它们不能被玩家控制(非常像在现实生活中!).因此,您没有使用Animal类,而是为它们使用了SpriteGameObject,它包含了您需要的一切。你遵循一个与企鹅相似的程序。您创建了一个图块和一条鲨鱼,并将鲨鱼存储在一个数组中,以便以后可以轻松找到它们:
t = new Tile(sprites.field);
t.sheetIndex = row + col % 2;
tileField.addAt(t, col, row);
var s = new SpriteGameObject(sprites.shark, ID.layer_objects_1);
s.position = t.position.copy();
playingField.add(s);
this.sharks.push(s);
break;
现在你已经在switch指令中处理了所有这些不同的情况,你可以加载每个级别了。看看示例中的Level类,了解完整的级别创建过程。图 21-2 显示了一个关卡加载后的截图。
图 21-2 。企鹅配对游戏中的一个关卡
维持玩家的进度
为了完成这一章,这一节将向你展示一个跟踪玩家在不同阶段的进度的好方法。你希望游戏记住玩家最后一次玩游戏时的位置。有几种方法可以做到这一点。一种方法是让玩家来做这项工作,默认情况下简单地向玩家开放所有关卡。这是一个解决方案,但它并没有真正激励玩家按顺序解决每个关卡。另一种方法是在服务器上使用一个可以被 JavaScript 应用访问的数据库。然后,您可以在服务器上存储玩家信息,并通过这种方式跟踪玩家。这是可行的,但也不理想,因为除了 JavaScript 应用之外,还需要一台服务器启动并运行。第三个选择是使用 HTML5 中引入的一个叫做 HTML5 网络存储的特性。
HTML5 web 存储提供了一种以两种不同方式存储信息的方法,使用两个不同的变量。如果你给变量code.sessionStorage,赋值,这个信息会一直保留到应用运行的标签关闭。如果给变量window.localStorage赋值,该值将在不同的会话中保留。后者是一个非常有用的选择。例如,您可以执行以下操作:
window.localStorage.playerName = "Bridget";
下一次开始游戏时,您可以读取变量的值,并通过记住她的名字来让玩家大吃一惊。然而,使用本地存储有一些附加条件。首先,很容易意外清除localStorage 变量,因为任何 JavaScript 程序都可以访问它。此外,用户可以从浏览器菜单中显式地这样做,因此您的程序不应该依赖于以这种方式存储的数据,而没有数据的备份或默认值。现代浏览器在私有模式下运行时,通常会禁用本地存储。如果你的游戏严重依赖本地存储,并且被禁用了,以某种方式通知玩家可能是个好主意。
另一个限制是,您只能在本地存储中将字符串存储为值。这意味着您想要存储的任何复杂数据都必须转换为字符串值。当您读取数据时,您需要解析字符串并将数据转换回来。因为windows.LEVELS变量包含所有级别数据,包括每个级别的锁定/已解决状态,所以您希望将该对象转换为字符串,并将其完整地存储在本地存储中。问题是,你如何把这样一个复杂的变量转换成一个字符串,然后再转换回来?
JavaScript 再次拯救了我们!这种语言的一个真正伟大的特性是 JavaScript 允许无缝地转换成和转换成对象文字的字符串,比如windows.LEVELS。这是使用JSON对象完成的。JavaScript Object Notation(JSON)是一个将结构化对象表示为字符串的开放标准,很像 XML。JavaScript 有几个有用的方法可以自动将对象文字转换成这样的字符串。例如,要将所有级别数据作为 JSON 字符串存储在本地存储中,您只需要以下代码行:
localStorage.penguinPairsLevels = JSON.stringify(window.LEVELS);
从 JSON 字符串到对象文字同样简单:
window.LEVELS = JSON.parse(localStorage.penguinPairsLevels);
在Level类中,您添加了两个方法loadLevelsStatus和writeLevelsStatus,它们从本地存储中读取级别信息,并将其写入本地存储。您在这些方法中添加了一些检查,以确保本地存储实际上是可用的(只有在较新的浏览器中才有)。下面是两种方法的定义:
PlayingState.prototype.loadLevelsStatus = function () {
if (localStorage && localStorage.penguinPairsLevels) {
window.LEVELS = JSON.parse(localStorage.penguinPairsLevels);
}
};
PlayingState.prototype.writeLevelsStatus = function () {
if (!localStorage)
return;
localStorage.penguinPairsLevels = JSON.stringify(window.LEVELS);
};
您在PlayingState的构造函数中调用loadLevelsStatus方法,这样当游戏开始时,就可以使用本地存储中更新的关卡信息。每当玩家完成一个关卡,你就调用writeLevelsStatus方法。这样,下次玩家开始游戏时,游戏会记住玩家已经完成的关卡。
在练习中,尝试通过存储更多信息来扩展PenguinPairs4示例。例如,目前游戏不记得玩家对音乐音量的偏好或是否应该显示提示。你能创建一个在不同阶段保留这些信息的游戏版本吗?
保存游戏的诅咒
大多数游戏都有一个让玩家保存进度的机制。这通常用于三种方式之一:以后继续玩,当玩家在游戏中失败时返回到以前的保存点,或者利用替代策略或故事情节。这些可能性听起来都很合理,但也带来了问题;当你设计游戏时,你必须仔细考虑何时(以及如何)允许玩家保存和加载游戏状态。
例如,在较老的第一人称射击游戏中,所有的敌人都在游戏世界中的固定位置。玩家之间的一个常见策略是保存游戏,跑进一个房间看看敌人在哪里(这导致了即时死亡),加载保存的游戏,并根据敌人的位置信息,仔细清理房间。这让游戏玩起来轻松了很多,但这绝对不是创作者的本意。这可以通过使保存游戏或加载已保存的游戏变得困难来部分补救。其他游戏只允许在特定的保存点保存。有些人甚至将到达保存点作为挑战的一部分。但这可能会导致挫败感,因为如果有一个非常困难的地方,玩家可能不得不一遍又一遍地重放游戏的各个部分。最有趣的游戏是那些你永远不需要返回来节省点数的游戏,因为你从来没有真正失败过,但这是非常难以设计的。
所以仔细想想你的储蓄机制。什么时候允许保存?你允许多少种不同的扑救?游戏中的保存是如何工作的?玩家如何加载保存的游戏?玩家保存或加载游戏需要花费一些东西吗?所有这些决定都会影响游戏性和玩家满意度。
你学到了什么
在本章中,您学习了:
- 如何创建基于磁贴的游戏世界
- 如何使用
switch指令处理不同的情况 - 如何使用本地存储检索和存储液位状态数据
二十二、企鹅配对
在这一章中,你将为企鹅配对游戏编写主要的游戏程序。您将学习如何在棋盘上移动企鹅,以及当企鹅与另一个游戏对象(如鲨鱼或另一只企鹅)发生碰撞时该怎么办。
选择企鹅
在你移动企鹅之前,你需要能够选择一只企鹅。当您单击企鹅或海豹等动物时,会出现四个箭头,允许您控制动物的移动方向。为了显示这些箭头并处理输入,您可以添加一个名为AnimalSelector的类。 因为动物选择器包含四个箭头,所以它继承自GameObjectList类。您还想添加一个漂亮的视觉效果,以便当玩家将鼠标移动到其中一个箭头上时,它会变得更暗。您可以通过添加一个包含两个精灵的类Arrow来实现这个效果:一个用于常规箭头,一个用于当您悬停在箭头上时的箭头图像。Arrow类还应该能够显示四个可能方向中的任何一个方向的箭头。
箭类
你可以有一个单一的箭头图像,并根据所需的方向旋转它,但是这个例子通过使用一个包含指向所有四个方向的箭头的图像使事情变得简单(见图 22-1 )。因此,当你加载精灵时,工作表索引指示显示哪个箭头。对于悬停状态,加载另一个包含相同箭头图像的精灵,顺序相同,但颜色更深。
图 22-1 。包含四个箭头的精灵,每个箭头指向不同的方向
注意我选择使用两个精灵来实现悬停状态并没有什么特别的原因。您也可以将八个箭头图像放在一个 sprite 工作表中,并使用它来代替这里使用的两个 sprite 工作表。
因为一个Arrow实例或多或少像一个按钮一样起作用,所以它继承自Button类。在构造函数中,首先通过调用超类的构造函数来创建实例的超类部分。这将加载第一个箭头图像。然后定义第二个 sprite,arrowHover,当鼠标悬停在它上面时,它包含箭头图像。默认情况下,这个精灵是不可见的,所以您将其可见性状态设置为false。您还将该 sprite 的父级设置为Arrow实例,以便它被绘制在正确的位置。下面是完整的构造函数:
function Arrow(sheetIndex, layer, id) {
Button.call(this, sprites.arrow, layer, id);
this.sheetIndex = sheetIndex;
this.arrowHover = new SpriteGameObject(sprites.arrow_hover);
this.arrowHover.sheetIndex = sheetIndex;
this.arrowHover.visible = false;
this.arrowHover.parent = this;
}
作为参数传递给Arrow构造函数的工作表索引被传递给实际的子画面,以便选择正确的箭头方向。
在handleInput方法中,通过计算鼠标位置是否在箭头精灵的边界框内来检查悬停精灵是否应该可见。只有当游戏不在触摸设备上运行时,您才需要这样做,因此在计算可见性状态时,您需要考虑这个条件:
Arrow.prototype.handleInput = function (delta) {
Button.prototype.handleInput.call(this, delta);
this.arrowHover.visible = !Touch.isTouchDevice &&
this.boundingBox.contains(Mouse.position);
};
最后,覆盖draw方法,这样就可以添加一条线来绘制悬停精灵:
Arrow.prototype.draw = function () {
Button.prototype.draw.call(this);
this.arrowHover.draw();
};
动物选择器
当玩家点击动物时,动物选择器使用Arrow类显示四个箭头(见图 22-2 )。这四个箭头作为成员变量存储在AnimalSelector类中。因为选择器控制一种特定的动物,所以你也必须跟踪它控制哪一种动物。因此,您还添加了一个成员变量selectedAnimal,它包含了对目标动物的引用。在构造函数方法中,您创建了四个Arrow对象,并按如下方式适当地放置它们:
function AnimalSelector(layer, id) {
GameObjectList.call(this, layer, id);
this._arrowright = new Arrow(0);
this._arrowright.position = new Vector2(this._arrowright.width, 0);
this.add(this._arrowright);
this._arrowup = new Arrow(1);
this._arrowup.position = new Vector2(0, -this._arrowright.height);
this.add(this._arrowup);
this._arrowleft = new Arrow(2);
this._arrowleft.position = new Vector2(-this._arrowright.width, 0);
this.add(this._arrowleft);
this._arrowdown = new Arrow(3);
this._arrowdown.position = new Vector2(0, this._arrowright.height);
this.add(this._arrowdown);
this.selectedAnimal = null;
this.visible = false;
}
图 22-2 。当玩家点击一只企鹅时,会显示四个箭头,这样玩家可以选择企鹅应该移动的方向
在handleInput方法中,首先检查选择器是否可见。如果不是,则不需要处理输入:
if (!this.visible)
return;
然后检查是否按下了其中一个箭头。如果是这样,你就可以计算出想要的动物速度:
var animalVelocity = Vector2.zero;
if (this._arrowdown.pressed)
animalVelocity.y = 1;
else if (this._arrowup.pressed)
animalVelocity.y = -1;
else if (this._arrowleft.pressed)
animalVelocity.x = -1;
else if (this._arrowright.pressed)
animalVelocity.x = 1;
animalVelocity.multiplyWith(300);
如果玩家点击了鼠标左键或触摸了屏幕(在哪里并不重要),您可以再次将动物选择器的状态设置为不可见:
if (Mouse.left.pressed || Touch.containsTouchPress(Game.screenRect))
this.visible = false;
最后,如果你计算的速度不为零,并且有一只目标企鹅,你更新它的速度:
if (this.selectedAnimal !== null && animalVelocity.isZero)
this.selectedAnimal.velocity = animalVelocity;
在Animal类的handleInput方法中,你必须处理点击动物。但是,在某些情况下,您不必处理这个问题:
- 这只动物在冰的一个洞里。
- 动物是看不见的。
- 这只动物已经在移动了。
在所有这些情况下,你不做任何事情,你从方法返回:
if (!this.visible || this.boxed || !this.velocity.isZero)
return;
如果玩家没有触摸或点击动物,你也可以从方法返回。因此,您添加下面的if指令来验证这一点:
if (Touch.isTouchDevice) {
if (!Touch.containsTouchPress(this.boundingBox))
return;
} else {
if (!Mouse.left.pressed || !this.boundingBox.contains(Mouse.position))
return;
}
你需要考虑的最后一种情况是,如果玩家触摸或点击了动物,但动物选择器已经可见。在这种情况下,您不需要做任何事情,您可以从方法返回:
var animalSelector = this.root.find(ID.animalSelector);
if (animalSelector.visible)
return;
现在您已经处理了所有情况,您可以使选择器可见,设置其位置,并将动物指定为选择器的目标动物。以下说明涵盖了这些内容:
animalSelector.position = this.position;
animalSelector.visible = true;
animalSelector.selectedAnimal = this;
如您所见,正确处理用户输入有时会很复杂。你需要考虑玩家可能采取的所有行动,并恰当地处理输入。如果你做得不好,你就冒着在游戏中引入错误的风险,这些错误会导致游戏崩溃(这很糟糕)或者玩家作弊(这甚至更糟,尤其是在在线多人游戏中)。
你刚刚写的指令允许玩家随意选择动物,并告诉它们向特定的方向移动。现在你需要处理动物、游戏场地和其他游戏对象之间的交互。
以相反的顺序处理输入
在屏幕上绘制对象的顺序很重要。例如,如果在绘制背景图像之前先绘制企鹅,玩家将永远看不到企鹅。然而,对象处理输入的顺序不应该和它们被绘制的顺序一样!在企鹅配对游戏中,这将导致意想不到的行为。
假设两只企鹅在操场上挨着,你点击其中一只。然后出现四个箭头。因为两只企鹅紧挨着,所以其中一个箭头画在了另一只企鹅的上方(见图 22-3 )。如果你点击那个箭头,会发生什么?被选中的企鹅向左移动,还是你选择另一只企鹅?
图 22-3 。点击左箭头会发生什么?
这个问题的结果取决于每个游戏对象处理输入的顺序。如果企鹅在企鹅选择器之前处理输入,那么企鹅选择器将移动到另一只企鹅。如果先调用选择器的handleInput方法,那么选中的企鹅会向左移动。一般来说,当你开发程序时,你想控制程序的行为。这意味着你必须选择处理输入的顺序,并确保总是这样。在这种情况下,期望的行为是选定的企鹅向左移动。一般来说,你会希望绘制在顶部的对象首先处理输入。换句话说,您需要在列表中的对象上调用handleInput方法,调用顺序与绘制顺序相反。这可以很容易地用下面的for指令来完成,你把它放在GameObjectList. handleInput方法体中:
GameObjectList.prototype.handleInput = function (delta) {
for (var i = this._gameObjects.length - 1; i >=0; i--)
this._gameObjects[i].handleInput(delta);
};
因此,绘制在顶部的对象现在首先处理输入。这再次说明了在指定播放器界面时,获得这样的细节是多么重要。对玩家来说不直观的界面会很快导致挫败感——玩家可能会因为界面问题而不再想玩你的游戏。
更新动物
动物和其他游戏对象之间的交互是在Animal类的update方法中完成的。在Animal类中这样做的主要原因是每个动物处理自己的交互。如果你在游戏中添加了多个动物(就像你在这里所做的),你不需要改变任何处理交互的代码。首先,如果动物不可见或者速度为零,你不必更新它。因此,update方法中的第一条指令是
SpriteGameObject.prototype.update.call(this, delta);
if (!this.visible || this.velocity.isZero)
return;
可以看到,首先调用基类的update方法。因为SpriteGameObject类没有覆盖update方法,所以它调用了GameObject类中定义的update方法,该方法通过添加速度乘以游戏时间来更新对象的位置。现在你要检查动物是否与另一个游戏对象发生碰撞。因为您在update方法开始时所做的检查,所以您只对可见和移动的动物进行检查。
如果动物在移动,你需要知道它正在移动到哪个格子。然后,您可以检查它是哪种类型的图块,以及是否有其他游戏对象位于该图块。为此,您向Animal类添加一个名为currentBlock的属性。你怎么能计算出动物正在进入的瓷砖呢?当一只企鹅向左移动时,可以计算出该瓷砖的 x 指数如下:
var tileField = this.root.find(ID.tiles);
var xIndex = Math.floor(this.position.x / tileField.cellWidth);
因为Math.floor产生的最接近的整数比它作为参数得到的值小,所以你会在精灵的左边位置结束。然而,这只在动物向左移动时找到正确的 x 指数。当动物移动到右侧时,您需要计算企鹅精灵的最右侧像素移动到的区块。为了解决这个问题,如果 x 速度为正,则在计算的 x 指数上加 1。你做一些类似的事情来计算 y 指数。以下是currentBlock属性的完整头部和主体:
Object.defineProperty(Animal.prototype, "currentBlock",
{
get: function () {
var tileField = this.root.find(ID.tiles);
var p = new Vector2(Math.floor(this.position.x /
tileField.cellWidth),Math.floor(this.position.y /
tileField.cellHeight));
if (this.velocity.x > 0)
p.x++;
if (this.velocity.y > 0)
p.y++;
return p;
}
});
下一步是找出动物正在移动到哪种瓷砖。为此,您必须向 tile 字段添加一些方法。要正确地做到这一点,您需要添加一个继承自GameObjectGrid的名为TileField的类,并向该类添加一些方法。一种方法检查给定的 x 和 y 索引是否在图块区域之外。这种方法叫做isOutsideField,很简单:
TileField.prototype.isOutsideField = function (pos) {
return (pos.x < 0 || pos.x >=this.columns || pos.y < 0 || pos.y >=
this.rows);
};
该方法在另一个方法getTileType中使用,该方法检索给定图块位置的图块类型。在这种方法中,首先要检查的是该点是否在平铺区域之外。如果是这种情况,则返回背景(透明)平铺类型:
if (this.isOutsideField(pos))
return TileType.background;
在所有其他情况下,您可以通过从图块字段获取Tile对象并返回其类型: 来检索图块类型
return this.at(pos.x, pos.y).type;
现在你可以回到Animal.update的方法,检查动物是否从瓷砖地上掉了下来。如果是这样,你将动物的可见性设置为false并将其速度设置为零,以确保动物在不可见时不会无限移动:
var target = this.currentBlock;
var tileField = this.root.find(ID.tiles);
if (tileField.getTileType(target) === TileType.background) {
this.visible = false;
this.velocity = Vector2.zero;
}
另一种可能是动物撞到了墙砖。如果是这样,它必须停止移动:
else if (tileField.getTileType(target) === TileType.wall)
this.stopMoving();
停止移动并不像听起来那么容易。您可以简单地将动物的速度设置为零,但这样动物的一部分就会在另一个图块中。你需要把动物放在刚刚离开的格子上。方法stopMoving完成 正是如此。在这个方法中,你首先要计算旧瓷砖的位置。您可以从动物当前移动到的区块的 x 和 y 索引开始。这些是作为参数传递的。例如,如果动物的速度是向量 (300,0) (向右移动),则需要从 x 索引中减去 1,以获得动物正在移出的图块的 x 索引。如果动物的速度是 (0,-300) (向上移动),那么您需要将* 1 加到 y 索引上,以获得动物正在移出的图块的 y 索引。你可以通过标准化速度矢量并从 x 和 y 索引中减去它来实现。这是可行的,因为规范化一个向量会产生一个长度为 1(单位长度)的向量。因为动物只能在 x 或 y 方向移动,而不能沿对角线方向移动,所以在第一个示例中,您最终得到一个矢量 (1,0) ,在第二个示例中得到一个矢量 (0,-1) 。因此,您将动物的位置设置为它刚刚移出的图块的位置,如下所示:*
var tileField = this.root.find(ID.tiles);
this.velocity.normalize();
var currBlock = this.currentBlock;
this.position = new Vector2(Math.floor(currBlock.x - this.velocity.x) *
tileField.cellWidth, Math.floor(currBlock.y - this.velocity.y) *
tileField.cellHeight);
请注意,您将位置乘以平铺字段中单元格的宽度和高度。这是因为图块索引与屏幕上的实际像素位置不同。图块索引仅指示图块在网格中的位置,而动物位置需要以像素表示为屏幕上的位置。
最后,将动物的速度设置为零,使其保持在新位置:
this.velocity = Vector2.zero;
遇见其他游戏对象
您仍然需要检查动物是否与另一个游戏对象发生碰撞,例如另一只企鹅或鲨鱼。有一些特殊类型的动物:
- 五彩企鹅
- 空盒子
- 密封
您可以向Animal类添加一些方法来确定您是否正在处理这些特殊情况。例如,如果纸张索引等于 7 并且没有装箱,您正在处理一个印章:
Animal.prototype.isSeal = function () {
return this.sheetIndex === 7 && !this.boxed;
};
如果工作表索引是 7 并且是有框的,则您处理的是一个空框:
Animal.prototype.isEmptyBox = function () {
return this.sheetIndex === 7 && this.boxed;
};
最后,如果纸张索引是 6 并且没有装箱,那么您将处理一只多色企鹅:
Animal.prototype.isMulticolor = function () {
return this.sheetIndex === 6 && !this.boxed;
};
首先,你要检查动物要进入的区域是否有鲨鱼。为此,您检索关卡并使用来自Level类的findSharkAtPosition方法来找出是否有鲨鱼:
var s = this.root.findSharkAtPosition(target);
if (s !== null && s.visible) {
// handle the shark interaction
}
findSharkAtPosition方法很简单;看看属于本章的示例代码中的方法。如果企鹅遇到鲨鱼,企鹅会被吃掉,鲨鱼会带着满满的肚子离开赛场。在游戏中,这意味着企鹅永远停止移动,鲨鱼和企鹅都变得看不见了。下面几行代码实现了这一点:
s.visible = false;
this.visible = false;
this.stopMoving();
接下来要检查的是是否有另一只企鹅或海豹。为此,您可以使用来自Level类的findAnimalAtPosition方法。按如下方式取回动物:
var a = this.root.findAnimalAtPosition(target);
如果方法返回null或者动物不可见,你不需要做任何事情,你可以从方法返回:
if (a === null || !a.visible)
return;
你解决的第一个案例是企鹅和海豹相撞。在这种情况下,企鹅什么都不用做——它只是停止移动:
if (a.isSeal())
this.stopMoving();
下一种情况是如果动物撞上一个空盒子。如果是这种情况,通过将盒子的纸张索引设置为动物的纸张索引来移动盒子内的动物,并使动物不可见:
else if (a.isEmptyBox()) {
this.visible = false;
a.sheetIndex = this.sheetIndex;
}
如果动物a的表索引与企鹅的表索引相同,或者其中一只企鹅是多色的,则您拥有一对有效的企鹅,并使两只企鹅都不可见:
else if (a.sheetIndex === this.sheetIndex || this.isMulticolor() || a.isMulticolor()) {
a.visible = false;
this.visible = false;
}
你还必须在屏幕的左上角显示一个额外的对子,但是你将在下一节中处理这个问题。最后,在所有其他情况下,企鹅停止移动:
else
this.stopMoving();
保持线对的数量
为了保持对子的数量并在屏幕上很好地绘制出来,您向游戏中添加了另一个名为PairList的类。PairList类继承自SpriteGameObject类。它由一个框架组成,在框架的顶部绘制了许多小精灵,指示所需的对子数。因为您想要指示配对的颜色,所以您将这些配对作为整数值存储在一个数组中。这个数组是PairList类的成员变量:
this._pairs = [];
您将整数值放在这个数组中,因为您可以定义每一对的颜色以及总共需要多少对。在成员变量pairSprite中,存储代表一对的 sprite,并将对列表设置为该 sprite 的父级:
this._pairSprite = new SpriteGameObject(sprites.penguin_pairs);
this._pairSprite.parent = this;
那个精灵的图像是一个精灵带,彩色的一对以和企鹅一样的方式排列(见图 22-4 )。如果仍然需要制作一对,则条带(纸张索引 7)中最右边的图像是您显示的图像。因此,如果_pairs数组包含值{0, 0, 2, 7, 7},这意味着玩家已经做了两对蓝色企鹅和一对绿色企鹅,玩家需要再做两对才能完成关卡。
图 22-4 。包含所有可能的图像对的子画面
您将参数nrPairs传递给PairList类的构造函数,这样您就知道数组应该有多大。然后填充数组,以便将每个元素设置为空槽(工作表索引 7):
for (var i = 0; i < nrPairs; i++)
this._pairs.push(7);
您还向该类添加了一个方法addPair,该方法在数组中查找第一次出现的值 7,并用作为参数传递的索引来替换它:
PairList.prototype.addPair = function (index) {
var i = 0;
while (i < this._pairs.length && this._pairs[i] !== 7)
i++;
if (i < this._pairs.length)
this._pairs[i] = index;
};
这个例子使用了一个while指令来增加i变量,直到你找到一个空的点。
现在你添加一个有用的属性来检查玩家是否完成了关卡。如果配对列表不再包含任何值 7(意味着所有空位都已被配对替换),则该级别完成:
Object.defineProperty(PairList.prototype, "completed",
{
get: function () {
for (var i = 0, l = this._pairs.length; i < l; i++)
if (this._pairs[i] === 7)
return false;
return true;
}
});
最后,你需要用draw方法在屏幕上画出对子。这里您使用一个for指令来遍历对列表中的所有索引。对于每个索引,在适当的位置绘制正确的精灵。请注意,您使用相同的精灵,并简单地用不同的工作表索引绘制多次:
PairList.prototype.draw = function () {
SpriteGameObject.prototype.draw.call(this);
if (!this.visible)
return;
for (var i = 0, l = this._pairs.length; i < l; i++) {
this._pairSprite.position = new Vector2(110 + i * this.height, 8);
this._pairSprite.sheetIndex = this._pairs[i];
this._pairSprite.draw();
}
};
对 base draw方法的调用确保首先绘制背景帧。
现在您已经有了PairList类,您可以在Level类中创建它的一个实例,将它添加到游戏世界中,并将其放在屏幕左上角附近:
var pairList = new PairList(levelData.nrPairs, ID.layer_overlays, ID.pairList);
pairList.position = new Vector2(20, 15);
this.add(pairList);
在Animal类中,如果一只动物遇到另一只相同颜色的企鹅,或者两只动物中有一只是多色企鹅,你就在列表中添加一对:
else if (a.sheetIndex === this.sheetIndex || this.isMulticolor() ||
a.isMulticolor()) {
a.visible = false;
this.visible = false;
this.root.find(ID.pairList).addPair(this.sheetIndex);
}
完整示例见本章的PenguinPairs5程序。在下一章中,您将为企鹅配对游戏添加最后的润色,您将看到一种更好的方法来将游戏通用代码(如SpriteGameObject类或GameStateManager类)与游戏特定代码(如PairList类)分开。
你学到了什么
在本章中,您学习了:
- 如何编程一个游戏对象选择器
- 如何模拟不同种类的游戏对象之间的交互
- 如何保持和抽牌手所做的对子数*
二十三、完成企鹅配对游戏
在这一章中,你将完成企鹅配对游戏。第一步是稍微重组一下你的代码,这样其他程序就可以更容易地使用其中的一部分。然后你通过扩展用户界面和添加音效来完成游戏。
将代码分成不同的模块
尤其是在开发更复杂的应用时,将相关的类组合在一起是有意义的。例如,如果你正在开发一个复杂的游戏,将会有与模拟物理相关的类,做人工智能如路径规划的类,提供网络可玩性的类,用户界面类,等等。尽管本书中使用的例子并不复杂,但是您仍然可以看到只对特定游戏有用的类和跨不同游戏使用的类之间的明显区别。例如,这三个游戏都有一些基本游戏对象的概念,宝石果酱游戏和企鹅配对游戏都使用网格作为其游戏场的基础。此外,在几层上绘制的游戏对象的层次结构的概念在不同的游戏中使用。总的来说,这本书坚持在不同的游戏中使用相似的类。事实上,您复制了宝石果酱游戏中的类,并在企鹅配对游戏中使用它们。
正如本书前面几次讨论的那样,将代码复制到不同的项目中是一件坏事。复制代码意味着 bug 也可以被复制;如果您做了任何更改或改进,您将不得不在复制代码的所有地方都这样做。如何避免不同游戏之间复制代码?实现这一点的最佳方式是将游戏专用代码与游戏通用代码分开。通过将通用类放在一个单独的文件夹中,您可以更容易地在其他游戏项目中重用这些代码。通过在不同的游戏项目中选择智能文件夹结构,您可以轻松确保不必为每个项目复制通用代码。在本节中,您将建立这种结构,并看到一种在 JavaScript 中区分通用类和特定于游戏的类的简洁方法,即使用一种叫做名称空间的概念。
作为名称空间的变量
名称空间通常在编程语言中使用,为类的归属提供一些结构。许多编程语言都在语言规范中包含了名称空间支持。JavaScript 不属于这些语言中的一种,但是使用该语言中的现有功能来建立类似的东西是非常容易的。
您已经在示例代码中看到,变量是对象组。这些对象可以是对象文字、字符串、数字,甚至是函数。因为类是由 JavaScript 中的函数定义的,所以您可以将类组合在一个变量中,使该变量充当名称空间。例如,假设您想要创建一个 JavaScript 游戏引擎,其中包含您在本书中构建的所有通用类和对象。我们姑且称这个游戏引擎为powerupjs。您可以开始如下定义您的类:
var powerupjs = {
GameObject : function(layer, id) {
...
},
GameObjectList : function(layer, id) {
...
}
};
现在,每当你想使用类GameObject,你输入powerupjs.GameObject。在 JavaScript 代码中,这会让用户清楚地知道GameObject属于powerupjs名称空间。这很棒,但是这意味着你必须把所有的类放在一个 JavaScript 文件中,这并没有真正提高你的程序的可读性。让我们研究一下如何以更聪明的方式做到这一点。
名称空间的设计模式
为了使在 JavaScript 中使用名称空间更容易,您使用了一个设计模式。第二十章在讨论单例(只允许一个实例的类)时简要地谈到了设计模式。这种单例设计模式使用如下:
function MySingletonClass_Singleton () {
...
}
// add methods and properties here
MySingletonClass_Singleton.prototype.myMethod() = function () {
...
};
...
var MySingletonClass = new MySingletonClass_Singleton();
// now we can use the variable as a single instance
MySingletonClass.myMethod();
将类放入名称空间的设计模式使用 JavaScript 机制,让您可以同时定义和调用函数。也许您还记得以前在定义请求下一次游戏循环迭代的函数时使用过这个方法:
var requestAnimationFrame = (function () {
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
};
})();
这里的变量requestAnimationFrame包含一个函数的结果,这个函数被定义并立即调用。以非常相似的方式,您可以将类的定义放在这样的函数中。看看下面的例子:
var powerupjs = (function (module) {
function Vector2(x, y) {
this.x = typeof x !== 'undefined' ? x : 0;
this.y = typeof y !== 'undefined' ? y : 0;
}
Object.defineProperty(Vector2, "zero",
{
get: function () {
return new powerupjs.Vector2();
}
});
Vector2.prototype.equals = function (obj) {
return this.x === obj.x && this.y === obj.y;
};
// add more methods and properties
...
module.Vector2 = Vector2;
return module;
})({});
这个例子创建了一个函数,它需要一个对象文本module作为参数。在函数中,您创建了Vector2类并定义了它的属性和方法。您将该类赋给module变量中的一个变量,然后返回该变量。通过向函数传递一个空的对象文字来执行该函数。函数的结果存储在变量powerupjs中,该变量现在包含一个名为Vector2的变量,该变量引用类Vector2。对于您定义的下一个类,您将传递变量powerupjs,而不是一个空的对象文字,这样powerupjs变量就被所有应该在powerupjs名称空间中的类填充了。通过使用智能 JavaScript 语法,您可以让这变得更好。考虑下面的类定义:
var powerupjs = (function (powerupjs) {
function Vector2(x, y) {
this.x = typeof x !== 'undefined' ? x : 0;
this.y = typeof y !== 'undefined' ? y : 0;
}
// etc.
powerupjs.Vector2 = Vector2;
return powerupjs;
})(powerupjs || {});
为了清楚起见,这里您将参数module重命名为powerupjs;不是传递一个空的对象文字,而是传递表达式powerupjs || {}。如果定义了变量,这个表达式的结果是powerupjs,否则是一个空的对象文字。您将这个名称空间设计模式添加到所有通用游戏类中。不管这些类被添加到名称空间的顺序如何,第一次添加时,你从一个空的对象文字开始,之后,powerupjs变量被定义并用其余的类补充。属于本章的示例代码包括一个名为powerupjs的文件夹;在这个文件夹中是所有通用的游戏类,它们都在powerupjs名称空间中。本书剩余的例子重用了powerupjs模块(或库)作为示例游戏的基础。
名称空间模式对于将相关的类组合在一起是一个非常有用的模式。无论何时构建复杂的应用,使用名称空间都是一个好主意。这样,您就可以清楚地向代码的用户展示这些类是如何相互关联的。您甚至可以更极端,使用完全相同的设计模式将名称空间分组到其他更大的名称空间中。
名称空间还提供了一点额外的安全性。例如,看看命名空间中的以下类定义:
var powerupjs = (function (powerupjs) {
function GameStateManager_Singleton() {
this._gameStates = [];
this._currentGameState = null;
}
// add methods/properties here
...
powerupjs.GameStateManager = new GameStateManager_Singleton();
return powerupjs;
})(powerupjs || {});
这个例子展示了GameStateManager类的定义,它是一个单例类。这是一个 singleton 的事实可以通过你将类的一个实例分配给powerupjs.GameStateManager而不是类定义本身来看出来。真正好的是,类定义现在被命名空间封装了——不再可能在其他 JavaScript 文件中访问GameStateManager_Singleton,从而确保只能使用该类的单个实例,这正是 singleton 设计模式的要点!
封装是封闭类定义的函数的结果。您可以用其他方式来控制哪些函数或类在哪里可用。例如,也许一个类的一些方法应该是内部的(或者是私有的)。您可以这样做:
var powerupjs = (function (powerupjs) {
...
function privateMethod(obj) {
// do something with the object
obj._currentGameState = ...;
}
GameStateManager_Singleton.prototype.publicMethod() {
privateMethod(this);
...
}
powerupjs.GameStateManager = new GameStateManager_Singleton();
return powerupjs;
})(powerupjs || {});
在这个例子中,方法privateMethod可以在GameStateManager实例上执行操作,并且可以从对象中的其他方法调用它,但是该方法不能从其他 JavaScript 类访问。
在模块和文件夹中组织类有助于为一组相关类的结构提供更好的感觉。图 23-1 显示了如何将powerupjs模块组织到不同的文件夹中。当你创建一个模块时,为模块的用户提供一个如图图 23-1 所示的图表是一个好主意。此外,因为一个模块可以由许多不同的类组成,所以您可能还想提供一些文档来描述该模块的总体思想。在powerupjs的例子中,重要的是让用户知道该模块严重依赖于一个运行中的游戏循环,该循环具有更新和绘制自己的游戏对象。此外,最好详细描述每个方法做什么,它期望什么样的参数,调用方法做什么,以及任何特殊情况。本书的最后一部分更详细地讨论了文档,并且您还学习了一些使文档更容易阅读和更容易被您的类的用户访问的方法。
图 23-1 。powerupjs模块 的模块和文件夹结构概述
整理用户界面
在本节中,您将完成用户界面。首先,您将看到如何在游戏中添加提示机制。然后,您将看到如何重置并进入下一个级别。你通过添加音效来完成游戏。
显示提示
既然你已经重组了你的代码,企鹅配对游戏中还会增加一些新的功能。首先,您希望能够在用户单击按钮时显示提示。该提示由一个可见一秒钟的橙色箭头组成。当您加载关卡时,您从levelData变量中读取提示位置和方向,并创建一个SpriteGameObject实例来加载箭头,选择正确的工作表索引,并在将其添加到游戏世界之前对其进行适当定位:
var hint = new powerupjs.SpriteGameObject(sprites.arrow_hint, ID.layer_objects_2);
hint.sheetIndex = levelData.hint_arrow_direction;
hint.position = new powerupjs.Vector2(levelData.hint_arrow_x * 73,
levelData.hint_arrow_y * 72);
playingField.add(hint);
为了临时显示箭头,您重用了宝石果酱游戏中的VisibilityTimer类。您创建了该类的一个实例,并将其添加到游戏世界中:
this.hintVisible = new VisibilityTimer(hint);
playingField.add(this.hintVisible);
您还可以添加一个按钮,玩家可以单击该按钮在屏幕上显示提示:
this.hintButton = new powerupjs.Button(sprites.button_hint, ID.layer_overlays);
this.hintButton.position = new powerupjs.Vector2(916, 20);
this.add(this.hintButton);
最后,您扩展了Level的handleInput方法来处理被按下的提示按钮:
if (this.hintButton.pressed)
this.hintVisible.startVisible();
提示按钮只有在可见的情况下才能被按下,在某些情况下它不应该是可见的:
- 玩家走完第一步后,提示按钮应该消失,重试按钮应该出现。
- 如果玩家选择关闭选项菜单中的提示,提示按钮应该永远不可见。
对于第一种情况,您需要跟踪玩家何时开始第一步行动。您向Level类添加了一个额外的成员变量firstMoveMade。当你给一个动物一个速度,这是在AnimalSelector类中完成的。一旦玩家点击了一个箭头,动物开始移动,你就将firstMoveMade变量设置为true:
this.selectedAnimal.velocity = animalVelocity;
this.root.firstMoveMade = true;
第二,你必须处理游戏选项菜单中的提示设置。您可以在Level类的update方法中这样做。您只需检查GameSettings变量中提示设置的值,并相应地更新提示和重试按钮的可见性状态:
this.hintButton.visible = GameSettings.hints && !this.firstMoveMade;
this.retryButton.visible = !this.hintButton.visible;
从这两行代码中可以看出,提示按钮只有在GameSettings.hints为true且玩家尚未迈出第一步的情况下才可见。重试按钮的可见性状态总是与提示按钮的可见性状态相反。因此,如果提示按钮可见,重试按钮不可见,反之亦然。
重置级别
在玩家移动了几个动物后,可能会出现关卡无法解决的情况。与其退出并重启游戏,不如给玩家一个重置关卡到初始状态的方法。
由于在游戏对象类中到处都正确地实现了reset方法,重置一个关卡到它的初始状态变得非常容易。你必须在所有的游戏对象上调用reset方法,然后在Level类本身中处理重置的事情。你唯一需要做的就是将firstMoveMade变量设置为false,这样玩家就可以再次查看提示:
Level.prototype.reset = function () {
powerupjs.GameObjectList.prototype.reset.call(this);
this.firstMoveMade = false;
};
注意企鹅配对游戏有许多扩展方式。例如,您能否编写代码来确定某个级别是否仍然可解?如果发生这种情况,你可以通过向用户显示消息来延长游戏。你可能对如何改进游戏有自己的想法。通过修改和添加示例,您可以随意尝试它们。
进入下一阶段
当玩家完成一关(万岁!),你想显示一个鼓励的覆盖图(截图见图 23-2 )。当玩家点击或轻敲屏幕时,将显示下一关。因为您创建了GameStateManager类,所以让我们通过添加另一个状态来利用它:LevelFinishedState。这个状态唯一能做的就是显示覆盖图,并对玩家的点击做出反应。因为覆盖图显示在关卡的顶部,所以您仍然需要对游戏状态做一些事情。因此,您将它存储在成员变量中。此外,您加载一个覆盖图,将其放置在屏幕中央,并将其添加到游戏世界中。下面是完整的构造函数方法:
function LevelFinishedState() {
powerupjs.GameObjectList.call(this);
this.playingState = powerupjs.GameStateManager.get(ID.game_state_playing);
this.overlay = new powerupjs.SpriteGameObject(sprites.level_finished, ID.layer_overlays);
this.overlay.position = this.overlay.screenCenter;
this.add(this.overlay);
}
图 23-2 。玩家完成一关后显示的覆盖图截图
您希望在播放状态上显示覆盖图,但是您不希望播放状态能够再处理输入(否则玩家仍然可以移动企鹅)。因此,您只需要在playingState对象上调用update和draw方法,而不是handleInput方法。
在LevelFinishedState的handleInput方法中,你检查玩家是否按下了鼠标键或者轻击了屏幕。如果是这样,你将当前状态设置为播放状态,并对其调用nextLevel方法:
LevelFinishedState.prototype.handleInput = function (delta) {
if (powerupjs.Touch.isTouchDevice) {
if (!powerupjs.Touch.containsTouchPress(this.overlay.boundingBox))
return;
}
else if (!powerupjs.Mouse.left.pressed)
return;
powerupjs.GameStateManager.switchTo(ID.game_state_playing);
this.playingState.nextLevel();
};
nextLevel方法是如何工作的?它必须处理两种可能性。第一种可能是玩家完成了最后一关。在这种情况下,您将返回到级别菜单状态。在所有其他情况下,你增加当前级别索引,并为玩家解锁下一个级别。最后,因为您更改了关卡状态,所以您将它写入本地存储,以便玩家下次开始游戏时,游戏会记住玩家已经解决了哪些关卡。完整的nextLevel方法如下所示:
PlayingState.prototype.nextLevel = function () {
if (this.currentLevelIndex >=window.LEVELS.length - 1)
powerupjs.GameStateManager.switchTo(ID.game_state_levelselect);
else {
this.goToLevel(this.currentLevelIndex + 1);
window.LEVELS[this.currentLevelIndex].locked = false;
}
this.writeLevelsStatus();
};
你唯一需要做的就是确保当玩家获胜时,游戏进入关卡完成状态。您可以通过使用Level类的completed属性,在播放状态的update方法中实现这一点:
if (this.currentLevel.completed) {
window.LEVELS[this.currentLevelIndex].solved = true;
powerupjs.GameStateManager.switchTo(ID.game_state_levelfinished);
}
如果玩家已经完成了关卡,您将该关卡的solved状态设置为true,并且您将当前游戏状态更改为关卡完成状态。
教程
正如你可能已经注意到的,企鹅配对游戏的前几关也是一个教程,解释这个游戏应该怎么玩。当你创造一个游戏时,玩家必须学会如何玩它。如果你不告诉玩家挑战和目标是什么,以及如何控制游戏,他们很可能会感到沮丧,停止游戏。
一些游戏提供了大量的帮助文件,用很长的文字解释故事和控制。玩家不再想阅读这样的文档或屏幕。他们想直接进入游戏。你必须在玩家玩的时候教育他们。
您可以创建几个特定的教程级别,玩家可以在其中练习控制,而不会严重影响游戏本身的进度。这种方法很受休闲游戏玩家的欢迎,作为对游戏的介绍。经验丰富的游戏玩家更喜欢立即投入行动。注意不要在教程中解释所有的内容。只解释基本的控制。解释游戏中需要的更高级的控制:例如,使用简单的弹出消息,或者在 HUD 中的可见位置。
教程在自然地融入游戏故事时效果最好。例如,游戏角色可能开始在他们安全的家乡跑来跑去,学习基本的移动控制。接下来角色和几个朋友一起练习格斗。之后,玩家进入树林,试图用弓射一些鸟。这将为游戏后期的战斗提供所有需要的练习。
你应该确保你的教程水平的工作,并确保玩家记住控制,即使他们把游戏放了几天。否则,他们可能再也不会回来玩游戏了。
添加声音效果
为了完成游戏,你应该在适当的地方添加声音和音乐。您可能还记得,选项菜单中的一个选项是更改背景音量。您可以使用下面的代码行来实现这一点:
sounds.music.volume = this.musicSlider.value;
在PenguinPairs课上,你开始播放音乐:
sounds.music.play();
同样,你在适当的时候播放音效,就像你在宝石果酱游戏中做的那样。例如,每当制作一对企鹅时,就播放一个声音效果(参见Animal类中的update方法):
sounds.pair.play();
如果你看看属于这一章的PenguinPairsFinal例子,你就可以看到完整的游戏是如何工作的,音效是在哪里回放的,当然你也可以自己玩游戏。
团队合作
第一代游戏是由程序员创造的。他们做了所有的工作。他们设计了游戏机制,他们创造了艺术(仅由几个像素组成),他们用汇编语言编写了游戏程序。所有的工作都集中在编程上。游戏机制经常被改编成可以有效编程的东西。
但是当更多的内存变得可用时,这种情况慢慢改变了。用有限数量的像素和颜色创建看起来很花哨的对象成为了一种艺术形式,这样的像素艺术家开始在开发游戏中发挥重要作用。在早期,没有绘图程序(没有足够强大的计算机能做到这一点)。像素化的角色被设计在绘图纸上,然后被转换成十六进制数字,输入到游戏代码中。
随着计算机能力和 CD 等存储媒体的增加,艺术变得越来越重要,艺术家也随之发展。3D 图形和动画变得普遍,导致新的专家可以使用新的工具和技术来支持这种工作。如今,艺术家构成了游戏制作团队的大多数。
在某种程度上,设计游戏成了一项独立的工作。游戏机制被调整到用户群的兴趣,并且越来越基于心理学和教育科学的原则。这需要单独的专业知识。故事扮演了一个重要的角色,导致了作家的加入。这些团队被扩展到包括制作人、音响工程师、作曲家和许多其他类型的人。今天,顶级游戏的团队可以由数百人组成。但是没有程序员,什么都做不了。
最后一点
在本书的这一部分,你已经创建了一个比之前的示例游戏更复杂的游戏,宝石果酱。你可能已经注意到职业的数量已经变得相当大了,你越来越依赖于游戏软件的某种设计。例如,你在一个树形结构中组织游戏对象,并使用一个类来处理游戏状态。在更基本的层面上,你假设游戏对象负责处理它们的输入,更新它们自己,并在屏幕上绘制它们自己。您可能不同意这些设计选择中的一些(或全部)。或许,在读完这本书之后,你已经对游戏软件应该如何设计有了自己的想法。这是一件好事。我在本书中提出的设计并不是做事的唯一方式。设计总是可以被评估和改进,甚至被抛弃,被完全不同的东西所取代。所以,不要犹豫,批判地看待我提出的设计,尝试其他设计。通过尝试不同的方法来解决问题,您可以更好地理解问题,并因此成为更好的软件开发人员。
你学到了什么
在本章中,您学习了:
- 如何将类分组到命名空间中
- 如何将一个级别重置为其初始状态,并处理进入下一个级别
二十四、主游戏结构
在这一章中,你将为滴答滴答游戏设计一个框架。因为你已经为之前的游戏做了很多工作,所以你可以依赖很多已经存在的类。事实上,你是在前一章的powerupjs命名空间/库中分组的类上构建游戏的。这意味着你已经有了处理游戏状态和设置的基本设计,游戏对象的层次结构,等等。稍后,您可以通过添加与动画游戏对象相关的类来扩展powerupjs库。你可以在图书馆看到这些课程;它们将在下一章讨论。
游戏结构概述
这个游戏的结构与企鹅配对游戏非常相似。有一个标题屏幕允许玩家进入等级选择菜单或帮助页面(见图 24-1 )。为了简单起见,您不需要实现选项页面,尽管添加它会很简单,因为您可以使用与 Penguin Pairs 中相同的方法。因为菜单结构非常相似,所以这里不讨论。您可以在TickTick1文件夹中看到包含属于本章的示例代码的代码。
图 24-1 。滴答滴答游戏的标题画面
PlayingState类保持当前等级,处理载入和保存等级状态(已解决/锁定),就像企鹅配对游戏一样。游戏状态创建了Level对象,每个对象包含一个基于瓷砖的游戏世界,同样非常类似于企鹅配对的构造方式。
级别的结构
我们先来看看嘀嗒嘀嗒里什么样的东西可以在一个等级里。首先,有一个背景图像。现在,您显示一个简单的背景精灵;不需要在 level 数据变量中存储任何相关信息。也有不同种类的块,玩家可以跳,随着水滴,敌人,玩家的开始位置,和玩家必须到达的结束位置。就像在企鹅配对游戏中一样,你将等级信息存储在一个全局变量中。这个变量存储在本地存储器中,以便当玩家完成一个级别时,浏览器在玩家下一次玩游戏时记住它。当然,这是假设玩家没有同时清空本地存储器。
使用瓷砖定义标高,其中每个瓷砖都有特定的类型(墙、背景等)。然后,在 level 数据变量中用一个字符来表示每种瓷砖类型。就像在企鹅配对游戏中一样,您可以在与游戏场地相对应的二维空间中以文本的形式显示关卡。在实际的图块旁边,还存储了一个提示和级别定义。这里你可以看到在LEVELS全局变量中存储第一级的指令:
window.LEVELS.push({
hint : "Pick up all the water drops and reach the exit in time.",
locked : false,
solved : false,
tiles : ["....................",
".................X..",
"..........##########",
"....................",
"WWW....WWWW.........",
"---....####.........",
"....................",
"WWW.................",
"###.........WWWWW...",
"............#####...",
"....WWW.............",
"....###.............",
"....................",
".1........W.W.W.W.W.",
"####################"]
});
该级别定义定义了许多不同的单幅图块和对象。例如,墙砖由#符号定义,水滴由W字符定义,玩家的开始位置由1字符定义。如果在特定的位置没有牌,你使用.字符。对于平台游戏,您需要不同类型的瓷砖:玩家可以站在上面或与之碰撞的墙壁瓷砖,以及指示该位置没有障碍物的背景/透明瓷砖。您还想定义一个平台图块。这种瓷砖的特性是玩家可以像墙砖一样站在上面,但是如果他们站在下面,他们可以从下面跳过去。这种磁贴在很多经典的平台游戏中都有使用,这里不收录就太可惜了!在级别数据变量中,平台瓦片由一个-字符表示。表 24-1 给出了滴答滴答游戏中不同牌的完整列表。
表 24-1 。Tick Tick 游戏中不同种类的牌概述
|
性格;角色;字母
|
瓷砖描述
|
| --- | --- |
| . | 背景瓷砖 |
| # | 瓷面砖 |
| ^ | 墙砖(热的) |
| * | 墙砖(冰) |
| - | 平台瓷砖 |
| + | 平台瓷砖(热) |
| @ | 平台瓷砖(冰) |
| X | 末端瓷砖 |
| W | 水滴 |
| 1 | 开始牌(初始玩家位置) |
| R | 火箭敌人(向左移动) |
| r | 火箭敌人(向右移动) |
| S | 闪亮的敌人 |
| T | 龟敌 |
| A | 火焰敌人(随机速度和方向变化) |
| B | 火焰敌人(玩家跟随) |
| C | 火焰敌人(巡逻) |
水滴
每一关的目标是收集所有的水滴。每个水滴都由一个WaterDrop类的实例来表示。这个类是一个SpriteGameObject子类,但是你想给它添加一点行为:水滴应该上下弹跳。你可以用update方法做到这一点。首先你计算一个反弹偏移量,你可以把它加到水滴的当前位置上。这个反弹偏移量存储在成员变量_bounce中,该变量在构造函数 中初始设置为 0
this._bounce = 0;
为了计算每个游戏循环迭代中的反弹偏移,您使用了一个正弦函数。根据水滴的 x 位置,你可以改变正弦信号的相位,这样就不会所有的水滴同时上下移动:
var t = powerupjs.Game.totalTime * 3 + this.position.x;
this._bounce = Math.sin(t) * 5;
将反弹值加到水滴的 y 位置:
this.position.y += this._bounce;
+=运算符将反弹值加到 y 位置(关于这些类型运算符的更多信息,参见第十章)。然而,简单地将反弹值加到 y 位置是不正确的,因为这是反弹偏移——换句话说,是相对于原始 y 位置的偏移。要获得原始的 y 位置,您需要从update方法的第一条指令中的 y 位置减去反弹偏移量:
this.position.y -= this._bounce;
这是可行的,因为此时,_bounce变量仍然包含前一次游戏循环迭代的反弹偏移量。所以,从 y 位置中减去就得到原始的 y 位置。
在下一章,你会添加更多的游戏对象,比如玩家和各种各样的敌人。但是我们先来看看在 Tick Tick 这样的平台游戏中如何定义瓦片。
瓷砖类
Tile类与企鹅配对中使用的非常相似,但也有一些不同。首先,在变量中定义不同的图块类型:
var TileType = {
background: 0,
normal: 1,
platform: 2
};
在Tile类中,然后声明一个成员变量type来存储一个实例所代表的图块类型。除了这些基本的牌类型,还有冰牌和热牌,它们是普通牌或平台牌的特殊版本。在级别数据变量中,一个冰砖由*字符表示(如果是平台砖,则由@字符表示),一个热砖由^字符表示(对于平台版本,则由+字符表示)。您向Tile类添加两个布尔成员变量及其相关属性来表示这些不同种类的图块。下面是完整的Tile构造函数:
function Tile(sprite, tileTp, layer, id) {
sprite = typeof sprite !== 'undefined' ? sprite : sprites.wall;
powerupjs.SpriteGameObject.call(this, sprite, layer, id);
this.hot = false;
this.ice = false;
this.type = typeof tileTp !== 'undefined' ? tileTp : TileType.background;
}
如您所见,您检查了是否定义了sprite和tileTp变量。如果不是,就给它们分配一个默认值。这允许您创建Tile实例,而不必一直传递参数。例如,以下指令创建了一个简单的背景(透明)单幅图块:
var myTile = new Tile();
现在,让我们看看Level类和Tile实例是如何创建的。
水平等级
本节展示了Level类是如何在 Tick Tick 中设计的。这与企鹅配对的方式非常相似。在Level类的构造函数中,你做了几件事:
- 创建背景精灵游戏对象。
- 添加退出按钮。
- 从关卡数据中创建基于图块的游戏世界。
前两个很简单。看看示例代码中的Level类,看看它们是如何工作的。创建基于磁贴的游戏世界是在一个叫做loadTiles的独立方法中完成的。根据等级指数的不同,创造出不同的游戏世界。第一步是创建一个具有所需高度和宽度的GameObjectGrid实例,取自levelData变量:
var tiles = new powerupjs.GameObjectGrid(levelData.tiles.length,
levelData.tiles[0].length, 1, ID.tiles);
this.add(tiles);
您设置网格中每个单元格的宽度和高度,以便游戏对象网格知道在屏幕上的何处绘制图块:
tiles.cellWidth = 72;
tiles.cellHeight = 55;
然后创建Tile对象,并将它们添加到GameObjectGrid对象中:
for (var y = 0, ly = tiles.rows; y < ly; ++y)
for (var x = 0, lx = tiles.columns; x < lx; ++x) {
var t = this.loadTile(levelData.tiles[y][x], x, y);
tiles.add(t, x, y);
}
嵌套的for循环检查从级别数据变量中读取的所有字符。你使用一个叫做loadTile的方法,它为你创建一个Tile对象,给定一个角色和格子中瓷砖的 x -和y-位置。
在loadTile方法中,您希望根据作为参数传递的字符加载不同的图块。对于每种类型的图块,您向Level类添加一个方法来创建这种特殊类型的图块。例如,LoadWaterTile加载一个顶部有水滴的背景拼贴:
Level.prototype.loadWaterTile = function (x, y) {
var tiles = this.find(ID.tiles);
var w = new WaterDrop(ID.layer_objects);
w.origin = w.center.copy();
w.position = new powerupjs.Vector2((x + 0.5) * tiles.cellWidth,
(y + 0.5) * tiles.cellHeight - 10);
this._waterdrops.add(w);
return new Tile();
};
这个特殊的例子创建了一个WaterDrop实例,并将其放置在图块的中心。您将每个水滴放置在比中心高 10 个像素的位置,这样水滴就不会在它下面的瓷砖上反弹。查看Level类,了解如何在每一层创建不同的图块和对象。图 24-2 显示了第一关中物体的截图(除了玩家角色,你会在后面的章节中处理)。
图 24-2 。属于滴答滴答第一关的游戏世界
你学到了什么
在本章中,您学习了:
- 如何设置滴答滴答游戏的总体结构
- 如何创建一个弹跳水滴
二十五、动画
在这一章中,你将看到如何给你的游戏添加动画。在你到目前为止开发的游戏中,游戏对象可以在屏幕上四处移动,但是在游戏中添加一些像跑步的角色稍微有点挑战性。在这一章中,你要编写一个程序,其中包含一个在屏幕上从左向右行走的角色。玩家按下左右箭头键来控制角色。在这个特殊的例子中,您没有添加触摸界面控件,但是稍后您将看到如何在触摸设备上控制移动的角色。
本书中没有明确涉及的另一件事是使用大多数现代设备内置的加速度计。您可以在 JavaScript 中通过处理诸如ondeviceorientation(或者 Firefox 中的onmozorientation)和ondevicemotion之类的事件来访问这些数据,这些事件提供了与设备当前加速度相关的数据。如果你觉得你能胜任,你可以尝试扩展本章中的例子,这样它就能以一种有意义的方式对这些事件做出反应。
什么是动画?
在你研究如何设计一个角色在屏幕上走来走去之前,你首先要考虑什么是动画。要理解这一点,你必须回到 20 世纪 30 年代,当时几家动画工作室(其中包括华特·迪士尼)制作了第一部黑白动画片。
一部卡通片实际上是一系列非常快速的静止图像,也称为帧。电视以非常高的速度绘制这些帧,大约每秒 25 次。当图像每次都发生变化时,你的大脑会将其解释为运动。人类大脑的这一特殊功能(也称为 phi 现象)非常有用,尤其是当你想要编写需要包含移动或动画对象的游戏时。
你已经在本书开发的游戏中使用过这个特性。每次调用draw方法,你就在屏幕上画一个新的“框架”。通过每次在不同的位置绘制精灵,你给人一种精灵在移动的感觉。然而,这并不是真正发生的事情:你只是在每秒钟内多次在不同的位置绘制精灵,这使得玩家认为精灵在移动。
以类似的方式,你可以画一个行走或奔跑的角色。除了移动精灵之外,您每次绘制的精灵都略有不同。通过绘制一系列精灵,每个精灵代表行走运动的一部分,您可以创建一个角色在屏幕上行走的幻觉。图 25-1 中给出了一个子画面序列的例子。
图 25-1 。代表行走动作的图像序列
游戏中的动画
在游戏中放动画有不同的原因。当你创作 3D 游戏时,动画通常是增强真实感所必需的,但对于 2D 游戏来说,情况并非总是如此。尽管如此,动画可以极大地丰富游戏。
动画将物体变得栩栩如生。但是动画制作并不复杂。角色闭上和睁开眼睛的简单动画会产生一种强烈的感觉,即角色是活的。动画角色也更容易让人产生共鸣。如果你看一个类似剪绳的游戏,主角(名为 Om Nom)简直就是坐在一个角落里。但是这个角色时不时会做一些有趣的动作,让你知道它在那里,并希望你给它带食物。这为玩家创造了继续玩游戏的非常有效的动机。
动画还有助于将玩家的注意力吸引到某个对象、任务或事件上。例如,在按钮上有一个小动画可以让玩家更清楚地知道他们必须按下按钮。而一颗跳动的水滴或一颗旋转的星星表明这个物体应该被收集或避开。动画也可以用来提供反馈。当你用鼠标点击一个按钮向下移动时,很明显这个按钮点击成功了。
然而,制作动画是一项繁重的工作。因此,事先仔细考虑哪里需要动画,哪里可以避免动画,以节省时间和金钱。
动画课
对于动画角色,通常为每种类型的运动设计一个精灵。图 25-1 中的例子是一个动画角色的精灵。在企鹅配对游戏的开发过程中,您设计了代表一条或一张图片的SpriteSheet类。您可以将该类与一个新类Animation结合使用。除了精灵表,动画需要额外的信息。例如,您想要指示每一帧应该在屏幕上显示多长时间。你也希望能够循环你的动画,这意味着一旦你到达最后一帧,第一帧应该再次显示。循环动画非常有用:例如,在行走角色的情况下,您只需绘制一个行走循环,然后循环动画以获得连续的行走运动。然而,并不是所有的动画都应该是循环的。例如,一个垂死的动画不应该循环播放(那会对角色非常残忍)。下面是Animation类的完整构造函数:
function Animation(sprite, looping, frameTime) {
this.sprite = sprite;
this.frameTime = typeof frameTime !== 'undefined' ? frameTime : 0.1;
this.looping = looping;
}
动画游戏对象
Animation类提供了表现动画的基础。本节介绍一种新的游戏对象:动画游戏对象*,它使用了这个类。AnimatedGameObject类是SpriteGameObject的子类。*
动画游戏对象可能包含许多不同的动画,因此您可以拥有一个可以执行不同(动画)动作的角色,如行走、奔跑、跳跃等。每个动作都由一个动画来表示。根据玩家的输入,您可以更改当前活动的动画。然后,根据经过的时间和当前活动动画的属性(例如它是否循环),确定应该在屏幕上显示的精灵的工作表索引。
要存储不同的动画,可以使用复合对象。对于每个动画,都要向该对象添加一个变量。您还需要一个变量来跟踪当前活动的动画。最后,还有一个额外的成员变量:_time。这个变量跟踪显示当前帧还需要多长时间,后面会解释。下面是AnimatedGameObject的完整构造器:
function AnimatedGameObject(layer, id) {
powerupjs.SpriteGameObject.call(this, null, layer, id);
this._animations = {};
this._current = null;
this._time = 0;
}
您还向该类添加了两个方法:loadAnimation和playAnimation。第一种方法创建一个Animation对象,并将其添加到_animations变量:
AnimatedGameObject.prototype.loadAnimation = function (animname, id, looping,
frametime) {
this._animations[id] = new powerupjs.Animation(animname, looping,
frametime);
};
如前所述,AnimatedGameObject类是SpriteGameObject的子类。这意味着当这个对象被绘制在屏幕上时,它试图绘制成员变量sprite指向的 sprite 工作表。但是,请注意,当您在AnimatedGameObject构造函数中调用基构造函数时,您将null作为参数传递:
function AnimatedGameObject(layer, id) {
powerupjs.SpriteGameObject.call(this, null
, layer, id);
...
}
您需要将属于当前运行动画的精灵分配给sprite成员变量,这样这个动画就可以在屏幕上绘制了。您可以很容易地做到这一点,因为每个Animation实例都包含一个对它应该激活的 sprite 的引用。将这个精灵分配给精灵成员变量是在playAnimation方法中完成的。
在该方法中,您首先检查想要播放的动画是否已经在播放。如果是,您不必做任何其他事情,您可以从方法返回:
if (this._current === this._animations[id])
return;
接下来,将当前工作表索引和_time变量设置为 0,并根据作为参数传递的 ID 分配当前活动的动画:
this._sheetIndex = 0;
this._time = 0;
this._current = this._animations[id];
最后,将sprite成员变量设置为应该绘制的 sprite:
this.sprite = this._current.sprite;
播放动画
您已经定义了一些用于加载和选择动画的有用的类和方法。你仍然需要能够播放一个动画。打到底是什么意思?这意味着你必须根据已经过去的时间来确定应该显示哪一帧,并在屏幕上绘制该帧。计算应该画哪一帧是在AnimatedGameObject类的update方法中完成的。因为动画中的每一帧都对应于某个工作表索引,所以您只需计算哪个工作表索引对应于当前帧。从SpriteGameObject继承的draw方法不需要修改。
在update方法中,你要计算应该画哪一帧。但是这意味着你需要知道从最后一帧画出来到现在过了多长时间。如果在每次调用update方法时增加帧索引,动画会播放得太快。因此,你在成员变量_time中保存了自最后一帧被绘制以来已经过去的时间。您在update方法的开头更新这个变量:
this._time += delta;
现在,您可以计算应该显示的帧的索引。为此,您使用一条while指令:
while (this._time > this._current.frameTime) {
this._time -= this._current.frameTime;
this._sheetIndex++;
if (this._sheetIndex >=this.sprite.nrSheetElements)
if (this._current.looping)
this._sheetIndex = 0;
else
this._sheetIndex = this.sprite.nrSheetElements - 1;
}
这里发生了什么?只要_time变量包含一个大于frameTime的值,while指令就会继续。在while指令中,你从_time变量中减去帧时间。假设每一帧显示的时间被设置为 1 秒。您输入update方法,并将经过的时间添加到_time成员变量中。假设这个变量现在包含值 1.02,这意味着您当前显示的帧已经显示了 1.02 秒。这意味着您应该显示下一帧。你可以通过增加当前显示的帧的索引来实现,这是while循环中的第二条指令。然后更新_time变量并减去帧时间(1 秒),因此_time的新值变为 0.02。您将这段代码放在一个while指令中,而不是一个if指令中,这样您就可以确保始终显示正确的帧,即使自上次更新以来经过的时间是帧时间的数倍。例如,如果_time的新值是 3.4,您需要向前移动三帧,并从_time变量中减去三次帧时间。while指令会处理这一点。
在增加当前帧索引后,你必须注意在你过了最后一帧后会发生什么。为此,您需要检查纸张索引是否大于或等于this.sprite.nrSheetElements。根据您是否希望动画循环,您可以将工作表索引重置为 0,或者将其设置为工作表中的最后一个元素。
玩家阶层
要使用上一节中介绍的AnimatedGameObject类,您需要从它继承。因为玩家将控制动画角色,所以让我们定义一个Player类,它是AnimatedGameObject的子类。在这个类中,您加载属于播放器的动画并处理来自播放器的输入。在Player构造函数中,加载这个角色所需的动画。在本例中,您希望角色行走或静止不动。所以,你通过调用loadAnimation方法两次来加载两个动画。您希望这两个动画都循环,因此您将循环参数设置为true :
this.loadAnimation(sprites.idle, "idle", true);
this.loadAnimation(sprites.run, "run", true, 0.05);
因为空闲动画只包含单个工作表元素,所以不需要指定帧时间。对于正在运行的动画,您指定每一帧应该显示五百分之一秒。当应用启动时,角色的空闲动画应该播放:
this.playAnimation("idle");
你也改变了玩家的出身。如果你想画在地板上移动的动画角色,使用角色精灵底部的一个点作为它的原点是很有用的。此外,正如您稍后看到的,这对于冲突检查非常有用。由于这些原因,你将播放器的原点定义为 sprite 元素底部的中心点:
this.origin = new powerupjs.Vector2(this.width / 2, this.height);
现在你需要在这个类中处理玩家的输入。当玩家按下左或右箭头键时,角色的速度应该改变。您可以在handleInput方法中使用if指令:来实现这一点
var walkingSpeed = 400;
if (powerupjs.Keyboard.down(powerupjs.Keys.left))
this.velocity.x = -walkingSpeed;
else if (powerupjs.Keyboard.down(powerupjs.Keys.right))
this.velocity.x = walkingSpeed;
else
this.velocity.x = 0;
注意我为walkingSpeed参数选择了 400 的值。摆弄这个值,看看它如何改变角色的行为。为这样的参数选择正确的值对游戏性有很大的影响。选择“恰到好处”的价值观很重要。用各种各样的玩家测试游戏可以帮助你决定这些值应该是什么,这样游戏才感觉自然。
使用图 25-1 所示的精灵可以让你制作一个向右走的角色的动画。要设置向左行走的角色的动画,可以使用另一个精灵。然而,有一个更简单的方法来实现这一点:当你绘制精灵时,镜像精灵。镜像精灵对于任何种类的精灵游戏对象都很有用,所以在SpriteGameObject类中,您添加了一个成员变量mirror,它指示精灵是否应该被镜像。在SpriteSheet的draw方法中,您将mirror变量的值传递给Canvas2D.drawImage,如下所示:
powerupjs.Canvas2D.drawImage(this._image, position, 0, 1, origin, imagePart,
mirror);
你必须扩展Canvas2D使其支持绘制镜像精灵。你可以通过使用下面的指令将精灵负向缩放来实现:
if (mirror) {
this._canvasContext.scale(scale * canvasScale.x * -1, scale *
canvasScale.y);
...
}
下一步,你必须转换和旋转画布上下文,同时考虑精灵的镜像状态。这里没有详细介绍,但是您可以查看一下Canvas2D类,看看它是如何实现的。为了结束输入处理,如果玩家正在移动:,根据速度设置mirror状态
if (this.velocity.x != 0)
this.mirror = this.velocity.x < 0;
在update方法中,您根据速度选择播放哪个动画。如果速度为零,则播放空闲动画;否则,播放跑步动画:
if (this.velocity.x === 0)
this.playAnimation("idle");
else
this.playAnimation("run");
最后,您调用基类中的update方法,以确保动画游戏对象版本的update方法也被调用。
为了测试您的动画类,您创建了一个单独的AnimationState实例,并将其添加到游戏状态管理器中:
ID.game_state_animation = powerupjs.GameStateManager.add(new AnimationState());
powerupjs.GameStateManager.switchTo(ID.game_state_animation);
在AnimationState类中,您创建了一个Player实例,将其设置在所需的位置,并将其添加到游戏世界:
function AnimationState(layer) {
powerupjs.GameObjectList.call(this, layer);
var player = new Player();
player.position = new powerupjs.Vector2(50, 300);
this.add(player);
}
如果你运行程序,你会看到一个可以用左右箭头键控制的动画角色(见图 25-2 )。如果角色走出可见屏幕,它不只是在屏幕外“停止”——而是继续前进。因此,如果你按住右箭头键 5 秒钟,你需要按住左箭头键 5 秒钟,以及获得角色回来。
图 25-2 。在画布底部从右向左移动的动画角色
绕过这种能够离开屏幕边缘的行为的一种方法是实现换行:如果角色离开屏幕的右侧,它会重新出现在左侧,反之亦然。通过在代码中添加一个if指令,可以很容易地实现换行,该指令检查字符的当前位置,并根据该位置选择将字符移动到屏幕的另一端。你能自己改变例子来添加包装吗?
你学到了什么
在本章中,您学习了:
- 如何创建和控制动画
- 如何构建一个由多个动画组成的动画游戏对象
二十六、游戏物理学
在上一章中,您看到了如何创建动画角色。您还了解了如何从本地存储中加载关卡和关卡状态,以及如何构建基于图块的游戏世界。最重要的一个方面仍然缺失:定义角色如何与游戏世界互动。你可以让一个角色从左向右移动,但是如果你只是简单地把角色放在关卡中,它只能在屏幕的底部行走。这还不够。您希望角色能够跳到墙砖上,并在它离开墙砖时掉下来,并且您不希望角色从屏幕边缘掉下来。对于这些东西,你需要实现一个基本的物理系统。因为它是与世界交互的角色,所以你在Player类中实现这个物理。处理物理有两个方面:赋予角色跳跃或坠落的能力,处理角色与其他游戏对象之间的碰撞并对这些碰撞做出响应。
锁定游戏世界中的角色
你要做的第一件事就是锁定游戏世界中的角色。在第二十五章的例子中,角色可以毫无问题地走出屏幕。你可以通过在屏幕左右放置一堆虚拟的墙式瓷砖来解决这个问题。然后你假设你的碰撞检测机制(你还没有写)将确保角色不能穿过这些墙。你只想防止角色走出屏幕的左侧或右侧。角色应该能够跳出屏幕顶部的视线。这个角色还应该能够通过地上的一个洞从游戏世界中掉下来(很明显,会死掉)。
为了在屏幕的左右两侧构建虚拟的墙砖堆,您必须向墙砖网格添加一些行为。你不想修改GameObjectGrid类。这种行为与游戏对象的网格无关,但它是你的平台游戏特有的。因此,您定义了一个名为TileField的新类,而继承了的GameObjectGrid类。您向名为getTileType的类添加一个方法,该方法返回给定其在网格上的 x 和 y 位置的图块的类型。这种方法的好处是允许这些索引落在网格中有效索引的之外。例如,询问位置(-2,500)的牌的牌类型就可以了。通过在该方法中使用if指令,您可以检查 x 步进是否超出范围。如果是,则返回一个普通的(墙)瓷砖类型:
if (x < 0 || x >= this.columns)
return TileType.normal;
如果 y 指数超出范围,你返回一个背景平铺类型,这样角色可以跳过屏幕的顶部或者掉进一个洞:
if (y < 0 || y >= this.rows)
return TileType.background;
如果两个if指令的条件都是false,这意味着网格中实际图块的类型被请求,因此您检索该图块并返回其图块类型:
return this.at(x, y).type;
完整的类可以在属于本章的示例程序TickTick2中找到。如果您想以更符合 JavaScript 哲学的方式扩展GameObjectGrid类,您可以在一个单独的 JavaScript 文件中将getTileType方法直接添加到GameObjectGrid类中。您可以调用文件GameObjectGrid_ext.js,它将包含一个添加到GameObjectGrid的方法,该方法将是:
GameObjectGrid.prototype.getTileType = function (x, y) {
if (x < 0 || x >= this.columns)
return TileType.normal;
if (y < 0 || y >= this.rows)
return TileType.background;
return this.at(x, y).type;
};
通过这种方式,您不需要创建一个新的类,而是简单地向GameObjectGrid注入您需要的行为。
将字符设置在正确的位置
当你从关卡数据变量中加载关卡时,你使用字符1来表示玩家角色开始的关卡。基于该图块的位置,您必须创建Player对象并将其设置在正确的位置。为此,您向Level类添加一个方法loadStartTile。在这种方法中,首先检索图块字段,然后计算角色的起始位置。因为角色的原点是精灵底部中心的点,你可以如下计算这个位置:
var startPosition = new powerupjs.Vector2((x + 0.5) * tiles.cellWidth,
(y + 1) * tiles.cellHeight);
请注意,您使用了瓷砖的宽度和高度,并将它们乘以角色应该位于的位置的 x 和 y 索引。单元格宽度乘以x + 0.5,因此字符被放置在图块位置的中间,单元格高度乘以y + 1,以将字符放置在图块的底部。然后,您可以创建Player对象并将其添加到游戏世界:
this.add(new Player(startPosition, ID.layer_objects, ID.player));
最后,您仍然需要在这里制作一个可以存储在网格中的实际瓷砖,因为每个字符应该代表一个瓷砖。在这种情况下,您可以创建一个背景单幅图块,放置在角色站立的位置:
return new Tile();
跳跃…
你已经看到了一个角色如何向左或向右行走。你如何处理跳跃和坠落?如果游戏在有键盘的设备上运行,当玩家按下空格键时,角色就会跳跃。
使用空格键跳跃在很大程度上是一种传统。游戏中常用的还有其他按键,比如用 Q 和 E 扫射;用 W、A、D、X 定向移动;用 S 停止或刹车;等等。在你的游戏中使用这些或多或少被接受的标准会给你的用户提供更好的体验,因为他们已经知道这个界面了。
当玩家按下空格键跳跃时,基本上意味着角色获得一个负 y-速度。这可以在Player类的handleInput方法中轻松完成
if (powerupjs.Keyboard.pressed(powerupjs.Keys.space))
this.jump();
jump方法如下:
Player.prototype.jump = function (speed) {
speed = typeof speed !== 'undefined' ? speed : 1100;
this.velocity.y = -speed;
};
所以,在不提供任何参数值的情况下调用jump方法的效果是y-速度被设置为 1100。我随机选择了这个数字。使用更大的数字意味着角色可以跳得更高。较低的数字意味着角色必须更频繁地去健身房,或者戒烟。我选择了这个值,这样角色可以跳得足够高来够到瓷砖,但又不会高到让游戏变得太容易(这样角色就可以跳到关卡的末尾)。
这种方法有一个小问题:你总是允许玩家的角色跳跃,不管角色当前的情况如何。因此,如果角色正在跳下或跌落悬崖,你允许玩家让角色跳回安全的地方。这不是你真正想要的。你想让角色只在站在地上的时候跳。这是可以通过观察角色与墙壁或平台瓷砖(角色可以站立的唯一瓷砖)之间的碰撞来检测的。现在让我们假设您尚未编写的碰撞检测算法将会处理这个问题,并通过使用一个成员变量来跟踪角色是否在地面上:
this.onTheGround = true;
有时候,在编写一个类之前,有必要用英语(相对于 JavaScript)勾画出一个类,这样你就可以编写游戏的其他部分了。在碰撞检测的情况下也是如此。在构建冲突检测算法之前,您无法对其进行测试,但是在创建并测试该算法之前,您不会想要构建它。一个必须先发生,所以你必须在心理上知道另一个发生了什么,并计划它或做笔记。CollisionTest 例子是我写的一个程序,用来测试独立于游戏的碰撞检测算法。您可能会发现,在某些情况下,编写单独的测试程序有助于您理解部分代码应该如何工作。
如果这个成员变量是true,你就知道这个角色是站在地上的。你现在可以改变最初的if指令,这样它只允许一个角色从地上跳,而不允许从空中跳:
if (powerupjs.Keyboard.pressed(powerupjs.Keys.space) && this.onTheGround)
this.jump();
如果你在没有键盘的设备上玩游戏(比如平板电脑或智能手机),你必须以不同的方式处理玩家的输入。一种方法是在屏幕上添加几个按钮,只有当触摸输入可用时,这些按钮才能控制玩家角色。这是在创建Level对象时完成的:
if (powerupjs.Touch.isTouchDevice) {
var walkLeftButton = new powerupjs.Button(sprites.buttons_player,
ID.layer_overlays, ID.button_walkleft);
walkLeftButton.position = new powerupjs.Vector2(10, 500);
this.add(walkLeftButton);
var walkRightButton = new powerupjs.Button(sprites.buttons_player,
ID.layer_overlays, ID.button_walkright);
walkRightButton.position = new powerupjs.Vector2(walkRightButton.width +
20, 500);
walkRightButton.sheetIndex = 1;
this.add(walkRightButton);
var jumpButton = new powerupjs.Button(sprites.buttons_player,
ID.layer_overlays, ID.button_jump);
jumpButton.position = new powerupjs.Vector2(powerupjs.Game.size.x –
jumpButton.width - 10, 500);
jumpButton.sheetIndex = 2;
this.add(jumpButton);
}
控制字符的方式与处理键盘输入的方式非常相似:
if (powerupjs.Touch.isTouchDevice) {
var jumpButton = this.root.find(ID.button_jump);
if (jumpButton.pressed && this.onTheGround)
this.jump();
}
这是一个很好的例子,说明了如何自动调整游戏界面以适应不同的设备。只有当触摸显示屏可用时,才会添加按钮。另一种选择是在设备中使用内置传感器,例如加速度计。涂鸦跳跃是一个使用这种传感器来控制角色的游戏的好例子。
…然后下落
你目前唯一改变 y 速度的地方是在handleInput方法中,当玩家想要跳跃的时候。因为y-速度无限期地保持 1100 的值,角色在屏幕外的空中向上移动,离开地球的大气层,进入外层空间。因为你不是在做一个关于太空炸弹的游戏,你必须对此做些什么。你忘了加到游戏世界的是重力。
您可以遵循一个简单的方法来模拟重力对角色速度的影响。在每个更新步骤中,在 y 方向的速度上增加一个小值:
this.velocity.y += 55;
如果角色有一个负的速度,这个速度慢慢变小,直到它达到零,然后又开始增加。效果是角色跳到某个高度,然后又开始往下掉,就像在现实世界里一样。然而,冲突检测机制现在变得更加重要。如果没有碰撞检测,角色会在游戏开始时就开始倒下!
碰撞检测
检测游戏对象之间的碰撞是模拟交互式游戏世界的一个非常重要的部分。碰撞检测在游戏中用于许多不同的事情:检测角色是否走过电源,检测角色是否与投射物碰撞,检测角色与墙壁或地板之间的碰撞,等等。鉴于这种非常常见的情况,你在以前的游戏中不需要碰撞检测几乎是很奇怪的。还是你没有?请看来自画师游戏的PaintCan类中的update方法的代码
var ball_center = Game.gameWorld.ball.center;
var ball_position = Game.gameWorld.ball.position;
var distance = ball_position.add(ball_center).subtractFrom(this.position)
.subtractFrom(this.center);
if (Math.abs(distance.x) < this.center.x && Math.abs(distance.y) <
this.center.y) {
this.color = Game.gameWorld.ball.color;
Game.gameWorld.ball.reset();
}
您在这里所做的是检测球和油漆罐之间的碰撞(尽管是非常基本的方式)。你取每个物体的中心位置,看看这两个位置之间的距离是否小于某个值。如果是这样,你说它们碰撞了,你改变了罐子的颜色。如果您更仔细地观察这种情况,您可以看到您正在用基本形状表示游戏对象,例如圆,并且您通过验证中心之间的距离是否小于圆的半径之和来检查它们是否相互碰撞。
这是第一个,在游戏中进行碰撞检查的简单例子。当然,这不是一种非常精确的检查碰撞的方法。球的形状可以近似为圆形,但油漆罐看起来一点也不像圆形。因此,在某些情况下,当没有碰撞时会检测到碰撞,有时当精灵实际碰撞时不会检测到碰撞。尽管如此,许多游戏在进行碰撞检测时还是会使用圆形和矩形等简化形状来代表物体。因为这些形状将对象约束在其中,所以它们也被称为边界圆 和边界框 。Tick Tick 游戏使用轴对齐的边界框,意味着你不考虑边不平行于 x -和y-轴的框。
不幸的是,使用包围盒进行碰撞检测并不总是足够精确。当游戏对象彼此靠近时,它们的边界形状可能会相交(从而触发碰撞),但实际对象不会。并且当游戏对象被动画化时,它的形状可以随着时间而改变。您可以使边界形状更大,以便对象在任何情况下都适合它,但这将导致更多错误的碰撞触发器。
对此的解决方案是在每个像素的基础上检查碰撞。基本上,您可以编写一个算法,遍历 sprite 中的非透明像素(使用嵌套的for指令),并检查这些像素中的一个或多个是否与另一个 sprite 中的一个像素冲突(同样,通过使用嵌套的for指令遍历它们)。通常,这种高度详细的碰撞检测对于浏览器游戏来说成本太高(即使浏览器变得越来越快)。另一方面,您不必经常执行这个相当昂贵的任务。只有当两个边界形状相交时才需要这样做。然后你只需要对实际相交的形状部分做同样的操作。此外,如果你聪明的话,你可以决定哪种对象应该使用逐像素碰撞检测,这样你就可以只对边界框不能很好工作的对象使用它。
当你使用圆形和矩形来检测碰撞时,你需要处理三种情况(参见图 26-1 ):
- 一个圆与另一个圆相交。
- 一个圆与一个矩形相交。
- 一个矩形与另一个矩形相交。
图 26-1 。不同类型的碰撞:圆-圆,圆-矩形和矩形-矩形
第一种情况是最简单的。你唯一需要做的就是检查两个中心之间的距离是否小于半径之和。您已经看到了如何做到这一点的示例。
对于圆与矩形相交的情况,可以使用以下方法:
- 找到矩形上最靠近圆心的点。
- 计算这个点到圆心的距离。
- 如果这个距离小于圆的半径,就有碰撞。
让我们假设您想要找出类型为Rectangle的对象是否与由类型为\expr{Vector2}的对象和半径表示的圆相交。通过巧妙的钳制值,可以找到最接近圆心的点。箝位最大值和最小值之间的一个值通过以下方法完成:
Math.clamp = function (value, min, max) {
if (value < min)
return min;
else if (value > max)
return max;
else
return value;
};
现在看一下下面的代码:
Vector2 closestPoint = Vector2.zero;
closestPoint.x = Math.clamp(circleCenter.x, rectangle.left, rectangle.right);
closestPoint.y = Math.clamp(circleCenter.y, rectangle.top, rectangle.bottom);
通过夹紧矩形边缘之间中心的 x 和 y 值,可以找到最近的点。如果圆的中心在矩形内,这种方法也有效,因为在这种情况下箝位不起作用,并且最近的点与圆的中心相同。下一步是计算最近点和圆心之间的距离:
Vector2 distance = closestPoint.subtract(circleCenter);
如果这个距离小于半径,就会发生碰撞:
if (distance.length < circleRadius)
// collision!
最后一种情况是检查两个矩形是否冲突。为了进行计算,您需要了解两个矩形的以下信息:
- 最小的x-矩形(
rectangle.left)的值 - 最小的y-矩形(
rectangle.top)的值 - 最大x-矩形(
rectangle.right)的值 - 最大的y-矩形(
rectangle.bottom)的值
假设您想知道矩形 A 是否与矩形 b 冲突。在这种情况下,您必须检查以下条件:
A.left(A 的最小x-值)< =B.right(B 的最大x-值)A.right(A 的最大x-值)> =B.left(B 的最小x-值)A.top(A 的最小y-值)< =B.bottom(B 的最大y-值)A.bottom(A 最大的y-值)> =B.top(B 最小的y-值)
如果所有这些条件都满足,那么矩形 A 和 B 就发生了碰撞。为什么会有这些特殊情况?让我们看看第一个条件,看看如果它不为真会发生什么。假设A.left > B.right取而代之。在这种情况下,矩形 A 完全位于矩形 B 的右侧,因此它们不会发生碰撞。如果第二个条件不成立(换句话说,A.right < B.left,那么矩形 A 完全位于 B 的左侧,这意味着它们也不会发生碰撞。你自己也要检查一下另外两个条件。总之,这些条件表明,如果矩形 A 既不完全位于 B 的左侧、右侧、顶部,也不完全位于 B 的底部,那么这两个矩形就会发生碰撞。
在 JavaScript 中,编写检查矩形间冲突的代码很容易。如果你看一下Rectangle类,你可以看到一个方法intersects为你做这件事:
Rectangle.prototype.intersects = function (rect) {
return (this.left <= rect.right && this.right >= rect.left &&
this.top <= rect.bottom && this.bottom >= rect.top);
};
检索边界框
为了有效地处理游戏中的碰撞,SpriteGameObject类有一个属性boundingBox,它返回精灵的边界框:
Object.defineProperty(SpriteGameObject.prototype, "boundingBox",
{
get: function () {
var leftTop = this.worldPosition.subtractFrom((this.origin));
return new powerupjs.Rectangle(leftTop.x, leftTop.y, this.width,
this.height);
}
});
正如你所看到的,为了计算盒子的正确位置,它考虑了精灵的原点。还要注意,边界框的位置是用世界位置表示的。当进行碰撞检测时,您希望知道对象在世界上的位置——您不关心它们在游戏对象层次结构中的本地位置。
逐像素碰撞检测
除了boundingBox属性,您还可以在SpriteGameObject类中添加一个方法collidesWith来处理冲突检测。然而,仅仅检查边界框是否重叠通常是不够的。图 26-2 显示了两个精灵的边界框重叠的例子,但是图像实际上并没有碰撞。这可能是因为你绘制的精灵 的部分可以是透明的。因此,如果您想要进行精确的碰撞检测,您需要查看像素级别是否存在碰撞。只有在子画面重叠的矩形中的给定位置,两个子画面都有不透明的像素时,才会发生像素级的冲突。
图 26-2 。两个精灵没有碰撞,但是它们的边界框重叠的例子
访问图像中的像素颜色数据
要进行逐像素碰撞检测,您需要访问图像的像素颜色数据。这在 HTML/JavaScript 中并不难做到,但是在了解如何实现之前,您需要知道逐像素碰撞检测是一个开销很大的操作(稍后您会明白为什么)。如果你在游戏世界中的每个精灵之间这样做,你将面临游戏无法在旧的移动设备或平板电脑上运行的风险。因此,只有在真正必要时才进行逐像素碰撞检测。
因为每像素碰撞检测是昂贵的,所以以这样一种方式设计代码是有意义的,即对于某些精灵来说很容易关闭它。为此,在SpriteSheet类中维护一个布尔变量,该变量指示是否应该对该精灵进行逐像素碰撞检测。因为检索像素颜色数据是昂贵的,所以当精灵被加载时,你检索所有的数据并把它存储在一个叫做碰撞遮罩 的数组中。为什么检索像素颜色数据很昂贵?因为为了检索这些数据,你需要首先绘制精灵,然后从画布中检索颜色数据。您不想在玩家可以看到的主画布上绘制这些精灵,所以您在Canvas2D类中定义了另一个画布来实现这个目的:
this._pixeldrawingCanvas = document.createElement('canvas');
向SpriteSheet类添加一个名为createPixelCollisionMask的方法,在该方法中,在像素绘图画布上绘制精灵,然后从该画布中提取像素颜色数据。初始化将包含碰撞遮罩的数组,并确保像素绘图画布的大小正确:
this._collisionMask = [];
var w = this._image.width;
var h = this._image.height;
powerupjs.Canvas2D._pixeldrawingCanvas.width = w;
powerupjs.Canvas2D._pixeldrawingCanvas.height = h;
然后使用画布上下文来绘制精灵:
var ctx = powerupjs.Canvas2D._pixeldrawingCanvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.drawImage(this._image, 0, 0, w, h, 0, 0, w, h);
ctx.restore();
canvas 上下文有一个方法getImageData ,它检索每个像素的颜色数据并将其存储在一个数组中。因此,让我们检索当前显示在画布上的所有像素:
var imageData = ctx.getImageData(0, 0, w, h);
变量现在指的是一个非常大的数字数组。对于每个像素,数组中有四个数字,每个数字都在 0 到 255 之间。前三个数字是决定像素颜色的 R(红色)、G(绿色)和 B(蓝色)值。第四个数字是决定像素透明度的 A (alpha)值。alpha 值为 0 表示像素完全透明,值为 255 表示像素不透明。在碰撞遮罩中,您只需要存储 alpha 值,因为碰撞对象的颜色并不重要:重要的是哪些像素代表这些对象。因此,您使用一条for指令来遍历数组,并将每个第四个值存储在碰撞掩码数组中,如下所示:
for (var x = 3, l = w * h * 4; x < l; x += 4) {
this._collisionMask.push(imageData.data[x]);
}
当创建一个SpriteSheet实例时,只有当用户在调用构造函数时将参数createCollisionMask设置为true时,才会计算碰撞遮罩。例如,您表示希望在加载播放器精灵时对播放器进行精确的碰撞检测:
sprites.player_idle = loadSprite("player/spr_idle.png", true);
sprites.player_run = loadSprite("player/spr_run@13.png", true);
sprites.player_jump = loadSprite("player/spr_jump@14.png", true);
另一方面,您不需要这些图块的精确信息,因为它们或多或少都是矩形的,所以使用矩形边界框就足够了:
sprites.wall = loadSprite("tiles/spr_wall.png");
sprites.wall_hot = loadSprite("tiles/spr_wall_hot.png");
sprites.wall_ice = loadSprite("tiles/spr_wall_ice.png");
sprites.platform = loadSprite("tiles/spr_platform.png");
sprites.platform_hot = loadSprite("tiles/spr_platform_hot.png");
sprites.platform_ice = loadSprite("tiles/spr_platform_ice.png");
为了使访问碰撞遮罩变得更容易,您在SpriteSheet类中添加了一个getAlpha方法来访问碰撞遮罩,同时考虑当前在工作表中选择的元素以及子画面是否被镜像绘制。下面是该方法的标题:
SpriteSheet.prototype.getAlpha = function (x, y, sheetIndex, mirror)
作为参数,该方法需要 x 和 y 像素坐标、工作表索引以及子画面是否被镜像。首先要做的是检查是否有一个碰撞遮罩与这个 sprite sheet 关联,因为不是所有的 sprite 都有这样的遮罩。如果没有碰撞遮罩,只需返回值 255(完全不透明):
if (this._collisionMask === null)
return 255;
然后,您计算对应于当前工作表索引的列和行索引,使用与在draw方法中相同的方式:
var columnIndex = sheetIndex % this._sheetColumns;
var rowIndex = Math.floor(sheetIndex / this._sheetColumns) % this._sheetRows;
然后,您可以计算图像中的实际像素坐标(或纹理),将工作表索引给出的 sprite 元素考虑在内。通过将一个工作表元素的宽度乘以列索引并加上本地的 x 值来计算 x 坐标:
var textureX = columnIndex * this.width + x;
但是,如果精灵是镜像的,则使用稍微不同的计算方法:
if (mirror)
textureX = (columnIndex + 1) * this.width - x - 1;
这里发生的事情是,你从 sprite 元素的右边开始,然后减去 x 得到本地的 x 坐标。对于 y 坐标,不需要检查镜像,因为在游戏引擎中你只允许水平镜像:
var textureY = rowIndex * this.height + y;
基于图像中的 x 和 y 坐标,现在计算碰撞遮罩中的相应索引,如下所示:
var arrayIndex = Math.floor(textureY * this._image.width + textureX);
为了确保万无一失,您检查您计算的索引是否落在数组的范围内。如果不是,则返回 0(完全透明):
if (arrayIndex < 0 || arrayIndex >= this._collisionMask.length)
return 0;
这样,如果您试图访问不存在的像素,getAlpha方法也会返回一个逻辑结果。最后,返回存储在碰撞遮罩中的 alpha 值:
return this._collisionMask[arrayIndex];
为了方便起见,您还向SpriteGameObject添加了一个getAlpha方法,该方法使用正确的参数从SpriteSheet调用getAlpha方法:
SpriteGameObject.prototype.getAlpha = function (x, y) {
return this.sprite.getAlpha(x, y, this._sheetIndex, this.mirror);
};
注意不是所有的浏览器都允许你访问像素颜色数据。例如,如果你在电脑上将 HTML 页面作为本地文件打开,Chrome 和 Firefox 就不允许这种访问。Internet Explorer 确实允许这样做,所以要测试逐像素碰撞检测,您可以使用该浏览器或将文件放在服务器上,以便使用这两种浏览器中的任何一种。在 TickTick2 的例子中,我注释掉了TickTick.js中的collisionMask参数,因此游戏可以在所有浏览器上运行,但是当然在这种情况下游戏不会执行逐像素碰撞检测。
计算重叠矩形
SpriteGameObject中的collidesWith方法处理碰撞检测的两个步骤:首先检查边界框是否相交,然后在重叠矩形中执行逐像素碰撞检测。该方法的第一步是确定是否需要进行任何碰撞检测。如果两个对象中的任何一个不可见,或者如果它们的边界框不相交,那么从方法:返回
if (!this.visible || !obj.visible ||
!this.boundingBox.intersects(obj.boundingBox))
return false;
下一步是计算两个边界框的重叠部分。因为在处理碰撞检测时,这是一个很有用的计算方法,所以在Rectangle类中添加一个名为intersection的方法,该方法返回一个矩形,表示作为参数传递的矩形(边界框)和调用该方法的矩形对象(this)之间的重叠。
为了计算这个重叠矩形,你需要知道矩形的最小和最大 x 和 y 坐标(见图 26-3 )。将Rectangle类中一些有用的属性与Math对象的min和max方法结合使用,可以很容易地计算出这些值:
var xmin = Math.max(this.left, rect.left);
var xmax = Math.min(this.right, rect.right);
var ymin = Math.max(this.top, rect.top);
var ymax = Math.min(this.bottom, rect.bottom);
图 26-3 。使用最小和最大 x 和 y 坐标计算重叠矩形
现在,您可以计算重叠矩形的位置和大小,并从方法返回它:
return new powerupjs.Rectangle(xmin, ymin, xmax - xmin, ymax - ymin);
在SpriteGameObject的collidesWith方法中,通过从Rectangle类中调用intersection方法来存储重叠矩形:
var intersect = this.boundingBox.intersection(obj.boundingBox);
检查重叠矩形 中的像素
重叠矩形的坐标用世界坐标表示,因为两个边界框都用世界坐标表示。你首先需要找出重叠矩形在两个重叠精灵中的位置。因此,你需要减去每个精灵的世界位置和它的原点来找到每个精灵中的局部重叠矩形:
var local = intersect.position.subtractFrom(this.worldPosition
.subtractFrom(this.origin));
var objLocal = intersect.position.subtractFrom(obj.worldPosition
.subtractFrom(obj.origin));
要检查重叠矩形内是否有碰撞,使用嵌套的for指令遍历矩形中的所有像素:
for (var x = 0; x < intersect.width; x++)
for (var y = 0; y < intersect.height; y++) {
// check transparency of pixel (x, y)...
}
在这个嵌套的for指令中,你检查两个像素在这些局部位置是否都是而不是透明的。如果是这种情况,你有一个碰撞。您使用getAlpha方法来检查这两个像素:
if (this.getAlpha(Math.floor(local.x + x), Math.floor(local.y + y)) !== 0
&& obj.getAlpha(Math.floor(objLocal.x + x), Math.floor(objLocal.y + y)) !== 0)
return true;
既然已经实现了基本的碰撞检测方法,您可以通过调用collidesWith方法来检查两个游戏对象是否发生碰撞:
if (this.collidesWith(enemy))
// ouch...
处理字符-图块冲突
在 Tick Tick 游戏中,您需要检测角色和瓷砖之间的碰撞。你可以在一个名为handleCollisions的方法中做到这一点,这个方法是从Player类中的update方法调用的。这个想法是,你做所有的计算 跳跃,下落,并且首先跑(你在这一章的开始就做了)。如果角色和瓷砖之间发生碰撞,您可以修正角色的位置,使其不再发生碰撞。在handleCollisions方法中,你走过方格并检查角色和你正在检查的方格之间是否有冲突。
您不需要检查网格中的所有图块,只需检查靠近角色当前位置的图块。您可以计算距离角色位置最近的牌,如下所示:
var tiles = this.root.find(ID.tiles);
var x_floor = Math.floor(this.position.x / tiles.cellWidth);
var y_floor = Math.floor(this.position.y / tiles.cellHeight);
现在你可以使用嵌套的for指令来查看角色周围的瓷砖。为了考虑快速跳跃和下落,你在 y 方向上考虑了更多的瓷砖。在嵌套的for指令中,然后检查角色是否与瓷砖发生碰撞。但是,只有当图块是而不是背景图块时,您才需要这样做。完成所有这些工作的代码如下:
for (var y = y_floor - 2; y <= y_floor + 1; ++y)
for (var x = x_floor - 1; x <= x_floor + 1; ++x) {
var tileType = tiles.getTileType(x, y);
if (tileType === TileType.background)
continue;
var tileBounds = new powerupjs.Rectangle(x * tiles.cellWidth, y *
tiles.cellHeight, tiles.cellWidth, tiles.cellHeight);
if (!tileBounds.intersects(this.boundingBox))
continue;
}
如您所见,您没有直接访问Tile对象。原因是有时,因为角色靠近屏幕边缘,所以 x 或 y 索引可能为负。这里您看到了使用添加到TileField类中的getTileType方法的优势。您并不关心您是否真的在处理一个图块:只要您知道它的类型和边界框,您就可以完成您的工作。
在嵌套的for指令中,还会看到一个新的关键字:continue。这个关键字可以在for或while指令中使用,以停止执行循环的当前迭代,并继续下一个迭代。在这种情况下,如果图块的类型为background,则剩余的指令不再执行,您继续增加x并开始新的迭代以检查下一个图块。结果是只考虑不属于类型background的图块。continue关键字与break相关,它完全停止循环。与break不同,continue只停止当前迭代。
然而,这些代码并不总是正确地工作。特别是当角色站在瓷砖上时,计算边界框时的舍入误差会导致算法认为角色不是站在地上。然后,角色的速度会增加,结果角色可能会从瓷砖中掉下来。为了补偿任何舍入误差,可以将边界框的高度增加 1:
var boundingBox = this.boundingBox;
boundingBox.height += 1;
if (!tileBounds.intersects(boundingBox))
continue;
// handle the collision
处理碰撞
现在,您可以检测游戏世界中角色和瓷砖之间的碰撞,您必须确定当碰撞发生时该做什么。有几种可能性。你可以让游戏崩溃(如果你想把你的游戏卖给很多人,这并不好),你可以警告用户他们不应该与游戏中的物体碰撞(导致许多弹出消息),或者你可以在角色与物体碰撞时自动纠正角色的位置。
为了纠正角色的位置,你需要知道碰撞有多糟糕。例如,如果角色撞上了右边的墙,你必须知道你要向左移动角色多远才能取消碰撞。这也被称为交点深度。让我们用一个叫做calculateIntersectionDepth的方法来扩展Rectangle类,该方法计算两个Rectangle对象在 x 和 y 方向的相交深度。在这个例子中,这些矩形是角色的边界框和与之碰撞的瓷砖的边界框。
可以通过首先确定矩形中心之间的最小允许距离来计算相交深度,使得两个矩形之间没有碰撞:
var minDistance = this.size.addTo(rect.size).divideBy(2);
然后计算两个矩形中心之间的实际距离:
var distance = this.center.subtractFrom(rect.center);
现在,您可以计算最小允许距离和实际距离之间的差异,以获得相交深度。如果你观察两个中心之间的实际距离,两个维度都有两种可能( x 和 y ):距离要么是负的,要么是正的。例如,如果 x 距离为负,这意味着矩形rect被放置在矩形this的右侧(因为rect.center.x > this.center.x)。如果矩形this代表该字符,这意味着您必须将该字符移动到左侧来纠正这个交叉点。因此,你将 x 相交深度返回为一个负值,可以计算为-minDistance.x - distance.x。为什么呢?因为有碰撞,所以两个矩形之间的距离小于minDistance。并且因为distance是负的,所以表达式-minDistance.x - distance.x给出两者之差作为负值*。如果distance为正,表达式minDistance.x - distance.x给出两者之间的正差。同样的推理也适用于 y 距离。然后,您可以按如下方式计算深度:*
var depth = powerupjs.Vector2.zero;
if (distance.x > 0)
depth.x = minDistance.x - distance.x;
else
depth.x = -minDistance.x - distance.x;
if (distance.y > 0)
depth.y = minDistance.y - distance.y;
else
depth.y = -minDistance.y - distance.y;
最后,您返回深度向量作为该方法的结果:
return depth;
当您知道角色与瓷砖发生碰撞时,您可以使用刚刚添加到Rectangle类中的方法来计算相交深度:
var depth = boundingBox.calculateIntersectionDepth(tileBounds);
现在你已经计算了相交深度,有两种方法可以解决这个碰撞:在 x 方向移动角色,或者在 y 方向移动角色。通常,您希望将角色移动尽可能短的距离,以避免不自然的运动或位移。所以,如果 x 深度小于 y 深度,你在 x 方向移动角色;否则,沿 y 方向移动。您可以使用if指令来检查这一点。当比较两个深度尺寸时,你必须考虑到它们可能是负数。您可以通过比较绝对值来解决这个问题:
if (Math.abs(depth.x) < Math.abs(depth.y)) {
// move character in the x direction
}
如果与瓷砖发生碰撞,是否总是要移动角色?这取决于瓷砖的类型。请记住,TileType用于表示三种可能的牌类型:TileType.background、TileType.normal和TileType.platform。如果角色碰撞的瓷砖是背景瓷砖,你肯定不想移动角色。此外,在向 x 方向移动的情况下,您希望角色能够穿过平台瓷砖。因此,只有当角色与墙瓷砖(TileType.normal)发生碰撞时,才需要移动角色来纠正碰撞。在这种情况下,通过将 x 深度值添加到角色位置来移动角色:
if (tileType === TileType.normal)
this.position.x += depth.x;
如果你想在 y 方向修正字符位置,事情会变得稍微复杂一些。因为你正在处理在 y 方向的运动,这也是一个确定角色是否在地面上的好地方。在handleCollisions方法的开始,您将isOnTheGround成员变量设置为false。所以,出发点是假设地上的人物是而不是。在有些的情况下,是在地面上,你要把变量设置成true。你如何能检查角色是否在地面上?如果它不在地面上,它一定在下落。如果是下降,那么先前的 y 位置小于当前位置。为了访问前一个 y 位置,在每次调用handleCollisions方法结束时,将它存储在一个成员变量中:
this._previousYPosition = this.position.y;
现在很容易确定角色是否在地面上。如果先前的 y 位置小于角色正在碰撞的瓷砖的顶部,并且该瓷砖是而不是背景瓷砖,那么角色正在下落并且已经到达瓷砖。如果是这样,您将isOnTheGround变量设置为true并将 y 速度设置为 0:
if (this._previousYPosition <= tileBounds.top && tileType !==
TileType.background) {
this.onTheGround = true;
this.velocity.y = 0;
}
在某些情况下,您仍然需要纠正字符位置。如果你与墙砖相撞,你总是想纠正角色的位置。如果角色与平台瓷砖发生碰撞,您只需在角色站在瓷砖顶部时纠正角色位置。如果isOnTheGround变量设置为true,则后者仅为true。因此,您可以将这一切写入下面的if指令:
if (tileType === TileType.normal || this.onTheGround)
this.position.y += depth.y + 1;
请注意,要校正位置,您需要添加一个额外的像素来补偿您添加到边界框高度的额外像素。
你学到了什么
在本章中,您学习了:
- 如何在环境中约束角色
- 如何模拟跳跃和坠落
- 如何处理游戏中的碰撞