☰
用JavaScript手写8x8飞机大战:完整实现与避坑指南
2026/9/30 4:52:59 网站建设 项目流程

1. 项目概述与需求理解

1.1 “8x8x飞机大战”到底是个什么项目

严格来说,javascript打飞机程序8x8x飞机大战并不是一个官方术语,也不是某个开源库里约定俗成的叫法。我理解它就是典型的“打飞机”(竖版射击游戏)练手项目,核心玩法是你控制一架战机,在屏幕底部左右移动、向上开火,敌机从屏幕上方不断出现,你需要在被撞到或者被子弹打中之前把所有敌机消灭。

标题里的“8x8x”我倾向于理解为一种阵型描述:8列敌机、8行编队,加上后面的“x”代表关卡难度倍率或者说第N波刷新。当然你也可以把它理解成8行8列的网格型敌机阵列——这类编队玩法在街机时代特别常见,比如《小蜜蜂》《太空侵略者》《1945》都是这种思路。用JavaScript实现这样一个游戏,既不需要安装复杂的游戏引擎,也不需要后端服务,一个HTML文件、一个canvas画布、一段原生JS逻辑就能跑起来,非常适合前端初学者作为“第一个完整的游戏项目”来做。

聊到这类项目,绕不开一个问题:为什么大家都爱拿“飞机大战”练手?因为它的游戏机制足够标准但又不完全简单。你要处理玩家的输入(键盘或者触摸)、要维护游戏主循环(帧更新)、要做碰撞检测(子弹打中敌机、敌机撞上玩家)、要管理游戏状态(开局、进行中、结算、复活无敌帧),还要考虑画面渲染(Canvas绘制自机、敌机、子弹、计分板)。一套流程走下来,“定时器、事件循环、对象管理、数组遍历”这些JS基础知识点全练到了,而且游戏本身有正反馈,跑起来之后成就感很强。

1.2 用纯JavaScript做,不打框架,图的是什么

你可能会问:现在做游戏为什么不直接用Phaser或者PixiJS,甚至Unity、Cocos,为什么偏要用纯JavaScript手写?我的答案很简单:练的就是“内功”。飞机大战这种判定逻辑相对简单的2D游戏,正好处在一个“用原生代码能轻松驾驭、又不至于简单到无话可说”的甜点区间。

选纯JavaScript的好处有三点。第一,零依赖,零构建。你不需要npm install,不需要装Node环境,随手新建一个HTML文件,在文本编辑器里写完代码,浏览器双击就能玩。第二,代码所见即所得。整个游戏的逻辑全在你的控制之下,哪一帧出了问题、哪个变量没更新,打开DevTools一行行断点就能查,不容易出现“框架帮你做了什么、你怎么也找不到原因”的黑盒情况。第三,对JavaScript核心基本功的锻炼是实打实的。游戏里的每一帧都在考验你对requestAnimationFrame的理解,每一次碰撞检测都在锻炼你对数组增删、对象属性访问的熟练度。把这个项目吃透了,以后上React、Vue做可视化大屏、做复杂交互,很多底层思维是通用的。

另外,8x8的敌机编队规模设计得很有分寸感。8行8列一共64架敌机,既可以铺满屏幕形成气势,又不会像100架那样把屏幕渲染拖垮。加上我前面说的“x”倍率,关卡增长后可以通过调整垂直下压速度、射击间隔、敌机横向摆动的频率来提升难度,而不是盲目堆数量。这种“用规则增加难度”的思路,是游戏设计的核心,也是这个项目值得写一篇长文来拆解的原因。

2. 整体设计与方案选型

2.1 渲染方案:Canvas 2D还是DOM操作

实现一个简单的飞机大战,表面上至少有两种技术路线:用DOM节点(比如div加上背景图、改坐标)来实现,或者用Canvas画布来绘制。我个人的建议是直接上Canvas,除非你是想刻意练习DOM操作性能优化。

