news 2026/11/4 4:10:08

原生JS+Canvas 2D飞机射击:主循环、对象池与碰撞检测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
原生JS+Canvas 2D飞机射击:主循环、对象池与碰撞检测

简介:这是一份面向初学者的 j2ME 手机射击游戏源码学习资源,以飞机射击为主线,适合想了解早期移动平台游戏开发流程的 Java 学习者练手。压缩包共 200 个文件,约 4.27MB,包含 138 个 png 图像素材、11 个 java 源文件与 22 个 class 编译文件,另有 20 个 mid 音效、2 个 jar 及配套 jad、eclipseme、classpath 等工程配置,构成可直接导入 EclipseME 调试运行的完整项目,素材、源码与可执行文件齐备。已有 1952 人学习下载。代码围绕游戏循环、精灵对象、矩形碰撞检测、按键输入响应、Graphics 图形渲染、音效播放与多状态切换等核心知识展开,飞机、敌机与子弹各自封装为独立类,逻辑清晰,读者可据此理解 MIDP 与 CLDC 基础 API 的实际用法,掌握从素材加载到画面刷新的完整链路,并以此为基础改造出自己的飞行射击小游戏。

1. 一份双击就能玩的飞机射击代码,适合拿来练手

前阵子一个做后端的同行找我,说想给孩子做个能玩的射击类小游戏,结果找回来的要么是整套引擎工程,光是打不开的依赖就有一堆;要么只有一段片段代码,连敌机都刷不出来。我给他的是这份约六百行的飞机射击代码:原生 JavaScript 加 Canvas 2D,没有框架、没有构建、没有 npm install,解压后双击页面就能开始打。代码刻意保持简单:一架玩家飞机、两种敌机、一堆子弹、一个分数和最高分,全部逻辑摊在八个 js 文件里,变量名和函数名都写得很直白。它适合三类人:刚学完 JS 语法、想找一个能跑完整的练手项目的人;想在自己项目里加一个关卡小游戏的前端开发者;以及需要一份能当教学演示、当场改给别人看的人。反过来,想要 3D、想要联网对战、想要商用级美术表现,这份东西帮不上忙,它更像是一台拆开盖子的发动机,够小,才看得清每个齿轮怎么咬。

2. 技术选型与目录结构:为什么是 Canvas 2D 而不是游戏引擎

飞机射击这个题材有个好处:实体少、状态少、规则线性。玩家只有左右上下移动和开火两个操作,敌机只会往下掉,子弹只会往上飞,胜负判定就是一条命掉光。这种复杂度用引擎反而是负担,自己用 Canvas 画几百个精灵完全够用,而且每一行都能改。

2.1 三种常见做法的取舍

我在动手前把三条路都试了一遍,结论放在下面这张表里:

方案依赖启动成本适合不适合
Phaser / Cocos 等引擎CDN 或 npm 包要先理解场景、预制体、生命周期关卡多、有动画编辑需求只想看懂一局游戏怎么跑起来
Python + pygamePython 环境、SDL装环境、处理中文字体本地桌面小工具分享给别人玩,对方没装 Python
原生 JS + Canvas 2D无双击或起个静态服务学习循环、碰撞、对象池需要 3D、物理引擎、联机

我最后选第三条,核心原因很务实:分发成本最低。给一个不写代码的人发压缩包,他解压完双击 index.html 就能玩,这比让他装 Python 靠谱得多。代价是碰撞检测、音效播放、资源加载全得自己写,但这份代码里这些加起来不到两百行,还在可读范围内。

2.2 目录结构:按实体的生命周期拆文件

很多新手写小游戏喜欢把所有东西塞进一个 main.js,写到八百行就开始找不到变量。这份代码按“实体的生命周期”拆,而不是按 MVC 那套拆,因为游戏里真正变化的东西就是实体的出生、移动、消亡。

