HTML+JavaScript+Canvas编写2D小游戏
'# HTML+JavaScript+Canvas编写2D小游戏
一、背景与问题
在Web开发中,Canvas API 是构建2D游戏的核心技术之一。相比SVG的矢量图形渲染,Canvas提供了更底层的像素级控制能力,但同时也需要开发者手动管理所有渲染逻辑。
Canvas技术在游戏开发中的典型应用场景包括:
- 2D动作游戏(如《超级马里奥》式横版卷轴)
- 战略类游戏(如《文明》系列的回合制战斗)
- 教育类小游戏(如数学训练、物理模拟)
其核心优势在于:
- 像素级控制能力
- 实时渲染性能
- 可定制的渲染流程
但同时面临挑战:
- 需要手动管理动画循环
- 需要处理图形状态(颜色、透明度、变换等)
- 需要处理输入事件和帧同步
二、基本原理
1. Canvas上下文管理
Canvas通过2D上下文对象(CanvasRenderingContext2D)进行绘制,其核心方法包括:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// 设置画布尺寸
canvas.width = 800;
canvas.height = 600;
// 设置填充颜色
ctx.fillStyle = '#FF0000';
ctx.fillRect(10, 10, 100, 100); // 绘制红色矩形2. 动画循环机制
Canvas动画通过requestAnimationFrame实现:
function gameLoop(timestamp) {
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 游戏逻辑更新
updateGameState(timestamp);
// 重绘画面
drawGameState();
// 继续循环
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);3. 双缓冲技术
Canvas通过离屏Canvas(OffscreenCanvas)实现双缓冲,避免画面撕裂:
const offscreen = document.createElement('canvas');
const offctx = offscreen.getContext('2d');
// 在离屏Canvas绘制
offctx.fillStyle = 'blue';
offctx.fillRect(0, 0, 100, 100);
// 将离屏Canvas内容绘制到主Canvas
ctx.drawImage(offscreen, 0, 0);三、环境准备
1. 开发环境配置
# 创建项目目录
mkdir 2d-game
cd 2d-game
# 初始化项目
npm init -y
# 安装开发依赖
npm install --save-dev typescript webpack webpack-cli2. 项目结构
2d-game/
├── src/
│ ├── game.ts
│ ├── assets/
│ └── index.html
├── tsconfig.json
├── webpack.config.js
└── package.json3. TypeScript配置
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}四、核心实现
1. 基础游戏元素绘制
// game.ts
class Game {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private player: { x: number; y: number; width: number; height: number };
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.player = { x: 100, y: 100, width: 50, height: 50 };
}
public draw() {
// 清除画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制玩家
this.ctx.fillStyle = 'blue';
this.ctx.fillRect(this.player.x, this.player.y, this.player.width, this.player.height);
}
public update(deltaTime: number) {
// 玩家移动逻辑
this.player.x += 100 * deltaTime;
if (this.player.x > this.canvas.width) {
this.player.x = 0;
}
}
}关键点解析:
- 使用
deltaTime实现时间无关的运动 - 通过
requestAnimationFrame触发动画循环 - 使用
clearRect实现画面重绘
2. 用户输入处理
// game.ts
class Game {
// ... 其他代码
private keys: Record<string, boolean> = {};
public init() {
window.addEventListener('keydown', (e) => {
this.keys[e.key] = true;
});
window.addEventListener('keyup', (e) => {
this.keys[e.key] = false;
});
}
public update(deltaTime: number) {
// 玩家移动逻辑
if (this.keys['ArrowRight']) {
this.player.x += 100 * deltaTime;
}
if (this.keys['ArrowLeft']) {
this.player.x -= 100 * deltaTime;
}
// 边界检测
if (this.player.x < 0) {
this.player.x = 0;
} else if (this.player.x > this.canvas.width - this.player.width) {
this.player.x = this.canvas.width - this.player.width;
}
}
}3. 动画循环实现
// game.ts
class Game {
// ... 其他代码
public start() {
const lastTime = performance.now();
function gameLoop(timestamp: number) {
const deltaTime = (timestamp - lastTime) / 1000; // 秒
lastTime = timestamp;
this.update(deltaTime);
this.draw();
requestAnimationFrame(gameLoop.bind(this));
}
requestAnimationFrame(gameLoop.bind(this));
}
}五、完整案例
1. 简单的打砖块游戏
完整HTML文件:
<!DOCTYPE html>
<html>
<head>
<title>打砖块游戏</title>
</head>
<body>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<script type="module" src="game.js"></script>
</body>
</html>完整JavaScript代码:
// game.js
const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;
interface Ball {
x: number;
y: number;
radius: number;
dx: number;
dy: number;
}
interface Paddle {
x: number;
y: number;
width: number;
height: number;
dx: number;
}
class Game {
private ball: Ball;
private paddle: Paddle;
private bricks: { x: number; y: number; width: number; height: number }[];
private score: number;
private lives: number;
constructor() {
this.ball = {
x: canvas.width / 2,
y: canvas.height - 30,
radius: 10,
dx: 2,
dy: -2
};
this.paddle = {
x: canvas.width / 2 - 50,
y: canvas.height - 20,
width: 100,
height: 10,
dx: 5
};
this.bricks = [];
this.score = 0;
this.lives = 3;
this.createBricks();
}
private createBricks() {
for (let i = 0; i < 5; i++) {
for (let j = 0; j < 3; j++) {
this.bricks.push({
x: 100 + i * 120,
y: 30 + j * 30,
width: 80,
height: 20
});
}
}
}
private drawBall() {
ctx.beginPath();
ctx.arc(this.ball.x, this.ball.y, this.ball.radius, 0, Math.PI * 2);
ctx.fillStyle = 'red';
ctx.fill();
ctx.closePath();
}
private drawPaddle() {
ctx.fillStyle = 'blue';
ctx.fillRect(this.paddle.x, this.paddle.y, this.paddle.width, this.paddle.height);
}
private drawBricks() {
ctx.fillStyle = 'green';
for (const brick of this.bricks) {
ctx.fillRect(brick.x, brick.y, brick.width, brick.height);
}
}
private drawScore() {
ctx.fillStyle = 'black';
ctx.font = '16px Arial';
ctx.fillText(`Score: ${this.score}`, 10, 20);
}
private drawLives() {
ctx.fillStyle = 'black';
ctx.font = '16px Arial';
ctx.fillText(`Lives: ${this.lives}`, 10, 40);
}
private update() {
// 球体运动
this.ball.x += this.ball.dx;
this.ball.y += this.ball.dy;
// 碰撞检测(左右边界)
if (this.ball.x + this.ball.radius > canvas.width || this.ball.x - this.ball.radius < 0) {
this.ball.dx *= -1;
}
// 碰撞检测(顶部边界)
if (this.ball.y - this.ball.radius < 0) {
this.ball.dy *= -1;
}
// 碰撞检测(底部边界)
if (this.ball.y + this.ball.radius > canvas.height) {
this.lives--;
this.resetBall();
}
// 碰撞检测(挡板)
if (
this.ball.x + this.ball.radius > this.paddle.x &&
this.ball.x - this.ball.radius < this.paddle.x + this.paddle.width &&
this.ball.y + this.ball.radius > this.paddle.y &&
this.ball.y - this.ball.radius < this.paddle.y + this.paddle.height
) {
this.ball.dy *= -1;
}
// 挡板移动
if (this.keys['ArrowLeft'] && this.paddle.x > 0) {
this.paddle.x -= this.paddle.dx;
}
if (this.keys['ArrowRight'] && this.paddle.x + this.paddle.width < canvas.width) {
this.paddle.x += this.paddle.dx;
}
// 砖块碰撞检测
for (let i = this.bricks.length - 1; i >= 0; i--) {
const brick = this.bricks[i];
if (
this.ball.x + this.ball.radius > brick.x &&
this.ball.x - this.ball.radius < brick.x + brick.width &&
this.ball.y + this.ball.radius > brick.y &&
this.ball.y - this.ball.radius < brick.y + brick.height
) {
this.score += 10;
this.bricks.splice(i, 1);
this.ball.dy *= -1;
}
}
}
private resetBall() {
this.ball.x = canvas.width / 2;
this.ball.y = canvas.height - 30;
this.ball.dx = 2;
this.ball.dy = -2;
}
private draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawBall();
this.drawPaddle();
this.drawBricks();
this.drawScore();
this.drawLives();
}
public start() {
const lastTime = performance.now();
function gameLoop(timestamp: number) {
const deltaTime = (timestamp - lastTime) / 1000; // 秒
lastTime = timestamp;
this.update();
this.draw();
if (this.lives > 0) {
requestAnimationFrame(gameLoop.bind(this));
}
}
requestAnimationFrame(gameLoop.bind(this));
}
}
// 键盘事件处理
const keys: Record<string, boolean> = {};
window.addEventListener('keydown', (e) => {
keys[e.key] = true;
});
window.addEventListener('keyup', (e) => {
keys[e.key] = false;
});
// 启动游戏
const game = new Game();
game.start();关键实现说明:
- 碰撞检测使用矩形碰撞算法(AABB)
- 球体运动采用deltaTime实现时间无关运动
- 砖块碰撞检测采用数组遍历和splice方法
- 使用requestAnimationFrame实现动画循环
六、源码解析
动画循环机制:
requestAnimationFrame(gameLoop.bind(this));使用
requestAnimationFrame确保动画与屏幕刷新率同步,避免卡顿。碰撞检测逻辑:
if ( this.ball.x + this.ball.radius > brick.x && this.ball.x - this.ball.radius < brick.x + brick.width && this.ball.y + this.ball.radius > brick.y && this.ball.y - this.ball.radius < brick.y + brick.height ) { // 碰撞处理 }采用AABB碰撞检测算法,通过矩形区域判断是否发生碰撞。
性能优化:
ctx.clearRect(0, 0, canvas.width, canvas.height);使用
clearRect而非fillRect实现画面重绘,避免全屏填充的性能损耗。
七、进阶使用
1. 动画帧管理
class FrameManager {
private lastTime: number = performance.now();
private maxFPS: number = 60;
private frameCount: number = 0;
public update(deltaTime: number) {
this.frameCount++;
if (this.frameCount >= this.maxFPS) {
this.frameCount = 0;
this.lastTime = performance.now();
}
}
public getDeltaTime() {
return (performance.now() - this.lastTime) / 1000;
}
}2. 粒子系统实现
class Particle {
public x: number;
public y: number;
public vx: number;
public vy: number;
public life: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 2;
this.vy = (Math.random() - 0.5) * 2;
this.life = 100;
}
public update(deltaTime: number) {
this.x += this.vx * deltaTime;
this.y += this.vy * deltaTime;
this.life -= deltaTime;
}
public draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.arc(this.x, this.y, 2, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
ctx.closePath();
}
}3. 音效集成
// 使用Web Audio API
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.type = 'square';
oscillator.frequency.value = 440; // A4 note
gainNode.gain.value = 0.5;
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
function playSound() {
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.5);
}八、性能与工程实践
1. 性能优化策略
离屏Canvas缓存:
const offscreen = document.createElement('canvas'); const offctx = offscreen.getContext('2d')!; // 在离屏Canvas绘制静态内容 offctx.fillStyle = 'blue'; offctx.fillRect(0, 0, 100, 100); // 将离屏Canvas内容绘制到主Canvas ctx.drawImage(offscreen, 0, 0);减少重绘范围:
ctx.clearRect(this.player.x, this.player.y, this.player.width, this.player.height);使用Web Workers:
// worker.js self.onmessage = function(e) { const data = e.data; // 复杂计算逻辑 self.postMessage(data); };
2. 安全风险防范
XSS防护:
// 设置CSP头 document.querySelector('meta').setAttribute('content', "default-src 'self'; script-src 'self'; style-src 'self'");Canvas数据安全:
// 避免图像数据泄露 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // 对敏感数据进行加密处理
九、常见问题与踩坑
1. 动画卡顿问题
错误示例:
function gameLoop() {
requestAnimationFrame(gameLoop);
// 绘制逻辑
}问题分析:未处理帧间隔导致CPU占用过高
解决方案:
let lastTime = 0;
function gameLoop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
// 更新和绘制逻辑
requestAnimationFrame(gameLoop);
}2. 内存泄漏问题
常见陷阱:
- 未清除事件监听器
- 未释放Canvas上下文
- 未清理离屏Canvas
解决方案:
// 清除事件监听
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('keyup', handleKeyup);
// 清除Canvas上下文
ctx.clearRect(0, 0, canvas.width, canvas.height);3. 跨域问题
常见场景:使用Canvas绘制外部图像时
解决方案:
- 使用
crossOrigin属性 - 配置服务器CORS头
- 使用代理服务器
十、最佳实践
- 使用TypeScript:提升代码可维护性和类型安全
- 采用模块化架构:将游戏逻辑拆分为独立模块
- 使用离屏Canvas:提高渲染性能
- 实现帧率控制:避免过高的CPU占用
- 添加游戏状态管理:处理暂停、暂停、游戏结束等状态
- 使用Web Workers:处理计算密集型任务
- 配置CSP头:防止XSS攻击
- 进行性能测试:使用Chrome DevTools进行性能分析
十一、总结
HTML+Canvas+JavaScript组合提供了强大的2D游戏开发能力,但也需要开发者深入理解其底层机制。通过合理使用requestAnimationFrame、离屏Canvas、碰撞检测算法等技术,可以实现高质量的2D游戏。
在实际开发中,建议:
- 对于简单的2D游戏,直接使用Canvas API即可
- 对于复杂游戏,结合WebGL或Three.js进行扩展
- 对于需要高帧率的场景,采用Web Workers进行计算分离
- 对于涉及用户输入的场景,使用防抖和节流技术优化性能
需要注意的是,Canvas技术虽然强大,但不适合需要复杂3D效果或实时渲染的场景。同时,需要特别注意安全风险,如XSS攻击和Canvas数据泄露等问题。通过合理的设计和优化,可以充分利用Canvas技术的优势,打造优秀的2D游戏体验。
评论已关闭