JavaScript 编程精解 中文第三版 十7、在画布上绘图

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Drawing on Canvasjavascript

译者:飞龙html

协议:CC BY-NC-SA 4.0java

自豪地采用谷歌翻译git

部分参考了《JavaScript 编程精解(第 2 版)》github

绘图就是欺骗。web

M.C. Escher,由 Bruno Ernst 在《The Magic Mirror of M.C. Escher》中引用apache

浏览器为咱们提供了多种绘图方式。最简单的方式是用样式来规定普通 DOM 对象的位置和颜色。就像在上一章中那个游戏展现的,咱们可使用这种方式实现不少功能。咱们能够为节点添加半透明的背景图片,来得到咱们但愿的节点外观。咱们也可使用transform样式来旋转或倾斜节点。编程

可是,在一些场景中,使用 DOM 并不符合咱们的设计初衷。好比咱们很难使用普通的 HTML 元素画出任意两点之间的线段这类图形。canvas

这里有两种解决办法。第一种方法基于 DOM,但使用可缩放矢量图形(SVG,Scalable Vector Graphics)代替 HTML。咱们能够将 SVG 当作文档标记方言,专用于描述图形而非文字。你能够在 HTML 文档中嵌入 SVG,还能够在<img>标签中引用它。数组

咱们将第二种方法称为画布(canvas)。画布是一个可以封装图片的 DOM 元素。它提供了在空白的html节点上绘制图形的编程接口。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定义的命名空间,规定了咱们当前使用的语言。在 HTML 中不存在<circle><rect>标签,但这些标签在 SVG 中是有意义的,你能够经过这些标签的属性来绘制图像并指定样式与位置。

和 HTML 标签同样,这些标签会建立 DOM 元素,脚本能够和它们交互。例如,下面的代码能够把<circle>元素的颜色替换为青色。

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

canvas元素

咱们能够在<canvas>元素中绘制画布图形。你能够经过设置widthheight属性来肯定画布尺寸(单位为像素)。

新的画布是空的,意味着它是彻底透明的,看起来就像文档中的空白区域同样。

<canvas>标签容许多种不一样风格的绘图。要获取真正的绘图接口,首先咱们要建立一个可以提供绘图接口的方法的上下文(context)。目前有两种获得普遍支持的绘图接口:用于绘制二维图形的"2d"与经过openGL接口绘制三维图形的"webgl"

本书只讨论二维图形,而不讨论 WebGL。可是若是你对三维图形感兴趣,我强烈建议你们自行深刻研究 WebGL。它提供了很是简单的现代图形硬件接口,同时你也可使用 JavaScript 来高效地渲染很是复杂的场景。

您能够用getContext方法在<canvas> DOM 元素上建立一个上下文。

<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>

在建立完context对象以后,做为示例,咱们画出一个红色矩形。该矩形宽 100 像素,高 50 像素,它的左上点坐标为(10,10)。

与 HTML(或者 SVG)相同,画布使用的坐标系统将(0,0)放置在左上角,而且y轴向下增加。因此(10,10)是相对于左上角向下并向右各偏移 10 像素的位置。

直线和平面

咱们可使用画布接口填充图形,也就是赋予某个区域一个固定的填充颜色或填充模式。咱们也能够描边,也就是沿着图形的边沿画出线段。SVG 也使用了相同的技术。

fillRect方法能够填充一个矩形。他的输入为矩形框左上角的第一个xy坐标,而后是它的宽和高。类似地,strokeRect方法能够画出一个矩形的外框。

两个方法都不须要其余任何参数。填充的颜色以及轮廓的粗细等等都不能由方法的参数决定(像你的合理预期同样),而是由上下文对象的属性决定。

设置fillStyle参数控制图形的填充方式。咱们能够将其设置为描述颜色的字符串,使用 CSS 所用的颜色表示法。

strokeStyle属性的做用很类似,可是它用于规定轮廓线的颜色。线条的宽度由lineWidth属性决定。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>