plane-shooter/ ├── index.html # 唯一页面:canvas + 开始/暂停/结束三层 UI ├── css/style.css # 只负责画布居中、UI 层的显示隐藏 ├── js/ │ ├── main.js # 入口:取 canvas、起主循环 │ ├── game.js # 状态机、实体数组、难度曲线、绘制顺序 │ ├── input.js # 键盘 + 指针,统一成「目标 x/y」 │ ├── player.js # 玩家:移动、射击冷却、无敌帧 │ ├── enemy.js # 敌机:生成参数、下落、被击毁 │ ├── bullet.js # 子弹:速度、越界回收 │ ├── pool.js # 通用对象池 │ └── collide.js # 圆形碰撞判定 └── assets/ # 可选:没有图片时自动降级成纯色矩形

对应职责和改动频率我记在下面这张表里,二次开发时按“改得最多”的顺序找文件,比全局搜索快:

文件大致行数职责二次开发改动频率
game.js约 180状态切换、难度曲线、渲染顺序最高
player.js约 90移动手感、射击节奏、生命高
enemy.js约 80敌机类型、血量、掉落高
collide.js约 40命中判定中
pool.js约 50对象复用低
input.js约 60键位、触摸低

这里有个约定值得说清楚:所有 js 文件都用 IIFE 挂到 window 上,比如window.Player、window.Pool,而不是用 ES module 的 import/export。原因很直接——浏览器在file://协议下会拦截 module 脚本,一拆模块就必须起服务器,双击即玩的优势就没了。这个坑我在第 5 章还会再提一次。

2.3 两种启动方式和第一个可玩版本

拿到压缩包后,第一种方式是直接双击 index.html。第二种是起个静态服务器,我更推荐这个,因为后面如果你想加载 json 配置或者用getImageData做像素级检测,file://会被跨域策略卡住:

# 进入项目根目录(index.html 所在的那一层) cd plane-shooter # Python 3 自带,不用装任何东西;8000 是端口,被占用就换 8080 python3 -m http.server 8000 # 或者装了 Node 的话 npx serve .

命令跑完,浏览器打开http://localhost:8000。正常的话你应该看到:深色背景、底部一架飞机跟着方向键左右移动、按空格连发子弹、屏幕上方不断有敌机落下来、撞上或者被撞就掉一条命。如果画面出来了但飞机不动,先按 F12 看 Console 有没有报 404——assets 目录缺失是最常见的原因,这份代码做了降级处理,图片加载失败会用纯色矩形代替,所以更可能是某个 js 文件路径写错了。想看得更清楚可以在 Console 里敲Game.debug = true,碰撞圆和帧率会一起画出来,调手感的时候非常有用。

3. 主循环与游戏状态机:一帧十几毫秒里发生了什么

游戏和普通网页最大的区别是它有“心跳”。所有移动、生成、判定都挂在这个心跳上,心跳写歪了,后面写多漂亮的绘制代码都是白搭。这份代码的主循环只有十几行,但每一行都有它存在的理由。

3.1 requestAnimationFrame 与 dt:为什么不能用帧数当时间单位

先看入口:

// js/main.js const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const game = new Game(canvas); // Game 持有状态机、实体数组和绘制顺序 let last = performance.now(); function loop(now) { // 单帧最多推进 50ms:切到别的标签页再切回来时,不会一次性补算几秒钟 const dt = Math.min((now - last) / 1000, 0.05); last = now; game.update(dt); // dt 单位是秒 game.render(ctx); requestAnimationFrame(loop); // 下一帧继续,浏览器管节流 } requestAnimationFrame(loop);

三个参数解释一下。now是 rAF 自动传进来的高精度时间戳,单位毫秒;除以 1000 转成秒,是为了让所有速度都写成“像素/秒”,这样玩家飞机写speed = 320,意思就是一秒横穿 320 像素,跟屏幕刷新率彻底解耦。Math.min(..., 0.05)是个保险:显示器 60Hz 时 dt 大约 0.0167,一旦用户切走标签页,rAF 会暂停,回来时两帧之间可能差了好几秒,不封顶的话飞机会瞬移出屏幕、子弹直接穿过敌机。封在 0.05 秒,最多丢一点动画平滑度,逻辑不会崩。