原因很简单:飞机大战的动态元素太多了。自机1架、敌机最多64架、子弹每帧可能有几十颗在飞,如果你用DOM方案,每一帧都要去改元素的style.transform或者style.left/top。改一次DOM,浏览器就要走一次样式计算和布局流程,几十个元素同时高频修改,很容易触发性能瓶颈,掉帧是常有的事。而Canvas本质上是一块位图画布,你把每一帧的画面画上去,只更新改变区域对应的像素位置,性能开销要小得多。

我做这个项目时用的是Canvas 2D API,不是WebGL。原因也和标题里的“javascript”关键词密切相关:WebGL的学习曲线太陡,着色器、缓冲区、矩阵变换这些概念对刚接触游戏开发的JS新手来说负担过重;而Canvas 2D的ctx.fillRect()、ctx.drawImage()、ctx.beginPath()几乎是直觉式的,画一个矩形、画一个圆圈、贴一张图,很快就上手了。8x8飞机大战这个规模完全在Canvas 2D的能力范围之内,不需要为了炫技引入WebGL。你只要把一些绘制操作合并起来,避免每一帧无谓的clearRect全屏清除,帧率轻轻松松就能稳定在60帧。

2.2 物理模型与坐标系统设计

游戏里所有的运动都要有坐标系支撑,我的做法是把Canvas的左上角定为原点(0, 0),x轴向右为正,y轴向下为正。自机的初始位置放在(canvas.width / 2, canvas.height - 100),屏幕底部留出一点空间给计分信息。敌机的编队坐标则用二维数组来维护,数组索引对应行和列,值存布尔或敌机对象。每一帧更新时,整个编队作为整体向右移动一个偏移量,移动到边界就反弹并向下降一行,这是“太空侵略者”式编队的标准做法。

这里有一个值得强调的设计细节:坐标值的更新要让“移动”和“绘制”完全分离。也就是在每帧开始时先根据速度和时间积累更新所有对象的逻辑坐标,然后在同一个帧内统一绘制,不要在绘制的过程中再去修改坐标。否则可能出现顺序不一致导致画面闪烁或逻辑错乱的情况。为了计算匀速运动,我建议记录上一帧到当前帧的时间间隔dt,用x += speed * dt的方式,而不是简单地在每一帧里加一个固定值。固定值的问题在于:requestAnimationFrame的触发频率在不同显示器上不一样,60Hz显示器和120Hz显示器上游戏速度会差一倍。用时间差来控制,无论显示器刷新率多少,物体的移动速度都是恒定的。

2.3 自机控制:键盘、鼠标还是触摸

对飞机大战这类游戏,手感是灵魂。我在这个项目中优先支持键盘控制,方向键或者WASD控制移动,空格键发射子弹。如果后期兼容移动端,就需要再绑一套触摸或鼠标控制:通过监听mousemove或touchmove事件,把手势位置直接映射为战机位置。

键盘控制的实现有一个新手非常容易踩的坑:如果只监听keydown事件来移动飞机,你会遇到按键黏滞和方向切换不跟手的问题。比如按住左键再按右键,松开右键后,理论上应该继续左移,但很多初版代码会后直接停在原地。正确的做法是维护一个“按键状态表”,用keydown记录某个键是否被按下,用keyup把它释放,然后每一次主循环轮询这张表来决定移动方向。这样连续按、同时按多键、长按都变得非常自然。

另外,我要特别提醒一个细节:不要用event.preventDefault()去阻止浏览器默认行为。方向键和空格键在页面里会触发滚动,如果你不阻止,玩游戏的时候页面会上下乱跳。但反过来,有些浏览器会拦截空格键的默认行为,所以写法上要兼容。我通常会把监听对象分组:移动方向交给方向键/WASD,射击交给空格键,这些事件统一用e.preventDefault()禁止默认动作,但如果你的页面里还有别的表单或输入框,要适当加一些过滤。

2.4 数据结构和对象管理策略

8x8编队意味着每一帧都要遍历判断64个敌机的状态,而且子弹、爆炸特效、分数飘字这些对象的生命周期是动态的。用数组管理它们时,推荐“每帧用过滤器替换旧数组”的方式,而不是在循环里直接splice删除对象。原因是splice删除元素会改变数组索引,导致循环跳项,而用array = array.filter(isAlive)的思路,所有存活对象保留到新数组,把删除动作延后到一整个遍历周期结束之后,逻辑清爽、bug也少。