当没有设置width或者height参数时,正如示例同样,画布元素的默认宽度为 300 像素,默认高度为 150 像素。

路径

路径是线段的序列。2D canvas接口使用一种奇特的方式来描述这样的路径。路径的绘制都是间接完成的。咱们没法将路径保存为能够后续修改并传递的值。若是你想修改路径,必需要调用多个方法来描述他的形状。

<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,下一条线段会从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),而后画出两条穿过控制点而且回到线段起点的线段。绘制的结果相似一个星际迷航的图章。你能够观察到控制点的效果:从下端的角落里发出的线段朝向控制点并向他们的目标点弯曲。

bezierCurve(贝塞尔曲线)方法能够绘制一种相似的曲线。不一样的是贝塞尔曲线须要两个控制点而不是一个,线段的每个端点都须要一个控制点。下面是描述贝塞尔曲线的简单示例。

<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*Math.PI(大约为 6.28)的弧度。弧度从圆心右边的点开始并以顺时针的方向计数。你能够以 0 起始并以一个比大的数值(好比 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)的左侧的直线。arc与其余绘制路径的方法同样,会自动链接到上一个路径上。你能够调用moveTo或者开启一个新的路径来避免这种状况。

绘制饼状图

设想你刚刚从 EconomiCorp 得到了一份工做,而且你的第一个任务是画出一个描述其用户满意度调查结果的饼状图。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"}
];

要想画出一个饼状图,咱们须要画出不少个饼状图的切片,每一个切片由一个圆弧与两条到圆心的线段组成。咱们能够经过把一个整圆()分割成以调查结果数量为单位的若干份,而后乘以作出相应选择的用户的个数来计算每一个圆弧的角度。

<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>

但表格并无告诉咱们切片表明的含义,它毫无用处。所以咱们须要将文字画在画布上。

文本

2D 画布的context对象提供了fillText方法和strokeText方法。第二个方法能够用于绘制字母轮廓,但一般状况下咱们须要的是fillText方法。该方法使用当前的fillColor来填充特定文字的轮廓。

<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属性来设定文字的大小,样式和字体。本例给出了一个字体的大小和字体族名称。也能够添加italic或者bold来选择样式。

传递给fillTextstrokeText的后两个参数用于指定绘制文字的位置。默认状况下,这个位置指定了文字的字符基线(baseline)的起始位置,咱们能够将其假想为字符所站立的位置,基线不考虑jp字母中那些向下突出的部分。你能够设置textAlign属性(endcenter)来改变起始点的水平位置,也能够设置textBaseline属性(topmiddlebottom)来设置基线的竖直位置。

在本章末尾的练习中,咱们会回顾饼状图,并解决给饼状图分片标注的问题。

图像

计算机图形学领域常常将矢量图形和位图图形分开来讨论。本章一直在讨论第一种图形,即经过对图形的逻辑描述来绘图。而位图则相反,不须要设置实际图形,而是经过处理像素数据来绘制图像(光栅化的着色点)。

咱们可使用drawImage方法在画布上绘制像素值。此处的像素数值能够来自<img>元素,或者来自其余的画布。下例建立了一个独立的<img>元素,而且加载了一张图像文件。但咱们没法立刻使用该图片进行绘制,由于浏览器可能尚未完成图片的获取操做。为了处理这个问题,咱们在图像元素上注册一个"load"事件处理程序而且在图片加载完以后开始绘制。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/img/hat.png";
  img.addEventListener("load", () => {
    for (let x = 10; x < 200; x += 30) {
      cx.drawImage(img, x, 10);
    }
  });
</script>

默认状况下,drawImage会根据原图的尺寸绘制图像。你也能够增长两个参数来设置不一样的宽度和高度。

若是咱们向drawImage函数传入 9 个参数,咱们能够用其绘制出一张图片的某一部分。第二个到第五个参数表示须要拷贝的源图片中的矩形区域(xy坐标,宽度和高度),同时第六个到第九个参数给出了须要拷贝到的目标矩形的位置(在画布上)。

