canvas非正式笔记:能用的几个例子

1,321 阅读6分钟

canvas绘图比较常见,尤其是前端在一些简单图片处理中用到的比较频繁。其中有一些优秀的 js 库中也有使用,如:three.js 、qrcode、html2canvas、Chart.js,因此需要对 canvas 的基础知识有一定的了解。

这里例举几个有用的例子,方便以后在canvas绘图的时候做参考。

canvas 基础绘图

绘制一条直线

// 拿到画板
const canvas = document.getElementById("canvas");
canvas.width = 1000;
canvas.height = 1000;

// 拿到上下文
const context = canvas.getContext("2d");

if (context) {
  // 设置线条的颜色
  context.strokeStyle = "red";
  // 设置线条的宽度
  context.lineWidth = 5;

  // 绘制直线
  context.beginPath();
  // 起点
  context.moveTo(200, 200);
  // 终点
  context.lineTo(500, 200);
  context.closePath();
  context.stroke();
}

参考:HTML Canvas 参考手册

绘制图片

一般绘制图片需要允许跨域,若图片在服务端设置为不允许跨域,则前端无法将图片绘制在 canvas 上,因此一般绘制图片的情况,都要考虑要绘制的图片是否会有跨域问题。 对于那些图片地址不确定的情况应该谨慎使用 canvas 绘图。

1. 图片加载

export function loadImg(src: string) {
  return new Promise((resolve, reject) => {
    if (!src) {
      reject(Error("no src"));
      return;
    }
    const img = new Image();
    // 设置跨域
    img.setAttribute("crossOrigin", "anonymous");
    // 具体动画是否加载完成
    img.addEventListener("load", () => {
      resolve(img);
    });
    img.addEventListener("error", (e) => {
      reject(e);
    });
    img.src = src;
  });
}

2. 绘制图片

context.drawImage(img,x,y,width,height); context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);

export async function getBase64(imgUrl: string, width: number, height: number) {
  if (!imgUrl) {
    return "";
  }
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  canvas.width = width;
  canvas.height = height;
  if (context) {
    const img: any = await loadImg(imgUrl);
    context.drawImage(img, 0, 0, canvas.width, canvas.height);
  }
  // 导出base64的图片
  return canvas.toDataURL("image/png", 1);
}

这里的width和height是画布的宽高,宽高必须要大于0。

混合模式

canvas 混合模式是 canvas 最重要要功能之一,搭配 save/store 使用可以满足大部分图片处理需求。

context.globalCompositeOperation

选项解释
source-over在目标图像上显示源图像
source-atop在目标图像顶部显示源图像。源图像位于目标图像之外的部分是不可见的
source-in在目标图像中显示源图像。只有目标图像内的源图像部分会显示,目标图像是透明的
source-out在目标图像之外显示源图像。只会显示目标图像之外源图像部分,目标图像是透明的
destination-over在源图像上方显示目标图像
destination-atop在源图像顶部显示目标图像。源图像之外的目标图像部分不会被显示。
destination-in在源图像中显示目标图像。只有源图像内的目标图像部分会被显示,源图像是透明的
destination-out在源图像外显示目标图像。只有源图像外的目标图像部分会被显示,源图像是透明的
lighter源图像 + 目标图像
copy显示源图像
xor异或

复杂例子:

折线图的绘制

  • 数据预处理:

    • 计算区域颜色
    • 最小值添加数据抖动(抖动部分的数值不参与缩放)
    • 数据区域分割
  • 初始化

    • 计算最大值、最小值

    • 放大倍数:影响图片清晰度,一般设置为 window.devicePixelRatio

    • x/y 轴坐标刻度:

      stepX = (width - padding.left - padding.right) / (datasource.length - 1)

      stepY = (height - padding.top - padding.bottom) / max

    • 当前点的坐标:用于绘制坐标点,用于显示提示文案

    • 上下左右间距

  • 绘制曲线 使用二次贝塞尔曲线绘制:

/**
 * 绘制quadratic曲线
 */