如果你玩过复杂一点的动作游戏,你可能会听说过“对象池”这个名词。在这个项目里,子弹的发射频率高、存活时间短,频繁创建和销毁对象确实会带来一定的垃圾回收压力。但8x8飞机大战这个量级还远远到不了必须用对象池的地步。就算每秒发射20颗子弹,一次游戏两三分钟也就两三千个对象,现代JavaScript引擎处理这个量级的对象毫无压力。所以我的建议是:第一版先把代码写对写清楚,用最直白的方式管理对象;等真正遇到性能瓶颈了,再用对象池来优化。不要一开始就优化,那是过早优化。

3. 核心细节解析与实操要点

3.1 敌机编队的8行8列排列算法

先说自己程序员怎么理解“8x8x”:我的实现里,8x8指的是敌机以8行8列排成一个矩形阵列,最后一行的x代表“扩展难度”,也就是每过一关,阵列的下压速度和子弹发射周期会按比例增加。初始时,每一架敌机的中心坐标可以这样算,假设编队左上角的起始坐标为(offsetX, offsetY),行间距为rowGap,列间距为colGap,那么第i行第j列的敌机坐标就是x = offsetX + j * colGap,y = offsetY + i * rowGap。

用代码表示大致是这样:

const COLS = 8; const ROWS = 8; const COL_GAP = 60; const ROW_GAP = 48; const enemies = []; for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { enemies.push({ x: 80 + col * COL_GAP, y: 60 + row * ROW_GAP, width: 40, height: 30, alive: true, row, col }); } }

这种矩阵排列的好处是,你可以在每一帧里用一个统一的“编队速度”变量来控制整组敌机的运动方向预定。到了边界之后整体反向并下移,这样只要改一个变量,所有敌机的运动行为就都变了,非常省心。另外,保留row和col字段,方便你在特殊关卡里让某一行敌机拥有不同行为,比如最下面一行是红色且速度更快,这种扩展性是这个结构的一个额外福利。

3.2 子弹系统:单发到双发的演变

子弹的逻辑是整个游戏循环里最“高频”的部分。我通常把子弹维护成一个数组,里面存每个子弹的x、y和speed,速度为负数(因为y轴向下为正,向上发射要让y减少)。每帧遍历数组,更新坐标,如果y < 0就把它标记为“不存活”,等过滤器统一清除。

玩家可以发射子弹,敌机也应该能发射子弹。但敌机发射的子弹要控制频率,不然64架敌机同时射击,满屏弹幕完全没法玩。我的策略是:让每次到达一个发射节点时,只从当前存活的敌机里随机挑两架开火,每隔1.2秒触发一次。这样屏幕上的敌方子弹始终被控制在一个“有压力但不绝望”的范围。如果你希望游戏更硬核,可以把间隔下调到0.6秒,再把选取数量提高到4架。

以下是子弹发射的核心逻辑片段:

let playerBullets = []; function shoot() { playerBullets.push({ x: player.x + player.width / 2 - 2, y: player.y - 8, speed: -600, width: 4, height: 12, alive: true }); }

子弹的碰撞箱不要做得和贴图一样大,通常要比视觉稍小一圈,这样玩家在极限操作时手感更宽容。我第一次做的时候,碰撞箱和子弹像素一样大,结果玩家躲子弹总感觉“明明错开了还是被打中”,体验非常糟糕。后来把子弹碰撞箱缩小到视觉大小的一半,游戏手感立刻提升了一个档次。

3.3 碰撞检测:为什么“看起来很准”还是会被说作弊

基本的碰撞检测方式有两种:矩形碰撞(AABB)和圆形碰撞。飞机大战这种块状物体居多的游戏,用AABB就够了——两个矩形相交就认为碰撞发生,判断条件是:

function rectHit(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; }

