第 3 版现已推出。点击此处阅读

第 16 章
在画布上绘图

绘画是一种欺骗。

M.C. Escher,引自布鲁诺·恩斯特的《M.C. Escher 的魔镜》

浏览器提供了多种显示图形的方式。最简单的方法是使用样式来定位和设置常规 DOM 元素的颜色。这可以让你走得相当远,就像上一章中的游戏所展示的那样。通过向节点添加部分透明的背景图像,我们可以使它们看起来完全符合我们的要求。甚至可以通过使用transform样式来旋转或倾斜节点。

但我们会将 DOM 用于它最初并非设计的用途。一些任务,例如在任意点之间绘制一条线,对于使用常规 HTML 元素来说非常笨拙。

有两种替代方法。第一种是基于 DOM 的,但利用的是可缩放矢量图形 (SVG),而不是 HTML 元素。可以将 SVG 文档嵌入到 HTML 文档中,也可以通过<img>标签将其包含在内。

第二种替代方法称为画布。画布是一个单一的 DOM 元素,封装了一幅图片。它提供了一个用于在节点所占空间上绘制形状的编程接口。画布和 SVG 图片的主要区别在于,在 SVG 中,形状的原始描述被保留下来,以便它们可以随时移动或调整大小。另一方面,画布会将形状转换为像素(栅格上的彩色点),一旦绘制完成,就无法记住这些像素代表什么。移动画布上形状的唯一方法是清除画布(或画布周围的形状部分)并将其重新绘制在新的位置。

SVG

本书不会详细介绍 SVG,但我将简要解释它的工作原理。在本章末尾,我将回到在决定哪种绘图机制适合特定应用程序时必须考虑的权衡取舍。

这是一个包含简单 SVG 图片的 HTML 文档

<p>Normal HTML here.</p>
<svg xmlns="http://www.w3.org/2000/svg">
  <circle r="50" cx="50" cy="50" fill="red"/>
  <rect x="120" y="5" width="90" height="90"
        stroke="blue" fill="none"/>
</svg>

xmlns属性将一个元素(及其子元素)更改为不同的XML 命名空间。此命名空间由一个 URL 标识,指定了我们当前使用的方言。<circle><rect>标签在 HTML 中不存在,但在 SVG 中有意义——它们使用其属性指定的样式和位置绘制形状。

这些标签创建 DOM 元素,就像 HTML 标签一样。例如,这将<circle>元素更改为青色

var circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");

画布元素

画布图形可以绘制到<canvas>元素上。你可以为这样的元素提供widthheight属性来确定它的像素大小。

新的画布是空的,这意味着它是完全透明的,因此在文档中只是显示为空白空间。

<canvas>标签旨在支持不同的绘制样式。要获得实际绘制接口的访问权限,我们首先需要创建一个上下文,它是一个对象,其方法提供绘制接口。目前,两种广泛支持的绘制样式是:"2d"用于二维图形,"webgl"用于通过 OpenGL 接口进行三维图形绘制。

本书不会讨论 WebGL。我们坚持二维。但如果你对三维图形感兴趣,我鼓励你去研究 WebGL。它为现代图形硬件提供了非常直接的接口,因此你可以使用 JavaScript 非常有效地渲染复杂的场景,即使这些场景非常复杂。

上下文是通过<canvas>元素上的getContext方法创建的。

<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
  var canvas = document.querySelector("canvas");
  var context = canvas.getContext("2d");
  context.fillStyle = "red";
  context.fillRect(10, 10, 100, 50);
</script>

在创建上下文对象后,示例将绘制一个宽度为 100 像素、高度为 50 像素的红色矩形,其左上角位于坐标 (10,10) 处。

就像在 HTML(和 SVG)中一样,画布使用的坐标系将 (0,0) 放在左上角,正 y 轴从那里向下延伸。因此,(10,10) 位于左上角下方和右侧 10 像素处。

填充和描边

在画布接口中,形状可以填充,这意味着它的区域被赋予某种颜色或图案,也可以描边,这意味着沿着其边缘绘制一条线。SVG 使用相同的术语。

fillRect方法填充矩形。它首先获取矩形的左上角的 x 和 y 坐标,然后是它的宽度,最后是它的高度。类似的方法strokeRect绘制矩形的轮廓。

这两个方法都不接受任何其他参数。填充的颜色、描边的厚度等不是由方法的参数(正如你可能预期的那样)决定的,而是由上下文对象的属性决定的。

设置fillStyle将改变形状的填充方式。它可以设置为一个指定颜色的字符串,任何 CSS 可以理解的颜色也可以在这里使用。

strokeStyle属性的工作原理类似,但它决定用于描边的线的颜色。该线的宽度由lineWidth属性决定,该属性可以包含任何正数。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.strokeStyle = "blue";
  cx.strokeRect(5, 5, 50, 50);
  cx.lineWidth = 5;
  cx.strokeRect(135, 5, 50, 50);
</script>