export function drawQuadraticLines({
  context,
  padding, // 间距
  height, // 高度
  stepX = 0, // x刻度
  stepY = 0, // y刻度
  datasource = [], // 坐标点
  hasStroke = true, // 是否是描线(否则为填充)
}: LineConfig) {
  if (datasource[0]) {
    if (hasStroke) {
      context.moveTo(
        padding.left,
        height -
          datasource[0].props * stepY -
          padding.bottom +
          (datasource[0].offset || 0)
      );
    } else {
      context.lineTo(
        padding.left,
        height -
          datasource[0].props * stepY -
          padding.bottom +
          (datasource[0].offset || 0)
      );
    }
    for (let i = 1; i < datasource.length; i++) {
      const x1 = padding.left + (i - 1) * stepX;
      const y1 =
        height -
        datasource[i - 1].props * stepY -
        padding.bottom +
        (datasource[i - 1].offset || 0);
      const x2 = padding.left + i * stepX;
      const y2 =
        height -
        datasource[i].props * stepY -
        padding.bottom +
        (datasource[i].offset || 0);

      const cx = (x1 + x2) / 2;
      const cy = (y1 + y2) / 2;
      context.quadraticCurveTo(x1, y1, cx, cy);
    }
    context.lineTo(
      padding.left + stepX * (datasource.length - 1),
      height -
        datasource[datasource.length - 1].props * stepY -
        padding.bottom +
        (datasource[datasource.length - 1].offset || 0)
    );
    hasStroke && context.stroke();
  }
}
  • 曲线着色:

    使用矩形区域填充+source-atop 的混合模式,就可以给曲线上色,曲线外的颜色并不会被填充颜色

canvas 应用:二维码生成

使用 qrcode 可以轻松生成二维码:

import qrcode from "qrcode/lib/browser";

/**
 * 获取二维码的参数
 */
export interface CodeParams {
  // 宽度 默认200
  width?: number;
  // 间距 默认4
  margin?: number;
  // 地址
  url: string;
}

export function getCodeBase64(params: CodeParams): Promise<string> {
  const { url, width = 200, margin = 4 } = params;
  return qrcode.toDataURL(url, {
    width,
    margin,
    errorCorrectionLevel: "H",
  });
}

其中 errorCorrectionLevel 表示容错率,容错率越高表示该二维码可被遮挡的面积越大,遮挡后依然能被识别出来,高容错率也意味着二维码就越复杂,因此较小的图片宽高不太适合较高的容错率,这个需要自己取舍。 容错率一般有 L/N/Q/H 四个级别,其中 H 表示容错率最高。

如果要生成个性的二维码,则需要借助 qrcode 在终端生成文字二维码的功能,先将内容转为控制台能显示的二维码,然后再用 canvas 将二维码绘制上去:

import QRCode from "qrcode/lib/server";

const CODE_TYPE = [
  "█", // 上下两个点
  "▄", // 下边的点
  "▀", // 上边的点
  " ", // 空点
];

const code = await QRCode.toString(content, {
  errorCorrectionLevel,
  margin: 0,
  quality,
  type: "txt",
});

const codeList = code.split("\n");
// 二维码的单元格数
const codeLen = codeList[0].length;
// 二维码的单元宽度 width:图片宽度,padding:间距
const unitWidth = Math.floor(width / (padding * 2 + codeLen));

// 二维码的宽度宽度
const codeWidth = unitWidth * codeLen;
// 起始坐标
const startX = Math.floor((width - codeWidth) / 2);
const canvas = document.createElement("canvas");

// 绘制文字环境
const context = canvas.getContext("2d");
// 画布宽度
canvas.width = width;
// 画布高度
canvas.height = width;

// 绘制背景
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);

// 绘制二维码
context.fillStyle = foreColor;

// 绘制码点 pointType 码点类型 pointSize码点大小
const handleDrawUnit = (x, y, w, height, _pointSize = pointSize) =>
  drawUnit(pointType, { x, y, width: w, height }, w * _pointSize, context);

for (let i = 0; i < codeList.length; i++) {
  const codeStr = codeList[i];
  for (let j = 0; j < codeStr.length; j++) {
    // 找到起始点
    const x = startX + j * unitWidth;
    const y = startX + i * 2 * unitWidth;

    const ch = codeStr[j];
    if (CODE_TYPE[0] === ch) {
      handleDrawUnit(x, y + unitWidth, unitWidth, unitWidth);
      handleDrawUnit(x, y, unitWidth, unitWidth);
    } else if (CODE_TYPE[1] === ch) {
      // 下
      handleDrawUnit(x, y + unitWidth, unitWidth, unitWidth);
    } else if (CODE_TYPE[2] === ch) {
      // 上
      handleDrawUnit(x, y, unitWidth, unitWidth);
    }
  }
}
// 绘制码眼 eyeType码眼类型
handleDrawEyes(config, unitWidth, startX, codeLen, eyeType, context);

文字显示的二维码,占位二维码上下两个网格,共有四种类型:

  • “█”:上下两个网格都是实心
  • “▄”:下边的网格是实心
  • “▀”:上边的网格是实心
  • “ ”:上下网格都是空白

绘制二维码,其中有两个重要的参数,就是网格宽度和二维码的边缘间距,其中网格宽度是根据整个图片的宽度计算出来的

码点的绘制

  • 圆点
export function drawCircle(x, y, r, ctx) {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2 * Math.PI);
  ctx.fill();
}

