简介:面向J2ME初学者与移动游戏开发爱好者的坦克大战游戏工程包,聚焦MIDP/CLDC平台上的2D游戏实现,可用于理解Canvas绘图、按键事件处理、线程刷新与碰撞检测等核心机制。压缩包共139个文件、约570KB,以66个class编译文件、21个java源码和32个html文档为主,另含jar、jad部署文件及png、gif图像和snd音频等游戏资源,class与java便于对照阅读,html为API说明,jad/jar可直接在模拟器或老款手机上运行。已有255人学习下载。读者可从中获取战场地图、玩家坦克、敌方坦克、资源管理等模块的完整代码结构,借助构建脚本与Javadoc快速定位游戏循环、状态切换和资源加载逻辑,并据此改写关卡、调整参数或移植到其他轻量Java设备,适合作为移动游戏入门与课程设计的参考工程。
1. 从今天重新翻出 J2ME 手机经典游戏说起
十几年前那批 J2ME 手机经典游戏,如今被重新放到桌面模拟器里运行,画面还是 176×208,操作手感也几乎没变。它们本质上就是 MIDP 规范下的 jar 包:一个继承MIDlet的入口类、一块Canvas、一个自己实现的游戏循环,再加几张 PNG 精灵图。这套代码能在堆内存只有几百 KB、主频几十兆赫兹的机器上把 20 帧跑稳,靠的全是精简出来的写法。放在今天,它有两类用途:一是复现和修改老游戏,二是当作资源受限环境下的极简引擎范本来研究。后面按编译打包、游戏循环、适配存档、检查问题、调优手感的顺序,把它拆成能照着复现的步骤。
2. 编译打包流程跑通:从源文件到 jar 加 jad
2.1 CLDC 与 MIDP 两套库,缺了预校验就跑不起来
J2ME 被拆成两层:CLDC 是配置层,管的是java.lang、java.io的一个精简子集,没有反射、没有浮点强依赖、没有java.util里的很多集合类;MIDP 是简表层,管的是javax.microedition.lcdui、javax.microedition.rms、javax.microedition.media这些界面、存储、多媒体的接口。写 J2ME 手机经典游戏,日常打交道的Canvas、Graphics、Sprite全在 MIDP 里。
麻烦在于编译。如果直接用桌面 JDK 跑javac,编译器会拿桌面rt.jar里的java.lang去校验你的代码,于是String.format、ArrayList、HashMap这些设备上根本不存在的东西照样能编过,装到模拟器里立刻报NoClassDefFoundError或NoSuchMethodError。正确做法是用-bootclasspath把类库整体换成 CLDC + MIDP 两份 API 文件,让编译器在一个“假装的设备环境”里校验代码。
第二道工序是预校验(preverify)。设备端的 KVM 为了节省体积和启动时间,自带类加载器的校验能力比桌面 JVM 弱得多,它要求 class 文件里预先写入栈映射信息。这一步由宿主侧的preverify工具完成,输出的是设备能接受的 class 格式。少了这一步,模拟器最常见的报错就是Verification error或者干脆“找不到可用的 MIDlet”。
提示:
-source 1.4 -target 1.4不是可选项。很多老设备的类加载器只认 48.0 版本的字节码,用 JDK 8 之后的版本编译还会直接告诉你 1.4 已不支持,实践中一般备一个 JDK 8 来做这条编译链。
2.2 命令行编译、预校验、打包的完整流程
先约定目录:lib/放两份 API 文件,src/放源码,classes/放编译产物,preverified/放预校验后的产物,dist/放最终交付。
# 目录约定与依赖声明,路径按自己机器调整 export CLDC_JAR=lib/cldc-api.jar export MIDP_JAR=lib/midp-api.jar mkdir -p classes preverified dist # 1) 编译:类库整体替换为设备 API,禁止误用桌面 JDK 的类 javac -source 1.4 -target 1.4 \ -bootclasspath "$CLDC_JAR:$MIDP_JAR" \ -classpath "$CLDC_JAR:$MIDP_JAR" \ -encoding UTF-8 \ -d classes \ $(find src -name '*.java')-bootclasspath是整条流程里最不能省的一项,它决定了“你以为能用的 API”和“设备真有的 API”是否一致。-d classes让包结构原样落到输出目录,后面打包时-C指过去即可,不会出现路径错乱。
# 2) 预校验:生成设备端类加载器需要的栈映射信息 preverify -classpath "$CLDC_JAR:$MIDP_JAR" \ -d preverified \ classespreverify的输入是classes这个根目录,它会自己递归找 class 文件;-classpath要能同时看到 CLDC 和 MIDP 的 API,否则父类解析不到会报cannot find class。输出目录必须和输入目录分开,覆盖写会留下校验失败的中间产物,后面很难排查。
# 3) 生成清单文件,最后一行必须有换行 cat > MANIFEST.MF <<'EOF' MIDlet-1: Demo, /icon.png, com.example.game.DemoMidlet MIDlet-Name: DemoGame MIDlet-Version: 1.0 MIDlet-Vendor: example MicroEdition-Configuration: CLDC-1.1 MicroEdition-Profile: MIDP-2.0 EOF # 4) 打包,注意打的是预校验后的目录而不是 classes jar cfm dist/demo.jar MANIFEST.MF -C preverified .MIDlet-1这一行的三段分别是显示名、图标路径、入口类全名,中间用英文逗号和空格分隔,写成中文逗号或者少个空格,都会导致安装器解析不出入口。MicroEdition-Profile写MIDP-2.0却用了setFullScreenMode这类 2.0 才有的方法,在 1.0 设备上会直接抛异常,写版本号时要跟代码实际用到的 API 对齐。
# 5) 生成 jad,并把它与 jar 的实际字节数对齐 JAR_SIZE=$(wc -c < dist/demo.jar | tr -d ' ') cat > dist/demo.jad <<EOF MIDlet-Name: DemoGame MIDlet-Version: 1.0 MIDlet-Vendor: example MIDlet-Jar-URL: demo.jar MIDlet-Jar-Size: $JAR_SIZE MIDlet-1: Demo, /icon.png, com.example.game.DemoMidlet MicroEdition-Configuration: CLDC-1.1 MicroEdition-Profile: MIDP-2.0 EOF2.3 jad 描述文件里哪几行最容易写错
jad 是 jar 的“说明书”,设备上安装器先读 jad 再决定要不要下载 jar。容易出问题的有三处:
一是MIDlet-Jar-Size与实际字节数不一致。手工改过代码重新打包却忘了更新这个值,安装器会报“文件大小不匹配”并拒绝安装;用上面的wc -c自动生成可以彻底规避。
二是清单文件末尾缺换行。jar工具解析 MANIFEST 时按行读,最后一行没有换行符时这一条属性会被悄悄丢弃,表现是模拟器里 MIDlet 列表为空、提示“没有可用的应用”。这个问题看着像打包工具的问题,实际上是文本编辑器自动去掉了文件末尾换行导致的,属于常见问题。
三是编码。清单和 jad 里的中文应用名在不同实现上表现不一,稳妥做法是 jad 与 MANIFEST 全部用 ASCII,显示名在代码里通过Display设置,别指望文件头里的 UTF-8 能被所有老设备正确读取。
注意:模拟器能跑起来,不代表真机能跑起来。安装器校验比模拟器严,jad 与 jar 的字段一致性、图标文件是否存在、图标是否为 PNG,这三项要逐条对照检查。
3. 用 Canvas 手写游戏循环:帧、按键与精灵
3.1 MIDlet 三个生命周期方法里各自该放什么
MIDlet的startApp、pauseApp、destroyApp是整局游戏的三个关键入口,放错代码是白屏和卡死的高发区。startApp会被调用多次——首次启动、从后台切回、来电结束后恢复,都会走它。所以这里必须做幂等处理:对象已存在就不要再 new,线程已启动就不要再起第二个。
package com.example.game; import javax.microedition.midlet.MIDlet; import javax.microedition.lcdui.Display; public class DemoMidlet extends MIDlet { private GameCanvas canvas; private Display display; protected void startApp() { display = Display.getDisplay(this); if (canvas == null) { canvas = new GameCanvas(); // 只创建一次 canvas.startLoop(); // 线程只起一次 } display.setCurrent(canvas); // 每次回到前台都重新设为当前屏 } protected void pauseApp() { if (canvas != null) { canvas.pauseLoop(); // 停游戏线程,别让它继续耗电和改状态 } } protected void destroyApp(boolean unconditional) { if (canvas != null) { canvas.stopLoop(); canvas = null; // 置空,下次 startApp 重新构建 } } }关键点是pauseApp里停线程、startApp里重设当前屏。很多老游戏切后台回来白屏,就是因为setCurrent只在构造函数里调用了一次,恢复时显示的还是个已被系统回收的Displayable。
3.2 主循环线程与按键状态机
Canvas自己实现Runnable,起一条独立线程跑循环,这是 J2ME 游戏的通行做法。paint由系统回调,tick由自己的线程驱动,两者通过一个volatile布尔量做开关。
import javax.microedition.lcdui.Canvas; import javax.microedition.lcdui.Graphics; public class GameCanvas extends Canvas implements Runnable { private static final int FRAME_MS = 40; // 约 25 帧,老设备留余量 private volatile boolean running; private volatile boolean paused; private Thread loop; public void startLoop() { running = true; paused = false; if (loop == null) { loop = new Thread(this); loop.start(); } } public void pauseLoop() { paused = true; } public void stopLoop() { running = false; loop = null; } public void run() { long next = System.currentTimeMillis(); while (running) { if (paused) { sleepQuietly(100); continue; } tick(); // 更新逻辑 repaint(); // 提交重绘请求 next += FRAME_MS; long wait = next - System.currentTimeMillis(); if (wait > 0) sleepQuietly(wait); else next = System.currentTimeMillis(); // 落后太多就重置基准,避免追帧 } } private void sleepQuietly(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } } }next += FRAME_MS这种累积写法和“每帧固定睡 40ms”不同,它把渲染耗时算进去了,长期平均帧率更稳。else分支的重置同样重要,如果某帧因为垃圾回收卡了 300ms,不重置基准的话接下来会连续补跑好几帧,手感会突然抽搐一下。
按键用状态机而不是事件回调,因为游戏需要知道“按住”,而不是“按下过一次”。
private final boolean[] keys = new boolean[16]; protected void keyPressed(int code) { int k = mapKey(code); if (k > 0 && k < keys.length) keys[k] = true; } protected void keyReleased(int code) { int k = mapKey(code); if (k > 0 && k < keys.length) keys[k] = false; } private int mapKey(int code) { int action = getGameAction(code); // 方向与确认键优先走标准动作码 if (action != 0) return action; if (code == KEY_NUM5) return FIRE; // 数字键补一个确认 return -1; }getGameAction是把机型相关的原始键码翻译成UP、DOWN、LEFT、RIGHT、FIRE的标准入口,不同品牌功能机的方向键键码并不相同,直接判断-1、-2这类原始值,换一台设备方向键就会失灵。
3.3 Sprite 与 LayerManager,以及像素级碰撞
MIDP 2.0 提供了Sprite和LayerManager,把逐帧drawImage手工计算坐标的活接了一部分过去。精灵图集按等宽等高切分,Sprite用帧序号索引,翻转用setTransform实现,比准备两套图省一半体积。
import javax.microedition.lcdui.Image; import javax.microedition.lcdui.game.Sprite; import javax.microedition.lcdui.game.LayerManager; private Sprite player; private LayerManager layers; private void initSprites() throws java.io.IOException { Image sheet = Image.createImage("/player.png"); // 图集,4 帧横排 player = new Sprite(sheet, 16, 16); player.defineReferencePixel(8, 15); // 锚点设在脚底中心 player.setRefPixelPosition(120, 200); layers = new LayerManager(); layers.append(player); layers.setViewWindow(0, 0, getWidth(), getHeight()); } private void drawScene(Graphics g) { layers.paint(g); // 按 append 顺序绘制 } // 水平翻转只改变换,不换图 private void faceLeft() { player.setTransform(Sprite.TRANS_MIRROR); } private void faceRight() { player.setTransform(Sprite.TRANS_NONE); }defineReferencePixel设成脚底中心,后面所有定位、碰撞都以这个点为准,比用左上角坐标好算得多。collidesWith(other, true)走逐像素判断,精确但慢,同屏精灵数量上到二十个以上就会明显掉帧,常规做法是先用包围盒粗筛一遍,只有相撞的少数对象才做像素级判断。
3.4 repaint 的调用节奏与双缓冲
repaint()只是向系统提交重绘请求,真正执行在系统回调的paint里。一帧里调十次repaint和调一次的效果基本一样,但每次都触发一次区域合并计算,纯属浪费。规矩是:一个循环周期只调一次repaint,所有状态更新都在tick里完成。
MIDP 2.0 的Canvas默认开启双缓冲,可以用isDoubleBuffered()查询;MIDP 1.0 的Canvas没有这个保证,画面会出现明显闪烁。兼容做法是自己维护一张离屏Image:
private Image buffer; private Graphics bufferG; protected void paint(Graphics g) { if (buffer == null) { buffer = Image.createImage(getWidth(), getHeight()); bufferG = buffer.getGraphics(); } render(bufferG); // 先全画到离屏图上 g.drawImage(buffer, 0, 0, Graphics.TOP | Graphics.LEFT); // 再一次性贴到屏幕 }离屏图只创建一次,放在成员变量里复用。每帧Image.createImage是 J2ME 上最典型的显存和堆内存两重浪费,老设备上连续创建几十张全屏图,很快就会堆碎片化并卡死。
4. 屏幕适配与 RMS 存档:同一份 jar 跨分辨率不翻车
4.1 分辨率映射:一套逻辑坐标加一次缩放
功能机时代的分辨率从 128×128 到 240×320 都有,写死像素坐标的代码换台机器就全乱。行业里通用的做法是内部按一套固定逻辑分辨率(比如 240×320)设计,绘制时统一缩放。
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 居中留边 | 逻辑画布居中,四周留黑边 | 实现最简单,不会变形 | 浪费屏幕面积,小屏上黑边明显 |
| 等比缩放 | g.scale(sx, sy)后整体绘制 | 铺满屏幕,代码改动小 | 非整数缩放有锯齿,坐标要反算 |
| 分区布局 | 按屏幕高度分背景区、游戏区、HUD 区 | 视觉最自然 | 每个场景都要单独适配 |
我一般先用居中留边把逻辑跑通,再对铺满需求单独做缩放。缩放时要注意Graphics.scale只影响后续绘制,不影响已经画上去的内容,而且按键坐标和精灵坐标都要做同样的逆变换,否则点哪都点不准。
private static final int VW = 240, VH = 320; // 逻辑分辨率 private int scaleX, scaleY, offX, offY; protected void sizeChanged(int w, int h) { scaleX = (w << 8) / VW; // 定点数,避免浮点运算 scaleY = (h << 8) / VH; offX = 0; offY = (h - (VH * scaleY >> 8)) / 2; } protected void paint(Graphics g) { g.translate(offX, offY); g.scale(scaleX / 256f, scaleY / 256f); // 仅 MIDP 2.0 支持浮点参数 renderScene(g); }sizeChanged是系统在屏幕尺寸或方向变化时回调的钩子,适配逻辑放这里最省事。用定点数存缩放系数是为了在老设备上避免浮点运算的开销,绘制前再转一次浮点。
4.2 RMS 存档的读写、容量与版本迁移
RecordStore是 J2ME 唯一的持久化手段,本质是一个按记录号索引的字节数组容器。三个高频错误:同一名字的 RecordStore 在同一时刻只能打开一次;用完必须closeRecordStore;记录号从 1 开始且不会因为删除而重排。
import javax.microedition.rms.RecordStore; import javax.microedition.rms.RecordStoreException; private static final String STORE_NAME = "save"; private static final byte VERSION = 2; public synchronized void save(byte[] body) throws RecordStoreException { byte[] payload = new byte[body.length + 1]; payload[0] = VERSION; // 头字节存版本号 System.arraycopy(body, 0, payload, 1, body.length); RecordStore rs = RecordStore.openRecordStore(STORE_NAME, true); try { if (rs.getNumRecords() == 0) { rs.addRecord(payload, 0, payload.length); } else { rs.setRecord(1, payload, 0, payload.length); // 固定用 1 号记录 } } finally { rs.closeRecordStore(); // 必须关,异常路径也不能漏 } } public synchronized byte[] load() throws RecordStoreException { RecordStore rs = RecordStore.openRecordStore(STORE_NAME, true); try { if (rs.getNumRecords() == 0) return null; byte[] raw = rs.getRecord(1); if (raw == null || raw.length < 2) return null; if (raw[0] != VERSION) return migrate(raw); // 旧版本走迁移分支 byte[] body = new byte[raw.length - 1]; System.arraycopy(raw, 1, body, 0, body.length); return body; } finally { rs.closeRecordStore(); } }固定只写 1 号记录,好处是记录数量恒定、读写耗时稳定。如果每存一次就addRecord,记录数会不断增长,不仅占用容量,getNumRecords和遍历也会变慢,最后触发RecordStoreFullException。版本号放在首字节,读档时先比对再解析,旧档用migrate补默认字段,比直接清档友好得多。
注意:RMS 的可用容量由设备决定,常见只有几十 KB。存档里别塞整张地图或音乐,只存进度、坐标、道具位图这类小数据,压缩后一般能压到几 KB 以内。
4.3 jar 体积与内存:混淆、资源格式和缓存策略
体积是 J2ME 的硬性限制。运营商和安装器对单文件大小有上限,具体数值各平台不同,但“越小越好”是共识。压缩手段按收益排序:资源图集化(多张小图合成一张)、PNG 索引色量化、去掉没用到的类、混淆。
混淆有个坑:MIDlet入口类的全名写在 jad 和 MANIFEST 里,混淆器一旦改了类名,安装器就找不到入口。配置里必须 keep 住这个类,以及所有被清单文件引用的名字。
内存侧最有效的三招是:图片对象全局只建一次;绘制时优先用drawRegion而不是先createImage再drawImage;别在paint里做字符串拼接,StringBuffer也尽量复用。
// 反面写法:每帧创建一次 Image,老设备上几秒就卡 for (int i = 0; i < bullets.size(); i++) { Image img = Image.createImage("/bullet.png"); // 每帧 new,必须避免 g.drawImage(img, x[i], y[i], Graphics.TOP | Graphics.LEFT); } // 正确写法:图集只加载一次,按帧索引绘制 g.drawRegion(bulletSheet, 0, 0, 8, 8, Sprite.TRANS_NONE, x[i], y[i], Graphics.TOP | Graphics.LEFT);drawRegion直接引用已有Image对象的子区域,不产生新对象,是控制堆增长最直接的手段。
5. 排查:复现 J2ME 老游戏时最容易出问题的 5 个点
现象一:模拟器里提示“没有可用的应用”,MIDlet 列表是空的。原因有两个方向:一是 class 没有预校验,设备端类加载器拒绝了它;二是 MANIFEST 里MIDlet-1的类名与 jar 内实际路径不一致,比如写了DemoMidlet但源码在com.example.game包下。解决方式是先解压 jar 看目录结构,确认com/example/game/DemoMidlet.class存在,再逐字比对清单里的全限定名;预校验缺失则重跑preverify,并确认打包打的是preverified目录。
现象二:模拟器能装能跑,真机安装器报“文件大小不匹配”。原因是 jad 里的MIDlet-Jar-Size与实际字节数不一致,通常是改完代码只重新打了 jar 忘了更新 jad。解决方式是每次打包后自动重算,把wc -c的结果写进 jad,不要手填。顺带检查 jad 与 jar 里的MIDlet-Name、MIDlet-Version是否一致,有的安装器会校验这两项。
现象三:游戏第一次跑正常,切到后台再回来白屏或者按键没反应。原因是startApp里没有重新setCurrent,或者游戏线程在pauseApp里没停、恢复时起了第二条线程导致状态互相覆盖。解决办法是把“创建对象”和“设为当前屏”分开,startApp里用canvas == null判断做幂等,pauseApp里只停线程不销毁对象,destroyApp里才置空。
现象四:存档时有时无,偶尔直接抛RecordStoreException。常见原因是同一个RecordStore名字被打开了两次没关,或者closeRecordStore只写在正常路径上,异常路径直接跳出。写法上必须用try/finally包住,把closeRecordStore放finally。另一个原因是每存一次就addRecord,记录数涨到几百条后容量耗尽,改成固定写 1 号记录即可。
现象五:帧率忽高忽低,跑一会儿卡一下。典型原因是paint里有对象分配,包括Image.createImage、字符串拼接、new int[],堆增长触发垃圾回收,几十毫秒的停顿在 25 帧的游戏里就是明显一卡。排查方法是在循环里打时间戳,记录每帧耗时并把超过 100ms 的帧打出来,对照代码看那一帧做了什么。修法是所有资源在初始化阶段一次性建好,绘制路径上零分配。
提示:这类问题的共同点是“模拟器上看不出来”。模拟器的内存和 CPU 都比真机宽裕,能跑通只代表逻辑没错,性能与安装兼容必须上真机或低配设备验证一遍。
6. 帧率与手感调优:把参数落到可验证的数字上
手感调优的核心不是调画面,是调时间步长。如果物理更新直接用“上一帧耗时”作为步长,帧率一波动,跳跃高度就跟着变,同一段操作在不同设备上表现完全不同,玩家会觉得“这游戏在空中飘”。固定步长加累积器的写法能把这个变量彻底消掉。
private static final long STEP_MS = 20; // 物理固定 50Hz private long acc; private void tick(long elapsed) { acc += elapsed; while (acc >= STEP_MS) { physics(STEP_MS); // 物理只吃固定步长 acc -= STEP_MS; } render(); // 渲染帧率可以和物理帧率解耦 }STEP_MS取 20 意味着物理每秒推进 50 次,和屏幕刷新多少帧无关。elapsed是两次tick之间的真实毫秒差,用System.currentTimeMillis()记录即可。累积器要设上限,单帧elapsed超过 200ms 时直接丢弃多余累积,否则窗口切换回来会瞬间快进一段。
几个手感参数的常用取值,按 240×320 逻辑分辨率、25 帧的基准整理:
| 参数 | 常用取值 | 调大了会怎样 | 调小了会怎样 |
|---|---|---|---|
| 跳跃初速度 | 每步 -7 像素 | 跳太高,镜头要跟着抬 | 跳不上平台,关卡过不去 |
| 重力加速度 | 每步 +0.5 像素 | 下落生硬,像石头 | 滞空感强,操作发飘 |
| 水平加速度 | 每步 ±0.4 像素 | 起步太冲,微调困难 | 转身迟钝,跑图拖沓 |
| 最大下落速度 | 每步 12 像素 | 可能穿墙,碰撞漏检 | 落得太慢,节奏松散 |
| 输入缓冲帧数 | 8 步(160ms) | 会出现“没按也跳” | 连跳容错差,手感硬 |
验证方式很朴素:在屏幕上叠一行调试文本,直接打当前帧率和物理步数累计值,跑三分钟看这两个数的波动范围。帧率稳定在 24~26 之间、物理步数与时间比值接近 50,就说明时间步长没问题了。剩下的是主观调参,我的习惯是每次只改一个参数,改完固定跑同一段关卡,用同一套操作序列对比,别一次改五个然后凭感觉说“好像好点了”。
踩过最坑的一次是给一个横版关卡的斜坡加碰撞,重力调小之后角色在斜坡上每帧下滑一点点,视觉上看不出来,但存档里的坐标一直在变,读档回到斜坡位置就会穿到地形下面。后来在所有地形碰撞之后加了一步“位置修正”,把角色顶回最近的合法格点,这个问题才不再出现。老平台上的很多“玄学”现象,追到底都是这类数值精度和更新顺序的问题,多打日志、少猜。
希望这些能帮到你。
本文还有配套的精品资源,点击获取