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

ixRic

2020-11-26 21:53:31

Tech. & Eng.

可能更好的阅读体验

运行效果图

太大了传不了就把偶数帧删了。

什么是 EGE

最初想要制作一个小游戏的 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++。如果还要问有什么区别的话:

那么 EGE 到底能干什么呢?

并且它远没有 Qt 这么多复杂的概念,所有操作都由简单的函数实现。适合大家平时写着玩。

安装 EGE

在上文提到的两个链接中都能找到十分详细的安装教程,本文就只介绍下自己用的 Code::Blocks 20.03 的安装方法(DevC++ 类似,可参照下面的方法):

下载

进入官网 https://xege.org/ 可以看到:

点击下方的按钮后选择第一项即可得到:

解压后进入有这五个文件(夹):

找到你的 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 中。

  2. \ege20.08_all\EGE20.08\lib\codeblocks20.03 里面的文件复制到 MinGW\x86_64-w64-mingw32\lib 中。

  3. 打开 Code::Blocks,进入 Settings - Compiler,点击 Linker settings 选项卡。

  4. 在右侧的 Other linker options 框中输入 -lgraphics64 -luuid -lmsimg32 -lgdi32 -limm32 -lole32 -loleaut32 -lwinmm -lgdiplus,点击右下角的 OK。

  5. 如果你是 Code::Blocks 20.03 版本,建议在 Compiler settings 选项卡里勾选图示三个编译选项,因为不勾选编译得到的 exe 文件无法双击运行只能在 CB 中点击运行:

好了,现在你已经拥有了 EGE。

测试

和正常写代码完全一样,在 Code::Blocks 中新建一个控制台的 Project 输入以下代码:

#include <graphics.h>

int main() {
    initgraph(500, 600);    // 初始化画布, 宽 500 像素, 高 600 像素
    circle(250, 300, 200);  // 在 (250, 300) 处画一个半径为 200 的圆
    getch();                // 等待任意按键
    closegraph();           // 关闭画布
    return 0;
}

然后编译运行:

开始吧

我将按照自己完成代码的顺序介绍编写一个小游戏的步骤。

获取素材

我们可以通过 Apktool 解析一个 apk 文件得到我们所需要的图片素材,当然如果你足够强可以自己画(注意背景得是透明的而不是白色)。这里既然是模仿就直接白嫖了 。

下载 Apktool

我放了一份在网盘上:https://pan.baidu.com/s/1OYjVkfWGUXIaUhOasgo2sg,提取码 f18k。当然也可以从 github 上面下,但是需要自己配置运行。

下载一个 FlappyBird.apk

随便找一个手机应用商店,下载一个 Flappy Bird 的安卓安装包,为了方便我也把我找的放在这里:https://pan.baidu.com/s/1rxOhuYxoQVsG03m4N2DZEw,提取码: nqay

开始解析

可以看到 apktool 中只有一个 bat 文件和一个 jar 文件(要用到 java,电脑上没安装的就自己处理了 都是玩 MC 的还会没有 java 么 ):

在 cmd 中输入 ...\apktool 进入工作目录,其中 ... 是上图两个文件的位置,出现下图说明安装好了:

然后输入 apktool d -f 输入文件位置 -o 输出文件夹,例如:

出现上图就已经解析好了!然后进入输出文件夹,是这个样子的:

翻一翻就能找到我们想要的素材了:

用某软件打开 atlas.png 可以看到背景是透明的:

实际上在 atlas.txt 中有各个图像在 atlas.png 中的位置和大小等数据,但是用起来比较麻烦(事实上后面会涉及到图片的旋转),因此我用肝把这玩意裁剪了一下。为什么是用肝,因为你要记录好每张图片大小,并且要尽量从边缘裁,否则你的碰撞检测就更难判断了。我也把本次要用到的图片都传了上去:https://pan.baidu.com/s/1jrZLZ8HrhhjW0a1AaN8ISw,提取码 6dqu

前置知识

图片相关常量的定义

由于程序中大量设计调用图片,我们可以先把图片名与数字 id 用宏定义联系起来:

#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 相对应:

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 }
};

其他常量的定义

大部分常量都是在编写过程中再去定义的,毕竟刚开始写也不能完全想好要用到哪些,我先定义了下面这些:

const int WINDOW_WIDTH = 285, WINDOW_HEIGHT = 510;  /* 窗口大小 */
const int FPS = 60;                                 /* 帧率, 游戏时间单位为帧,
                                                       长度单位为像素 */
