HTML5高级Canvas技术

Published on 2016 - 09 - 22

Canvas功能十分庞大,而且还在逐步发展。上一篇,我们学习了如何绘制直线,乃至使用为数不多的JavaScript代码开发一个还像那么回事儿的画图程序。可是,Canvas本身的功能还远不止这些。使用它不仅能显示动态图片、开发画图工具,还可以播放动画、在像素级别上处理图像,甚至基于它创建交互游戏。这一篇,我们介绍上述所有功能的实际应用。

首先,我们从绘图上下文支持的绘制图像和文本的不同方法讲起,然后讨论为图像添加阴影、使用图案和渐变填充。最后,学习为Canvas添加交互功能,以及通过它实现动画效果。最关键的是,实现这些功能只要编写基本的JavaScript代码即可,当然还要有你的创意。

高级Canvas绘图

使用Canvas可以绘制你能想到的任何图形,无论是线条、三角形,还是着色讲究的人物素描。但绘图任务越复杂,代码自然也越复杂。很多时候,要得到精细的最终结果,靠手工编写每一行代码是不现实的。

好在我们有的选择。绘图上下文不只是能绘制直线和曲线,它还支持各种方法,让我们能直接绘制已有的图片、文本、图案,甚至视频帧。接下来的几节就介绍如何使用这些方法,从而在画布中生成更丰富的内容。

绘制图像

大家都见过使用卫星图片构建的网页地图吧,其中的地图切片都是下载后又拼合到一起的。这个典型的例子说明,我们可以利用已有图片,将它们组织成最终作品。

绘图上下文提供了drawImage()方法,用于在画布上绘制图片。使用这个方法很简单,调用它的时候传入相应的图片对象及起点坐标即可:

context.drawImage(img, 10, 10);

虽然,在调用drawImage()之前,需要准备好图片对象。HTML5为此提供了三个方案。首先,可以使用createImageData()方法一个像素一个像素地创建图像。这种方法很麻烦,也没有效率。

其次,是使用网页中已有的元素。比如,假设网页中存在如下标记:

<img id="arrow_left" src="arrow_left.png">

那么,使用以下代码就可以把该图片复制到画布上:

var img = document.getElementById("arrow_left");
context.drawImage(img, 10, 10);

第三种方案是在代码中创建一个图片对象,然后把一个外部图片加载进来。但这个方案有一个缺点,即必须先等待图片加载完毕,然后才能把图片对象传递给drawImage()方法使用。为此,需要等待图片对象的onLoad事件发生,然后再处理图片。

为了理解这个过程,最好看一个例子。假设我们有一张名为maze.png的图片,你想把它显示在画布上。理论上讲,应该通过如下几步实现:

//创建图片对象
var img = new Image();

//加载图片文件
img.src = "maze.png";
//绘制图片。(可能会因为图片尚未加载完而导致失败)

context.drawImage(img, 0, 0);

以上代码的问题是设置图片对象的src属性后只是开始加载外部图片,但代码没有等到加载完成就立即执行绘图操作。对此,正确的方式是像下面这样:

//创建图片对象
var img = new Image();

//添加onload事件处理程序
//告诉浏览器在图片加载完成后该做什么
img.onload = function() {
  context.drawImage(img, 0, 0);
};

//加载图片文件
img.src = "maze.png";

乍一看,这有点违反直觉。因为代码的顺序与执行顺序并不一致。对这个例子而言,context.drawImage()实际上会后执行,也就是会在设置img.src属性的代码执行后才执行。

有了图片,就可以实现很多新奇的功能。比如,可以用它们来装饰自己的线条图作品,可以直接绘制图片而节省手工绘制时间。如果是在游戏里,可以使用图片来表示物体和人物,把它们分别摆放在画布的不同位置上。而在画图程序里使用图片代替线段,就可以画出“纹理化”的线条来。本文会介绍一些使用图片绘图的实用技术。

我的图片变形了

在绘制图片时,如果你发现原来的图片不知为什么被拉长了、压扁了,总之变形了,那幕后黑手很可能是样式表规则。

为画布指定宽度和高度的最佳方案,就是在HTML标记中使用width和height属性。可能有人觉得像下面这样使用标记更简洁:

<canvas></canvas>

因为可以通过样式表规则来控制画布的大小,比如:

canvas {
  height: 300px;
  width: 500px;
}

但这个方案行不通!问题在于,CSS的width和height属性与画布的width和height属性并不是一回事儿。假如你真的这么做了,那画布会取得其默认尺寸(300像素×150像素)。然后,CSS的width和height属性又会把画布拉伸或压缩到它设置的大小。与此同时,画布中的内容也会随之变形。结果,在通过画布显示图片时,图片也会被压扁,这显示会降低图片的吸引力。

为避免这个问题,请一定要在HTML标记中为画布指定宽度和高度。如果你想在某个条件下改变画布的大小,可以使用JavaScript代码来修改<canvas>元素的宽和高。

裁剪、切割和伸缩图片

可以给drawImage()函数传递一些可选的参数,从而影响在画布上绘制图片的方式。首先,如果想改变图片的大小,可以添加宽度和高度,例如:

context.drawImage(img, 10, 10, 30, 30);

这就相当于为图片准备了一个30×30的方框,其左上角在画布上的坐标为(10,10)。假设图片实际上是60像素×60像素,则执行上面的代码会把图片的宽度和高度都缩小一半,最终在画布上呈现的大小只有原来的1/4。

如果想裁剪掉一部分图片,可以再为drawImage()函数传入4个参数,这个4个参数从图片对象参数后面开始。之所以传入4个参数,正是为了定义从原始图片的什么位置,裁剪多大的图片,每个参数的含义如下所示:

context.drawImage(img, source_x, source_y,
 source_width, source_height, x, y, width, height);

最后4个参数与上一个例子中的相同,它们定义被裁剪后的图片在画布上的位置和大小。

比如有一张200像素×200像素的图片,但我们只想在画布上绘制它的上半部分。为此,就要创建一个200像素×100像素的矩形框,从原始图片的(0,0)位置开始裁剪,得到图片的上半部分。然后,把裁剪后的结果绘制到画布上,起点为(75,25)。用代码表示就是:

context.drawImage(img, 0, 0, 200, 100, 75, 25, 200, 100);

图1演示了这个例子的结果。
 

绘制视频帧

我们知道,drawImage()方法的第一个参数是要绘制的图片。如前所述,这个图片可以是临时创建的图片对象,也可以是页面某个地方已经存在的<img>元素。

但这些并非HTML5为drawImage()定义的全部功能。实际上,除了绘制图片,还可以绘制整个<canvas>元素(不是当前的这个)。另外,还可以绘制目前正在播放的<video>元素,且无需额外的工作:

var  video  =
 document.getElementById("videoPlayer");
 
context.drawImage(video,  0,  0,
 video.clientWidth, video.clientWidth);

以上代码运行后,会捕获代码运行瞬间正在播放的视频中的一帧画面,然后把该画面绘制到画布上。

这就为实现其他很多有趣的效果提供了可能。例如,可以利用一个计时器来不断捕获播放中的视频,然后不断将新画面绘制到画布上。假如整个过程足够快,则复制的画面在画布上连续播放,就会成为另一个视频播放器。