不用setInterval(fn, 16)的原因也说一句:定时器不受浏览器渲染节奏控制,会出现“这一帧算两次、下一帧一次都没算”的抖动,而且后台标签页里定时器会被降频到 1 秒一次,回来直接乱套。

update(dt)里所有位移都应该写成x += vx * dt的形式。血泪经验是:只要有一处写成x += vx(靠帧数驱动),在高刷屏手机上游戏会快将近一倍,在低端机上又慢得像卡住,而且这种 bug 在开发机上永远复现不了。

3.2 状态机:开始、进行、暂停、结束四态

小游戏最容易写乱的地方是“什么时候该更新、什么时候不该更新”。这份代码用一个很土但很好用的方式解决:把四个状态各自对应一组 update/render。

// js/game.js 片段 const handlers = { // READY:只画标题和提示,不更新任何实体 ready: { update() {}, render: renderReady }, // PLAYING:正常推进 playing: { update: updatePlaying, render: renderPlaying }, // PAUSED:画面照画,但不更新,视觉上就是「冻住」 paused: { update() {}, render: renderPlaying }, // OVER:画结算面板 over: { update() {}, render: renderOver }, }; Game.prototype.setState = function (next) { // 切状态的收尾动作集中在这里,不要散在 update 里 if (next === 'over') { this.saveBest(); // 顺手写 localStorage 存最高分 this.pool.releaseAll(); // 子弹、敌机全部归还对象池 } this.state = next; }; Game.prototype.update = function (dt) { handlers[this.state].update.call(this, dt); };

这样写的好处是,暂停不需要在 update 开头塞一堆if (paused) return,结束也不需要中途 break 出循环。玩家按 Esc 就setState('paused'),再按一次回playing,画面上的敌机原地停住,松开继续掉。另外建议加一行监听:document.addEventListener('visibilitychange', ...),页面被切走时自动转 paused,不然用户接个电话回来,飞机已经撞了三回。

4. 核心模块拆解:玩家、子弹、敌机与碰撞检测

到这一步,主循环转起来了,剩下的活就是把实体塞进去。这一章按“玩家输入 → 子弹发射 → 敌机生成 → 命中判定”的顺序走一遍,这也是实际写代码的顺序。

4.1 玩家飞机:键盘和触摸统一成一个目标坐标

输入这块最省事的做法是不要区分键盘和触摸,而是都转成“飞机的目标 x 坐标”,实际位置向目标平滑靠拢:

// js/input.js window.Input = (function () { const keys = new Set(); window.addEventListener('keydown', (e) => { // 不拦的话,空格会滚动页面、方向键会滚动整个画布 if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space'].includes(e.code)) { e.preventDefault(); } keys.add(e.code); }); window.addEventListener('keyup', (e) => keys.delete(e.code)); return { keys, // 把指针的屏幕坐标换算成画布内部坐标,CSS 缩放和 DPR 一起在这里抵消 pointerX(canvas, clientX) { const rect = canvas.getBoundingClientRect(); const scale = canvas.width / rect.width; return (clientX - rect.left) * scale; } }; })();

配合玩家那边的更新逻辑:

// js/player.js 片段 Player.prototype.update = function (dt) { const speed = 320; // 像素/秒 let dx = 0; if (Input.keys.has('ArrowLeft')) dx -= 1; if (Input.keys.has('ArrowRight')) dx += 1; this.x += dx * speed * dt; // 触摸/鼠标拖拽时,向目标点插值,比直接赋值跟手 if (this.targetX !== null) { this.x += (this.targetX - this.x) * Math.min(1, dt * 12); } // 夹在画布内,留半个身位,别让机翼出画 this.x = Math.max(this.r, Math.min(this.w - this.r, this.x)); };

