ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Canvas从零实现JS飞机大战:游戏循环、碰撞检测与性能优化

用Canvas从零实现JS飞机大战:游戏循环、碰撞检测与性能优化 简介基于JSHTMLCSS实现的飞机大战源代码复刻经典雷电玩法是Web前端课程设计与高分大作业的高质量参考。项目面向入门阶段的学生与开发者解决从零搭建网页游戏无从下手的问题代码结构紧凑可在浏览器中直接运行。整个压缩包共6个文件核心为1个HTML文件将页面结构、样式与游戏逻辑集中在一起便于对照学习另外5个MP3覆盖背景音乐、子弹射击、敌机爆炸、游戏结束等场景音效增强游戏沉浸感。压缩包仅129KB轻量且无外部依赖已有201人学习下载。阅读源码可以理解游戏主循环、键盘或鼠标操控、敌机随机生成、碰撞检测、计分刷新、关卡状态切换等前端游戏开发要点同时可自由替换音效素材或基于现有框架扩展武器系统、Boss战等玩法适合作为大作业展示或进一步二次开发的项目基底。1. 为什么“JS飞机大战”成了web高分大作业的常客“js实现飞机大战源代码”这个标题在课程设计和技术社区里出现频率极高它对应的是雷电风格竖版射击游戏英文里常叫airplane war或space shooter。这类项目从头写一遍正好落在Canvas 2D的能力边界内键盘交互、游戏循环、碰撞检测、随机生成、分数持久化每一项都是大作业评分时能快速看到的点。很多失败案例不是不会画飞机而是把状态全堆在全局变量里、用setInterval硬刷、子弹数组无限增长最后画面越玩越卡逻辑越改越乱。这篇文章不只会给一份能跑的代码还会讲清楚为什么用requestAnimationFrame、为什么用deltaTime、为什么子弹要重用对象以及雷电最核心的道具、弹幕和本地最高分怎么加。读者可以完全按照中间几章的代码顺序敲下来也能在最后章节里拿到答辩和性能验证的技巧。对已经会基础JS但没写过完整游戏的人来说这比背模板有用得多。2. 先搭游戏循环与Canvas画布地基不牢后面全是补丁2.1 Canvas 2D绘图与游戏循环的关系飞机大战里所有移动物体本质上都是“每隔一帧被擦掉并按新坐标重画”的图形。Canvas本身不保存对象状态它只认绘图路径和像素因此游戏的运行节奏完全由手动的循环代码控制。常见做法是用requestAnimationFrame驱动循环而不是setInterval因为浏览器会在标签页不可见时自动暂停前者而setInterval仍然在后台空转既浪费CPU也会让页面重新打开时出现“瞬间死亡”的错乱。一个完整的循环至少要包含两个步骤update负责更新坐标和状态render负责把最新状态画到画布上。update和render分离后调试碰撞时可以只跑update而不绘图这在后面排查“哪一帧开始卡住”时会非常方便。为了让循环在任意刷新率的显示器上表现一致需要记录两帧之间经过的时间也就是deltaTime。canvas idgame width480 height720/canvas script const canvas document.getElementById(game); const ctx canvas.getContext(2d); let lastTime 0; function update(deltaTime) { // 这里写所有游戏逻辑更新 } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 擦掉上一帧 // 这里写所有绘制操作 } function loop(timestamp) { const deltaTime (timestamp - lastTime) / 1000; // 转成秒 lastTime timestamp; update(deltaTime); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop); /script这段代码里的timestamp由浏览器在调用requestAnimationFrame回调时自动传入精度高于Date.now单位是毫秒。除以1000是为了将时间单位统一成“秒”这样后面所有速度参数都能用“像素/秒”来表达。循环函数里先更新再绘制顺序不要颠倒否则玩家按一次方向键时会先看到旧画面再看到新画面产生一帧的输入延迟。2.2 坐标系、移动速度和帧率无关的写法Canvas坐标系原点在左上角x轴向右y轴向下。注意游戏窗口里“向上飞”在代码中其实是y坐标减小不是增大这是初学者最容易写反的地方。玩家飞机左移是x减右移是x加子弹向上飞是y减。定义速度时我会统一用“像素/秒”例如player.speed220表示每秒移动220像素。对象属性推荐初始值说明playerspeed220 px/s按住方向键后的水平速度bulletspeed420 px/s子弹向上飞行速度enemyspeed120 px/s敌机向下移动速度enemy_spawnspawn_interval1.2 s初始敌机生成间隔使用“像素/秒”而不是“每帧移动5像素”是因为不同显示器的刷新率差异巨大。在60Hz屏幕上每帧约16.7ms在120Hz屏幕上每帧约8.3ms。如果每帧固定移动相同距离高刷屏幕上游戏会明显变快敌人出现和碰撞的节奏完全失控。把数值统一放到一个settings对象里答辩时老师问“难度怎么调”直接看这几个属性的变化就够了。2.3 用背景滚动制造出“你在向前飞”的错觉雷电的镜头感主要靠背景向下滚动实现。常见做法是准备一张横向接缝的星空图或者方格纹理让图首尾相连每帧改变偏移量并绘制两张让画面连续滚动。如果没有美术素材可以用canvas绘制网格线再让横向线整体下移视觉上类似“按行扫描”的雷达感。let bgOffset 0; const bgSpeed 80; // 像素/秒 const gridSize 40; function drawBackground(deltaTime) { bgOffset (bgOffset bgSpeed * deltaTime) % gridSize; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #2a6b4a; ctx.lineWidth 1; ctx.beginPath(); // 纵向线不滚动只滚动横向线 for (let x 0; x canvas.width; x gridSize) { ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); } for (let y -gridSize; y canvas.height; y gridSize) { const drawY y bgOffset; ctx.moveTo(0, drawY); ctx.lineTo(canvas.width, drawY); } ctx.stroke(); }bgOffset取模gridSize后偏移量永远保持在0到40之间不会因为长时间运行变成超大数字。纵向线不参与滚动横向线每帧向下移动bgSpeed * deltaTime像素就实现了一个简化的“战斗区域向前推进”的效果。后续要换成真实背景图片时只需要把画线部分替换成drawImage游戏实体更新逻辑完全不用动。3. 玩家飞机与子弹实现键盘输入怎么处理才不“粘键”3.1 用对象管理玩家状态而不是一堆全局变量初学者在写飞机大战时很容易直接声明player_x、player_y、player_speed这种全局变量。问题在于玩家状态一多比如坐标、尺寸、生命、火力等级、无敌时间、子弹冷却变量名会越来越多碰撞检测和事件回调都要靠“猜”来引用正确变量。我会用一个player对象集中管理对象里只保存状态不写和绘制无关的业务逻辑。const player { x: canvas.width / 2 - 25, y: canvas.height - 80, width: 50, height: 60, speed: 220, // 像素/秒 fireCooldown: 0, // 剩余冷却时间 fireRate: 0.25, // 每次射击间隔单位秒 lives: 3 };这里x和y默认是矩形左上角而不是中心这样clearRect和后续的AABB矩形检测都更直观。如果需要以中心点计算坐标可以在绘制函数里做转换不要修改状态存储方式。fireCooldown和fireRate分开设计是为了让道具系统能直接修改fireRate而冷却计时独立走update二者互不干扰。width和height留在这里是为了后面碰撞检测不额外创建临时对象。按键行为实现位置← / A左移updatePlayer→ / D右移updatePlayer空格发射子弹shoot()3.2 用键盘状态表处理多键同时按下很多暴力方案是在keydown里直接移动飞机、在keyup里停止。这样按下方向键后如果没触发keyup比如切换窗口或系统弹窗抢焦点飞机会一直往一个方向滑这就是“粘键”。正确做法是维护一个keys对象keydown和keyup只更新布尔值更新循环里统一读取状态。这样同时按住“→”和“↓”时两个方向都能移动空格射击也不会受移动处理影响。const keys { ArrowLeft: false, ArrowRight: false }; window.addEventListener(keydown, (e) { if (keys.hasOwnProperty(e.key)) { keys[e.key] true; e.preventDefault(); // 阻止方向键滚动页面 } }); window.addEventListener(keyup, (e) { if (keys.hasOwnProperty(e.key)) { keys[e.key] false; } }); function updatePlayer(deltaTime) { if (keys.ArrowLeft player.x 0) { player.x - player.speed * deltaTime; } if (keys.ArrowRight player.x canvas.width - player.width) { player.x player.speed * deltaTime; } }这段代码里的hasOwnProperty是为了让普通字母键不触发preventDefault否则页面里放一个输入框后按下A无法正常输入文本。左边界判断用“player.x 0”右边界用“canvas.width - player.width”保证飞机不会半身飞出屏幕。如果要支持WASD就在keys对象里加w、a、s、d并在keydown里做一次别名映射让w和ArrowUp指向同一个布尔值。3.3 子弹池与对象复用避免GC卡顿每按一次空格创建一个新对象如果数组里旧子弹不清除几轮游戏后数组长度会到几千每一帧都要遍历几千次碰撞检测也会变慢。常见做法是维护一个子弹池或者至少及时把isActive设为false在更新时一次性过滤掉。游戏循环里频繁创建对象并触发垃圾回收会出现肉眼可见的停顿尤其大作业要在普通笔记本上演示这个细节值得提前处理。let bullets []; function shoot() { if (player.fireCooldown 0) return; bullets.push({ x: player.x player.width / 2 - 3, y: player.y, width: 6, height: 14, speed: 420, isActive: true }); player.fireCooldown player.fireRate; } function updateBullets(deltaTime) { player.fireCooldown Math.max(0, player.fireCooldown - deltaTime); for (const bullet of bullets) { bullet.y - bullet.speed * deltaTime; if (bullet.y bullet.height 0) bullet.isActive false; } bullets bullets.filter(b b.isActive); // 移除飞出屏幕的子弹 }isActive标记让子弹在飞出屏幕后变成无效对象但仍在数组中占位更新闭环里每帧用filter真正删除。相比在遍历中直接splice这种方式不会因为删除元素而跳过后面的对象逻辑更安全。bullets里存了width和height后面增加扩散弹时碰撞检测函数不用区分子弹类型只需要读取这些属性。shoot里只用cooldown控制频率不直接依赖帧数因此帧率波动不会导致子弹连续发射过快。4. 敌机生成、碰撞检测与计分雷电的“对抗感”从哪来4.1 按时间生成敌机用游戏时间控制难度曲线敌机不能一上来就铺满屏幕也不能永远一种速度。我采用一个spawnTimer每帧累加deltaTime达到阈值就生成一架敌机并重置计时。阈值从初始1.2秒开始随着得分提高逐渐减小下限设为0.4秒。这样做能让玩家感知到节奏变化答辩时可以讲清“难度曲线”的数学模型而不是含糊地说“越来越快”。let enemies []; let spawnTimer 0; let spawnInterval 1.2; let score 0; function spawnEnemy() { const width 42 Math.random() * 18; // 随机大小 enemies.push({ x: Math.random() * (canvas.width - width), y: -width, width: width, height: 30, speed: 100 Math.random() * 80, // 随机速度避免一列齐步走 scoreValue: Math.floor(100 width), isActive: true }); } function updateEnemies(deltaTime) { spawnTimer deltaTime; const currentInterval Math.max(0.4, spawnInterval - score / 5000 * 0.1); if (spawnTimer currentInterval) { spawnEnemy(); spawnTimer 0; } for (const enemy of enemies) { enemy.y enemy.speed * deltaTime; if (enemy.y canvas.height) enemy.isActive false; } enemies enemies.filter(e e.isActive); }currentInterval用Math.max限制下限避免分数很高后生成间隔变成0导致瞬间满屏敌机。scoreValue绑定在敌机宽度上视觉上更大的飞机值更多分玩家能建立策略认知。x坐标使用Math.random() * (canvas.width - width)确保敌机不会右侧跑出画面。y设为负width可以让敌机从屏幕上方“滑入”而不是突然出现在边界上。事件数值实现要点击落普通敌机100分enemy.scoreValue击落大型敌机200分大型敌机初始spawnInterval更低玩家被撞扣1条命同时清除该敌机或进入无敌帧游戏结束lives 0停止循环并显示结束画面4.2 AABB矩形碰撞检测与边缘容错碰撞检测是飞机大战的核心用四个矩形的相交判断即可不需要像素级检测。两个矩形是否重叠条件是甲的右侧大于乙的左侧甲的左侧小于乙的右侧甲的底部大于乙的顶部甲的顶部小于乙的底部。这里的left对应xright对应xwidthtop对应ybottom对应yheight。function isCollision(a, b) { return ( a.x b.x b.width a.x a.width b.x a.y b.y b.height a.y a.height b.y ); }注意这里使用小于和大于而不是和这样两个矩形刚好贴边时不算碰撞视觉上减少“擦到就死”的误判。如果玩家飞机贴图较大可以给碰撞矩形缩一圈例如在碰撞前传入一个临时对象使用player.x4, player.y4, player.width-8, player.height-6。这种“碰撞盒略小于贴图”的做法在雷电原版里很常见目的是让手感更宽容。碰撞时还要在同一帧里优先判断子弹与敌机再判断玩家与敌机不要先删除所有敌机再判定玩家否则会漏掉“敌机已经撞到玩家但本帧没画”的情况。4.3 得分规则、生命扣除和游戏结束状态游戏结束的判定不能放在render里因为渲染可能被省略。标准做法是在update里检查lives 0然后切换游戏状态到gameover再停止生成和更新。大作业里常见问题是没有独立状态游戏结束还继续检测键鼠事件导致出现“死了之后还能按方向键”的现象。// 子弹与敌机 for (const bullet of bullets) { if (!bullet.isActive) continue; for (const enemy of enemies) { if (!enemy.isActive) continue; if (isCollision(bullet, enemy)) { bullet.isActive false; enemy.isActive false; score enemy.scoreValue; break; // 一颗子弹只打中一架 } } } // 玩家与敌机 for (const enemy of enemies) { if (enemy.isActive isCollision(player, enemy)) { enemy.isActive false; player.lives--; break; } }第二段循环中每处理一次碰撞只清掉一架敌机再加入无敌帧机制就能避免“一架敌机在玩家身上停留两帧导致连续扣三条命”。无敌帧可以这样实现在player对象中增加invincibleTime初始为0碰撞后设置为1秒每帧减少deltaTime且在碰撞前判断invincibleTime 0。想要体现“高分大作业”还可以在碰撞后让飞机闪烁0.5秒以示无敌期未结束。5. 增加雷电经典要素道具、弹幕、本地最高分5.1 道具掉落与状态叠加火力1、护盾雷电的经典道具是P火力、B保险和护盾。在我们用矩形表示所有物体的框架里道具可以看成一个带有type属性的移动矩形。常见做法是让某些敌机被击落时按概率掉落道具掉落率通常在15%左右。type从power和shield中随机选择玩家踩中后修改player对象上的状态。// 敌机被击落时 const dropChance Math.random(); if (dropChance 0.15) { const type Math.random() 0.5 ? power : shield; items.push({ type, x: enemy.x enemy.width / 2 - 18, y: enemy.y, width: 36, height: 28, speed: 90, isActive: true }); } // 拾取判定 for (const item of items) { if (item.isActive isCollision(player, item)) { item.isActive false; if (item.type power) { player.fireRate Math.max(0.08, player.fireRate - 0.03); } else if (item.type shield) { player.shield 1; } } } items items.filter(i i.isActive);power道具的效果是缩短射击间隔也就是从0.25秒逐渐变成0.08秒。这里必须用Math.max设置下限否则火力等级叠满后每帧都发射游戏体验会失衡。shield用1表示有一层护盾碰撞时如果shield 0则消耗盾而不是扣命。道具下落的速度应该比敌机慢一点比如90px/s这样玩家更容易看清掉落位置也来得及主动移动去接。道具效果推荐设计值power缩短射击间隔0.25s 降到 0.08sshield抵挡一次碰撞0 / 15.2 弹幕效果扇形子弹与敌机射击角度计算雷电到后期会有敌机发射扇形弹幕这种效果用Math.atan2计算敌机指向玩家的基础角度然后加上一个偏移范围即可。Math.atan2返回的是弧度不是角度。更新子弹坐标时用Math.cos得到x方向速度分量用Math.sin得到y方向速度分量二者再乘以固定的弹速。function enemyShoot(enemy) { const baseAngle Math.atan2( player.y - enemy.y, player.x - enemy.x ); const bulletSpeed 160; const spreadCount 5; const spreadStep 0.2; // 弧度约11度 for (let i 0; i spreadCount; i) { const angle baseAngle (i - Math.floor(spreadCount / 2)) * spreadStep; enemyBullets.push({ x: enemy.x enemy.width / 2, y: enemy.y enemy.height, vx: Math.cos(angle) * bulletSpeed, vy: Math.sin(angle) * bulletSpeed, radius: 4, isActive: true }); } }spreadCount是奇数5颗子弹会对称分布在基础方向两侧。spreadStep是相邻子弹之间的弧度差0.2弧度约等于11度属于比较密集的扇面如果改成0.4散开后覆盖角度更大但中间会出现缝隙。enemy对象上可以增加shootable属性只有shootable为true的敌机才能调用这个函数避免满屏敌机都放弹幕导致游戏难度失控。5.3 最高分用localStorage持久化刷新不丢大作业验收时老师会刷新页面如果分数在刷新后清零看起来就像没写完。localStorage读写本身只有几行代码但有些浏览器在隐私模式或禁用cookie时会抛异常所以封装时最好包一层try/catch。这样即使在限制环境下游戏也能正常运行只是不保存最高分而已。const SCORE_KEY plane_war_max_score; function saveMaxScore(score) { try { localStorage.setItem(SCORE_KEY, String(score)); } catch (e) { console.warn(保存失败可能是隐私模式限制, e); } } function loadMaxScore() { try { const value localStorage.getItem(SCORE_KEY); return value ? parseInt(value, 10) : 0; } catch (e) { return 0; } }parseInt这里必须传第二个参数10否则以“0”开头的字符串会被当成八进制解析导致最高分变成另一个数字。调用时机在game over之后写入在页面初始化时读取并渲染。你可以在游戏开始界面上显示“历史最高X分”这样大作业的效果会显得更完整。如果时间允许还可以将最高分对应的玩家名称存成对象序列化但本地单机游戏不需要做服务端排名。6. 收尾做一个能拿高分的大作业代码组织、性能与答辩点6.1 用Chrome DevTools确认帧率与卡顿点代码都跑通后先别急着加特效。打开Chrome DevTools的Performance面板录制10秒实际游戏过程重点看FPS和每帧耗时。常见瓶颈有三个update里发生大量字符串拼接、每帧都重置字体或渐变对象、没有正确使用ctx.save和ctx.restore导致状态错乱。有一个快速定位方法把canvas.height从720临时改成480如果帧率明显回升说明渲染开销是主因如果帧率没变化再看子弹和敌机数组的长度重点检查是否每帧都创建新对象。大作业演示时最好先关掉不必要的光标和被遮挡的调试面板。浏览器窗口缩放也会影响canvas内部坐标如果你没有做响应式适配建议固定canvas尺寸并居中避免老师一调整窗口大小游戏就乱套。6.2 代码结构、答辩问题和“加分项”清单即使所有代码都写在一个HTML文件里也应该按注释分区Layout、Input、Update、Draw、Helpers。老师问你“这个游戏哪里最复杂”你就指向子弹过滤和碰撞检测说明你是先标记isActive再filter而不是循环里splice。问你“怎么保证不同电脑速度一致”你给出deltaTime相关的代码。问你“敌机多了怎么办”你把子弹池和对象复用讲一遍答辩就基本过关了。这个题目最值得做的加分项包括暂停按钮、Boss血条、连击提示、击毁爆炸粒子特效、双人模式。每个功能本质上都是往update和render两个函数里加一小段独立代码不会破坏现有结构。如果时间有限优先加Boss血条因为雷电游戏的高潮就是Boss战老师一眼就能看出你做了额外设计。最后提交时把localStorage最高分、难度曲线、deltaTime这三个点用README写清楚这个“web大作业”的质量就不只是“能跑”而是“能讲”。本文还有配套的精品资源点击获取
返回列表