快速开始
canvas 是 H5 新增加的标签 我们可以通过 JavaScript Canvas API(2D) 或 WebGL API(3D) 绘制图形及图形动画, 现阶段主要学习 2D 相关 API
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>Canvas and SVG</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
padding: 20px;
}
#app {
border: 1px solid #f00;
}
</style>
</head>
<body>
<canvas id="app">Your browser does not support the canvas element.</canvas>
<script src="./index.js" type="module"></script>
</body>
</html>window.onload = function () {
const canvasDom = document.getElementById("app");
// 2d: canvas API 是 CanvasRenderingContext2D 类的实例
// webgl: webgl API 是 WebGLRenderingContext 类的实例
// 加上注释可以让编辑器显示代码提示
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
console.log(ctx instanceof CanvasRenderingContext2D); // true
// 设置宽高属性
// 注意了: 这是设置 canvasDom 的属性, 而不是样式
// 这个dom对象是 HTMLCanvasElement 类的实例, 它本身就有
// width 和 height 属性, 但是它不是 style 的属性
canvasDom.width = window.innerWidth;
canvasDom.height = window.clientHeight;
console.log(ctx.canvas instanceof HTMLCanvasElement); // true
};快速开始:体验用代码画图
window.onload = function () {
function initCanvasDom() {
const canvasDom = document.getElementById("app");
canvasDom.width = window.innerWidth;
canvasDom.height = window.innerHeight;
return canvasDom;
}
// 0. 初始化dom & 获取 canvas上下文
const canvasDom = initCanvasDom();
const ctx = canvasDom.getContext("2d");
// 1.将 "画笔" 放到指定的位置
ctx.moveTo(50, 50);
// 2.用 "画笔" 画一条线到指定的位置
ctx.lineTo(100, 50);
ctx.stroke();
};思考 🤔
为什么不应该用 css 的方式去设置 canvas 的宽度和高度
注意我说到的是 不应该 而非 不可以, 实际上是可以通过 css, 去强行改变 canvas 元素显示的宽度和高度的, 既然可以改变那为什么不应该使用 css 设置宽高呢? 究其原因: 主要是因为 canvas 元素有 2 个尺寸
- 内在尺寸: 或者叫绘图的缓冲区尺寸, 可以通过
HTMLCanvasElement类的width和height属性来设置 - 显示尺寸: 展示在浏览器中的尺寸, 默认情况下, 是和
HTMLCanvasElement类的width和height相同, 如果非要不同就会拉伸/缩放
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 200;
canvasDom.height = 200;
return canvasDom;
}
function drawLine(canvasDom) {
const ctx = canvasDom.getContext("2d");
ctx.moveTo(50, 50);
ctx.lineTo(100, 50);
ctx.stroke();
}
drawLine(initCanvasDom("app"));
drawLine(initCanvasDom("app2"));
};<!doctype html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>Canvas and SVG</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
padding: 20px;
}
#app {
background: #f00;
}
#app2 {
background: #f00;
width: 400px;
height: 400px;
}
</style>
</head>
<body>
<canvas id="app">Your browser does not support the canvas element.</canvas>
<canvas id="app2">Your browser does not support the canvas element.</canvas>
<script src="./index.js" type="module"></script>
</body>
</html>
画线条
绘制线条并设置样式
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
// 设置线条的粗细(高度)
ctx.lineWidth = 10;
// 设置线条的样式
ctx.strokeStyle = "#f00";
// 设置线条两端的样式
ctx.lineCap = "round"; // butt(默认)/round/square
// 设置线条链接处的样式(需要两条线才能看出效果来)
ctx.lineJoin = "round"; // miter(默认)/round/bevel
ctx.moveTo(50, 50);
ctx.lineTo(200, 50);
ctx.stroke();
};
绘制多个线条
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
// line1
ctx.moveTo(50, 50);
ctx.lineTo(200, 50);
ctx.lineWidth = 10;
ctx.strokeStyle = "#f00";
ctx.stroke();
// line2
// 如果没有调用 beginPath 开启新的绘制路径(拿起另外一支笔)
// 那么前面 line1 的样式会被后面设置的样式给覆盖掉
// 因此为了避免影响, 应该每次划线之前拿起新的一支笔(调用: beginPath)
ctx.beginPath();
ctx.moveTo(50, 100);
ctx.lineTo(300, 100);
ctx.lineWidth = 20;
ctx.strokeStyle = "#0f0";
ctx.stroke();
};利用线条绘制一些简单图形
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
function drawLines(startPos = { x: 0, y: 0 }, linesPos = [], lineColor = "#f00", lineWidth = 10) {
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
for (let i = 0; i < linesPos.length; i++) {
const linePos = linesPos[i];
ctx.lineTo(linePos.x, linePos.y);
}
ctx.strokeStyle = lineColor;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// 三角形
drawLines({ x: 100, y: 100 }, [
{ x: 100, y: 200 },
{ x: 200, y: 200 },
// 默认情况下不会从最后一个点链接到第一个点,
// 所以需要最后一个点和第一个点位置一样,这样才能闭合
{ x: 100, y: 100 },
]);
// 正方形
drawLines({ x: 100, y: 300 }, [
{ x: 200, y: 300 },
{ x: 200, y: 400 },
{ x: 100, y: 400 },
{ x: 100, y: 300 },
]);
};
由上图可以发现, 利用最后一个点和第一个点重合的方式闭合划线的方式, 会让连接处出现 "瑕疵"
那有没有办法可以避免这个 "瑕疵" 呢? 让最后一个点自动连接到起始点
闭合路径
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
function drawLines(startPos = { x: 0, y: 0 }, linesPos = [], lineColor = "#f00", lineWidth = 10) {
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
for (let i = 0; i < linesPos.length; i++) {
const linePos = linesPos[i];
ctx.lineTo(linePos.x, linePos.y);
}
// 闭合路径:让最后一个点自动连接到起始点
ctx.closePath();
ctx.strokeStyle = lineColor;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// 三角形
drawLines({ x: 100, y: 100 }, [
{ x: 100, y: 200 },
{ x: 200, y: 200 },
// 默认情况下不会从最后一个点链接到第一个点,
// 所以需要最后一个点和第一个点位置一样,这样才能闭合
// { x: 100, y: 100 },
]);
// 正方形
drawLines({ x: 100, y: 300 }, [
{ x: 200, y: 300 },
{ x: 200, y: 400 },
{ x: 100, y: 400 },
// { x: 100, y: 300 },
]);
};绘制虚线
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
function drawLine(startPos = { x: 0, y: 0 }, dashes = [5]) {
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
ctx.lineTo(startPos.x + 300, startPos.y);
ctx.strokeStyle = "red";
ctx.lineWidth = 10;
ctx.setLineDash(dashes);
ctx.stroke();
}
drawLine({ x: 100, y: 150 }, [5]);
console.log("lineDash-1", ctx.getLineDash());
// [5, 5]
drawLine({ x: 100, y: 250 }, [5, 10]);
console.log("lineDash-2", ctx.getLineDash());
// [5, 10]
drawLine({ x: 100, y: 300 }, [5, 10, 15]);
console.log("lineDash-3", ctx.getLineDash());
// [5, 10, 15, 5, 10, 15]
drawLine({ x: 100, y: 350 }, [5, 10, 15, 20]);
console.log("lineDash-4", ctx.getLineDash());
// [5, 10, 15, 20]
};
填充图形
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
function drawRect(positionArray = [], color = "#f00") {
if (positionArray.length < 3) {
return;
}
const [startPos, ...linesPos] = positionArray;
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
for (let i = 0; i < linesPos.length; i++) {
const item = linesPos[i];
ctx.lineTo(item.x, item.y);
}
ctx.closePath();
ctx.strokeStyle = color;
ctx.lineWidth = 10;
ctx.stroke();
// 设置填充颜色并且填充
ctx.fillStyle = color;
ctx.fill();
}
drawRect([
{ x: 100, y: 100 },
{ x: 300, y: 100 },
{ x: 300, y: 300 },
{ x: 100, y: 300 },
]);
};非零环绕原则
注意
只有在同一条路径路径中才会遵守这个绘制原则, 如果是不同的路径没有这个规则的影响
- 每次填充前从图形的中心拉出一条线, 看路径上与之相交的点是顺时针还是逆时针
- 默认
x = 0如果是逆时针那么x + 1, 如果是顺时针那么x - 1 - 最后如果
x = 0, 就不会填充, 如果结果非零那么就会填充
注: 这个规则中的 x 是表示一个未知数, 不是坐标位置的意思
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
// 大的矩形是顺时针, 所以: x += 1
ctx.moveTo(100, 100);
ctx.lineTo(300, 100);
ctx.lineTo(300, 300);
ctx.lineTo(100, 300);
ctx.closePath();
// 小的矩形也是顺时针 x += 1
ctx.moveTo(150, 150);
ctx.lineTo(250, 150);
ctx.lineTo(250, 250);
ctx.lineTo(150, 250);
ctx.closePath();
// 注: 我只 stroke 绘制了一次, 且是同一个 path
const fillColor = "#f00";
ctx.strokeStyle = fillColor;
ctx.lineWidth = 10;
ctx.fillStyle = fillColor;
ctx.stroke();
ctx.fill();
// 最后: 大矩形的 x = 1, 小矩形的 x = 2, 所以都会填充
///////////////////////////////////////////////////////
// 这是第二个 canvas, 注意对比: 小矩形的绘制顺序不同 //
///////////////////////////////////////////////////////
const canvasDom2 = initCanvasDom("app2");
const ctx2 = canvasDom2.getContext("2d");
// 大的矩形是顺时针, 所以: x += 1
ctx2.moveTo(100, 100);
ctx2.lineTo(300, 100);
ctx2.lineTo(300, 300);
ctx2.lineTo(100, 300);
ctx2.closePath();
// 小的矩形也是顺时针, 所以: x -= 1
ctx2.moveTo(250, 150);
ctx2.lineTo(150, 150);
ctx2.lineTo(150, 250);
ctx2.lineTo(250, 250);
ctx2.closePath();
// 注: 我只 stroke 绘制了一次, 且是同一个 path
ctx2.strokeStyle = fillColor;
ctx2.lineWidth = 10;
ctx2.fillStyle = fillColor;
ctx2.stroke();
ctx2.fill();
// 最后: 大矩形的 x = 1, 小矩形的 x = 0, 所以小矩形不会绘制
};<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>Canvas and SVG</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
padding: 20px;
}
#app,
#app2 {
border: 1px solid #f00;
}
</style>
</head>
<body>
<canvas id="app">Your browser does not support the canvas element.</canvas>
<canvas id="app2">Your browser does not support the canvas element.</canvas>
<script src="./index.js" type="module"></script>
</body>
</html>