这个公式一旦写对,基本是零调试的。但要注意的是,子弹速度高的时候,可能出现“隧道效应”:上一帧子弹还在敌机左边,下一帧已经跑到敌机右边去了,中间跨越的距离超过了敌机的宽度,矩形检测就会漏判。解决方案有三种:降低子弹单帧移动距离(限制速度)、用上一帧和当前帧之间连线段做检测、或者把子弹碰撞盒在运动方向上拉长。飞机大战里子弹速度通常在每帧10像素以内,用第一种方案最省事:控制速度上限,让子弹的移动量不超过敌机宽度的一半,基本就不会出现隧道问题。

关于碰撞后的表现,我还想强调一下“反馈”设计。玩家看到子弹打中敌机,必须立刻得到视觉和声音反馈,否则会觉得自己打击无效。我做了两层反馈:第一层是打中后敌机闪烁一下、分数飘出来;第二层是爆炸粒子特效,用20个小色块向外飞散,经过0.3秒后透明消失。这些细节虽然增加了一点代码量,但它们决定了游戏是“像个能玩的Demo”还是“像个正经作品”。

3.4 计分与生命系统:数值平衡的第一课

分数系统的第一版完全可以做成“每架敌机固定10分”,但玩一会儿你会觉得无聊。我后来增加了分层:最上面两行的敌机是10分,中间四行20分,最下面两行30分。因为最下面两行离玩家最近、威胁最大,理应给更高分值。分数飘字动画用一个小数组维护,包含文字内容、坐标和存活时间,每帧把y往上移一点、透明度降低一点,0.8秒后消失。

生命系统方面,我设计了3条命和1秒无敌帧:玩家被敌机或敌弹击中后,生命减1,自机原地闪烁1秒(通过每0.1秒交替设置可见性实现),期间不参与任何碰撞。这一步非常关键——如果没有无敌帧,玩家会因为“连续碰撞”瞬间失去所有生命,挫败感极强。无敌帧结束后如果还有命,游戏继续;命为0,游戏状态切换到“Game Over”,显示本局得分,并提示按键重新开始。

4. 实操过程与核心环节实现

4.1 创建HTML骨架和Canvas画布

动手第一步,新建一个index.html,不需要任何外部库。核心就是把Canvas作为游戏舞台,并配合keydown/keyup事件捕获用户的键盘输入。为了让游戏区域在不同屏幕上表现稳定,我固定了画布尺寸为800x600,然后通过CSS把它水平居中。如果要在手机上玩,可以给Canvas加一个max-width: 100%,但内部逻辑坐标不变,只需要额外监听touchmove事件。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>8x8 飞机大战</title> <style> body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background: #1a1a2e; overflow: hidden; } canvas { border: 2px solid #e94560; background: radial-gradient(circle at center, #16213e, #0f0f1f); } </style> </head> <body> <canvas id="gameCanvas" width="800" height="600"></canvas> <script src="game.js"></script> </body> </html>

这里特别建议把game.js单独拆出来写,而不是全塞在HTML的<script>标签里。原因有二:一是方便用浏览器开发者工具打断点调试;二是逻辑文件独立后,后续想把Canvas改成WebGL、把键盘改成手柄输入,都不需要大改页面结构。项目场景虽然是零依赖,但“代码组织习惯”是为将来更复杂的工程做准备的。

4.2 主循环:requestAnimationFrame与时间步长

主循环是任何游戏的心脏。网上很多教程会直接写:

setInterval(update, 16);

我劝你不要这么干。setInterval连隔多久执行都无法精确控制,还可能因为浏览器标签页切换到后台而被强制节流,甚至完全暂停。requestAnimationFrame才是浏览器专门为动画和游戏提供的机制,它会在每一帧屏幕刷新前自动调用你传入的回调函数,并且当页面不可见时长自动暂停,不消耗多余资源。

正确的主循环写法是把“逻辑更新”和“画面绘制”放在同一帧里,并计算间隔时间:

