EGE 库入门 —— 手把手教你从零完成 Flappy Bird 的编写
ixRic
2020-11-26 21:53:31
[可能更好的阅读体验](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)。