发挥一点想象,比如可以在绘制画面之前,对其进行一些修改。比如,可以放大或缩小画面,或者取得其中的像素数据,然后应用Photoshop滤镜般的效果。要了解这方面的实际示例,可以参考这篇文章:http://html5doctor.com/video-canvas-magic。这篇文章里介绍了在画布中播放黑白画面的实现过程,方法就是从现有视频中实时取得画面截图,然后把每个彩色像素转换成黑白像素,最后再绘制到画布上。

绘制文本

除了直线和曲线,你一定还想在画布上绘制文本,但你肯定不愿意自己通过绘制线条来形成文本。HTML5规范也没有认为你愿意。为此,我们就有了另外两个绘图上下文方法支持绘制文本。

首先,在绘制文本之前要设置绘图上下文的font属性。这个属性的值是一个字符串,与设置CSS的font属性时使用的“多合一”的值相同。最简单的情况,也要设置字体大小(像素)和字体名称,比如:

context.font = "20px Arial";

如果不能确定用户的浏览器支持哪种字段,可以多列出几种来:

context.font = "20px Verdana,sans-serif";

此外,还可以为字体应用加粗效果,不过要把它放在字符串的开头:

context.font = "bold 20px Arial";

设置好字体后,就可以调用fillText()方法绘制文本内容了。以下示例代码将把文本内容的左上角放在画布的(10,10)坐标点处:

context.textBaseline = "top";
context.fillStyle = "black";
context.fillText("I'm stuck in a canvas. Someone let me out!", 10, 10);

可以把文本内容绘制到任何地方,但每次却只能绘制一行。如果要绘制多行文本,那只能多次调用fillText()方法。

除了fillText()方法,还有另一个绘制文本的方法,即strokeText()。这个方法用于绘制文本的轮廓,轮廓的颜色取自strokeStyle属性,而轮廓的宽度取自lineWidth属性。下面是一个例子:

context.font = "bold 40px Verdana,sans-serif";
context.lineWidth = "1";
context.strokeStyle = "red";
context.strokeText("I'm an OUTLINE", 20, 50);

使用strokeText()时,文本的中部是空白的。当然,如果你想得到加了彩色描边的文本,可以先调用fillText()绘制实心文本,然后调用strokeText()绘制文本的轮廓。图2展示了这两个方法绘制的文本。

提示 与绘制线条和图片相比,绘制文本的速度稍慢一些。如果你想创建静态、不变的图像(如数据图表),这个速度不是问题。但是,如果你想创建交互、动态的应用,那么绘制文本的速度可能就会影响性能。至于优化速度的手段,可能就是事先把文本保存为图片,然后再使用drawImage()把图片绘制到画布上。

阴影与填充

现在,我们在画布上绘制线条和填充图形时,使用的都是实色。这当然没有问题,不过要是我告诉大家Canvas还有一些新奇的绘图功能,有创意的设计师一定会兴高采烈。比如,可以在画布上为图形绘制模糊的阴影,可以用小图案来填充图形。当然,最令人拍手叫绝的还是渐变功能,把多种颜色组合起来,能够形成千变万化的模式。

接下来的几节我们就来介绍这些新功能,实际上只要简单地设置绘图上下文的另外一些属性就好了。

添加阴影

为绘制的任何内容添加阴影是Canvas最便利的功能。图3展示了几种阴影的示例。

阴影与形状、图片和文本一样。特别是给带透明背景的图片加阴影时,阴影的形状会随不透明部分的形状变化,比如图中右上角的五角星,其阴影的形状也是相同的五角形,而不是方形。(在编写本书时,还只有IE和Firefox支持这个功能。)阴影与文本也是相辅相成,而且设置不同,阴影的效果也不一样

本质上,可以把阴影看成原来绘制内容(直线、形状、图片或文本)的模糊版。控制阴影的外观,需要使用绘图上下文的几个属性,如表1所示。

属  性 说  明
shadowColor 设置阴影颜色。可以把阴影设置为黑色或彩色,但中性灰还是最佳选择。另外一种技术是使用半透明的颜色,以便下方内容可以若隐若现。在不需要阴影的时候,可以把shadowColor 设置为完全透明
shadowBlur 设置阴影的模糊程度。值为0表示锐利的阴影,结果会生成原始形状的一个轮廓鲜明的副本。相对来说,值为20的时候已经比较模糊了,不过当然还可设置更大的值。一般来说,这个值不小于3才会达到最佳效果
shadowOffsetX, shadowOffsetY 设置阴影相对于内容的位置。例如,把这两个属性都设置为5,会导致阴影被绘制到原图形向右和右下各5像素的位置。使用负值可以把阴影移动到其他位置(左或上方)

以下是创建图3中所示各种阴影的代码:

//绘制矩形阴影
context.rect(20, 20, 200, 100);
context.fillStyle = "#8ED6FF";
context.shadowColor = "#bbbbbb";
context.shadowBlur = 20;
context.shadowOffsetX = 15;
context.shadowOffsetY = 15;
context.fill();

//绘制星形阴影
context.shadowOffsetX = 10;
context.shadowOffsetY = 10;
context.shadowBlur = 4;
img = document.getElementById("star");
context.drawImage(img, 250, 30);

context.textBaseline = "top";
context.font = "bold 20px Arial";

// 绘制三行文本的阴影
context.shadowBlur = 3;
context.shadowOffsetX = 2;
context.shadowOffsetY = 2;
context.fillStyle = "steelblue";
context.fillText("This is a subtle, slightly old-fashioned shadow.", 10, 175);

context.shadowBlur = 5;
context.shadowOffsetX = 20;
context.shadowOffsetY = 20;
context.fillStyle = "green";
context.fillText("This is a distant shadow...", 10, 225);

context.shadowBlur = 15;
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowColor = "black";
context.fillStyle = "white";
context.fillText("This shadow isn't offset. It creates a halo effect.", 10,
300);

填充图案

说到填充,我们到目前为止用到的都是实色或部分透明的颜色。除此之外,还可以使用图案和渐变。图案和渐变可以让平淡无奇的图形一下子变得活泼起来。这两种填充方式很简单,只要两步。首先,创建要填充的内容。然后,将其添加到fillStyle属性(有时候需要使用strokeStyle属性)。

要实现用图案填充,首先要选择一张小图片,而且要能够前后左右拼接在一起覆盖一块大区域(参见图4)。当然,需要利用前面介绍的技术把这张图片加载到图片对象中,比如在页面中放一个隐藏的<img>元素,或者使用代码创建图片对象,把外部图片加载进来,然后处理图片对象的onLoad事件。在此,我们使用第一种方法:

var img = document.getElementById("brickTile");

有了图片对象后,就可以利用绘图上下文的createPattern()方法创建一个图案对象。此时,可以选择图案是水平(repeat-x)、垂直(repeat-y),还是在两个方向(repeat)重复:

var pattern = context.createPattern(img, "repeat");

最后是使用图案对象设置fillStyle或strokeStyle属性:

context.fillStyle = pattern;
context.rect(0, 0, canvas.width, canvas.height);
context.fill();

这样,就创造出了用小幅图片填充画布的效果。

