扫雷游戏——代码编写练习(C语言)
引入
初学者编写第一个较完整的有实际功能的程序或者说游戏时,大概也是TA第一次领略到编程的奇妙与智慧之时。但是若缺乏足够的引导,这一步也将严重打击TA的学习热情。因为对于初学者而言,有序地写出多功能、有一定代码量的程序并不容易,如果写代码的顺序和思路没有规划好,没有及时对程序功能进行测试,迎接他的将会是无尽的、令人焦头烂额的报错,甚至有可能因为过程中的某些疏漏导致需要将代码推翻重来……
这些都是我在初次编写大学课堂布置的pj时的感悟,当时,编写pj简直是我的噩梦,一开始,我无法对着长长的说明文档有序地写出代码,后来,我如同挤牙膏般敲完代码后,又发现报错几百条,脑子里一片混乱,几乎要疯掉。那时我就知道,我在错误的路上已经走了很远,可我在找不到正确的方向时,也只能咬着牙往里走去。
而现在,我也许为过去的自己找到了一条新路。让我们一起通过编写扫雷游戏这个简单的小游戏开始,学会有组织性地编写代码。
游戏说明
使用IDE:VS2022(社区版)
我认为,在编写有一定代码量的程序时,一个简单好用的IDE可以帮我这样的初学者省去大量的麻烦,VS2022(社区版)就完全满足了我的要求。说几个我喜欢它的几个理由:可以自由改变窗口布局,报错调试信息几乎全是中文,调试界面简单好用……对我来说,这帮我省去了很多麻烦,譬如看不懂报错信息,不会进行调试,不会配置环境等等等,VS2022真的可以称得上即下即用,非常好使。(仅仅从作者个人视角说明,当然还有很多好用的IDE,作者了解信息有限哈哈哈)
一些特点展示
拖动改变窗口布局
报错和警告信息都是中文,好理解,便于改错
游戏功能说明
• 使用控制台实现经典的扫雷游戏
• 游戏可以通过菜单实现玩游戏或者退出游戏
• 扫雷的棋盘是9*9的格⼦
• 默认随机布置10个雷
• 可以排查雷
• 如果位置不是雷,就显示周围有几个雷。
• 如果位置不是雷,且周围有0个雷,实现展开所有周围没有雷的区域
• 如果位置是雷,就炸死游戏结束
• 把除10个雷之外的所有非雷都找出来,排雷成功,游戏结束
这里划分的”游戏功能“其实就指示着函数的设计方法,我们需要根据每个功能编写出实现对应功能的函数。随着编程学习的深入,”拆解功能“这一步将交给我们自己完成,毕竟解决实际问题时,一个整体的问题不会自己分成清晰的几部分,而需要我们自己拆分。
代码编写
分文件处理
编写有一定代码量的工程/项目时,分文件是一个梳理编写逻辑的重要方法,将各种函数与实现归类处理,让代码各司其职,体现出鲜明的运行顺序。
对于这个扫雷游戏,我们可以分出分出三个文件:
game.h——游戏相关头文件、宏定义常量声明、游戏关键函数声明
test.c ——整体游戏测试逻辑实现,包含main函数
game.c ——扫雷游戏核心函数实现
这样的解释是什么意思?不理解没关系,接下来我们直接进入代码编写,按合理的顺序,一步步实现扫雷游戏。
game.h——游戏相关头文件、宏定义常量、游戏关键函数声明
头文件的主要作用是提供代码的接口,使得多个源文件可以共享这些声明和定义,从而实现代码的模块化和复用。
以本项目为例,game.h是一个我们玩家自定义的头文件,聚合了游戏的核心逻辑功能,主要包括实现游戏需要用到的头文件、宏定义常量和game.h中有关游戏实现的函数声明,那么有了这个头文件,我们在想使用这个游戏时,只需要在main函数所在的源文件中引用这个头文件 #include “game.h” 就可以保证其运行,代码简洁且不易出错,这就是所谓头文件的作用:提供接口,使代码根据不同的功能模块化,不同功能的代码可以很方便的成块复用
在这个游戏里,由于代码量较小,我们可以把需要用到的头文件、宏定义常量等等都聚合在game.h,这样在test.c和game.c中,只用声明game.h即可。
在更复杂的项目里,我们可能需要设计更多用于包装总结不同功能的头文件,让代码更加灵活,实现更精细的模块化。
那么,编写项目的第一步,就从创建game.h开始吧,在编写代码的过程中,我们会不断向game.h中填充相应的内容,使其成为一个内容饱满、有功能的一个头文件。
现在只用写下以下两行
#define _CRT_SECURE_NO_WARNINGS 1 //这行代码是为了让编译器不对scanf报警告
#include <stdio.h>
代码编写完后的头文件大概是这样的
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define ROWS 11
#define COLS 11
#define ROW 9
#define COL 9
#define NUM 10
//初始化棋盘
void init(char board[ROWS][COLS], int row, int col,char set);
//显示棋盘
void displayBoard(char board[ROWS][COLS], int row, int col);
//布置雷
void setMine(char board[ROWS][COLS], int num);
//扫雷核心游戏逻辑实现
int play(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y,int* num);
//结算界面
void resultShow(int outReason,int mine[ROWS][COLS]);
test.c——整体游戏测试逻辑实现
main函数
1、我们从已知的成品扫雷游戏开始,思考游戏对玩家的呈现顺序是什么样的?
(1)展现选择菜单
(2)提示用户进行选择
输入1进入游戏
输入0退出游戏
(3)根据用户选择进入下一步
进入游戏——游戏进行,结束后再回到1、的选择菜单
退出游戏——程序结束
其实就是这三步就构成了游戏的基本框架,我们从最宏观的视角出发,先构建出游戏的基本框架,后续再去慢慢细化。这个基本的框架一般就是放在main函数的内容,让人能迅速理解这个程序的运行顺序。
2、接下来我们选取合适的语句和数据结构去实现这三步。
首先,使用循环语句实现游戏基本逻辑的循环(此处采用do-while,当然譬如while,for循环语句都可以实现)
其次,使用switch语句实现选择分支效果(if语句当然也可以,但是此处switch语句可以更清晰地看出同级分支的运行内容)
3、以上的1、2步确定了要实现的逻辑和采用的语句,那么我们就可以大致编写出整体游戏逻辑的代码了。
int main()
{
int input = 0;
do
{
//显示菜单
menu();
//提示输入选择并记录输入
printf("请输入选择:");
scanf("%d", &input);
//根据选择进入不同游戏分支
switch (input)
{
case 1:
printf("扫雷游戏\n");
game();//进入扫雷游戏
break;
case 0:
exit(1);//退出游戏,定义在stdlib.h头文件中,参数为1表示非正常退出,为0表示正常退出
default:
printf("输入错误,请重新输入\n");
}
} while (input);
return 0;
}
可以看到,代码中我们使用menu(),game()这类函数表示了游戏逻辑,让main函数的基本框架完整,这是我们后续进一步细化时需要再拆分的函数。
那么如果我们现在想测试这个基本框架是否能正常运行要怎么做呢?
```c
int main()
{
int input = 0;
do
{
//显示菜单
printf("显示菜单\n");
//menu();
//提示输入选择并记录输入
printf("请输入选择:");
scanf("%d", &input);
//根据选择进入不同游戏分支
switch (input)
{
case 1:
printf("扫雷游戏\n");
//game();//进入扫雷游戏
break;
case 0:
printf("退出游戏\n");
exit(1);//退出游戏,定义在stdlib.h头文件中,参数为1表示非正常退出,为0表示正常退出
default:
printf("输入错误,请重新输入\n");
}
} while (input);
return 0;
}
运行效果
就像这样,用输出文字代表对应的逻辑,再加上对应的头文件,这段代码就可以跑起来,用于测试基本框架的逻辑是否构建正确了。确定正确后我们再往下走。(这里因为还在测试阶段,所以没有做清屏之类的处理,实际的游戏为了界面美观可以勤加清屏)
编写代码需要分模块、勤测试,这样才能花费最小的时间成本编写出最清晰的代码,否则,如果这里的问题遗留到最后再排查,花费的时间精力会大幅增加。
接下来,我们就可以对menu()和game()做具体的实现了
menu函数
这个函数简单,设计一个菜单就可以了
void menu()
{
printf("************************\n");
printf("********扫雷游戏********\n");
printf("*****输入1进入游戏******\n");
printf("*****输入0退出游戏******\n");
printf("************************\n");
}
这一步做完也可以再测试一下输出,看看菜单是否可以正常显示。
game函数
1、编写game()函数的思路和编写main()函数的思路相似,都要先对扫雷游戏的内在逻辑进行拆分,
(1)设置棋盘并初始化
(2)布置雷
(3)进入排雷
(4)游戏结束后进行结算
2、选取合适的语句和数据结构去实现这四步。
(1)设置棋盘并初始化——使用二维数组作为棋盘并初始化
(2)布置雷——一个”布置雷“功能函数实现
(3)进入排雷——循环语句实现
a.打印界面
b.输入排雷坐标
(4)游戏结束后进行结算——一个结算函数实现
3、逐步用代码实现
确定了语句和数据结构,还要考虑更多细节上的问题,比如:二维数组的参数是多少?数组要初始化成什么样?布置雷、排雷、结算逻辑具体该怎么写?
想解决这些问题,就要结合游戏的要求和实际呈现效果来逐个思考了。
(1)数组相关思考
游戏实际效果中,呈现的是未知的一片9*9棋盘,玩家通过输入坐标逐渐扫雷解锁棋盘,那么,不妨就设定数组char show[9][9],并把每个数组位都初始化为’*'。那么我们以这个数组为基础看一下需要用到二维数组游戏逻辑能否走通:
这里,我们要有寻找问题、找解决方法、优化设计的三步思路。
a. 确定并存储雷的位置
随机生成坐标+以某个符号表示雷,存储在二维数组中
b. 需要排查非雷坐标周围雷的数量并存储
i. 排查:玩家输入坐标,要去排查这个坐标周围的八个坐标,
发现问题:处于较边界的一些坐标的周围八个超出二维数组9*9边界,如果直接排查会造成数组越界。(如下左图)
找解决方法:把储存数组扩大成11*11(隐形扩大一圈,这样输入9*9内的坐标,绝对不会越界),牺牲一点空间换来写代码更容易。(如下右图)
优化设计:char show[9][9]改成char show [11][11]
ii. 存储:同样按坐标存进二维数组中。
发现问题:目前二维数组需要存储的信息有:
a雷的坐标、b非雷坐标、c非雷坐标上雷的数量,c由ab推出,如果abc都放在一个数组里储存,容易混乱,而且不太好打印。不如ab和c分开存储,换取更清晰的逻辑,而且存储c的数组是便于直接打印的。
找解决方法:创建两个char类型数组mine[11][11],show[11][11],
一个全部初始化为’0‘(非雷),全部一个初始化为’*‘,
在mine中布置雷,存储雷的位置。
按输入坐标排查雷的数量并修改在show里面,打印show数组显示给用户
优化设计:
棋盘的行列长度,数组的大小会多次使用,直接用常数填充,移植性太差,可以直接用宏定义设为常量,写代码时引用常量名。
宏定义就写在game.h里!可以把11和9都宏定义一下,使用频率都会比较高。
至此,数组的问题基本解决,后续有问题还可以继续修改。
(2)初始化、布置雷、排雷、结算等游戏核心逻辑实现
这些功能都是扫雷游戏核心功能,就是我们需要在game.c中具体实现的函数了,所以在这里的game函数中,我们只用大概划分一下这些函数实现的逻辑,随即投入到game.c函数的编写中。以下是完整的game函数,这里主要注意这个逻辑划分的方法。
void game()
{
char mine[ROWS][COLS];//存储雷的信息的棋盘
char show[ROWS][COLS];//直接显示给玩家的棋盘
//初始化
init(mine, ROWS, COLS, '0');
init(show, ROWS, COLS, '*');
//布置雷
setMine(mine, NUM);
int outReason=0;
int num = 0;
while (num<=ROW*COL-NUM)//条件应该是开启除了10个雷之外的所有坐标
{
int x=0,y=0;
system("cls");
//打印界面
displayBoard(show,ROW,COL);
//对应的坐标输入
printf("请输入要排雷的坐标:");
scanf("%d %d", &x, &y);
//按照坐标清算雷的数量并更新界面
int isRight=play(mine, show, x, y,&num);
if (isRight && num == ROW * COL - NUM)
{
outReason = 1;
break;
}
else if(isRight==0)
{
outReason = 2;
break;
}
}
//结算界面呈现
resultShow(outReason,mine);
}
game.c ——扫雷游戏核心功能函数实现
接下来,我们就要在game函数中开始具体实现核心游戏逻辑了,再回顾一下开头的功能说明。还未实现的有:
• 默认随机布置10个雷
• 可以排查雷
• 如果位置不是雷,就显示周围有几个雷。
• 如果位置不是雷,且周围有0个雷,实现展开所有周围没有雷的区域
• 如果位置是雷,就炸死游戏结束
• 把除10个雷之外的所有非雷都找出来,排雷成功,游戏结束
划分好功能,分析好二维数组的形式以后,这一部分反而不算难点了,只要按要求一个个实现即可,建议自己独立去实现这些功能,遇到困难时先自己寻找解决方法,再参考以下的示例代码。
编写中会遇到的困难和错误点可能主要会有以下几个:
1、忘记二维数组的类型是char,犯给数组输入非字符型数据等等等的错误。
2、对输入坐标周围地雷的记数有问题
解:如何计算某座标周围雷的数量?
法一:逐一排查周围八个坐标是不是雷并记数
好想到,但实现较为复杂
法二:因为雷是’1‘,空地是’0‘,其实周围八个坐标显示的数字的和就是雷的数量。
但注意:这里的1,0是字符,其实对应48和49,所以计数时要做一定的转换!
3、实现展开逻辑有困难
这里采用递归实现,让我们用deepseek解释一下
#define _CRT_SECURE_NO_WARNINGS 1
#include "game.h"
//初始化棋盘
void init(char board[ROWS][COLS], int row, int col,char set)
{
for (int i = 0;i < row;i++)
{
for (int j = 0;j < col;j++) {
board[i][j] = set;
}
}
}
//显示棋盘
void displayBoard(char board[ROWS][COLS], int row, int col)
{
//打印坐标系
//打印x轴
for (int i = 0;i <= row;i++)
{
printf("%d ", i);
}
printf("\n");
//打印y轴
for (int i = 1;i <=row;i++)
{
printf("%d ", i);
for (int j = 1;j <=col;j++)
{
printf("%c ", board[i][j]);
}
printf("\n");
}
}
//布置雷
void setMine(char board[ROWS][COLS], int num)
{
int x = 0;
int y = 0;
for (int i = 0; i<num ; i++)
{
x = rand() % ROW + 1;
y = rand() % COL + 1;
board[x][y] = '1';
}
}
//连续解开周围没有雷的格子
int allBoom(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y)
{
static num = 0;
//注意越界问题!棋盘之外的点不用计算
if (x < 1 || x > ROW || y < 1 || y > COL)
{
return num;
}
// 如果该位置已经被显示,避免重复处理
if (show[x][y] != '*' )
{
return num;
}
num++;
int count = 0;
count = mine[x + 1][y] + mine[x + 1][y + 1] + mine[x + 1][y - 1] + mine[x - 1][y] + mine[x - 1][y - 1] + mine[x - 1][y + 1] + mine[x][y - 1] + mine[x][y + 1] - 8 * '0';
show[x][y] = '0' + count;
if (count != 0)
{
return num;
}
else if (count == 0)
{
allBoom(mine, show, x + 1, y);
allBoom(mine, show, x + 1, y + 1);
allBoom(mine, show, x + 1, y - 1);
allBoom(mine, show, x , y + 1);
allBoom(mine, show, x , y - 1);
allBoom(mine, show, x - 1, y + 1);
allBoom(mine, show, x - 1, y );
allBoom(mine, show, x - 1, y - 1);
}
}
//按照输入坐标判断是否踩雷,没踩雷就清算雷的数量
int play(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y,int* num)
{
int count = 0;
if (show[x][y] == '*')
{
if (mine[x][y] == '1')
{
return 0;//游戏失败的退出
}
else if (mine[x][y] == '0')
{
*num=allBoom(mine, show, x, y);
return 1;//游戏没失败的退出
}
}
else
{
return 1;//如果坐标已经解锁过,不做处理
}
}
//结算界面
void resultShow(int outReason,int mine[ROWS][COLS])
{
system("cls");
switch (outReason)
{
case 1:
printf("恭喜你!扫雷成功!\n");
system("pause");
system("cls");
return;
case 2:
printf("很遗憾,踩到雷了,扫雷失败。\n地雷分布如下\n");
displayBoard(mine, ROW, COL);
system("pause");
system("cls");
return;
}
}
总结
通过分三个文件的讲解,我们大致地就把这个扫雷游戏的整体编写逻辑给说完了,请在编写代码的过程中勤调试,多动手,以锻炼代码编写能力。如果还有需要更精细讲解的地方、有任何错误、好的优化方法,请在评论区留言!感谢您的阅读!