运行效果图

太大了传不了就把偶数帧删了。
运行效果图

什么是 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++。如果还要问有什么区别的话:

区别

那么 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 中。

第一步

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

第二步

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

第三步

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

第四步

  1. 如果你是 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 输出文件夹,例如:

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

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

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

背景是透明的

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

前置知识

  • EGE 中的图像类使用的是 PIMAGE,其实际上是一个指针指向该图像。因此定义一个图像要使用 PIMAGE img = newimage(),此时 img 指向了一个 1 × 1 1 \times 1 1×1 空白像素的图像,然后可以通过 getimage 从路径获得图像,在后文会详细说明;
  • 在 EGE 中我们的动画通过一帧一帧地在画布上绘图实现,因此代码中时间单位应为“帧”,长度单位是常见的像素;
  • EGE 中的坐标系:左上角为原点,水平向右为 x x x 正方向,竖直向下为 y y y 正方向;
  • 代码中用到的 EGE 库相关内容都会用“引用”格式解释清楚。

图片相关常量的定义

由于程序中大量设计调用图片,我们可以先把图片名与数字 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 后进行实现,否则在使用的时候还没声明就很尴尬。

画下一帧鸟

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

  • 需要切换图像以展示不同翅膀形态;
  • 鸟在飞的时候要旋转一定角度;
  • 游戏开始时我设计鸟从屏幕左侧飞入而不是直接出现,在鸟到达屏幕中间时,鸟的 x x x 坐标就不变了。
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 x x 坐标。
  4. 绘制图像左上角 y y y 坐标。
  5. 旋转中心在绘制图像坐标系的 x x x 坐标。
  6. 旋转中心在绘制图像坐标系的 y y y 坐标。
  7. 旋转角度(弧度),逆时针为正。
  8. 是否允许透明通道。

注意参数 5 和 6,值为 [ 0 , 1 ] [0, 1] [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 x x 坐标。
  4. 绘制图像左上角 y y 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 x x 坐标。
  3. 窗口左上角在显示器的位置 y y 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(
意外
不建议使用 setinitmode
可以创建过后用 movewindow 函数改:
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 x x 坐标。
  4. 绘制位置 y y 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 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 x, y 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

后记

  • 参考了 依稀的博客
  • EGE 的源代码在 这里
  • EGE 官方群欢迎加入:1060223135;
  • 之前写的控制台小游戏
  • 写完过后本来想用它继续学着写个 q-learning 的结果结果它 learn 不出来。
    哭
    如果你愿意给予点帮助可以私信我。
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