原生JavaScript飞机大战:从Canvas游戏循环到碰撞检测实战

发布时间:2026/7/27 9:23:01
原生JavaScript飞机大战:从Canvas游戏循环到碰撞检测实战 1. 项目概述从零到一用原生JavaScript构建你的飞机大战看到“JavaScript实现飞机大战”这个标题很多前端新手可能会觉得这是一个庞大的工程涉及复杂的游戏引擎和图形学知识。但实际上这正是用原生JavaScriptVanilla JS练手、深入理解前端核心技术的绝佳项目。它不依赖任何第三方游戏库纯粹用HTML5的Canvas绘图、DOM事件监听和基础的JS逻辑就能构建出一个玩法完整、体验流畅的射击游戏。这个项目能帮你打通任督二脉理解游戏循环Game Loop、碰撞检测、状态管理、事件驱动编程等核心概念而这些概念在开发复杂Web应用时同样至关重要。无论你是想巩固JS基础、准备面试还是单纯想体验一把“造轮子”的乐趣亲手实现一个飞机大战都会让你收获满满。整个游戏的核心架构可以概括为一个不断重绘的画布Canvas构成游戏世界玩家控制的飞机、敌机、子弹、爆炸效果等都是这个世界的“精灵”Sprite它们拥有位置、速度、生命值等属性。游戏主循环以每秒60帧约16.7毫秒/帧的频率运行在每一帧里我们依次执行“清空画布 - 更新所有精灵状态 - 检测碰撞 - 重新绘制所有精灵”这四个步骤。听起来是不是有点像React/Vue的“状态改变 - 重新渲染”思想其本质都是声明式地描述视图应该如何根据数据变化而变化。接下来我们就一步步拆解看看如何用不到500行代码实现这个经典游戏。2. 核心架构与游戏循环设计2.1 为什么选择Canvas而非DOM实现动态游戏画面通常有两种选择使用DOM操作用div和img元素模拟或使用Canvas绘图。对于飞机大战这类需要大量精灵、频繁重绘且对性能要求较高的游戏Canvas是更优的选择。DOM方案的局限性每个飞机、子弹都是一个独立的HTML元素。当屏幕上同时存在几十个对象时频繁地修改它们的style.left和style.top属性来移动会触发大量的浏览器重排Reflow与重绘Repaint性能开销巨大容易导致画面卡顿。此外精确的碰撞检测如两个圆形或非矩形区域的碰撞用DOM实现起来也非常繁琐。Canvas方案的优势性能卓越Canvas是一个位图画布所有的绘制操作都是在一个上下文CanvasRenderingContext2D中通过API调用完成的。它更像是在内存中操作像素绘制完成后再一次性输出到屏幕避免了DOM的重排重绘非常适合高频更新的动画。绘制灵活可以轻松绘制矩形、圆形、路径、图像以及进行像素级操作实现更复杂的视觉效果如粒子爆炸。控制精细碰撞检测可以通过计算精灵的坐标和形状矩形、圆形的数学关系来完成精度高且计算效率高。因此我们首先在HTML中创建一个Canvas元素并获取其2D绘图上下文。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleJS飞机大战/title style body { margin: 0; padding: 0; overflow: hidden; background: #000; } #gameCanvas { display: block; margin: 20px auto; background: #001122; } /style /head body canvas idgameCanvas width480 height600/canvas script srcgame.js/script /body /html在game.js中我们初始化画布和上下文const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d);注意Canvas的width和height属性在JS中设置或HTML属性决定了画布坐标系的分辨率而CSS样式中的width和height控制其显示尺寸。如果两者比例不一致会导致绘制的内容被拉伸变形。最佳实践是保持它们一致或者通过JS动态计算比例进行适配。2.2 游戏主循环驱动世界的引擎游戏的核心是一个永不停止的循环。在Web环境中我们通常使用requestAnimationFrame(简称rAF) 来实现。与过去的setInterval相比rAF有两大优势与浏览器刷新率同步它会在下一次浏览器重绘之前调用回调函数通常是每秒60次从而提供最流畅的动画体验。后台标签页暂停当页面不可见时rAF会自动暂停节省CPU和电量。我们的游戏循环函数gameLoop结构如下let lastTime 0; // 上一帧的时间戳 function gameLoop(timestamp) { // 计算时间增量Delta Time使游戏速度与帧率解耦 const deltaTime timestamp - lastTime; lastTime timestamp; // 1. 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 更新游戏状态更新所有精灵的位置、状态等 update(deltaTime); // 3. 渲染所有精灵 render(); // 4. 请求下一帧 requestAnimationFrame(gameLoop); } // 启动游戏循环 requestAnimationFrame(gameLoop);关键点Delta Time (Δt)直接使用每帧固定的移动距离在高性能电脑帧率可能高于60fps或低性能电脑帧率低于60fps上游戏速度会不一致。引入deltaTime两帧之间的时间间隔单位毫秒将所有与速度相关的计算都乘以一个基于deltaTime的系数就能实现“帧率无关”的运动确保在任何设备上游戏体验一致。例如如果希望子弹每秒移动500像素那么每帧的移动距离应该是500 * (deltaTime / 1000)。2.3 游戏状态管理一个完整的游戏需要管理多种状态例如开始界面、游戏中、暂停、游戏结束。我们可以用一个简单的状态机来管理const GameState { MENU: 0, PLAYING: 1, PAUSED: 2, GAME_OVER: 3 }; let currentGameState GameState.MENU;在update和render函数中我们需要根据currentGameState来决定执行哪些逻辑。例如在PLAYING状态才更新敌机和检测碰撞在GAME_OVER状态则显示得分和重新开始按钮。3. 核心游戏对象精灵的设计与实现我们将游戏中的每个可交互元素都抽象为一个“精灵”类。它们共享一些基本属性坐标、速度、图像、是否存活和方法更新、绘制。采用面向对象的方式能让代码结构更清晰。3.1 基类Sprite首先创建一个所有精灵的基类封装通用逻辑。class Sprite { constructor(x, y, width, height, speedX 0, speedY 0) { this.x x; this.y y; this.width width; this.height height; this.speedX speedX; this.speedY speedY; this.isAlive true; // 图像对象子类赋值 this.image null; } update(deltaTime) { // 根据速度和时间更新位置 this.x this.speedX * (deltaTime / 1000); this.y this.speedY * (deltaTime / 1000); // 简单的边界检查可根据需要由子类重写 this._checkBounds(); } _checkBounds() { // 默认不移除子类如敌机、子弹需要重写此方法 } render(ctx) { if (!this.isAlive || !this.image) return; // 绘制图像 ctx.drawImage(this.image, this.x, this.y, this.width, this.height); // 调试绘制碰撞框 // ctx.strokeStyle red; // ctx.strokeRect(this.x, this.y, this.width, this.height); } }3.2 玩家飞机Player玩家飞机继承自Sprite并增加键盘控制逻辑和发射子弹的能力。class Player extends Sprite { constructor(x, y) { super(x, y, 50, 50); // 假设飞机图片宽高50px this.speed 300; // 像素/秒 this.cooldown 0; // 射击冷却计时器 this.cooldownTime 200; // 冷却时间200毫秒 // 加载图像 this.image new Image(); this.image.src images/player.png; // 需准备图片资源 } update(deltaTime, keys) { // 根据按键更新速度 let moveX 0, moveY 0; if (keys[ArrowLeft] || keys[a]) moveX - 1; if (keys[ArrowRight] || keys[d]) moveX 1; if (keys[ArrowUp] || keys[w]) moveY - 1; if (keys[ArrowDown] || keys[s]) moveY 1; // 归一化对角线移动速度避免斜向移动更快 if (moveX ! 0 moveY ! 0) { moveX * 0.7071; // 即 1 / √2 moveY * 0.7071; } this.x moveX * this.speed * (deltaTime / 1000); this.y moveY * this.speed * (deltaTime / 1000); // 边界限制不让飞机飞出画布 this.x Math.max(0, Math.min(canvas.width - this.width, this.x)); this.y Math.max(0, Math.min(canvas.height - this.height, this.y)); // 更新射击冷却 if (this.cooldown 0) { this.cooldown - deltaTime; } // 空格键射击 if (keys[ ] this.cooldown 0) { this.shoot(); this.cooldown this.cooldownTime; } } shoot() { // 在飞机头部中央创建一颗子弹 const bulletX this.x this.width / 2 - 2; // 假设子弹宽4px const bulletY this.y; gameObjects.bullets.push(new Bullet(bulletX, bulletY)); // 可以在这里添加射击音效 } _checkBounds() { // 玩家飞机的边界检查已在update中处理这里无需额外操作 } }实操心得键盘事件处理为了获得流畅的操控体验我们不应在keydown事件中直接修改位置而是用keydown和keyup事件来更新一个keys状态对象。在主循环的update中根据这个状态对象来持续移动飞机。这样可以支持多个按键同时按下如斜向移动并且响应更即时。const keys {}; window.addEventListener(keydown, e keys[e.key] true); window.addEventListener(keyup, e keys[e.key] false); // 防止空格键触发页面滚动 window.addEventListener(keydown, e { if(e.key ) e.preventDefault(); });3.3 敌机Enemy敌机有多种类型普通、快速、重型我们可以创建一个基类然后派生子类。class Enemy extends Sprite { constructor(x, y, width, height, speedY, hp 1, score 100) { super(x, y, width, height, 0, speedY); this.hp hp; // 生命值 this.maxHp hp; this.score score; // 击毁得分 this.image new Image(); this.image.src images/enemy1.png; } update(deltaTime) { super.update(deltaTime); // 敌机可能有的特殊行为比如左右摆动 // this.x Math.sin(this.y * 0.05) * 1; } takeDamage(damage) { this.hp - damage; if (this.hp 0) { this.isAlive false; // 创建爆炸效果 gameObjects.effects.push(new Explosion(this.x, this.y)); return true; // 表示敌机被摧毁 } return false; } _checkBounds() { // 如果敌机飞出画布底部则标记为死亡便于垃圾回收 if (this.y canvas.height) { this.isAlive false; } } } // 快速敌机子类 class FastEnemy extends Enemy { constructor(x, y) { super(x, y, 30, 30, 200, 1, 150); // 速度更快分数更高 this.image.src images/enemy_fast.png; } }3.4 子弹Bullet与碰撞检测子弹相对简单主要是一直向上移动。class Bullet extends Sprite { constructor(x, y) { super(x, y, 4, 10, 0, -500); // 宽4px高10px速度向上500px/秒 this.damage 1; this.image new Image(); this.image.src images/bullet.png; // 一个简单的矩形或图片 } _checkBounds() { // 子弹飞出画布顶部则移除 if (this.y this.height 0) { this.isAlive false; } } }碰撞检测这是游戏逻辑的核心。我们采用**轴对齐包围盒AABB**算法因为它计算简单高效对于矩形精灵或近似矩形足够精确。 原理两个矩形不碰撞的条件是一个矩形在另一个的右、左、上、下四个方向之外。反之则碰撞。function checkCollision(rectA, rectB) { return rectA.x rectB.x rectB.width rectA.x rectA.width rectB.x rectA.y rectB.y rectB.height rectA.y rectA.height rectB.y; }在主循环的update逻辑中我们需要遍历所有子弹和所有敌机进行碰撞检测function updateCollisions() { for (let i gameObjects.bullets.length - 1; i 0; i--) { const bullet gameObjects.bullets[i]; if (!bullet.isAlive) continue; for (let j gameObjects.enemies.length - 1; j 0; j--) { const enemy gameObjects.enemies[j]; if (!enemy.isAlive) continue; if (checkCollision(bullet, enemy)) { bullet.isAlive false; // 子弹消失 const isDestroyed enemy.takeDamage(bullet.damage); if (isDestroyed) { score enemy.score; // 增加分数 } break; // 一颗子弹只能击中一个敌机 } } } // 同样需要检测敌机与玩家的碰撞 for (const enemy of gameObjects.enemies) { if (enemy.isAlive checkCollision(player, enemy)) { player.isAlive false; currentGameState GameState.GAME_OVER; break; } } }注意事项在遍历数组并可能删除元素时务必从后向前遍历for (let i array.length - 1; i 0; i--)。如果从前向后遍历当删除当前元素后后面元素的索引会前移导致循环跳过某些元素或访问越界。3.5 游戏对象管理器与资源加载我们需要一个中央管理器来存储和更新所有的游戏对象。const gameObjects { player: null, enemies: [], bullets: [], effects: [] // 爆炸等特效 };同时图像资源需要预加载避免绘制时图片还未下载完成。const resources {}; function loadResource(name, src) { return new Promise((resolve, reject) { const img new Image(); img.onload () { resources[name] img; resolve(); }; img.onerror reject; img.src src; }); } async function loadAllResources() { const resourcesToLoad [ [player, images/player.png], [enemy1, images/enemy1.png], [bullet, images/bullet.png], [explosion, images/explosion.png] ]; const promises resourcesToLoad.map(([name, src]) loadResource(name, src)); await Promise.all(promises); console.log(所有资源加载完毕); // 初始化游戏对象例如 player.image resources[player]; }4. 游戏逻辑的完整实现与打磨4.1 敌机生成系统敌机不能一次性全部出现需要有一个生成器Spawner来控制节奏。我们可以使用计时器和随机数来实现。let enemySpawnTimer 0; const ENEMY_SPAWN_INTERVAL 1000; // 初始生成间隔1秒 function updateEnemySpawn(deltaTime) { if (currentGameState ! GameState.PLAYING) return; enemySpawnTimer deltaTime; if (enemySpawnTimer ENEMY_SPAWN_INTERVAL) { enemySpawnTimer 0; // 随机生成敌机 const enemyType Math.random(); const x Math.random() * (canvas.width - 50); // 随机横坐标留出敌机宽度 let enemy; if (enemyType 0.7) { enemy new Enemy(x, -50, 40, 40, 100); // 70%普通敌机 } else if (enemyType 0.9) { enemy new FastEnemy(x, -50); // 20%快速敌机 } else { // 10%重型敌机需要设计一个HeavyEnemy类 // enemy new HeavyEnemy(x, -50); } if (enemy) gameObjects.enemies.push(enemy); } // 随着游戏进行可以逐渐减少生成间隔增加难度 // ENEMY_SPAWN_INTERVAL Math.max(200, 1000 - score * 0.1); }4.2 渲染与特效优化基础的渲染就是遍历所有对象调用其render方法。但我们可以做得更好粒子爆炸效果当敌机被摧毁时生成一个Explosion对象。它可以在几帧内播放一系列动画帧精灵图或者生成一堆随机运动的小粒子粒子系统。class Explosion { constructor(x, y) { this.x x; this.y y; this.frame 0; this.totalFrames 5; // 假设爆炸动画有5帧 this.frameDuration 50; // 每帧50ms this.timer 0; this.isFinished false; this.image resources[explosion]; // 假设是一张横向排列的精灵图 } update(deltaTime) { this.timer deltaTime; if (this.timer this.frameDuration) { this.timer 0; this.frame; if (this.frame this.totalFrames) { this.isFinished true; } } } render(ctx) { if (this.isFinished) return; const frameWidth this.image.width / this.totalFrames; ctx.drawImage( this.image, this.frame * frameWidth, 0, frameWidth, this.image.height, // 源图像裁剪区域 this.x - frameWidth/2, this.y - this.image.height/2, frameWidth, this.image.height // 画布绘制区域 ); } }对象池Object Pooling优化频繁创建和销毁对象如子弹、敌机会触发垃圾回收GC可能导致游戏卡顿。对象池的核心思想是预先创建一组对象使用时从池中取出用完后放回而不是真正销毁。对于子弹这种数量多、生命周期短的对象优化效果显著。class BulletPool { constructor(size) { this.pool []; for (let i 0; i size; i) { this.pool.push(new Bullet(-100, -100)); // 初始放在屏幕外 } this.index 0; } get(x, y) { const bullet this.pool[this.index]; bullet.x x; bullet.y y; bullet.isAlive true; this.index (this.index 1) % this.pool.length; return bullet; } } // 使用时const bullet bulletPool.get(player.x, player.y);4.3 游戏状态与UI绘制我们需要根据游戏状态绘制不同的UI如开始菜单、分数、生命值、游戏结束画面。function renderUI() { ctx.fillStyle white; ctx.font 20px Arial; ctx.textAlign left; ctx.fillText(得分: ${score}, 10, 30); // 绘制玩家生命值用图标表示 for (let i 0; i playerLives; i) { ctx.drawImage(resources[player_icon], 10 i * 30, 50, 25, 25); } if (currentGameState GameState.MENU) { ctx.fillStyle rgba(0, 0, 0, 0.7); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle white; ctx.font 40px Arial; ctx.textAlign center; ctx.fillText(飞机大战, canvas.width / 2, canvas.height / 2 - 50); ctx.font 20px Arial; ctx.fillText(按空格键开始游戏, canvas.width / 2, canvas.height / 2 20); } else if (currentGameState GameState.GAME_OVER) { ctx.fillStyle rgba(0, 0, 0, 0.7); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle red; ctx.font 40px Arial; ctx.textAlign center; ctx.fillText(游戏结束, canvas.width / 2, canvas.height / 2 - 50); ctx.fillStyle white; ctx.font 24px Arial; ctx.fillText(最终得分: ${score}, canvas.width / 2, canvas.height / 2 20); ctx.font 20px Arial; ctx.fillText(按R键重新开始, canvas.width / 2, canvas.height / 2 60); } }在render函数中先渲染游戏对象再调用renderUI()。4.4 音效与交互完善音效能极大提升游戏体验。使用Audio对象播放短促的音效射击、爆炸。const audioShoot new Audio(sounds/shoot.wav); audioShoot.volume 0.3; // 在Player.shoot()中调用 audioShoot.currentTime 0; audioShoot.play();注意浏览器对自动播放音频有严格策略通常需要用户先与页面交互如点击。可以在游戏开始按钮的点击事件中播放一个无声的音频来“解锁”音频上下文。最后绑定全局按键来控制游戏状态切换。window.addEventListener(keydown, (e) { if (e.key currentGameState GameState.MENU) { startGame(); } if (e.key p) { currentGameState currentGameState GameState.PLAYING ? GameState.PAUSED : GameState.PLAYING; } if (e.key r currentGameState GameState.GAME_OVER) { startGame(); } }); function startGame() { score 0; playerLives 3; gameObjects.enemies []; gameObjects.bullets []; gameObjects.effects []; gameObjects.player new Player(canvas.width/2 - 25, canvas.height - 80); currentGameState GameState.PLAYING; }5. 性能调优、常见问题与扩展思路5.1 性能瓶颈分析与优化当敌机和子弹数量很多时可能会遇到性能问题。主要瓶颈通常在于碰撞检测的复杂度最简单的实现是双重循环O(n²)当对象数量n超过几百时计算量会剧增。优化方案1空间分割。将画布划分为网格只检测在同一网格或相邻网格内的对象。这需要维护一个空间索引数据结构如四叉树、网格对于小游戏简单的网格法已足够。优化方案2分层检测。先进行粗略的检测如比较两个对象的边界圆距离如果距离很远则跳过精确的矩形检测。Canvas绘制开销每一帧绘制大量图像drawImage也有开销。优化方案离屏Canvas。对于背景、静态UI等不常变化的部分可以预先绘制到一个离屏Canvas上每一帧直接绘制这个离屏Canvas而不是重新绘制所有元素。垃圾回收GC压力频繁创建对象如new Bullet()会导致GC频繁触发引起卡顿。终极方案对象池。如前所述这是游戏开发中的经典优化模式。5.2 常见问题与调试技巧画面撕裂或卡顿确保使用了requestAnimationFrame并且所有与时间相关的移动都乘以deltaTime。检查update函数中是否有耗时过长的操作如复杂的数学计算或DOM操作。碰撞检测不准确首先打开调试模式绘制出所有精灵的碰撞框在Sprite.render中取消注释绘制strokeRect的代码直观地查看碰撞框是否与图像匹配。检查checkCollision函数的逻辑是否正确。图像闪烁或显示不全确保图片资源已正确加载。在Image.onload回调中再开始游戏或使用前面提到的loadAllResources函数。按键响应延迟确认使用了基于状态keys对象的键盘控制而不是在事件回调中直接行动作。5.3 项目扩展与进阶方向完成基础版本后你可以尝试以下扩展让游戏更丰满增加敌机类型与AI让敌机拥有不同的行为模式如追踪玩家、发射子弹、按特定路线移动贝塞尔曲线。添加道具系统击毁特定敌机掉落道具如火力增强、生命恢复、护盾玩家拾取后获得临时效果。实现关卡系统设计不同的波次Wave每波敌机的数量、类型和生成节奏不同击败一波后进入下一波难度递增。加入本地存储使用localStorage保存最高分记录。网络联机高级使用WebSocket尝试实现简单的双人对战或排行榜功能。通过这个项目你实践了从零设计一个完整应用的过程涵盖了状态机、对象池、碰撞检测、游戏循环、资源管理等在大型前端应用中同样会遇到的核心模式。下次当有人问起“JavaScript能做什么”时你就可以把这个可玩的飞机大战丢过去了这比任何苍白的解释都更有说服力。