let lastTime = 0; function gameLoop(timestamp) { const dt = Math.min((timestamp - lastTime) / 1000, 0.05); lastTime = timestamp; update(dt); // 逻辑更新 render(); // 画面绘制 requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);

注意Math.min(dt, 0.05)这行:当浏览器标签页被切到后台再切回来时,timestamp会出现一个很大的跳变,如果不加限制,dt会变得非常大,所有对象会瞬间移动一大段距离——也就是“物体穿越”现象。把dt限制在0.05秒(约一帧的3倍),就算从后台回来也不会出现明显的爆炸性位移。

4.3 按键监听与状态管理

我在第2.3节提到要维护按键状态表,代码实现大概是这样的:

const keys = {}; document.addEventListener('keydown', e => { e.preventDefault(); keys[e.code] = true; if (e.code === 'Space') { shoot(); } }); document.addEventListener('keyup', e => { keys[e.code] = false; });

然后在update函数里读取按键状态,处理自机移动:

const MOVE_SPEED = 400; function updatePlayer(dt) { let dx = 0; if (keys['ArrowLeft'] || keys['KeyA']) dx -= 1; if (keys['ArrowRight'] || keys['KeyD']) dx += 1; if (keys['ArrowUp'] || keys['KeyW']) dy -= 1; if (keys['ArrowDown'] || keys['KeyS']) dy += 1; player.x += dx * MOVE_SPEED * dt; player.y += dy * MOVE_SPEED * dt; player.x = Math.max(0, Math.min(canvas.width - player.width, player.x)); player.y = Math.max(0, Math.min(canvas.height - player.height, player.y)); }

这里每一次都做Math.max和Math.min的边界钳制,是为了防止战机飞出画面。我见过一些半成品代码,战机飞到画面外还能继续开火,感觉特别出戏。钳制的写法从上到下都有,很机械化,但少了它游戏就不完整。

关于连发,我又加了额外限制:按住空格键会让子弹连续射,但要控制射速,否则按着不动就会把子弹铺满整个屏幕。我用一个shootCooldown变量,每次发射后把它设为0.15秒,每帧用dt递减,归零后才能继续发射。这个设计的好处是玩家可以按住空格不放,也能通过节奏控制打出不同密度的火力。

4.4 敌机编队的移动与碰撞检查完整流程

敌机编队整体是“一个大队移动,而不是每一架各自随机乱飞”,这样游戏才有阵型和规律感。我的实现方式是维护一个全局的编队对象:

const fleet = { dir: 1, // 1为向右,-1为向左 speed: 80, // 横向移动速度(像素/秒) dropStep: 24, // 碰到边界时下降的像素 x: 70, // 整体左上角x y: 60 // 整体左上角y };

每帧更新时,计算整体x的偏移量,然后判断是否超出左右边界。一旦超出,就反向并把y增加dropStep。然后遍历整个阵列,用这架敌机的行列坐标算出绝对位置,逐个检查是否和子弹碰撞、是否和玩家碰撞。这样做的复杂度是O(rows x cols),在当前规模下完全没问题。

移动判定示例:

function updateFleet(dt) { fleet.x += fleet.dir * fleet.speed * dt; if (fleet.x + COLS * COL_GAP >= canvas.width - 40 || fleet.x <= 40) { fleet.dir *= -1; fleet.y += fleet.dropStep; } }

这里我留了40像素的安全边距,避免敌机阵列整体贴边导致最外侧敌机离边框太近,看起来像是“被墙挡住的撞墙判定太突然”。这个边距数值越小,玩家会觉得敌机贴边反弹越“硬”;越大则越“软”。40是我试过之后觉得最自然的数值。

碰到敌机和子弹碰撞后,把敌机的alive标记为false,播放爆炸反馈;当编队中所有敌机都死亡时,触发下一关:重置编队到初始位置,把speed乘以1.15、dropStep加几像素,然后再把shootCooldown缩短一点。就这样,难度通过数值迭代螺旋上升。

4.5 渲染细节:绘制自机、敌机、子弹与界面

Canvas绘制本身不复杂,但一些细节能让画面质感提升不少。自机我用一个三角形加两侧机翼来画,核心是用ctx.moveTo和ctx.lineTo画出多边形,填充渐变蓝色。敌机则简单一点,每行用不同颜色,这样玩家能直观区分哪些是高分行敌机。子弹我直接用fillRect画一个长条,玩家子弹是黄色的,敌方子弹是红色的。

关键绘制逻辑大致如下:

function drawPlayer() { ctx.save(); ctx.translate(player.x + player.width / 2, player.y + player.height / 2); ctx.beginPath(); ctx.moveTo(0, -14); ctx.lineTo(10, 10); ctx.lineTo(0, 4); ctx.lineTo(-10, 10); ctx.closePath(); ctx.fillStyle = '#4fc3f7'; ctx.fill(); ctx.restore(); }

这里用了ctx.save()和ctx.restore()把坐标系平移到了自机中心,这样绘制时不需要每次手动计算三角形的绝对顶点,也不影响后续其他内容的绘制。边框、命数、分数这些HUD信息,每次渲染时用一个单独的drawHUD()函数处理,保持在最顶层。

还有一个容易忽略的点:背景。纯黑背景玩久了眼睛容易累,而且视觉上没有层次。我可以在画布底部画一条淡淡的动态星空背景:用一个数组保存几十颗星星的位置和速度,每帧让它们缓慢向下移动,超出底界就重置到顶部。虽然不影响玩法,但它能强烈地提升“游戏感”,过来人经验:新手做游戏经常忽略这种“视听反馈”,导致代码跑通了却没有游戏的感觉。

5. 常见问题排查与避坑实录

5.1 方向键控制页面滚动,自机不移动

这是最典型的“第一次做网页游戏”的踩坑点。默认情况下,方向键和空格键会让页面元素滚动,如果你的页面高度超过一屏,按方向键时页面会滚来滚去,而自机却不动。

解决办法是给keydown事件加e.preventDefault(),把默认行为屏蔽掉。但要注意,如果你在页面上还有输入框要接收方向键,不要无条件阻止,可以用e.target判断事件源是否来自输入框。我的标准写法:

document.addEventListener('keydown', e => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) { e.preventDefault(); } keys[e.code] = true; });

