我虽然是90后,但是也很喜欢热血传奇2(以下简称“传奇”)这款游戏。
进入程序员行业后自己也对传奇客户端实现有所研究,现在将我的一些研究结果展示出来,如果大家有兴趣的话不妨与我交流。
项目我托管到codeplex上了,使用GPLv2开源协议。大家可以checkout代码出来看。
我现在将地图加载出来了,算是达到了里程碑1吧。

如果要将传奇的地图和资源文件详细解析可能我得写上几万字,不过我现在越来越懒了,就只将读取wix、wil、map文件的方法和它们的解析贴出来吧。
准备工作:
JDK7
Eclipse
注意:
阅读此篇文章后您将不需要再到网络上搜索传奇资源文件和地图文件解析,因为我的随笔绝对是最全最完整最详细的!但这可能需要您花费一些耐心。
第一部分——地图:
第一节——描述:
Q: Tile是什么?
A: Tile在中文是“瓷砖”、“块”的意思,具体到传奇地图中就是48*32屏幕像素大小的矩形区域。单个传奇地图就是由多个Tile构成的。
Q: map格式文件究竟存放了哪些信息?
A: map格式文件保存了一个完成地图的所有信息,但是对于当前Tile的图片只是保存了一个索引而不是把图片色彩数据保存下来。
Q: map格式文件怎样读取?
A: 对于文件读取以及对应到Java语言中的数据类型和数据结构我们要从两方面考虑。
一是map的数据内容:
map文件分为两部分。一个文件头标识了当前地图的高度、宽度等重要信息;剩余部分则是多个Tile的详细信息
二是map格式文件是由Object-Pascal(以下简称Delphi)语言序列化而成的,我们首先需要了解从Delphi序列化的数据到Java反序列化需要进行的操作。
以上内容表明了地图的信息,热血传奇中地图由Tile构成,每个Tile对应48*32屏幕像素大小。
.map文件则保存了地图的宽度、高度以及每个Tile的详细信息。
第二节——对应:
.map文件如果对应到编程语言中数据结构的话在Delphi中如下(文件头):
1 TMapHeader = packed record 2 wWidth: Word; 3 wHeight :Word; 4 sTitle :String[16]; 5 UpdateDate :TDateTime; 6 Reserved :array[0..22] of Char;
(Tile,两种都可以):
1 type 2 TMapInfo = packed record 3 wBkImg :Word; 4 wMidImg :Word; 5 wFrImg :Word; 6 btDoorIndex :Byte; 7 btDoorOffset :Byte; 8 btAniFrame :Byte; 9 btAniTick :Byte; 10 btArea :Byte; 11 btLight :Byte; 12 13 type 14 TMapInfo = packed record 15 wBigTileImg :Word; 16 wSmTileImg :Word; 17 wObjImg :Word; 18 btDoorIndex :Byte; 19 btDoorOffset :Byte; 20 btAniFrame :Byte; 21 btAniTick :Byte; 22 btObjFile :Byte; 23 btLight :Byte;
每个.map文件如果在Delphi中就成了一个TMapHeader加wWidth*wHeight个MapTile。
(对于每个字段占用的字节数请查看下面Java代码中注释)

