了解canvas

背景

canvas是为了解决页面只能显示静态图片而出现的一种可以使用JavaScript绘制的HTML标签,它可以接受两个参数width和height(原来有三个,还有一个moz-opaque控制透明度,已经废弃了)

注意事项:不同于img标签的自闭和,canvas必须要有闭合标签;能直接在canvas标签上设置宽高尽量直接在标签属性设置宽高,其次可以通过js来设置,尽量不要通过css样式来设置宽高,可能会出现拉伸等情况,这一点MDN也给出了说明

注意: 如果你绘制出来的图像是扭曲的, 尝试用width和height属性为<canvas>明确规定宽高,而不是使用CSS。

除了canvas之外你可能还会听过svg,svg是一种使用xml定义的矢量图,而canvas是使用JavaScript控制绘制出来的位图

1487709-20190809150321714-50339721

由于主题是canvas,所以这里我们之说canvas,不聊svg

用途

canvas最常用的功能是用来绘制图表,比如我们常用的ECharts图标库底层就是使用的canvas,我手上最近的一个项目中就用到了ECharts

QQ截图20210420183619-3

这是他的DOM文档体现

QQ截图20210420183634-4

由于是通过JavaScript驱动绘制的,所以数据都是可以动态传入的,这是静态图片(jpg/png……)所无法比拟的

此外canvas可以用来制作游戏,这是我以前玩过的魔方游戏(虽然体验完全不如实体)

QQ截图20210420184354-5

还可以用来做活动页面,很多商家都会用这种形式来做营销活动

QQ截图20210420184651-6

你可能还会再某些博客中看到这样的特效,他也是canvas实现的

QQ截图20210420185247-7

基本用法

收先需要在HTML文档中声明canvas标签,标签可以添加后备内容,以防止浏览器不兼容canvas,后备内容可以是一串文本、一张图片或者是动态提娜佳的内容

<canvas id="canvas">
    很遗憾你的浏览器不支持canvas
</canvas>

<canvas id="canvas">
    <img src="./img.png" />
</canvas>

在使用canvas之前,我们需要获取canvas的上下文

const canvas = document.getElementById('canvas') 
// 标注id属性的元素会自动创建全居变量,可以直接使用id操作,但是不推荐
const context = canvas.getContext('2d')

上下文的类型有三种,分别是

  • 2d(本小册所有的示例都是 2d 的):代表一个二维渲染上下文
  • webgl(或”experimental-webgl”):代表一个三维渲染上下文
  • webgl2(或”experimental-webgl2”):代表一个三维渲染上下文;这种情况下只能在浏览器中实现 WebGL 版本2 (OpenGL ES 3.0)

在获取路径之后,我们就可以通过上下文的api来进行绘制路径了,比如你可以使用context.arc(x, y, r,angle1, angle2, direction)来绘制一个圆,其中参数的含义为:

  • x:圆心x坐标
  • y:圆心y坐标
  • r:半径
  • angle1:起始角度,默认水平向右,也就是三点钟
  • angle2:结束角度,从开始角度旋转的度数
  • direction:旋转方向,true是逆时针,false是顺时针
context.beginPath();       // 起始一条路径,或重置当前路径
context.arc(100, 100, 50, 0, Math.PI * 2, true);  // 曲线
context.closePath();       // 闭合曲线
context.fillStyle = 'rgb(0,0,0)'; // 设置填充样式
context.fill();            // 填充

效果如下

Dingtalk_20210421122914-8

这一节我们大体介绍了canvas的用途,下一节开始我们就开始讲解canvas的具体使用

canvasAPI(上)

上一节最后我们说了一个绘制曲线的API——arc(那个API并不是专门用来绘制圆形,只是使用曲线可以绘制圆形),他还有一个兄弟——arcTo(x1, y1, x2, y2, r),根据当前描点与给定的控制点1连接的直线,和控制点1与控制点2连接的直线,作为使用指定半径的圆的切线,画出两条切线之间的弧线路径

  • x1:第一个控制点x坐标
  • y1:第一个控制点y坐标
  • x2:第二个控制点x坐标
  • y2:第二个控制点y坐标
  • r:曲线半径

示意图如下

QQ截图20210421171144-9

路径

先来熟悉一下必用的路径相关的API

  • beginPath():新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
  • closePath():闭合路径之后图形绘制命令又重新指向到上下文中。
  • stroke():通过线条来绘制图形轮廓。
  • fill():通过填充路径的内容区域生成实心的图形。

