HTML5 Canvas 从入门到游戏实战

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 的 widthheight 必须用 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 已经完全可玩,但有几点可以优化:

  1. 帧依赖移动 :玩家速度 5、子弹速度 8 都是每帧固定位移。在不同刷新率下体验不一致。生产级代码应引入 deltaTime。
  2. 星星背景Date.now() 被放在 draw() 中计算滚动位置------这虽然简便,但时间逻辑混入了渲染层,更好的做法是在 update() 中维护一个 starOffset 变量。
  3. 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 的学习路径可以拆成三步:

  1. 画得出来------掌握基本 API:矩形、路径、颜色、样式
  2. 动得起来 ------理解帧动画循环:clearRectupdatedrawrequestAnimationFrame
  3. 玩得转场景------游戏开发 = 帧动画 + 交互输入 + 碰撞检测 + 特效;数据可视化 = Canvas 引擎(ECharts)+ 数据 + 配置

本文的完整代码涵盖了这三个阶段,从几行 demo 到一个完整的飞机大战游戏,再到 ECharts 数据报表,读者可以逐步深入。

核心要点回顾:

  • Canvas 宽高必须用属性设置,不能用 CSS 拉伸
  • fillStyle/strokeStyle 控制颜色,fillRect/strokeRect 绘制矩形
  • clearRect 擦除区域,是帧动画的第一步
  • requestAnimationFrame 替代 setInterval 做动画,与屏幕刷新率同步
  • ctx.save()/ctx.restore() 管理绘图状态,避免污染
  • 从数组删除元素时从后往前遍历
  • ECharts 通过声明式 option 配置图表,内置响应式 resize
相关推荐
Darling噜啦啦3 小时前
Canvas 游戏开发与数据可视化实战:从飞机大战到 ECharts 报表
前端·echarts·canvas
TA远方1 天前
【HTML】JavaScript Canvas 图像截取与保存完整指南
前端·javascript·html·canvas·截图·截取
HYCS7 天前
用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互
前端·javascript·canvas
戈德斯文11 天前
我做了一面互联网摸鱼墙:从无限 Canvas 到本地生产环境
react.js·canvas·next.js
七夜zippoe11 天前
OpenClaw Canvas A2UI:AI驱动的交互式界面开发实战
人工智能·canvas·交互式·a2ui·openclaw
QING61813 天前
如何使用Compose 绘制提升性能 —— 新手指南
kotlin·android jetpack·canvas
七夜zippoe13 天前
OpenClaw Canvas 截图:页面捕获与保存
canvas·捕获·页面·openclaw
七夜zippoe15 天前
OpenClaw Canvas 执行:JavaScript 注入实战
开发语言·javascript·udp·canvas·openclaw
HYCS15 天前
用pixi.js实现fabric.js(五):事件系统
前端·javascript·canvas