5. C++ EGE图形库推箱子游戏进阶

1.前言

本项目可以看作是本站另一门入门教程《C语言:推箱子小游戏》的进阶教程。

如果本文看起来过于困难的话,可以尝试先去阅读基础版本的,里面介绍的会更加详细。

项目整体演示可以观看视频:推箱子小游戏项目演示

2.项目准备

还是老规矩,先在原解决方案中,新建一个项目,项目名为:day5--BoxMan,设置为启动项,并添加一个main.cpp的文件:

image-20231129064140790

既然是写游戏,用控制台肯定不行,太难看了,同时为了兼顾从没有接触过图形编程的新手,所以我打算采用EGE图形库

这个库的使用非常简单,体积也很小,非常适合新手学习,下载地址:Easy Graphics Engine

然后点击下图所示进行下载:

image-20231129064242636

下载并解压后如下图:

image-20231129064508505

image-20231129064553875

include文件夹和graphics64.lib拷贝到今天的项目中:

image-20231129064717665

然后回到项目,写下代码,运行一下,不报错即成功:

#include"include/graphics.h"
#pragma comment(lib,"graphics64.lib")
int main() {
}

自此,我们的准备工作就完成了!

下面,我开始对上面的步骤进行解释:

  • 首先,为什么我只复制include文件夹和VS2019文件夹下的graphics64.lib

因为include文件夹下是ege库的所有头文件,就和我们使用C/C++的头文件iostream一样,既然我们要使用它,就必须得包含它。

而之所以使用VS2019下的库文件,只是因为ege目前还没有更新VS2022版本,只能暂时用VS2019凑合,但区别也不大。

至于其它文件夹中的内容,主要是一些使用该库的示例,以及其它开发环境用的库,有兴趣的可以自己看一看。

  • include是头文件,前面学过我明白,那graphics64.lib是什么东西?

这个文件被称为静态库,前面我们提到过,一般我们写函数或者类的时候,都是将声明定义放在同名的两个文件里面,一个叫做头文件,一个叫做源文件,这样如果别人想要使用我写的代码,就可以直接把这两个文件复制给对方。

但是这有一个问题,就是对方能看到我们写的代码!

如果是付费的产品,别人如果都拿到了你写的源代码,以后还会找你吗?直接自己改不就行了!

这种时候,我们是想让对方用,但我们又不能将源代码给对方,而这就可以用到静态库,它就像.cpp文件一样,需要配合.h文件使用。

但好处是,此时对方就不能看到你源代码了,因为你的源码都被打包成了.lib的库文件!后面章节我也会教大家如何打包自己的库文件。

总结一句话就是:graphics64.lib文件就是用来代替.cpp文件的,要结合对应的.h文件使用,而所有的.h文件都在include文件夹里面。

需要注意的是,由于我一直以来都是使用的x64平台,这也是未来的趋势,所以选择的是x64文件夹中的库文件,如果你非要使用x86,那就请使用x86文件夹下的库文件。

而使用这个库文件,就需要用该语句#pragma comment(lib,"graphics64.lib"),如果是其它库,就换个名字就行了,这也没啥讲究,直接复制就行了。

  • 代码中包含头文件的方式怎么这么奇怪?

这是由于VS默认是在当前项目文件夹中寻找头文件的,如果你的头文件在include文件夹中,那就得先输入文件夹include,然后vs会自动给你弹出该文件夹下有哪些头文件,然后包含对应的头文件即可。

3.游戏属性

有了前面的准备,现在我们就可以开始写代码了,但首先还是要先确定一下我们目前的思路:

  1. 为避免过于复杂,我们只要一个游戏窗口,用来显示游戏就行了。
  2. 但我们还得有选择关卡,选择英雄,选择地图等功能,这一部分就直接用控制台完成。

所以我们的代码总体逻辑就是:点开游戏之后,可以通过控制台输入的方式来选择一些游戏特性,然后通过游戏窗口来显示游戏。

所以我们先来写一个显示选择属性的控制台界面函数:

#define SHOW_CONSOLE  //必须定义这个宏在 ege的两个头文件前面,才能使用控制台
#include"include/graphics.h"
#include<iostream>
#pragma comment(lib,"graphics64.lib")
using std::cout;  //输出
using std::cin;  //输入
using std::endl; //换行,与 \n 类似

int hero;	//保存用户保存的英雄
int pce;	//选择用户保存的场景地图
void ChooseAttribute(); //游戏开始前,选择属性的函数