const float GA = 0.42;                              /* 重力加速度 (每帧增加的速
                                                       度) */

注意窗口大小(其实是画布大小)应该跟背景一样,帧率大家都知道。

事实上最终代码的常量大约占用的 100 行。

基本变量

Flappy Bird 的精灵(Sprite,指二维动画中的图形对象)很简单,最基本的只有两个:鸟和管道。我使用了一个结构体储存鸟的相关信息:

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;

对于管道,用循环队列即可:

int pipeHead, pipeTail;                             /* 使用循环队列储存管道, 不
                                                       能使用 STL, 左闭右开 */
std::pair<int, int> pipeOnScreen[MAX_PIPE_NUMBER
                                    + 5];           /* 储存所有下管道的左上角 */

还有游戏中用到的图像,实际上初始化完了过后不再改变,但我们视为变量:

PIMAGE image[IMAGE_NUMBER + 5];                     /* 储存所有图像 */

当然还有当前分数变量:

int score;                                          /* 得分 */

变量定义中涉及到的常量:

const int MAX_PIPE_NUMBER = 8;                      /* 屏幕上最多出现多少管道 */

函数

注意应该在 main 前声明所有函数,在 main 后进行实现,否则在使用的时候还没声明就很尴尬。

画下一帧鸟

根据鸟当前的状态画出鸟下一帧的状态,返回是否发生与地面或管道的碰撞。一些细节问题:

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],因为是与原图像宽 / 高的比值。 另外,透明通道可以直接理解为图像的透明像素,如果不允许透明通道,透明像素会被绘制成白色,允许则不绘制透明像素。

根据速度计算鸟的旋转角度

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

const float DOWN_SPEED = 8.2;                       /* 鸟头竖直朝下时的速度, 用
                                                       于转换速度和飞行角度 */
const float SMOOTH_ROTATE_SPEED = 6;                /* 为了旋转不太剧烈设置了这
                                                       个参数, 使得上升时和下降
                                                       开始时不按照速度旋转图片 */

鸟与管道的碰撞检测

事实上这个函数比较复杂:

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 常量进行模糊处理:

const int CRASH_SIZE = -2;                          /* 考虑到管道和鸟都不是矩形,
                                                        实际碰撞体积比图片大小小 */

另外,circleNext 函数会返回循环加一的值,比较简单不特意说明。

画下一帧管道

取出管道队列中的每一个元素,画下一帧即可。需要随机判断是否增加一个管道。

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 坐标。

新增的常量如下:

//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() 用于随机一个管道的高度,比较简单不过多说明。

主函数

初始化

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 函数重新进行了宏定义:

#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(

可以创建过后用 movewindow 函数改。

initgraph 函数:初始化绘图环境,两个参数分别是窗口的宽和高。

randomize 函数:相当于 srand(time(NULL)) 之类的,EGE 库中有自带的随机函数,据说随机度比 rand 函数高。

getimage 函数,用于从路径中获取图片,两个参数分别为存入位置和路径。

等待开始

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 则为完全不透明。

注意每次都需要重印背景,否则不断叠加,透明度会很快变成完全不透明。

reprintBackgroundreprintGround 是重印背景和重印地面的函数,reprintGround比较简单,reprintBackground 涉及到一个变背景的细节,即每得 10 分变换一次白天黑夜:

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. 是否下划线
  8. 是否斜体。
  9. 是否删除线。

setcolor:设置文本输出颜色。

主函数中涉及的常量比较多,这里都不再展示了,可以参看最后的完整代码。

gameBegin: 标签用于重新开始。

主循环

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 会处理不完。

游戏结束

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/

其他

隐藏控制台窗口

在链接选项(Dev C++ 的编译选项)中加入 -mwindows 即可。

提示一下(虽然我没写)控制台窗口可以用于输出程序运行日志便于查错,用正常的 printf 或者 std::cout 即可。例如著名的 Teeworlds。

设置应用图标

Code::Blocks 可以通过资源文件快速设置应用图标: 左上角 File - New - Empty file,然后点“是”加入当前工程,保存为 xxx.rc

然后 DebugRelease 版本都选上,点 OK。

在里面输入:MAINICON ICON "FlappyBird.ico",其中 FlappyBird.ico 是你的图标,存在 main.cpp 同级文件夹里。再编译一次,图标就按上去了。

之前用 Apktool 解析后的文件中其实也有 png 格式的图标,可以自己寻找一下,网上一搜可以搜到很多在线 pngico 的网站,转换一下就行辣。

完整程序包

https://github.com/ixRic/Flappy-Bird

后记