第四版可用。 点击此处阅读!

第 17 章在画布上绘图

绘画是欺骗。

M.C. 埃舍尔,摘自布鲁诺·恩斯特《M.C. 埃舍尔的魔镜》
Picture of a robot arm drawing on paper

浏览器为我们提供了多种显示图形的方式。最简单的方法是使用样式来定位和着色常规的 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>元素更改为青色

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

画布元素

画布图形可以绘制到<canvas>元素上。可以给这样的元素widthheight属性来确定它的大小(以像素为单位)。

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

<canvas>标签旨在允许不同的绘图风格。为了获得实际的绘图接口,首先需要创建一个上下文,一个其方法提供了绘图接口的对象。目前有两种广泛支持的绘图风格:"2d"用于二维图形,"webgl"用于通过 OpenGL 接口进行三维图形。

本书不会讨论 WebGL——我们将坚持二维。但是,如果你对三维图形感兴趣,我鼓励你研究 WebGL。它提供了一个与图形硬件的直接接口,并允许你使用 JavaScript 高效地渲染复杂的场景。

可以使用<canvas> DOM 元素上的getContext方法创建上下文。

<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
  let canvas = document.querySelector("canvas");
  let 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 个像素和右侧 10 个像素处。

线条和表面

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

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

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

fillStyle属性控制形状的填充方式。它可以设置为一个字符串,该字符串使用 CSS 中使用的颜色表示法指定颜色。

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