int main() {
	ChooseAttribute(); //选择属性
}
void ChooseAttribute() {
	cout << "操作提示:" << endl;
	cout << "    ↑" << endl;
	cout << "←\t→" << endl;
	cout << "    ↓" << endl;
	cout << "↑:上移动 ↓:下移动 ←:左移动 →:右移动 r/R:撤回" << endl << endl;
	while (1) {
		cout << "1、小火 2、马里奥 3、皮卡丘" << endl;
		cout << "请选择你的英雄:";
		cin >> hero;
		if (!cin.good()) { //如果出现错误,比如输入的不是数字
			cin.clear(); //清除错误标志
			cin.ignore(1, '\n'); // 忽略一个字符,遇到换行符终止该函数继续
			cout << "请输入数字!" << endl;
			continue;
		}
		if (hero < 1 || hero>3) {
			cout << "请输入正确的序号!" << endl;
			continue;
		}
		break;
	}
	while (1) {
		cout << "1、森林 2、泥地" << endl;
		cout << "请选择游戏场景:";
		cin >> pce;
		if (!cin.good()) { //如果出现错误,比如输入的不是数字
			cin.clear(); //清除错误标志
			cin.ignore(1, '\n'); // 忽略一个字符,遇到换行符终止该函数继续
			cout << "请输入数字!" << endl;
			continue;
		}
		if (pce < 1 || pce>2) {
			cout << "请输入正确的序号!" << endl;
			continue;
		}
		break;
	}
}

运行结果:

image-20231129070459934

然后来解释一下上面的代码:

  • 由于ege默认是禁用我们的控制台的,所以第一行必须添加这个宏定义SHOW_CONSOLE,否则无法使用控制台。
  • 使用的输入输出大家应该知道,那么使用std里面的这个endl是干嘛的?

一般来说,它基本等价于前面我们一直用到的\n换行符,如果深究的话,endl不仅仅只换行,它还会刷新缓存区,更多细节,可以浏览器搜索endl的作用。

  • 然后我们申请了两个全局变量heropce,分别用来保存用户的选择,与全局变量所对应的是局部变量,不理解的可以参考一下文章常量与变量,里面介绍了变量的生命周期。
  • 接着就是函数声明,因为将这么一大坨代码写在main函数前面实在不好看啊。
  • main函数里面只是调用了一下这个函数。
  • ChooseAttribute函数里面的代码也很简单,就只是一些输入与输出,用来构建一个游戏启动选择属性的界面。

唯一值得注意的是这个写法:

if (!cin.good()) { //如果出现错误,比如输入的不是数字
    cin.clear(); //清除错误标志
    cin.ignore(1, '\n'); // 忽略一个字符,遇到换行符终止该函数继续
    cout << "请输入数字!" << endl;
    continue;
}

由于cin输入是可以自动判断推断数据类型的,我们这里虽然想要接受的是数字,但并不能保证用户就一定会输入数字,如果此时用户输入字母,那就会出现问题。

所以当用户输入后,我们可以用cin上的good函数来判断当前是否没事,前面添加了取反符号!,结合起来就是如果cin解析输入出现了问题,那么就执行内部的语句,等价于if(cin.good()==false)

内部主要调用了clear函数,用于清除其内部的错误标志,作用就是此后调用good函数就可以再次返回true了,当然前提是不会再出现输入错误问题。

只是单纯的忽略错误是不够的,我们还需要让它忽略掉用户输入的这个不符合的字符,也就是调用的cin上的ignore函数。

因为不忽略的话,下次循环它依旧会尝试解析这个错误的字符。

它有两个参数,第一个参数是忽略的字符数量,第二个是遇到什么字符时终止忽略,比如这里我填入的1代表只忽略一个字符即可,这种情况下第二个参数是没效果的。

它的第二个参数作用在于:如果我第一个参数为100,也就是想要忽略100个字符,可忽略到第10个字符时发现该字符是我们传入的第二个参数,也就是这里我填入的是换行符,那就直接停止忽略继续运行。

4.初始游戏内容

完成了前置工作,现在我们就正式开始研究游戏界面的操作。

因为即使是单纯的界面编程来说,都是非常复杂的,所以我们使用了EGE这个库,可以极大的简化我们的编程过程。

