🎨 HTML5 Canvas 从入门到实战:手把手教你打造一款"打飞机"小游戏
阅读提示: 本文从零开始讲解 Canvas 的核心概念,通过「基础绘图 → 动画原理 → 完整游戏」三个递进阶段,带你彻底掌握 Canvas 开发。文章包含大量可运行的代码示例和详细的原理讲解,建议收藏后配合代码实操。
📖 目录
- [一、Canvas 是什么?](#一、Canvas 是什么? "#%E4%B8%80canvas-%E6%98%AF%E4%BB%80%E4%B9%88")
- [二、Canvas 基础入门:绘制你的第一幅画](#二、Canvas 基础入门:绘制你的第一幅画 "#%E4%BA%8Ccanvas-%E5%9F%BA%E7%A1%80%E5%85%A5%E9%97%A8%E7%BB%98%E5%88%B6%E4%BD%A0%E7%9A%84%E7%AC%AC%E4%B8%80%E5%B9%85%E7%94%BB")
- [2.1 Canvas 元素与渲染上下文](#2.1 Canvas 元素与渲染上下文 "#21-canvas-%E5%85%83%E7%B4%A0%E4%B8%8E%E6%B8%B2%E6%9F%93%E4%B8%8A%E4%B8%8B%E6%96%87")
- [2.2 填充矩形 fillRect](#2.2 填充矩形 fillRect "#22-%E5%A1%AB%E5%85%85%E7%9F%A9%E5%BD%A2-fillrect")
- [2.3 描边矩形 strokeRect](#2.3 描边矩形 strokeRect "#23-%E6%8F%8F%E8%BE%B9%E7%9F%A9%E5%BD%A2-strokerect")
- [2.4 清除矩形 clearRect](#2.4 清除矩形 clearRect "#24-%E6%B8%85%E9%99%A4%E7%9F%A9%E5%BD%A2-clearrect")
- [2.5 Canvas 坐标系详解](#2.5 Canvas 坐标系详解 "#25-canvas-%E5%9D%90%E6%A0%87%E7%B3%BB%E8%AF%A6%E8%A7%A3")
- [三、Canvas 动画原理:让画面动起来](#三、Canvas 动画原理:让画面动起来 "#%E4%B8%89canvas-%E5%8A%A8%E7%94%BB%E5%8E%9F%E7%90%86%E8%AE%A9%E7%94%BB%E9%9D%A2%E5%8A%A8%E8%B5%B7%E6%9D%A5")
- [3.1 requestAnimationFrame ------ 动画的心脏](#3.1 requestAnimationFrame —— 动画的心脏 "#31-requestanimationframe--%E5%8A%A8%E7%94%BB%E7%9A%84%E5%BF%83%E8%84%8F")
- [3.2 实现一个平滑移动的矩形](#3.2 实现一个平滑移动的矩形 "#32-%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E5%B9%B3%E6%BB%91%E7%A7%BB%E5%8A%A8%E7%9A%84%E7%9F%A9%E5%BD%A2")
- [3.3 动画循环的完整流程](#3.3 动画循环的完整流程 "#33-%E5%8A%A8%E7%94%BB%E5%BE%AA%E7%8E%AF%E7%9A%84%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B")
- [四、实战项目:打飞机小游戏 🚀](#四、实战项目:打飞机小游戏 🚀 "#%E5%9B%9B%E5%AE%9E%E6%88%98%E9%A1%B9%E7%9B%AE%E6%89%93%E9%A3%9E%E6%9C%BA%E5%B0%8F%E6%B8%B8%E6%88%8F-")
- [4.1 项目架构概览](#4.1 项目架构概览 "#41-%E9%A1%B9%E7%9B%AE%E6%9E%B6%E6%9E%84%E6%A6%82%E8%A7%88")
- [4.2 游戏核心设计](#4.2 游戏核心设计 "#42-%E6%B8%B8%E6%88%8F%E6%A0%B8%E5%BF%83%E8%AE%BE%E8%AE%A1")
- [4.3 玩家飞机绘制](#4.3 玩家飞机绘制 "#43-%E7%8E%A9%E5%AE%B6%E9%A3%9E%E6%9C%BA%E7%BB%98%E5%88%B6")
- [4.4 子弹系统](#4.4 子弹系统 "#44-%E5%AD%90%E5%BC%B9%E7%B3%BB%E7%BB%9F")
- [4.5 敌机生成与难度递增](#4.5 敌机生成与难度递增 "#45-%E6%95%8C%E6%9C%BA%E7%94%9F%E6%88%90%E4%B8%8E%E9%9A%BE%E5%BA%A6%E9%80%92%E5%A2%9E")
- [4.6 碰撞检测算法](#4.6 碰撞检测算法 "#46-%E7%A2%B0%E6%92%9E%E6%A3%80%E6%B5%8B%E7%AE%97%E6%B3%95")
- [4.7 粒子爆炸特效](#4.7 粒子爆炸特效 "#47-%E7%B2%92%E5%AD%90%E7%88%86%E7%82%B8%E7%89%B9%E6%95%88")
- [4.8 星空背景](#4.8 星空背景 "#48-%E6%98%9F%E7%A9%BA%E8%83%8C%E6%99%AF")
- [4.9 HUD 与游戏状态管理](#4.9 HUD 与游戏状态管理 "#49-hud-%E4%B8%8E%E6%B8%B8%E6%88%8F%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86")
- 五、关键技术点深度解析
- 六、项目运行与调试
- 七、总结与进阶方向
一、Canvas 是什么?
<canvas> 是 HTML5 引入的核心特性之一,它提供了一块位图渲染区域,允许开发者通过 JavaScript 动态绘制图形、图像、动画甚至游戏。
简单来说,Canvas 就像一块画布------你拿到画笔(JavaScript API),就可以在上面自由作画。
Canvas 的核心特点
| 特性 | 说明 |
|---|---|
| 像素级操作 | 可以直接操作每一个像素,实现图像滤镜等高级效果 |
| 即时模式 | 绘制后不保留图形对象,只保留最终的像素结果 |
| 高性能 | 利用 GPU 加速渲染,适合游戏和动画场景 |
| 2D/3D 支持 | 通过 '2d' 或 'webgl' 上下文分别支持 2D 和 3D 渲染 |
即时模式 vs 保留模式: Canvas 是即时模式渲染------画完一笔,系统不会记住"这是一个矩形",只会记住像素颜色。这与 SVG 的保留模式(每个图形都是一个可操作的 DOM 节点)截然不同。
二、Canvas 基础入门:绘制你的第一幅画
2.1 Canvas 元素与渲染上下文
让我们从最基础的 HTML 结构开始:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 Canvas 入门</title>
</head>
<body>
<!--
width/height 属性定义画布的实际像素尺寸
注意:不要用 CSS 的 width/height 来设置,那只会拉伸画布
-->
<canvas id="myCanvas" width="600" height="400"
style="border: 1px solid #333;">
你的浏览器不支持Canvas(旧浏览器会显示这段文字)
</canvas>
<script>
// ① 获取画布元素
const canvas = document.querySelector('#myCanvas');
// ② 获取 2D 渲染上下文 ------ 这是所有绘制的入口
const ctx = canvas.getContext('2d');
// ③ 接下来就可以使用 ctx 进行各种绘制操作了
</script>
</body>
</html>
🔑 关键概念:渲染上下文(Rendering Context)
getContext('2d') 返回一个 CanvasRenderingContext2D 对象,它是所有 2D 绘制 API 的集合。你可以把它理解为:
- 画布(canvas) = 画板
- 上下文(ctx) = 画笔 + 调色板 + 所有绘画工具
⚠️ 常见误区: 不要用 CSS 的
width/height来设置 Canvas 尺寸。Canvas 有两个尺寸 ------元素自身的width/height属性(定义像素分辨率)和 CSS 的width/height(定义显示大小)。用 CSS 拉伸会导致图像模糊。
2.2 填充矩形 fillRect
fillRect(x, y, width, height) 绘制一个填充矩形:
javascript
const canvas = document.querySelector('#myCanvas');
const ctx = canvas.getContext('2d');
// 设置填充颜色
ctx.fillStyle = '#4299e1'; // 类似于 Tailwind 的 blue-400
// 绘制填充矩形:(x, y, 宽度, 高度)
ctx.fillRect(20, 20, 100, 80); // 从 (20,20) 开始画一个 100×80 的蓝色矩形
fillStyle 支持所有 CSS 颜色格式:
- 颜色名:
'red','blue' - 十六进制:
'#4299e1' - RGB/RGBA:
'rgb(66, 153, 225)','rgba(66, 153, 225, 0.5)' - HSL:
'hsl(210, 75%, 57%)'
2.3 描边矩形 strokeRect
strokeRect(x, y, width, height) 绘制一个空心矩形框:
javascript
// 设置描边颜色
ctx.strokeStyle = '#f56565'; // 红色边框
// 设置线条宽度(单位:像素)
ctx.lineWidth = 4;
// 绘制描边矩形
ctx.strokeRect(150, 20, 100, 80);
描边相关的属性:
| 属性 | 说明 | 示例值 |
|---|---|---|
strokeStyle |
线条颜色 | '#f56565' |
lineWidth |
线条宽度 | 4 |
lineCap |
线条端点样式 | 'butt' / 'round' / 'square' |
lineJoin |
线条连接处样式 | 'miter' / 'round' / 'bevel' |
2.4 清除矩形 clearRect
clearRect(x, y, width, height) 擦除指定区域,使其变为透明:
javascript
ctx.clearRect(50, 50, 40, 30); // 在 (50,50) 位置擦除一块 40×30 的区域
这个 API 在动画中至关重要------每一帧都需要先清除上一帧的画面,再绘制新内容,否则画面会不断叠加。
2.5 Canvas 坐标系详解
Canvas 使用标准的 二维笛卡尔坐标系:
scss
(0,0) ──────────────── X 轴(向右为正)→
│
│ * (x, y)
│
↓
Y 轴(向下为正)
记住一个关键点: Canvas 的 Y 轴方向是向下的 ,这与数学中常见的坐标系相反。坐标原点 (0, 0) 在画布的左上角。
📝 完整示例代码(基础篇)
结合以上所有知识点,以下是 1.html 的完整代码:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 Canvas 基础绘图</title>
</head>
<body>
<canvas id="myCanvas" width="600" height="400"
style="border: 1px solid #333;">
你的浏览器不支持Canvas(旧浏览器会显示这段文字)
</canvas>
<script>
// 1. 获取画布元素
const canvas = document.querySelector('#myCanvas');
// 2. 获取 2D 渲染上下文
const ctx = canvas.getContext('2d');
// 3. 填充矩形 ------ 蓝色实心方块
ctx.fillStyle = '#4299e1';
ctx.fillRect(20, 20, 100, 80);
// 4. 描边矩形 ------ 红色空心框
ctx.strokeStyle = '#f56565';
ctx.lineWidth = 4;
ctx.strokeRect(150, 20, 100, 80);
// 5. 清除矩形 ------ 擦除指定区域
ctx.clearRect(50, 50, 40, 30);
</script>
</body>
</html>
🎯 运行效果: 画布上会出现一个蓝色填充矩形、一个红色描边矩形,以及被擦除的一块透明区域。
三、Canvas 动画原理:让画面动起来
静态绘图只是第一步,Canvas 真正的威力在于动画。
3.1 requestAnimationFrame ------ 动画的心脏
在 Canvas 中实现动画,核心是不断重复"清除 → 绘制"这一循环 。而驱动这个循环的最佳工具就是 requestAnimationFrame。
为什么不用 setInterval?
| 对比维度 | setInterval | requestAnimationFrame |
|---|---|---|
| 帧率 | 固定间隔,无法与屏幕刷新同步 | 自动匹配屏幕刷新率(通常 60fps) |
| 性能 | 页面不可见时仍在执行 | 页面不可见时自动暂停,节省资源 |
| 流畅度 | 可能掉帧或撕裂 | 浏览器优化,画面更流畅 |
| 用途 | 定时任务 | 动画专用 |
javascript
// ❌ 不推荐:setInterval 动画
setInterval(() => {
// 绘制逻辑
}, 16); // 约 60fps
// ✅ 推荐:requestAnimationFrame 动画
function animate() {
// 绘制逻辑
requestAnimationFrame(animate); // 递归调用,形成循环
}
animate(); // 启动循环
3.2 实现一个平滑移动的矩形
以下是 2.html 的完整实现,演示了 Canvas 动画的核心流程:
html
<!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>
</head>
<body>
<canvas id="myCanvas" width="600" height="400"
style="border: 1px solid #333;">
你的浏览器不支持Canvas(旧浏览器会显示这段文字)
</canvas>
<script>
const canvas = document.querySelector('#myCanvas');
const ctx = canvas.getContext('2d');
// ── 动画参数 ──
let x = 20; // 矩形的初始 x 坐标
const y = 20; // 矩形的 y 坐标(固定不变)
const width = 100; // 矩形宽度
const height = 80; // 矩形高度
const speed = 3; // 每帧移动的像素数
// ── 动画循环 ──
function animate() {
// ① 清除整个画布(这是最关键的一步!)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ② 在新位置绘制矩形
ctx.fillStyle = '#4299e1';
ctx.fillRect(x, y, width, height);
// ③ 更新位置
x += speed;
// ④ 边界处理:移出屏幕后从左侧重新进入
if (x > canvas.width) {
x = -width;
}
// ⑤ 请求下一帧
requestAnimationFrame(animate);
}
// 启动动画
animate();
</script>
</body>
</html>
3.3 动画循环的完整流程
Canvas 动画遵循一个标准循环,我称之为 动画五步法:
bash
┌─────────────────────────────────────────────┐
│ │
│ ① clearRect 清除上一帧画面 │
│ ↓ │
│ ② save/restore 保存/恢复绘制状态(可选) │
│ ↓ │
│ ③ 绘制 绘制当前帧的所有内容 │
│ ↓ │
│ ④ 更新 更新所有对象的位置/状态 │
│ ↓ │
│ ⑤ requestAnimationFrame 请求下一帧 │
│ ↓ │
│ 回到 ① ...无限循环 │
│ │
└─────────────────────────────────────────────┘
⚠️ 为什么必须 clearRect? 如果不清除画布,每一帧的内容会叠加在上一帧之上,矩形就会变成一条长长的"拖尾"。可以试着注释掉
clearRect那行看看效果------这是一个很有教学意义的实验。
四、实战项目:打飞机小游戏 🚀
掌握了基础绘图和动画原理后,我们来实现一个完整的打飞机小游戏。这是一个非常好的 Canvas 综合练习,涵盖了图形绘制、动画循环、碰撞检测、粒子特效、状态管理等核心技能。
4.1 项目架构概览
bash
airplane/
├── index.html # 游戏入口页面
├── package.json # 项目配置(使用 Vite 构建)
├── vite.config.js # Vite 配置
└── src/
└── main.js # 游戏核心逻辑(约 410 行)
我们使用 Vite 作为开发服务器------它提供热更新(HMR),修改代码后浏览器自动刷新,极大提升开发效率。
启动方式:
bash
cd airplane
npm install
npm run dev
4.2 游戏核心设计
在动手写代码之前,让我们先理清整个游戏的架构设计:
sql
┌──────────────────────────────────────────────────┐
│ Game Loop (60fps) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 输入处理 │→│ 游戏逻辑 │→│ 渲染绘制 │ │
│ │ KeyBoard │ │ 碰撞检测 │ │ 飞机/子弹/敌机/UI │ │
│ └──────────┘ │ 难度递增 │ └──────────────────┘ │
│ └──────────┘ │
│ │
│ 状态机: RUNNING ←→ PAUSED → OVER │
│ ↕ (按R重置) │
└──────────────────────────────────────────────────┘
游戏状态机
javascript
const STATE = { RUNNING: 0, PAUSED: 1, OVER: 2 };
let state = STATE.RUNNING;
使用简单的状态机模式管理三种游戏状态:
- RUNNING:正常游戏进行中
- PAUSED:游戏暂停
- OVER:游戏结束,显示结算画面
4.3 玩家飞机绘制
玩家飞机采用纯 Canvas 路径绘制,不使用任何图片资源------这意味着零依赖,一个 HTML 文件就能跑起来。
javascript
const PLAYER_W = 48;
const PLAYER_H = 56;
const PLAYER_SPEED = 5;
const player = {
x: W / 2 - PLAYER_W / 2, // 初始位于画布底部中央
y: H - PLAYER_H - 30,
w: PLAYER_W,
h: PLAYER_H,
};
function drawPlayer() {
const { x, y, w, h } = player;
ctx.save();
ctx.translate(x + w / 2, y + h / 2); // 将坐标系原点移到飞机中心
// ── 发光效果(shadow)──
ctx.shadowColor = '#00ffcc';
ctx.shadowBlur = 12;
// ── 机身主体(六边形战斗机型)──
ctx.fillStyle = '#00d4ff';
ctx.beginPath();
ctx.moveTo(0, -h / 2); // 机头(上方尖端)
ctx.lineTo(-w / 2, h / 2); // 左下翼
ctx.lineTo(-w / 6, h / 3); // 左内收
ctx.lineTo(0, h / 4 + 2); // 尾部中心
ctx.lineTo(w / 6, h / 3); // 右内收
ctx.lineTo(w / 2, h / 2); // 右下翼
ctx.closePath();
ctx.fill();
// ── 驾驶舱(椭圆形高光)──
ctx.fillStyle = '#7effff';
ctx.shadowBlur = 0; // 驾驶舱不需要发光
ctx.beginPath();
ctx.ellipse(0, -6, 8, 12, 0, 0, Math.PI * 2);
ctx.fill();
// ── 引擎火焰(动态效果)──
ctx.fillStyle = '#ff6600';
ctx.shadowColor = '#ff4400';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(-7, h / 4 + 2);
ctx.lineTo(0, h / 2 + 6 + Math.random() * 8); // Math.random() 让火焰抖动!
ctx.lineTo(7, h / 4 + 2);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
}
🎨 绘制技巧分析
| 技巧 | 代码 | 效果 |
|---|---|---|
| 坐标平移 | ctx.translate(x + w/2, y + h/2) |
以飞机中心为原点绘制,简化坐标计算 |
| 状态保存/恢复 | ctx.save() / ctx.restore() |
隔离绘制状态,防止相互影响 |
| 发光阴影 | shadowColor + shadowBlur |
科幻风格的光晕效果 |
| 动态火焰 | Math.random() * 8 |
引擎火焰随机抖动,更有真实感 |
| 多层绘制 | 机身 → 驾驶舱 → 火焰 | 从底到顶分层绘制,形成丰富细节 |
💡 核心思想:
save()和restore()是 Canvas 最重要的状态管理方法。每次绘制独立的图形时,都应该包裹在这两者之间,这样修改translate、shadowBlur等属性就不会影响到其他图形的绘制。
4.4 子弹系统
子弹系统涉及生成、移动、渲染、回收四个环节:
javascript
const BULLET_W = 4;
const BULLET_H = 14;
const BULLET_SPEED = 8;
const BULLET_COOLDOWN = 12; // 射击冷却帧数
let bullets = [];
let bulletCooldown = 0;
// ── 射击逻辑(在 handleInput 中)──
if (keys['Space'] && bulletCooldown <= 0) {
bullets.push({
x: player.x + player.w / 2 - BULLET_W / 2, // 从飞机中心射出
y: player.y - BULLET_H, // 从飞机顶部出现
w: BULLET_W,
h: BULLET_H,
});
bulletCooldown = BULLET_COOLDOWN; // 重置冷却计数器
}
// ── 子弹更新(在 updateGame 中)──
for (const b of bullets) {
b.y -= BULLET_SPEED; // 子弹向上移动
}
bullets = bullets.filter(b => b.y + b.h > 0); // 越界回收
// ── 子弹渲染 ──
function drawBullet(b) {
ctx.save();
ctx.shadowColor = '#ffdd00';
ctx.shadowBlur = 6;
// 渐变填充:白 → 黄 → 橙
const grad = ctx.createLinearGradient(b.x, b.y, b.x, b.y + b.h);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.3, '#ffee00');
grad.addColorStop(1, '#ff8800');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(b.x, b.y, b.w, b.h, 2); // 圆角矩形子弹
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
}
系统设计要点
-
冷却机制(Cooldown): 如果不加冷却限制,按住空格键时每帧都会生成一颗子弹(60发/秒),游戏会瞬间变得毫无挑战。12 帧的冷却意味着每秒最多发射 5 发。
-
对象池思想: 虽然这里用的是简单的数组
filter,但本质上是在复用内存------飞出去的子弹被过滤回收,新子弹加入数组。 -
循环遍历技巧: 碰撞检测时使用倒序遍历 (
i = bullets.length - 1),这样在splice删除元素时不会影响后续索引。
4.5 敌机生成与难度递增
javascript
const ENEMY_MIN_W = 30;
const ENEMY_MAX_W = 55;
let enemies = [];
let enemySpawnTimer = 0;
let enemySpawnInterval = 45; // 初始每 45 帧生成一架敌机
let enemySpeed = 2.5; // 初始速度
function spawnEnemy() {
if (state !== STATE.RUNNING) return;
const w = ENEMY_MIN_W + Math.random() * (ENEMY_MAX_W - ENEMY_MIN_W);
const h = w * 1.1;
enemies.push({
x: Math.random() * (W - w), // 随机水平位置
y: -h, // 从屏幕顶端外出现
w,
h,
});
}
// ── 难度递增(在 updateGame 中)──
enemySpawnTimer++;
if (enemySpawnTimer >= enemySpawnInterval) {
enemySpawnTimer = 0;
spawnEnemy();
// 🎯 关键:根据分数动态调整难度
enemySpawnInterval = Math.max(15, 45 - Math.floor(score / 100));
enemySpeed = 2.5 + Math.floor(score / 80) * 0.5;
}
📈 难度曲线分析
| 分数范围 | 生成间隔 | 敌机速度 | 游戏体验 |
|---|---|---|---|
| 0-99 | 45 帧 | 2.5 px/frame | 🌱 轻松入门 |
| 100-179 | 44→45 帧 | 3.0 px/frame | 稍有压力 |
| 200-299 | 43→44 帧 | 3.5 px/frame | 😰 开始紧张 |
| 500+ | ≤40 帧 | 5.5+ px/frame | 🔥 高强度 |
Math.max(15, ...)保证生成间隔不会低于 15 帧(约每秒 4 架),防止难度无限增长导致不可玩。- 双重维度(生成速度 + 移动速度)同时递增,形成乘数效应,体验曲线更加丰富。
4.6 碰撞检测算法
游戏中最核心的算法之一------判断两个矩形是否相交:
javascript
function rectCollide(a, b) {
return (
a.x < b.x + b.w && // a 的左边 < b 的右边
a.x + a.w > b.x && // a 的右边 > b 的左边
a.y < b.y + b.h && // a 的上边 < b 的下边
a.y + a.h > b.y // a 的下边 > b 的上边
);
}
这个算法的本质是反向判断:如果两个矩形在 X 轴和 Y 轴上都有重叠,则它们相交。
markdown
X轴重叠:a_left < b_right AND a_right > b_left
Y轴重叠:a_top < b_bottom AND a_bottom > b_top
↓
两者同时满足 → 碰撞!
碰撞检测在游戏循环中的应用:
javascript
// 子弹 vs 敌机(双重倒序循环 + break 优化)
for (let i = bullets.length - 1; i >= 0; i--) {
for (let j = enemies.length - 1; j >= 0; j--) {
if (rectCollide(bullets[i], enemies[j])) {
// 生成爆炸特效
spawnExplosion(
enemies[j].x + enemies[j].w / 2,
enemies[j].y + enemies[j].h / 2,
12, '#ff6600'
);
bullets.splice(i, 1); // 移除子弹
enemies.splice(j, 1); // 移除敌机
score += 10; // 加分
break; // 一颗子弹只能击中一架敌机
}
}
}
// 敌机 vs 玩家(简单遍历即可)
for (const e of enemies) {
if (rectCollide(player, e)) {
spawnExplosion(player.x + player.w / 2, player.y + player.h / 2, 30, '#00ff88');
gameOver();
return; // 游戏结束,立即退出
}
}
🔑 倒序遍历 + splice 是游戏开发的经典组合技,避免了正序遍历删除元素时的索引错位问题。
4.7 粒子爆炸特效
爆炸效果是游戏爽感的重要来源。我们用粒子系统来实现:
javascript
let particles = [];
// ── 生成爆炸粒子 ──
function spawnExplosion(x, y, count, baseColor) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2; // 随机角度(0~360°)
const speed = 1 + Math.random() * 4; // 随机速度
particles.push({
x, y, // 初始位置
vx: Math.cos(angle) * speed, // X 方向速度
vy: Math.sin(angle) * speed, // Y 方向速度
size: 1.5 + Math.random() * 3, // 粒子大小
color: baseColor, // 粒子颜色
life: 1, // 生命值(1 = 完全可见)
decay: 0.02 + Math.random() * 0.04, // 衰减速度
});
}
}
// ── 更新粒子 ──
function updateParticles() {
for (const p of particles) {
p.x += p.vx; // 匀速运动
p.y += p.vy;
p.life -= p.decay; // 逐渐消失
}
particles = particles.filter(p => p.life > 0); // 移除死亡粒子
}
// ── 绘制粒子 ──
function drawParticle(p) {
ctx.save();
ctx.globalAlpha = p.life; // 透明度随生命值衰减
ctx.fillStyle = p.color;
ctx.shadowColor = p.color;
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
}
粒子系统的生命周期
ini
诞生(spawnExplosion)
│ 设定:位置、速度、大小、颜色、生命值
↓
更新(updateParticles)
│ 每帧:位置 += 速度,生命值 -= 衰减
↓
绘制(drawParticle)
│ 透明度 = 生命值,颜色渐淡
↓
死亡(life ≤ 0)
│ 被 filter 过滤回收
↓
消失
💡 通过改变
count(粒子数量)和baseColor(基色),同一个spawnExplosion函数可以创建出不同风格的爆炸效果。敌机爆炸用橙色(#ff6600),玩家爆炸用青绿色(#00ff88)。
4.8 星空背景
一个静态的黑色背景太无聊了,我们用程序化生成的星空来营造太空氛围:
javascript
function drawStars() {
for (let i = 0; i < 60; i++) {
// 使用质数 + 取模来生成"伪随机"但稳定的位置
const sx = (i * 73 + 17) % W;
const sy = ((i * 97 + 31) + (Date.now() * 0.02) % (H * 10)) % H;
// 不同星星有不同亮度,增加层次感
const brightness = 0.3 + (i % 3) * 0.2;
ctx.fillStyle = `rgba(255, 255, 255, ${brightness})`;
ctx.fillRect(sx, sy, 2, 2); // 每个星星是一个 2×2 的小方块
}
}
🎯 为什么不用 Math.random()?
在动画循环中使用 Math.random() 会导致每帧星星位置都在变------看起来像是闪烁干扰而不是星空。这里用 质数散列 (i * 73 + 17 等)来生成确定性的"随机"位置:
- X 坐标:固定值,保证星星水平位置不变
- Y 坐标 :加上
Date.now() * 0.02的偏移,让星星缓慢向下流动 - 亮度 :
i % 3产生 3 种不同亮度等级,模拟远近不同的星星
4.9 HUD 与游戏状态管理
HUD(抬头显示)
javascript
function drawHUD() {
ctx.font = 'bold 16px "Courier New"';
ctx.textBaseline = 'top';
// 当前分数(带发光)
ctx.fillStyle = '#00ff88';
ctx.shadowColor = '#00ff88';
ctx.shadowBlur = 4;
ctx.fillText(`SCORE: ${score}`, 14, 14);
ctx.shadowBlur = 0;
// 最高分
ctx.fillStyle = '#888';
ctx.font = '12px "Courier New"';
ctx.fillText(`BEST: ${highScore}`, 14, 38);
}
游戏结束画面
javascript
function drawGameOver() {
// 半透明遮罩
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, W, H);
// 标题
ctx.font = 'bold 42px "Courier New"';
ctx.fillStyle = '#ff3344';
ctx.shadowColor = '#ff3344';
ctx.shadowBlur = 14;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('GAME OVER', W / 2, H / 2 - 30);
ctx.shadowBlur = 0;
// 最终得分
ctx.font = '18px "Courier New"';
ctx.fillStyle = '#fff';
ctx.fillText(`最终得分: ${score}`, W / 2, H / 2 + 30);
// 操作提示
ctx.fillStyle = '#aaa';
ctx.font = '14px "Courier New"';
ctx.fillText('按 R 键重新开始', W / 2, H / 2 + 65);
// 重置文本对齐(避免影响后续绘制)
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
持久化最高分
javascript
// 读取历史最高分
let highScore = parseInt(localStorage.getItem('planeHighScore') || '0');
// 游戏结束时保存
function gameOver() {
state = STATE.OVER;
if (score > highScore) {
highScore = score;
localStorage.setItem('planeHighScore', highScore); // 持久化到浏览器
}
}
使用 localStorage 可以让最高分在刷新页面甚至关闭浏览器后依然保留,这是提升玩家留存率的小细节。
🎮 完整游戏循环
把所有模块串联起来的主循环:
javascript
function gameLoop() {
// ① 清除画布
ctx.clearRect(0, 0, W, H);
// ② 绘制背景
drawStars();
// ③ 处理输入 + 更新游戏状态
handleInput();
updateGame();
// ④ 绘制所有游戏对象
drawPlayer();
for (const b of bullets) drawBullet(b);
for (const e of enemies) drawEnemy(e);
for (const p of particles) drawParticle(p);
// ⑤ 绘制 UI
drawHUD();
if (state === STATE.OVER) {
drawGameOver();
}
// ⑥ 请求下一帧
requestAnimationFrame(gameLoop);
}
// 🚀 启动游戏
gameLoop();
五、关键技术点深度解析
5.1 Canvas 状态管理:save/restore
这是 Canvas 开发中最容易被忽视但又最重要的概念:
javascript
// ❌ 错误示例:shadow 污染了后续绘制
ctx.shadowBlur = 20;
drawPlayer(); // 玩家有发光效果 ✓
drawEnemy(); // 敌机也有发光效果 ✗(本不想要)
// ✅ 正确示例:save/restore 隔离状态
ctx.save();
ctx.shadowBlur = 20;
drawPlayer(); // 只有玩家有发光
ctx.restore();
drawEnemy(); // 敌机不受影响 ✓
save() 保存的状态包括:
- 变换矩阵(translate, rotate, scale)
- 裁剪区域(clip)
- 样式属性(fillStyle, strokeStyle, shadowBlur 等)
- 字体、文本对齐等
5.2 渐变(Gradient)
Canvas 支持两种渐变:
javascript
// 线性渐变:createLinearGradient(x1, y1, x2, y2)
const grad = ctx.createLinearGradient(0, 0, 0, 100);
grad.addColorStop(0, '#ffffff'); // 起点颜色
grad.addColorStop(0.5, '#ffee00'); // 中间颜色
grad.addColorStop(1, '#ff8800'); // 终点颜色
ctx.fillStyle = grad;
// 径向渐变:createRadialGradient(x1, y1, r1, x2, y2, r2)
const radialGrad = ctx.createRadialGradient(50, 50, 0, 50, 50, 50);
radialGrad.addColorStop(0, '#ffffff');
radialGrad.addColorStop(1, '#000000');
5.3 性能优化建议
| 优化项 | 说明 |
|---|---|
| 减少状态切换 | 批量绘制相同颜色的图形,减少 fillStyle 的修改次数 |
| 离屏 Canvas | 复杂且不常变的背景可以预渲染到离屏 Canvas,然后一次性 drawImage |
| 限制粒子数量 | 粒子系统要设置上限,防止爆炸特效过多导致卡顿 |
| 碰撞检测优化 | 大量对象时考虑空间分割(如四叉树),而不是 O(n²) 暴力遍历 |
| 避免浮点坐标 | 非整数坐标会导致抗锯齿,影响性能,必要时使用 Math.floor |
六、项目运行与调试
环境要求
- Node.js ≥ 16
- 现代浏览器(Chrome / Edge / Firefox)
启动步骤
bash
# 1. 进入项目目录
cd airplane
# 2. 安装依赖
npm install
# 3. 启动开发服务器(默认端口 3000,自动打开浏览器)
npm run dev
游戏操作
| 按键 | 功能 |
|---|---|
↑ ↓ ← → 或 W S A D |
移动飞机 |
空格键 |
发射子弹 |
R |
游戏结束后重新开始 |
七、总结与进阶方向
📊 学习路径回顾
less
基础绘图(1.html)
│ 掌握: Canvas元素、渲染上下文、fillRect/strokeRect/clearRect
│ 理解了: Canvas坐标系、即时模式渲染
↓
动画原理(2.html)
│ 掌握: requestAnimationFrame、动画循环、帧更新模式
│ 理解了: 清除-更新-绘制 三步循环
↓
实战游戏(airplane/)
│ 掌握: 多对象管理、碰撞检测、粒子系统、状态机、难度曲线
│ 理解了: 完整的游戏架构设计
↓
🎉 恭喜!你已经具备了 Canvas 开发的完整知识体系
🚀 进阶方向
- WebGL / WebGPU:当 2D Canvas 性能不够时,可以进阶到 WebGL 进行 GPU 加速的 3D 渲染
- 游戏引擎:了解 Phaser.js、PixiJS 等基于 Canvas/WebGL 的专业游戏框架
- 数据可视化:结合 ECharts 图表库,Canvas 在数据可视化领域有广泛应用
- 图像处理 :使用
getImageData/putImageData实现图像滤镜、美颜等效果 - 更多游戏特性:音效(Web Audio API)、关卡系统、道具系统、触屏支持
如果你跟着代码一步步实践下来,相信你已经从 Canvas 新手成长为能够独立开发小游戏的开发者了。Canvas 的世界远不止于此,持续探索,你会发现更多有趣的可能性!