填充渐变

第二种填充形式是渐变,也就是混合在一起的两种或多种颜色。Canvas支持线性渐变和放射性渐变,图5展示了这两种渐变形式。

线性渐变(左上)是在一个方向上混合色彩。放射性渐变(右上)是从一点向四周混合色彩。这两种渐变形式都支持多种颜色混合,因此使用线性渐变可以创造出色谱效果(左下),使用放射性渐变可以创造出同心圆扩散的效果(右下)

估计读者也猜到了,使用渐变填充的第一步是创建渐变对象。绘图上下文为此提供了两个方法:createLinearGradient()和createRadialGradient()。这两个方法的用法大致相同,即它们接收一组坐标,表示不同颜色的起点。

理解渐变的最简单方式就是看一个例子。以下代码创建的是图5左上角心形的渐变:

//创建一个从(10,0)到(100,0)的渐变
var gradient = context.createLinearGradient(10, 0, 100, 0);

//添加两种颜色
gradient.addColorStop(0, "magenta");
gradient.addColorStop(1, "yellow");
//调用另一个函数绘制心形
drawHeart(60, 50);

//填充心形
context.fillStyle = gradient;
context.fill();
context.stroke();

这里是创建线性渐变,因此我们给createLinearGradient()传入两个坐标点,分别表示渐变的起点和终点。起点和终点构成了颜色逐渐过渡的区间。

起点到终点的渐变线很重要,因为它决定了渐变的最终效果(参见图6)。例如一个从品红过渡到黄色的线性渐变,这个渐变可以在几个像素的距离上完成,也可以跨越整个画布的宽度。而且,渐变可以是从左到右,也可以是从上到下,甚至发生在两个任意点之间(渐变线的角度可以任意变化)。总之,渐变线决定了这一切。

左:以下是为图5左下角的心形生成的渐变。用这个渐变填充心形后,只能看到整个渐变的一部分。右:为图5右下角的心形生成的放射性渐变,同样也只是整个渐变的一部分
提示 可以把渐变想象成位于画布下方的彩色图样。在创建渐变时,你是在创建这个彩色但却隐藏的图样。而在填充图形时,你会在画布上按照图形的形状抠出一个洞,从而让下面那部分图样显示出来。实际的效果(在画布上呈现的结果),取决于渐变的设置和形状的大小及位置。

对这个例子而言,渐变线的起点和终点分别是(10,0)和(100,0)。这两个点决定以下重要信息。

  • 渐变是水平的。也就是说,渐变的颜色将从左到右混合。之所以知道渐变是水平的,是因为这两个点的y轴坐标相等。如果你想创建从上到下的渐变,可以把起点和终点设置为(0,10)和(0,100)。类似地,对角线方向的渐变(从左上到右下),可以使用(10,10)和(100,100)。
  • 实际的混合颜色区宽度为90个像素(x坐标从10到100)。在这个例子中,心形比渐变范围稍小一些,因此可以在心形里看到大部分渐变。
  • 超过渐变范围的颜色会变成实色。因此,如果把心形设置得更宽,就会看到更多品红(左)和黄色(右)。

提示 一般来说,我们创建的渐变只要恰好比要填充的图形大一点即可,正如这个例子所示。当然,也还有其他可能。比如,要是你想利用一个渐变的不同部分填充几个图形,可能就需要创建一个画布那么宽的渐变。

确定了渐变线的宽度和角度之后,接下来就该实际地设置构成渐变的颜色了。要设置渐变颜色,需要使用渐变对象的addColorStop()方法。每次调用这个方法,都需要提供一个0~1的偏移值和一个颜色值(颜色名)。其中,偏移值决定颜色在渐变中的位置:0表示位于渐变的起点,1表示位置渐变的终点。改变这两个值(比如,分别改为0.2和0.8),就会压缩渐变的范围,让两端显示出更多的实色。

在创建双色渐变时,最好将0和1分别作为两种颜色的偏移值。而在创建多种颜色构成的渐变时,可以通过选择不同的偏移值来加宽某种颜色区的宽度,或者缩小某种颜色的范围。图5中左下角心形渐变的偏移值是平均分布的,即每种颜色的范围都一样宽:

var gradient = context.createLinearGradient(10, 0, 100, 0);
gradient.addColorStop("0", "magenta");
gradient.addColorStop(".25", "blue");
gradient.addColorStop(".50", "green");
gradient.addColorStop(".75", "yellow");
gradient.addColorStop("1.0", "red");

drawHeart(60, 200);
context.fillStyle = gradient;
context.fill();
context.stroke();

创建放射性渐变与创建线性渐变类似。只不过,这次不是指定两个点,而是要指定两个圆。这是因为放射性渐变就是颜色从一个小圆过渡到一个更大的、包含它的圆。要定义圆,需要提供圆心坐标和半径。

在图5右上角那个放射性渐变的例子中,渐变的起点是在心形内部,坐标为(180,100)。内部颜色由一个半径为10像素的圆表示,外部颜色由一个半径为50像素的圆表示。同样,在小圆内部或者大圆外部(即超出两个圆范围之外的地方)会显示实色,因此该放射性渐变的中心是品红色,而外围是实心黄色。

以下是创建这个双色放射性渐变的代码:

var gradient = context.createRadialGradient(180, 100, 10, 180, 100, 50);
gradient.addColorStop(0, "magenta");
gradient.addColorStop(1, "yellow");

drawHeart(180, 80);
context.fillStyle = gradient;
context.fill();
context.stroke();

注意 把两个圆设置为同心圆是最常见的做法。不过,当然可以给内圆和外圆设置不同的圆心,而这样可以实现拉伸、压缩或其他颜色变形效果。

在这个例子的基础上,我们可以创建图5右下角那个多色放射性渐变效果。只要把两个圆的圆心坐标平移到那个心形的内部,然后再(使用渐变对象的addColorStop()方法)加上不同的色标(与创建多色线性渐变时使用的色标相同)即可:

var gradient = context.createRadialGradient(180, 250, 10, 180, 250, 50);
gradient.addColorStop("0","magenta");
gradient.addColorStop(".25","blue");
gradient.addColorStop(".50","green");
gradient.addColorStop(".75","yellow");
gradient.addColorStop("1.0","red");

drawHeart(180, 230);
context.fillStyle = gradient;
context.fill();
context.stroke();

好了,以你现在掌握的技术,要创建出光怪陆离的图案已经不成问题了。

综合示例:绘制图解

既然你已经历尽千难万险,征服了Canvas绘图更具有挑战性的功能,现在该停下来好好享受一下胜利果实了。下面,我们将介绍一个示例,看看怎么利用Canvas把一堆毫无吸引力的文本和数字,转换成简单而漂亮的图解。

图7展示了这个示例的起点状态:由两个页面组成的个性测试,其中点缀着一些图片。用户在第一个页面回答问题,然后点击Get Score转到下一页。第二页根据第一页众所周知的“大五人格理论”得到个性测试的得分(参见后面的附注栏)。

图7:点击回答问题(上),然后看看得分(下)。然而,这个测试没有可视化的刻度,一般人很难理解结果分数的具体含义

怎样把人格转换成5个数字