dt * 12这个 12 是插值系数,数值越大越跟手、越小越“飘”,我一般在 8 到 15 之间试。用插值而不是直接赋值,是为了让手机上的拖拽有一点惯性,手感明显更好。Math.max(this.r, ...)用的是半径而不是 0,因为玩家飞机的绘制原点在中心,不留半径的话半边机身会跑到画布外面。

4.2 子弹与对象池:为什么不能一直 new

一秒钟打十发子弹、屏幕上同时存在三十架敌机,如果每发都new Bullet(),一分钟就是几百个对象,GC 一触发,画面就一顿。这份代码做了个极简对象池:

// js/pool.js function createPool(size, factory) { const items = Array.from({ length: size }, factory); // 开局一次性分配好 return { items, get() { // 池子满了宁可不发这一颗,也不要在主循环里分配内存 return items.find(b => !b.active) || null; }, spawn(x, y, vx, vy, r) { const b = this.get(); if (!b) return null; b.active = true; b.x = x; b.y = y; b.vx = vx; b.vy = vy; b.r = r; b.life = 0; // 复用对象必须清状态,否则上一发的计时会带过来 return b; } }; }

createPool(200, () => ({ active: false })),200 是经验值,覆盖了目前最大弹幕密度。重点在b.life = 0这一行:对象池最经典的 bug 就是复用时忘记重置,表现是“新子弹一出生就消失”或者“敌机刚出来就带血”。只要是从池子里取出来的对象,所有影响逻辑的字段都得重新赋一遍,不能指望构造函数。

子弹发射本身靠冷却计时,不靠按键事件:

// js/game.js 片段 this.fireTimer -= dt; if (Input.keys.has('Space') && this.fireTimer <= 0) { this.fireTimer = 0.12; // 每秒约 8 发,改这个数就是改射速 this.bullets.spawn(this.player.x, this.player.y - this.player.r, 0, -680, 6); }

-680是子弹的纵向速度,负数代表往上飞;6是判定半径。这两个值一起决定了打起来爽不爽:速度太快会显得弹道断成虚线,太慢又像扔石头。

4.3 敌机生成与难度曲线

敌机生成最重要的是“别让玩家一上来就被淹死”。这份代码用一个随时间爬升的档位控制:

// js/game.js 片段 this.time += dt; const level = Math.min(Math.floor(this.time / 15), 5); // 每 15 秒一档,封顶 5 档 this.spawnInterval = Math.max(0.8 - level * 0.1, 0.25); // 0.8s 压到 0.25s this.enemySpeed = 90 + level * 25; // 90 到 215 像素/秒 this.spawnTimer -= dt; if (this.spawnTimer <= 0) { this.spawnTimer = this.spawnInterval; const x = Math.random() * (this.w - 80) + 40; // 留 40 边距,别贴边生成 this.enemies.spawn(x, -40, 0, this.enemySpeed, 22); }

this.time是累计游戏时长,暂停时不累加,所以难度跟着实际游玩时间走,不会因为你挂机就变难。Math.random() * (this.w - 80) + 40这个写法是为了让敌机在水平方向均匀分布又不贴边——贴边生成的敌机在窄屏上会有一半看不见。想加“编队”或者“斜线俯冲”,改的就是这个 spawn 函数,把随机 x 换成固定阵型坐标即可。

4.4 碰撞检测:圆形判定为什么更合适

飞机和子弹都是竖长条或被旋转过的图形,用矩形判定会出现“看着没挨上却死了”。这份代码统一用圆形,半径取贴图短边的一半:

// js/collide.js // 圆与圆相交:比较距离平方,省一次开方,一帧几百次判定时这点开销有意义 function hit(a, b) { const dx = a.x - b.x; const dy = a.y - b.y; const r = a.r + b.r; return dx * dx + dy * dy <= r * r; }

配套还有一个细节:玩家被撞后要有 1.5 秒无敌帧,否则一帧内连撞两架敌机直接掉两条命,玩家会觉得“莫名其妙就死了”。无敌帧的实现就是在 player 上挂一个invincible计时,碰撞检测前先判断,同时渲染时让飞机闪烁,给玩家明确反馈。