接下来从点开始

moveTo(x, y)——移动当前描点到指定坐标

然后是线

lineTo(x, y)——绘制从当前描点到指定点的直线

到这里我们可以来做一个小练习,绘制一个圆角矩形的一个角

首先画一条线,然后画一条曲线,然后再画一条直线

ctx.beginPath();
ctx.moveTo(20, 20)
ctx.lineTo(60, 20)
ctx.arcTo(120, 20, 120, 40, 50)
ctx.lineTo(120, 80)
ctx.stroke();

这里在绘制曲线的时候要需要计算曲线半径,曲线半径设置错误之后效果体现不出来了

QQ截图20210421172354-10

绘制矩形,有三个API与绘制矩形有关,分别是:

  • fillRect(x, y, width, height)绘制一个填充的矩形
  • strokeRect(x, y, width, height)绘制一个矩形的边框
  • clearRect(x, y, width, height)清除指定矩形区域,让清除部分完全透明。
  • rect(x, y, width, height)绘制一个左上角坐标为(x,y),宽高为width以及height的矩形。

参数:x——矩形的起点x坐标,y——矩形的起点y坐标,width——矩形的宽,height——矩形的高

ctx.fillRect(25, 25, 100, 100);
ctx.clearRect(45, 45, 60, 60);
ctx.strokeRect(50, 50, 50, 50);

QQ截图20210421175716-11

注意:重头戏要来了——二次贝塞尔曲线和三次贝塞尔曲线

如果你使用过PhotoShop的钢笔工具,你一定不会陌生贝塞尔曲线,放一个简书的链接,如果你对数学感兴趣可以看看

  • quadraticCurveTo(cp1x, cp1y, x, y)绘制二次贝塞尔曲线,cp1x,cp1y为一个控制点,x,y为结束点。
  • bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)绘制三次贝塞尔曲线,cp1x,cp1y为控制点一,cp2x,cp2y为控制点二,x,y为结束点。

这里再贴一张MDN的图片,红点代表控制点,蓝点代表起始点和结束点

Canvas_curves-12

总之,贝塞尔曲线用于绘制不规则曲线,当然这可能需要你有耐心去计算,因为绘制的曲线没有给我们提供直接的视觉反馈

有关路径的API基本就是这些,但是在绘制过程中不可能一个路径一个路径地去绘制,canvas也提供了路径服用的方法——Path2D

Path2D是一个构造函数,可以用来克隆路径,比如

const path1 = new Path2D()
path1.rect(10, 10, 100,100)

const path2 = new Path2D(path1)
path2.arc(225, 55, 55, 0, 2 * Math.PI)

ctx.fill(path2);

创建一个路径path1,绘制一个矩形,然后创建一个path2克隆path1,在此基础上绘制一个圆,最后只渲染path2,可以看到path1的内容也有了

QQ截图20210422112744-13

样式和颜色

  • fillStyle = color设置图形的填充颜色。
  • strokeStyle = color设置图形轮廓的颜色。

color的值跟css色彩的值是一样的,可以是16进制色彩、色彩英文单词、RGB以及RGBA,如

fillStyle = 'red'
fillStyle = '#409eff'
fillStyle = 'rgb(255, 255, 0)'
fillStyle = 'rgba(255, 255, 255, 0.1)'

rgba的最后一个参数是透明度,除此之外还提供了专门的API来设置透明度,globalAlpha 取值介于0(全透)-1(不透)之间,设置globalAlpha 之后透明度可以叠加,叠加越多透明度越低。

下面的API可以用来设置线条的样式

  • lineWidth = value设置线条宽度。
  • lineCap = type设置线条末端样式。取值有butt(平齐),round(超出圆头)和 square(超出方形)。默认是 butt
  • lineJoin = type设定线条与线条间接合处的样式。
  • miterLimit = value限制当两条线相交时交接处最大长度;所谓交接处长度(斜接长度)是指线条交接处内角顶点到外角顶点的长度。

  • getLineDash()返回一个包含当前虚线样式,长度为非负偶数的数组。

  • setLineDash(segments)设置当前虚线样式。接受一个数组,来指定线段与间隙的交替
  • lineDashOffset = value设置虚线样式的起始偏移量。