大五人格测试根据每个人的五方面人格“因素”来确定性格。这五方面因素是:开放性、责任心、外倾性、亲和力和情绪稳定性。这些因素是研究人员在分析了人们用成千上万个英语形容词描述的性格特征之后提炼出来的。

为了得到五方面信息,心理学家综合运用了基准统计信息、个性调查和计算机。他们希望知道人们会选择哪些形容词,然后据以提炼出最小的性格特征。比如,认为自己乐于助人的人,一般会把自己描述为喜欢社交和过集体生活,因此就可以把这些特征归纳为一个人格因素(心理学家称其为外倾性)。经过对近两万个形容词的研究,他们最终归纳出五个最相关的因素。

这个示例的JavaScript代码非常容易理解。在用户单击一个数字按钮时,按钮背景会改变,以反映用户的选择。而用户回答完所有问题后,会有一个简单的算法,把答案传给一组计分公式,从而计算出5个人格因素。

到目前为止,还没有使用HTML5。不过,请大家考虑一下怎么改进这个两页的人格测试示例,比如通过图解形式显示5个人格因素的得分情况。图8展示了对这个人格测试的结果页面进行改进之后的结果,即以图解形式显示了得分。

这个页面使用了几种不同的绘图方式,绘制了直线、图像和文本。但最关键的地方还是根据测试答案动态绘制的图示

为了显示图解,结果页面中使用了5个Canvas,每个对应一个人格因素。以下是相应的 标记:

<hgroup>
  <h1>Five Factor Personality Test</h1>
  <h2>The Results</h2>
</hgroup>

<div class="score">
  <h2 id="headingE">Extraversion: </h2>
  <canvas id="canvasE" height="75" width="550"></canvas>
</div>

<div class="score">
  <h2 id="headingA">Accommodation: </h2>
  <canvas id="canvasA" height="75" width="550"></canvas>
</div>

<div class="score">
  <h2 id="headingC">Conscientiousness: </h2>
  <canvas id="canvasC" height="75" width="550"></canvas>
</div>

<div class="score">
  <h2 id="headingN">Neuroticism: </h2>
  <canvas id="canvasN" height="75" width="550"></canvas>
</div>

<div class="score">
  <h2 id="headingO">Openness: </h2>
  <canvas id="canvasO" height="75" width="550"></canvas>
</div>

每个图示都使用了同一个自定义JavaScript函数,plotScore()。这个页面调用了plotScore()函数5次,每次都传入不同的参数。例如,在页面顶部绘制“外倾性”的图示时,传递的参数是最顶部的Canvas元素、分数(从-20~20的值),以及文本标题(“Extraversion”):

window.onload = function() {
  ...
  //取得显示外倾性图示的画布
  var canvasE = document.getElementById("canvasE");
  //将分数添加到对应的标题后面
  //(分数保存在变量extraversion里)
  document.getElementById("headingE").innerHTML += extraversion;

//在对应的画布中标绘分数
  plotScore(canvasE, extraversion, "Extraversion");
  ...
}

下面再看看plotScroe()函数,该函数执行一系列绘图代码,根据前面介绍的知识,读者应该不难理解这些代码。总之,代码中使用了各种绘图上下文的方法,绘制了分数图示的不同部分:

function plotScore(canvas, score, title) {
  var context = canvas.getContext("2d");

  //在图示的两端绘制箭头
  var img = document.getElementById("arrow_left");
  context.drawImage(img, 12, 10);
  img = document.getElementById("arrow_right");
  context.drawImage(img, 498, 10);

  //绘制箭头之间的刻度线
  context.moveTo(39, 25);
  context.lineTo(503, 25);
  context.lineWidth = 10;
  context.strokeStyle = "rgb(174,215,244)";
  context.stroke();

  //把数值写在刻度位置上
  context.fillStyle = context.strokeStyle;
  context.font = "italic bold 18px Arial";
  context.textBaseline = 'top';

  context.fillText("-20", 35, 50);
  context.fillText("0", 255, 50);
  context.fillText("20", 475, 50);

  //绘制星星,显示分数在图示上的位置
  img = document.getElementById("star");
  context.drawImage(img, (score+20)/40*440+35-17, 0);
}

最重要的是最后一行代码,这行代码通过有点不好理解的公式,把星星绘制在正确的位置上:

context.drawImage(img, (score+20)/40*440+35-17, 0);

这里稍微解释一下。首先是把分数转换为0~100的百分比值。因为分数一般会落在-20~20这个区间内,所以代码第一步要把这个分数转换成0~40的值:

score+20

而用这个值除以40就可以得到百分比值:

(score+20)/40

得到百分比值后,接下来需要用它乘以刻度线的长度。0%表示在最左端,100%表示在另外一端,而其他百分比值的结果就是位于两端之间:

(score+20)/40*440

如果刻度线的x坐标是从0到400,这个公式就已经够用了。但实际上,这条线是从画布左边偏右一点绘制的,目的是为了留下一些空间。因此,需要在绘制星星时也偏移相应的像素数:

(score+20)/40*440+35

可是,这样只把星星的左边放到正确的位置上。而我们实际上是想把星星的中心点放在该位置。为了补偿这个距离,需要再减去星星宽度的一半:

(score+20)/40*440+35-17

这就是根据分数计算得到的星星的x坐标了。

