系统性学习C语言-第七讲-数组和函数实践:扫雷游戏
经过前六讲内容的学习,我们已经初步掌握了一些 C语言 的基础知识,俗话说实践出真知,光是纸上谈兵我们的技艺终究不会有大的进步,现在我们就对所学知识进行实践,在实践中更进一步地掌握
1. 扫雷游戏分析和设计
我们想要通过代码来模仿实现扫雷游戏,那么我们就要对它的游戏机制,功能进行分析,将其拆分,分析每个模块如何实现
当然,以我们当前的知识储备,完美实现一个完善的扫雷游戏显然是不太可能的,所以我们要在适当程度上对功能进行一定精简
1.1 扫雷游戏的功能说明
-
使用控制台实现经典的扫雷游戏
-
游戏可以通过菜单实现继续玩或者退出游戏
-
扫雷的棋盘是 9 * 9 的格子
-
默认随机布置10个雷
-
可以排查雷
-
如果位置不是雷,就显示周围有几个雷
-
如果位置是雷,就炸死游戏结束
-
把除 10 个雷之外的所有非雷都找出来,排雷成功,游戏结束
游戏的界面:
1.2 游戏的分析和设计
1.2.1 数据结构的分析
扫雷的过程中,布置的雷和排查出的雷的信息都需要存储,所以我们需要⼀定的数据结构来存储这些信息。
因为我们需要在 9 * 9 的棋盘上布置雷的信息和排查雷,我们首先想到的就是创建⼀个 9 * 9 的数组来存放信息。
那如果这个位置布置雷,我们就存放 1 ,没有布置雷就存放 0 。
假设我们排查 ( 2 , 5 ) 这个坐标时,我们访问周围的⼀圈 8 个黄色位置,统计周围雷的个数是 1 。
假设我们排查( 8 , 6 )这个坐标时,我们访问周围的⼀圈 8 个黄色位置,统计周围雷的个数时,最下面的三个坐标就会越界,
为了防止越界,我们在设计的时候,给数组扩大⼀圈,雷还是布置在中间的 9 * 9 的坐标上,周围⼀圈不去布置雷就行,
这样就解决了越界的问题。所以我们将存放数据的数组创建成 11 * 11 是比较合适。
再继续分析,我们在棋盘上布置了雷,棋盘上雷的信息(1)和非雷的信息(0),假设我们排查了某⼀个位置后,这个坐标处不是雷,
这个坐标的周围有1个雷,那我们需要将排查出的雷的数量信息记录存储,并打印出来,作为排雷的重要参考信息的。
那这个雷的个数信息存放在哪里呢?如果存放在布置雷的数组中,这样雷的信息和雷的个数信息就可能或产生混淆和打印上的困难。
这⾥我们肯定有办法解决,比如:雷和非雷的信息不要使用数字,使用某些字符就行,这样就避免冲突了,
但是这样做棋盘上有雷和非雷的信息,还有排查出的雷的个数信息,就比较混杂,不够方便。这⾥我们采用另外⼀种方案,
我们专门给⼀个棋盘(对应⼀个数组mine)存放布置好的雷的信息,再给另外⼀个棋盘(对应另外⼀个数组show)存放排查出的雷的信
息。这样就互不干扰了,把雷布置到 mine 数组,在 mine 数组中排查雷,排查出的数据存放在 show 数组,
并且打印 show 数组的信息给后期排查参考。同时为了保持神秘,show 数组开始时初始化为字符 ‘*’,为了保持两个数组的类型⼀致,
可以使用同⼀套函数处理,mine数组最开始也初始化为字符 ’ 0 ’ ,布置雷改成 ’ 1 ’ 。如下如:
对应的数组应该是:
char mine[11][11] = {0};//⽤来存放布置好的雷的信息
char show[11][11] = {0};//⽤来存放排查出的雷的个数信息
1.2.2 文件结构设计
之前学习了多文件的形式对函数的声明和定义,这里我们实践⼀下,我们设计三个文件:
test.c //⽂件中写游戏的测试逻辑
game.c //⽂件中写游戏中函数的实现等
game.h //⽂件中写游戏需要的数据类型和函数声明等
2. 扫雷游戏的代码实现
2.1 头文件代码分析与实现
我们先对 game.h 头文件中的代码内容进行分析
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
# define eazy_count 10
# define ROW 9
# define COL 9
# define COLS COL + 2
# define ROWS ROW + 2
void InitBoard(char board[ROWS][COLS], int row, int col, char set); //初始化棋盘
void SetBoard(char board[ROWS][COLS], int row, int col); //布置雷
void PrintBoard(char board[ROWS][COLS], int row, int col); //展示棋盘
void FindBoard(char show[ROWS][COLS], char mine[ROWS][COLS], int row, int col); //查找雷
2.1.1 头文件包含与常量定义
首先我们尽量将 .c 文件中所需要的头文件包含进来,这样就可以仅包含一个 game.h 头文件就能实现全部的功能
接下来我们需要用 define 定义一些常量,给这些常量换个名字,更加方便理解,相比直接在代码中使用常量,可读性更高
同时以后要进行修改时,我们只需修改定义的常量即可,十分便捷
我们现在可暂且将在此处 define 定义常量的作用为起别名,在别处使用这些名字,系统在编译时会自动将其替换为常量
在一些情况下,我们展示的数组,通常是 9 行 9 列的,所以我们将 ROW
与 COL
定义为常量 9,但是出于实际的需要
我们在定义数组是仍需定义为 11 行 11 列,11 这个数我们也经常会使用,且具有一定的意义,我们也如定义ROW
和 COL
一样,将其定义为 ROWS
与 COLS
,不过我们并不将其直接定义为 11 ,而是通过ROW
和 COL
各自 + 2,
这样我们在修改ROW
和 COL
的同时, ROWS
与 COLS
就已经顺便修改了,无需我们再进行修改
2.1.2 头文件函数模块拆分与声明
这里我们将我们要实现的代码部分拆分为四部分,分析一下他们需要的变量
-
InitBoard
初始化数组,用于将数组初始化为我们想要的数据:需要传递字符型数组参数char board[ROWS][COLS]
,要初始化的行数与列数int row, int col
,以及初始化为什么符号char set
-
SetBoard
布置地雷的数组,用于在mine
数组中布置 ‘1’ 的炸弹:需要传递字符型数组参数char board[ROWS][COLS]
,要布置炸弹限制的最大行数与列数int row, int col
,关于具体地雷的坐标,我们会用专门的随机数生成函数来生成,所以不用传参。 -
PrintBoard
打印数组,用于打印我们的数组,给玩家展示相关信息:需要传递字符型数组参数char board[ROWS][COLS]
,要展示的行数与列数int row, int col
-
FindBoard
查找数组,查找数组中的地雷:需要传递show
与mine
两个字符符型数组参数,方便在查找后根据各种情况,调整数组数据,char show[ROWS][COLS], char mine[ROWS][COLS]
,需要传递最大的查找范围,行与列int row, int col
,这里我们将输入行与列的代码,在查找数组内部实现,所以我们无需传递要查找的坐标
2.2 game.c 源文件代码分析与实现
对头文件的代码分析完毕后,我们开始对函数的定义进行分析,以及代码实现
2.2.1 InitBoard
初始化数组函数
1 . 如何使用循环来实现数组的初始化部分
要对数组进行初始化,我们就要生成所有的下标,对一维数组进行初始化,我们使用一重循环即可产生所有的下标
二重循环具有行、列双下标进行定位,所以我们要使用二重循环进行初始化,对于初始化数组来言,循环最好的选择为 for
限制循环的条件是,在一重与二重 for
循环中定义的变量分别小于作为参数传递进来的 row
与 col
真正在函数中运用 InitBoard
初始化数组时,我们传递的限制变量实际为 ROWS
与 COLS
,这是因为初始化数组必须完全,彻底
并不是像展示数组函数 PrintBoard
一样只是用部分的数组,所以读者不必对限制循环变量部分产生疑惑。
在我们产生坐标的同时,我们要对数组的内容进行初始化,将我们传递的字符 set
赋予坐标
2 . 函数返回类型的思考
这个函数并不需要我们返回任何数据,我们初始化数组的目的已经在函数的主体部分实现,返回数据的类型定义成 void
即可
此时,函数定义部分的代码逻辑我们基本完成,现在我们就按照对应思路,来实现代码。
注意:
在用到 for
循环时,我们通常需要定义变量,若这个变量还需要在循环中进行使用,要根据其使用的含义,为其定义一个有意义的名称
void InitBoard(char board[ROWS][COLS], int row, int col, char set) //初始化棋盘
{
for (int current_row = 0; current_row < row; current_row++)
{
for (int current_column = 0; current_column < col; current_column++)
{
board[current_row][current_column] = set;
}
}
}
3 . 变量命名所要注意的事项
这里我们将在 for
循环内定义的变量分别命名成 current_row
与 current_column
,代表着当时数组的行与列,由于还会进行自增,
变化,所以在前面加上了 current ,这样代码的可读性就会增强。
以上就是我们 InitBoard
初始化数组函数定义部分的实现。
2.2.2 SetBoard
布置地雷数组函数
1 . 如何随机生成地雷的数组下标
在 SetBoard
布置地雷数组中,我们需要对 mine
数组进行地雷的布置,数量为 10 个。
我们先思考如何产生地雷的坐标,首先排除我们一个一个输入的可能,这样的运行过程过于繁琐 ,我们使用 rand
来生成随机数
对于尚未学习
rand
函数的读者可以进行搜索学习,简述而言这是一个可以生成随机数的函数,但要使用它还需要进行处理,后续我们会在主函数中遇见
2 . 对于随机生成的数组下标各种情况的思考
但生成的随机数很有可能超出我们的数组范围,我们需要将雷的行和列限制在 1 ~ 9,所以我们在计算是要使用取模,
我们将限制的最大行数与列数传递到了参数 row
与 col
中,所以我们对于随机出来的行数与列数分别对 row
与 col
取模即可,
但是这样我们就的炸弹行数和列数就可能取不到 0 或者 9,所以我们要在取模的基础上 + 1 。
在我们处理完设置的行数与列数超出范围的情况外,我们还需要处理一种情况,虽然有极小的概率,但是在设置雷的过程中,
我们可能产生重复的坐标,此时我们就要对这个位置是否被设置为雷作出判断,然后再进行设置。
3 . 根据已知的条件对循环进行选择
对于重复坐标的情况,运用 for
循环显然无法简单的处理,无论是否是重复坐标,只要循环结束,我们再 for
循环中设置的变量都会
产生自增行为,这是我们就可以使用 while
循环,只用非重复的坐标点此时我们才对限制变量进行自增。
基于我们上面的代码逻辑,我们可以进行函数定义的实现。
void SetBoard(char board[ROWS][COLS], int row, int col) //布置雷
{
int boom_count = 0;
while (boom_count < eazy_count)
{
int boom_row = rand() % ROW + 1;
int boom_col = rand() % ROW + 1;
if (board[boom_row][boom_col] != '1')
{
board[boom_row][boom_col] = '1';
boom_count++;
}
}
}
4 . 变量命名所要注意的事项
我们将限制变量定义为 boom_count
,限制变量每自增代表我们安放的地雷增加一个,有助于代码的可读性
2.2.3 PrintBoard
打印数组函数
1 . 如何使用双重循环输出仅需展示的部分数组
首先我们要打印出数组,需要生成数组的部分下标,对于我们创建的 11 行 11 列数组,实际上我们只展示其中的 1 ~ 9 行 1 ~ 9 列,
所以我们要用上二重循环来生成对应的坐标,对于二重循环产生数组下标的部分,我们使用 for
循环的效果最佳。、
但同时我们要对限制循环的变量做出一定的调整,达到展示部分数组的目的。
因为我们第 0 行,第 0 列,第 10 行 ,第 10 列并不是我们打印的对象,我们将限制变量初始化为 1 ,
这样我们就能跳过第 0 行,第 0 列的打印。对于第 10 行 ,第 10 列,因为我们是部分打印数组,所以我们在函数部分传入的参数为
ROW
与 COL
,所以我们并不用担心第 10 行 ,第 10 列被打印到数组中,因为当限制条件为 < ROW
, < COL
时我们最多也打印到
第 8 行,第 8 列,所以我们现在要担心的问题转化为了如何打印 第 9 行 ,第 9 列,我们只需将限制条件出 + 1 即可,
即变为 < ROW +1
, < COL + 1
,这样我们便可打印到第 9 行,第 9 列。
2 . 如何实现在界面添加数组坐标参照
为了便于玩家更好的输入需要排查的数组坐标,我们最好打印出对应的坐标行数与列数,就像这样。
这样玩家就能快速找到自己像查找的对应坐标。
对于第一行对应的坐标,我们可以单独使用一层循环来实现,对于第一列的坐标,我们则可以在数组的第一层循环中生成,
3 . 对于数组内容的打印,什么条件下我们要进行换行操作
对于数组数据的打印,我们还有一个注意的点,即换行符的打印。
这里我们选择用 if
在特定条件时,打印数组数据后,直接紧接换行符的打印,现在我们就要分析一下判断条件为何时,
我们需要进行换行符的打印,当我们的限制条件为 < COL + 1
时,我们的限制变量 == COL
时,
我们打印的数组就来到了这一行最后一列的打印,这是我们就要打印换行符,所以我们分析出了打印换行的条件为限制变量 == COL
基于以上的函数实现逻辑,我们可以对函数进行代码层面的实现。
void PrintBoard(char board[ROWS][COLS], int row, int col) //展示棋盘
{
printf("--------扫雷游戏--------\n");
int col_mark = 0;
for (col_mark = 0; col_mark < col + 1; col_mark++)
{
printf("%d ",col_mark);
}
printf("\n");
int row_mark = 0;
for (int current_row = 1; current_row < row + 1; current_row++)
{
row_mark++;
printf("%d ", row_mark);
for (int current_col = 1; current_col < col + 1; current_col++)
{
if (current_col == col )
printf("%c\n", board[current_row][current_col]);
else
printf("%c ", board[current_row][current_col]);
}
}
}
4 . 变量命名所要注意的事项
这里我们将第一行下标参考定义为 row_mark
,第一列下标参考为 col_mark
,都是为了可读性做考量,for
循环中的限制变量同理
2.2.4 FindBoard
查找数组函数
1 . 对于输入坐标的实现
这里定义两个变量,分别储存输入的需要排查的行数、列数,用 scanf
库函数实现输入即可。
2 . 是否使用循环,使用何种循环,循环进入的条件是什么?
排查雷,一次并不能排完全部的雷,所以需要使用循环,此处我们使用 while
循环,原因是在排查雷时,我们会遇到多种情况,
不是每一种情况限制变量都要自增, 使用 for
循环会使我们的代码复杂,需要对每次循环自增的限制变量进行处理。
由于我们对扫雷的功能进行了精简,我们单次排雷并不能像游戏中那样,如果有一大片的无雷区全部展开,
所以我们可能会将所有的无雷区都排查到,仅剩有雷区,我们定义一个限制变量,当这个变量小于无雷区最大可以取到的数时,
则进入循环,也就是他等于所有无雷区坐标个数的数时,循环停止,代表剩下的坐标全部都是雷
3 . 被查找处坐标的几种可能,以及对应操作
一 :坐标合法,不超出数组范围:
( 1 )此处坐标已经被排查过:此时我们需要告诉玩家,此坐标已经被排查过,需要重新排查,并不对限制变量进行任何更改,
此次查找相当于一次无效操作,然后进入新一轮循环重新输入。
( 2 )此处坐标不为雷:此时我们需要定义一个数,来统计此坐标九宫格内雷的数量,并将值放入 show
数组的相同坐标处,
并打印 show
数组,然后限制变量自增,此次操作有效,成功找到一处无雷坐标,再重新进入循环。
( 3 )此处坐标恰好是雷:告诉玩家被炸死了,展示 mine
数组,告诉玩家雷坐标的安放位置,然后跳出循环。
二:坐标不合法,超出范围:
( 1 )此处坐标不合法:超出数组对应的范围,我们对限制变量不进行操作,这是一次无效的查找,我们需要重新进入循环输入。
前三次可以归为坐标合法的情况,第四种情况是坐标不合法的情况,此处我们在循环中使用
if - else
来实现情况的分类
4 . 坐标不为雷时,返回九宫格内是雷个数的函数如何实现
首先判断一下函数需要传入的参数有哪些,函数需要一个 mine
数组,被选择的行坐标、与列坐标,
我们需要对坐标的九宫格进行判断,所以我们就需要生成对应的坐标,这里我们不使用循环,直接使用作为参数传递进的行、列
进行 + 1 或者 - 1,由于字符数组在内存中以 ASCII 码表的形式存在,所以字符 ’ 1 ’ + ’ 1 ’ 的结果不为 ’ 2 '。
在将八个坐标的数据全部相加后,我们要减去 8 * ’ 0 ’ ,得出一个整形代表着雷的个数,所以函数的返回类型为整数。
在使用返回的结果时,直接 + ’ 0 ',或者直接加字符数组的内容也是可以的。
int GetMineCount(char mine[ROWS][COLS], int row, int col)
{
return (mine[row - 1][col] + mine[row - 1][col - 1] + mine[row][col - 1] + mine[row + 1][col - 1] + mine[row + 1][col] +
mine[row + 1][col + 1] + mine[row][col + 1] + mine[row - 1][col + 1] - 8 * '0');
}
5 . 循环结束的几种情况,以及对应处理
跳出循环的情况有两种:
( 1 )正常跳出循环:那么此时说明我们的条件已经不满足进入循环的条件,说明我们此时已经排查了所有无雷处,
此时我们应该告诉玩家排雷成功,并打印 mine
数组,展示布置雷的具体坐标,然后退出函数。
( 2 )被炸死跳出循环:这是我们只需退出函数即可,并不用进行任何操作
基于以上的函数实现逻辑,我们可以对函数进行代码层面的实现。
void FindBoard(char show[ROWS][COLS], char mine[ROWS][COLS], int row, int col) //查找雷
{
int row_chosen = 0;
int col_chosen = 0;
int win = 0;
while (win < row * col - eazy_count)
{
printf("输入你想查询的坐标\n");
scanf("%d%d", &row_chosen, &col_chosen);
if (row_chosen > 0 && col_chosen > 0 && row_chosen <= row && col_chosen <= col)
{
if (show[row_chosen][col_chosen] != '*')
{
printf("此位置已被查找,请重新选择位置\n");
}
if (mine[row_chosen][col_chosen] == '0')
{
int num_boom_nearby = 0;
num_boom_nearby = GetMineCount(mine, row_chosen, col_chosen);
show[row_chosen][col_chosen] = num_boom_nearby + '0';
PrintBoard(show, ROW, COL);
win++;
}
if (mine[row_chosen][col_chosen] == '1')
{
printf("很遗憾,你被炸死了\n");
PrintBoard(mine, ROW, COL);
break;
}
}
else
{
printf("坐标不合法,请重新输入");
}
}
if (win == row * col - eazy_count)
{
printf("恭喜你,排雷成功");
PrintBoard(mine, row, col);
return;
}
return;
}
6. 变量命名所要注意的事项
我们将所选则的行与列命名为 row_chosen
与 col_chosen
,增加代码的可读性。
到此 game.c 文件内函数定义就已经实现完全。
2.3 测试文件代码分析与实现
我们将测试文件中需要实现的代码分成三部分:
-
menu
菜单生成函数:生成一个游戏的开始菜单,用数字对应不同的选项,比如“开始游戏”、“退出游戏”。 -
game
游玩逻辑函数:完整实现一遍游玩的逻辑,从初始化数组开始,到查找数组结束,实现完整的游玩逻辑。 -
main
主函数:在主函数中,运用函数,实现完整的游玩体验。
2.3.1 menu
菜单生成函数
menu
菜单生成函数的任务很明确,生成一个菜单对应游玩选项即可,我们使用 printf
来操作即可。
对于此函数来言,我们无需返回任何数据,返回类型为 void
。
void menu()
{
printf("***********************\n");
printf(" ***** 1. play *****\n");
printf(" ***** 0. exit *****\n");
printf("***********************\n");
}
2.3.2 game
游玩逻辑函数
在此函数中我们要完整地实现一遍游玩逻辑。
首先我们要创建出二维数组 show
, mine
,它们均为 11 行 11 列的二维数组。
在创建处数组后我们要对数组的内容进行初始化,此时我们使用 InitBoard
初始化数组函数,对其进行初始化。
初始化函数过后,我们使用PrintBoard
打印数组函数将show
数组打印出,便于玩家对数组下标有选择参照。
之后我们要使用 SetBoard
布置地雷数组函数对 mine
数组进行地雷的布置。
最后我们使用 FindBoard
查找数组函数,进行查找步骤,正式开始游戏。
在此函数中我们仍无需返回任何数据,所以函数的返回类型为 void
。
我们将逻辑梳理清楚后,代码层面的实现就会变得清晰、简单。
void game()
{
char show[ROWS][COLS];
char mine[ROWS][COLS];
InitBoard(show, ROWS, COLS, '*');
InitBoard(mine, ROWS, COLS, '0');
PrintBoard(show, ROW, COL);
SetBoard(mine, ROW, COL);
FindBoard(show, mine, ROW, COL);
}
2.3.3 main
主函数
1 . 如何实现游戏的重复游玩,若要使用循环,使用哪种循环,循环结束的条件是什么
要实现游戏的重复游玩,我们就要使用循环,我们希望每次循环都会出现菜单给玩家进行选择,所以我们要讲 menu
菜单生成函数
融入到循环中,也就意味着这个循环至少执行一次,不存在一次不执行的情况,所以我们使用 do-while
循环来实现。
同事为了获取到玩家看到, menu
菜单生成函数上输入决定游玩还是退出的数字,我们还要定义一个变量,用来接受数据。
当玩家输入 0 时,意味着我们需要结束循环,退出游戏,所以循环进入的条件为定义的变量不为 0 时,0 正好在判断中表示假,
所以在循环进入条件处,我们只需要放入定义的变量名即可。
2 . 在循环中会出现哪些情况,这些情况我们应该进行哪些操作
在循环中,玩家输入的变量会出现三种情况:
-
玩家想要进行游戏,输入了 1 :这时我们调用 game 游玩逻辑函数即可,开始游戏。
-
玩家不想要进行游戏,输入了 0 :这是我们就要跳出循环,经循环判断后结束程序。
-
玩家输入了除了 1 或 0 ,之外没有意义的值,:这是就要提示玩家输入数据有误重新输入。
基于以上多种情况,而且我们判断的依据为常量,常量的不同值意味着我们要进行不同的操作,所以我们可以使用 switch-case
语句
来实现我们的条件判断。
将逻辑梳理清晰后,我就可以实现代码:
int main()
{
int input = 0;
srand((unsigned int)time(NULL));
do
{
menu();
printf("请选择:>");
scanf("%d", &input);
switch (input)
{
case 1:
game();
break;
case 0:
printf("退出游戏\n");
break;
default:
printf("选择错误,重新选择\n");
break;
}
} while (input);
return 0;
}
在主函数的实现中,
srand((unsigned int)time(NULL));
这段代码的存在是为了随机数的生成不连续,规律性更小,是配合我们在SetBoard
布置地雷数组函数中生成随机数函数使用的,这部分读者们可自行查阅资料,在此不做讲述。
到此我们测试函数的代码部分就已经完备实现。
接下来我们只需将对应代码放入对应文件内,整个扫雷的程序代码就完成了。
在这个程序中,我们对数组和函数的实践加强了我们对概念的理解,对编程逻辑的思考。
希望读者们可以再复现一次代码,加强理解,纸上得来终觉浅,绝知此事要躬行。
到底第七讲文章结束,这篇文章算是我第一次这么耗费精力去细细撰写,可能在某个我不注意的地方就出现错误了,望读者们批评指正,同时如对文章有更好的意见与建议,一定要告知作者,读者的反馈对于我十分重要,希望读者们继续勤勉励学,精益求精,
我们下篇文章再见👋。