游戏截图
游戏中用到的类结构介绍图像层
图像基类MYBITMAP
游戏背景MYBKSKY—>MYBITMAP
游戏图片MYANIOBJ—>MYBITMAP
魔法攻击MYANIMAGIC—>MYBITMAP
逻辑层
游戏逻辑GAMEMAP
时钟处理MYCLOCK
字体处理MYFONT
跟踪打印FILEREPORT
玩家控制MYROLE—>MYBITMAP
结构和表
精灵结构ROLE
物品结构MapObject
地图信息表MAPINFO
一、工程开始介绍下准备工作,也就是所需要的开发工具。代码编写调试:VC 6.0,美术工具:Windows自带的画图(开始-程序-附件-画图)。这是最简陋的开发工具,但已足够。最好再有Photoshop,记事本或UltraEdit等等你喜欢的文本编辑工具。
游戏代码分两部分,图像部分和逻辑部分。
先说图像部分:图像分两种,矩形图片和不规则图片。工程中的PIC文件夹下,可以看到所有图像资源。
矩形图片有:地面、砖块、水管、血条、血条背景。
不规则图片有:蘑菇(玩家,敌人1,敌人2),子弹、旋风、爆炸效果、金币、撞击金币后的得分、攻击武器(那个从魂斗罗里抠来的东东)、火圈1、火圈2、箭头(用于开始菜单选择)、树木、河流、WIN标志、背景图片(游戏背景和菜单背景)。
所有图片都分成几个位图BMP文件存储。一个文件中,每种图片,都纵向排列。每种图片可能有多帧。比如,金币需要4帧图像,才能构成一个旋转的动画效果,那么,各帧图像横向排列。
图像层的结构就这样简单,逻辑层只需要确定“哪个图像,哪一帧”这两个参数,就能在屏幕上绘制出所有图片。
图像层的基类是:
class MYBITMAP{public: MYBITMAP(); ~MYBITMAP(); // 初始化 void Init(HINSTANCE hInstance,int iResource,int row,int col); void SetDevice(HDC hdest,HDC hsrc,int wwin,int hwin); void SetPos(int istyle,int x,int y); // 显示 void Draw(DWORD dwRop); void Stretch(int x,int y); void Stretch(int x,int y,int id); void Show(int x,int y); void ShowCenter(int y); void ShowLoop(int left,int top,int right,int bottom,int iframe); void ShowNoBack(int x,int y,int iFrame); void ShowNoBackLoop(int x,int y,int iFrame,int iNum); // 动画播放 void ShowAni(); void SetAni(int x,int y); HBITMAP hBm;public: // 按照行列平均分成几个 int inum; int jnum; int width; int height; int screenwidth; int screenheight; HDC hdcdest; HDC hdcsrc; // 当前位置 int xpos; int ypos; int iStartAni;};
这只是一个基类,上面是几个重要的数据成员和函数。它所描述的图片,是一个m行n列构成的m*n个图片,每个图片大小一致,都是矩形。显然,这并不能满足上面的设计要求,怎么解决呢?派生,提供更多的功能。但是,这个基类封装了足够的物理层信息:设备上下文HDC,和位图句柄HBITMAP。矩形图片的显示、不规则图片的显示、图片组织排列信息,这些功能交给它的派生类MYANIOBJ。
还有,我们最关心的问题是图片坐标,比如,不同位置的砖块、精灵、金币,这些由逻辑层处理。
二、图片基类MYBITMAP先说一下代码风格,大家都说看不懂,这就对了。整套代码约有3000行,并不都是针对这个游戏写的。我想把代码写成一个容易扩展、容易维护、功能全面的“框架”,需要什么功能,就从这个框架中取出相应功能,如果是一个新的功能,比如新的图像显示、新的运动控制,我也能方便地实现。所以,这个游戏的代码,是在前几个游戏的基础上扩充起来的。部分函数,部分变量在这款游戏中,根本不用,但要保留,要为下一款游戏作准备。只要理解了各个类,就理解了整个框架。
今天先讲最基础的图像类MYBITMAP,先说一下代码风格,大家都说看不懂,这就对了。整套代码约有3000行,并不都是针对这个游戏写的。我想把代码写成一个容易扩展、容易维护、功能全面的“框架”,需要什么功能,就从这个框架中取出相应功能,如果是一个新的功能,比如新的图像显示、新的运动控制,我也能方便地实现。所以,这个游戏的代码,是在前几个游戏的基础上扩充起来的。部分函数,部分变量在这款游戏中,根本不用,但要保留,要为下一款游戏作准备。只要理解了各个类,就理解了整个框架。
今天先讲最基础的图像类MYBITMAP,成员函数功能列表:
// 功能 根据一个位图文件,初始化图像// 入参 应用程序实例句柄 资源ID 横向位图个数 纵向位图个数void Init(HINSTANCE hInstance,int iResource,int row,int col);// 功能 设置环境信息// 入参 目的DC(要绘制图像的DC),临时DC,要绘制区域的宽 高void SetDevice(HDC hdest,HDC hsrc,int wwin,int hwin);// 功能 设置图片位置// 入参 设置方法 横纵坐标void SetPos(int istyle,int x,int y);// 功能 图片显示// 入参 图片显示方式void Draw(DWORD dwRop);// 功能 图片缩放显示// 入参 横纵方向缩放比例void Stretch(int x,int y);// 功能 图片缩放显示// 入参 横纵方向缩放比例 缩放图像ID(纵向第几个)void Stretch(int x,int y,int id);// 功能 在指定位置显示图片// 入参 横纵坐标void Show(int x,int y);// 功能 横向居中显示图片// 入参 纵坐标void ShowCenter(int y);// 功能 将某个图片平铺在一个区域内// 入参 左上右下边界的坐标 图片ID(横向第几个)void ShowLoop(int left,int top,int right,int bottom,int iframe);// 功能 不规则图片显示// 入参 横纵坐标 图片ID(横向第几个)void ShowNoBack(int x,int y,int iFrame);// 功能 不规则图片横向平铺// 入参 横纵坐标 图片ID(横向第几个) 平铺个数void ShowNoBackLoop(int x,int y,int iFrame,int iNum);// 动画播放// 功能 自动播放该图片的所有帧,函数没有实现,但以后肯定要用:)// 入参 无void ShowAni();// 功能 设置动画坐标// 入参 横纵坐标void SetAni(int x,int y);
成员数据:
// 图像句柄HBITMAP hBm;// 按照行列平均分成几个int inum;int jnum;// 按行列分割后,每个图片的宽高(显然各个图片大小一致,派生后,这里的宽高已没有使用意义)int width;int height;// 屏幕宽高int screenwidth;int screenheight;// 要绘制图片的dcHDC hdcdest;// 用来选择图片的临时dcHDC hdcsrc; // 当前位置int xpos;int ypos;// 是否处于动画播放中(功能没有实现)int iStartAni;
这个基类的部分函数和变量,在这个游戏中没有使用,是从前几个游戏中保留下来的,所以看起来有些零乱。这个游戏的主要图像功能,由它的派生类完成。由于基类封装了物理层信息(dc和句柄),派生类的编写就容易一些,可以让我专注于逻辑含义。
基类的函数实现上,很简单,主要是以下几点:
1.图片初始化
// 根据程序实例句柄,位图文件的资源ID,导入该位图,得到位图句柄 hBm=LoadBitmap(hInstance,MAKEINTRESOURCE(iResource)); // 获取该位图文件的相关信息 GetObject(hBm,sizeof(BITMAP),&bm); // 根据横纵方向的图片个数,计算出每个图片的宽高(对于超级玛丽,宽高信息由派生类处理) width=bm.bmWidth/inum; height=bm.bmHeight/jnum;
2.图片显示
各个图片的显示函数,大同小异,都要先选入一个临时DC,再bitblt到要绘制的dc上。矩形图片,可以直接用SRCCOPY的方式绘制;不规则图片,需要先用黑白图与目的区域相”与”(SRCAND),再用”或”的方法显示图像(SRCPAINT),这是一种简单的”绘制透明位图”的方法。
void MYBITMAP::ShowNoBack(int x,int y,int iFrame){ xpos=x; ypos=y; SelectObject(hdcsrc,hBm); BitBlt(hdcdest,xpos,ypos,width,height/2,hdcsrc,iFrame*width,height/2,SRCAND); BitBlt(hdcdest,xpos,ypos,width,height/2,hdcsrc,iFrame*width,0,SRCPAINT); }
3.图片缩放
用StretchBlt的方法实现。
void MYBITMAP::Stretch(int x,int y,int id){ SelectObject(hdcsrc,hBm); StretchBlt(hdcdest,xpos,ypos,width*x,height*y, hdcsrc,0,id*height, width,height, SRCCOPY); }
在超级玛丽这个游戏中,哪些图像的处理是通关这个基类呢?只有一个:MYBITMAP bmPre;由于这个基类只能处理几个大小均等的图片,只有这些图片大小一致,且都是矩形:游戏开始前的菜单背景,操作信息的背景,每一关开始前的背景(此时显示LIFE x WORLD x),通关或游戏结束时显示的图片,共5个,将这5个图片,放在一个位图文件中,于是,这些图片的操作就做完了,代码如下:
// 初始设置,在InitInstance函数中bmPre.Init(hInstance,IDB_BITMAP_PRE1,1,5);bmPre.SetDevice(hscreen,hmem,GAMEW*32,GAMEH*32);bmPre.SetPos(BM_USER,0,0);// 图片绘制,在WndProc中,前两个参数指横纵方向扩大2倍显示.bmPre.Stretch(2,2,0);bmPre.Stretch(2,2,4);bmPre.Stretch(2,2,2); bmPre.Stretch(2,2,1); bmPre.Stretch(2,2,3);
三、游戏背景类MYBKSKY类说明
这是一个专门处理游戏背景的类。在横版游戏或射击游戏中,都有一个背景画面,如山、天空、云、星空等等。这些图片一般只有1到2倍屏幕宽度,然后像一个卷轴一样循环移动,连成一片,感觉上像一张很长的图片。这个类就是专门处理这个背景的。在超级玛丽增强版中,主要关卡是3关,各有一张背景图片;从水管进去,有两关,都用一张全黑图片。共四张图。这四张图大小一致,纵向排列在一个位图文件中。MYBKSKY这个类,派生于MYBITMAP。由于背景图片只需要完成循环移动的效果,只需要实现一个功能,而无需关心其他任何问题(例如句柄、dc)。编码起来很简单,再次反映出面向对象的好处。
技术原理
怎样让一张图片像卷轴一样不停移动呢?很简单,假设有一条垂直分割线,把图片分成左右两部分。先显示右边部分,再把左边部分接到图片末尾。不停移动向右移动分割线,图片就会循环地显示。
MYBKSKY类定义如下所示:
class MYBKSKY:public MYBITMAP{public: MYBKSKY(); ~MYBKSKY(); // show // 功能 显示一个背景. // 入参 无 void DrawRoll(); // 循环补空 // 功能 显示一个背景,并缩放图片 // 入参 横纵方向缩放比例 void DrawRollStretch(int x,int y); // 功能 指定显示某一个背景,并缩放图片,游戏中用的就是这个函数 // 入参 横纵方向缩放比例 背景图片ID(纵向第几个) void DrawRollStretch(int x,int y,int id); // 功能 设置图片位置 // 入参 新的横纵坐标 void MoveTo(int x,int y); // 功能 循环移动分割线 // 入参 分割线移动的距离 void MoveRoll(int x); // data // 分割线横坐标 int xseparate;};
函数具体实现都很简单,例如:
void MYBKSKY::DrawRollStretch(int x,int y, int id){ // 选入句柄 SelectObject(hdcsrc,hBm); // 将分割线右边部分显示在当前位置 StretchBlt(hdcdest, xpos,ypos, // 当前位置 (width-xseparate)*x,height*y, // 缩放比例 hdcsrc, xseparate,id*height, // 右边部分的坐标 width-xseparate,height, // 右边部分的宽高 SRCCOPY); // 将分割线左边部分接在图片末尾 StretchBlt(hdcdest,xpos+(width-xseparate)*x,ypos, xseparate*x,height*y, hdcsrc,0,id*height, xseparate,height, SRCCOPY); }
使用举例:
// 定义 MYBKSKY bmSky;// 初始化bmSky.Init(hInstance,IDB_BITMAP_MAP_SKY,1,4);bmSky.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32);bmSky.SetPos(BM_USER,0,0);// 游戏过程中显示bmSky.DrawRollStretch(2,2,gamemap.mapinfo.iBackBmp);// 每隔一定时间,移动分割线bmSky.MoveRoll(SKY_SPEED);//云彩移动// 以下两处与玩家角色有关:// 当玩家切换到一张新地图时,刷新背景图片的坐标bmSky.SetPos(BM_USER,viewx,0);// 当玩家向右移动时,刷新背景图片的坐标bmSky.SetPos(BM_USER,viewx,0);
至此,游戏背景图片的功能就做完了。
四、图片显示类MYANIOBJ类说明
这个类负责游戏中的图片显示。菜单背景、通关和游戏结束的提示图片,由MYBITMAP处理(大小一致的静态图片)。游戏背景由MYBKSKY处理。其余图片,也就是游戏过程中的所有图片,都是MYANIOBJ处理。
技术原理
游戏中的图片大小不一致,具体在超级玛丽中,可以分成两类:矩形图片和不规则图片。在位图文件中,都是纵向排列各个图片,横向排列各帧。用两个数组存储各个图片的宽和高。为了方便显示某一个图片,用一个数组存储各个图片的纵坐标(即位图文件中左上角的位置)。使用时,由逻辑层指定“哪个图片”的“哪一帧”,显示在“什么位置”。这样图片的显示功能就实现了。
MYANIOBJ类定义如下所示:
class MYANIOBJ:public MYBITMAP{public: MYANIOBJ(); ~MYANIOBJ(); // init list // 功能 初始化宽度数组 高度数组 纵坐标数组 是否有黑白图 // 入参 宽度数组地址 高度数组地址 图片数量 是否有黑白图(0 没有, 1 有) // (图片纵坐标信息由函数计算得出) void InitAniList(int *pw,int *ph,int inum,int ismask); // 功能 初始化一些特殊的位图,例如各图片大小一致,或者有其他规律 // 入参 初始化方式 参数1 参数2 // (留作以后扩展, 目的是为了省去宽高数组的麻烦) void InitAniList(int style,int a,int b); // show // 功能 显示图片(不规则图片) // 入参 横纵坐标(要显示的位置) 图片id(纵向第几个), 图片帧(横向第几个) void DrawItem(int x,int y,int id,int iframe); // 功能 显示图片(矩形图片) // 入参 横纵坐标(要显示的位置) 图片id(纵向第几个), 图片帧(横向第几个) void DrawItemNoMask(int x,int y,int id,int iframe); // 功能 指定宽度, 显示图片的一部分(矩形图片) // 入参 横纵坐标(要显示的位置) 图片id(纵向第几个), 显示宽度 图片帧(横向第几个) void DrawItemNoMaskWidth(int x,int y,int id,int w,int iframe); // 功能 播放一个动画 即循环显示各帧 // 入参 横纵坐标(要显示的位置) 图片id(纵向第几个) void PlayItem(int x,int y,int id); // 宽度数组 最多支持20个图片 int wlist[20]; // 高度数组 最多支持20个图片 int hlist[20]; // 纵坐标数组 最多支持20个图片 int ylist[20]; // 动画播放时的当前帧 int iframeplay;};
函数实现上也很简单。构造函数中,所有成员数据清零;初始化时,将各图片的高度累加,即得到各图片的纵坐标。显示图片的方法如前所述。
使用举例:
游戏图片分成三类:地图物品、地图背景物体、精灵(即所有不规则图片)。
MYANIOBJ bmMap;MYANIOBJ bmMapBkObj;MYANIOBJ bmAniObj;
初始化宽高信息,程序中定义一个二维数组,例如:
int mapani[2][10]={ {32,32,64,32,32,52,64,32,64,32}, {32,32,64,32,32,25,64,32,64,32},};
第一维mapani[0]存储10个图片的宽度,第二维mapani[1]存储10个图片的高度,初始化时,将mapani[0],mapani[1]传给初始化函数即可。
1.地图物品的显示
// 定义MYANIOBJ bmMap;// 初始化// 这一步加载位图bmMap.Init(hInstance,IDB_BITMAP_MAP,1,1);// 这一步初始化DCbmMap.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32);// 这一步设置宽高信息, 图片为矩形bmMap.InitAniList(mapsolid[0],mapsolid[1], sizeof(mapsolid[0])/sizeof(int),0);// 对象作为参数传给逻辑层, 显示地图物品gamemap.Show(bmMap);
2.血条的显示
打怪时,屏幕上方要显示血条。由于同样是矩形图片,也一并放在了地图物品的位图中。
// 变量声明extern MYANIOBJ bmMap;// 显示血条背景,指定图片宽度:最大生命值*单位生命值对应血条宽度bmMap.DrawItemNoMaskWidth(xstart-1, ATTACK_TO_Y-1, ID_MAP_HEALTH_BK, iAttackMaxLife*BMP_WIDTH_HEALTH, 0);// 显示怪物血条,指定图片宽度:当前生命值*单位生命值对应血条宽度 bmMap.DrawItemNoMaskWidth(xstart, ATTACK_TO_Y, ID_MAP_HEALTH, iAttackLife*BMP_WIDTH_HEALTH, 0);
3.地图背景物体的显示
背景物体包括草、河流、树木、目的地标志。这些物体都不参与任何逻辑处理,只需要显示到屏幕上。图片放在一个位图文件中,都是不规则形状。
// 定义MYANIOBJ bmMapBkObj;// 初始化并加载位图bmMapBkObj.Init(hInstance,IDB_BITMAP_MAP_BK,1,1);// 设置dcbmMapBkObj.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32);// 设置各图片宽高信息bmMapBkObj.InitAniList(mapanibk[0],mapanibk[1],sizeof(mapanibk[0])/sizeof(int),1);// 对象作为参数传给逻辑层, 显示地图背景物体gamemap.ShowBkObj(bmMapBkObj);
4.精灵的显示
精灵包括:蘑菇(玩家,敌人1,敌人2),子弹、旋风、爆炸效果、金币、撞击金币后的得分、攻击武器(那个从魂斗罗里抠来的东东)、火圈1、火圈2、箭头(用于开始菜单选择)。
// 定义MYANIOBJ bmAniObj;// 初始化加载位图bmAniObj.Init(hInstance,IDB_BITMAP_ANI,1,1);// 设置dcbmAniObj.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32);// 设置宽高信息bmAniObj.InitAniList(mapani[0],mapani[1],sizeof(mapani[0])/sizeof(int),1);// 菜单显示(即菜单文字左边的箭头)gamemap.ShowMenu(bmAniObj);// 对象作为参数传给逻辑层, 显示各个精灵gamemap.ShowAniObj(bmAniObj);
五、魔法攻击类MYANIMAGIC类说明:玩家有两种攻击方式:普通攻击(子弹),魔法攻击(旋风)。这个类是专门处理旋风的。我最初的想法是用一些特殊的bitblt方法制造特效,例如或、与、异或。试了几次,都失败了。最后只能用“先与后或”的老方法。这个类可看成MYANIOBJ的一个简化版,只支持不规则图片的显示。
MYANIMAGIC类定义如下所示:
class MYANIMAGIC:public MYBITMAP{public: MYANIMAGIC(); ~MYANIMAGIC(); // init list // 功能 初始化宽度数组 高度数组 纵坐标数组(必须有黑白图) // 入参 宽度数组地址 高度数组地址 图片数量 // (图片纵坐标信息由函数计算得出) void InitAniList(int *pw,int *ph,int inum); // 功能 设置dc // 入参 显示dc 临时dc(用于图片句柄选择) 临时dc(用于特效实现) void SetDevice(HDC hdest,HDC hsrc,HDC htemp); // show // 功能 显示某个图片的某帧 // 入参 横纵坐标(显示位置) 图片id(纵向第几个) 帧(横向第几个) void DrawItem(int x,int y,int id,int iframe); // 宽度数组 int wlist[20]; // 高度数组 int hlist[20]; // 纵坐标数组 int ylist[20]; // 用于特效的临时dc, 功能没有实现 HDC hdctemp;};
函数具体实现很简单,可参照MYANIOBJ类。
使用举例
// 定义MYANIMAGIC bmMagic;// 初始化加载位图bmMagic.Init(hInstance,IDB_BITMAP_MAGIC,1,1);// 设置dcbmMagic.SetDevice(hscreen,hmem, hmem2);// 初始化宽高信息bmMagic.InitAniList(mapanimagic[0],mapanimagic[1],sizeof(mapanimagic[0])/sizeof(int));// 变量声明extern MYANIMAGIC bmMagic;// 在逻辑层中, 显示旋风图片bmMagic.DrawItem(xstart,ystart, 0, FireArray[i].iframe);
六、时钟控制类MYCLOCK类说明
时间就是生命。这对于游戏来说,最为准确。游戏程序只做两件事:显示图片、处理逻辑。更准确的说法是:每隔一段时间显示图片并处理逻辑。程序中,要设置一个定时器。这个定时器会每隔一段时间发出一个WM_TIMER消息。在该消息的处理中,先逻辑处理。逻辑处理完毕,通过InvalidateRect函数发出WM_PAINT消息,显示各种图片。游戏就不停地运行下去,直至程序结束。
时间表示
用一个整数iNum表示当前时间,游戏中的时间是1,2,3, … , n, 1,2,3, …,n 不停循环.假设1秒内需要25个WM_TIMER消息(每40毫秒1次),则n=25。也可以用一个变量,统计过了几秒。
控制事件频率的方法
一秒内发生多次
以游戏背景图片为例, 每秒移动5下, 可以在iNum为5,10,15,20,25这5个时间点上移动.即iNum可以被5整除时,修改背景图片的坐标.
一秒内发生一次
例如火圈, 每秒产生一个新的蘑菇兵. 可以随便指定一个时间点,如20. 当iNum等于20时,生成一个蘑菇兵。
多秒内发生一次
需要一个辅助变量iNumShow,统计时间过了几秒。每隔一秒iNumShow减1,当iNumShow等于0时处理逻辑。
MYCLOCK类定义如下所示:(所有函数都是内联函数)
class MYCLOCK{public: // 构造函数 初始化所有变量 MYCLOCK() { iNum=0; // 时间点 iIsActive=0; // 是否已经开始计时 iNumShow=0; // 计时秒数 iElapse=100; // 默认每100ms发一个WM_TIMER消息 ishow=0; // 是否显示时间 } // 析构函数 销毁计时器 ~MYCLOCK() { Destroy(); } // 功能 开始计时, 产生WM_TIEMR消息的时间间隔为elapse. // 设置计时秒数(timetotal). // 入参 窗口句柄 时间间隔 计时秒数 void Begin(HWND hw,int elapse,int timetotal) { if(iIsActive) return;//已经启动了,直接返回 hWnd=hw; iElapse=elapse; SetTimer(hWnd,1,iElapse,NULL); iNum=1000/iElapse;//一秒钟的时间消息数量 iNumShow=timetotal; iIsActive=1; } // 功能 销毁计时器. // 入参 无 void Destroy() { if(iIsActive) { iIsActive=0; KillTimer(hWnd,1); } } // 功能 重置计时秒数 // 入参 秒数 void ReStart(int timetotal) { iNumShow=timetotal; iNum=1000/iElapse; ishow=1; } //////////////////////////// 显示部分 // 功能 设置显示dc (在超级玛丽增强版中不显示时间) // 入参 显示dc void SetDevice(HDC h) { hDC=h; } // 功能 显示时间, TIME 秒数 // 入参 显示坐标 void Show(int x,int y) { char temp[20]={0}; if(!ishow) return; // 设置显示文本 sprintf(temp,"TIME: %d ",iNumShow); TextOut(hDC,x, y, temp,strlen(temp)); } // 功能 时间点减一 // 如果到了计时秒数, 函数返回1, 否则返回0. // 入参 无 int DecCount() { iNum--; if(iNum==0) { // 过了一秒 iNum=1000/iElapse; iNumShow--; if(iNumShow<=0) { // 不销毁计时器 return 1; } } return 0; } // 功能 时间点减一 // 如果到了计时秒数, 函数返回1并销毁计时器, 否则返回0. // 入参 无 int Dec() { iNum--; if(iNum<=0) { //过了一秒 iNum=1000/iElapse; iNumShow--; if(iNumShow<=0) { iNumShow=0; Destroy(); return 1; } } return 0; } // 功能 设置是否显示 // 入参 1,显示; 0, 不显示 void SetShow(int i) { ishow=i; }public: // 窗口句柄 HWND hWnd; // 显示dc HDC hDC; // 时间点 int iNum; // 计时秒数 int iNumShow; // 消息时间间隔 int iElapse; // 是否开始计时 int iIsActive; // 是否显示 int ishow;};
具体函数实现很简单,如上所述。
使用举例
// 定义MYCLOCK c1;// 设置显示dcc1.SetDevice(hscreen);// 开始计时(计时秒数无效)c1.Begin(hWnd, GAME_TIME_CLIP ,-1);// 选择游戏菜单,每隔一定时间,重绘屏幕,实现箭头闪烁c1.DecCount();if(0 == c1.iNum%MENU_ARROW_TIME)// 屏幕提示LIFE,WORLD,如果达到计时秒数,进入游戏。if(c1.DecCount())// 进入游戏,计时300秒(无意义,在超级玛丽增强版中取消时间限制)c1.ReStart(TIME_GAME_IN); // 在游戏过程中,每隔一定时间,处理游戏逻辑c1.DecCount();if(0 == c1.iNum%SKY_TIME)gamemap.ChangeFrame(c1.iNum); // 帧控制gamemap.CheckAni(c1.iNum); // 逻辑数据检测// 玩家过关后,等待一定时间。if(c1.DecCount())// 玩家进入水管,等待一定时间。if(c1.DecCount())c1.ReStart(TIME_GAME_IN); // 玩家失败后,等待一定时间。if(c1.DecCount())// 玩家通关后,等待一定时间。if(c1.DecCount())// 玩家生命值为0,游戏结束,等待一定时间。if(c1.DecCount())// 程序结束(窗口关闭),销毁计时器c1.Destroy();// 变量声明extern MYCLOCK c1;// 游戏菜单中,选择“开始游戏”,显示LIFE,WORLD提示,计时两秒c1.ReStart(TIME_GAME_IN_PRE); // 停顿两秒// 进入水管,等待,计时两秒c1.ReStart(TIME_GAME_PUMP_WAIT);// 玩家过关,等待,计时两秒c1.ReStart(TIME_GAME_WIN_WAIT);// 生命值为0,游戏结束,等待,计时三秒c1.ReStart(TIME_GAME_END); // 玩家失败,显示LIFE,WORLD提示,计时两秒c1.ReStart(TIME_GAME_IN_PRE); // 玩家失败,等待,计时两秒c1.ReStart(TIME_GAME_FAIL_WAIT);
至此,所有的时间消息控制、时间计时都已处理完毕。
七、字体管理类MYFONT类说明
游戏当然少不了文字。在超级玛丽中,文字内容是比较少的,分两类:游戏菜单中的文字,游戏过程中的文字。菜单中的文字包括:
“操作: Z:子弹 X:跳 方向键移动 W:默认窗口大小”,“地图文件错误,请修正错误后重新启动程序。”,“(上下键选择菜单,回车键确认)”,“开始游戏”,“操作说明”,“(回车键返回主菜单)”
这几个字符串存储在一个指针数组中(全局变量),通关数组下标使用各个字符串。
游戏中的文字只有两个:’LIFE’,’WORLD’。
其他的文字其实都是位图,例如“通关”、“gameover”以及碰到金币后的“+10”。这些都是位图图片,在pic文件夹里一看便知。
技术原理
要在屏幕上显示一个字符串,分以下几步:将字体句柄选入dc,设置文字背景色,设置文字颜色,最后用TextOut完成显示。这个类就是将整个过程封装了一下。显示dc,背景色,文字颜色,字体句柄都对应各个成员数据。函数具体实现很简单,一看便知。
MYFONT类定义如下所示:
class MYFONT{public: // 构造函数,初始化”字体表”,即5个字体句柄构成的数组,字体大小依次递增. MYFONT(); ~MYFONT(); // 功能 设置显示文字的dc // 入参 显示文字的dc句柄 void SetDevice(HDC h); // 功能 设置当前显示的字体 // 入参 字体表下标 void SelectFont(int i); // 功能 设置当前字体为默认字体 // 入参 无 void SelectOldFont(); // 功能 在指定坐标显示字符串 // 入参 横纵坐标 字符串指针 void ShowText(int x,int y,char *p); // 功能 设置文字背景颜色,文字颜色 // 入参 文字背景颜色 文字颜色 void SetColor(COLORREF cbk, COLORREF ctext); // 功能 设置文字背景颜色,文字颜色 // 入参 文字背景颜色 文字颜色 void SelectColor(COLORREF cbk, COLORREF ctext); // 显示文字的dc HDC hdc; // 字体表,包含5个字体句柄,字体大小依次是0,10,20,30,40 HFONT hf[5]; // 默认字体 HFONT oldhf; // color COLORREF c1; // 字体背景色 COLORREF c2; // 字体颜色};
使用举例
// 定义MYFONT myfont;// 初始化设置显示dcmyfont.SetDevice(hscreen);// 地图文件错误:设置颜色,设置字体,显示提示文字myfont.SelectColor(TC_WHITE,TC_BLACK);myfont.SelectFont(0);myfont.ShowText(150,290,pPreText[3]);// 游戏开始菜单:设置字体,设置颜色,显示三行菜单文字myfont.SelectFont(0);myfont.SelectColor(TC_BLACK, TC_YELLOW_0);myfont.ShowText(150,260,pPreText[4]);myfont.ShowText(150,290,pPreText[5]);myfont.ShowText(150,320,pPreText[6]);// 游戏操作说明菜单:设置字体,设置颜色,显示四行说明文字myfont.SelectFont(0);myfont.SelectColor(TC_BLACK, TC_YELLOW_0);myfont.ShowText(150,230,pPreText[8]);myfont.ShowText(50,260,pPreText[1]);myfont.ShowText(50,290,pPreText[0]);myfont.ShowText(50,320,pPreText[7]);
这个类的使用就这些。这个类只是负责菜单文字的显示,那么,游戏中的LIFE,WORLD的提示,是在哪里完成的呢?函数如下:
void GAMEMAP::ShowInfo(HDC h){ char temp[50]={0}; SetTextColor(h, TC_WHITE); SetBkColor(h, TC_BLACK); sprintf(temp, "LIFE : %d",iLife); TextOut(h, 220,100,temp,strlen(temp)); sprintf(temp, "WORLD : %d",iMatch+1); TextOut(h, 220,130,temp,strlen(temp));}
这个函数很简单。要说明的是,它并没有设置字体,因为在显示菜单的时候已经设置过了。
至此,所有文字的处理全部实现。
八、跟踪打印类FILEREPORT前面介绍了图片显示、时钟控制、字体管理几项基本技术。这是所有游戏都通用的基本技术。剩下的问题就是游戏逻辑,例如益智类、运动类、射击类、格斗类等等。当然,不同的游戏需要针对自身做一些优化,比如益智类游戏的时钟控制、画面刷新都更简单,而格斗游戏,画面的质量要更酷、更炫。下面要介绍整个游戏的核心层:逻辑控制。地图怎样绘制的?物品的坐标怎么存储?人物怎样移动?游戏流程是什么样的?
在介绍这些内容前,先打断一下思路,说程序是怎样写出来的,即“调试”。
程序就是一堆代码,了无秘密。初学时,dos下一个猜数字的程序,只需要十几行。一个纸牌游戏,一千多行,而超级玛丽增强版,近三千行。怎样让这么一堆程序从无到有而且运行正确?开发不是靠设计的巧妙或者笨拙,而是靠反复调试。在三千行的代码中,增加一千行,仍然运行正确,这是编程的基本要求。这个最基本的要求,靠设计做不到,只能靠调试。正如公司里的测试部,人力规模,工作压力,丝毫不比开发部差。即使如此,还是能让一些简单bug流入最终产品。老板只能先问测试部:“这么简单的bug,怎么没测出来?”再问开发部:“这么明显的错误,你怎么写出来的?”总之,程序是调出来的。
怎么调?vc提供了很全面的调试方法,打断点、单步跟踪、看变量。这些方法对游戏不适用。一个bug,通常发生在某种情况下,比如超级玛丽,玩家在水管上,按方向键“下”,新的地图显示不出来,屏幕上乱七八糟。请问,bug在哪里?玩家坐标出问题、按键响应出问题、地图加载出问题、图片显示出问题?打断点,无处下手。
解决方法是:程序中,创建一个文本文件,在“可能有问题”的地方,添加代码,向这个文件写入提示信息或变量内容(称为跟踪打印)。这个文本文件,就成了代码运行的日志。看日志,就知道代码中发生了什么事情。最终,找到bug。
FILEREPORT,就是对日志文件创建、写入等操作的封装。
FILEREPORT类定义如下所示:
class FILEREPORT{public: // 功能 默认构造函数,创建日志trace.txt // 入参 无 FILEREPORT(); // 功能 指定日志文件名称 // 入参 日志文件名称 FILEREPORT(char *p); // 功能 析构函数,关闭文件 // 入参 无 ~FILEREPORT(); // 功能 向日志文件写入字符串 // 入参 要写入的字符串 void put(char *p); // 功能 向日志文件写入一个字符串,两个整数 // 入参 字符串 整数a 整数b void put(char *p,int a,int b); // 功能 计数器计数, 并写入一个提示字符串 // 入参 计时器id 字符串 void putnum(int i,char *p); // 功能 判断一个dc是否为null, 如果是,写入提示信息 // 入参 dc句柄 字符串 void CheckDC(HDC h,char *p); // 功能 设置显示跟踪信息的dc和文本坐标 // 入参 显示dc 横纵坐标 void SetDevice(HDC h,int x,int y); // 功能 设置要显示的跟踪信息 // 功能 提示字符串 整数a 整数b void Output(char *p,int a,int b); // 功能 在屏幕上显示当前的跟踪信息 void Show();private: // 跟踪文件指针 FILE *fp; // 计数器组 int num[5]; // 显示dc HDC hshow; // 跟踪文本显示坐标 int xpos; int ypos; // 当前跟踪信息 char info[50];};
函数具体实现很简单,只是简单的文件写入。要说明的是两部分,
一:计数功能,有时要统计某个事情发生多少次,所以用一个整数数组,通过putnum让指定数字累加。二:显示功能,让跟踪信息,立刻显示在屏幕上。
使用举例:没有使用。程序最终完成,所有的跟踪打印都已删除。
九、精灵结构struct ROLE这个结构用来存储两种精灵:敌人(各种小怪)和子弹(攻击方式)。敌人包括两种蘑菇兵和两种火圈。子弹包括火球和旋风。游戏中,精灵的结构很简单:
struct ROLE{ int x; // 横坐标 int y; // 纵坐标 int w; // 图片宽度 int h; // 图片高度 int id; // 精灵id int iframe; // 图片当前帧 int iframemax; // 图片最大帧数 // 移动部分 int xleft; // 水平运动的左界限 int xright; // 水平运动的右界限 int movex; // 水平运动的速度 // 人物属性 int health; // 精灵的生命值 int show; // 精灵是否显示};
游戏中的子弹处理非常简单,包括存储、生成、销毁。子弹的存储:所有的子弹存储在一个数组中,如下:
struct ROLE FireArray[MAX_MAP_OBJECT];
其实,所有的动态元素都有从生成到销毁的过程。看一下子弹是怎样产生的。
首先,玩家按下z键:发出子弹,调用函数:
int GAMEMAP::KeyProc(int iKey) case KEY_Z: // FIRE if(iBeginFire) break; iTimeFire=0; iBeginFire=1; break;
这段代码的意思是:如果正在发子弹,代码结束。否则,设置iBeginFire为1,表示开始发子弹。
子弹是在哪里发出的呢?
思路:用一个函数不停地检测iBeginFire,如果它为1,则生成一个子弹。函数如下:
int GAMEMAP::CheckAni(int itimeclip)
发子弹的部分:
// 发子弹 if(iBeginFire) { // 发子弹的时间到了(连续两个子弹要间隔一定时间) if(0 == iTimeFire ) { // 设置子弹属性: 可见, 动画起始帧:第0帧 FireArray[iFireNum].show=1; FireArray[iFireNum].iframe = 0; // 子弹方向 // 如果人物朝右 if(0==rmain.idirec) { // 子弹向右 FireArray[iFireNum].movex=1; } else { // 子弹向左 FireArray[iFireNum].movex=-1; } // 区分攻击种类: 子弹,旋风 switch(iAttack) { // 普通攻击: 子弹 case ATTACK_NORMAL: // 精灵ID: 子弹 FireArray[iFireNum].id=ID_ANI_FIRE; // 设置子弹坐标 FireArray[iFireNum].x=rmain.xpos; FireArray[iFireNum].y=rmain.ypos; // 设置子弹宽高 FireArray[iFireNum].w=FIREW; FireArray[iFireNum].h=FIREH; // 设置子弹速度: 方向向量乘以移动速度 FireArray[iFireNum].movex*=FIRE_SPEED; break;
最后,移动数组的游标iFireNum.这个名字没起好, 应该写成cursor.游标表示当前往数组中存储元素的位置。
// 移动数组游标iFireNum=(iFireNum+1)%MAX_MAP_OBJECT;
至此,游戏中已经生成了一个子弹。 由图像层,通过子弹的id,坐标在屏幕上绘制出来。
子弹已经显示在屏幕上,接下来,就是让它移动、碰撞、销毁。
十、子弹的显示和帧的刷新继续介绍子弹的显示和动画帧的刷新,这个思路,可以应用的其他精灵上。
上次讲所有的子弹存储到一个数组里,用一个游标(数组下标)表示新生产的子弹存储的位置。设数组为a,长度为n。游戏开始,一个子弹存储在a0,然后是a1,a2,…,a(n-1)。然后游标又回到0,继续从a0位置存储。数组长度30,保存屏幕上所有的子弹足够了。
子弹的显示功能由图像层完成,如同图像处理中讲的,显示一个子弹(所有图片都是如此),只需要子弹坐标,子弹图片id,图片帧。函数如下:
void GAMEMAP::ShowAniObj(MYANIOBJ & bmobj)
代码部分:
// 显示子弹,魔法攻击 for(i=0;i<MAX_MAP_OBJECT;i++) { if (FireArray[i].show) { ystart=FireArray[i].y; xstart=FireArray[i].x; switch(FireArray[i].id) { case ID_ANI_FIRE: bmobj.DrawItem(xstart,ystart,FireArray[i].id,FireArray[i].iframe); break;
子弹图片显示完成。游戏中,子弹是两帧图片构成的动画。动画帧是哪里改变的呢?
刷新帧的函数是:
voidGAMEMAP::ChangeFrame(int itimeclip)
游戏中,不停地调用这个函数,刷新各种动画的当前帧。其中子弹部分的代码:
// 子弹,攻击控制 for(i=0;i<MAX_MAP_OBJECT;i++) { if(FireArray[i].show) { switch(FireArray[i].id) { default: FireArray[i].iframe=1-FireArray[i].iframe; break; } } }
子弹的动画只有两帧,所以iframe只是0,1交替变化。至此,子弹在屏幕上显示,并且两帧图片不停播放。
子弹和小怪碰撞,是游戏中的关键逻辑。网游里也是主要日常工作,打怪。消灭小怪,也是这个游戏的全部乐趣。那么, 这个关键的碰撞检测,以及碰撞检测后的逻辑处理,是怎样的呢?
十一、子弹运动和打怪玩家按攻击键,生成子弹,存储在数组中,显示,接下来:子弹运动,打怪。先说子弹是怎样运动的。思路:用一个函数不停地检测子弹数组,如果子弹可见,刷新子弹的坐标。
实现如下:
int GAMEMAP::CheckAni(int itimeclip){ // 子弹移动 for(i=0;i<MAX_MAP_OBJECT;i++) { // 判断子弹是否可见 if (FireArray[i].show) { // 根据子弹的移动速度movex,修改子弹坐标. // (movex为正,向右移动;为负,向左移动,). FireArray[i].x+=FireArray[i].movex; // 判断子弹是否超出了屏幕范围,如果超出,子弹消失(设置为不可见) if( FireArray[i].x > viewx+VIEWW || FireArray[i].x<viewx-FIRE_MAGIC_MAX_W) { FireArray[i].show = 0; } } }}
至此,子弹在屏幕上不停地运动。
打怪是怎样实现的呢:碰撞检测的思路:用一个函数不停地检测所有子弹,如果某个子弹碰到了小怪,小怪消失,子弹消失。
实现如下:
int GAMEMAP::CheckAni(int itimeclip){ // 检测子弹和敌人的碰撞(包括魔法攻击) for(i=0;i<MAX_MAP_OBJECT;i++) { // 判断小怪是否可见 if(MapEnemyArray[i].show) { // 检测所有子弹 for(j=0;j<MAX_MAP_OBJECT;j++) { // 判断子弹是否可见 if (FireArray[j].show) { // 判断子弹和小怪是否"碰撞" if(RECT_HIT_RECT(FireArray[j].x+FIRE_XOFF, FireArray[j].y, FireArray[j].w, FireArray[j].h, MapEnemyArray[i].x, MapEnemyArray[i].y, MapEnemyArray[i].w, MapEnemyArray[i].h) ) { // 如果碰撞,小怪消灭 ClearEnemy(i); switch(iAttack) { case ATTACK_NORMAL: // 子弹消失 FireArray[j].show=0;
如果是旋风,在旋风动画帧结束后消失。
碰撞检测说明
子弹和小怪,都被看作是矩形,检测碰撞就是判断两个矩形是否相交。以前,有网友说,碰撞检测有很多优化算法。我还是想不出来,只写成了这样:
// 矩形与矩形碰撞#define RECT_HIT_RECT(x,y,w,h,x1,y1,w1,h1) ( (y)+(h)>(y1) && (y)<(y1)+(h1) && (x)+(w)>(x1) && (x)<(x1)+(w1) )
小怪的消失,代码如下所示:
void GAMEMAP::ClearEnemy(int i){ // 小怪的生命值减一 MapEnemyArray[i].health--; // 如果小怪的生命值减到0, 小怪消失(设置为不可见) if(MapEnemyArray[i].health<=0) { MapEnemyArray[i].show=0; }}
至此,玩家按下攻击键,子弹生成、显示、运动,碰到小怪,子弹消失,小怪消失。这些功能全部完成。如果只做成这样,不算本事。
攻击方式分两种:子弹和旋风。小怪包括:两种蘑菇兵和两种火圈。同时,火圈能产生两种蘑菇兵,而旋风的攻击效果明显高于普通子弹。这是不是很复杂?怎样做到的呢?
十二、旋风攻击、小怪运动、火圈前面介绍了子弹的生成、显示、运动、碰撞、消失的过程。这个过程可以推广到其他精灵上。继续介绍旋风、蘑菇兵、火圈。
作为魔法攻击方式的旋风,和子弹大同小异。旋风的存储与子弹同存储在一个数组中,如下:
struct ROLE FireArray[MAX_MAP_OBJECT];
使用时,用id区分。旋风生成函数如下所示:
int GAMEMAP::CheckAni(int itimeclip){ // 发子弹 if(iBeginFire) { if(0 == iTimeFire ) { FireArray[iFireNum].show=1; FireArray[iFireNum].iframe = 0; // 子弹方向 if(0==rmain.idirec) { FireArray[iFireNum].movex=1; } else { FireArray[iFireNum].movex=-1; } switch(iAttack) { case ATTACK_MAGIC: FireArray[iFireNum].id=ID_ANI_FIRE_MAGIC; FireArray[iFireNum].x=rmain.xpos-ID_ANI_FIRE_MAGIC_XOFF; FireArray[iFireNum].y=rmain.ypos-ID_ANI_FIRE_MAGIC_YOFF; FireArray[iFireNum].w=FIRE_MAGIC_W; FireArray[iFireNum].h=FIRE_MAGIC_H; FireArray[iFireNum].movex=0; break; } // 移动数组游标 iFireNum=(iFireNum+1)%MAX_MAP_OBJECT; } iTimeFire=(iTimeFire+1)%TIME_FIRE_BETWEEN; }}
这和子弹生成的处理相同。唯一区别是旋风不移动,所以movex属性最后设置为0。
旋风的显示原理
旋风在屏幕上的绘制和子弹相同,代码部分和子弹相同。但是旋风的帧刷新有些特殊处理:
void GAMEMAP::ChangeFrame(int itimeclip){ // 子弹,攻击控制 for(i=0;i<MAX_MAP_OBJECT;i++) { // 如果攻击(子弹、旋风)可见 if(FireArray[i].show) { switch(FireArray[i].id) { case ID_ANI_FIRE_MAGIC: // 旋风当前帧加一 FireArray[i].iframe++; // 如果帧为2(即第三张图片) ,图片坐标修正,向右移 if(FireArray[i].iframe == 2) { FireArray[i].x+=FIRE_MAGIC_W; } // 如果帧号大于3,即四张图片播放完,旋风消失,设置为不可见 if(FireArray[i].iframe>3) { FireArray[i].show=0; } break; } }
至此,旋风显示,动画播放结束后消失。旋风不涉及运动。碰撞检测的处理和子弹相同,唯一区别是:当旋风和小怪碰撞,旋风不消失。
int GAMEMAP::CheckAni(int itimeclip){ switch(iAttack) { case ATTACK_NORMAL: // 子弹消失 FireArray[j].show=0; break; // 旋风不消失 default: break; }
那么,再看小怪消失的函数:
void GAMEMAP::ClearEnemy(int i){ MapEnemyArray[i].health--; if(MapEnemyArray[i].health<=0) { MapEnemyArray[i].show=0; }
可以看到,此时并不区分攻击方式。但旋风存在的时间长(动画结束后消失),相当于多次调用了这个函数,间接提高了杀伤力。至此,两种攻击方式都已实现。
再看小怪,分蘑菇兵和火圈两种。
存储问题和攻击方式处理相同,用数组加游标的方法,蘑菇兵和火圈存储在同一数组中,如下:
struct ROLE MapEnemyArray[MAX_MAP_OBJECT];int iMapEnemyCursor;
小怪是由地图文件设定好的,以第二关的地图文件为例,其中小怪部分如下:
;enemy21 6 1 1 0 15 2423 6 1 1 0 15 2448 7 2 2 6 0 0 68 5 2 2 8 0 0
各个参数是什么意义呢?看一下加载函数就全明白了。函数如下所示:
int GAMEMAP::LoadMap(){// 如果文件没有结束后while(temp[0]!='#' && !feof(fp)) { // 读入小怪数据 横坐标 纵坐标 宽 高 id 运动范围左边界 右边界 sscanf(temp,"%d %d %d %d %d %d %d", &MapEnemyArray[i].x, &MapEnemyArray[i].y, &MapEnemyArray[i].w, &MapEnemyArray[i].h, &MapEnemyArray[i].id, &MapEnemyArray[i].xleft, &MapEnemyArray[i].xright); // 坐标转换.乘以32 MapEnemyArray[i].x*=32; MapEnemyArray[i].y*=32; MapEnemyArray[i].w*=32; MapEnemyArray[i].h*=32; MapEnemyArray[i].xleft*=32; MapEnemyArray[i].xright*=32; MapEnemyArray[i].show=1; // 设置移动速度(负,表示向左) MapEnemyArray[i].movex=-ENEMY_STEP_X; // 动画帧 MapEnemyArray[i].iframe=0; // 动画最大帧 MapEnemyArray[i].iframemax=2; // 设置生命值 switch(MapEnemyArray[i].id) { case ID_ANI_BOSS_HOUSE: MapEnemyArray[i].health=BOSS_HEALTH; break; case ID_ANI_BOSS_HOUSE_A: MapEnemyArray[i].health=BOSS_A_HEALTH; break; default: MapEnemyArray[i].health=1; break; } // 将火圈存储在数组的后半段,数值长30, BOSS_CURSOR为15 if ( i<BOSS_CURSOR && ( MapEnemyArray[i].id == ID_ANI_BOSS_HOUSE || MapEnemyArray[i].id == ID_ANI_BOSS_HOUSE_A) ) { // move data to BOSS_CURSOR MapEnemyArray[BOSS_CURSOR]=MapEnemyArray[i]; memset(&MapEnemyArray[i],0,sizeof(MapEnemyArray[i])); i=BOSS_CURSOR; } i++; // 读取下一行地图数据 FGetLineJumpCom(temp,fp); }
看来比生成子弹要复杂一些,尤其是火圈,为什么要从第15个元素上存储?因为,火圈要不停地生成蘑菇兵,所以”分区管理”,数值前一半存储蘑菇兵,后一半存储火圈。那么,小怪和火圈是怎样显示和运动的呢?火圈怎样不断产生新的小怪?
十三、小怪和火圈小怪的显示问题,蘑菇兵和火圈处于同一个数组,很简单:
void GAMEMAP::ShowAniObj(MYANIOBJ & bmobj){ // 显示敌人 for(i=0;i<MAX_MAP_OBJECT;i++) { if (MapEnemyArray[i].show) { bmobj.DrawItem(MapEnemyArray[i].x,MapEnemyArray[i].y, MapEnemyArray[i].id,MapEnemyArray[i].iframe); } }
同样,如同图片处理所讲,显示一个图片,只需要坐标、id、帧。
帧刷新和小怪运动的代码如下所示:
void GAMEMAP::ChangeFrame(int itimeclip){ // 移动时间:每隔一段时间ENEMY_SPEED,移动一下 if(0 == itimeclip% ENEMY_SPEED) { for(i=0;i<MAX_MAP_OBJECT;i++) { // 如果小怪可见 if(MapEnemyArray[i].show) { // 帧刷新 MapEnemyArray[i].iframe=(MapEnemyArray[i].iframe+1)%MapEnemyArray[i].iframemax; switch(MapEnemyArray[i].id) { case ID_ANI_ENEMY_NORMAL: case ID_ANI_ENEMY_SWORD: // 蘑菇兵移动(士兵,刺客) MapEnemyArray[i].x+=MapEnemyArray[i].movex; // 控制敌人移动:向左移动到左边界后,移动速度movex改为向右。移动到右边界后,改为向左。 if(MapEnemyArray[i].movex<0) { if(MapEnemyArray[i].x<=MapEnemyArray[i].xleft) { MapEnemyArray[i].movex=ENEMY_STEP_X; } } else { if(MapEnemyArray[i].x>=MapEnemyArray[i].xright) { MapEnemyArray[i].movex=-ENEMY_STEP_X; } } break; }
至此,所有小怪不停移动。(火圈的movex为0,不会移动)
在前面的子弹、旋风的碰撞处理中已讲过。碰撞后,生命值减少,减为0后,消失。火圈会产生新的蘑菇兵,怎样实现的呢?思路:不断地检测火圈是否出现在屏幕中,出现后,生成蘑菇兵。
int GAMEMAP::CheckAni(int itimeclip){ // 如果在显示范围之内,则设置显示属性 for(i=0;i<MAX_MAP_OBJECT;i++) { // 判断是否在屏幕范围内 if ( IN_AREA(MapEnemyArray[i].x, viewx, VIEWW) ) { // 如果有生命值,设置为可见 if(MapEnemyArray[i].health) { MapEnemyArray[i].show=1; switch(MapEnemyArray[i].id) { // 普通级火圈 case ID_ANI_BOSS_HOUSE: // 每隔一段时间, 产生新的敌人 if(itimeclip == TIME_CREATE_ENEMY) { MapEnemyArray[iMapEnemyCursor]=gl_enemy_normal; MapEnemyArray[iMapEnemyCursor].x=MapEnemyArray[i].x; MapEnemyArray[iMapEnemyCursor].y=MapEnemyArray[i].y+32; // 移动游标 iMapEnemyCursor=(iMapEnemyCursor+1)%BOSS_CURSOR; } break; // 下面是战斗级火圈,处理相似 } } } else { // 不在显示范围内,设置为不可见 MapEnemyArray[i].show=0; } }
这样,火圈就不断地产生蘑菇兵。
再说一下模板,这里的模板不是C++的模板。据说template技术已发展到艺术的境界,游戏中用到的和template无关,而是全局变量。如下:
// 普通蘑菇兵struct ROLE gl_enemy_normal={ 0, 0, 32, 32, ID_ANI_ENEMY_NORMAL, 0, 2, 0, 0, -ENEMY_STEP_X, // speed 1, 1};
当火圈不断产生新的蘑菇兵时,直接把这个小怪模板放到数组中,再修改一下坐标即可。(对于蘑菇刺客,还要修改id和生命值)
游戏的主要逻辑完成。此外,还有金币,爆炸效果等其他动态元素,它们是怎么实现的?
十四、爆炸效果和金币子弹每次攻击到效果,都会显示一个爆炸效果。由于只涉及图片显示,它的结构很简单。如下:
struct MapObject{ int x; int y; int w; int h; int id; int iframe; int iframemax; // 最大帧数 int show; // 是否显示};
存储问题,爆炸效果仍然使用数组加游标的方法,如下:
struct MapObject BombArray[MAX_MAP_OBJECT];int iBombNum;
当子弹和小怪碰撞后,生成。
void GAMEMAP::ClearEnemy(int i){ // 生成BombArray[iBombNum].show=1; BombArray[iBombNum].id=ID_ANI_BOMB; BombArray[iBombNum].iframe=0; BombArray[iBombNum].x=MapEnemyArray[i].x-BOMB_XOFF; BombArray[iBombNum].y=MapEnemyArray[i].y-BOMB_YOFF; // 修改数组游标 iBombNum=(iBombNum+1)%MAX_MAP_OBJECT;
和子弹、小怪的显示方法相同。
void GAMEMAP::ShowAniObj(MYANIOBJ & bmobj){ for(i=0;i<MAX_MAP_OBJECT;i++) { if (BombArray[i].show) { ystart=BombArray[i].y; xstart=BombArray[i].x; bmobj.DrawItem(xstart,ystart,BombArray[i].id, BombArray[i].iframe); } }
和子弹、小怪的帧刷新方法相同。
void GAMEMAP::ChangeFrame(int itimeclip){for(i=0;i<MAX_MAP_OBJECT;i++) { if(BombArray[i].show) { BombArray[i].iframe++; // 当第四张图片显示完毕,设置为不可见。 if(BombArray[i].iframe>3) { BombArray[i].show=0; } } }
碰撞检测:爆炸效果不涉及碰撞检测。
消失:如上所述,爆炸效果在动画结束后消失。
金币的处理比小怪更简单。当玩家和金币碰撞后,金币消失,增加金钱数量。用数组加游标的方法存储,如下:
struct MapObject MapCoinArray[MAX_MAP_OBJECT];int iCoinNum;
金币的生成,和小怪相似,从地图文件中加载。以第二关为例,地图文件中的金币数据是:
6 5 32 32 3 7 5 32 32 3 8 5 32 32 3 9 5 32 32 3 18 4 32 32 3 19 4 32 32 3 20 4 32 32 3
数据依次表示横坐标、纵坐标、宽、高、图片id。
int GAMEMAP::LoadMap(){ while(temp[0]!='#' && !feof(fp)) { sscanf(temp,"%d %d %d %d %d", &MapCoinArray[i].x, &MapCoinArray[i].y, &MapCoinArray[i].w, &MapCoinArray[i].h, &MapCoinArray[i].id); MapCoinArray[i].show=1; MapCoinArray[i].iframe=0; // 坐标转换,乘以32 MapCoinArray[i].x*=32; MapCoinArray[i].y*=32; // 设置这个动画元件的最大帧 switch(MapCoinArray[i].id) { case ID_ANI_COIN: MapCoinArray[i].iframemax=4; break; } i++; iCoinNum++; // 读取下一行数据 FGetLineJumpCom(temp,fp); }
金币显示和小怪的显示方法相同:
void GAMEMAP::ShowAniObj(MYANIOBJ & bmobj){ // 显示金币,和其他物品 for(i=0;i<iCoinNum;i++) { ystart=MapCoinArray[i].y; xstart=MapCoinArray[i].x; bmobj.DrawItem(xstart,ystart,MapCoinArray[i].id, MapCoinArray[i].iframe); }
金币帧刷新和小怪的帧刷新方法相同:
void GAMEMAP::ChangeFrame(int itimeclip){ for(i=0;i<MAX_MAP_OBJECT;i++) { // 如果金币可见,帧加一 if(MapCoinArray[i].show) { MapCoinArray[i].iframe=(MapCoinArray[i].iframe+1)%MapCoinArray[i].iframemax; } }
金币碰撞检测和小怪的碰撞检测方法相似,区别在于:金币的碰撞检测没有判断是否可见,只要金币位于屏幕中,和玩家碰撞,则金币消失,金钱数量iMoney增加。
int GAMEMAP::CheckAni(int itimeclip){ for(i=0;i<iCoinNum;i++) { tempx=MapCoinArray[i].x; tempy=MapCoinArray[i].y; if ( IN_AREA(tempx, viewx-32, VIEWW) ) { // 玩家坐标是rmain.xpos rmain.ypos if( RECT_HIT_RECT(rmain.xpos, rmain.ypos, 32,32, tempx, tempy, MapCoinArray[i].w,MapCoinArray[i].h) ) { switch(MapCoinArray[i].id) { case ID_ANI_COIN: // 增加金钱数量 iMoney+=10; // 金币消失 ClearCoin(i); break; } return 0; } } } // end of for
金币消失和小怪的消失不一样,不需要设置show为0,而是直接删除元素,即数组移动的方法:
void GAMEMAP::ClearCoin(int i){ // 检查合法性 if(i<0 || i>=iCoinNum) return; // 减少一个金币,或者减少一个其他物品 for(;i<iCoinNum;i++) { MapCoinArray[i]=MapCoinArray[i+1]; } // 修改数量 iCoinNum--;
由此可见,直接删除元素,省去了是否可见的判断。但凡事都有两面性,移动数组显然比单个元素的设置要慢(实际上不一定,可以优化)。方法多种多样,这就是程序的好处,永远有更好的答案。
所有的动态元素都介绍完了。所谓动态元素,就是有一个生成、运行、销毁的过程。只不过,有的复杂一些,如子弹、旋风、蘑菇兵、火圈,有些元素简单一些,如爆炸效果、金币。方法都大同小异,要强调的是,这不是最好的方法。碰到金币后,会出现‘+10’的字样,怎么做呢?
十五、金币提示和攻击提示提示信息,是玩家得到的反馈。比如,碰到金币,金币消失,此时就要显示“+10”;攻击小怪,小怪却没有消失,这时要显示血条,告知玩家小怪的生命值。下面讲提示信息。
金币提示+10的字样,并没有用文字处理,而是用图片(4帧的动画)。这样,实现起来很简单,和爆炸效果用同一个数组存储,处理方法相同。
金币的碰撞检测函数如下所示:
int GAMEMAP::CheckAni(int itimeclip){ for(i=0;i<iCoinNum;i++) { // 判断玩家是否碰到金币 switch(MapCoinArray[i].id) { case ID_ANI_COIN: // 碰到金币 iMoney+=10; // 金币消失,显示+10字样 ClearCoin(i); break;
金币消失函数如下所示:
void GAMEMAP::ClearCoin(int i){ switch(MapCoinArray[i].id) { case ID_ANI_COIN: // 碰到了金币,显示+10字样. 和爆炸效果的处理一样, 只是图片id不同 BombArray[iBombNum].show=1; BombArray[iBombNum].id=ID_ANI_COIN_SCORE; BombArray[iBombNum].iframe=0; BombArray[iBombNum].x=MapCoinArray[i].x-COIN_XOFF; BombArray[iBombNum].y=MapCoinArray[i].y-COIN_YOFF; iBombNum=(iBombNum+1)%MAX_MAP_OBJECT; break; }
攻击提示需要给出攻击对象名称,血条。存储:
// 攻击对象提示 char AttackName[20]; // 攻击对象名称 int iAttackLife; // 攻击对象当前生命值 int iAttackMaxLife; // 攻击对象最大生命值
提示信息设置:在小怪被攻击的时候,设置提示信息。其他攻击对象处理相似。
void GAMEMAP::ClearEnemy(int i){ // 设置攻击对象生命值 iAttackLife=MapEnemyArray[i].health; switch(MapEnemyArray[i].id) { case ID_ANI_BOSS_HOUSE: // 设置名称 strcpy(AttackName,"普通级火圈"); // 设置最大生命值 iAttackMaxLife=BOSS_HEALTH;
提示信息显示:
void GAMEMAP::ShowOther(HDC h){ // 如果攻击对象生命值不为0, 显示提示信息 if(iAttackLife) { // 输出名称 TextOut(h,viewx+ATTACK_TO_TEXT_X, ATTACK_TO_TEXT_Y,AttackName,strlen(AttackName)); // 显示血条 xstart=viewx+ATTACK_TO_X-iAttackMaxLife*10; // 按最大生命值显示一个矩形, 作为背景 bmMap.DrawItemNoMaskWidth(xstart-1, ATTACK_TO_Y-1,ID_MAP_HEALTH_BK, iAttackMaxLife*BMP_WIDTH_HEALTH, 0); // 按当前生命值对应的宽度, 显示一个红色矩形 bmMap.DrawItemNoMaskWidth(xstart, ATTACK_TO_Y,ID_MAP_HEALTH, iAttackLife*BMP_WIDTH_HEALTH, 0); }
金钱数量显示和攻击提示位于同一个函数:
void GAMEMAP::ShowOther(HDC h){ sprintf(temp,"MONEY: %d",iMoney); TextOut(h,viewx+20,20,temp,strlen(temp));
至此,攻击系统(子弹、旋风、蘑菇兵,火圈),金币(金币,金钱数量),提示信息(金币提示,攻击提示),这几类元素都介绍过了,还有一个,武器切换,就是从魂斗罗里抠来的那个东西。
十六、攻击方式切换当玩家碰到武器包(就是魂斗罗里那个东西),攻击方式切换。
思路:把它放到存储金币的数组中,用id区别。碰撞检测时,如果是金币,金币消失,如果是武器包,攻击方式切换。存储:和金币位于同一个数组MapCoinArray。生成:由地图文件加载。比如第一关的地图文件数据:
25 4 52 25 5
各参数含义:横坐标、纵坐标、宽、高、图片id。
和金币的加载相同,唯一区别是金币图片有4帧,武器包只有2帧,加载函数如下所示:
int GAMEMAP::LoadMap(){ MapCoinArray[i].iframemax=2;
显示和金币的处理相同,相同函数,相同代码。(再次显示出图像层的好处)
帧刷新和金币的处理相同,相同函数,相同代码。(再次显示出图像层的好处)
碰撞检测和金币的处理相同,如果是武器包,设置新的攻击方式,武器包消失。
int GAMEMAP::CheckAni(int itimeclip){ switch(MapCoinArray[i].id) { case ID_ANI_ATTACK: // 设置新的攻击方式 iAttack=ATTACK_MAGIC; // 武器包消失 ClearCoin(i); break; }
武器包的消失和金币的处理相同,相同函数,相同代码,这是逻辑层的好处(放在同一个数组中,处理简单)。
至此,攻击系统,金币系统,提示信息,武器切换,全部完成。只需要一个地图把所有的物品组织起来,构成一个虚拟世界,呈现在玩家眼前。
十七、地图物品自从游戏机发明以来,地图是什么样的呢?打蜜蜂,吃豆,地图是一个矩形,玩家在这个矩形框内活动。后来,地图得到扩展,可以纵向移动,比如打飞机;可以横向移动,比如超级玛丽、魂斗罗等等横板过关游戏。再后来,横向纵向都可以移动,后来又有45度地图,3D技术后终于实现了高度拟真的虚拟世界。
超级玛丽的地图可以看成是一个二维的格子。每个格子的大小是32x32像素。游戏窗口大小为12个格子高,16个格子宽。游戏地图宽度是游戏窗口的5倍,即12个格子高,5x16个格子宽。
地图物品有哪些呢?地面,砖块,水管。先看一下存储结构:
struct MapObject{ int x; int y; int w; int h; int id; int iframe; int iframemax; // 最大帧数 int show; // 是否显示};
各个成员含义是横坐标、纵坐标、宽、高、id、当前帧、最大帧、是否可见。用第一关地图文件的地图物品举例:(只包含5个参数)
0 9 10 3 0这个物品是什么呢?横向第0个格子,纵向第9个格子,宽度10个格子,高度3个格子,id为0,表示地面。
在显示的时候,只要把坐标、宽高乘以32,即可正确显示。
地图所有物品仍然用数组+游标的方法存储,如下:
struct MapObject MapArray[MAX_MAP_OBJECT];int iMapObjNum;
从地图文件中加载并生成地图。
int GAMEMAP::LoadMap(){ while(temp[0]!='#' && !feof(fp)) { // 读取一个物品 sscanf(temp,"%d %d %d %d %d", &MapArray[i].x, &MapArray[i].y, &MapArray[i].w, &MapArray[i].h, &MapArray[i].id); MapArray[i].show=0; iMapObjNum++; i++; // 读取下一个物品 FGetLineJumpCom(temp,fp); }
地图显示和物品显示一样,只是地面和砖块需要双重循环。对于每个宽w格,高h格的地面、砖块,需要把单个地面砖块平铺w*h次,所以用双重循环。
void GAMEMAP::Show(MYANIOBJ & bmobj){ for(i=0;i<iMapObjNum;i++) { ystart=MapArray[i].y*32; switch(MapArray[i].id) { //进出水管 case ID_MAP_PUMP_IN: case ID_MAP_PUMP_OUT: xstart=MapArray[i].x*32; bmobj.DrawItemNoMask(xstart, ystart, MapArray[i].id, 0); break; default: for(j=0;j<MapArray[i].h;j++) { xstart=MapArray[i].x*32; for(k=0;k<MapArray[i].w;k++) { bmobj.DrawItemNoMask(xstart, ystart, MapArray[i].id, 0); xstart+=32; } ystart+=32; } // end of for break;
其中,水管是一个单独完整的图片,直接显示,不需要循环。
地面、砖块、水管都是静态图片,不涉及帧刷新。保证玩家顺利地行走,如果玩家不踩在物品上,则不停地下落。
int GAMEMAP::CheckRole(){ // 检测角色是否站在某个物体上 for(i=0;i<iMapObjNum;i++) { // 玩家的下边线,是否和物品的上边线重叠 if( LINE_ON_LINE(rmain.xpos, rmain.ypos+32, 32, MapArray[i].x*32, MapArray[i].y*32, MapArray[i].w*32) ) { // 返回1,表示玩家踩在这个物品上 return 1; } } // 角色开始下落 rmain.movey=1; rmain.jumpx=0; // 此时要清除跳跃速度,否则将变成跳跃,而不是落体 return 0;
十八、背景物品背景物品更简单,包括草丛,树木,河流,win标志。这些背景物品只需要显示,不涉及逻辑处理。用数组+游标的方法存储,如下:
struct MapObject MapBkArray[MAX_MAP_OBJECT];int iMapBkObjNum;
第一关的背景物品数据(含义和地图物品相同):
17 5 3 2 0 (草丛)76 7 3 2 1 (win标志)10 10 3 2 2 (河流)
背景物品加载和地图物品加载方法相同。
int GAMEMAP::LoadMap(){ while(temp[0]!='#' && !feof(fp)) { sscanf(temp,"%d %d %d %d %d", &MapBkArray[i].x, &MapBkArray[i].y, …... MapBkArray[i].iframe=0; iMapBkObjNum++; i++; // 下一个物品 FGetLineJumpCom(temp,fp); }
背景物品的显示:
void GAMEMAP::ShowBkObj(MYANIOBJ & bmobj){ for(i=0;i<iMapBkObjNum;i++) { bmobj.DrawItem(xstart,ystart,MapBkArray[i].id,ibkobjframe); }
帧刷新:背景物品都是2帧动画。所有背景物品当前帧用ibkobjframe控制。
void GAMEMAP::ChangeFrame(int itimeclip){ if(0 == itimeclip% WATER_SPEED) { ibkobjframe=1-ibkobjframe;
十九、视图怎样把所有东西都显示在窗口中,并随着玩家移动呢?
思路:玩家看到的区域称为视图,即12格高,16格宽的窗口(每格32*32像素)。先把整个地图则绘制在一个DC上,然后从这个地图DC中,截取当前视图区域的图像,绘制到窗口中。修改视图区域的坐标(横坐标增加),就实现了地图的移动。
初始化:
BOOL InitInstance(HINSTANCE hInstance, int nCmdShow){ // hwindow是游戏窗口的DC句柄 hwindow=GetDC(hWnd); // hscreen是整个地图对应的DC hscreen=CreateCompatibleDC(hwindow); // 建立一个整个地图大小(5倍窗口宽)的空位图,选入hscreen hmapnull=CreateCompatibleBitmap(hwindow,GAMEW*32*5,GAMEH*32); SelectObject(hscreen,hmapnull);
视图的显示:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ case WM_PAINT: // hwindow是游戏窗口的DC句柄 hwindow = BeginPaint(hWnd, &ps); SelectObject(hscreen,hmapnull); case GAME_IN: // 显示天空 bmSky.DrawRollStretch(2,2,gamemap.mapinfo.iBackBmp); // 显示背景物品 gamemap.ShowBkObj(bmMapBkObj); // 显示地图物品 gamemap.Show(bmMap); // 显示动态元素 gamemap.ShowAniObj(bmAniObj); // 显示提示信息 gamemap.ShowOther(hscreen); // 显示玩家 rmain.Draw(); break; if(gamemap.iScreenScale) { // 窗口大小调整功能,代码略 } else { // 从整个地图的DC中, 截取当前视图区域的图像,绘制到窗口 BitBlt(hwindow, 0, 0, GAMEW*32, GAMEH*32, hscreen, gamemap.viewx, 0, SRCCOPY); }
可以看到,视图的左上角横坐标是viewx,只需要刷新这个坐标,就实现了地图移动。
视图坐标刷新思路:用一个函数不停地检测,玩家角色和视图左边界的距离,超过特定值,把视图向右移。如果玩家坐标和视图左边界的距离大于150,移动视图。
void GAMEMAP::MoveView(){ if(rmain.xpos - viewx > 150) { viewx+=ROLE_STEP; //判断视图坐标是否达到最大值(地图宽度减去一个窗口宽度) if(viewx>(mapinfo.viewmax-1)*GAMEW*32) viewx=(mapinfo.viewmax-1)*GAMEW*32; }
二十、地图切换地图分两种,普通地图和隐藏地图(指通过水管进入的地图)。先讲普图地图的切换,再讲隐藏地图的切换。
普通地图的切换思路:很简单,用一个数字iMatch表示当前是第几关。每过一关,iMatch+1,加载下一张地图。
过关检测:用一个函数不停地检测玩家是否到了地图终点,如果是,加载下一关的地图。
int GAMEMAP::IsWin(){ // 判断玩家的坐标是否到达地图终点(横坐标大于等于地图宽度) if(rmain.xpos >= MAX_PAGE*GAMEW*32 ) { // iMatch增加 iMatch=mapinfo.iNextMap; if(iMatch>=MAX_MATCH) { // 如果iMatch大于关卡数量(即通过最后一关),加载第一关的数据,代码略 } else { // 没有通关 InitMatch();//初始化游戏数据 // 设置玩家角色坐标,初始化玩家角色 rmain.SetPos(BM_USER,3*32,8*32); rmain.InitRole(0,GAMEW*32*MAX_PAGE-32); // 加载下一关的地图 LoadMap(); }
函数LoadMap()根据iMatch的值加载某一关的地图。而iMatch的修改代码是:
对于普通地图iMatch取值为0,1,2,…,只需要+1即可,为什么要有一个复杂的赋值过程呢?是为了实现隐藏地图的切换。
隐藏地图的切换,先看一下LoadMap加载的地图文件是什么样子?超级玛丽增强版的地图存储在一个文本文件中,结构为:
*0// 第0关的地图数据*1// 第1关的地图数据…*4// 第4关的地图数据
其中,编号0,1,2表示前三关的普图地图,编号3,4是隐藏地图(3是第0关的隐藏地图,4是第1关的隐藏地图)。怎样表示地图之间的关系呢?
思路:设计一张“地图信息表”,格式如下:
第0关:下一关编号,隐藏地图编号第1关:下一关编号,隐藏地图编号…第4关:下一关编号,隐藏地图编号
这样就形成一个地图信息的处理:
从“地图信息表”中读取当前关卡的的地图信息。当玩家到达地图终点,读取“下一关”编号;玩家进入水管,读取“隐藏地图编号”。
游戏的地图信息结构:
struct MAPINFO{ int iNextMap; int iSubMap;};
地图信息表(全局变量): (数组的第i个元素,表示第i关的地图信息)
struct MAPINFO allmapinfo[]={ {1,3}, {2,4}, {MAX_MATCH,-1, }, {-1,0}, {-1,1}};
对应的逻辑信息为:
第0关的下一关是第1关,从水管进入第3关。第1关的下一关是第2关,从水管进入第4关。第2关(最后一关)没有下一关(MAX),没有从水管进入的地图。第3关没有下一关,从水管进入第0关。第4关没有下一关,从水管进入第1关。
这样,实现了从水管进入隐藏关,又从水管返回的功能。
地图信息的存储在 struct MAPINFO mapinfo; 结构体变量中,每一关的游戏开始前,都要用这个函数初始化游戏数据。包括读取地图信息:
void GAMEMAP::InitMatch(){ mapinfo=allmapinfo[iMatch];
玩家到达地图终点的检测:
int GAMEMAP::IsWin(){ iMatch=mapinfo.iNextMap;
切换到下一关的地图编号。
玩家进入水管的检测思路:当玩家按下方向键“下”,判断是否站在水管上(当然进入地图的水管),如果是,切换地图。
int GAMEMAP::KeyProc(int iKey){ case VK_DOWN: for(i=0;i<iMapObjNum;i++) { // 判断玩家是否站在一个地图物品上 if( LINE_IN_LINE(玩家坐标,地图物品坐标)) { // 这个物品是水管 if(MapArray[i].id == ID_MAP_PUMP_IN) { // 设置游戏状态:进入水管 iGameState=GAME_PUMP_IN;
函数WndProc中,不断检测GAME_PUMP_IN状态,代码如下:
case WM_TIMER: switch(gamemap.iGameState) { case GAME_PUMP_IN: if(c1.DecCount()) { // 如果GAME_PUMP_IN状态结束,加载隐藏地图。 gamemap.ChangeMap();:
是不是复杂一些?确实,它可以简化。我想这还是有好处,它容易扩展。这仍然是我最初的构思,这是一个代码框架。看一下ChangeMap的处理:
void GAMEMAP::ChangeMap(){ //读取隐藏地图编号 iMatch=mapinfo.iSubMap; //游戏初始化 InitMatch(); //加载地图 LoadMap();
可见,ChangeMap的简单很简单。因为,LoadMap的接口只是iMatch,我只要保证iMatch在不同情况下设置正确,地图就会正确地加载。
至此,地图切换实现。但是,地图切换中,还有其它的游戏数据要刷新,怎样处理呢?
二十一、游戏数据管理进入每一关之前,需要对所有游戏数据初始化。进入隐藏地图,同样需要初始化。而且,从隐藏地图返回上层地图,还要保证玩家出现在“出水管”处。地图数据、玩家数据、视图数据,都要设置正确。
所有的游戏数据,即封装在gamemap中的数据,分成如下几种:
场景数据:包含当前关卡的地图,所有精灵,金币,提示信息。视图数据:视图窗口坐标。玩家数据:玩家角色的个人信息,例如金钱数量,攻击方式,游戏次数。
1.场景数据
int iGameState; // 当前游戏状态 int iMatch; // 当前关卡 // 各种精灵的数组: struct MapObject MapArray[MAX_MAP_OBJECT]; // 地图物品 struct MapObject MapBkArray[MAX_MAP_OBJECT]; // 地图背景物品 struct ROLE MapEnemyArray[MAX_MAP_OBJECT]; // 小怪 struct MapObject MapCoinArray[MAX_MAP_OBJECT]; // 金币 struct ROLE FireArray[MAX_MAP_OBJECT]; // 子弹 struct MapObject BombArray[MAX_MAP_OBJECT]; // 爆炸效果 // 当前关卡的地图信息 struct MAPINFO mapinfo; // 图片帧int ienemyframe; // 小怪图片帧 int ibkobjframe; // 背景图片帧 // 玩家攻击 int iTimeFire; // 两个子弹的间隔时间 int iBeginFire; // 是否正在发子弹 // 攻击对象提示 char AttackName[20]; // 攻击对象名称 int iAttackLife; // 攻击对象生命值 int iAttackMaxLife; // 攻击对象最大生命值
2.视图数据
int viewx; // 视图起始坐标
3.玩家数据
int iMoney; // 金钱数量 int iAttack; // 攻击方式 int iLife; // 玩家游戏次数
可见,每次加载地图前,要初始化场景数据和视图数据,而玩家数据不变,如金钱数量。
游戏数据处理,假设没有隐藏地图的功能,游戏数据只需要完成初始化的功能,分别位于以下三个地方:
程序运行前,初始化;过关后,初始化,再加载下一关地图;失败后,初始化,再加载当前地图;
1.游戏程序运行,所有游戏数据初始化
BOOL InitInstance(HINSTANCE hInstance, int nCmdShow){ gamemap.Init();void GAMEMAP::Init(){ // 设置游戏初始状态 iGameState=GAME_PRE; // 设置当前关卡 iMatch=0; // 设置玩家数据 玩家游戏次数,金钱数量,攻击种类 iLife=3; iMoney=0; iAttack=ATTACK_NORMAL; // 设置视图坐标 viewx=0; // 初始化场景数据 InitMatch();void GAMEMAP::InitMatch(){ memset(MapArray,0,sizeof(MapArray)); memset(BombArray,0,sizeof(BombArray)); ienemyframe=0; iFireNum=0; ……
这样,程序启动,InitInstance中完成第一次初始化。
2.过关后,游戏数据初始化,加载下一关地图
int GAMEMAP::IsWin(){ // 判断玩家是否到达地图终点 if(rmain.xpos >= MAX_PAGE*GAMEW*32 ) { // 读取下一关地图编号 iMatch=mapinfo.iNextMap; if(iMatch>=MAX_MATCH) { // 如果全部通过 Init(); // 初始化所有数据 LoadMap(); // 加载地图 } else { InitMatch(); // 初始化场景数据 // 设置玩家坐标 rmain.SetPos(BM_USER,3*32,8*32); rmain.InitRole(0,GAMEW*32*MAX_PAGE-32); // 加载下一关的地图 LoadMap(); }
3.如果玩家失败,重新加载当前地图
int GAMEMAP::IsWin(){ // 检测角色和敌人的碰撞 for(i=0;i<MAX_MAP_OBJECT;i++) { if(MapEnemyArray[i].show) { if(HLINE_ON_RECT(玩家坐标 小怪坐标)) { if(0 == rmain.movey) { // 玩家在行走过程中,碰到小怪,游戏失败 Fail(); } else { // 玩家在下落过程中,碰到火圈,游戏失败 switch(MapEnemyArray[i].id) { case ID_ANI_BOSS_HOUSE: case ID_ANI_BOSS_HOUSE_A: Fail(); …… // 玩家到达地图底端(掉入小河),游戏失败 if(rmain.ypos > GAMEH*32) { Fail(); return 0; } void GAMEMAP::Fail(){ // 玩家游戏次数减1 iLife--; // 设置游戏状态 iGameState=GAME_FAIL_WAIT;// GAME_FAIL_WAIT状态结束后,调用函数void GAMEMAP::Fail_Wait()加载地图。void GAMEMAP::Fail_Wait(){ if( iLife <=0) { // 游戏次数为0,重新开始,初始化所有数据 Init(); } else { // 还能继续游戏 } // 设置玩家坐标 rmain.SetPos(BM_USER,3*32,8*32); rmain.InitRole(0,GAMEW*32*MAX_PAGE-32); // 加载当前地图 LoadMap();
至此,在没有隐藏地图的情况下,游戏数据管理(只有初始化)介绍完了。
增加了隐藏地图的功能,游戏数据管理包括:初始化,数据刷新。哪些数据需要刷新呢?
刷新玩家坐标
例如,从第一关(地图编号为0)进入隐藏地图,玩家出现在(3,8),即横向第3格,纵向第8格。玩家返回第一关后,要出现在“出水管”的位置(66,7)。
刷新视图坐标
例如,从第一关进入隐藏地图,玩家出现在(3,8),视图对应地图最左边,玩家返回第一关后,视图要移动到“出水管”的位置。
刷新背景图片的坐标
例如,从第一关进入隐藏地图,玩家出现在(3,8),天空背景对应地图最左边,玩家返回第一关后,背景图片要移动到“出水管”的位置。
void GAMEMAP::ChangeMap(){ // 初始化视图坐标 viewx=0; // 获取隐藏地图编号 iMatch=mapinfo.iSubMap; // 初始化场景数据 InitMatch(); // 设置玩家坐标 rmain.SetPos(BM_USER,mapinfo.xReturnPoint*32,mapinfo.yReturnPoint*32); // 玩家角色初始化rmain.InitRole(0,GAMEW*32*MAX_PAGE-32); // 设定视图位置 if(rmain.xpos - viewx > 150) { SetView(mapinfo.xReturnPoint*32-32); // 往左让一格 if(viewx>(mapinfo.viewmax-1)*GAMEW*32) viewx=(mapinfo.viewmax-1)*GAMEW*32; } // 设定人物活动范围 rmain.SetLimit(viewx, GAMEW*32*MAX_PAGE); // 设定背景图片坐标 bmSky.SetPos(BM_USER,viewx,0); // 加载地图 LoadMap();}
所以,地图信息表中,要包含“出水管”的坐标。完整的地图信息表如下:
struct MAPINFO{ int iNextMap; // 过关后的下一关编号 int iSubMap; // 进入水管后的地图编号 int xReturnPoint; // 出水管的横坐标 int yReturnPoint; // 出水管的纵坐标 int iBackBmp; // 背景图片ID int viewmax; // 视图最大宽度};struct MAPINFO allmapinfo[]={{1,3,66,7,0,5},{2,4,25,4,1,5},{MAX_MATCH,-1,-1,-1,2,5},{-1,0,3,8,3,1},{-1,1,3,8,3,2}};
第0关
{1,3,66,7,0,5},表示第0关的下一关是第1关,从水管进入第3关,出水管位于(66,7),天空背景id为0,视图最大宽度为5倍窗口宽度。
第3关
{-1,0,3,8,3,1},表示第3关没有下一关,从水管进入第0关,出水管位于(3,8),天空背景id为3,视图最大宽度为1倍窗口宽度。
这样,隐藏地图切换的同时,视图数据,玩家数据均正确。
各个动态元素,地图的各种处理都已完成,只需要让玩家控制的小人,走路,跳跃,攻击,进出水管。玩家的动作控制怎样实现?
二十二、玩家角色类MYROLE玩家控制的小人,和各种小怪基本一致。没什么神秘的。主要有三个功能要实现:键盘响应,动作控制,图片显示。
为了方便图片显示,玩家角色类MYROLE直接派生自图片类MYBITMAP。
MYROLE类定义如下所示:
class MYROLE:public MYBITMAP{public: // 构造函数,析构函数 MYROLE(); ~MYROLE(); // 初始化部分 // 功能 初始化玩家信息 // 入参 玩家运动范围的左边界 右边界() void InitRole(int xleft, int xright); // 功能 设置玩家运动范围 // 入参 玩家运动范围的左边界 右边界() void SetLimit(int xleft, int xright); // 图片显示部分 // 功能 显示玩家角色图片(当前坐标 当前帧) // 入参 指定的横坐标 纵坐标 帧 void Draw(int x,int y,int iframe); // 功能 刷新帧,该函数没有使用, 帧刷新的功能在其它地方完成 // 入参 无 void ChangeFrame(); // 功能 设置玩家状态. 该函数没有使用 // 入参 玩家状态 void SetState(int i); // 动作部分 // 功能 玩家角色移动 // 入参 无 void Move(); // 功能 玩家角色跳跃. 该函数没有使用 // 入参 指定地点横坐标 纵坐标 void MoveTo(int x,int y); // 功能 从当前位置移动一个增量 // 入参 横坐标增量 纵坐标增量 void MoveOffset(int x,int y); // 功能 向指定地点移动一段距离(移动增量是固定的) // 入参 指定地点横坐标 纵坐标 void MoveStepTo(int x,int y); // 动画部分 // 功能 播放动画 // 入参 无 void PlayAni(); // 功能 设置动画方式 // 入参 动画方式 void SetAni(int istyle); // 功能 判断是否正在播放动画, 如果正在播放动画,返回1.否则,返回0 // 入参 无 int IsInAni(); // 数据部分 // 玩家状态, 该变量没有使用 int iState; // 图片数据 // 玩家当前帧 int iFrame; // 动作控制数据 // 玩家活动范围: 左边界 右边界(只有横坐标) int minx; int maxx; // 运动速度 int movex; // 正值,向右移动 int movey; // 正值,向下移动 // 跳跃 int jumpheight; // 跳跃高度 int jumpx; // 跳跃时, 横向速度(正值,向右移动) // 玩家运动方向 int idirec; // 动画数据 int iAniBegin; // 动画是否开始播放 int iparam1; // 动画参数 int iAniStyle; // 动画方式};
各个功能的实现:
键盘响应
玩家通过按键,控制人物移动。
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ case WM_KEYDOWN: if(gamemap.KeyProc(wParam)) InvalidateRect(hWnd,NULL,false); break; case WM_KEYUP: gamemap.KeyUpProc(wParam); break;
按键消息包括“按下”和“抬起”两种方式:
int GAMEMAP::KeyProc(int iKey){ switch(iGameState) { case GAME_PRE: // 选择游戏菜单 switch(iKey) { case 0xd: // 按下回车键 switch(iMenu) { case 0: // 菜单项0“开始游戏” c1.ReStart(TIME_GAME_IN_PRE); // 计时两秒 iGameState=GAME_IN_PRE; // 进入游戏LIFE/WORLD提示状态 break; case 1: // 菜单项1“操作说明” SetGameState(GAME_HELP); // 进入游戏状态“操作说明”,显示帮助信息 break; } break; case VK_UP: // 按方向键“上”,切换菜单项 iMenu=(iMenu+1)%2; break; case VK_DOWN: // 按方向键“下”,切换菜单项 iMenu=(iMenu+1)%2; break; } return 1; case GAME_HELP: // 游戏菜单项“操作说明”打开 switch(iKey) { case 0xd: // 按回车键,返回游戏菜单 SetGameState(GAME_PRE); // 设置游戏状态:选择菜单 break; } return 1; case GAME_IN: // 游戏进行中 // 如果人物正在播放动画,拒绝键盘响应 if(rmain.IsInAni()) { break; } // 根据方向键, X, Z, 触发移动,跳跃,攻击等功能 switch(iKey) { case VK_RIGHT: case VK_LEFT: case VK_DOWN: case KEY_X: // 跳 case KEY_Z: // FIRE // 秘籍 case 0x7a: // 按键F11, 直接切换攻击方式 iAttack=(iAttack+1)%ATTACK_MAX_TYPE; break; case 0x7b: // 按键F12 直接通关(游戏进行中才可以,即游戏状态GAME_IN) rmain.xpos = MAX_PAGE*GAMEW*32; break; } break; } return 0;}
可见,按键响应只需要处理三个状态:
菜单选择GAME_PRE操作说明菜单打开GAME_HELP游戏进行中GAME_IN
说明前两个状态属于菜单控制,函数返回1,表示立即刷新屏幕。对于状态GAME_IN,返回0。游戏过程中,屏幕刷新由其它地方控制。
按键“抬起”的处理:
void GAMEMAP::KeyUpProc(int iKey){ switch(iKey) { // 松开方向键“左右”,清除横向移动速度 case VK_RIGHT: rmain.movex=0; break; case VK_LEFT: rmain.movex=0; break; case KEY_X: // 松开跳跃键,无处理 break; case KEY_Z: // 松开攻击键,清除变量iBeginFire,表示停止攻击 iBeginFire=0; break; case KEY_W: // 按W,调整窗口为默认大小 MoveWindow(hWndMain, (wwin-GAMEW*32)/2, (hwin-GAMEH*32)/2, GAMEW*32, GAMEH*32+32, true); break; }
显示问题:
void MYROLE::Draw(){ // 判断是否播放动画,即iAniBegin为1 if(iAniBegin) { // 显示动画帧 PlayAni(); } else { // 显示当前图片 SelectObject(hdcsrc,hBm); BitBlt(hdcdest,xpos,ypos, width,height/2, hdcsrc,iFrame*width,height/2,SRCAND); BitBlt(hdcdest,xpos,ypos, width,height/2, hdcsrc,iFrame*width,0,SRCPAINT); }
二十三、玩家动作控制玩家移动:把行走和跳跃看成两个状态,各自用不同的变量表示横纵方向的速度。
相关属性:
行走:横向速度为movex,纵向不移动跳跃:横向速度为jumpx,纵向速度为movey。当前跳跃高度jumpheight运动方向:idirec
思路:
第一步:玩家按键,按键处理函数设置这些属性。按键松开,清除动作属性。第二步:用一个函数不停检测这些变量,控制玩家移动。
按键触发
int GAMEMAP::KeyProc(int iKey){ switch(iKey) { case VK_RIGHT: // 按右 // 判断是否正在跳跃, 即纵向速度不为0 if(rmain.movey!=0) { // 跳跃过程中, 设置横向速度, 方向向右, 大小为4像素 rmain.jumpx=4; } rmain.movex=4; // 设置横向速度, 方向向右, 大小为4像素 rmain.idirec=0; // 设置玩家方向, 向右 break; case VK_LEFT: // 按左 // 如果是跳跃过程中, 设置横向速度, 方向向左, 大小为4像素 if(rmain.movey!=0) { rmain.jumpx=-4; } rmain.movex=-4; // 设置横向速度, 方向向左, 大小为4像素 rmain.idirec=1; // 设置玩家方向, 向左 break; case KEY_X: // X键跳 // 如果已经是跳跃状态,不作处理,代码中断 if(rmain.movey!=0) break; // 设置纵向速度,方向向上(负值),大小为13 rmain.movey=-SPEED_JUMP; // 将当前的横向速度,赋值给“跳跃”中的横向速度 rmain.jumpx=rmain.movex; break; case KEY_Z: // FIRE if(iBeginFire) break; // 如果已经开始攻击,代码中断 iTimeFire=0; // 初始化子弹间隔时间 iBeginFire=1; // 置1,表示开始攻击 break;
按键松开
void GAMEMAP::KeyUpProc(int iKey){ // 松开左右键,清除横向速度 case VK_RIGHT: rmain.movex=0; break; case VK_LEFT: rmain.movex=0; break; case KEY_X: // 跳// 不能清除跳跃的横向速度jumpx// 例如,移动过程中起跳,整个跳跃过程中都要有横向速度 break; case KEY_Z: // FIRE iBeginFire=0; // 停止攻击 break;
控制移动
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ case WM_TIMER: switch(gamemap.iGameState) { case GAME_IN: rmain.Move();//人物移动 …… break;
每45毫秒产生一个WM_TIMER消息,在GAME_IN状态下,调用各种检测函数。其中rmain.Move()就是不断检测玩家动作属性,实现移动。
void MYROLE::Move(){ if(0 == movey) { // 如果不是跳跃, 横向移动 MoveOffset(movex, 0); } else { // 跳跃, 先横向移动, 再纵向移动 MoveOffset(jumpx, 0); MoveOffset(0, movey); } // 玩家帧控制 ”纠错法” if(movex<0 && iFrame<3) { iFrame=3; // 如果玩家向左移动, 而图片向右, 则设置为3(第4张图片) } if(movex>0 && iFrame>=3) { iFrame=0; // 如果玩家向右移动, 而图片向右, 则设置为0(第1张图片) } // 帧刷新 if(movex!=0) { if(0==idirec) iFrame=1-iFrame; // 如果方向向右, 图片循环播放0,1帧 else iFrame=7-iFrame; // 如果方向向左, 图片循环播放3,4帧 } if(movey!=0) {// 跳跃过程中, 帧设置为0(向右),3(向左)// 帧刷新后, 重新设置帧, 就实现了跳跃过程中, 图片静止 iFrame=idirec*3; } // 跳跃控制 if(movey<0) { // 向上运动(纵向速度movey为负值) jumpheight+=(-movey); // 增加跳跃高度 // 重力影响,速度减慢 if(movey<-1) { movey++; } // 到达顶点后向下落, 最大跳跃高度为JUMP_HEIGHT * 32, 即3个格子的高度 if(jumpheight >= JUMP_HEIGHT * 32) { jumpheight = JUMP_HEIGHT * 32; // 跳跃高度置为最大 movey=4; // 纵向速度置为4, 表示开始下落 } } else if(movey>0) { // 下落过程, 跳跃高度减少 jumpheight -= movey; // 重力影响,速度增大 movey++; }
玩家移动
void MYROLE::MoveOffset(int x,int y){ // 横纵增量为0,不移动,代码结束 if(x==0 && y==0) return; // 如果碰到物体,不移动,代码结束 if(!gamemap.RoleCanMove(x,y)) return; // 修改玩家坐标 xpos+=x; ypos+=y; // 判断是否超出左边界 if(xpos<minx) xpos=minx; // 设置玩家坐标为左边界 // 判断是否超出右边界 if(xpos>maxx) xpos=maxx;
碰撞检测
无论行走,跳跃,都是用函数MoveOffset操纵玩家坐标。这时,就要判断是否碰到物体。如果正在行走,则不能前进;如果是跳跃上升,则开始下落。
int GAMEMAP::RoleCanMove(int xoff, int yoff){ int canmove=1;// 初始化, 1表示能移动 for(i=0;i<iMapObjNum;i++) { if( RECT_HIT_RECT(玩家坐标加增量,地图物品坐标)) { // 碰到物体,不能移动 canmove=0; if(yoff<0) { // 纵向增量为负(即上升运动), 碰到物体开始下落 rmain.movey=1; } if(yoff>0) { // 纵向增量为正(即下落运动), 碰到物体, 停止下落 rmain.jumpheight=0; // 清除跳跃高度 rmain.movey=0; // 清除纵向速度 rmain.ypos=MapArray[i].y*32-32;// 纵坐标刷新,保证玩家站在物品上 } break; } } return canmove;
玩家移动的过程中,要不断检测是否站在地图物品上。如果在行走过程中,且没有站在任何物品上,则开始下落。
int GAMEMAP::CheckRole(){ if(rmain.movey == 0 ) { // 检测角色是否站在某个物体上 for(i=0;i<iMapObjNum;i++) { // 玩家的下边线,是否和物品的上边线重叠 if( LINE_ON_LINE(rmain.xpos, rmain.ypos+32, 32, MapArray[i].x*32, MapArray[i].y*32, MapArray[i].w*32) ) { // 返回1,表示玩家踩在这个物品上 return 1; } } // 角色开始下落 rmain.movey=1; rmain.jumpx=0;// 此时要清除跳跃速度,否则将变成跳跃,而不是落体 return 0;
至此,玩家在这个虚拟世界可以做出各种动作,跳跃,行走,攻击。增强版中,加入了水管,玩家在进出水管,就需要动画。
二十四、角色动画玩家在进出水管的时候,需要进入水管、从水管中升起两个动画。当动画播放结束后,切换到新的地图。动画播放过程中,禁止键盘响应,即玩家不能控制移动。
玩家进水管
地图物品中,水管分两个,进水管(玩家进入地图)和出水管(从别的地图返回)。两种水管对应不同的图片ID:
#define ID_MAP_PUMP_IN 9#define ID_MAP_PUMP_OUT 10
玩家进入水管的检测:
int GAMEMAP::KeyProc(int iKey){ // 检测玩家按“下”,如果玩家站在进水管上,开始播放动画 case VK_DOWN: for(i=0;i<iMapObjNum;i++) { if( LINE_IN_LINE(玩家坐标的下边界,地图物品的上边界)) { // 判断是否站在进水管上 if(MapArray[i].id == ID_MAP_PUMP_IN) { // 如果站在设置角色动画方式,向下移动 rmain.SetAni(ROLE_ANI_DOWN); iGameState=GAME_PUMP_IN; // 设置游戏状态:进水管 c1.ReStart(TIME_GAME_PUMP_WAIT);// 计时2秒 } } } break;
动画设置函数:
void MYROLE::SetAni(int istyle){ iAniStyle=istyle; // 设置动画方式 iparam1=0; // 参数初始化为0 iAniBegin=1; // 表示动画开始播放
iparam1是动画播放中的一个参数,根据动画方式不同,可以有不同的含义。
动画播放
玩家角色显示函数:
void MYROLE::Draw(){ //判断是否播放动画,即iAniBegin为1 if(iAniBegin) { PlayAni(); //播放当前动画 }
动画播放函数:
void MYROLE::PlayAni(){ // 根据不同的动画方式,播放动画 switch(iAniStyle) { case ROLE_ANI_DOWN: // 玩家进入水管的动画,iparam1表示下降的距离 if(iparam1>31) { // 下降距离超过31(即图片高度),玩家完全进入水管,无需图片显示 break; } // 玩家没有完全进入水管,截取图片上半部分,显示到当前的坐标处 SelectObject(hdcsrc,hBm); BitBlt(hdcdest, xpos,ypos+iparam1, width,height/2-iparam1, hdcsrc, iFrame*width,height/2,SRCAND); BitBlt(hdcdest, xpos,ypos+iparam1, width,height/2-iparam1, hdcsrc, iFrame*width,0,SRCPAINT); // 增加下降高度 iparam1++; break;
玩家进入水管后,切换地图
在时间片的处理中,当GAME_PUMP_IN状态结束,切换地图,并设置玩家动画:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ case GAME_PUMP_IN: if(c1.DecCount()) { gamemap.ChangeMap(); // 切换地图 gamemap.SetGameState(GAME_IN); // 设置游戏状态 c1.ReStart(TIME_GAME_IN); // 计时300秒 rmain.SetAni(ROLE_ANI_UP); // 设置动画,图片上升 } InvalidateRect(hWnd,NULL,false); break;
从水管中上升
根据不同的动画方式,播放动画:
void MYROLE::PlayAni(){ switch(iAniStyle) { case ROLE_ANI_UP: if(iparam1>31) { // 如果上升距离超过31(图片高度),动画结束 break; } // 人物上升动画,截取图片上部,显示到当前坐标 SelectObject(hdcsrc,hBm); BitBlt(hdcdest, xpos,ypos+32-iparam1, width,iparam1, hdcsrc, iFrame*width,height/2,SRCAND); BitBlt(hdcdest, xpos,ypos+32-iparam1, width,iparam1, hdcsrc, iFrame*width,0,SRCPAINT); // 增加上升距离 iparam1++; // 如果上升距离超过31(图片高度) if(iparam1>31) { iAniBegin=0; // 动画结束,清除动画播放状态 }
至此,两个动画方式都实现了。但是,如果在动画播放过程中,玩家按左右键,移动,就会出现,角色一边上升,一边行走,甚至跳跃。怎样解决?如果播放动画,屏蔽键盘响应。
int GAMEMAP::KeyProc(int iKey){ case GAME_IN: // 如果人物正在播放动画,拒绝键盘响应 if(rmain.IsInAni()) { break; }
这样,在播放过程中,不受玩家按键影响。玩家所有功能全部实现,接下来看一下整个游戏逻辑。
二十五、GAMEMAP全局变量类所有游戏数据都需要封装到实际的变量中。整个游戏,就是用类GAMEMAP表示的。
GAMEMAP类定义如下所示:
class GAMEMAP{public: // 加载地图 int LoadMap(); // 初始化所有游戏数据 void Init(); // 初始化场景数据 void InitMatch(); // 显示地图物品 void Show(MYANIOBJ & bmobj); // 显示地图背景物品,河流,树木 void ShowBkObj(MYANIOBJ & bmobj); // 显示所有动态元素,金币,小怪等 void ShowAniObj(MYANIOBJ & bmobj); // 显示LIFE, WORLD提示 void ShowInfo(HDC h); // 显示金钱, 攻击提示信息 void ShowOther(HDC h); // 键盘处理 int KeyProc(int iKey); // 按键抬起处理 void KeyUpProc(int iKey); // 移动视图 void MoveView(); // 设置视图起始坐标 void SetView(int x); // 设置视图状态, 函数没有使用 void SetViewState(int i); // 设置游戏状态 void SetGameState(int i); // 碰撞检测 // 判断人物能否移动 int RoleCanMove(int xoff, int yoff); // 检测人物是否站在物品上 int CheckRole(); // 检测所有动态元素之间的碰撞, 子弹和蘑菇兵的生成 int CheckAni(int itimeclip); // 清除一个小怪 void ClearEnemy(int i); // 清除一个金币 void ClearCoin(int i); // 帧刷新 void ChangeFrame(int itimeclip); // 逻辑检测 int IsWin(); // 胜负检测 void Fail(); // 失败处理 void Fail_Wait(); //失败后, 加载地图 // 地图切换 void ChangeMap(); // 错误检查 void CodeErr(int i); // 菜单控制 void ShowMenu(MYANIOBJ & bmobj); // 构造和析构函数 GAMEMAP(); ~GAMEMAP(); // 数据部分 int iMatch; // 当前关卡 int iLife; // 游戏次数 int iGameState; // 游戏状态 // 地图物品数组 游标 struct MapObject MapArray[MAX_MAP_OBJECT]; int iMapObjNum; // 地图背景物品数组 游标 struct MapObject MapBkArray[MAX_MAP_OBJECT]; int iMapBkObjNum; // 小怪火圈数组 游标 struct ROLE MapEnemyArray[MAX_MAP_OBJECT]; int iMapEnemyCursor; // 金币武器包 数组 游标 struct MapObject MapCoinArray[MAX_MAP_OBJECT]; int iCoinNum; // 下一个地图编号, 变量没有使用 int iNextMap; // 玩家数据 int iMoney; // 金钱数量 int iAttack; // 攻击方式 // 视图数据 int viewx; // 视图横坐标 int viewy; // 视图纵坐标 int iViewState; // 视图状态 // 地图信息 struct MAPINFO mapinfo; // frame control int ienemyframe; // 小怪帧 int ibkobjframe; // 背景物品帧 // 子弹数组 游标 struct ROLE FireArray[MAX_MAP_OBJECT]; int iFireNum; int iTimeFire; // 两个子弹的时间间隔 int iBeginFire; // 是否开始攻击 // 爆炸效果,+10字样 数组 游标 struct MapObject BombArray[MAX_MAP_OBJECT]; int iBombNum; // 攻击对象提示 char AttackName[20]; // 名称 int iAttackLife; // 生命值 int iAttackMaxLife; // 最大生命值 // 菜单部分 int iMenu; // 当前菜单项编号 // 屏幕缩放 int iScreenScale; // 是否是默认窗口大小};
所有的数据都存储到一系列全局变量中:
// 所有菜单文字char *pPreText[]={ "操作: Z:子弹 X:跳 方向键移动 W:默认窗口大小",};// 所有动态元素的图片宽 高int mapani[2][10]={{32,32,64,32,32,52,64,32,64,32},{32,32,64,32,32,25,64,32,64,32},};// 所有地图物品的图片宽 高int mapsolid[2][13]={{32,32,32,32,32,32,32,32,32,64,64,20,100},{32,32,32,32,32,32,32,32,32,64,64,10,12}};// 所有背景物品的图片宽 高int mapanibk[2][4]={{96,96,96,96},{64,64,64,64},};// 旋风的宽 高int mapanimagic[2][1]={{192},{128}};// 所有地图信息struct MAPINFO allmapinfo[]={{1,3,66,7,0,5},{2,4,25,4,1,5},{MAX_MATCH,-1,-1,-1,2,5},{-1,0,3,8,3,1},{-1,1,3,8,3,2}};// 普通蘑菇兵模板struct ROLE gl_enemy_normal={ 0, 0, 32, 32, ID_ANI_ENEMY_NORMAL,};// 跟踪打印// FILEREPORT f1;// 计时器MYCLOCK c1;// 游戏全部逻辑GAMEMAP gamemap;//各种图片MYBITMAP bmPre; // 菜单背景,通关,GAMEOVERMYBKSKY bmSky; // 天空背景MYANIOBJ bmMap; // 地图物品MYANIOBJ bmMapBkObj; // 地图背景物品MYANIOBJ bmAniObj; // 所有动态元素MYROLE rmain; // 玩家角色MYANIMAGIC bmMagic; // 旋风// 字体管理MYFONT myfont; // 字体// DC句柄HDC hwindow,hscreen,hmem,hmem2;// 窗口DC, 地图DC, 临时DC,临时DC2// 空位图HBITMAP hmapnull;// 窗口大小int wwin,hwin; // 显示器屏幕宽 高int wwingame,hwingame; // 当前窗口宽 高HWND hWndMain; // 窗口句柄
二十六、菜单控制 窗口缩放菜单控制:开始菜单只有两项:0项“开始游戏”,1项“操作说明”,菜单编号用iMenu表示。
菜单文字显示:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ // 在WM_PAINT绘制消息中: case GAME_PRE: gamemap.viewx=0; // 设置视图坐标 bmPre.Stretch(2,2,0); // 菜单背景图片 myfont.SelectFont(0); // 设置文字字体 myfont.SelectColor(TC_BLACK, TC_YELLOW_0);// 设置文字颜色 // 显示3行文字 myfont.ShowText(150,260,pPreText[4]); myfont.ShowText(150,290,pPreText[5]); myfont.ShowText(150,320,pPreText[6]); // 显示箭头 gamemap.ShowMenu(bmAniObj); break;
菜单箭头显示:
void GAMEMAP::ShowMenu(MYANIOBJ & bmobj){ // 根据当前菜单编号,决定箭头的纵坐标 bmobj.PlayItem(115,280+iMenu*30, ID_ANI_MENU_ARROW);
箭头会不停闪烁,怎样刷新帧?就在显示函数PlayItem中,如下
void MYANIOBJ::PlayItem(int x,int y,int id){ // 按照坐标,ID,显示图片 …… // 切换当前帧 iframeplay=(iframeplay+1)%2;}
菜单的按键响应:
int GAMEMAP::KeyProc(int iKey){ switch(iGameState) { case GAME_PRE:// 选择游戏菜单 switch(iKey) { case 0xd:// 按下回车键 switch(iMenu) { case 0: // 菜单项0“开始游戏” c1.ReStart(TIME_GAME_IN_PRE); // 计时两秒 iGameState=GAME_IN_PRE;// 进入游戏LIFE WORLD提示状态 break; case 1: // 菜单项1“操作说明” SetGameState(GAME_HELP); // 进入游戏状态“操作说明”,显示帮助信息 break; } break; case VK_UP: // 按方向键“上”,切换菜单项 iMenu=(iMenu+1)%2; break; case VK_DOWN: // 按方向键“下”,切换菜单项 iMenu=(iMenu+1)%2; break; } return 1; // 表示立即刷新画面
窗口缩放功能的实现
窗口是否为默认大小,用iScreenScale表示。iScreenScale为1,表示窗口被放大,将视图区域缩放到当前的窗口大小。
初始化由构造函数完成,窗口大小检测,用户拉动窗口,触发WM_SIZE消息。
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ case WM_SIZE: // 获取当前窗口宽 高 wwingame=LOWORD(lParam); hwingame=HIWORD(lParam); // 如果窗口小于默认大小,仍然设置为默认数值,图像不缩放 if( wwingame <= GAMEW*32 || hwingame <= GAMEH*32) { wwingame = GAMEW*32; hwingame = GAMEH*32; gamemap.iScreenScale = 0; } else { // 宽度大于高度的4/3 if(wwingame*3 > hwingame*4) { wwingame = hwingame*4/3; // 重新设置宽度 } else { hwingame = wwingame*3/4; // 重新设置高度 } gamemap.iScreenScale =1; // 表示图像需要缩放 } break;
图像缩放,在WM_PAINT消息处理中,绘制完所有图片后,根据iScreenScale缩放视图区域的图像。
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ // 判断是否缩放图像 if(gamemap.iScreenScale) { // 缩放视图区域图像 StretchBlt(hwindow,0,0, wwingame,hwingame, hscreen, gamemap.viewx,0, GAMEW*32,GAMEH*32, SRCCOPY); } else { // 不缩放,视图区域拷贝到窗口 BitBlt(hwindow, 0, 0, GAMEW*32, GAMEH*32, hscreen, gamemap.viewx, 0, SRCCOPY); }
二十七、程序框架WinProc怎样把所有的功能组织起来,形成一个完整的游戏呢?游戏状态。不同的游戏状态下,对应不同的图片显示、逻辑处理、按键响应。这样就形成了一个结构清晰的框架。各个模块相对独立,也方便扩展。
由于是消息处理机制,所有功能对应到消息处理函数WndProc,程序框架如下:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ 绘图消息WM_PAINT: 状态1:状态1绘图。 状态2:状态2绘图。 …… 计时消息WM_TIMER: 状态1:状态1逻辑处理。发WM_PAINT消息,通知绘图。 状态2:状态2逻辑处理。发WM_PAINT消息,通知绘图。 …… 按键消息WM_KEYDOWN WM_KEYUP: 状态1:状态1逻辑处理。发WM_PAINT消息,通知绘图。 状态2:状态2逻辑处理。发WM_PAINT消息,通知绘图。 ……}
程序入口:
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow){ MyRegisterClass(hInstance); // 类注册 // 初始化 if (!InitInstance (hInstance, nCmdShow)) { return FALSE; } // 消息循环 while (GetMessage(&msg, NULL, 0, 0)) { if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg)) { TranslateMessage(&msg); DispatchMessage(&msg); } } return msg.wParam;}
整个消息处理循环,是默认的结构。InitInstance函数复杂初始化。类注册函数MyRegisterClass中,把菜单栏取消了,即wcex.lpszMenuName=NULL,其它不变。
消息处理函数:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM){ switch (message) { case WM_PAINT: // 窗口DC hwindow = BeginPaint(hWnd, &ps); // 初始化空图 SelectObject(hscreen,hmapnull); switch(gamemap.iGameState) { case GAME_ERR: // 地图文件加载错误 gamemap.viewx=0; // 视图坐标 // 显示错误信息 bmPre.Stretch(2,2,0); // 背景图片 myfont.SelectColor(TC_WHITE,TC_BLACK);// 文字颜色 myfont.SelectFont(0); // 字体 myfont.ShowText(150,290,pPreText[3]); // 显示文字 break; case GAME_PRE: // 菜单显示 (代码略) break; case GAME_HELP: // 菜单项“操作说明” (代码略) break; case GAME_IN_PRE: // 游戏LIFE,WORLD提示 gamemap.viewx=0; // 视图坐标 bmPre.Stretch(2,2,2); // 背景图片 gamemap.ShowInfo(hscreen); // 显示LIFE,WORLD break; case GAME_IN: // 游戏进行中 case GAME_WIN: // 游戏进行中,过关 case GAME_FAIL_WAIT: // 游戏进行中,失败 case GAME_PUMP_IN: // 游戏进行中,进入水管 bmSky.DrawRollStretch(2,2,gamemap.mapinfo.iBackBmp);// 背景图片 gamemap.ShowBkObj(bmMapBkObj); // 地图背景物品 gamemap.Show(bmMap); // 地图物品 gamemap.ShowAniObj(bmAniObj); // 动态元素 gamemap.ShowOther(hscreen); // 金钱数量,攻击提示 rmain.Draw(); // 玩家角色 break; case GAME_OVER: // 游戏结束 gamemap.viewx=0; bmPre.Stretch(2,2,1); // 输出图片GAME OVER break; case GAME_PASS: // 游戏通关 gamemap.viewx=0; bmPre.Stretch(2,2,3); // 输出图片通关 break; } if(gamemap.iScreenScale) { // 窗口缩放,放大视图区域 StretchBlt(hwindow,0,0,wwingame,hwingame,hscreen, gamemap.viewx,0,GAMEW*32,GAMEH*32,SRCCOPY); } else { // 拷贝视图区域 BitBlt(hwindow,0,0,GAMEW*32,GAMEH*32,hscreen, gamemap.viewx, 0, SRCCOPY); } EndPaint(hWnd, &ps); // 绘图结束 break; case WM_TIMER: switch(gamemap.iGameState) { case GAME_PRE: // 游戏菜单 c1.DecCount();// 计时器减1 if(0 == c1.iNum%MENU_ARROW_TIME) { // 每隔10个时间片(即箭头闪烁的时间),刷新屏幕 InvalidateRect(hWnd,NULL,false); } break; case GAME_IN_PRE: // 游戏LIFE,WORLD提示 if(c1.DecCount()) { // 计时结束,进入游戏。 gamemap.SetGameState(GAME_IN); c1.ReStart(TIME_GAME_IN); // 启动计时300秒 } InvalidateRect(hWnd,NULL,false); // 刷新屏幕 break; case GAME_IN: // 游戏进行中 case GAME_WIN: // 游戏进行中,过关 c1.DecCount();// 计时器计时 if(0 == c1.iNum%SKY_TIME) { bmSky.MoveRoll(SKY_SPEED);// 云彩移动 } gamemap.ChangeFrame(c1.iNum);// 帧控制 rmain.Move();// 人物移动 gamemap.MoveView();// 视图移动 gamemap.CheckRole();// 角色检测 gamemap.CheckAni(c1.iNum);// 逻辑数据检测 gamemap.IsWin(); // 胜负检测 InvalidateRect(hWnd,NULL,false); // 刷新屏幕 break; case GAME_WIN_WAIT: // 游戏进行中,过关,停顿2秒 if(c1.DecCount()) { // 计时结束,进入游戏LIFE,WORLD提示 gamemap.SetGameState(GAME_IN_PRE); InvalidateRect(hWnd,NULL,false); // 刷新屏幕 } break; case GAME_PUMP_IN: // 游戏进行中,进入水管,停顿2秒 if(c1.DecCount()) { // 计时结束,切换地图 gamemap.ChangeMap(); gamemap.SetGameState(GAME_IN); // 进入游戏 c1.ReStart(TIME_GAME_IN); // 启动计时300秒 rmain.SetAni(ROLE_ANI_UP); // 设置玩家出水管动画 } InvalidateRect(hWnd,NULL,false); // 刷新屏幕 break; case GAME_FAIL_WAIT: // 游戏进行中,失败,停顿2秒 if(c1.DecCount()) { // 计时结束,加载地图 gamemap.Fail_Wait(); } break; case GAME_PASS: //全部通关,停顿2秒 if(c1.DecCount()) { // 计时结束,设置游戏状态:游戏菜单 gamemap.SetGameState(GAME_PRE); } InvalidateRect(hWnd,NULL,false); // 刷新屏幕 break; case GAME_OVER: // 游戏结束,停顿3秒 if(c1.DecCount()) { // 计时结束,设置游戏状态:游戏菜单 gamemap.SetGameState(GAME_PRE); } InvalidateRect(hWnd,NULL,false); // 刷新屏幕 break; } break; case WM_KEYDOWN: // 按键处理 if(gamemap.KeyProc(wParam)) InvalidateRect(hWnd,NULL,false); break; case WM_KEYUP: // 按键“抬起”处理 gamemap.KeyUpProc(wParam); break; case WM_SIZE: // 窗口大小调整,代码略 break; case WM_DESTROY: // 窗口销毁,释放DC, 代码略 break;
终于,所有模块全部完成,游戏制作完成。整个工程差不多3000行代码。第一个制作超级玛丽的程序员,是否用了这么多代码,肯定没有。当时,应该是汇编。3000行C++代码,还达不到汇编程序下的地图规模、图片特效、游戏流畅度。可见,程序的乐趣无穷。
二十八、InitInstance函数说明BOOL InitInstance(HINSTANCE, int){ // 默认窗口大小 wwingame=GAMEW*32; hwingame=GAMEH*32; // 显示器屏幕大小 wwin=GetSystemMetrics(SM_CXSCREEN); hwin=GetSystemMetrics(SM_CYSCREEN); // 创建窗口 hWnd = CreateWindow(szWindowClass, szTitle, WS_OVERLAPPEDWINDOW, (wwin-wwingame)/2, (hwin-hwingame)/2, wwingame, hwingame+32, NULL, NULL, hInstance, NULL); // 设置窗口句柄 hWndMain=hWnd; //DC hwindow=GetDC(hWnd); // 窗口DC hscreen=CreateCompatibleDC(hwindow); // 地图绘制DC hmem=CreateCompatibleDC(hwindow); // 临时DC hmem2=CreateCompatibleDC(hwindow); // 临时DC // 用空位图初始化各个DC hmapnull=CreateCompatibleBitmap(hwindow,GAMEW*32*5,GAMEH*32); SelectObject(hscreen,hmapnull); SelectObject(hmem,hmapnull); SelectObject(hmem2,hmapnull); // 释放窗口DC ReleaseDC(hWnd, hwindow); // 位图初始化 // 菜单背景图片,通关,GAMEOVER bmPre.Init(hInstance,IDB_BITMAP_PRE1,1,5); bmPre.SetDevice(hscreen,hmem,GAMEW*32,GAMEH*32); bmPre.SetPos(BM_USER,0,0); // 天空背景图片 bmSky.Init(hInstance,IDB_BITMAP_MAP_SKY,1,4); bmSky.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32); bmSky.SetPos(BM_USER,0,0); // 地图物品图片 bmMap.Init(hInstance,IDB_BITMAP_MAP,1,1); bmMap.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32); bmMap.InitAniList(mapsolid[0],mapsolid[1], sizeof(mapsolid[0])/sizeof(int),0); // (其它位图代码略) // 玩家图片初始化 rmain.Init(hInstance,IDB_BITMAP_ROLE,5,1); rmain.SetDevice(hscreen,hmem,GAMEW*32*MAX_PAGE,GAMEH*32); // 字体初始化 myfont.SetDevice(hscreen); // 游戏数据初始化 gamemap.Init(); // 玩家角色初始化坐标,数据初始化 rmain.SetPos(BM_USER,3*32,8*32); rmain.InitRole(0,GAMEW*32*MAX_PAGE-32); // 文件检查 if(!gamemap.LoadMap()) { // 文件加载失败,设置游戏状态:文件错误 gamemap.CodeErr(ERR_MAP_FILE); } // 计时器初始化 c1.SetDevice(hscreen); // 计时器启动,每40毫秒一次WM_TIMER消息 c1.Begin(hWnd, GAME_TIME_CLIP ,-1); // 设置显示方式,显示窗口 ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd);}
2 评论
76 下载
2018-10-04 21:31:36
下载需要15点积分