团队项目(2.1) -- 飞机躲避小游戏
这个项目开始于2019年下半年,也就是大二上长学期的时候,当时申报了一个校级srtp项目,自拟题为“基于瞳孔检测的注意力检测方法研究”,而这一部分是作为最后眼控展示的一个小平台。有趣的是,当时开题答辩第一个上台,被怼得要死,中期答辩也是,好在结题答辩在我一波疯狂舔审核老师的操作后,竟然混了个校级优秀项目,后来还在大学生创新创业成果展贴了咱的展板(我的大头...),不过yysy,现在看起来这项目确实很low哈哈哈哈哈。
这个项目估计会分成三章更完:游戏部分、人脸检测和瞳孔检测部分
以及两个统一的完整展示。下面是第一部分正文:
一、开发平台及辅助工具
开发平台:Visual Studio
编程语言:C++ / C语言
界面开发:EasyX(点击访问)
二、游戏介绍
1、主界面
启动游戏之后主界面如下图所示;

通过按下键盘最右边的方向键 “↑、←、↓、→” 可以操纵飞机移动到"开始游戏"字样处开始游戏,也可以同时按下类似“←↑”这样的组合实现斜向移动;
进入游戏应该可以听到游戏音乐,任意时刻点击右上角的喇叭按钮可以开关音乐;
2、游玩界面
进入游戏之后,操纵方式同前,界面如下:

此时应该在玩家周围自界面最顶端产生了随机位置、随机数量的敌机;
通过操控玩家飞机进行躲避,可以看到左上角分数累加;
当玩家与敌机发生碰撞之后,玩家会扣血,当玩家血量扣完时,游戏结束;
玩家可以按下空格键发射子弹进行攻击,当敌机与子弹发生碰撞后,敌机会扣血,当敌机血量扣完时,会从游戏界面中消失;
3、暂停
点击右上角的Pokemon精灵球可以暂停/继续游戏,
4、结束界面
玩家死亡后,结束界面如下,玩家可以选择再来一次或者退出游戏;
当玩家游玩分数高于最高纪录时,在游戏结束时会保存最高分。