注意 从静态绘图到这个例子中所展示的根据数据来动态绘图,应该说只是向前跨越了一小步。而哪怕是跨越了这一小步之后,你就具备了创建各种数据驱动图表的经验,无论是传统的饼图,还是使用刻度盘和计量仪的信息图。什么,有没有简化工作的工具?有啊,推荐大家使用Canvas图形库,这些库包含写好的JavaScript函数,可以根据你的数据绘制常见的图表。比如,RGraph(http://www.rgraph.net/)和ZingChart(http://www.zingchart.com/)都是不错的选择。

赋予图形交互能力

Canvas是一种非保留性的绘图界面。换句话说,它不会记录过去执行的绘图操作,而只是保持最终结果——构成图像的彩色像素。

比如,你要在画布中央绘制一个红色的正方形,调用stroke()或fill()之后,那个正方形仅仅就是包含红色像素的正方形区域。Canvas不会保存这个正方形区域。

这个模型能保证绘图速度,但同时也导致不便为绘制的图形添加交互性。假设你想为图11所示的画图程序创建一个更智能的版本,比如不仅支持画线,还支持画矩形。(支持画矩形不难。)而且,不仅支持画矩形,还要支持让用户选择、拖动矩形,以及调整矩形大小、改变颜色,等等。在实现这些功能之前,必须理清几方面思路。首先,怎么知道用户选择了矩形?其次,怎么知道矩形的相关信息,比如坐标、大小、描边颜色、填充颜色?最后,怎么知道画布上其他形状的信息——这些信息在需要改变矩形和重绘画布时有用?

要解决这些问题,把Canvas变得具有交互性,必须记录绘制的每一个对象。此外,在有人单击Canvas中的某个地方时,还要检测被单击的是不是其中一个图形(这个过程叫碰撞检测)。如果能实现这两个任务,剩下的(修改某个图形或重绘画布)就简单了。

记录绘制的内容

为了修改和重绘画面,必须先知道要修改和重绘什么内容。就以图9所示的绘制圆圈的程序为例,但为了简单起见,其中包含的圆圈的大小、颜色各不相同。

这个绘制圆圈的程序是交互性的。单击可以选择一个圆(边框会变成另一种颜色),而且能把它拖动到新位置

为了记录每一个圆圈,需要知道它们的位置、半径以及填充色。与其声明一大堆变量来保存这些信息,不如把上述4个值都放在同一个小数据结构中。这个数据结构就是自定义对象。

什么,不知道怎么创建自定义对象?下面就是一种标准的做法。首先,创建一个函数,函数名就是用于创建这种对象的类型名。比如,要创建一种圆圈类型,可以把函数命名为Circle():

function Circle() {
}

如果创建圆圈的函数中要保存3方面信息:圆的x坐标、y坐标和半径,可以这样写:

function Circle() {
  this.x = 0;
  thix.y = 0;
  this.radius = 15;
}

好了,现在可以使用这个Circle()函数来创建新的圆圈对象了。这里的关键是并非要调用该函数,而是要使用new关键字来创建它的一个副本,比如:

//创建一个新的Circle对象,并将其保存在变量myCircle中
var myCircle = new Circle();

之后,就可以像下面这样来访问这个圆圈对象的属性:

//修改半径
myCircle.radius = 20;

不仅如此,要是你想让定义新圆圈对象的过程更灵活一些,还可以为Circle()函数传递参数。这样就可以在创建新圆圈对象的时候,一次性设置圆圈的所有属性。下面就是用于创建图9中圆圈对象的另一个Circle()函数:

function Circle(x, y, radius, color) {
  this.x = x;
  this.y = y;
  this.radius = radius;
  this.color = color;
  this.isSelected = false;
}

这里的isSelected属性值不是true就是false。在用户单击这个圆圈时,isSelected的值就会变成true,而此时绘图代码就知道应该为它绘制一个不同的边框了。

使用这个Circle()函数,可以利用如下代码来创建一个圆圈对象:

var myCircle = new Circle(0, 0, 20, "red");

当然,圆圈绘图程序最终是要支持用户画任意圆圈的。所以不可能只创建一个圆圈对象。为此,需要创建一个数组,用于保存所有圆圈。下面就是我们这个例子中所要用到的全局数组变量:

var circles = [];

剩下的代码也不难。在用户单击Add Circle按钮创建新的圆圈时,就会触发addRandomCircle()函数。addRandomCircle()函数会以随机大小、颜色和坐标值绘制一个圆圈:

function addRandomCircle() {
  //为圆圈计算一个随机大小和位置
  var radius = randomFromTo(10, 60);
  var x = randomFromTo(0, canvas.width);
  var y = randomFromTo(0, canvas.height);

  //为圆圈计算一个随机颜色
  var colors = ["green", "blue", "red", "yellow", "magenta",
   "orange", "brown", "purple", "pink"];
  var color = colors[randomFromTo(0, 8)];

  //创建一个新圆圈
  var circle = new Circle(x, y, radius, color);

  //把它保存在数组中
  circles.push(circle);

  //重新绘制画布
  drawCircles();
}

以上代码也利用了另一个自定义函数randomFromTo(),它在某个范围内生成随机数。

最后一步当然就是基于当前圆圈的集合实际地在画布上绘图了。创建新圆圈后,addRandomCircle()调用了另一个函数drawCircles()来执行绘图操作。drawCircles()函数会遍历圆圈数组,像下面这样:

for(var i=0; i<circles.length; i++) {
  var circle = circles[i];
  ...
}

以上代码使用可靠的for循环。花括号中的代码块针对每个圆圈都会运行一次。第一行代码先从数组中取得当前圆圈,将其赋给一个变量,以方便后面使用。

下面就是drawCircles()函数的完整代码,它的任务就是根据当前圆圈的集合来填充画布:

function drawCircles() {
  //清除画布,准备绘制
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.globalAlpha = 0.85;
  context.strokeStyle = "black";

  //遍历所有圆圈
for(var i=0; i<circles.length; i++) {
    var circle = circles[i];
    //绘制圆圈
    context.beginPath();
    context.arc(circle.x, circle.y, circle.radius, 0, Math.PI*2);
    context.fillStyle = circle.color;

    context.fill();
    context.stroke();
  }
}

注意 圆圈绘图程序每次刷新画布,都会先使用clearRect()方法清除画布上的所有内容。有些极其追求完美的程序员就担心这一步操作会造成画布闪烁,即画布上的圆圈一下全都消失,然后一下子又重新出现。不过,Canvas针对这个问题进行了优化。换句话说,它实际上会在绘图逻辑执行完毕后才清除或绘制所有内容,因此可以把最终结果流畅不间断地复制到画布上。

现在,圆圈仍然还没有交互性。不过,页面中用于记录绘制的每个圆圈的代码已经齐备了。尽管画布看上去仍然还是彩色像素块,但我们的代码知道画布所有圆圈的精确信息。而这就意味着可以随时操作这些圆圈。

下一节,我们就来看看如何在此基础上让用户选择圆圈。

基于坐标的碰撞检测

只要创建交互图形,几乎就一定要用到碰撞检测,也就是测试某个点是否“碰到”了某个图形。在绘制圆圈的程序中,我们需要检测用户单击的点是否碰到某个圆圈,或者只是点击了空白区域。

为检测碰撞是否发生,就要检测每一个形状,计算鼠标点击的那个点是否落在某个形状里。如果是,说明单击“碰到”了该形状。分析起来简单,而实现起来可就远没有那么容易了。

第一件事儿就是遍历所有形状。这个循环与前面drawCircles()函数所用的循环有一点不同:

for (var i=circles.length-1; i>=0; i--) {
  var circle = circles[i];
  ...
}

不同之处是这里的代码在反向遍历数组:从末尾开始(末尾的索引等于数组中包含的元素数减1),向开头迭代(第一个元素的索引为0)。这里的反向遍历是有意为之的,因为在大多数应用中(包括我们这个),都会按照数组中列出对象的顺序来绘制对象。结果,后来的对象可能就会叠加在先前对象上面。而在两个形状叠加起来后,那么单击的只能是上面的那个对象。

要确定单击点是否位于形状内,需要一些数学计算。对于圆圈而言,需要计算单击点与圆心的直线距离。如果这个距离小于等于圆圈半径,那么就可以确定单击点位于圆圈内。

在我们这个例子中,页面会处理Canvas的onClick事件,以检测被单击的圆圈。当用户单击画布时,就会触发canvasClick()函数。这个函数会取得单击点的坐标,然后检测该坐标是否位于某个圆圈内:

function canvasClick(e) {
  //取得画布上被单击的点
  var clickX = e.pageX - canvas.offsetLeft;
  var clickY = e.pageY - canvas.offsetTop;

  //查找被单击的圆圈
  for (var i=circles.length; i>0; i--) {
    //使用勾股定理计算这个点与圆心之间的距离
    var distanceFromCenter =
    Math.sqrt(Math.pow(circle.x - clickX, 2) + Math.pow(circle.y - clickY, 2))

    //这个点在圆圈中吗
    if (distanceFromCenter <= circle.radius) {
      //清除之前选择的圆圈
      if (previousSelectedCircle != null) {
        previousSelectedCircle.isSelected = false;
      }
      previousSelectedCircle = circle;

      //选择新圆圈
      circle.isSelected = true;

      //更新显示
      drawCircles();

      //停止搜索
      return;
    }
  }
}

这个例子的最后,要稍微调整一下drawCircles()函数中的代码。现在,应该为被选择圆圈加点标记,让它突出出来(如前所述,这里会加上一个粗边框):

function drawCircles() {
  ...

  //循环所有圆圈
  for(var i=0; i<circles.length; i++) {
    var circle = circles[i];

    if (circle.isSelected) {
      context.lineWidth = 5;
    }
    else {
      context.lineWidth = 1;
    }
    ...
  }
}

这个例子当然还可以更加完善,功能更强大。例如,可以添加一个命令工具条,用于修改圆圈(修改颜色或把它从画布上删除)。或者,允许用户在画布上拖动圆圈。为此,只要侦听Canvas的onMouseMove事件,相应地修改圆圈的坐标,然后再调用drawCircles()函数重绘画布即可。

记住这个要点:只有记录绘制的所有内容,才能在将来灵活地修改并重绘它们。

给Canvas添加动画

绘制一幅完美的图画已经够复杂的了,所以就算是经验丰富的开发人员,让他实现每秒绘制几十个图形的程序,也难免不眉头紧锁。做动画的关键是绘制和重绘画布的速度要足够快,这样才能让人感觉移动和变化自然流畅。

动画对某些应用来说可以是最基本的,比如实时游戏、物理模拟器。不过,比较简单的动画在包含Canvas的页面中同样大有用武之地。可以通过动画来突出用户交互(例如,在鼠标悬停时,给图形加上光晕、让图形跳动或闪烁),也可以利用动画效果来吸引人注意改变的内容(例如,淡入新场景,或创建“长”到恰当位置的图形、图表)。如此说来,动画确实是为网页增光增彩的强大手段,能给人活生生的感觉,而且还能帮我们从一大堆竞争者中脱颖而出。

基本的动画

在HTML5中利用Canvas实现动画非常容易。首先,要设置一个定时器,反复调用绘图函数(一般每秒30~40次)。每次调用,都会重绘整个画布。完成后的效果就像动画一样,每一帧间的过渡会平滑而流畅。

JavaScript为控制重复绘制提供了两种手段。

  • 使用setTimeout()函数。这个函数告诉浏览器等待多长时间(毫秒),然后再运行一段代码(即绘制画布的代码)。运行代码后,可以再调用setTimeout()让浏览器准备下一次运行。如此往复,直至动画结束。

    • 使用setInterval()函数。这个函数告诉浏览器每隔一定时间(如20毫秒)就运行某一段代码。它与setTimeout()的效果类似,但只需调用setInterval()一次。要阻止浏览器继续运行代码,可以调用clearInterval()。

假如运行绘图代码的速度非常快,使用这两个函数都可以,结果都一样。可假如绘图代码没那么快,setInterval()则能保证精确地按时重绘,但又可能因此牺牲性能。(最差的情况下,如果绘图代码执行时间比设定的时间还要长,浏览器将很难跟上,随着绘图代码连续执行,页面会出现短暂地停顿。)考虑到这个原因,本文的例子都使用setTimeout()函数。

调用setTimeout()时,要提供两个参数:要运行的函数名和运行该函数之前等待的时间。这里的时间要使用毫秒(千分之一秒),因此20毫秒(典型的动画延迟时间)就是0.02秒。来看下面这个例子:

var canvas;
var context;

window.onload = function() {
  canvas = document.getElementById("canvas");
  context = canvas.getContext("2d");

  //每0.02秒绘制一次画布
  setTimeout("drawFrame()", 20);
};

任何动画的关键都在于调用setTimeout()。例如,要想编写一个方形从上到下坠落的动画,就需要像下面这样用两个全局变量跟踪方形的位置:

//设置方形的初始位置
var squarePosition_y = 0;
var squarePosition_x = 10;

接下来,只要在每次调用drawFrame()函数时改变方形的位置,然后在新位置重绘方形 即可:

function drawFrame() {
  //清除画布
  context.clearRect(0, 0, canvas.width, canvas.height);

  //调用beginPath(),确保不会接着上次绘制的图形绘制
  context.beginPath();

  //在当前位置绘制10像素×10像素的方形
  context.rect(squarePosition_x, squarePosition_y, 10, 10);
  context.lineStyle = "black";
  context.lineWidth = 1;
  context.stroke();

  //向下移动1像素(下一帧将在此位置绘制)
  squarePosition_y += 1;

  //20毫秒后绘制下一帧
  setTimeout("drawFrame()", 20);
}

运行这个例子,就会看到一个方形从画布上方不断下落,最后消失在画布下方。

如果动画更复杂,计算过程也会相应复杂。比如,要模拟重力加速度,或者模拟方形撞击底边后反弹。但“设置计时器、调用绘制函数和重绘整个画布”这个基本过程都是完全相同的。

多物体动画

好了,既然都介绍了动画和交互绘制画布的基本知识,下面我们就更进一步,把这些知识综合起来运用到一个例子中。图10展示了一个测试页面,其中有多个下落和弹跳的球。这个例子使用了上一节用到的setTimeout()方法,而此次绘制代码必须支持无数个下落的小球。
 

在这个测试页面中,可以添加任意多个球,可以选择球的大小(默认半径为15像素),还可以打开连线功能。添加每个球之后,这个球就会独立运动,加速向下坠落,直至碰到画布底边弹回来

动画的性能问题

由于绘制速度很快,因此与基本的绘图操作相比,动画对画布的要求要高得多。但出人意料的是,画布并没有反应迟钝。这是因为现代浏览器都使用了硬件加速等性能增强技术,把图形处理工作转移给了显卡,从而节省了CPU。即使JavaScript不是现有最快的语言,但仍然可以利用它来创造出复杂、高速的动画,甚至是实时电子游戏——只要有脚本和画布即可。
然而,对于移动设备(比如iPhone或Android手机)来说,由于能力不足,性能就是一个问题了。测试表明,在桌面浏览器中运行速度达每秒60帧的动画,在智能手机中最高才能达到每秒10帧,因此,要是想为手机用户开发应用,一定要尽早测试并准备牺牲一些夺人眼目的动画效果,从而确保应用运行流畅。
 
要管理这些球,需要用到自定义对象。只不过现在需要记录很多球对象,而每个球对象不仅要有位置(属性x和y),还要有速度(属性dx和dy):

//下面就是用于表示球的所有细节的Ball函数
function Ball(x, y, dx, dy, radius) {
  this.x = x;
  this.y = y;
  this.dx = dx;
  this.dy = dy;
  this.radius = radius;
  this.color = "red";
}

//这个数组用于保存画布上出现的所有球
var balls = [];

注意 用数学书里的说法,dx就是x改变的速度,而dy就是y改变的速度。因此,随着球的下落,每一帧x都会增加dx,而y都会增加dy。

用户单击Add Ball按钮后,几行代码会执行:创建一个新的ball对象并将其保存在balls数组中:

function addBall() {
  //取得用户设定的大小
  var radius = parseFloat(document.getElementById("ballSize").value);

  //创建新的ball对象
  var ball = new Ball(50,50,1,1,radius);

  //将其保存在balls数组中
  balls.push(ball);
}

Clear Canvas按钮的任务恰恰相反——清空balls数组:

function clearBalls() {
  //删除所有球对象
  balls = [];
}

可是,addBall()和clearBalls()函数实际上都不会绘制图形;它们都没有调用绘制函数。实际上,调用drawFrame()函数的代码会在页面加载后计时执行,每隔20毫秒就重绘一次画布:

var canvas;
var context;

window.onload = function() {
  canvas = document.getElementById("canvas");
  context = canvas.getContext("2d");

  //每20毫秒重绘一次
  setTimeout("drawFrame()", 20);
};

drawFrame()函数是这个例子的关键所在,它不仅负责在画布上绘制所有球,而且还要计算每个球的当前位置和速度。为此,drawFrame()函数使用了一些计算方法模拟真实的运动。比如,坠落时加速,而反弹时减速。下面就来看看它的完整代码:

function drawFrame()   //清除画布
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.beginPath();

  //循环所有球
  for(var i=0; i<balls.length; i++) {
    //把每个球移动到新位置
    var ball = balls[i];
    ball.x += ball.dx;
    ball.y += ball.dy;

    //添加重力作用的效果,让球加速下落
    if ((ball.y) < canvas.height) ball.dy += 0.22;

    //添加摩擦力作用的效果,减慢左右移动速度
    ball.dx = ball.dx * 0.998;

    //如果球碰到某一边,就反弹回来
    if ((ball.x + ball.radius > canvas.width) || (ball.x - ball.radius < 0)) {
      ball.dx = -ball.dx;
    }

    //如果球碰到底部,反弹回来,但慢慢地减速
    if ((ball.y + ball.radius > canvas.height) || (ball.y - ball.radius < 0))
    {
      ball.dy = -ball.dy*0.96;
    }

    //检测用户是否选择了连线功能
    if (!document.getElementById("connectedBalls").checked) {
      context.beginPath();
      context.fillStyle = ball.fillColor;
    }
    else {
      context.fillStyle = "white";
    }

    //绘制球
    context.arc(ball.x, ball.y, ball.radius, 0, Math.PI*2);
    context.lineWidth = 1;
    context.fill();
    context.stroke();
  }

  //20毫秒后绘制下一帧
  setTimeout("drawFrame()", 20);
}