export const drawUnit = (rect, padding, ctx) => {
  const r = Math.floor((rect.width - 2 * padding) / 2);
  drawCircle(rect.x + padding + r, rect.y + padding + r, r, ctx);
};
  • 四角星
export function drawStar(rect, ctx) {
  const w = Math.floor(rect.width / 2);

  const ptA = Point(rect.x, rect.y);
  const ptB = Point(rect.x, rect.y + rect.height);
  const ptC = Point(rect.x + rect.width, rect.y + rect.height); // 下中
  const ptD = Point(rect.x + rect.width, rect.y); // 左中

  ctx.beginPath();

  ctx.moveTo(rect.x + w, rect.y);
  ctx.arc(ptA.x, ptA.y, w, 0, 0.5 * Math.PI);
  ctx.arc(ptB.x, ptB.y, w, 1.5 * Math.PI, 2 * Math.PI);
  ctx.arc(ptC.x, ptC.y, w, Math.PI, 1.5 * Math.PI);
  ctx.arc(ptD.x, ptD.y, w, 0.5 * Math.PI, Math.PI);

  ctx.fill();
}

export const drawUnit = (rect, padding, ctx) =>
  drawStar(
    {
      x: rect.x + padding,
      y: rect.y + padding,
      width: rect.width - 2 * padding,
      height: rect.height - 2 * padding,
    },
    ctx
  );

同理,绘制码眼就是在三个角区域绘制图形,他们的坐标分别是:

【起始坐标:(0,0)空隙偏量:(0,1,1,0)】

【起始坐标:(len-7,0)空隙偏量:(0,0,1,-1)】

【起始坐标:(0,len-7)空隙偏量:(1,1,0,0)】

码眼是一个 7x7 的矩阵,其中码眼和码点之间有一条空隙,码眼和码眼中心点之间也有一条缝隙。 空隙偏量就是码眼的上右下左位置在否存在空隙

绘制码眼前需要清除码眼的区域:

const arr = [
  [0, 0, 0, 1, 1, 0], // 左上 上,右,下,左
  [codeLen - 7, 0, 0, 0, 1, -1], // 右上
  [0, codeLen - 7, -1, 1, 0, 0], // 左下
];

arr.forEach((area, index) => {
  // 背景清除
  context.fillStyle = backgroundColor;
  context.fillRect(
    startX + (area[0] + area[5]) * unitWidth,
    startX + (area[1] + area[2]) * unitWidth,
    (7 + Math.abs(area[3]) + Math.abs(area[5])) * unitWidth,
    (7 + Math.abs(area[2]) + Math.abs(area[4])) * unitWidth
  );

  context.fillStyle = foreColor;
  handleDrawEye(area, index);
});

关于游戏

游戏一般是通过不断刷新游戏画面,然后通过 update-draw 将一个个画面绘制到页面上。

全局通过刷新频率来控制整体的流畅程度,在 update 方法中获取步长(elapsedTime),通过步长来更新偏移量,在 draw 方法中获取 canvas.context, 然后绘制具体的图形

其中,屏幕中可交互的物体称之为精灵(sprite),一个精灵一般拥有位置、区域、层级、动作(animation)等属性。通过更新精灵的位置属性,来改变精灵的位置

动作是由多个动作帧(animate frame)组成,一个动作帧包含一个图片集合和区域(宽高),以及该帧的时长,通过步长来判断当前动作应该展示哪一个动作帧,然后将动作帧配合精灵的位置、区域属性绘制在屏幕上。

一个游戏基本上需要包含这几个元素:资源管理器、交互系统、精灵工厂、音频管理器、UI:界面弹窗、屏幕管理器、以及游戏控制中心,通过游戏控制中心管理界面,然后在游戏界面负责游戏的生命周期

/**
 * 循环
*/
public gameLoop(){

    if(this.screenManager == undefined)return;

    var startTime = new Date().getTime();
    var currTime = startTime;
    var self = this;

    var _time = setInterval(function(){
        if(self.isRunning && self.screenManager){
            var elapsedTime = new Date().getTime() - currTime;
            currTime += elapsedTime;
            // update
            self.update(elapsedTime);
            self.draw(self.screenManager.getContext());
            // screen.update();
        }else{
            clearInterval(_time);
            self.layzilyExit();
        }
    }, this.screenManager.getRefreshRate())
}

protected update(elapsedTime:number){
    var input = this.getInputManager();
    input && input.checkAllActions();
    this.panelMap[this._panelID] && this.panelMap[this._panelID].update(elapsedTime);
}

protected draw(context:CanvasRenderingContext2D){
    this.panelMap[this._panelID] && this.panelMap[this._panelID].draw(context);
}