该方法能够用于在单个图像文件中放入多个精灵(图像单元)并画出你须要的部分。

咱们能够改变绘制的人物造型,来展示一段看似人物在走动的动画。

clearRect方法能够帮助咱们在画布上绘制动画。该方法相似于fillRect方法,可是不一样的是clearRect方法会将目标矩形透明化,并移除掉以前绘制的像素值,而不是着色。

咱们知道每一个精灵和每一个子画面的宽度都是 24 像素,高度都是 30 像素。下面的代码装载了一幅图片并设置定时器(会重复触发的定时器)来定时绘制下一帧。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/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绑定用于记录角色在动画图像中的位置。每显示一帧,咱们都要将cycle加 1,并经过取余数确保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>

由于调用了scale,所以圆形长度变为原来的 3 倍,高度变为原来的一半。scale能够调整图像全部特征,包括线宽、预约拉伸或压缩。若是将缩放值设置为负值,能够将图像翻转。因为翻转发生在坐标(0,0)处,这意味着也会同时反转坐标系的方向。当水平缩放 –1 时,在x坐标为 100 的位置画出的图形会绘制在缩放以前x坐标为 –100 的位置。

为了翻转一张图片,只是在drawImage以前添加cx.scale(–1,–1)是没用的,由于这样会将咱们的图片移出到画布以外,致使图片不可见。为了不这个问题,咱们还须要调整传递给drawImage的坐标,将绘制图形的x坐标改成 –50 而不是 0。另外一个解决方案是在缩放时调整坐标轴,这样代码就不须要知道整个画布的缩放的改变。

除了scale方法还有一些其余方法能够影响画布里坐标系统的方法。你可使用rotate方法旋转绘制完的图形,也可使用translate方法移动图形。毕竟有趣但也容易引发误解的是这些变换以栈的方式工做,也就是说每一个变换都会做用于前一个变换的结果之上。

若是咱们沿水平方向将画布平移两次,每次移动 10 像素,那么全部的图形都会在右方 20 像素的位置从新绘制。若是咱们先把坐标系的原点移动到(50, 50)的位置,而后旋转 20 度(大约0.1π弧度),这次的旋转会围绕点(50,50)进行。

可是若是咱们先旋转 20 度,而后平移原点到(50,50),这次的平移会发生在已经旋转过的坐标系中,所以会有不一样的方向。变换发生顺序会影响最后的结果。

咱们可使用下面的代码,在指定的x坐标处竖直反转一张图片。

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

咱们先把y轴移动到咱们但愿镜像所在的位置,而后进行镜像翻转,最后把y轴移动到被翻转的坐标系当中相应的位置。下面的图片解释了以上代码是如何工做的:

上图显示了经过中线进行镜像翻转先后的坐标系。对三角形编号来讲明每一步。若是咱们在x坐标为正值的位置绘制一个三角形,默认状况下它会出如今图中三角形 1 的位置。调用filpHorizontally首先作一个向右的平移,获得三角形 2。而后将其翻转到三角形 3 的位置。这不是它的根据给定的中线翻转以后应该在的最终位置。第二次调用translate方法解决了这个问题。它“去除”了最初的平移的效果,而且使三角形 4 变成咱们但愿的效果。

咱们能够沿着特征的竖直中心线翻转整个坐标系,这样就能够画出位置为(100,0)处的镜像特征。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/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>

存储与清除图像的变换状态

图像变换的效果会保留下来。咱们绘制出一次镜像特征后,绘制其余特征时都会产生镜像效果,这可能并不方便。

对于须要临时转换坐标系统的函数来讲,咱们常常须要保存当前的信息,画一些图,变换图像而后从新加载以前的图像。首先,咱们须要将当前函数调用的全部图形变换信息保存起来。接着,函数完成其工做,并添加更多的变换。最后咱们恢复以前保存的变换状态。

2D 画布上下文的saverestore方法执行这个变换管理。这两个方法维护变换状态堆栈。save方法将当前状态压到堆栈中,restore方法将堆栈顶部的状态弹出,并将该状态做为当前context对象的状态。