最后看效果图, 说明, 结论没错: 
练习:绘制折线图表格
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
const gridSize = 50;
const { width, height } = ctx.canvas;
const rows = Math.floor(height / gridSize);
const cols = Math.floor(width / gridSize);
// 绘制网格
for (let i = 0; i < rows; i++) line chart{
const y = i * gridSize - 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
for (let i = 0; i < cols; i++) {
const x = i * gridSize - 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
// 计算坐标系起点 和 终点
const originPos = {
x: gridSize,line chart
y: height - gridSize,
};
const targetPos = {
x: width - gridSize,
y: gridSize,
};
// 绘制 x 轴
ctx.beginPath();
ctx.moveTo(originPos.x, originPos.y);
ctx.lineTo(targetPos.x, originPos.y);
ctx.strokeStyle = "#f00";
ctx.stroke();
// 绘制x轴箭头line chart
ctx.beginPath();
ctx.lineTo(targetPos.x - 10, originPos.y - 5);
ctx.lineTo(targetPos.x - 10, originPos.y + 5);
ctx.lineTo(targetPos.x, originPos.y);
ctx.closePath();
ctx.fillStyle = "#f00";
ctx.fill();
ctx.stroke();
// 绘制 y 轴
ctx.beginPath();
ctx.moveTo(originPos.x, originPos.y);
ctx.lineTo(originPos.x, targetPos.y);
ctx.strokeStyle = "#f00";
ctx.stroke();
// 绘制y轴箭头
ctx.beginPath();
ctx.lineTo(originPos.x - 5, targetPos.y + 10);
ctx.lineTo(originPos.x + 5, targetPos.y + 10);
ctx.lineTo(originPos.x, targetPos.y);
ctx.closePath();
ctx.fillStyle = "#f00";
ctx.fill();
ctx.stroke();
// 模拟折线图点的坐标数据
const tableData = [
{ x: 100, y: 300 },
{ x: 200, y: 200 },
{ x: 300, y: 250 },
{ x: 400, y: 150 },
{ x: 400, y: 150 },
];
// 绘制折线图所有的点
const dotSize = 10;
const halfDotSize = dotSize / 2;
for (let i = 0; i < tableData.length; i++) {
const item = tableData[i];
ctx.beginPath();
ctx.moveTo(item.x - halfDotSize, item.y - halfDotSize);
ctx.lineTo(item.x + dotSize - halfDotSize, item.y - halfDotSize);
ctx.lineTo(item.x + dotSize - halfDotSize, item.y + dotSize - halfDotSize);
ctx.lineTo(item.x - halfDotSize, item.y + dotSize - halfDotSize);
ctx.closePath();
ctx.fillStyle = "#f00";
ctx.fill();
ctx.strokeStyle = "#f00";
ctx.stroke();
}
// 绘制折线图所有点之间的连线
const [start, ...dots] = tableData;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineWidth = 2;
for (let i = 0; i < dots.length; i++) {
const dot = dots[i];
ctx.lineTo(dot.x, dot.y);
}
ctx.strokeStyle = "#f00";
ctx.stroke();
};class LineChart {
canvasContext = null;
constructor(width = 300, height = 300) {
const canvasDom = document.createElement("canvas");
canvasDom.width = width;
canvasDom.height = height;
this.canvasContext = canvasDom.getContext("2d");
document.body.appendChild(canvasDom);
}
drawGrid(gridSize = 50) {
const ctx = this.canvasContext;
const { width, height } = ctx.canvas;
const rows = Math.floor(height / gridSize);
const cols = Math.floor(width / gridSize);
// 绘制行
for (let i = 0; i < rows; i++) {
const y = i * gridSize - 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// 绘制列
for (let i = 0; i < cols; i++) {
const x = i * gridSize - 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
// 绘制坐标系
drawCoor(gridSize = 50, coorColor = "#f00") {
const ctx = this.canvasContext;
const { width, height } = ctx.canvas;
// 计算坐标系起点 和 终点
const originPos = {
x: gridSize,
y: height - gridSize,
};
const targetPos = {
x: width - gridSize,
y: gridSize,
};
this.originPos = originPos;
this.targetPos = targetPos;
// 绘制 x 轴
ctx.beginPath();
ctx.moveTo(originPos.x, originPos.y);
ctx.lineTo(targetPos.x, originPos.y);
ctx.strokeStyle = coorColor;
ctx.stroke();
// 绘制x轴箭头
ctx.beginPath();
ctx.lineTo(targetPos.x - 10, originPos.y - 5);
ctx.lineTo(targetPos.x - 10, originPos.y + 5);
ctx.lineTo(targetPos.x, originPos.y);
ctx.closePath();
ctx.fillStyle = coorColor;
ctx.fill();
ctx.stroke();
// 绘制 y 轴
ctx.beginPath();
ctx.moveTo(originPos.x, originPos.y);
ctx.lineTo(originPos.x, targetPos.y);
ctx.strokeStyle = coorColor;
ctx.stroke();
// 绘制y轴箭头
ctx.beginPath();
ctx.lineTo(originPos.x - 5, targetPos.y + 10);
ctx.lineTo(originPos.x + 5, targetPos.y + 10);
ctx.lineTo(originPos.x, targetPos.y);
ctx.closePath();
ctx.fillStyle = coorColor;
ctx.fill();
ctx.stroke();
}
// 绘制折线图所有的点
drawDots(tableData = [], dotSize = 10, dotColor = "#f00") {
const ctx = this.canvasContext;
const halfDotSize = dotSize / 2;
for (let i = 0; i < tableData.length; i++) {
const item = tableData[i];
ctx.beginPath();
ctx.moveTo(item.x - halfDotSize, item.y - halfDotSize);
ctx.lineTo(item.x + dotSize - halfDotSize, item.y - halfDotSize);
ctx.lineTo(item.x + dotSize - halfDotSize, item.y + dotSize - halfDotSize);
ctx.lineTo(item.x - halfDotSize, item.y + dotSize - halfDotSize);
ctx.closePath();
ctx.fillStyle = dotColor;
ctx.fill();
ctx.strokeStyle = dotColor;
ctx.stroke();
}
}
// 绘制折线图所有点之间的连线
drawLines(tableData = [], lineWidth = 2, lineColor = "#f00") {
const ctx = this.canvasContext;
const [start, ...dots] = tableData;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineWidth = lineWidth;
for (let i = 0; i < dots.length; i++) {
const dot = dots[i];
ctx.lineTo(dot.x, dot.y);
}
ctx.strokeStyle = lineColor;
ctx.stroke();
}
// 绘制柱状图矩形(后续会学到)
drawBars(tableData = [], gridSize = 50, barColor = "#f00") {
const ctx = this.canvasContext;
for (const item of tableData) {
ctx.fillRect(item.x, item.y, gridSize, this.originPos.y - item.y);
}
}
}
window.onload = function () {
const lineChart = new LineChart(500, 500);
lineChart.drawGrid();
lineChart.drawCoor();
const tableData = [
{ x: 100, y: 300 },
{ x: 200, y: 200 },
{ x: 300, y: 250 },
{ x: 400, y: 150 },
{ x: 400, y: 150 },
];
lineChart.drawDots(tableData);
lineChart.drawLines(tableData);
};
绘制/清除矩形
- rect 绘制矩形但并不会立即 stroke 或 fill,需要手动 stroke/fill 才能绘制, 并且调用多次也是一个 Path, 需要手动 beginPath
- strokeRect 绘制矩形并且立即 stroke
- fillRect 绘制矩形并且立即 fill
- clearRect 清除矩形, 清除矩形内的填充颜色和线条等
者 3 个函数的参数都是一样的: rect(x, y, width, height)
- x: 起始点的 x 坐标
- y: 起始点的 y 坐标
- width: 矩形的宽度
- height: 矩形的高度
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
ctx.strokeStyle = "#f00";
ctx.rect(10, 10, 100, 100); // 这个就等同于下面这 5 行代码
// ctx.moveTo(10, 10);
// ctx.lineTo(100, 10);
// ctx.lineTo(100, 100);
// ctx.lineTo(10, 100);
// ctx.closePath();
ctx.stroke(); // 需要手动 stroke/fill 才会绘制出来
ctx.strokeStyle = "#0f0";
ctx.strokeRect(120, 120, 100, 100); // 这个代码就等同于下面这 7 行代码
// ctx.beginPath();
// ctx.moveTo(120, 120);
// ctx.lineTo(120 + 100, 120);
// ctx.lineTo(120 + 100, 120 + 100);
// ctx.lineTo(120, 120 + 100);
// ctx.closePath();
// ctx.stroke();
ctx.fillStyle = "#00f";
ctx.fillRect(230, 230, 100, 100); // 这个代码就等同于下面这 7 行代码
// ctx.beginPath();
// ctx.moveTo(230, 230);
// ctx.lineTo(230 + 100, 230);
// ctx.lineTo(230 + 100, 230 + 100);
// ctx.lineTo(230, 230 + 100);
// ctx.closePath();
// ctx.fill();
// 清空所有内容: 调用后上面绘制的内容全部被清除
const { width, height } = ctx.canvas;
ctx.clearRect(0, 0, width, height);
};渐变色
- createLinearGradient 创建线性渐变方案(常用)
- createRadialGradient 创建径向渐变方案
- createConicGradient 创建扇形渐变方案
- CanvasGradient 渐变方案
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
// 创建一个线性渐变 createLinearGradient(x1, y1, x2, y2)
// x1, y1 和 x2, y2 的坐标可以构成一条线, 这线就是渐变的方向
// 渐变起点: x1=10 y1=10
// 渐变终点: x2=110 y2=10
// 为什么是 10, 20, 110, 20 因为渐变方案从 0 开始计算
// 因为矩形起始点离左边有 10 像素所以需要 矩形宽度 + 10
// const gradient = ctx.createLinearGradient(10, 20, 110, 20); // 从左到右渐变
const gradient = ctx.createLinearGradient(10, 20, 110, 120); // 从左上到右下渐变
// 添加色标: addColorStop(progress, color)
// progress 是一个值在 0-1 表示渐变进度
// color 颜色
gradient.addColorStop(0, "#f00");
gradient.addColorStop(0.5, "#0f0");
gradient.addColorStop(1, "#00f");
// 填充矩形
ctx.fillStyle = gradient;
ctx.fillRect(10, 20, 100, 100);
};绘制圆弧
基础知识了解
- 角度: 一个圆 360 度(角度), 一个半圆是 180 度
- 弧度: 一个圆 π, 一个半圆是 π/2
角度转弧度公式:
因为: 180角度 = π 弧度
所以: 1角度 = π/180
所以: 弧度 = 角度 * π/180
90角度 * π/180 = π/2
弧度转角度
因为: π 弧度 = 180角度
所以: 1 弧度 = 180/π
所以: 角度 = 弧度 * 180/π
π/2 * 180/π = 90 角度绘制圆形
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
// ctx.arc(x, y, round, startAngle, endAngle, isCounterclockwise);
// x,y: 圆心坐标
// round: 半径
// startAngle: 起始角度
// endAngle: 结束角度
// isCounterclockwise: 是否是逆时针方向绘制, 默认false(顺时针方向)
ctx.arc(100, 100, 50, 0, Math.PI, false);
ctx.strokeStyle = "#f00";
ctx.stroke();
// 两个半圆, 一个逆时针, 一个顺时针, 组合成一个圆
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI, true);
ctx.strokeStyle = "#0f0";
ctx.stroke();
// 直接绘制一个圆形
ctx.beginPath();
ctx.arc(200, 200, 50, 0, Math.PI * 2);
ctx.strokeStyle = "#00f";
ctx.stroke();
};绘制扇形
所谓扇形, 起始就是四分之一的圆形
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
ctx.moveTo(200, 200); // 设置起点为圆心的坐标点
ctx.arc(200, 200, 50, 0, Math.PI / 2);
ctx.strokeStyle = "#00f";
ctx.closePath(); // 直接闭合路径
ctx.stroke();
// 第二个逆时针扇形:从360°(2π)到270°(3π/2), 逆时针绘制90°扇形
ctx.beginPath();
ctx.moveTo(200, 200);
ctx.arc(200, 200, 50, Math.PI * 2, (Math.PI * 3) / 2, true);
ctx.closePath(); // 回到圆心,形成扇形区域
ctx.strokeStyle = "#0f0";
ctx.stroke();
};绘制饼状图
所谓的饼状图起始就是多个扇形组合而成, 仅此而已
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
const ctx = canvasDom.getContext("2d");
// 设定圆心的位置为画布的正中间
const rx = Math.floor(ctx.canvas.width / 2);
const ry = Math.floor(ctx.canvas.height / 2);
/*
// 4 等分圆形
// 0-90
ctx.moveTo(rx, ry);
ctx.arc(rx, ry, 100, 0, Math.PI / 2);
ctx.fillStyle = "#f00";
ctx.fill();
// 90-180
ctx.beginPath()
ctx.moveTo(rx, ry);
ctx.arc(rx, ry, 100, Math.PI / 2, Math.PI);
ctx.fillStyle = "#0f0";
ctx.fill();
// 180-270
ctx.beginPath()
ctx.moveTo(rx, ry);
ctx.arc(rx, ry, 100, Math.PI, Math.PI + Math.PI / 2);
ctx.fillStyle = "#00f";
ctx.fill();
// 270-360
ctx.beginPath()
ctx.moveTo(rx, ry);
ctx.arc(rx, ry, 100, Math.PI + Math.PI / 2, Math.PI * 2);
ctx.fillStyle = "#ff0";
ctx.fill();
*/
function randomColor() {
return `#${Math.floor(Math.random() * 0xffffff).toString(16)}`;
}
let startAngle = 0;
for (let i = 1; i <= 4; i++) {
const endAngle = (i * Math.PI) / 2;
ctx.beginPath();
ctx.moveTo(rx, ry);
ctx.arc(rx, ry, 100, startAngle, endAngle);
ctx.fillStyle = randomColor();
ctx.fill();
ctx.closePath();
startAngle = endAngle;
}
};
绘制文字
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 500;
canvasDom.height = 500;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
const { width, height } = ctx.canvas;
const halfWidth = Math.floor(width / 2);
const halfHeight = Math.floor(height / 2);
// 参考坐标线
ctx.moveTo(halfWidth, halfHeight);
ctx.lineTo(0, halfHeight);
ctx.lineTo(width, halfHeight);
ctx.moveTo(halfWidth, halfHeight);
ctx.lineTo(halfWidth, 0);
ctx.lineTo(halfWidth, height);
ctx.stroke();
// 绘制矩形: 以矩形的左上角作为参考坐标
// 绘制文字: 以文字的左下角作为参考坐标
ctx.fillStyle = "#f00";
ctx.fillRect(halfWidth, halfHeight, 100, 100);
// 设置文字的大小和样式
ctx.font = "30px 微软雅黑";
// 设置文字垂直方向的对齐方式, 以绘制文字的 Y 坐标作为参考点
ctx.textBaseline = "bottom"; // top/bottom/middle/alphabetic/ideographic
// 设置文字水平方向的对齐方式, 以绘制文字的 X 坐标作为参考点
ctx.textAlign = "left"; // start/center /end/left/right
// 文字描边
// ctx.strokeText("hello world", halfWidth, halfHeight);
// 填充文字(实心的)
ctx.fillText("hello world", halfWidth, halfHeight);
};
绘制图片
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 600;
canvasDom.height = 600;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
const img = new Image();
img.onload = function () {
// 如果只有 3 个参数: drawImage(img, x, y)
// img: 是需要绘制的 图片
// x,y: 后面两个参数就是设置图片开始绘制的位置(图片左上角)
// ctx.drawImage(img, 10, 10);
// 如果只有 5 个参数: drawImage(img, x, y, w, h)
// img: 是需要绘制的 图片
// x,y: 设置图片开始绘制的位置(图片左上角)
// w,h: 设置绘制的图片的宽度/高度(会拉伸)
// ctx.drawImage(img, 10, 10, 300, 100);
// 如果有 9 个参数: drawImage(img, imgX1, imgY1, imgX2, imgY2, x, y, w, h)
// img: 是需要绘制的图片(被截取后的图片)
// imgX1,imgY1: 以原图片的左上角作为参考, 设置截取的开始位置
// imgX2,imgY2: 以原图片的左上角作为参考, 设置截取的结束位置
// x,y: 设置图片开始绘制的位置(图片左上角)
// w,h: 设置绘制的图片的宽度/高度(会拉伸)
ctx.drawImage(img, 10, 10, 150, 150, 100, 100, 120, 120);
};
img.src = "https://raw.githubusercontent.com/liaohui5/images/refs/heads/main/images/20220420132604.png";
};
形变
canvas 中与 css 的形变不同, canvas 中所有的形变操作, 都是针对坐标系, 而不是绘制的图形
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 600;
canvasDom.height = 600;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
// 整个坐标系向 x,y 移动 100, 而不是绘制的矩形移动
ctx.translate(100, 100);
ctx.fillStyle = "#f00";
ctx.fillRect(50, 50, 100, 100);
ctx.fillRect(0, 0, 10, 10);
// 如果注释上面的 ctx.translate, 把形变的操作放到
// 绘制图形的后面, 就会发现矩形并不会移动
// ctx.translate(100, 100);
};
合成模式
在 canvas 中, 不像 DOM 那样有 z-index 层的概念, 当在 canvas 上绘制图形、图像或文本时,这些新内容不会总是简单地 覆盖 已有内容
globalCompositeOperation 决定了新绘制的像素(称为源 source) 和 canvas 上已经存在的像素(称为 目标 destination) 之间如何组合 最终决定显示什么颜色
window.onload = function () {
function initCanvasDom() {
const canvasDom = document.getElementById("canvas");
canvasDom.width = window.innerWidth;
canvasDom.height = window.innerHeight;
return canvasDom;
}
const canvasDom = initCanvasDom();
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
// 注: 所谓隐藏其实就是像素是透明的
// 这个属性取值非常多, 但是日常开发中, 有这10个基本可以搞定了
// 如果搞不定, 具体可以查看 mdn 文档, 有更详细的解读例子
// 两个矩形重叠的区域显示绿色(说明重叠区显示后绘制的颜色)
// ctx.globalCompositeOperation = "source-over"; // 默认值
// 两个矩形重叠的区域显示红色矩形(说明重叠区域显示先绘制的颜色)
// ctx.globalCompositeOperation = "destination-over";
// 红色矩形:全部隐藏 绿色矩形:两个矩形重叠的区域隐藏,不重叠的区域显示
// ctx.globalCompositeOperation = "source-out";
// 将两个矩形重叠区域的颜色进行混合(红+绿=黄)
// ctx.globalCompositeOperation = "lighter";
// 红色矩形:全部隐藏 绿色矩形:全部显示(也就是说仅显示后绘制的矩形)
// ctx.globalCompositeOperation = "copy";
// 将两个矩形重叠的区域隐藏
// ctx.globalCompositeOperation = "xor";
// 将两个矩形重叠的区域颜色变黑
// ctx.globalCompositeOperation = "multiply";
// 将两个矩形重叠的区域颜色变亮(效果类似 lighter)
// ctx.globalCompositeOperation = "screen";
// 保留了底层的亮度, 同时采用了顶层的色调和色度: 混合两个矩形重叠区域的颜色
// ctx.globalCompositeOperation = "color";
// 保持底层的色调和色度, 同时采用顶层的亮度
// ctx.globalCompositeOperation = "luminosity";
// 红色矩形 destination
ctx.fillStyle = "#f00";
ctx.fillRect(100, 100, 100, 100);
// 绿色矩形 source
ctx.fillStyle = "#0f0";
ctx.fillRect(150, 150, 100, 100);
// destination
ctx.fillStyle = "#383838";
ctx.fillRect(400, 100, 100, 100);
// source
ctx.fillStyle = "#ff0000";
ctx.fillRect(450, 150, 100, 100);
};图形交互
默认情况下, JS 无法和 canvas 内绘制的图形进行交互, 但是可以用事件触发的位置和 canvas 绘制的图形的位置去判断, 是否点击了绘制的图形
window.onload = function () {
function initCanvasDom(id) {
const canvasDom = document.getElementById(id);
canvasDom.width = 600;
canvasDom.height = 600;
return canvasDom;
}
const canvasDom = initCanvasDom("app");
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
// rect1
const rect = {
x: 50,
y: 50,
width: 100,
height: 100,
};
ctx.fillStyle = "#f00";
ctx.rect(rect.x, rect.y, rect.width, rect.height);
ctx.fill();
// rect2
// ctx.beginPath();
ctx.rect(200, 200, 100, 100);
ctx.fill();
ctx.canvas.addEventListener("click", (e) => {
// 手动判断位置
const { offsetX, offsetY } = e;
// const maxRectX = rect.x + rect.width;
// const maxRectY = rect.y + rect.height;
// if (offsetX >= rect.x && offsetX <= maxRectX && offsetY >= rect.y && offsetY <= maxRectY) {
// console.log("inside the rect");
// } else {
// console.log("outside");
// }
// 使用 isPointInPath 方法判断:
// 注意: 默认情况下, rect1 rect2 都会返回 true
// 但是如果绘制 rect2 时, 开启了新路径 ctx.beginPath()
// 那么就只有点击 rect2 会返回 true, rect1 会返回 false
if (ctx.isPointInPath(offsetX, offsetY)) {
console.log("inside the rect");
} else {
console.log("outside");
}
});
};使用 HTMLCanvasElement 实例方法压缩图片体积
这个类除了 getContext 方法外, 还有一个 toBlob 方法比较常用:
window.onload = function () {
const inputDom = document.getElementById("fileInput");
inputDom.addEventListener("change", async (e) => {
const [file] = e.target.files;
const blob = await compressImageAsync(file);
// upload blob ...
console.log("blob", blob);
});
// 压缩图片
function compressImageAsync(file, quality = 0.5, imageType = "image/png") {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image();
image.src = url;
image.onerror = () => reject("compressImageAsync: failed to load image");
image.onload = function () {
const canvasDom = document.createElement("canvas");
// 这里只是压缩但是并不缩放, 如果需要也可以等比例缩放 canvas 宽高
canvasDom.width = image.width;
canvasDom.height = image.height;
/** @type {CanvasRenderingContext2D} */
const ctx = canvasDom.getContext("2d");
ctx.drawImage(image, 0, 0); // 将图片画到 canvas 上
// 第一个参数是压缩完后的回调函数, 压缩后的 blob 是参数
// 第二个参数是压缩后图片的文件类型 jpg/png
// 第三个参数是压缩的质量取值是 0~1 数值越低, 图片越模糊
canvasDom.toBlob(resolve, imageType, quality);
};
});
}
};<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canvas Demo</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
</style>
</head>
<body>
<input type="file" id="fileInput" />
<script type="module" src="./index.js"></script>
</body>
</html>Canvas 练习
- 画板工具
- 实现截图
- 刮刮乐效果
- 弹幕效果
- 五子棋
- 贪食蛇
画板工具

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>canvas draw board</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.tool-bar {
width: 100%;
padding: 10px 20px;
position: fixed;
top: 0;
left: 0;
z-index: 10;
background: #f8f8f8;
border-bottom: 1px solid #eee;
display: flex;
}
.tool-bar .item {
display: inline-flex;
align-items: center;
margin-right: 20px;
}
.tool-bar .item .label {
margin-right: 10px;
}
.tool-bar .item .status {
justify-self: flex-end;
}
</style>
</head>
<body>
<div class="tool-bar">
<div class="item">
<span class="label">选择颜色</span>
<input type="color" id="colorPicker" />
</div>
<div class="item">
<span class="label">笔画粗细</span>
<input type="range" id="lineWidthPicker" min="1" max="20" />
</div>
<div class="item">
<button id="clearButton" class="label">清除画布</button>
</div>
<div class="item">
<button id="eraserButton" class="label">橡皮擦</button>
</div>
<div class="item">
<button id="penButton" class="label">画笔</button>
</div>
<div class="item">
<button id="undoButton" class="label">撤销</button>
</div>
<div class="item">
<button id="redoButton" class="label">恢复</button>
</div>
<div class="item status">
<span class="label" id="statusBar"></span>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>import Stack from "./stack.js";
/** @type {CanvasRenderingContext2D} */
let canvas2dContext = null;
const PEN_TYPES = {
PEN: {
id: "PEN",
label: "画笔",
},
ERASER: {
id: "ERASER",
label: "橡皮擦",
},
};
const state = {
// 鼠标按下时的位置
mouseDownPos: {
x: 0,
y: 0,
},
lineCap: "round", // 画笔线条端点样式
lineWidth: 10, // 当前画笔宽度
pen: PEN_TYPES.PEN, // "画笔" or "橡皮擦"
penColor: "#000000", // 当前画笔颜色
cachePenColor: null, // 缓存的画笔颜色(切换到橡皮擦时使用)
backgroundColor: "#FFFFFF", // 画布背景色(橡皮擦颜色)
drawStack: new Stack(), // 绘制操作栈
undoStack: new Stack(), // 撤销操作栈
getOptions() {
// 获取当前画笔配置
return {
penColor: this.penColor,
lineCap: this.lineCap,
lineWidth: this.lineWidth,
};
},
setPenColor(color) {
this.penColor = color;
syncCanvasOptions(canvas2dContext, this.getOptions());
},
clearCachePenColor() {
this.cachePenColor = null;
},
setCachePenColor(color) {
this.cachePenColor = color;
},
setLineWidth(width) {
this.lineWidth = width;
syncCanvasOptions(canvas2dContext, this.getOptions());
},
setMouseDownPos(pos) {
this.mouseDownPos = pos;
},
getPenLabel() {
return `当前工具:${this.pen.label}`;
},
setPen() {
this.pen = PEN_TYPES.PEN;
this.setPenColor(this.cachePenColor); // restore pen color
this.clearCachePenColor(); // clear cache
initStatusBar();
},
setEraser() {
this.pen = PEN_TYPES.ERASER;
this.setCachePenColor(this.penColor); // cache current pen color
this.setPenColor(this.backgroundColor); // set pen color to background color
initStatusBar();
},
clearStacks() {
this.drawStack.clear();
this.undoStack.clear();
},
createDrawRecord() {
this.drawStack.push({
id: Math.random().toString(36).substring(2, 10),
moveTo: [],
lineTo: [],
options: this.getOptions(),
});
},
setLastRecordMoveTo(pos) {
const lastRecord = this.drawStack.peek();
lastRecord.moveTo = pos;
},
setLastRecordLineTo(pos) {
const lastRecord = this.drawStack.peek();
lastRecord.lineTo.push(pos);
},
};
window.onload = init;
function init() {
initCanvas();
syncCanvasOptions(canvas2dContext, state.getOptions()); // initial sync
initColorPicker();
initLineWidthPicker();
initClearButton();
initEraserButton();
initPenButton();
initUndoButton();
initRedoButton();
initStatusBar();
}
function initCanvas() {
const canvasDom = document.createElement("canvas");
const ctx = canvasDom.getContext("2d");
canvas2dContext = ctx; // global variable assignment
canvasDom.width = window.innerWidth;
canvasDom.height = window.innerHeight;
canvasDom.addEventListener("mousedown", handleMouseDown, false);
document.body.appendChild(canvasDom);
}
function initColorPicker() {
const colorPickerDom = document.getElementById("colorPicker");
if (!colorPickerDom) return;
colorPickerDom.addEventListener("input", (e) => {
state.setPenColor(e.target.value);
});
colorPickerDom.value = state.penColor; // initialize color
}
function initLineWidthPicker() {
const lineWidthPickerDom = document.getElementById("lineWidthPicker");
if (!lineWidthPickerDom) return;
lineWidthPickerDom.addEventListener("change", (e) => {
state.setLineWidth(parseInt(e.target.value));
});
lineWidthPickerDom.value = state.lineWidth; // initialize picker value
}
function initClearButton() {
const clearButtonDom = document.getElementById("clearButton");
if (!clearButtonDom) return;
clearButtonDom.addEventListener("click", clearAll, false);
}
function initEraserButton() {
const eraserButtonDom = document.getElementById("eraserButton");
if (!eraserButtonDom) return;
eraserButtonDom.addEventListener("click", () => state.setEraser(), false);
}
function initPenButton() {
const penButtonDom = document.getElementById("penButton");
if (!penButtonDom) return;
penButtonDom.addEventListener("click", () => state.setPen(), false);
}
function initStatusBar() {
const statusBarDom = document.getElementById("statusBar");
if (!statusBarDom) return;
statusBarDom.innerText = state.getPenLabel();
}
function initUndoButton() {
const undoButtonDom = document.getElementById("undoButton");
if (!undoButtonDom) return;
undoButtonDom.addEventListener("click", undo, false);
}
function initRedoButton() {
const redoButtonDom = document.getElementById("redoButton");
if (!redoButtonDom) return;
redoButtonDom.addEventListener("click", redo, false);
}
function syncCanvasOptions(ctx, options) {
const { penColor, lineCap, lineWidth } = options;
ctx.strokeStyle = penColor;
ctx.fillStyle = penColor;
ctx.lineCap = lineCap;
ctx.lineWidth = lineWidth;
}
function handleMouseDown(e) {
const { clientX, clientY } = e;
state.setMouseDownPos({
x: clientX,
y: clientY,
});
drawPoint(clientX, clientY);
const canvasDom = canvas2dContext.canvas;
canvasDom.addEventListener("mousemove", handleMouseMove, false);
canvasDom.addEventListener("mouseup", handleMouseUp, false);
}
function handleMouseMove(e) {
const { clientX: x2, clientY: y2 } = e;
const { x: x1, y: y1 } = state.mouseDownPos;
drawLine({ x1, y1, x2, y2 });
}
function handleMouseUp(_e) {
const canvasDom = canvas2dContext.canvas;
canvasDom.removeEventListener("mousemove", handleMouseMove, false);
canvasDom.removeEventListener("mouseup", handleMouseUp, false);
}
function drawPoint(x, y) {
state.createDrawRecord();
state.setLastRecordMoveTo([x, y]);
$drawPoint(x, y);
}
function drawLine({ x1, y1, x2, y2 }) {
state.setLastRecordLineTo([x1, y1, x2, y2]);
canvas2dContext.beginPath();
canvas2dContext.moveTo(x1, y1);
canvas2dContext.lineTo(x2, y2);
canvas2dContext.stroke();
state.setMouseDownPos({ x: x2, y: y2 });
}
function $drawPoint(x, y) {
canvas2dContext.beginPath();
canvas2dContext.arc(x, y, canvas2dContext.lineWidth / 2, 0, Math.PI * 2);
canvas2dContext.fill();
}
function clearAll() {
const canvasDom = canvas2dContext.canvas;
canvas2dContext.clearRect(0, 0, canvasDom.width, canvasDom.height);
}
function drawLineBatch(lines) {
canvas2dContext.beginPath();
for (const line of lines) {
const [x1, y1, x2, y2] = line;
canvas2dContext.moveTo(x1, y1);
canvas2dContext.lineTo(x2, y2);
}
canvas2dContext.stroke();
}
function redraw() {
clearAll();
for (const record of state.drawStack.items) {
const { moveTo, lineTo, options } = record;
// sync options for each record
syncCanvasOptions(canvas2dContext, options);
$drawPoint(moveTo[0], moveTo[1]);
drawLineBatch(lineTo);
}
// restore current options
syncCanvasOptions(canvas2dContext, state.getOptions());
}
function undo() {
if (state.drawStack.isEmpty()) {
console.log("drawStack is empty, cannot undo");
return;
}
const record = state.drawStack.pop();
state.undoStack.push(record);
console.log("poped record:", record);
console.log("drawStack:", state.drawStack.items);
console.log("undoStack:", state.undoStack.items);
redraw();
}
function redo() {
if (state.undoStack.isEmpty()) {
console.log("undoStack is empty, cannot redo");
return;
}
const record = state.undoStack.pop();
state.drawStack.push(record);
console.log("poped record:", record);
console.log("drawStack:", state.drawStack.items);
console.log("undoStack:", state.undoStack.items);
redraw();
}/**
* 栈是一种受限的线性数据结构,相较于数组来说只能后进先出
*/
export default class Stack {
items = [];
/**
* 获取栈的总长度
* @returns number
*/
size() {
return this.items.length;
}
/**
* 栈是否为空
* @returns boolean
*/
isEmpty() {
return this.size() === 0;
}
/**
* 出栈
* @returns T | undefined
*/
pop() {
return this.items.pop();
}
/**
* 入栈
* @param item
*/
push(item) {
this.items.push(item);
}
/**
* 查看栈顶元素(但是不会执行出栈操作)
* @returns T | undefined
*/
peek() {
return this.items[this.items.length - 1];
}
/**
* 清栈
*/
clear() {
this.items = [];
}
/**
* 字符串序列化
* @returns string
*/
toString() {
return "[" + this.items.toString() + "]";
}
}实现截图功能