当没有指定widthheight属性时,就像在前面的示例中一样,画布元素将获得默认宽度 300 像素和高度 150 像素。

路径

路径是一系列线段。二维画布接口采取了一种奇特的方法来描述这样的路径。它是完全通过副作用完成的。路径不是可以存储和传递的值。相反,如果你想对路径做些什么,你就需要进行一系列方法调用来描述它的形状。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  for (var y = 10; y < 100; y += 10) {
    cx.moveTo(10, y);
    cx.lineTo(90, y);
  }
  cx.stroke();
</script>

此示例创建一个具有多个水平线段的路径,然后使用stroke方法对其进行描边。使用lineTo创建的每个线段都从路径的当前位置开始。该位置通常是最后一个线段的末端,除非调用了moveTo。在这种情况下,下一个线段将从传递给moveTo的位置开始。

填充路径(使用fill方法)时,每个形状都是单独填充的。路径可以包含多个形状——每个moveTo动作都会开始一个新的形状。但是,路径需要闭合(意味着其起点和终点位于同一个位置)才能填充。如果路径尚未闭合,则会添加一条从其终点到其起点的线,然后填充由完成的路径包围的形状。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(50, 10);
  cx.lineTo(10, 70);
  cx.lineTo(90, 70);
  cx.fill();
</script>

此示例绘制一个填充的三角形。请注意,只显式绘制了三角形的两条边。第三条边,从右下角回到顶部,是隐含的,当描边路径时,它将不存在。

你也可以使用closePath方法通过添加一条实际的线段回到路径的起点来显式闭合路径。当描边路径时,这条线段会被绘制出来。

曲线

路径也可以包含曲线。不幸的是,绘制曲线比绘制直线要复杂得多。

quadraticCurveTo方法绘制一条到给定点的曲线。为了确定线的曲率,该方法同时给出了一个控制点和一个目标点。想象一下,这个控制点吸引着这条线,使其弯曲。这条线不会穿过控制点。相反,线的起点和终点的方向将与从那里到控制点的线对齐。下面的示例说明了这一点

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 90);
  // control=(60,10) goal=(90,90)
  cx.quadraticCurveTo(60, 10, 90, 90);
  cx.lineTo(60, 10);
  cx.closePath();
  cx.stroke();
</script>

我们从左侧绘制一条二次曲线到右侧,(60,10) 作为控制点,然后绘制两条线段,分别穿过控制点并返回到线的起点。结果有点类似于星际迷航的徽章。你可以看到控制点的影响:从下角离开的线从控制点开始,然后向其目标弯曲。

bezierCurveTo方法绘制类似类型的曲线。它不是一个控制点,而是两个——每个端点一个。这里有一个类似的草图来说明这种曲线的行为

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 90);
  // control1=(10,10) control2=(90,10) goal=(50,90)
  cx.bezierCurveTo(10, 10, 90, 10, 50, 90);
  cx.lineTo(90, 10);
  cx.lineTo(10, 10);
  cx.closePath();
  cx.stroke();
</script>

两个控制点指定曲线的两端方向。它们离各自的点越远,曲线就越会在那个方向上“凸起”。

这种曲线可能很难处理——你并不总是清楚如何找到提供你想要形状的控制点。有时你可以计算它们,有时你只需要通过反复试验来找到合适的值。

——圆的一部分——更容易理解。arcTo方法需要不少于五个参数。前四个参数的行为类似于quadraticCurveTo的参数。第一对提供了一种控制点,第二对提供了线的目标。第五个参数提供弧的半径。该方法将在概念上投影一个角——一条到控制点的线,然后到目标点的线——并将角的点圆化,使其构成半径为给定值的圆的一部分。然后,arcTo方法绘制圆化的部分,以及从起始位置到圆化部分开始位置的一条线。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 10);
  // control=(90,10) goal=(90,90) radius=20
  cx.arcTo(90, 10, 90, 90, 20);
  cx.moveTo(10, 10);
  // control=(90,10) goal=(90,90) radius=80
  cx.arcTo(90, 10, 90, 90, 80);
  cx.stroke();
</script>

arcTo方法不会绘制从圆化部分末端到目标位置的线,尽管其名称中的to会暗示它会绘制。你可以使用具有相同目标坐标的lineTo调用来添加该部分的线。

要绘制一个圆形,可以使用四个 arcTo 调用(每次旋转 90 度)。但 arc 方法提供了更简单的方法。它需要圆心坐标对、半径,以及起始角度和结束角度。

最后两个参数可以只绘制圆形的一部分。角度以弧度为单位,而不是度数。这意味着一个完整的圆形的角度为 2π,或 2 * Math.PI,大约为 6.28。角度从圆心右侧的点开始计算,并从该点顺时针方向开始。可以使用 0 作为起点,以及大于 2π 的终点(例如,7)来绘制一个完整的圆形。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  // center=(50,50) radius=40 angle=0 to 7
  cx.arc(50, 50, 40, 0, 7);
  // center=(150,50) radius=40 angle=0 to ½π
  cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
  cx.stroke();