渐变作为一种比较炫酷的样式当然不能少了

  • createLinearGradient(x1, y1, x2, y2)createLinearGradient 方法接受 4 个参数,表示渐变的起点 (x1,y1) 与终点 (x2,y2)。
  • createRadialGradient(x1, y1, r1, x2, y2, r2)createRadialGradient 方法接受 6 个参数,前三个定义一个以 (x1,y1) 为原点,半径为 r1 的圆,后三个参数则定义另一个以 (x2,y2) 为原点,半径为 r2 的圆

createLinearGradient 是线性渐变,createRadialGradient 是圆形渐变

在创建渐变对象之后就可以给他上色了,使用gradient.addColorStop(position, color)方法给渐变对象上色,posotion是一个介于0-1的值,0.5表示在正中心渐变

var lineargradient = ctx.createLinearGradient(0,0,150,150);
lineargradient.addColorStop(0,'white');
lineargradient.addColorStop(1,'black');
ctx.fillStyle = lineargradient
ctx.fillRect(10,10,130,130)

QQ截图20210422164110-14

阴影也是一个非常好看的效果:

  • shadowOffsetX = float:shadowOffsetX 用来设定阴影在 X 轴的延伸距离,不受变换矩阵所影响。负值表示阴影会往左延伸,正值则表示会往右延伸,默认为 0
  • shadowOffsetY = float:shadowOffsetY用来设定阴影在 Y 轴的延伸距离,不受变换矩阵所影响。负值表示阴影会往上,正值则表示会往下,默认为 0
  • shadowBlur = float:shadowBlur 用于设定阴影的模糊程度,默认为 0
  • shadowColor = color:shadowColor 用于设定阴影颜色效果,默认是全透明的黑色。

在我们上一个渐变的基础上加上阴影

ctx.shadowOffsetX = 8;
ctx.shadowOffsetY = 8;
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";

QQ截图20210422164730-15

填充规则fill(),填充我们之前已经用到很多了,他有两个规则,也就是参数nonzero(默认路径内部)和evenodd(路径外部),这是相对于当前路径来说的,在路径嵌套式效果明显

canvasAPI(下)

书接上回

图片

所有的内容都是手动绘制还是有点麻烦,好在canvas支持导入图片,createPattern(image, type),该方法接受两个参数。Image 可以是一个 Image 对象的引用,或者另一个 canvas 对象。Type 必须是下面的字符串值之一:repeatrepeat-xrepeat-yno-repeat

这里引一张我掘金的头像来做示例,要注意这里需要等待图片加载完之后再渲染

// 创建新 image 对象,用作图案
var img = new Image();
img.src = 'https://sf6-ttcdn-tos.pstatp.com/img/user-avatar/01434b575492b00010706d94d5b975aa~300x300.image';
img.onload = function() {

  // 创建图案
  var p = ctx.createPattern(img, 'no-repeat');
  ctx.fillStyle = p;
  ctx.fillRect(0, 0, 150, 150);
}

QQ截图20210422165755-16

文本

图片都可以渲染那文本不也轻轻松松,canvas 提供了两种方法来渲染文本:

  • fillText(text, x, y [, maxWidth])在指定的(x,y)位置填充指定的文本,绘制的最大宽度是可选的.
  • strokeText(text, x, y [, maxWidth])在指定的(x,y)位置绘制文本边框,绘制的最大宽度是可选的.

根据API的语义就可以知道作用,stroke和fill就是我们前面用的描边和填充

文本可以设置字体等样式

  • font = value当前我们用来绘制文本的样式. 这个字符串使用和 CSS font 属性相同的语法. 默认的字体是 10px sans-serif
  • textAlign = value文本对齐选项. 可选的值包括:start, end, left, right or center. 默认值是 start
  • textBaseline = value基线对齐选项. 可选的值包括:top, hanging, middle, alphabetic, ideographic, bottom。默认值是 alphabetic。`
  • direction = value文本方向。可能的值包括:ltr,rtl,inherit。默认值是inherit。`

这些跟css的样式是基本一致的

移动和缩放

既然canvas是为了解决动态图片的问题而设计的,那么移动和缩放可以说是动画的基础

移动很简单,使用translate(x, y)来控制移动,x和y分别就是x轴和y轴的偏移量;

旋转使用rotate(angle),接受一个角度参数,沿顺时针方向,旋转点默认是(0,0)点,除非我们使用translate来移动它(比如直接ctx.translate(10, 10)即可);

