mirror of
https://github.com/apachecn/eloquent-js-3e-zh.git
synced 2025-05-23 20:02:20 +00:00
17.
This commit is contained in:
parent
e71c868bfd
commit
9651dbed33
746
17.md
Normal file
746
17.md
Normal file
@ -0,0 +1,746 @@
|
||||
## 十七、使用`canvas`绘图
|
||||
|
||||
> Drawing is deception.
|
||||
>
|
||||
> M.C. Escher, cited by Bruno Ernst in The Magic Mirror of M.C. Escher
|
||||
|
||||
浏览器为我们提供了多种绘图方式。最简单的方式是用样式来规定普通DOM对象的位置和颜色。就像在上一章中那个游戏展示的,我们可以使用这种方式实现很多功能。我们可以为节点添加半透明的背景图片,来获得我们希望的节点外观。我们也可以使用transform样式来旋转或倾斜节点。
|
||||
|
||||
但是,在一些场景中,使用DOM并不符合我们的设计初衷。比如我们很难使用普通的HTML元素画出任意两点之间的线段这类图形。
|
||||
|
||||
这里有两种解决办法。第一种方法基于DOM,但使用可缩放矢量图形(SVG,Scalable Vector Graphics)代替HTML元素。我们可以将SVG看成一门专用于描述图形文档而非描述文字文档。你可以在HTML文档中嵌入SVG,还可以在<img>标签中引用它。
|
||||
|
||||
我们将第二种方法称为画布(canvas)。画布是一个能够封装图片的DOM元素。它提供了在空白的html节点上绘制图形的编程接口。SVG与画布的最主要区别在于SVG保存了对于图像的基本信息的描述,我们可以随时移动或修改图像。
|
||||
|
||||
另外,画布在绘制图像的同时会把图像转换成像素(在栅格中的具有颜色的点)并且不会保存这些像素表示的内容。唯一的移动图形的方法就是清空画布(或者围绕着图形的部分画布)并在新的位置重画图形。
|
||||
|
||||
### 16.1 SVG
|
||||
|
||||
本书不会深入研究SVG的细节,但是我会简单地解释其工作原理。在本章的结尾,我会再次来讨论,对于某个具体的应用来说,我们应该如何权衡利弊选择一种绘图方式。
|
||||
|
||||
这是一个带有简单的SVG图片的HTML文档。
|
||||
|
||||
```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>元素的颜色替换为青色。
|
||||
|
||||
```html
|
||||
var circle = document.querySelector("circle");
|
||||
circle.setAttribute("fill", "cyan");
|
||||
```
|
||||
|
||||
### 16.2 canvas元素
|
||||
|
||||
我们可以在<canvas>元素中绘制画布图形。你可以通过设置width与height属性来确定画布尺寸(单位为像素)。
|
||||
|
||||
新的画布是空的,意味着它是完全透明的,看起来就像文档中的空白区域一样。
|
||||
|
||||
<canvas>标签可以支持多种不同风格的绘图。要获取真正的绘图接口,首先我们要创建一个能够提供绘图接口的方法的上下文(context)。目前有两种得到广泛支持的绘图接口:用于绘制二维图形的“2d”与通过openGL接口绘制三维图形的“webgl”。
|
||||
|
||||
本书只讨论二维图形,而不讨论WebGL。但是如果你对三维图形感兴趣,我强烈建议大家自行深入研究WebGL。它提供了非常简单的现代图形硬件接口,同时你也可以使用JavaScript来高效地渲染非常复杂的场景。
|
||||
|
||||
我们可以在<canvas>元素上调用getContext方法创建context对象。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
在创建完context对象之后,作为示例,我们画出一个红色矩形。该矩形宽100像素,高50像素,它的左上点坐标为(10,10)。
|
||||
|
||||
与HTML(或者SVG)相同,画布使用的坐标系统将(0,0)放置在左上角,并且y轴向下增长。所以(10,10)是相对于左上角向下并向右各偏移10像素的位置。
|
||||
|
||||
### 16.3 填充与描边
|
||||
|
||||
我们可以使用画布接口填充图形,也就是赋予某个区域一个固定的填充颜色或填充模式。我们也可以描边,也就是沿着图形的边沿画出线段。SVG也使用了相同的技术。
|
||||
|
||||
fillRect方法可以填充一个矩形。他的输入为矩形框左上角的第一个x和y坐标,然后是它的宽和高。相似地,strokeRect方法可以画出一个矩形的外框。
|
||||
|
||||
两个方法都不需要其他任何参数。填充的颜色以及轮廓的粗细等等都不能由方法的参数决定(假如你这样期望),而是由context对象的属性决定。
|
||||
|
||||
通过设置fillStyle参数可以改变图形填充的方式。我们可以将其设置为描述颜色的字符串,或其他可由css解析的颜色信息。
|
||||
|
||||
strokeStyle属性的作用很相似,但是它用于规定轮廓线的颜色。线条的宽度由lineWidth属性决定。lineWidth的值都为正值。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
当没有设置width或者height参数时,正如上例一样,画布元素的默认宽度为300像素,默认高度为150像素。
|
||||
|
||||
### 16.4 路径
|
||||
|
||||
路径是线段的序列。2D canvas接口使用一种奇特的方式来描述这样的路径。Path的绘制都是间接完成的。我们无法将路径保存为可以后续修改并传递的值。如果你想修改路径,必须要调用多个方法来描述他的形状。
|
||||
|
||||
```html
|
||||
<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,下一条线段会从moveTo指定的位置开始。
|
||||
|
||||
当使用fill方法填充一个路径时,我们需要分别填充这些图形。一个路径可以包含多个图形,每个moveTo都会创建一个新的图形。但是在填充之前我们需要封闭路径(路径的起始节点与终止节点必须是同一个点)。如果一个路径尚未封闭,会出现一条从终点到起点的线段,然后才会填充整个封闭图形。
|
||||
|
||||
```html
|
||||
<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方法显示地通过增加一条回到路径起始节点的线段来封闭一个路径。这条线段在勾勒路径的时候将被显示地画出。
|
||||
|
||||
### 16.5 曲线
|
||||
|
||||
路径也可能会包含曲线。绘制曲线比直线更加复杂。
|
||||
|
||||
quadraticCurveTo方法绘制到某一个点的曲线。为了确定一条线段的曲率,需要设定一个控制点以及一个目标点。设想这个控制点会吸引这条线段,以此来使线段成为曲线。线段不会穿过控制点。同时,线段的起始节点与终止节点的方向会与两个点到控制点的方向平行。见下例:
|
||||
|
||||
```html
|
||||
<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),然后画出两条穿过控制点并且回到线段起点的线段。绘制的结果类似一个星际迷航的图章。你可以观察到控制点的效果:从下端的角落里发出的线段朝向控制点并向他们的目标点弯曲。
|
||||
|
||||
bezierCurve(贝塞尔曲线)方法可以绘制一种类似的曲线。不同的是贝塞尔曲线需要两个控制点而不是一个,线段的每一个端点都需要一个控制点。下面是描述贝塞尔曲线的简单示例。
|
||||
|
||||
```html
|
||||
<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方法至少接受5个的参数。前四个参数与quadraticCurveTo方法有类似的作用。第一对参数提供了一些控制点,第二对参数提供了线段的方向。第五个参数提供了圆弧的半径。该方法会形成一个无形的拐角并在拐点的周围形成一个由指定半径形成的圆弧。该拐角由一条沿着控制点的方向的线段和另一条从控制点向目标点的线段组成。arcTo方法接着会画出圆弧的部分,以及一条从起点到圆弧部分的线段。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
尽管to单词暗示着arcTo方法会画出从圆弧部分终点到目标点的线段,但是该方法并不会这样做。你可以通过调用lineTo方法并赋予相同的目标点坐标来画出这部分线段。
|
||||
|
||||
如果想画出一个圆,你可以调用4次arcTo方法(每次旋转90度)。但是arc方法提供了一个更加简单的方式。它接受一对弧度中心的坐标,一个半径,以及一个起始和终止的弧度。
|
||||
|
||||
我们可以使用最后两个参数画出部分圆。角度是通过弧度来测量的,而不是度数。这意味着一个完整的圆拥有2π的弧度,或者2*Math.PI(大约为6.28)的弧度。弧度从圆心右边的点开始并以顺时针的方向计数。你可以使用一个以0为起始并以一个比2π大的数值(比如7)作为终止值来画出一个完整的圆。
|
||||
|
||||
```html
|
||||
<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)的左侧的直线。arc与其他绘制路径的方法一样,默认会自动连接到上一个路径上。如果你想避免这种行为,需要使用moveTo创建一条新路径。
|
||||
|
||||
### 16.6 绘制饼状图
|
||||
|
||||
设想你刚刚从EconomiCorp获得了一份工作,并且你的第一个任务是画出一个描述其用户满意度调查结果的饼状图。results变量包含了一个表示调查结果的对象的数组。
|
||||
|
||||
```js
|
||||
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π)分割成以调查结果数量为单位的若干份,然后乘以做出相应选择的用户的个数来计算每个圆弧的角度。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
但如果表格无法告诉我们其代表的含义,那么这个表格是毫无用处的。因此我们需要将文字画在画布上。
|
||||
|
||||
### 16.7 文本
|
||||
|
||||
2D画布的context对象提供了fillText方法和strokeText方法。第二个方法可以用于绘制字母轮廓,但通常情况下我们需要的是fillText方法。该方法使用当前的fillColor来填充特定的文字。
|
||||
|
||||
```html
|
||||
<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属性来设定文字的大小,样式和字体。本例给出了一个字体的大小和字体族名称。你可以添加italic或者bold来选择样式。
|
||||
|
||||
传递给fillText(和strokeText)的后两个参数用于指定绘制文字的位置。默认情况下,这个位置指定了文字的字符基线(baseline)的起始位置,我们可以将其假想为字符所站立的位置,基线不考虑j或p字母中那些向下突出的部分。你可以设置textAlign属性(end或center)来改变起始点的横向位置,也可以设置textBaseline属性(top、middle或bottom)来设置基线的纵向位置。
|
||||
|
||||
在本章末尾的练习中,我们会回头来看看饼状图,并解决给饼状图分片标注的问题。
|
||||
|
||||
### 16.8 图像
|
||||
|
||||
计算机图形学领域经常将矢量图形和位图图形分开来讨论。本章一直在讨论第一种图形,即通过对图形的逻辑描述来绘图。而位图则相反,不需要设置实际图形,而是通过处理像素数据来绘制图像(光栅化的着色点)。
|
||||
|
||||
我们可以使用drawImage方法在画布上绘制像素值。此处的像素数值可以来自<img>元素,或者来自其他的画布,并且两者都不需要在实际的文档中可见。下例创建了一个独立的<img>元素,并且加载了一张图像文件。但我们无法马上使用该图片进行绘制,因为浏览器可能还没有完成图片的获取操作。为了处理这个问题,我们在图像元素上注册一个“load”事件处理程序并且在图片加载完之后开始绘制。
|
||||
|
||||
```html
|
||||
<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函数传入9个参数,我们可以用其绘制出一张图片的某一部分。第二个到第五个参数表示需要拷贝的源图片中的矩形区域(x,y坐标,宽度和高度),同时第六个到第九个参数给出了需要拷贝到的目标矩形的位置(在画布上)。
|
||||
|
||||

|
||||
|
||||
该方法可以用于在单个图像文件中推放多个精灵(图像单元)并画出你需要的部分。
|
||||
|
||||
我们可以改变绘制的人物造型,来展现一段看似人物在走动的动画。
|
||||
|
||||
clearRect方法可以帮助我们在画布上绘制动画。该方法类似于fillRect方法,但是不同的是clearRect方法会将目标矩形透明化,并移除掉之前绘制的像素值,而不是着色。
|
||||
|
||||
我们知道每个精灵和每个子画面的宽度都是24像素,高度都是30像素。下面的代码装载了一幅图片并设置定时器(会重复触发的定时器)来定时绘制下一帧。
|
||||
|
||||
```html
|
||||
<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变量用于记录角色在动画图像中的位置。每显示一帧,我们都要将cycle加1,并通过取余数确保cycle的值在0~7这个范围内。我们随后使用该变量计算精灵当前形象在图片中的x坐标。
|
||||
|
||||
### 16.9 变换
|
||||
|
||||
但是,如果我们希望角色可以向左走而不是向右走该怎么办?诚然,我们可以添加一组新的精灵,但我们也可以使用另一种方式在画布上绘图。
|
||||
|
||||
我们可以调用scale方法来缩放之后绘制的任何元素。该方法接受两个输入参数,第一个参数是横向缩放比例,第二个参数是纵向缩放比例。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
因为调用了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坐标处纵向反转一张图片。
|
||||
|
||||
```html
|
||||
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)处的镜像特征。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
### 16.10 存储与清除图像的变换状态
|
||||
|
||||
图像变换的效果会保留下来。我们绘制出一次镜像特征后,绘制其他特征时都会产生镜像效果,这会成为一个问题。
|
||||
|
||||
对于需要临时转换坐标系统的函数来说,我们经常需要保存当前的信息,画一些图,变换图像然后重新加载之前的图像。首先,我们需要将当前函数调用的所有图形变换信息保存起来。接着,函数完成其工作,并添加更多的变换。最后我们恢复之前保存的变换状态。
|
||||
|
||||
2D画布的save与restore方法负责执行这类变换状态管理任务。这两个方法维护变换状态堆栈。save方法将当前状态压到堆栈中,restore方法将堆栈顶部的状态弹出,并将该状态作为当前context对象的状态。
|
||||
|
||||
下面示例中的branch函数首先修改变换状态,然后调用其他函数(本例中就是该函数自身)继续在特定变换状态中进行绘图。
|
||||
|
||||
这个方法通过画出一条线段,并把坐标系的中心移动到线段的端点,然后调用自身两次,先向左旋转,接着向右旋转,来画出一个类似树一样的图形。每次调用都会减少所画分支的长度,当长度小于8的时候递归结束。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
如果没有调用save与restore方法,第二次递归调用branch将会在第一次调用的位置结束。它不会与当前的分支相连接,而是更加靠近中心偏右第一次调用所画出的分支。结果图像会很有趣,但是它肯定不是一棵树。
|
||||
|
||||
### 16.11 回到游戏
|
||||
|
||||
我们现在已经了解了足够多的画布绘图知识,我们已经可以使用基于画布的显示系统来改造前面几章中开发的游戏了。新的界面不会再是一个个色块,而使用drawImage来绘制游戏中元素对应的图片。
|
||||
|
||||
我们会定义一种叫作CanvasDisplay的对象类型,支持与第15章中的DOMDisplay相同的接口,也就是drawFrame方法与clear方法。
|
||||
|
||||
这个对象需要比DOMDisplay多保存一些信息。该对象不仅需要使用DOM元素的滚动位置,还需要追踪自己的视口(viewport)。视口会告诉我们目前处于哪个关卡,也会记录时间以及当前使用哪一帧的数据。最后,该对象会保存一个filpPlayer属性,确保即便玩家站立不动时,它面朝的方向也会与上次移动所面向的方向一致。
|
||||
|
||||
```js
|
||||
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);
|
||||
};
|
||||
```
|
||||
|
||||
我们之所以要将步长传入第15章中定义的drawFrame函数,是因为我们添加了一个animationTime计数器。新的drawFrame函数使用计数器来记录时间,这样该函数可以根据当前时间切换动画帧。
|
||||
|
||||
```js
|
||||
CanvasDisplay.prototype.drawFrame = function(step) {
|
||||
this.animationTime += step;
|
||||
|
||||
this.updateViewport();
|
||||
this.clearDisplay();
|
||||
this.drawBackground();
|
||||
this.drawActors();
|
||||
};
|
||||
```
|
||||
|
||||
除了跟踪时间,这个方法会为当前玩家更新视口的位置。为整个画布填充背景颜色,并且画出背景以及上面的角色。注意,这个方法与第15章中提到的方法不同。第15章中我们只画一次背景,并且通过滚动DOM元素来移动它。
|
||||
|
||||
因为画布上的图形在我们绘制之后都只是像素,所以我们没有办法移动他们(或者删除他们)。唯一的更新画布的方式是清除并重新绘制场景。
|
||||
|
||||
updateViewport方法与DOMDisplay的scrollPlayerintoView方法相似。它检查玩家是否过于接近屏幕的边缘,并且当这种情况发生时移动视口。
|
||||
|
||||
```js
|
||||
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.max和Math.min的调用保证了视口不会显示当前这层之外的物体。Math.max(x,0)保证了结果数值不会小于0。同样地,Math.min保证了数值保持在给定范围内。
|
||||
|
||||
在清空图像时,我们依据游戏是获胜(明亮的颜色)还是失败(灰暗的颜色)来使用不同的颜色。
|
||||
|
||||
```js
|
||||
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中相似的手段,遍历在当前视口中可见的所有瓦片。
|
||||
|
||||
```js
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
非空的瓦片是使用drawImage绘制的。otherSprites包含了描述除了玩家之外需要用到的图片。它包含了从左到右的墙上的瓦片,火山岩瓦片以及精灵硬币。
|
||||
|
||||
![]()
|
||||
|
||||
背景瓦片是20×20像素的,因为我们将要用到与DOMDisplay中相同的比例。因此,火山岩瓦片的偏移是20,墙面的偏移是0。
|
||||
|
||||
我们不需要等待精灵图片加载完成。调用drawImage时使用一幅并未加载完毕的图片不会有任何效果。因为图片仍然在加载当中,我们可能无法正确地画出游戏的前几帧。但是这不是一个严重的问题,因为我们持续更新荧幕,正确的场景会在加载完毕之后立即出现。
|
||||
|
||||
前面展示过的走路的特征将会被用来代替玩家。绘制它的代码需要根据玩家的当前动作选择正确的动作和方向。前8个子画面包含一个走路的动画。当玩家沿着地板移动时,我们根据animationTime属性把他围起来。这是用秒为单位来衡量的,并且我们希望每秒更换12次画面,所以时间是先被放大12倍。当玩家站立不动时,我们画出第九张子画面。当竖直方向的速度不为0,从而被判断为跳跃时,我们使用第10张,也是最右边的子画面。
|
||||
|
||||
因为子画面宽度为24像素而不是16像素,会稍微比玩家的对象宽,这时为了腾出脚和手的空间,该方法需要根据某个给定的值(playerXOverlap)调整x坐标的值以及宽度值。
|
||||
|
||||
```js
|
||||
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方法调用,该方法负责画出游戏中的所有角色。
|
||||
|
||||
```js
|
||||
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的地方(放大了两倍)。
|
||||
|
||||
当计算角色的位置时,我们需要减掉视口的位置,因为(0,0)在我们的画布坐标系中代表着视口层面的左上角,而不是该关卡的左上角。我们也可以使用translate方法,这样可以作用于所有元素。
|
||||
|
||||
这就形成了新的展示系统。最后的游戏界面如下所示。
|
||||
|
||||
```html
|
||||
<body>
|
||||
<script>
|
||||
runGame(GAME_LEVELS, CanvasDisplay);
|
||||
</script>
|
||||
</body>
|
||||
```
|
||||
|
||||
### 16.12 选择图像接口
|
||||
|
||||
无论何时,只要在浏览器中绘图时,你都可以选择纯粹的HTML、SVG或画布。没有唯一的最适合的且在所有动画中都是最好的方法。每个选择都有它的利与弊。
|
||||
|
||||
单纯的HTML的优点是简单。它也可以很好地与文字集成使用。SVG与画布都可以允许你绘制文字,但是它们不会只通过一行代码来帮助你放置text或者包装它,在一个基于HTML的图像中,包含一篇文字是非常方便的。
|
||||
|
||||
SVG可以被用来制造可以任意缩放而仍然清晰的图像。它比单纯的HTML更加难以使用,但是它更加强大。
|
||||
|
||||
SVG与HTML都会构建一个新的数据结构(DOM)来代表图片对象。这使得在绘制元素之后对其进行修改更为可能。如果你需要重复的修改在一张大图片中的一小部分,来对用户的动作进行响应或者作为动画的一部分时,在画布里做这件事情将会极其的昂贵。DOM也可以允许我们在图片上的每一个元素(甚至在SVG画出的图形上)注册鼠标事件的处理器。在画布里则实现不了。
|
||||
|
||||
但是画布的基于像素的方法在需要绘制大量的微小元素时会有优势。它不会构建新的数据结构而是仅仅重复的在同一个像素上绘制,这使得画布在每个图形上拥有更低的消耗。
|
||||
|
||||
有一些效果,像在逐像素的渲染一个场景(比如,使用光线追踪)或者使用javaScript对一张图片进行后加工(虚化或者扭曲),只能通过基于像素的技术来进行真实的处理。在某些情况下,你可能想要将这些技术整合起来使用。比如,你可能用SVG或者画布画出一个图形,但是通过将一个HTML元素放在图片的顶端来展示像素信息。
|
||||
|
||||
对于一些要求低的程序来说,选择哪个接口并没有什么太大的区别。因为不需要绘制文字,处理鼠标交互或者与大量的外母元素交互。我们在本章游戏中构建的第二个显示屏可以通过使用三种图像技术中的任意一种来实现。
|
||||
|
||||
### 16.13 本章小结
|
||||
|
||||
在本章中,我们讨论了在浏览器中绘制图形的技术,重点关注了<canvas>元素。
|
||||
|
||||
一个canvas节点代表了我们的程序可以绘制在文档中的一片区域。这个绘图动作是通过一个由getContext方法创建的绘图上下文对象完成的。
|
||||
|
||||
2D绘图接口允许我们填充或者拉伸各种各样的图形。这个上下文的fillStyle属性决定了图形的填充方式。strokeStyle和lineWidth属性用来控制线条的绘制方式。
|
||||
|
||||
矩形与文字可以通过使用一个简单的方法调用来绘制。采用fillRect和strokeRect方法绘制矩形,同时采用fillText和strokeText方法绘制文字。要创建一个自定义的图形,我们必须首先建立一个路径。
|
||||
|
||||
调用beginPath会创建一个新的路径。很多其他的方法可以向当前的路径添加线条和曲线。比如,lineTo方法可以添加一条直线。当一条路径画完时,它可以被fill方法填充或者被stroke方法勾勒轮廓。
|
||||
|
||||
从一张图片或者另一个画布上移动像素到我们的画布上可以用drawImage方法实现。默认情况下,这个方法绘制了整个原图像,但是通过给它更多的参数,你可以拷贝一张图片的某一个特定的区域。我们在游戏中使用了这项技术,从包括许多动作的图像中拷贝出游戏角色的单个独立动作。
|
||||
|
||||
图形变换允许你向多个方向绘制图片。2D绘制上下文拥有一个当前的可以通过translate、scale与rotate进行变换。这些会影响所有的后续的绘制操作。一个变换的状态可以通过save方法来保存,通过restore方法来恢复。
|
||||
|
||||
当在一个画布上绘制动画时,clearRect方法可以用来在重绘之前清除画布的某一部分。
|
||||
|
||||
### 16.14 习题
|
||||
|
||||
#### 16.14.1 形状
|
||||
|
||||
编写一个程序,在画布上画出下面的图形。
|
||||
|
||||
1.一个不规则四边形(一个在一边比较长的矩形)
|
||||
|
||||
2.一个红色的钻石(一个矩形旋转45度角)
|
||||
|
||||
3.zigzagging——一个2字形折线
|
||||
|
||||
4.一个由100条直线线段构成的螺旋
|
||||
|
||||
5.一个黄色的星星
|
||||
|
||||