/** @type {CanvasRenderingContext2D} */
let canvas2dContext;
const state = {
// 通过文件选择框选中的文件对应的图片元素
imageElement: null,
width: 0,
height: 0,
setImageElement(dom) {
this.width = dom.width;
this.height = dom.height;
this.imageElement = dom;
},
getImageElement() {
return this.imageElement;
},
// 获取图片尺寸
getImageSize() {
const { width, height } = this;
return { width, height };
},
// 鼠标按下时的坐标
canvasMouseDownPos: {
x: 0,
y: 0,
},
setCanvasMouseDownPos(x, y) {
this.canvasMouseDownPos = {
x,
y,
};
},
// 截屏选取区域的坐标
screenShotPos: [0, 0, 0, 0],
setScreenShotPos(data) {
this.screenShotPos = data;
},
getScreenShotPos() {
return this.screenShotPos;
},
};
// init
window.onload = function init() {
initFileInput();
};
function initFileInput() {
const fileInputDom = document.getElementById("fileInput");
fileInputDom.addEventListener("change", (e) => {
loadImage(e.target.files[0]);
});
}
function loadImage(file) {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = function () {
// 图片加载完之后, 将图片元素的信息保存到 state
// 然后初始化 canvas, 将图片/遮罩画出来
// 最后 revokeObjectURL
state.setImageElement(image);
initCanvas();
drawImage();
drawImageMask();
URL.revokeObjectURL(url);
};
image.src = url;
}
function initCanvas() {
const canvasDom = document.createElement("canvas");
const ctx = canvasDom.getContext("2d");
canvas2dContext = ctx;
const { width, height } = state.getImageSize();
canvasDom.width = width;
canvasDom.height = height;
canvas2dContext.canvas.addEventListener("mousedown", handleCanvasMouseDown, false);
document.body.appendChild(canvasDom);
}
function drawImage() {
const image = state.getImageElement();
canvas2dContext.drawImage(image, 0, 0);
}
function drawImageMask(opacity = 0.5) {
const { width, height } = canvas2dContext.canvas;
canvas2dContext.fillStyle = `rgba(0,0,0,${opacity})`;
canvas2dContext.fillRect(0, 0, width, height);
}
function handleCanvasMouseDown(e) {
state.setCanvasMouseDownPos(e.offsetX, e.offsetY);
canvas2dContext.canvas.addEventListener("mousemove", handleCanvasMouseMove, false);
}
function handleCanvasMouseMove(e) {
canvas2dContext.canvas.addEventListener("mouseup", handleCanvasMouseUp, false);
const { offsetX, offsetY } = e;
const { x, y } = state.canvasMouseDownPos;
const rectWidth = offsetX - x;
const rectHeight = offsetY - y;
const { width, height } = state.getImageSize();
// 保存截取区域的4个坐标 x1,y1 x2,y2
state.setScreenShotPos([x, y, rectWidth, rectHeight]);
// 清除画布
canvas2dContext.clearRect(0, 0, width, height);
// 重新绘制 mask 和 截取选中部分
drawImageMask();
drawScreenShot(rectWidth, rectHeight);
}
function handleCanvasMouseUp() {
drawScreenShotImage();
canvas2dContext.canvas.removeEventListener("mousedown", handleCanvasMouseDown, false);
canvas2dContext.canvas.removeEventListener("mousemove", handleCanvasMouseMove, false);
}
function drawScreenShot(rectWidth, rectHeight) {
// globalCompositeOperation docs:
// https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
// 仅保留现有画布内容和新形状不重叠的部分
canvas2dContext.globalCompositeOperation = "destination-out";
canvas2dContext.fillStyle = "#000";
const { x, y } = state.canvasMouseDownPos;
canvas2dContext.fillRect(x, y, rectWidth, rectHeight);
// 在现有画布内容的后面绘制新的图形
canvas2dContext.globalCompositeOperation = "destination-over";
drawImage();
}
function drawScreenShotImage() {
const screenShotPos = state.getScreenShotPos();
const imageData = canvas2dContext.getImageData(...screenShotPos);
const [_x1, _y1, w, h] = screenShotPos;
createPreview(w, h, imageData);
}
function createPreview(width, height, imageData) {
const canvasDom = document.createElement("canvas");
canvasDom.width = width;
canvasDom.height = height;
const ctx = canvasDom.getContext("2d");
ctx.putImageData(imageData, 0, 0);
document.body.appendChild(canvasDom);
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canvas Demo</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<div>
<input type="file" id="fileInput" accept="image/*" />
</div>
<script type="module" src="./index.js"></script>
</body>
</html>刮刮乐刮奖效果

其实就是默认给元素加一个默认的遮罩,然后将设置 globalCompositeOperation=destination-out 那么用鼠标划过的位置就会透明, 那么底下的元素就会展示出来, 那么就得到了类似刮奖的效果
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canvas Demo</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.container {
width: 400px;
height: 200px;
position: relative;
}
.container #mask,
.container #anwser {
position: absolute;
top: 0;
left: 0;
}
#anwser {
z-index: 1;
}
#mask {
z-index: 2;
}
</style>
</head>
<body>
<div class="container">
<canvas id="mask"></canvas>
<canvas id="anwser"></canvas>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>// global states
const state = {
width: 400,
height: 200,
isOpenable: false,
setIsOpening(value) {
this.isOpenable = value;
},
};
function initAnwser() {
const { width, height } = state;
const anwserDom = document.getElementById("anwser");
anwserDom.width = width;
anwserDom.height = height;
/** @type {CanvasRenderingContext2D} */
const ctx = anwserDom.getContext("2d");
const text = Math.random() > 0.5 ? "再来一瓶" : "谢谢惠顾";
ctx.fillStyle = "#f00";
ctx.font = "32px '微软雅黑'";
ctx.textAlign = "center";
ctx.fillText(text, width / 2, height / 2);
}
function initMask() {
const { width, height } = state;
const maskDom = document.getElementById("mask");
maskDom.width = width;
maskDom.height = height;
/** @type {CanvasRenderingContext2D} */
const ctx = maskDom.getContext("2d");
// draw mask
ctx.fillStyle = "#ccc";
ctx.fillRect(0, 0, 400, 200);
// bind events
maskDom.addEventListener("mousedown", () => {
state.setIsOpening(true);
});
maskDom.addEventListener("mousemove", (e) => {
if (!state.isOpenable) {
return;
}
// 设置这个属性后, 后续画的路径会让canvas透明
ctx.globalCompositeOperation = "destination-out";
const { offsetX, offsetY } = e;
ctx.arc(offsetX, offsetY, 20, 0, Math.PI * 2);
ctx.fill();
});
maskDom.addEventListener("mouseup", () => {
state.setIsOpening(false);
});
}
window.onload = function () {
initAnwser();
initMask();
};弹幕效果

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canvas Demo</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="container">
<ul class="tool-bar">
<li class="tool-bar-item">
<span>弹幕颜色</span>
<input type="color" id="danmu-color" value="#f8f8f8" />
</li>
<li class="tool-bar-item">
<span>字体大小</span>
<input type="range" id="danmu-size" min="15" max="25" step="1" value="20" />
</li>
<li class="tool-bar-item">
<span>弹幕速度</span>
<select id="danmu-speed">
<option value="1">慢</option>
<option selected="selected" value="2">默认</option>
<option value="4">快</option>
</select>
</li>
<li class="tool-bar-item">
<input type="text" id="danmu-text" />
<button id="send-danmu">发送</button>
</li>
</ul>
<div class="video-container">
<video
id="video"
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/nupenuvpxnuvo/xgplayer_doc/xgplayer-demo-720p.mp4"
controls
></video>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>* {
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.container {
padding: 20px;
width: 1000px;
margin: 0 auto;
}
.container .tool-bar {
list-style: none;
display: flex;
align-items: center;
gap: 15px;
padding: 15px 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
flex-wrap: wrap;
}
.container .tool-bar li.tool-bar-item {
list-style: none;
display: inline-flex;
align-items: center;
gap: 8px;
color: white;
font-weight: 500;
}
.container .tool-bar span {
font-size: 14px;
min-width: 80px;
}
.container .tool-bar input[type="color"] {
width: 40px;
height: 40px;
border: 2px solid white;
border-radius: 6px;
cursor: pointer;
transition: transform 0.2s;
}
.container .tool-bar input[type="color"]:hover {
transform: scale(1.1);
}
.container .tool-bar input[type="range"] {
width: 120px;
cursor: pointer;
accent-color: #fff;
}
.container .tool-bar select,
.container .tool-bar input[type="text"],
.container .tool-bar button {
padding: 8px 12px;
border: 2px solid white;
border-radius: 6px;
font-size: 14px;
background: rgba(255, 255, 255, 0.95);
color: #333;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.container .tool-bar input[type="text"] {
min-width: 150px;
}
.container .tool-bar select:hover,
.container .tool-bar input[type="text"]:hover {
background: white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.container .tool-bar button {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border: none;
min-width: 80px;
}
.container .tool-bar button:hover {
box-shadow: 0 6px 12px rgba(245, 87, 108, 0.4);
transform: translateY(-2px);
}
.container .tool-bar button:active {
transform: translateY(0);
}
.container #video {
width: 100%;
border: 1px solid #ccc;
}import { createVideoDanmu } from "./VideoDanmu.js";
window.onload = function init() {
const danmuColorDom = document.getElementById("danmu-color");
const danmuSizeDom = document.getElementById("danmu-size");
const danmuSpeedDom = document.getElementById("danmu-speed");
const danmuTextDom = document.getElementById("danmu-text");
const danmuSendDom = document.getElementById("send-danmu");
const videoDom = document.getElementById("video");
const videoDanmuInst = createVideoDanmu(videoDom);
videoDanmuInst.loadData([
{
text: "我太喜欢这首歌了",
color: "#ff0000",
fontSize: 20,
speed: 2,
showtime: 3,
},
{
text: "你们好你们好你们好你们好你们好",
color: "#00ff00",
fontSize: 20,
speed: 3,
showtime: 6,
},
{
text: "这首歌很好听",
color: "#ffffff",
fontSize: 20,
speed: 1,
showtime: 7,
},
{
text: "我太喜欢这首歌了",
color: "#ffffff",
fontSize: 20,
speed: 3,
showtime: 8,
},
{
text: "我太喜欢这首歌了2",
color: "#ff0000",
fontSize: 20,
speed: 4,
showtime: 10,
},
{
text: "你们好你们好你们好你们好你们好2",
color: "#00ff00",
fontSize: 20,
speed: 3,
showtime: 12,
},
{
text: "这首歌很好听222",
color: "#ffffff",
fontSize: 20,
speed: 2,
showtime: 14,
},
{
text: "我太喜欢这首歌了222",
color: "#ffffff",
fontSize: 20,
speed: 2,
showtime: 15,
},
]);
videoDom.addEventListener("play", () => videoDanmuInst.play(), false);
videoDom.addEventListener("pause", () => videoDanmuInst.pause(), false);
danmuSendDom.addEventListener("click", () => {
const text = danmuTextDom.value.trim();
if (text.length === 0) {
console.log("请输入弹幕内容");
return;
}
if (text.length === 1) {
console.log("不能发送单个字符");
return;
}
const color = danmuColorDom.value;
const fontSize = parseInt(danmuSizeDom.value);
const speed = parseInt(danmuSpeedDom.value);
const showtime = videoDom.currentTime;
const danmu = {
color,
fontSize,
speed,
text,
showtime,
};
videoDanmuInst.loadData([danmu]);
danmuTextDom.value = "";
});
};export class VideoDanmu {
/** @type {CanvasRenderingContext2D} */
canvasContext = null;
// 是否暂停弹幕
isPaused = true;
// 弹幕池数据
danmuPool = [];
// 默认弹幕样式配置
styleOptions = {
text: "",
color: "#ff0000",
speed: 6,
fontSize: 30,
fontFamily: "Hack",
};
// 可显示弹幕的区域份数(总视频高度 / 2 = 可显示弹幕高度)
visibleAreaHeight = 2;
constructor(videoDom, styleOpts = {}) {
this.videoDom = videoDom;
this.styleOptions = Object.assign(this.styleOptions, styleOpts);
this.init();
this.render();
}
init() {
this.initConvas();
this.initDom();
}
initConvas() {
// 用于绘制弹幕
const canvasDom = document.createElement("canvas");
this.canvasContext = canvasDom.getContext("2d");
// 用于计算弹幕宽度 4GTW: forGetTextWidth
const canvasDom2 = document.createElement("canvas");
this.canvasCtx4GTW = canvasDom2.getContext("2d");
}
initDom() {
const container = document.createElement("div");
const videoDom = this.videoDom;
// container style
container.style.position = "relative";
container.width = videoDom.offsetWidth;
container.height = videoDom.offsetHeight;
// canvas style
const canvasDom = this.canvasContext.canvas;
canvasDom.width = videoDom.offsetWidth;
canvasDom.height = videoDom.offsetHeight;
canvasDom.style.position = "absolute";
canvasDom.style.top = 0;
canvasDom.style.left = 0;
// append to dom tree
const parentNode = videoDom.parentNode;
container.append(canvasDom);
container.append(videoDom);
parentNode.append(container);
}
getVisibleAreaHeight() {
return this.canvasContext.canvas.height / this.visibleAreaHeight;
}
setVisibleAreaHeight(n) {
if (n < 1 || n > 5) {
throw new Error("setVisibleAreaHeight: please give an number between 1 and 5");
}
this.visibleAreaHeight = n;
}
play() {
this.isPaused = false;
this.render();
}
pause() {
this.isPaused = true;
}
loadData(data) {
if (!Array.isArray(data)) {
throw new Error("loadData: please give an array");
}
const danmus = data.map((item) => this.formatDanmu(item));
this.danmuPool.push(...danmus);
console.log("loadData:", danmus);
}
formatDanmu(item) {
// {
// text: "", // 弹幕内容
// color: "#ff0000", // 弹幕颜色
// speed: 2, // 弹幕滚动速度
// showtime: 8, // 弹幕展示时间
// fontSize: 20, // 弹幕字体大小
// fontFamily:'Hack', // 弹幕字体
// width: ? // 弹幕宽度
// x: ? // 弹幕起始 x 坐标
// y: ? // 弹幕起始 y 坐标
// isDone: false, // 是否已经展示过了
// }
const danmu = Object.assign({}, this.styleOptions, item);
const { text, fontSize, fontFamily } = danmu;
const danmuWidth = this.getDanmuWidth(text, fontSize, fontFamily);
// 计算起始位置: 默认在最右边
// x: canvas宽度 + 弹幕本身的宽度
// y: fontSize + canvas可显示弹幕区域随机
// 弹幕起始位置放在画布右侧外,这样才会从右向左进入画面
const startX = this.canvasContext.canvas.width + danmuWidth;
const startY = random(fontSize, this.getVisibleAreaHeight() - fontSize);
return Object.assign(danmu, {
isDone: false,
width: danmuWidth,
x: startX,
y: startY,
});
}
// 获取弹幕的宽度
getDanmuWidth(text, fontSize, fontFamily) {
this.canvasCtx4GTW.font = `${fontSize}px ${fontFamily}`;
const { width } = this.canvasCtx4GTW.measureText(text);
return width;
}
drawDanmu(danmu) {
// 只有 视频的播放时间大于等于弹幕的展示时间
// 或者 弹幕还没有展示过, 此时: 才会绘制否则
// 一次性绘制太多可能会导致卡顿
if (!danmu.isDone && this.videoDom.currentTime < danmu.showtime) {
return;
}
// 移动坐标, 不停往左移动
danmu.x -= danmu.speed;
// 绘制
const { text, color, fontSize, x, y } = danmu;
const ctx = this.canvasContext;
ctx.shadowColor = "#000000";
ctx.shadowBlur = 2;
ctx.font = `${fontSize}px '${danmu.fontFamily}'`;
ctx.fillStyle = color;
ctx.fillText(text, x, y);
// 如果已经移动到最左边了
if (danmu.x <= -danmu.width) {
danmu.isDone = true;
}
}
renderDanmu() {
const items = this.danmuPool;
// console.log("renderDanmu count:", items.length);
for (const danmu of items) {
this.drawDanmu(danmu);
}
}
removeDoneDanmu() {
this.danmuPool = this.danmuPool.filter((item) => !item.isDone);
}
render() {
this.clear();
this.removeDoneDanmu();
this.renderDanmu();
!this.isPaused && requestAnimationFrame(() => this.render());
}
clear() {
const { width, height } = this.canvasContext.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
}
/**
* 返回指定范围内的随机数
* @param min - 最小值
* @param max - 最大值
* @returns 返回值
*/
export function random(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
export const createVideoDanmu = (videoEl, options) => new VideoDanmu(videoEl, options);五子棋游戏

window.addEventListener("DOMContentLoaded", () => {
init();
});
/** @type {CanvasRenderingContext2D} */
let _canvas2dContext;
const setContext = (ctx) => {
_canvas2dContext = ctx;
};
const getContext = () => _canvas2dContext;
// 棋子的类型
const CHESS_TYPE = {
BLACK: {
name: "黑棋",
color: "#000",
value: 0,
},
WHITE: {
name: "白棋",
color: "#fff",
value: 1,
},
};
// 全局的状态管理
const state = {
width: 800, /////////// 棋盘总宽度
height: 800, ////////// 棋盘中高度
columns: 15, ////////// 棋盘列数
rows: 15, ///////////// 棋盘行数
gridSize: 50, ///////// 棋盘网格大小
gridColor: "#f00", // 棋盘网格颜色
chessRadius: 20, ////// 棋子的半径
isBlack: true, //////// 黑棋先手
chessData: [], //////// 棋盘上的棋子数据
winChessCount: 5, ///// 五子棋: 当然是连续5个算赢
switchPlayer() {
state.isBlack = !state.isBlack;
},
getChess() {
return this.isBlack ? CHESS_TYPE.BLACK : CHESS_TYPE.WHITE;
},
getChessName() {
return this.getChess().name;
},
getChessColor() {
return this.getChess().color;
},
getChessValue() {
return this.getChess().value;
},
getChessData(rowIndex, colIndex) {
return this.chessData[colIndex][rowIndex];
},
hasChessData(rowIndex, colIndex) {
// 判断某个位置是否有棋子了, 不能重复下棋子到同一个位置
const value = this.getChessData(rowIndex, colIndex);
return [CHESS_TYPE.BLACK.value, CHESS_TYPE.WHITE.value].includes(value);
},
putChessData(rowIndex, colIndex) {
// 给棋盘数据设置棋子数据
this.chessData[colIndex][rowIndex] = this.getChessValue();
this.putChessCount += 1;
},
};
function init() {
initCanvasContext();
drawGrids();
initCanvasEvents();
renderToDom(document.getElementById("app"));
}
function initCanvasContext() {
const canvasDom = document.createElement("canvas");
setContext(canvasDom.getContext("2d"));
const { width, height } = state;
canvasDom.width = width;
canvasDom.height = height;
}
function drawGrids() {
const ctx = getContext();
const { columns, rows, gridSize, width, height, gridColor, chessData } = state;
ctx.strokeStyle = gridColor;
const endX = width - gridSize;
for (let i = 1; i <= rows; i++) {
const y = i * gridSize;
ctx.moveTo(gridSize, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
const endY = height - gridSize;
for (let i = 1; i <= columns; i++) {
const x = i * gridSize;
ctx.moveTo(x, gridSize);
ctx.lineTo(x, endY);
ctx.stroke();
}
const chessDataCount = Math.max(columns, rows);
for (let i = 0; i <= chessDataCount; i++) {
chessData.push([]);
}
}
// 渲染到页面上
function renderToDom(containerDOM) {
createTipDom(containerDOM);
const ctx = getContext();
containerDOM.append(ctx.canvas);
updateTipMsg();
}
// 创建提示信息元素
function createTipDom(containerDOM) {
const tipDiv = document.createElement("div");
tipDiv.id = "tip";
containerDOM.append(tipDiv);
}
// 更新提示信息
function updateTipMsg() {
const tipDiv = document.getElementById("tip");
if (!tipDiv) return;
tipDiv.textContent = `请 ${state.getChessName()} 落子`;
}
// 初始化 canvas 交互事件
function initCanvasEvents() {
const ctx = getContext();
ctx.canvas.addEventListener("click", (e) => {
const { gridSize } = state;
const { offsetX, offsetY } = e;
// 判断其实是否在棋盘之内
if (!isInBoard(offsetX, offsetY)) {
console.log("请在棋盘内点击", { offsetX, offsetY });
return;
}
// 让棋子下在线的交汇处
const rowIndex = getGridIndex(offsetX, gridSize);
const colIndex = getGridIndex(offsetY, gridSize);
const x = rowIndex * gridSize;
const y = colIndex * gridSize;
console.log({ x, y, rowIndex, colIndex });
if (state.hasChessData(rowIndex, colIndex)) {
alert("这个位置已经有棋子了!");
return;
}
putChess(x, y, rowIndex, colIndex);
if (isWin(rowIndex, colIndex)) {
restartGame(`${state.getChessName()}已经赢了!`);
return;
}
state.switchPlayer();
updateTipMsg();
});
}
// 判断其实是否在棋盘之内
function isInBoard(x, y) {
const { width, height, gridSize } = state;
const halfGridSize = gridSize / 2;
const minX = halfGridSize;
const minY = halfGridSize;
const maxX = width - halfGridSize;
const maxY = height - halfGridSize;
return x > minX && x < maxX && y > minY && y < maxY;
}
// 计算点击的是第几个格子
function getGridIndex(pos, gridSize) {
// 其实就是要得到距离 v, 最近(gridSize)的倍数数字
// v 就是点击的位置(但是有可能不是 gridSize 的倍数数字)
// Math.floor((x + 25) / 50) * 50, 这样就可以得到
// 50 的倍数并向下取整, +25是因为是向下取整所以需要加上半
// 个格子宽度, 这样点击一个格子右侧的时候才会准确
const halfGridSize = gridSize / 2;
return Math.floor((pos + halfGridSize) / gridSize);
}
// 放置棋子
function putChess(x, y, rowIndex, colIndex) {
drawChess(x, y);
state.putChessData(rowIndex, colIndex);
}
// 绘制棋子
function drawChess(x, y) {
const ctx = getContext();
ctx.beginPath();
ctx.arc(x, y, state.chessRadius, 0, Math.PI * 2);
ctx.fillStyle = state.getChessColor();
ctx.fill();
ctx.closePath();
}
// 判断是否已经赢了:任意一个方向连续有5个棋子就算赢棋了
function isWin(rowIndex, colIndex) {
return (
// 竖向查找
checkRows(rowIndex, colIndex) ||
// 横向查找
checkCols(rowIndex, colIndex) ||
// 左上 -> 右下 查找
checkBackslash(rowIndex, colIndex) ||
// 左下 -> 右上 查找
checkSlash(rowIndex, colIndex)
);
}
// 通用的方向检查函数
function checkDirection({ rowIndex, colIndex, directions, limit }) {
const targetChess = state.getChessValue();
let lineChessCount = 1; // 在一行的棋子个数
// 初始化两个方向的搜索信息
const [dir1, dir2] = directions;
const search1 = {
rowIndex: rowIndex + dir1.rowIndex,
colIndex: colIndex + dir1.colIndex,
isNeed: true,
};
const search2 = {
rowIndex: rowIndex + dir2.rowIndex,
colIndex: colIndex + dir2.colIndex,
isNeed: true,
};
let index = limit;
while (index > 0) {
if (lineChessCount === state.winChessCount) {
return true;
}
// 检查第一个方向
if (
search1.isNeed &&
search1.rowIndex > 0 &&
search1.rowIndex <= limit &&
search1.colIndex > 0 &&
search1.colIndex <= limit
) {
const chess = state.getChessData(search1.rowIndex, search1.colIndex);
if (chess === targetChess) {
lineChessCount += 1;
search1.rowIndex += dir1.rowIndex;
search1.colIndex += dir1.colIndex;
} else {
search1.isNeed = false;
}
}
// 检查第二个方向
if (
search2.isNeed &&
search2.rowIndex > 0 &&
search2.rowIndex <= limit &&
search2.colIndex > 0 &&
search2.colIndex <= limit
) {
const chess = state.getChessData(search2.rowIndex, search2.colIndex);
if (chess === targetChess) {
lineChessCount += 1;
search2.rowIndex += dir2.rowIndex;
search2.colIndex += dir2.colIndex;
} else {
search2.isNeed = false;
}
}
index--;
}
return false;
}
// 竖向查找是否有5个颜色相同的棋子
function checkRows(rowIndex, colIndex) {
return checkDirection({
rowIndex,
colIndex,
directions: [
{ rowIndex: -1, colIndex: 0 }, // 向上
{ rowIndex: 1, colIndex: 0 }, // 向下
],
limit: state.rows,
});
}
// 横向查找是否有5个颜色相同的棋子
function checkCols(rowIndex, colIndex) {
return checkDirection({
rowIndex,
colIndex,
directions: [
{ rowIndex: 0, colIndex: -1 }, // 向左
{ rowIndex: 0, colIndex: 1 }, // 向右
],
limit: state.columns,
});
}
// 左上 -> 右下 查找(反斜线)
function checkBackslash(rowIndex, colIndex) {
return checkDirection({
rowIndex,
colIndex,
directions: [
{ rowIndex: -1, colIndex: -1 }, // 左上
{ rowIndex: 1, colIndex: 1 }, // 右下
],
limit: Math.max(state.columns, state.rows),
});
}
// 左下 -> 右上 查找(斜线)
function checkSlash(rowIndex, colIndex) {
return checkDirection({
rowIndex,
colIndex,
directions: [
{ rowIndex: 1, colIndex: -1 }, // 左下
{ rowIndex: -1, colIndex: 1 }, // 右上
],
limit: Math.max(state.columns, state.rows),
});
}
// 一方胜利, 重启游戏
function restartGame(message) {
requestIdleCallback(() => {
// 异步弹出通知, 让棋子先画出来
// 否则还没来得及渲染棋子就直接alert卡住了
window.alert(message);
window.location.reload(true);
});
}<!doctype html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<link rel="stylesheet" href="./styles.css" />
<title>title</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.js"></script>
</body>
</html>* {
margin: 0;
padding: 0;
}
#app {
padding: 30px 0;
}
#app canvas {
display: block;
background: #ccc;
margin: 0 auto;
}
#app #tip {
margin-bottom: 20px;
font-size: 18px;
font-weight: bold;
color: #f00;
text-align: center;
}