一下子看到这么多代码,是不是有点害怕?别紧张,整体流程并没有变。以上代码执行了下列任务:

清除画布;
循环球的数组;
调整每个球的位置和速度;
绘制每个球;
调用setTimeout()以便每隔20毫秒就执行一次drawFrame()函数。

其中第3步相对最复杂,因为球的属性是在这一步改变的。根据要实现的效果,这里的代码可能比现在还要复杂很多倍。渐进地、自然地运行非常难模仿,因此往往需要很多数学计算。

实例:迷宫游戏

到目前为止,我们已经学习了针对画布编程的基本技术,学习了画布的交互功能和动画效果。运用这些基本的技术,不仅仅能绘图,而且可以实现完整的应用,比如游戏或Flash风格的迷你应用等。

图11展示了一个更有挑战性的例子,这个例子利用了迄今所学,包括两个概念。实际上,这是一个简单的游戏,让用户引导一个可爱的小笑脸图标走出迷宫。用户按下方向键时,笑脸图标会沿相应方向移动(动画),遇到墙时(碰撞检测)就会停下来。

引导笑脸走出迷宫。对于用户来说,这是个好玩的游戏。对于开发者来说,可以借以熟悉HTML5的Canvas和JavaScript编程技巧

当然,一分耕耘一分收获。要想利用画布实现这种高级应用,就得多编写很多代码。接下来的几节会详细介绍这个例子的开发过程,不过在此之前,建议你多学点JavaScript备用。