首先,我们先来大致理解一下我们游戏界面的运作流程:

  1. 游戏窗口,我们可以直接看作是一块画布。
  2. 而游戏界面,就是将不同的贴图,贴到画布上。
  3. 而画布是不断刷新的,而且非常快,所以当画布上的贴图变化时,我们根本看不出来是贴图变化了,就以为是游戏里面的人物动了。

如果难以理解,就直接实战吧,首先我们需要初始化一块画布,在上面已有的基础上,改为下面的代码:

int main() {
	//ChooseAttribute(); //选择属性
	initgraph(805, 630); //初始化窗口的属性
	getch(); //让程序在这里停止,直到用户按下任意一个按键,才继续执行
	closegraph(); //关闭窗口
}

上面的代码运行后,就可以得到一个窗口:

image-20231129071954791

上面使用的三个函数就是EGE库中提供给我们的函数:

  1. initgraph:用于初始化一个窗口界面,有多个构造函数,第一个窗口为窗口的宽,第二个为窗口的高,单位都是像素。
  2. getch:等待用户的一个任意按键,然后返回用户所按的键,这里的作用是卡住程序,不让它一下子就结束,前面我们用过Sleep函数,目的也是一样的,让它别太快就结束了。
  3. closegraph:关闭窗口。

这三个函数也是非常简单的,唯一让大家疑惑的是,我怎么知道有这些函数?

首先,肯定是浏览器搜索,看有没有前人总结的经验,然后就是翻官方文档,比如这篇:EGE介绍

这篇文章的作者应该也是EGE库的维护人员之一,他写了相当多的关于EGE库的使用教程,感兴趣的可以看一看。

这篇文章里面就提到库函数所在网址:库函数

image-20231129072225767

需要哪种函数,直接点进去看就行了,比如第一个函数,就是绘图函数,里面就有相关的使用介绍:

image-20231129072510186

它有三个参数,只不过第三个参数有默认值了,所以不需要填,重要的是前两个参数:

image-20231129072606163

有了画布,接下来,就是往上面贴图了,该游戏的素材图片我已经准备好了,

可以直接点击这里进行下载,解压后,将得到的img文件夹放入项目中:

image-20231129072901049

这里总共有两个场景的地图的图片,一是传统的森林场景,前缀为sl,还额外有一个场景为泥地,前缀为nd

image-20231129072952245

我们先来试试森林场景:

int main() {
	//ChooseAttribute(); //选择属性
	initgraph(805, 630); //初始化窗口的属性
	PIMAGE imgblock = newimage(); //创建一个图片对象
	getimage(imgblock, ".\\img\\sl.bmp"); //给这个图片对象关联一张图片,这里关联的是草地图片
	//为整个游戏地图铺上草地
	for (int i = 0; i < 23; i++)
	{
		for (int j = 0; j < 18; j++)
		{
			putimage(i * 35, j * 35, imgblock);
		}
	}
	getch(); //让程序在这里停止,直到用户按下任意一个按键,才继续执行
	closegraph(); //关闭窗口
}

运行结果:

image-20231129073104967

是不是很简单?就是把图片往窗口上贴就完事了!

下面再来解释一下代码:

  • newimage是EGE库提供的函数,用于创建一个图片对象,通过它返回的对象,可以存放图片资源。
  • getimage同样也是EGE库函数,用途是给上面得到的图片对象里面放图片的,第一个参数为要存放图片的图片对象PIMAGE,而第二个参数就是图片的路径,这里的路径写法不清楚的可以看一看这篇文章:路径详解
  • 然后我们就可以通过两个for循环,使用putimage函数将这个图片贴到窗口上,因为这个图片的大小是35*35的,而窗口大小是805*630的,单位均为像素,所以横着可以放23张图片,竖着可以放18张图片。
  • putimage函数的第1、2参数为要贴的位置坐标,作为图片的左上角,第三个参数为要贴的图片,需要注意的是,计算机里面一般以左上角为原点,横着为X轴,竖着为Y轴

image-20231129073731547

其它图片也是一样的操作,至此,我们就学会了贴图!

但如果只贴图肯定是的,毕竟游戏需要动起来,所以用getch这个函数把程序卡着肯定不行。

那如何让程序能够不断地贴图,让人觉着它是在动的感觉呢?那肯定就是循环了!

将代码进行如下改造:

