HTML5 Canvas 从入门到游戏实战:300行代码打造「雷霆战机」

87 阅读7分钟

你有多久没写过一个"好玩"的程序了?

每天跟 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);   // 擦除一块区域

widthheight 是画布的实际像素尺寸(不是 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 项目是什么?欢迎在评论区分享作品链接,一起交流。