HTML5 Canvas 从入门到游戏实战
HTML5 Canvas 是前端领域一块极具魅力的技术阵地------它把浏览器变成了一个像素级的画布,数据可视化、网页游戏、炫酷动效,都能在 Canvas 上施展拳脚。本文从零开始梳理 Canvas 的核心概念,并通过一个完整的飞机大战游戏和 ECharts 数据可视化案例,串联起 Canvas 的实用知识体系。
一、Canvas 是什么
Canvas(画布)是 HTML5 提供的一个标签,本身不绘制任何图形------它只负责在页面上开辟一块矩形"画板"。真正画画的是 JavaScript API。
html
<canvas id="myCanvas" width="600" height="400" style="border:1px solid #333;">
你的浏览器不支持Canvas
</canvas>
标签中间的文字是降级内容------只有浏览器不支持 Canvas 时才会显示。
注意:Canvas 的
width和height必须用 HTML 属性或 JS 设置,不要用 CSS 的 width/height 去控制------CSS 只会拉伸画布,不会改变绘制坐标系的分辨率。
二、核心 API:绘制与擦除
2.1 获取上下文
一切绘图操作都始于渲染上下文:
js
const canvas = document.querySelector('#myCanvas');
const ctx = canvas.getContext('2d'); // 2D 上下文
如果是 3D,则用 getContext('webgl') 或 getContext('webgl2')------这也是 Three.js 等 3D 引擎的底层入口。
2.2 基本图形
Canvas 2D 提供了两种绘制模式:
| 模式 | 方法 | 说明 |
|---|---|---|
| 填充 | fillRect(x, y, w, h) |
画实心矩形 |
| 描边 | strokeRect(x, y, w, h) |
画空心矩形边框 |
js
ctx.fillStyle = '#4299e1'; // 填充色
ctx.fillRect(20, 20, 100, 80); // 实心蓝矩形
ctx.strokeStyle = '#f56565'; // 描边色
ctx.lineWidth = 4; // 线宽
ctx.strokeRect(150, 20, 100, 80); // 空心红矩形
除了矩形,Canvas 还支持通过**路径(Path)**绘制任意形状:
js
ctx.beginPath();
ctx.moveTo(0, 0); // 起点
ctx.lineTo(100, 50); // 线段
ctx.arc(150, 100, 30, 0, Math.PI * 2); // 圆弧
ctx.closePath();
ctx.fill(); // 填充路径
ctx.stroke(); // 描边路径
2.3 clearRect------橡皮擦
js
ctx.clearRect(x, y, width, height);
擦除指定矩形区域内的所有像素,让画布恢复透明。它是帧动画的基础------每帧先用 clearRect 清空上一帧,再绘制新帧。
三、动画的核心:requestAnimationFrame
很多人一开始会用 setInterval 做动画:
js
// ❌ 不推荐
setInterval(() => {
ctx.clearRect(0, 0, 600, 400);
ctx.fillRect(x, y, 100, 80);
x += 3;
}, 16); // 约 60fps
问题在哪?setInterval 的间隔时间和显示器的刷新频率不在一个频道上,容易造成画面撕裂、丢帧或过度绘制。
浏览器专门提供了 requestAnimationFrame(以下简称 rAF),它会在下一次屏幕刷新之前调用你的回调函数,完美同步显示器刷新率(通常是 60Hz,即每 16.67ms 一帧)。
3.1 基本用法
rAF 只执行一次,所以需要递归调用来形成连续的帧循环:
js
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 擦掉上一帧
ctx.fillRect(x, y, 100, 80); // 画新位置
x += 3;
if (x > canvas.width) x = -100; // 边界循环
requestAnimationFrame(animate); // 递归请求下一帧
}
animate(); // 启动循环
3.2 帧动画通用模式
scss
┌─────────────────────────┐
│ requestAnimationFrame │ ← 浏览器在下次刷新前调用
└──────────┬──────────────┘
▼
┌─────────────────────────┐
│ clearRect() 清空画布 │
├─────────────────────────┤
│ 更新状态(位置/速度) │
├─────────────────────────┤
│ 绘制所有元素 │
├─────────────────────────┤
│ 递归请求下一帧 │
└─────────────────────────┘
3.3 一个注意点
上面例子里 x += 3 是帧依赖的------在 120Hz 屏幕上物体会比 60Hz 屏幕快一倍。正式的游戏开发应该使用**时间增量(deltaTime)**来保证不同刷新率下体验一致:
js
let lastTime = 0;
function animate(timestamp) {
const deltaTime = (timestamp - lastTime) / 1000; // 秒
lastTime = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
x += speed * deltaTime * 60; // 以 60fps 为基准
// ...
requestAnimationFrame(animate);
}
四、实战:飞机大战游戏
基于上面的知识,我们来实现一个完整的飞机射击游戏(MVP 版本),涵盖以下要素:
- 玩家飞机移动(键盘 WASD / 方向键)
- 发射子弹(空格键)
- 敌机生成与移动
- 碰撞检测
- 爆炸粒子特效
- 分数系统与游戏结束/重开
4.1 工程初始化
bash
npm create vite@latest airplan -- --template vanilla
项目结构:
css
airplan/
├── index.html
├── package.json
├── vite.config.js
└── src/
├── main.js # 游戏核心逻辑
└── style.css
4.2 缩放适配
游戏使用固定的逻辑分辨率(400×600),通过 setTransform 按比例缩放到实际屏幕:
js
const GAME_W = 400;
const GAME_H = 600;
function resize() {
const scale = Math.min(
window.innerWidth / GAME_W,
window.innerHeight / GAME_H
);
canvas.width = GAME_W * scale;
canvas.height = GAME_H * scale;
canvas.style.width = canvas.width + 'px';
canvas.style.height = canvas.height + 'px';
ctx.setTransform(scale, 0, 0, scale, 0, 0);
}
setTransform(scale, 0, 0, scale, 0, 0) 将坐标系整体缩放,后续所有绘制坐标只需按 400×600 的逻辑坐标来写,不用关心实际像素尺寸。
4.3 键盘输入管理
用一个对象追踪按键状态:
js
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
e.preventDefault();
});
document.addEventListener('keyup', e => {
keys[e.code] = false;
e.preventDefault();
});
在每帧的 update() 中读取 keys['ArrowLeft']、keys['Space'] 等来决定行为。这种状态驱动的方式比直接监听 keydown 事件更平滑------按钮可以持续按住而非每次都要抬起再按下。
4.4 玩家飞机绘制
用 Canvas 路径 API 手绘飞机形状:
js
function drawPlayer() {
const { x, y, w, h } = player;
ctx.save();
ctx.translate(x, y);
// 机身(五边形)
ctx.fillStyle = '#4fc3f7';
ctx.beginPath();
ctx.moveTo(0, -h / 2);
ctx.lineTo(-w / 2, h / 2);
ctx.lineTo(-w / 6, h / 3);
ctx.lineTo(0, h / 2.5);
ctx.lineTo(w / 6, h / 3);
ctx.lineTo(w / 2, h / 2);
ctx.closePath();
ctx.fill();
// 驾驶舱(圆)
ctx.fillStyle = '#b3e5fc';
ctx.beginPath();
ctx.arc(0, -h / 6, w / 7, 0, Math.PI * 2);
ctx.fill();
// 尾焰(随机抖动)
ctx.fillStyle = '#ff9800';
ctx.beginPath();
ctx.moveTo(-w / 8, h / 3);
ctx.lineTo(0, h / 2 + 8 + Math.random() * 4);
ctx.lineTo(w / 8, h / 3);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.save() 和 ctx.restore() 是状态管理的关键------它们保存和恢复当前的变换矩阵、填充色、线宽等所有绘图状态。因为用了 translate 移动了坐标系原点,必须在函数结束时 restore() 不影响后续绘制。
尾焰的 Math.random() 让火焰每帧随机抖动,产生动态燃烧的效果。
4.5 碰撞检测
使用中心点距离法(适用于轴对齐矩形):
js
function rectCollide(a, b) {
return (
Math.abs(a.x - b.x) < (a.w + b.w) / 2 &&
Math.abs(a.y - b.y) < (a.h + b.h) / 2
);
}
两个矩形中心点的 x 方向距离小于它们半宽之和,且 y 方向距离小于半高之和,即判定碰撞。
4.6 粒子爆炸特效
敌机被击中或玩家死亡时,生成一组粒子:
js
function spawnExplosion(x, y, count = 12) {
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
const speed = 1 + Math.random() * 3;
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 2 + Math.random() * 3, // 半径
g: 100 + Math.floor(Math.random() * 155), // 绿色分量(形成橙→黄渐变)
alpha: 1, // 初始透明度
decay: 0.02 + Math.random() * 0.04, // 衰减速度
});
}
}
每帧更新时粒子向外扩散、逐渐透明,最终消失:
js
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.alpha -= p.decay;
if (p.alpha <= 0) particles.splice(i, 1);
}
从数组末尾向前遍历,才能安全地在循环中
splice删除元素------如果从前往后遍历,删除元素会导致后续索引错位。
4.7 游戏主循环
js
function loop() {
if (!gameOver) update(); // 游戏结束则冻结更新,但继续绘制
draw();
requestAnimationFrame(loop);
}
更新与绘制分离,游戏结束时停止逻辑更新但保留画面渲染(显示 Game Over 画面)。
4.8 值得改进的地方
当前版本作为 MVP 已经完全可玩,但有几点可以优化:
- 帧依赖移动 :玩家速度
5、子弹速度8都是每帧固定位移。在不同刷新率下体验不一致。生产级代码应引入 deltaTime。 - 星星背景 :
Date.now()被放在draw()中计算滚动位置------这虽然简便,但时间逻辑混入了渲染层,更好的做法是在update()中维护一个starOffset变量。 - resize 未防抖:窗口大小变化时会频繁触发 resize,可加 debounce 减少不必要的重绘。
五、数据可视化:ECharts
Canvas 的另一个主战场是数据可视化。ECharts 是百度开源的图表库,底层基于 Canvas(部分场景用 SVG),提供了开箱即用的丰富图表类型。
5.1 快速上手
bash
npm install echarts
js
import * as echarts from 'echarts';
const chartDom = document.querySelector('#app');
const chart = echarts.init(chartDom);
chart.setOption({
title: { text: '年度销售数据', left: 'center' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['1月', '2月', ...] },
yAxis: { type: 'value', name: '万元' },
series: [{
name: '销售额',
type: 'bar',
data: [82.1, 85.3, ...],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 1, color: '#188df0' }
])
},
label: { show: true, position: 'top' }
}]
});
// 响应式缩放
window.addEventListener('resize', () => chart.resize());
5.2 模拟数据生成
项目中使用了一个简单的**伪随机数生成器(PRNG)**配合季节性波动来生成合理的销售数据:
js
function seedRandom(seed) {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
用固定种子保证每次生成的"随机"数据一致,便于调试和演示。
5.3 核心配置项理解
ECharts 的配置采用声明式设计,几个关键概念:
| 配置项 | 作用 |
|---|---|
xAxis / yAxis |
坐标轴定义(category=类目, value=数值, time=时间) |
series |
数据系列,一个图表可以有多条 series |
tooltip |
鼠标悬停时的提示框 |
grid |
图表的内边距区域 |
itemStyle |
数据项的样式(颜色、渐变等) |
label |
数据标签(显示在柱子上方的数值) |
六、总结
Canvas 的学习路径可以拆成三步:
- 画得出来------掌握基本 API:矩形、路径、颜色、样式
- 动得起来 ------理解帧动画循环:
clearRect→update→draw→requestAnimationFrame - 玩得转场景------游戏开发 = 帧动画 + 交互输入 + 碰撞检测 + 特效;数据可视化 = Canvas 引擎(ECharts)+ 数据 + 配置
本文的完整代码涵盖了这三个阶段,从几行 demo 到一个完整的飞机大战游戏,再到 ECharts 数据报表,读者可以逐步深入。
核心要点回顾:
- Canvas 宽高必须用属性设置,不能用 CSS 拉伸
fillStyle/strokeStyle控制颜色,fillRect/strokeRect绘制矩形clearRect擦除区域,是帧动画的第一步requestAnimationFrame替代setInterval做动画,与屏幕刷新率同步ctx.save()/ctx.restore()管理绘图状态,避免污染- 从数组删除元素时从后往前遍历
- ECharts 通过声明式 option 配置图表,内置响应式 resize