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();
}
绘制图片
一般绘制图片需要允许跨域,若图片在服务端设置为不允许跨域,则前端无法将图片绘制在 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);
}