下面示例中的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,支持第 14 章中的DOMDisplay的相同接口,也就是setState方法与clear方法。

这个对象须要比DOMDisplay多保存一些信息。该对象不只须要使用 DOM 元素的滚动位置,还须要追踪本身的视口(viewport)。视口会告诉咱们目前处于哪一个关卡。最后,该对象会保存一个filpPlayer属性,确保即使玩家站立不动时,它面朝的方向也会与上次移动所面向的方向一致。

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();
  }
}

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

CanvasDisplay.prototype.setState = 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)保证告终果数值不会小于 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 = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/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包含了描述除了玩家以外须要用到的图片。它包含了从左到右的墙上的瓦片,火山岩瓦片以及精灵硬币。

背景瓦片是20×20像素的,由于咱们将要用到DOMDisplay中的相同比例。所以,火山岩瓦片的偏移是 20,墙面的偏移是 0。

咱们不须要等待精灵图片加载完成。调用drawImage时使用一幅并未加载完毕的图片不会有任何效果。由于图片仍然在加载当中,咱们可能没法正确地画出游戏的前几帧。可是这不是一个严重的问题,由于咱们持续更新荧幕,正确的场景会在加载完毕以后当即出现。

前面展现过的走路的特征将会被用来代替玩家。绘制它的代码须要根据玩家的当前动做选择正确的动做和方向。前 8 个子画面包含一个走路的动画。当玩家沿着地板移动时,咱们根据当前时间把他围起来。咱们但愿每 60 毫秒切换一次帧,因此时间先除以 60。当玩家站立不动时,咱们画出第九张子画面。当竖直方向的速度不为 0,从而被判断为跳跃时,咱们使用第 10 张,也是最右边的子画面。

由于子画面宽度为 24 像素而不是 16 像素,会稍微比玩家的对象宽,这时为了腾出脚和手的空间,该方法须要根据某个给定的值(playerXOverlap)调整x坐标的值以及宽度值。

let playerSprites = document.createElement("img");
playerSprites.src = "https://gitee.com/wizardforcel/eloquent-js-3e-zh/raw/master/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 的地方(放大了两倍)。

当计算角色的位置时,咱们须要减掉视口的位置,由于(0,0)在咱们的画布坐标系中表明着视口层面的左上角,而不是该关卡的左上角。咱们也可使用translate方法,这样能够做用于全部元素。

这个文档将新的显示屏插入runGame中:

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

选择图像接口

因此当你须要在浏览器中绘图时,你均可以选择纯粹的 HTML、SVG 或画布。没有惟一的最适合的且在全部动画中都是最好的方法。每一个选择都有它的利与弊。

单纯的 HTML 的优势是简单。它也能够很好地与文字集成使用。SVG 与画布均可以容许你绘制文字,可是它们不会只经过一行代码来帮助你放置text或者包装它,在一个基于 HTML 的图像中,包含文本块更加简单。

SVG 能够被用来制造能够任意缩放而仍然清晰的图像。与 HTML 相反,它其实是为绘图而设计的,所以更适合于此目的。

SVG 与 HTML 都会构建一个新的数据结构(DOM),它表示你的图片。这使得在绘制元素以后对其进行修改更为可能。若是你须要重复的修改在一张大图片中的一小部分,来对用户的动做进行响应或者做为动画的一部分时,在画布里作这件事情将会极其的昂贵。DOM 也能够容许咱们在图片上的每个元素(甚至在 SVG 画出的图形上)注册鼠标事件的处理器。在画布里则实现不了。

可是画布的基于像素的方法在须要绘制大量的微小元素时会有优点。它不会构建新的数据结构而是仅仅重复的在同一个像素上绘制,这使得画布在每一个图形上拥有更低的消耗。