由于我们是使用Java语言描述热血传奇地图,所以我针对上述两个数据结构使用Java语言进行了描述:
1 package org.coderecord.jmir.entt.internal; 2 3 import java.util.Date; 4 5 /** 6 * 热血传奇2地图文件头 7 *8 * 针对*.map文件的数据结构使用Java语言描述 9 *
10 * 地图文件头为52字节,在Pascal中定义为 11 *
12 * TMapHeader = packed record 13 *
14 * wWidth: Word; 15 *
16 * wHeight :Word; 17 *
18 * sTitle :String[16]; 19 *
20 * UpdateDate :TDateTime; 21 *
22 * Reserved :array[0..22] of Char; 23 * 24 *25 * wWidth 表示地图宽度(占用两个字节,相当于Java语言short;一般不超过1000) 26 *
27 * wHeight 表示地图高度(占用两个字节,相当于Java于洋short;一般不超过1000) 28 *
29 * sTitle 标题,静态单字符串(占用17个字节,首字节为字符串已使用的长度即已存放的字符数,一般为“Legend of mir”) 30 *
31 * UpdateDate 地图最后更新时间(占用8个字节,为TDateTime类型,可使用{@link org.coderecord.jmir.kits.Pascal#readDate(byte[], int, boolean) readDate} 转换为java.util.Date) 32 *
33 * Reserved 保留字符,固定为23字节 34 * 35 * 36 * @author ShawRyan 37 * 38 */ 39 public class MapHeader { 40 41 /** 地图宽度(横向长度) */ 42 private short width; 43 /** 地图高度(纵向长度) */ 44 private short height; 45 /** 标题 */ 46 private String title; 47 /** 更新日期 */ 48 private Date updateDate; 49 /** 保留字符 */ 50 private char[] reserved; 51 52 /** 默认构造函数 */ 53 public MapHeader() {} 54 /** 带全部参数的构造函数 */ 55 public MapHeader(short width, short height, String title, Date updateDate, char[] reserved) { 56 this.width = width; 57 this.height = height; 58 this.title = title; 59 this.updateDate = updateDate; 60 this.reserved = reserved; 61 } 62 /** 使用已有对象构造实例 */ 63 public MapHeader(MapHeader mapHeader) { 64 this.width = mapHeader.getWidth(); 65 this.height = mapHeader.getHeight(); 66 this.title = mapHeader.getTitle(); 67 this.updateDate = mapHeader.getUpdateDate(); 68 this.reserved = mapHeader.getReserved(); 69 } 70 71 /** 获取地图宽度(横向长度) */ 72 public short getWidth() { 73 return width; 74 } 75 /** 设置地图宽度(横向长度) */ 76 public void setWidth(short width) { 77 this.width = width; 78 } 79 /** 获取地图高度(纵向长度) */ 80 public short getHeight() { 81 return height; 82 } 83 /** 设置地图高度(纵向长度) */ 84 public void setHeight(short height) { 85 this.height = height; 86 } 87 /** 获取标题 */ 88 public String getTitle() { 89 return title; 90 } 91 /** 设置标题 */ 92 public void setTitle(String title) { 93 this.title = title; 94 } 95 /** 获取更新时间 */ 96 public Date getUpdateDate() { 97 return updateDate; 98 } 99 /** 设置更新时间 */ 100 public void setUpdateDate(Date updateDate) { 101 this.updateDate = updateDate; 102 } 103 /** 获取保留字符 */ 104 public char[] getReserved() { 105 return reserved; 106 } 107 /** 设置保留字符 */ 108 public void setReserved(char[] reserved) { 109 this.reserved = reserved; 110 } 111 }
(Tile我使用了两种描述方式,后一种用于生产环境更加优秀):
1 package org.coderecord.jmir.entt.internal; 2 3 /** 4 * 热血传奇2地图“块” 5 *
6 * 即 “逻辑坐标”点(人物/NPC等放置需要占用一个逻辑坐标点) 7 *
8 * 需要注意的是逻辑坐标和屏幕坐标是不一样的,屏幕坐标一般为像素值,根据显示器分辨率设置而有所不同 9 *
10 * 热血传奇2中一个逻辑坐标点(地图块)需要占用 48 * 32 屏幕坐标大小 11 *
12 * 每个地图块为2层结构,包括‘地’和‘空’ 13 * 例如树叶投影下的地图块就是2层,包括地表及物体(如有突起石头的地面或有水流的地面)和树叶 14 *15 * 在Pascal语言中使用以下数据结构对地图块进行描述和存储(两种) 16 *
17 * type 18 *
19 * TMapInfo = packed record 20 *
21 * wBkImg :Word; 22 *
23 * wMidImg :Word; 24 *
25 * wFrImg :Word; 26 *
27 * btDoorIndex :Byte; 28 *
29 * btDoorOffset :Byte; 30 *
31 * btAniFrame :Byte; 32 *
33 * btAniTick :Byte; 34 *
35 * btArea :Byte; 36 *
37 * btLight :Byte; 38 * 39 *40 * type 41 *
42 * TMapInfo = packed record 43 *
44 * wBigTileImg :Word; 45 *
46 * wSmTileImg :Word; 47 *
48 * wObjImg :Word; 49 *
50 * btDoorIndex :Byte; 51 *
52 * btDoorOffset :Byte; 53 *
54 * btAniFrame :Byte; 55 *
56 * btAniTick :Byte; 57 *
58 * btObjFile :Byte; 59 *
60 * btLight :Byte; 61 * 62 *63 * wBkImg或wBigTileImg 表示地图地表图片,如果最高位为1则表示不能通过(或站立),如河水型地表等。在判断是否可以飞过(从空中通过)时则不需要考虑 64 *
65 * wMidImg或wSmTileImg 表示地图可视物体图片(有时被称为可视数据/中间层/小地图块/地图补充背景等等),如果wBkImg(或wBigTileImg)没有铺满则使用此地图块进行铺垫。最高位不作为判断依据,不过图片索引一般小于0x8000,即最高位一般为0。例如在某地图中第一个地图块的wBkImg(或wBigTileImg)大小为96 * 64,则代表该地图左上角4个块儿的地表都不为空,此时紧邻的三个地图块都可以不用设置wBkImg(或wBigTileImg)和wMidImg(或wSmTileImg);如果某个地图块的没有被其他块儿的wBkImg(或wBigTileImg)铺满,自己也没有wBkImg(或wBigTileImg),那么它就需要一个wMidImg(或wSmTileImg)进行铺垫。值得一提的是并不一定在有了wMidImg(或wBigTileImg)后就不需要绘制此层图片了 66 *
67 * wFrImg或wObjImg 表示表层图片(对象),即空中遮挡物,如植物或建筑物,如果最高位为1则表示不能通过(或站立)。在判断是否可飞过(从空中通过)时需要作为唯一条件判断,在判断是否可以徒步通过或站立时需要联合wBkImg进行判断 68 *
69 * 总的来说,地图一般为两层(只是针对上面的三个属性,下方的也属于地图部分,不过先不纳入考虑),包括背景层与对象层,背景层为wBkImg(或wBigTileImg)和wMidImg(或wSmTileImg)的集合,一般来说wBkImg就能搞定,也有时候需要两者都有;Spirit(人物/怪物/NPC/掉落物品等)在两层中间;索引从1开始,所以在从资源中真正取图片时应该减1(适用于所有资源索引);索引一般最高位为0,为1一般表示特殊情况(在Java语言中可以理解为大于0,因为首位为1表示负数) 70 *
71 * btDoorIndex 门索引,最高位为1表示有门,为0表示没有门。 72 *
73 * btDoorOffset 门偏移,最高位为1表示门打开了,为0表示门为关闭状态 74 *
75 * btAniFrame 帧数,指示当前地图块动态内容由多少张静态图片轮询播放,需要和btAniTick一起起作用;如果最高位为1(即值大于0x80,或者在Java中为小于0的数值)则表示有动态内容 76 *
77 * btAniTick 跳帧数,指示当前地图块动态内容应该每隔多少帧变换当前显示的静态图片,需要和btAniFrame一起作用 78 *
79 * btAniFrame和btAniTick作用时表达式如下index = (gAniCount % (btAniFrame * (1 + btAniTick))) / (1 + btAniTick) 80 *
81 * 其中gAniCount是当前画面帧是第几帧,它会在每次绘制游戏界面时累加,它可以有最大值,超过可以置0;index是相对当前objImgIdx的偏移量,比如当前对象层图片索引为1,而AniFrame为10,则表示从1到11这10副图片应该作为一动态内容播放(有待考证) 82 * 83 *
84 * btArea或btObjFile 表示当前wFrImg(或wObjImg)和动态内容构成图片来自哪个Object资源文件,具体为Object{btArea}.wil中,如果btArea为0则是Objects.wil 85 *
86 * btLight 亮度,一般为0/1/4 87 * 88 * @author ShawRyan 89 * 90 */ 91 public class MapTile { 92 93 /** 背景图索引 */ 94 private short bngImgIdx; 95 /** 补充背景图索引 */ 96 private short midImgIdx; 97 /** 对象图索引 */ 98 private short objImgIdx; 99 /** 门索引 */ 100 private byte doorIdx; 101 /** 门偏移 */ 102 private byte doorOffset; 103 /** 动画帧数 */ 104 private byte aniFrame; 105 /** 动画跳帧数 */ 106 private byte aniTick; 107 /** 资源文件索引 */ 108 private byte objFileIdx; 109 /** 亮度 */ 110 private byte light; 111 112 /** 默认构造函数 */ 113 public MapTile() { } 114 /** 使用已有对象构造实例 */ 115 public MapTile(MapTile mapTile) { 116 this.bngImgIdx = mapTile.bngImgIdx; 117 this.midImgIdx = mapTile.midImgIdx; 118 this.objImgIdx = mapTile.objImgIdx; 119 this.doorIdx = mapTile.doorIdx; 120 this.doorOffset = mapTile.doorOffset; 121 this.aniFrame = mapTile.aniFrame; 122 this.aniTick = mapTile.aniTick; 123 this.objFileIdx = mapTile.objFileIdx; 124 this.light = mapTile.light; 125 } 126 /** 带全部参数的构造函数 */ 127 public MapTile(short bngImgIdx, short midImgIdx, short objImgIdx, byte doorIdx, byte doorOffset, byte aniFrame, byte aniTick, byte objFileIdx, byte light) { 128 this.bngImgIdx = bngImgIdx; 129 this.midImgIdx = midImgIdx; 130 this.objImgIdx = objImgIdx; 131 this.doorIdx = doorIdx; 132 this.doorOffset = doorOffset; 133 this.aniFrame = aniFrame; 134 this.aniTick = aniTick; 135 this.objFileIdx = objFileIdx; 136 this.light = light; 137 } 138 139 /** 获取背景图索引 */ 140 public short getBngImgIdx() { 141 return bngImgIdx; 142 } 143 /** 设置背景图索引 */ 144 public void setBngImgIdx(short bngImgIdx) { 145 this.bngImgIdx = bngImgIdx; 146 } 147 /** 获取补充图索引 */ 148 public short getMidImgIdx() { 149 return midImgIdx; 150 } 151 /** 设置补充图索引 */ 152 public void setMidImgIdx(short midImgIdx) { 153 this.midImgIdx = midImgIdx; 154 } 155 /** 获取对象图索引 */ 156 public short getObjImgIdx() { 157 return objImgIdx; 158 } 159 /** 设置对象图索引 */ 160 public void setObjImgIdx(short objImgIdx) { 161 this.objImgIdx = objImgIdx; 162 } 163 /** 获取门索引 */ 164 public byte getDoorIdx() { 165 return doorIdx; 166 } 167 /** 设置门索引 */ 168 public void setDoorIdx(byte doorIdx) { 169 this.doorIdx = doorIdx; 170 } 171 /** 获取门偏移 */ 172 public byte getDoorOffset() { 173 return doorOffset; 174 } 175 /** 设置门偏移 */ 176 public void setDoorOffset(byte doorOffset) { 177 this.doorOffset = doorOffset; 178 } 179 /** 获取动画帧数 */ 180 public byte getAniF