int main() {
	//ChooseAttribute(); //选择属性
	initgraph(805, 630, 0); //初始化窗口的属性
	//游戏循环
	for (; is_run(); delay_fps(60)) {
		//todo:贴图的函数,后面补充

		//如果当前用户没有按任何按键,则进行下一次循环
		if (!kbmsg()) {
			continue;
		}
		key_msg kMsg = getkey(); //得到用户点击的按键
		if (kMsg.msg != key_msg_down) { //如果这个消息不是按键按下的消息,则进行下一次循环
			continue;
		}
		//根据按下的键,分别进行不同的操作
		switch (kMsg.key)
		{
		case key_up:	//向上移动
			//todo:执行向上移动的操作
			break;
		case key_right:	//向右移动
			//todo:执行向右移动的操作
			break;
		case key_down:	//向下移动
			//todo:执行向下移动的操作
			break;
		case key_left:	//向左移动
			//todo:执行向左移动的操作
			break;
			//悔步骤
		case 'R':
			//todo:执行悔棋的操作
			break;
		default:
			break;
		}
	}
	closegraph(); //关闭窗口
}

在讲解代码之前, 还需要先引入消息循环机制,否则不好理解。

你知道为什么当你拖动浏览器窗口的时候,浏览器会跟着你的鼠标走吗?没错,这就是依靠的消息循环:

  1. 当你点击浏览器窗口时,系统就会给浏览器发送一个消息,里面就包含了你鼠标的消息,比如你鼠标当前在屏幕上的位置等等。
  2. 浏览器接收到这个消息,就进行处理,如果是鼠标拖动的消息,就通过计算坐标,将自己的窗口移动到你鼠标拖动的位置。

同理,按键消息也是,当你按下键盘上的按键时,系统会将你的按键信息发送给软件,软件接收到这个消息就开始解析,然后执行对应的操作。

现在,再让我们来看这段程序,首先是一个for循环:

image-20231129074218744

第一个参数为初始化变量,没有则可以不填。

第二个为判断语句,用到了EGE的函数is_run函数,这个函数只要你不主动关闭窗口,就将一直返回true,也就形成了循环。

第三个为执行语句,调用的delay_fps函数,即设置每秒60帧,也就是每秒窗口都会被重绘60次,该函数的作用有点类似于前面的getch函数,也是用来卡住程序的,只不过它只卡一定的时间,精确的时间就是1秒除以传入的参数60

然后我们来到循环内部:

image-20231129074311507

首先调用了kbmsg函数,该函数就是用来检测当前用户是否有按键按下,如果没有将返回false,然后我在其前面添加了一个!号意为取反,则结果为true,整句话的意思就是:如果没有按键按下,我就执行if里面的语句continue,跳过后面的内容,直接去下一次循环。

continue语句与前面提到的break语句有点类似,唯一的不同是,break语句是完全跳出当前循环,而continue只跳过这一次,但还会继续执行下一次循环。

然后我们调用了getkey函数,如果能执行到这里,说明用户已经按下了按键,我们就需要用这个函数将用户按下的按键消息取过来,其返回值key_msg被赋值给了kMsg 变量,该结构里面就是我们按下的按键信息。

该结构有三个成员变量,详情可点击这里去官网查看。

image-20231129074603207

其中key指明你按下的是什么键,点击这里看有哪些键可以用,msg指明按键的类型,是按下?还是弹起?而flags用于查看是不是同时还按了ctrlalt键?也就是我们常用的组合键Ctrl+C等。

从上述官网中,我们看到上图的代码结构,并且以后我们会经常看到,先不看前面和后面的单词:

struct key_msg {
    UINT msg; //按键类型
    UINT key; //按的什么键
    UINT flags; //是否还按了Ctrl或Alt组合键
}

这不就是个很简单的结构体吗?

所以从上可以看出,typedef的用法也很简单,就是将前面内容,定义为后面的内容。

也就是将上面这个结构体,定义为key_msg,看上去是不是和define语句很相似?以后使用这个结构体,就可以通过后面这个名词进行定义。但这两者还是有很多的不同的,大家可自行搜浏览器搜索一下两者的区别与用法!

但就单纯使用、能看懂而言,你只需要知道它就是用来给前面类型新添加一个名字的,而新名字就是紧跟在最后的那个,比如这里的key_msg,虽然它这里写的有点废话的感觉。

紧接着就可以通过判断该变量中的msg值,是否为按键按下的类型,其它类型可查看官网:key_msg,如果不是按键按下,则跳过本次循环,不执行下面的内容。

随后,就来到了switch语句,通过变量内部的key值来进行判断我们按下的是什么键,所有可能的键值点这里查看。