布置迷宫

在一切发生之前,需要在页面中设置画布。当然可以手工绘制迷宫的线条或矩形,但这样就需要编写很多代码。手工编写这些代码极其烦琐。你得在大脑中想象一个迷宫,然后再用独立的绘图操作绘制每一堵墙。要是你真打算这样做,应该使用能够自动创建绘图代码的工具。例如,可以在Adobe Illustrator里绘图,然后使用插件导出画布代码。

另一种思路是选择一幅迷宫图片,把整幅图绘制到画布上。这个办法就简单多了,因为在网上可以找到很多能生成迷宫的免费页面。找到某个页面后,设置一些参数(如迷宫大小、形状、颜色、密度和复杂性),页面就能创建一个可下载的图片。(想试试?用Google搜索maze generator。)

我们这个例子使用迷宫图片。当页面加载时,它会取得一张图片(名为maze.png),然后把它绘制到画布上。以下就是实现上述过程的代码:

//定义全局变量,保存画布及绘图上下文
var canvas;
var context;

window.onload = function() {
  //设置画布
  canvas = document.getElementById("canvas");
  context = canvas.getContext("2d");
  //绘制迷宫背景
  drawMaze("maze.png", 268, 5);

  //当用户按下键盘上的键时,运行processKey()函数
  window.onkeydown = processKey;
};

以上代码并没有自己绘制迷宫背景,而是把这个任务交给了另一个名为drawMaze()的函数。

由于绘制迷宫的是一个独立的函数,因此就可以不局限于只绘制一个迷宫。只要在调用drawMaze()时传入迷宫图片的文件名以及笑脸的起始位置,就可以加载任何迷宫图片。下面就是绘制迷宫的drawMaze()函数:

//记录笑脸图标的当前位置
var x = 0;
var y = 0;

function drawMaze(mazeFile, startingX, startingY) {
  //加载迷宫图片
  imgMaze = new Image();
  imgMaze.onload = function() {
    //调整画布大小以适应迷宫图片
    canvas.width = imgMaze.width;
    canvas.height = imgMaze.height;

    //绘制迷宫
    var imgFace = document.getElementById("face");
    context.drawImage(imgMaze, 0,0);

    //绘制笑脸
    x = startingX;
    y = startingY;

    context.drawImage(imgFace, x, y);
    context.stroke();

    //10毫秒后绘制下一帧
    setTimeout("drawFrame()", 10);
  };
  imgMaze.src = mazeFile;
}

首先,定义一个处理图片onLoad事件并在图片加载完毕后绘制迷宫的函数。其次,它设置了图片对象的src属性,这样就会加载图片并在加载完毕后触发事件处理函数。与从隐藏的<img>元素中取出图片相比,这个两步方法稍微复杂那么一点,但为了让函数足够灵活,可以加载任意迷宫图片,就必须采取这种方法。

加载完迷宫图片后,代码会根据图片大小调整画布的大小,把笑脸图标放到正确的位置上,然后绘制笑脸图标。最后,调用setTimeout()开始绘制动画帧。

让笑脸动起来

在用户按下键盘上的方向键时,笑脸开始移动。比如,按向下键,笑脸就会一直向下移动,不是碰到障碍或用户又按了其他方向键,就不会停下来。

