简介:这是一份面向初学者的 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 + pygame | Python 环境、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 章随便哪种方式都能跑起来,剩下的就交给你改。希望帮到你。
本文还有配套的精品资源,点击获取