我们这里只判断了上、下、左、右的方向键,和R键,这里注意一下,R键并没有官方宏定义,因为本质来说它们都是宏定义的数字,而这些字母按键其实就是它们本身大写字母的ascii码值,即字符 'R' 所代表的值,所以可以直接这么用。

同时这里注意要用单引号,单引号代表字符,双引号代表字符串,这两者有很大的不同,可以自己搜索一下两者的区别

现在,让我们简单总结一下上面代码实现的效果:游戏运行在一个趋近于死循环的for循环中,不断接受用户的按键信息,然后根据按键信息做出相应的反应。

至此,我们对于该游戏窗口的基础知识就学完了!

5.游戏界面绘制

前面内容总的来说,我们已经知道怎么创建一个窗口、往窗口贴图、以及如何实现一个游戏循环。

现在回到起点,我们有如下代码:

#define SHOW_CONSOLE  //必须定义这个宏在 ege的两个头文件前面,才能使用控制台
#include"include/graphics.h"
#include<iostream>
#pragma comment(lib,"graphics64.lib")
using std::cout;  //输出
using std::cin;  //输入
using std::endl; //换行,与 \n 类似

int hero;	//保存用户保存的英雄
int pce;	//选择用户保存的场景地图
void ChooseAttribute(); //游戏开始前,选择属性的函数

int main() {
	ChooseAttribute(); //选择属性
}

void ChooseAttribute() {
	cout << "操作提示:" << endl;
	cout << "    ↑" << endl;
	cout << "←\t→" << endl;
	cout << "    ↓" << endl;
	cout << "↑:上移动 ↓:下移动 ←:左移动 →:右移动 r/R:撤回" << endl << endl;
	while (1) {
		cout << "1、小火 2、马里奥 3、皮卡丘" << endl;
		cout << "请选择你的英雄:";
		cin >> hero;
		if (!cin.good()) { //如果出现错误,比如输入的不是数字
			cin.clear(); //清除错误标志
			cin.ignore(1, '\n'); // 忽略一个字符,遇到换行符终止该函数继续
			cout << "请输入数字!" << endl;
			continue;
		}
		if (hero < 1 || hero>3) {
			cout << "请输入正确的序号!" << endl;
			continue;
		}
		break;
	}
	while (1) {
		cout << "1、森林 2、泥地" << endl;
		cout << "请选择游戏场景:";
		cin >> pce;
		if (!cin.good()) { //如果出现错误,比如输入的不是数字
			cin.clear(); //清除错误标志
			cin.ignore(1, '\n'); // 忽略一个字符,遇到换行符终止该函数继续
			cout << "请输入数字!" << endl;
			continue;
		}
		if (pce < 1 || pce>2) {
			cout << "请输入正确的序号!" << endl;
			continue;
		}
		break;
	}
}

前面我们已经有了选择属性的函数,但由于我们还需要选择关卡,而如果采用图形界面来选择关卡的话,还是太过麻烦,容易让我们陷进去,所以这里还是采用控制台的方式选择关卡:

  1. 首先在控制台中选择关卡,然后打开该关卡的地图绘制游戏界面。
  2. 如果通关了,就重新弹出控制台窗口选择游戏关卡。

为了实现以上目的,我们就得在main函数里面写下如下代码:

int main() {
	ChooseAttribute(); //选择属性
	char title[200] = "";
	while (1) {
		cout << "当前已有关卡:1、2、3" << endl; //输出当前已有的关卡数据
		cout << "请选择关卡:";  //让用户选择关卡
		int n;
		cin >> n;  //输入的关卡数
		if (!cin.good()) { //如果出现错误,比如输入的不是数字
			cin.clear(); //清除错误标志
			cin.ignore(1, '\n'); // 忽略一个字符,遇到换行符终止该函数继续
			cout << "请输入数字!" << endl;
			continue;
		}
		if (n <= 0 || n > 3) {  //如果不正确,则进入下一个循环,重新输入
			cout << "请输入正确的关卡!" << endl;
			continue;
		}

		//todo:写一个开始指定关卡的函数

		//设置标题
		sprintf_s(title, 200, "恭喜第%d关通关成功,请返回命令窗口重新选择关卡", n - 1);
		setcaption(title);//设置窗口标题
		//输出通关信息
		cout << "恭喜过关!" << endl;
		//切换窗口到控制台,让用户选择关卡
		HWND hWndCon = GetConsoleWindow();