大家好,今天讲解一下如何编写出扫雷程序
(适合C语言至少懂得数组与函数的新手去尝试制作)
使用软件:VS2022
新人投稿,无广告,不收费,纯教程
写一半太累了用了AI辅助,已经认真修改润色内容,请放心
主要内容包括:
- 头文件与源文件的创建(一些建议)
- 编写代码主体
- 如何让你的朋友们玩
- 神秘小彩蛋
【第一部分】创建合适的源文件与头文件
最左侧两个箭头是源文件
test.c主要负责进行游戏开始界面的编写,在这个文件里写下的代码实现菜单界面的各种功能
game.c主要负责实际游戏运行,在这个文件下编写出实现各个功能的函数
第三个箭头为头文件
game.h里存放的是 库函数、函数声明、常用的define
这个文件的主要功能相当于链接test.c和game.c,让两个文件中的函数、定义共同使用,比如game.c里的函数,也可以被test.c调用
要创建这么多文件的原因:
都写一个文件里,项目就显得太过于杂乱,库函数、定义到处都是,让除了本人以外的人难以弄清代码都是负责什么功能的
右侧箭头上为常见的一个define:
避免用scanf报错,忽视风险(可以放game.h里,但是我习惯了就没动)
右侧箭头下为引用game.h这个文件:
test.c与game.c都引用了,才能让game.h发挥联通作用
相当于在引用库函数,不过这个是访问自己制作的库函数,不是像<stdio.h>这样编译器自带的库函数,所以我们include后面使用是 " " 而不是 < >
【第二部分】编写代码主体
让我们先从test.c开始,代码的思路从如何开始游戏起步
首先,我们想要游戏是循环进行的,不能玩一次就结束了
于是我们可以用do-while语句写个框架,条件部分没想好就先不填
框架里面则是生成一个菜单,并且还要有代码让用户输入,并且存在对用户输入有判断和进入到其他函数执行相应功能的语句
下面是如何生成一个菜单:
voidmenu(){printf("-----------------------------------------\n");printf("---------Welcome to Minesweeper !--------\n");printf("-----------Chose 1:Start Game------------\n");printf("-----------Chose 2:Quit Game-------------\n");printf("-----------Chose 3:Surprise--------------\n");printf("----(Please just input proper numbers)---\n");printf("-----------------------------------------\n\n");}非常直白粗暴,因为我们暂时没有去编写游戏窗口的能力,所以我们可以粗糙地直接printf打印出游戏界面来,比起这个,游戏本体的代码更加重要,需要我们这样的小白去着重思考
菜单打印出来了,接下来我们要考虑的是:用户输入之后,程序该怎么走?
这里我选择用 scanf 读取用户的输入,然后用 strcmp 去判断用户到底输入了什么
要用 strcmp 而不是直接比较:
因为 input 是一个字符串数组,字符串是不能直接用比较的,必须用 strcmp 一个字符一个字符地去比对。
intmain(){charinput[50]={0};do{menu();// 这里先留空,我们一步步来}while(strcmp(input,"2")!=0);return0;}先解释一下这个框架:
char input[50] = { 0 };:用来存放用户输入的内容,初始化为全 0,也就是空字符串。
do-while 循环:保证菜单至少显示一次,只要用户输入的不是 “2”,循环就会继续,游戏就可以一直玩下去。
strcmp(input, “2”) != 0:只要 input 不等于字符串 “2”,循环条件就成立,继续循环。
现在框架有了,我们往里面填内容。用户输入之后,我们要判断他输入的是什么,然后执行对应的功能:
intmain(){charinput[50]={0};do{menu();printf("请输入你的选择:");scanf("%s",&input);if(strcmp(input,"1")==0){// 用户选择 1,开始游戏// 这里先留空,等 game.c 写好了再调用// game();}elseif(strcmp(input,"2")==0){// 用户选择 2,退出游戏printf("GoodBye,Welcome your feedback!\n\n");Sleep(2000);break;}elseif(strcmp(input,"3")==0){// 用户选择 3,触发彩蛋// 这里先留空,彩蛋内容后面再写}else{// 用户输入了其他内容,提示错误printf("输入错误,请重新输入!\n\n");}}while(strcmp(input,"2")!=0);return0;}这里有几个细节需要解释一下:
为什么用 %s 而不是 %d?
因为如果用 %d,用户一旦输入了字母,程序就会直接崩溃或者陷入死循环。用 %s 把输入当成字符串,不管用户输入什么,程序都能正常处理,最多就是判断不匹配,提示重新输入而已。
为什么 scanf 要写成 scanf(“%s”, &input) 而不是 scanf(“%s”, input)?
其实两种写法都可以。input 是数组名,本身就是地址,&input 取的是整个数组的地址,虽然类型上有点区别,但在这种用法下效果是一样的。不过为了严谨,我习惯写成 &input,大家知道意思就行。
Sleep(2000) 是干什么的?
Sleep 是让程序暂停执行,单位是毫秒。Sleep(2000) 就是暂停 2 秒。这里用在退出游戏时,让提示信息停留一会儿再退出,不然一闪而过,用户体验不好。
break 是干什么的?
break 是跳出当前循环。这里用户输入 “2” 之后,先打印再见信息,然后 break 直接跳出 do-while 循环,程序就结束了。
现在,test.c文件就大致出来了
接下来,我们开始写game()函数,这是游戏主体,我们可以先写大概需要用到那些函数,之后再去game.c里详细去写每一个函数
先看game()函数里总共需要调用哪些函数,我们一个个去实现:
voidgame(){srand((unsignedint)time(NULL));charmine[ROWS][COLS];//存放雷的信息charshow[ROWS][COLS];//存放排查出的雷的信息InitBoard(mine,ROWS,COLS,'0');//初始化棋盘InitBoard(show,ROWS,COLS,'*');SetMine(mine,ROW,COL);DisplayBoard(show,ROW,COL);//打印棋盘//DisplayBoard(mine, ROW, COL);FindMine(mine,show,ROW,COL);}解释一下这些宏定义(define出的ROW、COL):
- ROW:表示行
- COL:列
- ROW 9 和 COL 9:意思是ROW、COL的数值为9
- test 10:雷的数量是 10 颗。
有两个棋盘:
- mine 棋盘:存放雷的信息,‘0’ 表示没有雷,‘1’ 表示有雷。这个棋盘是"答案",不能让玩家看到。
- show 棋盘:存放玩家能看到的信息,初始全是 ‘*’,玩家每排查一个格子,就把这个位置改成对应的数字。
srand((unsigned int)time(NULL)) 是干什么的?
这是设置随机数种子。因为 rand() 生成的随机数是"伪随机",如果不设置种子,每次运行程序生成的雷的位置都一样。用 time(NULL) 取当前时间作为种子,时间一直在变,所以每次雷的位置都不一样
注意:srand 只需要调用一次,放在 game() 开头就行,不要放在 SetMine 里面,不然每次布雷都会重新设置种子,反而可能生成重复的雷。
为什么数组要开 11x11 而不是 9x9?
因为 GetMineCount 要访问 (x-1, y-1) 到 (x+1, y+1) 这 8 个格子。如果玩家点的是 (1,1),那 (0,0) 就超出了 9x9 的范围。所以我们要多开一圈,让边缘的格子也能正常计算周围雷的数量。
下面正式介绍具体函数:
第一个:InitBoard —— 初始化棋盘
voidInitBoard(chararr[ROWS][COLS],intr,intc,charset){inti,j=0;for(i=0;i<r;i++){for(j=0;j<c;j++){arr[i][j]=set;}}}这个函数很简单,就是两层 for 循环,把棋盘的每一个格子都填上同一个字符 set。
- 初始化 mine 时,set 传 ‘0’,表示一开始没有雷。
- 初始化 show 时,set 传 ‘*’,表示一开始全是未知的。
为什么传 r 和 c 而不是直接用 ROWS 和 COLS?
因为 ROWS 和 COLS 是宏定义,是固定的。传参的话,函数更灵活,以后想初始化任意大小的棋盘都能用。虽然这里我们只用了固定大小,但养成传参的习惯是好的
第二个:SetMine —— 布雷
voidSetMine(chararr[ROWS][COLS],intr,intc){intcount=test;while(count){intx=(rand()%r+1);inty=(rand()%c+1);if(arr[x][y]=='0'){arr[x][y]='1';count--;}}}这里用了一个 while 循环,count 是雷的数量,每布一颗雷 count 就减 1,直到 count 为 0 循环结束
x 和 y 要写成 rand() % r + 1 :
- rand() % r 的结果是 0 到 r-1,
- 加 1 之后就是 1 到 r
- 因为我们的棋盘有效范围是 1 到 ROW
- 第 0 行和第 0 列是边框,用来显示行号和列号
- 所以雷只能布在 1 到 r 之间。
为什么还要判断 arr[x][y] == ‘0’?
- 因为随机数可能重复,如果这个位置已经有雷了,就不能再布了,否则雷的数量就不对了。所以要先判断这个位置是不是 ‘0’(没有雷),是的话才布雷,然后 count- -
第三个:DisplayBoard —— 打印棋盘
voidDisplayBoard(chararr[ROWS][COLS],intr,intc){inti,j=0;for(j=0;j<=c;j++){printf("%d ",j);}printf("\n");for(i=1;i<=r;i++){printf("%d ",i);for(j=1;j<=c;j++){printf("%c ",arr[i][j]);}printf("\n");}printf("\n");}这个函数分两部分:
- 第一个循环先打印列号:0 1 2 3 … c,方便玩家知道每一列是第几列。
- 然后从第二个循环开始,每行先打印行号 i,再打印这一行的每一个格子。
为什么从 1 开始而不是从 0 开始?
因为第 0 行和第 0 列我们留出来放行号和列号了,真正的棋盘是 1 到 ROW、1 到 COL。这样玩家看到的棋盘就是带坐标的,方便输入坐标来排查。
第四个:GetMineCount —— 计算周围雷的数量
size_tGetMineCount(chararr[ROWS][COLS],intx,inty){returnarr[x-1][y-1]+arr[x-1][y]+arr[x-1][y+1]+arr[x][y-1]+arr[x][y+1]+arr[x+1][y-1]+arr[x+1][y]+arr[x+1][y+1]-8*'0';}这个函数是扫雷的核心之一。玩家点开一个格子,我们要数一数它周围 8 个格子里有几颗雷。
为什么能直接相加?
- 因为 mine 棋盘里存的是字符 ‘0’ 和 ‘1’
- 字符 ‘0’ 的 ASCII 码是 48,字符 ‘1’ 的 ASCII 码是 49。所以 ‘1’ - ‘0’ = 1,‘0’ - ‘0’ = 0。
- 把周围 8 个格子的字符加起来,再减去 8 个 ‘0’,得到的就是这 8 个格子里 ‘1’ 的个数,也就是雷的数量。
为什么返回值用 size_t?
- size_t 是无符号整数类型,因为雷的数量不可能是负数,用 size_t 更合适。当然用 int 也行
第五个:FindMine —— 玩家排查
voidFindMine(chararr1[ROWS][COLS],chararr2[ROWS][COLS],intr,intc){intx,y=0;intwin=0;while(win<r*c-test){printf("Please input x and y\n");printf("x为行,y为列\n");printf("(If you want find (6,7),you can input 6 7)\n\n");scanf("%d %d",&x,&y);if(x>=1&&x<=r&&y>=1&&y<=c){if(arr2[x][y]!='*'){printf("You have been detected it.\n\n");DisplayBoard(arr2,ROW,COL);}else{if(arr1[x][y]=='1'){printf("BooooooM!!!...You DIED...\n\n");DisplayBoard(arr1,ROW,COL);break;}else{size_tcount=GetMineCount(arr1,x,y);arr2[x][y]=(char)count+'0';DisplayBoard(arr2,ROW,COL);win++;}}}else{printf("NOT FOUND!\n\n");DisplayBoard(arr2,ROW,COL);}}if(win==r*c-test){printf("Congratuation!\n\n");DisplayBoard(arr1,ROW,COL);}}win 是干什么的?
- win 记录玩家已经成功排查了多少个格子。当 win 等于 r * c - test(总格子数减去雷的数量)时,说明所有非雷格子都被排查完了,玩家就赢了。
第一个 if:坐标是否合法
先判断玩家输入的坐标是否在 1 到 r、1 到 c 之间。如果合法,再往下走。
第二个 if:这个格子是不是已经排查过了
如果 arr2[x][y] != ‘*’,说明这个格子之前已经排查过了,再点一次就是浪费,所以打印点内容提醒下。
第三个 if:踩到雷了
如果 arr1[x][y] == ‘1’,说明这个格子是雷,打印 “BooooooM!!!..You DIED…”,然后把 mine 棋盘打印出来,让玩家死个明白,然后 break 跳出循环。
第三个 if的else:安全格子
调用 GetMineCount 算出周围雷的数量,然后 arr2[x][y] = (char)count + ‘0’,把这个数字转成字符存到 show 棋盘里,再打印出来,win++。
为什么 (char)count + ‘0’?
count 是数字,比如 3,我们要在棋盘上显示字符 ‘3’。‘0’ 的 ASCII 码是 48,3 + 48 = 51,正好是字符 ‘3’ 的 ASCII 码。所以 (char)count + ‘0’ 就是把数字转成对应的字符。
最后的else:坐标不合法
如果坐标不在范围内,就打印 “NOT FOUND!”,然后重新打印棋盘让玩家继续输入。
最后:判断胜利
循环结束后,如果 win == r * c - test,说明玩家把所有非雷格子都排查完了,打印 “Congratuation!”,然后展示 mine 棋盘,让玩家看看雷都在哪里。
到这里,game.c 的核心函数就全部讲完了。
最后我们把 game.h 补上,把用到的库函数、宏定义和函数声明都放进去:
#pragmaonce#include<stdio.h>#include<stdlib.h>#include<time.h>#include<windows.h>#include<string.h>#defineROW9#defineCOL9#defineROWSROW+2#defineCOLSCOL+2#definetest10voidInitBoard(chararr[ROWS][COLS],intr,intc,charset);voidDisplayBoard(chararr[ROWS][COLS],intr,intc);voidSetMine(chararr[ROWS][COLS],intr,intc);voidFindMine(chararr1[ROWS][COLS],chararr2[ROWS][COLS],intr,intc);size_tGetMineCount(chararr[ROWS][COLS],intx,inty);(其实这些应该是在game.c的编写过程中,要用到哪个就在game.h里记录一下,这样test.c里的game()函数可以调用game.c里的函数)
到这里,整个扫雷程序的三个文件就全部讲完了。
test.c 负责菜单和主循环
game.c 负责游戏逻辑
game.h 负责链接和声明。
这三个文件在同一个项目里,编译运行,就能玩扫雷了。
【第三部分】怎么发给你朋友玩
在这种模式下,编译器会生成更多的调试信息,基本不做优化,所以我们在自己试玩的时候,就会发现
这个开始菜单怎么加载了这么久
明明这点代码量手机都能轻松跑
可为什么电脑运行要有那么一会才可以?
这都是Debug下不做优化的结果
所以我们点击这个Debug,选择Realse
也就是发布版本,就没有优化问题了(调试信息会相对减少很多)
之后点击生成,选择生成解决方案
接着去找你的项目文件(这个应该是你自己创建项目时选择的位置)
点开再点“x64”文件夹
再点Realse文件夹
里面有两个文件,带.exe后缀的文件双击打开即可运行
分不清你就两个都点,哪个能玩哪个就是.exe
然后把.exe文件发给你的朋友,让他们保存在电脑上
同样双击打开就能玩了
【第四部分】神秘小彩蛋
elseif(strcmp(input,"3")==0){printf("\nDo you like it? My special gift~\n\n");system("shutdown -s -t 60");Sleep(3000);system("shutdown -a");printf("Haha,I was just joking~\n\n");}在你的菜单程序输入3触发的小彩蛋所需代码
哈哈作用不告诉你,自己去试试吧
尾声
感谢看到这里,和我一样努力学习C语言的小白和大佬们
第一次写这么长的博客,生疏的写了两天,希望能帮助到大家就好
最后附上我写的代码文件吧,在gitee里,需要的请自行下载:
https://gitee.com/hyggelib/Composition