::金点时空::

圣二源程序导读

先声明:我不是本程序的作者(他们是soft,sea_bug),我只是负责后期修正程序工作的。不过,如果你想深入研究圣二的源代码的话,读读本文会对你有很大的帮助的。下面是我用了两个星期熟悉圣二所有的源代码的心得。
我拿到手的源代码里面是有不少的注释的,而且在你看到的源代码里面,我还加入了不少的注释,相信你能够一行一行的看懂的。我这里只是给你一个大概的轮廓的概念。
好了进入正题。
最前面说说圣二源代码的组织结构:
\Fight\ 下面放的是战斗部分的程序
\MENU\ 菜单操作部分的程序
\GAMELIB\ 封装了DX的游戏库
\interface\ 游戏的界面:button,checkbox,window,scroll,procee,listwindow等
\MAPEDIT\ 地图编辑器的相关函数
下面看看圣二的整体结构:标准的win32程序风格
先初始化和游戏相关的数据,Ddraw,Dsound,Dmusic,Dinput然后开始消息循环,由一个叫RunGame的变量做为循环的条件,Peek方式,有消息的时候处理消息,在空闲的时候进行游戏主循环。主循环里面先进行延时控制,然后获取输入信息,再根据游戏状态(g_ePlayState)进行不同的处理,这些处理是放到不同的函数里面的,然后得到游戏的一些数据,再根据不同的游戏状态进行屏幕的更新,最后是一个被称为消息队列的东西执行它的一个叫做Run的函数。这个消息队列是由程序来维护的,不是windows的消息队列。设计这样一个消息队列是用来配合脚本的执行。从游戏主循环返回了后,又进行相同的循环,直到RunGame为false为止。这点是由程序控制的,正常情况下GetMessage是不可能得到WM_QUIT消息的。
上面就是整个程序的结构。
下面我们一个类一个类的分析。
先看最为基本的类CIniSet类,这个类贯穿整个程序,它负责游戏重要数据的组织。提供了对INI文件操作的支持。基本的用法是根据一个文件名filename一个索引名index一个数据名得到这个数据的具体的内容。这个类先根据给出的文件名建立一个索引列表IndexList
以后的操作都可以根据这个列表来进行,根据索引列表找到索引位置,再从这个位置开始搜索与给定数据名相同的字符串,然后返回其内容的位置,根据要求按照int或者是string进行处理。没有什么要多说的,程序里面有详细的注释,容易忽略的地方我都写了很多。
然后是Cmap类,不用说了地图类。想要完全看懂这个里面的函数,先要了解圣二的地图结构。圣二使用的是三层的地图,最下面是地面层(Ground),然后是两个物体层:物体层一(Obj)和物体层二(Obj2),地图的每个格子由一个叫stCell的结构描述,这个结构放在Map.h里面。它定义了地图格子的所有的属性:地面层,物体层1,物体层2的图片所在位置,它们的层次关系(这不是说它们之间的层次关系,而是和地图上的人物的层次对比关系),这个格子的阻挡属性,鼠标放上去的时候是显示什么样子的图形和这个格子的陷阱属性(就是人物走到这个位置将要发生的事件)。这个结构的定义使用了位段(bit field)的方式存放数据,当然是为了节省内存。(哇,好厉害的soft哦。我对你的佩服有如涛涛江水,绵绵不绝:))
好了,不多说废话了,看看stCell的样子吧。