还有一点容易被忽略:keydown的e.repeat属性。如果你按住方向键不放,浏览器会持续触发keydown事件,而且重复触发的间隔由系统按键重复率决定,不是由游戏帧率决定。如果你在keydown里直接做坐标加减,会因为重复率不稳定导致移动速度忽快忽慢。这就是我们必须维护“按键状态表”、每帧统一读取状态再算位移的原因。这是新手最容易出错的地方。

5.2 碰撞检测“有延迟”或者“完全不触发”

“完全不触发”通常有两个原因。第一,子弹或敌机的坐标不是每帧更新,比如你只在按空格时创建了子弹,但忘了在主循环里对子弹数组做遍历更新,导致子弹刚发射出来就静止在原地,自然打不到后来移动的敌机。第二,碰撞条件写反了。rectHit(a, b)函数判断的顺序不影响结果,但如果你混用了对象字段,比如用了bullet.size来表示宽高,而碰撞函数里用的却是bullet.width,那这个值永远是undefined,判断永远不成立。

“有延迟”则多半是碰撞的检测频率不够。requestAnimationFrame下每一帧检测一次,60Hz下每帧约16.7毫秒,前一次和后一次检测之间子弹可能移动了好几像素。如果你觉得打中判定“晚了一丁点”,把子弹速度适当降低,或者把敌机碰撞箱扩大几像素即可。推荐做法是在敌机对象里留一个hitBox膨胀系数,统一调整,而不是把宽度写死。

5.3 游戏越玩越卡,帧率从60掉到40

很多人第一次写这个项目,前期帧率很稳,越往后期越卡。到时候先检查数组里是不是堆积了大量“已死亡但还没清理”的对象。尤其是爆炸粒子、分数飘字、敌方子弹这些动态数组,如果只负责“添加”不负责“清理”,到最后每一帧都要遍历几千个无意义对象,自然卡顿。

解决方案是组件化清理:每帧update结束后,对所有动态数组执行filter,剔除非存活对象。注意一定要用“生成新数组”的方式,而不是在循环里splice,原因我在2.4节已经详细说过。写成代码是这样:

playerBullets = playerBullets.filter(b => b.alive); enemyBullets = enemyBullets.filter(b => b.alive); particles = particles.filter(p => p.life > 0 && p.alpha > 0);