接下来是缩放,scale(x, y),x和y分别是x轴和y轴方向的所当比例,1为不变,<1是缩小,反之放大;如果出现负数,就是按照所在的轴进行镜像翻转

如果你熟悉css的transform,那这里应该也很简单,他们很相似。

蒙版

关于蒙版内容,放个文档在这里吧,说起来太费劲了,如果你用过PhotoShop,你可能会比较好理解

动画

关于动画可以使用的方法有三种setTimeout、setInterval、requestAnimationFrame,前两个就是你熟悉的那两个定时器,requestAnimationFrame诉浏览器你希望执行一个动画,并在重绘之前,请求浏览器执行一个特定的函数来更新动画。

动画的大体过程如下:

  • 清除上一帧,可以使用的方法很多,最简单的就是清空矩形,长宽为canvas的长宽
  • 保存canvas状态(样式,变形之类的),ctx.save()
  • 绘制图形
  • 恢复canvas状态,ctx.restore()

我们这里来做一个小的示例,矩形下落

ctx.beginPath();
let h = 0
function down() {
  h += 1
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillRect(0, h, 100, 100)
  window.requestAnimationFrame(down)
}
down()

效果如下

QQ录屏20210422174621-17

那么关于canvas的API我们也已经了解的差不多了,下一节,我们来实现一个五子棋的小游戏

canvas实战——五子棋

之前三节我们铺垫了canvas的相关知识,这一节我们来“落地”,实现一个五子棋的小游戏

开始之前,我们先想一下要做什么:

  1. 首先我们要绘制一个棋盘
  2. 然后要有黑子和白子
  3. 每次落子时判断当前位置是否已经落子,“米”字型方向上是否有五个子,如果满足条件,当前落子一方获胜

下面马上开始,先做好准备工作,准备一个canvas容器和一个刷新按钮用来重新开盘,然后准备一个结果区显示提示信息

<canvas id="canvas" width="200px" height="200px"></canvas>
<p class="result"></p>
<button onclick="loadPanel(400, 400,30,13)">刷新</button>

棋盘

首先我们来绘制棋盘,这个很简单,循环画直线即可,我们将所有的操作放置在一个函数中,以便重新开局

/**
 * @param w 棋盘宽度
 * @param h 棋盘高度
 * @param cs 格子尺寸
 * @param ps 棋子半径
 */
function loadPanel(w, h, cs, ps) {
  let i, j, k;

  //1)绘制棋盘,边缘应隔开棋子半径的距离
  cs = cs || 16;//默认格子宽高
  ps = ps || 4;//棋子半径
  h = h || w;//高度默认等于宽度

  let el = document.getElementById('canvas');
  el.setAttribute('width', w + 'px');
  el.setAttribute('height', h + 'px');
  let context = el.getContext("2d");
  //计算棋盘分割,向下取整
  let splitX = ~~((w - 2 * ps) / cs), 
      splitY = ~~((h - 2 * ps) / cs);

  //循环划线
  context.translate(ps, ps);
  context.beginPath();
  context.strokeStyle = '#000';
  //垂直线
  for (i = 0; i < splitX + 1; i++) {
    context.moveTo(cs * i, 0);
    context.lineTo(cs * i, splitY * cs);
    context.stroke();
  }
  //水平线
  for (j = 0; j < splitY + 1; j++) {
    context.moveTo(0, cs * j);
    context.lineTo(splitX * cs, cs * j);
    context.stroke();
  }
  context.closePath();
}

// 绘制棋盘
loadPanel(400, 400, 30, 13);

~~作用是向下取整,可以将浮点类型转为整数,字符串类型的数字也可以,但是无法转为数字的结果为0

现在打开浏览器,看一下效果

image-20210422221727005

棋子

棋子的绘制也不难,就是画圆,黑子填充黑色,白子填充白色,然后描个边,相关API之前也有过介绍,我们先在棋盘上绘制两个静态的棋子观察效果

context.beginPath()
context.arc(cs * 0, cs * 0, ps, 2 * Math.PI, false);
context.fillStyle = '#fefefe'
context.fill()
context.stroke();
context.closePath()

context.beginPath()
context.arc(cs * 1, cs * 0, ps, 2 * Math.PI, false);
context.fillStyle = '#000'
context.fill()
context.stroke();
context.closePath()

