针对在校大学生的C语言入门学习——扫雷
- 聊完了C语言的语法,咱们该回归学习C语言的本质了,那就是编程。编程是需要刻苦训练的,仅仅是刻苦还不够,训练要有针对性。今天给大家带来一个C语言的扫雷项目来练练手。
- 下面是项目的运行截图,整个项目在控制台运行。
- 接下来是我的编码过程,请大家耐心跟着我的思路一步一步完成。
结构体封装
- 首先我要用一个二维数组表示游戏中所有的点。前面我们聊过,二维数组经常用来表示平面直角坐标系。考虑到每个点至少包含两个信息:1.显示,2.雷。所以我要将点封装成一个结构体类型,表示游戏界面的二维数组自然就是结构体类型的二维数组。
typedef struct Point
{
char show;//在游戏中的显示信息
int mine;//包含雷的信息 1包含雷 0不包含雷
}Point;
#define WIDTH 10
#define HEIGHT 10
Point map[HEIGHT][WIDTH];//表示游戏屏幕中所有点的二维数组
- 上面代码中我要使用typedef给结构体定义新的名字,因为C语言对结构体名字的使用很繁琐,所以给结构体定义新名字是常用手段。
- WDITH和HEIGHT是两个宏定义,因为在程序中常量本身是没有意义的。如果在程序中直接使用10分别表示屏幕的长和宽,不仅不利于程序阅读,也不利于程序修改。
- 我将二维数组的第一维定义成纵轴,第二维定义成横轴。这符合正常人的编程思维。当然不是一定的,你也可以将第一维定义成横轴,只是要注意在整个程序中都要统一以第一维做横轴就可以。
- 接下来我定义一段代码对二维数组map初始化。初始化的目的就是先把所有的点show属性都设置成*,mine属性都设置成0,表示所有的点现在都没有雷。
/*
初始化屏幕
*/
void initMap()
{
int i,j;
for(i = 0;i < HEIGHT;i++)//纵轴
{
for(j = 0;j < WIDTH;j++)//横轴
{
map[i][j].show = '*';
map[i][j].mine = 0;
}
}
}
- 上面程序中需要注意的是,map[i][j]作为二维数组中的一个元素,所以map[i][j]是一个Point类型的结构体变量。
- 下面我们看一下运行的效果。建议大家在开发的时候,不要写很多代码以后再测试。因为出现问题的话不好调试。打个比方,我们写了10行代码测试出现了问题,和我们写100行代码后测试出现了问题,哪个好找呢?当然是前者,因为问题就在10行代码中找就可以了。
/*
打印屏幕
*/
void printMap()
{
int i,j;
for(i = 0;i < HEIGHT;i++)//纵轴
{
for(j = 0;j < WIDTH;j++)//横轴
{
printf("%c ",map[i][j].show);
}
printf("\n");
}
}
int main(void)
{
initMap();
printMap();
return 0;
}
布雷
- 接下来我们布雷,这里得使用随机。关于随机我简单说一下,计算机的随机都是假随机。是以一个数字作为种子,然后通过一套复杂的算法得到的一系列数字。也就是说如果种子是一样的,那么随机序列也是一样的。所以我们一般使用系统时间作为随机的种子。下面给大家一个随机数的示例:
#include <time.h>
#include <stdlib.h>
int main()
{
srand(time(0));
int i;
for(i = 0;i < 10;i++)
{
printf("%d\n", rand()%100);
}
return 0;
}
- 上面示例的作用是生成10个0~99的范围内的随机数。大家可以自行尝试理解一下上面的代码,如果ok了那我们就回到扫雷项目中,来完成我们的随机布雷。
#include <time.h>
#include <stdlib.h>
#define MINE_NUM 99
/*
布雷
*/
void setMine()
{
srand(time(0));//以系统时间作为随机种子
int i, x, y;
for(i = 0;i < MINE_NUM;i++)
{
do{
x = rand()%WIDTH;
y = rand()%HEIGHT;
}while(map[y][x].mine==1);
map[y][x].mine = 1;
}
}
- 宏定义MINE_NUM 是我要布置的雷数,但是我们一共才100个点位,布置99个雷玩起来会不会太刺激了呢?这里我之所以设置99个雷,是为了测试布雷结果的。大家可能注意到了,我在for循环中还嵌套了一个do-while循环。因为随机布雷难免有两次随机得到相同的结果,所以do-while循环的意义就是如果随机到的点位已经有雷了,那么重新随机。
- 接下来我们打印布雷结果再进行一次测试。
/*
打印布雷信息
*/
void printMine()
{
int i,j;
for(i = 0;i < HEIGHT;i++)//纵轴
{
for(j = 0;j < WIDTH;j++)//横轴
{
printf("%d ",map[i][j].mine);
}
printf("\n");
}
}
int main(void)
{
initMap();
setMine();
printMap();
printMine();
return 0;
}
- 99个雷如数布置完毕,如果没有do-whlie循环避免重复的话,这里几乎没有可能布满99个雷。接下来的测试中我们可以把MINE_NUM 的值根据需求改的小一些。
查找一个点周围的雷数
- 上面的内容都属于游戏的初始化部分。接下来我们开始逐步完成游戏的主逻辑,首先就是当玩家输入一对坐标以后,我们要判断这对坐标的点是否有雷。如果没有雷的话,我们就得把这个点周围的雷数统计出来。
- 查找周围雷数的基本思路是把xy点周围的8个点遍历一遍,数一数有几个点是有雷的。
- 但是这个问题复杂就在xy点还可能出现在边缘位置。
- 而边缘又分上、下、左、右,逻辑上我们要对四个边缘的情况都做判断。那么就可能写出如下代码。
int countMine(int x, int y)
{
int xBegin;
int xEnd;
int yBegin;
int yEnd;
if(x == 0)
{
xBegin = 0;
}
else
{
xBegin = x-1;
}
if(x == WIDTH-1)
{
xEnd = WIDTH-1;
}
else
{
xEnd = x+1;
}
if(y == 0)
{
yBegin = 0;
}
else
{
yBegin = y-1;
}
if(y == HEIGHT-1)
{
yEnd = HEIGHT-1;
}
else
{
yEnd = y+1;
}
int i,j;
int count = 0;
for(i = yBegin;i <= yEnd;i++)
{
for(j = xBegin;j <= xEnd;j++)
{
if(map[i][j].mine == 1)
{
count++;
}
}
}
return count;
}
- 可能有些同学的写法比上面代码还要麻烦。首先我要肯定上面的思路没有问题,既然事实上存在边界的问题,那我们编程的时候就必须对边界问题做处理。但是大家观察上面代码中的四对if-else,它们要做的事情简直太简单了,仅仅是确定了x轴和y轴的遍历范围。像这样简单的if-else逻辑,我们完全可以使用条件运算符?:嵌套到表达式中。
/*
查找点周围的雷数
*/
int countMine(int x, int y)
{
int i,j;
int count = 0;
for(i = (y==0?0:y-1);i <= (y==HEIGHT-1?HEIGHT-1:y+1);i++)
{
for(j = (x==0?0:x-1);j <= (x==WIDTH-1?WIDTH-1:x+1);j++)
{
if(map[i][j].mine == 1)
{
count++;
}
}
}
return count;
}
- 这样写代码,是不是思路清晰了很多呢。接下来我们完成main函数中的逻辑进行测试。
/*
显示输入的点
*/
void open(int x, int y)
{
int count = countMine(x, y);
map[y][x].show = count+'0';//数字整数转换为数字字符
}
int main(void)
{
initMap();
setMine();
printMine();
while(1)
{
printMap();
int x, y;
printf("请输入坐标:\n");
scanf("%d%d",&x, &y);
if(map[y][x].mine == 1)
{
//GAME OVER
printf("GAME OVER\n");
break;
}
else
{
open(x, y);
}
}
return 0;
}
扩散
- 有认真玩过扫雷的同学应该都知道,上面我输入9 0的时候,显示周围雷数是0,此时游戏应该会自动向四周扩散。扩散的原则就是把xy点周围的8个点(如果有8个点)都点开,如果点开后的点周围雷数还是0,那么继续扩散。这显然是一个递归的过程。所以这里我给大家提供一个分治算法的解决方案。
- 我不解释什么是分治算法,因为没有意义。算法就像是武功的招式,不要局限于固定的模式。唯有深刻理解其思想之后才能灵活运用。那我为什么还要提一下分治算法的名称呢?一是为了让大家日后深入学习的时候有一个方向;二是向大家表明我用的都是“正派武功”,请大家放心学习。
- 任何递归的算法大家都不要忽略递归的结束条件。甚至在思路还不是很清晰的时候,首先思考递归的结束条件可以作为一个思考问题的突破口。
- 这个算法我写在open函数中,修改后的代码如下
/*
显示输入的点
*/
void open(int x, int y)
{
if(map[y][x].show!='*')//相邻的两个点避免A递归到B,又从B递归到A,已经点开后的点不再做任何处理
{
return;
}
int count = countMine(x, y);
map[y][x].show = count+'0';//数字整数转换为数字字符
if(count > 0)
return;
int i,j;
for(i = (y==0?0:y-1);i <= (y==HEIGHT-1?HEIGHT-1:y+1);i++)
{
for(j = (x==0?0:x-1);j <= (x==WIDTH-1?WIDTH-1:x+1);j++)
{
open(j, i);//注意open的参数是x y顺序,所以应该传递j i
}
}
}
- main函数无需改动,直接测试即可。
胜利条件
- 现在我们就来迎接胜利吧,可能有的同学玩扫雷从来没赢过,所以也没思考过胜利的条件。我对胜利条件的判断方案是通过计算已经点开的点数和屏幕中所有的点数以及雷数的关系来判断。首先我定义一个全局变量来计数,然后在open函数中对其加1。
int openCount = 0;
/*
显示输入的点
*/
void open(int x, int y)
{
if(map[y][x].show!='*')//相邻的两个点避免A递归到B,又从B递归到A,已经点开后的点不再做任何处理
{
return;
}
openCount++;//统计打开的点数
int count = countMine(x, y);
map[y][x].show = count+'0';//数字整数转换为数字字符
if(count > 0)
return;
int i,j;
for(i = (y==0?0:y-1);i <= (y==HEIGHT-1?HEIGHT-1:y+1);i++)
{
for(j = (x==0?0:x-1);j <= (x==WIDTH-1?WIDTH-1:x+1);j++)
{
open(j, i);//注意open的参数是x y顺序,所以应该传递j i
}
}
}
- 最后的判断只是一个数学算式,我就不封装了。
int main(void)
{
initMap();
setMine();
printMine();
while(1)
{
printMap();
int x, y;
printf("请输入坐标:\n");
scanf("%d%d",&x, &y);
if(map[y][x].mine == 1)
{
//GAME OVER
printf("GAME OVER\n");
break;
}
else
{
open(x, y);
}
if(openCount == WIDTH*HEIGHT-MINE_NUM)
{
printMap();
printf("胜利\n");
break;
}
}
return 0;
}
- 接下来我们测试,为了测试便捷,我将MINE_NUM设置成1
- 一步胜利的感觉挺爽的吧。我就写到这里了,大家如果还有什么想加入的功能大胆尝试。欢迎留言讨论。源码下载点击扫雷源码