你有多久没写过一个"好玩"的程序了?
每天跟 API、表单、状态管理打交道,前端开发变得越来越像搬砖。
但你还记得第一次写出一个能跑的小游戏时的那种兴奋吗?
HTML5 Canvas 就是那把钥匙——它不需要任何第三方库,就躺在浏览器里,等你拿起它画点什么。今天,我们从零开始,先用几行代码让一个方块动起来,再一步步拆解一个完整的射击游戏「雷霆战机」,看看 Canvas 到底能做什么。
Canvas 是什么?
简单说,Canvas 就是浏览器给你的一块"画布"。你在 HTML 里写一个 <canvas> 标签,JavaScript 里拿到它的"画笔"(context),然后想画什么画什么。
<canvas id="myCanvas" width="600" height="400"
style="border: 1px solid #333;">
你的浏览器不支持Canvas(旧IE会显示这段文字)
</canvas>
const canvas = document.querySelector('#myCanvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#4299e1'; // 设置填充颜色
ctx.fillRect(20, 20, 100, 80); // 画一个矩形
ctx.strokeStyle = '#f56565'; // 设置描边颜色
ctx.lineWidth = 4;
ctx.strokeRect(150, 20, 100, 80); // 画一个描边矩形
ctx.clearRect(50, 50, 40, 30); // 擦除一块区域
width 和 height 是画布的实际像素尺寸(不是 CSS 样式),默认 300×150。getContext('2d') 拿到 2D 绘图上下文对象——它就是你的画笔,上面挂着所有绘制方法。fillStyle 管填充颜色,strokeStyle 管描边颜色,fillRect(x, y, w, h) 从左上角开始画矩形。
就这么几个 API,已经能画任何形状了。Canvas 没有"组件"的概念——它只有像素,这恰恰是它最自由的地方。
让画面动起来:帧动画的秘密
静态画布没什么意思。真正让 Canvas 发光的是动画。
游戏和动画的核心原理出奇地简单:擦掉上一帧 → 画上新一帧 → 重复。你的眼睛会把连续的静态画面脑补成流畅的运动。
但这里有个坑:用什么来驱动这个循环?
很多人第一反应是 setInterval:
setInterval(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(x, y, 100, 80);
x += 3;
}, 16); // 1000ms / 60 ≈ 16ms
理论上 16ms 一次就是 60 帧。实际上呢?setInterval 的计时和显示器的刷新率完全不在一个频道上——屏幕可能在两帧之间刷新,导致画面撕裂;后台标签页里它还在跑,白白消耗资源。
正确的做法是 requestAnimationFrame:
let x = 20;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4299e1';
ctx.fillRect(x, 20, 100, 80);
x += 3;
if (x > canvas.width) x = -100; // 超出右边界就从左边回来
requestAnimationFrame(animate); // 递归调用,交给浏览器调度
}
animate();
requestAnimationFrame 会把绘制回调精确地排进浏览器的渲染流水线,跟屏幕刷新率完美同步(通常是 60Hz),标签页切到后台自动暂停——省电、流畅、不掉帧。这是做 Canvas 动画的第一个分水岭。
实战拆解:雷霆战机
理论讲够了。我们来看一个真实可玩的射击游戏——「雷霆战机」,全部代码不到 370 行,包含星空背景、玩家飞机、三种敌机、子弹系统、爆炸粒子和双端操控。
工程架子
const canvas = document.getElementById('gc');
const ctx = canvas.getContext('2d');
const GW = 480, GH = 800; // 游戏世界尺寸
// 响应式缩放:保持比例,适配任意屏幕
function resize() {
const s = Math.min(window.innerWidth / GW, window.innerHeight / GH);
canvas.width = GW;
canvas.height = GH;
canvas.style.width = (GW * s) + 'px';
canvas.style.height = (GH * s) + 'px';
}
window.addEventListener('resize', resize);
resize();
这里有个细节值得注意:canvas.width/height 设置的是实际像素,canvas.style.width/height 是 CSS 显示尺寸。两者分离,游戏逻辑始终在 480×800 的坐标系里运算,缩放交给 CSS,代码完全不用关心屏幕物理尺寸。
游戏主循环
function loop() {
update(); // 更新所有对象的位置和状态
draw(); // 绘制当前帧
requestAnimationFrame(loop);
}
update 负责计算:玩家移动、子弹飞行、敌机下落、碰撞检测、粒子衰减、星星滚动。draw 负责渲染:背景 → 星星 → 子弹 → 敌机 → 粒子 → 玩家 → UI 文字。两个阶段职责分明,这是游戏开发最基本的架构模式。
星空背景
80 颗星星,不同半径和速度——越大的越亮,简单的视差效果就有了。星星滚出底部就回到顶部重新随机位置,无限循环:
const stars = Array.from({ length: 80 }, () => ({
x: rand(0, GW), y: rand(0, GH),
r: rand(0.5, 2), // 半径
spd: rand(0.3, 2), // 下落速度
}));
// 绘制:透明度跟星星大小挂钩
for (const s of stars) {
ctx.globalAlpha = 0.4 + s.r / 5;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
用路径画飞机
Canvas 没有"精灵"概念,飞机得用 beginPath() → moveTo() → lineTo() → closePath() 一笔一笔描出来:
function drawPlane(x, y, w, h, color) {
ctx.save();
ctx.translate(x + w / 2, y + h / 2); // 原点移到飞机中心
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, -h / 2); // 机头
ctx.lineTo(w * 0.15, -h * 0.15); // 右侧机身
ctx.lineTo(w / 2, h * 0.12); // 右翼尖
ctx.lineTo(-w / 2, h * 0.12); // 左翼尖
// ... 更多顶点
ctx.closePath();
ctx.fill();
// 驾驶舱(白色椭圆)
ctx.fillStyle = '#fff';
ctx.ellipse(0, -h * 0.02, w * 0.09, h * 0.17, 0, 0, Math.PI * 2);
ctx.fill();
// 尾焰(橙色光点)
ctx.fillStyle = '#ffaa00';
ctx.arc(0, h * 0.36, w * 0.07, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
translate 把坐标原点移到飞机中心,后续所有坐标都相对于这个中心——飞机无论出现在屏幕哪个位置,绘制逻辑完全一致。敌机的画法类似,用 ctx.scale(1, -1) 做垂直翻转倒过来飞。
难度递进的敌机系统
三种敌机类型,分数越高,出场种类越多、速度越快。生成间隔从 50 帧逐渐降到 18 帧——玩家能明显感觉到压力在持续上升:
const ETYPES = [
{ w: 30, h: 30, speed: 2.5, score: 10, color: '#ff4444' },
{ w: 42, h: 42, speed: 2.0, score: 20, color: '#ff8800' },
{ w: 54, h: 54, speed: 1.5, score: 50, color: '#ff00aa' },
];
function spawnEnemy() {
let t;
if (score < 200) t = 0; // 只出小型
else if (score < 500) t = randI(0, 1); // 混合两种
else t = randI(0, 2); // 三种全出
const tp = ETYPES[t];
enemies.push({
x: rand(10, GW - tp.w - 10), y: -tp.h,
speed: tp.speed + rand(-0.5, 0.5) + score * 0.002,
score: tp.score, color: tp.color,
});
}
子弹与碰撞检测
碰撞用 AABB(轴对齐矩形包围盒)——两个矩形不重叠就是没碰上,简单可靠。注意遍历必须倒序:splice 会改变数组长度,正序遍历会跳过相邻元素:
function rectHit(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
}
// 倒序遍历
for (let bi = bullets.length - 1; bi >= 0; bi--) {
for (let ei = enemies.length - 1; ei >= 0; ei--) {
if (rectHit(bullets[bi], enemies[ei])) {
boom(enemies[ei].x + enemies[ei].w / 2,
enemies[ei].y + enemies[ei].h / 2,
enemies[ei].color);
score += enemies[ei].score;
enemies.splice(ei, 1);
bullets.splice(bi, 1);
break;
}
}
}
粒子爆炸:最小的细节决定手感
每一帧粒子向外扩散,同时 life 递减。绘制时用 ctx.globalAlpha = p.life / p.max 做渐隐——粒子从亮到暗再到消失。没有爆炸特效的射击游戏就像在点按钮,打爆敌机的反馈感全靠这个:
function boom(x, y, color) {
const n = randI(8, 16);
for (let i = 0; i < n; i++) {
const a = rand(0, Math.PI * 2); // 随机方向
const sp = rand(1, 5); // 随机速度
particles.push({
x, y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
life: rand(20, 40), max: 40, // 生命值
color, size: rand(1.5, 3.5),
});
}
}
键盘 + 触屏双操控
游戏同时支持键盘(方向键/WASD 移动,空格/J 射击)和触屏。触屏获取坐标时做了 70 像素的上偏移——移动端手指会挡住飞机,这个小偏移让手感从"没法玩"变成"挺顺手":
function canvasPos(e) {
const r = canvas.getBoundingClientRect();
const sx = GW / r.width, sy = GH / r.height;
const t = e.touches[0];
return {
x: (t.clientX - r.left) * sx,
y: (t.clientY - r.top) * sy - 70 // 手指不挡飞机
};
}
Canvas 还能做什么?
做完游戏只是冰山一角。
数据可视化是 Canvas 的另一大主场。百度开源的 ECharts 底层就是 Canvas(新版也支持 SVG),折线图、饼图到热力地图,各种酷炫报表本质上都是在 Canvas 上画点、线、面。需要定制化可视化的时候,直接手写 Canvas 比套库灵活得多。
3D 场景也离不开 Canvas。getContext('webgl') 拿到 WebGL 上下文,Three.js 这类 3D 引擎的底层渲染目标就是 Canvas。AI 游戏爆发后,物理大模型 + Three.js + Canvas 的组合正在催生新一代浏览器端沉浸式体验。
写在最后
回到开头的问题:我们为什么还要学 Canvas?
因为 Canvas 让你重新成为"创造者"而不是"装配工"。没有组件库替你决定一切,没有框架约束想象力——一块画布、一支画笔,从粒子效果到完整游戏,代码即是创造。
「雷霆战机」的全部源码就放在一个 HTML 文件里,400 行不到。用 Vite 起个工程、引入模块化拆分、加上音效和关卡系统,就是一个可以上架微信小游戏的完整项目。而这一切的起点,不过是一行代码:
const ctx = canvas.getContext('2d');
拿起画笔吧。
你写过最好玩的 Canvas 项目是什么?欢迎在评论区分享作品链接,一起交流。