1.前言
本项目可以看作是本站另一门入门教程《C语言:推箱子小游戏》的进阶教程。
如果本文看起来过于困难的话,可以尝试先去阅读基础版本的,里面介绍的会更加详细。
项目整体演示可以观看视频:推箱子小游戏项目演示
2.项目准备
还是老规矩,先在原解决方案中,新建一个项目,项目名为:day5--BoxMan
,设置为启动项,并添加一个main.cpp
的文件:
既然是写游戏,用控制台肯定不行,太难看了,同时为了兼顾从没有接触过图形编程的新手,所以我打算采用EGE图形库
。
这个库的使用非常简单,体积也很小,非常适合新手学习,下载地址:Easy Graphics Engine。
然后点击下图所示进行下载:
下载并解压后如下图:
将include
文件夹和graphics64.lib
拷贝到今天的项目中:
然后回到项目,写下代码,运行一下,不报错即成功:
#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.游戏属性
有了前面的准备,现在我们就可以开始写代码了,但首先还是要先确定一下我们目前的思路:
- 为避免过于复杂,我们只要一个游戏窗口,用来显示游戏就行了。
- 但我们还得有选择关卡,选择英雄,选择地图等功能,这一部分就直接用控制台完成。
所以我们的代码总体逻辑就是:点开游戏之后,可以通过控制台输入的方式来选择一些游戏特性,然后通过游戏窗口来显示游戏。
所以我们先来写一个显示选择属性的控制台界面函数:
#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;
}
}
运行结果:
然后来解释一下上面的代码:
- 由于
ege
默认是禁用我们的控制台的,所以第一行必须添加这个宏定义SHOW_CONSOLE
,否则无法使用控制台。 - 使用的输入输出大家应该知道,那么使用
std
里面的这个endl
是干嘛的?
一般来说,它基本等价于前面我们一直用到的\n
换行符,如果深究的话,endl
不仅仅只换行,它还会刷新缓存区,更多细节,可以浏览器搜索endl
的作用。
- 然后我们申请了两个全局变量
hero
与pce
,分别用来保存用户的选择,与全局变量所对应的是局部变量,不理解的可以参考一下文章常量与变量,里面介绍了变量的生命周期。 - 接着就是函数声明,因为将这么一大坨代码写在
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
这个库,可以极大的简化我们的编程过程。
首先,我们先来大致理解一下我们游戏界面的运作流程:
- 游戏窗口,我们可以直接看作是一块画布。
- 而游戏界面,就是将不同的贴图,贴到画布上。
- 而画布是不断刷新的,而且非常快,所以当画布上的贴图变化时,我们根本看不出来是贴图变化了,就以为是游戏里面的人物动了。
如果难以理解,就直接实战吧,首先我们需要初始化一块画布,在上面已有的基础上,改为下面的代码:
int main() {
//ChooseAttribute(); //选择属性
initgraph(805, 630); //初始化窗口的属性
getch(); //让程序在这里停止,直到用户按下任意一个按键,才继续执行
closegraph(); //关闭窗口
}
上面的代码运行后,就可以得到一个窗口:
上面使用的三个函数就是EGE
库中提供给我们的函数:
initgraph
:用于初始化一个窗口界面,有多个构造函数,第一个窗口为窗口的宽,第二个为窗口的高,单位都是像素。getch
:等待用户的一个任意按键,然后返回用户所按的键,这里的作用是卡住程序,不让它一下子就结束,前面我们用过Sleep
函数,目的也是一样的,让它别太快就结束了。closegraph
:关闭窗口。
这三个函数也是非常简单的,唯一让大家疑惑的是,我怎么知道有这些函数?
首先,肯定是浏览器搜索,看有没有前人总结的经验,然后就是翻官方文档,比如这篇:EGE介绍。
这篇文章的作者应该也是EGE库的维护人员之一,他写了相当多的关于EGE库的使用教程,感兴趣的可以看一看。
这篇文章里面就提到库函数所在网址:库函数
需要哪种函数,直接点进去看就行了,比如第一个函数,就是绘图函数,里面就有相关的使用介绍:
它有三个参数,只不过第三个参数有默认值了,所以不需要填,重要的是前两个参数:
有了画布,接下来,就是往上面贴图了,该游戏的素材图片我已经准备好了,
可以直接点击这里进行下载,解压后,将得到的img文件夹放入项目中:
这里总共有两个场景的地图的图片,一是传统的森林场景,前缀为sl
,还额外有一个场景为泥地,前缀为nd
:
我们先来试试森林场景:
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(); //关闭窗口
}
运行结果:
是不是很简单?就是把图片往窗口上贴就完事了!
下面再来解释一下代码:
newimage
是EGE库提供的函数,用于创建一个图片对象,通过它返回的对象,可以存放图片资源。getimage
同样也是EGE库函数,用途是给上面得到的图片对象里面放图片的,第一个参数为要存放图片的图片对象PIMAGE
,而第二个参数就是图片的路径,这里的路径写法不清楚的可以看一看这篇文章:路径详解。- 然后我们就可以通过两个
for
循环,使用putimage
函数将这个图片贴到窗口上,因为这个图片的大小是35*35
的,而窗口大小是805*630
的,单位均为像素,所以横着可以放23
张图片,竖着可以放18
张图片。 putimage
函数的第1、2参数为要贴的位置坐标,作为图片的左上角,第三个参数为要贴的图片,需要注意的是,计算机里面一般以左上角为原点,横着为X轴,竖着为Y轴
其它图片也是一样的操作,至此,我们就学会了贴图!
但如果只贴图肯定是的,毕竟游戏需要动起来,所以用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(); //关闭窗口
}
在讲解代码之前, 还需要先引入消息循环机制,否则不好理解。
你知道为什么当你拖动浏览器窗口的时候,浏览器会跟着你的鼠标走吗?没错,这就是依靠的消息循环:
- 当你点击浏览器窗口时,系统就会给浏览器发送一个消息,里面就包含了你鼠标的消息,比如你鼠标当前在屏幕上的位置等等。
- 浏览器接收到这个消息,就进行处理,如果是鼠标拖动的消息,就通过计算坐标,将自己的窗口移动到你鼠标拖动的位置。
同理,按键消息也是,当你按下键盘上的按键时,系统会将你的按键信息发送给软件,软件接收到这个消息就开始解析,然后执行对应的操作。
现在,再让我们来看这段程序,首先是一个for
循环:
第一个参数为初始化变量,没有则可以不填。
第二个为判断语句,用到了EGE
的函数is_run
函数,这个函数只要你不主动关闭窗口,就将一直返回true
,也就形成了循环。
第三个为执行语句,调用的delay_fps
函数,即设置每秒60
帧,也就是每秒窗口都会被重绘60
次,该函数的作用有点类似于前面的getch
函数,也是用来卡住程序的,只不过它只卡一定的时间,精确的时间就是1
秒除以传入的参数60
。
然后我们来到循环内部:
首先调用了kbmsg
函数,该函数就是用来检测当前用户是否有按键按下,如果没有将返回false
,然后我在其前面添加了一个!
号意为取反,则结果为true
,整句话的意思就是:如果没有按键按下,我就执行if
里面的语句continue
,跳过后面的内容,直接去下一次循环。
continue
语句与前面提到的break
语句有点类似,唯一的不同是,break
语句是完全跳出当前循环,而continue
只跳过这一次,但还会继续执行下一次循环。
然后我们调用了getkey
函数,如果能执行到这里,说明用户已经按下了按键,我们就需要用这个函数将用户按下的按键消息取过来,其返回值key_msg
被赋值给了kMsg
变量,该结构里面就是我们按下的按键信息。
该结构有三个成员变量,详情可点击这里去官网查看。
其中key
指明你按下的是什么键,点击这里看有哪些键可以用,msg
指明按键的类型,是按下?还是弹起?而flags
用于查看是不是同时还按了ctrl
或alt
键?也就是我们常用的组合键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;
}
}
前面我们已经有了选择属性的函数,但由于我们还需要选择关卡,而如果采用图形界面来选择关卡的话,还是太过麻烦,容易让我们陷进去,所以这里还是采用控制台的方式选择关卡:
- 首先在控制台中选择关卡,然后打开该关卡的地图绘制游戏界面。
- 如果通关了,就重新弹出控制台窗口选择游戏关卡。
为了实现以上目的,我们就得在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();