EGE 库入门 —— 手把手教你从零完成 Flappy Bird 的编写

ixRic

2020-11-26 21:53:31

Personal

[可能更好的阅读体验](https://blog.csdn.net/C20190102/article/details/109811248) # 运行效果图 ~~太大了传不了就把偶数帧删了。~~ ![运行效果图](https://img-blog.csdnimg.cn/20201119161335558.gif#pic_center) # 什么是 EGE - 为什么我一天写的东西都只能在一个黑漆漆的窗口里运行? - 为什么控制台只能设置 16 种颜色? - 为什么我只能打印一些简单的字符或文字? 最初想要制作一个小游戏的 OIer 或许都会这么想。事实上,以常规 OI 编译器(例如,Windows 下的 MinGW)是无法完成所谓的图形化界面的,因此出现了很多的 C++ GUI(图形用户界面)开发框架,例如大家熟知的 Qt。然而他们较高的门槛让一个普通的 OIer 没有时间和精力去接触,因此一些简单的 C / C++ 图形库 ~~应运而生~~ 进入了我们的视野,EGE(Easy Graphics Engine)就是其中之一。 > 另一个为人熟知的简单图形库是 EasyX,我选用 EGE 的原因主要是其可以在众多 IDE 下使用(包括一切使用 MinGW 或 MSVC 编译器的 IDE),而 EasyX 仅支持 VC,虽然对于大部分大学生 IDE无关紧要,但对于 OIer 来说,能用熟悉的 DevC++。如果还要问有什么区别的话: > > ![区别](https://img-blog.csdnimg.cn/20201126205128178.png#pic_center) 那么 EGE 到底能干什么呢? - 获取鼠标信息; - 打印图片; - 绘图; - 动画; - 音乐…… 并且它远没有 Qt 这么多复杂的概念,所有操作都由简单的函数实现。~~适合大家平时写着玩。~~ # 安装 EGE 在上文提到的两个链接中都能找到十分详细的安装教程,本文就只介绍下自己用的 Code::Blocks 20.03 的安装方法(DevC++ 类似,可参照下面的方法): ## 下载 进入官网 [https://xege.org/](https://xege.org/) 可以看到: ![官网](https://img-blog.csdnimg.cn/20201119150534213.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 点击下方的按钮后选择第一项即可得到: ![下载中](https://img-blog.csdnimg.cn/20201119150724106.png#pic_center) 解压后进入有这五个文件(夹): ![文件夹](https://img-blog.csdnimg.cn/2020111915102656.png#pic_center) ## 找到你的 MinGW 通常它在你的 IDE 文件夹中,例如 `C:\Program Files\CodeBlocks\MinGW` 或者 `C:\Program Files (x86)\Dev-Cpp\MinGW64`。 ## 复制文件 ~~据说由于自动安装包没有开发好,~~ 我们需要手动把头文件和链接库之类的东西复制 MinGW 中: 1. 把 `ege20.08_all\EGE20.08\include` 中 **所有东西** 复制到 `MinGW\x86_64-w64-mingw32\include` 中。 ![第一步](https://img-blog.csdnimg.cn/20201119152431642.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 2. 把 `\ege20.08_all\EGE20.08\lib\codeblocks20.03` 里面的文件复制到 `MinGW\x86_64-w64-mingw32\lib` 中。 ![第二步](https://img-blog.csdnimg.cn/20201119152835965.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 3. 打开 Code::Blocks,进入 `Settings - Compiler`,点击 `Linker settings` 选项卡。 ![第三步](https://img-blog.csdnimg.cn/20201119153059333.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 4. 在右侧的 `Other linker options` 框中输入 `-lgraphics64 -luuid -lmsimg32 -lgdi32 -limm32 -lole32 -loleaut32 -lwinmm -lgdiplus`,点击右下角的 OK。 ![第四步](https://img-blog.csdnimg.cn/2020111915340378.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 5. 如果你是 Code::Blocks 20.03 版本,建议在 `Compiler settings` 选项卡里勾选图示三个编译选项,因为不勾选编译得到的 exe 文件无法双击运行只能在 CB 中点击运行: ![第五步](https://img-blog.csdnimg.cn/20201119153913536.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 好了,现在你已经拥有了 EGE。 ## 测试 和正常写代码完全一样,在 Code::Blocks 中新建一个控制台的 Project 输入以下代码: ```cpp #include <graphics.h> int main() { initgraph(500, 600); // 初始化画布, 宽 500 像素, 高 600 像素 circle(250, 300, 200); // 在 (250, 300) 处画一个半径为 200 的圆 getch(); // 等待任意按键 closegraph(); // 关闭画布 return 0; } ``` 然后编译运行: ![运行结果](https://img-blog.csdnimg.cn/20201126160932840.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) # 开始吧 我将按照自己完成代码的顺序介绍编写一个小游戏的步骤。 ## 获取素材 我们可以通过 Apktool 解析一个 apk 文件得到我们所需要的图片素材,当然如果你足够强可以自己画(注意背景得是透明的而不是白色)。~~这里既然是模仿就直接白嫖了 。~~ ### 下载 Apktool 我放了一份在网盘上:[https://pan.baidu.com/s/1OYjVkfWGUXIaUhOasgo2sg](https://pan.baidu.com/s/1OYjVkfWGUXIaUhOasgo2sg),提取码 `f18k`。当然也可以从 github 上面下,但是需要自己配置运行。 ### 下载一个 FlappyBird.apk 随便找一个手机应用商店,下载一个 Flappy Bird 的安卓安装包,为了方便我也把我找的放在这里:[https://pan.baidu.com/s/1rxOhuYxoQVsG03m4N2DZEw](https://pan.baidu.com/s/1rxOhuYxoQVsG03m4N2DZEw),提取码: `nqay`。 ### 开始解析 可以看到 apktool 中只有一个 bat 文件和一个 jar 文件(要用到 java,电脑上没安装的就自己处理了 ~~都是玩 MC 的还会没有 java 么~~ ): ![两个文件](https://img-blog.csdnimg.cn/20201119163631438.png#pic_center) 在 cmd 中输入 `...\apktool` 进入工作目录,其中 `...` 是上图两个文件的位置,出现下图说明安装好了: ![安装好了](https://img-blog.csdnimg.cn/20201126163034826.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 然后输入 `apktool d -f 输入文件位置 -o 输出文件夹`,例如: ![示例](https://img-blog.csdnimg.cn/20201126163347978.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 出现上图就已经解析好了!然后进入输出文件夹,是这个样子的: ![resource](https://img-blog.csdnimg.cn/20201126163446897.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 翻一翻就能找到我们想要的素材了: ![素材位置](https://img-blog.csdnimg.cn/20201126163608818.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 用某软件打开 `atlas.png` 可以看到背景是透明的: ![背景是透明的](https://img-blog.csdnimg.cn/20201126163813219.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 实际上在 `atlas.txt` 中有各个图像在 `atlas.png` 中的位置和大小等数据,但是用起来比较麻烦(事实上后面会涉及到图片的旋转),因此我用肝把这玩意裁剪了一下。为什么是用肝,因为你要记录好每张图片大小,并且要尽量从边缘裁,否则你的碰撞检测就更难判断了。我也把本次要用到的图片都传了上去:[https://pan.baidu.com/s/1jrZLZ8HrhhjW0a1AaN8ISw](https://pan.baidu.com/s/1jrZLZ8HrhhjW0a1AaN8ISw),提取码 `6dqu`。 ## 前置知识 - EGE 中的图像类使用的是 `PIMAGE`,其实际上是一个指针指向该图像。因此定义一个图像要使用 `PIMAGE img = newimage()`,此时 `img` 指向了一个 $1 \times 1$ 空白像素的图像,然后可以通过 `getimage` 从路径获得图像,在后文会详细说明; - 在 EGE 中我们的动画通过一帧一帧地在画布上绘图实现,因此代码中时间单位应为“帧”,长度单位是常见的像素; - EGE 中的坐标系:左上角为原点,水平向右为 $x$ 正方向,竖直向下为 $y$ 正方向; - 代码中用到的 EGE 库相关内容都会用“引用”格式解释清楚。 ## 图片相关常量的定义 由于程序中大量设计调用图片,我们可以先把图片名与数字 id 用宏定义联系起来: ```cpp #define BACKGROUND_DAY 0 #define BACKGROUND_NIGHT 1 #define BIRD_UP 2 #define BIRD_HORIZONTAL 3 #define BIRD_DOWN 4 #define BOARD 5 #define BUTTON_LIGHT 6 #define BUTTON_DARK 7 #define GROUND 8 #define INSTRUCTION 9 #define SILVER_MEDAL 10 #define GOLD_MEDAL 11 #define PIPE 12 #define TITLE 13 #define GAME_OVER 14 /* 将图片名称与标号对应, 代 码中涉及一些运算和标号有 关, 不可随意更改名称与标 号的对应关系 */ ``` 然后就是图片路径与图片大小,注意与上面的 id 相对应: ```cpp const int IMAGE_NUMBER = 15; /* 图片数量 */ const char *IMAGE_PATH[IMAGE_NUMBER + 5] = { /* 图片文件位置 */ "resource/background1.png", // 0 白天背景 "resource/background2.png", // 1 夜晚背景 "resource/bird1.png", // 2 鸟 (翅膀向上) "resource/bird2.png", // 3 鸟 (翅膀水平) "resource/bird3.png", // 4 鸟 (翅膀向下) "resource/board.png", // 5 记分板 "resource/button1.png", // 6 按钮 (未激活) "resource/button2.png", // 7 按钮 (激活) "resource/ground.png", // 8 地面 "resource/instruction.png", // 9 开始提示 "resource/medal1.png", // 10 银奖章 "resource/medal2.png", // 11 金奖章 "resource/pipe.png", // 12 管道 (朝上) "resource/title.png", // 13 标题 (Flappy Bird) "resource/gameOver.png", // 14 Game Over }; const int IMAGE_SIZE[IMAGE_NUMBER][2] = { /* 图片的宽和高 */ { 285, 510 }, { 285, 510 }, { 35, 25 }, { 35, 25 }, { 35, 25 }, { 228, 116 }, { 116, 69 }, { 116, 69 }, { 285, 110 }, { 113, 100 }, { 45, 45 }, { 45, 45 }, { 54, 321 }, { 185, 50 }, { 194, 44 } }; ``` ## 其他常量的定义 大部分常量都是在编写过程中再去定义的,毕竟刚开始写也不能完全想好要用到哪些,我先定义了下面这些: ```cpp const int WINDOW_WIDTH = 285, WINDOW_HEIGHT = 510; /* 窗口大小 */ const int FPS = 60; /* 帧率, 游戏时间单位为帧, 长度单位为像素 */ const float GA = 0.42; /* 重力加速度 (每帧增加的速 度) */ ``` 注意窗口大小(其实是画布大小)应该跟背景一样,帧率大家都知道。 事实上最终代码的常量大约占用的 100 行。 ## 基本变量 Flappy Bird 的精灵(Sprite,指二维动画中的图形对象)很简单,最基本的只有两个:鸟和管道。我使用了一个结构体储存鸟的相关信息: ```cpp struct bird { /* 鸟结构体 */ int posX, posY; /* 图像左上角位置 */ int shape, /* 形态 (2 / 3 / 4 <=> 翅膀 向上 / 水平 / 向下) */ cnt; /* 帧计数, 当 speed < 0 且 cnt % TIME_PER_WINGING = 0 时切换形态; 当 speed = 0 时为形态 3; 当 speed < 0 时为形态 4 */ float speedX, speedY; /* 以 x, y 正方向为正, 图像 角度与速度相关, 由于开场 从左边进入, 所以需要 x 方 向的速度 */ } player; ``` 对于管道,用循环队列即可: ```cpp int pipeHead, pipeTail; /* 使用循环队列储存管道, 不 能使用 STL, 左闭右开 */ std::pair<int, int> pipeOnScreen[MAX_PIPE_NUMBER + 5]; /* 储存所有下管道的左上角 */ ``` 还有游戏中用到的图像,实际上初始化完了过后不再改变,但我们视为变量: ```cpp PIMAGE image[IMAGE_NUMBER + 5]; /* 储存所有图像 */ ``` 当然还有当前分数变量: ```cpp int score; /* 得分 */ ``` 变量定义中涉及到的常量: ```cpp const int MAX_PIPE_NUMBER = 8; /* 屏幕上最多出现多少管道 */ ``` ## 函数 注意应该在 `main` 前声明所有函数,在 `main` 后进行实现,否则在使用的时候还没声明就很尴尬。 ### 画下一帧鸟 根据鸟当前的状态画出鸟下一帧的状态,返回是否发生与地面或管道的碰撞。一些细节问题: - 需要切换图像以展示不同翅膀形态; - 鸟在飞的时候要旋转一定角度; - 游戏开始时我设计鸟从屏幕左侧飞入而不是直接出现,在鸟到达屏幕中间时,鸟的 $x$ 坐标就不变了。 ```cpp inline int drawNextBird(bird &p) { int w = IMAGE_SIZE[p.shape][0], h = IMAGE_SIZE[p.shape][1]; p.cnt = (p.cnt + 1) % TIME_PER_WINGING; if (p.speedY < 0 && p.cnt == 0) p.shape = (p.shape - 1) % 3 + 2; // 切换翅膀形态 if (p.speedY < MAX_DOWN_SPEED) // 限定最大下降速度 p.speedY += GA; float angle = speedToAngle(p.speedY); // 根据速度计算图像要偏转的角度 p.posY += (int)p.speedY; p.posX += (int)p.speedX; if (p.posX >= WINDOW_WIDTH / 2 - IMAGE_SIZE[BIRD_UP][0] / 2) // 开始一段时间横向运动 p.posX = WINDOW_WIDTH / 2 - IMAGE_SIZE[BIRD_UP][0] / 2 - 1, p.speedX = 0; if (p.posY < 0) p.posY = 0; // 不能向上出界, 但碰到顶部不算失败 w /= 2, h /= 2; putimage_rotate(NULL, image[p.shape], p.posX + w, p.posY + h, 0.5, 0.5, angle, 1); // 旋转一定角度输出 // xyprintf(100, 10, "111"); return birdCrash(player); // 碰撞判断 } ``` > 其中用到了 EGE 库的 `putimage_rotate` 函数,用于绘制旋转后的图像,其 8 个参数分别为: > 1. 目标图像指针,若为 `NULL`,则是窗口。 > 2. 绘制图像指针。 > 3. 绘制图像左上角 $x$ 坐标。 > 4. 绘制图像左上角 $y$ 坐标。 > 5. 旋转中心在绘制图像坐标系的 $x$ 坐标。 > 6. 旋转中心在绘制图像坐标系的 $y$ 坐标。 > 7. 旋转角度(弧度),逆时针为正。 > 8. 是否允许透明通道。 > > 注意参数 5 和 6,值为 $[0, 1]$,因为是与原图像宽 $/$ 高的比值。 > 另外,透明通道可以直接理解为图像的透明像素,如果不允许透明通道,透明像素会被绘制成白色,允许则不绘制透明像素。 ### 根据速度计算鸟的旋转角度 ```cpp inline float speedToAngle(const float &speed) { if (speed < SMOOTH_ROTATE_SPEED) return PI / 6; // 上升时和刚开始下降时稳定为向上 30 度 return -((speed - SMOOTH_ROTATE_SPEED) / (DOWN_SPEED - SMOOTH_ROTATE_SPEED) * PI / 2) + PI / 6; // 下落时计算速度占竖直朝下速度的比例, 转化为角度, 由于刚开始下降时没有旋转, // 要减掉那部分速度才能平滑旋转 } ``` 写到这里会定义常量 `SMOOTH_ROTATE_SPEED` 以及 `DOWN_SPEED`: ```cpp const float DOWN_SPEED = 8.2; /* 鸟头竖直朝下时的速度, 用 于转换速度和飞行角度 */ const float SMOOTH_ROTATE_SPEED = 6; /* 为了旋转不太剧烈设置了这 个参数, 使得上升时和下降 开始时不按照速度旋转图片 */ ``` ### 鸟与管道的碰撞检测 事实上这个函数比较复杂: ```cpp inline int birdCrash(bird cur) { int x = cur.posX, y = cur.posY; int w = IMAGE_SIZE[BIRD_UP][0], h = IMAGE_SIZE[BIRD_UP][1]; int lft = x - CRASH_SIZE, rgt = x + w + CRASH_SIZE, up = y - CRASH_SIZE, dwn = y + h + CRASH_SIZE; // 实际碰撞体积比图片大小小 int groundHeight = IMAGE_SIZE[GROUND][1]; static bool added = false; if (cur.speedX) added = false; // 游戏开始时的初始化 if (y + h >= WINDOW_HEIGHT - groundHeight) // 落地 return 2; // xyprintf(10, 80, "%d", nextPipeToMeet); if ((nextPipeToMeet >= pipeHead && nextPipeToMeet < pipeTail) || (nextPipeToMeet < pipeTail && pipeTail <= pipeHead) || (nextPipeToMeet >= pipeHead && pipeHead >= pipeTail)) { // 当前有管道 (考虑循环队列) int pipeX = pipeOnScreen[nextPipeToMeet].first, pipeY = pipeOnScreen[nextPipeToMeet].second; if (x + IMAGE_SIZE[BIRD_UP][0] / 2 >= pipeX && !added) // 第一次通过当前管道 ++score, added = true; // 加分 if (lft > pipeX + IMAGE_SIZE[PIPE][0]) { // 完全通过当前管道 nextPipeToMeet = circleNext(nextPipeToMeet); added = false; } // xyprintf(30, 10, "%d %d", y, pipeY); return rgt >= pipeX && lft <= pipeX + IMAGE_SIZE[PIPE][0] && (!(up > pipeY - PIPE_GAP_VERTICAL && dwn < pipeY)); } return 0; } ``` 写到这里会发现需要一个 `nextPipeToMeet` 变量记录鸟前面第一个变量。又由于鸟不是一个标准的矩形,我们不能直接判断,我为了方便就设置一个 `CRASH_SIZE` 常量进行模糊处理: ```cpp const int CRASH_SIZE = -2; /* 考虑到管道和鸟都不是矩形, 实际碰撞体积比图片大小小 */ ``` 另外,`circleNext` 函数会返回循环加一的值,比较简单不特意说明。 ### 画下一帧管道 取出管道队列中的每一个元素,画下一帧即可。需要随机判断是否增加一个管道。 ```cpp void drawNextPipe(const int &speed) { int d = random(2 * PIPE_GAP_HORIZONTAL_RANGE) - PIPE_GAP_HORIZONTAL_RANGE + PIPE_GAP_HORIZONTAL; // 随机最后一个管道和新加管道之间的间隔 for (int i = pipeHead; i != pipeTail; i = circleNext(i)) { std::pair<int, int> &p = pipeOnScreen[i]; int x = p.first, y = p.second; putimage_withalpha(NULL, image[PIPE], x, y); // 输出下半部分 putimage_rotate(NULL, image[PIPE], x + IMAGE_SIZE[PIPE][0] / 2, y - PIPE_GAP_VERTICAL, 0.5, 0, PI, 1); // 输出上半部分 p.first += (int)speed; // 移动 if (i == pipeHead) { if (p.first + IMAGE_SIZE[PIPE][0] < 0) // 最左边的管道出界了 pipeHead = circleNext(pipeHead); } if (circleNext(i) == pipeTail) { if (p.first + d <= WINDOW_WIDTH) { // 可以新加一个管道 pipeOnScreen[pipeTail] = { WINDOW_WIDTH, getPipeHeight() }; // 确保从窗口最右边进入 pipeTail = circleNext(pipeTail); } } } } ``` > 其中用到了 `putimage_withalpha` 函数,用于绘制带透明通道的图像,四个参数分别为: > > 1. 目标图像指针,若为 `NULL`,则是窗口。 > 2. 绘制图像指针。 > 3. 绘制图像左上角 $x$ 坐标。 > 4. 绘制图像左上角 $y$ 坐标。 新增的常量如下: ```cpp //const int PIPE_GAP_VERTICAL = 90; /* 上下管道间隙 (变态) */ const int PIPE_GAP_VERTICAL = 105; /* 上下管道间隙 (正常) */ //const int PIPE_GAP_VERTICAL = 150; /* 上下管道间隙 (几乎无敌) */ const int PIPE_GAP_HORIZONTAL = 260; /* 左右管道间隙基准值 */ const int PIPE_GAP_HORIZONTAL_RANGE = 100; /* 左右管道间隙随机范围 */ ``` 另外 `getPipeHeight()` 用于随机一个管道的高度,比较简单不过多说明。 ### 主函数 #### 初始化 ```cpp setcaption("Flappy Bird"); // 设置窗口标题 setinitmode(INIT_WITHLOGO | INIT_NOFORCEEXIT, 100, 100); // 显示开场 LOGO | 关闭窗口时不强制结束程序 // INIT_NOFORCEEXIT 意味着有长时间循环时必须判断 is_run() // 否则会出现用户无法关闭窗口的情况 initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); // 初始化画布 randomize(); // 随机函数初始化 for (int i = 0; i < IMAGE_NUMBER; i++) { image[i] = newimage(); getimage(image[i], IMAGE_PATH[i]); } // 初始化图片 ``` > `setcaption` 函数,用于设置绘图主窗口的标题。 > `setinitmode` 函数,用于初始化窗口的参数,这里用到的 3 个参数分别为: > 1. 窗口属性。 > 2. 窗口左上角在显示器的位置 $x$ 坐标。 > 3. 窗口左上角在显示器的位置 $y$ 坐标。 > > 第一个参数通常使用多个常量或起来表达,我设置了显示开场 logo 和关闭窗口时不强制退出程序。 > > 关于不强制退出程序:如果不设置,那么用户点击界面右上角的叉时,窗口会立即关闭且程序会立即结束;如果设置了,窗口不会关闭程序也不会结束,但是所有的 `is_run` 函数会返回 `false`,当然如果窗口还在, `is_run` 函数会返回 `true`,因此我们需要时刻使用 `is_run` 函数检测窗口是否需要关闭,如果 `is_run` 函数返回了 `false`,我们需要用 `closegraph` 函数手动帮助用户关闭窗口。这点非常重要。 > 因此我们不能用 EGE 库的 `delay_ms` 函数(延迟给定的毫秒数),因为这样会造成无法关闭窗口的情况,于是我将 `delay_ms` 函数重新进行了宏定义: > > ```cpp > > #define delay_ms(t) \ > > [](const int &msTime) { \ > > int fpsTime = msTime * FPS / 1000; \ > > while (fpsTime-- && is_run()) \ > > delay_fps(FPS); \ > > } (t) > > // 将 delay_ms 转化为 delay_fps (因为要判断 is_run 所以不能直接 delay_ms) > > // 用 lambda 可以避免变量重名 > > ``` > 后两个参数在我写文章时 EGE 20.08 版本似乎有一些 bug( > > > > ![意外](https://img-blog.csdnimg.cn/20201126185011425.png#pic_center) > > > > ![不建议使用 setinitmode](https://img-blog.csdnimg.cn/20201126185208737.png#pic_center) > > > > 可以创建过后用 `movewindow` 函数改。 > `initgraph` 函数:初始化绘图环境,两个参数分别是窗口的宽和高。 > `randomize` 函数:相当于 `srand(time(NULL))` 之类的,EGE 库中有自带的随机函数,据说随机度比 `rand` 函数高。 > `getimage` 函数,用于从路径中获取图片,两个参数分别为存入位置和路径。 #### 等待开始 ```cpp putimage(0, 0, image[BACKGROUND_DAY]); putimage(0, WINDOW_HEIGHT - IMAGE_SIZE[GROUND][1], image[GROUND]); // 打印背景 for (int i = 0; i <= TITLE_APPEAR_TIME && is_run(); i++) { reprintBackground(); reprintGround(); // 注意要重印背景否则下面设置的透明度就是假的 putimage_alphatransparent(NULL, image[TITLE], WINDOW_WIDTH / 2 - IMAGE_SIZE[TITLE][0] / 2, WINDOW_HEIGHT / 2 - IMAGE_SIZE[TITLE][1] / 2 - IMAGE_SIZE[TITLE][1] / 2 - 100, 0, i * 0xFF / TITLE_APPEAR_TIME); delay_fps(FPS); } delay_ms(1000); putimage_withalpha(NULL, image[INSTRUCTION], WINDOW_WIDTH / 2 - IMAGE_SIZE[INSTRUCTION][0] / 2, WINDOW_HEIGHT / 2 - IMAGE_SIZE[INSTRUCTION][1] / 2); // 打印提示和标题 flushmouse(); // 清空鼠标消息缓冲区 mouse_msg msg = { 0 }; bool isBegin = false; while (!isBegin && is_run()) { msg = getmouse(); if (msg.is_left() && msg.is_down()) isBegin = true; } // 等待开始 gameBegin: // 游戏开始 flushmouse(); background = BACKGROUND_DAY; int maxScore = 0; std::ofstream outMaxScore(MAX_SCORE_PATH, std::ios::in); // 输出流 (没有则新建, 必须加 // ios::in 否则会清空文件) std::ifstream inMaxScore(MAX_SCORE_PATH); // 输入流 if (inMaxScore.peek() != EOF) // 有记录则读取 inMaxScore >> maxScore; // 读取历史最大分数 player.shape = 3; player.posX = 0; player.posY = (WINDOW_HEIGHT - IMAGE_SIZE[GROUND][1]) / 2 - IMAGE_SIZE[player.shape][1] / 2; player.cnt = 0; player.speedX = INITIAL_SPEEDX; player.speedY = CLICK_SPEED; // 初始化玩家的鸟 pipeHead = pipeTail = nextPipeToMeet = 1; pipeOnScreen[pipeTail] = { WINDOW_WIDTH, getPipeHeight() }; pipeTail = circleNext(pipeTail); // 初始化第一个管道 setfont(SCORE_HEIGHT, SCORE_WIDTH, SCORE_FONT, 0, 0, SCORE_WEIGHT, false, false, false); setcolor(SCORE_COLOR); // 设置分数的字体及颜色 ``` 这里涉及到一个渐入的标题,用循环加上 `putimage_alphatransparent` 函数实现。 > `putimage_alphatransparent` 函数:按指定透明度输出一个带透明通道的图片,参数: > > 1. 透明混合的背景图片,若为 `NULL` 则为窗口。 > 2. 透明绘制的图片。 > 3. 绘制位置 $x$ 坐标。 > 4. 绘制位置 $y$ 坐标。 > 5. 变为透明的像素颜色。 > 6. 透明度,若为 `0x0` 则完全透明,若为 `0xFF` 则为完全不透明。 注意每次都需要重印背景,否则不断叠加,透明度会很快变成完全不透明。 `reprintBackground` 和 `reprintGround` 是重印背景和重印地面的函数,`reprintGround`比较简单,`reprintBackground` 涉及到一个变背景的细节,即每得 10 分变换一次白天黑夜: ```cpp inline void reprintBackground() { static int lastBackground = background, cnt = 0; // cnt 对应 change 后的时间 if (lastBackground != background && !cnt) cnt = CHANGE_BACKGROUND_TIME; putimage(0, 0, image[lastBackground]); // 注意先输出 lastbackground 以达到清屏的目的 if (cnt) { // 再混合新背景 putimage_alphablend(NULL, image[background], 0, 0, (CHANGE_BACKGROUND_TIME - cnt) * 0xFF / CHANGE_BACKGROUND_TIME); // 根据 cnt 计算透明度 if (!(--cnt)) lastBackground = background; } } ``` > `delay_fps(FPS)`:等待帧率为 `FPS` 时的一帧所需时间,用这个函数可以很方便地控制动画。 > `delay_ms(t)`:等待 $t$ 毫秒,注意这不是 EGE 库的函数,而是重新宏定义后的。 > `flushmouse`:清空鼠标消息的缓冲区,和常见的键盘消息类似,防止用户在之前的操作被后面的 `getmouse` 获取到。 > `mouse_msg`:这是一个鼠标消息类,可以类比于键盘消息的 `char`。 > `getmouse`:获取鼠标消息,类比于键盘消息的 `getch()`。 > `mouse_msg::is_left()`:消息是否右鼠标左键产生。 > `mouse_msg::is_down()`:是否有鼠标按键按下。 > `setfont`:设置文字输出时的字体,参数分别时: > 1. 字高。 > 2. 字宽,若为 `0`,则自适应。 > 3. 字体名称,一个字符串。 > 4. 字符串书写角度。 > 5. 字符书写角度。 > 6. 笔画粗细。 > 7. 是否下划线 > 7. 是否斜体。 > 8. 是否删除线。 > `setcolor`:设置文本输出颜色。 主函数中涉及的常量比较多,这里都不再展示了,可以参看最后的完整代码。 `gameBegin:` 标签用于重新开始。 #### 主循环 ```cpp int lastScore = 0; int shineCount = -1; // 都是防止动画永动 score = 0; bool brokenRecord = false; while (is_run()) { reprintBackground(); if (drawNextBird(player)) // 鸟运动并判断是否碰撞 break; // 碰撞则游戏结束 if (player.speedX == 0) // 不横向运动再开始输出管道 drawNextPipe(PIPE_SPEED); // 管道运动 reprintGround(); // 确保地面显示在最前 if (score == maxScore + 1) { if (shineCount == -1) { // 破纪录动画只播放一次 brokenRecord = true; shineCount = SHINING_TIME; setfont(SHINING_SCORE_HEIGHT, SHINING_SCORE_WIDTH, SHINING_SCORE_FONT, 0, 0, SHINING_SCORE_WEIGHT, false, false, false); // 换字体 } maxScore = score; outMaxScore.seekp(std::ios::beg); outMaxScore << maxScore; // 实时更新文件中的最大分数 } if (shineCount > 0) { if (shineCount % (SHINING_TIME / SHINING_TIMES) == 0) { int c = shineCount / (SHINING_TIME / SHINING_TIMES); if (c & 1) setcolor(SCORE_COLOR); // 恢复颜色 else setcolor(SHINING_SCORE_COLOR); // 变色 } --shineCount; if (!shineCount) { setfont(SCORE_HEIGHT, SCORE_WIDTH, SCORE_FONT, 0, 0, SCORE_WEIGHT, false, false, false); setcolor(SCORE_COLOR); // 变回来 } } char scoreString[20]; sprintf(scoreString, "%d", score); int len = strlen(scoreString); if (shineCount > 0) // 闪烁时字体大小不同位置也要变 ege_drawtext(scoreString, WINDOW_WIDTH / 2 - len * SHINING_SCORE_WIDTH / 2, SCORE_Y - SHINING_SCORE_HEIGHT / 2); else ege_drawtext(scoreString, WINDOW_WIDTH / 2 - len * SCORE_WIDTH / 2, SCORE_Y - SCORE_HEIGHT / 2); // 打印分数 if (score != lastScore && score % 10 == 0) { // lastScore 防止永动 background = (background + 1) % 2, lastScore = score; // 切换背景 } bool isClick = false; bool isPause = false; while (mousemsg()) { msg = getmouse(); if (msg.is_left() && msg.is_down()) // 判断是否左键单击 isClick = true; if (msg.is_right() && msg.is_down()) // 判断是否右键单击 isPause = true; } while (isPause && is_run()) { while (mousemsg()) { msg = getmouse(); if (msg.is_right() && msg.is_down()) isPause = false; if (msg.is_left() && msg.is_down()) isPause = false, isClick = true; } delay_fps(FPS); } // 右键单击, 暂停; 左键或右键单击解除暂停 if (isClick) player.speedY = CLICK_SPEED; // 左键单击, 速度改变 delay_fps(FPS); // 延迟 } // 游戏主体 ``` > `ege_drawtext`:在屏幕上指定位置绘制字符串,三个参数分别为输出字符串和输出位置左上角的 $x, y$ 坐标。 > `mousemsg()`:相当于键盘事件的 `kbhit()` 函数,判断有没有产生鼠标事件,必须用 `while` 处理,因为鼠标时间会短时间内大量产生,用 `if` 会处理不完。 #### 游戏结束 ```cpp flushmouse(); gameOver: reprintBackground(); reprintGround(); putimage_withalpha(NULL, image[GAME_OVER], WINDOW_WIDTH / 2 - IMAGE_SIZE[GAME_OVER][0] / 2, WINDOW_HEIGHT / 2 - IMAGE_SIZE[GAME_OVER][1] / 2 - 160); // 重印背景 putimage_alphatransparent(NULL, image[BOARD], WINDOW_WIDTH / 2 - IMAGE_SIZE[BOARD][0] / 2, WINDOW_HEIGHT / 2 - IMAGE_SIZE[BOARD][0] / 2, EGERGB(255, 255, 255), // 设置白色为透明色 0xFF); // 重印计分板 char scoreString[20]; sprintf(scoreString, "%d", score); int len = strlen(scoreString); setfont(END_SCORE_HEIGHT, END_SCORE_WIDTH, END_SCORE_FONT, 0, 0, END_SCORE_WEIGHT, false, false, false); setcolor(brokenRecord ? SHINING_SCORE_COLOR : SCORE_COLOR); // 打破纪录换颜色 ege_drawtext(scoreString, END_SCORE_X - len * END_SCORE_WIDTH, // 右对齐 END_SCORE_Y - END_SCORE_HEIGHT / 2); // 打印得分 sprintf(scoreString, "%d", maxScore); len = strlen(scoreString); ege_drawtext(scoreString, END_SCORE_X - len * END_SCORE_WIDTH, // 右对齐 END_MAX_SCORE_Y - END_SCORE_HEIGHT / 2); // 打印最佳得分 int medal = score >= 30 ? GOLD_MEDAL : SILVER_MEDAL; if (score >= 10) putimage_withalpha(NULL, image[medal], MEDAL_X, MEDAL_Y); // 打印奖章 if (is_run()) { putimage_withalpha(NULL, image[BUTTON_LIGHT], BUTTON_X, BUTTON_Y); int x, y; mousepos(&x, &y); if (x >= BUTTON_X && x <= BUTTON_X + IMAGE_SIZE[BUTTON_LIGHT][0] && y >= BUTTON_Y && y <= BUTTON_Y + IMAGE_SIZE[BUTTON_LIGHT][1]) { putimage_withalpha(NULL, image[BUTTON_DARK], BUTTON_X, BUTTON_Y); // 按钮变暗产生互动感 bool isClick = false; while (mousemsg()) { msg = getmouse(); if (msg.is_left() && msg.is_down()) // 判断是否左键单击 isClick = true; } if (isClick) goto gameBegin; // 重新开始 } delay_fps(FPS); goto gameOver; // 由于按钮的阴影重复打印会变黑, 这里用循环的话要重印很多图片 // 所以直接用了 goto } for (int i = 0; i < IMAGE_NUMBER; i++) delimage(image[i]); // 销毁图像, 释放内存 return 0; // 结束程序 ``` > `delimage`:释放图像所占有的内存。 > 这里需要提醒:一定不要在循环中大量使用 `getimage`,因为这样会不断申请新空间而没有销毁原来的空间!除非每次用完了都 `delimage`。 ## 完整代码 (Tab 是 8 个空格所以没有对齐)[https://paste.ubuntu.com/p/jR7dbZ75Ck/](https://paste.ubuntu.com/p/jR7dbZ75Ck/) ## 其他 ### 隐藏控制台窗口 在链接选项(Dev C++ 的编译选项)中加入 `-mwindows` 即可。 ![链接选项](https://img-blog.csdnimg.cn/20201126195503844.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 提示一下(虽然我没写)控制台窗口可以用于输出程序运行日志便于查错,用正常的 `printf` 或者 `std::cout` 即可。~~例如著名的 Teeworlds。~~ ### 设置应用图标 Code::Blocks 可以通过资源文件快速设置应用图标: 左上角 `File - New - Empty file`,然后点“是”加入当前工程,保存为 `xxx.rc`。 ![保存](https://img-blog.csdnimg.cn/20201126200405578.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 然后 `Debug` 和 `Release` 版本都选上,点 OK。 ![选项](https://img-blog.csdnimg.cn/20201126200327776.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0MyMDE5MDEwMg==,size_16,color_FFFFFF,t_70#pic_center) 在里面输入:`MAINICON ICON "FlappyBird.ico"`,其中 `FlappyBird.ico` 是你的图标,存在 `main.cpp` 同级文件夹里。再编译一次,图标就按上去了。 之前用 Apktool 解析后的文件中其实也有 `png` 格式的图标,可以自己寻找一下,网上一搜可以搜到很多在线 `png` 转 `ico` 的网站,转换一下就行辣。 ## 完整程序包 [https://github.com/ixRic/Flappy-Bird](https://github.com/ixRic/Flappy-Bird) # 后记 - 参考了 [依稀的博客](https://blog.csdn.net/qq_39151563/category_9311717.html); - EGE 的源代码在 [这里](https://github.com/wysaid/xege); - EGE 官方群欢迎加入:1060223135; - ([之前写的控制台小游戏](https://blog.csdn.net/C20190102/article/details/102727963)。