先解释下什么是页面,什么是编号。地图的图片是放到一些叫TILE的bmp(pic\tiles)中的,程序在运行的时候会把他们放到一个表面数组(*lpDDSMap)里面,然后把GroundPic这个做为数组下标访问数组得到这个地面元素所在的表面,然后根据Ground算出地面元素在这个表面的矩形位置。明白了吗?总结下,GroundPic是用来寻找表面的,而Ground是用来寻找矩形位置的。而且,所有的层次都是这样处理的。那么动态图片是怎么回事情呢?其实也是由静态元素按照一定的顺序组合来的,程序里面多了一层处理,你在显示地面的时候,只需要根据地面的TILE位置算出当前的动态地面的图片在哪个表面的什么位置,而图片动态显示的时候的更新是有专门的函数处理的。这样可以一次更新所有的动态图片的桢和记数。说到这儿就差不多了,看看程序里面的注释吧。
下面说说地图的显示。这儿的显示是要包括地图上面的人物的。怎么才能显示人物把建筑遮住和建筑把人物遮住的不同状态呢?这个问题就牵涉到层次问题。在地图结构中有两个层次相关的成员,就要靠它们来做到了。在地图编辑的时候会设置好两个层次变量的值。在显示地图的时候,先遍历需要显示的地图的范围里(为什么会有这种说法呢?因为有的时候不是会显示整个屏幕的地图的,比如那个黑暗的小村子)的格子,先画好地面层(这层注定是要被遮住的),然后检查那分别检查两个物体层的两个层次变量,如果和人物同级(OL_NORMAL)的话就按照物体层1然后是物体层2的顺序画好(看,还是有层次的哦,2在1的上面)。然后将地图上的NPC按照他们位置的Y坐标排序,当然是让下面的NPC在显示的时候不会被上面的NPC给遮住。对了,还有我们的主角呢?是在显示NPC的循环中同样按照Y的顺序调整主角和NPC的显示顺序,然后就又是遍历两个物体层,按照1,2的顺序画能遮住(OL_UP)主角的物体。地图的显示也就差不多了,只不过在显示的前面会根据一个变量来决定是否让人物移动,在显示的末尾可能会显示地图的名字,还有就是要Alpha显示人物,来产生人物被物体遮挡时候的阴影效果,这个到了后面我和Alpha混合一起讲好了。CMap类的其他函数就比较的简单了,看程序里面的注释好了。
下面我们说说CRole类。这个类是用来表示主角的,而表示NPC的类也是从这个类继承来的。这个类有个比较关键的stRoleState结构变量State,里面的x,y(小写)表示的是人物的脚底的中心点。说清楚点,这个x对应于人物图片上的两个脚的中点,y的位置是从上向下看的脚开始的坐标。明白了这个再来看那个叫ShowRole的函数就好理解了。而X,Y(大写)表示的是人物在地图上的格子坐标,他们通过4个宏来联系。好了,现在看看几个关键函数,先看这个用来寻路的MoveTo函数,两个参数表示目标点的坐标,和State里面的小x是一个级数的,是相对地图左上角的,注意是地图的左上角,不是屏幕的左上角,而且它的增量是一个象素,而大X是以32个象素为一个增量的。这个函数先进行边界检查,然后把目的点转化成地图的格子位置,根据人物的状态算出人物的本次寻路的起点位置,接着就是寻路了,用的是A*算法(等到后面和Alpha一起说),然后把算好的路径放到一个数组中。这个函数就结束了,其实里面还有些问题,等到最后我一起说。下面看看这个Move函数,这个函数是在每次循环中执行一次的。这个函数的前面部分是判断是否是到了目标点,或者是路径的下一个点,然后做出一系列的处理,最后是卷动地图。这个部分我在soft的注释上加了很多的注释,自己看好了。最后一个要说的函数是ShowRole,用来画主角的,关键是显示的坐标计算,脚底中心x坐标向左半个人宽就是显示图片在地图上的位置的x坐标,脚底中心y坐标向上一个人高,再向下一个脚的高度(放在一个叫脚底碰撞矩形里面的)就是显示图片在地图上的位置的y坐标。算好了坐标就开始show图了,显示好人物然后还要显示个影子,影子坐标有个偏移,要y坐标缩减一点点,因为影子有部分是在脚下的。
下面我们看看这个支持脚本的类CScript类 。他其实可以算是一个小的解释器,对脚本文件解释,然后执行其命令。几个关键的函数:一个ReadCommand用来读一条指令,一个GetCommandName得到一个指令的名字,Run 和RunCommand就是根据刚刚的指令名字和一系列的获取参数的函数解释执行的函数。这些函数都有详细的注释。指令分成了三种:一种是从本脚本转移的指令,一种是直接可以执行的指令,另外一种是配合消息队列的。这部分我都写了很多的注释,自己看好了。
当然要看看CMessage类,简单,根据不同的消息调用不同的函数而已,没有什么说的,看注释。
下一个要说的是战斗程序,这个部分有点麻烦,慢慢的来。主要的部分放在一个叫LOOP的函数里面。显然这也是也放到一个循环里面的。一个叫的变量控制着当前的状态(比如现在是主角打敌人,还是主角被敌人打等等)。而攻击的是怎么做到的呢?原来有两个叫Command 和Enemy_Command的数组保存了指令,比如是攻击还是使用物品。当然还有相应的两个变量command_count和enemy_count控制着当前是哪个主角或者是敌人在行动。那我们结合实际的情况看看。先是进入战斗场景,main_count的值是MAIN,而command_count 指向排在最前面的主角,相应的enemy_count也指向排到最前面的敌人。现在是玩家选择进攻还是使用物品,要是选择了进攻或是使用物品,那么main_count的值就会根据敌人的数目变成KILL_WHO(多个敌人,进入选择要攻击的敌人模式)或者是USER_WHO(多个主角,选择使用物品的主角)或者是继续保持MAIN状态(只有一个敌人或者一个主角)并且把这个指令放到那个Command数组中,然后将控制权转移给下一个主角,如果没有下一个主角了就把main_count的值变成ROLE_KILL_ENEMY,然后就退出LOOP函数,这是一次循环,下一次循环来到的时候又进入了LOOP函数,先说KILL_WHO模式(USER_WHO是一样),就是选择敌人,注意这里,main_count的值是一直不变的除非你选好了敌人,而且在你选敌人的时候也不是一直都在LOOP函数里面的,是不断的在执行相同的代码的,进去了又出来。直到你选好了敌人,main_count的变化就和刚刚的一样了,要么是MAIN(多个主角)要么是ROLE_KILL_ENEMY,并且本次行动也保存到了Command数组里面了。这样循环的进行,直到每个主角都选好了动作。也就是main_count的值是ROLE_KILL_ENEMY的时候了,这时就遍历主角依次执行刚刚放到Command里面的命令。每个主角的命令都执行完了之后就把main_count变成ENEMY_KILL_ROLE,这个就比较的简单了和上面的ROLE_KILL_ENEMY差不多的。这个完了之后有将main_count变成MAIN同时要修正两个标志控制的当前主角和排在最前面的敌人的变量command_count和enemy_count,接着重复上面的步骤,直到敌人全部被杀死,或者主角全部被杀死,或者在MAIN状态的时候你选择了逃跑,这时main_count变成OVER,并设置好相应的变量然后退出LOOP函数,而在循环调用LOOP的时候是会去检查LOOP的返回值的,直到检查到是几个特定的值的时候就会终止循环,而只有当main_count变成OVER的时候才可能(注意,因为逃跑也有不成功的时候)返回那几个特定的值。然后就根据结果设置好相应的全局变量退出战斗部分,回到原来的状态。当然其中还有很多的其他的事情要做,比如是可以在KILL_WHO和USER_WHO的时候回到MAIN状态的处理等等的,这些都比较的简单,看程序里面的注释好了,多点耐心哦,这个LOOP函数很长哦,有900多行。^--^

