大家好,很高兴又和大家见面啦!在上一篇内容中,我们详细介绍了三子棋的编写思路,相信大家在阅读完上一篇后对相关的知识点及其运用也有了相应的提升。下面我们就来开始介绍今天的内容——扫雷。
扫雷的游戏规则很简单。盘面上有许多方格,方格中随机分布着一些雷。你的目标是避开雷,打开其他所有格子。一个非雷格中的数字表示其响铃的8格中的雷数,你可以利用这个信息推导出安全格和雷的位置。你可以用右键在你认为是雷的地方插棋(称为标雷)。你可以用左键打开安全的地方,左键打开雷将被判定为失败。
下面我们看一下扫雷的几种模式:
这个是初级扫雷,盘面由9*9的方格组成,方格里面有随机的10个格子中藏着雷;
这个是中级扫雷,盘面由16*16的方格组成,方格里面有随机的40个格子中藏着雷;
这个是高级扫雷,盘面由30*16的方格组成,方格里面有随机的99个格子中藏着雷;
这个是自定义扫雷,盘面和藏雷数可以凭自己的喜好在宽度和高度这一栏去更改,现在展示的是由30*30的方格组成,方格里面有随机的150个格子中藏着雷;
这里因为能力和时间有限,咱先给大家展示一下初级扫雷,以此来更加详细的介绍扫雷的玩法:
游戏界面由游戏模式选择区、剩余雷数统计区、重开按钮、扫雷时间记录区以及排雷区组成;
这里我们可以看到,游戏开始的标志是从我们翻开第一个格子之后就开始了,但翻开的格子为空格时会将空格周围的九宫格全部翻开,直到遇到数字时停止,这里的数字的作用是提示用户以当前数字为中心的九宫格内有几颗雷,1就代表1颗,2就代表2颗以此类推,数字为几,就代表以数字为中心的九宫格有几颗雷;
当以数字为中心的九宫格减去数字大小的格数全部翻开时,剩下的格子百分百是雷,如图示圈出来的数字1,以它为中心的九宫格的8个格子以被翻开,那剩余的1格百分百是雷,图中圈中的以数字2为中心的九宫格也是同样的情况,这个九宫格内的7格全部翻开,剩余的两格百分百是雷,此时我们可以通过右键在雷上插上旗帜来标记地雷的位置;
如图所示,在未完全翻开的九宫格内,如果以经找到了与中心数字相同的雷数并插上了旗帜,此时我们可以通过右键选中中间的数字,并点击左键来翻开其它区域;
但是如果我们并未在九宫格内插上旗帜,则通过右键选中正中间的数字并点击左键时,系统只会提示这里还有格子未翻开,并不会帮你翻开这一格,这也是一个扫雷的常用技巧,通过多个数字的盲格区交叉点来判断藏雷的概率大小从而进行盲扫;
如图所示,当游戏陷入僵局时,我们也可以通过这个技巧来判断雷分布的大致位置以及每个位置的概率大小,多个数字的交叉点是雷的概率很大,就像图中的3/3/2这三个数字的交叉点,就有很大概率是雷,但并不是百分百,这里要注意;
游戏胜利时,重开按钮会从笑脸变为耍酷脸,游戏胜利的判断是所有的安全区全部被翻开,并不是剩余雷数为0,这个一定要切记;
游戏失败的判断是当还有安全区未被翻开时,雷区被翻开了,此时游戏失败,重开按钮会从笑脸变为哭丧脸。
经过前面的玩法展示,我相信大家应该多少都知道扫雷应该怎么玩了,接下来我们就要开始分析扫雷的各项功能,以及功能如何实现,从而来梳理咱们的编写思路了;
在开始理思路之前,咱们要先弄清楚一个前提,咱们本次做的扫雷,是一个简易版的扫雷,更多的是类似与前面编写的三子棋一样的游戏,肯定不能像我给大家展示的游戏一样,就目前我所掌握的知识还不足以制作一款这样的游戏,我们只能将之前学过的知识,给将它们结合起来运用,以此来达到提升我们自身编码水平与知识理解运用能力的目的。
在前面的介绍中我们也提到了,一款扫雷游戏的游戏界面由游戏模式选择区、剩余雷数统计区、重开按钮、扫雷时间记录区以及排雷区组成,同样我们在编写的时候肯定也是需要这些内容,但是可以将其简化一下,以达到一个最低的需求,也就是我们需要编写的游戏界面由——菜单栏(选择开始游戏还是退出游戏)、排雷区(进行游戏)两个部分组成。
下面来分析一下这两个部分的一个设计思路:
依照之前的游戏设计来看,我们只需要让它能够提供给玩家是选择开始游戏还是结束游戏就行,并不需要其它的功能,所以这里我们依旧可以通过printf
来完成;
排雷区是咱们这个游戏的设计重点,它需要有以下几个功能:
rand
函数以及起点生成器与时间戳在场地内随机生成一定数量的地雷;scanf
函数来进行坐标的接收,并通过二维数组来进行存储;坐标输入完后,我们需要进行第一步判断,玩家输入的坐标是否合理;
坐标合理的情况下,我们要进行第二步判断,该坐标是否有地雷;
该坐标不是雷的情况下,我们要进行第三步判断,该坐标周围是否有雷,有几颗雷;
要实现这些判断,我们可以通过条件语句来实现;
有了上面的设计思路,那我们现在就要开始一步一步的实现了。
首先我们还是创建test.c
和game.c
两个项目与game.h
这个头文件;
创建好后我们先偷个懒,在前面的编写思路里我们有提到过要使用printf、scanf、rand、srand、time
这些函数,所以我们先提前在game.h
这个头文件中进行引用相关的头文件,这样我们后续只需要在test.c
和game.c
两个项目中引用game.h
这个头文件就可以了,如下图所示:
准备工作做好,我们开始进行各个功能的编码吧;
菜单栏我们已经编写过很多次了,这里我还是要重复一遍,菜单栏可以通过printf
函数实现,通过菜单栏,我们可以告诉玩家两个选项,开始游戏和退出游戏,玩家通过输入相应的数字来进行后续内容,代码如下:
//功能一——菜单栏
void menu()
{
printf("##############################\n");
printf("####0.退出游戏 1.开始游戏####\n");
printf("##############################\n");
}
int main()
{
int choose = 0;
do
{
menu();
printf("请输入(0/1)>:");
scanf("%d", &choose);
switch (choose)
{
//玩家选择0,则退出游戏
case 0:
printf("退出游戏\n");
break;
//玩家选择1,则开始游戏
case 1:
printf("开始扫雷\n");
break;
//玩家输入其它选项,则提示错误,重新输入
default:
printf("输入错误,请重新输入>:\n");
break;
}
} while (choose);
return 0;
}
接下里我们测试一下功能一:
可看到,很完美的实现了咱们的功能,接下来,我们继续编写后面的功能;
在创建放置区与盲区前,我们要先确定一下这两个区域内存放的内容分别是什么。
既然是扫雷,我们就可以很容易联想到用字符来表示地雷,当然有朋友也可能说,我也可以用数字来表示地雷呀,所以这里我们先暂定用字符或者整型来表示;
我们还需要一个盲区来把地雷掩藏起来,在扫雷游戏中我们看到的是一片空白,也就是说我可以通过空格来将其掩藏,还有朋友说我也可以通过‘*’或者‘#’来掩藏地雷;
既然如此,那我们不妨直接采用字符数组来进行存放地雷信息与掩盖信息,当然如果你们愿意的话也可以定义其它类型的数组来存储信息,这个根据个人喜好;
在确定了存放信息的数组类型后,紧接着我们还要确定的是存放信息的空间大小,这时可能就有朋友会说了,扫雷游戏上是9*9
的空间大小,我们不妨也采用9*9
的空间大小,这不就解决了吗?下面我们来思考一个问题,在扫雷游戏中,处于边角的格子是如何判定的:
从图中我们可以看到,处于界的格子如果在后面扫雷判定阶段的话它无法实现一个正常的九宫格判定,而是根据位置的不同导致它需要判定的方格数量不同,有朋友可能就会说了,这没关系呀,只要我到时候在在边角处加入一个判断条件不就好了吗?这个方法确实可行,但是会比较麻烦,麻烦的地方在于以下几点:
那基本上每一个格子我们都需要完成这三步判定,感觉上在无形之中就给咱们的编码增加了负担。
那我们可不可以调整一下呢?使每一次判定都是一个九宫格,这样我们就不需要担心出现数组越界的情况了。
答案是肯定的,我们只需要在边界外面再补一圈,也就是将9*9
的范围扩大到11*11
,但是我们在进行显示的时候,只显示中间的9*9是不是就可以解决了。
在确认了数组类型与空间大小后,我们就可以定义对应的数组了:
两个区域定义好后我们就可以开始对两个区域进行初初始化了;
对于初始化的内容就没有那么多的约束,可以根据自己的喜好初始化,比如放置区我将全部区域都初始化为'0'
或者' '
,盲区我可以全部初识化为' '
或者'*'
这里都可以根据自己的喜好来;
我们在初始化的时候只需要保证初始化的是整个区域11*11
也就是实际行ROWS
,而不是9*9
也就是实际列COLS
就可以了,这样方便我们后面排查的时候能够正常排查;
在确定好内容和区域后,我们就可以开始编写初始化函数了,可以参照三子棋的初始化过程来定义,先在game.c
中定义函数,再到game.h
中声明函数,最后到test.c
中调用函数就可以了:
如果只是这样去编写的话,那我要初始化盲区的话是不是也全部变成字符0了呀,但是我希望我初始化盲区的时候能够有不同的符号,这样怎么处理呢?
有朋友马上就反应过来了,我只需要传参的时候把要初识化的内容一并传过来就好了呀,这里我们只需要再定义一个形参来接收就可以了,废话不多说,咱们直接开始操作:
现在我们通过形参set来介绍初始化的字符,这样就可以完美解决了,但是到底解决没有呢?别着急,下面我们就要借助打印功能来一探究竟了;
打印功能我们同样可以参考三子棋的打印,不过现在我们只需要按照循序,一行一行的打印出来就可以了,但是要注意,我们打印的范围为是9*9
不是11*11
,所以传参时我们传的是显示的行ROW
和显示的列COL
:
现在我们就可以看到,不管是放置区还是盲区都已经成功被初始化了,这时候可能就有朋友说了,你盲区哪里被初始化了呀,我们都看不到。这是因为我们此时将盲区用空格初始化的,这也说明了目前因为我们没法做的像展示的扫雷一样有颜色的区分,那我们就需要有一些显眼的字符来表示区域,这里我们进行一下修改,将盲区的空格改为'*'
:
现在是不是就清楚多了呀。这时有朋友可能就会说了,你光是一个这些0或者*丢给玩家,别人怎么知道具体坐标,这不就是玩着玩着就眼花了吗?所以现在大打印还不够完美,我们还要将对应的行号和列号一起打印上去:
现在是不是对应的行和列号就一清二楚了,这里要注意的是列号我们是从0开始打印到9打印结束,每一行打印的是十个元素,这里是因为我们每一行都加了一个行号,让原先的一行9个元素变成了十个元素,所以打印列时要从0开始。
初始化与界面打印的函数代码如下:
//定义初始化函数
void InitBaord(char board[ROWS][COLS], int rows, int cols, char set)
{
int i = 0, j = 0;
for (i = 0; i < rows; i++)
{
for (j = 0; j < cols; j++)
{
board[i][j] = set;
}
}
}
//定义界面打印函数
void PrintBoard(char board[ROWS][COLS], int row, int col)
{
//定义行、列下标
int i = 0, j = 0;
//打印列号
for (i = 0; i <= row; i++)
{
printf("%d ", i);
}
printf("\n");
for (i = 1; i <= row; i++)//打印范围1~9行
{
//打印行号
printf("%d ", i);
//打印一行内容
for (j = 1; j <= col; j++)//打印范围1~9列
{
//每行按列数打印
printf("%c ", board[i][j]);
}
printf("\n");//打印完一行就进行换行
}
}
PS:现在是功能测试阶段,正式开始游戏时肯定是不能将放置区打印出来的。
在前面的功能后,我们就要开始生成地雷了;
这里需要用到的同样还是伪随机函数rand
以及随机数起点srand
和时间戳time
;
我们生成地雷的范围为应该是1~9,但是我们直接与显示行和显示列取模的话,得到的是0~8,所以我们在取模完后需要再加上1才是我们的正常范围,我们在生成坐标后,还需要判断该坐标是否重复布置地雷,未重复,则放置地雷:
现在我们可以看到,放置区很好的生成了10个地雷。代码如下:
//定义生成地雷函数
void SetMine(char board[ROWS][COLS], int row, int col)
{
//定义地雷数变量
int count = Easy_Count;
while (count)
{
//生成随机行下标
int i = rand() % row + 1;
//生成随机列下标
int j = rand() % col + 1;
//判断坐标是否重复布置地雷
if (board[i][j] == '0')//为字符0,则是未布置
{
//将地雷设为字符1,并随机放入放置区
board[i][j] = '1';
count--;
}
}
}
地雷已经放好了,那我就需要开始排雷了;
实现这个功能前我们需要先理清几个问题:
这个问题比较简单,我们现在还无法做到通过屏幕点击来进行查找,所以可以通过输入坐标来查找对应的元素;
我们的目的是查找地雷,所以我们是需要通过查找存放地雷的数组来进行地雷的查找;
查找完之后,如果我们是将放置雷的数组打印出来肯定是不合适的,所以打印的只能是盲区的数组了,通过将盲区的对应元素进行替换来实现查找的展示;
理清楚了如何查找了,接下来我们要解决的问题是判断哪些内容?
在玩家输入完坐标后,我们就要开始进行第一次判断了,判断坐标的合理性,也就是坐标是否在打印范围内:
不在范围内,我们就要提示玩家坐标输入错误,请重新输入,此时需要用到循环语句来完成;
在范围内,我们将进入第二次判断;
第二次判断的内容是玩家输入的坐标对应的放置区是否为地雷:
是地雷,则提示玩家已经踩到地雷,游戏结束,并将放置区打印出来;
不是地雷则开始计算坐标周围地雷数;
我们可以通过函数来完成这个功能,在完成这个功能前我们要先理清函数实现的逻辑:
在计算地雷数之前我们要先明确计算的范围——以输入坐标为中心的九宫格:
输入的坐标对应的九宫格的个点坐标如上图所示。在了解了这些信息后,我们就要开始计算了;
这里计算的方法可以是直接判断各个坐标是否为地雷,是则记录下来,最后算总个数就可以了;
但是我们这里要介绍的是第二种方法,将这些坐标的地雷数直接加起来,得数为几,地雷就是几个。
如何实现呢?这里我们要清楚的是此时放置区存放的可不是数字0和1,而是字符0和1,难道这样就没办法了吗?
当然不是,下面我们要回顾一下ASCII码值的知识了:
从表中我们可以看到,字符0和字符1对应的ASCII码值差值为1,同理,字符0与字符0的差值为0,只要我将坐标周围八个坐标每一个坐标的字符都与字符0相减并将得数相加,是不是就能得到地雷的个数了。代码如下:
//定义并声明计算坐标周围地雷数
int get_mine_count(char mine[ROWS][COLS], int i, int j)
{
//定义计数变量count来记录地雷数
return (mine[i - 1][j - 1] +
mine[i - 1][j] +
mine[i - 1][j + 1] +
mine[i][j - 1] +
mine[i][j + 1] +
mine[i + 1][j - 1] +
mine[i + 1][j] +
mine[i + 1][j + 1]) - 8 * '0';
}
PS:这里我们可以拓展一下,如果我们使用的是字符#代表地雷,那字符#与字符0的差值为-13,此时我们只要将这个差值除以-13是不是也能得到1,字符0与字符0的差值为0,0/(-13)结果还是0,所以使用ASCII码值,不管用什么字符来代表地雷,都是可行的。
从ASCII码表中我们可以看到字符0-9的ASCII码值之间的差值刚好为0-9,也就是说我们要将0-9的数字转换为字符,是不是只需要加上一个字符0就完成了,装换完之后的字符放在哪里呢?前面我们也分析了,放在放置区肯定是不合适的,此时我们来存放排雷信息的数组只能是盲区。
搞清楚上面的问题后,我们就可以开始设计函数了:
此时我们就完成了简单的查找雷并判断的功能。
这个自动排查功能是来干什么的呢?在前面的介绍中我们有提到过,如果翻开的坐标即不是雷也没有数字,则它会将周围的不是雷的区域全部翻开,直到翻到数字为止,也就是说我们在翻开一个格子,它会重复的进行计算坐标周围有几颗雷,这时就有两种情况:
如同上图所示一样排查完后最终呈现的效果是以地雷数为边界将有雷区与无雷区隔离开的效果。
通过上面的情况分析可知,我们要不断地重复去做排查这件事,这里可以想到的方式就是递归与迭代,这里我们选用递归来尝试实现这个功能,具体的步骤如下:
理清了编写思路之后,我们尝试着编写代码并进行测试,这里观察方式还是通过打印函数来进行观察:
对照放置区,我们输入坐标4/4后,它成功的进行了自动排查排查,但是排查的并够彻底,这时为什么呢?
我们来通过图像进一步理解并完善:
从图中我们可以看到,如果只是单纯的对坐标进行8个方向的递归,最终呈现的效果会是这种情况,并不是特别完美,怎么解决呢?我们继续来分析如何对空缺部分进行排查:
从上图我们可以看到,空缺的部分其实是斜角与水平方向和纵向之间的夹角,这里我们可以很快想到两种方式来实现:
从图中我们可以看到,这种解决方式就是将水平方向和纵向作为两条分界线,将其分为了四个区域,每个区域的每个斜边上的点都进行一次水平和纵向的排查。下面我们进行第一次测试:
在测试结果中我们可以看到,现在像这样去改善并不完美,还是有很多地方都没有实现自动排查的,未实现排查的原因如图所示:
这是正常按照八个方向递归会呈现的效果,现在我们加上四个角的水平向与纵向的排查:
可以看到在每个斜边已经排查到地雷后,就已经从递进中将字符替换好后回归了,现在就有两种解决方案:
(1)不为0,则不进行任何操作;
(2)为0,则判断坐标是否合法:
a.坐标不合法,则不进行任何操作;
b.坐标合法,则开始进行统计周围地雷数、替换字符、递进到下一个点进行排查;
c.当递进到坐标不合法或者地雷数不为0时,开始进行回归;
(3)回归结束后进行条件判断,根据具体情况进行不同方向的嵌套:
如图所示,在坐标合法的情况下嵌套水平和纵向的排查,并在水平和纵向的排查中再根据条件来判断是否嵌套斜向的排查:
从测试结果中我们可以看到,像这样去编码还是会有遗漏的地方,并不能很好的将每一个点都排查到;
从图中我们可以看到这个方法的执行逻辑是以斜边为分割线,将盘面分割成8份,这八分地区根据不同的条件分别在水平和纵向嵌套斜向的排查。下面我们进行第一次测试:
从图中我们可以看到,这样直接嵌套还是会存在一点空缺被忽略掉。我们来看看能不能将其完善一下,下面我们拿一个方向的递归为例来分析一下这种方式的缺陷在哪里:
从图中我们可以看到,即使我们给右下角加入右方与下方的排查,还是会漏掉几个点。
从这两种实现情况的测试结果来看,如果我们对除了中心点以外的其它点进行单一方向的排查,它总是会有遗漏的点位不能被排查到。
经过我的测试,如果我们在每个方向上都嵌套其它方向的排查,那么久很容易栈溢出,也就是说如果不能保证在排查的时候每个点都能有选择性的进行多方位排查,那么就达不到完美的自动排查功能。
既然如此,我们能不能另寻它法呢?我们还是对着示意图来分析一下:
从图中我们可以看到,如果只是按8个方向来排查的话,排查的结果会是图示结果,但是我们期望的结果是:
如果我们在排查的时候能够像这3个点一样去进行多方位的向外排查,才能实现全面排查。
为了避免出现栈溢出的问题,因此我们在排查每一个点时,它都需要满足以下条件:
这个排查的思路是将每个点的八个方位都进行排查,通过各种条件限制,来避免因不必要的重复排查而导致递归出现死循环从而导致栈溢出。
相比于前两种,这种排查方式会更加的全面一点,下面顺着这个思路,我们开始完善自动排查的功能;
在这个功能中,最关键的是将每个点的八个坐标点都进行排查,最终确定是否要继续排查。八个点的坐标如图所示:
从图中我们可以看到,每个点的坐标变化无非是横坐标从i-1到i+1,纵坐标也是从j-1到j+1,既然这样,我们能不能通过循环来完成坐标的变化呢?自动排查代码如下:
//定义自动排查函数
void Auto_get_mine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col, int i, int j, int count)
{
//判断坐标点地雷数是否为0
if (count == 0)
{
int x = 0;
int y = 0;
//排查i-1到i+1的坐标点
for (x = -1; x <= 1; x++)
{
//排查y-1到y+1的坐标点
for (y = -1; y <= 1; y++)
{
//判断坐标点是否合法
if (i + x >= 1 && i + x <= row && j + y >= 1 && j + y <= col)
{
//判断坐标是否被排查
if (show[i + x][j + y] == '*')
{
//计算坐标周围有几颗雷
int count = get_mine_count(mine, i + x, j + y);
//计算后的值替换在盲区对应坐标的字符
show[i + x][j + y] = count + '0';
//通过递归继续逐个排查
Auto_get_mine(mine, show, row, col, i + x, j + y, count);
}
}
}
}
}
}
测试结果如下所示:
可以看到,通过这种更加全面的排查方式,我们最终实现了自动排查的功能。下面附上功能六的完整代码:
//定义查找与判断函数
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
//定义坐标变量来接收坐标
int i = 0, j = 0;
do
{
//提示玩家输入坐标
printf("请输入要排查的坐标>:");
scanf("%d%d", &i, &j);
//第一次判断坐标是否合理
if (i >= 1 && i <= row && j >= 1 && j <= col)
{
//坐标合理进行第二次判断
if (mine[i][j] == '1')//判断放置区对应坐标是否为雷
{
//放置区为地雷,提示玩家踩到地雷
printf("你踩到了地雷,游戏结束\n");
//打印放置区
PrintBoard(mine, ROW, COL);
//跳出循环
break;
}
else//放置区对应坐标不是地雷
{
//判断坐标是否被排查
if (show[i][j] == '*')//坐标未被排查
{
//计算坐标周围有几颗雷
int count = get_mine_count(mine, i, j);
//计算后的值替换在盲区对应坐标的字符
show[i][j] = count + '0';
//功能七——自动查找——玩家输入坐标表
Auto_get_mine(mine, show, row, col, i, j, count);
PrintBoard(show, ROW, COL);//打印查找后的信息
}
else
{
printf("该坐标已排查,请重新输入坐标\n");
}
}
}
else
{
//坐标不合理,提示用户重新输入
printf("输入坐标不合理,请重新输入>:\n");
}
} while (1);
}
现在排查雷的所有内容就全部实现了,现在我们只要完成游戏胜利的判定,那我们就完成了扫雷这个游戏的编写;
通过前面的介绍我们也知道了,扫雷游戏的判定是根据安全区是否全部排查完为依据,也就是说我们需要完成以下几个任务:
下面开始编写代码:
//定义判断胜利函数
int Is_Win(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
//定义安全区统计变量
int win = 0;
//定义行列坐标变量
int i = 0, j = 0;
//从第一行开始统计
for (i = 1; i <= row; i++)
{
//从第一列开始统计
for (j = 1; j <= col; j++)
{
//统计已经排查的坐标数量
if (show[i][j] != '*')
{
win++;
}
}
}
return win;
}
测试结果如下:
可以看到我们很好的完成了扫雷游戏的编写。
接下来我们来总结一下扫雷游戏运用到的知识点:
从这两个小游戏的编写看来,我们目前需要完成一款游戏的编写的话选择与循环语句、函数以及数组的相关知识点我们都必须牢牢掌握才行。
到这里咱们本章的内容就全部结束了,这一篇博客总字数超过了10000字,耗费了我一整天的时间来编写。在这个过程中耗时最多的还是自动排查的功能,为了理清思路,我通过CAD作图、手绘等等一系列的操作才最终确定了代码的编写方向。希望这些内容能够帮助大家完成扫雷游戏的编写以及对相关知识点的掌握。接下来随着学习的深入,我会继续给大家分享我在学习过程中的感受。如果各位喜欢博主的内容,还请给博主的文章点个赞支持一下,有需要的朋友也可以收藏起来反复观看哦!感谢各位的翻阅,咱们下一篇见。