在画布上绘画

绘画是一种欺骗。

M.C. Escher, 引自布鲁诺·恩斯特的《埃舍尔的魔镜》
Illustration showing an industrial-looking robot arm drawing a city on a piece of 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 接口绘制三维图形,以及 "webgpu",它是 WebGL 的更现代、更灵活的替代方案。

本书不会讨论 WebGL 或 WebGPU——我们将坚持二维。但是,如果您对三维图形感兴趣,我鼓励您研究 WebGPU。它提供了一个与图形硬件的直接接口,并允许您使用 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, 宽度和高度),第六个到第九个参数表示应该将该矩形复制到的矩形(在画布上)。

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

Pixel art showing a computer game character in 10 different poses. The first 8 form its running animation cycle, the 9th has the character standing still, and the 10th shows him jumping.

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

为了在画布上为图片制作动画,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) 点发生。

Diagram showing the result of stacking transformations. The first diagram translates and then rotates, causing the translation to happen normally and rotation to happen around the target of the translation. The second diagram first rotates, and then translates, causing the rotation to happen around the origin and the translation direction to be tilted by that rotation.

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

要将图片翻转到给定x 坐标位置的垂直线周围,我们可以执行以下操作

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

我们将 y 轴移动到我们想要镜像的位置,应用镜像,最后将 y 轴移回镜像宇宙中的正确位置。下图解释了为什么这样做有效

Diagram showing the effect of translating and mirroring a triangle

这展示了沿中心线镜像前后的坐标系。三角形被编号以说明每个步骤。如果我们在正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 图像包含用于除玩家以外的元素的图片。它从左到右包含墙壁瓦片、熔岩瓦片以及一枚硬币的精灵。

Pixel art showing three sprites: a piece of wall, made out of small white stones, a square of orange lava, and a round coin.

背景瓦片为 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 方法完成的。默认情况下,此方法会绘制整个源图像,但通过提供更多参数,您可以复制图像的特定区域。我们在游戏中使用它来从包含许多此类姿势的图像中复制游戏角色的各个姿势。

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

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

练习

形状

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

  1. 梯形(一边比另一边宽的矩形)

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

  3. 之字形线

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

  5. 黄色星星

Picture showing the shapes you are asked 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,您可以将中心用作控制点。

饼图

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

您可能还需要 Math.sinMath.cos,它们在 第 14 章 中有所描述。

<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" 处理程序可以用来将反转的图像绘制到额外的画布上。这个画布可以立即用作绘制源(它在我们将角色绘制到它之前将只是空白)。