5. 避坑与排查:这几处翻车我踩过不止一次

代码能跑起来只是第一步,真正折磨人的是那些“看着像玄学”的问题。下面五条是我在这个项目上真实翻过的车,按现象、原因、解决写,遇到类似的直接对号入座。

5.1 画面与坐标类

现象一:飞机和文字糊成一团,边缘有明显锯齿。原因是用 CSS 把 canvas 拉大了。<canvas width="480">配合style="width:100%",实际是先把 480 像素画好再放大,等于把一张小图糊到全屏。解决是让 canvas 的内部尺寸和显示尺寸对齐,并且处理高分屏:

// 按 devicePixelRatio 放大内部尺寸,再用 CSS 收回视觉尺寸 const dpr = window.devicePixelRatio || 1; canvas.width = 480 * dpr; canvas.height = 800 * dpr; canvas.style.width = '480px'; canvas.style.height = '800px'; ctx.scale(dpr, dpr); // 之后所有绘制仍按 480x800 的逻辑坐标写

现象二:手机上点哪飞机都不跟手,越靠右偏得越多。原因是直接拿了e.clientX当画布坐标。画布在页面上有偏移,还可能被 CSS 缩放过,客户端坐标和画布内部坐标根本不是一个体系。解决就是 4.1 里那个pointerX():先getBoundingClientRect()拿到画布在视口中的位置,再乘上canvas.width / rect.width的缩放比。所有需要坐标的地方都走这个函数,不要各处自己算。

5.2 逻辑与性能类

现象三:切出去接个电话回来,飞机已经撞毁,或者直接闪现到对面。原因是 dt 没封顶,rAF 恢复时把积压的时间一次性算完。解决在第 3.1 已经写了,Math.min(..., 0.05)一行就够;顺手再加个visibilitychange自动暂停,双保险。

现象四:子弹打中敌机,敌机不掉血,或者一发子弹打掉两架。两个方向。前者通常是碰撞判定用的是贴图左上角坐标,而绘制用的是中心点,坐标系没统一;后者是敌机被击毁后active没及时置 false,同一帧又被另一颗子弹判定了一次。解决是把所有实体的x/y约定为“中心点”,并且在命中后立刻enemy.active = false,再走回收流程,不要等到这一帧的循环结束。

现象五:改了模块化写法,双击 index.html 白屏,Console 报 CORS。这就是第 2.2 提到的:一旦用了<script type="module">,浏览器会以安全策略拦截file://下的模块加载。要么退回全局挂载的写法,要么老老实实起静态服务器。我现在的习惯是——只要项目里出现 import,就在 README 第一行写清楚“必须起服务”,省得下一个拿到代码的人以为文件坏了。

6. 二次开发:从能玩到好玩,我会先改这三处

这份代码的定位是“骨架”,好玩的部分得自己往上长。我按投入产出比排了个序,想动手的话从下往上改,难度递增但成就感也递增:

改造点改动文件工作量说明
换美术、换配色assets/、style.css半小时图片按原文件名替换,尺寸不一致时改一下r
加道具(三连发、护盾)enemy.js、player.js一两小时敌机死亡时按概率掉一个掉落物实体,和玩家做圆形判定
加 Boss 和阶段切换game.js半天见下面的代码,血量条和阶段提示是加分项
加音效与震动新增 audio.js一两小时移动端记得首次点击后再初始化 AudioContext

Boss 是最典型的一次改造,代码量不大但会把整个状态机跑一遍:

// js/game.js:第 90 秒刷 Boss,血量归零才进入下一关 Game.prototype.checkBoss = function () { if (!this.boss && this.time > 90) { this.boss = { active: true, x: this.w / 2, y: -100, r: 46, hp: 40, maxHp: 40, vx: 70, dir: 1 }; } const b = this.boss; if (!b || !b.active) return; // 左右来回横移,撞到边界翻转方向 b.x += b.vx * b.dir * this.lastDt; if (b.x > this.w - b.r || b.x < b.r) b.dir *= -1; if (b.y < 120) b.y += 60 * this.lastDt; // 入场动画,进到位就停 if (b.hp <= 0) { b.active = false; this.setState('over'); // 或者切下一关 } };

