前言
今天来讲讲如何利用c语言实现扫雷这个游戏程序,这是学c的人一定会遇到的项目实战。
扫雷相信大家都玩过,毕竟是windows上最经典的游戏之一。扫雷的源码功能实现并不困难,困难的是如何将其逻辑转化为计算机语言的形式。
游戏不同于那些比较简单的程序,首先需要调用很多的函数以及变量,这样就需要单独设置一个源文件存储游戏函数;此外,也需要一个主函数进行菜单以及基础的逻辑选择——哪个游戏没菜单选项嘛,这样又需要一个源文件;要想进行游戏项目编程,肯定需要很多的宏定义以及库函数头文件声明,这样的话又需要创建一个头文件。
综上,我们需要创建一个头文件两个源文件:
1.game.h(头文件)————用以存储宏定义、函数声明以及头文件声明
2.game.c(源文件)————用以存储游戏所需要调用的函数
3.test.c(源文件)—————用以存储主函数以及菜单选项
接下来分别讲解三个文件的内容,首先是test.c源文件~
test.c
首先,创建一个menu函数打印菜单:
void menu()
{
printf("******************************\n");
printf("***** 1. play ******\n");
printf("***** 0. over ******\n");
printf("******************************\n");
}
选择1.play即游玩扫雷程序
选择0.over即退出程序
我们希望它一直循环,想继续玩不需要再ctrl+F5再执行一次程序,不想玩了直接退出,这样就用到do——while循环里嵌套switch的方法来做菜单选择逻辑:
int input = 0;
srand((unsigned int)time(NULL));
do
{
menu();
scanf("%d", &input);
switch (input)
{
case 1:
game();
break;
case 0:
printf("游戏结束....\n");
break;
default:
printf("选择错误,请重新选择!\n");
break;
}
} while (input);
判断是否循环的主体为变量input,switch的判断主体也是变量input。循环一次scanf一次input的值,当input==0的时候刚好switch退出,同时do——while循环也终止。
当选择——也就是input==1时,进入game()函数,开始游戏程序。
注意在do——while前有行代码为:
srand((unsigned int)time(NULL));
因为后期埋雷的时候希望雷每次都是随机分布的,所以需要用到time.h库函数来创造随机,这部分就不过多详解了。time.h将在后文game.h头文件讲解中包含到。
game函数主要功能有:
void game()
{
int win = 0;
char mine[ROWS][COLS];//基础棋盘
char show[ROWS][COLS];//打印棋盘
//初始化基础棋盘和打印棋盘
gameinitial(mine, ROWS, COLS, '0');
gameinitial(show, ROWS, COLS, '*');
//打印棋盘的函数
printchess(show, ROW, COL,win);
//埋雷函数
arrangemine(mine, ROW, COL);
//排雷函数
finemine(mine, show, ROW, COL);
}
为了方便游玩扫雷,我们可以创建一个变量win用来记录所排查的坐标个数。
对于“10雷”的扫雷棋盘,共有71格是无雷的,10格是有雷的,当我们排完了空格的71个坐标时,游戏也即胜利了,也就是说win除了能显示还有多少个空格需要我们排,也表明了我们还需要排多少格才能胜利。
为了方便区别,所以需要创建两个棋盘,一个mine[ ][ ]用来埋雷,一个show[ ][ ]用来打印棋盘,也就是我们玩扫雷时显示数字的那个棋盘。
棋盘大小该设为多少呢?经典扫雷有10、40、99个雷分别对应基础,中级、专家三个难度,为了方便讲解本文以基础难度也就是10雷的难度进行设置。
也就是说,定义棋盘mine与show的长度宽度应该一样,但为了后续拓展我们需要宏定义两个常量名不一样的常量,也就是上面代码里的ROWS和COLS。
具体讲解留到头文件game.h再说。
总之,扫雷一共要用到四个主要函数,也就是:
gameinitial————初始化棋盘函数
printchess————打印棋盘函数
arrangemine———埋雷函数
finemine—————排雷函数
在这些函数里面还需嵌套一些额外函数才能实现这些函数的功能,在此之前,我们先讲讲头文件game.h的内容。
以下是test.c全部代码:
#include"game.h"
void menu()
{
printf("******************************\n");
printf("***** 1. play ******\n");
printf("***** 0. over ******\n");
printf("******************************\n");
}
void game()
{
int win = 0;
char mine[ROWS][COLS];//基础棋盘
char show[ROWS][COLS];//打印棋盘
//初始化基础棋盘和打印棋盘
gameinitial(mine, ROWS, COLS, '0');
gameinitial(show, ROWS, COLS, '*');
//打印棋盘的函数
printchess(show, ROW, COL,win);
//埋雷函数
arrangemine(mine, ROW, COL);
//排雷函数
finemine(mine, show, ROW, COL);
}
int main()
{
int input = 0;
srand((unsigned int)time(NULL));
do
{
menu();
scanf("%d", &input);
switch (input)
{
case 1:
game();
break;
case 0:
printf("游戏结束....\n");
break;
default:
printf("选择错误,请重新选择!\n");
break;
}
} while (input);
return 0;
}
game.h
需注意,这里以vs2022为例。
头文件在解决方案管理器里当前项目的头文件里创建,当然,你也可以利用ctrl+shift+A快捷键来创建。
选择头文件(.h),再在下面名称那里改下名称,就可以使用创建的头文件了。
头文件的使用非常简单,只需要在需要的源文件声明一下这个头文件即可,也就是:
#include"game.h"
前面讲到,我们需要利用time.h这个库函数来让雷随机分布,同时也需要最基础的库函数stdio.h,为了简洁我们都可以在game.h里声明再在其他源文件里调用:
#include<stdio.h>
#include<time.h>
此外,我们也需要宏定义一些常量,比如棋盘的长宽度,雷的数量以及三个难度的棋盘格格数,这样在源文件里就可以直接调用无需再创建变量。
#define GAMEMINE 10 //基础难度雷数
#define ROW 9 //棋盘长度
#define COL 9 //棋盘宽度
#define ROWS ROW+2 //内部计算棋盘长度
#define COLS COL+2 //内部计算棋盘宽度
#define win1 81 //棋盘总格数(胜利格数)
宏定义常量以#define开头,空格+常量名+数值 为内容,也就是:
#define 常量名 数值
常量名的话尽量大写,这里就不改win1了哈哈哈。
在test.c中game()函数里的创建棋盘以及初始化棋盘函数调用中,并没有用到ROW与COL来设置,而是用ROWS以及COLS这是为了方便后续判断该格周边8格内雷数计算,也就是扫雷游戏里的数字格计算,因此需要设计多一圈用以方便计算,所以需要+2。
难理解吗?画个图试试:
+2是为了上下左右都多一行,这样的话角落计算就会方便很多。具体请见后文讲解。
接下来声明下函数就行。
//初始化基础棋盘
void gameinitial(char mine[ROWS][COLS], int rows, int cols, char re);
//打印棋盘的函数
void printchess(char show[ROWS][COLS], int rows, int cols,int win);
//埋雷函数
void arrangemine(char mine[ROW][COL], int row, int col);
//排雷函数
void finemine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);
game.h头文件全部代码如下:
#pragma once
#include<stdio.h>
#include<time.h>
#define GAMEMINE 10
#define ROW 9
#define COL 9
#define ROWS ROW+2
#define COLS COL+2
#define win1 81
//初始化基础棋盘
void gameinitial(char mine[ROWS][COLS], int rows, int cols, char re);
//打印棋盘的函数
void printchess(char show[ROWS][COLS], int rows, int cols,int win);
//埋雷函数
void arrangemine(char mine[ROWS][COLS], int row, int col);
//排雷函数
void finemine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);
好的接下来就是整个程序里最重要的几行代码。
game.c
这里全部存放扫雷设置相关的函数,主要有四个主要函数以及三个次要函数:
*1.——gameinitial——初始化函数
*2. ——printchess——打印棋盘函数
*3.——arrangemine—埋雷函数
*4.——findmine———排雷函数
4.1——retmine———计算函数
4.2——install————扫荡函数
4.3——tab—————标记函数
三个次要函数都包含在排雷函数中,计算函数的功能是返回指定坐标周边格雷的个数,也就是实现扫雷游戏中数字格的功能;扫荡函数的功能是连片清除非数字格,也就是扫雷游戏中周边没雷则连片扫的功能;而标记函数,就是实现了扫雷游戏中标记推断出的雷格的功能。
接下来的讲解中,要注意实参和形参的区别,以及mine棋盘和show棋盘的区别,同时也要注意区分什么是未知格,雷格,数字格以及标记格等等。
*1. gameinitial 初始化函数
//初始化棋盘的函数
void gameinitial(char mine[ROWS][COLS], int rows, int cols, char re)
{
int i = 0;
for (i = 0; i < rows; i++)
{
int j = 0;
for (j = 0; j < cols; j++)
{
mine[i][j] = re;
}
}
}
主要传了四个参数到gameinitial函数中,一个是棋盘,两个分别是长度和宽度,最后一个参数是字符类型。为了方便游玩,show棋盘中各个格子我们都设置为 “ * ”的形式;mine棋盘中,我们规定雷格表示为“ 1 ”,空格表示为“ 0 ”。所以,两个棋盘分别初始化,分别要传字符类型“ * ”与“ 0 ”过来该函数中。
至于长度宽度,show棋盘自然没啥影响,只需打印中间部分就行;而mine棋盘,为了计算方便,我们一样得初始化多一圈的那部分内容,既然对show没影响而mine又需要,干脆直接传ROWS和COLS过来。
初始化非常容易,两层for循环遍历棋盘每一格,分别把字符赋给每一格即可。
注意形参是mine,是“复制拷贝”的,这里可以改成其他名。
*2. printchess 打印棋盘函数
//打印棋盘的函数
void printchess(char show[ROWS][COLS], int row, int col,int win)
{
int i = 0;
int j = 0;
printf(" ———扫 雷 游 戏——— \n");
printf("距离胜利还有 %d 个坐标\n", win1-10-win);
printf("输入0 0 进入或退出标记程序\n");
for (j = 0; j <= col; j++)
{
printf("%d ", j);
}
printf("\n");
for (i = 1; i <= row; i++)
{
printf("%d ", i);
j = 0;
for (j = 1; j <= col; j++)
{
printf("%c ", show[i][j]);
}
printf("\n");
}
}
传参传了四个,一个棋盘三个整型。
win的作用前面讲过,不再赘述。
printf("输入0 0 进入或退出标记程序\n") 这一行是标明如何进入标记程序,具体实现还在后面。因为是打印棋盘嘛,排雷函数中也要调用这个函数打印棋盘,所以需要多打印一行,要不然就得写个说明书附在程序文件里了。
为了定位要排查的坐标,需要格外打印一行数字,用以标明行数列数,也就是长度和宽度。所以在打印棋盘之前需要for循环先打印行数出来,然后换行再打印其余部分。
打印列数需要在打印棋盘的俩层for循环中打印,放在第一层for循环第一行,如上代码。打印棋盘原理与初始化棋盘两层循环原理无异,不再赘述。
*3. arrangemine 埋雷函数
//埋雷函数
void arrangemine(char mine[ROWS][COLS], int row, int col)
{
int mid = GAMEMINE;
while (mid)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (mine[x][y] == '0')
{
mine[x][y] = '1';
mid--;
}
}
}
传参传了三个,mine棋盘和长宽。
首先定义一个中间变量mid,将基础难度的雷数“10”赋值给mid,先前在game.h中已宏定义过,所以直接调用赋值即可。
因为需要一个一个雷来埋,而且需要埋十个,故利用while循环来埋。
循环判断条件为mid,每埋雷成功一次mid--。
为什么是“每埋雷成功”?
因为mine棋盘有11行11列!我们需要将雷准确埋到9 x 9的正方形里,所以多的那一圈不能埋不能埋不能埋!
此外,雷需要“随机地”埋,而且需要满足上述的条件。
首先,在test.c中我们已预先用到了srand((unsigned int)time(NULL)),在这里我们直接使用rand()就行。怎样使得埋的雷落在9 x 9的方格里呢?
用%。当得到的随机数<=9时,此时埋的雷恰好满足上限条件——埋不到mine[ ][ 10 ]和mine[ 10 ][ ]这行列里,因为11%9==2;要想再满足下限条件——埋不到mine[ ][ 0 ]和mine[ 0 ][ ]这行列里,就需要当随机数=9时结果不为0,这样就需要+1。
例如:
x = 9%9 +1(注意%优先级>加的优先级)==1,这样就埋不到mine[ ][ 0 ]和mine[ 0 ][ ]。
x=11%9 +1==3,满足条件。
x=76%9+1==4+1==5,也满足条件。
x=0%9+1==0+1==1,同样满足条件。
具体定位到一个二维坐标(x,y),就需要创建两个式子计算,就是上面代码的:
int x = rand() % row + 1;
int y = rand() % col + 1;
无论长度宽度传参传多少,都不会落在大一圈的范围里。
如果格子内已经埋了雷,而随机坐标刚好重复埋雷在该雷格的话,可以借助if判断该坐标是否是雷格。不是雷格则埋下雷,同时mid--减少剩余要埋的雷数。
至此棋盘的设置已经完成,接下来是玩家操作的函数的编写。
*4. findmine 排雷函数
接下来从三个次函数分别讲起,再讲回排雷函数的主体。
4.1. retmine 计算函数
int retmine(char mine[ROWS][COLS], int x, int y)
{
int mid = (mine[x - 1][y - 1]
+ mine[x - 1][y]
+ mine[x - 1][y + 1]
+ mine[x][y - 1]
+ mine[x][y + 1]
+ mine[x + 1][y - 1]
+ mine[x + 1][y]
+ mine[x + 1][y + 1]
- 8 * '0');
return mid;
}
传参传的是mine棋盘,因为要从雷棋盘计算再返回到show棋盘上,同时也要传x,y坐标。
计算函数的作用是返回计算输入坐标周边八格内的雷的数量。
此前说过棋盘需要大一圈,就是为了统计周边雷方便,因为大一圈的那个范围内没有埋雷且已经置 ‘ 0 ’处理,所以无需再判断是否为棋盘边界的情况。
扫雷的数字格可以想象成一个九宫格,输入的排查坐标位于该九宫格的中心点,如果除中心外其余八格有雷,则该中心数字格就会显示周边有多少个雷。
比如这一轮游戏,绿色圈起来的数字格除了右下角的红色圈圈格有雷,周边都是数字或者空格,反过来推理也能证明红圈格是雷格。
对于绿圈格右边的这个数字“ 1 ”格,因为已经确定该格周边两个未知格里的唯一一个雷的位置,则红圈格右边的就不会是雷格。
我们点击一下,红圈格右边果然不是雷格。像这样,就算是排了一些未知格。
实现这个功能非常简单,我们只需要将mine棋盘排查坐标的周边格的值一并加起来,最后减去八个“ 0 ”,就能得到该格周边的雷数,也就得到了该格若是数字格它应该显示的数字。
因为埋雷时我们预先规定“ 0 ”是空格,也就是没有雷的格,而“ 1 ”则表示埋了雷的格,字符类型的“ 0 ”和“ 1 ”的差值(也就是ASCII码值)刚好是1,这样都会就能统计周边的雷数了。
像这样子将mine棋盘上的周边格一一加起来就可以了,具体见上面粘贴的代码。
最后定义一个中间变量mid存放数值并返回即可。
4.2. install 扫荡函数
void install(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y,int *win)
{
if (x<1 || x>ROW || y<1 || y>COL)
return;
if (show[x][y] != '*')
return;
int mid = retmine(mine, x, y);
if (mid > 0)
{
(*win)++;
show[x][y] = mid + '0';
return;
}
else if (mid == 0)
{
(*win)++;
show[x][y] = ' ';
install(mine, show, x - 1, y,win);
install(mine, show, x - 1, y - 1,win);
install(mine, show, x - 1, y + 1,win);
install(mine, show, x, y - 1,win);
install(mine, show, x, y + 1,win);
install(mine, show, x + 1, y,win);
install(mine, show, x + 1, y - 1,win);
install(mine, show, x + 1, y + 1,win);
}
}
注意返回类型和最后一个形参是*win,传指针是为了修改win的数值,因为最后判断胜利的条件就是win。
先理解后面两个if。
首先创建一个中间变量mid,用来存放计算函数返回的值,这个值代表了周边格可能存在的雷数。
如果mid > 0,也就是说明周边格存在雷格,此时 (*win)++表明排查了一个坐标,同时在show棋盘中将排查坐标由未知格改为数字格,也就是将mid+‘ 0 ’,就可以实现显示数字格的功能。
注意此时是排查格是数字格的情况,在扫雷游戏中,当扫出的是数字格时,就会停止向周边格的“清扫”,也就是连片排雷。所以,当当前格是数字格时,我们不需要连片清扫,此时需要return退出该函数。
当当前格不是数字格时,也即mid == 0时,我们需要再次判断当前情况并进行递归,由当前格向周边八格进行清扫。也因为当前格不是雷格,所以也需要 (*win)++,并且将show棋盘改为“ ”,也就是空格。
之后,我们写八行当前格周边八格的install函数,递归判断是否是数字格,以及是否需要再次对这些周边八格的周边八格再次递归判断。
以上,我们需要再判断数字格之前进行两次if判断。
一次是判断当前格是否位于棋盘边界,若是,则超过棋盘边界的格无需判断,直接return即可,见install函数的第一个if。
而第二个if是判断当前清扫格是否已经清扫过,也即show棋盘上当前格是否为未知格 ‘ * ’ ,若不是则代表此前排查过了,直接return。
如果当前格非数字格非雷格,同时也没被排查过,则再进行递归计算, 直到将附近所有非雷格以及数字格清扫干净,同时,win也会记录清扫了多少格。
4.3. tab 标记函数
void tab(char show[ROWS][COLS], int row, int col)
{
int x = 1;
int y = 1;
int j = 0;
int k = 0;
printf("输入0 0退出程序,输入0 1取消标记\n");
while (1)
{
printf("请输入要标记的坐标:");
scanf("%d %d", &x, &y);
if (x >= 1 && x <= row && y >= 1 && y <= col)
{
show[x][y] = '!';
}
else if (x == 0&&y == 0)
{
printf("标记程序结束...\n");
break;
}
else if (x == 0 && y == 1)
{
printf("请再次输入要取消标记的坐标:");
scanf("%d %d", &j, &k);
show[j][k] = '*';
}
else
printf("坐标错误!请重新输入!\n");
}
}
在经典扫雷游戏中,鼠标右击即将点击的格子标记为标记格,也就是当你推测出当前格为雷格时,可以借用标记功能标记,以方便后一步的推测。
要想实现这个功能非常简单因为只需要标记,所以我们只需要调用show棋盘将要标记的格子改为标记符号就可以了,因此只需要传参传show,row以及col即可。
在这里我规定进入标记模式只需要输入 "0 0" 即进入。因为 "0 0" 不是埋雷格也不是需要操作的格子,同时也是简单的数字。当然你也可以更改进入的方式,需要注意的是进入标记模式的判断并不在tab函数里,而是在由tab函数组成的findmine函数里,判断进入将在那里进行判断。同时,退出tab函数也需要判断,此时 "0 0" 也可以充当退出标记模式的判定。
因为只需要判断show棋盘,数值范围应当在1~9的范围内,所以需要定义两个变量x和y用以接收标记格的坐标,同时这两个坐标也用来判断是否需要退出函数。
有喜就有悲,有对就有错,标记也有被取消的时候,所以还需要定义两个坐标用以接收需要取消标记的坐标,同时进入标记取消模式的判定也由x和y来判定。
由于需要进行多次判断,我们需要打印提醒信息,见上文代码。
当需要标记时,这里我用符号 ' ! ' 来表示标记格,当然你用其他的也可以。
当不需要标记时,只需要将符号 ' ! ' 改回 ' * ' 即可。
要注意循环是一直进行的,因为很多时候都是推理出多数雷格才进行标记,所以循环一直进行方便标记,不然就得一个一个敲进入标记模式的0 0再进行标记了。
排雷函数主体
void finemine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
int x = 0;
int y = 0;
int win = 0;
while (win < row * col - GAMEMINE)
{
printf("请输入要排查的坐标:");
scanf("%d %d", &x, &y);
if ((x >= 1 && x <= row) && (y >= 1 && y <= col))
{
if (mine[x][y] == '1')
{
printf("很遗憾,你被炸死了...\n");
printchess(mine, ROW, COL,win);
break;
}
else
{
install(mine,show, x, y,&win);
printchess(show, ROW, COL,win);
}
}
else if (x == 0&&y == 0)
{
tab(show, ROWS, COLS);
printchess(show, ROW, COL,win);
}
else
{
printf("坐标值错误,请重新输入!\n");
}
}
if (win == row * col - GAMEMINE)
{
printf("恭喜你排雷成功!游戏通关!\n");
printchess(mine, ROW, COL,win);
}
}
排雷函数findmine的传参依据上面的三个函数就可以猜的七七八八了,没错只需要传mine,show,row以及col即可。
在这里我们需要定义三个变量,x和y用以接收排雷坐标,win用以判断胜利。
使用while循环,循环判定条件是:
while (win < row * col - GAMEMINE)
因为这里讲的是最基础的简单模式,所以GAMEMINE的值是10,也就是10个雷。胜利条件就是当把棋盘中81个未知格中的10个雷格排查掉,即获得胜利,故当 win == 71 时,扫雷成功,游戏结束,所以循环判定条件如上。
if (mine[x][y] == '1')
{
printf("很遗憾,你被炸死了...\n");
printchess(mine, ROW, COL,win);
break;
}
当排查的未知格是雷时,游戏结束,此时可以打印雷棋盘以供玩家观看,同时break退出该函数。
else
{
install(mine,show, x, y,&win);
printchess(show, ROW, COL,win);
}
若不是,则进行一系列的扫荡清扫,也就是判定是否为数字格,是否为空格,是否需要清扫等等。在此调用扫荡函数install,然后再打印一次show棋盘给玩家进行下一步操作。
else if (x == 0&&y == 0)
{
tab(show, ROWS, COLS);
printchess(show, ROW, COL,win);
}
else
{
printf("坐标值错误,请重新输入!\n");
}
当输入 " 0 0" 时,循环判断进入标记函数tab进行标记操作,退出标记函数后再打印一次棋盘供玩家进行下一步操作。
当输入坐标出错时,则提醒玩家。
if (win == row * col - GAMEMINE)
{
printf("恭喜你排雷成功!游戏通关!\n");
printchess(mine, ROW, COL,win);
}
当达到胜利条件时,while循环自动退出,进入胜利条件判断。
在这里提醒玩家排雷成功,游戏通关,并打印mine棋盘即可。
最后回到 test.c ,以确认玩家下一步操作。
结语
至此,扫雷游戏的基本代码已经讲完了。
至于其他难度比如中等难度以及困难难度,还有时间功能等等就请读者自己推导,只需要再多加几个宏,多加几段函数几段代码,就可以实现和原版扫雷几乎一样的功能了。
功能归功能,棋盘展示的话也可以自行学习easyx图形库用绘图窗口制作。
咱们下一期贪吃蛇见~