|
||||
|
||||
当绘制最后两个图形时,你可以参考在第13章中的Math.cos和Math.sin的定义,这两个函数定义了如何在一个圆上的坐标。
|
||||
|
||||
建议你为每一个图形创建一个方法,传入坐标信息,以及其他的一些参数,比如大小或者点的数量。另一种方法,可以在你的代码中硬编码,会使得你的代码变得难以阅读和修改。
|
||||
|
||||
```html
|
||||
<canvas width="600" height="200"></canvas>
|
||||
<script>
|
||||
var cx = document.querySelector("canvas").getContext("2d");
|
||||
|
||||
// Your code here.
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 16.14.2 饼状图
|
||||
|
||||
在本章的前部分,我们看到一个绘制饼状图的样例程序。修改这个程序,使得每个部分的名字可以被显示在相应的切片旁边。试着找到一个合适的方法来自动放置这些文字,同时也可以适用于其他数据。你可以假设这些部分不会小于5%(这意味着,不会有大量的非常微小的相邻的部分)。你可能还会需要Math.sin和Math.cos方法,像上面的练习中描述的一样。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
|
||||
#### 16.14.3 弹力球
|
||||
|
||||
使用在第13章和第15章出现的requestAnimationFrame方法画出一个装有弹力球的盒子。这个球以匀速运动并且当撞到盒子的边缘的时候反弹。
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
#### 16.14.4 预处理镜像
|
||||
|
||||
当进行图形变换时,绘制位图图像会很慢。对于矢量图形,这种效果并不明显,因为只有一小部分点(比如,圆的中心)需要被变换,之后绘制图形可以跟正常情况一样。对一个位图图像,每个像素的位置必须被变换,并且即便浏览器可以在未来以一种更加高效科学的方法绘制,目前绘制位图会产生一种在时间上可度量的复杂度提升。
|
||||
|
||||
在一个像我们这样的只绘制一个简单的子画面图像变换的游戏中,这个不是问题。但是如果我们需要绘制成百上千的角色或者爆炸产生的旋转粒子时,这将会成为一个问题。
|
||||
|
||||
思考一种方法来允许我们不需要加载更多的图片文件就可以画出一个倒置的角色,并且不需要在每一帧调用drawImage方法。
|
Loading…
x
Reference in New Issue
Block a user