为此,我们在代码中需要使用两个全局变量记录笑脸的速度。换句话说,就是记录笑脸在x和y轴方向上每一帧要移动多少像素。这两个变量就是dx和dy,与上一节弹跳球例子中的一样。区别在于,这个例子不会用到数组,因为只有一个笑脸图标:

var dx = 0;
var dy = 0;

用户按下键盘上的键时,画布就会调用processKey()函数。然后,该函数检查用户按下的是不是方向键,然后据以调整笑脸的速度。为了检测方向键,要用已知的值与用户按下键的键码进行比较。比如,38是向上键的键码。processKey()函数会忽略除方向键之外的按键:

function processKey(e) {
  //如果笑脸在移动,停止
  dx = 0;
  dy = 0;

  //按下了向上键,向上移动
  if (e.keyCode == 38) {
    dy = -1;
  }

  //按下了向下键,向下移动
  if (e.keyCode == 40) {
    dy = 1;
  }

  //按下了向左键,向左移动
  if (e.keyCode == 37) {
    dx = -1;
  }

  //按下了向右键,向右移动
  if (e.keyCode == 39) {
    dx = 1;
  }
}

从代码中可见,processKey()函数并不改变笑脸的当前位置,也没有绘制笑脸。在调用了drawFrame()函数之后,每隔10毫秒就会执行一次这种检测任务。

接下来看drawFrame()函数,这个函数的代码很好理解,只是会涉及很多细节。这个函数执行几个任务,首先是检测笑脸是否正在哪个方向上移动。如果不是,则什么也不必做:

function drawFrame() {
  if (dx != 0 || dy != 0) {

如果笑脸在移动,drawFrame()会在当前笑脸的位置绘制一块黄色背景(用于创造“痕迹”感),然后把笑脸移动到下一个位置:

    context.beginPath();
    context.fillStyle = "rgb(254,244,207)";
    context.rect(x, y, 15, 15);
    context.fill()

    //增大位置值
    x += dx;
    y += dy;

接下来,调用checkForCollision()函数,检查新位置是否与障碍物冲突。(下一节将介绍这个碰撞检测函数的代码。)如果新位置无效,说明笑脸碰到了墙,代码必须将其放回上一位置并停止移动它:

    if (checkForCollision()) {
      x -= dx;
      y -= dy;
      dx = 0;
      dy = 0;
    }

到这里就可以绘制笑脸了,以下就是代码:

    var imgFace = document.getElementById("face");
    context.drawImage(imgFace, x, y);

然后,检测笑脸是否到达迷宫底部(完成了游戏)。如果是,则显示一个消息框:

    if (y > (canvas.height - 17)) {
      alert("You win!");
      return;
    }
  }

如果没有,则通过setTimeout()设置在10毫秒之后再次调用drawFrame()方法:

  //10毫秒后绘制下一帧
  setTimeout("drawFrame()", 10);
}

除了checkForCollision()函数,这个例子的代码就都介绍完了。下面我们就来介绍用于碰撞检测的创新逻辑。

基于像素颜色的碰撞检测

本文前面曾讨论过基于数学计算来实现碰撞检测。除此之外,还有另一种手段。那就是不检测已经绘制了哪些对象,而是取得像素块,检测它们的颜色。很多情况下,这种手段更简单,因为它不涉及全部对象,也不必编写图形记录代码。然而,这种手段只适合能明确判断颜色的场合。

注意 对于迷宫游戏来说,基于像素颜色进行碰撞检测是最理想的手段。利用像素颜色,可以确定笑脸什么时候碰到黑色的墙。如果不使用这种技术,那么就必须将迷宫的信息保存在内存中,然后再确定笑脸的当前坐标是否与迷宫中的某面墙重叠。

能够基于像素颜色进行碰撞检测的关键,就在于画布支持对个别像素(也就是组成每张图片的小点)的操作。绘图上下文为操作像素提供了三个方法:getImageData()、putImageData()和createImageData()。其中,getImageData()用于从矩形区域中取得一个像素块,然后再检测这些像素(我们的迷宫游戏中使用了这个方法)。可以修改像素使用putImageData()并将它们回写到画布。最后,createImageData()用于在内存中创建新的、空的像素块,以便你根据自己的想法自定义其中的像素,然后使用putImageData()把它们回写到画布上。

为了深入地理解这几个操作像素的方法,来看下面这行代码。这行代码首先从当前画布上取得一个100像素×50像素大的像素块,使用的是getImageData()方法:

//取得像素的起点为(0,0),向右拓展100像素,向下拓展50像素
var imageData = context.getImageData(0, 0, 100, 50);

然后,再通过data属性取得一个包含图像数据的数值数组:

var pixels = imageData.data;

可能有读者会猜测每个像素都用数组中的一个数值表示。要是那么简单就好了!实际上,每个像素是用4个数值来表示的,前三个分别表示红、绿、蓝,第四个表示不透明度(alpha值)。因此,要检测每个像素,必须四个一组四个一组地遍历这个数组,如下所示:

//遍历每个像素,反转其颜色
for (var i = 0, n = pixels.length; i < n; i += 4) {

//取得每个像素的数据
  var red = pixels[i];
  var green = pixels[i+1];
  var blue = pixels[i+2];
  var alpha = pixels[i+3];

  //反转颜色
  pixels[i] = 255  red;
  pixels[i+1] = 255  green;
  pixels[i+2] = 255  blue;
}

每个数值的范围是0~255。上面的代码使用了最简单的图像操作技术——反转颜色。如果反转的是一张照片,那么结果就像看到其负片一样。

为了看到反转颜色后的效果,可以把修改后的像素回写到画布中原来的位置上(当然,绘制到任何地方都易如反掌):

context.putImageData(imageData, 0, 0);

能够操作每个像素,当然为我们控制画布提供了很多可能性。但是,操作像素也有缺点,主要是操作速度慢,而且一般画布中包含的像素数目都十分巨大。如果取得一大块图片数据,可能就需要遍历几万个像素。如果你觉得画直线或画曲线都很烦人,那么手工处理一个个像素只会更令人生厌。

但是,操作像素能够解决其他手段解决不了的问题。比如,通过像素操作可以方便地绘制出分形图像,或者实现Photoshop风格的图片滤镜。而在我们的迷宫游戏中,通过像素操作可以用简单的代码来确定笑脸图标的走向,判断它是否碰到了墙。以下就是处理这个任务的checkForCollision()函数的代码:

function checkForCollision() {
  //取得笑脸所在的像素块,再稍微扩展一点
  var imgData = context.getImageData(x-1, y-1, 15+2, 15+2);
  var pixels = imgData.data;

  //检测其中的像素
  for (var i = 0; n = pixels.length, i < n; i += 4) {
    var red = pixels[i];
    var green = pixels[i+1];
    var blue = pixels[i+2];
    var alpha = pixels[i+3];
    //检测黑色的墙(如果检测到了,就说明撞墙了)
    if (red == 0 && green == 0 && blue == 0) {
      return true;
    }
    //检测灰色的边(如果检测到了,就说明撞墙了)
    if (red == 169 && green == 169 && blue == 169) {
      return true;
    }
  }
  //没有碰到墙
  return false;
}

参考文档

HTML5秘籍 第7章 高级Canvas技术