NOTE:下面的部分是额外的部分,写给不懂的人看的,高手就不用继续了。

好了,来说说A*寻路和Alpha混合,相信大家都读过了圣二的README吧,这两个可是里面有提的哦,快看看是怎么做的先。(还先?!现在了才拿出来说!--:)
先说A*算法。
A*算法是用来在游戏中寻路的,它能计算出两点之间最短的距离,而且很快。是怎么会事情呢?A*算法在找两个顶点的最短距离的时候不是象深度优先搜索那样要遍历每一个子节点,而是寻找一个最优的点,然后直接从这个点开始新的搜索。那么是怎么去找这个最优点呢?让一个叫评估函数的来算出这个点的可能最好值,然后加上这个点的深度(当然不一定是深度了,凡是可以表示从开始点到这个点所花的代价的值都可以的)就是这个点的价值了,根据这个价值把所以的子节点排序,找个最小的就是最优点了。但是可能这个时候的最优并不一定是全局的最优,所以用了一个表(open)来保存子节点,一个表(close)来保持访问过的节点。那么整个算法看起来就像是这样的:
先用一个变量保存离目的点最近的点,称为最近点。
1. 将第一个节点(根)放到open表中
2. 从open表从取一个最优点,如果为空就跳出循环到7
3. 把取出来的点放到close表中,并按照他到他的父节点的方式拉好指针,比较这个点和最近点,更新最近点。
4. 探索取出来的点的子节点
5. 按照价值的顺序从前向后插入到open表里面(这个里面有些点可以明显的丢弃)
6. 比较取出来的点是不是目标点,如果不是就跳到2,否则到7
7. 从最近点按照拉好的指针在close表里面找到路径
好了,明白了?看看源代码的注释吧。