<canvas></canvas>
<script>
  let 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>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  for (let 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>
  let 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>
  let 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>
  let 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>

两个控制点指定了曲线的两端的方向。它们离相应的点越远,曲线在该方向上就会“鼓起”越多。

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

arc 方法是绘制沿着圆周边缘弯曲的线的途径。它接受圆弧中心的坐标对、半径,以及起始角和结束角。

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

<canvas></canvas>
<script>
  let 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 绑定包含一个表示调查响应的对象数组。

const 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>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  // Start at the top
  let currentAngle = -0.5 * Math.PI;
  for (let result of results) {
    let 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。它将使用当前 fillStyle 填充给定文本的轮廓。

<canvas></canvas>
<script>
  let 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 添加到字符串的开头以选择一种样式。

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

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

图像

在计算机图形学中,通常区分矢量图形和位图图形。我们本章迄今为止一直在做的就是第一个——通过给出形状的逻辑描述来指定图片。另一方面,位图图形不会指定实际形状,而是使用像素数据(彩色点的栅格)。

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

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "img/hat.png";
  img.addEventListener("load", () => {
    for (let 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>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "img/player.png";
  let spriteW = 24, spriteH = 30;
  img.addEventListener("load", () => {
    let cycle = 0;
    setInterval(() => {
      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>
  let 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),因为这会将图片移到画布之外,在那里它将不可见。你可以调整提供给 drawImage 的坐标以弥补这一点,方法是在 x 坐标 -50 处而不是 0 处绘制图像。另一种解决方案,它不需要执行绘制的代码了解缩放更改,就是调整缩放发生的轴。

除了 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>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "img/player.png";
  let spriteW = 24, spriteH = 30;
  img.addEventListener("load", () => {
    flipHorizontally(cx, 100 + spriteW / 2);
    cx.drawImage(img, 0, 0, spriteW, spriteH,
                 100, 0, spriteW, spriteH);
  });
</script>

存储和清除转换

转换会保留。我们之后绘制的任何内容也会被镜像。这可能不方便。

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

二维画布上下文的 saverestore 方法用于管理这种变换。它们在概念上维护着一个变换状态栈。调用 save 时,当前状态会被压入栈中,而调用 restore 时,栈顶的状态会被弹出并用作上下文的当前变换。也可以调用 resetTransform 来完全重置变换。

以下示例中的 branch 函数说明了如何在改变变换后调用一个函数(在本例中为自身),以继续绘制具有给定变换的图形。

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

<canvas width="600" height="300"></canvas>
<script>
  let 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 的显示对象类型,它支持与第 16 章 DOMDisplay 相同的接口,即 syncStateclear 方法。

此对象比 DOMDisplay 保留了更多信息。它不使用其 DOM 元素的滚动位置,而是跟踪自己的视窗,这告诉我们当前正在查看关卡的哪一部分。最后,它保留了一个 flipPlayer 属性,即使玩家静止不动,它也会始终面向最后移动的方向。

class CanvasDisplay {
  constructor(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.flipPlayer = false;

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

  clear() {
    this.canvas.remove();
  }
}

syncState 方法首先计算一个新的视窗,然后在适当的位置绘制游戏场景。

CanvasDisplay.prototype.syncState = function(state) {
  this.updateViewport(state);
  this.clearDisplay(state.status);
  this.drawBackground(state.level);
  this.drawActors(state.actors);
};

DOMDisplay 相反,这种显示方式在每次更新时都必须重新绘制背景。因为画布上的形状只是像素,在我们绘制它们之后,没有很好的方法来移动(或删除)它们。更新画布显示的唯一方法是清除它并重新绘制场景。我们也可能滚动过,这需要背景处于不同的位置。

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

CanvasDisplay.prototype.updateViewport = function(state) {
  let view = this.viewport, margin = view.width / 3;
  let player = state.player;
  let 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,
                         state.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,
                        state.level.height - view.height);
  }
};

Math.maxMath.min 的调用确保视窗不会最终显示关卡外部的空间。Math.max(x, 0) 确保结果数字不小于零。Math.min 以类似的方式保证值保持在给定边界之下。

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

CanvasDisplay.prototype.clearDisplay = function(status) {
  if (status == "won") {
    this.cx.fillStyle = "rgb(68, 191, 255)";
  } else if (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);
};

为了绘制背景,我们将遍历当前视窗中可见的瓷砖,使用上一章 touches 方法中使用的相同技巧。

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

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

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

非空的瓷砖将使用 drawImage 绘制。otherSprites 图像包含用于除玩家以外的元素的图片。它包含,从左到右,墙壁瓷砖、熔岩瓷砖和硬币的精灵。

Sprites for our game

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

我们不费心等待精灵图像加载。使用尚未加载的图像调用 drawImage 只是什么也不做。因此,我们可能无法在最初的几帧中正确绘制游戏,因为图像仍在加载,但这并不是一个严重的问题。由于我们一直在更新屏幕,因此一旦加载完成,正确的场景就会出现。

前面显示的行走角色将用于表示玩家。绘制它需要根据玩家的当前运动选择正确的精灵和方向。前八个精灵包含一个行走动画。当玩家沿着地板移动时,我们会根据当前时间循环遍历它们。我们希望每 60 毫秒切换一次帧,因此首先将时间除以 60。当玩家静止不动时,我们会绘制第九个精灵。在跳跃期间(通过垂直速度不为零来识别),我们使用第十个最右侧的精灵。

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

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

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

  let tile = 8;
  if (player.speed.y != 0) {
    tile = 9;
  } else if (player.speed.x != 0) {
    tile = Math.floor(Date.now() / 60) % 8;
  }

  this.cx.save();
  if (this.flipPlayer) {
    flipHorizontally(this.cx, x + width / 2);
  }
  let tileX = tile * width;
  this.cx.drawImage(playerSprites, tileX, 0, width, height,
                                   x,     y, width, height);
  this.cx.restore();
};

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

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

绘制非玩家对象时,我们会查看其类型以找到正确精灵的偏移量。熔岩瓷砖位于偏移量 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 方法将图像或其他画布上的像素移动到我们的画布上。默认情况下,此方法绘制整个源图像,但通过提供更多参数,您可以复制图像的特定区域。我们在游戏中使用它来从包含许多此类姿势的图像中复制游戏角色的单个姿势。

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

在画布上显示动画时,可以使用 clearRect 方法在重新绘制之前清除画布的一部分。

练习

形状

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

  1. 梯形(一边较宽的矩形)

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

  3. 曲折线

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

  5. 黄色星星

The shapes to draw

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

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

<canvas width="600" height="200"></canvas>
<script>
  let 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,您可以使用中心作为控制点。

饼图

在本章前面,我们看到一个绘制饼图的示例程序。修改此程序,以便每个类别的名称显示在其表示的切片旁边。尝试找到一种美观的方式来自动定位此文本,使其也能适用于其他数据集。您可以假设类别足够大,可以为其标签留出充足的空间。

您可能需要再次使用第 14 章中描述的 Math.sinMath.cos

<canvas width="600" height="300"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  let currentAngle = -0.5 * Math.PI;
  let centerX = 300, centerY = 150;

  // Add code to draw the slice labels in this loop.
  for (let result of results) {
    let 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 并设置上下文 textAligntextBaseline 属性,以便文本最终出现在您想要的位置。

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

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

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

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

如果您不确定如何找出给定角度在圆的哪一侧,请参考第 14 章中对 Math.cos 的解释。角度的余弦告诉我们它对应于哪个 x 坐标,这也告诉我们我们确切地在圆的哪一侧。

弹跳球

使用我们在第 14 章第 16 章中看到的 requestAnimationFrame 技术,在盒子中绘制一个带有弹跳球的盒子。球以恒定速度移动,并在撞击盒子的侧面时反弹。

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

  let 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),这将创建一个从零到超过整个圆的弧。然后填充路径。

要模拟球的位置和速度,您可以使用第 16 章中的 Vec 类(在本页上可用)。为其提供一个起始速度,最好是一个不完全是垂直或水平的速度,并且对于每一帧,将该速度乘以经过的时间量。当球离垂直墙壁太近时,反转其速度中的 x 分量。类似地,当球撞击水平墙壁时,反转 y 分量。

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

预先计算的镜像

变换的一个不幸之处在于,它们会减慢位图的绘制速度。每个像素的位置和大小都必须进行变换,尽管浏览器可能会在未来更智能地处理变换,但它们目前会导致绘制位图所需的时间明显增加。

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

想办法让我们绘制一个倒置的角色,而无需加载额外的图像文件,也无需在每一帧都进行变换的 drawImage 调用。

解决方案的关键在于我们可以使用画布元素作为 drawImage 的源图像。可以创建一个额外的 <canvas> 元素(无需将其添加到文档中),并在其中绘制我们的倒置精灵(一次)。绘制实际帧时,我们只需将已倒置的精灵复制到主画布。

需要小心,因为图像不是立即加载的。我们只执行一次倒置绘制,如果我们在图像加载之前执行,则不会绘制任何内容。图像上的 "load" 处理程序可用于将倒置图像绘制到额外画布。此画布可以立即用作绘制源(它只是在我们将角色绘制到其中之前为空白)。