HTML5 和 JavaScript 高级游戏设计(四)
七、碰撞检测
两个精灵碰撞会发生什么?这就是本章的全部内容:碰撞检测。你将学习如何判断两个精灵是否在触碰,并让他们在触碰时做出有趣的反应。对于 2D 游戏,有四种基本的碰撞检测技术你需要知道,我们将在本章中一一介绍:
- 点与形状:当一个点与一个形状相交时
- 圆对圆:两个圆相交时
- 矩形 vs 矩形 :两个矩形相交时
- 圆形对矩形:矩形和圆形相交时
有了这四种技术,整个游戏设计的可能性就展现在你面前了。您将学习一些有用的、可重用的函数,这些函数将帮助您使用我们在本书中开发的 sprite 系统进行碰撞检测,以及如何将这些函数应用于各种实际的游戏原型。
你会在本章源文件的library/collision.js文件中找到我们将在本章中用到的所有碰撞函数。我们将在本章中使用以下碰撞函数:
hitTestPoint
hitTestCircle
hitTestRectangle
rectangleCollision
circleCollision
movingCircleCollision
hitTestCirclePoint
circlePointCollsion
hitTestCircleRectangle
circleRectangleCollision
hit
在使用这些函数之前,请确保将其导入到您的应用程序代码中。他们将与任何具有以下属性的精灵一起工作:
x, y, centerX, centerY, vx, vy, width, height,
halfWidth, halfHeight, radius, diameter
只要你的精灵拥有这些属性,碰撞功能就会起作用——即使你使用的是其他显示系统或游戏引擎,而不是我们在本书中构建的。让我们来看看如何使用这些碰撞功能来开始制作一些引人注目的游戏。
注意在这一章中,我不打算深究本章中碰撞函数如何工作的所有杂乱的细节。它们被设计成你可以将它们放入任何游戏项目中——让它们发挥它们的魔力并享受它。你是游戏设计师,不是数学家,所以放轻松!这一章是关于如何以有趣的方式使用这些碰撞函数。你会发现所有的碰撞函数都在源代码中做了细致的注释,所以如果你真的很好奇,可以看看。如果你想深入了解这些碰撞函数背后的数学原理,当我们在附录中检查向量时,你会学到你需要知道的一切。
点与形状
最基本的碰撞测试是检查一个点是否与一个形状相交。您可以使用一个名为hitTestPoint的函数来解决这个问题。hitTestPoint接受两个参数:一个具有 x 和 y 属性的点对象,以及一个 sprite。
hitTestPoint(
{x: 128, y: 128}, //An object with `x` and `y` properties
sprite //A sprite
)
如果点与精灵相交,hitTestPoint将返回true,如果不相交,false将返回。下面是如何使用它来检查指针和一个名为box的矩形精灵之间的冲突:
if (hitTestPoint(pointer.position, box) {
//The point is touching the box
}
(你会记得上一章中指针的position属性是一个包含一个 x,y 值的对象。)
hitTestPoint函数同样适用于矩形和圆形精灵。如果精灵有一个radius属性,hitTestPoint假设精灵是圆形的,并对圆形应用点碰撞检测算法。如果 sprite 没有一个radius属性,那么这个函数假设它是一个正方形。你会在pointVsShape.html文件中找到一个hitTestPoint的工作示例,如图图 7-1 所示。
图 7-1 。检查是否有一个点,比如指针,接触到了一个精灵
惊喜!hitTestPoint和你上一章学的指针的hitTestSprite方法几乎一模一样。唯一的区别是你可以对你定义的任何点使用hitTestPoint,而不仅仅是指针。下面是示例pointVsShape.html文件中游戏循环的代码,实现了图 7-1 : 所示的效果
if(hitTestPoint(pointer.position, ball)) {
message.content = "Ball!"
} else if(hitTestPoint(pointer.position, box)) {
message.content = "Box!"
} else {
message.content = "No collision..."
}
圆形 vs 圆形
如果你想检查两个圆形精灵之间的碰撞,使用hitTestCircle函数 :
hitTestCircle(sprite1, sprite2)
将它与任何具有radius属性的 sprite 一起使用。如果圆接触,它返回true,所以你可以用一个if语句来检查碰撞,语法如下:
if (hitTestCircle(sprite1, sprite2)) {
//The circles are touching
}
注意章节中所有的碰撞函数默认使用精灵的本地坐标。如果要强制函数使用全局坐标,将最后一个可选参数
global设置为true。以下是如何:
hitTestCircle(sprite1, sprite2, true)
本章中的所有碰撞函数都有这个可选的最终global参数。
运行circleCollision.html文件,看看如何使用这个函数和我们在上一章创建的拖放指针系统,如图 7-2 所示。
图 7-2 。检查两个圆是否接触
下面是游戏循环中使用hitTestCircle来实现这种效果的代码。红圈称为c1(代表“圈 1”),蓝圈称为c2(代表“圈 2”)。
if(hitTestCircle(c1, c2)) {
message.content = "Collision!"
} else {
message.content = "No collision..."
}
这是创建一个拼图或拖放形状匹配游戏所需的基本系统。
反应循环碰撞
在上一个示例中,您可以检测到发生了碰撞,但是圆没有以任何方式对碰撞做出反应。在大多数动作游戏中,你会希望你的精灵阻止其他精灵的移动,或者在它们碰撞时相互弹开。有两个函数可以用来制作具有这种真实碰撞反应的精灵:circleCollision和movingCircleCollision。为什么有两个?因为运动的圆根据撞上的是静止的圆还是另一个运动的圆反应略有不同。在接下来的两个部分中,你将学习如何在一些非常实用的游戏原型中使用这两个函数。
运动圆和静止圆之间的碰撞
如果一个运动的圆碰到一个不运动的圆,你可以使用circleCollision功能创建一个碰撞反应:
circleCollision(circle1, circle2, true);
第一个参数是运动的球,第二个参数是不运动的球。第三个参数是一个可选的布尔值,它决定第一个圆是否应该从第二个圆弹回。(如果你忽略它,布尔值将默认为false,所以如果你想让圆圈反弹,将其设置为true。)
注意将可选的第四个参数设置为
true使得函数使用精灵的全局坐标。如果您想要检查具有不同父容器的精灵之间的碰撞,这是很重要的。您将在前面的示例中看到这是如何有帮助的。
任何带有radius属性的 sprite 都可以用在这个函数中。如果精灵也有一个mass属性,circleCollision函数将使用它的值按比例减弱反弹效果。
运行pegs.html来看看你如何使用这个函数来制作一个球,让它在一个圆形的格子中弹跳。球从每个木栓上弹开,停在画布的底部。网格中每个木栓的大小和颜色是随机的,球的大小、质量和起始速度也是随机的,因此每次运行的效果都是不同的。图 7-3 显示了您将看到的内容。
图 7-3 。一个球落下来,穿过一排排钉子
钉子在名为pegs的数组中,球的碰撞反应在游戏循环中使用这段代码创建:
pegs.children.forEach(peg => {
circleCollision(ball, peg, true, true);
});
这就是创建反弹效果所需的全部内容。但是在这个程序中还有一些更有趣的东西,你可能会在你自己的游戏中发现它们的用处。让我们来看看钉子网格是如何制作的。
绘制网格
仔细观察,你会注意到每一个木桩都被布置在一个 5 列 5 行的隐形网格中,如图 7-4 所示。
图 7-4 。每个 peg 在一个不可见的网格中居中
您可以看到,每个 peg 都在一个宽度和高度为 48 像素的网格单元内居中。每个木栓都有一个介于 8 和 32 像素之间的随机直径,以及一个从颜色值数组中选择的随机颜色。网格从画布左侧偏移 8 像素,从顶部偏移 48 像素。网格是父组容器,每个圆形 peg 是该组的子组。这个网格是怎么做出来的?
在这样的网格形状中绘制精灵是一项非常常见的视频游戏设计任务——事实上,如此常见,以至于将这项工作分配给一个可重复使用的功能会有所帮助,它会自动为您完成这项工作。在library/display文件夹中,你会找到一个名为grid的函数来完成这个任务。下面是如何在示例文件中使用grid函数来制作pegs网格:
pegs = grid(
5, //The number of columns
4, //The number of rows
48, //The width of each cell
48, //The height of each cell
true, //Should the sprite be centered in the cell?
0, //The sprite's xOffset from the left of the cell
0, //The sprite's yOffset from the top of the cell
//A function that describes how to make each peg in the grid.
//A random diameter and color are selected for each one
() => {
let peg = circle(randomInt(8, 32));
let colors = [
"#FFABAB", "#FFDAAB", "#DDFFAB", "#ABE4FF", "#D9ABFF"
];
peg.fillStyle = colors[randomInt(0, 4)];
return peg;
},
//Run any optional extra code after each
//peg is made
() => console.log("extra!")
);
该函数返回一个名为pegs的group。网格中每个单元格内的所有精灵都是那个pegs组的孩子。因为它是一个组,所以你可以像操纵任何其他精灵一样操纵整个网格。这意味着你可以使用我们在第四章中创建的setPosition方法在画布中定位组,就像这样:
pegs.setPosition(8, 48);
测试球和钉子之间的碰撞只是循环通过pegs.children并为每个调用circleCollision函数。因为精灵在组内的位置是相对于组的本地坐标的,你需要将circleCollision的global标志设置为true。
pegs.children.forEach(peg => {
circleCollision(ball, peg, true, true);
});
如果你不使用全局坐标,所有的碰撞看起来都会偏离画布的左边和上边的网格的偏移量(8 个像素在 x 轴上,48 个像素在 y 轴上)。
下面是完整的grid函数,它创建所有的标桩,在网格上绘制它们,并将它们添加到组中:
export function grid(
columns = 0, rows = 0, cellWidth = 32, cellHeight = 32,
centerCell = false, xOffset = 0, yOffset = 0,
makeSprite = undefined,
extra = undefined
){
//Create an empty group called `container`. This `container`
//group is what the function returns to the main program.
//All the sprites in the grid cells will be added
//as children to this container
let container = group();
//The `create` method plots the grid
let createGrid = () => {
//Figure out the number of cells in the grid
let length = columns * rows;
//Create a sprite for each cell
for(let i = 0; i < length; i++) {
//Figure out the sprite's x/y placement in the grid
let x = (i % columns) * cellWidth,
y = Math.floor(i / columns) * cellHeight;
//Use the `makeSprite` function supplied in the constructor
//to make a sprite for the grid cell
let sprite = makeSprite();
//Add the sprite to the `container`
container.addChild(sprite);
//Should the sprite be centered in the cell?
//No, it shouldn't be centered
if (!centerCell) {
sprite.x = x + xOffset;
sprite.y = y + yOffset;
}
//Yes, it should be centered
else {
sprite.x
= x + (cellWidth / 2)
- sprite.halfWidth + xOffset;
sprite.y
= y + (cellHeight / 2)
- sprite.halfHeight + yOffset;
}
//Run any optional extra code. This calls the
//`extra` function supplied by the constructor
if (extra) extra(sprite);
}
};
//Run the `createGrid` method
createGrid();
//Return the `container` group back to the main program
return container;
}
这段代码有点神奇。该计算为网格中的每个单元格找到正确的 x,y 位置:
let x = (i % columns) * cellWidth,
y = Math.floor(i / columns) * cellHeight;
这让您可以使用一个for循环来绘制网格,而不必使用两个嵌套循环。这是一条便捷的捷径。(如果你想知道这到底是为什么,在本章后面的平台游戏例子中会有详细的解释。)
提示这个例子有一个有趣的扩展。如果你需要让一个球看起来在滚动,你可以用球的
vx除以它的radius,然后把结果加到它的rotation,如下所示:ball.rotation += ball.vx / ball.radius;
在这个例子中,你看到了如何让一个移动的球与静止的球互动,但是如果所有的球都在移动,就像在台球或弹珠游戏中一样,那该怎么办呢?
运动圆之间的碰撞
您可以使用movingCircleCollision功能创建两个移动圆之间的碰撞反应。提供两个圆形精灵作为参数:
movingCircleCollision(circle1, circle2)
如果圆圈有一个mass属性,它将用于帮助计算出圆圈相互弹开的力。默认情况下,movingCircleCollision使精灵弹开。
这个函数的一个重要特征是,当两个运动的圆碰撞时,它们以一种使它们非常真实地弹开的方式将它们的速度传递给彼此。这开启了一个全新的游戏世界。你现在已经非常接近能够玩台球或弹珠游戏了。运行marbles.html得到这样一个游戏的工作原型,如图图 7-5 所示。在任何弹球上按住指针,拉动并释放指针,弹开弹球。开始拖移时,弹球和指针之间会出现一条黄线。这条线代表了一种弹力带或弹弓,它将弹球拉向你拉动的相反方向。线的长度决定了弹球移动的力。当你松开弹弓时,弹球会从画布的边缘和所有其他弹球上弹开,导致它们互相反弹。
图 7-5 。拉动并释放以使多个圆圈 在画布周围相互弹开
这里有一些非常有趣的东西,将你目前为止在书中学到的许多技术结合在一起。让我们一步一步来看看这个游戏原型是如何制作的。
制作弹珠
每个大理石是如何制作的?弹珠实际上是来自一个名为marbles.png的单幅拼贴图像的图像,如图 7-6 中的所示。每个单幅图块为 32 x 32 像素。
图 7-6 。大理石瓷砖
这个游戏只使用了前两行图像:六个彩色圆圈。在第四章的中,你学习了如何使用frames函数在 tileset 上捕捉多个图像。以下是如何使用它来捕捉对所有六个彩色圆圈的引用:
let marbleFrames = frames(
assets["img/marbles.png"], //The tileset image
[
[0,0],[32,0],[64,0], //A 2D array that defines the
[0,32],[32,32],[64,32] //x and y image positions
],
32, 32 //The width and height of each image
);
现在可以使用这些帧初始化精灵:
let marble = sprite(marbleFrames);
大理石精灵现在引用了所有六个图像帧,您可以使用gotoAndStop来显示其中的任何一个。下面是如何使用randomInt让大理石显示一个随机帧:
marble.gotoAndStop(randomInt(0, 5));
将弹球的circular属性 设置为true,使其具有碰撞功能所需的diameter和radius属性:
marble.circular = true;
如果你想给弹球一个随机的直径呢?创建一个大小数组,并随机分配一个给弹球的diameter属性 :
let sizes = [8, 12, 16, 20, 24, 28, 32];
marble.diameter = sizes[randomInt(0, 6)];
当然,你不只是制造一个弹珠——游戏原型有 25 个弹珠。所以在网格中初始化它们是有意义的。以下是游戏中的setup函数的所有代码,该函数使用grid函数创建所有 25 个大理石精灵。
marbles = grid(
//Set the grid's properties
5, 5, 64, 64,
true, 0, 0,
//A function that describes how to make each marble
() => {
let marbleFrames = frames(
assets["img/marbles.png"],
[
[0,0],[32,0],[64,0],
[0,32],[32,32],[64,32]
],
32, 32
);
//Initialize a marble with the frames
let marble = sprite(marbleFrames);
//Set the marble to a random frame
marble.gotoAndStop(randomInt(0, 5));
//Give it circular properties (`diameter` and `radius`)
marble.circular = true
//Give the marble a random diameter
let sizes = [8, 12, 16, 20, 24, 28, 32];
marble.diameter = sizes[randomInt(0, 6)];
//Give it a random initial velocity
marble.vx = randomInt(-10, 10);
marble.vy = randomInt(-10, 10);
//Assign the rest of the marble's physics properties
marble.frictionX = 0.99;
marble.frictionY = 0.99;
marble.mass = 0.75 + (marble.diameter / 32);
//Return the marble sprite so that it can
//be added to the grid cell
return marble;
}
);
您可以在这段代码中看到,弹珠也被赋予了随机的初始速度,从–10 到 10:
marble.vx = randomInt(-10, 10);
marble.vy = randomInt(-10, 10);
这意味着一旦他们的位置在游戏循环中更新,他们就会朝不同的方向飞走。代码还计算出每个弹球的mass :
marble.mass = 0.75 + (marble.diameter / 32);
质量较小的较轻弹珠会比质量较大的较重弹珠以更快的速度弹开。
制作弹弓
这个例子的一个关键特征是弹弓效应,当你把指针放在一个弹球上,拖动,然后放开它,你就会得到弹弓效应。弹球逆着你拉动的方向弹开,并反弹到它碰到的任何其他弹球上,产生一个复杂的碰撞链,如图图 7-7 所示。
图 7-7 。在弹球上按下、拖动并释放指针,产生弹弓效果
这个弹弓装置是很多游戏的重要功能,比如愤怒的小鸟,所以让我们快速看看它是怎么做的。
当你按下并拖动一个弹球时,你看到的被称为吊带 的黑线就是一个line精灵。它的visible属性在游戏刚开始的时候被设置为setup函数中的false,这样你就看不到它了:
sling = line("Yellow", 4);
sling.visible = false;
该游戏使用一个名为capturedMarble 的变量来跟踪指针选择了哪个弹球。游戏第一次开始时,它被初始化为null:
capturedMarble = null;
在每一帧中,游戏循环遍历所有的弹珠,并检查与pointer的碰撞。如果pointer是关闭的,并且一个弹球还没有被捕获,代码使用hitTestPoint来发现在pointer下面是否有一个弹球。如果有,代码会将弹球设置为capturedMarble,并将弹球的速度设置为零以阻止其移动:
marbles.children.forEach(marble => {
//Check for a collision with the pointer and marble
if (pointer.isDown && capturedMarble === null) {
if (hitTestPoint(pointer, marble)) {
//If there's a collision, capture the marble
capturedMarble = marble;
capturedMarble.vx = 0;
capturedMarble.vy = 0;
}
}
//... make the marbles move and set screen boundaries...
});
如果一个弹球被捕获,吊索变得可见,并从pointer 被拉到弹球的中心:
if (capturedMarble !== null) {
sling.visible = true;
sling.ax = capturedMarble.centerX;
sling.ay = capturedMarble.centerY;
sling.bx = pointer.x;
sling.by = pointer.y;
}
当pointer被释放时,吊索的长度被转换成速度,使弹球以成比例的速度向相反的方向射出。你拖得越远,弹球的速度就越快。就像橡皮筋一样。这实际上只是我们在前一章中用来发射子弹的代码的一个微小变化。
if (pointer.isUp) {
//Make the sling invisible when it is released
sling.visible = false;
if (capturedMarble !== null) {
//Find out how long the sling is
sling.length = distance(capturedMarble, pointer);
//Get the angle between the center of the marble and the pointer
sling.angle = angle(pointer, capturedMarble);
//Shoot the marble away from the pointer with a velocity
//proportional to the sling's length
let speed = 5;
capturedMarble.vx = Math.cos(sling.angle) * sling.length / speed;
capturedMarble.vy = Math.sin(sling.angle) * sling.length / speed;
//Release the captured marble
capturedMarble = null;
}
}
检查多重碰撞
在每一帧上,你需要检查每个弹球和其他弹球之间的碰撞。你需要确保没有一对弹珠会被检查一次以上的碰撞。实现这一点的关键是使用一个嵌套的 for 循环的**,并使内部循环的计数器比外部循环大 1。下面是嵌套的for循环,它使用movingCircleCollision函数让弹珠相互弹开:**
for (let i = 0; i < marbles.children.length; i++) {
//The first marble to use in the collision check
var c1 = marbles.children[i];
for (let j = i + 1; j < marbles.children.length; j++) {
//The second marble to use in the collision check
let c2 = marbles.children[j];
//Check for a collision and bounce the marbles apart if they collide
movingCircleCollision(c1, c2);
}
}
您可以看到内部循环的起始数字比外部循环大一:
let j = i + 1
这可以防止任何一对对象被多次检查碰撞。
注意
library/collision文件包含一个名为multipleCircleCollision的便利方法,可以自动完成整个嵌套的for循环。您可以在游戏循环中使用它来检查一个数组中的所有精灵和同一数组中的所有其他精灵,而不会重复。像这样使用它:
multipleCircleCollision(marbles.children)
它会自动调用每一对精灵的movingCircleCollision来让它们互相弹开。
你现在知道了使用圆形精灵制作各种游戏所需的大部分重要技术。接下来,我们将看看如何处理矩形精灵之间的碰撞。
注意需要做圆和单点的碰撞检查吗?就把一个点想象成一个直径为 1 个像素,半径为 0.5 个像素的非常小的圆。然后使用那个非常小的圆和任何能和普通圆一起工作的碰撞函数。为了方便起见,
library/collision模块包含了两个“圆对点”函数:hitTestCirclePoint测试碰撞,circlePointCollision将圆从点上弹开。第一个参数应该是 circle sprite,第二个参数应该是具有 x 和 y 属性的 point 对象。
矩形与矩形
要找出两个矩形精灵是否重叠,使用一个名为hitTestRectangle 的函数:
hitTestRectangle(rectangle1, rectangle2)
运行文件rectangleCollision.html获得一个简单的例子。用指针拖动方块,观察输出文本显示“Hit!”当它们碰撞时,如图图 7-8 所示。
图 7-8 。检查矩形之间的冲突
在简单的if语句中使用hitTestRectangle会改变输出文本:
if (hitTestRectangle(rectangle1, rectangle2)) {
output.text = "Hit!";
} else {
output.text = "No collision...";
}
像这样检查矩形之间的碰撞是目前为止游戏中最常见的碰撞检测。事实上,你可以用比hitTestRectangle更复杂的东西制作无数游戏。让我们来仔细看看如何使用它来制作一个简单的对象收集和敌人回避游戏,名为寻宝者 。
寻宝者
寻宝者(图 7-9 )是一个很好的例子,展示了一个最简单的完整游戏,你可以使用我们在本书中已经建立的工具来制作。(通过章节源文件中的treasureHunter.html来感受一下。)使用箭头键帮助探险家找到宝藏,并把它带到出口。六个斑点怪物在地牢墙壁之间上下移动,如果它们击中探险家,他就会变成半透明,右上角的生命值会缩小。如果所有生命值用完,画布上显示“你输了”;如果玩家角色带着宝藏到达出口,“你赢了!”已显示。虽然这是一个基本的原型,寻宝者包含了你在更大的游戏中会发现的大部分元素:纹理贴图、交互性、碰撞和多个游戏场景。让我们快速地看一下它是如何被组合在一起的,这样你就可以用它作为你自己的一个游戏的起点。
图 7-9 。找到宝藏,避开敌人,到达出口
制作游戏精灵
每个精灵最初都是一个单独的图像文件。我用纹理打包器把它们变成了纹理图谱(如图图 7-10 ,用assets.load导入纹理图谱。
图 7-10 。用单独的图像文件制作纹理贴图集
assets.load(["img/treasureHunter.json"]).then(() => setup());
玩家角色、出口门、宝箱和地下城背景图像都是来自纹理贴图帧 的精灵。
//The dungeon background image
dungeon = sprite(assets["dungeon.png"]);
//The exit door
exit = sprite(assets["door.png"]);
exit.x = 32;
//The player character sprite
player = sprite(assets["explorer.png"]);
stage.putCenter(player, -128);
//Create the treasure
treasure = sprite(assets["treasure.png"]);
//Position the treasure next to the right edge of the canvas
stage.putRight(treasure, -64);
所有精灵被组合在一个gameScene : 中
gameScene = group(dungeon, exit, player, treasure);
将它们放在一个组中会让我们很容易隐藏gameScene并在游戏结束时显示gameOverScene。
六个斑点怪物在一个循环中被创建。每个斑点被赋予一个随机的初始位置和速度。对于每个斑点,垂直速度交替乘以 1 或–1,这就是导致每个斑点向与其相邻斑点相反的方向移动的原因:
let numberOfEnemies = 6,
spacing = 48,
xOffset = 150,
speed = 2,
direction = 1;
//An array to store all the enemies
enemies = [];
//Make as many enemies as there are `numberOfEnemies`
for (let i = 0; i < numberOfEnemies; i++) {
//Each enemy is made from a blob texture atlas frame
let enemy = sprite(assets["blob.png"]);
//Space each enemy horizontally according to the `spacing` value.
//`xOffset` determines the point from the left of the screen
//at which the first enemy should be added
let x = spacing * i + xOffset;
//Give the enemy a random y position
let y = randomInt(0, canvas.height - enemy.height);
//Set the enemy's direction
enemy.x = x;
enemy.y = y;
//Set the enemy's vertical velocity. `direction` will be either `1` or
//`-1`. `1` means the enemy will move down and `-1` means the enemy will
//move up. Multiplying `direction` by `speed` determines the enemy's
//vertical direction
enemy.vy = speed * direction;
//Reverse the direction for the next enemy
direction *= -1;
//Push the enemy into the `enemies` array
enemies.push(enemy);
//Add the enemy to the `gameScene`
gameScene.addChild(enemy);
}
你会注意到,当玩家触摸其中一个敌人时,屏幕右上角的生命值条的宽度会减小。这款保健棒是怎么做出来的?它只是两个相同位置的矩形精灵:一个黑色矩形在后面,一个红色矩形在前面。它们被组合在一起,形成一个名为healthBar 的复合精灵。然后将healthBar 添加到gameScene??。
//Make the inner and outer bars
let outerBar = rectangle(128, 8, "black"),
innerBar = rectangle(128, 8, "red");
//Group the inner and outer bars
healthBar = group(outerBar, innerBar);
//Set the `innerBar` as a property of the `healthBar`
healthBar.inner = innerBar;
//Position the health bar
healthBar.x = canvas.width - 164;
healthBar.y = 4;
//Add the health bar to the `gameScene`
gameScene.addChild(healthBar);
您可以看到一个名为inner的属性被添加到了healthBar中。它只是引用了innerBar(红色矩形),以便以后方便访问:
healthBar.inner = innerBar;
你不必包括这个属性;但是为什么不呢!这意味着如果你想控制innerBar的宽度,你可以写一些类似这样的流畅代码:
healthBar.inner.width = 30;
这是非常整洁和可读的,所以我们将保持它!
当游戏结束时,一些文字显示“你赢了!”或者“你输了!”,视结果而定。我们通过使用一个文本精灵来创建这个文本,并将其添加到一个名为gameOverScene的组中。游戏开始时,gameOverScene的visible属性被设置为false,这样你就看不到这段文字了。下面是来自setup函数的代码,它创建了gameOverScene和message文本:
//Add some text for the game over message
message = text("Game Over!", "64px Futura", "black", 20, 20);
message.x = 120;
message.y = canvas.height / 2 - 64;
//Create a `gameOverScene` group and add the message sprite to it
gameOverScene = group(message);
//Make the `gameOverScene` invisible for now
gameOverScene.visible = false;
当游戏结束后,gameOverScene.visible将被设置为true以揭示结果。
移动和包含精灵
使用键盘控制播放器,这样做的代码与您在上一章中学习的键盘控制代码非常相似。键盘对象修改玩家的速度,并且该速度被添加到玩家在游戏循环中的位置。《寻宝者》的一个重要细节是,所有玩家和敌人的精灵都被包含在地下城的墙壁里,以一种与艺术品的 2.5D 视角相匹配的方式。该区域略小于整个画布区域,在图 7-11 中用绿色矩形表示。
图 7-11 。精灵被藏在地牢的墙壁里
借助我们在前一章中编写的自定义函数contain??,这很容易做到。contain函数的第二个参数是一个对象,它定义了应该包含精灵的矩形区域。在《寻宝者》中,这是一个从画布区域偏移并略小于画布区域的区域。
contain(
player,
{
x: 32, y: 16,
width: canvas.width - 32,
height: canvas.height - 32
}
);
游戏循环还会移动敌人,将他们控制在地下城内,并检查每个敌人是否与玩家发生冲突。如果一个敌人撞上了地牢的顶壁或底壁,它的方向就会反转。一个forEach循环在每一帧完成所有这些工作:
//Loop through all the enemies
enemies.forEach(enemy => {
//Move the enemy
enemy.x += enemy.vx;
enemy.y += enemy.vy;
//Check the enemy's screen boundaries
let enemyHitsEdges = contain(
enemy,
{
x: 32, y: 16,
width: canvas.width - 32,
height: canvas.height - 32
}
);
//If the enemy hits the top or bottom of the stage, it reverses
//its direction
if (enemyHitsEdges === "top" || enemyHitsEdges === "bottom") {
enemy.vy *= -1;
}
//Test for a collision. If any of the enemies are touching
//the player, set `playerHit` to `true`
if(hitTestRectangle(player, enemy)) {
playerHit = true;
}
});
最后一个if语句检查敌人和玩家之间的冲突——让我们仔细看看它是如何工作的。
检查碰撞
用于判断是否有敌人接触过玩家。如果hitTestRectangle 返回true,则表示发生了碰撞。然后,代码将名为playerHit的变量设置为true。
if(hitTestRectangle(player, enemy)) {
playerHit = true;
}
如果playerHit为true,游戏循环使玩家半透明,生命值条宽度减少 1 个像素:
if(playerHit) {
//Make the player semitransparent
player.alpha = 0.5;
//Reduce the width of the health bar's inner rectangle by 1 pixel
healthBar.inner.width -= 1;
} else {
//Make the player fully opaque (nontransparent) if it hasn't been hit
player.alpha = 1;
}
游戏循环还检查宝箱和玩家之间的碰撞。如果有击中,宝藏被设置到玩家的位置,稍微偏移,使它看起来像是玩家带着它(图 7-12 )。
if (hitTestRectangle(player, treasure)) {
treasure.x = player.x + 8;
treasure.y = player.y + 8;
}
图 7-12 。玩家角色可以拿起并携带宝箱
到达出口并结束游戏
游戏有两种结束方式:如果你把宝藏带到出口你就能赢,如果你耗尽了生命值你就输了。要赢得游戏,宝箱只需要触摸出口门。如果发生这种情况,包含所有精灵的gameScene将不可见,而显示消息文本的gameOverScene 会显示出来。下面是游戏循环中的if语句:
if (hitTestRectangle(treasure, exit)) {
gameScene.visible = false;
gameOverScene.visible = true;
message.content = "You won!";
}
要输掉游戏,生命值栏的宽度必须小于 0。如果是,那么gameOverScene以同样的方式显示。游戏循环使用这个if语句来检查:
if (healthBar.inner.width < 0) {
gameScene.visible = false;
gameOverScene.visible = true;
message.content = "You lost!";
}
这真的就是全部了!再做一点工作,你就可以把这个简单的原型变成一个完整的游戏——试试吧!
反应矩形碰撞
在前面的例子中,你可以检查两个矩形是否冲突,但是没有什么可以阻止它们重叠。有了一个叫做rectangleCollision 的新函数,我们可以更进一步,让矩形看起来像有实体一样;rectangleCollision将防止其前两个参数中的任何矩形子画面重叠:
rectangleCollision(rectangle1, rectangle2)
rectangleCollision也返回一个字符串,它的值可能是"left"、"right"、"top"或"bottom",告诉你第一个矩形的哪一侧接触了第二个矩形。您可以将返回值赋给一个变量,并在游戏中使用该信息。以下是如何:
let collision = rectangleCollision(rectangle1, rectangle2);
//On which side of the red square is the collision occurring?
switch (collision) {
case "left":
message.content = "Collision on left";
break;
case "right":
message.content = "Collision on right";
break;
case "top":
message.content = "Collision on top";
break;
case "bottom":
message.content = "Collision on bottom";
break;
default:
message.content = "No collision...";
}
collision的默认值为undefined。
这段代码防止矩形重叠,并在message文本精灵中显示碰撞边。运行reactiveRectangles.html作为工作示例,如图图 7-13 所示。使用指针将红色方块拖到蓝色方块中。无论你如何努力,方块将保持清晰的分离,永远不会重叠。输出文本显示碰撞发生在红色方块的哪一侧。
图 7-13 。方块不会重叠,输出文本会告诉您碰撞的一面
rectangleCollision函数有一个非常有用的副作用。参数中的第二个精灵有能力推开第一个精灵。你可以在例子中看到这种效果,用蓝色方块推动画布周围的红色方块,如图 7-14 所示。
图 7-14 。参数中的第二个精灵可以推动第一个精灵
如果你需要在游戏中加入推方块或滑动方块的功能,你可以这样做。
rectangleCollision函数有第三个可选的布尔参数bounce:
rectangleCollision(rectangle1, rectangle2, true)
如果bounce是true,当第一个精灵和第二个精灵碰撞时,它会使第一个精灵从第二个精灵身上弹开。其默认值为false。(与本章中所有其他碰撞函数一样,如果你想使用精灵的全局坐标,你应该将最后一个可选参数global设置为true。)
像这样精确的矩形碰撞反应是你武器库中最有用的游戏设计工具之一。为了向您展示它有多有用,我们将详细介绍一个实际的例子,它将为您提供许多灵感,让您可以立即着手大量的游戏项目。
制作平台游戏
平台游戏对于游戏设计者来说是一个伟大的技术和创意基准,因为它们给你一个机会来使用你的玩具盒中所有的游戏设计玩具。如果你能解决制作平台游戏需要克服的所有挑战,你会发现缩小规模并使用相同的技术制作许多其他类型的 2D 动作游戏很容易。
让我们利用本书中迄今为止已经创建的所有工具来构建一个极简平台游戏原型。运行platforms.html文件,如图 7-15 中的所示,尝试工作示例。用方向键左右移动红色方块,用空格键跳跃。你可以在岩石平台(黑色方块)或草地平台(绿色方块)上跳跃。草地平台总是在岩石平台之上。收集宝藏(黄色方块)以增加您的分数。游戏关卡地图是程序化生成的,所以每次玩都不一样。
图 7-15 。围绕一个程序化生成的平台游戏跑跳
这个游戏程序有两个主要部分。第一个控制平台跳跃的机制和碰撞检测的使用方式。第二部分是使用一些简单的规则随机创建游戏关卡的方式。我们先来看看游戏机制,然后看看关卡是如何创建的。在本节的最后,你将学习如何将这些简单的形状精灵替换为图像精灵,这样你就可以轻松地定制游戏的外观。
平台碰撞
游戏使用rectangleCollision功能来防止玩家角色(红色方块)从平台(黑色和绿色方块)上掉落。当玩家降落在一个平台上时,代码需要通过将玩家的速度设置为零来停止玩家。游戏还需要知道玩家何时站在“地上”这个游戏中的“地面”是任何平台的顶面。当玩家站在一个平台的顶部时,我们需要将一个名为isOnGround的变量设置为false,并通过从玩家的速度中减去重力来抵消重力的影响。
下面是游戏循环中完成这一切的代码。它遍历所有的平台,并使用rectangleCollision函数来找出玩家是否接触了任何平台。如果是,代码会防止玩家掉下去。代码还使用rectangleCollision'的返回值("left"、"right"、"top"或"bottom"来找出玩家触摸平台的哪一侧。
world.platforms.forEach(platform => {
//Use `rectangleCollision` to prevent the player and platforms
//from overlapping
let collision = rectangleCollision(player, platform);
//Use the `collision` variable to figure out what side of the player
//is hitting the platform
if (collision) {
if(collision === "bottom" && player.vy >= 0) {
//Tell the game that the player is on the ground if
//it's standing on top of a platform
player.isOnGround = true;
//Neutralize gravity by applying its
//exact opposite force to the character's vy
player.vy = -player.gravity;
}
else if(collision === "top" && player.vy <= 0) {
player.vy = 0;
}
else if(collision === "right" && player.vx >= 0) {
player.vx = 0;
}
else if(collision === "left" && player.vx <= 0) {
player.vx = 0;
}
//Set `isOnGround` to `false` if the bottom of the player
//isn't touching the platform
if(collision !== "bottom" && player.vy > 0) {
player.isOnGround = false;
}
}
});
玩家每捡起一个宝藏(黄色方块),宝藏就会从游戏中消失,分数增加一,如图图 7-16 所示。代码通过遍历每个平台精灵并使用hitTestRectangle 来检查碰撞来实现这一点。
图 7-16 。收集宝藏以增加分数
world.treasure = world.treasure.filter(box => {
//Check for a collision between the player and the treasure
if (hitTestRectangle(player, box)){
//Increase the score by 1
score += 1;
//Remove the treasure sprite
remove(box);
//Remove the treasure from the array
return false;
} else {
//Keep the treasure in the array
return true;
}
});
//Display the score
message.content = `score: ${score}`;
现在你知道了游戏中的碰撞检测是如何工作的,那么玩家是如何移动的呢?
让玩家移动和跳跃
这个平台游戏原型使用了你在前面章节中学到的所有物理力。它还增加了一个新功能:当你按下空格键时应用的jumpForce。jumpForce 是player精灵的属性:
player.jumpForce = -6.8;
它被设置为一个负数,让玩家向画布的顶部跳去。(记住,负 y 力使事情向上发展。)当玩家按下空格键,这个jumpForce就加到玩家的垂直速度上(vy)。找到使玩家的跳跃看起来自然的正确数字真的只是一个试错的问题。
只有当玩家站在平台上时,才允许他跳跃。幸运的是,我们为平台碰撞设置的isOnGround变量可以告诉我们这一点。此外,如果按下左右箭头键,玩家的移动应该不会受到摩擦的影响,这样它就可以在平台表面上平稳地移动。但是如果玩家在空中移动呢?一些风阻应该会使它慢一点,这样跳跃就更容易控制。这些都是微妙的细节,但最终的代码并不复杂。下面是来自setup函数的代码,它创建了玩家的键盘控件 。
leftArrow = keyboard(37);
rightArrow = keyboard(39);
space = keyboard(32);
//Left arrow key
leftArrow.press = () => {
if(rightArrow.isUp) {
player.accelerationX = -0.2;
}
};
leftArrow.release = () => {
if(rightArrow.isUp) {
player.accelerationX = 0;
}
};
//Right arrow key
rightArrow.press = () => {
if(leftArrow.isUp) {
player.accelerationX = 0.2;
}
};
rightArrow.release = () => {
if(leftArrow.isUp) {
player.accelerationX = 0;
}
};
//Space key (jump)
space.press = () => {
if(player.isOnGround) {
player.vy += player.jumpForce;
player.isOnGround = false;
player.frictionX = 1;
}
};
然后,游戏循环通过更新这些物理属性并将其应用于玩家的位置来使玩家移动:
//Regulate the amount of friction acting on the player
if (player.isOnGround) {
//Add some friction if the player is on the ground
player.frictionX = 0.92;
} else {
//Add less friction if it's in the air
player.frictionX = 0.97;
}
//Apply the acceleration
player.vx += player.accelerationX;
player.vy += player.accelerationY;
//Apply friction
player.vx *= player.frictionX;
//Apply gravity
player.vy += player.gravity;
//Move the player
player.x += player.vx;
player.y += player.vy;
这些都是创建大多数平台游戏需要知道的基本机制。但是实际的游戏世界是怎么创造出来的呢?
创造游戏世界
程序的setup功能创造了游戏世界。所有的关卡数据都存储在一个名为level的对象中,这个对象描述了游戏世界有多大。这个世界是由一个由瓷砖组成的网格构成的:16 块横向瓷砖和 16 块纵向瓷砖。每个拼贴宽 32 像素,高 32 像素,这意味着世界的像素大小为 512 乘 512。每块瓷砖的尺寸与我们用来创造世界的精灵的最大尺寸相匹配。
level = {
//The height and width of the level, in tiles
widthInTiles: 16,
heightInTiles: 16,
//The width and height of each tile, in pixels
tilewidth: 32,
tileheight: 32
};
在一个更复杂的游戏中,你可以在level对象中添加特定于游戏级别的其他类型的数据。这些属性可以存储特定项目的位置、使级别更容易或更难的值,或者应该创建的级别的大小和类型。如果你正在创建一个多级游戏,你可以在游戏中的每个级别使用不同的级别对象。然后你可以在一大堆游戏关卡中存储和访问它们,你可以随着游戏的进展动态地加载和创建它们。
这个等级数据然后被用来制作游戏世界:
world = makeWorld(level);
什么是world,makeWorld是如何工作的?world是一个由makeWorld返回的群组,包含游戏中所有的精灵。makeWorld功能基本上只是创建一个组,给它添加游戏精灵,然后把这个组返回给游戏程序。所有这些都发生在游戏循环开始运行之前的setup函数中。
makeWorld函数有很多工作要做,所以在我们看细节之前,让我们先来鸟瞰一下它做了什么。
function makeWorld(level) {
//create the `world` object
let world = group();
//Add some arrays to the world that will store the objects that we're
//going to create
world.map = [];
world.itemLocations = [];
world.platforms = [];
world.treasure = [];
//Initialize a reference to the player sprite
world.player = null;
//1\. Make the map
makeMap();
//2\. Terraform the map
terraformMap();
//3\. Add the items
addItems();
//4\. Make the sprites
makeSprites();
//The four functions that do all the work:
function makeMap() {/* Make the map */}
function terraformMap() {/* Add grass, rock, sky and clouds */}
function addItems() {/* Add the player and treasure to the map */}
function makeSprites() {/* Use the map data to make the actual game sprites */}
//Return the `world` group back to the main program
return world;
}
可以看到makeWorld有条不紊地依次调用了四个函数:makeMap、terraformMap、addItems、makeSprites。就像一条小流水线。这些函数中的每一个都做一点工作,然后将工作交给下一个函数继续。当最后一个makeSprites完成时,所有的精灵都被制作完成,并且world组返回到主游戏程序。这些都是按顺序发生的,所以让我们来看看每个函数是如何工作的。
制作地图
第一个函数makeMap ,用随机单元格填充地图数组。这些单元格只是人们熟悉的普通 JavaScript 对象:
cell = {};
每个单元格都有 x 和 y 属性,根据级别的宽度和高度表示它在网格上的位置。在这个例子中,它们代表 16×16 的单元网格。单元格有一个terrain属性,可以是"rock"或"sky"。每个细胞有 25%的几率是石头,75%的几率是天空,这是由一个叫做cellIsAlive的辅助函数决定的。这些单元格还有一个名为item的属性,我们将在后面的步骤中使用它来放置游戏物品:玩家和宝箱。
function makeMap() {
//The `cellIsAlive` helper function.
//Give each cell a 1 in 4 chance to live. If it's "alive", it will
//be rock, if it's "dead" it will be sky.
//`cellIsAlive` will be `undefined` unless the random number is 0
let cellIsAlive = () => randomInt(0, 3) === 0;
//First, figure out the number of cells in the grid
let numberOfCells = level.heightInTiles * level.widthInTiles;
//Next, create the cells in a loop
for (let i = 0; i < numberOfCells; i++) {
//Figure out the x and y position
let x = i % level.widthInTiles,
y = Math.floor(i / level.widthInTiles);
//Create the `cell` object
let cell = {
x: x,
y: y,
item: ""
};
//Decide whether the cell should be "rock" or "sky"
if (cellIsAlive()) {
cell.terrain = "rock";
} else {
cell.terrain = "sky";
}
//Push the cell into the world's `map` array
world.map.push(cell);
}
}
该函数运行后,map数组将包含 256 个cell对象,其中 25%的对象将随机将其地形设置为"rock",其余的设置为"sky"。它们的x和y属性也会告诉你它们在 16 乘 16 的网格上的位置。图 7-17 显示了一个随机地图的例子。(我们还没有为这些单元格创建精灵,所以图 7-17 只是展示了我们创建的数组数据。)
图 7-17 。创建岩石(黑色方块)和天空(蓝色方块)的随机贴图
这是一个好的开始,但是你会看到我们如何在接下来的步骤中改进这张地图。
将 1D 阵列用于 2D 地图
你会注意到,虽然我们正在创建一个 2D 网格单元,我们只使用一个平面,1D 阵列。这与我们在本章开始时编写的grid函数中使用的技术相同,所以让我们仔细看看它是如何工作的。
您可以通过将网格的宽度(16)乘以高度(16)来计算出 1D 数组应该有多长:
let numberOfCells = this.height * this.width;
这将得到 256 个数组元素:16 行和 16 列。
我们不需要使用 2D 数组的原因是每个单元格对象都有存储其在网格上的位置的x和y属性,但是我们不需要可以从 2D 数组中获得的行和列信息。相反,代码使用这个公式将数组的索引计数器i转换为x(列)和y(行)坐标:
x = i % this.width;
y = Math.floor(i / this.width);
x位置总是索引计数器除以网格宽度的余数:i % this.width。y位置总是索引计数器的值除以网格的宽度,余数被截断:Math.floor(i / this.width)。这是一个方便的神奇公式,放在你的后口袋里!
为什么不用 2D 阵?主要是风格问题。通过使用 1D 数组,我们可以消除内部嵌套的for循环。因为每个数组元素都包含一个对象,所以我们可以将网格位置直接存储在该对象上,而不必使用循环索引计数器来计算它。此外,我们可以用描述地图单元格所需的额外属性来包装cell对象。这使它成为地图信息的有效存储容器。当我们继续建造游戏关卡时,你将会看到这将会如何有帮助。
地形图
现在我们已经有了一个随机方格的网格,我们可以改进它,使它更适合平台游戏环境。To terraform 的意思是修改现有的环境——这就是我们接下来要做的。我决定做四件事来改进这张地图:
- 我想在游戏区域周围添加一个边框。
- 我想找到每一块上面有天空细胞的石头。这些岩石应该变成
"grass"细胞。(你可以在完成的原型中看到这些绿色的方块。)草细胞是玩家将能够在上面跳跃的所有平台。 - 我已经决定在每个草细胞上至少要有两个天空细胞。这将使游戏角色很容易自由跳跃,而不会撞到它的头。
- 原来,正上方的格子是放置玩家和宝箱的理想位置。我想把这些单元格都找出来,推到一个名为
itemLocations的数组里,在游戏刚开始的时候用它们来随机定位玩家和宝箱。
(你可以在图 7-18 中看到所有这些改进,以及查找这些单元格的代码。)
图 7-18 。添加要素以改善地图
为此,我们需要遍历map数组并分析每个单元格。我们需要知道它是什么类型的细胞(“岩石”或“草地”),它在网格上的 x/y 位置,以及它周围是什么类型的细胞。map是一个 1D 阵列,但是单元格代表一个 2D 网格。我们如何将单元格的x和y位置转换成正确的数组索引号?我们可以使用这个简单的助手函数,叫做getIndex:
let getIndex = (x, y) => x + (y * level.widthInTiles);
要使用它,在地图数组的方括号内调用getIndex。使用当前单元格的x和y值来定位要查找的单元格。下面是如何使用它来查找直接位于当前单元格左侧的单元格的数组索引:
cellTotheLeft = map[getIndex(cell.x - 1, cell.y)]
下面是如何找到当前单元格上方两个网格单元格的索引号:
cellTwoAbove = map[getIndex(cell.x, cell.y - 2)]
我们现在有一个简单的方法来导航 1D 阵列内的 2D 网格。
注意如果
getIndex试图引用数组中小于 0 或大于数组长度的元素,它将返回undefined。如果您的代码有可能产生未定义的值,比如引用了地图边界之外的单元格,请确保为此添加额外的条件检查。
有了这个技巧,我们可以遍历地图数组中的所有单元,分析它们和它们的相邻单元,并使用这些信息来改进地图。下面是完成这一切的terraformMap函数。阅读注释以了解代码如何工作,并将代码与图 7-18 进行比较以了解它如何改变地图。
function terraformMap() {
//A `getIndex` helper function to convert the cell x and y position to an
//array index number
let getIndex = (x, y) => x + (y * level.widthInTiles);
world.map.forEach((cell, index, map) => {
//Some variables to help find the cells to the left, right, below
//and above the current cell
let cellToTheLeft = world.map[getIndex(cell.x - 1, cell.y)],
cellToTheRight = world.map[getIndex(cell.x + 1, cell.y)],
cellBelow = world.map[getIndex(cell.x, cell.y + 1)],
cellAbove = world.map[getIndex(cell.x, cell.y - 1)],
cellTwoAbove = world.map[getIndex(cell.x, cell.y - 2)];
//If the cell is on the border of the map, change its terrain to "border"
if (cell.x === 0 || cell.y === 0
|| cell.x === level.widthInTiles - 1
|| cell.y === level.heightInTiles - 1) {
cell.terrain = "border";
}
//If the cell isn't on the border, find out if we can
//grow some grass on it. Any rock with a sky cell above
//it should be made into grass. Here's how to figure this out:
else {
//1\. Is the cell a rock?
if (cell.terrain === "rock") {
//2\. Is there sky directly above it?
if (cellAbove && cellAbove.terrain === "sky") {
//3\. Yes there is, so change its name to "grass"
cell.terrain = "grass";
//4\. Make sure there are 2 sky cells above grass cells
//so that it's easy to jump to higher platforms
//without bumping your head. Change any rock cells that are
//2 above the current grass cell to "sky"
if (cellTwoAbove) {
if (cellTwoAbove.terrain === "rock"
|| cellTwoAbove.terrain === "grass") {
cellTwoAbove.terrain = "sky";
}
}
}
}
}
});
//We now have the finished map.
//Next, we're going to loop through the map one more time
//to find all the item location cells and push them into the
//`itemLocations` array. `itemLocations` is a list of cells that
//we'll use later to place the player and treasure on the map
world.map.forEach((cell, index, map) => {
//Is the cell a grass cell?
if (cell.terrain === "grass") {
//Yes, so find the cell directly above it and push it
//into the `itemLocations` array
let cellAbove = world.map[getIndex(cell.x, cell.y - 1)];
world.itemLocations.push(cellAbove);
}
});
}
我们的平台游戏环境现在已经完成,我们有一个名为itemLocations的数组,可以用来放置玩家和宝藏。
注意显然你可以对这张地图做更多的微调,比如确保没有封闭的空间,确保所有的平台都可以到达,找到陷阱和敌人的好地方。继续应用这些相同的原则来为你自己的游戏定制地图。这就像你认为的那样简单。要了解更多关于程序生成的游戏地图,使用细胞自动机对游戏关卡设计做一些研究。
添加游戏物品
将玩家和宝箱添加到游戏中只是从我们在上一步中填充的itemLocations数组中随机选择单元格。然后单元格的item属性被设置为我们希望它包含的任何项目。level对象的addItems方法为我们做到了这一点。
function addItems() {
//The `findStartLocation` helper function returns a random cell
let findStartLocation = () => {
//Randomly choose a start location from the `itemLocations` array
let randomIndex = randomInt(0, world.itemLocations.length - 1);
let location = world.itemLocations[randomIndex];
//Splice the cell from the array so we don't choose the
//same cell for another item
world.itemLocations.splice(randomIndex, 1);
return location;
};
//1\. Add the player
//Find a random cell from the `itemLocations` array
let cell = findStartLocation();
cell.item = "player";
//2\. Add 3 treasure boxes
for (let i = 0; i < 3; i++) {
cell = findStartLocation();
cell.item = "treasure";
}
}
地图现在完成了。最后一步是使用单元格信息来创建我们可以在画布上显示的精灵。
制造精灵
一个名为makeSprites 的函数使用地图数据来创建你在画布上看到的实际精灵。makeSprites函数首先遍历map数组,并使用单元格的属性创建边界、岩石、天空和草地单元格。单元格的 x 和 y 属性只需乘以关卡的cellWidth和cellHeight就可以在画布上的正确位置绘制精灵。边界、岩石和草地单元也被推入到platforms数组中,这样它们就可以用在我们前面看到的平台碰撞代码中。
在这些地形单元制作完成后,代码第二次循环通过map数组来添加玩家和宝藏物品。游戏道具精灵是以地形精灵的一半大小创建的,并位于它们的正上方。
项目精灵将被添加到sprites数组的末尾,这意味着当render函数显示它们时,它们将是最后渲染的精灵。这将使它们在地形精灵的前面重叠。
下面是完成这一切的完整的makeSprites函数:
function makeSprites() {
//Make the terrain
world.map.forEach(cell => {
let mapSprite = rectangle();
mapSprite.x = cell.x * level.tilewidth;
mapSprite.y = cell.y * level.tileheight;
mapSprite.width = level.tilewidth;
mapSprite.height = level.tileheight;
switch (cell.terrain) {
case "rock":
mapSprite.fillStyle = "black";
world.platforms.push(mapSprite);
break;
case "grass":
mapSprite.fillStyle = "green";
world.platforms.push(mapSprite);
break;
case "sky":
mapSprite.fillStyle = "cyan";
break;
case "border":
mapSprite.fillStyle = "blue";
world.platforms.push(mapSprite);
break;
}
});
//Make the game items. (Do this after the terrain so
//that the item sprites display above the terrain sprites)
world.map.forEach(cell => {
//Each game object will be half the size of the cell.
//They should be centered and positioned so that they align
//with the bottom of the cell
if(cell.item !== "") {
let mapSprite = rectangle();
mapSprite.x = cell.x * level.tilewidth + level.tilewidth / 4;
mapSprite.y = cell.y * level.tileheight + level.tilewidth / 2;
mapSprite.width = level.tilewidth / 2;
mapSprite.height = level.tileheight / 2;
switch (cell.item) {
case "player":
mapSprite.fillStyle = "red";
mapSprite.accelerationX = 0;
mapSprite.accelerationY = 0;
mapSprite.frictionX = 1;
mapSprite.frictionY = 1;
mapSprite.gravity = 0.3;
mapSprite.jumpForce = -6.8;
mapSprite.vx = 0;
mapSprite.vy = 0;
mapSprite.isOnGround = true;
world.player = mapSprite;
break;
case "treasure":
mapSprite.fillStyle = "gold";
//Push the treasure into the treasures array
world.treasure.push(mapSprite);
break;
}
}
});
}
这个方法运行后,world对象返回到主程序,游戏开始。
使用图像精灵
我们的平台游戏无非就是大量的数据。代码完全不知道精灵实际上是什么样子。这意味着您可以使用来自map数组的完全相同的数据,而不是制作简单的形状,从 tileset 创建图像精灵。您可以通过启用platforms.html示例文件中的makeImageSprites方法来查看这个例子。游戏以完全相同的方式运行和玩,但是精灵现在是真实的插图,而不是彩色的方块,如图图 7-19 所示。
图 7-19 。使用 tileset 完全自定义游戏的外观
根本不需要改变底层代码,只需使用不同的 tileset 就可以完全改变游戏的外观。看一下下面的makeImageSprites函数的细节,你会发现它是基于我们用纹理贴图集创建图像精灵的所有相同技术,你在这一章已经看过很多例子了。
function makeImageSprites() {
//Make the terrain
world.map.forEach((cell, index, map) => {
let mapSprite,
x = cell.x * level.tilewidth,
y = cell.y * level.tileheight;
switch (cell.terrain) {
case "rock":
mapSprite = sprite(assets["rock.png"]);
mapSprite.setPosition(x, y);
world.platforms.push(mapSprite);
break;
case "grass":
mapSprite = sprite(assets["grass.png"]);
mapSprite.setPosition(x, y);
world.platforms.push(mapSprite);
break;
case "sky":
//Add clouds every 6 cells and only on the top
//80% of the level
let sourceY = 0;
if (index % 6 === 0 && index < map.length * 0.8) {
mapSprite = sprite(assets["cloud.png"]);
} else {
mapSprite = sprite(assets["sky.png"]);
}
mapSprite.setPosition(x, y);
break;
case "border":
mapSprite = rectangle(level.tilewidth, level.tileheight, "black");
mapSprite.setPosition(x, y);
world.platforms.push(mapSprite);
break;
}
});
//Make the game items
world.map.forEach(cell => {
if (cell.item !== "") {
let mapSprite,
x = cell.x * level.tilewidth + level.tilewidth / 4,
y = cell.y * level.tileheight + level.tilewidth / 2,
width = level.tilewidth / 2,
height = level.tileheight / 2;
switch (cell.item) {
case "player":
mapSprite = sprite(assets["cat.png"]);
mapSprite.width = width;
mapSprite.height = height;
mapSprite.setPosition(x, y);
mapSprite.accelerationX = 0;
mapSprite.accelerationY = 0;
mapSprite.frictionX = 1;
mapSprite.frictionY = 1;
mapSprite.gravity = 0.3;
mapSprite.jumpForce = -6.8;
mapSprite.vx = 0;
mapSprite.vy = 0;
mapSprite.isOnGround = true;
world.player = mapSprite;
break;
case "treasure":
mapSprite = sprite(assets["star.png"]);
mapSprite.width = width;
mapSprite.height = height;
mapSprite.setPosition(x, y);
//Push the treasure into the `treasures` array
world.treasure.push(mapSprite);
break;
}
}
});
}
请注意这段代码是如何为每六个天空图块放置一张云的图像的,以及它是如何限制将云放置在地图的顶部 80%的:
if (index % 6 === 0 && index < map.length * 0.8) { //...
使用这种技术作为起点,给你自己的游戏环境增加一些变化。
提示你的游戏好玩吗?确定无疑的方法是使用简单的基本形状和颜色来构建你的游戏原型。事实上,在这个平台游戏示例中,我使用了与 1982 年的 Commodore 64 相同的字体和调色板,这是我学习制作游戏的第一台计算机之一。如果你的游戏用一堆方块和圆圈玩起来不好玩,那么世界上最好的图形也救不了它。
圆形与矩形
你在游戏中需要的最后一个重要的碰撞检查是找出一个圆形是否撞上了一个矩形。你可以使用一个名为hitTestCircleRectangle 的函数来帮你做到这一点。第一个参数是圆形 sprite,第二个参数是矩形 sprite:
let collision = hitTestCircleRectangle(ball, box);
如果它们在接触,返回值(collision)会告诉你圆碰到矩形的位置。它可以有值"topLeft"、"topMiddle"、"topRight"、"leftMiddle"、"rightMiddle"、"bottomLeft"、"bottomMiddle"或"bottomRight"。如果没有碰撞,它将是undefined。运行circleVsRectangle.html文件以获得一个交互式示例。拖动图形使它们接触,文本会告诉你碰撞发生在哪里。图 7-20 显示了您将看到的内容。
图 7-20 。检查圆和矩形之间的碰撞
下面是来自示例文件的游戏循环的代码,它检查冲突并显示结果:
let collision = hitTestCircleRectangle(ball, box);
if (collision) {
message.content = collision;
} else {
message.content = "No collision..."
}
您可以使用一个名为circleRectangleCollision 的配套功能让一个圆从一个正方形的边或角上反弹回来:
circleRectangleCollision(ball, box, true);
(将可选的第三个参数设置为true会使精灵弹开,将第四个参数设置为true会告诉函数使用精灵的全局坐标。)
运行本章源代码中的bricks.html文件,查看一个工作示例。这和本章前面的“钉子”例子是一样的,除了圆形的钉子被换成了长方形的砖块。(图 7-21 )。球从画布顶部落下,在落在地面上之前在砖块网格周围反弹。
图 7-21 。一个球在砖块网格中弹跳
下面是游戏循环中实现这一点的代码:
bricks.children.forEach(brick => {
circleRectangleCollision(ball, brick, true, true);
});
这就是你开始制作一些真正迷人的游戏所需要知道的一切!
万能命中功能
为了让您的生活更加轻松,library/collision文件包含了一个名为hit的通用碰撞函数。它会自动检测碰撞中使用的精灵种类,并为您选择合适的碰撞功能。这意味着你不需要记住本章中的碰撞函数,你只需要记住一个:hit。
最简单的形式是,你可以像这样使用hit:
hit(spriteOne, spriteTwo)
精灵可以是圆形或矩形。如果您希望它们对碰撞做出反应,以便它们不相交,请将第三个参数设置为true。如果想让它们分开,将第四个参数设置为true。将第五个参数设置为true会使函数使用精灵的全局坐标。
hit(spriteOne, spriteTwo, react, bounce, global)
如果要检查点对象与精灵的碰撞,请使用点作为第一个参数,如下所示:
hit({x: 145, y:65}, sprite)
hit函数还可以让你检查一个精灵和一个精灵数组之间的冲突。只需将数组作为第二个参数:
hit(ball, bricks.children, true, true, true);
你会看到hit自动循环数组中的所有精灵,并根据第一个精灵检查它们。这意味着你不必编写自己的for或forEach循环。
hit函数还返回一个collision对象,其返回值与您正在检查的精灵类型相匹配。例如,如果两个精灵都是矩形,您可以找到发生碰撞的一边,如下所示:
let collision = hit(rectangleOne, rectangleTwo, true);
message.text = `collision side: ${collision}`;
如果没有碰撞,collision将一直是undefined。
最后一个特性是,您可以使用可选的回调函数作为第五个参数。这允许您注入一些额外的代码,这些代码应该在冲突发生时运行。这对于检查单个精灵和精灵数组之间的冲突特别有用。如果有碰撞,回调将运行,您可以访问碰撞返回值和碰撞中涉及的 sprite。下面是我们在本章前面看到的平台游戏例子中如何使用这个特性,在玩家和平台之间进行碰撞检查:
let playerVsPlatforms = hit(
player, world.platforms, true, false, false,
(collision, platform) => {
//`collision` tells you the side on player that the collision occurred on.
//`platform` is the sprite from the `world.platforms` array
//that the player is colliding with
}
);
这是一种进行复杂碰撞检查的简洁方法,它提供了大量的信息和底层控制,但使您不必手动遍历数组中的所有精灵。
如果你想知道hit函数是如何工作的,请翻到附录,那里有详细的解释。本质上,它只是分析参数中提供的精灵种类,并将它们发送到正确的碰撞函数。
摘要
恭喜你,你刚刚从碰撞检测训练营毕业!本章涵盖了 2D 动作游戏中你需要知道的所有最重要的碰撞功能:矩形碰撞、圆形碰撞和点碰撞。事实上,你现在已经掌握了使用 HTML5 和 JavaScript 来重现视频游戏历史上大多数经典游戏的所有技能,而且现在很少有 2D 游戏不是你力所能及的。使用本章中的工作原型作为你自己游戏的起点,再加上一点想象力,你会惊讶于你能做什么。
但是您的工具包中缺少一个重要的工具:关键帧动画。在下一章中,你将学习一些先进的动画技术来帮助你制作富有表现力的动画游戏角色和特效。但是在你翻开新的一页之前,为什么不用你在这一章中学到的所有新技能制作一个游戏呢?你做完后我会在第八章见你!
八、丰富多汁
有了目前为止我们在书中建立的所有工具,你实际上可以开始制作真正的游戏了。但是到目前为止缺少的是游戏开发者所说的果汁:让游戏世界感觉有活力的浮华效果和动画。这一章是关于给你的游戏增加活力的三种重要方法:
- 关键帧动画:让你的游戏角色播放一系列预先渲染的动画帧,就像电影胶片一样。
- 粒子效果:使用大量的微小粒子创造爆炸或水流效果。
- 平铺精灵:这是一种快速简单的方法来添加无限滚动背景,尤其是创建视差深度效果。
通过仔细使用这些效果,你甚至可以将一个非常简单的游戏变成一个引人入胜的虚拟世界,让你的玩家无法抗拒。
关键帧动画
在第二章中,你学会了如何通过交互改变小精灵的 x 和 y 位置来移动它们。这是一种叫做脚本动画的动画技术:使用数学公式让事物移动。在这一章你将学习另一种动画技术,叫做关键帧动画 。关键帧动画显示一系列预渲染图像,使精灵看起来像是在执行某个操作。这些可以是改变精灵外观的任何动作,比如改变它的颜色,把它打碎,或者移动它的脚让它行走。关键帧动画是关于当精灵的状态改变时改变它的样子。如果您将脚本动画(更改精灵的位置)与关键帧动画(更改精灵的外观)相结合,您可以开始开发丰富而复杂的精灵交互性。
在本章的第一部分,我们将详细了解如何在精灵上播放和控制一组动画序列。但在此之前,让我们先来看看这个过程的第一步:如何改变一个精灵的状态。
改变状态
图 8-1 显示了一个名为states.png 的 tileset。它包含一个 elf 字符,显示为四种状态:上、左、下和右。每个状态由一个图像帧表示。
图 8-1 。具有四种字符状态的 tileset
想象一下,你正在创建一个游戏,这个精灵角色应该根据你按下的箭头键改变它面对的方向。你怎么能这么做?
第一步是创建一个帧数组:图像中的四个帧各一个。你可以用你在《??》第四章中学到的frames方法来做这件事。
let elfFrames = frames(
assets["img/states.png"], //The tileset image to use
[[0,0], [0,64], [0,128], [0, 192]], //Array of x/y positions of each frame
64, 64 //The width and height of each frame
);
elfFrames现在是一个数组,包含四个帧,匹配 elf 的每个图像状态。(你会在本书的源文件中的library/display中找到frames方法。)
现在使用elfFrames数组制作一个精灵:
elf = sprite(elfFrames);
接下来,定义四个状态属性 : up、left、down和right。它们被包裹在一个叫做states的物体里。给每一个赋予一个与它在数组中的帧的索引号相对应的值。
elf.states = {
up: 0,
left: 1,
down: 2,
right: 3
};
然后只需使用gotoAndStop来显示您想要显示的状态:
elf.gotoAndStop(elf.states.right);
那太容易了吗?嘿,享受简单吧!
这些只是静态的图像状态,但是在大多数游戏中你会想要做一些比这更复杂的事情。用一系列动画序列加载精灵,然后根据精灵在游戏中的表现有选择地播放这些序列,这不是很好吗?如果精灵在行走,播放它的行走动画;如果它在跳跃,播放它的跳跃动画。这是你希望大多数游戏精灵都具备的一个基本特性。让我们构建自己的动画状态播放器来实现这一点。
创建状态播放器
我们将分两个阶段构建我们的状态播放器。在第一阶段,我们将只使用它来显示一个静态图像状态,就像我们在上一节中所做的那样。这只是给你一个它是如何工作的基本概念。在下一节中,我们将修改它,使我们的精灵可以播放连续的帧序列。
在我们创建状态播放器之前,让我们先来看看当我们完成后你将如何使用它。如果你想显示 elf 的left状态,你可以使用一种叫做show的新方法:
elf.show(elf.states.left);
show方法只是根据我们在状态中定义的值调用 sprite 上的gotoAndStop。我们如何设置它?
我们将借助一个名为addStatePlayer的函数向 sprite 添加show方法。它的工作是创建show方法并将其添加到精灵中。它是这样做的:
function addStatePlayer(sprite) {
//The `show` function (to display static states)
function show(frameNumber) {
//Find the new state on the sprite
sprite.gotoAndStop(frameNumber);
}
//Add the `show` method to the sprite
sprite.show = show;
}
您可以看到该函数将 sprite 作为参数,创建了show方法,然后在最后一行中,将show方法添加到 sprite 中。
现在,您可以使用以下语句将状态播放器应用到 sprite:
addStatePlayer(elf);
elf对象现在有了自己的新方法,名为show:
elf.show(anyFrameNumber)
show方法只是gotoAndStop的包装器。这本身不是很有用,但是在本章的后面,我们将使用这个基本的addStatePlayer函数作为构建更复杂的东西的垫脚石。在此之前,让我们来看看如何在游戏中改变精灵的静态图像状态。
运行本章源文件中的statePlayer.html文件,获得一个工作示例。使用箭头键在画布上移动小精灵,如图 8-2 中的所示。
图 8-2 。使用箭头键使精灵移动和改变方向
下面是使这种状态改变策略起作用的代码:
function setup() {
//...Create the sprite...
//Create the keyboard objects
let leftArrow = keyboard(37),
upArrow = keyboard(38),
rightArrow = keyboard(39),
downArrow = keyboard(40);
//Assign key `press` methods
leftArrow.press = () => {
//Display the elf's new state and set its velocity
elf.show(elf.states.left);
elf.vx = -1;
elf.vy = 0;
};
upArrow.press = () => {
elf.show(elf.states.up);
elf.vy = -1;
elf.vx = 0;
};
rightArrow.press = () => {
elf.show(elf.states.right);
elf.vx = 1;
elf.vy = 0;
};
downArrow.press = () => {
elf.show(elf.states.down);
elf.vy = 1;
elf.vx = 0;
};
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Move the elf
elf.x += elf.vx;
elf.y += elf.vy;
//Render the canvas
render(canvas);
}
能够以这种方式设置精灵状态真的很容易做到,并且在游戏中有广泛的应用。对于许多不需要复杂动画的游戏,像这样的简单状态机可能就是你所需要的。只是gotoAndStop!
但是,如果你想通过移动精灵的胳膊和腿来让它看起来像是在走路,那该怎么办呢?
播放帧
您可以按顺序播放一系列图像帧来创建动画动作,而不是只显示给定状态的一个图像。图 8-3 显示了我们的 elf 角色行走的九帧序列。
图 8-3 。动画序列
拥有可以用来控制动画的名为play和stop的方法不是很好吗?我们来看看如何为游戏搭建这样一个角色动画播放器。
在第六章中,你学习了如何设置每秒帧数和计算帧速率,如下所示:
fps = 12
frameRate = 1000 / this.fps
如果fps是 12,那么frameRate大约是 83 毫秒。
对于我们的角色动画播放器,我们将使用 JavaScript 的setInterval 定时器来控制连续帧显示的速度。使用setInterval的好处是让你使用一个独立于游戏帧速率的帧速率来制作角色动画。这也意味着你可以在同一个游戏中对不同种类的动画使用不同的帧率。您可以使用setInterval以设定的间隔推进动画帧。在这个例子中,我们将使用setInterval每 83 毫秒运行一次名为advanceFrame 的函数。以下是如何:
let timerInterval = setInterval(advanceFrame, frameRate);
advanceFrame的作用是显示动画序列中的下一帧:
function advanceFrame() {
sprite.gotoAndStop(sprite.currentFrame + 1);
}
这将每隔 83 毫秒显示 sprite 的frames数组中的下一幅图像。这就是事情的全部。
这些是关键帧动画的绝对基础。但是在实践中,你需要添加更多的功能来为游戏制作一个完全健壮和灵活的系统。
添加功能
要构建功能全面的动画帧播放器,您需要解决以下问题:
- 如何让动画在到达最后一帧时停止?或者说,你怎么能让它循环回到起点?
- 如何播放和循环特定范围的帧?例如,如果您的角色的完整动画是 36 帧,但您只想在 10 帧和 17 帧之间循环,如何才能做到这一点?
- 如果你想停止和重新开始一个动画,你必须清除当前的时间间隔,并重新设置动画开始。怎么做?
你的精灵也需要属性来帮助你控制他们的动画。在第四章中,我们给每个精灵的父类添加了一些属性来帮助我们完成这些任务:
this.frames = [];
this.loop = true;
this._currentFrame = 0;
get currentFrame() {
return this._currentFrame;
}
当时我告诉你,“现在不要担心这些财产;你以后会发现如何使用它们。”你一直很有耐心的等待,但是“以后”变成了“现在”!因此,让我们找出如何使所有这些工作。
循环播放动画
要制作动画循环,首先需要知道它有多少帧,以及当前正在播放哪一帧。使用一些变量来帮助您跟踪这一点。这些新变量将帮助您开始:
startFrame, endFrame, numberOfFrames, frameCounter
如果你要在一个帧范围内循环,你需要知道那些帧号是什么。例如,如果您想遍历 1 和 8 之间的所有帧,您可以使用这些startFrame和endFrame值:
startFrame = 1;
endFrame = 8;
然后使用这些值计算总帧数:
numberOfFrames = endFrame - startFrame;
numberOfFrames的值将是 7。因为我们从 0 开始编号帧,所以 7 号帧实际上将是序列中的第八帧。
图 8-4 显示了帧序列如何工作的例子。我们精灵动画的九帧从 0 到 8 编号。第一帧,0,只是显示了小精灵静止不动时的样子。第 1 帧到第 8 帧显示了小精灵行走时的样子。如果我们想让小精灵看起来像是在行走,我们必须排除第 0 帧,并且在连续的循环中只播放第 1 帧到第 8 帧。
图 8-4 。帧的子序列
很快您就会看到,我们将使用这些startFrame和endFrame值来播放这个循环的子帧序列。
我们还需要计算动画播放时已经过去的帧数;所以一个frameCounter变量可以帮助跟踪这个:
frameCounter = 0;
我们是否希望动画循环播放?我们可以使用已经内置到 sprites 中的布尔属性loop来确定这一点(它的默认值是true)。
这是我们新的advanceFrame函数,它实现了循环特性。如果loop是true,将从startFrame重新开始动画。如果loop是false,它会停在最后一帧。
function advanceFrame() {
//Advance the frame if `frameCounter` is less than the total frames
if (frameCounter < numberOfFrames) {
//Advance the frame
sprite.gotoAndStop(sprite.currentFrame + 1);
//Update the frame counter
frameCounter += 1;
//If we've reached the last frame and `loop`
//is `true`, then start from the first frame again
} else {
if (sprite.loop) {
sprite.gotoAndStop(startFrame);
frameCounter = 1;
}
}
}
现在我们有了一个简单的动画循环系统。
重置动画
在一个真正的游戏开发项目中,你不会只是运行一次动画,然后就忘记它;更有可能的是,您需要多次启动、停止和重启它。你可能还会有许多其他的动画可以同时播放。因此,能够跟踪动画当前是否正在播放是一个好主意,这样您就可以微调它的开始和停止条件。在的第四章中,我们在DisplayObject类中创建了一个名为playing的属性来帮助管理它。
this.playing = false;
现在,在您开始一个新的动画之前,您可以检查这个变量以确保动画还没有开始播放。如果不是,启动它,然后将playing设置为true:
if(!sprite.playing) {
timerInterval = setInterval(advanceFrame, frameRate);
sprite.playing = true;
}
如果您已经播放了一次动画,然后需要重新启动它,您必须将其重置回初始状态。您还需要清除timerInterval以便创建一个新的定时器。这里有一个reset函数完成所有这些事情。
function reset() {
if (timerInterval !== undefined && sprite.playing === true) {
sprite.playing = false;
frameCounter = 0;
startFrame = 0;
endFrame = 0;
numberOfFrames = 0;
clearInterval(timerInterval);
}
}
现在,您可以从头开始播放动画了。
这些都是你需要知道的为游戏建立一个健壮的精灵动画的重要概念。但是我们怎样才能把这一切付诸实践呢?
改进addStatePlayer功能
在本章的前面,我们构建了一个有趣的小函数,叫做addStatePlayer,当我们按下箭头键时,它可以改变精灵的图像状态。我们将通过给它一些新方法来改进它。
如果你想播放精灵的帧数组中的所有帧,使用play方法。
elf.play();
如果精灵的loop属性为true,这些帧将从头到尾播放并循环播放。如果你想让动画停止,使用stop方法:
elf.stop();
如果您只想播放特定范围的帧,请使用名为playSequence的方法。例如,如果您想播放第 10 帧到第 17 帧之间的所有帧,可以使用以下语句:
elf.playSequence([10, 17]);
如果 sprite 的loop属性为true,该序列将循环。
您可以通过以下方式设定动画的每秒帧数:
elf.fps = 12;
这是一个完整的新的addStatePlayer函数,带有解释每个部分如何工作的注释。(你会在library/display文件夹中找到工作代码。)本质上,所有这些新代码都与我们刚刚看到的基本动画代码相同。
function addStatePlayer(sprite) {
let frameCounter = 0,
numberOfFrames = 0,
startFrame = 0,
endFrame = 0,
timerInterval = undefined;
//The `show` function (to display static states)
function show(frameNumber) {
//Reset any possible previous animations
reset();
//Find the new state on the sprite
sprite.gotoAndStop(frameNumber);
}
//The `play` function plays all the sprite's frames
function play() {
playSequence([0, sprite.frames.length - 1]);
}
//The `stop` function stops the animation at the current frame
function stop() {
reset();
sprite.gotoAndStop(sprite.currentFrame);
}
//The `playSequence` function, to play a sequence of frames
function playSequence(sequenceArray) {
//Reset any possible previous animations
reset();
//Figure out how many frames there are in the range
startFrame = sequenceArray[0];
endFrame = sequenceArray[1];
numberOfFrames = endFrame - startFrame;
//Compensate for two edge cases:
//1\. If the `startFrame` happens to be `0`
if (startFrame === 0) {
numberOfFrames += 1;
frameCounter += 1;
}
//2\. If only a two-frame sequence was provided
if(numberOfFrames === 1){
numberOfFrames = 2;
frameCounter += 1;
};
//Calculate the frame rate. Set the default fps to 12
if (!sprite.fps) sprite.fps = 12;
let frameRate = 1000 / sprite.fps;
//Set the sprite to the starting frame
sprite.gotoAndStop(startFrame);
//If the state isn't already `playing`, start it
if(!sprite.playing) {
timerInterval = setInterval(advanceFrame.bind(this), frameRate);
sprite.playing = true;
}
}
//`advanceFrame` is called by `setInterval` to display the next frame
//in the sequence based on the `frameRate`. When the frame sequence
//reaches the end, it will either stop or loop
function advanceFrame() {
//Advance the frame if `frameCounter` is less than
//the state's total frames
if (frameCounter < numberOfFrames) {
//Advance the frame
sprite.gotoAndStop(sprite.currentFrame + 1);
//Update the frame counter
frameCounter += 1;
//If we've reached the last frame and `loop`
//is `true`, then start from the first frame again
} else {
if (sprite.loop) {
sprite.gotoAndStop(startFrame);
frameCounter = 1;
}
}
}
function reset() {
//Reset `sprite.playing` to `false`, set the `frameCounter` to 0,
//and clear the `timerInterval`
if (timerInterval !== undefined && sprite.playing === true) {
sprite.playing = false;
frameCounter = 0;
startFrame = 0;
endFrame = 0;
numberOfFrames = 0;
clearInterval(timerInterval);
}
}
//Add the `show`, `play`, `stop`, and `playSequence` methods to the sprite
sprite.show = show;
sprite.play = play;
sprite.stop = stop;
sprite.playSequence = playSequence;
}
我们还应该做一件事。这些动画方法非常有用,如果将它们自动添加到任何有多个图像帧的 sprite 中,会很有帮助。为此,我们需要修改库/显示模块中的子画面函数,该函数创建并返回每个子画面。让它为任何在其frames数组中有多个元素的 sprite 调用这个新的addStatePlayer函数:
export function sprite(source, x, y) {
let sprite = new Sprite(source, x, y);
if (sprite.frames.length > 0) addStatePlayer(sprite);
stage.addChild(sprite);
return sprite;
}
太好了,我们都准备好了!我们如何在一个实际的游戏项目中使用我们刚刚探索的所有技术?
打造行走精灵
运行animation.html文件,获得一个使用这些新技术制作行走精灵的交互式示例。使用箭头键让小精灵在森林景观中漫步。四个不同的行走周期动画匹配小精灵可以行走的四个方向。当释放按键时,精灵停止并面向它移动的方向。图 8-5 说明了你将会看到的东西。
图 8-5 。动画行走精灵
捕捉帧
elf 的动画基于包含所有帧的单一 tileset 图像,如图 8-6 所示。
图 8-6 。tileset 图像包含所有动画帧
在动画精灵之前,您需要一个包含所有这些帧的数组作为单独的图像。您知道您可以使用frames函数将一组帧位置值转换成一组图像。但是这个 tileset 中有 36 帧,跨越四行,所以您肯定不想手工输入这些位置值。让我们使用一个名为filmstrip 的新自定义函数,它为我们计算出每一帧的 x / y 位置,并返回所有动画帧:
export function filmstrip(image, frameWidth, frameHeight, spacing = 0){
//An array to store the x and y positions of each frame
let positions = [];
//Find out how many columns and rows there are in the image
let columns = image.width / frameWidth,
rows = image.height / frameHeight;
//Find the total number of frames
let numberOfFrames = columns * rows;
for(let i = 0; i < numberOfFrames; i++) {
//Find the correct row and column for each frame
//and figure out its x and y position
let x = (i % columns) * frameWidth,
y = Math.floor(i / columns) * frameHeight;
//Compensate for any optional spacing (padding) around the frames if
//there is any. This bit of code accumulates the spacing offsets from the
//left side of the tileset and adds them to the current tile's position
if (spacing && spacing > 0) {
x += spacing + (spacing * i % columns);
y += spacing + (spacing * Math.floor(i / columns));
}
//Add the x and y value of each frame to the `positions` array
positions.push([x, y]);
}
//Create and return the animation frames using the `frames` method
return frames(image, positions, frameWidth, frameHeight);
};
(您将在library/display文件中找到filmstrip函数。)
你现在可以使用这个filmstrip函数来创建一个精灵的frames数组。提供要使用的图像、每个框架的宽度和高度以及框架之间的任何可选间距作为参数:
let elfFrames = filmstrip(assets["img/walkcycle.png"], 64, 64);
然后使用elfFrames初始化精灵:
elf = sprite(elfFrames);
精灵现在已经加载了 36 帧,可以开始制作动画了。
定义 Elf 的状态
精灵总共有八种状态:四种站立状态和四种行走状态。图 8-7 显示了具有这八种状态的 tileset。插图中的黑线定义了各州的边界。
图 8-7 。tileset 包含 sprite 的八种状态
up、left、down和right状态是静态的,这意味着它们不包含任何动画帧。我们将使用show方法显示它们,就像我们在本章开始时所做的那样。walkUp、walkLeft、walkDown和walkRight状态是动画,我们将使用playSequence来显示它们。walkLeft和walkRight动画的第一帧也恰好是左右静态,这就是为什么在图 8-7 中有虚线将它们分开。
这里是精灵的状态,设置这一切:
elf.states = {
up: 0,
left: 9,
down: 18,
right: 27,
walkUp: [1, 8],
walkLeft: [10, 17],
walkDown: [19, 26],
walkRight: [28, 35]
};
接下来,设置 elf 的帧速率:
elf.fps = 12;
现在,您只需要根据哪些键是向上或向下的来判断要显示哪个状态。经过一点试验,您可能会得出类似如下的一些代码:
leftArrow.press = function() {
//Play the elf's `walkLeft` animation sequence
elf.playSequence(elf.states.walkLeft);
elf.vx = -1;
elf.vy = 0;
};
leftArrow.release = function() {
if (!rightArrow.isDown && elf.vy === 0) {
//Show the elf's `left` state
elf.show(elf.states.left);
elf.vx = 0;
}
};
其他三个键rightArrow、upArrow和downArrow都遵循相同的格式。
这就是关键帧动画的全部知识吗?差不多吧,是的!我们创建的状态播放器可以在各种不同的游戏中使用,您可以根据需要对其进行定制。将这些技术与你在第六章中学到的脚本动画结合起来,你将可以用无穷无尽的丰富而复杂的精灵来填充你的游戏。
提示你怎么能设计出像我们行走的精灵这样复杂的角色动画呢?你显然需要一些艺术能力和图形设计技能,但是有很多工具可以帮助你。这里有一些你可以尝试的软件:ShoeBox、Spine、Spriter、DragonBones、Animo Sprites、Piskel 和 Flash Professional(如果你使用的是 Flash Professional,将动画导出为 sprite 工作表)。
接下来,让我们来看看如何给你的游戏增加一点魔力。
粒子效果
你如何创造像火、烟、魔法和爆炸这样的效果?你制造了许多小精灵;几十个,几百个或者几千个。然后对这些精灵应用一些物理或重力约束,这样它们的行为就像你试图模拟的元素一样。你还需要给他们一些规则,关于他们应该如何出现和消失,以及他们应该形成什么样的模式。这些小精灵被称为粒子。你可以用它们来制作各种游戏特效。
只需几十行代码,你就可以编写一个通用的粒子效果引擎,这是大多数 2D 动作游戏所需要的。要查看运行中的粒子引擎,运行particleEffect.html 文件,如图图 8-8 所示。点击指针,一个小的星星爆炸在画布上爆发,从指针的位置放射出来。恒星被引力拉下,每一颗都有不同的速度、旋转、褪色和缩放率。
图 8-8 。粒子效果
这是由名为particleEffect的自定义函数创建的,您可以在library/display文件夹中找到它。下面是它的使用方法,包括例子中用来产生爆炸的所有参数。
particleEffect(
pointer.x, //The particle's starting x position
pointer.y, //The particle's starting y position
() => sprite(assets["img/star.png"]), //Particle function
20, //Number of particles
0.1, //Gravity
true, //Random spacing
0, 6.28, //Min/max angle
12, 24, //Min/max size
1, 2, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
);
您可以看到,大多数参数描述了用于更改精灵速度、旋转、缩放或 alpha 的最小值和最大值之间的范围。您还可以指定应该创建的粒子数量,并添加可选的重力。
通过自定义第三个参数,你可以使用任何精灵来制作粒子。只需提供一个函数,返回你想为每个粒子使用的精灵类型:
() => sprite(assets["img/star.png"]),
如果你提供一个有多个帧的精灵,particleEffect函数会自动为每个粒子选择一个随机帧。
当粒子从原点向外辐射时,最小和最大角度值对于定义粒子的圆形扩散非常重要。对于完全圆形的爆炸效果,使用最小角度 0,最大角度 6.28。
0, 6.28
(这些值是弧度;等效度数为 0 度和 360 度。)0 从 3 点钟位置开始,直接指向右边。3.14 是 9 点的位置,6.28 带你绕回 0 再一次。
如果要将粒子范围限制在一个较窄的角度,只需提供描述该范围的最小值和最大值。这里有一些值,你可以用它们来限制披萨饼的角度,使饼皮指向左边。
2.4, 3.6
你可以像这样使用一个受约束的角度范围来创建一个粒子流,就像那些用来创建喷泉或火箭发动机火焰的粒子流。(在本章末尾的示例游戏中,您将会看到具体的操作方法。)随机间距值(第六个参数)决定了粒子在此范围内是应该均匀(false)还是随机(true)分布。
通过仔细选择粒子的精灵并微调每个参数,您可以使用这个通用的particl eEffect函数来模拟从液体到火焰的一切。让我们来看看particleEffect功能到底是如何工作的,以及如何在游戏中使用它。
构建particleEffect函数
游戏中的所有粒子都需要在每一帧更新它们的属性。在你开始制作粒子之前,你需要创建一个单独的particles数组来存储它们。
export let particles = [];
正如你将看到的,我们将通过在每一帧上循环这个数组来更新所有的粒子,这与我们在第六章中更新按钮使用的策略相同。
particleEffect函数获取你指定的所有参数,并使用它们来创建每个粒子。它为每个粒子计算出一个唯一的角度,并使用该角度值来指定粒子的速度以及所有其他属性。如果您提供的 sprite 函数返回一个具有多个图像帧的 sprite,则会为每个粒子选择一个新的随机帧。每个粒子也是用一个update方法创建的,这个方法描述了粒子的属性应该如何改变。这个update方法必须由游戏循环在每个粒子上调用,以使粒子移动、渐变、旋转和缩放。当该函数创建完每个粒子后,它将该粒子推入到particles数组中。
export function particleEffect(
x = 0,
y = 0,
spriteFunction = () => circle(10, "red"),
numberOfParticles = 10,
gravity = 0,
randomSpacing = true,
minAngle = 0, maxAngle = 6.28,
minSize = 4, maxSize = 16,
minSpeed = 0.1, maxSpeed = 1,
minScaleSpeed = 0.01, maxScaleSpeed = 0.05,
minAlphaSpeed = 0.02, maxAlphaSpeed = 0.02,
minRotationSpeed = 0.01, maxRotationSpeed = 0.03
) {
//`randomFloat` and `randomInt` helper functions
let randomFloat = (min, max) => min + Math.random() * (max - min),
randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
//An array to store the angles
let angles = [];
//A variable to store the current particle's angle
let angle;
//Figure out by how many radians each particle should be separated
let spacing = (maxAngle - minAngle) / (numberOfParticles - 1);
//Create an angle value for each particle and push that
//value into the `angles` array
for(let i = 0; i < numberOfParticles; i++) {
//If `randomSpacing` is `true`, give the particle any angle
//value between `minAngle` and `maxAngle`
if (randomSpacing) {
angle = randomFloat(minAngle, maxAngle);
angles.push(angle);
}
//If `randomSpacing` is `false`, space each particle evenly,
//starting with the `minAngle` and ending with the `maxAngle`
else {
if (angle === undefined) angle = minAngle;
angles.push(angle);
angle += spacing;
}
}
//Make a particle for each angle
angles.forEach(angle => makeParticle(angle));
//Make the particle
function makeParticle(angle) {
//Create the particle using the supplied sprite function
let particle = spriteFunction();
//Display a random frame if the particle has more than 1 frame
if (particle.frames.length > 0) {
particle.gotoAndStop(randomInt(0, particle.frames.length - 1));
}
//Set the x and y position
particle.x = x - particle.halfWidth;
particle.y = y - particle.halfHeight;
//Set a random width and height
let size = randomInt(minSize, maxSize);
particle.width = size;
particle.height = size;
//Set a random speed to change the scale, alpha and rotation
particle.scaleSpeed = randomFloat(minScaleSpeed, maxScaleSpeed);
particle.alphaSpeed = randomFloat(minAlphaSpeed, maxAlphaSpeed);
particle.rotationSpeed = randomFloat(minRotationSpeed, maxRotationSpeed);
//Set a random velocity at which the particle should move
let speed = randomFloat(minSpeed, maxSpeed);
particle.vx = speed * Math.cos(angle);
particle.vy = speed * Math.sin(angle);
//The particle's `update` method is called on each frame of the
//game loop
particle.update = () => {
//Add gravity
particle.vy += gravity;
//Move the particle
particle.x += particle.vx;
particle.y += particle.vy;
//Change the particle's `scale`
if (particle.scaleX - particle.scaleSpeed > 0) {
particle.scaleX -= particle.scaleSpeed;
}
if (particle.scaleY - particle.scaleSpeed > 0) {
particle.scaleY -= particle.scaleSpeed;
}
//Change the particle's rotation
particle.rotation += particle.rotationSpeed;
//Change the particle's `alpha`
particle.alpha -= particle.alphaSpeed;
//Remove the particle if its `alpha` reaches zero
if (particle.alpha <= 0) {
remove(particle);
particles.splice(particles.indexOf(particle), 1);
}
};
//Push the particle into the `particles` array.
//The `particles` array needs to be updated by the game loop each frame
particles.push(particle);
}
}
在particleEffect中需要注意的一个重要细节是,如果粒子的alpha值达到零,update方法会将粒子拼接到particles数组之外。它使用以下代码来实现这一点:
if (particle.alpha <= 0) {
remove(particle);
particles.splice(particles.indexOf(particle), 1);
}
(你在第四章的中学习了如何使用remove函数从父精灵中移除任何精灵。)任何时候你写这样的代码,其中一个对象负责自己的删除,你必须非常小心!这无疑是方便的,但总是要停下来问:是否有其他的依赖项需要被通知这个对象已经被移除了?如果有,而您忘记了它们,您可能正在为一些令人紧张的调试会议做准备。
如果物体在一个循环的上下文中把自己从一个数组中拼接出来,这是特别正确的,这就是我们的粒子将要做的。为了安全地做到这一点,在不将循环索引计数器减 1 的情况下,您需要反向循环数组中的所有元素。接下来您将学习如何做到这一点。
在游戏中使用particleEffect功能
要使用这个particleEffect函数,首先将它和particles数组导入到您的游戏程序中:
import {particles, particleEffect} from "../library/display";
然后遍历每一帧上的所有粒子,并为每个粒子调用update方法。您需要反向循环遍历粒子(从数组中的最后一个元素开始),这样,如果其中一个元素被拼接出来,就不会影响循环索引计数器。通过将计数器变量(i)初始化为数组的length,然后在每次迭代中递减它,可以使for循环反向运行。代码如下:
function gameLoop() {
requestAnimationFrame(gameLoop);
if (particles.length > 0) {
//Loop through the particles in reverse
for(let i = particles.length - 1; i >= 0; i--) {
let particle = particles[i];
particle.update();
}
}
render(canvas);
}
当粒子的alpha达到零时,粒子的update方法现在可以安全地移除粒子。
要启动particleEffect,只要您想让效果发生,就用任何自定义参数调用它。在本例中,这发生在调用指针的press方法时:
pointer.press = () => {
particleEffect(
//Assign the particle’s arguments...
);
};
当然,你可以在游戏中的任何时候调用particleEffect。在本章的最后你会看到更多的例子。
particleEffect功能对于创造单次粒子爆发非常有用。但是,如果你想在一个连续的流中产生粒子,就像你想模拟从水龙头流出的水滴或火箭发动机的火焰一样,那该怎么办呢?为此,你需要一个粒子发射器的帮助。
添加粒子发射器
粒子发射器只是一个简单的计时器,它以固定的时间间隔创建粒子。这意味着发射器不是只调用一次particleEffect函数,而是定期调用它。在下一节中,我们将构建一个emitter函数,你可以用它在任何你需要的时间间隔创建一个恒定的粒子流。以下是您可以使用它的方式:
let particleStream = emitter(
100, //The interval
() => particleEffect( //The `particleEffect` function
//Assign particle parameters...
)
);
emitter函数只是包装了我们在上一节中创建的particleEffect函数。它的第一个参数是一个以毫秒为单位的数字,它决定了粒子创建的频率。第二个参数是particleEffect函数,您可以随意定制。
发射器函数返回一个带有play和stop方法的对象,您可以使用它们来控制粒子流。你可以像我们在本章开始时创建的play和stop方法一样使用它们来控制精灵的动画。
particleStream.play();
particleStream.stop();
发射器对象还有一个playing属性,根据发射器的当前状态,该属性可以是true或false。下面是创建发射器对象并向其添加方法和属性的完整函数。(你会在library/display文件夹中找到这个工作代码。)
export function emitter(interval, particleFunction) {
let emitter = {},
timerInterval = undefined;
emitter.playing = false;
function play() {
if (!emitter.playing) {
particleFunction();
timerInterval = setInterval(emitParticle.bind(this), interval);
emitter.playing = true;
}
}
function stop() {
if (emitter.playing) {
clearInterval(timerInterval);
emitter.playing = false;
}
}
function emitParticle() {
particleFunction();
}
emitter.play = play;
emitter.stop = stop;
return emitter;
}
运行particleEmitter.html文件来查看这段代码的运行情况,如图 8-9 中的所示。按住鼠标左键以产生连续的粒子流。当你松开按钮时,水流将停止。
图 8-9 。粒子发射器产生连续的粒子流
下面是来自setup函数的代码,它创建了指针和粒子发射器。当指针被按下时,发射器的play方法被调用,当指针被释放时,发射器的stop方法被调用。
pointer = makePointer(canvas);
let particleStream = emitter(
100, //The timer interval
() => particleEffect( //The function
pointer.x, pointer.y, //x and y position
() => sprite(assets["img/star.png"]), //Particle sprite
10, //Number of particles
0.1, //Gravity
false, //Random spacing
3.14, 6.28, //Min/max angle
16, 32, //Min/max size
2, 5 //Min/max speed
)
);
pointer.press = () => {
particleStream.play();
};
pointer.release = () => {
particleStream.stop();
};
通过以这种方式一起使用particleEffect和emitter函数,您将能够创建游戏所需的大部分粒子爆炸和流效果。
平铺精灵
你将在本章学习的最后一个特效实际上是一种新的精灵:一种平铺精灵。它是一种特殊的矩形,具有重复的平铺背景图像图案。平铺子画面有两个新属性,tileX和tileY,可以控制平铺背景的位置。平铺背景无缝包裹,这样如果你在游戏循环中改变tileX和tileY的值,你就可以创建一个无限滚动的背景效果。
运行tilingSprite.html 文件来查看一个平铺精灵的例子,如图 8-10 中的所示。这是一个简单的矩形,将一个tile.png 图像设置为其重复背景。背景图案从左上向右下连续滚动。
图 8-10 。平铺子画面具有由单个平铺构成的连续重复的背景图案
平铺精灵是用一个叫做tilingSprite的新函数制作的。你可以把它想象成一个矩形精灵,一个图像被指定为它的填充:
box = tilingSprite(128, 128, assets["img/tile.png"]);
拼贴图像可以是图像文件或纹理贴图集帧。因为平铺精灵本质上只是一个普通的矩形精灵,所以你可以像在游戏中使用其他矩形精灵一样使用它。一个重要的区别是它有tileX和tileY属性,可以让你重新定位重复背景图案的原点。通过改变游戏循环中tileX和tileY的值,可以创造出无限的滚动效果:
box.tileY += 1;
box.tileX += 1;
平铺精灵是一个很好的例子,展示了如何在玩具箱中使用许多不同的技巧来创建一个复杂的复合精灵。那么它是如何工作的呢?
构建平铺精灵
平铺子画面本质上是一个矩形,它遮盖了由平铺图像构成的子画面网格。网格比填充矩形所需的最大拼贴数量大一行一列。这意味着在矩形的可视区域之外总是有一行和一列。如果背景图案向上、向下、向左或向右移动,隐藏行或列中的子画面将移动到网格的另一侧进行补偿。这产生了无缝滚动模式的错觉。但你真正做的是移动内部瓷砖精灵。图 8-11 显示了隐藏的行和列是如何被屏蔽和重新定位以匹配移位的图形的。
图 8-11 。当图案移动时,来自额外隐藏的行和列的精灵被重新定位,以创建无缝、无限滚动背景的幻觉
关于平铺精灵,要认识到的最重要的事情是,它只是一个矩形,掩盖了子精灵的网格。你可能记得在《??》第四章中,矩形精灵有一个可选的mask属性,默认为false。如果您将mask设置为 true,则在矩形的render方法中启用以下代码行:
if (this.mask && this.mask === true) ctx.clip();
这将导致任何矩形的子精灵被矩形掩盖。(您可以用同样的方式在圆形精灵中启用遮罩。)
在library/display文件夹中,你会发现tilingSprite函数,它设置所有这些并返回被屏蔽的网格。这其实并不复杂,但确实有很多工作要做。为了帮助理解它在做什么,您可以将其所有工作分解为以下几个主要步骤:
- 确定所提供的图块图像是来自图像文件还是纹理贴图集帧,然后捕获图像的宽度和高度值。
- 计算出有多少瓷砖可以放入矩形的尺寸中。
- 制作一个比精灵尺寸大一行一列的
grid对象。 - 创建一个矩形精灵,并添加网格作为其子元素。
- 将矩形的
mask属性设置为true。 - 给矩形添加
tileX和tileY属性。这些属性的设置器根据提供的偏移值按比例移动网格块的位置。 - 将矩形精灵返回到主程序。
下面是完成所有这些的tilingSprite函数。注释详细解释了每一位代码是如何工作的。
export function tilingSprite(width, height, source, x = 0, y = 0) {
//Figure out the tile's width and height
let tileWidth, tileHeight;
//If the source is a texture atlas frame, use its
//`frame.w` and `frame.h` properties
if(source.frame) {
tileWidth = source.frame.w;
tileHeight = source.frame.h;
}
//If it's an image, use the image's
//`width` and `height` properties
else {
tileWidth = source.width;
tileHeight = source.height;
}
//Figure out the rows and columns.
//The number of rows and columns should always be
//one greater than the total number of tiles
//that can fit into the rectangle. This give us one
//additional row and column that we can reposition
//to create the infinite scroll effect
let columns, rows;
//1\. Columns
//If the width of the rectangle is greater than the width of the tile,
//calculate the number of tile columns
if (width >= tileWidth) {
columns = Math.round(width / tileWidth) + 1;
}
//If the rectangle's width is less than the width of the
//tile, set the columns to 2, which is the minimum
else {
columns = 2;
}
//2\. Rows
//Calculate the tile rows in the same way
if (height >= tileHeight) {
rows = Math.round(height / tileHeight) + 1;
} else {
rows = 2;
}
//Create a grid of sprites that's just one sprite larger
//than the `totalWidth` and `totalHeight`
let tileGrid = grid(
columns, rows, tileWidth, tileHeight, false, 0, 0,
() => {
//Make a sprite from the supplied `source`
let tile = sprite(source);
return tile;
}
);
//Declare the grid's private properties that we'll use to
//help scroll the tiling background
tileGrid._tileX = 0;
tileGrid._tileY = 0;
//Create an empty rectangle sprite without a fill or stroke color.
//Set it to the supplied `width` and `height`
let container = rectangle(width, height, "none", "none");
container.x = x;
container.y = y;
//Set the rectangle's `mask` property to `true`. This switches on `ctx.clip()`
//In the rectangle sprite's `render` method
container.mask = true;
//Add the tile grid to the rectangle container
container.addChild(tileGrid);
//Define the `tileX` and `tileY` properties on the parent container
//so that you can scroll the tiling background
Object.defineProperties(container, {
tileX: {
get() {
return tileGrid._tileX;
},
set(value) {
//Loop through all of the grid's child sprites
tileGrid.children.forEach(child => {
//Figure out the difference between the new position
//and the previous position
let difference = value - tileGrid._tileX;
//Offset the child sprite by the difference
child.x += difference;
//If the x position of the sprite exceeds the total width
//of the visible columns, reposition it to just in front of the
//left edge of the container. This creates the wrapping
//effect
if (child.x > (columns - 1) * tileWidth) {
child.x = 0 - tileWidth + difference;
}
//Use the same procedure to wrap sprites that
//exceed the left boundary
if (child.x < 0 - tileWidth - difference) {
child.x = (columns - 1) * tileWidth;
}
});
//Set the private `_tileX` property to the new value
tileGrid._tileX = value;
},
enumerable: true, configurable: true
},
tileY: {
get() {
return tileGrid._tileY;
},
//Follow the same format to wrap sprites on the y axis
set(value) {
tileGrid.children.forEach(child => {
let difference = value - tileGrid._tileY;
child.y += difference;
if (child.y > (rows - 1) * tileHeight) child.y = 0 - tileHeight + difference;
if (child.y < 0 - tileHeight - difference) child.y = (rows - 1) * tileHeight;
});
tileGrid._tileY = value;
},
enumerable: true, configurable: true
}
});
//Return the rectangle container
return container;
}
最常见的视频游戏需求之一是无限滚动的背景,而平铺精灵的设计就是为了让您轻松实现这一点。接下来让我们来看看如何在一个游戏中使用它。
案例研究:Flappy 仙女
关键帧动画、粒子效果和平铺精灵有助于为游戏增添全新的趣味性和沉浸感。随着你的游戏设计技能和自信的增长,你可能会发现你制作的大多数游戏会用到至少一种或者所有这些效果。在本章的最后一节,我们将详细了解一款名为 Flappy Fairy 的游戏原型——向视频游戏史上最臭名昭著的游戏之一致敬。它使用了所有三种效果,并将为您将它们集成到您自己的游戏中提供一个良好的起点。
运行flappyFairy.html 文件玩游戏。点击屏幕让仙女飞起来,帮助她通过 15 根柱子的缝隙到达终点,如图图 8-12 所示。当她在迷宫中飞行时,一串五彩缤纷的仙尘跟随着她。如果她撞上其中一个绿色方块,她就会在一阵灰尘中爆炸。但是,如果她设法通过所有 15 根柱子之间越来越窄的缝隙,她会到达一个巨大的浮动“完成”标志。
图 8-12 。帮助 Flappy 仙女飞行通过迷宫的柱子到达终点
创建滚动背景
Flappy Fairy 是一款使用视差效果的侧滚游戏。视差是一种浅 3D 效果,通过使背景以比前景更慢的速度滚动来创造深度的幻觉。这使得背景看起来更远。
为了制作天空背景,我从一些云的无缝 512 × 512 图像开始。是游戏纹理图谱中的一帧,如图图 8-13 所示。
图 8-13 。纹理图谱中的天空帧图像
在setup函数中,我使用“sky.png”帧创建了一个名为sky的平铺精灵。
sky = tilingSprite(canvas.width, canvas.height, assets["sky.png"]);
然后,游戏循环将tileX位置每帧向左移动一点。
sky.tileX -= 1;
这就是它的全部——无限滚动!
创建支柱
游戏中有十五根柱子。每隔五根柱子,顶部和底部之间的间隙就变得更窄。前五根柱子的间距为四块,后五根柱子的间距为三块,最后五根柱子的间距为两块。随着 Flappy Fairy 飞得更远,这使得游戏越来越难。对于每个柱子来说,缺口的确切位置是随机的,每次玩游戏时都不一样。每个柱子的间距为 384 像素,但是图 8-14 显示了如果它们紧挨着的话会是什么样子。
图 8-14 。每根柱子顶部和底部之间的间隙逐渐变窄
你可以看到差距是如何从左边的四个空格逐渐缩小到右边的两个。
组成柱子的所有积木都在一个叫做blocks的group里。
blocks = group();
嵌套的for循环创建每个块,并将其添加到blocks容器中。外环运行 15 次;一次创建一个支柱。内循环运行八次;柱中的每个块一次。只有当块没有占据为间隙随机选择的范围时,才添加块。外环每运行五次,间隙的大小就缩小一。
//What should the initial size of the gap be between the pillars?
let gapSize = 4;
//How many pillars?
let numberOfPillars = 15;
for (let i = 0; i < numberOfPillars; i++) {
//Randomly place the gap somewhere inside the pillar
let startGapNumber = randomInt(0, 8 - gapSize);
//Reduce the `gapSize` by one after every fifth pillar. This is
//what makes gaps gradually become narrower
if (i > 0 && i % 5 === 0) gapSize -= 1;
//Create a block if it's not within the range of numbers
//occupied by the gap
for (let j = 0; j < 8; j++) {
if (j < startGapNumber || j > startGapNumber + gapSize - 1) {
let block = sprite(assets["greenBlock.png"]);
blocks.addChild(block);
//Space each pillar 384 pixels apart. The first pillar will be
//placed at an x position of 512
block.x = (i * 384) + 512;
block.y = j * 64;
}
}
//After the pillars have been created, add the finish image
//right at the end
if (i === numberOfPillars - 1) {
finish = sprite(assets["finish.png"]);
blocks.addChild(finish);
finish.x = (i * 384) + 896;
finish.y = 192;
}
}
代码的最后一部分给这个世界添加了一个大精灵,Flappy Fairy 将会看到她是否能够坚持到最后。
游戏循环每帧将一组方块向右移动 2 个像素,但仅在finish精灵不在屏幕上时:
if (finish.gx > 256) {
blocks.x -= 2;
}
当finish sprite 滚动到画布的中心时,blocks容器将停止移动。注意,代码使用了finish精灵的全局 x 位置(gx)来测试它是否在画布的区域内。因为全局坐标是相对于画布的,而不是相对于父容器的,所以对于那些你想在画布上找到一个嵌套的精灵的位置的情况,它们真的很有用。
让 Flappy 仙女飞起来
仙女角色是一个使用三个纹理贴图帧制作的动画精灵。每一帧都是仙女振翅动画中的一个图像。(图 8-15 说明了这三个纹理图谱框架。)
let fairyFrames = [
assets["0.png"],
assets["1.png"],
assets["2.png"]
];
fairy = sprite(fairyFrames);
fairy.fps = 24;
fairy.setPosition(232, 32);
fairy.vy = 0;
fairy.oldVy = 0;
仙女精灵有一个新的属性叫做oldVy,正如你将在前面看到的,它将帮助我们计算仙女的垂直速度。
为了让仙女移动,游戏循环对每一帧的垂直速度应用-0.05 来产生重力。
fairy.vy += -0.05;
fairy.y -= fairy.vy;
玩家可以通过点击或点击画布上的任何地方让她飞起来。每一次点击都会增加 Flappy 仙女的垂直速度 1.5,将她向上推。
pointer = makePointer(canvas);
pointer.tap = () => {
fairy.vy += 1.5;
};
散发仙尘
仙女拍动翅膀时会发出一股五彩缤纷的粒子流。粒子被限制在 2.4 到 3.6 弧度之间的角度,所以它们以一个锥形的楔形发射到仙女的左边,如图图 8-15 所示。粒子流随机发射粉色、黄色、绿色或紫色粒子,每个粒子都是纹理贴图集上的一个单独的帧。
图 8-15 。当仙女扇动翅膀时,会发射出一股五彩缤纷的粒子流
正如你在本章前面学到的,我们写的particleEffect函数将在一个精灵上随机显示一帧,如果这个精灵包含多个帧的话。为此,首先定义要使用的纹理贴图集帧的数组:
dustFrames = [
assets["pink.png"],
assets["yellow.png"],
assets["green.png"],
assets["violet.png"]
];
接下来,使用这些帧初始化提供给发射器的 sprite 函数:
dust = emitter(
300, //The interval
() => particleEffect( //The function
fairy.x + 8, //x position
fairy.y + fairy.halfHeight + 8, //y position
() => sprite(dustFrames), //Particle sprite
3, //Number of particles
0, //Gravity
true, //Random spacing
2.4, 3.6, //Min/max angle
12, 18, //Min/max size
1, 2, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
)
);
你现在有了一个名为dust的粒子发射器。只需调用它的play函数,让它开始发射粒子:
dust.play();
微调仙女的动画
Flappy 仙女在往上走的时候,扇动翅膀,散发出神奇的仙尘。当她坠落时,灰尘停止了,她也停止了拍动翅膀。但是我们怎么知道她是向上飞还是向下飞呢?
我们必须找出当前帧和前一帧之间的速度差。如果她现在的速度大于之前的速度,她就会上升。如果它更小,并且之前的速度大于零,她就向下。代码将当前帧中仙女的vy值存储在一个名为oldVy的属性中。当oldVy在下一个帧中被访问时,它会告诉你仙女之前的 vy 值是多少。
//If she's going up, make her flap her wings and emit fairy dust
if (fairy.vy > fairy.oldVy) {
if(!fairy.playing) {
fairy.play();
if (fairy.visible && !dust.playing) dust.play();
}
}
//If she's going down, stop flapping her wings, show the first frame
//and stop the fairy dust
if (fairy.vy < 0 && fairy.oldVy > 0) {
if (fairy.playing) fairy.stop();
fairy.show(0);
if (dust.playing) dust.stop();
}
//Store the fairy's current vy so we can use it
//to find out if the fairy has changed direction
//in the next frame. (You have to do this as the last step)
fairy.oldVy = fairy.vy;
当下一帧来回摆动时,oldVy属性将用于计算帧之间的速度差。这是一个非常常用的技巧,每当你想比较两帧之间的速度精灵的差异时,你可以使用它。
与积木的碰撞
当 Flappy Fairy 撞上一个方块时,她消失在一团灰尘中,如图图 8-16 所示。这种行为是如何运作的?
图 8-16 。噗!她不见了!
游戏循环是在hitTestRectangle函数的帮助下完成的,你在前一章已经学会了使用这个函数。代码在blocks.children数组中循环,并测试每个块和仙女之间的冲突。如果hitTestRectangle返回true,循环退出,一个名为fairyVsBlock的碰撞物体变成true。
let fairyVsBlock = blocks.children.some(block => {
return hitTestRectangle(fairy, block, true);
});
提示你可以看到代码使用了
some方法来遍历所有的块。使用some的好处是,一旦找到等于true的值,循环就会退出。
hitTestRectangle的第三个参数需要是true,以便使用精灵的全局坐标(gx和gy)来完成碰撞检测。那是因为fairy是舞台的孩子,但是每个积木都是blocks组的孩子。这意味着它们不共享同一个局部坐标空间。使用它们的全局坐标迫使hitTestRectangle使用精灵相对于画布的位置。
如果fairyVsBlock为true,且仙女当前可见,则运行碰撞代码。它让仙女隐形,制造粒子爆炸,延迟 3 秒后调用游戏的reset功能。
if (fairyVsBlock && fairy.visible) {
//Make the fairy invisible
fairy.visible = false;
//Create a fairy dust explosion
particleEffect(
fairy.centerX, fairy.centerY, //x and y position
() => sprite(dustFrames), //Particle sprite
20, //Number of particles
0, //Gravity
false, //Random spacing
0, 6.28, //Min/max angle
16, 32, //Min/max size
1, 3 //Min/max speed
);
//Stop the dust emitter that's trailing the fairy
dust.stop();
//Wait 3 seconds and then reset the game
wait(3000).then(() => reset());
}
reset功能只是将精灵和方块重新定位到它们的初始位置,并使精灵再次可见。
function reset() {
fairy.visible = true;
fairy.y = 32;
dust.play();
blocks.x = 0;
}
作为用some遍历每个块并测试与hitTestRectangle冲突的替代方法,你可以使用通用的hit函数。正如你在前一章的结尾所了解到的,hit是一个更高级的“奢侈”功能,它会自动为你做很多工作。如果你提供一个精灵数组作为第二个参数,hit会自动循环遍历它们,并检查它们是否与第一个参数中的精灵有冲突。以下是如何使用hit在精灵和积木之间执行相同的碰撞测试:
let fairyVsBlock = hit(
fairy, blocks.children, false, false, true,
() => {
if (fairy.visible) {
fairy.visible = false;
particleEffect(/*...particle arguments...*/);
dust.stop();
wait(3000).then(() => reset());
}
}
);
使用您喜欢的碰撞检测功能。
Flappy 仙女:完整代码
Flappy Fairy 使用了到目前为止你在这本书中学到的所有技术,它包含了一个完整游戏需要的大部分元素。你如何从头开始编写这样的游戏?如何开始或构建一个完整的、全功能的游戏并不总是显而易见的,所以这里是完整的 JavaScript 代码,供您参考。使用它作为开始你自己的新游戏的模型。
//Import code from the library
import {
makeCanvas, sprite, group, particles, particleEffect,
tilingSprite, emitter, stage, render
} from "../library/display";
import {assets, randomInt, contain, wait} from "../library/utilities";
import {makePointer} from "../library/interactive";
import {hit, hitTestRectangle} from "../library/collision";
//Load the assets
assets.load([
"img/flappyFairy.json"
]).then(() => setup());
//Declare any variables shared between functions
let pointer, canvas, fairy, sky, blocks,
finish, dust, dustFrames;
function setup() {
//Make the canvas and initialize the stage
canvas = makeCanvas(910, 512);
canvas.style.backgroundColor = "black";
stage.width = canvas.width;
stage.height = canvas.height;
//Make the sky background
sky = tilingSprite(canvas.width, canvas.height, assets["sky.png"]);
//Create a `group` for all the blocks
blocks = group();
//What should the initial size of the gap be between the pillars?
let gapSize = 4;
//How many pillars?
let numberOfPillars = 15;
//Loop 15 times to make 15 pillars
for (let i = 0; i < numberOfPillars; i++) {
//Randomly place the gap somewhere inside the pillar
let startGapNumber = randomInt(0, 8 - gapSize);
//Reduce the `gapSize` by one after every fifth pillar. This is
//what makes gaps gradually become narrower
if (i > 0 && i % 5 === 0) gapSize -= 1;
//Create a block if it's not within the range of numbers
//occupied by the gap
for (let j = 0; j < 8; j++) {
if (j < startGapNumber || j > startGapNumber + gapSize - 1) {
let block = sprite(assets["greenBlock.png"]);
blocks.addChild(block);
//Space each pillar 384 pixels apart. The first pillar will be
//placed at an x position of 512
block.x = (i * 384) + 512;
block.y = j * 64;
}
}
//After the pillars have been created, add the finish image
//right at the end
if (i === numberOfPillars - 1) {
finish = sprite(assets["finish.png"]);
blocks.addChild(finish);
finish.x = (i * 384) + 896;
finish.y = 192;
}
}
//Make the fairy
let fairyFrames = [
assets["0.png"],
assets["1.png"],
assets["2.png"]
];
fairy = sprite(fairyFrames);
fairy.fps = 24;
fairy.setPosition(232, 32);
fairy.vy = 0;
fairy.oldVy = 0;
//Create the frames array for the fairy dust images
//that trail the fairy
dustFrames = [
assets["pink.png"],
assets["yellow.png"],
assets["green.png"],
assets["violet.png"]
];
//Create the particle emitter
dust = emitter(
300, //The interval
() => particleEffect( //The function
fairy.x + 8, //x position
fairy.y + fairy.halfHeight + 8, //y position
() => sprite(dustFrames), //Particle sprite
3, //Number of particles
0, //Gravity
true, //Random spacing
2.4, 3.6, //Min/max angle
12, 18, //Min/max size
1, 2, //Min/max speed
0.005, 0.01, //Min/max scale speed
0.005, 0.01, //Min/max alpha speed
0.05, 0.1 //Min/max rotation speed
)
);
//Make the particle stream start playing when the game starts
dust.play();
//Make the pointer and increase the fairy's
//vertical velocity when it's tapped
pointer = makePointer(canvas);
pointer.tap = () => {
fairy.vy += 1.5;
};
//Start the game loop
gameLoop();
}
function gameLoop() {
requestAnimationFrame(gameLoop);
//Update all the particles in the game
if (particles.length > 0) {
for(let i = particles.length - 1; i >= 0; i--) {
let particle = particles[i];
particle.update();
}
}
//The `play` function contains all the game logic
play();
}
function play() {
//Make the sky background scroll by shifting the `tileX`
//of the `sky` tiling sprite
sky.tileX -= 1;
//Move the blocks 2 pixels to the left each frame.
//This will just happen while the finish image is off-screen.
//As soon as the finish image scrolls into view, the blocks
//container will stop moving
if (finish.gx > 256) {
blocks.x -= 2;
}
//Add gravity to the fairy
fairy.vy += -0.05;
fairy.y -= fairy.vy;
//Decide whether the fairy should flap her wings
//If she's going up, make her flap her wings and emit fairy dust
if (fairy.vy > fairy.oldVy) {
if(!fairy.playing) {
fairy.play();
if (fairy.visible && !dust.playing) dust.play();
}
}
//If she's going down, stop flapping her wings, show the first frame
//and stop the fairy dust
if (fairy.vy < 0 && fairy.oldVy > 0) {
if (fairy.playing) fairy.stop();
fairy.show(0);
if (dust.playing) dust.stop();
}
//Store the fairy's current vy so we can use it
//to find out if the fairy has changed direction
//in the next frame. (You have to do this as the last step)
fairy.oldVy = fairy.vy;
//Keep the fairy contained inside the stage and
//neutralize her velocity if she hits the top or bottom boundary
let fairyVsStage = contain(fairy, stage.localBounds);
if (fairyVsStage === "bottom" || fairyVsStage === "top") {
fairy.vy = 0;
}
//Loop through all the blocks and check for a collision between
//each block and the fairy. (`some` will quit the loop as soon as
//`hitTestRectangle` returns `true`.) Set `hitTestRectangle`s third argument
//to `true` to use the sprites' global coordinates
let fairyVsBlock = blocks.children.some(block => {
return hitTestRectangle(fairy, block, true);
});
//If there's a collision and the fairy is currently visible,
//create the explosion effect and reset the game after
//a three second delay
if (fairyVsBlock && fairy.visible) {
//Make the fairy invisible
fairy.visible = false;
//Create a fairy dust explosion
particleEffect(
fairy.centerX, fairy.centerY, //x and y position
() => sprite(dustFrames), //Particle sprite
20, //Number of particles
0, //Gravity
false, //Random spacing
0, 6.28, //Min/max angle
16, 32, //Min/max size
1, 3 //Min/max speed
);
//Stop the dust emitter that's trailing the fairy
dust.stop();
//Wait 3 seconds and then reset the game
wait(3000).then(() => reset());
}
//Alternatively, you can achieve the same collision effect
//using the higher-level universal `hit` function
//Render the canvas
render(canvas);
}
function reset() {
//Reset the game if the fairy hits a block
fairy.visible = true;
fairy.y = 32;
dust.play();
blocks.x = 0;
}
如果您在构建自己的游戏时,对在哪里或如何应用某项技术感到困惑或不确定,请回头看看这段源代码,并将其用作框架和指南。
摘要
你现在已经掌握了开始制作一些相当复杂和引人入胜的游戏的所有技能。关键帧动画、粒子效果和视差滚动等技术远非无足轻重的附加功能,而是为您的游戏增添了全新的沉浸感。他们可以把一个仅仅是有趣的游戏变成一个活生生的、会呼吸的另一个世界。
您还了解了如何利用代码抽象的力量,用不到 200 行代码制作一个非常复杂的游戏原型。通过创建可重用的对象和函数,并将所有代码隐藏在代码库中,你最终的游戏代码是轻量级的和可读的。现在,你已经为制作各种各样的游戏打下了坚实的基础,你也有了一个易于理解的架构。
在你翻过这一页之前,我要你做最后一件事。再玩一次 Flappy Fairy,打开电脑音量。你听到了什么?没错:没事!我们将在下一章解决这个问题。