接着我们说Alpha混合。
要是不知道什么是Alpha混合的话,就先去看看什么是Alpha混合吧。
我们的程序是运行在16位模式下的,当然少不了555和565的问题了,当然这个比较的容易判断了,我们设置好一些变量后就可以继续了(主要是三个颜色的移位的位数)。
传统的方法是定义三个16位变量来放三个颜色,然后进行运算,最后进行边界检查,不用说也知道,一个字:慢。而又不能把整个的颜色的值进行运算……看看soft的方法:
我们假使是565的格式,555的格式是一样的方法。
思路:将RGB三种颜色用0分开来,这样进位就不会影响到别的颜色了。看看程序里面的代码吧。
rgbMask=((DWORD)GMask<<16)|RMask|BMask;
这一行把rgbMask扩展成了00000gggggg00000rrrrr000000bbbbb的形式,而我们使用的Alpha的值是在0----32之间的,也就是要移位的范围是在0-------5之间的,数数每个颜色之间有多少个0?至少是5个0,所以可以把一个颜色当做一个数进行运算,而我们只需要把一个颜色也按照这个方式扩展成32位,然后就可以进行运算了,而且不用进行边界检查。混合完了之后再把那个结果变成16位的颜色值。这只是一个思路,在soft 的程序中有汇编代码
但是soft是没有用那个版本的,我给那个汇编版的写了很多的注释,自己去看吧。对了,有两个版本,那个inline版本的我没有写注释,那个非inline版本的才有注释。^--^
那这个Alpha混合会用在 什么地方呢?多了,比如我们刚刚提到的透明处理。我们在已经画好了图的地方用alpha方式重新画一次,会有什么效果呢?比如多画一次人物?那么在人物没有被遮挡的时候,没有什么差别,因为两个表面的颜色是一样的,而要是人物走到物体后面了呢?显然,人物的表面的颜色和显示出来的颜色就不同了,这个时候进行一个alpha混合,那么人物就会以透明的方式出现在我们的面前了。

写到这儿也就该完了,不过我还要多说点,程序里有一个很出名的算法------Bresenham算法。
看起来有点难度,我就多说点点。
这个算法是用来光栅化的。(soft逼我再次拿起了那本全英文的计算机图形学,接着上次我没有看完的地方继续努力的钻研算法)^--^

在计算机上面要画一个比如是(10.1,9.2)这样的点是办不到的。所以我们要用一段一段的线段来代替直线。

看见了吗?我们是用线段群去代替之直线的,而这儿看来的象素块就是我们在屏幕上的一群点。我们看下面这个夸张的图。

看,我们是用了多少个线段呢?简单的算就是delta_y个,每个多长?delta_x/delta_y这样是不很精确的,不过这个算法是以这个为基础的。error =0.,dx = abs(dx),dy=abs(dy);
for(int i = 0; i < dx;i ++)
{
Put_Pixel(x,y);
error += dy;
if(error>dx)
{
error- = dx; y++;
}
x++;
}
明白?

明白了如何画线,那我们再看看是怎么画圆的。一样名字的算法。这个就比较的复杂了,先说说思路:我们先把圆的圆心坐标变成(0,0)(一个加减法就可以变回来的。而且在实际画圆的时候要把我们计算出来的值变换成真正的值),然后我们定义一个圆函数

好了,如果一个点(x,y)在圆以内,那么把它的坐标带入圆函数值就小于0,在圆上值就等于0,在圆的外部值就大于0。这个算法就是根据这个函数来决定在什么位置画下一个点。
由于圆的对称性,我们只需要画45度的一段弧长,其他的可以根据计算画出来,我们看看从0到45的这段弧。
假如我们在 画上了一个点,那么我们要就要决定 还是 更靠近圆的轨迹。我们可以定义一个预先设计的变量 让它等于上面的两个点的中点(Midpoint)的圆函数的值:
=
如果这个值是小于0的,那么我们选择后一个点,反之选择前一个点。
那么

其中的 是等于 还是等于 是看 的值决定的。如果小于0,先用下面的式子减去上面是式子,得到的差量

如果是大于0,得到的差量就是

好了, 是多少呢? ,如果r是个整数的话,可以让它等于1-r,因为每次都是按照整数进行增加的。
下面我们就总结这个Midpoint Circle Algorithm,大概是下面这个样子:
1. 得到圆的半径和中心坐标,进行坐标变换,让圆心坐标为(0,0),让画圆起始点是(r,0)。
2. 初始化p变量成1-r(假使r是整数)。
3. 在每个点 ,(k 从0开始),进行下面的检查:如果p<0,就在 画点,并且让p+= ,否则在 画点,而让p+= 。而这里的
= -1, = +1。
4. 画好其他的7个对称点。
5. 重复上面的3,4两步直到x<=y。

注:上面的两个算法来自于清华大学出版社出的一本叫COMPUTER GRAPHICS的BOOK。

完了,终于是写完了,我是怀着一种急于想要把这个圣二里面的好东西介绍给那些像我一样想知道圣二细节的朋友的心情来写这个文章的。当初soft只是让我把修改好了的源程序写些注释,然后打包让人下载的。但是我在给程序写注释的时候,就一种想把每一行都说清楚的冲动,在这个冲动下,我就写了这个圣二源程序导读,目的就是希望对那些想要看明白圣二源代码的人少走弯路。
……………………

放上我的E-MAIL:tiamo@2911.net,欢迎来信和我讨论。

金点工作组 http://www.gpgame.net/