文章目录
- 前言
- 发现问题
- 分析问题
- 解决问题
- [void init_game(char arr[][COLS],int row,int col,char set);](#1.初始化游戏地图)
- [void mine(char arr[][COLS],int row,int col);](#布置地雷)
- [void print_map(char arr[][COLS],int row,int col);](#显示棋盘)
- [void print_test(char arr[][COLS], int row, int col);](#显示棋盘)
- [void menu(void);](#开始菜单)
- [void sweep(char game[][COLS], char show[][COLS], int row, int col);](#扫雷)
- [int MINE_COUNT(char game[][COLS], int col, int row);](#扫雷)
- 后续思路
前言
当初步接触了C语言,掌握了基本的输入、输出和函数的基本操作之后,你就可以尝试自己编写一个基于C语言实现的小游戏了。本文面向编程小白,从发现问题,到分析问题,最后解决问题,带你一步步实现“扫雷”小游戏。
由于博主也刚接触编程不久,更多的是分享自己解决问题的思路等,部分代码依然有优化提升的空间,还请大佬指正、纠错!
发现问题
-
如果想要使用C语言编写一个程序,复刻经典游戏“扫雷”,应该怎么做?
-
我们的程序要具备那些功能?
-
基础功能完成后,如何进一步优化?
分析问题
为编程复刻“扫雷”这个经典游戏,我们要明确一个大的方向:代码为游戏功能而服务。即先确定“扫雷”游戏本身需要具有的功能,然后一一编程实现这些功能。通过C语言的函数,我们可以封装不同的函数实现不同的功能,不仅可以提高的代码的可读性,后续优化游戏时也更加方便。
“扫雷”的游戏目标很简单,排查出所有的雷。游戏交互很简单,点击一次,是雷则被炸死,不是雷则显示出格子周围八个格子中雷的数量。作为一个游戏,必然还需要一些交互界面,例如菜单、输入提示等等。因此,我们可以将要实现的功能大致分为两类:为游戏服务的和为人服务的。
为游戏服务
初始化游戏地图
将游戏棋盘规定为10*10的大小,如图:
由于要通过命令窗口进行扫雷的操作,所以在初始时,要使整个地图将被图标覆盖,表示没有排查过的地方。像这样:
void init_game(char arr[][COLS],int row,int col,char set);
布置地雷
“扫雷”游戏,地图中当然需要有雷,所以我们需要在地图中放置10颗雷。我们用字符‘0’表示地图上没有雷的位置,用字符‘1’表示有雷的位置,像这样:
void mine(char arr[][COLS],int row,int col);
显示棋盘
游戏棋盘需要通过命令窗口展示给玩家,因此需要一个函数,专门用于打印棋盘。
void print_map(char arr[][COLS],int row,int col);
为了方便检查函数的实现的功能是否正确,我们也可以准备一个测试函数
void print_test(char arr[][COLS], int row, int col)
为人服务
开始菜单
游戏需要一个简单的主菜单,让玩家选择是否开始游戏【主要还是让游戏更像游戏🤣】。
void menu(void);
扫雷
游戏最核心的功能便是扫雷交互。扫雷交互中,整个流程简单概括为玩家输入想要排雷的位置,程序需要接收玩家的输入,程序返回结果。
程序接收输入时,如果输入的是有效的坐标位置,则正常的比对排雷结果。如果输入的是无效位置,或者玩家输入错误,程序需要给予反馈,提示玩家输入错误。【一个简单的小游戏,也需要考虑极端情况的发生】然后程序内部需要比对并统计玩家想要排查的坐标的周围八个位置的雷的数量,然后输出,向玩家显示排查位置周围雷的个数。
至此,一个游戏回合闭环,接着就是不断重复这个核心环节。
void sweep(char game[][COLS], char show[][COLS], int row, int col);
int MINE_COUNT(char game[][COLS], int col, int row);
解决问题
在实现具体的功能之前,我们需要做一些基础的设定,比如宏定义一些参数等[宏定义的优势在于方便后续调整参数]
#define ROW 10 //棋盘的行数
#define COL 10 //棋盘的列数
#define ROWS ROW+2 //棋盘的行数
#define COLS COL+2 //棋盘的列数
#define MINE 10 //地雷的数量
此处定义了两个棋盘的长、宽,具体原因稍后做解释
现在,我们将用不同函数实现上文所述的不同功能。 点击函数声明可以跳转到对应的分析位置
void init_game(char arr[][COLS],int row,int col,char set);
根据上文的分析,我们不仅需要记录初始化游戏时地雷的位置(在游戏第一个回合,地雷布置好后,就不需要再次布置地雷,也不能再次布置地雷),还需要记录玩家排查雷的位置,并输出反馈给玩家。因此,我们通过两个二维字符数组分别实现上述两个目的,一个gameboard[ROWS][COLS]
,一个showboard[ROWS][COLS]
。
初始化时也要根据需求,分别对两个字符数组进行初始化。
void init_game(char arr[][COLS],int row,int col,char set){
int i, j;
//主棋盘全部填入字符“set”
for (i = 0; i < row; i++) {
for (j = 0; j < col; j++) {
arr[i][j] = set;
}
}
}
void mine(char arr[][COLS],int row,int col);
布置雷时,我们希望每一局游戏的雷的位置都是随机的,所以我们可以通过随机数,每次都随机生成一个埋雷的坐标,从而可以实现雷的位置随机的效果。
具体需要注意的细节如下:
- rand()函数是通过种子,计算出伪随机数,我们通过
time()
函数,使种子依据时间变化而变化,这样才能使每一局生成的雷的坐标不同 - 生成的坐标需要考虑范围,需要确保其在[1,10]的范围内
- 埋雷的时候不能重复埋雷,从而保证每一局都有十个雷
void mine(char arr[][COLS],int row,int col) {
srand((unsigned int)time(NULL));
int cot = MINE;
while (cot > 0) {
//随机生成雷生成的坐标
int x = rand() % COL + 1; //横坐标范围1到10
int y = rand() % ROW + 1; //纵坐标范围1到10
//判断一下原本的位置是否有雷
if (arr[y][x] != '1') {
arr[y][x] = '1';
cot--;
}
}
}
具体操作时,只需要传入gameboard
,因为雷的信息并不需要向玩家展示。
void print_map(char arr[][COLS],int row,int col);
初步考虑实现这个功能,只需要遍历二维数组并打印每一个元素即可,但是作为游戏,需要考虑玩家的交互体验,所以我们需要加上坐标的索引,以方便玩家查看坐标。
void print_map(char arr[][COLS],int row,int col) {
int i, j, k;
for (k = 0; k <= row; k++) {
printf("%3d", k);
}
printf("\n");
for (i = 1; i <=row; i++) {
printf("%3d", i);
for (j = 1; j <=col; j++) {
printf("%3c", arr[i][j]);
}
printf("\n");
}
}
void print_test(char arr[][COLS], int row, int col);
测试棋盘的打印逻辑与普通的棋盘打印逻辑一致,唯一的小细节是棋盘打印的范围不同。测试时,我们希望打印数组下标为[0,ROWS],[0,COLS]
void print_test(char arr[][COLS], int row, int col) {
int i, j;
for (i = 0; i < row; i++) {
for (j = 0; j < col; j++) {
printf("%3c", arr[i][j]);
}
printf("\n");
}
printf("\n");
}
这样的测试函数只是一个样例。当时我在编写布置雷部分时的函数时,经常布置的出现问题,所以编写了测试函数帮助我直观展示整个棋盘的状态。当你自己具体实现项目的时候,你可以根据自己的需求编写不同的测试函数,以提高自己的编程效率。
void menu(void);
简单的菜单页面,提示选择项
void menu(void) {
printf("*************************\n");
printf("***1.play 0.quit **\n");
printf("*************************\n");
printf("请输入>");
}
编程时常会用到分割线,亦可以单独封装一个打印分割线的函数:
void devison(void) {
printf("——————————————————");
printf("——————————————————\n");
}
void sweep(char game[][COLS], char show[][COLS], int row, int col);
上文已经分析过扫雷核心功能需要注意的细节。
只要游戏没有结束,我们都要重复这个操作,所以程序的主体是一个while
循环,只有满足条件时,我们才跳出循环。
在循环中,我们通过分支结构,处理不同情况下的输出。
void sweep(char game[][COLS], char show[][COLS],
int row, int col) {
int win =ROW*COL-MINE; //记录排雷的状态
while (!win) {
int x = 0, y = 0;
printf("请输入排查的坐标(横纵坐标请用空格隔开)\n");
printf(">");
scanf("%d %d", &x, &y); //要注意处理多种情况:坐标输入格式以及范围
if (x >= 1 && x <= col && y >= 1 && y <= row) {
if (game[y][x] == '1') {
printf("游戏失败,你被炸死了!\n");
print_map(game,row,col);
devison();
break;
}
else {
int mine_count = MINE_COUNT(game,x,y);
//将排查位置周围雷的数量存入到showboard中
show[y][x] = mine_count + '0';
win--;
if (!win)
print_map(show, row, col);
else {
print_map(game, row, col);
printf("排雷成功!\n");
devison();
}
}
}
else
printf("输入无效!\n");
}
}
int MINE_COUNT(char game[][COLS], int col, int row);
统计雷的数量也是该项目需要实现的主体功能之一。其基本逻辑很简单,通过遍历排查坐标周围的八个格子,与雷比较【字符’1’】,就能得到排查坐标周围的雷的数量。
主要需要考虑边界的处理,这也是为什么在宏定义时定义两个棋盘大小的原因。
我们手动将游戏棋盘扩大,可以帮助我们在统计雷的时候不出现问题,而不影响打印操作【只需要更改打印的下标范围】。无论是排查边界位置,还是角落位置,我们的统计逻辑都是一致的。
int MINE_COUNT(char game[][COLS],int x,int y) {
return game[y - 1][x - 1] +game[y - 1][x] +game[y - 1][x + 1] +
game[y][x - 1] +game[y][x + 1] +
game[y + 1][x - 1] +game[y + 1][x] +game[y + 1][x + 1]-'0'* 8;
//字符'1'减去字符'0',同样得到数值1
}
后续思路
上文已经介绍了各个主要功能的实现,只差一步整合就可以完成这个小项目了。具体内容就不展示了,需要思考的问题有二:一,如何实现菜单选择;二,不同的函数的存放方式,全部编入一个.c
文件?
同时,就算游戏能正常的运行,完成一局游戏,也只能将其称之为1.0版本,依然有很大的优化空间,基于现实已有的扫雷游戏,我们很容易想到对自己项目的优化方向。
优化交互逻辑
扫雷优化
1.0版本的游戏,玩家需要扫完所有的格子,与实际的游戏不太一样:当周围的格子的雷的数量为0的时候,便会自动展开。本质逻辑,依然是周围八个格子中雷的数量,“自动”展开只需要我们将其处理为递归函数即可:
void map_open(char game[][COLS], char show[][COLS],
int row, int col,
int x, int y) {
int mine_count;
if (x >= 1 && x <= col && y >= 1 && y <= row) {
if (show[y][x] == '_')
return;
else if ((mine_count = MINE_COUNT(game, x, y)) != 0) {
show[y][x] = mine_count + '0';
return;
}
else {
show[y][x] = '_';
//周围八个格子均可能可以向外展开
map_open(game, show, row, col, x - 1, y);
map_open(game, show, row, col, x + 1, y);
map_open(game, show, row, col, x - 1, y - 1);
map_open(game, show, row, col, x + 1, y - 1);
map_open(game, show, row, col, x, y - 1);
map_open(game, show, row, col, x - 1, y + 1);
map_open(game, show, row, col, x, y + 1);
map_open(game, show, row, col, x + 1, y + 1);
}
}
else
return;
}
1.0版我们判断胜利条件是通过累加排查次数实现的,那此处自动展开空格时应该如何处理胜利条件呢?
界面优化
通过控制台实现的游戏只是具有基本的游戏功能,但是不够美观,所以有以下的优化方向:
- 每次都刷新控制台,使其总是只显示一个棋盘,看上去更加简洁
- 让棋盘看上去更加方正,添加一定的框线等
- 状态提示,在主界面显示雷的数量
- 一步到位,使用其他的库函数,做到点击交互
丰富游戏功能
添加游戏模式
1.0的游戏菜单,只有“开始游戏”的选项。后续我们可以添加难度,即不同选项,对应不同大小的棋盘,以及不同的雷的数量,甚至让玩家自定义雷的数量。
添加游戏交互
当前游戏只能进行扫雷操作,我们可以添加标记雷、不确定等功能,以提高玩家的游戏体验。