</script>

生成的图像包含一条从完整圆形的右侧(第一个 arc 调用)到四分之一圆形的右侧(第二个调用)的线。与其他路径绘制方法一样,默认情况下,使用 arc 绘制的线与前一个路径段相连。如果要避免这种情况,则必须调用 moveTo 或开始一个新的路径。

绘制饼图

假设您刚在 EconomiCorp, Inc. 找到一份工作,您的第一个任务是绘制其客户满意度调查结果的饼图。

results 变量包含一个表示调查回复的对象数组。

var results = [
  {name: "Satisfied", count: 1043, color: "lightblue"},
  {name: "Neutral", count: 563, color: "lightgreen"},
  {name: "Unsatisfied", count: 510, color: "pink"},
  {name: "No comment", count: 175, color: "silver"}
];

要绘制饼图,我们绘制一些饼图切片,每个切片都由一个弧形和一对到弧形中心的线组成。我们可以通过将一个完整的圆形 (2π) 除以回复总数,然后将该数字(每条回复的角度)乘以选择特定选项的人数,来计算每个弧形所占的角度。

<canvas width="200" height="200"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var total = results.reduce(function(sum, choice) {
    return sum + choice.count;
  }, 0);
  // Start at the top
  var currentAngle = -0.5 * Math.PI;
  results.forEach(function(result) {
    var sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.beginPath();
    // center=100,100, radius=100
    // from current angle, clockwise by slice's angle
    cx.arc(100, 100, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(100, 100);
    cx.fillStyle = result.color;
    cx.fill();
  });
</script>

但是,一个没有说明其含义的图表不是很有用。我们需要一种在画布上绘制文本的方法。

文本

二维画布绘图上下文提供了 fillTextstrokeText 方法。后者可用于描绘字母轮廓,但通常使用 fillText。它将使用当前 fillColor 填充给定文本。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.font = "28px Georgia";
  cx.fillStyle = "fuchsia";
  cx.fillText("I can draw text, too!", 10, 50);
</script>

可以使用 font 属性指定文本的大小、样式和字体。此示例只给出字体大小和字体系列名称。可以在字符串的开头添加 italicbold 来选择样式。

fillText(和 strokeText)的最后两个参数提供了绘制字体的坐标。默认情况下,它们表示文本字母基线的开始位置,即字母“站立”的线,不包括 jp 等字母的悬挂部分。可以通过将 textAlign 属性设置为 "end""center" 来更改水平位置,并将 textBaseline 属性设置为 "top""middle""bottom" 来更改垂直位置。

我们将在本章末尾的 练习 中回到我们的饼图以及对切片进行标记的问题。

图像

在计算机图形学中,通常区分矢量图形和位图图形。第一个是我们本章一直在做的事情——通过提供形状的逻辑描述来指定图片。另一方面,位图图形不指定实际形状,而是处理像素数据(彩色点的栅格)。

drawImage 方法允许我们在画布上绘制像素数据。此像素数据可以源于 <img> 元素或其他画布,两者都不必在实际文档中可见。以下示例创建一个分离的 <img> 元素,并将图像文件加载到其中。但它不能立即开始从这张图片绘制,因为浏览器可能还没有获取它。为了处理这个问题,我们注册一个 "load" 事件处理程序,并在图像加载后进行绘制。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var img = document.createElement("img");
  img.src = "img/hat.png";
  img.addEventListener("load", function() {
    for (var x = 10; x < 200; x += 30)
      cx.drawImage(img, x, 10);
  });
</script>

默认情况下,drawImage 将以原始大小绘制图像。您还可以为它提供两个额外的参数来指定不同的宽度和高度。

drawImage 提供九个参数时,它可以用来绘制图像的片段。第二个到第五个参数表示源图像中应复制的矩形(x、y、宽度和高度),第六个到第九个参数表示应复制其中的矩形(在画布上)。

这可以用来将多个精灵(图像元素)打包到一个图像文件,然后只绘制你需要的部分。例如,我们有这张图片包含一个以多种姿势出现的游戏角色

Various poses of a game character

通过交替绘制姿势,我们可以展示一个看起来像行走角色的动画。

要在画布上对图片进行动画处理,clearRect 方法很有用。它类似于 fillRect,但它不是对矩形进行着色,而是使其透明,删除之前绘制的像素。

我们知道每个精灵,每个子图,都是 24 像素宽,30 像素高。以下代码加载图像,然后设置一个间隔(重复计时器)来绘制下一

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var img = document.createElement("img");
  img.src = "img/player.png";
  var spriteW = 24, spriteH = 30;
  img.addEventListener("load", function() {
    var cycle = 0;
    setInterval(function() {
      cx.clearRect(0, 0, spriteW, spriteH);
      cx.drawImage(img,
                   // source rectangle
                   cycle * spriteW, 0, spriteW, spriteH,
                   // destination rectangle
                   0,               0, spriteW, spriteH);
      cycle = (cycle + 1) % 8;
    }, 120);
  });
</script>

cycle 变量跟踪我们在动画中的位置。每帧,它都会递增,然后使用余数运算符将其剪切回 0 到 7 的范围。然后使用此变量来计算当前姿势的精灵在图片中所处的 x 坐标。

转换

但如果我们希望我们的角色向左走而不是向右走呢?当然,我们可以添加另一组精灵。但我们也可以指示画布以相反的方式绘制图片。

调用 scale 方法将导致在其之后绘制的任何内容都进行缩放。此方法采用两个参数,一个用于设置水平缩放,另一个用于设置垂直缩放。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  cx.scale(3, .5);
  cx.beginPath();
  cx.arc(50, 50, 40, 0, 7);
  cx.lineWidth = 3;
  cx.stroke();
</script>

缩放将导致绘制图像的所有内容(包括线宽)按照指定的方式拉伸或挤压。用负数进行缩放将反转图片。反转发生在点 (0,0) 处,这意味着它也将反转坐标系的方位。当应用水平缩放 -1 时,在 x 位置 100 处绘制的形状将最终位于以前位置 -100 处。

因此,要反转图片,我们不能简单地在调用 drawImage 之前添加 cx.scale(-1, 1),因为这会将我们的图片移到画布之外,在该位置不可见。您可以通过在 x 位置 -50 而不是 0 处绘制图像来调整提供给 drawImage 的坐标以进行补偿。另一种解决方案是不需要执行绘制的代码了解缩放更改,而是调整缩放发生的轴。

除了 scale 之外,还有几种其他方法会影响画布的坐标系。可以使用 rotate 方法旋转随后绘制的形状,并使用 translate 方法移动它们。有趣且令人困惑的是,这些转换会堆叠,这意味着每个转换都是相对于先前的转换发生的。

因此,如果我们水平移动 10 个像素两次,所有内容都将向右移动 20 个像素。如果我们首先将坐标系的中心移动到 (50,50),然后旋转 20 度(以弧度计为 0.1π),则旋转将围绕点 (50,50) 发生。

Stacking transformations

但是,如果我们首先旋转 20 度,然后平移 (50,50),则平移将在旋转后的坐标系中发生,从而产生不同的方向。应用转换的顺序很重要。

要翻转给定 x 位置处的垂直线上的图片,我们可以执行以下操作

function flipHorizontally(context, around) {
  context.translate(around, 0);
  context.scale(-1, 1);
  context.translate(-around, 0);
}

我们将 y 轴移动到我们希望镜子所在的点,应用镜像,最后将 y 轴移动回镜像宇宙中的正确位置。以下图片解释了为什么这样做有效

Mirroring around a vertical line

这显示了镜像中央线之前和之后的坐标系。如果我们在正 x 位置绘制一个三角形,它默认情况下将位于三角形 1 所在的位置。调用 flipHorizontally 首先向右平移,这将我们带到三角形 2。然后它进行缩放,将三角形翻转回位置 3。如果它在给定线中镜像,则这不是它应该在的位置。第二个 translate 调用修复了这个问题——它“取消”了初始平移,使三角形 4 恰好出现在它应该在的位置。

现在,我们可以通过围绕角色的垂直中心翻转世界来在位置 (100,0) 处绘制一个镜像角色。

<canvas></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var img = document.createElement("img");
  img.src = "img/player.png";
  var spriteW = 24, spriteH = 30;
  img.addEventListener("load", function() {
    flipHorizontally(cx, 100 + spriteW / 2);
    cx.drawImage(img, 0, 0, spriteW, spriteH,
                 100, 0, spriteW, spriteH);
  });
</script>

存储和清除转换

转换会保留下来。我们在绘制该镜像角色之后绘制的任何其他内容也会被镜像。这可能是一个问题。

可以保存当前转换,执行一些绘制和转换,然后恢复旧转换。对于需要临时转换坐标系的函数来说,这通常是正确的方法。首先,我们保存调用该函数的代码正在使用的任何转换。然后,该函数执行其操作(在现有转换之上),可能还会添加更多转换。最后,我们恢复我们开始使用的转换。

二维画布上下文上的 saverestore 方法执行这种转换管理。在概念上,它们保留了一个转换状态堆栈。当您调用 save 时,当前状态会被推入堆栈,当您调用 restore 时,堆栈顶部的状态会被取下,并用作上下文的当前转换。

以下示例中的 branch 函数说明了使用更改转换然后调用另一个函数(在本例中是它自己)的函数可以做什么,该函数会使用给定转换继续绘制。

此函数通过绘制一条线、将坐标系的中心移动到线的末端,然后调用自身两次来绘制树状形状——第一次向左旋转,然后向右旋转。每次调用都会减少所绘制分支的长度,当长度降至 8 以下时,递归停止。

<canvas width="600" height="300"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  function branch(length, angle, scale) {
    cx.fillRect(0, 0, 1, length);
    if (length < 8) return;
    cx.save();
    cx.translate(0, length);
    cx.rotate(-angle);
    branch(length * scale, angle, scale);
    cx.rotate(2 * angle);
    branch(length * scale, angle, scale);
    cx.restore();
  }
  cx.translate(300, 0);
  branch(60, 0.5, 0.8);
</script>

如果代码中没有对 saverestore 的调用,那么对 branch 的第二次递归调用将最终使用第一次调用创建的位置和旋转。它不会连接到当前分支,而是连接到第一次调用绘制的最内层、最右边的分支。得到的形状可能也很有意思,但绝对不是一棵树。

回到游戏

现在我们对画布绘制已经有了足够的了解,可以开始为上一章 游戏 中的游戏构建一个基于画布的显示系统。新的显示不再仅仅显示彩色的方块。相反,我们将使用 drawImage 来绘制代表游戏元素的图片。

我们将定义一个名为 CanvasDisplay 的对象类型,它支持与第 15 章 DOMDisplay 相同的接口,即 drawFrameclear 方法。

此对象保留的信息比 DOMDisplay 多一些。它不使用其 DOM 元素的滚动位置,而是跟踪自己的视口,告诉我们当前正在查看关卡的哪个部分。它还跟踪时间,并使用它来决定使用哪个动画帧。最后,它保留一个 flipPlayer 属性,即使玩家站着不动,它也会保持面向它最后移动的方向。

function CanvasDisplay(parent, level) {
  this.canvas = document.createElement("canvas");
  this.canvas.width = Math.min(600, level.width * scale);
  this.canvas.height = Math.min(450, level.height * scale);
  parent.appendChild(this.canvas);
  this.cx = this.canvas.getContext("2d");

  this.level = level;
  this.animationTime = 0;
  this.flipPlayer = false;

  this.viewport = {
    left: 0,
    top: 0,
    width: this.canvas.width / scale,
    height: this.canvas.height / scale
  };

  this.drawFrame(0);
}

CanvasDisplay.prototype.clear = function() {
  this.canvas.parentNode.removeChild(this.canvas);
};

animationTime 计数器是我们在第 15 章 DOMDisplay 中将步长传递给 drawFrame 的原因,尽管 DOMDisplay 没有使用它。我们新的 drawFrame 函数使用计数器跟踪时间,以便它可以根据当前时间在动画帧之间切换。

CanvasDisplay.prototype.drawFrame = function(step) {
  this.animationTime += step;

  this.updateViewport();
  this.clearDisplay();
  this.drawBackground();
  this.drawActors();
};

除了跟踪时间之外,该方法还会更新当前玩家位置的视口,用背景色填充整个画布,并将背景和角色绘制到画布上。请注意,这与第 15 章 DOMDisplay 中的方法不同,在第 15 章中,我们绘制一次背景,然后滚动包装的 DOM 元素以移动它。

由于画布上的形状只是像素,在我们绘制完它们之后,就无法移动(或删除)它们。更新画布显示的唯一方法是清除它并重新绘制场景。

updateViewport 方法类似于 DOMDisplayscrollPlayerIntoView 方法。它会检查玩家是否离屏幕边缘太近,并在这种情况下移动视口。

CanvasDisplay.prototype.updateViewport = function() {
  var view = this.viewport, margin = view.width / 3;
  var player = this.level.player;
  var center = player.pos.plus(player.size.times(0.5));

  if (center.x < view.left + margin)
    view.left = Math.max(center.x - margin, 0);
  else if (center.x > view.left + view.width - margin)
    view.left = Math.min(center.x + margin - view.width,
                         this.level.width - view.width);
  if (center.y < view.top + margin)
    view.top = Math.max(center.y - margin, 0);
  else if (center.y > view.top + view.height - margin)
    view.top = Math.min(center.y + margin - view.height,
                        this.level.height - view.height);
};

Math.maxMath.min 的调用确保视口不会最终显示关卡外部的空间。Math.max(x, 0) 确保结果数字不小于零。Math.min 同样确保值保持在一个给定的范围内。

清除显示时,我们将使用略微不同的颜色,具体取决于游戏是获胜(更亮)还是失败(更暗)。

CanvasDisplay.prototype.clearDisplay = function() {
  if (this.level.status == "won")
    this.cx.fillStyle = "rgb(68, 191, 255)";
  else if (this.level.status == "lost")
    this.cx.fillStyle = "rgb(44, 136, 214)";
  else
    this.cx.fillStyle = "rgb(52, 166, 251)";
  this.cx.fillRect(0, 0,
                   this.canvas.width, this.canvas.height);
};

为了绘制背景,我们将遍历当前视口中可见的瓷砖,使用与上一章 关卡obstacleAt 中使用的相同技巧。

var otherSprites = document.createElement("img");
otherSprites.src = "img/sprites.png";

CanvasDisplay.prototype.drawBackground = function() {
  var view = this.viewport;
  var xStart = Math.floor(view.left);
  var xEnd = Math.ceil(view.left + view.width);
  var yStart = Math.floor(view.top);
  var yEnd = Math.ceil(view.top + view.height);

  for (var y = yStart; y < yEnd; y++) {
    for (var x = xStart; x < xEnd; x++) {
      var tile = this.level.grid[y][x];
      if (tile == null) continue;
      var screenX = (x - view.left) * scale;
      var screenY = (y - view.top) * scale;
      var tileX = tile == "lava" ? scale : 0;
      this.cx.drawImage(otherSprites,
                        tileX,         0, scale, scale,
                        screenX, screenY, scale, scale);
    }
  }
};

不为空(null)的瓷砖将使用 drawImage 绘制。otherSprites 图像包含用于除玩家之外的元素的图片。它从左到右包含墙面瓷砖、熔岩瓷砖和金币的精灵。

Sprites for our game

背景瓷砖为 20x20 像素,因为我们将使用与 DOMDisplay 中相同的比例。因此,熔岩瓷砖的偏移量为 20(scale 变量的值),墙面的偏移量为 0。

我们不必等待精灵图像加载。使用尚未加载的图像调用 drawImage 只是什么也不做。因此,在图像加载完成之前的几帧内,我们可能无法正确绘制游戏,但这并不是什么大问题。由于我们一直在更新屏幕,因此加载完成后,正确的场景就会出现。

前面显示的行走角色将用于表示玩家。绘制它的代码需要根据玩家的当前运动选择正确的精灵和方向。前八个精灵包含行走动画。当玩家在地板上移动时,我们将根据显示器的 animationTime 属性循环遍历它们。这是以秒为单位测量的,我们希望每秒切换 12 次帧,因此时间首先乘以 12。当玩家静止不动时,我们会绘制第九个精灵。在跳跃期间,通过垂直速度不为零来识别跳跃,我们使用第十个,最右边的精灵。

由于精灵比玩家对象略宽——24 像素而不是 16 像素,以留出一些空间用于脚和手臂——该方法必须根据给定量(playerXOverlap)调整 x 坐标和宽度。

var playerSprites = document.createElement("img");
playerSprites.src = "img/player.png";
var playerXOverlap = 4;

CanvasDisplay.prototype.drawPlayer = function(x, y, width,
                                              height) {
  var sprite = 8, player = this.level.player;
  width += playerXOverlap * 2;
  x -= playerXOverlap;
  if (player.speed.x != 0)
    this.flipPlayer = player.speed.x < 0;

  if (player.speed.y != 0)
    sprite = 9;
  else if (player.speed.x != 0)
    sprite = Math.floor(this.animationTime * 12) % 8;

  this.cx.save();
  if (this.flipPlayer)
    flipHorizontally(this.cx, x + width / 2);

  this.cx.drawImage(playerSprites,
                    sprite * width, 0, width, height,
                    x,              y, width, height);

  this.cx.restore();
};

drawPlayer 方法由 drawActors 调用,后者负责绘制游戏中所有角色。

CanvasDisplay.prototype.drawActors = function() {
  this.level.actors.forEach(function(actor) {
    var width = actor.size.x * scale;
    var height = actor.size.y * scale;
    var x = (actor.pos.x - this.viewport.left) * scale;
    var y = (actor.pos.y - this.viewport.top) * scale;
    if (actor.type == "player") {
      this.drawPlayer(x, y, width, height);
    } else {
      var tileX = (actor.type == "coin" ? 2 : 1) * scale;
      this.cx.drawImage(otherSprites,
                        tileX, 0, width, height,
                        x,     y, width, height);
    }
  }, this);
};

绘制非玩家对象时,我们会查看其类型以查找正确精灵的偏移量。熔岩瓷砖位于偏移量 20 处,金币精灵位于偏移量 40 处(scale 的两倍)。

在计算角色位置时,我们必须减去视口的位置,因为画布上的 (0,0) 对应于视口左上角,而不是关卡左上角。我们也可以使用 translate 来实现这一点。两种方法都可以。

下面显示的微型文档将新的显示插入 runGame

<body>
  <script>
    runGame(GAME_LEVELS, CanvasDisplay);
  </script>
</body>

选择图形界面

每当您需要在浏览器中生成图形时,您可以在普通 HTML、SVG 和画布之间进行选择。没有一种适用于所有情况的最佳方法。每个选项都有优点和缺点。

普通 HTML 的优点是简单。它也与文本很好地集成。SVG 和画布都允许您绘制文本,但它们不会帮助您定位文本或在文本占用超过一行时对其进行换行。在基于 HTML 的图片中,很容易包含文本块。

SVG 可用于生成清晰的图形,这些图形在任何缩放级别下看起来都很好。它比普通 HTML 更难使用,但功能也强大得多。

SVG 和 HTML 都构建了一个表示图片的数据结构(DOM)。这使得在绘制元素之后修改它们成为可能。如果您需要根据用户的操作或作为动画的一部分反复更改大型图片的某个小部分,那么在画布中执行此操作可能会不必要地昂贵。DOM 还允许我们在图片中的每个元素(甚至使用 SVG 绘制的形状)上注册鼠标事件处理程序。您不能用画布做到这一点。

但是,画布的像素化方法在绘制大量微小元素时可能是一种优势。它没有构建数据结构,而是反复绘制到同一像素表面,这使得画布每形状的成本更低。

还有一些效果,例如逐像素渲染场景(例如,使用光线追踪器)或使用 JavaScript 对图像进行后处理(模糊或扭曲),这些效果只能通过基于像素的技术才能真正地处理。

在某些情况下,您可能希望结合使用这几种技术。例如,您可以使用 SVG 或画布绘制图表,但可以通过在图片顶部放置一个 HTML 元素来显示文本信息。

对于不苛刻的应用程序,您选择哪种接口实际上并不重要。我们在本章中为游戏构建的 第二个显示 就可以使用这三种图形技术中的任何一种来实现,因为它不需要绘制文本、处理鼠标交互或处理数量极其多的元素。

总结

在本章中,我们讨论了在浏览器中绘制图形的技术,重点介绍了 <canvas> 元素。

画布节点表示文档中一个区域,我们的程序可以在该区域上绘制。这种绘制是通过使用 getContext 方法创建的绘制上下文对象来完成的。

二维绘制接口允许我们填充和描边各种形状。上下文的 fillStyle 属性决定了形状的填充方式。strokeStylelineWidth 属性控制线条的绘制方式。

矩形和文本片段可以使用单个方法调用来绘制。fillRectstrokeRect 方法绘制矩形,而 fillTextstrokeText 方法绘制文本。要创建自定义形状,我们必须首先构建一条路径。

调用 beginPath 将开始一条新的路径。许多其他方法会将线条和曲线添加到当前路径中。例如,lineTo 可以添加一条直线。路径完成后,可以使用 fill 方法对其进行填充,或者使用 stroke 方法对其进行描边。

将像素从图像或另一个画布移动到我们的画布上是通过 drawImage 方法来完成的。默认情况下,此方法会绘制整个源图像,但通过向它提供更多参数,您可以复制图像的特定区域。我们通过从包含许多此类姿势的图像中复制游戏角色的单个姿势来将此用于我们的游戏。

变换允许您以多种方向绘制形状。二维绘制上下文具有一个当前变换,可以使用 translatescalerotate 方法对其进行更改。这些将影响所有后续绘制操作。可以使用 save 方法保存变换状态,并使用 restore 方法恢复它。

在画布上绘制动画时,可以使用 clearRect 方法清除画布的一部分,然后再重新绘制它。

练习

形状

编写一个程序,在画布上绘制以下形状

  1. 一个梯形(一边更宽的矩形)

  2. 一个红色的菱形(旋转 45 度或 ¼π 弧度的矩形)

  3. 一条锯齿状的线

  4. 由 100 个直线段组成的螺旋线

  5. 一颗黄色的星星

The shapes to draw

绘制最后两个形状时,您可能需要参考第 13 章 第 13 章 中对 Math.cosMath.sin 的解释,该解释描述了如何使用这些函数获取圆上的坐标。

我建议为每个形状创建一个函数。将位置和其他属性(例如大小或点数)作为参数传递。另一种方法是在整个代码中硬编码数字,这会使代码变得难以阅读和修改。

<canvas width="600" height="200"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");

  // Your code here.
</script>

梯形(1)很容易用路径绘制。选择合适的中心坐标并在其周围添加四个角。

菱形(2)可以使用路径以简单的方式绘制,也可以使用 `rotate` 变换以有趣的方式绘制。若要使用旋转,则必须应用类似于我们在 `flipHorizontally` 函数中所做的技巧。因为你想围绕矩形的中心旋转,而不是围绕点 (0,0) 旋转,所以你必须先 `translate` 到那里,然后旋转,最后再 `translate` 回来。

对于锯齿(3),为每条线段编写新的 `lineTo` 调用变得不切实际。相反,你应该使用循环。你可以让每次迭代绘制两条线段(向右,然后再次向左),或者绘制一条线段,在这种情况下,你必须使用循环索引的奇偶性 ( `% 2` ) 来确定是向左还是向右。

你还需要为螺旋(4)使用循环。如果你绘制一系列点,每个点沿着围绕螺旋中心的圆形移动更远,你就会得到一个圆形。如果在循环期间,你改变放置当前点的圆形的半径并绕圈多次,结果就会是一个螺旋形。

所示的星星(5)由 `quadraticCurveTo` 线构建。你也可以用直线绘制一个。将一个圆形分成八块,或一个点,你希望你的星星拥有。在这些点之间绘制线条,使它们弯曲朝向星星的中心。使用 `quadraticCurveTo`,你可以使用中心作为控制点。

饼图

之前在本章中,我们看到一个绘制饼图的示例程序。修改此程序,以便每个类别的名称显示在代表该切片的切片旁边。尝试找到一种美观的自动定位文本的方法,该方法也可以用于其他数据集。你可以假设类别不小于 5%(也就是说,不会有一堆很小的类别彼此相邻)。

你可能再次需要 `Math.sin` 和 `Math.cos`,如上一个练习中所述。

<canvas width="600" height="300"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");
  var total = results.reduce(function(sum, choice) {
    return sum + choice.count;
  }, 0);

  var currentAngle = -0.5 * Math.PI;
  var centerX = 300, centerY = 150;
  // Add code to draw the slice labels in this loop.
  results.forEach(function(result) {
    var sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.beginPath();
    cx.arc(centerX, centerY, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(centerX, centerY);
    cx.fillStyle = result.color;
    cx.fill();
  });
</script>

你需要调用 `fillText` 并设置上下文 `textAlign` 和 `textBaseline` 属性,以便文本最终出现在你想要的位置。

一种合理的定位标签的方法是将文本放在从饼图中心穿过切片中间的直线上。你不想将文本直接放在饼图边缘,而是将文本向饼图边缘移动一定像素。

这条线的角度是 `currentAngle + 0.5 * sliceAngle`。以下代码找到了这条线上的一个位置,距离中心 120 个像素

var middleAngle = currentAngle + 0.5 * sliceAngle;
var textX = Math.cos(middleAngle) * 120 + centerX;
var textY = Math.sin(middleAngle) * 120 + centerY;

对于 `textBaseline`,值 `“middle”` 在使用这种方法时可能很合适。使用什么作为 `textAlign` 取决于我们所在的圆圈的哪一侧。在左侧,它应该是 `“right”`,在右侧,它应该是 `“left”`,以便文本定位在远离饼图的地方。

如果你不确定如何确定某个角度位于圆圈的哪一侧,请参阅上一个练习中 `Math.cos` 的解释。角度的余弦告诉我们它对应哪个 x 坐标,这反过来告诉我们我们确切地位于圆圈的哪一侧。

一个弹跳球

使用我们在 第 13 章第 15 章 中看到的 `requestAnimationFrame` 技术来绘制一个带有弹跳球的框。球以恒定速度移动,并在碰到盒子的侧面时反弹。

<canvas width="400" height="400"></canvas>
<script>
  var cx = document.querySelector("canvas").getContext("2d");

  var lastTime = null;
  function frame(time) {
    if (lastTime != null)
      updateAnimation(Math.min(100, time - lastTime) / 1000);
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  function updateAnimation(step) {
    // Your code here.
  }
</script>

一个框很容易用 `strokeRect` 绘制。定义一个变量来保存它的尺寸,或者如果你的框的宽度和高度不同,定义两个变量。若要创建一个圆形球,请启动一个路径,调用 `arc(x, y, radius, 0, 7)`,它会创建一个从零到超过整个圆形的弧,并将其填充。

若要模拟球的位置和速度,可以使用 第 15 章 中的 `Vector` 类型(在该页面上可用)。给它一个起始速度,最好是它不是纯粹的垂直或水平速度,并且在每一帧中,将该速度乘以经过的时间量。当球离垂直墙壁太近时,反转其速度中的 x 分量。同样,当球碰到水平墙壁时,反转 y 分量。

找到球的新位置和速度后,使用 `clearRect` 删除场景并使用新位置重新绘制它。

预先计算的镜像

变换的一个不幸之处是,它们会减慢位图的绘制速度。对于矢量图形,效果不那么严重,因为只需要转换几个点(例如,圆的中心),然后就可以照常进行绘制。对于位图图像,必须转换每个像素的位置,虽然浏览器将来可能会对这一点变得更聪明,但这目前会导致绘制位图所需时间的可衡量增长。

在我们的游戏中,我们只绘制一个变换后的精灵,这不是问题。但想象一下,我们需要绘制数百个角色或数千个来自爆炸的旋转粒子。

想出一个方法,让我们能够绘制一个反转的角色,而无需加载额外的图像文件,也无需在每一帧都进行变换后的 `drawImage` 调用。

解决方案的关键是我们可以使用画布元素作为使用 `drawImage` 时源图像的事实。可以创建一个额外的 `<canvas>` 元素,而不将其添加到文档中,并将我们的反转精灵绘制到它上面,一次。当绘制实际的帧时,我们只需将已经反转的精灵复制到主画布上。

需要小心,因为图像不会立即加载。我们只进行一次反转绘制,如果我们在图像加载之前进行绘制,它将不会绘制任何内容。图像上的 `“load”` 处理程序可以用来将反转的图像绘制到额外的画布上。此画布可以立即用作绘制源(它只是在我们将角色绘制到它上面之前是空白的)。