我用一个开关enableMemoryCheck = true,在每100帧时打印一下数组长度,用来观察峰值。如果某类数组长度突破了几千,就说明清理逻辑有漏网之鱼。这算是一个比较实用的调优手段。

5.4 敌机反弹时“穿墙”或整体越界

敌机编队整体在左右边界自动反弹,但如果你在计算时没有把每架敌机的宽度也算进去,就会看到最外侧的敌机一半已经超出画布边界了才反弹回来。我的做法在前面展示过:边界判断时预留了40像素边距,比最外侧敌机的一半宽度还要大一点。

如果追求更精确,可以实时计算编队的总宽度:

const fleetWidth = COLS * COL_GAP - (COL_GAP - 40); if (fleet.x + fleetWidth >= canvas.width - 10 || fleet.x <= 10) { fleet.dir *= -1; fleet.y += fleet.dropStep; }

这里把“飞机本身宽度的余量”纳入计算,边界判断会更精准。有些实现还会在反弹时播放一个清脆的提示音,在视觉上也在反弹瞬间把整个编队整体闪烁一下,让玩家的注意力被引导到“编队改变方向”上。

5.5 “按住空格键”射击体验与系统卡顿问题

在5.1里我提过e.repeat,这里再展开一点。直接监听keydown并发子弹,同时按住方向键和空格键,某些键盘会触发重复的keydown事件,导致子弹一次性连发两发或三发,体验很怪。正确做法是:把“是否在播放射击动作”交给主循环的shootCooldown去管,keydown里只维护一个isFiring状态,这样按住与点按不会混乱。

另外,如果你发现按住空格射击时页面明显卡顿,多半是子弹对象的创建数量没有节制。特别是配合5.5中的filter清理,如果每帧都允许发射且不设冷却,子弹数量会以指数级增长。我的建议是:给发射动作加一个最低冷却间隔0.12~0.18秒,同时一次发射只产生1~2颗子弹,这样屏幕上的活跃子弹始终控制在50颗以内,对渲染没有压力。

6. 项目延展与个人经验总结

8x8飞机大战写完之后,我给它加了好几个“进阶功能”,每一个其实都是新知识点。最推荐的第一个扩展是“关卡波次系统”,不是简单地把敌机重新排列,而是每过一关换一种阵型,比如第三关改成V字阵,第四关改成菱形阵。阵型变换本质上是“给定参数重新计算每个敌机的初始位置”,但游戏可玩性立刻翻倍。第二个推荐是“道具掉落系统”,Boss或敌机有概率掉出两种道具:加快射速的R和加一条命的H。道具掉落会引入“移动矩形与玩家矩形碰撞”的判定场景,难度不大但很锻炼面向对象的编码习惯。第三个推荐是音频:用Web Audio API合成射击声和爆炸声,不需要引入任何音频文件,十几行代码就能搞出合格的音效,能让作品完整度再上一个台阶。

如果你打算把这个项目做成简历里可展示的作品,我建议再做一项“工程化改造”:把逻辑拆分成Player、EnemyFleet、BulletManager、ParticleSystem等几个模块,配合requestAnimationFrame的清晰主循环结构,这样即使实际游戏逻辑不算复杂,从代码组织层面上也体现了作为一个开发者的基本工程素养。

最后说一点我个人在这个项目上最有感触的地方:别小看“看起来很简单”的小游戏。飞机大战的代码总量写出来可能也就是六七百行,但它逼着你把“事件驱动”“状态管理”“碰撞检测”“帧循环”这些前端核心概念全部用到实处。很多人在简历里写“熟悉JavaScript”,但如果让他现场把这个小游戏从零写一遍,大概率是写不利索的。反过来说,你要是能不看参考、独立把这个项目从零写到能玩、能调难度、能处理边界情况和性能问题,你对JavaScript“基本功”的掌握程度是经得起问的。这个项目我前后共写了三遍,每一次重写都能发现以前设计的不足——第一版全是全局变量,第二版开始用对象组织逻辑,第三版才真正理解“状态机”和“数据驱动”的含义。如果有朋友正在学前端,我特别推荐他把它当作一个“自我检验”的里程碑项目:写一遍,你的JS基础会明显上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询