当前位置: 首页 > news >正文

2.canvas学习

学习计划详举。基础→进阶→高级→综合

1.入门基础阶段(掌握 Canvas 绘图核心 API)

1.1 Canvas 初始化

1. Canvas 元素创建与尺寸设置
2. 获取 2D 绘图上下文(getContext('2d')
3. Vue/React 中 DOM 就绪时机(onMounted/useEffect

<template><canvas id="myCanvas" width="400" height="300"></canvas>
</template>
<script setup>
import { onMounted } from "vue"; 
onMounted(() => {const canvas = document.getElementById("myCanvas");const ctx = canvas.getContext("2d");
});
</script>

1.2 基本图形绘制

1. 矩形(fillRect/strokeRect

  //绘制矩形的边框ctx.strokeRect(300, 100, 100, 100);//绘制一个填充的矩形ctx.fillRect(100, 100, 100, 100);//清除矩形的指定区域,清除部分透明ctx.clearRect(120, 120, 50, 50);


2.线条三角形(moveTo+lineTo)

  ctx.beginPath(); //结束之前的路径绘制,之后的绘制都在新路径上ctx.moveTo(20, 20); //定一个起始点,不定就是0,0ctx.lineTo(100, 75); //从起始点开始到这个坐标点ctx.lineTo(100, 25); //从上个坐标点到这个坐标点ctx.closePath(); //将开口的连接起来ctx.stroke(); //渲染路径(必须调用才会显示线条)

3.圆形 / 弧线(arc+beginPath

arc(x,y,radius, startAngle, endAngle, anticlockwise)

  • x, y:圆心坐标

  • radius:半径

  • startAngle:起始角度(弧度制,0 表示 3 点钟方向)

  • endAngle:结束角度(弧度制)

  • counterclockwise:是否逆时针绘制(true 为逆时针,false 为顺时针)

arcTo(x1,y1,x2,y2,radius)

根据给定的控制点和半径画一段圆弧,再以直线连接两个控制点。

arc() 函数中表示角的单位是弧度,弧度=(Math.PI/180)*角度

2 * Math.PI为360度,可以控制他来展示圆还是弧线。

  ctx.beginPath();ctx.arc(200, 200, 30, 0, 2 * Math.PI, true);ctx.stroke();


绘制一个1/4圆:

  ctx.beginPath();ctx.arc(200, 200, 30, 0, 2 * Math.PI, true);ctx.stroke();//// 绘制一个扇形(带两条半径的1/4圆)ctx.beginPath();const x = 100,y = 250,r = 50;ctx.moveTo(x, y); // 移动到圆心ctx.arc(x, y, r, 0, Math.PI / 2); // 绘制1/4圆弧ctx.lineTo(x, y); // 从弧线终点连接回圆心ctx.fillStyle = "pink";ctx.fill(); // 填充扇形

4.复杂图形(二次贝塞尔曲线+三次贝塞尔曲线)


作用:用于绘制平滑曲线。

二次贝塞尔曲线、


二次贝塞尔曲线由起点、控制点、终点三个点定义,曲线的形状由控制点 "牵引" 形成,呈现出平滑的抛物线效果。,可以设置多个曲线形成自己想要的图案。

ctx.quadraticCurveTo(cpx, cpy, x, y)
  • cpx, cpy:控制点坐标(影响曲线的弯曲方向和程度)

  • x, y:终点坐标

  ctx.beginPath()ctx.moveTo(30,30)ctx.quadraticCurveTo(400,100,300,400)ctx.stroke()

三次贝塞尔曲线


三次贝塞尔曲线由起点、两个控制点、终点四个点定义,比二次曲线更灵活,可绘制更复杂的弯曲效果(如 S 形曲线)。

ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
  • cp1x, cp1y:第一个控制点坐标

  • cp2x, cp2y:第二个控制点坐标

  • x, y:终点坐标

  • 起点同样由当前路径位置决定

   ctx.beginPath()ctx.moveTo(30,30)ctx.bezierCurveTo(400,100,50,200,300,400)ctx.stroke()

5.文本(fillText/font/textAlign

fillText(text, x, y [, maxWidth])
  • text:要绘制的文本内容(字符串)

  • x, y:文本绘制的起点坐标(注意:y 是文本基线位置,不是顶部)

  • maxWidth(可选):文本最大宽度,超过会自动缩小字体

 ctx.font = "16px Arial";ctx.fillStyle = "#333";ctx.fillText("基本文本", 50, 60);

fillText() :文本基线 y 坐标是文本的基线(baseline)位置,不是顶部。可通过 textBaseline 属性调整(如 top/middle/bottom

font:设置文本的字体样式,语法与 CSS font 属性一致

textAlign :设置文本相对于 x 坐标的对齐方式

ctx.textBaseline = "middle"; // 让y坐标对应文本垂直中心
  const baseX = 300;const baseY = 240;// 左对齐(默认)ctx.font = "18px Arial";ctx.textAlign = "start";ctx.fillStyle = "#3498db";ctx.fillText("左对齐 (start)", baseX, baseY - 40);// 居中对齐ctx.textAlign = "center";ctx.fillStyle = "#2ecc71";ctx.fillText("居中对齐 (center)", baseX, baseY);// 右对齐ctx.textAlign = "end";ctx.fillStyle = "#f39c12";ctx.fillText("右对齐 (end)", baseX, baseY + 40);

1.3 样式与颜色

1.纯色设置(fillStyle/strokeStyle)

  • fillStyle:定义图形内部的填充颜色

  • strokeStyle:定义图形边缘的描边颜色

ctx.fillStyle = "red";//绘制一个填充的矩形ctx.fillRect(100, 100, 100, 100);

 ctx.strokeStyle = "blue";ctx.strokeRect(10, 10, 100, 100);

2.线性渐变(createLinearGradient)

线性渐变是沿直线从一种颜色平滑过渡到另一种(或多种)颜色的效果。

  const linearGrad = ctx.createLinearGradient(50, 100, 250, 100);linearGrad.addColorStop(0, "red");linearGrad.addColorStop(0.5, "yellow");linearGrad.addColorStop(1, "green");ctx.fillStyle = linearGrad;ctx.fillRect(50, 100, 200, 80);

3.径向渐变(createRadialGradient)

径向渐变是从一个圆心向另一个圆心(通常是同一点)以圆形或椭圆形扩散的颜色过渡效果。

ctx.createRadialGradient(x1, y1, r1, x2, y2, r2);
//圆心坐标和半径
  const radialGrad = ctx.createRadialGradient(150, 250, 10, 150, 250, 90);radialGrad.addColorStop(0, "yellow");radialGrad.addColorStop(1, "blue");ctx.fillStyle = radialGrad;ctx.beginPath();ctx.arc(100, 200, 80, 0, 2 * Math.PI);ctx.fill();

4. 线条样式(lineWidth/lineCap/lineJoin)

  ctx.lineWidth = 10; //线条宽度ctx.strokeStyle = "#9b59b6"; //描线颜色ctx.lineCap = "round" //设置线段的顶端图形ctx.lineJoin = "bevel" //线段拐角的样式:miter:默认尖角,round:圆角,bevel:斜角ctx.beginPath();ctx.moveTo(80, 250);ctx.lineTo(400, 250);ctx.stroke(); //渲染一条线ctx.fillStyle = "blue"; //填充颜色ctx.fillText("文本", 80, 240); //文本

1.4 示例

1.数据可视化图表的渐变柱状图

<!DOCTYPE html>
<html>
<head><title>Canvas 数据可视化</title><style>.container { margin: 20px; }canvas { border: 1px solid #e0e0e0; background: #fff; }h3 { color: #333; }</style>
</head>
<body><div class="container"><h3>月度销售额统计</h3><canvas id="chartCanvas" width="800" height="400"></canvas></div><script>const canvas = document.getElementById('chartCanvas');const ctx = canvas.getContext('2d');// 模拟销售数据const data = {labels: ['1月', '2月', '3月', '4月', '5月', '6月'],values: [120, 190, 150, 240, 210, 320],maxValue: 350 // 最大值(用于计算比例)};// 绘制图表function drawChart() {// 1. 绘制背景和网格drawBackground();// 2. 绘制坐标轴drawAxes();// 3. 绘制柱状图drawBars();// 4. 绘制数据标签drawLabels();}// 绘制背景和网格function drawBackground() {// 填充背景ctx.fillStyle = '#f9f9f9';ctx.fillRect(0, 0, canvas.width, canvas.height);// 绘制水平网格线ctx.strokeStyle = '#e0e0e0';ctx.lineWidth = 1;const gridCount = 5;const gridStep = canvas.height / (gridCount + 1);for (let i = 1; i <= gridCount; i++) {const y = canvas.height - gridStep * i;ctx.beginPath();ctx.moveTo(80, y); // 左边距80pxctx.lineTo(canvas.width - 40, y); // 右边距40pxctx.stroke();// 网格值标签ctx.fillStyle = '#666';ctx.font = '12px Arial';ctx.textAlign = 'right';ctx.fillText((data.maxValue / gridCount * i).toFixed(0), 70, y + 4);}}// 绘制坐标轴function drawAxes() {ctx.strokeStyle = '#333';ctx.lineWidth = 2;// X轴ctx.beginPath();ctx.moveTo(80, canvas.height - 40); // 下边距40pxctx.lineTo(canvas.width - 40, canvas.height - 40);ctx.stroke();// Y轴ctx.beginPath();ctx.moveTo(80, 40); // 上边距40pxctx.lineTo(80, canvas.height - 40);ctx.stroke();}// 绘制柱状图function drawBars() {const padding = 40; // 柱子间距const startX = 100; // 起始X坐标const availableWidth = canvas.width - 160; // 可用宽度const barWidth = (availableWidth - padding * (data.labels.length - 1)) / data.labels.length;const maxBarHeight = canvas.height - 120; // 最大柱高data.values.forEach((value, index) => {// 计算柱子位置和高度const x = startX + index * (barWidth + padding);const barHeight = (value / data.maxValue) * maxBarHeight;const y = canvas.height - 40 - barHeight; // 40是下边距// 绘制柱子const gradient = ctx.createLinearGradient(x, y, x, canvas.height - 40);gradient.addColorStop(0, '#4285f4');gradient.addColorStop(1, '#34a853');ctx.fillStyle = gradient;ctx.fillRect(x, y, barWidth, barHeight);// 柱子顶部值ctx.fillStyle = '#333';ctx.font = '14px Arial';ctx.textAlign = 'center';ctx.fillText(value, x + barWidth / 2, y - 10);});}// 绘制标签function drawLabels() {// X轴标签const padding = 40;const startX = 100;const availableWidth = canvas.width - 160;const barWidth = (availableWidth - padding * (data.labels.length - 1)) / data.labels.length;data.labels.forEach((label, index) => {const x = startX + index * (barWidth + padding) + barWidth / 2;ctx.fillStyle = '#333';ctx.font = '14px Arial';ctx.textAlign = 'center';ctx.fillText(label, x, canvas.height - 20);});// 标题ctx.font = '16px Arial bold';ctx.textAlign = 'center';ctx.fillText('月度销售额 (万元)', canvas.width / 2, 30);}// 初始化图表drawChart();</script>
</body>
</html>

2.制作渐变色按钮(矩形 + 文字,添加鼠标悬浮样式)

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Canvas渐变色按钮</title><script src="https://cdn.tailwindcss.com"></script><link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"><style>body {display: flex;justify-content: center;align-items: center;min-height: 100vh;margin: 0;background-color: #f0f0f0;}#buttonCanvas {border: none;cursor: pointer;box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);}</style>
</head>
<body><canvas id="buttonCanvas" width="200" height="60"></canvas><script>// 获取Canvas元素和上下文const canvas = document.getElementById('buttonCanvas');const ctx = canvas.getContext('2d');// 按钮文本const buttonText = '点击按钮';// 初始状态:两种颜色渐变function drawNormalState() {// 清除画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 创建渐变const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);gradient.addColorStop(0, '#4a6cf7');gradient.addColorStop(1, '#1a36b9');// 绘制圆角矩形背景drawRoundedRect(0, 0, canvas.width, canvas.height, 8, gradient, '#ffffff');// 绘制文本ctx.fillStyle = '#ffffff';ctx.font = '16px Arial, sans-serif';ctx.textAlign = 'center';ctx.textBaseline = 'middle';ctx.fillText(buttonText, canvas.width / 2, canvas.height / 2);}// 悬浮状态:三种颜色渐变function drawHoverState() {// 清除画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 创建三种颜色的渐变const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);gradient.addColorStop(0, '#ff6b6b');gradient.addColorStop(0.5, '#feca57');gradient.addColorStop(1, '#48dbfb');// 绘制圆角矩形背景(稍微大一点,创造放大效果)drawRoundedRect(-2, -2, canvas.width + 4, canvas.height + 4, 10, gradient, '#ffffff');// 绘制文本(稍微大一点)ctx.fillStyle = '#ffffff';ctx.font = '17px Arial, sans-serif';ctx.textAlign = 'center';ctx.textBaseline = 'middle';ctx.fillText(buttonText, canvas.width / 2, canvas.height / 2);}// 绘制圆角矩形的辅助函数function drawRoundedRect(x, y, width, height, radius, fillStyle, strokeStyle) {ctx.beginPath();ctx.moveTo(x + radius, y);ctx.lineTo(x + width - radius, y);ctx.arcTo(x + width, y, x + width, y + radius, radius);ctx.lineTo(x + width, y + height - radius);ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);ctx.lineTo(x + radius, y + height);ctx.arcTo(x, y + height, x, y + height - radius, radius);ctx.lineTo(x, y + radius);ctx.arcTo(x, y, x + radius, y, radius);ctx.closePath();if (fillStyle) {ctx.fillStyle = fillStyle;ctx.fill();}if (strokeStyle) {ctx.strokeStyle = strokeStyle;ctx.stroke();}}// 初始绘制drawNormalState();// 鼠标事件监听canvas.addEventListener('mouseenter', drawHoverState);canvas.addEventListener('mouseleave', drawNormalState);canvas.addEventListener('click', () => {alert('按钮被点击了!');});</script>
</body>
</html>

2.动画基础阶段(掌握帧循环与简单交互)

2.1动画循环原理

1. requestAnimationFrame 用法

requestAnimationFrame 是浏览器提供的用于优化动画效果的 API,它能让浏览器根据自身刷新率(通常是 60fps)来调度动画帧,避免不必要的性能消耗。

// 定义动画函数
function animate(timestamp) {// timestamp 是一个时间戳,单位为毫秒,标识当前帧的时间// 1. 执行动画逻辑(如更新元素位置、修改样式等)// 2. 清除上一帧(可选,如 Canvas 动画需要清除画布)// 3. 递归调用,继续下一帧动画requestId = requestAnimationFrame(animate);
}// 启动动画
let requestId = requestAnimationFrame(animate);

 2.帧更新逻辑(清空画布 + 重绘)

// 停止动画
cancelAnimationFrame(requestId);

3.基础运动公式(位置 = 初始位置 + 速度 × 时间)+运动示例

  let x = 0;function animate(timestamp) {// 清除画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制移动的矩形ctx.fillStyle = "blue";ctx.fillRect(x, 50, 50, 50);// 更新位置x = (x + 2) % (canvas.width + 50);// 继续下一帧requestId = requestAnimationFrame(animate);}// 启动动画animate();

2.2 基础物理运动

1. 速度与加速度(如重力 vy += 0.5

  • 速度(Velocity):描述物体运动的快慢和方向,分为水平速度(vx)和垂直速度(vy)。

    • 正数表示向右 / 向下运动,负数表示向左 / 向上运动。

    • 示例:vx = 2 表示物体每帧向右移动 2 像素;vy = -3 表示每帧向上移动 3 像素。

  • 加速度(Acceleration):描述速度的变化率,会持续改变物体的速度。

    • 常见场景:模拟重力(垂直方向加速度)、摩擦力(减速效果)等。

    • 示例:vy += 0.5 表示每帧垂直速度增加 0.5,模拟物体下落时速度越来越快的重力效果。

  • x += vx;  // 水平位置 = 当前位置 + 水平速度
    y += vy;  // 垂直位置 = 当前位置 + 垂直速度
    vy += 0.5; // 应用重力加速度(每帧加速)

2. 边界碰撞检测(判断坐标是否超出画布)

用于判断物体是否超出 Canvas 画布范围,避免物体 "跑出" 可视区域。

比较物体的坐标与画布的宽高边界

//物体半径为r(圆形)或宽高为w/h(矩形):
// 圆形物体碰撞检测(以中心坐标(x,y)为例)if (x - r < 0) { /* 碰左边界 */ }
if (x + r > canvas.width) { /* 碰右边界 */ }
if (y - r < 0) { /* 碰上边界 */ }
if (y + r > canvas.height) { /* 碰下边界 */ }// 矩形物体碰撞检测(以左上角坐标(x,y)为例)
// 宽为canvas.width,高为canvas.height
if (x < 0) { /* 碰左边界 */ }
if (x + w > canvas.width) { /* 碰右边界 */ }
if (y < 0) { /* 碰上边界 */ }
if (y + h > canvas.height) { /* 碰下边界 */ }

3. 反弹逻辑(速度取反 vx = -vx

当物体碰撞边界后,通过改变速度方向实现 "反弹" 效果,让运动更符合物理规律。

碰撞边界时,将对应方向的速度取反(vx = -vx 或 vy = -vy

添加能量损耗(乘以一个小于 1 的系数),让反弹逐渐减弱(更真实)。

// 圆形物体反弹示例
if (x - r < 0) {x = r; // 修正位置(避免物体卡入边界)vx = -vx * 0.8; // 水平速度反向,乘以0.8模拟能量损失
}
if (x + r > canvas.width) {x = canvas.width - r;vx = -vx * 0.8;
}
if (y - r < 0) {y = r;vy = -vy * 0.8;
}
if (y + r > canvas.height) {y = canvas.height - r;vy = -vy * 0.8;
}

4.示例:多个小球在画布中移动并碰撞反弹,带有能量损失和重力效果。

<!DOCTYPE html>
<html>
<head><title>碰撞检测动画</title><style>canvas { background: #fef3c7; display: block; }body { margin: 0; }.info { position: fixed; top: 10px; left: 0; width: 100%; color: #78350f; text-align: center; font-family: sans-serif; }</style>
</head>
<body><div class="info">点击添加新球 | 小球会碰撞反弹</div><canvas id="collisionCanvas"></canvas><script>const canvas = document.getElementById('collisionCanvas');const ctx = canvas.getContext('2d');// 设置全屏canvas.width = window.innerWidth;canvas.height = window.innerHeight;window.addEventListener('resize', () => {canvas.width = window.innerWidth;canvas.height = window.innerHeight;});const balls = [];const gravity = 0.2;const friction = 0.99; // 摩擦系数(能量损失)const restitution = 0.8; // 弹性系数(碰撞后速度保留比例)// 球类class Ball {constructor(x, y, size = null) {this.x = x;this.y = y;this.size = size || Math.random() * 20 + 10; // 10-30pxthis.mass = this.size * 0.1; // 质量与大小成正比this.vx = (Math.random() - 0.5) * 8; // 初始水平速度this.vy = (Math.random() - 0.5) * 8; // 初始垂直速度this.color = `hsl(${Math.random() * 360}, 70%, 60%)`;this.alpha = 0.8;}update() {// 应用重力this.vy += gravity;// 应用摩擦this.vx *= friction;this.vy *= friction;// 更新位置this.x += this.vx;this.y += this.vy;// 边界碰撞检测this.checkWallCollision();}// 墙壁碰撞checkWallCollision() {// 左右边界if (this.x - this.size < 0) {this.x = this.size;this.vx = -this.vx * restitution;} else if (this.x + this.size > canvas.width) {this.x = canvas.width - this.size;this.vx = -this.vx * restitution;}// 上下边界if (this.y - this.size < 0) {this.y = this.size;this.vy = -this.vy * restitution;} else if (this.y + this.size > canvas.height) {this.y = canvas.height - this.size;this.vy = -this.vy * restitution;// 在地面上时进一步减小水平速度(模拟地面摩擦更大)this.vx *= 0.95;}}// 球与球碰撞检测checkBallCollision(otherBall) {const dx = otherBall.x - this.x;const dy = otherBall.y - this.y;const distance = Math.sqrt(dx * dx + dy * dy);const minDistance = this.size + otherBall.size;// 如果两球相撞if (distance < minDistance) {// 计算碰撞法线const nx = dx / distance;const ny = dy / distance;// 计算相对速度const dvx = otherBall.vx - this.vx;const dvy = otherBall.vy - this.vy;const speedAlongNormal = dvx * nx + dvy * ny;// 只有相互靠近时才处理碰撞if (speedAlongNormal > 0) return;// 计算冲量const j = -(1 + restitution) * speedAlongNormal;const jDividedByMassSum = j / (this.mass + otherBall.mass);// 更新速度this.vx -= jDividedByMassSum * otherBall.mass * nx;this.vy -= jDividedByMassSum * otherBall.mass * ny;otherBall.vx += jDividedByMassSum * this.mass * nx;otherBall.vy += jDividedByMassSum * this.mass * ny;// 分离两球(防止卡住)const overlap = 0.5 * (minDistance - distance + 1);this.x -= overlap * nx;this.y -= overlap * ny;otherBall.x += overlap * nx;otherBall.y += overlap * ny;}}draw() {// 绘制球ctx.beginPath();ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);// 添加渐变效果const gradient = ctx.createRadialGradient(this.x - this.size * 0.3,this.y - this.size * 0.3,0,this.x, this.y,this.size);gradient.addColorStop(0, 'rgba(255, 255, 255, 0.8)');gradient.addColorStop(1, this.color);ctx.fillStyle = gradient;ctx.fill();// 球边框ctx.strokeStyle = this.color;ctx.lineWidth = 2;ctx.stroke();}}// 初始化球function initBalls(count = 10) {for (let i = 0; i < count; i++) {// 确保球不会初始就重叠let ball;let validPosition = false;while (!validPosition) {ball = new Ball(Math.random() * (canvas.width - 60) + 30,Math.random() * (canvas.height - 60) + 30);validPosition = true;for (const b of balls) {const dx = b.x - ball.x;const dy = b.y - ball.y;const distance = Math.sqrt(dx * dx + dy * dy);if (distance < b.size + ball.size) {validPosition = false;break;}}}balls.push(ball);}}// 处理点击添加新球canvas.addEventListener('click', (e) => {balls.push(new Ball(e.clientX, e.clientY, Math.random() * 15 + 10));});// 动画循环function animate() {ctx.clearRect(0, 0, canvas.width, canvas.height);// 更新和绘制所有球balls.forEach(ball => {ball.update();ball.draw();});// 检测所有球之间的碰撞for (let i = 0; i < balls.length; i++) {for (let j = i + 1; j < balls.length; j++) {balls[i].checkBallCollision(balls[j]);}}requestAnimationFrame(animate);}// 初始化initBalls();animate();</script>
</body>
</html>
  • 速度(vx/vy)决定物体移动的快慢和方向。

  • 加速度(如vy += 0.5)模拟重力等持续作用力,改变速度。

  • 边界检测通过坐标对比判断物体是否碰撞边界。

  • 反弹逻辑通过速度取反(vx = -vx)实现碰撞后的方向改变,配合摩擦系数让运动更真实。

2.3 基础交互

1. 鼠标事件(mousemove/click 坐标获取)

获取鼠标在 Canvas 中的相对坐标。

关键事件与用法
  • 常用事件mousedown(按下)、mousemove(移动)、mouseup(松开)、click(点击)

  • 坐标获取
    鼠标事件对象的 clientX/clientY 是相对于浏览器视口的坐标,需转换为 Canvas 内部坐标:

    • const rect = canvas.getBoundingClientRect(); // 获取Canvas的位置和尺寸
      const x = e.clientX - rect.left; // Canvas内部X坐标
      const y = e.clientY - rect.top;  // Canvas内部Y坐标
示例:鼠标跟随的圆形

// 鼠标移动时绘制跟随的圆
canvas.addEventListener('mousemove', (e) => {const rect = canvas.getBoundingClientRect();const x = e.clientX - rect.left;const y = e.clientY - rect.top;// 清空画布并绘制新圆ctx.clearRect(0, 0, canvas.width, canvas.height);ctx.beginPath();ctx.arc(x, y, 20, 0, 2 * Math.PI);ctx.fillStyle = 'blue';ctx.fill();
});


2. 键盘事件(keydown 控制方向)

键盘事件用于监听按键输入,常用来控制元素移动(如游戏中的上下左右)。

关键事件与用法
  • 常用事件keydown(按键按下)、keyup(按键松开)

  • 方向判断:通过事件对象的 key 属性或 keyCode 判断按键(如方向键 ArrowUp/ArrowDown

示例:键盘控制方块移动
let x = 50, y = 50; // 方块初始位置
const speed = 5;    // 移动速度// 监听键盘按下
document.addEventListener('keydown', (e) => {switch(e.key) {case 'ArrowUp':y -= speed;break;case 'ArrowDown':y += speed;break;case 'ArrowLeft':x -= speed;break;case 'ArrowRight':x += speed;break;}// 限制方块在画布内x = Math.max(0, Math.min(canvas.width - 40, x));y = Math.max(0, Math.min(canvas.height - 40, y));redraw();
});// 重绘函数
function redraw() {ctx.clearRect(0, 0, canvas.width, canvas.height);ctx.fillStyle = 'red';ctx.fillRect(x, y, 40, 40); // 绘制方块
}

3. 触摸事件(touchstart/touchmove 适配移动端)

触摸事件用于移动端交互,与鼠标事件类似但支持多点触摸。

关键事件与坐标获取
  • 常用事件touchstart(触摸开始)、touchmove(触摸移动)、touchend(触摸结束)

  • 坐标获取:触摸事件的坐标存储在 touches 列表中(支持多指):

    const rect = canvas.getBoundingClientRect();
    const touch = e.touches[0]; // 获取第一个触摸点
    const x = touch.clientX - rect.left;
    const y = touch.clientY - rect.top;
    
  • 注意:需调用 e.preventDefault() 防止触摸时页面滚动

示例:移动端触摸绘制
let isDrawing = false;// 触摸开始
canvas.addEventListener('touchstart', (e) => {e.preventDefault(); // 阻止页面滚动const rect = canvas.getBoundingClientRect();const touch = e.touches[0];const x = touch.clientX - rect.left;const y = touch.clientY - rect.top;isDrawing = true;ctx.beginPath();ctx.moveTo(x, y); // 开始绘制路径
});// 触摸移动
canvas.addEventListener('touchmove', (e) => {if (!isDrawing) return;e.preventDefault();const rect = canvas.getBoundingClientRect();const touch = e.touches[0];const x = touch.clientX - rect.left;const y = touch.clientY - rect.top;ctx.lineTo(x, y); // 继续绘制路径ctx.strokeStyle = 'green';ctx.lineWidth = 3;ctx.stroke();
});// 触摸结束
canvas.addEventListener('touchend', () => {isDrawing = false;
});

4.示例:方向键控制移动,收集绿色方块得分

<!DOCTYPE html>
<html>
<head><title>Canvas游戏场景</title><style>canvas { border: 3px solid #333; background: #1a1a1a; }.info { color: #fff; background: #333; padding: 10px; margin-bottom: 10px; }</style>
</head>
<body><div class="info">方向键控制移动,收集绿色方块得分 | 得分: <span id="score">0</span></div><canvas id="gameCanvas" width="800" height="600"></canvas><script>const canvas = document.getElementById('gameCanvas');const ctx = canvas.getContext('2d');const scoreElement = document.getElementById('score');let score = 0;// 玩家const player = {x: canvas.width / 2,y: canvas.height / 2,width: 30,height: 30,speed: 5,color: '#4af',dx: 0,dy: 0};// 收集物const collectibles = [];const collectibleCount = 8;// 初始化收集物function initCollectibles() {for (let i = 0; i < collectibleCount; i++) {collectibles.push({x: Math.random() * (canvas.width - 20) + 10,y: Math.random() * (canvas.height - 20) + 10,size: 20,color: '#4f4'});}}// 绘制玩家function drawPlayer() {ctx.fillStyle = player.color;// 绘制三角形玩家ctx.beginPath();ctx.moveTo(player.x, player.y - player.height/2);ctx.lineTo(player.x - player.width/2, player.y + player.height/2);ctx.lineTo(player.x + player.width/2, player.y + player.height/2);ctx.closePath();ctx.fill();}// 绘制收集物function drawCollectibles() {collectibles.forEach(item => {ctx.fillStyle = item.color;ctx.fillRect(item.x - item.size/2, item.y - item.size/2, item.size, item.size);});}// 更新玩家位置function updatePlayer() {player.x += player.dx;player.y += player.dy;// 边界限制if (player.x - player.width/2 < 0) player.x = player.width/2;if (player.x + player.width/2 > canvas.width) player.x = canvas.width - player.width/2;if (player.y - player.height/2 < 0) player.y = player.height/2;if (player.y + player.height/2 > canvas.height) player.y = canvas.height - player.height/2;}// 检测收集碰撞function checkCollection() {for (let i = collectibles.length - 1; i >= 0; i--) {const item = collectibles[i];// 矩形碰撞检测if (player.x - player.width/2 < item.x + item.size/2 &&player.x + player.width/2 > item.x - item.size/2 &&player.y - player.height/2 < item.y + item.size/2 &&player.y + player.height/2 > item.y - item.size/2) {// 收集成功collectibles.splice(i, 1);score += 10;scoreElement.textContent = score;// 补充新收集物if (collectibles.length < 5) {initCollectibles();}}}}// 绘制场景function draw() {// 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制背景网格drawGrid();// 绘制游戏元素drawCollectibles();drawPlayer();// 更新游戏状态updatePlayer();checkCollection();requestAnimationFrame(draw);}// 绘制背景网格function drawGrid() {ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';ctx.lineWidth = 1;// 横线for (let y = 0; y < canvas.height; y += 40) {ctx.beginPath();ctx.moveTo(0, y);ctx.lineTo(canvas.width, y);ctx.stroke();}// 竖线for (let x = 0; x < canvas.width; x += 40) {ctx.beginPath();ctx.moveTo(x, 0);ctx.lineTo(x, canvas.height);ctx.stroke();}}// 键盘控制function keyDown(e) {if (e.key === 'ArrowRight' || e.key === 'Right') {player.dx = player.speed;} else if (e.key === 'ArrowLeft' || e.key === 'Left') {player.dx = -player.speed;} else if (e.key === 'ArrowUp' || e.key === 'Up') {player.dy = -player.speed;} else if (e.key === 'ArrowDown' || e.key === 'Down') {player.dy = player.speed;}}function keyUp(e) {if ((e.key === 'ArrowRight' || e.key === 'Right') && player.dx > 0 ||(e.key === 'ArrowLeft' || e.key === 'Left') && player.dx < 0) {player.dx = 0;} else if ((e.key === 'ArrowUp' || e.key === 'Up') && player.dy < 0 ||(e.key === 'ArrowDown' || e.key === 'Down') && player.dy > 0) {player.dy = 0;}}// 事件监听document.addEventListener('keydown', keyDown);document.addEventListener('keyup', keyUp);// 初始化游戏initCollectibles();draw();</script>
</body>
</html>

3.中级进阶阶段(掌握复杂特效与批量元素管理)

3.1 粒子系统基础

1. 粒子类设计(属性:x/y/ 速度 / 颜色 / 生命周期)

粒子类的设计遵循 “封装性” 原则,将每个粒子的状态(属性) 和动作(方法) 整合到一个类中。核心属性围绕 “粒子如何存在” 和 “如何运动” 展开,核心方法围绕 “如何更新状态” 和 “如何展示自己” 展开。


核心属性
  1. 空间坐标(x, y):粒子在二维坐标系中的位置向量,决定粒子的空间定位

  2. 运动矢量(velocity):包含水平速度(vx)和垂直速度(vy)的二维向量,控制粒子位移速率与方向

  3. 视觉属性(color):可以是 RGB、HSV 或十六进制值,定义粒子的视觉呈现

  4. 生命周期(lifespan):通常以帧计数或时间戳表示,用于控制粒子从生成到消亡的存续过程

  5. 辅助属性:可能包括大小(size)、旋转(rotation)、透明度(alpha)等增强视觉表现的参数


核心行为
  1. 初始化方法(constructor):设置粒子初始状态,包括随机化部分属性以产生多样性
  2. 更新方法(update):每帧更新粒子状态,包含位置计算、速度变化和生命周期衰减
  3. 渲染方法(draw/render):将粒子绘制到渲染上下文(如 Canvas)
  4. 存活检测(isAlive):判断粒子是否超出生命周期或边界,决定是否移除

示例:雨滴粒子系统

解析

  1. 属性设计

    • x/y:控制雨滴在画布中的位置,初始 y 坐标设为负值实现从屏幕顶部落下的效果

    • vx/vy:分别控制水平和垂直速度,vy值较大确保雨滴主要向下运动

    • color:使用半透明蓝色模拟雨滴的透明感

    • length:作为视觉属性同时参与生命周期判断,life属性标记存活状态

  2. 方法作用

    • constructor:通过随机值初始化属性,使雨滴呈现自然变化的效果

    • update:每帧更新位置并检测是否超出画布边界(生命周期结束条件)

    • draw:将雨滴绘制为线段,视觉上更接近真实雨滴

    • isAlive:提供简单的存活判断接口

  3. 系统设计

    • 维护一个粒子数组raindrops统一管理所有雨滴

    • 通过spawnRaindrops控制粒子数量,避免性能问题

    • 使用requestAnimationFrame实现平滑动画循环

<!DOCTYPE html>
<html>
<head><title>雨滴粒子系统</title><style>body { margin: 0; background: #1a1a1a; }canvas { display: block; }</style>
</head>
<body><canvas id="canvas"></canvas><script>// 粒子类定义class Raindrop {// 初始化粒子属性constructor(canvasWidth) {// 位置属性:随机x坐标,从顶部生成this.x = Math.random() * canvasWidth;this.y = -Math.random() * 100; // 从画布外上方开始// 速度属性:垂直下落速度,带轻微水平偏移this.vx = (Math.random() - 0.5) * 2; // 左右微小偏移this.vy = 5 + Math.random() * 8; // 下落速度(5-13之间)// 颜色属性:半透明蓝色this.color = 'rgba(100, 149, 237, 0.8)';// 生命周期属性:以雨滴长度表示this.length = 10 + Math.random() * 20; // 雨滴长度this.life = 1; // 存活状态标记}// 更新粒子状态update(canvasHeight) {// 更新位置this.x += this.vx;this.y += this.vy;// 检测是否超出画布(生命周期结束)if (this.y > canvasHeight) {this.life = 0;}}// 绘制粒子draw(ctx) {ctx.beginPath();ctx.moveTo(this.x, this.y);ctx.lineTo(this.x, this.y + this.length); // 绘制雨滴线段ctx.strokeStyle = this.color;ctx.lineWidth = 2;ctx.stroke();}// 判断是否存活isAlive() {return this.life > 0;}}// 初始化画布const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');canvas.width = window.innerWidth;canvas.height = window.innerHeight;// 粒子管理数组const raindrops = [];// 生成新粒子function spawnRaindrops() {// 控制雨滴密度if (raindrops.length < 100) {raindrops.push(new Raindrop(canvas.width));}}// 动画循环function animate() {// 清空画布ctx.fillStyle = 'rgba(26, 26, 26, 0.15)';ctx.fillRect(0, 0, canvas.width, canvas.height);// 生成新雨滴spawnRaindrops();// 更新并绘制所有雨滴for (let i = raindrops.length - 1; i >= 0; i--) {const drop = raindrops[i];drop.update(canvas.height);drop.draw(ctx);// 移除消亡的雨滴if (!drop.isAlive()) {raindrops.splice(i, 1);}}requestAnimationFrame(animate);}// 启动动画animate();// 窗口大小调整window.addEventListener('resize', () => {canvas.width = window.innerWidth;canvas.height = window.innerHeight;});</script>
</body>
</html>

2. 批量粒子管理(数组存储 + 循环更新)

在粒子系统开发中,当需要同时控制成百上千个粒子时,批量粒子管理是核心技术。其核心逻辑是通过数组存储所有粒子实例,再通过循环遍历实现批量更新与渲染,最终高效实现复杂粒子效果(如火焰、烟雾、爆炸等)。

 核心概念定义
术语专业解释作用
粒子实例池(Particle Pool)用数组(或列表)存储所有活跃的粒子对象,统一管理粒子的创建、更新、渲染和销毁避免零散管理粒子导致的逻辑混乱,便于批量操作
批量更新(Batch Update)遍历粒子数组,对每个粒子执行状态更新(如位置、速度、生命周期、颜色变化等)确保所有粒子按统一节奏遵循物理规则(如重力、阻力),保持运动一致性
批量渲染(Batch Render)遍历粒子数组,对每个存活的粒子执行绘制操作(如绘制圆形、矩形或纹理)统一控制粒子的视觉呈现,避免重复初始化绘图上下文,提升性能
反向遍历销毁(Reverse Traversal Destruction)从粒子数组末尾向前遍历,移除生命周期结束的粒子(避免正向遍历删除元素导致的索引错乱)安全高效地清理无效粒子,释放内存,防止数组冗余
粒子回收(Particle Recycling)不直接删除过期粒子,而是将其标记为 “闲置”,后续创建新粒子时复用闲置对象减少频繁创建 / 删除对象的性能开销(尤其在高并发粒子场景)
技术优势
  • 性能优化:数组的随机访问特性(O (1) 时间复杂度)让粒子的查询、更新更高效,避免链表等结构的遍历开销;

  • 逻辑统一:所有粒子的生命周期(创建→更新→渲染→销毁)由统一逻辑控制,便于调试和扩展(如添加全局风力、重力开关);

  • 内存可控:通过批量销毁和回收,避免内存泄漏,确保粒子数量在合理范围(尤其适合移动端等性能敏感场景)。

示例:雨粒子系统

思路解析:

  1. 数组存储机制

    • 使用this.particles = []作为所有粒子的容器

    • 初始化时通过循环创建maxParticles数量的粒子并加入数组

    • 动态添加粒子通过push()方法实现,受限于最大数量控制

  2. 循环更新策略

    • updateParticles()方法通过for循环遍历数组,调用每个粒子的update()方法

    • 统一处理所有粒子的位置更新和边界检测

    • 雨滴超出画布后重置位置,实现循环利用效果

  3. 批量渲染流程

    • renderParticles()方法先清空画布,再遍历数组调用每个粒子的draw()方法

    • 保证同一帧内完成所有粒子绘制,避免视觉撕裂

  4. 性能控制

    • 设置maxParticles限制总数量,防止粒子过多导致性能下降

    • 通过简单的重置机制(而非删除重建)提高粒子复用效率

<!DOCTYPE html>
<html>
<head><title>批量粒子管理示例</title><style>body { margin: 0; }canvas { background: #1a1a1a; }</style>
</head>
<body><canvas id="canvas"></canvas><script>// 1. 粒子类定义class Raindrop {constructor(canvasWidth) {// 初始化粒子属性this.x = Math.random() * canvasWidth; // 随机x坐标this.y = Math.random() * -100; // 从画布上方开始this.length = 5 + Math.random() * 15; // 雨滴长度this.speed = 3 + Math.random() * 8; // 下落速度this.alpha = 0.3 + Math.random() * 0.7; // 透明度}// 更新粒子状态update(canvasHeight) {this.y += this.speed; // 下落// 超出画布底部则重置位置(循环利用)if (this.y > canvasHeight) {this.y = Math.random() * -100;this.x = Math.random() * canvasWidth;}}// 绘制粒子draw(ctx) {ctx.save();ctx.strokeStyle = `rgba(173, 216, 230, ${this.alpha})`; // 浅蓝色ctx.lineWidth = 1;ctx.beginPath();ctx.moveTo(this.x, this.y);ctx.lineTo(this.x, this.y + this.length);ctx.stroke();ctx.restore();}}// 2. 粒子管理器class ParticleManager {constructor() {this.canvas = document.getElementById('canvas');this.ctx = this.canvas.getContext('2d');this.resizeCanvas();window.addEventListener('resize', () => this.resizeCanvas());this.particles = []; // 粒子数组(核心存储容器)this.maxParticles = 200; // 最大粒子数量// 初始化粒子this.initParticles();}// 调整画布大小resizeCanvas() {this.canvas.width = window.innerWidth;this.canvas.height = window.innerHeight;}// 初始化粒子数组initParticles() {// 批量创建粒子并加入数组for (let i = 0; i < this.maxParticles; i++) {this.particles.push(new Raindrop(this.canvas.width));}}// 批量更新所有粒子updateParticles() {// 遍历数组更新每个粒子for (let i = 0; i < this.particles.length; i++) {this.particles[i].update(this.canvas.height);}}// 批量渲染所有粒子renderParticles() {// 清空画布this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);// 遍历数组绘制每个粒子for (let i = 0; i < this.particles.length; i++) {this.particles[i].draw(this.ctx);}}// 添加新粒子(动态增加)addParticle() {if (this.particles.length < this.maxParticles) {this.particles.push(new Raindrop(this.canvas.width));}}// 动画循环animate() {this.updateParticles(); // 批量更新this.renderParticles(); // 批量渲染requestAnimationFrame(() => this.animate());}}// 3. 启动粒子系统const rainSystem = new ParticleManager();rainSystem.animate();// 点击增加雨滴(演示动态添加)rainSystem.canvas.addEventListener('click', () => {for (let i = 0; i < 10; i++) {rainSystem.addParticle();}});</script>
</body>
</html>

3. 粒子回收与性能优化(避免内存泄漏)

粒子回收与性能优化是游戏 / 动画开发中基于对象池模式(Object Pool Pattern) 的资源管理策略,核心目的是减少垃圾回收(GC, Garbage Collection) 频率、避免内存泄漏,提升系统稳定性。

想象你在举办派对(相当于动画运行),需要很多杯子(相当于粒子)装饮料。

  • 不回收的情况:每次需要杯子就买新的(创建新粒子),用完就扔掉(销毁)。频繁买和扔不仅浪费钱(性能开销),垃圾桶堆满还需要清洁工频繁来清理(GC 触发),影响派对进行(动画卡顿)。

  • 回收的情况:准备一个收纳盒(对象池),杯子用完后洗干净(重置属性)放回盒子,需要时再拿出来用。这样既不用反复买新杯子(减少创建开销),清洁工也不用频繁来(减少 GC),收纳盒的大小还能控制(避免内存暴涨)。

示例:通过对象池存储消亡的粒子,需要时重置复用,避免频繁创建新对象。
  • 核心思想:用 “复用” 代替 “创建 / 销毁”,通过对象池管理粒子生命周期。

  • 性能收益:减少内存分配开销、降低 GC 压力、避免内存泄漏,动画更流畅。

  • 适用场景:粒子系统、子弹发射、特效动画等需要频繁创建 / 销毁对象的场景。

<canvas id="canvas" width="600" height="400"></canvas>
<script>const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');let activeParticles = []; // 活跃粒子let particlePool = []; // 粒子对象池(存储可复用的消亡粒子)// 粒子类(增加reset方法用于复用)class Particle {constructor() {// 初始化时不赋值,通过reset设置}// 重置粒子属性(关键:复用前重置为初始状态)reset(x, y) {this.x = x;this.y = y;this.vy = Math.random() * 3;this.life = 1;}update() {this.y += this.vy;this.life -= 0.02;}draw() {ctx.fillStyle = `rgba(255, 100, 100, ${this.life})`;ctx.beginPath();ctx.arc(this.x, this.y, 5, 0, Math.PI * 2);ctx.fill();}isDead() {return this.life <= 0;}}// 从对象池获取粒子(没有则创建新的)function getParticle(x, y) {let p;if (particlePool.length > 0) {p = particlePool.pop(); // 从池里取} else {p = new Particle(); // 池为空时创建新的}p.reset(x, y); // 重置属性return p;}// 回收粒子到对象池function recycleParticle(p) {particlePool.push(p); // 放入池,不删除}// 动画循环function animate() {ctx.clearRect(0, 0, 600, 400);// 生成新粒子(复用对象池中的粒子)for (let i = 0; i < 5; i++) {activeParticles.push(getParticle(Math.random() * 600, 0));}// 更新并绘制粒子for (let i = activeParticles.length - 1; i >= 0; i--) {const p = activeParticles[i];p.update();p.draw();// 粒子消亡后回收(不删除,放入池)if (p.isDead()) {activeParticles.splice(i, 1); // 从活跃列表移除recycleParticle(p); // 放入对象池}}requestAnimationFrame(animate);}animate();
</script>

3.2 路径动画

1. 贝塞尔曲线(quadraticCurveTo/bezierCurveTo

入门阶段得复杂图形有解释和示例。


2. 路径点计算(按比例映射 t 值 0→1)

贝塞尔曲线上的任意点可通过「参数 t」计算,t 值的范围是 0 到 1,代表 “从起点到终点的比例”:

  • t=0 对应起点,t=1 对应终点;

  • t=0.5 对应曲线中点(非几何中点,由曲线形态决定);

  • 通过 t 按比例映射,可获取曲线上任意位置的点(例如动画中物体沿曲线运动的位置)

二次贝塞尔曲线的点计算

已知起点 S(sx, sy)、控制点 C(cx, cy)、终点 E(ex, ey),曲线上任意点 P(t) 的坐标公式:

  • 公式含义:通过 t 混合起点、控制点、终点的坐标,t 越大,终点权重越高。

Px(t) = (1-t)²·sx + 2t(1-t)·cx + t²·ex  
Py(t) = (1-t)²·sy + 2t(1-t)·cy + t²·ey  
示例:根据 t 计算曲线上的点
// 二次贝塞尔曲线点计算函数
function getQuadraticPoint(t, p0, p1, p2) {const x = (1 - t) ** 2 * p0.x + 2 * (1 - t) * t * p1.x + t ** 2 * p2.x;const y = (1 - t) ** 2 * p0.y + 2 * (1 - t) * t * p1.y + t ** 2 * p2.y;return { x, y };
}// 定义曲线的三个点
const p0 = { x: 50, y: 200 };
const p1 = { x: 250, y: 50 };
const p2 = { x: 450, y: 200 };// 计算不同t对应的点
console.log(getQuadraticPoint(0, p0, p1, p2)); // {x:50, y:200} → 起点
console.log(getQuadraticPoint(0.2, p0, p1, p2)); // {x:122, y:148} → 20%进度
console.log(getQuadraticPoint(0.5, p0, p1, p2)); // {x:250, y:112.5} → 50%进度(最高点)
console.log(getQuadraticPoint(0.8, p0, p1, p2)); // {x:378, y:148} → 80%进度
console.log(getQuadraticPoint(1, p0, p1, p2)); // {x:450, y:200} → 终点
t 与时间绑定(让点沿曲线动起来)
<canvas id="moveCanvas" width="500" height="300" style="border:1px solid #000;"></canvas>
<script>
const ctx = document.getElementById('moveCanvas').getContext('2d');
const p0 = { x: 50, y: 200 };
const p1 = { x: 250, y: 50 };
const p2 = { x: 450, y: 200 };
let t = 0; // 初始进度// 计算点+绘制的循环函数
function animate() {// 1. 清空画布ctx.clearRect(0, 0, 500, 300);// 2. 画曲线(参考线)ctx.beginPath();ctx.moveTo(p0.x, p0.y);ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y);ctx.strokeStyle = 'gray';ctx.stroke();// 3. 计算当前t对应的点const currentPoint = getQuadraticPoint(t, p0, p1, p2);// 4. 画当前点(红色实心圆)ctx.beginPath();ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2);ctx.fillStyle = 'red';ctx.fill();// 5. 更新t(循环:t到1后重置为0)t += 0.01;if (t > 1) t = 0;// 6. 每16ms执行一次(约60帧/秒,流畅)requestAnimationFrame(animate);
}// 复用之前的点计算函数
function getQuadraticPoint(t, p0, p1, p2) {const x = (1 - t) ** 2 * p0.x + 2 * (1 - t) * t * p1.x + t ** 2 * p2.x;const y = (1 - t) ** 2 * p0.y + 2 * (1 - t) * t * p1.y + t ** 2 * p2.y;return { x, y };
}// 启动动画
animate();
</script>

3. 方向跟随(根据路径切线调整元素旋转)

计算曲线在当前点的 “切线方向”,然后让元素绕该点旋转对应的角度

如何计算切线角度?

切线方向的本质是 “曲线在当前点的斜率”,斜率可以通过贝塞尔曲线的导数计算(导数代表 “变化率”,即方向):

  • 二次贝塞尔曲线的导数(切线方向):
    dx/dt = 2(1-t)(P1.x - P0.x) + 2t(P2.x - P1.x)
    dy/dt = 2(1-t)(P1.y - P0.y) + 2t(P2.y - P1.y)
    (dx/dt 是 x 方向的变化率,dy/dt 是 y 方向的变化率)

  • 角度计算:通过Math.atan2(dy, dx)得到切线与 x 轴正方向的夹角(弧度制),再转成角度制(rad * 180 / Math.PI)。

示例:让汽车图标沿曲线运动并跟随方向
<canvas id="carCanvas" width="500" height="300" style="border:1px solid #000;"></canvas>
<!-- 汽车图标(简化为三角形,实际可用图片) -->
<script>
const ctx = document.getElementById('carCanvas').getContext('2d');
const p0 = { x: 50, y: 200 };
const p1 = { x: 250, y: 50 };
const p2 = { x: 450, y: 200 };
let t = 0;// 动画循环
function animate() {ctx.clearRect(0, 0, 500, 300);// 1. 画参考曲线ctx.beginPath();ctx.moveTo(p0.x, p0.y);ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y);ctx.strokeStyle = 'gray';ctx.stroke();// 2. 计算当前点坐标和切线方向const { point, angle } = getQuadraticPointWithAngle(t, p0, p1, p2);// 3. 画汽车(三角形),并按angle旋转ctx.save(); // 保存当前画布状态ctx.translate(point.x, point.y); // 把旋转中心移到当前点ctx.rotate(angle); // 按切线角度旋转// 画三角形(车头朝右,旋转后会面朝切线方向)ctx.beginPath();ctx.moveTo(15, 0); // 车头(右)ctx.lineTo(-10, 10); // 左后轮ctx.lineTo(-10, -10); // 右后轮ctx.closePath();ctx.fillStyle = 'blue';ctx.fill();ctx.restore(); // 恢复画布状态// 更新tt += 0.01;if (t > 1) t = 0;requestAnimationFrame(animate);
}// 计算二次贝塞尔曲线的“当前点”和“切线角度”
function getQuadraticPointWithAngle(t, p0, p1, p2) {// 1. 计算当前点坐标(复用之前的逻辑)const x = (1 - t) ** 2 * p0.x + 2 * (1 - t) * t * p1.x + t ** 2 * p2.x;const y = (1 - t) ** 2 * p0.y + 2 * (1 - t) * t * p1.y + t ** 2 * p2.y;// 2. 计算切线方向(导数)const dx = 2 * (1 - t) * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);const dy = 2 * (1 - t) * (p1.y - p0.y) + 2 * t * (p2.y - p1.y);// 3. 计算角度(弧度→角度,atan2(dy, dx)是关键)const angle = Math.atan2(dy, dx); // 弧度制// (可选)如果元素初始方向不对,可加角度偏移,比如 + Math.PI/2 让车头朝上return { point: { x, y }, angle };
}animate();
</script>

3.3 像素操作

1. 像素数据读取(getImageData)——“拆图看细节”

概念:

getImageData(x, y, width, height) 是 Canvas 2D 上下文的核心方法,作用是从 Canvas 指定区域(左上角坐标 (x,y),宽高 width/height)读取像素信息,返回一个 ImageData 对象。

这个对象里的 data 属性是一个 Uint8ClampedArray 类型的数组(只能存 0-255 的整数),数组结构按 “每个像素的 RGBA 通道” 顺序排列:
[R1, G1, B1, A1, R2, G2, B2, A2, ...]

  • R/G/B:红 / 绿 / 蓝通道,0 = 无此颜色,255 = 颜色最浓(比如 R=255 是纯红);

  • A:透明度通道,0 = 完全透明,255 = 完全不透明;

  • 每个像素占数组的 4 个位置(RGBA 各 1 个),比如 100 个像素的 data 数组长度是 400。

把 Canvas 想象成一张 “彩色马赛克画”,每个马赛克小块就是一个像素。getImageData 相当于 “从画中抠出一块区域,把每个小块的‘颜色配方’(RGBA)记在小本子上”,方便后续修改。

示例:读取图片像素并显示信息

加载一张图片到 Canvas,点击图片任意位置,读取该点的像素颜色并显示

<canvas id="canvas" width="400" height="300" style="border:1px solid #000;"></canvas>
<p>点击画布查看像素颜色:<span id="colorInfo"></span></p><script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const colorInfo = document.getElementById('colorInfo');// 先在画布上画一个渐变色背景(方便测试取色)
const gradient = ctx.createLinearGradient(0, 0, 400, 300);
gradient.addColorStop(0, 'red');
gradient.addColorStop(0.5, 'green');
gradient.addColorStop(1, 'blue');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 400, 300);// 点击画布取色
canvas.addEventListener('click', (e) => {// 1. 获取点击位置相对于画布的坐标(避免画布有偏移)const rect = canvas.getBoundingClientRect();const x = e.clientX - rect.left;const y = e.clientY - rect.top;// 2. 读取“1×1像素”的区域(只读取点击的那一个像素)const imageData = ctx.getImageData(x, y, 1, 1);const data = imageData.data; // 数组:[R, G, B, A]// 3. 提取 RGBA 值const r = data[0];const g = data[1];const b = data[2];const a = data[3] / 255; // 透明度转成 0-1 小数(更易理解)// 4. 显示结果(同时显示十六进制颜色,更贴近实际使用)const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;colorInfo.textContent = `RGBA(${r}, ${g}, ${b}, ${a.toFixed(2)}) | 十六进制:${hexColor}`;
});
</script>


2. 像素数据修改(RGB 通道调整)

简单说,就是 “拆解开图像的颜色积木,重新调配出想要的效果”

  • R 通道:单独控制像素中 “红色” 的亮度,数值范围通常是 0~255(0 = 完全没红色,255 = 最浓的红色);

  • G 通道:单独控制 “绿色” 的亮度,数值同样 0~255;

  • B 通道:单独控制 “蓝色” 的亮度,数值 0~255;

  • (补充:有些图像还有 A 通道,控制 “透明度”,0 = 完全透明,255 = 完全不透明,常说的 RGBA 就是这四个通道)

RGB 通道调整的 3 种常见方式

单通道增强 / 减弱:突出或压制某一种颜色


单独修改 R、G、B 中某一个通道的数值(比如把 R 通道所有像素的数值 + 50,或 G 通道所有像素的数值 - 30),打破原有的 RGB 平衡,让目标颜色更突出或更暗淡。

通道反向(反相):生成 “底片效果”


将某通道(或所有通道)的数值 “反转”—— 公式是 新数值 = 255 - 原数值。比如原 R=100,反相后 R=155;原 G=255,反相后 G=0。

就像看镜子:原本 “白墙(255,255,255)” 在镜子里变成 “黑墙(0,0,0)”,原本 “黑字(0,0,0)” 变成 “白字(255,255,255)”,所有颜色都 “反过来”。

通道分离与合并:提取单色 / 混合新颜色


  • 通道分离:把 RGB 三个通道拆成 3 张 “灰度图”—— 每张图只显示对应通道的亮度(比如 R 通道分离图中,越亮的地方表示红色越浓);

  • 通道合并:把分离后的通道(或修改后的通道)重新组合成一张彩色图,甚至可以 “替换通道”(比如用 R 通道的灰度图替换 G 通道,生成特殊颜色)。

就像把 “三色冰淇淋”(RGB 混合)拆成 “红色球、绿色球、蓝色球”(分离通道),可以单独吃某个球(看单色灰度图),也可以把 “红色球和蓝色球” 重新捏成一个新冰淇淋(合并通道,生成紫色调)。

3. 像素数据渲染(putImageData

用于直接操作像素级数据并渲染到画布。它跳过了 Canvas 常规的 “绘制图形(如矩形、圆形)” 流程,直接将一段描述像素颜色的二进制数据 “贴” 到画布上,是实现像素级特效(如滤镜、粒子、图像处理)的关键技术

核心原理:理解 “像素数据” 与 putImageData 的角色

  • Canvas 画布本质是一个二维像素网格,每个像素由 “红(R)、绿(G)、蓝(B)、透明度(A)”4 个分量组成(称为 RGBA 模式);

  • 每个分量的取值范围是 0-255(8 位二进制),比如纯红色像素是 R=255, G=0, B=0, A=255

  • 描述这些像素的数据被封装在 ImageData 对象中,而 putImageData 的作用就是:把 ImageData 里的像素矩阵,精准 “画” 到 Canvas 的指定位置

  • 概念作用通俗理解
    ImageData 对象存储像素数据的 “容器”像一张 “像素清单”,记录了每个位置的像素颜色
    data 属性(Uint8ClampedArray)ImageData 的核心,二进制像素数组“清单的具体内容”:每 4 个元素对应 1 个像素的 RGBA
    putImageData 方法渲染 ImageData 到 Canvas“按清单贴瓷砖”:把像素数组里的颜色一一对应贴到画布格子上
putImageData 语法与核心参数

基本语法:

// 核心:将 ImageData 渲染到 Canvas (x, y) 位置
ctx.putImageData(imageData, dx, dy);// 扩展:只渲染 ImageData 中的部分区域(裁剪后渲染)
ctx.putImageData(imageData, dx, dy, sx, sy, sw, sh);
参数类型作用通俗解释
imageDataImageData要渲染的像素数据对象准备好的 “像素贴纸”
dxNumber渲染到 Canvas 的水平起始位置(x 坐标)贴纸左上角在画板的横向位置
dyNumber渲染到 Canvas 的垂直起始位置(y 坐标)贴纸左上角在画板的纵向位置
sx(可选)Number从 ImageData 中裁剪的水平起始位置只取贴纸的哪部分(横向起点)
sy(可选)Number从 ImageData 中裁剪的垂直起始位置只取贴纸的哪部分(纵向起点)
sw(可选)Number裁剪区域的宽度取贴纸的多大宽度
sh(可选)Number裁剪区域的高度取贴纸的多大高度
示例:

渐变色方块

通过直接修改 ImageData 的像素数组,生成 “从红到蓝” 的渐变色,再用 putImageData 渲染。

<!DOCTYPE html>
<html>
<head><title>案例1:渐变色方块</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body>
<canvas id="myCanvas" width="300" height="200"></canvas><script>
// 1. 获取 Canvas 和上下文
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');// 2. 创建 ImageData(宽300,高200,对应画布大小)
const width = canvas.width;
const height = canvas.height;
const imageData = ctx.createImageData(width, height);// 3. 操作像素数据:让 x 从左到右,红色减弱、蓝色增强(渐变)
const pixelData = imageData.data; // 像素数组(每4个元素对应1个像素)for (let y = 0; y < height; y++) { // 遍历每一行(垂直方向)for (let x = 0; x < width; x++) { // 遍历每一列(水平方向)// 计算当前像素在数组中的索引:(y*宽度 + x) * 4(因为每个像素占4位)const index = (y * width + x) * 4;// 设置 RGBA:R随x增大减小,B随x增大增大,G固定为0,A=255(不透明)pixelData[index] = 255 - (x / width) * 255; // R(红)pixelData[index + 1] = 0; // G(绿)pixelData[index + 2] = (x / width) * 255; // B(蓝)pixelData[index + 3] = 255; // A(透明度)}
}// 4. 用 putImageData 渲染到画布(从 (0,0) 位置开始)
ctx.putImageData(imageData, 0, 0);
</script>
</body>
</html>

给图片添加 “灰度滤镜”

先加载一张图片,用 getImageData 获取图片像素,将彩色转为灰度(修改像素数组),再用 putImageData 渲染处理后的效果。

<!DOCTYPE html>
<html>
<head><title>案例3:像素粒子下落</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body>
<canvas id="particleCanvas" width="500" height="400"></canvas><script>
const canvas = document.getElementById('particleCanvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;// 1. 创建空的 ImageData(用于存储粒子像素)
let imageData = ctx.createImageData(width, height);
const pixelData = imageData.data;// 2. 初始化粒子:随机生成100个白色像素点(初始位置随机)
const particles = [];
for (let i = 0; i < 100; i++) {particles.push({x: Math.random() * width | 0,  // 水平位置(取整)y: Math.random() * height | 0, // 垂直位置(取整)speed: Math.random() * 2 + 1   // 下落速度(1-3像素/帧)});
}// 3. 动画循环:更新粒子位置 → 清空画布 → 渲染粒子
function animate() {// 清空像素数据(所有像素设为透明:RGBA=0)pixelData.fill(0);// 更新每个粒子的位置,并绘制到像素数组particles.forEach(particle => {// 粒子下落(y增加速度值)particle.y += particle.speed;// 超出画布底部后,重置到顶部if (particle.y > height) {particle.y = 0;particle.x = Math.random() * width | 0; // 重置x为随机位置}// 计算粒子在像素数组中的索引(确保x、y在合法范围内)if (particle.x >= 0 && particle.x < width && particle.y >= 0 && particle.y < height) {const index = (particle.y * width + particle.x) * 4;pixelData[index] = 255;    // R=255(白)pixelData[index + 1] = 255;// G=255pixelData[index + 2] = 255;// B=255pixelData[index + 3] = 255;// A=255(不透明)}});// 渲染更新后的像素数据到画布ctx.putImageData(imageData, 0, 0);// 循环调用动画(浏览器刷新率同步)requestAnimationFrame(animate);
}// 启动动画
animate();
</script>
</body>
</html>

 像素级粒子动画

点击按钮或画布任意位置触发爆炸效果,观察粒子如何从爆炸中心向外扩散、受重力影响并逐渐消失。

<!DOCTYPE html>
<html>
<head><title>像素爆炸动画</title><style>canvas {border: 2px solid #333;background: #000;}.container {display: flex;flex-direction: column;align-items: center;}button {margin-top: 10px;padding: 8px 16px;cursor: pointer;}</style>
</head>
<body><div class="container"><h3>点击按钮触发像素爆炸动画</h3><canvas id="animationCanvas" width="600" height="400"></canvas><button onclick="triggerExplosion()">触发爆炸</button></div><script>const canvas = document.getElementById('animationCanvas');const ctx = canvas.getContext('2d');const width = canvas.width;const height = canvas.height;// 创建ImageData对象存储像素数据let imageData = ctx.createImageData(width, height);let pixels = imageData.data;// 粒子数组存储所有活动粒子let particles = [];// 颜色库 - 爆炸使用的颜色const colors = [[255, 50, 50],   // 红色[255, 150, 50],  // 橙色[255, 255, 50],  // 黄色[255, 100, 200], // 粉色[100, 100, 255]  // 蓝色];// 清除画布像素function clearPixels() {for (let i = 0; i < pixels.length; i++) {pixels[i] = 0;}}// 创建爆炸粒子function createExplosion(x, y) {// 每次爆炸创建300个粒子for (let i = 0; i < 300; i++) {// 随机方向和速度const angle = Math.random() * Math.PI * 2;const speed = Math.random() * 4 + 1;// 随机选择颜色const color = colors[Math.floor(Math.random() * colors.length)];particles.push({x: x,y: y,vx: Math.cos(angle) * speed,vy: Math.sin(angle) * speed,r: color[0],g: color[1],b: color[2],alpha: 1,decay: 0.01 + Math.random() * 0.02 // 随机衰减速度});}}// 更新粒子状态function updateParticles() {// 清除上一帧的像素clearPixels();// 更新每个粒子for (let i = particles.length - 1; i >= 0; i--) {const p = particles[i];// 更新位置p.x += p.vx;p.y += p.vy;// 应用重力p.vy += 0.05;// 减少透明度p.alpha -= p.decay;// 移除已消失的粒子if (p.alpha <= 0) {particles.splice(i, 1);continue;}// 将粒子绘制到像素数组const pixelX = Math.floor(p.x);const pixelY = Math.floor(p.y);// 确保粒子在画布范围内if (pixelX >= 0 && pixelX < width && pixelY >= 0 && pixelY < height) {const index = (pixelY * width + pixelX) * 4;// 绘制粒子(添加发光效果,绘制周围几个像素)drawGlow(pixelX, pixelY, p.r, p.g, p.b, p.alpha);}}// 将像素数据渲染到画布ctx.putImageData(imageData, 0, 0);// 继续动画循环requestAnimationFrame(updateParticles);}// 为粒子添加发光效果function drawGlow(x, y, r, g, b, alpha) {// 在粒子周围绘制几个像素,创建发光效果for (let dy = -1; dy <= 1; dy++) {for (let dx = -1; dx <= 1; dx++) {const nx = x + dx;const ny = y + dy;if (nx >= 0 && nx < width && ny >= 0 && ny < height) {const index = (ny * width + nx) * 4;const distance = Math.sqrt(dx * dx + dy * dy);const glowAlpha = alpha * (1 - distance * 0.5);// 混合颜色(如果已有像素)pixels[index] = Math.min(255, pixels[index] + r * glowAlpha);pixels[index + 1] = Math.min(255, pixels[index + 1] + g * glowAlpha);pixels[index + 2] = Math.min(255, pixels[index + 2] + b * glowAlpha);pixels[index + 3] = 255; // 不透明}}}}// 触发爆炸(在画布中心)function triggerExplosion() {createExplosion(width / 2, height / 2);}// 启动动画updateParticles();// 允许点击画布任意位置触发爆炸canvas.addEventListener('click', (e) => {const rect = canvas.getBoundingClientRect();const x = e.clientX - rect.left;const y = e.clientY - rect.top;createExplosion(x, y);});</script>
</body>
</html>

热力图数据可视化

将抽象数据通过像素颜色直观呈现,热力图广泛应用于数据分析、环境监测、用户行为分析等领域,通过颜色直观展示数据的密度或强度分布。

总结

putImageData 的核心价值是 **“跳过图形绘制,直接控制像素”**—— 它让开发者从 “画形状” 的层面,深入到 “画像素” 的底层,是实现图像滤镜、像素动画、数据可视化(如热力图)等高级效果的基础。

3.4 状态管理

1. 状态保存与恢复(save()/restore())Canvas 的 “快照功能”
什么是 Canvas 状态?

Canvas 绘图时,会维护一个 “绘图状态栈”,状态包含以下信息:

  • 样式:fillStyle(填充色)、strokeStyle(描边色)、font(字体)等;

  • 变形:当前的坐标变换(平移、旋转、缩放);

  • 其他:globalAlpha(透明度)、lineWidth(线宽)等。

save() 相当于 “拍快照”—— 把当前绘图状态压入栈中保存;
restore() 相当于 “恢复快照”—— 从栈中取出最近保存的状态,覆盖当前状态。

想象你在画画:

  • 你先调好了红色颜料、10px 画笔(这是 “初始状态”),用 save() 保存这个状态;

  • 接着换成蓝色颜料、5px 画笔(修改状态),画了一个圆形;

  • 画完后用 restore() 恢复到之前的红色、10px 画笔状态,继续画其他图形,不用重新调颜料和画笔。

案例:嵌套图形的样式隔离

用 save()/restore() 确保 “内部小矩形” 的样式不影响 “外部大矩形”,同时内部矩形的旋转也不会影响外部。

<!DOCTYPE html>
<html>
<head><title>状态保存与恢复示例</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body>
<canvas id="saveRestoreCanvas" width="400" height="300"></canvas><script>
const canvas = document.getElementById('saveRestoreCanvas');
const ctx = canvas.getContext('2d');// 1. 绘制外部大矩形(初始状态:红色填充、黑色描边)
ctx.fillStyle = 'red';
ctx.strokeStyle = 'black';
ctx.lineWidth = 3;
ctx.fillRect(50, 50, 300, 200);
ctx.strokeRect(50, 50, 300, 200);// 2. 保存当前状态(红色填充、黑色描边、无旋转)
ctx.save();// 3. 修改状态,绘制内部旋转的小矩形
ctx.fillStyle = 'yellow'; // 换黄色填充
ctx.strokeStyle = 'blue'; // 换蓝色描边
ctx.lineWidth = 2;        // 线宽改2px
ctx.translate(200, 150);  // 移动原点到矩形中心(方便旋转)
ctx.rotate(Math.PI / 4);  // 旋转45度(π/4 弧度)// 绘制小矩形(此时坐标是相对于新原点的)
ctx.fillRect(-50, -50, 100, 100);
ctx.strokeRect(-50, -50, 100, 100);// 4. 恢复到之前保存的状态(红色填充、黑色描边、无旋转)
ctx.restore();// 5. 用恢复后的状态绘制右侧小正方形(验证状态是否正确)
ctx.fillRect(320, 80, 50, 50);
ctx.strokeRect(320, 80, 50, 50);
</script>
</body>
</html>

  • 外部红色大矩形、内部黄色旋转小矩形、右侧红色小正方形,三者样式互不干扰;

2. 坐标变换(translate/rotate/scale)Canvas 的 “画布操控术”

Canvas 默认的坐标原点在画布左上角(x 向右递增,y 向下递增),但实际绘图中常需要 “移动画布”“旋转图形” 或 “缩放大小”—— 这就是坐标变换的作用。它不改变图形本身,而是改变 “观察图形的坐标系”。

方法作用语法通俗理解
translate(x, y)平移坐标系:将原点移动到 (x, y)ctx.translate(100, 50)把画布 “拿起来”,让原本 (100,50) 的位置变成新的左上角
rotate(angle)旋转坐标系:绕当前原点旋转 angle 弧度ctx.rotate(Math.PI/4)把画布绕原点 “转一圈”,图形会跟着旋转(注意:角度需转成弧度,180°=π 弧度)
scale(sx, sy)缩放坐标系:x 方向缩放 sx 倍,y 方向缩放 sy 倍ctx.scale(2, 1.5)把画布 “放大” 或 “缩小”,sx=2 表示 x 方向放大 2 倍,sy=0.5 表示 y 方向缩小一半

坐标变换会累积生效,比如先平移再旋转,旋转会基于平移后的原点进行。如果要重置变换,需用 save()/restore() 隔离,或用 setTransform(1,0,0,1,0,0) 恢复默认变换(1,0,0,1,0,0 表示无缩放、无旋转、无平移)。

示例:变换组合实现时钟表盘

下面用 translate(移动原点到画布中心)、rotate(旋转绘制刻度)、scale(缩放数字)组合,实现一个简单的时钟表盘。

<!DOCTYPE html>
<html>
<head><title>坐标变换示例:时钟表盘</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body>
<canvas id="transformCanvas" width="400" height="400"></canvas><script>
const canvas = document.getElementById('transformCanvas');
const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2; // 画布中心 x
const centerY = canvas.height / 2; // 画布中心 y
const radius = 150; // 表盘半径// 1. 平移原点到画布中心(后续绘图基于中心)
ctx.translate(centerX, centerY);// 2. 绘制表盘外圈
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2); // 圆心在新原点 (0,0)
ctx.strokeStyle = '#333';
ctx.lineWidth = 5;
ctx.stroke();// 3. 绘制12个时刻刻度(用 rotate 循环绘制)
for (let i = 0; i < 12; i++) {// 保存当前状态(避免旋转累积影响文字)ctx.save();// 计算旋转角度:每个时刻占 360°/12 = 30°,转成弧度const angle = (i * 30) * Math.PI / 180;ctx.rotate(angle); // 旋转坐标系// 绘制刻度线(在旋转后的 y 轴负方向,即“上方”)ctx.beginPath();ctx.moveTo(0, -radius); // 刻度起点(表盘边缘)ctx.lineTo(0, -radius + 15); // 刻度终点(向内15px)ctx.strokeStyle = 'red';ctx.lineWidth = 3;ctx.stroke();// 绘制时刻数字(先旋转回水平,再缩放)ctx.save(); // 保存旋转后的状态ctx.rotate(-angle); // 旋转回水平(让数字不倾斜)ctx.scale(0.8, 0.8); // 缩小数字ctx.font = '20px Arial';ctx.textAlign = 'center';ctx.textBaseline = 'middle';ctx.fillText(i === 0 ? 12 : i, 0, -radius + 30); // 数字位置在刻度下方ctx.restore(); // 恢复到旋转后的状态ctx.restore(); // 恢复到初始平移状态,准备下一个刻度
}// 4. 绘制指针(用 scale 缩小指针宽度)
ctx.save();
ctx.scale(0.5, 1); // x 方向缩小一半,让指针更细
ctx.beginPath();
ctx.moveTo(0, 50); // 指针尾部
ctx.lineTo(0, -radius + 40); // 指针头部(接近表盘边缘)
ctx.strokeStyle = 'black';
ctx.lineWidth = 8;
ctx.lineCap = 'round'; // 指针头部圆角
ctx.stroke();
ctx.restore();
</script>
</body>
</html>

3. 图层叠加(先画背景→再画前景)Canvas 的 “分层作画”

Canvas 本身是单图层的(像一张透明的纸),但可以通过 “先画底层、再画上层” 的顺序,模拟出 “图层叠加” 的效果。上层图形会覆盖下层图形的重叠部分,就像画画时 “先涂背景色,再画前景物体”。

核心原理:绘制顺序决定层级

Canvas 绘图遵循 “后绘制的图形在上层” 的规则,即:

  1. 先画背景(如天空、地面);

  2. 再画中层元素(如树木、建筑);

  3. 最后画前景元素(如人物、近景物体)。

如果需要 “上层不完全覆盖下层”,可以通过 globalAlpha(设置透明度)或 globalCompositeOperation(设置混合模式,如 “叠加”“发光”)调整。

示例:分层绘制风景图
<!DOCTYPE html>
<html>
<head><title>图层叠加示例:风景图</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body>
<canvas id="layerCanvas" width="500" height="300"></canvas><script>
const canvas = document.getElementById('layerCanvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;// 1. 第一层:背景(天空)
ctx.fillStyle = '#87CEEB'; // 天空蓝
ctx.fillRect(0, 0, width, height);// 2. 第二层:中层(草地 + 远山)
// 草地(下方1/3区域)
ctx.fillStyle = '#32CD32'; // 草绿
ctx.fillRect(0, height * 2/3, width, height * 1/3);// 远山(中间层,半透明)
ctx.save();
ctx.globalAlpha = 0.6; // 透明度60%,让远山有“距离感”
ctx.beginPath();
ctx.moveTo(0, height * 2/3);
ctx.lineTo(width * 1/4, height * 1/2);
ctx.lineTo(width * 1/2, height * 2/3);
ctx.fillStyle = '#8FBC8F'; // 深绿
ctx.fill();ctx.beginPath();
ctx.moveTo(width * 1/2, height * 2/3);
ctx.lineTo(width * 3/4, height * 1/3);
ctx.lineTo(width, height * 2/3);
ctx.fillStyle = '#90EE90'; // 浅绿
ctx.fill();
ctx.restore();// 3. 第三层:前景(云朵 + 房子 + 太阳)
// 云朵(半透明,在天空上层)
ctx.save();
ctx.globalAlpha = 0.8; // 透明度80%
ctx.beginPath();
// 用多个圆组合成云朵
ctx.arc(100, 80, 20, 0, Math.PI * 2);
ctx.arc(130, 80, 15, 0, Math.PI * 2);
ctx.arc(160, 80, 20, 0, Math.PI * 2);
ctx.arc(130, 60, 15, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.restore();// 房子(在草地上层)
// 房子主体
ctx.fillStyle = '#FF6347'; // 红色
ctx.fillRect(350, height * 2/3 - 80, 100, 80);
// 屋顶
ctx.beginPath();
ctx.moveTo(330, height * 2/3 - 80);
ctx.lineTo(400, height * 2/3 - 120);
ctx.lineTo(470, height * 2/3 - 80);
ctx.fillStyle = '#8B4513'; // 棕色
ctx.fill();
// 窗户
ctx.fillStyle = '#87CEEB'; // 蓝色
ctx.fillRect(370, height * 2/3 - 60, 20, 20);
ctx.fillRect(410, height * 2/3 - 60, 20, 20);// 太阳(最上层,在天空右上角)
ctx.beginPath();
ctx.arc(width - 80, 80, 40, 0, Math.PI * 2);
ctx.fillStyle = '#FFD700'; // 金色
ctx.fill();
// 太阳光芒
for (let i = 0; i < 12; i++) {ctx.save();const angle = (i * 30) * Math.PI / 180;ctx.translate(width - 80, 80);ctx.rotate(angle);ctx.fillRect(0, -60, 10, 20);ctx.restore();
}
</script>
</body>
</html>

4.案例:“旋转的文字标签”(文字围绕中心点旋转,不影响其他元素)

<!DOCTYPE html>
<html>
<head><title>旋转的文字标签</title><style>canvas {border: 2px solid #333;background-color: #f5f5f5;}.container {display: flex;flex-direction: column;align-items: center;}.controls {margin: 10px 0;}input {width: 300px;}</style>
</head>
<body><div class="container"><h3>旋转的文字标签</h3><div class="controls"><label for="rotation">旋转角度: <span id="angleValue">0</span>°</label><br><input type="range" id="rotation" min="0" max="360" value="0"></div><canvas id="textCanvas" width="500" height="400"></canvas><p>文字围绕中心点旋转,不影响其他元素</p></div><script>const canvas = document.getElementById('textCanvas');const ctx = canvas.getContext('2d');const rotationSlider = document.getElementById('rotation');const angleValue = document.getElementById('angleValue');// 画布中心坐标const centerX = canvas.width / 2;const centerY = canvas.height / 2;// 旋转角度(弧度)let rotationAngle = 0;// 要显示的文本const text = "旋转的文字标签";// 绘制函数function draw() {// 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制背景和参考线(不受文字旋转影响)drawBackground();// 保存当前状态(重要!确保旋转只影响文字)ctx.save();// 1. 将坐标原点移动到画布中心ctx.translate(centerX, centerY);// 2. 旋转坐标系ctx.rotate(rotationAngle);// 3. 绘制文字(现在的(0,0)是画布中心)ctx.font = 'bold 24px Arial, sans-serif';ctx.fillStyle = '#ff3366';ctx.textAlign = 'center';    // 文字水平居中ctx.textBaseline = 'middle'; // 文字垂直居中ctx.fillText(text, 0, 0);    // 在新原点绘制文字// 恢复到之前保存的状态(消除旋转和位移影响)ctx.restore();// 绘制其他不受影响的元素(在文字上方)drawOverlayElements();// 循环动画requestAnimationFrame(draw);}// 绘制背景和参考线function drawBackground() {// 绘制中心点标记ctx.beginPath();ctx.arc(centerX, centerY, 5, 0, Math.PI * 2);ctx.fillStyle = '#333';ctx.fill();// 绘制十字参考线ctx.beginPath();ctx.moveTo(centerX, 0);ctx.lineTo(centerX, canvas.height);ctx.moveTo(0, centerY);ctx.lineTo(canvas.width, centerY);ctx.strokeStyle = '#ccc';ctx.lineWidth = 1;ctx.stroke();// 绘制固定的标题文字(不旋转)ctx.font = '16px Arial';ctx.fillStyle = '#333';ctx.textAlign = 'center';ctx.fillText('中心点', centerX, centerY + 30);}// 绘制不受旋转影响的叠加元素function drawOverlayElements() {// 绘制四个角落的固定文字const cornerText = ['左上', '右上', '左下', '右下'];const positions = [{x: 50, y: 50, align: 'left', baseline: 'top'},{x: canvas.width - 50, y: 50, align: 'right', baseline: 'top'},{x: 50, y: canvas.height - 50, align: 'left', baseline: 'bottom'},{x: canvas.width - 50, y: canvas.height - 50, align: 'right', baseline: 'bottom'}];positions.forEach((pos, index) => {ctx.save();ctx.font = '14px Arial';ctx.fillStyle = '#666';ctx.textAlign = pos.align;ctx.textBaseline = pos.baseline;ctx.fillText(cornerText[index], pos.x, pos.y);ctx.restore();});}// 监听滑块变化,更新旋转角度rotationSlider.addEventListener('input', function() {const degrees = parseInt(this.value);angleValue.textContent = degrees;// 将角度转换为弧度(Canvas旋转使用弧度)rotationAngle = degrees * Math.PI / 180;});// 启动动画draw();</script>
</body>
</html>

4.高级应用阶段(掌握物理模拟与 3D / 流体效果)

4.1 复杂碰撞检测

1. 圆形碰撞(距离公式 Math.sqrt(dx²+dy²)

圆形碰撞是最基础的碰撞类型,核心逻辑是计算两个圆的圆心距离,若距离≤两圆半径之和,则碰撞

根据欧几里得距离公式,两圆心的直线距离为:
distance = Math.sqrt( (x2-x1)² + (y2-y1)² )

示例:碰撞检测的小球

两个小球移动时,若碰撞则变色,未碰撞则保持原色。

<!DOCTYPE html>
<html>
<head><title>圆形碰撞案例</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body><canvas id="canvas" width="400" height="300"></canvas><script>const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');// 定义两个圆形(小球)const ball1 = { x: 100, y: 150, r: 30, color: 'red', vx: 2, vy: 1 };const ball2 = { x: 300, y: 150, r: 25, color: 'blue', vx: -1.5, vy: -0.8 };function drawBall(ball) {ctx.beginPath();ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); // 画圆ctx.fillStyle = ball.collided ? 'orange' : ball.color; // 碰撞时变橙色ctx.fill();}function checkCircleCollision(b1, b2) {// 1. 计算圆心x、y方向的差值(dx=水平距离,dy=垂直距离)const dx = b2.x - b1.x;const dy = b2.y - b1.y;// 2. 计算圆心距离(用距离公式)const distance = Math.sqrt(dx * dx + dy * dy);// 3. 判断是否碰撞:距离 ≤ 半径之和return distance <= b1.r + b2.r;}function update() {// 1. 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 2. 检测碰撞(给小球加collided状态)const isCollided = checkCircleCollision(ball1, ball2);ball1.collided = isCollided;ball2.collided = isCollided;// 3. 小球移动(边界反弹,避免出画布)ball1.x += ball1.vx;ball1.y += ball1.vy;ball2.x += ball2.vx;ball2.y += ball2.vy;// 边界检测:碰到画布边缘反弹if (ball1.x - ball1.r < 0 || ball1.x + ball1.r > canvas.width) ball1.vx *= -1;if (ball1.y - ball1.r < 0 || ball1.y + ball1.r > canvas.height) ball1.vy *= -1;if (ball2.x - ball2.r < 0 || ball2.x + ball2.r > canvas.width) ball2.vx *= -1;if (ball2.y - ball2.r < 0 || ball2.y + ball2.r > canvas.height) ball2.vy *= -1;// 4. 绘制小球drawBall(ball1);drawBall(ball2);// 循环执行(动画效果)requestAnimationFrame(update);}// 启动动画update();</script>
</body>
</html>

2. 矩形碰撞(轴对齐检测 AABB)

矩形碰撞的核心是轴对齐检测(AABB,Axis-Aligned Bounding Box),适用于 “边与画布坐标轴平行” 的矩形(比如手机屏幕里的按钮、游戏里的方块)。判断逻辑是:两个矩形在 “水平方向” 和 “垂直方向” 都没有 “完全错开”,则碰撞

AABB 碰撞的否定条件(更易理解):
若满足以下任意一条,则无碰撞

  1. 矩形 A 的右边缘 ≤ 矩形 B 的左边缘(A 在 B 左边)

  2. 矩形 A 的左边缘 ≥ 矩形 B 的右边缘(A 在 B 右边)

  3. 矩形 A 的下边缘 ≤ 矩形 B 的上边缘(A 在 B 上边)

  4. 矩形 A 的上边缘 ≥ 矩形 B 的下边缘(A 在 B 下边)

反之,若四条都不满足,则两个矩形碰撞(比如两个盒子叠在一起)。

示例:碰撞检测的方块

红色方块可通过鼠标拖动,蓝色方块固定,拖动红色方块与蓝色方块重叠时,红色方块变绿色(表示碰撞)。

<!DOCTYPE html>
<html>
<head><title>矩形碰撞(AABB)案例</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body><canvas id="canvas" width="400" height="300"></canvas><script>const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');// 定义两个矩形:可拖动的红方块、固定的蓝方块const rect1 = { x: 50, y: 50, w: 60, h: 60, color: 'red', isDragging: false };const rect2 = { x: 200, y: 120, w: 80, h: 80, color: 'blue' };// 鼠标事件:拖动红方块canvas.addEventListener('mousedown', (e) => {const rect = canvas.getBoundingClientRect();const mouseX = e.clientX - rect.left;const mouseY = e.clientY - rect.top;// 检测鼠标是否点中rect1(用AABB逻辑)if (mouseX >= rect1.x && mouseX <= rect1.x + rect1.w && mouseY >= rect1.y && mouseY <= rect1.y + rect1.h) {rect1.isDragging = true;}});canvas.addEventListener('mousemove', (e) => {if (!rect1.isDragging) return;const rect = canvas.getBoundingClientRect();// 鼠标位置对应画布坐标(让方块中心跟随鼠标)rect1.x = e.clientX - rect.left - rect1.w / 2;rect1.y = e.clientY - rect.top - rect1.h / 2;});canvas.addEventListener('mouseup', () => {rect1.isDragging = false;});function drawRect(rect, isCollided) {ctx.fillStyle = isCollided ? 'green' : rect.color;ctx.fillRect(rect.x, rect.y, rect.w, rect.h); // 画矩形}function checkAABBCollision(r1, r2) {// AABB否定条件:只要一条满足,就无碰撞const noCollision = r1.x + r1.w <= r2.x ||    // r1在r2左边r1.x >= r2.x + r2.w ||    // r1在r2右边r1.y + r1.h <= r2.y ||    // r1在r2上边r1.y >= r2.y + r2.h;      // r1在r2下边// 反之则碰撞return !noCollision;}function update() {ctx.clearRect(0, 0, canvas.width, canvas.height);// 检测碰撞const isCollided = checkAABBCollision(rect1, rect2);// 绘制矩形drawRect(rect1, isCollided);drawRect(rect2, false);requestAnimationFrame(update);}update();</script>
</body>
</html>

3. 碰撞响应(动量守恒公式计算反弹速度)

碰撞响应是 “碰撞后的动作”—— 比如台球相撞后会改变速度方向和大小。核心逻辑基于动量守恒定律(现实中两个物体碰撞,总动量保持不变),简化后可实现 “物体反弹” 效果。

以两个小球的正碰(碰撞方向沿圆心连线)为例,简化后的动量守恒公式为:
碰撞后,小球 1 的速度 v1' 和小球 2 的速度 v2' 满足:

v1' = v1 * (m1 - m2) / (m1 + m2) + v2 * 2*m2 / (m1 + m2)
v2' = v2 * (m2 - m1) / (m1 + m2) + v1 * 2*m1 / (m1 + m2)

其中:

  • v1/v2:碰撞前两球的速度(沿碰撞方向的分量);

  • m1/m2:两球的质量(质量越大,碰撞后速度变化越小,比如乒乓球撞篮球,乒乓球反弹更明显)。

简化处理:若假设两球质量相等(m1 = m2),公式会简化为 v1' = v2v2' = v1—— 即两球碰撞后 “交换速度”(类似两个质量相同的台球相撞,一个停下,一个继续运动)。

示例:
<!DOCTYPE html>
<html>
<head><title>碰撞响应(动量守恒)案例</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body><canvas id="canvas" width="500" height="300"></canvas><p>说明:红色小球质量小(轻),蓝色小球质量大(重),碰撞后轻球反弹更明显</p><script>const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');// 定义两个小球(质量不同:m1=1,m2=3)const ball1 = { x: 150, y: 150, r: 20, color: 'red', vx: 3, vy: 1, m: 1 // 质量m1=1(轻)};const ball2 = { x: 350, y: 150, r: 30, color: 'blue', vx: -2, vy: -0.5, m: 3 // 质量m2=3(重)};function drawBall(ball) {ctx.beginPath();ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);ctx.fillStyle = ball.color;ctx.fill();// 绘制质量标签ctx.fillStyle = 'white';ctx.font = '12px Arial';ctx.textAlign = 'center';ctx.textBaseline = 'middle';ctx.fillText(`m=${ball.m}`, ball.x, ball.y);}// 1. 检测圆形碰撞(同第一部分)function checkCollision(b1, b2) {const dx = b2.x - b1.x;const dy = b2.y - b1.y;const distance = Math.sqrt(dx * dx + dy * dy);return distance <= b1.r + b2.r;}// 2. 碰撞响应(基于动量守恒)function resolveCollision(b1, b2) {// 计算碰撞方向的单位向量(确定反弹的“方向”)const dx = b2.x - b1.x;const dy = b2.y - b1.y;const distance = Math.sqrt(dx * dx + dy * dy);const nx = dx / distance; // 水平方向单位向量(-1~1)const ny = dy / distance; // 垂直方向单位向量(-1~1)// 计算两球在碰撞方向上的速度分量(把速度分解到碰撞方向)const v1 = b1.vx * nx + b1.vy * ny;const v2 = b2.vx * nx + b2.vy * ny;// 动量守恒公式计算碰撞后的速度分量const m1 = b1.m;const m2 = b2.m;const v1New = (v1 * (m1 - m2) + 2 * m2 * v2) / (m1 + m2);const v2New = (v2 * (m2 - m1) + 2 * m1 * v1) / (m1 + m2);// 更新两球的速度(将碰撞方向的速度分量还原到x、y方向)b1.vx = (b1.vx - v1 * nx) + v1New * nx;b1.vy = (b1.vy - v1 * ny) + v1New * ny;b2.vx = (b2.vx - v2 * nx) + v2New * nx;b2.vy = (b2.vy - v2 * ny) + v2New * ny;// 避免两球重叠(轻微分离,防止卡在一起)const overlap = (b1.r + b2.r) - distance;b1.x -= overlap * nx / 2;b1.y -= overlap * ny / 2;b2.x += overlap * nx / 2;b2.y += overlap * ny / 2;}function update() {ctx.clearRect(0, 0, canvas.width, canvas.height);// 检测碰撞:若碰撞则执行响应if (checkCollision(ball1, ball2)) {resolveCollision(ball1, ball2);}// 小球移动+边界反弹ball1.x += ball1.vx;ball1.y += ball1.vy;ball2.x += ball2.vx;ball2.y += ball2.vy;if (ball1.x - ball1.r < 0 || ball1.x + ball1.r > canvas.width) ball1.vx *= -1;if (ball1.y - ball1.r < 0 || ball1.y + ball1.r > canvas.height) ball1.vy *= -1;if (ball2.x - ball2.r < 0 || ball2.x + ball2.r > canvas.width) ball2.vx *= -1;if (ball2.y - ball2.r < 0 || ball2.y + ball2.r > canvas.height) ball2.vy *= -1;// 绘制小球drawBall(ball1);drawBall(ball2);requestAnimationFrame(update);}update();</script>
</body>
</html>

4.2 3D 效果模拟

在 Canvas(2D 绘图环境)中实现 3D 效果,本质是 “用 2D 画面欺骗眼睛”—— 通过3D 坐标投影到 2D 平面按深度排序避免穿透定义顶点和面构建模型这三个核心技术,让平面图形呈现出立体层次感

1. 3D 坐标到 2D 投影(透视公式 scale = 距离系数 / (z + 偏移)

在 Canvas 中,3D 物体的每个点都有 (x, y, z) 三个坐标:

  • x:水平方向(左右);

  • y:垂直方向(上下);

  • z:深度方向(前后,约定 z 越大,物体越远)。

我们需要把 (x, y, z) 转换成 Canvas 上的 2D 坐标 (x2d, y2d),关键就是用 “透视公式” 计算缩放比例。

专业核心:透视公式与原理
参数作用通俗类比
scale3D 点到 2D 平面的缩放比例,scale 越小,物体在 2D 上显示越小远处的树缩放比例小,看起来小
距离系数控制透视强度(通常设为 500-2000),值越大,透视越平缓(远小近大不明显)眯眼时透视感弱,对应大系数
z3D 点的深度坐标(z 越大,物体越远)树的深度(越远 z 越大)
偏移避免 z 过小导致分母接近 0(防止缩放比例无限大),通常设为 200-500防止 “太近的物体超出画布”
投影步骤(以一个 3D 点 (x, y, z) 为例):
  1. 计算缩放比例scale = 800 / (z + 300)(假设距离系数 = 800,偏移 = 300);

  2. 3D 转 2D 坐标
    x2d = 画布中心x + x * scale(水平方向:中心为原点,x 正向右,x 负向左);
    y2d = 画布中心y + y * scale(垂直方向:中心为原点,y 正向下,y 负向上);

  3. 绘制:按 scale 缩放物体(如文字、图形),在 (x2d, y2d) 位置绘制。

示例:透视效果演示(远小近大)
<!DOCTYPE html>
<html>
<head><title>3D 到 2D 透视投影</title><style>canvas { border: 1px solid #000; }.controls { margin: 10px 0; }</style>
</head>
<body><div class="controls">深度 z 值:<input type="range" id="zSlider" min="0" max="1000" value="200"><span id="zValue">20<span id="zValue">200</span>(值越大,物体越远)</div><canvas id="projCanvas" width="600" height="400"></canvas><script>const canvas = document.getElementById('projCanvas');const ctx = canvas.getContext('2d');const zSlider = document.getElementById('zSlider');const zValue = document.getElementById('zValue');const centerX = canvas.width / 2; // 画布中心 xconst centerY = canvas.height / 2; // 画布中心 yconst distanceCoeff = 800; // 距离系数const offset = 300; // 偏移// 3D 物体的基础尺寸(假设是一个 100x100 的正方形)const objSize = 100;function draw() {// 1. 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 2. 获取当前 z 值(深度)const z = parseInt(zSlider.value);zValue.textContent = z;// 3. 计算透视缩放比例const scale = distanceCoeff / (z + offset);const scaledSize = objSize * scale; // 缩放后的物体尺寸// 4. 计算 2D 位置(3D 原点 (0,0,z) 投影到 2D 中心)const x2d = centerX - scaledSize / 2; // 左对齐到中心const y2d = centerY - scaledSize / 2; // 上对齐到中心// 5. 绘制投影后的 2D 图形ctx.fillStyle = `rgba(255, 100, 100, 0.8)`;ctx.fillRect(x2d, y2d, scaledSize, scaledSize);// 绘制文字(同样受缩放影响)ctx.font = `${16 * scale}px Arial`;ctx.fillStyle = '#333';ctx.textAlign = 'center';ctx.fillText(`z=${z}`, centerX, centerY);requestAnimationFrame(draw);}zSlider.addEventListener('input', draw);draw();</script>
</body>
</html>

2. 深度排序(Z 值大的先画,解决 “谁挡谁” 的问题)

现实中,你站在桌子前,手会挡住桌子的一部分 —— 因为手比桌子 “近”。在 3D 模拟中,如果不处理 “远近”,可能会出现 “桌子挡住手” 的错误(穿透问题)。

深度排序的核心逻辑:按物体的深度(z 值)排序,先画远的(z 大的),再画近的(z 小的)。就像画画时先画背景(远),再画前景(近),近的自然会覆盖远的,避免穿透。

 专业核心:排序规则与实现
  • 坐标约定:统一 z 值的意义 —— 通常约定 z 越大,物体越远(如前面的案例);
  • 排序依据:对每个物体,取其 “平均 z 值”(如果是面,取所有顶点 z 的平均值)作为深度判断标准;
  • 排序算法:用数组的 sort() 方法,按平均 z 值 “从大到小” 排序(大 z 在前,先画)。
为什么取平均 z 值?

一个 3D 面(如立方体的一个面)有多个顶点,每个顶点的 z 值可能不同(比如倾斜的面),取平均值能代表整个面的 “整体深度”,避免因单个顶点的 z 值偏差导致排序错误。

示例:深度排序实现遮挡
<!DOCTYPE html>
<html>
<head><title>深度排序与遮挡</title><style>canvas { border: 1px solid #000; }.controls { margin: 10px 0; }</style>
</head>
<body><div class="controls">蓝色矩形 z 值:<input type="range" id="blueZSlider" min="100" max="500" value="300"><span id="blueZValue">300</span>(红色 z=200,蓝色 z>200 时红色遮挡蓝色)</div><canvas id="depthCanvas" width="600" height="400"></canvas><script>const canvas = document.getElementById('depthCanvas');const ctx = canvas.getContext('2d');const blueZSlider = document.getElementById('blueZSlider');const blueZValue = document.getElementById('blueZValue');const centerX = canvas.width / 2;const centerY = canvas.height / 2;const distanceCoeff = 800;const offset = 300;const objSize = 100;// 定义两个 3D 物体:红色(固定 z=200)、蓝色(z 可调节)function getObjects() {const blueZ = parseInt(blueZSlider.value);return [{ name: 'red', z: 200, // 固定深度color: 'rgba(255, 100, 100, 0.8)' },{ name: 'blue', z: blueZ, // 可调节深度color: 'rgba(100, 100, 255, 0.8)' }];}// 计算物体的 2D 位置和尺寸function get2dProps(z) {const scale = distanceCoeff / (z + offset);const scaledSize = objSize * scale;// 让两个物体有轻微偏移,方便观察遮挡const xOffset = name === 'red' ? -20 : 20; // 红色左移20,蓝色右移20const x2d = centerX + xOffset * scale - scaledSize / 2;const y2d = centerY - scaledSize / 2;return { x2d, y2d, scaledSize };}function draw() {ctx.clearRect(0, 0, canvas.width, canvas.height);const objects = getObjects();blueZValue.textContent = objects[1].z;// 关键:深度排序(按 z 从大到小,先画远的)const sortedObjects = objects.sort((a, b) => b.z - a.z);// 绘制排序后的物体sortedObjects.forEach(obj => {const { x2d, y2d, scaledSize } = get2dProps(obj.z, obj.name);ctx.fillStyle = obj.color;ctx.fillRect(x2d, y2d, scaledSize, scaledSize);// 绘制物体名称和 z 值ctx.font = `${14 * (distanceCoeff / (obj.z + offset))}px Arial`;ctx.fillStyle = '#333';ctx.textAlign = 'center';ctx.fillText(`${obj.name} (z=${obj.z})`, x2d + scaledSize/2, y2d + scaledSize/2);});requestAnimationFrame(draw);}blueZSlider.addEventListener('input', draw);draw();</script>
</body>
</html>

3. 3D 模型构建(用 “顶点” 和 “面” 搭积木)

3D 模型就像 “数字积木”:

  • 顶点:积木的 “小端点”,每个顶点有 3D 坐标 (x, y, z)(比如立方体有 8 个顶点);
  • :积木的 “平面”,由多个顶点连接而成(比如立方体的一个面是四边形,由 4 个顶点组成);
  • 模型:由 “顶点数组”(存储所有端点)和 “面数组”(存储每个面用哪些顶点)组成。

类比现实中的立方体:8 个角是顶点,6 个正方形是面,每个面由 4 个角(顶点)连接而成。

专业核心:顶点与面的定义规则
(1)顶点数组(vertices)

用数组存储所有顶点的 3D 坐标,每个元素是 [x, y, z],例如立方体的 8 个顶点(这里的 50 是立方体的半边长,方便以原点为中心):

const vertices = [[-50, -50, -50], // 顶点 0:左上前[50, -50, -50],  // 顶点 1:右上前[50, 50, -50],   // 顶点 2:右后前[-50, 50, -50],  // 顶点 3:左后前[-50, -50, 50],  // 顶点 4:左上前(后)[50, -50, 50],   // 顶点 5:右上前(后)[50, 50, 50],    // 顶点 6:右后前(后)[-50, 50, 50]    // 顶点 7:左后前(后)
];
(2)面数组(faces)

用数组存储每个面的信息,每个元素包含:

  • vertices:该面使用的顶点 “索引”(对应顶点数组的下标,比如 [0,1,2,3] 表示用顶点 0、1、2、3 组成面);
  • color:面的颜色(可选)。

由于 Canvas 画多边形时,三角形最稳定(不会变形),通常将四边形拆成两个三角形,例如立方体的前面(z=-50):

const faces = [// 前面(z=-50):拆成两个三角形{ vertices: [0,1,2], color: 'rgba(255,100,100,0.8)' },{ vertices: [0,2,3], color: 'rgba(255,100,100,0.8)' },// 后面(z=50):拆成两个三角形{ vertices: [4,5,6], color: 'rgba(100,100,255,0.8)' },{ vertices: [4,6,7], color: 'rgba(100,100,255,0.8)' },// 其他面...(左右、上下)
];

总结示例:3D 立方体模型(整合三大知识点)

  • 模型构建:定义立方体的顶点和面;

  • 3D 到 2D 投影:用透视公式计算每个顶点的 2D 坐标;

  • 深度排序:按面的平均 z 值排序,实现遮挡。

    <!DOCTYPE html>
    <html>
    <head><title>3D 立方体模型(整合三大知识点)</title><style>canvas { border: 1px solid #000; }.info { margin: 10px 0; }</style>
    </head>
    <body><div class="info">拖动鼠标旋转立方体,观察 3D 效果和遮挡</div><canvas id="cubeCanvas" width="1000" height="800"></canvas><script>const canvas = document.getElementById('cubeCanvas');const ctx = canvas.getContext('2d');const centerX = canvas.width / 2;const centerY = canvas.height / 2;const distanceCoeff = 1000; // 透视系数const offset = 300; // 偏移// 1. 3D 模型构建:定义立方体的顶点和面// 顶点数组(8个顶点,半边长50,中心在原点)const vertices = [[-50, -50, -50], [50, -50, -50], [50, 50, -50], [-50, 50, -50], // 前面4个顶点[-50, -50, 50],  [50, -50, 50],  [50, 50, 50],  [-50, 50, 50]  // 后面4个顶点];// 面数组(6个面,每个面拆成2个三角形,共12个三角形)const faces = [// 前面(z=-50){ verts: [0,1,2], color: 'rgba(255,100,100,0.8)' },{ verts: [0,2,3], color: 'rgba(255,100,100,0.8)' },// 后面(z=50){ verts: [4,5,6], color: 'rgba(100,100,255,0.8)' },{ verts: [4,6,7], color: 'rgba(100,100,255,0.8)' },// 左面(x=-50){ verts: [0,3,7], color: 'rgba(100,255,100,0.8)' },{ verts: [0,7,4], color: 'rgba(100,255,100,0.8)' },// 右面(x=50){ verts: [1,5,6], color: 'rgba(255,255,100,0.8)' },{ verts: [1,6,2], color: 'rgba(255,255,100,0.8)' },// 上面(y=-50){ verts: [0,4,5], color: 'rgba(255,100,255,0.8)' },{ verts: [0,5,1], color: 'rgba(255,100,255,0.8)' },// 下面(y=50){ verts: [2,6,7], color: 'rgba(100,255,255,0.8)' },{ verts: [2,7,3], color: 'rgba(100,255,255,0.8)' }];// 旋转角度(初始为0,鼠标拖动控制)let rotateX = 0, rotateY = 0;let isDragging = false, lastX, lastY;// 2. 辅助函数:3D 点旋转(绕 X 轴和 Y 轴)function rotatePoint(x, y, z) {// 绕 X 轴旋转(影响 y 和 z)const cosX = Math.cos(rotateX);const sinX = Math.sin(rotateX);let yRot = y * cosX - z * sinX;let zRot = y * sinX + z * cosX;// 绕 Y 轴旋转(影响 x 和 z)const cosY = Math.cos(rotateY);const sinY = Math.sin(rotateY);let xRot = x * cosY + zRot * sinY;zRot = -x * sinY + zRot * cosY;return [xRot, yRot, zRot];}// 3. 辅助函数:3D 点投影到 2Dfunction projectPoint(x, y, z) {const scale = distanceCoeff / (z + offset);const x2d = centerX + x * scale;const y2d = centerY + y * scale;return [x2d, y2d, scale]; // 返回 2D 坐标和缩放比例}// 4. 绘制函数(整合投影、排序、绘制)function draw() {ctx.clearRect(0, 0, canvas.width, canvas.height);// 对每个面进行处理:旋转→投影→计算平均 z 值(用于排序)const processedFaces = faces.map(face => {// 获取面的3个顶点,旋转后投影const projectedVerts = face.verts.map(idx => {const [x, y, z] = vertices[idx];const [xRot, yRot, zRot] = rotatePoint(x, y, z);const [x2d, y2d, scale] = projectPoint(xRot, yRot, zRot);return { x2d, y2d, zRot }; // 保留旋转后的 z 值(用于深度排序)});// 计算面的平均 z 值(深度)const avgZ = (projectedVerts[0].zRot + projectedVerts[1].zRot + projectedVerts[2].zRot) / 3;return {...face,projectedVerts,avgZ // 用于深度排序};});// 5. 深度排序:按平均 z 值从大到小(先画远的)const sortedFaces = processedFaces.sort((a, b) => b.avgZ - a.avgZ);// 6. 绘制排序后的面sortedFaces.forEach(face => {const { projectedVerts, color } = face;// 绘制面(三角形)ctx.beginPath();ctx.moveTo(projectedVerts[0].x2d, projectedVerts[0].y2d);ctx.lineTo(projectedVerts[1].x2d, projectedVerts[1].y2d);ctx.lineTo(projectedVerts[2].x2d, projectedVerts[2].y2d);ctx.closePath();ctx.fillStyle = color;ctx.fill();ctx.strokeStyle = '#333';ctx.stroke();});requestAnimationFrame(draw);}// 鼠标交互:控制旋转canvas.addEventListener('mousedown', (e) => {isDragging = true;lastX = e.clientX;lastY = e.clientY;});canvas.addEventListener('mousemove', (e) => {if (!isDragging) return;const dx = e.clientX - lastX;const dy = e.clientY - lastY;// 控制旋转速度(弧度)rotateY += dx * 0.01;rotateX += dy * 0.01;lastX = e.clientX;lastY = e.clientY;});canvas.addEventListener('mouseup', () => isDragging = false);canvas.addEventListener('mouseleave', () => isDragging = false);// 启动绘制draw();</script>
    </body>
    </html>

4.3 流体与波动模拟

1. 波动公式(如液体表面点的受力计算 force = 张力 + 相邻点拉力

波动公式是描述 “介质中某点受力状态” 的数学模型,核心是 “局部受力 = 自身属性力 + 相邻点的相互作用力”。以液体表面为例,某一点的受力 force 由两部分构成:

  • 张力(自身属性力):液体分子间的吸引力,决定 “液体表面尽量收缩” 的特性(比如水珠成球形);
  • 相邻点拉力(相互作用力):该点与周围点的位置差异产生的拉力 —— 相邻点越高,对它的拉力越大,反之越小,这是 “波纹扩散” 的核心动力。

把液体表面想象成 “一张蹦床”:

蹦床的 “弹性” 就是张力—— 即使没人踩,蹦床也有保持平整的力;

当你在蹦床上踩一个坑(某点位置降低),周围的蹦床面(相邻点)会因为 “高低差” 拉这个坑,这就是相邻点拉力

两者共同作用,让坑周围产生 “一圈圈扩散的波纹”,这就是波动公式的效果。

示例:简易水面波纹

用 Canvas 模拟 “点击水面产生波纹”,核心是通过波动公式计算每个像素点的受力和位置。

<!DOCTYPE html>
<html>
<head><title>波动公式:水面波纹</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body><canvas id="waveCanvas" width="400" height="300"></canvas><script>const canvas = document.getElementById('waveCanvas');const ctx = canvas.getContext('2d');const width = canvas.width;const height = canvas.height;// 存储两个状态:当前位置(y坐标)、上一帧位置(用于计算速度)let currentY = new Array(width * height).fill(0); // 当前每个点的y位置(初始平的)let prevY = new Array(width * height).fill(0);   // 上一帧每个点的y位置let dampening = 0.92; // 阻尼(后面会讲,先用来衰减能量)// 1. 初始化:画初始水面(蓝色)function drawWater() {ctx.fillStyle = '#87CEEB'; // 天空蓝ctx.fillRect(0, 0, width, height);// 画波纹:根据currentY的位置,每个点的y坐标 = 中点y + currentY值const centerY = height / 2; // 水面中点ctx.fillStyle = '#1E90FF'; // 深蓝色(水)for (let x = 0; x < width; x++) {for (let y = 0; y < height; y++) {const index = x + y * width;// 只画“水面线以下”的区域(模拟水的体积)if (y > centerY + currentY[index]) {ctx.fillRect(x, y, 1, 1); // 逐个像素画水}}}}// 2. 波动公式计算:更新每个点的受力和位置function updateWave() {for (let x = 1; x < width - 1; x++) { // 避开边缘(无相邻点)for (let y = 1; y < height - 1; y++) {const index = x + y * width;// 波动核心公式:当前点受力 = 相邻点平均位置 - 当前位置(模拟相邻拉力)// 相邻点:上下左右4个点,平均位置 = (上+下+左+右)/4const neighborAvg = (currentY[x + (y-1)*width] + // 上currentY[x + (y+1)*width] + // 下currentY[(x-1) + y*width] + // 左currentY[(x+1) + y*width]   // 右) / 2;// 速度 = 相邻拉力(neighborAvg - currentY) - 上一帧速度(阻尼衰减)const velocity = (neighborAvg - currentY[index]) - prevY[index] * dampening;prevY[index] = currentY[index]; // 保存当前位置为“上一帧”currentY[index] = currentY[index] + velocity; // 更新当前位置}}}// 3. 点击生成波纹(给某点一个初始“扰动”)canvas.addEventListener('click', (e) => {const rect = canvas.getBoundingClientRect();const x = Math.floor(e.clientX - rect.left);const y = Math.floor(e.clientY - rect.top);const centerY = height / 2;const index = x + Math.floor(centerY) * width;currentY[index] = -20; // 给点击点一个向下的初始位移(模拟“砸坑”)});// 4. 动画循环:更新→绘制→重复function animate() {updateWave();drawWater();requestAnimationFrame(animate);}animate(); // 启动动画</script>
</body>
</html>

2. 数值迭代(状态更新的迭代计算)

专业定义

数值迭代是 **“通过重复计算同一公式,逐步更新系统状态”** 的方法。在动画中,系统状态通常是 “位置、速度、颜色” 等,每一次迭代(对应一帧)都基于上一帧的状态,代入公式计算出当前帧状态,最终形成连续的动态效果。

核心逻辑:当前状态 = f(上一帧状态),其中 f 是自定义的更新公式(比如波动公式、运动公式)。

通俗类比

把动画想象成 “翻页漫画”:

  • 每一页漫画是一个 “状态”(比如人物的位置);

  • 下一页的位置(当前状态),是根据上一页的位置(上一帧状态)“算” 出来的(比如上一页人物在左边,下一页就往右移一点);

  • 重复这个 “算位置→画页面” 的过程(迭代),快速翻页就成了动画。

示例:弹跳的小球

用数值迭代模拟 “小球从高处落下、弹起、再落下” 的过程,每帧都通过迭代更新小球的位置和速度。

<!DOCTYPE html>
<html>
<head><title>数值迭代:弹跳小球</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body><canvas id="ballCanvas" width="400" height="300"></canvas><script>const canvas = document.getElementById('ballCanvas');const ctx = canvas.getContext('2d');const width = canvas.width;const height = canvas.height;// 小球的初始状态const ball = {x: 200,    // x位置(中点)y: 50,     // y位置(初始高度)radius: 20,// 半径vx: 0,     // x方向速度(初始不动)vy: 0,     // y方向速度(初始不动)gravity: 0.5, // 重力(向下的加速度)bounce: 0.7 // 反弹系数(弹起时速度衰减)};// 1. 数值迭代:更新小球状态(每帧执行一次)function updateBall() {// 迭代公式1:速度 = 上一帧速度 + 重力(重力让速度越来越大,加速下落)ball.vy += ball.gravity;// 迭代公式2:位置 = 上一帧位置 + 当前速度(速度驱动位置变化)ball.y += ball.vy;ball.x += ball.vx;// 边界检测:碰到地面反弹(更新速度方向和大小)if (ball.y + ball.radius >= height) {ball.y = height - ball.radius; // 防止小球陷入地面ball.vy = -ball.vy * ball.bounce; // 速度反向,同时乘以反弹系数(衰减)}}// 2. 绘制小球function drawBall() {ctx.clearRect(0, 0, width, height); // 清空画布ctx.fillStyle = '#FF6347'; // 番茄红ctx.beginPath();ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); // 画圆ctx.fill();}// 3. 动画循环:迭代更新→绘制→重复function animate() {updateBall(); // 每帧迭代更新状态drawBall();requestAnimationFrame(animate);}animate();</script>
</body>
</html>

3. 阻尼效果(能量衰减 velocity *= 0.95

专业定义

阻尼效果是 **“通过在状态更新中加入‘衰减系数’,让系统的能量(如速度、振幅)随时间逐渐减少”** 的物理模拟。核心公式:新速度 = 旧速度 × 阻尼系数(阻尼系数通常在 0~1 之间,越接近 0 衰减越快)。

现实意义:没有绝对 “无能量损失” 的系统,阻尼模拟了 “空气阻力、摩擦力、材料损耗” 等现实因素(比如钟摆会慢慢停下,就是阻尼的作用)。

 通俗类比

把阻尼想象成 “小球在沙子里滚动”:

  • 小球初始有速度(能量),但沙子会 “阻碍” 它(阻尼);
  • 每滚一段距离(每帧),速度就会变慢一点(速度 × 0.95);
  • 最终速度减到 0,小球停下 —— 这就是阻尼的效果。
示例:带阻尼的钟摆

模拟钟摆的摆动,通过阻尼让钟摆逐渐停下,直观感受 “能量衰减”

<!DOCTYPE html>
<html>
<head><title>阻尼效果:带阻尼的钟摆</title><style> canvas { border: 1px solid #000; } </style>
</head>
<body><canvas id="pendulumCanvas" width="400" height="300"></canvas><script>const canvas = document.getElementById('pendulumCanvas');const ctx = canvas.getContext('2d');const width = canvas.width;const height = canvas.height;// 钟摆的初始状态const pendulum = {pivotX: 200,  // 悬挂点x(画布中点)pivotY: 50,   // 悬挂点y(画布上方)length: 150,  // 摆长angle: Math.PI/4, // 初始角度(45度,Math.PI是180度)angularV: 0,  // 角速度(初始不动)gravity: 0.02, // 重力加速度(驱动摆动)damping: 0.98 // 阻尼系数(0.98表示每帧速度保留98%)};// 1. 阻尼+迭代:更新钟摆状态function updatePendulum() {// 步骤1:计算扭矩(驱动钟摆摆动的力)// 扭矩 = 重力 × sin(角度)(角度越大,扭矩越大,摆动越猛)const torque = -pendulum.gravity * Math.sin(pendulum.angle);// 步骤2:更新角速度(加入阻尼)// 角速度 = 上一帧角速度 + 扭矩(驱动加速) × 阻尼(衰减能量)pendulum.angularV += torque;pendulum.angularV *= pendulum.damping; // 阻尼核心:每帧衰减2%速度// 步骤3:更新角度(迭代)// 角度 = 上一帧角度 + 角速度(角速度驱动角度变化)pendulum.angle += pendulum.angularV;}// 2. 绘制钟摆(摆线+摆球)function drawPendulum() {ctx.clearRect(0, 0, width, height); // 清空画布// 计算摆球的当前位置(三角函数:x=摆长×sin(角度),y=摆长×cos(角度))const ballX = pendulum.pivotX + pendulum.length * Math.sin(pendulum.angle);const ballY = pendulum.pivotY + pendulum.length * Math.cos(pendulum.angle);// 画摆线(悬挂点到摆球)ctx.strokeStyle = '#333';ctx.lineWidth = 2;ctx.beginPath();ctx.moveTo(pendulum.pivotX, pendulum.pivotY);ctx.lineTo(ballX, ballY);ctx.stroke();// 画摆球ctx.fillStyle = '#4169E1'; // royalbluectx.beginPath();ctx.arc(ballX, ballY, 15, 0, Math.PI * 2);ctx.fill();}// 3. 动画循环:更新→绘制→重复function animate() {updatePendulum();drawPendulum();requestAnimationFrame(animate);}animate();</script>
</body>
</html>

4.4 音频可视化

1. Web Audio API 基础(AnalyserNode 获取音频数据)

  • 专业定义
    Web Audio API 是浏览器处理音频的 “工具箱”,而 AnalyserNode 是其中的 “音频探测器”—— 它能从音频流(如音乐、麦克风输入)中提取 频率数据(高低音分布)和 时间域数据(音量波动),将不可见的声音转化为可计算的数字数组。

  • 通俗类比
    就像 “声音的体温计”:你听不到声音的 “高低音比例”,但 AnalyserNode 能 “测量” 出 —— 比如低频(鼓声)占多少、高频(吉他泛音)占多少,并用数字列表告诉你结果(例如 [20, 50, 180, ...],数字越大代表该频率的声音越响)。

  • 关键逻辑
    要获取数据,需先搭建 “音频链路”:

    1. 创建 AudioContext(音频上下文,相当于 “工作台”);
    2. 将音频源(如 <audio> 标签、麦克风)接入上下文;
    3. 插入 AnalyserNode 到链路中,让音频流 “经过” 探测器;
    4. 通过 AnalyserNode.getByteFrequencyData() 读取频率数据数组。

2. 音频数据映射(频率数据→图形高度)

  • 专业定义
    将 AnalyserNode 输出的 8 位无符号整数数组(值范围 0~255,代表对应频率的振幅),转化为 Canvas 中图形的 视觉属性(如矩形高度、圆形半径、颜色亮度),实现 “数据→视觉” 的转换。

  • 通俗类比
    好比 “根据考试分数画柱状图”:频率数据数组里的每个数字是 “分数”(0 分 = 没声音,255 分 = 最大音量),Canvas 里的每个矩形是 “柱状图柱子”—— 分数越高,柱子越高,让你一眼看出 “哪个频率的声音最响”。

  • 关键逻辑
    假设数据数组长度为 64(代表 64 个频率段),Canvas 宽度为 600px,则:

    1. 每个矩形的宽度 = Canvas宽度 / 数据长度(如 600/64 ≈ 9px);
    2. 矩形高度 = 数据值 / 255 * 最大高度(如最大高度设为 200px,数据值 127 对应高度 100px);
    3. 通过比例换算,确保数据范围与视觉范围匹配,避免图形溢出或过小。

3. 实时更新(与音频播放同步刷新)

  • 专业定义
    使用 requestAnimationFrame(浏览器原生动画 API)创建 “循环刷新机制”,让 Canvas 每帧(约 16.7ms / 帧,对应 60fps)都重新读取最新音频数据、重新绘制图形,实现 “音频播放→图形实时变化” 的同步效果。

  • 通俗类比
    类似 “电影每秒 24 帧”:每帧画面都是独立的,但快速切换时会形成 “动态效果”。音频可视化中,每帧都根据当前音频数据画一帧新图形,60 帧 / 秒的刷新速度会让图形看起来 “跟音乐无缝同步”,不会卡顿。

  • 关键逻辑

    1. 定义一个 draw() 函数,内部完成 “读数据→清画布→画图形” 三步;
    2. 在 draw() 末尾调用 requestAnimationFrame(draw),让浏览器在下一帧继续执行 draw()
    3. 音频播放时启动循环,暂停时停止循环,确保图形与音频状态一致。

4.示例:音频频谱可视化

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>音频频谱可视化</title><style>body {text-align: center;background: #000;color: #fff;padding-top: 50px;}#canvas {border: 2px solid #444;background: #111;margin-top: 20px;}audio {width: 500px;margin-top: 20px;}</style>
</head>
<body><h1>实时音频频谱</h1><!-- 音频源:可替换为自己的音频文件路径 --><audio id="audio" controls src="https://cdn.freesound.org/previews/347/347862_6101606-lq.mp3">您的浏览器不支持音频播放</audio><!-- 绘制频谱的Canvas --><canvas id="canvas" width="800" height="300"></canvas><script>// 1. 获取DOM元素const audio = document.getElementById('audio');const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d'); // Canvas绘图上下文// 2. 初始化Web Audio API核心对象let audioContext; // 音频上下文(工作台)let analyser;     // 音频探测器(AnalyserNode)let dataArray;     // 存储频率数据的数组// 3. 初始化音频链路(需用户交互后启动,浏览器安全限制)audio.addEventListener('play', initAudioContext);function initAudioContext() {// 避免重复初始化if (audioContext) return;// 创建音频上下文(兼容不同浏览器)audioContext = new (window.AudioContext || window.webkitAudioContext)();// 创建AnalyserNode(设置频率数据数组长度为128)analyser = audioContext.createAnalyser();analyser.fftSize = 256; // fftSize决定数据长度:数据长度 = fftSize / 2 → 128// 创建音频源(将<audio>标签接入音频上下文)const source = audioContext.createMediaElementSource(audio);// 搭建链路:源 → 探测器 → 扬声器(destination)source.connect(analyser);analyser.connect(audioContext.destination);// 初始化频率数据数组(8位无符号整数,长度=analyser.frequencyBinCount)const bufferLength = analyser.frequencyBinCount; // 128dataArray = new Uint8Array(bufferLength);// 启动绘制循环drawSpectrum();}// 4. 绘制频谱(核心:读数据→映射→实时刷新)function drawSpectrum() {// 每帧执行一次(浏览器自动同步刷新频率)requestAnimationFrame(drawSpectrum);// 步骤1:读取最新频率数据到dataArrayanalyser.getByteFrequencyData(dataArray);// 步骤2:清空画布(避免上一帧图形残留)ctx.clearRect(0, 0, canvas.width, canvas.height);// 步骤3:计算每个频谱柱的基础参数const bufferLength = dataArray.length; // 128const barWidth = canvas.width / bufferLength; // 每个柱子的宽度(800/128≈6.25px)let x = 0; // 柱子的X轴起始位置// 步骤4:遍历数据数组,绘制每个频谱柱for (let i = 0; i < bufferLength; i++) {// 数据映射:频率数据(0~255)→ 柱子高度(0~canvas.height的70%,避免过高)const barHeight = (dataArray[i] / 255) * (canvas.height * 0.7);// 设置柱子颜色(低频→红色,高频→蓝色,渐变效果)const hue = (i / bufferLength) * 240; // 色相:0(红)→240(蓝)ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;// 绘制矩形(X, Y, 宽, 高):Y轴从下往上画(Canvas原点在左上角)ctx.fillRect(x,                          // X:当前柱子的水平位置canvas.height - barHeight,  // Y:底部对齐(画布高度 - 柱子高度)barWidth - 1,               // 宽:减1避免柱子之间无缝隙barHeight                   // 高:映射后的高度);// 更新下一个柱子的X位置x += barWidth;}}</script>
</body>
</html>

5.综合项目阶段(整合多知识点,落地实际应用)

1.实现一个模拟引力效果的动画,多个小球受中心引力影响,展示物理效果模拟

<!DOCTYPE html>
<html>
<head><title>引力模拟</title><style>canvas { border: 2px solid #333; background: #1a1a1a; }body { margin: 20px; background: #f5f5f5; }</style>
</head>
<body><canvas id="myCanvas" width="800" height="600"></canvas><script>const canvas = document.getElementById('myCanvas');const ctx = canvas.getContext('2d');const centerX = canvas.width / 2; // 引力中心Xconst centerY = canvas.height / 2; // 引力中心Yconst balls = [];const numBalls = 50; // 小球数量// 初始化小球for (let i = 0; i < numBalls; i++) {balls.push({x: Math.random() * canvas.width,y: Math.random() * canvas.height,radius: Math.random() * 8 + 3,color: `hsl(${Math.random() * 360}, 70%, 60%)`, // 随机色相vx: (Math.random() - 0.5) * 4, // x方向速度vy: (Math.random() - 0.5) * 4  // y方向速度});}// 计算引力function applyGravity(ball) {// 计算小球到中心的距离const dx = centerX - ball.x;const dy = centerY - ball.y;const distance = Math.sqrt(dx * dx + dy * dy);// 计算引力(距离越近引力越大)const gravity = 0.5 / distance;// 应用引力到速度ball.vx += dx * gravity;ball.vy += dy * gravity;// 加入阻力(避免速度无限增加)ball.vx *= 0.98;ball.vy *= 0.98;}// 绘制引力中心function drawCenter() {ctx.beginPath();ctx.arc(centerX, centerY, 15, 0, Math.PI * 2);ctx.fillStyle = 'yellow';ctx.fill();// 绘制中心光晕ctx.beginPath();ctx.arc(centerX, centerY, 30, 0, Math.PI * 2);ctx.fillStyle = 'rgba(255, 255, 0, 0.2)';ctx.fill();}// 更新并绘制所有小球function update() {// 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制引力中心drawCenter();// 更新每个小球balls.forEach(ball => {// 应用引力applyGravity(ball);// 更新位置ball.x += ball.vx;ball.y += ball.vy;// 绘制小球ctx.beginPath();ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);ctx.fillStyle = ball.color;ctx.fill();// 小球描边ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';ctx.stroke();});requestAnimationFrame(update);}// 启动动画update();</script>
</body>
</html>

2.交互式绘图工具

鼠标事件、路径连续绘制、橡皮擦功能、颜色选择

<!DOCTYPE html>
<html>
<head><title>交互式绘图工具</title><style>.tools { margin: 10px 0; }button, input { margin-right: 10px; }canvas { border: 2px solid #555; background: #fff; }</style>
</head>
<body><div class="tools"><button id="pen">画笔</button><button id="eraser">橡皮擦</button><input type="color" id="colorPicker" value="#ff0000"><input type="number" id="lineWidth" value="5" min="1" max="50"></div><canvas id="canvas" width="800" height="500"></canvas><script>const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');let isDrawing = false;let tool = 'pen'; // 默认为画笔let lastX = 0;let lastY = 0;// 初始化ctx.lineCap = 'round'; // 线条端点圆润ctx.lineJoin = 'round'; // 线条拐角圆润ctx.strokeStyle = document.getElementById('colorPicker').value;ctx.lineWidth = document.getElementById('lineWidth').value;// 鼠标按下canvas.addEventListener('mousedown', (e) => {isDrawing = true;[lastX, lastY] = getCanvasCoordinates(e);});// 鼠标移动canvas.addEventListener('mousemove', draw);// 鼠标释放window.addEventListener('mouseup', () => isDrawing = false);window.addEventListener('mouseout', () => isDrawing = false);// 工具切换document.getElementById('pen').addEventListener('click', () => {tool = 'pen';ctx.strokeStyle = document.getElementById('colorPicker').value;});document.getElementById('eraser').addEventListener('click', () => {tool = 'eraser';ctx.strokeStyle = '#fff'; // 橡皮擦本质是白色画笔});// 颜色和线宽更新document.getElementById('colorPicker').addEventListener('input', (e) => {if (tool === 'pen') ctx.strokeStyle = e.target.value;});document.getElementById('lineWidth').addEventListener('input', (e) => {ctx.lineWidth = e.target.value;});// 绘制函数function draw(e) {if (!isDrawing) return;const [x, y] = getCanvasCoordinates(e);ctx.beginPath();ctx.moveTo(lastX, lastY); // 起点ctx.lineTo(x, y);         // 终点ctx.stroke();// 更新最后位置[lastX, lastY] = [x, y];}// 获取鼠标在Canvas中的坐标function getCanvasCoordinates(e) {const rect = canvas.getBoundingClientRect();return [e.clientX - rect.left,e.clientY - rect.top];}</script>
</body>
</html>

3.Canvas 游戏场景

<!DOCTYPE html>
<html>
<head><title>Canvas游戏场景</title><style>canvas { border: 3px solid #333; background: #1a1a1a; }.info { color: #fff; background: #333; padding: 10px; margin-bottom: 10px; }</style>
</head>
<body><div class="info">方向键控制移动,收集绿色方块得分 | 得分: <span id="score">0</span></div><canvas id="gameCanvas" width="800" height="600"></canvas><script>const canvas = document.getElementById('gameCanvas');const ctx = canvas.getContext('2d');const scoreElement = document.getElementById('score');let score = 0;// 玩家const player = {x: canvas.width / 2,y: canvas.height / 2,width: 30,height: 30,speed: 5,color: '#4af',dx: 0,dy: 0};// 收集物const collectibles = [];const collectibleCount = 8;// 初始化收集物function initCollectibles() {for (let i = 0; i < collectibleCount; i++) {collectibles.push({x: Math.random() * (canvas.width - 20) + 10,y: Math.random() * (canvas.height - 20) + 10,size: 20,color: '#4f4'});}}// 绘制玩家function drawPlayer() {ctx.fillStyle = player.color;// 绘制三角形玩家ctx.beginPath();ctx.moveTo(player.x, player.y - player.height/2);ctx.lineTo(player.x - player.width/2, player.y + player.height/2);ctx.lineTo(player.x + player.width/2, player.y + player.height/2);ctx.closePath();ctx.fill();}// 绘制收集物function drawCollectibles() {collectibles.forEach(item => {ctx.fillStyle = item.color;ctx.fillRect(item.x - item.size/2, item.y - item.size/2, item.size, item.size);});}// 更新玩家位置function updatePlayer() {player.x += player.dx;player.y += player.dy;// 边界限制if (player.x - player.width/2 < 0) player.x = player.width/2;if (player.x + player.width/2 > canvas.width) player.x = canvas.width - player.width/2;if (player.y - player.height/2 < 0) player.y = player.height/2;if (player.y + player.height/2 > canvas.height) player.y = canvas.height - player.height/2;}// 检测收集碰撞function checkCollection() {for (let i = collectibles.length - 1; i >= 0; i--) {const item = collectibles[i];// 矩形碰撞检测if (player.x - player.width/2 < item.x + item.size/2 &&player.x + player.width/2 > item.x - item.size/2 &&player.y - player.height/2 < item.y + item.size/2 &&player.y + player.height/2 > item.y - item.size/2) {// 收集成功collectibles.splice(i, 1);score += 10;scoreElement.textContent = score;// 补充新收集物if (collectibles.length < 5) {initCollectibles();}}}}// 绘制场景function draw() {// 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制背景网格drawGrid();// 绘制游戏元素drawCollectibles();drawPlayer();// 更新游戏状态updatePlayer();checkCollection();requestAnimationFrame(draw);}// 绘制背景网格function drawGrid() {ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';ctx.lineWidth = 1;// 横线for (let y = 0; y < canvas.height; y += 40) {ctx.beginPath();ctx.moveTo(0, y);ctx.lineTo(canvas.width, y);ctx.stroke();}// 竖线for (let x = 0; x < canvas.width; x += 40) {ctx.beginPath();ctx.moveTo(x, 0);ctx.lineTo(x, canvas.height);ctx.stroke();}}// 键盘控制function keyDown(e) {if (e.key === 'ArrowRight' || e.key === 'Right') {player.dx = player.speed;} else if (e.key === 'ArrowLeft' || e.key === 'Left') {player.dx = -player.speed;} else if (e.key === 'ArrowUp' || e.key === 'Up') {player.dy = -player.speed;} else if (e.key === 'ArrowDown' || e.key === 'Down') {player.dy = player.speed;}}function keyUp(e) {if ((e.key === 'ArrowRight' || e.key === 'Right') && player.dx > 0 ||(e.key === 'ArrowLeft' || e.key === 'Left') && player.dx < 0) {player.dx = 0;} else if ((e.key === 'ArrowUp' || e.key === 'Up') && player.dy < 0 ||(e.key === 'ArrowDown' || e.key === 'Down') && player.dy > 0) {player.dy = 0;}}// 事件监听document.addEventListener('keydown', keyDown);document.addEventListener('keyup', keyUp);// 初始化游戏initCollectibles();draw();</script>
</body>
</html>

4.后台管理系统数据展示(路径绘制、文本标注、网格线、比例计算)

<!DOCTYPE html>
<html>
<head><title>Canvas 数据可视化</title><style>.container { margin: 20px; }canvas { border: 1px solid #e0e0e0; background: #fff; }h3 { color: #333; }</style>
</head>
<body><div class="container"><h3>月度销售额统计</h3><canvas id="chartCanvas" width="800" height="400"></canvas></div><script>const canvas = document.getElementById('chartCanvas');const ctx = canvas.getContext('2d');// 模拟销售数据const data = {labels: ['1月', '2月', '3月', '4月', '5月', '6月'],values: [120, 190, 150, 240, 210, 320],maxValue: 350 // 最大值(用于计算比例)};// 绘制图表function drawChart() {// 1. 绘制背景和网格drawBackground();// 2. 绘制坐标轴drawAxes();// 3. 绘制柱状图drawBars();// 4. 绘制数据标签drawLabels();}// 绘制背景和网格function drawBackground() {// 填充背景ctx.fillStyle = '#f9f9f9';ctx.fillRect(0, 0, canvas.width, canvas.height);// 绘制水平网格线ctx.strokeStyle = '#e0e0e0';ctx.lineWidth = 1;const gridCount = 5;const gridStep = canvas.height / (gridCount + 1);for (let i = 1; i <= gridCount; i++) {const y = canvas.height - gridStep * i;ctx.beginPath();ctx.moveTo(80, y); // 左边距80pxctx.lineTo(canvas.width - 40, y); // 右边距40pxctx.stroke();// 网格值标签ctx.fillStyle = '#666';ctx.font = '12px Arial';ctx.textAlign = 'right';ctx.fillText((data.maxValue / gridCount * i).toFixed(0), 70, y + 4);}}// 绘制坐标轴function drawAxes() {ctx.strokeStyle = '#333';ctx.lineWidth = 2;// X轴ctx.beginPath();ctx.moveTo(80, canvas.height - 40); // 下边距40pxctx.lineTo(canvas.width - 40, canvas.height - 40);ctx.stroke();// Y轴ctx.beginPath();ctx.moveTo(80, 40); // 上边距40pxctx.lineTo(80, canvas.height - 40);ctx.stroke();}// 绘制柱状图function drawBars() {const padding = 40; // 柱子间距const startX = 100; // 起始X坐标const availableWidth = canvas.width - 160; // 可用宽度const barWidth = (availableWidth - padding * (data.labels.length - 1)) / data.labels.length;const maxBarHeight = canvas.height - 120; // 最大柱高data.values.forEach((value, index) => {// 计算柱子位置和高度const x = startX + index * (barWidth + padding);const barHeight = (value / data.maxValue) * maxBarHeight;const y = canvas.height - 40 - barHeight; // 40是下边距// 绘制柱子const gradient = ctx.createLinearGradient(x, y, x, canvas.height - 40);gradient.addColorStop(0, '#4285f4');gradient.addColorStop(1, '#34a853');ctx.fillStyle = gradient;ctx.fillRect(x, y, barWidth, barHeight);// 柱子顶部值ctx.fillStyle = '#333';ctx.font = '14px Arial';ctx.textAlign = 'center';ctx.fillText(value, x + barWidth / 2, y - 10);});}// 绘制标签function drawLabels() {// X轴标签const padding = 40;const startX = 100;const availableWidth = canvas.width - 160;const barWidth = (availableWidth - padding * (data.labels.length - 1)) / data.labels.length;data.labels.forEach((label, index) => {const x = startX + index * (barWidth + padding) + barWidth / 2;ctx.fillStyle = '#333';ctx.font = '14px Arial';ctx.textAlign = 'center';ctx.fillText(label, x, canvas.height - 20);});// 标题ctx.font = '16px Arial bold';ctx.textAlign = 'center';ctx.fillText('月度销售额 (万元)', canvas.width / 2, 30);}// 初始化图表drawChart();</script>
</body>
</html>

5.图片裁剪工具(用户头像上传裁剪)

图像绘制、鼠标拖拽、区域选择、Canvas 转图片

<!DOCTYPE html>
<html>
<head><title>Canvas 图片裁剪工具</title><style>.container { display: flex; gap: 20px; margin: 20px; }.controls { display: flex; flex-direction: column; gap: 10px; }button { padding: 8px 16px; cursor: pointer; }canvas { border: 1px solid #ccc; }#croppedPreview { border: 1px solid #ccc; width: 200px; height: 200px; object-fit: contain; }</style>
</head>
<body><div class="container"><div><h3>原图与裁剪区域</h3><canvas id="cropCanvas" width="600" height="400"></canvas></div><div class="controls"><h3>裁剪结果</h3><img id="croppedPreview" alt="裁剪预览"><input type="file" id="imageUpload" accept="image/*"><button id="cropButton">裁剪图片</button><button id="downloadButton">下载结果</button><p>拖动鼠标选择裁剪区域</p></div></div><script>const cropCanvas = document.getElementById('cropCanvas');const ctx = cropCanvas.getContext('2d');const croppedPreview = document.getElementById('croppedPreview');const imageUpload = document.getElementById('imageUpload');const cropButton = document.getElementById('cropButton');const downloadButton = document.getElementById('downloadButton');let image = null;let isSelecting = false;let startX = 0, startY = 0;let cropArea = { x: 0, y: 0, width: 200, height: 200 };// 加载图片imageUpload.addEventListener('change', (e) => {const file = e.target.files[0];if (!file) return;const reader = new FileReader();reader.onload = (event) => {image = new Image();image.onload = () => {// 绘制原图(按比例缩放)drawImageWithCropArea();};image.src = event.target.result;image.crossOrigin = 'anonymous';};reader.readAsDataURL(file);});// 绘制图片和裁剪区域function drawImageWithCropArea() {// 清空画布ctx.clearRect(0, 0, cropCanvas.width, cropCanvas.height);if (!image) return;// 计算缩放比例(适应画布)const scale = Math.min(cropCanvas.width / image.width,cropCanvas.height / image.height);const drawWidth = image.width * scale;const drawHeight = image.height * scale;const offsetX = (cropCanvas.width - drawWidth) / 2;const offsetY = (cropCanvas.height - drawHeight) / 2;// 绘制图片ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);// 绘制裁剪区域(半透明矩形)ctx.strokeStyle = '#4285f4';ctx.lineWidth = 2;ctx.setLineDash([5, 5]);ctx.strokeRect(cropArea.x, cropArea.y,cropArea.width, cropArea.height);ctx.setLineDash([]);// 填充半透明遮罩ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';// 左上角遮罩ctx.fillRect(0, 0, cropCanvas.width, cropArea.y);// 左下角遮罩ctx.fillRect(0, cropArea.y + cropArea.height, cropCanvas.width, cropCanvas.height);// 左中遮罩ctx.fillRect(0, cropArea.y, cropArea.x, cropArea.height);// 右中遮罩ctx.fillRect(cropArea.x + cropArea.width, cropArea.y,cropCanvas.width - (cropArea.x + cropArea.width), cropArea.height);}// 鼠标事件 - 开始选择cropCanvas.addEventListener('mousedown', (e) => {if (!image) return;const rect = cropCanvas.getBoundingClientRect();startX = e.clientX - rect.left;startY = e.clientY - rect.top;isSelecting = true;});// 鼠标事件 - 拖动选择cropCanvas.addEventListener('mousemove', (e) => {if (!isSelecting || !image) return;const rect = cropCanvas.getBoundingClientRect();const currentX = e.clientX - rect.left;const currentY = e.clientY - rect.top;// 更新裁剪区域cropArea.x = Math.min(startX, currentX);cropArea.y = Math.min(startY, currentY);cropArea.width = Math.abs(currentX - startX);cropArea.height = Math.abs(currentY - startY);// 重新绘制drawImageWithCropArea();});// 鼠标事件 - 结束选择window.addEventListener('mouseup', () => {isSelecting = false;});// 裁剪按钮cropButton.addEventListener('click', () => {if (!image || cropArea.width < 10 || cropArea.height < 10) return;// 创建临时Canvas用于裁剪const tempCanvas = document.createElement('canvas');tempCanvas.width = cropArea.width;tempCanvas.height = cropArea.height;const tempCtx = tempCanvas.getContext('2d');// 从原图裁剪区域tempCtx.drawImage(cropCanvas,cropArea.x, cropArea.y,cropArea.width, cropArea.height,0, 0,cropArea.width, cropArea.height);// 显示裁剪结果const dataUrl = tempCanvas.toDataURL('image/png');croppedPreview.src = dataUrl;// 保存下载链接downloadButton.onclick = () => {const link = document.createElement('a');link.href = dataUrl;link.download = 'cropped-image.png';link.click();};});</script>
</body>
</html>

6.签名组件(电子合同、表单签名)

路径绘制、贝塞尔曲线、压力感应模拟、数据导出

<!DOCTYPE html>
<html>
<head><title>Canvas 签名组件</title><style>.signature-pad { border: 2px dashed #ccc; background: #fff; cursor: crosshair; }.controls { margin: 10px 0; display: flex; gap: 10px; }button { padding: 8px 16px; cursor: pointer; }#signaturePreview { margin-top: 10px; max-width: 400px; border: 1px solid #eee; }</style>
</head>
<body><h3>电子签名</h3><canvas id="signatureCanvas" class="signature-pad" width="600" height="300"></canvas><div class="controls"><button id="clearButton">清除</button><button id="saveButton">保存签名</button><button id="undoButton">撤销上一步</button></div><div><h4>签名预览:</h4><img id="signaturePreview" alt="签名预览" src=""></div><script>const canvas = document.getElementById('signatureCanvas');const ctx = canvas.getContext('2d');const clearButton = document.getElementById('clearButton');const saveButton = document.getElementById('saveButton');const undoButton = document.getElementById('undoButton');const preview = document.getElementById('signaturePreview');// 签名状态let isDrawing = false;let lastX = 0, lastY = 0;let lastPressure = 0.5; // 模拟压力值const history = []; // 用于撤销功能let historyIndex = -1;// 初始化画布function initCanvas() {// 设置线条样式ctx.lineCap = 'round';ctx.lineJoin = 'round';ctx.strokeStyle = '#000';ctx.lineWidth = 2;// 保存初始状态saveState();}// 保存当前状态到历史记录function saveState() {// 移除当前状态之后的历史if (historyIndex < history.length - 1) {history.splice(historyIndex + 1);}// 保存当前画布数据history.push(canvas.toDataURL());historyIndex = history.length - 1;}// 从历史记录恢复function restoreState(index) {if (index < 0 || index >= history.length) return;const img = new Image();img.onload = () => {ctx.clearRect(0, 0, canvas.width, canvas.height);ctx.drawImage(img, 0, 0);};img.src = history[index];historyIndex = index;}// 开始绘制function startDrawing(e) {isDrawing = true;const [x, y] = getCanvasPosition(e);[lastX, lastY] = [x, y];// 记录初始点ctx.beginPath();ctx.moveTo(x, y);}// 绘制中function draw(e) {if (!isDrawing) return;const [x, y] = getCanvasPosition(e);// 计算两点之间的距离(用于模拟压力)const distance = Math.sqrt(Math.pow(x - lastX, 2) + Math.pow(y - lastY, 2));// 根据速度调整线条粗细(速度快则线条细)const pressure = Math.max(0.3, Math.min(1, 1 - distance / 50));ctx.lineWidth = pressure * 5 + 1; // 线条粗细范围 1-6px// 使用贝塞尔曲线让线条更平滑const cpx = (lastX + x) / 2;const cpy = (lastY + y) / 2;ctx.quadraticCurveTo(lastX, lastY, cpx, cpy);ctx.stroke();[lastX, lastY, lastPressure] = [x, y, pressure];}// 结束绘制function endDrawing() {if (!isDrawing) return;isDrawing = false;ctx.closePath();// 保存当前状态saveState();}// 获取鼠标在Canvas中的位置function getCanvasPosition(e) {const rect = canvas.getBoundingClientRect();return [e.clientX - rect.left,e.clientY - rect.top];}// 清除签名clearButton.addEventListener('click', () => {ctx.clearRect(0, 0, canvas.width, canvas.height);saveState();preview.src = '';});// 保存签名saveButton.addEventListener('click', () => {// 导出为PNGconst dataUrl = canvas.toDataURL('image/png');preview.src = dataUrl;// 实际项目中可以将dataUrl发送到服务器console.log('签名数据:', dataUrl);});// 撤销上一步undoButton.addEventListener('click', () => {if (historyIndex > 0) {restoreState(historyIndex - 1);}});// 事件监听canvas.addEventListener('mousedown', startDrawing);canvas.addEventListener('mousemove', draw);window.addEventListener('mouseup', endDrawing);window.addEventListener('mouseout', endDrawing);// 支持触摸设备canvas.addEventListener('touchstart', (e) => {e.preventDefault(); // 防止触摸滚动const touch = e.touches[0];startDrawing(touch);});canvas.addEventListener('touchmove', (e) => {e.preventDefault();const touch = e.touches[0];draw(touch);});canvas.addEventListener('touchend', endDrawing);// 初始化initCanvas();</script>
</body>
</html>

7.实时数据仪表盘(IoT 监控、系统性能监控)

动画更新、多图层绘制、实时数据处理、响应式布局

<!DOCTYPE html>
<html>
<head><title>Canvas 实时仪表盘</title><style>body { background: #f0f2f5; margin: 0; padding: 20px; }.dashboard { display: flex; gap: 20px; flex-wrap: wrap; }.panel { background: white; border-radius: 8px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }.gauge-panel { width: 300px; height: 300px; }.chart-panel { width: 600px; height: 300px; }h3 { margin: 0 0 15px 0; color: #333; }</style>
</head>
<body><div class="dashboard"><div class="panel gauge-panel"><h3>CPU 使用率</h3><canvas id="cpuGauge"></canvas></div><div class="panel chart-panel"><h3>网络流量实时监控</h3><canvas id="networkChart"></canvas></div></div><script>// CPU仪表盘const cpuCanvas = document.getElementById('cpuGauge');cpuCanvas.width = 300;cpuCanvas.height = 300;const cpuCtx = cpuCanvas.getContext('2d');// 网络流量图表const networkCanvas = document.getElementById('networkChart');networkCanvas.width = 600;networkCanvas.height = 300;const networkCtx = networkCanvas.getContext('2d');// 模拟实时数据let cpuUsage = 30; // CPU使用率(%)const networkData = {time: [],upload: [],download: []};// 初始化网络数据for (let i = 0; i < 30; i++) {networkData.time.push(i);networkData.upload.push(Math.random() * 50 + 10);networkData.download.push(Math.random() * 100 + 30);}// 绘制CPU仪表盘function drawCpuGauge() {// 清空画布cpuCtx.clearRect(0, 0, cpuCanvas.width, cpuCanvas.height);const centerX = cpuCanvas.width / 2;const centerY = cpuCanvas.height * 0.6;const radius = 100;// 1. 绘制背景圆环cpuCtx.beginPath();cpuCtx.arc(centerX, centerY, radius, 0.7 * Math.PI, 2.3 * Math.PI);cpuCtx.strokeStyle = '#eee';cpuCtx.lineWidth = 20;cpuCtx.stroke();// 2. 绘制进度圆环cpuCtx.beginPath();const startAngle = 0.7 * Math.PI;const endAngle = startAngle + (1.6 * Math.PI) * (cpuUsage / 100);cpuCtx.arc(centerX, centerY, radius, startAngle, endAngle);// 根据使用率设置颜色let gradient;if (cpuUsage < 50) {gradient = cpuCtx.createLinearGradient(0, 0, 300, 0);gradient.addColorStop(0, '#4cd964');gradient.addColorStop(1, '#34a853');} else if (cpuUsage < 80) {gradient = cpuCtx.createLinearGradient(0, 0, 300, 0);gradient.addColorStop(0, '#ffcc00');gradient.addColorStop(1, '#ff9500');} else {gradient = cpuCtx.createLinearGradient(0, 0, 300, 0);gradient.addColorStop(0, '#ff3b30');gradient.addColorStop(1, '#d93025');}cpuCtx.strokeStyle = gradient;cpuCtx.lineWidth = 20;cpuCtx.lineCap = 'round';cpuCtx.stroke();// 3. 绘制中心文本cpuCtx.fillStyle = '#333';cpuCtx.font = '36px Arial bold';cpuCtx.textAlign = 'center';cpuCtx.textBaseline = 'middle';cpuCtx.fillText(`${cpuUsage}%`, centerX, centerY);// 4. 绘制刻度for (let i = 0; i <= 10; i++) {const angle = startAngle + (1.6 * Math.PI) * (i / 10);const x1 = centerX + Math.cos(angle) * (radius - 10);const y1 = centerY + Math.sin(angle) * (radius - 10);const x2 = centerX + Math.cos(angle) * (radius + 10);const y2 = centerY + Math.sin(angle) * (radius + 10);cpuCtx.beginPath();cpuCtx.moveTo(x1, y1);cpuCtx.lineTo(x2, y2);cpuCtx.strokeStyle = '#999';cpuCtx.lineWidth = i % 5 === 0 ? 3 : 1;cpuCtx.stroke();// 绘制刻度值if (i % 5 === 0) {const textX = centerX + Math.cos(angle) * (radius + 30);const textY = centerY + Math.sin(angle) * (radius + 30);cpuCtx.font = '12px Arial';cpuCtx.fillText(`${i * 10}%`, textX, textY);}}}// 绘制网络流量图表function drawNetworkChart() {// 清空画布networkCtx.clearRect(0, 0, networkCanvas.width, networkCanvas.height);const padding = { top: 30, right: 30, bottom: 40, left: 50 };const chartWidth = networkCanvas.width - padding.left - padding.right;const chartHeight = networkCanvas.height - padding.top - padding.bottom;// 1. 绘制网格networkCtx.strokeStyle = '#eee';networkCtx.lineWidth = 1;// 水平网格const hGridCount = 5;for (let i = 0; i <= hGridCount; i++) {const y = padding.top + (chartHeight / hGridCount) * i;networkCtx.beginPath();networkCtx.moveTo(padding.left, y);networkCtx.lineTo(padding.left + chartWidth, y);networkCtx.stroke();// 网格值networkCtx.fillStyle = '#666';networkCtx.font = '11px Arial';networkCtx.textAlign = 'right';networkCtx.fillText(Math.round(150 - (i * 30)), padding.left - 5, y + 4);}// 2. 绘制坐标轴networkCtx.strokeStyle = '#333';networkCtx.lineWidth = 1;// X轴networkCtx.beginPath();networkCtx.moveTo(padding.left, padding.top + chartHeight);networkCtx.lineTo(padding.left + chartWidth, padding.top + chartHeight);networkCtx.stroke();// Y轴networkCtx.beginPath();networkCtx.moveTo(padding.left, padding.top);networkCtx.lineTo(padding.left, padding.top + chartHeight);networkCtx.stroke();// 3. 绘制上传流量线drawLineChart(networkData.upload, 'rgba(66, 133, 244, 0.8)', 'rgba(66, 133, 244, 0.1)',padding, chartWidth, chartHeight);// 4. 绘制下载流量线drawLineChart(networkData.download, 'rgba(15, 157, 88, 0.8)', 'rgba(15, 157, 88, 0.1)',padding, chartWidth, chartHeight);// 5. 绘制图例networkCtx.fillStyle = 'rgba(66, 133, 244, 0.8)';networkCtx.fillRect(padding.left + 10, padding.top + 10, 10, 10);networkCtx.fillStyle = '#333';networkCtx.font = '12px Arial';networkCtx.fillText('上传 (KB/s)', padding.left + 25, padding.top + 20);networkCtx.fillStyle = 'rgba(15, 157, 88, 0.8)';networkCtx.fillRect(padding.left + 120, padding.top + 10, 10, 10);networkCtx.fillStyle = '#333';networkCtx.fillText('下载 (KB/s)', padding.left + 135, padding.top + 20);}// 绘制线图辅助函数function drawLineChart(data, lineColor, areaColor, padding, chartWidth, chartHeight) {const pointCount = data.length;const xStep = chartWidth / (pointCount - 1);const maxValue = 150; // 最大值// 绘制填充区域networkCtx.beginPath();networkCtx.moveTo(padding.left, padding.top + chartHeight);data.forEach((value, index) => {const x = padding.left + index * xStep;const y = padding.top + chartHeight - (value / maxValue) * chartHeight;if (index === 0) networkCtx.moveTo(x, y);else networkCtx.lineTo(x, y);});networkCtx.lineTo(padding.left + (pointCount - 1) * xStep,padding.top + chartHeight);networkCtx.closePath();networkCtx.fillStyle = areaColor;networkCtx.fill();// 绘制线条networkCtx.beginPath();data.forEach((value, index) => {const x = padding.left + index * xStep;const y = padding.top + chartHeight - (value / maxValue) * chartHeight;if (index === 0) networkCtx.moveTo(x, y);else networkCtx.lineTo(x, y);});networkCtx.strokeStyle = lineColor;networkCtx.lineWidth = 2;networkCtx.stroke();}// 更新实时数据function updateData() {// 更新CPU使用率(模拟波动)cpuUsage = Math.max(10, Math.min(95, cpuUsage + (Math.random() - 0.5) * 5));// 更新网络数据(移除最早的数据,添加新数据)networkData.upload.shift();networkData.download.shift();networkData.upload.push(Math.max(10, Math.min(60, networkData.upload[networkData.upload.length - 1] + (Math.random() - 0.5) * 10)));networkData.download.push(Math.max(30, Math.min(130, networkData.download[networkData.download.length - 1] + (Math.random() - 0.5) * 15)));// 重绘drawCpuGauge();drawNetworkChart();// 每秒更新一次setTimeout(updateData, 1000);}// 初始化drawCpuGauge();drawNetworkChart();updateData();</script>
</body>
</html>

8.贪吃蛇游戏

经典贪吃蛇游戏,通过方向键控制蛇移动,吃到食物后变长,撞到墙壁或自身则游戏结束。

<!DOCTYPE html>
<html>
<head><title>贪吃蛇游戏</title><style>body { margin: 0; display: flex; flex-direction: column; align-items: center; background: #1e293b; color: white; font-family: sans-serif;}canvas { border: 2px solid #475569; background: #0f172a;}.score { margin: 10px 0; font-size: 20px; }.controls { margin-top: 10px; text-align: center; }</style>
</head>
<body><h1>贪吃蛇游戏</h1><div class="score">得分: <span id="score">0</span></div><canvas id="snakeCanvas" width="600" height="600"></canvas><div class="controls"><p>使用方向键控制蛇的移动</p><button id="restart">重新开始</button></div><script>const canvas = document.getElementById('snakeCanvas');const ctx = canvas.getContext('2d');const scoreElement = document.getElementById('score');const restartButton = document.getElementById('restart');// 游戏配置const gridSize = 20;const tileCount = canvas.width / gridSize;// 游戏状态let snake = [{ x: 10, y: 10 } // 初始位置];let food = { x: 15, y: 15 };let dx = 1; // 水平方向速度let dy = 0; // 垂直方向速度let nextDx = 1; // 下一次水平方向(防止180度转向)let nextDy = 0; // 下一次垂直方向let score = 0;let gameOver = false;let gameLoop;const speed = 150; // 游戏速度(毫秒)// 绘制网格(可选)function drawGrid() {ctx.strokeStyle = 'rgba(71, 85, 105, 0.1)';ctx.lineWidth = 1;for (let i = 0; i < tileCount; i++) {// 水平线ctx.beginPath();ctx.moveTo(0, i * gridSize);ctx.lineTo(canvas.width, i * gridSize);ctx.stroke();// 垂直线ctx.beginPath();ctx.moveTo(i * gridSize, 0);ctx.lineTo(i * gridSize, canvas.height);ctx.stroke();}}// 绘制蛇function drawSnake() {snake.forEach((segment, index) => {// 蛇头特殊颜色if (index === 0) {ctx.fillStyle = '#10b981';} else {// 蛇身渐变色const colorIndex = (index * 5) % 100;ctx.fillStyle = `hsl(142, 71%, ${30 + colorIndex * 0.4}%)`;}// 绘制蛇段ctx.fillRect(segment.x * gridSize, segment.y * gridSize, gridSize - 1, // 留1px间隙gridSize - 1);});}// 绘制食物function drawFood() {ctx.fillStyle = '#ef4444';ctx.beginPath();ctx.arc(food.x * gridSize + gridSize / 2,food.y * gridSize + gridSize / 2,gridSize / 2 - 1,0, Math.PI * 2);ctx.fill();}// 移动蛇function moveSnake() {if (gameOver) return;// 更新方向(防止180度转向)if (!(dx === -nextDx && dy === -nextDy)) {dx = nextDx;dy = nextDy;}// 创建新头部const head = {x: snake[0].x + dx,y: snake[0].y + dy};// 检查碰撞if (head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount ||snake.some(segment => segment.x === head.x && segment.y === head.y)) {gameOver = true;clearInterval(gameLoop);alert(`游戏结束!最终得分: ${score}`);return;}// 添加新头部snake.unshift(head);// 检查是否吃到食物if (head.x === food.x && head.y === food.y) {score += 10;scoreElement.textContent = score;spawnFood();} else {// 没吃到食物则移除尾部snake.pop();}}// 随机生成食物function spawnFood() {// 确保食物不会出现在蛇身上let newFood;do {newFood = {x: Math.floor(Math.random() * tileCount),y: Math.floor(Math.random() * tileCount)};} while (snake.some(segment => segment.x === newFood.x && segment.y === newFood.y));food = newFood;}// 绘制游戏function draw() {ctx.clearRect(0, 0, canvas.width, canvas.height);drawGrid();drawSnake();drawFood();}// 游戏循环function startGame() {// 重置游戏状态snake = [{ x: 10, y: 10 }];dx = 1;dy = 0;nextDx = 1;nextDy = 0;score = 0;gameOver = false;scoreElement.textContent = score;spawnFood();// 清除之前的循环if (gameLoop) clearInterval(gameLoop);// 启动新循环gameLoop = setInterval(() => {moveSnake();draw();}, speed);}// 处理键盘输入document.addEventListener('keydown', (e) => {switch (e.key) {case 'ArrowUp':if (dy !== 1) { // 防止向下移动时直接向上nextDx = 0;nextDy = -1;}break;case 'ArrowDown':if (dy !== -1) { // 防止向上移动时直接向下nextDx = 0;nextDy = 1;}break;case 'ArrowLeft':if (dx !== 1) { // 防止向右移动时直接向左nextDx = -1;nextDy = 0;}break;case 'ArrowRight':if (dx !== -1) { // 防止向左移动时直接向右nextDx = 1;nextDy = 0;}break;case ' ': // 空格键重新开始if (gameOver) startGame();break;}});// 重新开始按钮restartButton.addEventListener('click', startGame);// 开始游戏startGame();</script>
</body>
</html>

9.流动的粒子背景(适合网站背景)

粒子系统、距离检测、连线绘制、鼠标交互

<!DOCTYPE html>
<html>
<head><title>粒子网络背景</title><style>body { margin: 0; overflow: hidden; }canvas { display: block; background: #0f172a; }</style>
</head>
<body><canvas id="particleCanvas"></canvas><script>const canvas = document.getElementById('particleCanvas');const ctx = canvas.getContext('2d');// 设置Canvas为全屏function resizeCanvas() {canvas.width = window.innerWidth;canvas.height = window.innerHeight;}resizeCanvas();window.addEventListener('resize', resizeCanvas);// 粒子配置const particleCount = 80;const particles = [];const maxDistance = 150; // 粒子连线最大距离// 鼠标位置let mouse = { x: null, y: null, radius: 100 };// 粒子类class Particle {constructor() {this.x = Math.random() * canvas.width;this.y = Math.random() * canvas.height;this.size = Math.random() * 3 + 1;this.speedX = (Math.random() - 0.5) * 0.5;this.speedY = (Math.random() - 0.5) * 0.5;this.color = 'rgba(148, 163, 184, 0.8)'; // 淡蓝色粒子}// 更新位置update() {this.x += this.speedX;this.y += this.speedY;// 边界反弹if (this.x < 0 || this.x > canvas.width) this.speedX *= -1;if (this.y < 0 || this.y > canvas.height) this.speedY *= -1;// 鼠标吸引效果if (mouse.x && mouse.y) {const dx = mouse.x - this.x;const dy = mouse.y - this.y;const distance = Math.sqrt(dx * dx + dy * dy);if (distance < mouse.radius) {// 计算吸引力const force = (mouse.radius - distance) / mouse.radius;this.x -= dx * force * 0.01;this.y -= dy * force * 0.01;}}}// 绘制粒子draw() {ctx.fillStyle = this.color;ctx.beginPath();ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);ctx.fill();}}// 初始化粒子function initParticles() {for (let i = 0; i < particleCount; i++) {particles.push(new Particle());}}// 绘制粒子间连线function connectParticles() {for (let a = 0; a < particles.length; a++) {for (let b = a; b < particles.length; b++) {const dx = particles[a].x - particles[b].x;const dy = particles[a].y - particles[b].y;const distance = Math.sqrt(dx * dx + dy * dy);// 距离小于阈值则绘制连线if (distance < maxDistance) {const opacity = 1 - (distance / maxDistance);ctx.strokeStyle = `rgba(148, 163, 184, ${opacity * 0.5})`;ctx.lineWidth = 0.5;ctx.beginPath();ctx.moveTo(particles[a].x, particles[a].y);ctx.lineTo(particles[b].x, particles[b].y);ctx.stroke();}}}}// 动画循环function animate() {ctx.clearRect(0, 0, canvas.width, canvas.height);// 更新并绘制所有粒子particles.forEach(particle => {particle.update();particle.draw();});// 绘制连线connectParticles();requestAnimationFrame(animate);}// 鼠标移动事件window.addEventListener('mousemove', (e) => {mouse.x = e.x;mouse.y = e.y;});// 鼠标离开事件window.addEventListener('mouseout', () => {mouse.x = null;mouse.y = null;});// 初始化initParticles();animate();</script>
</body>
</html>

10.音频可视化

响应麦克风输入的音频可视化效果(需要用户授权麦克风)

<!DOCTYPE html>
<html>
<head><title>音频可视化</title><style>canvas { background: #121212; display: block; }body { margin: 0; }.info { position: fixed; top: 20px; left: 0; width: 100%; text-align: center; color: white; }</style>
</head>
<body><div class="info">请允许麦克风访问以查看音频可视化效果</div><canvas id="audioCanvas"></canvas><script>const canvas = document.getElementById('audioCanvas');const ctx = canvas.getContext('2d');// 设置全屏canvas.width = window.innerWidth;canvas.height = window.innerHeight;window.addEventListener('resize', () => {canvas.width = window.innerWidth;canvas.height = window.innerHeight;});const centerY = canvas.height / 2;const barCount = 100; // 柱状数量const barWidth = canvas.width / barCount;let audioData = new Uint8Array(barCount);// 初始化音频上下文async function initAudio() {try {const audioContext = new (window.AudioContext || window.webkitAudioContext)();const stream = await navigator.mediaDevices.getUserMedia({ audio: true });const source = audioContext.createMediaStreamSource(stream);const analyser = audioContext.createAnalyser();analyser.fftSize = 256;analyser.getByteFrequencyData(audioData);source.connect(analyser);// analyser.connect(audioContext.destination); // 取消注释可听到输入声音return analyser;} catch (err) {console.error('无法访问麦克风:', err);document.querySelector('.info').textContent = '无法访问麦克风: ' + err.message;return null;}}function draw(analyser) {if (analyser) {analyser.getByteFrequencyData(audioData);}ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制频谱for (let i = 0; i < barCount; i++) {// 音频数据映射到高度const height = audioData[i] * 2;const x = i * barWidth;// 计算颜色const hue = (i * 3 + audioData[i]) % 360;const gradient = ctx.createLinearGradient(x, centerY - height/2, x, centerY + height/2);gradient.addColorStop(0, `hsl(${hue}, 100%, 70%)`);gradient.addColorStop(1, `hsl(${(hue + 60) % 360}, 100%, 40%)`);// 绘制柱状ctx.fillStyle = gradient;ctx.fillRect(x, centerY - height/2, barWidth - 1, // 减1留间隙height);// 绘制反射效果ctx.fillStyle = `hsla(${hue}, 100%, 60%, 0.2)`;ctx.fillRect(x, centerY + height/2, barWidth - 1, height * 0.3);}requestAnimationFrame(() => draw(analyser));}// 启动initAudio().then(analyser => {draw(analyser);});</script>
</body>
</html>

11.烟花爆炸效果(粒子动画)

模拟烟花发射、上升到空中后爆炸成彩色粒子的效果,粒子会逐渐消失

<!DOCTYPE html>
<html>
<head><title>烟花动画</title><style>canvas { background: #0a0a16; display: block; }body { margin: 0; overflow: hidden; }.info { position: fixed; top: 10px; left: 0; width: 100%; color: white; text-align: center; }</style>
</head>
<body><div class="info">点击屏幕发射烟花</div><canvas id="fireworksCanvas"></canvas><script>const canvas = document.getElementById('fireworksCanvas');const ctx = canvas.getContext('2d');// 设置全屏canvas.width = window.innerWidth;canvas.height = window.innerHeight;window.addEventListener('resize', () => {canvas.width = window.innerWidth;canvas.height = window.innerHeight;});const fireworks = [];const particles = [];// 烟花类(发射阶段)class Firework {constructor(x, targetY) {this.x = x;this.y = canvas.height;this.targetY = targetY;this.speed = 8;this.color = `hsl(${Math.random() * 360}, 100%, 60%)`;this.reachedTarget = false;}update() {// 计算到目标的距离const dy = this.targetY - this.y;const distance = Math.sqrt(dy * dy);// 移动到目标if (distance > this.speed) {this.y -= this.speed;} else {this.y = this.targetY;this.reachedTarget = true;// 爆炸产生粒子this.explode();}}draw() {ctx.beginPath();ctx.moveTo(this.x, canvas.height);ctx.lineTo(this.x, this.y);ctx.strokeStyle = this.color;ctx.lineWidth = 2;ctx.stroke();}// 爆炸产生粒子explode() {const particleCount = Math.floor(Math.random() * 50) + 80;for (let i = 0; i < particleCount; i++) {const angle = Math.random() * Math.PI * 2;const speed = Math.random() * 5 + 2;particles.push(new Particle(this.x,this.y,Math.cos(angle) * speed,Math.sin(angle) * speed,this.color));}}}// 烟花粒子类(爆炸后)class Particle {constructor(x, y, vx, vy, color) {this.x = x;this.y = y;this.vx = vx;this.vy = vy;this.color = color;this.radius = Math.random() * 2 + 1;this.life = 1;this.decay = Math.random() * 0.03 + 0.01;this.gravity = 0.05;}update() {this.vy += this.gravity; // 应用重力this.x += this.vx;this.y += this.vy;this.life -= this.decay; // 减少生命值}draw() {ctx.beginPath();ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);ctx.fillStyle = this.color.replace(')', `, ${this.life})`);ctx.fill();}isDead() {return this.life <= 0;}}// 自动发射烟花function autoLaunchFireworks() {if (Math.random() < 0.02) { // 2%的概率const x = Math.random() * canvas.width;const targetY = Math.random() * canvas.height * 0.5 + 50;fireworks.push(new Firework(x, targetY));}}// 处理点击发射烟花canvas.addEventListener('click', (e) => {const targetY = Math.random() * canvas.height * 0.5 + 50;fireworks.push(new Firework(e.clientX, targetY));});// 动画循环function animate() {// 半透明覆盖,创建轨迹效果ctx.fillStyle = 'rgba(10, 10, 22, 0.15)';ctx.fillRect(0, 0, canvas.width, canvas.height);// 自动发射烟花autoLaunchFireworks();// 更新和绘制烟花for (let i = fireworks.length - 1; i >= 0; i--) {fireworks[i].update();fireworks[i].draw();if (fireworks[i].reachedTarget) {fireworks.splice(i, 1);}}// 更新和绘制粒子for (let i = particles.length - 1; i >= 0; i--) {particles[i].update();particles[i].draw();if (particles[i].isDead()) {particles.splice(i, 1);}}requestAnimationFrame(animate);}animate();</script>
</body>
</html>

12.粒子文字效果(文字与粒子结合)

<!DOCTYPE html>
<html>
<head><title>粒子文字效果</title><style>canvas { background: #0f172a; display: block; }body { margin: 0; }.info { position: fixed; top: 20px; left: 0; width: 100%; color: white; text-align: center; font-family: sans-serif; }</style>
</head>
<body><div class="info">移动鼠标干扰粒子文字</div><canvas id="particleCanvas"></canvas><script>const canvas = document.getElementById('particleCanvas');const ctx = canvas.getContext('2d');// 设置全屏canvas.width = window.innerWidth;canvas.height = window.innerHeight;window.addEventListener('resize', () => {canvas.width = window.innerWidth;canvas.height = window.innerHeight;initParticles(); // 重新初始化粒子});const particles = [];const text = "PARTICLES";const particleCount = 2000;let mouse = { x: null, y: null, radius: 150 };// 粒子类class Particle {constructor() {this.x = canvas.width / 2;this.y = canvas.height / 2;this.size = Math.random() * 2 + 1;this.baseX = this.x;this.baseY = this.y;this.density = Math.random() * 30 + 10; // 密度影响移动难度this.color = `hsl(${Math.random() * 60 + 180}, 70%, 60%)`; // 青蓝色系}draw() {ctx.beginPath();ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);ctx.fillStyle = this.color;ctx.fill();}update() {// 计算与鼠标的距离let dx = mouse.x - this.x;let dy = mouse.y - this.y;let distance = Math.sqrt(dx * dx + dy * dy);let force = (mouse.radius - distance) / mouse.radius;// 鼠标排斥力if (distance < mouse.radius) {let angle = Math.atan2(dy, dx);let repulsionX = Math.cos(angle) * force * 2;let repulsionY = Math.sin(angle) * force * 2;this.x -= repulsionX;this.y -= repulsionY;} else {// 回到原始位置let dxBase = this.baseX - this.x;let dyBase = this.baseY - this.y;this.x += dxBase / this.density;this.y += dyBase / this.density;}this.draw();}}// 初始化粒子位置(形成文字)function initParticles() {particles.length = 0;// 首先在临时画布上绘制文字const tempCanvas = document.createElement('canvas');const tempCtx = tempCanvas.getContext('2d');tempCanvas.width = canvas.width;tempCanvas.height = canvas.height;// 设置文字样式tempCtx.font = 'bold 120px Arial';tempCtx.textAlign = 'center';tempCtx.textBaseline = 'middle';tempCtx.fillStyle = 'white';tempCtx.fillText(text, canvas.width / 2, canvas.height / 2);// 获取文字像素数据const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height);const data = imageData.data;// 随机采样文字区域的像素作为粒子let particlesAdded = 0;while (particlesAdded < particleCount) {const x = Math.random() * canvas.width;const y = Math.random() * canvas.height;const index = (Math.floor(y) * canvas.width + Math.floor(x)) * 4;// 如果该位置是文字(Alpha值大于0)if (data[index + 3] > 10) {const p = new Particle();p.x = x;p.y = y;p.baseX = x;p.baseY = y;particles.push(p);particlesAdded++;}}}// 鼠标移动跟踪window.addEventListener('mousemove', (e) => {mouse.x = e.clientX;mouse.y = e.clientY;});// 鼠标离开画布window.addEventListener('mouseleave', () => {mouse.x = null;mouse.y = null;});// 动画循环function animate() {ctx.clearRect(0, 0, canvas.width, canvas.height);particles.forEach(particle => {particle.update();});requestAnimationFrame(animate);}// 初始化initParticles();animate();</script>
</body>
</html>

http://www.dtcms.com/a/390359.html

相关文章:

  • 【系统架构设计(34)】计算机网络架构与技术基础
  • 计网1.2 计算机网络体系结构与参考模型
  • ML-Watermelonbook
  • E/E架构新课题的解决方案
  • 【CVPR 2025】用于密集图像预测的频率动态卷积
  • 整体设计 语言拼凑/逻辑拆解/词典缝合 之 1 表达词项的散列/序列/行列 (豆包助手)
  • FPGA学习篇——Verilog学习之半加器的实现
  • Python快速入门专业版(三十五):函数实战2:文件内容统计工具(统计行数/单词数/字符数)
  • CSS的文本样式二【文本布局】
  • redis配置与优化
  • STM32 单片机 - 中断
  • 【网络工程师】ACL基础实验
  • 小实验--LCD1602显示字符和字符串
  • Java 的双亲委派模型(Parent Delegation Model)
  • ​​[硬件电路-249]:LDO(低压差线性稳压器)专用于线性电源,其核心设计逻辑与线性电源高度契合,而与开关电源的工作原理存在本质冲突。
  • conda命令行指令大全
  • TCP三次握手与四次挥手
  • Python读取Excel中指定列的所有单元格内容
  • 【DMA】DMA入门:理解DMA与CPU的并行
  • Redis数据库(一)—— 初步理解Redis:从基础配置到持久化机制
  • Salesforce中的事件驱动架构:构建灵活可扩展的企业应用
  • OpenCV实现消除功能
  • Qt QValueAxis详解
  • deepseek大模型部署
  • 消息队列与定时器:如何优雅地处理耗时任务?
  • Maya绑定基础知识总结合集:父子关系和父子约束对比、目标约束示例
  • STM32开发(中断模式:外部中断)
  • (圆方树)洛谷 P4630 APIO2018 铁人两项 题解
  • windows10 使用moon-pilot并配置模型
  • Linux笔记---epoll用法及原理:从内核探究文件等待队列的本质-回调机制