image-20210422222816357

效果还不错,但是在下棋时需要鼠标点击,这里的棋子渲染是需要鼠标点击事件来触发的,下面来给canvas添加鼠标点击事件,我们需要获取鼠标的点击坐标,然后计算出他应该落在哪个点,还要循环落子,

let user = 0, colors = ['#000', '#fefefe'];
el.addEventListener('click', function (e) {
  let x = e.offsetX,
    y = e.offsetY,
    //计算落子范围
    rx = ~~((x - ps) / cs) + (((x - ps) % cs <= cs / 2) ? 0 : 1),
    ry = ~~((y - ps) / cs) + (((y - ps) % cs <= cs / 2) ? 0 : 1);

  context.beginPath();
  context.arc(cs * rx, cs * ry, ps, 2 * Math.PI, false);
  context.fillStyle = colors[user];
  context.strokeStyle = '#000';
  user ? user = 0 : user = 1;//切换执子者
  context.fill();
  context.stroke();
  context.closePath();
})

现在我们已经完成了50%了

QQ录屏20210422223959

胜方判定

现在进行做事后的一步,判定“米”字方向上是否存在5个同色棋子,我们先来考虑一下答题思路,你先不要往下看,先考虑一下自己的想法

image-20210422224251852

好了,现在不管你想没想出来,我来说一下大概思路,首先定义一个对象来存储落子情况,大概的形式是这样的

{
  '1-1': 0,
  '1-2': 1
}
// key是棋盘位置,value是身份

然后使用分别使用[0,1]、[1, 0]、[1, 1]、[1, -1]表示上下、左右、斜向上、斜向下四个方位,待会会分别拿这几个方位来进行遍历,原理大概是这样的

以[0, 1]为例,在for(let i = 1; i<= 4 ; i++){}中,分别用0和1去乘以i,然后加上当前点击未知的x或者y坐标,就能够遍历,因为i无论是多少乘0都是0,所以x坐标是不会变的,那么y坐标会遍历上边的四个,如果这四个的都是当前落子选手的即判定为赢,如果找不到就向反方向去找,即循环for(let i = -1; i>= -4 ; i–){},如果当前循环结束没有判定胜利再去其他方向重读此步骤

image-20210422231326458

计算出坐标来之后在存储落子的对象中寻找落子者,如果是当前落子者就累加,如果遍历结束满足获胜条件游戏结束

let user = 0, colors = ['#000', '#fefefe'];
let chks = [[1, 0], [0, 1], [1, 1], [1, -1]]; // 四个方向
let pieces = {}; // 记录游戏者落子位置
let successNum = 5;//赢棋标准
let resultEl = document.querySelector('.result');

el.addEventListener('click', function (e) {
  let x = e.offsetX,
    y = e.offsetY,
    //计算落子范围
    rx = ~~((x - ps) / cs) + (((x - ps) % cs <= cs / 2) ? 0 : 1),
    ry = ~~((y - ps) / cs) + (((y - ps) % cs <= cs / 2) ? 0 : 1);

  context.beginPath();
  context.arc(cs * rx, cs * ry, ps, 2 * Math.PI, false);
  context.fillStyle = colors[user];
  context.strokeStyle = '#000';
  user ? user = 0 : user = 1;//切换执子者
  context.fill();
  context.stroke();
  context.closePath();


  piece = pieces[rx + '-' + ry] = user;

  for (j = 0; j < chks.length; j++) {
    let num = 1, chk = chks[j];
    for (i = 1; i <= 4; i++) {
      if (pieces[(rx + chk[0] * i) + '-' + (ry + chk[1] * i)] == piece) {
        num++
      } else {
        for (i = -1; i >= -4; i--) {
          if (pieces[(rx + chk[0] * i) + '-' + (ry + chk[1] * i)] == piece) {
            num++
          }
        }
        break
      }
    }
    if (num == successNum) {
      status = false
      resultEl.innerHTML = ['白', '黑'][user] + '方赢';
      break;
    }
  }
})

然后就完事了

QQ录屏20210422232143

但是现在还有很多逻辑问题没有解决,游戏结束之后限制不可继续落子,点击位置如果已经有过落子则不可继续落子等这些就当作业留给你吧,其实不难,好好考虑考虑吧

我最终的实现效果如下

QQ录屏20210422232851


前端小白