HTML5 和 JavaScript 高级游戏设计(二)
二、画布绘制 API
画布绘制 API 是 HTML5 游戏设计师最好的朋友。它易于使用,功能强大,可用于所有平台,速度非常快。不仅如此,学习 Canvas Drawing API 还为您提供了一个很好的低级图形编程入门,您将能够将其应用于各种不同的游戏设计技术。作为学习 HTML5 游戏设计艺术的核心技术,这是最好的起点。
注什么是 API?它代表应用程序编程接口。它只是一个函数和对象的代码库,帮助您执行一组特定的任务,如绘制形状。
在这一章中,你将得到一个在画布上绘制线条、形状、图像和文本的快速速成课程,这样你就可以开始使用它们来为你的游戏制作组件。我们将看看制作和修改线条和形状的所有基本方法。
搭建画布
在你开始画画之前,你需要一个可以画画的表面。下面介绍如何使用 JavaScript 创建一个画布 HTML 元素和一个绘图上下文。
let canvas = document.createElement("canvas");
canvas.setAttribute("width", "256");
canvas.setAttribute("height", "256");
canvas.style.border = "1px dashed black";
document.body.appendChild(canvas);
let ctx = canvas.getContext("2d");
这段代码是做什么的?它在 HTML 文档的主体中创建一个<canvas> HTML 标记,如下所示:
<canvas id="canvas" width="256" height="256" style="border:1px dashed #000000;"></canvas>
您可以将 canvas 标签视为包含绘图表面的框架。这段代码创建的画布的宽度和高度是 256px,周围有一个 1 像素宽的虚线边框。
实际的绘制是在画布的 drawing context 上完成的。你可以把上下文想象成一种位于画布框架内部的可编程绘图表面。上下文在此代码中表示为 ctx :
let ctx = canvas.getContext("2d");
现在,您已经准备好开始绘制线条和形状了。
注意你会注意到本书中大多数图像的宽度和高度尺寸都是 2 的幂,比如 32、64、128 和 256。这是因为图形处理器在历史上处理 2 的幂大小的图像非常有效:这与二进制图形数据存储在计算机内存中的格式相同。你会发现,如果你把你的游戏图像保持在 2 的幂的大小,它们将整齐地适合大多数计算机和设备的屏幕。
画线
让我们从最简单的图形元素开始:一条线。下面是如何从画布的左上角(0,0)到其中点(128,128)画一条线。线条为黑色,3 像素宽:
//1\. Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//2\. Draw the line
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(128, 128);
ctx.stroke();
图 2-1 显示了这段代码所创建的内容。
图 2-1 。在画布上画一条线
它是这样工作的。首先,我们设置线条样式选项。strokeStyle允许您设置线条的颜色,可以是任何 RGB、十六进制或预定义的 CSS 字符串颜色名称,如"black"。
ctx.strokeStyle = "black";
然后给它一个lineWidth,以像素为单位。下面是如何分配 3 个像素的线宽:
ctx.lineWidth = 3;
现在您已经设置了线条选项,您可以开始用beginPath方法绘制路径。这只是一种说“我们现在要开始划清界限了!”
ctx.beginPath();
用moveTo设置线的起始 x,y 位置。0,0 是画布的左上角。(左上角的 x 和 y 值都为零。)
ctx.moveTo(0, 0);
然后使用lineTo定义线条的终点。在这种情况下,它将在画布中间的 x,y 位置 128 处结束。(记住,我们的画布是 256×256 像素。)
ctx.lineTo(128, 128);
当你画完形状后,你可以选择使用closePath来自动连接路径中的最后一个点和第一个点。
ctx.closePath();
最后,我们需要使用stroke方法使这条线可见。这将应用我们之前设置的线条颜色和粗细选项,因此我们可以在画布上看到线条:
ctx.stroke();
这些都是开始使用绘图 API 需要知道的基础知识。接下来你会看到我们如何将线条连接在一起形成形状,并用颜色填充这些形状。
线帽
您可以很好地控制行尾的外观。lineCap属性有三个选项可以使用:"square"、"round"和"butt"。(注意,引号是字面意思。)您可以使用以下语法应用这些样式中的任何一种:
ctx.lineCap = "round";
(这行代码必须出现在我们调用ctx.stroke()方法之前。)
图 2-2 显示了这些风格的效果。你需要一条相当粗的线来看这些线条的不同。
图 2-2 。线帽样式
连接线条以创建形状
您可以将线条连接在一起形成形状,并用颜色填充这些形状。使用上下文的fillStyle属性定义要填充形状的颜色。下面的例子展示了如何将fillStyle设置为透明的灰色 RGBA 颜色:
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
color 是以 RGBA、十六进制或 HLSA 格式描述颜色的字符串。它也可以是 HTML/CSS 规范中的 140 个颜色词中的任何一个,比如“蓝色”或“红色”。
用线条画出形状的轮廓后,使用上下文的fill方法用fillStyle颜色填充形状:
ctx.fill();
下面是如何在画布中央画一个三角形,并赋予其透明的灰色填充颜色。图 2-3 显示了你将会看到的,以及用来创建它的moveTo和lineTo命令。
图 2-3 。画一个三角形
//Set the line and fill style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
//Connect lines together to form a triangle in the center of the canvas
ctx.beginPath();
ctx.moveTo(128, 85);
ctx.lineTo(170, 170);
ctx.lineTo(85, 170);
ctx.lineTo(128, 85);
ctx.fill();
ctx.stroke();
绘制复杂形状
如果您的形状很复杂,您可以将其定义为点的 2D 数组,并使用循环将这些点连接在一起。这里有一个由 x 、 y 点坐标组成的 2D 数组,它形成了与上一个例子中相同的三角形:
let triangle = [
[128, 85],
[170, 170],
[85, 170]
];
接下来,定义一个循环遍历这些点并使用moveTo和lineTo连接它们的函数。我们可以保持代码尽可能简单,从最后一点开始,然后从那里顺时针连接这些点:
function drawPath(shape) {
//Start drawing from the last point
let lastPoint = shape.length - 1;
ctx.moveTo(
shape[lastPoint][0],
shape[lastPoint][1]
);
//Use a loop to plot each point
shape.forEach(point => {
ctx.lineTo(point[0], point[1]);
});
}
你现在可以使用这个drawPath函数来绘制形状,就像这样:
ctx.beginPath();
drawPath(triangle);
ctx.stroke();
ctx.fill();
您可以使用这种技术来制作具有任意数量点的复杂形状。
线条连接
您可以设置线条与其他线条的连接方式。使用lineJoin属性可以做到这一点。您可以使用下列选项中的任何一个来设置它:"round"、"mitre"或"bevel"(同样,引号是文字)。下面是要使用的格式:
ctx.lineJoin = "round";
图 2-4 显示了这些风格的效果。
图 2-4 。线条连接样式
画正方形和长方形
使用rect方法快速创建一个矩形。它具有以下格式:
rect(x, y, width, height)
下面是如何制作一个 x 位置为 50、 y 位置为 49、宽度为 70、高度为 90 的矩形:
ctx.rect(50, 40, 70, 90);
设置线条和填充样式选项,绘制如图 2-5 所示的矩形。
图 2-5 。画一个长方形
//Set the line and fill style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
//Draw the rectangle
ctx.beginPath();
ctx.rect(50, 40, 70, 90);
ctx.stroke();
ctx.fill();
图 2-6 说明了如何使用尺寸和位置值来绘制矩形。
图 2-6 。x、y 位置以及矩形的宽度和高度
或者,您可以使用快捷方式strokeRect和fillRect方法绘制一个矩形。下面介绍如何使用它们来绘制如图 2-7 所示的矩形。
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
ctx.strokeRect(110, 170, 100, 50);
ctx.fillRect(110, 170, 100, 50);
图 2-7 。使用strokeRect和fillRect绘制一个矩形
梯度
您可以创建两种类型的渐变:线性 或径向。
要创建线性渐变,使用createLinearGradient方法。它需要四个参数。前两个参数是画布上渐变开始点的 x 、 y 坐标。后两个参数是渐变终点的 x 、 y 坐标。
let gradient = ctx.createLinearGradient(startX, startY, endX, endY);
这在画布上定义了一条渐变应该遵循的线。
接下来,你需要添加色站 **。**这些是渐变将混合在一起以创建色调平滑过渡的颜色。addColorStop方法使用两个参数进行混合。第一个是渐变上颜色应该开始的位置。这可以是 0(开始位置)和 1(结束位置)之间的任何数字。第二个参数是颜色(这是一个 RGBA、十六进制或 HLSA 格式的字符串,或者是 140 个 HTML 颜色词中的一个)。
以下是如何在从白色过渡到黑色的渐变的起点和终点添加两个色标。
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
(如果您想在这两种颜色之间添加第三种颜色,您可以使用 0 到 1 之间的任何数字。值为 0.5 时,第三种颜色介于 0 和 1 之间。)
最后,将渐变应用到上下文的fillStyle以能够使用它来填充形状:
ctx.fillStyle = gradient;
这是一个填充正方形的渐变的例子。渐变从正方形的左上角开始,到它的右下角结束。图 2-8 显示了这段代码产生的结果。
图 2-8 。用线性渐变填充形状
//Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//Create a linear gradient
let gradient = ctx.createLinearGradient(64, 64, 192, 192);
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
ctx.fillStyle = gradient;
//Draw the rectangle
ctx.beginPath();
ctx.rect(64, 64, 128, 128);
ctx.stroke();
ctx.fill();
在这个例子中,我使渐变比正方形稍微大一点,只是为了稍微柔化效果:
ctx.createLinearGradient(64, 64, 192, 192)
只有落在矩形内的前四分之三的渐变区域是可见的。
要创建径向渐变,使用createRadialGradient方法。它需要六个参数:前三个是渐变的起始圆的位置及其大小,后三个是渐变的结束圆的位置及其大小。
let gradient = ctx.createRadialGradient(x, y, startCircleSize, x, y, endCircleSize);
开始圆和结束圆通常具有相同的位置;只是尺寸会有所不同。
您可以添加色标并将渐变应用到画布fillStyle上,就像处理线性渐变一样。以下是用径向渐变填充正方形的方法:
let gradient = ctx.createRadialGradient(128, 128, 10, 128, 128, 96);
128 的渐变的 x 和 y 位置匹配画布中正方形的中心点。图 2-9 显示了结果。
图 2-9 。径向梯度
画圆和圆弧
使用arc方法画圆。以下是可以使用的参数:
arc(centerX, centerY, circleRadius, startAngle, endAngle, false)
中心 x , y 坐标是画布上确定圆中心点的点。circleRadius是一个以像素为单位的数字,它决定了圆的半径(其宽度的一半)。startAngle和endAngle是以弧度表示的数字,它们决定了圆的完整程度。对于一整圈,使用 0 的startAngle和 6.28 的endAngle(2 * Math.PI)。(startAngle的 0 位置在圆圈的 3 点钟位置。)最后一个参数false,表示应该从startAngle开始顺时针画圆。
以下是如何在画布中心绘制一个半径为 64 像素的完整圆:
ctx.arc(128, 128, 64, 0, 2*Math.PI, false)
图 2-10 显示了一个带有渐变填充的圆,你可以用下面的代码创建它。
图 2-10 。画一个带渐变的圆
//Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//Create a radial gradient
let gradient = ctx.createRadialGradient(96, 96, 12, 128, 128, 96);
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
ctx.fillStyle = gradient;
//Draw the circle
ctx.beginPath();
ctx.arc(128, 128, 64, 0, 2*Math.PI, false);
ctx.stroke();
ctx.fill();
注意弧度是圆的度量单位,在数学上比度数更容易处理。1 弧度是当你把半径绕在圆的边缘时得到的度量。3.14 弧度等于半个圆,非常方便,等于π(3.14)。一个完整的圆是 6.28 弧度(π* 2)。一弧度约等于 57.3 度,如果您需要将度转换为弧度,或将弧度转换为度,请使用以下公式:
弧度=度*(数学。 π/180) 度=弧度* (180 /数学。PI)
可以用同样的arc方法轻松画出一个圆弧(不完整的圆)。使用大于 0 的startAngle和小于 6.28 的endAngle(2 * Math.PI)即可。下面是一些绘制 3.14 到 5 弧度的圆弧的代码,如图图 2-11 所示。
图 2-11 。画一个弧线
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(128, 128, 64, 3.14, 5, false)
ctx.stroke();
如果你需要绘制曲线,画布绘制 API 有一些高级选项,我们接下来会看到。
画曲线
可以画的曲线有两种:二次曲线 和贝塞尔曲线。
要绘制二次曲线,使用quadraticCurveTo方法。下面的代码产生了你在图 2-12 中看到的曲线。
图 2-12 。二次曲线
ctx.moveTo(32, 128);
ctx.quadraticCurveTo(128, 20, 224, 128);
代码本身就令人困惑,但借助图表很容易理解。你需要做的第一件事是使用moveTo定义线条的起点,靠近画布的左中心边缘:
ctx.moveTo(32, 128);
然后使用quadraticCurveTo方法定义曲线。前两个参数定义了所谓的控制点 。你可以把控制点想象成一种无形的引力点,把线拉向它。在本例中,控制点靠近画布的中心顶部,在 128 的 x 位置和 20 的 y 位置,我已经在这里突出显示了:
ctx.quadraticCurveTo(128, 20, 224, 128);
最后两个参数是线条的终点,靠近画布的中右边缘:
ctx.quadraticCurveTo(128, 20, 224, 128);
你能在图 2-12 中看到这些点是如何一起创造曲线的吗?
贝塞尔曲线类似,但增加了第二个控制点:
bezierCurveTo(control1X, control1Y, control2X, control2Y, endX, endY);
再说一次,除非你看到一个清晰的例子,否则很难理解这是如何工作的。图 2-13 显示了一条贝塞尔曲线和用来创建它的四个点。下面是生成该曲线的代码:
ctx.moveTo(32, 128);
ctx.bezierCurveTo(32, 20, 224, 20, 224, 128);
图 2-13 。贝塞尔曲线
将代码与图表进行比较,并尝试制作一些自己的曲线,直到您对二次曲线和贝塞尔曲线的工作原理有所了解。如果您闭合这些线条,使它们在同一点开始和结束,您将生成一个可以用颜色或渐变填充的形状。
阴影
您可以使用shadowColor、shadowOffsetX、shadowOffsetY和shadowBlur属性为任何线条或形状添加阴影。图 2-14 显示一个带有浅灰色、略显模糊的圆形阴影。下面是生成它的代码:
ctx.shadowColor = "rgba(128, 128, 128, 0.9)";
ctx.shadowOffsetX = 10;
ctx.shadowOffsetY = 10;
ctx.shadowBlur = 10;
图 2-14 。添加阴影
shadowColor可以是任何 RGBA(如本例所示)、HSLA、十六进制或 CSS 颜色字符串名称。赋予阴影透明的 alpha 颜色(在本例中为 0.9)会使它们在覆盖另一个对象时看起来更真实。shadowOffsetX和shadowOffsetY决定阴影从形状偏移多少像素。shadowBlur是阴影应模糊的像素数,以产生漫射光效果。尝试这些值,直到你找到一个能产生你喜欢的效果的组合。像这样的投影适用于任何形状、线条或文本。
旋转
画布绘制 API 没有任何内置方法来旋转单个形状。相反,您必须旋转整个画布,将形状绘制到旋转后的状态,然后再次旋转整个画布。您还必须移动绘图上下文的坐标空间。它的 x , y 0,0 点通常是画布的左上角,您需要将其重新定位到形状的中心点。
起初,这似乎是画布绘制 API 的一个疯狂、糟糕的特性。实际上,这是最好的功能之一。正如你将在第四章中看到的,它允许你用最少的代码在形状之间创建非常有用的嵌套父子关系。但要理解发生了什么,首先确实需要一点概念上的飞跃。所以让我们来看看画布旋转是如何工作的。
下面的代码画了一个旋转后的正方形,如图图 2-15 所示。在代码清单之后,我将向您详细介绍它是如何工作的。
图 2-15 。旋转形状
//Set the line and fill style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 0.5)";
//Save the current state of the drawing context before it's rotated
ctx.save();
//Shift the drawing context's 0,0 point from the canvas's top left
//corner to the center of the canvas. This will be the
//square's center point
ctx.translate(128, 128);
//Rotate the drawing context's coordinate system 0.5 radians
ctx.rotate(0.5);
//Draw the square from -64 x and -64 y. That will mean its center
//point will be at exactly 0, which is also the center of the
//context's coordinate system
ctx.beginPath();
ctx.rect(-64, -64, 128, 128);
ctx.stroke();
ctx.fill();
//Restore the drawing context to
//its original position and rotation
ctx.restore();
这就是所有这些是如何工作的。我们需要做的第一件事是保存绘图上下文的当前状态:
ctx.save();
这很重要,因为我们要移动和旋转整个上下文。save方法让我们记住它的原始状态,这样我们可以在画完旋转的正方形后恢复它。
接下来,translate方法移动上下文的坐标空间,使位置 0,0 位于画布上与我们将要绘制的正方形中心相同的点上。正方形的中心将有一个 128 的 x 位置和一个 128 的 y 位置,将其放置在画布的正中心。
ctx.translate(128, 128);
这意味着上下文的 0,0 位置不是在画布的左上角,而是向右移动了 128 像素,向下移动了 128 像素。这将是正方形的中心点。图 2-16 显示了坐标空间是如何移动的。如果我们不这样做,正方形看起来不会绕着它的中心旋转。相反,它会围绕画布的左上角旋转。图 2-16 显示了这段代码对上下文坐标位置的不可见但重要的影响。
图 2-16 。将上下文的坐标空间移动到画布的中心
接下来,将上下文的整个坐标空间顺时针旋转 0.5 弧度(28.6 度),如图图 2-17 所示。
ctx.rotate(0.5);
图 2-17 。旋转上下文
下一步是围绕上下文的中心点绘制矩形*。这意味着你必须将矩形的宽度和高度各偏移一半。这个正方形的宽和高都是 128 像素,所以它的 x 、 y 位置都需要是–64。*
ctx.beginPath();
ctx.rect(-64, -64, 128, 128);
ctx.stroke();
ctx.fill();
这是令人困惑的,所以看一看图 2-18 来弄清楚发生了什么。您可以看到,绘制矩形后,其中心点落在上下文的 0,0 点上。并且因为上下文被旋转了,所以正方形看起来也旋转了。
图 2-18 。绘制正方形,使其位于上下文旋转中心点的中心
最后,我们必须将上下文恢复到移动和旋转之前的状态:
ctx.restore();
这让我们可以在这一点之后添加更多的线条或形状,它们不会被旋转。图 2-19 显示了最终恢复后的状态。
图 2-19 。旋转后将画布的状态恢复到正常状态
即使上下文的位置和旋转已经恢复,正方形仍然保持在它被绘制的相同位置。
保存上下文的状态、移动它、旋转它、在它上面绘制形状,以及恢复它的整个过程发生在几分之一毫秒内。它真的很快,你永远不会看到它发生。但是,对于要旋转的每条线或形状,您必须一步一步地遵循相同的过程。像这样手动完成是很乏味的,但是在第四章中,你将学习如何用一个自定义的形状精灵和render函数来自动完成这个过程。
标度
canvas context 的scale方法让您可以轻松地沿着 x / y 轴缩放形状的宽度和高度:
ctx.scale(scaleX, scaleY)
0 到 1 之间的scaleX和scaleY值将在 0 到 100%的原始大小之间缩放形状。这意味着如果你设置scaleX和scaleY为 0.5,形状将被缩放到其大小的 50%。
ctx.scale(0.5, 0.5)
将这些值设置为 2 会将形状缩放到其原始大小的 200%:
ctx.scale(2, 2)
最后,scaleX和scaleY值为 1 会将形状设置为其原始比例。
图 2-20 显示了这些比例值对前面例子中旋转矩形的影响。
图 2-20 。相对于形状的大小缩放形状
就像旋转一样,实际上缩放的不是形状,而是整个画布背景。上下文的缩放量与当前正在绘制的线条、形状或图像的宽度和高度有关。因此,就像你处理旋转一样,你需要在一对save和restore方法之间插入scale方法,这样上下文将返回到它的原始比例,用于它需要绘制的下一个东西。虽然这看起来是一种笨拙的方式,但在第四章中,你会看到它是如何让我们用很少的代码轻松创建一个复杂的嵌套精灵层次的。
让事情变得透明
有两种方法可以使画布元素透明。第一种方法是使用 RGBA 或 HSLA 颜色,并将 alpha 值(参数中的最后一个数字)设置为小于 1 的数字,这在前面的示例中已经看到了。( Alpha 是透明度的图形设计术语。)第二种方法是使用画布上下文的globalAlpha属性。globalAlpha与rotate相似,都会影响整个画布。这意味着只对一个形状应用透明度,你需要将globalAlpha夹在save和restore之间,如下例所示:
ctx.save();
ctx.globalAlpha = 0.5;
//...Draw your line or shape...
ctx.restore();
globalAlpha取 0(完全透明)和 1(完全不透明)之间的一个数字。图 2-21 显示了一个用globalAlpha做成半透明的正方形和圆形。下图是执行此操作的代码。
图 2-21 。用globalAlpha使形状透明
//Set the fill style options
ctx.fillStyle = "black";
//Draw the rectangle
ctx.save();
ctx.beginPath();
ctx.globalAlpha = 0.6;
ctx.rect(32, 32, 128, 128);
ctx.fill();
ctx.restore();
//Draw the circle
ctx.save();
ctx.beginPath();
ctx.globalAlpha = 0.3;
ctx.arc(160, 160, 64, 0, Math.PI * 2, false)
ctx.fill();
ctx.restore();
使用混合模式
画布上下文有一个globalCompositeOperation属性,允许您将一个混合模式分配给画布。混合模式决定了两个相交形状或图像的颜色应该如何组合。有 16 种混合模式可供选择,它们与 Photoshop 等图像编辑软件中的相同混合模式具有相同的效果。根据您使用的混合模式和形状或图像的颜色,效果可以是从微妙的透明到生动的颜色反转。
以下是如何使用globalCompositeOperation将混合模式设置为multiply:
ctx.globalCompositeOperation = "multiply";
乘法是一种对比效果,它使用一个公式将重叠颜色的值相乘来生成一种新颜色。图 2-22 显示了蓝色圆圈重叠红色方块的效果。
图 2-22 。使用混合模式来组合重叠图像的颜色
下面是产生这种效果的代码:
//Set the blend mode
ctx.globalCompositeOperation = "multiply";
//Draw the rectangle
ctx.save();
ctx.fillStyle = "red";
ctx.beginPath();
ctx.rect(32, 32, 128, 128);
ctx.fill();
ctx.restore();
//Draw the circle
ctx.save();
ctx.fillStyle = "blue";
ctx.beginPath();
ctx.arc(160, 160, 64, 0, Math.PI*2, false)
ctx.fill();
ctx.restore();
以下是您可以使用的混合模式的完整列表,以及每种模式产生的效果:
- 无融合 :
"normal" - 对比 :
"soft-light","hard-light","overlay" - 点亮 :
"lighten","color-dodge","screen" - 变暗 :
"darken","color-burn","multiply" - 颜色反转 :
"difference","exclusion" - 复杂调配 :
"hue","saturation","color","luminosity"
欣赏这些效果的最佳方式是打开您最喜欢的图像编辑器,并观察这些混合模式对两个重叠图像的影响。使用画布绘制 API 的效果是一样的。关于这些混合模式如何工作的更多细节,W3C 令人惊讶的可读规范是一个很好的起点:dev.w3.org/fxtf/compositing-1。
合成效果
globalCompositeOperation方法也可以让你详细控制重叠的形状应该如何组合。有十二种波特-达夫运算 可以应用到形状上;它们涵盖了两种形状组合的所有可能方式。
注意波特-达夫操作是以托马斯·波特和汤姆·达夫的名字命名的,他们在为星球大战电影做视觉特效时开发了它们。
应用波特-达夫运算的方式与应用混合模式的方式相同:
ctx.globalCompositeOperation = "source-over";
图 2-23 说明了这些操作的效果,下表(表 2-1 )简要描述了每个操作的作用。
图 2-23 。使用复合操作来合并和遮罩重叠的形状
表 2-1 。画布合成效果
|
复合操作
|
它的作用
|
| --- | --- |
| "source-over" | 在第二个形状前面绘制第一个形状。 |
| "destination-over" | 在第一个形状前面绘制第二个形状。 |
| "source-in" | 仅在两个形状重叠的画布部分绘制第二个形状。 |
| "destination-in" | 仅在两个形状重叠的画布部分绘制第一个形状。 |
| "source-out" | 在不与第一个形状重叠的地方绘制第二个形状。 |
| "destination-out" | 在不与第二个形状重叠的地方绘制第一个形状。 |
| "source-atop" | 仅在第二个形状与第一个形状重叠的地方绘制第二个形状。 |
| "destination-atop" | 仅在第一个形状与第二个形状重叠的地方绘制第一个形状。 |
| "lighter" | 将重叠的形状颜色混合成较浅的颜色。 |
| "darker" | 将重叠的形状颜色混合成较暗的颜色。 |
| "xor" | 使重叠区域透明。 |
| "复制" | 仅绘制第二个形状。 |
到目前为止,在这一章中,我们只是处理了形状,但是你可以很容易地将所有这些技术应用到图像上。我们接下来会这么做。
用图像填充形状
您可以使用createPattern方法用图像填充形状。图 2-24 显示了一只猫的图像是如何被用来填充一个正方形的。
图 2-24 。用图像填充形状
这是通过用rect方法画一个正方形,然后使用图像pattern作为fillStyle来完成的。边框是可选的;如果不使用描边样式,图像周围就不会有边框。此外,如果您希望图像的左上角与形状的左上角匹配,您需要偏移画布以匹配形状的 x , y 位置。这和我们在前面的例子中使用的技巧是一样的,但是更简单一点,因为我们没有旋转任何东西。我将在前面解释这是如何工作的。下面是产生这种效果的代码:
//Load an image
let catImage = new Image();
catImage.addEventListener("load", loadHandler, false);
catImage.src = "img/cat.png";
//The loadHandler is called when the image has loaded
function loadHandler() {
//Set the line style options
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
//Draw the rectangle
ctx.beginPath();
ctx.rect(64, 64, 128, 128);
//Set the pattern to the image, and the fillStyle to the pattern
let pattern = ctx.createPattern(catImage, "no-repeat");
ctx.fillStyle = pattern;
//Offset the canvas to match the rectangle's x and y position,
//then start the image fill from that point
ctx.save();
ctx.translate(64, 64);
ctx.stroke();
ctx.fill();
ctx.restore();
}
加载图像时,代码在画布上绘制并定位矩形,如您在前面的示例中所见:
ctx.beginPath();
ctx.rect(64, 64, 128, 128);
然后,它使用createPattern方法将加载的图像转换成形状图像模式。该模式存储在名为pattern的变量中,然后分配给fillStyle:
let pattern = ctx.createPattern(catImage, "no-repeat");
ctx.fillStyle = pattern;
在本例中,模式被设置为"no-repeat"。如果您想要具有纹理图案效果的形状,您可以使用较小的图像,并使其在形状的整个区域重复。除了"no-repeat"之外,还有另外三个选项可以使用:"repeat"用图案的连续重复图像覆盖形状的表面,而"repeat-x"和"repeat-y"只是沿着一个轴重复图像。
在这个例子中,我希望猫图像的左上角与矩形的左上角精确对齐。为了实现这一点,我需要在设置fillStyle之前用translate方法偏移画布。偏移量匹配矩形的 x 、 y 位置(64 乘 64 像素):
ctx.save();
ctx.translate(64, 64);
ctx.stroke();
ctx.fill();
ctx.restore();
save和restore方法用于在用图像填充形状后将画布重置回其原始位置。如果不这样做,图像将从画布左上角的位置 0,0 绘制,而不是从形状左上角的位置 64,64 绘制。
绘制图像
如果您只想在画布上显示图像,上下文的drawImage方法是一种简单的方法。图像加载后,使用ctx.drawImage定义图像名称、其 x 位置和其 y 位置:
ctx.drawImage(imageObject, xPosition, yPosition);
下面是如何预加载一只猫的图像,并使用drawImage将其显示在画布中央。图 2-25 显示了结果。
//Load an image
let catImage = new Image();
catImage.addEventListener("load", loadHandler, false);
catImage.src = "img/cat.png";
//The loadHandler is called when the image has loaded
function loadHandler() {
ctx.drawImage(catImage, 64, 64);
}
图 2-25 。在画布上画一幅图像
像这样使用drawImage既快速又简单。但是对于大多数游戏项目来说,你会希望使用稍微灵活一点的方式来显示游戏图像,我们一会儿就会谈到这一点。首先,快速进入掩蔽。
遮蔽图像
一个面具就像一个窗框。蒙版下的任何图像将仅在蒙版区域内可见。超出蒙版区域的图像部分不会显示在框架之外。您可以使用clip方法将任何形状变成遮罩。
图 2-26 显示了我们的猫被一个圆形遮住的图像。
图 2-26 。使用clip方法将形状变成遮罩
要创建一个蒙版,画一个形状,然后使用clip方法,而不是stroke或fill。在clip之后绘制的图像或形状中的任何内容都将被遮罩。下面是生成图 2-26 中图像的代码:
//Draw the circle as a mask
ctx.beginPath();
ctx.arc(128, 128, 64, 0, Math.PI * 2, false);
ctx.clip();
//Draw the image
ctx.drawImage(catImage, 64, 64);
您可以像遮罩图像一样轻松地遮罩形状。
将图像传送到画布上
在画布上只显示图像的一部分非常有用。这一功能对于制作游戏来说非常重要,因为这意味着你可以将所有的游戏角色和对象存储在一个名为 tileset 或 sprite sheet 的图像文件中。然后,通过有选择地在画布上只显示和定位 tileset 中您需要的那些部分来构建您的游戏世界。这是一种真正快速且节省资源的渲染游戏图形的方式,称为 blitting 。
在下一个例子中,我们将使用一个包含许多游戏角色和对象的 tileset,并且只显示其中一个对象:一艘火箭船。图 2-27 显示了我们将要加载的 tileset,以及我们将要在画布上显示的单火箭船。
图 2-27 。将部分图像文件复制到画布上
代码使用了drawImage方法来完成这个任务。drawImage需要知道我们要显示的 tileset 部分的原点 x 和 y 位置,以及它的高度和宽度。然后,它需要知道目的地 x 、 y 、宽度和高度值,以便在画布上绘制图像。
//Load the tileset image
let tileset = new Image();
tileset.addEventListener("load", loadHandler, false);
tileset.src = "img/tileset.png";
//The loadHandler is called when the image has loaded
function loadHandler() {
ctx.drawImage(
tileset, //The image file
192, 128, //The source x and y position
64, 64, //The source height and width
96, 96, //The destination x and y position
64, 64 //The destination height and width
);
}
tileset.png图像文件为 384×384 像素。火箭船的图像向右 192 像素,向下 128 像素。这是它的 x 、 y 源位置。它的宽度和高度都是 64 像素,所以这是它的源宽度和高度值。火箭船被绘制到画布上的 x , y 位置为 96,使用相同的宽度和高度值(64)。这些是它的目标值。图 2-28 通过选择 tileset 的正确部分并将其显示在画布上,展示了这段代码是如何工作的。
图 2-28 。blitting 的工作原理
如果您不熟悉 blitting,可以在源文件的工作示例中使用这些数字,您会很快发现在画布上的任何位置以任何大小显示您想要的图像是多么容易。
注意“blit”一词来自“位块传输”,这是一个早期的计算机图形术语,指的是这种技术。
我们已经介绍了线条、形状和图像,现在让我们来看看画布拼图的最后一块:文本。
正文
画布绘制 API 几乎没有有用的属性和方法来帮助您绘制文本。下一个例子展示了如何使用它们来显示单词“Hello world!”用红色粗体字表示,位于画布中央。图 2-29 显示了以下代码产生的结果。
图 2-29 。在画布上显示文本
//Create a text string defines the content you want to display
let content = "Hello World!";
//Assign the font to the canvas context.
//The first value is the font size, followed by the names of the
//font families that should be tried:
//1st choice, fall-back font, and system font fall-back
ctx.font = "24px 'Rockwell Extra Bold', 'Futura', sans-serif";
//Set the font color to red
ctx.fillStyle = "red";
//Figure out the width and height of the text
let width = ctx.measureText(content).width,
height = ctx.measureText("M").width;
//Set the text's x/y registration point to its top left corner
ctx.textBaseline = "top";
//Use `fillText` to Draw the text in the center of the canvas
ctx.fillText(
content, //The text string
canvas.width / 2 - width / 2, //The x position
canvas.height / 2 - height / 2 //The y position
);
让我们来看看这是如何工作的。
画布上下文的font属性允许您使用以下格式定义字体大小和字体系列:
ctx.font = "24px 'Rockwell Extra Bold', 'Futura', sans-serif";
第一个字体系列名称 Rockwell Extra Bold 是应该使用的主要字体。如果由于某种原因它不可用,font 属性将返回到 Futura。如果 Futura 也不可用,将使用好的旧系统字体 sans-serif。
注意在这个例子中,我使用了一些可靠的网络安全字体,这些字体在所有现代网络浏览器上都可以找到。(你可以在
cssfontstack.com找到网页安全字体列表。)在第三章中,你将学习如何使用 CSS @font-face 规则从文件中预加载自定义字体。
你可以使用measureText方法计算出文本的宽度,以像素为单位,如下所示:
width = ctx.measureText(content).width
在字体样式被分配后,你需要做这个*,以便根据特定字体的字母大小和磅值正确测量文本。*
measureText方法没有匹配的高度属性来告诉你文本的像素高度。相反,您可以使用这个非常可靠且广泛使用的方法来计算它:测量一个大写字母 M 的宽度:
height = ctx.measureText("M").width
令人惊讶的是,这与大多数字体的文本像素高度完全匹配。
您还需要定义一个文本基线,它告诉上下文文本的 x , y 0 点应该在哪里。要定义文本左上角的 x/y 点,将textBaseline设置为top:
ctx.textBaseline = "top";
将左上角设置为文本的 x 、 y 注册点,可以使文本的坐标与圆形、矩形和图像的坐标保持一致。
除了"top"之外,您可以分配给textBaseline的其他值有"hanging"、"middle"、"alphabetic"、"ideographic"和"bottom。这些选项比你可能需要的要多,但是图 2-30 说明了每个选项是如何影响文本对齐的。
图 2-30 。文本基线选项
fillText用于绘制特定 x/y 位置的字符串内容。如果您知道文本的宽度和高度,您可以使用fillText使文本在画布中居中,如下所示:
ctx.fillText(
content, //The text string
canvas.width / 2 - width / 2, //The x position
canvas.height / 2 - height / 2 //The y position
);
这些是你需要知道的在画布上显示文本的最重要的技术。
摘要
现在,您已经了解了使用画布绘制 API 绘制线条和形状的基本知识。画布绘制 API 是游戏设计师需要了解的最有用和最灵活的工具之一。它有一个简单的优雅,允许你用最少的代码创建大量的复杂性。现代 JavaScript 运行时系统(如 web 浏览器)使用 GPU 对画布绘制调用进行硬件加速,因此您会发现,即使与 WebGL 相比,画布绘制对于大多数 2D 动作游戏来说也足够快了。
绘图 API 是相当低级的,这意味着如果你想快速简单地在游戏中创建许多形状,你需要构建一些辅助函数和对象。但在我们学习如何做到这一点之前,让我们先来看看如何有效地加载图像,字体,数据文件和其他有用的素材到我们的游戏中。
三、使用游戏素材
一款游戏的素材是它使用的所有字体、声音、数据和图像文件。在这一章中,你将学习如何实现一个清晰的加载和管理素材的策略,以便在你的游戏代码中容易使用它们。您将学习如何创建一个assets对象来存储对所有游戏资源的引用,以及如何创建一个预加载器来加载资源并在一切就绪时初始化您的游戏。
游戏通常会使用大量的图像,如果你不知道如何处理这些图像,那么管理这些图像通常会是一件非常令人头疼的事情。但是不要害怕!游戏设计师有一个处理图像的秘密武器:纹理图谱。在本章的后半部分,你将会学到什么是纹理贴图集,以及如何用它来帮助你以一种有趣而有效的方式管理游戏图像。
素材对象
在这一章中,我们将构建一个名为assets的实用程序对象,它将成为游戏所有素材的中央仓库:图像、声音、字体、普通的 JSON 数据和代表纹理贴图集的 JSON 数据。在探索assets对象如何工作之前,让我们看看如何在你完成的程序中使用它。
对象有一个接受一个参数的方法:文件名字符串数组。在数组中列出所有要加载的文件名及其完整路径。当所有的素材都被加载后,load方法返回一个Promise,所以当一切就绪后,你可以调用一个setup函数来初始化你的游戏。下面是如何使用assets.load方法加载图像、字体和 JSON 文件,然后运行setup函数:
assets.load([
"img/cat.png",
"fonts/puzzler.otf",
"json/data.json"
]).then(() => setup());
function setup() {
//Initialize the game
}
只有在所有素材加载完毕后,setup功能才会运行。然后,您就可以在主游戏程序中的任何地方使用以下语法访问任何资源,如图像集:
let anyImage = assets["img/cat.png"];
您只需要构建这个素材加载器一次,然后就可以在任何游戏项目中使用它。它还被设计成易于定制,这样你就可以用它来加载你的游戏可能需要的任何类型的文件。
注意在本章中,你将学习如何配置素材加载器,以便它也可以加载声音文件。但是我们实际上不会编写加载声音的代码,直到第九章,在那里我们将在讨论 WebAudio API 时补充那些细节。
让我们来看看实现这一点的所有代码。
构建素材对象
起初,assets对象看起来很复杂,但是您很快就会看到,它只是遵循相同模式的单个组件的集合。这里是完整的代码清单,可以作为参考。现在不要担心去理解所有的事情;在前面的页面中,我将带您了解它是如何工作的,包括它如何解释 JSON 纹理图谱:
export let assets = {
//Properties to help track the assets being loaded
toLoad: 0,
loaded: 0,
//File extensions for different types of assets
imageExtensions: ["png", "jpg", "gif"],
fontExtensions: ["ttf", "otf", "ttc", "woff"],
jsonExtensions: ["json"],
audioExtensions: ["mp3", "ogg", "wav", "webm"],
//The `load` method creates and loads all the assets. Use it like this:
//`assets.load(["img/anyImage.png", "fonts/anyFont.otf"]);`
load(sources) {
//The `load` method will return a Promise when everything has loaded
return new Promise(resolve => {
//The `loadHandler` counts the number of assets loaded, compares
//it to the total number of assets that need to be loaded, and
//resolves the Promise when everything has loaded
let loadHandler = () => {
this.loaded += 1;
console.log(this.loaded);
//Check whether everything has loaded
if (this.toLoad === this.loaded) {
//Reset `toLoad` and `loaded` to `0` so you can use them
//to load more assets later if you need to
this.toLoad = 0;
this.loaded = 0;
console.log("Assets finished loading");
//Resolve the promise
resolve();
}
};
//Display a console message to confirm that the assets are
//being loaded
console.log("Loading assets...");
//Find the number of files that need to be loaded
this.toLoad = sources.length;
//Loop through all the source filenames and find out how
//they should be interpreted
sources.forEach(source => {
//Find the file extension of the asset
let extension = source.split(".").pop();
//Load images that have file extensions that match
//the imageExtensions array
if (this.imageExtensions.indexOf(extension) !== -1) {
this.loadImage(source, loadHandler);
}
//Load fonts
else if (this.fontExtensions.indexOf(extension) !== -1) {
this.loadFont(source, loadHandler);
}
//Load JSON files
else if (this.jsonExtensions.indexOf(extension) !== -1) {
this.loadJson(source, loadHandler);
}
//Load audio files
else if (this.audioExtensions.indexOf(extension) !== -1) {
this.loadSound(source, loadHandler);
//Display a message if a file type isn't recognized
else {
console.log("File type not recognized: " + source);
}
});
});
},
loadImage(source, loadHandler) {
//Create a new image and call the `loadHandler` when the image
//file has loaded
let image = new Image();
image.addEventListener("load", loadHandler, false);
//Assign the image as a property of the `assets` object so
//you can access it like this: `assets["path/imageName.png"]`
this[source] = image;
//Set the image's `src` property to start loading the image
image.src = source;
},
loadFont(source, loadHandler) {
//Use the font's filename as the `fontFamily` name
let fontFamily = source.split("/").pop().split(".")[0];
//Append an `@afont-face` style rule to the head of the HTML document
let newStyle = document.createElement("style");
let fontFace
= "@font-face {font-family: '" + fontFamily + "'; src: url('" + source + "');}";
newStyle.appendChild(document.createTextNode(fontFace));
document.head.appendChild(newStyle);
//Tell the `loadHandler` we're loading a font
loadHandler();
},
loadJson(source, loadHandler) {
//Create a new `xhr` object and an object to store the file
let xhr = new XMLHttpRequest();
//Use xhr to load the JSON file
xhr.open("GET", source, true);
//Tell xhr that it's a text file
xhr.responseType = "text";
//Create an `onload` callback function that
//will handle the file loading
xhr.onload = event => {
//Check to make sure the file has loaded properly
if (xhr.status === 200) {
//Convert the JSON data file into an ordinary object
let file = JSON.parse(xhr.responseText);
//Get the filename
file.name = source;
//Assign the file as a property of the assets object so
//you can access it like this: `assets["file.json"]`
this[file.name] = file;
//Texture atlas support:
//If the JSON file has a `frames` property then
//it's in Texture Packer format
if (file.frames) {
//Create the tileset frames
this.createTilesetFrames(file, source, loadHandler);
} else {
//Alert the load handler that the file has loaded
loadHandler();
}
}
};
//Send the request to load the file
xhr.send();
},
createTilesetFrames(file, source, loadHandler) {
//Get the tileset image's file path
let baseUrl = source.replace(/[^\/]*$/, "");
//Use the `baseUrl` and `image` name property from the JSON
//file's `meta` object to construct the full image source path
let imageSource = baseUrl + file.meta.image;
//The image's load handler
let imageLoadHandler = () => {
//Assign the image as a property of the `assets` object so
//you can access it like this:
//`assets["img/imageName.png"]`
this[imageSource] = image;
//Loop through all the frames
Object.keys(file.frames).forEach(frame => {
//The `frame` object contains all the size and position
//data for each sub-image.
//Add the frame data to the asset object so that you
//can access it later like this: `assets["frameName.png"]`
this[frame] = file.frames[frame];
//Get a reference to the source so that it will be easy for
//us to access it later
this[frame].source = image;
});
//Alert the load handler that the file has loaded
loadHandler();
};
//Load the tileset image
let image = new Image();
image.addEventListener("load", imageLoadHandler, false);
image.src = imageSource;
},
loadSound(source, loadHandler) {
console.log("loadSound called – see Chapter 10 for details");
}
};
您可以在本书源文件的“资源库/实用工具”文件夹中找到此代码。你可以像这样在游戏代码中导入并使用它作为 ES6 模块:
import {assets} from "../library/utilities";
现在让我们来看看这到底是如何工作的。
初始化加载过程
当你想加载一些文件到你的游戏中时,发送一个文件源路径数组到assets对象的load方法:
assets.load(["img/tileset.png", "fonts/puzzler.otf"]);
load方法首先使用数组的length来计算应该加载多少素材,并将结果复制到toLoad属性中。
load(sources) {
//...
this.toLoad = sources.length;
它现在知道您想要加载多少素材。
assets.load方法中的所有代码都包装在一个Promise中:
load(sources) {
return new Promise(resolve => {
//... all of the load function's code is here...
});
}
每次加载素材时,都会调用loadHandler 。它将1添加到loaded属性中。如果装入的素材数量与要装入的素材数量匹配,则承诺得到解决:
let loadHandler = () => {
this.loaded += 1;
console.log(this.loaded);
//Check whether everything has loaded
if (this.toLoad === this.loaded) {
//Reset `toLoad` and `loaded` to `0` so you can use them
//to load more assets later if you need to
this.toLoad = 0;
this.loaded = 0;
console.log("Assets finished loading");
//Resolve the promise
resolve();
}
};
但是在调用loadHandler之前,代码需要弄清楚您想要加载什么类型的素材。它首先遍历来自sources数组的每个文件源路径:
sources.forEach(source => {
let extension = source.split(".").pop();
forEach方法遍历每个源文件,并找到它的文件扩展名。它是怎么做到的?
首先,split将源字符串转换成一个新的数组。它通过在每个点(.)性格。因此,字符串中由点描绘的每一部分都将被转换为数组元素,并复制到新数组中:
source.split('.')
例如,假设我们的原始字符串如下所示:
"img/tileset.png"
split方法扫描字符串中的点。它将点左边的所有内容复制到一个数组元素中,将右边的所有内容复制到另一个数组元素中。这意味着源字符串现在位于如下所示的数组中:
["img/tileset", "png"]
我们已经成功了一半。我们只对文件扩展名"png"感兴趣。这是数组中的最后一个元素。我们怎样才能访问它?使用pop方法:
source.split('.').pop()
这一行“弹出”数组中的最后一个元素,非常方便的是文件扩展名。
当所有这些都完成后,名为extension的变量现在有了值"png"。但是代码如何知道png是一个图像文件呢?
assets对象有四个数组,存储每种文件类型的所有文件扩展名:
imageExtensions: ["png", "jpg", "gif"],
fontExtensions: ["ttf", "otf", "ttc", "woff"],
jsonExtensions: ["json"],
audioExtensions: ["mp3", "ogg", "wav", "webm"],
我们可以使用这些数组来计算出"png"是什么样的东西。使用数组的indexOf方法来帮助您做到这一点。
if (this.imageExtensions.indexOf(extension) !== -1) {
this.loadImage(source, loadHandler);
}
如果indexOf不能将"png"匹配到imageExtensions数组中的一个值,它将返回-1,表示“未找到”任何大于-1 的数字都意味着找到了匹配,因此"png"必须是一种图像类型。
注意或者,您可以使用 JavaScript ES6 的
find方法来帮助您完成这项工作。
加载图像
如果extension指的是一幅图像,则loadImage函数运行:
loadImage(source, loadHandler) {
//Create a new image and call the `loadHandler` when the image
//file has loaded
let image = new Image();
image.addEventListener("load", loadHandler, false);
//Assign the image as a property of this `assets` object
this[source] = image;
//Set the image's `src` property to start loading the image
image.src = source;
},
loadImage做的第一件事是创建一个新的Image对象并设置图像的loadHandler:
let image = new Image();
image.addEventListener("load", loadHandler, false);
当图像完成加载后,它将调用assets对象上的loadHandler。(记住,loadHandler每运行一次,它就给loaded的值加 1。当所有素材加载完毕后,Promise被解析,所有加载完成。)
下一步是将这个Image对象存储在asset对象本身中,并且能够通过它的文件名和路径名来引用它。我们如何做到这一点?
这里有一点编程巫术,你会觉得非常有趣。记住,如果我们想在我们的主程序中访问一个图像,我们应该能够编写一些类似这样的代码:
assets["img/tileset.png"]
我们如何设置它?
在引用图像并与图像文件同名的assets对象上创建一个属性。以下是如何:
this[source] = image;
现在,您可以使用以下语法访问您加载的任何图像:
assets["img/rocket.png"]
assets["img/cat.png"]
assets["img/star.png"]
这种语法易于阅读和编写,它使我们不必为assets对象添加单独的搜索功能。
最后一步是通过设置图像的src属性开始加载图像:
image.src = source;
我们现在完成了图像,但是其他文件类型呢?
加载字体
字体造成了一个特殊的问题,因为与图像不同,没有内置的 HTML5 API 来强制加载字体。在 CSS @font-face规则的帮助下,你能做的最好的事情就是链接到你想要使用的字体文件。下面是方法:
@font-face {
font-family: "fontFamilyName";
src: url("fonts/fontFile.ttf");
}
但是这段代码实际上并没有加载字体;它只是告诉浏览器在哪里可以找到它。所有的浏览器只会在页面上使用字体时才下载,以前从来不会。这意味着任何玩你的游戏的人都可能会在字体加载前看到一个短暂的无样式文本闪烁。不幸的是,在撰写本文时,还没有新的 HTML5 规范来帮助解决这个问题。(但是如果你是在这本书出版后很久的某个快乐的未来时间读到这篇文章的,请仔细检查一下!HTML5 规范现在可能已经包含了这一点。)
如果你不认为这将是一个问题,那么不要担心预加载字体,只需使用@font-face。结案了。
但是如果你想要更多的控制,使用开源字体加载器。所有的预加载器都以同样的方式工作。他们创建一些不可见的 HTML 文本,对其进行样式化,并使用一些编程技巧来计算字体文件何时被加载。字体预加载器的一个好选择是开源项目 font.js ( github.com/Pomax/Font.js)。这是一个轻量级的、久经沙场的脚本,它允许您使用与加载图像相同的语法来加载字体。要使用 font.js,下载它并在游戏中加入一个script标签:
<script src="Font.js"></script>
然后,您可以像这样加载字体:
let anyFont = new Font();
anyFont.src = "fonts/fileName.ttf";
anyFont.onload = function () {
console.log("font loaded");
}
语法就像加载图像一样。如果这对你有用,那就去做吧!但是本着这本书的 DIY 精神,我们不会走那条路。相反,我们将使用一个小技巧来满足游戏的大部分字体加载需求,而不需要第三方脚本。
如果assets.load方法检测到我们试图加载一个带有字体扩展名("ttf"、"otf"、"ttc"或"woff")的文件,它会调用loadFont方法,该方法只是将一个@font-face规则写入并附加到 HTML 文档中:
loadFont(source, loadHandler) {
//Use the font's filename as the `fontFamily` name. This code captures
//the font file's name without the extension or file path
let fontFamily = source.split("/").pop().split(".")[0];
//Append an `@afont-face` style rule to the head of the HTML document
let newStyle = document.createElement("style");
let fontFace
= "@font-face {font-family: '" + fontFamily + "'; src: url('" + source + "');}";
newStyle.appendChild(document.createTextNode(fontFace));
document.head.appendChild(newStyle);
//Tell the loadHandler we're loading a font
loadHandler();
},
loadFont做的第一件事是在完整的源路径中找到字体的名称:
let fontFamily = source.split("/").pop().split(".")[0];
我知道,这些代码看起来像是精神病院墙上的涂鸦!但是,嘿,我们应该是成年人——我们可以接受的!它所做的只是提取最后一个斜杠(/)之后和最后一个点()之前的字母。)性格。例如,假设您的字体源路径如下所示:
"fonts/puzzler.otf"
fontFamily现在有了这个值:
"puzzler"
之前/之后的一切。不见了。
代码做的下一件事是将一个@font-face规则写入 HTML 页面的<head>部分:
let newStyle = document.createElement("style");
let fontFace
= "@font-face {font-family: '" + fontFamily + "'; src: url('" + source + "');}";
newStyle.appendChild(document.createTextNode(fontFace));
document.head.appendChild(newStyle);
如果您使用的是名为puzzler.otf 的字体,这些行将编写以下 HTML 和 CSS 代码:
<style>
@font-face {
font-family: 'puzzler';
src: url('fonts/puzzler.otf');
}
</style>
正如我前面提到的,这段代码实际上不会加载字体文件;它只是告诉浏览器,当 HTML 元素或画布请求字体时,在哪里查找字体。但是正如你将在下一章看到的,这对于游戏来说很少是个问题。因为游戏是在一个连续的循环中渲染的,所以你使用的任何自定义字体通常会在每一帧中被连续请求。这意味着它们应该在其他资源加载时被加载和渲染,在大多数情况下,您可能看不到未样式化的文本。
注意如果你不是在循环中请求自定义字体,字体文件几乎肯定不会加载。在这种情况下,请使用 font.js 或等效的字体预加载程序,让您的生活更轻松。
正如加载图像的代码一样,loadFont方法做的最后一件事是调用loadHandler :
loadHandler();
这表明该字体已经被加载,如果它是最后一个加载的素材,则解析load方法的承诺。
现在您已经知道如何加载图像和字体,让我们来看看如何加载 JSON 数据文件。
加载 JSON 文件
在第一章中,你学习了如何使用 XHR 加载和解析 JSON 文件。如果assets.load方法检测到您正试图加载一个 JSON 文件,它将使用大部分相同的代码来加载该文件。
loadJson(source, loadHandler) {
//Create a new XHR object
let xhr = new XMLHttpRequest();
xhr.open("GET", source, true);
xhr.responseType = "text";
xhr.onload = event => {
if (xhr.status === 200) {
let file = JSON.parse(xhr.responseText);
file.name = source;
this[file.name] = file;
//If the JSON file has a `frames` property then
//it's in Texture Packer format
if (file.frames) {
this.createTilesetFrames(file, source, loadHandler);
} else {
loadHandler();
}
}
};
//Send the request to load the file
xhr.send();
},
JSON 文件加载并解析成功后,代码在assets对象上添加对它的引用:
file.name = source;
this[file.name] = file;
这意味着我们可以在稍后的应用程序代码中获得对 JSON 对象的引用,如下所示:
assets["json/data.json"]
loadJson函数还做了一件额外的事情。它检查 JSON 文件是否有一个名为frames的属性。如果是,那么 JSON 文件一定是纹理图谱,函数调用createTilesetFrames方法:
if (file.frames) {
this.createTilesetFrames(file, source, loadHandler);
} else {
loadHandler();
}
什么是纹理图谱,createTilesetFrames是做什么的?来看看吧!
使用纹理图谱
如果你正在开发一个大型复杂的游戏,你会想要一个快速有效的方法来处理图像。一个纹理图谱可以帮你做到这一点。纹理贴图集实际上由两个密切相关的独立文件组成:
- 一个 PNG tileset image 文件,包含所有你想在游戏中使用的图像
- 描述 tileset 中这些子图像的大小和位置的 JSON 文件
要使用纹理贴图集,通常需要将 JSON 文件加载到游戏中,并使用它包含的数据为它定义的每个子图像自动创建单独的对象。每个对象都包含子图像的 x 、 y 、宽度、高度和名称,您可以使用这些信息将图像从 tileset 传送到画布。
使用纹理地图可以节省大量时间。您可以按任何顺序排列 tileset 的子图像,JSON 文件将为您跟踪它们的大小和位置。这真的很方便,因为这意味着子图像的大小和位置不会硬编码到你的游戏程序中。如果您对 tileset 进行了更改,比如添加图像、调整图像大小或删除图像,只需重新发布 JSON 文件,您的游戏就会使用更新后的数据来正确显示图像。如果你要制作比一个非常小的游戏更大的东西,你肯定会想要使用纹理地图。
tileset JSON 数据事实上的标准是由一个流行的软件工具 Texture Packer 输出的格式。即使不使用 Texture Packer,Shoebox 之类的类似工具也输出相同格式的 JSON 文件。下面我们来了解一下如何用它制作一个带有纹理打包器的纹理图谱,以及如何加载到游戏程序中。
注意 Texture Packer 的“Essential”许可是免费的,可以在
www.codeandweb.com下载。
创建纹理图谱
打开纹理打包器,选择{JS}配置选项。将你的游戏图片拖到它的工作区。您也可以将它指向包含您的图像的任何文件夹。纹理打包器会自动将这些图像排列成一个单独的 tileset 图像,并给它们起一个与原始图像名称相匹配的名称。默认情况下,它会给它们一个 2 像素的填充。图 3-1 显示了由三个图像组成的 tileset。
图 3-1 。使用纹理打包器创建纹理图谱
完成后,确保数据格式设置为 JSON (Hash ),然后单击发布按钮。选择文件名和位置,并保存发布的文件。您将得到一个 PNG 文件和一个 JSON 文件。在这个例子中,我的文件名是animals.json和animals.png。为了让您的生活更轻松,只需将这两个文件都保存在项目的 images 文件夹中。(将 JSON 文件视为图像文件的额外元数据)。
JSON 文件描述了 tileset 中每个图像的名称、大小和位置。下面是描述 tileset 的完整 JSON 数据:
{"frames": {
"cat.png":
{
"frame": {"x":2,"y":2,"w":128,"h":128},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":128,"h":128},
"sourceSize": {"w":128,"h":128},
"pivot": {"x":0.5,"y":0.5}
},
"hedgehog.png":
{
"frame": {"x":132,"y":2,"w":128,"h":128},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":128,"h":128},
"sourceSize": {"w":128,"h":128},
"pivot": {"x":0.5,"y":0.5}
},
"tiger.png":
{
"frame": {"x":262,"y":2,"w":128,"h":128},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {"x":0,"y":0,"w":128,"h":128},
"sourceSize": {"w":128,"h":128},
"pivot": {"x":0.5,"y":0.5}
}},
"meta": {
"app": "http://www.codeandweb.com/texturepacker",
"version": "1.0",
"image": "animals.png",
"format": "RGBA8888",
"size": {"w":392,"h":132},
"scale": "1",
"smartupdate": "$TexturePacker:SmartUpdate:
a196e3e7dc7344bb1ddfbbb9ed914f90:
06a75246a1a4b65f2beacae47a5e81d7:
b00d48b51f56eb7c81e25100fcce2828$"
}
}
您可以看到该文件包含三个主要对象,名为"cat.png"、"hedgehog.png"和"tiger.png"。这些子图像中的每一个都被称为一个帧,它的第一组属性描述了子图像在 tileset 上的位置。还有一个名为“meta”的对象,它告诉您这些帧所属图像的名称和大小,以及其他一些信息。
注意使用像 Texture Packer 这样的软件来构建纹理地图的许多优点之一是,默认情况下,它会在每张图像周围添加 2 个像素的填充。这对于防止纹理出血的可能性很重要,这是一种当 tileset 上相邻图像的边缘出现在 sprite 旁边时发生的效果。发生这种情况是因为系统的渲染器(GPU 或 CPU)决定如何舍入分数像素值。应该向上还是向下取整?对于每个渲染器,这种选择是不同的。在 tileset 上的图像周围添加 1 或 2 像素的填充可以使所有图像显示一致。
加载纹理贴图集
现在我们已经得到了这些信息,我们如何将它加载到我们的游戏代码中呢?您会注意到 JSON 文件中的第一个属性叫做"frames":
{"frames": {
当assets.load方法加载任何 JSON 文件时,它检查文件的第一个属性。如果恰好是"frames",那么你知道你正在加载一个纹理打包格式的文件,你可以使用createTilesetFrames方法来解释它:
if (file.frames) {
this.createTilesetFrames(file, source, loadHandler);
} else {
loadHandler();
}
createTilesetFrames是做什么的?它遍历 JSON 文件中的所有frame对象,并将它们添加到assets中,以便您可以稍后在代码中访问它们。它还加载 tileset 图像文件:
createTilesetFrames(file, source, loadHandler) {
//Get the tileset image's file path
let baseUrl = source.replace(/[^\/]*$/, "");
//Use the `baseUrl` and `image` name property from the JSON
//file's `meta` object to construct the full image source path
let imageSource = baseUrl + file.meta.image;
//The image's load handler
let imageLoadHandler = () => {
//Assign the image as a property of the `assets` object so
//you can access it like this:
//`assets["img/imageName.png"]`
this[imageSource] = image;
//Loop through all the frames
Object.keys(file.frames).forEach(frame => {
//The `frame` object contains all the size and position
//data for each subimage.
//Add the frame data to the asset object so that you
//can access it later like this: `assets["frameName.png"]`
this[frame] = file.frames[frame];
//Get a reference to the source so that it will be easy for
//you to access it later
this[frame].source = image;
});
//Alert the load handler that the file has loaded
loadHandler();
};
//Load the tileset image
let image = new Image();
image.addEventListener("load", imageLoadHandler, false);
image.src = imageSource;
}
代码首先指出 tileset 图像的文件路径是什么。因为 JSON 文件和 PNG 文件存储在同一个文件夹中,所以它们都有相同的路径名。这意味着您可以通过提取source字符串中除图像名称之外的所有内容来找到图像的基本文件路径。createTilesetFrames的第一部分使用正则表达式和replace方法来查找文件名并用空字符串替换它:
let baseUrl = source.replace(/[^\/]*$/, "");
如果source字符串是"img/animals.json",那么baseUrl现在将具有值"img/"。
注意在
source字符串语法中,[^\/]指任何不是斜杠的字符。跟在它后面的*匹配任意数量的字符,而$指的是字符串的结尾。这意味着正则表达式将匹配字符串末尾非斜杠的任何字符。要了解更多正则表达式,我最喜欢的资源是http:qntm.org/files/re/re.html的“55 分钟左右学会正则表达式”。
现在我们知道了 tileset 图像的文件路径,我们可以使用 JSON 文件中的meta.image属性来构建完整的图像源:
let imageSource = baseUrl + file.meta.image;
现在我们有了对图像源的引用,我们可以像加载任何其他图像一样加载它,并在完成后调用imageLoadHandler:
let image = new Image();
image.addEventListener("load", imageLoadHandler, false);
image.src = imageSource;
imageLoadHandler通过 JSON 文件中的每个帧对象循环,并将对它们的引用存储在assets对象中。:
let imageLoadHandler = () => {
this[imageSource] = image;
Object.keys(file.frames).forEach(frame => {
this[frame] = file.frames[frame];
this[frame].source = image;
});
loadHandler();
};
每个frame对象还在一个名为source的属性中获得对其所属 tileset 的引用。这将使我们在游戏代码中更容易将框架与其 tileset 相关联。当所有这些都完成后,代码调用assets.load方法的loadHandler来通知素材对象 JSON 文件及其关联的 PNG 文件已经加载。
在你的游戏代码中加载和使用纹理贴图
那么,这一切最终给我们带来了什么?
这意味着你可以用下面的语法将纹理贴图加载到你的游戏代码中:
assets.load([
"img/animals.json"
]).then(() => setup());
然后,您可以像这样访问 JSON 对象和 PNG 图像文件:
assets["img/animals.json"]
assets["img/animals.png"]
您可以像这样访问 JSON 文件中的每个单独的帧:
assets["cat.png"]
assets["tiger.png"]
assets["hedgehog.png"]
这些是包含每个子图像的大小和位置信息的帧对象。现在,您可以使用该信息从 tileset 图像中提取子图像,并将它们复制到画布上。
我们如何做到这一点?这就是下一章要讲的!
摘要
现在,您已经掌握了在游戏中加载和管理图像、字体和纹理贴图集所需的所有技能。我们已经创建了一个有用的assets对象,它以一种易于使用的格式存储您所有的游戏素材。您还了解了如何构建一个相当复杂的微型应用程序,它使用承诺来通知您的程序何时工作完成。
这只是一个起点;你可以根据游戏的具体需求以任何方式定制assets对象。如果您需要加载其他类型的文件,比如视频,只需将文件扩展名添加到extensions数组中,并编写您自己的定制加载函数来管理加载。在第九章的中,我们将再次访问assets对象,并学习如何定制它来加载与 WebAudio API 兼容的声音文件。
在第二章中,你学习了如何在画布上绘制和显示游戏的基本图形,在这一章中,你学习了如何加载外部文件。在下一章中,你将学习如何将这两种技能结合起来,并使用它们来构建可重用的游戏组件,称为精灵。
四、制作精灵和场景图
游戏设计师的基本构建模块是精灵。精灵是你在屏幕上移动、制作动画或与之互动的任何图像、形状或文本。在这一章中,你将学习如何从头开始制作精灵,然后,在接下来的章节中,你将学习如何移动他们,使他们互动,添加一些碰撞检测,并使用他们来构建一个游戏。
我们将要构建的精灵系统的一个重要特性是,你可以将精灵组合在一起,制作复合物体和游戏场景。每个精灵都有自己的局部坐标系,因此如果移动、缩放或旋转精灵,它包含的任何嵌套子精灵都将随之移动、缩放或旋转。这是一个叫做 场景图的特性:嵌套精灵的层次结构。正如你将看到的,这是一个很容易实现的特性,给你很大的灵活性来制作复杂的游戏显示系统。
到本章结束时,你将有一个简单而强大的方法来显示形状、图像、线条和文本,它们将成为制作游戏最重要的组成部分。
注意在这一章中,我们将构建一个精灵显示系统,它严格模仿经典的 Flash API,但是有一些新的变化。Flash API 是大多数现代 2D 精灵渲染系统的基础,包括 Starling、Sparrow、CreateJS 和 Pixi,所以如果你想知道这些 API 是如何在幕后发挥其魔力的,本章将向你展示。
精灵是什么?
在第二章中,你学习了如何使用画布绘制 API 制作基本的形状和线条。这个 API 被描述为一个低级 API 。这意味着您可以控制代码的非常小的细节,以您喜欢的任何方式将它定制到一个很好的程度。这很好,但缺点是创建非常简单的东西需要大量代码。例如,如果你想画一个矩形,旋转它,给它一点透明度,你必须写 13 行代码,像这样:
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(128, 128, 128, 1)";
ctx.save();
ctx.globalAlpha = 0.5;
ctx.translate(128, 128);
ctx.rotate(0.5);
ctx.beginPath();
ctx.rect(-64, -64, 128, 128);
ctx.closePath();
ctx.stroke();
ctx.fill();
ctx.restore();
花了 13 行费力的代码,就为了做一个简单的矩形?更糟糕的是,你必须为每一个矩形重复这 13 行代码。忘记构建这样的游戏吧!
有更好的办法!你可以通过使用一种叫做抽象的重要编程技巧来解决这个问题。抽象是一种隐藏代码中所有混乱细节的策略,这样你就可以只处理大的、重要的想法。因此,你可能只需要编写两行高级代码,而不必编写 13 行冗长的低级代码。你可以这样把这 13 行抽象成两行:
let box = rectangle();
render(canvas);
可读性更强,不是吗?你怎么能这样做?
注低级代码和高级代码有什么区别?低级代码往往是告诉计算机如何做某事的一系列指令。高级代码往往是描述做什么的指令列表。写一个好的游戏程序是关于在低级和高级代码之间保持一个健康的平衡。您需要理解并访问底层代码,以便在出错时进行修复。但是你想用尽可能多的高层次代码来完成你的创造性工作,这样你就不会被混乱的低层次细节所拖累。找出完美的高层次/低层次的平衡需要实践,而且每个项目都不一样。
第一项任务是仔细查看底层代码,并尝试找出是否可以将其组织到不同的工作中。下面是我们的矩形代码正在做的两个主要工作:
- 描述矩形:其高度、宽度、位置、旋转、透明度。
- 渲染矩形:在画布上显示矩形。
在我们当前的 13 行代码中,这两项工作都混杂在一个大混乱中。这就是程序员所说的意大利面代码。我们需要解开所有的面条,这样我们就可以将它们分类成合理的和可重用的组件。在大多数游戏项目中,你会有三根意大利面条需要解开:游戏信息,游戏逻辑,游戏渲染系统。
游戏开发者有一个很好的方法来保持线程分离,那就是制作叫做精灵的组件。在接下来的几节中,你将学习如何制作一个游戏精灵,并且在这个过程中,学习如何解开任何种类的代码。
抽象将是我们本章剩余部分的指导原则,你将看到我们如何用它来解决一些复杂的问题,以便我们可以快速开始制作游戏。
制作矩形精灵
让我们从一个非常小而简单的例子开始,这样你就可以大致了解一个基本的 sprite 系统是如何工作的。我们将构建一个只显示一个矩形的极简主义精灵。您将能够定义矩形的形状、大小、颜色和位置,并根据您的喜好复制任意多的副本。
children阵列
首先,创建一个数组来保存所有要创建的精灵:
let children = [];
每次你创建一个新的精灵,你会把它放入这个children数组。正如你将看到的,这将使你更容易有效地渲染精灵。
这个数组为什么叫children?把你的主游戏想象成一个大容器。每次你创建一个精灵,它都会存在于这个大容器中。容器是父容器,容器中的所有东西都是父容器的子容器。在后面的步骤中,你会看到我们将如何扩展这个概念,为我们所有的精灵建立一个方便的父子层次结构。
rectangle雪碧
下一个任务是编写一个函数,创建并返回一个抽象的矩形精灵。该函数应该接受所有你想在游戏代码中控制的精灵参数:大小、位置和颜色。您还应该能够设置它的 alpha、旋转、缩放和可见性。因为你可能想要移动矩形精灵,我们还将添加代表精灵速度的vx和vy属性。(你将在第五章中了解所有关于速度以及vx和vy如何工作的知识。)sprite 对象也应该有自己的内部render函数,描述画布绘制 API 应该如何绘制矩形。rectangle 函数要做的最后一件事是将 sprite 推入到children数组中,以便我们稍后可以访问它。下面是完成这一切的完整的rectangle函数:
let rectangle = function(
//Define the function's parameters with their default values
width = 32,
height = 32,
fillStyle = "gray",
strokeStyle = "none",
lineWidth = 0,
x = 0,
y = 0
) {
//Create an object called `o` (the lowercase letter "o")
//that is going to be returned by this
//function. Assign the function's arguments to it
let o = {width, height, fillStyle, strokeStyle, lineWidth, x, y};
//Add optional rotation, alpha, visible, and scale properties
o.rotation = 0;
o.alpha = 1;
o.visible = true;
o.scaleX = 1;
o.scaleY = 1;
//Add `vx` and `vy` (velocity) variables that will help us move the sprite
o.vx = 0;
o.vy = 0;
//Add a `render` method that explains how to draw the sprite
o.render = ctx => {
ctx.strokeStyle = o.strokeStyle;
ctx.lineWidth = o.lineWidth;
ctx.fillStyle = o.fillStyle;
ctx.beginPath();
ctx.rect(-o.width / 2, -o.height / 2, o.width, o.height);
if (o.strokeStyle !== "none") ctx.stroke();
ctx.fill();
};
//Push the sprite object into the `children` array
children.push(o);
//Return the object
return o;
};
大部分代码都是不言自明的,但是有一个新东西您可能以前没有见过。该函数使用 ES6 的 object 文字简写来方便地将函数参数分配给函数返回的对象:
let o = {width, height, fillStyle, strokeStyle, lineWidth, x, y};
在 ES6 中,如果对象的属性名与其值相同,则不需要指定值。所以前面的语句相当于这样写:
let o = {
width: width,
height: height,
fillStyle: fillStyle,
strokeStyle: strokeStyle,
lineWidth: lineWidth,
x: x,
y: y
};
该代码只是创建一个对象,该对象的属性的名称与函数的参数值相同。
还有一种使用Object.assign 编写代码的替代方法,这在许多情况下可能更好:
let o = {};
Object.assign(
o,
{width, height, fillStyle, strokeStyle, lineWidth, x, y}
);
使用Object.assign的优点是它在对象上创建了全新的属性和值,而不仅仅是指向现有对象的指针引用。
render功能
现在你有了一个创建精灵的函数,你需要一个全局render函数来显示它们。render函数的工作是遍历children数组中的所有对象,并使用每个精灵自己的内部render函数在画布上绘制形状。该函数仅在 sprite 可见的情况下绘制 sprite,并将画布的属性设置为与 sprite 的属性相匹配。代码如下:
function render(canvas, ctx) {
//Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
//Loop through each sprite object in the `children` array
children.forEach(sprite => {
displaySprite(sprite);
});
function displaySprite(sprite) {
//Display a sprite if it's visible
if (sprite.visible) {
//Save the canvas's present state
ctx.save();
//Shift the canvas to the sprite's position
ctx.translate(
sprite.x + sprite.width / 2,
sprite.y + sprite.height /2
);
//Set the sprite's `rotation`, `alpha` and `scale`
ctx.rotate(sprite.rotation);
ctx.globalAlpha = sprite.alpha;
ctx.scale(sprite.scaleX, sprite.scaleY);
//Use the sprite's own `render` method to draw the sprite
sprite.render(ctx);
//Restore the canvas to its previous state
ctx.restore();
}
}
}
我们现在已经准备好制作精灵了。开始吧!
制造精灵
这里有一些代码使用我们新的rectangle和render函数来制作和显示三个矩形精灵,每个精灵都有不同的属性值。图 4-1 显示了这段代码产生的结果。rectangle构造函数参数代表宽度、高度、填充颜色、笔画(轮廓)颜色、轮廓宽度、 x 位置、 y 位置。您还可以设置精灵的alpha、scaleX、scaleY、旋转和visible属性。
图 4-1 。三个矩形精灵
let blueBox = rectangle(64, 64, "blue", "none", 0, 32, 32);
blueBox.rotation = 0.2;
let redBox = rectangle(64, 64, "red", "black", 4, 160, 100);
redBox.alpha = 0.5;
redBox.scaleY = 2;
let greenBox = rectangle(64, 64, "yellowGreen", "black", 2, 50, 150);
greenBox.scaleX = 0.5;
greenBox.rotation = 0.8;
//Render the sprites
render(canvas, ctx);
您可以使用相同的格式来制作任意数量的矩形,并按照您喜欢的方式自定义它们的大小、位置和颜色。你可能会惊讶地发现,我们刚刚打开了用 HTML5 和 JavaScript 制作游戏的最重要的大门。即使你没有比制作这些基本的矩形精灵更进一步,你也能够使用本书剩余部分的技术开始制作游戏。但是,我们可以做得更好!
构建场景图
下一步是创建一个系统,你可以将一个精灵嵌套在另一个精灵中。嵌套的 sprite 是其父容器 sprite 的子。每当父 sprite 改变其比例、位置、旋转或 alpha 透明度时,子 sprite 应该与该改变相匹配。子精灵在父精灵中也有自己的局部坐标系。这个系统叫做场景图,它是制作游戏场景和用不同组件创建复杂精灵的基础。图 4-2 展示了一个基本的父子精灵关系。
图 4-2 。嵌套的父子精灵层次结构
创建场景图需要一点规划。首先,你的精灵需要这些新属性:
- children :一个数组,存储对 sprite 包含的所有子 sprite 的引用。
- parent :对这个 sprite 的父级的引用。
- gx 和 gx :精灵的全局 x 和 y 坐标,相对于画布。
- x 和 y :精灵的局部坐标,相对于它的父对象。
- 层:一个数字,表示精灵的深度层。您可以通过更改子画面的深度层,使子画面显示在其他子画面的上方或下方。
精灵还需要两种新方法来帮助你管理它们的父子关系:
- addChild :让你添加一个精灵作为父精灵的子精灵。
addChild只是将一个 sprite 推入父元素的children数组中。 - removeChild :让你从父精灵中移除一个子精灵。
此外,您需要一个根容器对象,作为游戏中所有顶级精灵的父对象:
- stage:
stage是位置为 0,0 的对象,与画布的宽度和高度相同。它有一个包含游戏中所有顶级精灵的children数组。当你渲染你的精灵时,你可以通过循环舞台的children数组来实现。
最后,您需要一个新的render函数,它遍历 stage 对象中的所有子精灵,然后递归遍历这些子精灵的所有子精灵。让我们来看看实现所有这些所需的新代码。
创建可嵌套的矩形精灵
有了所有这些新特性,我们的矩形精灵的代码如下所示。
let rectangle = function(
width = 32, height = 32,
fillStyle = "gray", strokeStyle = "none", lineWidth = 0,
x = 0, y = 0
) {
//Create an object called `o` that is going to be returned by this
//function. Assign the function's arguments to it
let o = {width, height, fillStyle, strokeStyle, lineWidth, x, y};
//Create a "private" `_layer` property. (Private properties are prefixed
//by an underscore character.)
o._layer = 0;
//The sprite's width and height
o.width = width;
o.height = height
//Add optional rotation, alpha, visible and scale properties
o.rotation = 0;
o.alpha = 1;
o.visible = true;
o.scaleX = 1;
o.scaleY = 1;
//Add `vx` and `vy` (velocity) variables that will help us move
//the sprite in later chapters
o.vx = 0;
o.vy = 0;
//Create a `children` array on the sprite that will contain all the
//child sprites
o.children = [];
//The sprite's `parent` property
o.parent = undefined;
//The `addChild` method lets you add sprites to this container
o.addChild = sprite => {
//Remove the sprite from its current parent, if it has one and
//the parent isn't already this object
if (sprite.parent) {
sprite.parent.removeChild(sprite);
}
//Make this object the sprite's parent and
//add it to this object's `children` array
sprite.parent = o;
o.children.push(sprite);
};
//The `removeChild` method lets you remove a sprite from its
//parent container
o.removeChild = sprite => {
if(sprite.parent === o) {
o.children.splice(o.children.indexOf(sprite), 1);
} else {
throw new Error(sprite + "is not a child of " + o);
}
};
//Add a `render` method that explains how to draw the sprite
o.render = ctx => {
ctx.strokeStyle = o.strokeStyle;
ctx.lineWidth = o.lineWidth;
ctx.fillStyle = o.fillStyle;
ctx.beginPath();
ctx.rect(-o.width / 2, -o.height / 2, o.width, o.height);
if (o.strokeStyle !== "none") ctx.stroke();
ctx.fill();
};
//Getters and setters for the sprite's internal properties
Object.defineProperties(o, {
//The sprite's global x and y position
gx: {
get() {
if (o.parent) {
//The sprite's global x position is a combination of
//its local x value and its parent's global x value
return o.x + o.parent.gx;
} else {
return o.x;
}
},
enumerable: true, configurable: true
},
gy: {
get() {
if (o.parent) {
return o.y + o.parent.gy;
} else {
return o.y;
}
},
enumerable: true, configurable: true
},
//The sprite's depth layer. Every sprite and group has its depth layer
//set to `0` (zero) when it's first created. If you want to force a
//sprite to appear above another sprite, set its `layer` to a
//higher number
layer: {
get() {
return o._layer;
},
set(value) {
o._layer = value;
if (o.parent) {
//Sort the sprite's parent's `children` array so that sprites with a
//higher `layer` value are moved to the end of the array
o.parent.children.sort((a, b) => a.layer - b.layer);
}
},
enumerable: true, configurable: true
}
});
//Add the object as a child of the stage
if (stage) stage.addChild(o);
//Return the object
return o;
};
这段代码的一个新特性是它使用了一个名为_layer的私有属性:
o._layer = 0;
按照惯例,私有属性总是以下划线字符为前缀。下划线表示您不应该直接在主游戏代码中更改该属性,而只能通过 getter/setter 来访问或更改它。这是因为对象在返回值之前可能需要验证一个值或进行一些计算。在我们的矩形精灵中,你可以看到layer getter/setter 作为私有_layer值的接口。(在前面几页中,你会了解到_layer是如何改变精灵的深度层的。)
矩形精灵还有一个 getter/setter,用于精灵的gx和gy属性。这些告诉你精灵的全局位置,相对于画布的左上角。精灵的全局位置就是它的局部位置加上它的父对象的全局位置。
舞台和画布
我们需要创建一个名为stage 的对象,作为所有精灵的根父对象。在这个例子中,stage只是一个简单的对象,它有一些重要的属性,我们需要显示它的子精灵。
let stage = {
x: 0,
y: 0,
gx: 0,
gy: 0,
alpha: 1,
width: canvas.width,
height: canvas.height,
parent: undefined,
//Give the stage `addChild` and `removeChild` methods
children: [],
addChild(sprite) {
this.children.push(sprite);
sprite.parent = this;
},
removeChild(sprite) {
this.children.splice(this.children.indexOf(sprite), 1);
}
};
创建 canvas 元素和绘制上下文是一项非常常见的任务,因此让一个可重用的函数来为您完成这项任务是很有价值的。下面是 makeCanvas函数,它创建 canvas 元素,将其添加到 HTML 文档中,并创建绘图上下文:
function makeCanvas(
width = 256, height = 256,
border = "1px dashed black",
backgroundColor = "white"
) {
//Make the canvas element and add it to the DOM
let canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.border = border;
canvas.style.backgroundColor = backgroundColor;
document.body.appendChild(canvas);
//Create the context as a property of the canvas
canvas.ctx = canvas.getContext("2d");
//Return the canvas
return canvas;
}
下面是如何使用makeCanvas创建一个 512 × 512 像素的新画布元素:
let canvas = makeCanvas(512, 512);
为了方便起见,makeCanvas创建绘图上下文作为canvas的属性,这样您就可以像这样访问它:
canvas.ctx
现在让我们看看如何在画布上渲染精灵。
新的render功能
render函数首先遍历stage对象的children数组中的所有精灵。如果一个精灵的visible属性是true,这个函数使用我们在本章前面使用的相同代码显示这个精灵。sprite 显示后,代码检查 sprite 是否有自己的子级。如果是这样,代码会将绘制上下文重新定位到父 sprite 的左上角,并通过递归调用displaySprite函数来绘制子 sprite。
function render(canvas) {
//Get a reference to the drawing context
let ctx = canvas.ctx;
//Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
//Loop through each sprite object in the stage's `children` array
stage.children.forEach(sprite => {
displaySprite(sprite);
});
function displaySprite(sprite) {
//Display a sprite if it's visible
if (sprite.visible) {
//Save the canvas's present state
ctx.save();
//Shift the canvas to the center of the sprite's position
ctx.translate(
sprite.x + (sprite.width / 2),
sprite.y + (sprite.height / 2)
);
//Set the sprite's `rotation`, `alpha`, and `scale`
ctx.rotate(sprite.rotation);
ctx.globalAlpha = sprite.alpha * sprite.parent.alpha;
ctx.scale(sprite.scaleX, sprite.scaleY);
//Use the sprite's own `render` method to draw the sprite
sprite.render(ctx);
//If the sprite contains child sprites in its
//`children` array, display them by recursively calling this very same
//`displaySprite` function again
if (sprite.children && sprite.children.length > 0) {
//Reset the context back to the parent sprite's top-left corner
ctx.translate(-sprite.width / 2, -sprite.height / 2);
//Loop through the parent sprite's children
sprite.children.forEach(child => {
//display the child
displaySprite(child);
});
}
//Restore the canvas to its previous state
ctx.restore();
}
}
}
这段代码的一个新特性是精灵的 alpha 透明度被设置为相对于其父级的 alpha:
ctx.globalAlpha = sprite.alpha * sprite.parent.alpha;
因此,如果子对象的 alpha 为 0.5,而其父对象的 alpha 也为 0.5,则子对象的 alpha 将为 0.25。这种技术使嵌套对象的透明度以你认为它应该自然的方式表现:如果你改变父对象的透明度,子对象的透明度将按比例调整。
让整个场景图工作的关键是在render函数末尾的这个if语句:
if (sprite.children && sprite.children.length > 0) {
ctx.translate(-sprite.width / 2, -sprite.height / 2);
sprite.children.forEach(child => {
displaySprite(child);
});
}
如果父级包含子级,则上下文将重新定位到父级的左上角。这让我们在 x 和 y 坐标绘制子精灵,这些坐标是父坐标空间的本地坐标。然后代码循环遍历每个子 sprite,并为每个子 sprite 调用displaySprite,运行完全相同的代码。如果这些子精灵中的任何一个有自己的孩子,那么displaySprite也会被调用。这个层次结构可以有你需要的深度,尽管精灵很少有嵌套子级超过三或四层的。
现在我们已经有了所有的新组件,让我们来看看如何使用它们。
筑巢精灵
图 4-3 展示了嵌套四层的四个矩形精灵。虚线显示了画布的顶部和左侧边界。
图 4-3 。嵌套的父子精灵层次结构
以下代码显示了如何使用您刚刚学习的新函数创建和显示这些矩形:
//Make the canvas
let canvas = makeCanvas(312, 312);
//Make the first parent sprite: the blueBox
let blueBox = rectangle(96, 96, "blue", "none", 0, 64, 54);
//Make the goldBox and add it as a child of the blueBox
let goldBox = rectangle(64, 64, "gold");
blueBox.addChild(goldBox);
//Assign the goldBox's local coordinates (relative to the blueBox)
goldBox.x = 24;
goldBox.y = 24;
//Add a grayBox to the goldBox
let grayBox = rectangle(48, 48, "gray");
goldBox.addChild(grayBox);
grayBox.x = 8;
grayBox.y = 8;
//Add a pinkBox to the grayBox
let pinkBox = rectangle(24, 24, "pink");
grayBox.addChild(pinkBox);
pinkBox.x = 8;
pinkBox.y = 8;
//Render the canvas
render(canvas);
局部和全局坐标
主父对象blueBox是stage对象的子对象。stage的大小和位置与画布相同,其 0,0 x / y 注册点位于左上角。这意味着当blueBox被创建时,它的 x 和 y 位置是指它离画布左上角的距离。其 x 位置为 64 °,其 y 位置为 54 °,此处突出显示:
let blueBox = rectangle(96, 96, "blue", "none", 0, 64, 54);
64 和 54 是它的局部坐标,相对于它的母体stage。但是因为blueBox位于 sprite 层次的顶部,所以这些局部坐标也与其全局坐标相同。
如果你把一个精灵作为孩子添加到blueBox中会发生什么?子节点的 x 和 y 位置相对于其父节点是*。*
let goldBox = rectangle(64, 64, "gold");
blueBox.addChild(goldBox);
goldBox.x = 24;
goldBox.y = 24;
goldBox从blueBox的左上角偏移 24 个像素;这些是它的本地坐标。你可以使用goldBox的gx和gy属性来找出它的全球坐标:
goldBox.gx;
goldBox.gy;
在这个例子中goldBox.gx是 88,而goldBox.gy是 78。
在大多数游戏场景中,你只需要使用精灵的局部坐标,但是如果你需要全局坐标,你现在知道如何访问它们了。
旋转
如果旋转父精灵,所有子精灵都会随之旋转。
blueBox.rotation = 0.8;
图 4-4 显示了这段代码的效果。子精灵的实际rotation值不会改变:它们的旋转值仍然为零。但是因为它们绑定到父对象的坐标系,所以它们保持与旋转轴对齐。
图 4-4 。如果旋转父对象,其子对象将与其旋转轴相匹配
您可以通过旋转内部grayBox进行测试:
grayBox.rotation = 0.3;
图 4-5 显示效果:除了blueBox的旋转之外,grayBox和其子pinkBox旋转了 0.3 弧度。
图 4-5 。子对象的旋转值相对于父对象
就像你和我固定在地球的自转上,没有意识到它以大约 1600 千米/小时的速度旋转一样,孩子们也不会意识到他们父母的自转值。
标度
缩放也是如此。改变一个家长的尺度,所有的孩子都会匹配尺度效果。
blueBox.scaleX = 1.5;
图 4-6 显示了这个代码对孩子的影响。它们都拉伸以匹配父对象。
图 4-6 。如果缩放父对象,其子对象也会被缩放
阿尔法透明度
透明度以类似的方式工作。如果将父对象的 alpha 设置为 0.5,并将子对象的 alpha 设置为相同的值 0.5,会发生什么情况?
blueBox.alpha = 0.5
grayBox.alpha = 0.5;
虽然在印刷品上很难看到,图 4-7 显示了这种效果是复合的。渲染时,grayBox和其子pinkBox的 alpha 值似乎为 0.25。这是一种自然的效果,也是你所期望的嵌套对象在现实世界中的透明表现。
图 4-7 。子对象的 alpha 透明度与其父对象相关
深度分层
精灵从下到上相互堆叠,按照它们在父元素的children数组中出现的顺序排列。例如,如果您创建了三个重叠的矩形,最后创建的矩形将堆叠在前面创建的矩形之上。
let redBox = rectangle(64, 64, "red", "black", 4, 220, 180);
let greenBox = rectangle(64, 64, "yellowGreen", "black", 4, 200, 200);
let violetBox = rectangle(64, 64, "violet", "black", 4, 180, 220);
图 4-8 显示了这段代码产生的结果。
图 4-8 。精灵的深度堆叠顺序由渲染顺序决定
render方法按顺序遍历children数组,因此数组末尾的精灵是最后一个被渲染的。这意味着你可以通过改变精灵在它所属的children数组中的位置来改变它的深度层。
我们新的矩形精灵有一个layer setter 属性来做这件事。如果你改变了layer的值,代码会根据该值对精灵的父节点的children数组进行排序。具有较高层值的精灵被排序到子数组的末尾,因此它们将最后显示。
layer: {
get() {
return o._layer;
},
set(value) {
o._layer = value;
if (o.parent) {
o.parent.children.sort((a, b) => a.layer - b.layer);
}
},
enumerable: true, configurable: true
}
数组的sort方法是如何工作的?它采用一个带有两个参数的自定义函数,a和b,其中a是正在排序的当前元素,b是它右边的邻居。sort方法遍历所有元素并比较它们的layer值。如果a.layer减去b.layer小于 0,那么b被排序到数组中更高的位置。如果结果大于零,则a被排序到更高的位置。如果结果正好是 0,那么任何一个元素的位置都不会改变。
所有矩形精灵的默认layer值都是 0。这意味着你可以给一个精灵一个大于 0 的数字,让它显示在另一个精灵的上面。在这个例子中,你可以将redBox的layer属性设置为 1,让它出现在其他精灵的上面。
redBox.layer = 1;
图 4-9 显示效果。
图 4-9 。改变精灵的深度层
现在,如果您想让greenBox出现在redBox的上方,您可以将greenBox的layer属性设置为 2。
注意小心!对数组进行排序在计算上非常昂贵**。在游戏中,你应该避免这样做,除非绝对必要,并且永远不要在一个连续的循环中排序数组。**
**这就是我们的场景图!这些都是你需要知道的为游戏制作嵌套精灵层次的基础。
游戏精灵
你现在知道了如何制作矩形,但是如果你想开始制作游戏,你还需要更多的精灵类型。以下是制作几乎任何种类的 2D 动作游戏所需的最重要的核心精灵类型:
- 圆
- 线条
- 文本
- 图像
- 群组(一种特殊的精灵,你会在本章后面学到,它只是用来将其他精灵组合在一起)
- 矩形
在本章的后半部分,我们将学习所有关于制作矩形精灵的概念,并用它们来创建新的精灵类型。我们将通过利用 ES6 的类继承系统的优势来做到这一点。
DisplayObject类
在前面的例子中,我们使用功能组合来创建矩形精灵。在这种技术中,sprite 是由一个函数创建的,该函数组成一个对象,然后将该对象返回给主程序。这是制作精灵或任何可重复使用的对象的一个非常好的方法。如果你喜欢这种风格的编码,继续做下去!
但是为了教你一些新东西,我将向你展示如何使用浅继承模式实现一个游戏精灵系统。这正是 ES6 的类系统所设计的那种编程任务。
首先,创建一个名为DisplayObject的基类,包含所有不同 sprite 类型共享的属性和方法。
class DisplayObject {
constructor(properties) {
//Initialize the sprite
}
commonMethod() {
}
}
接下来,创建一个特定的 sprite 类型,它扩展了DisplayObject并实现了自己独特的方法和属性:
class SpriteType extends DisplayObject {
constructor() {
//Call DisplayObject's constructor to initialize
//all the default properties
super();
//Initialize the sprite's specific properties
}
specificMethod() {
}
}
我们将创建七个 sprite 类型,它们扩展了DisplayObject : Circle、Rectangle、Line、Text、Sprite(用于图像)、Group(将 sprite 分组在一起),以及Stage(所有 sprite 的根父容器)。这种模式将使我们的代码保持紧凑,并给我们一个定义良好的结构。
如果DisplayObject将成为我们所有精灵的基类,它需要包含哪些属性和方法?至少,它需要有所有的属性和方法,我们给了最新版本的矩形精灵。这些包括像一个children数组、layer属性、addChild / removeChild方法等基本元素。但是因为我们将使用我们的精灵来制作各种各样的游戏,我们也将增加一些新的功能来帮助游戏开发过程尽可能的流畅和有趣。
首先让我们添加一些属性来帮助我们定位精灵并计算它们的大小:
pivotX、pivotY:定义精灵的轴点,精灵应该围绕这个轴点旋转。halfWidth、halfHeight:返回一半宽度和高度值的属性。centerX、centerY:定义精灵的中心位置。localBounds、globalBounds:这些属性中的每一个都返回一个对象,告诉你 x 、 y ,精灵的宽度和高度(使用局部或全局坐标)。在边界检查计算中,您可以使用它们作为快捷方式来获得精灵的位置和大小。circular:如果将circular属性设置为true,则在 sprite 上会创建diameter和radius属性。将其设置为false会删除diameter和radius属性。在接下来的章节中,你会看到这个特性是如何对碰撞检测有用的。
一些增强的视觉效果会很好:
blendMode:设置精灵的混合模式。shadow、shadowColor、shadowOffsetX、shadowOffsetY、shadowBlur:让你给精灵添加阴影的属性。
我们还将实现一些方便的“奢侈”属性和方法。它们不是必不可少的,但拥有它们很好:
position:一个 getter,返回 sprite 的位置,作为一个具有 x 和 y 属性的对象。setPosition。一个让你在一行代码中设置一个 sprite 的 x 和 y 值的方法,像这样:sprite.setPosition(120, 45);empty:如果精灵的children数组为空,则返回false的布尔属性。putCenter、putTop、putRight、putBottom和putLeft:让你相对于这个精灵定位任何精灵的方法。swapChildren:交换children数组中两个精灵位置的方法。使用它来交换两个子精灵的深度层。add和remove:快捷方式addChild和removeChild,让你用一行代码添加或删除许多子精灵,比如:sprite.remove(firstChild, secondChild, thirdChild)。
我们还将增加一些高级功能。我们不会在本章中使用它们,但是您将了解它们是如何工作的,以及在接下来的章节中我们将如何使用它们:
draggable:定义是否可以用指针(鼠标或触摸)拖动 sprite。interactive:让你使精灵互动,使它对指针事件变得敏感。frame, currentFrame, loop和playing:属性我们需要改变一个精灵的图像状态或动画。
所有这些方法和属性将被所有 sprite 类型继承,包括根stage对象。
编码DisplayObject类
这是实现了所有这些特性的完整的DisplayObject类。(你会在本章的源文件中的library/display中找到工作代码。)
class DisplayObject {
constructor() {
//The sprite's position and size
this.x = 0;
this.y = 0;
this.width = 0;
this.height = 0;
//Rotation, alpha, visible, and scale properties
this.rotation = 0;
this.alpha = 1;
this.visible = true;
this.scaleX = 1;
this.scaleY = 1;
//`pivotX` and `pivotY` let you set the sprite's axis of rotation
//(o.5 represents the sprite's center point)
this.pivotX = 0.5;
this.pivotY = 0.5;
//Add `vx` and `vy` (velocity) variables that will help you move the sprite
this.vx = 0;
this.vy = 0;
//A "private" `_layer` property
this._layer = 0;
//A `children` array on the sprite that will contain all the
//child sprites in this container
this.children = [];
//The sprite's `parent` property
this.parent = undefined;
//The sprite's `children` array
this.children = [];
//Optional drop shadow properties.
//Set `shadow` to `true` if you want the sprite to display a shadow
this.shadow = false;
this.shadowColor = "rgba(100, 100, 100, 0.5)";
this.shadowOffsetX = 3;
this.shadowOffsetY = 3;
this.shadowBlur = 3;
//Optional blend mode property
this.blendMode = undefined;
//Properties for advanced features:
//Image states and animation
this.frames = [];
this.loop = true;
this._currentFrame = 0;
this.playing = false;
//Can the sprite be dragged?
this._draggable = undefined;
//Is the sprite circular? If it is, it will be given a `radius`
//and `diameter`
this._circular = false;
//Is the sprite `interactive`? If it is, it can become clickable
//or touchable
this._interactive = false;
}
/* Essentials */
//Global position
get gx() {
if (this.parent) {
//The sprite's global x position is a combination of
//its local x value and its parent's global x value
return this.x + this.parent.gx;
} else {
return this.x;
}
}
get gy() {
if (this.parent) {
return this.y + this.parent.gy;
} else {
return this.y;
}
}
//Depth layer
get layer() {
return this._layer;
}
set layer(value) {
this._layer = value;
if (this.parent) {
this.parent.children.sort((a, b) => a.layer - b.layer);
}
}
//The `addChild` method lets you add sprites to this container
addChild(sprite) {
if (sprite.parent) {
sprite.parent.removeChild(sprite);
}
sprite.parent = this;
this.children.push(sprite);
}
removeChild(sprite) {
if(sprite.parent === this) {
this.children.splice(this.children.indexOf(sprite), 1);
} else {
throw new Error(sprite + "is not a child of " + this);
}
}
//Getters that return useful points on the sprite
get halfWidth() {
return this.width / 2;
}
get halfHeight() {
return this.height / 2;
}
get centerX() {
return this.x + this.halfWidth;
}
get centerY() {
return this.y + this.halfHeight;
}
/* Conveniences */
//A `position` getter. It returns an object with x and y properties
get position() {
return {x: this.x, y: this.y};
}
//A `setPosition` method to quickly set the sprite's x and y values
setPosition(x, y) {
this.x = x;
this.y = y;
}
//The `localBounds` and `globalBounds` methods return an object
//with `x`, `y`, `width`, and `height` properties that define
//the dimensions and position of the sprite. This is a convenience
//to help you set or test boundaries without having to know
//these numbers or request them specifically in your code.
get localBounds() {
return {
x: 0,
y: 0,
width: this.width,
height: this.height
};
}
get globalBounds() {
return {
x: this.gx,
y: this.gy,
width: this.gx + this.width,
height: this.gy + this.height
};
}
//`empty` is a convenience property that will return `true` or
//`false` depending on whether this sprite's `children`
//array is empty
get empty() {
if (this.children.length === 0) {
return true;
} else {
return false;
}
}
//The following "put" methods help you position
//another sprite in and around this sprite. You can position
//sprites relative to this sprite's center, top, right, bottom or
//left sides. The `xOffset` and `yOffset`
//arguments determine by how much the other sprite's position
//should be offset from this position.
//In all these methods, `b` is the second sprite that is being
//positioned relative to the first sprite (this one), `a`
//Center `b` inside `a`
putCenter(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.halfWidth - b.halfWidth) + xOffset;
b.y = (a.y + a.halfHeight - b.halfHeight) + yOffset;
}
//Position `b` above `a`
putTop(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.halfWidth - b.halfWidth) + xOffset;
b.y = (a.y - b.height) + yOffset;
}
//Position `b` to the right of `a`
putRight(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.width) + xOffset;
b.y = (a.y + a.halfHeight - b.halfHeight) + yOffset;
}
//Position `b` below `a`
putBottom(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x + a.halfWidth - b.halfWidth) + xOffset;
b.y = (a.y + a.height) + yOffset;
}
//Position `b` to the left of `a`
putLeft(b, xOffset = 0, yOffset = 0) {
let a = this;
b.x = (a.x - b.width) + xOffset;
b.y = (a.y + a.halfHeight - b.halfHeight) + yOffset;
}
//Some extra conveniences for working with child sprites
//Swap the depth layer positions of two child sprites
swapChildren(child1, child2) {
let index1 = this.children.indexOf(child1),
index2 = this.children.indexOf(child2);
if (index1 !== -1 && index2 !== -1) {
//Swap the indexes
child1.childIndex = index2;
child2.childIndex = index1;
//Swap the array positions
this.children[index1] = child2;
this.children[index2] = child1;
} else {
throw new Error(`Both objects must be a child of the caller ${this}`);
}
}
//`add` and `remove` let you add and remove many sprites at the same time
add(...spritesToAdd) {
spritesToAdd.forEach(sprite => this.addChild(sprite));
}
remove(...spritesToRemove) {
spritesToRemove.forEach(sprite => this.removeChild(sprite));
}
/* Advanced features */
//If the sprite has more than one frame, return the
//value of `_currentFrame`
get currentFrame() {
return this._currentFrame;
}
//The `circular` property lets you define whether a sprite
//should be interpreted as a circular object. If you set
//`circular` to `true`, the sprite is given `radius` and `diameter`
//properties. If you set `circular` to `false`, the `radius`
//and `diameter` properties are deleted from the sprite
get circular() {
return this._circular;
}
set circular (value) {
//Give the sprite `diameter` and `radius` properties
//if `circular` is `true`
if (value === true && this._circular === false) {
Object.defineProperties(this, {
diameter: {
get () {
return this.width;
},
set (value) {
this.width = value;
this.height = value;
},
enumerable: true, configurable: true
},
radius: {
get() {
return this.halfWidth;
},
set(value) {
this.width = value * 2;
this.height = value * 2;
},
enumerable: true, configurable: true
}
});
//Set this sprite's `_circular` property to `true`
this._circular = true;
}
//Remove the sprite's `diameter` and `radius` properties
//if `circular` is `false`
if (value === false && this._circular === true) {
delete this.diameter;
delete this.radius;
this._circular = false;
}
}
//Is the sprite draggable by the pointer? If `draggable` is set
//to `true`, the sprite is added to a `draggableSprites`
//array. All the sprites in `draggableSprites` are updated each
//frame to check whether they're being dragged.
//(You’ll learn how to implement this in Chapter 6.)
get draggable() {
return this._draggable;
}
set draggable(value) {
if (value === true) {
draggableSprites.push(this);
this._draggable = true;
}
//If it's `false`, remove it from the `draggableSprites` array
if (value === false) {
draggableSprites.splice(draggableSprites.indexOf(this), 1);
}
}
//Is the sprite interactive? If `interactive` is set to `true`,
//the sprite is run through the `makeInteractive` function.
//`makeInteractive` makes the sprite sensitive to pointer
//actions. It also adds the sprite to the `buttons` array,
//which is updated each frame.
//(You’ll learn how to implement this in Chapter 6.)
get interactive() {
return this._interactive;
}
set interactive(value) {
if (value === true) {
//Add interactive properties to the sprite
//so that it can act like a button
makeInteractive(this);
//Add the sprite to the global `buttons` array so
//it can be updated each frame
buttons.push(this);
//Set this sprite’s private `_interactive` property to `true`
this._interactive = true;
}
if (value === false) {
//Remove the sprite's reference from the
//`buttons` array so that it's no longer affected
//by mouse and touch interactivity
buttons.splice(buttons.indexOf(this), 1);
this._interactive = false;
}
}
}
作为奖励,让我们也创建一个通用的remove函数,它将从任何父对象中删除任何精灵或精灵列表:
function remove(...spritesToRemove) {
spritesToRemove.forEach(sprite => {
sprite.parent.removeChild(sprite);
});
}
如果你需要从游戏中移除一个精灵,并且不知道或者不关心它的父精灵是什么,使用这个通用的remove函数。
全功能渲染功能
我们给精灵添加了一些新的特性——阴影、混合模式和旋转轴心点。让我们更新我们的render函数,以允许我们使用所有这些特性。我们还将添加一个进一步的优化:精灵只有在画布的可视区域内才会被绘制在画布上。
function render(canvas) {
//Get a reference to the context
let ctx = canvas.ctx;
//Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
//Loop through each sprite object in the stage's `children` array
stage.children.forEach(sprite => {
//Display a sprite
displaySprite(sprite);
});
function displaySprite(sprite) {
//Only display the sprite if it's visible
//and within the area of the canvas
if (
sprite.visible
&& sprite.gx < canvas.width + sprite.width
&& sprite.gx + sprite.width >= -sprite.width
&& sprite.gy < canvas.height + sprite.height
&& sprite.gy + sprite.height >= -sprite.height
) {
//Save the canvas's present state
ctx.save();
//Shift the canvas to the center of the sprite's position
ctx.translate(
sprite.x + (sprite.width * sprite.pivotX),
sprite.y + (sprite.height * sprite.pivotY)
);
//Set the sprite's `rotation`, `alpha` and `scale`
ctx.rotate(sprite.rotation);
ctx.globalAlpha = sprite.alpha * sprite.parent.alpha;
ctx.scale(sprite.scaleX, sprite.scaleY);
//Display the sprite's optional drop shadow
if(sprite.shadow) {
ctx.shadowColor = sprite.shadowColor;
ctx.shadowOffsetX = sprite.shadowOffsetX;
ctx.shadowOffsetY = sprite.shadowOffsetY;
ctx.shadowBlur = sprite.shadowBlur;
}
//Display the optional blend mode
if (sprite.blendMode) ctx.globalCompositeOperation = sprite.blendMode;
//Use the sprite's own `render` method to draw the sprite
if (sprite.render) sprite.render(ctx);
if (sprite.children && sprite.children.length > 0) {
//Reset the context back to the parent sprite's top-left corner,
//relative to the pivot point
ctx.translate(-sprite.width * sprite.pivotX , -sprite.height * sprite.pivotY);
//Loop through the parent sprite's children
sprite.children.forEach(child => {
//display the child
displaySprite(child);
});
}
//Restore the canvas to its previous state
ctx.restore();
}
}
}
现在我们有了一个基类和一个渲染器,让我们开始构建我们的游戏精灵。
舞台
stage 是所有精灵的根父容器,所以这是我们应该做的第一个新东西。stage只是一个不显示任何图形的精灵。这意味着您可以直接从DisplayObject创建stage,语法如下:
let stage = new DisplayObject();
然后只需给它与画布匹配的width和height值,就万事俱备了:
stage.width = canvas.width;
stage.height = canvas.height;
Rectangle类
Rectangle sprite 的代码将会非常熟悉,但是它实现了的一些巧妙的技巧,我将在代码清单之后解释。
class Rectangle extends DisplayObject {
constructor(
width = 32,
height = 32,
fillStyle = "gray",
strokeStyle = "none",
lineWidth = 0,
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(
this, {width, height, fillStyle, strokeStyle, lineWidth, x, y}
);
//Add a `mask` property to enable optional masking
this.mask = false;
}
//The `render` method explains how to draw the sprite
render(ctx) {
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.fillStyle = this.fillStyle;
ctx.beginPath();
ctx.rect(
//Draw the sprite around its `pivotX` and `pivotY` point
-this.width * this.pivotX,
-this.height * this.pivotY,
this.width,
this.height
);
if (this.strokeStyle !== "none") ctx.stroke();
if (this.fillStyle !== "none") ctx.fill();
if (this.mask && this.mask === true) ctx.clip();
}
}
//A higher-level wrapper for the rectangle sprite
function rectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y) {
//Create the sprite
let sprite = new Rectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y);
//Add the sprite to the stage
stage.addChild(sprite);
//Return the sprite to the main program
return sprite;
}
这是Rectangle类的代码;现在让我们看看它实现的三个新技巧:遮罩、API 保险和围绕 sprite 的枢轴点旋转。
屏蔽
你在第二章中学习了如何使用形状来遮盖画布区域。Rectangle类引入了一个有用的新mask属性,让你可以选择使用任何矩形精灵作为遮罩。
this.mask = false;
它被初始化为false。如果您在游戏代码中的任何地方将mask属性设置为true,矩形将会屏蔽掉这个矩形精灵的所有子精灵。矩形的render函数中的这段代码是遮罩工作的基础:
if (this.mask && this.mask === true) ctx.clip();
正如您将在前面看到的,圆形精灵也有这个mask属性。
API 保险
看看Rectangle类代码的最后一部分。你可以看到一个名为rectangle的函数被用来创建和返回一个使用Rectangle类制作的精灵。为什么我增加了一个额外的不必要的步骤?为什么不直接用new Rectangle()创建精灵,而不用用另一个函数包装它呢?
这就是我所说的 API 保险。它是这样工作的:rectangle函数是Rectangle类构造函数的高级包装器。这个额外的包装器意味着您可以使用一致的 API 来创建 sprite,即使在将来某个时候您的底层代码发生了根本的变化。例如,如果你突然决定使用一个完全不同的类来制作矩形,你可以用新的构造函数替换旧的Rectangle构造函数,就像这样:
function rectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y) {
let sprite = new BetterRectangle(width, height, fillStyle, strokeStyle, lineWidth, x, y);
stage.addChild(sprite);
return sprite;
}
你在游戏中用来创建矩形精灵的代码不会改变;rectangle函数只是将参数重定向到不同的类构造函数。
使用包装函数也意味着在创建 sprite 时可以运行有用的辅助任务,比如将 sprite 添加到stage中。这很重要,也是你需要为你创建的每一个精灵做的事情。但是为了保持模块化,这个任务可能不应该放在主 sprite 类中。(API 保险是我自己编的一个术语——不要在任何计算机科学教科书上找!)
围绕旋转轴旋转
如果你想旋转你的精灵偏离中心,使用一个轴点。您可以将轴心点视为一个可以插入精灵的大头针。当您旋转精灵时,它会围绕该大头针旋转。pivotX和pivotY属性取 0.1 到 0.99 之间的值,代表精灵的宽度或高度的百分比。(像这样介于 0 和 1 之间的百分比值通常被称为归一化值。)pivotX和pivotY初始化为 0.5 的值,这意味着精灵会围绕其中心旋转。让我们通过使用新代码创建一个矩形,将其 pivot 值设置为 0.25,并旋转它来测试这一点。
let box = rectangle(96, 96, "blue", "none", 0, 54, 64);
box.pivotX = 0.25;
box.pivotY = 0.25;
box.rotation = 0.8;
图 4-10 中的第三幅图像显示了结果。
图 4-10 。设置轴点
枢轴点不会改变精灵的 x 和 y 位置;这些保持固定在精灵的未旋转的左上角。
pivotX和pivotY是如何产生这种效果的?矩形的render功能围绕枢轴点绘制形状*。*
ctx.rect(
-this.width * this.pivotX,
-this.width * this.pivotY,
this.width,
this.height
);
你可以将pivotX和pivotY用于本章中所有新的精灵类型。
Circle类
Circle类遵循与Rectangle类相同的格式,但是它画了一个圆。像矩形一样,圆形也有一个mask属性,所以你可以使用它们来选择性地屏蔽任何其他精灵。
class Circle extends DisplayObject {
constructor(
diameter = 32,
fillStyle = "gray",
strokeStyle = "none",
lineWidth = 0,
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Enable `radius` and `diameter` properties
this.circular = true;
//Assign the argument values to this sprite
Object.assign(
this, {diameter, fillStyle, strokeStyle, lineWidth, x, y}
);
//Add a `mask` property to enable optional masking
this.mask = false;
}
//The `render` method
render(ctx) {
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.fillStyle = this.fillStyle;
ctx.beginPath();
ctx.arc(
this.radius + (-this.diameter * this.pivotX),
this.radius + (-this.diameter * this.pivotY),
this.radius,
0, 2*Math.PI,
false
);
if (this.strokeStyle !== "none") ctx.stroke();
if (this.fillStyle !== "none") ctx.fill();
if (this.mask && this.mask === true) ctx.clip();
}
}
//A higher level wrapper for the circle sprite
export function circle(diameter, fillStyle, strokeStyle, lineWidth, x, y) {
let sprite = new Circle(diameter, fillStyle, strokeStyle, lineWidth, x, y);
stage.addChild(sprite);
return sprite;
}
下面是如何使用这段代码画一个青色(浅蓝色)填充红色轮廓的圆,如图图 4-11 所示。
let cyanCircle = circle(64, "cyan", "red", 4, 64, 280);
图 4-11 。使用Circle类来画一个圆
圆的 x 和 y 值指的是限定圆的一个假想框的左上角。
圆圈有diameter和radius属性,这是由Circle类构造函数中的这行代码创建的:
this.circular = true;
DisplayObject基类有一个名为circular的设置器,当它被设置为true时,它在任何 sprite 上创建diameter和radius属性。如果你有显示圆形图像的精灵,你可能会发现这个特性很有用,你想把它和圆形的碰撞检测功能一起使用,你会在第七章的中了解到这一点。
Line类
Line类创建一个帮助你画线的精灵:
class Line extends DisplayObject {
constructor(
strokeStyle = "none",
lineWidth = 0,
ax = 0,
ay = 0,
bx = 32,
by = 32
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(
this, {strokeStyle, lineWidth, ax, ay, bx, by}
);
//The `lineJoin` style.
//Options are "round", "mitre" and "bevel".
this.lineJoin = "round";
}
//The `render` method
render(ctx) {
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.lineJoin = this.lineJoin;
ctx.beginPath();
ctx.moveTo(this.ax, this.ay);
ctx.lineTo(this.bx, this.by);
if (this.strokeStyle !== "none") ctx.stroke();
}
}
//A higher-level wrapper for the line sprite
function line(strokeStyle, lineWidth, ax, ay, bx, by) {
let sprite = new Line(strokeStyle, lineWidth, ax, ay, bx, by);
stage.addChild(sprite);
return sprite;
}
要创建线条精灵,请设置其颜色和宽度,然后定义其起点和终点。值ax和ay定义直线的起点,bx和by定义直线的终点:
let blackLine = line(fillStyle, lineWidth, ax, ay, bx, by);
您可以随时改变ax、ay、bx,和by的值来改变线条的位置。
下面的代码产生了图 4-12 中的十字交叉线的图像。
let blackLine = line("black", 4, 200, 64, 264, 128);
let redLine = line("red", 4, 200, 128, 264, 64);
let greenLine = line("green", 4, 264, 96, 200, 96);
let blueLine = line("blue", 4, 232, 128, 232, 64);
图 4-12 。用Line类画一些线
Text类
Text类给你一个快速的方法给游戏添加一些动态文本:
class Text extends DisplayObject {
constructor(
content = "Hello!",
font = "12px sans-serif",
fillStyle = "red",
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(
this, {content, font, fillStyle, x, y}
);
//Set the default text baseline to "top"
this.textBaseline = "top";
//Set `strokeText` to "none"
this.strokeText = "none";
}
//The `render` method describes how to draw the sprite
render(ctx) {
ctx.font = this.font;
ctx.strokeStyle = this.strokeStyle;
ctx.lineWidth = this.lineWidth;
ctx.fillStyle = this.fillStyle;
//Measure the width and height of the text
if (this.width === 0) this.width = ctx.measureText(this.content).width;
if (this.height === 0) this.height = ctx.measureText("M").width;
ctx.translate(
-this.width * this.pivotX,
-this.height * this.pivotY
);
ctx.textBaseline = this.textBaseline;
ctx.fillText(
this.content,
0,
0
);
if (this.strokeText !== "none") ctx.strokeText();
}
}
//A higher level wrapper
function text(content, font, fillStyle, x, y) {
let sprite = new Text(content, font, fillStyle, x, y);
stage.addChild(sprite);
return sprite;
}
下面是如何制作一个文本精灵来显示单词“Hello World!”:
let message = text("Hello World!", "24px Futura", "black", 330, 230);
第二个参数定义了文本应该使用的字体。你可以使用浏览器内置的任何标准字体,或者任何加载了@font-face CSS 规则或者我们在第三章中内置的assets对象的字体文件。
文本精灵有一个名为content的属性,可以用来改变文本显示的单词:
message.content = "Anything you like";
您可以在游戏过程中随时更改content,这对于更新动态文本非常有用,比如玩家的分数。
Group类
是一种特殊的精灵,它不显示自己的任何图形。相反,它用于将其他精灵分组在一起。你可以把它想象成一个小精灵的大容器。但是因为一个组和其他精灵有相同的属性,你可以用它作为复杂游戏角色、游戏场景或关卡的根父级。
组的高度和宽度是根据它包含的内容动态计算的。Group类实现了自定义的addChild和removeChild方法,每当一个精灵被添加到组中或者从组中移除时,这些方法都会重新计算组的大小。Group类的calculateSize方法循环遍历该组的每个子组,并将该组的width和height设置为其任何子组占据的最大宽度和高度。
class Group extends DisplayObject {
constructor(...spritesToGroup){
//Call the DisplayObject's constructor
super();
//Group all the sprites listed in the constructor arguments
spritesToGroup.forEach(sprite => this.addChild(sprite));
}
//Groups have custom `addChild` and `removeChild` methods that call
//a `calculateSize` method when any sprites are added or removed
//from the group
addChild(sprite) {
if (sprite.parent) {
sprite.parent.removeChild(sprite);
}
sprite.parent = this;
this.children.push(sprite);
//Figure out the new size of the group
this.calculateSize();
}
removeChild(sprite) {
if(sprite.parent === this) {
this.children.splice(this.children.indexOf(sprite), 1);
//Figure out the new size of the group
this.calculateSize();
} else {
throw new Error(`${sprite} is not a child of ${this}`);
}
}
calculateSize() {
//Calculate the width based on the size of the largest child
//that this sprite contains
if (this.children.length > 0) {
//Some temporary private variables to help track the new
//calculated width and height
this._newWidth = 0;
this._newHeight = 0;
//Find the width and height of the child sprites furthest
//from the top left corner of the group
this.children.forEach(child => {
//Find child sprites that combined x value and width
//that's greater than the current value of `_newWidth`
if (child.x + child.width > this._newWidth) {
//The new width is a combination of the child's
//x position and its width
this._newWidth = child.x + child.width;
}
if (child.y + child.height > this._newHeight) {
this._newHeight = child.y + child.height;
}
});
//Apply the `_newWidth` and `_newHeight` to this sprite's width
//and height
this.width = this._newWidth;
this.height = this._newHeight;
}
}
}
//A higher level wrapper for the group sprite
function group(...spritesToGroup) {
let sprite = new Group(...spritesToGroup);
stage.addChild(sprite);
return sprite;
}
要创建组,请在组的构造函数中列出要分组的精灵:
let squares = group(squareOne, squareTwo, squareThree);
或者,你可以创建一个空组,并用addChild或add将精灵分组在一起:
let squares = group();
squares.addChild(squareOne);
squares.add(squareTwo, squareThree);
团队在游戏中有很多用途,你会在接下来的章节中看到。
Sprite类
是一个强大的显示图像的类。它允许您显示单个图像文件中的图像、纹理贴图集帧或 tileset 中的子图像。它还允许您存储多个图像状态,并用图像数组初始化 sprite。为了帮助显示不同的图像状态,Sprite类还实现了一个名为gotoAndStop 的新方法。(我们将使用这些特性作为基础,在第七章的中制作交互按钮,并在第八章的中制作关键帧动画。)
这对于一个 sprite 类型来说是很大的工作量,所以Sprite类相当大。但是这段代码的最大部分只是计算出提供给它的是哪种图像信息。让我们看一下Sprite类的完整代码清单,然后我将带您了解每个特性是如何工作的。
class Sprite extends DisplayObject {
constructor(
source,
x = 0,
y = 0
){
//Call the DisplayObject's constructor
super();
//Assign the argument values to this sprite
Object.assign(this, {x, y});
//We need to figure out what the source is, and then use
//that source data to display the sprite image correctly
//Is the source a JavaScript Image object?
if(source instanceof Image) {
this.createFromImage(source);
}
//Is the source a tileset from a texture atlas?
//(It is if it has a `frame` property)
else if (source.frame) {
this.createFromAtlas(source);
}
//If the source contains an `image` subproperty, this must
//be a `frame` object that's defining the rectangular area of an inner subimage.
//Use that subimage to make the sprite. If it doesn't contain a
//`data` property, then it must be a single frame
else if (source.image && !source.data) {
this.createFromTileset(source);
}
//If the source contains an `image` subproperty
//and a `data` property, then it contains multiple frames
else if (source.image && source.data) {
this.createFromTilesetFrames(source);
}
//Is the source an array? If so, what kind of array?
else if (source instanceof Array) {
if (source[0] && source[0].source) {
//The source is an array of frames on a texture atlas tileset
this.createFromAtlasFrames(source);
}
//It must be an array of image objects
else if (source[0] instanceof Image){
this.createFromImages(source);
}
//throw an error if the sources in the array aren't recognized
else {
throw new Error(`The image sources in ${source} are not recognized`);
}
}
//Throw an error if the source is something we can't interpret
else {
throw new Error(`The image source ${source} is not recognized`);
}
}
createFromImage(source) {
//Throw an error if the source is not an Image object
if (!(source instanceof Image)) {
throw new Error(`${source} is not an image object`);
}
//Otherwise, create the sprite using an Image
else {
this.source = source;
this.sourceX = 0;
this.sourceY = 0;
this.width = source.width;
this.height = source.height;
this.sourceWidth = source.width;
this.sourceHeight = source.height;
}
}
createFromAtlas(source) {
this.tilesetFrame = source;
this.source = this.tilesetFrame.source;
this.sourceX = this.tilesetFrame.frame.x;
this.sourceY = this.tilesetFrame.frame.y;
this.width = this.tilesetFrame.frame.w;
this.height = this.tilesetFrame.frame.h;
this.sourceWidth = this.tilesetFrame.frame.w;
this.sourceHeight = this.tilesetFrame.frame.h;
}
createFromTileset(source) {
if (!(source.image instanceof Image)) {
throw new Error(`${source.image} is not an image object`);
} else {
this.source = source.image;
this.sourceX = source.x;
this.sourceY = source.y;
this.width = source.width;
this.height = source.height;
this.sourceWidth = source.width;
this.sourceHeight = source.height;
}
}
createFromTilesetFrames(source) {
if (!(source.image instanceof Image)) {
throw new Error(`${source.image} is not an image object`);
} else {
this.source = source.image;
this.frames = source.data;
//Set the sprite to the first frame
this.sourceX = this.frames[0][0];
this.sourceY = this.frames[0][1];
this.width = source.width;
this.height = source.height;
this.sourceWidth = source.width;
this.sourceHeight = source.height;
}
}
createFromAtlasFrames(source) {
this.frames = source;
this.source = source[0].source;
this.sourceX = source[0].frame.x;
this.sourceY = source[0].frame.y;
this.width = source[0].frame.w;
this.height = source[0].frame.h;
this.sourceWidth = source[0].frame.w;
this.sourceHeight = source[0].frame.h;
}
createFromImages(source) {
this.frames = source;
this.source = source[0];
this.sourceX = 0;
this.sourceY = 0;
this.width = source[0].width;
this.height = source[0].width;
this.sourceWidth = source[0].width;
this.sourceHeight = source[0].height;
}
//Add a `gotoAndStop` method to go to a specific frame
gotoAndStop(frameNumber) {
if (this.frames.length > 0 && frameNumber < this.frames.length) {
//a. Frames made from tileset subimages.
//If each frame is an array, then the frames were made from an
//ordinary Image object using the `frames` method
if (this.frames[0] instanceof Array) {
this.sourceX = this.frames[frameNumber][0];
this.sourceY = this.frames[frameNumber][1];
}
//b. Frames made from texture atlas frames.
//If each frame isn't an array, and it has a subobject called `frame`,
//then the frame must be a texture atlas ID name.
//In that case, get the source position from the atlas's `frame` object
else if (this.frames[frameNumber].frame) {
this.sourceX = this.frames[frameNumber].frame.x;
this.sourceY = this.frames[frameNumber].frame.y;
this.sourceWidth = this.frames[frameNumber].frame.w;
this.sourceHeight = this.frames[frameNumber].frame.h;
this.width = this.frames[frameNumber].frame.w;
this.height = this.frames[frameNumber].frame.h;
}
//c. Frames made from individual Image objects.
//If neither of the above is true, then each frame must be
//an individual Image object
else {
this.source = this.frames[frameNumber];
this.sourceX = 0;
this.sourceY = 0;
this.width = this.source.width;
this.height = this.source.height;
this.sourceWidth = this.source.width;
this.sourceHeight = this.source.height;
}
//Set the `_currentFrame` value to the chosen frame
this._currentFrame = frameNumber;
}
//Throw an error if this sprite doesn't contain any frames
else {
throw new Error(`Frame number ${frameNumber} does not exist`);
}
}
//The `render` method
render(ctx) {
ctx.drawImage(
this.source,
this.sourceX, this.sourceY,
this.sourceWidth, this.sourceHeight,
-this.width * this.pivotX,
-this.height * this.pivotY,
this.width, this.height
);
}
}
//A higher-level wrapper
function sprite(source, x, y) {
let sprite = new Sprite(source, x, y);
stage.addChild(sprite);
return sprite;
}
Sprite类是为最大的灵活性而设计的,这样你就可以显示各种来源的图像。让我们来看看您可以使用哪些图像源以及如何使用。
从单一图像制作精灵
要使用单个图像文件制作精灵,首先使用assets.load加载图像,你在第三章中学会了如何使用。
assets.load(["img/cat.png"]).then(() => setup());
然后提供对 image 对象的引用作为 sprite 函数的第一个参数。第二个和第三个参数是精灵的 x 和 y 位置:
function setup() {
let cat = sprite(assets["img/cat.png"], 64, 410);
}
这是从图像制作精灵的最基本的方法,但是你有更多的选择。
从纹理贴图帧制作精灵
使用纹理贴图帧就像使用单个图像文件一样简单。首先加载纹理图谱:
assets.load(["img/animals.json"]).then(() => setup());
然后提供 atlas 帧作为精灵的源:
let tiger = sprite(assets["tiger.png"], 192, 410);
(记住,"tiger.png"是纹理图集帧 ID,不是图像文件。)类Sprite知道这是一个纹理贴图集帧,因为当它检查source参数时,会发现一个名为frame的属性。那是纹理图谱的指纹。
这很方便,但是如果你想直接从一个单独的 tileset 图像中拼接一个子图像而不使用纹理贴图集呢?
从 Tileset 中创建子图像
假设您有一个单独的 tileset 图像,其中包含一个游戏角色的四个动画帧。图 4-13 显示了一个例子。
图 4-13 。具有四个角色动画帧的 tileset
您没有附带的 JSON 文件来告诉您这些帧的位置或大小;你只是想直接从图像中 blit 其中一个子图像。你怎么能这么做?
我们将使用一个名为frame的新函数来帮助你捕捉单独的 tileset 帧。
function frame(source, x, y, width, height) {
var o = {};
o.image = source;
o.x = x;
o.y = y;
o.width = width;
o.height = height;
return o;
};
要使用它,请提供您想要 blit 的 tileset 图像的名称。然后提供您想要使用的子图像的 x 、 y 、宽度和高度。 frame函数返回一个对象,您将能够使用该对象使用子图像创建精灵。下面是如何使用这个新的frame函数 blit 来自示例童话人物 tileset 的第一帧(示例 tileset 中的每一帧是 48 像素宽和 32 像素高):
let fairyFrame = frame(
assets["img/fairy.png"], //the tileset source image
0, 0, 48, 32 //The subimage's x, y, width and height
);
接下来,使用返回的fairyFrame对象初始化 sprite。
let fairy = sprite(fairyFrame, 164, 326);
这将创建一个显示 tileset 第一帧的 sprite,如图 4-14 所示。
图 4-14 。从切片集中复制子图像
fairyFrame是 sprite 的源参数。Sprite类检测到您正在对单个子图像进行块传输,因为源代码包含一个由frame函数创建的image属性。下面是来自Sprite类的代码片段,用于检查这一点:
else if (source.image && !source.data) {
//The source is a single subimage from a tileset
this.createFromTileset(source);
}
(下一节你就知道source.data是什么了。)
Sprite类调用它的createFromTileset方法,使用你在第二章中学到的相同技术,将子图像 blit 到画布上。
块传输多个 Tileset 帧
Sprite类的一个重要特性是它可以加载一个包含多个图像的 sprite。然后你可以使用sprite.gotoAndStop(frameNumber)来改变精灵显示的图像。这个特性将构成关键帧动画和按钮交互性的基础,你将在后面的章节中了解到。
但是如何将多个图像加载到一个 sprite 中呢?有几种不同的方法,但是让我们先来看看如何使用 tileset 图像。我们将使用一个名为frames(带“s”)的新函数,它允许您为想要使用的子图像指定一个由 x 和 y 位置组成的数组。
function frames(source, arrayOfPositions, width, height) {
var o = {};
o.image = source;
o.data = arrayOfPositions;
o.width = width;
o.height = height;
return o;
};
第二个参数让您提供 tileset 上子图像 x 和 y 位置的 2D 数组。该 2D 数组被复制到该函数返回的对象的一个名为data的属性中。下面是如何将它与我们的示例 tileset 一起使用,以指定您想要使用的前三个帧:
let fairyFrames = frames(
assets["img/fairy.png"], //The tileset image
[[0,0],[48,0],[96,0]], //The 2D array of x/y frame positions
48, 32 //The width and height of each frame
);
现在通过提供fairyFrames作为源来创建 sprite:
let fairy = sprite(fairyFrames, 224, 326);
仙女精灵现在有三个图像帧存储在一个名为frames的内部数组属性中。默认显示第一帧,但是你可以使用gotoAndStop让精灵显示另一帧。下面介绍如何让仙女显示第三帧,如图图 4-15 。
fairy.gotoAndStop(2);
图 4-15 。使用gotoAndStop改变精灵显示的帧
gotoAndStop方法通过将精灵的sourceX和sourceY值设置为由帧号指定的 x / y 位置 2D 数组来实现这一点。下面是来自Sprite类的代码:
if (this.frames[0] instanceof Array) {
this.sourceX = this.frames[frameNumber][0];
this.sourceY = this.frames[frameNumber][1];
}
您也可以应用类似的技术来加载多个纹理贴图集帧,如下所示。
使用多个纹理贴图集帧
假设您想为一个游戏制作一个具有三种图像状态的可点击按钮:向上、向上和向下。你在一个图像编辑器中创建每个状态,并使用这些图像制作一个纹理贴图集,如图图 4-16 所示。按钮框 ID 名称为up.png、over.png和down.png。
图 4-16 。使用纹理贴图集创建精灵图像状态
接下来,将纹理贴图集加载到游戏中:
assets.load([
"img/button.json"
]).then(() => setup());
您希望创建一个可以使用所有三种按钮图像状态的 sprite。首先,创建一个引用三个帧 ID 名称的数组:
let buttonFrames = [
assets["up.png"],
assets["over.png"],
assets["down.png"]
];
然后创建一个 sprite 并提供buttonFrames数组作为源:
let button = sprite(buttonFrames, 300, 280);
Sprite类将这些加载到 sprite 的frames数组中,并设置 sprite 显示第一帧。你现在可以使用gotoAndStop来有选择地显示这些帧中的任何一个。下面是如何显示"over.png"帧(帧数组中的第二个元素):
button.gotoAndStop(1);
gotoAndStop方法通过将精灵的sourceX和sourceY切换到纹理贴图集中正确的 x 和 y 帧值来实现这一点。下面是来自gotoAndStop方法的代码片段:
else if (this.frames[frameNumber].frame) {
this.sourceX = this.frames[frameNumber].frame.x;
this.sourceY = this.frames[frameNumber].frame.y;
}
你可以加载任意多帧的精灵,你将在第八章中学习如何使用gotoAndStop作为构建关键帧动画播放器的基本构件。
使用多个图像文件
为了获得最大的灵活性,Sprite类还允许您将单个图像文件加载到精灵中。假设您想要制作一个包含三种动物图像的精灵,每种动物都是单独的帧。首先,将你的图像文件加载到assets对象中:
assets.load([
"img/cat.png",
"img/tiger.png",
"img/hedgehog.png"
]).then(() => setup());
接下来,创建一个引用这些图像文件的数组:
let animalImages = [
assets["img/hedgehog.png"],
assets["img/tiger.png"],
assets["img/cat.png"]
];
然后使用图像数组初始化 sprite:
let animals = sprite(animalImages, 320, 410);
默认情况下,数组中的第一个图像刺猬将显示在 sprite 上。如果您希望 sprite 显示猫的图像,使用gotoAndStop显示第三个数组元素,如下所示:
animals.gotoAndStop(2);
看一下源代码中的gotoAndStop方法,你会发现只要将精灵的源切换到正确的图像就可以了。
制作你自己的精灵
现在你已经有了一个灵活而有用的制作精灵的系统,在接下来的章节中你会看到所有这些新代码将如何帮助我们以一种有趣而高效的方式制作游戏。这些精灵类型是你制作几乎所有你能想到的 2D 动作游戏所需要的。你会在本章源文件的library/display文件夹中找到所有这些新代码。如果你想使用这些新的类和函数开始制作你自己的精灵,按如下方式导入它们:
import {
makeCanvas, rectangle, circle, sprite,
line, group, text, stage, render, remove,
frame, frames
} from "../library/display";
另外,如果您需要预加载任何图像、字体或 JSON 文件,不要忘记从library/utilities导入assets对象:
import {assets} from "../library/utilities";
然后使用assets.load 加载您可能需要的任何文件,并调用setup函数:
assets.load([
"fonts/puzzler.otf",
"img/cat.png",
"img/animals.json",
"img/fairy.png",
"img/tiger.png",
"img/hedgehog.png",
"img/button.json"
]).then(() => setup());
接下来,使用setup函数创建画布,设置舞台,并创建你的精灵。调用render函数来显示它们。
function setup() {
//Create the canvas and stage
let canvas = makeCanvas(512, 512);
stage.width = canvas.width;
stage.height = canvas.height;
//..Use the code from this chapter to make sprites here...
//Then render them on the canvas:
render(canvas);
}
在本章的源文件中你会发现一个名为allTheSprites.html的文件,如图 4-17 中的所示,它展示了本章中的新代码。仔细看看代码如何生成您在画布上看到的图像,并尝试进行自己的更改和添加。
图 4-17 。你在本章学到的所有新技术场景图
摘要
在这一章中,你已经了解了精灵对于在屏幕上快速显示图像是多么的有用。您已经使用父/子嵌套创建了分层场景图,创建了显示矩形、圆形、线条、文本和图像的精灵,甚至创建了具有多个帧的精灵。您还深入了解了如何渲染小精灵,如何使用纹理贴图集制作小精灵,以及如何使用 ES6 类来构建有用的类继承系统,并且学习了代码抽象的原则。
现在我们知道了如何创建精灵,我们如何让他们移动呢?在下一章中,你将会学到所有关于脚本动画的知识,这是每个游戏设计者需要知道的在屏幕上移动精灵的基本技术。**