有一些效果,像在逐像素的渲染一个场景(好比,使用光线追踪)或者使用 javaScript 对一张图片进行后加工(虚化或者扭曲),只能经过基于像素的技术来进行真实的处理。在某些状况下,你可能想要将这些技术整合起来使用。好比,你可能用 SVG 或者画布画出一个图形,可是经过将一个 HTML 元素放在图片的顶端来展现像素信息。

对于一些要求低的程序来讲,选择哪一个接口并无什么太大的区别。由于不须要绘制文字,处理鼠标交互或者处理大量的元素。咱们在本章为游戏构建的显示屏,能够经过使用三种图像技术中的任意一种来实现。

本章小结

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

一个canvas节点表明了咱们的程序能够绘制在文档中的一片区域。这个绘图动做是经过一个由getContext方法建立的绘图上下文对象完成的。

2D 绘图接口容许咱们填充或者拉伸各类各样的图形。这个上下文的fillStyle属性决定了图形的填充方式。strokeStylelineWidth属性用来控制线条的绘制方式。

矩形与文字能够经过使用一个简单的方法调用来绘制。采用fillRectstrokeRect方法绘制矩形,同时采用fillTextstrokeText方法绘制文字。要建立一个自定义的图形,咱们必须首先创建一个路径。

调用beginPath会建立一个新的路径。不少其余的方法能够向当前的路径添加线条和曲线。好比,lineTo方法能够添加一条直线。当一条路径画完时,它能够被fill方法填充或者被stroke方法勾勒轮廓。

从一张图片或者另外一个画布上移动像素到咱们的画布上能够用drawImage方法实现。默认状况下,这个方法绘制了整个原图像,可是经过给它更多的参数,你能够拷贝一张图片的某一个特定的区域。咱们在游戏中使用了这项技术,从包括许多动做的图像中拷贝出游戏角色的单个独立动做。

图形变换容许你向多个方向绘制图片。2D 绘制上下文拥有一个当前的能够经过translatescalerotate进行变换。这些会影响全部的后续的绘制操做。一个变换的状态能够经过save方法来保存,经过restore方法来恢复。

在一个画布上展现动画时,clearRect方法能够用来在重绘以前清除画布的某一部分。

习题

形状

编写一个程序,在画布上画出下面的图形。

  1. 一个不规则四边形(一个在一边比较长的矩形)
  2. 一个红色的钻石(一个矩形旋转45度角)
  3. 一个锯齿线
  4. 一个由 100 条直线线段构成的螺旋
  5. 一个黄色的星星

当绘制最后两个图形时,你能够参考第 14 章中的Math.cosMath.sin的解释,它描述了如何使用这两个函数得到圆上的坐标。

建议你为每个图形建立一个方法,传入坐标信息,以及其余的一些参数,好比大小或者点的数量。另外一种方法,能够在你的代码中硬编码,会使得你的代码变得难以阅读和修改。

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

  // Your code here.
</script>

饼状图

在本章的前部分,咱们看到一个绘制饼状图的样例程序。修改这个程序,使得每一个部分的名字能够被显示在相应的切片旁边。试着找到一个合适的方法来自动放置这些文字,同时也能够适用于其余数据。你能够假设分类大到足觉得标签留出空间。

你可能还会须要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.
  results.forEach(function(result) {
  for (let result of results) {
    let sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.arc(centerX, centerY, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(centerX, centerY);
    cx.fillStyle = result.color;
    cx.fill();
  }
</script>

弹力球

使用在第 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>

预处理镜像

当进行图形变换时,绘制位图图像会很慢。每一个像素的位置和大小都必须进行变换,尽管未来浏览器可能会更加聪明,但这会致使绘制位图所需的时间显着增长。

在一个像咱们这样的只绘制一个简单的子画面图像变换的游戏中,这个不是问题。可是若是咱们须要绘制成百上千的角色或者爆炸产生的旋转粒子时,这将会成为一个问题。

思考一种方法来容许咱们不须要加载更多的图片文件就能够画出一个倒置的角色,而且不须要在每一帧调用drawImage方法。

相关文章
相关标签/搜索