hp: 40配合玩家每秒 8 发的射速,大概是五秒左右能打掉,正好在“有点紧张但不至于烦”的区间。想验证难度,最快的办法是临时把hp改成 4 打一遍,节奏顺了再改回去。这里有个小技巧:this.lastDt在 update 开头存一份,各个实体就能自己读时间,不用层层传参。

还有个习惯我保持了挺久——每次给这份代码加东西之前,先在 Console 里把Game.debug = true打开,把碰撞圆和 fps 都亮出来。因为九成的“新功能有问题”,最后查出来都是实体坐标或者判定半径写错了,肉眼看画面永远看不出来。从那以后我每次改完核心判定,都会先看一遍碰撞圈再截图给别人,省下好几轮“我这边好好的啊”的来回。这份代码我按上面的目录结构整理好了,解压按第 2 章随便哪种方式都能跑起来,剩下的就交给你改。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 4:10:06

大模型重塑个性化推荐:最新研究热点与工程落地

做推荐算法的这几年&#xff0c;我养成了一个赶论文的习惯&#xff1a;每两周把几个主要会议和预印本站上挂着“recommendation”“personalized”“user modeling”关键词的论文系统性刷一遍&#xff0c;筛出十几篇真正讲个性化的工作&#xff0c;然后逐一看动机、方法和实验设…

作者头像 李华
网站建设 2026/11/4 4:09:59

BNPL技术架构与消费金融安全解析

1. BNPL狂飙背后的消费革命"先买后付"&#xff08;Buy Now Pay Later&#xff0c;简称BNPL&#xff09;正在重塑全球消费金融版图。这种允许消费者先获得商品再分期付款的服务&#xff0c;本质上是通过技术手段将传统零售信贷进行了互联网化改造。与信用卡分期相比&a…

作者头像 李华
网站建设 2026/11/4 4:09:16

光学设计仿真为何成为行业新门槛?硕博士学习与实战指南

这几年我跟不少做光学方向的硕博士聊天&#xff0c;发现大家绕不开同一个东西&#xff1a;光学设计和仿真。无论是传统镜头成像、车载激光雷达、AR/VR显示&#xff0c;还是这两年很热的超表面、微纳光学&#xff0c;工作流里都离不开Zemax、CODE V、LightTools这类仿真软件。以…

作者头像 李华
网站建设 2026/11/4 4:08:44

Vue业务逻辑拆分与组件化设计实战指南

1. Vue业务逻辑拆分实战指南在Vue项目开发中&#xff0c;随着业务复杂度提升&#xff0c;如何优雅地拆分业务逻辑成为每个前端开发者必须掌握的技能。我经历过多个从简单到复杂的Vue项目&#xff0c;深刻体会到良好的逻辑拆分对项目可维护性的重要性。下面分享四种经过实战验证…

作者头像 李华
网站建设 2026/11/4 4:07:24

Python+pygame简单飞机射击游戏:主循环、对象池与二次开发

简介&#xff1a;面向j2ME初学者的简单飞机射击游戏代码包&#xff0c;围绕飞机主体与基础射击玩法展开&#xff0c;适合通过完整小项目理解移动游戏开发流程的学习者&#xff0c;可用于课程设计、自学练手或小型案例拆解。包内共200个文件&#xff0c;以138张png图像素材、22个…

作者头像 李华
网站建设 2026/11/4 4:07:20

现代数据代理架构设计与关键技术解析

1. 数据代理架构设计思路解析在2026年的技术环境下&#xff0c;数据代理系统已经演变为企业数据架构中的核心枢纽。不同于传统的数据管道&#xff0c;现代数据代理需要同时处理实时流数据、批量数据以及边缘设备产生的时序数据。我在金融和物联网行业实施过多个数据代理项目&am…

作者头像 李华