背景介绍
暑假闲来无事,我在家中无意翻找到了自己使用的第一部触屏手机,是一个Samsung Anycall手机,由于电池老化的原因,我无法再次打开这个手机了。十年前我还是个小学生,智能手机并不普及,对于这第一个触屏手机也是非常有新鲜感,什么都想玩一下。其中最为印象深刻的就是一个跳棋游戏,主人公需要和6个npc对战中国跳棋,最终通关游戏。由于是将近二十年前开发的老游戏,在互联网上完全无法查到游戏开发商的任何信息,这个游戏也不可能在现代的机器上运行了,想到这个,我有些心血来潮,想让这个童年吸引我的小游戏重新焕发一次生命力。
查找游戏资源
我只记得这个游戏叫做《中国跳棋》,并且是十年前的Samsung手机内置的,于是在bilibili和抖音都搜索了一下,果然搜出了几个视频,都是展示在十几年前的手机上玩这个游戏的。从视频中我回忆起了一些关于这个游戏的具体信息,在互联网上进行了查找。不久我就在一个论坛(DOSPY论坛)上找到了这个游戏的相关资源:【稀有资源】中 国 跳 棋,游戏的形式是一个jar包。
使用模拟器运行J2ME游戏
十几年前按键手机和部分触屏手机上的很多游戏都是使用J2ME开发的,维基百科对于J2ME的介绍为:
Java ME以往称作J2ME(Java Platform, Micro Edition)是为机顶盒、移动电话和PDA之类嵌入式消费电子设备提供的Java语言平台,包括虚拟机和一系列标准化的Java API。它和Java SE、Java EE一起构成Java技术的三大版本,并且同样是通过JCP(Java Community Process)制订的。
J2ME是一个过时的框架,使用其开发的游戏自然无法在现代的操作系统上运行。因此,需要使用模拟器运行游戏的jar包。手机端可以选择J2ME Loader,电脑端可以选择microemulator。
我使用microemulator运行原版游戏:
解包游戏并反编译
从DOSPY论坛上下载的jar包其实就是一个zip压缩文件,在Linux中,可通过unzip命令解压缩到指定目录extracted:
unzip your-file.jar -d extracted/
进入extracted目录,可看到包含Java的.class文件和资源文件(图片、音频)
与C/C++程序不同的是,Java编译出的.class字节码非常容易被反编译为Java代码,所以通常开发者会对代码进行混淆,使得其可读性下降,难以被破解。
我使用CFR工具反编译字节码,当然也有很多其他的工具。
CFR工具本身也是一个jar文件,反编译单个字节码文件:
java -jar cfr.jar target.jar > target.java
在Linux中,使用find命令,将extracted目录下所有的.class文件都反编译为.java文件:
find extracted/ -name "*.class" -exec java -jar cfr.jar {} --outputdir extracted \;
阅读源代码
幸运的是,本游戏的代码并没有过度混淆,不幸的是,即使反编译为Java源代码,我也不可能在我的机器上运行这些代码,只能阅读代码,同时借助Copilot加速理解代码。
虽然代码的结构清晰,但是由于代码量较大,我花了整整两天时间理解整个游戏的代码逻辑,由于篇幅限制,只能说几个较为重要的发现。
整型常量被大量使用
代码中整型常量被大量使用,例如:
public class BoardView
implements XTimerListener,
Viewable {
public static final int GAMEREADY = 0;
public static final int SELECTDIA = 1;
public static final int MOVEDIA = 2;
public static final int MOVINGDIA = 3;
public static final int COMTHINK = 4;
public static final int COMBO_MSG = 5;
public static final int TIMEOUT_MSG = 6;
public static final int CHANGETURN = 7;
public static final int GAMEOVER = 8;
public static final int VIEWRESULT = 9;
public static final int NEXTROUND = 10;
public static final int NEXTSTAGE = 11;
public static final int READYTALK = 12;
public static final int RETURNVSMENU = 13;
public static final int GAMEFAILED = 14;
public static final int COMMOVEDIA = 15;
public static final int HOMEIN = 16;
//...
}
Java的枚举类型是在Java5引入的特性,在当时的Java2中还不存在。开发者具有良好的编程习惯,将这些枚举值都使用常量代替,避免了Magic Number。然而,Java编译器直接将这些常量编译成了整数,反编译后,并未保留常量的变量名,使得代码难以理解,例如:
if (this.state == 12) {
// do something
} else if (this.state == 0) {
// do something
} else if (this.state == 1) {
// do something
}
如果没有联系上述常量的定义,确实难以理解这些分支的语义,因此,我在阅读这些代码时,还原了这些常量,使得代码更易于理解。
适配不同的屏幕大小
即使是二十年前的代码,也对不同型号的手机屏幕进行了适配:
public static void checkScreenSize() {
if (totalWidth > 118 && totalWidth < 122) {
lcdSize = 3;
} else if (totalWidth > 174 && totalWidth < 178) {
lcdSize = 2;
} else if (totalWidth > 238 && totalWidth < 242) {
lcdSize = totalHeight > 180 ? 1 : 2;
} else if (totalWidth > 319 && totalWidth < 321) {
lcdSize = 4;
}
}
public static boolean isQVGA() {
return lcdSize == 1 || lcdSize == 4;
}
我专门查了一下QVGA屏幕的含义:
QVGA images or videos are 320 pixels wide and 240 pixels tall (320 x 240 pixels). The name Quarter VGA is written as QVGA and the resolution is four times smaller than VGA resolution (640 x 480 pixels).
存在性能优化的代码写法
int computeMoveGuide(int n) {
byte by = this.dia[n].posx;
byte by2 = this.dia[n].posy;
int n2 = 0;
if (this.moveCnt > 0 && this.jumpMove == 0) {
this.possibleDirCnt = 0;
for (int i = 0; i < 6; ++i) {
this.moveGuide[i] = 0;
}
return 0;
}
for (int i = 0; i < 6; ++i) {
this.moveGuide[i] = 0;
this.moveGuide[i] = this.diaBoard.checkBoard(this.index, by + Resource.hInc[i], by2 + Resource.vInc[i]);
if (this.moveGuide[i] == 2) {
this.moveGuide[i] = this.diaBoard.checkBoard(this.index, by + Resource.hInc[i] * 2, by2 + Resource.vInc[i] * 2);
this.moveGuide[i] = this.moveGuide[i] == 1 ? 2 : 0;
}
if (this.jumpMove > 0 && this.moveGuide[i] == 1) {
this.moveGuide[i] = 0;
}
if (this.moveGuide[i] == 0) continue;
++n2;
}
this.possibleDirCnt = n2;
return n2;
}
在computeMoveGuide的for循环中,使用n2这个局部变量进行累加计算,最终将n2的值赋值给this.possibleDirCnt,而并没有将this.possibleDirCnt直接进行累加。this.possibleDirCnt是一个类的成员变量,存储在堆内存内,而n2是一个局部变量,可以被放在寄存器中,操作n2只需要读写寄存器,比读写堆内存快得多,这对于十几年前的手机来说应该是有性能的提升。
使用libGDX重写游戏代码
经过一个上午的了解,我选择了libGDX框架,对J2ME游戏进行移植,主要原因有几点:
-
编程语言相同:libGDX和J2ME都是使用Java开发,游戏核心算法部分不需要任何改动,只需要修改各种调用的库即可。
-
跨平台:不仅Java语言本身跨平台,libGDX框架也是跨平台的,支持桌面端(Windows, MacOS, Linux)和移动端(Android, iOS)
-
运行高效:libGDX本身对性能做了一定的优化。
具体的游戏移植过程技术难度并不高,只是比较繁琐,需要同时关注移植前后接口的变化,主要包括:
-
绘制场景的框架不同:J2ME使用
javax.microedition.lcdui.Graphics绘制图像,而libGDX使用SpriteBatch和Stage绘制图像和UI,同时,二者的坐标系统完全不同,需要进行坐标变换。 -
动画播放的框架不同:J2ME使用逐帧绘制的方式绘制动画,每个动画需要使用多个状态成员来记录动画播放的阶段,代码量较大且难以理解,而libGDX使用
Action来定义和绘制动画,内置了很多基本动画,编码较为简单可读。 -
响应事件的逻辑不通:J2ME使用了大量嵌套分支语句对于用户的按键编写对于用户按键的响应逻辑,而libGDX可以使用类似回调函数的方式定义事件的响应逻辑。
-
播放音效的框架不同。
移植代码仓库
现阶段已经完成了故事模式6关的核心部分,已经可以和20年前的AI完跳棋了,与原版游戏对比如图:
其中左侧为原版,右侧为移植版(可以看到较为简陋)
视频演示在Bilibili:
我将代码开源在了Github和Gitee,持续更新中:
欢迎共同交流学习!