三、游戏实现
1、贴图准备
背景图网上随便找的一张,其余贴图都是自己用AI画的(当时暑假留校比较闲,成天没事就搞这些花里胡哨的嘻嘻)
2、参数等预定义
下面这些主要是贴图的一些属性的预定义,而方向键的定义按照习惯还是定义成了wasd等字母按键了,实际上用的还是键盘上的上下左右键:
//贴图属性 #define PlayerBlock 75 //玩家方块大小,等于玩家飞机图像文件大小75x75 #define PlayerBlood 100 //玩家血量 #define PlayerSpeed 3 //玩家移动速度,数值越大越快 #define EnemyBlock 80 //敌机方块大小,等于敌机飞机图像文件大小80x80 #define EnemyBlood 100 //敌机血量 #define EnemySpeed 3 //敌机移动速度,数值越大越快 #define Enemy_NUM 15 //屏幕最多出现的敌机数量 #define BulletBlock 20 //子弹方块大小 #define Bullet_NUM 20 //屏幕最多出现的子弹数量 #define BulletSpeed 5 //子弹移动速度,数值越大越快 //定义方向键 #define LEFT 'a' #define UP 'w' #define RIGHT 'd' #define DOWN 's' #define L_UP 'q' #define R_UP 'e' #define L_DOWN 'z' #define R_DOWN 'x'
考虑到各个贴图的属性都差不多,这里就定义成一个结构体了,别问为啥不用Class类,问就是喜欢一个个函数,用起来舒服就完事!需要注意的是Block结构体成员point保存的是贴图方块的左上角顶点,这在第8点碰撞检测部分需要特别注意。
完整定义如下:
struct SinglePoint { int x = 0;//定义点坐标 int y = 0; }; struct Block { IMAGE image;//图片文件 SinglePoint point;//左顶点 uint8_t aliveflag = 0;//是否显示使能,默认不显示 uint8_t kickedflag = 0;//是否被攻击,默认未被攻击 long alivetime;//存活时间 float blood;//血量 };
3.初始化界面函数
在该函数中,依次进行:游戏背景音乐、子弹音效、死亡音效的装载;根据电脑分辨率动态调整游戏界面尺寸;各贴图装载;玩家飞机位置初始化。
//初始化界面 void Menu() { mciSendString(TEXT("open music\\地下BGM.wav alias backmusic"), NULL, 0, NULL); mciSendString(TEXT("open music\\der.wav alias begin"), NULL, 0, NULL); mciSendString(TEXT("open music\\SuperMariogameover.wav alias gameover"), NULL, 0, NULL); //playmusic(play, backmusic); uint16_t ScreenW = GetSystemMetrics(SM_CXFULLSCREEN); //获取屏幕宽度,即横向分辨率 uint16_t ScreenH = GetSystemMetrics(SM_CYFULLSCREEN); //获取屏幕高度 // 初始化绘图区域窗口 WindowW = 5 * ScreenW / 5; WindowH = 5 * ScreenH / 5; initgraph(WindowW, WindowH, NOMINIMIZE | SHOWCONSOLE); setbkmode(TRANSPARENT);//设置背景透明 //装载背景图、玩家飞机图、敌机障碍图 loadimage(&back, "JPG", MAKEINTRESOURCE(BACK_JPG), WindowW, WindowH); loadimage(&(player.image), "JPG", MAKEINTRESOURCE(Player0)); loadimage(&Planeimg[0], "JPG", MAKEINTRESOURCE(Plane0)); loadimage(&Planeimg[1], "JPG", MAKEINTRESOURCE(Plane1)); loadimage(&Planeimg[2], "JPG", MAKEINTRESOURCE(Plane2)); loadimage(&pokemon, "JPG", MAKEINTRESOURCE(Click)); goon = pokemon;//开始键 rotateimage(&pokemon, &pokemon, -PI / 2);//暂停键 loadimage(&speaker[0], "JPG", MAKEINTRESOURCE(Silence)); loadimage(&speaker[1], "JPG", MAKEINTRESOURCE(Sound)); //初始化背景 putimage(0, 0, &back); //初始化玩家坐标 player.point.x = WindowH - 1 - PlayerBlock; player.point.y = (WindowW - PlayerBlock) / 2; /************掩码显示************* *putimage(player.point.y, player.point.x, &(player_cover.image), SRCAND);//掩码图与运算 *putimage(player.point.y, player.point.x, &(player.image), SRCPAINT);//原图或运算 *********************************/ putimage(player.point.y, player.point.x, &(player.image), SRCAND);//初始化玩家飞机坐标 }
4.敌机产生
为方便理解,作以下解释:
1、enemy[]为上述Block结构类型,存储数量为Enemy_NUM的敌机信息(即结构体成员信息)
2、在该项目中,约定行为x,列为y;
3、敌机产生位置:x坐标默认为0,y坐标为玩家位置y坐标左右各3个敌机贴图大小(即3*EnemyBlock)的随机位置;
4、敌机移动速度:引入速度变量EnemySpeed,本质是每次循环敌机下行像素值,同时为避免刷新过快;引入刷新控制时间Nowtime,通过判断Nowtime是奇数还是偶数决定是否进行敌机贴图刷新;
5、敌机移动到最底端:若玩家未死亡,分数加一,对应敌机各属性清零,为下一次产生做准备。
下面是完整程序:
void Enemyplaneshow(uint8_t number, uint8_t** Board) { if (enemy[number].point.x == 0) { enemy[number].image = Planeimg[rand() % 3];//敌机机型图随机 enemy[number].blood = EnemyBlood;//血量初始化 int Locationrand = rand() % (6 * EnemyBlock) + player.point.y - 3 * EnemyBlock;//获得一个在玩家左右三个敌机位置区间的随机数 enemy[number].point.y = Limit_num(0, Locationrand, WindowW - EnemyBlock);//限幅 Board[enemy[number].point.x][enemy[number].point.y] = 1;//敌机面板置1 enemy[number].alivetime = clock(); } putimage(enemy[number].point.y, enemy[number].point.x, &(enemy[number].image), SRCAND);//显示敌机飞机 //获取当前时间,用于控制速度 long Nowtime = (clock() - enemy[number].alivetime) * 1000.0 / CLOCKS_PER_SEC; //更新敌机坐标 if (enemy[number].point.x < WindowH - EnemySpeed) { if (Nowtime % 2 == 0) { Board[enemy[number].point.x][enemy[number].point.y] = 0;//移动前敌机面板清0 enemy[number].point.x += EnemySpeed; Board[enemy[number].point.x][enemy[number].point.y] = 1;//移动后敌机面板置1 } else enemy[number].point.x = enemy[number].point.x; } else { Board[enemy[number].point.x][enemy[number].point.y] = 0;//结束敌机面板清0 enemy[number].point.x = 0; enemy[number].aliveflag = 0; enemy[number].kickedflag = 0; Score++;//飞机出界玩家还未死亡,得分加一; } }
5、键盘输入检测函数
为了解决getch()和getchar()等获取键盘输入函数存在的需要等待用户输入而造成的程序卡死问题(主要是影响贴图刷新,不然就卡成PPT了呜呜呜),调用Windows的API,利用函数GetAsyncKeyState异步获取键盘输入,函数定义可以点击查阅官方教程。
完整程序如下,分别检测键盘上的方向键“↑、←、↓、→”并返回按键值:
//输入检测 char Inputcheck() { if (GetAsyncKeyState(VK_LEFT) & 0x8000) { if (GetAsyncKeyState(VK_UP) & 0x8000) return 'q'; else if (GetAsyncKeyState(VK_DOWN) & 0x8000) return 'z'; else return 'a'; } else if (GetAsyncKeyState(VK_RIGHT) & 0x8000) { if (GetAsyncKeyState(VK_UP) & 0x8000) return 'e'; else if (GetAsyncKeyState(VK_DOWN) & 0x8000) return 'x'; else return 'd'; } else if (GetAsyncKeyState(VK_UP) & 0x8000) return 'w'; else if (GetAsyncKeyState(VK_DOWN) & 0x8000) return 's'; return 0; }
6、玩家坐标更新
这一部分是放在main函数中的(其实吧主要是懒得封装了呜呜呜),每次执行完按键检测之后,利用switch case语句执行判断玩家坐标作何更新,其中PlayerSpeed用于控制玩家移动速度,很简单粗暴。完整程序如下:
Input = Inputcheck();//检测玩家方向控制的输入 //根据不同输入进行玩家飞机的移动 switch (Input) { case LEFT: player.point.y -= PlayerSpeed; player.point.y = Limit_num(0, player.point.y, WindowW - 1 - PlayerBlock); break; case RIGHT: player.point.y += PlayerSpeed; player.point.y = Limit_num(0, player.point.y, WindowW - 1 - PlayerBlock); break; case UP: player.point.x -= PlayerSpeed; player.point.x = Limit_num(0, player.point.x, WindowH - 1 - PlayerBlock); break; case DOWN: player.point.x += PlayerSpeed; player.point.x = Limit_num(0, player.point.x, WindowH - 1 - PlayerBlock); break; case L_UP: player.point.y -= PlayerSpeed; player.point.x -= PlayerSpeed; player.point.y = Limit_num(0, player.point.y, WindowW - 1 - PlayerBlock); player.point.x = Limit_num(0, player.point.x, WindowH - 1 - PlayerBlock); break; case L_DOWN: player.point.y -= PlayerSpeed; player.point.x += PlayerSpeed; player.point.y = Limit_num(0, player.point.y, WindowW - 1 - PlayerBlock); player.point.x = Limit_num(0, player.point.x, WindowH - 1 - PlayerBlock); break; case R_UP: player.point.y += PlayerSpeed; player.point.x -= PlayerSpeed; player.point.y = Limit_num(0, player.point.y, WindowW - 1 - PlayerBlock); player.point.x = Limit_num(0, player.point.x, WindowH - 1 - PlayerBlock); break; case R_DOWN: player.point.y += PlayerSpeed; player.point.x += PlayerSpeed; player.point.y = Limit_num(0, player.point.y, WindowW - 1 - PlayerBlock); player.point.x = Limit_num(0, player.point.x, WindowH - 1 - PlayerBlock); break; default: break; } //更新玩家飞机显示 putimage(player.point.y, player.point.x, &(player.image), SRCAND);
7、子弹产生
与敌机产生类似,使用队列数组存储各子弹位置便于绘图。不同的是,子弹方块与玩家方块一样均为正立的正三角形▲,不使用贴图而直接调用EasyX的绘图函数绘制,且产生位置为玩家机头位置,子弹绘制结果是一个个小的纸飞机嘻嘻。具体程序如下:
void Bulletshow(uint8_t number, uint8_t** Board) { //if (player.point.x < BulletBlock - 1) return;//玩家在最顶部时,直接返回 if (bullet[number].point.x == 0) { bullet[number].point.x = player.point.x - BulletBlock / 2;//行数设置为玩家顶部 bullet[number].point.y = Limit_num(0, player.point.y + PlayerBlock / 2, WindowW - EnemyBlock);//列数设置为玩家中轴并限幅 Board[bullet[number].point.x][bullet[number].point.y] = 1;//子弹面板置1 bullet[number].alivetime = clock(); } HRGN rgn = CreateRectRgn(bullet[number].point.y - BulletBlock / 2 + 4, bullet[number].point.x, bullet[number].point.y + BulletBlock / 2 - 4, bullet[number].point.x + BulletBlock / 2 * sqrt(3)); setcliprgn(rgn); DeleteObject(rgn); //画纸飞机 setfillcolor(WHITE);//设置填充色 setpolyfillmode(WINDING);//设置凹多边形填充 //分别对应四个顶点 POINT pts[] = { { bullet[number].point.y, bullet[number].point.x}, //上 { bullet[number].point.y - BulletBlock / 2, bullet[number].point.x + BulletBlock / 2 * sqrt(3) }, //左 { bullet[number].point.y , bullet[number].point.x + BulletBlock / 2}, //中 { bullet[number].point.y + BulletBlock / 2,bullet[number].point.x + BulletBlock / 2 * sqrt(3) } //右 }; solidpolygon(pts, 4); //画中间阴影直线 setlinecolor(0xD0D0D0); line(bullet[number].point.y, bullet[number].point.x - 1, bullet[number].point.y, bullet[number].point.x - 1 + 3 * BulletBlock / 5); setcliprgn(NULL); //获取当前时间,用于控制速度 long Nowtime = (clock() - bullet[number].alivetime) * 1000.0 / CLOCKS_PER_SEC; if (bullet[number].point.x > BulletSpeed) { if (Nowtime % 5 == 0) { Board[bullet[number].point.x][bullet[number].point.y] = 0;//移动前子弹面板清0 bullet[number].point.x -= BulletSpeed; Board[bullet[number].point.x][bullet[number].point.y] = 1;//移动后子弹面板置1 } else bullet[number].point.x = bullet[number].point.x; } else { Board[bullet[number].point.x][bullet[number].point.y] = 0;//结束子弹面板清0 bullet[number].point.x = 0; bullet[number].aliveflag = 0; } }
8、碰撞检测
这里涉及的是玩家<->敌机以及子弹<->敌机之间的两种碰撞,敌机<->敌机之间的碰撞本工程中不作检测(所以会出现两敌机部分重合的Bug情况,不过频率比较少)。为便于理解,以玩家<->敌机的碰撞检测作以下说明:
1、为减轻编程难度,将玩家飞机抽象为正立的正三角形▲,敌机抽象为倒立的正三角形▼
2、通过考虑玩家附近可能出现的三种极端情况的敌机位置以贴图左上角顶点作为考虑对象可以划出一个最小的待碰撞检测正三角区域,即下图中粉色虚线内部区域:这里涉及到该碰撞区域三个顶点的坐标求取,简单的数学问题,就不作说明了,程序中写的很清楚,特别注意计算起点均在贴图左上顶点!
3、通过判断该区域内敌机面板EnemyBoard是否存在等于1的情况,即该区域内是否有敌机,即可判断是否发生碰撞。
完整程序如下:
/* /▼ / ▲ ▼ ▼ */ /*碰撞检测,(x,y)左上角的坐标,Blockhit碰撞方块大小,例如EnemyBlock,Blockhitted被碰撞方块大小,例如PlayerBlock Mode = 0 表示 //原理:以玩家周围可能出现的三种极端情况的敌机划定碰撞检测范围, 得出一个玩家三角块和敌机三角块组合形成的更大的三角区域 以此区域进行遍历检测是否有敌机 使用:Boomcheck(player.point.x, player.point.y, EnemyBlock, PlayerBlock, EnemyBoard, enemy, Enemy_NUM, 0) */ int8_t Boomcheck(int x, int y, uint16_t Blockhit, uint16_t Blockhitted, uint8_t** Board, Block block[], uint8_t blocksum, uint8_t Mode) { int left, right;//定义每一行的左右扫描边界 int begin = x - Num_45(1.0 * Blockhit / 2 * sqrt(3));//定义扫描开始行 int end = x + Num_45(Blockhitted / 2 * sqrt(3));//定义扫描结束行 SinglePoint Top; Top.y = y - (Num_45(1.0 * Blockhit / 2) - Num_45(1.0 * Blockhitted / 2)); for (int i = begin; i < end; i++) { if (i >= 0 && i < WindowH) { left = Limit_num(0, Top.y - Num_45(1.0 * (i - begin) / sqrt(3)), WindowW); right = Limit_num(0, Top.y + Num_45(1.0 * (i - begin) / sqrt(3)), WindowW); for (int j = left; j <= right; j++) { //检测到了碰撞 if (Board[i][j] == 1) { if (Mode == 1) { //查找是哪一个编号的方块发生的碰撞 for (int number = 0; number < blocksum; number++) { if (block[number].point.x == i && block[number].point.y == j) { return number; } } } else { return 1; } } } } } return -1;//未碰撞 }
9、绘图
如果你有类似的绘图经历,一定绕不过去两个坎:
1、如何实现贴图透明部分替换为背景色?
2、如何解决相邻两次绘图之间绘图的闪烁问题?
怎么解决呢?咱先来说第一个:
9.1、问题一解决方法
A、解决方法一:
这也是我在该项目中用的方法。仔细看我上面写的程序,不管是敌机还是玩家,不难发现每次绘图时我调用的只有一个函数putimage(),对比两次调用:
putimage(enemy[number].point.y, enemy[number].point.x, &(enemy[number].image), SRCAND);//显示敌方飞机 putimage(player.point.y, player.point.x, &(player.image), SRCAND);//更新玩家飞机显示
除开绘图位置x、y以及贴图文件image,关注最后一个参数SRCAND,这一参数意义是:“通过使用AND (与)操作符来将源和目标区域内的颜色合并”,啥意思呢?就是两个数作与运算,例如0x0101 AND 0x1010结果为0x0000。
那为什么通过这种方式就可以实现透明贴图呢?以16位RGB为例,项目中采用的贴图均为白底,对应RGB为0xFFFF,仔细想想可以发现0xFFFF与任何数字作与运算均为该数字原来的值,通过这种方式就可以将贴图中白色的部分替换为背景色啦!不过通过上一个例子可以看出,这样的方法缺陷是会导致贴图非白色部分的颜色出现“色差”,所以提供第二种方法供参考。
B、解决办法二:
这方法也是在CSDN一篇博客中学到的,当时看的那篇找不到了,就找了个类似的供参考:OpenCV之通过位运算实现图像的叠加,原理作简单阐述:
1、将需要贴图的图片提取出轮廓,将其内部区域置为黑色0x0000,其余区域为白色0xFFFF,得到掩码图;
2、将掩码图与背景图作与运算,抠出贴图待显示的区域;
3、待显示贴图底色要求为黑色0x0000,此时,将贴图与第2步同一区域作或运算,即可实现透明贴图。
这种方法我在Menu()函数中以注释的方法贴了出来,即:
/************掩码显示*************/ putimage(player.point.y, player.point.x, &(player_cover.image), SRCAND);//掩码图与运算 putimage(player.point.y, player.point.x, &(player.image), SRCPAINT);//原图或运算 /*********************************/
9.2、问题二解决方法
图像闪烁这个问题当时也是困扰了我好几天,后来仔细翻阅EasyX官方文档,终于找到了解决的好办法,怎么解决的呢?
其实用起来很简单,一共就三个函数:
BeginBatchDraw();//开始连续画图 FlushBatchDraw();//连续画图 EndBatchDraw();//结束连续画图
用的时候第一个函数在程序开始时调用,最后一个函数在程序退出时调用,第二个程序在循环中调用即可实现连续绘图而不会出现闪烁,不过第二个函数调用的时机其实我掌握的也不是很好,还是得多试试。
10、游戏音效
其实到这个地方整个游戏的基本功能几乎都实现了,主要考虑到游戏体验,才加了音效部分,这里还是作一下简单的介绍:
1、考虑到游戏音乐(包括背景音乐、死亡音效)播放涉及开始播放、暂停播放、重新播放等多个功能需求,因此用MCI的API指令之一mciSebdString函数实现
2、对于发射子弹音效,因为涉及音效的打断,也就是我们常听到的连续开火时“啾啾啾啾啾”的音乐效果,所以使用的是Windows用于播放音乐的API函数PlaySound()。
具体运用程序如下:
enum Function { play, pause, resume, close, replay }; enum Music { backmusic, beginmusic, firemusic, gameovermusic }; void playmusic(uint8_t function, uint8_t number) { switch (function) { case play: switch (number) { case backmusic: mciSendString(TEXT("play backmusic"), NULL, 0, NULL); break; case beginmusic: mciSendString(TEXT("play begin from 0"), NULL, 0, NULL); break; case firemusic: PlaySound(TEXT("music\\fire.wav"), NULL, SND_FILENAME | SND_ASYNC); break; case gameovermusic: mciSendString(TEXT("play gameover from 0"), NULL, 0, NULL); break; default: break; } break; case pause: switch (number) { case backmusic: mciSendString(TEXT("pause backmusic"), NULL, 0, NULL); break; case beginmusic: mciSendString(TEXT("pause begin"), NULL, 0, NULL); break; case gameovermusic: mciSendString(TEXT("pause gameover"), NULL, 0, NULL); break; default: break; } break; case resume: switch (number) { case backmusic: mciSendString(TEXT("resume backmusic"), NULL, 0, NULL); break; default: break; } break; case replay: switch (number) { case backmusic: mciSendString(TEXT("play backmusic from 0"), NULL, 0, NULL); break; } } }
至此,除了按钮点击事件(如开始游戏、结束游戏、暂停、开关音乐)等简单的按键检测未叙述之外,整个游戏的核心基本全部叙述完毕。
四、总结
这是我上大学以来第一款独立地从第一个字母撸到最后一个字母的游戏,总共花费时间断断续续差不多一个周(其实也就早上起来摸一摸,其他时间都在寝室肥宅哈哈哈哈),编写过程还是比较轻松愉快的,值得纪念一下嘻嘻~
鞠躬~~~


浙公网安备 33010602011771号