第6讲:数组和函数实践------写一个控制台扫雷

C语言主理人:老王爱玩车
适合人群 :已经学过
if、switch、while、for、数组和函数基础,想通过一个完整项目把知识串起来。学习方式 :先理解设计,再逐个实现函数,最后组合成可运行程序。
本讲目标:用二维数组存储棋盘,用函数拆分逻辑,完成一个 9×9、10 个雷的控制台扫雷游戏。
目录
- [1. 为什么用数组和函数写扫雷](#1. 为什么用数组和函数写扫雷)
- [2. 扫雷游戏分析与设计](#2. 扫雷游戏分析与设计)
- [2.1 功能说明](#2.1 功能说明)
- [2.2 数据结构设计](#2.2 数据结构设计)
- [2.3 文件结构设计](#2.3 文件结构设计)
- [3. 核心函数逐个实现](#3. 核心函数逐个实现)
- [3.1 初始化棋盘
InitBoards](#3.1 初始化棋盘 InitBoards) - [3.2 打印棋盘
PrintBoard](#3.2 打印棋盘 PrintBoard) - [3.3 布置地雷
PlaceMines](#3.3 布置地雷 PlaceMines) - [3.4 统计周围雷数
CountAdjacentMines](#3.4 统计周围雷数 CountAdjacentMines) - [3.5 排查地雷
PlayGame](#3.5 排查地雷 PlayGame)
- [3.1 初始化棋盘
- [4. 完整代码](#4. 完整代码)
- [4.1
game.h](#4.1 game.h) - [4.2
game.c](#4.2 game.c) - [4.3
main.c](#4.3 main.c) - [4.4 编译运行说明](#4.4 编译运行说明)
- [4.1
- [5. 数组和函数知识点复盘](#5. 数组和函数知识点复盘)
- [6. 扩展练习](#6. 扩展练习)
- [7. 小结](#7. 小结)
学习目标
学完本讲,你应该能够:
- 说清楚二维数组在棋盘类程序中的作用;
- 会使用多文件编程组织一个完整项目;
- 能定义、声明、调用自定义函数;
- 理解形参和实参的值传递;
- 掌握数组做函数参数时为什么要传行列长度;
- 理解
return的用法和函数出口; - 初步认识
static和extern在链接属性上的区别; - 独立完成扫雷基础版,并知道如何继续扩展。
1. 为什么用数组和函数写扫雷
扫雷的棋盘天然适合用二维数组表示。
如果不用函数,所有逻辑都堆在 main 里,代码会又长又乱:初始化、打印、布置雷、统计、判断输赢全混在一起。
函数的作用就是"把一段完成特定任务的代码封装起来,起一个名字,需要时调用"。
在扫雷里,我们可以拆成:
- 初始化棋盘
- 打印棋盘
- 布置雷
- 统计周围雷数
- 排查雷
每个函数只做一件事,主函数只负责"搭流程"。这样代码清晰,也方便调试和扩展。
2. 扫雷游戏分析与设计
2.1 功能说明
- 控制台版经典扫雷;
- 菜单:
1. play开始游戏,0. exit退出; - 棋盘 9×9,默认随机布置 10 个雷;
- 玩家输入坐标排查;
- 不是雷:显示周围雷数;
- 是雷:显示所有雷,游戏结束;
- 把所有非雷位置都排查完,排雷成功。





2. 扫雷游戏分析与设计
2.1 功能说明
我们先明确基础版扫雷要完成哪些功能。这个功能列表就是我们的"需求文档",后续所有代码都是为实现这些需求服务的:
- 使用控制台实现经典扫雷;
- 游戏可以通过菜单选择继续玩或者退出;
- 棋盘是 9×9 的格子;
- 默认随机布置 10 个雷;
- 玩家可以输入坐标排查雷;
- 如果位置不是雷,就显示周围有几个雷;
- 如果位置是雷,就显示所有雷,游戏结束;
- 把除 10 个雷之外的所有非雷都找出来,排雷成功,游戏结束。
菜单可以设计成:
text
******************************
******** 1. 开始游戏 *******
******** 0. 退出游戏 *******
******************************
请选择:>

玩家输入 1 进入游戏,输入 0 退出。这个菜单会循环显示,直到玩家选择退出。用 do-while 循环实现最自然:先显示菜单,读入选择,根据选择执行相应操作,只要用户没有选择 0,就继续循环。


2.2 数据结构设计
扫雷棋盘是一个网格,最自然的存储方式就是二维数组。这里有两个关键问题需要仔细解决,否则代码会写得很别扭,甚至出现越界、信息混淆等问题。
第一个问题:统计周围雷数时,边界格子会越界。
假设棋盘是 9×9,我们排查 (1,1) 这个坐标时,要访问它周围的 8 个格子,包括 (0,0)、(0,1)、(0,2)、(1,0)、(1,2)、(2,0)、(2,1)、(2,2)。但 (0,0)、(0,1)、(0,2)、(1,0)、(2,0) 这些坐标已经超出了 9×9 的范围。如果数组是 board[9][9],访问 board[0][0] 还是合法的,但访问 board[-1][0] 就非法了。而排查 (1,1) 时,如果按"当前行减一到当前行加一、当前列减一到当前列加一"去访问,就会访问到 board[0][0],这虽然不越界,但并不是我们想要的位置。如果排查的是 (0,0),那么 x-1 就是 -1,直接越界,程序可能崩溃或出现不可预期的行为。
常见的解决办法是:把数组扩大一圈 ,变成 11×11。也就是说,实际数组是
board[11][11],但雷只布置在中间的 9×9 区域(行和列都从 1 到 9),周围一圈(第 0 行、第 10 行、第 0 列、第 10 列)不布置雷,专门作为"缓冲带"。这样,任何中间 9×9 的格子都有完整的 8 个邻居,统计时不需要写一堆if判断边界,只需要直接循环x-1到x+1、y-1到y+1即可。这个技巧在图像处理、矩阵运算中也非常常见,叫做"加边"或"padding"。
第二个问题:雷的信息和排查出的数字信息如果放在同一个数组里,会互相干扰。
比如,我们用 1 表示"这里有雷",用 0 表示"这里没有雷"。当玩家排查一个位置后,如果这个位置不是雷,我们需要显示它周围有几个雷,比如"周围有 1 个雷"。那么,这个 1 到底表示"这里有雷"还是"周围有 1 个雷"?如果放在同一个数组里,就会产生歧义。打印棋盘时,我们无法区分一个 1 是雷还是数字。
解决办法是使用两个棋盘:
mine:存放布置好的雷。'0'表示非雷,'1'表示雷。show:存放排查出的信息。'#'表示未排查,数字字符'0'到'8'表示周围雷数,'*'表示踩雷后显示的雷。
使用字符而不是整数,是为了打印时统一用 %c,并且可以用 '0' 到 '8' 表示周围雷数。mine 初始化为 '0',放雷后改成 '1';show 初始化为 '#',排查后改成数字字符或 '*'。这样两个数组各司其职,互不干扰。
相关宏定义如下:
c
#define EASY_MINE_COUNT 10
#define BOARD_ROW 9
#define BOARD_COL 9
#define PAD_ROW (BOARD_ROW + 2) // 11
#define PAD_COL (BOARD_COL + 2) // 11
PAD_ROW 和 PAD_COL 是扩大一圈后的数组尺寸,实际有效区域是 1 到 BOARD_ROW、1 到 BOARD_COL。使用宏定义的好处是,如果以后想改成 16×16 或 30×16,只需要修改 BOARD_ROW 和 BOARD_COL,PAD_ROW 和 PAD_COL 会自动跟着变。
2.3 文件结构设计
实际开发中,代码通常分文件管理。这里我们设计三个文件:
game.h:放宏定义、函数声明、头文件包含;game.c:放游戏函数的实现;main.c:放菜单和游戏测试逻辑。
这样结构清晰,便于多人协作和代码复用。编译时,三个文件分别编译,最后链接在一起。头文件里使用"头文件卫士"防止重复包含:
c
#ifndef GAME_H
#define GAME_H
// ...
#endif
#ifndef GAME_H 的意思是"如果没有定义过 GAME_H 这个宏",#define GAME_H 就定义它,#endif 结束条件编译。这样,如果同一个头文件被多次包含,第二次包含时 GAME_H 已经定义,#ifndef 条件不成立,内容就不会被重复展开,避免重复声明的错误。
game.h中只放声明,不放定义。game.c中包含game.h,然后实现函数。main.c中也包含game.h,这样就能调用game.c中定义的函数。头文件中包含<stdio.h>、<stdlib.h>、<time.h>,因为game.h中的函数声明用到了PAD_ROW、PAD_COL等宏,而game.c和main.c都需要这些头文件。
3. 核心函数逐个实现
3.1 初始化棋盘 InitBoards
设计思路:
游戏开始时,mine 数组应该全部是 '0',表示还没有放雷;show 数组应该全部是 '#',表示所有位置都还没有被排查。因为数组扩大了一圈,所以循环范围是 PAD_ROW 和 PAD_COL,而不是 BOARD_ROW 和 BOARD_COL。这样,包括缓冲带在内的所有位置都被正确初始化,避免出现未初始化的"垃圾值"。
参数说明:
mine:存放雷的二维数组;show:存放显示信息的二维数组。
函数实现:
c
void InitBoards(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL]) {
for (int i = 0; i < PAD_ROW; i++) {
for (int j = 0; j < PAD_COL; j++) {
mine[i][j] = '0';
show[i][j] = '#';
}
}
}

逐行解释:
- 外层循环
i从 0 到PAD_ROW - 1,遍历每一行; - 内层循环
j从 0 到PAD_COL - 1,遍历每一列; mine[i][j] = '0'把当前位置设为非雷;show[i][j] = '#'把当前位置设为未排查。
注意事项:
- 二维数组做函数参数时,列数不能省略。
char mine[PAD_ROW][PAD_COL]中的PAD_COL必须写,因为编译器需要知道一行有多少个元素,才能正确计算地址。如果写成char mine[][PAD_COL]也可以,行数可以省略,但列数不行。如果写成char mine[][]就错了,编译器无法确定每一行的长度。- 形参看起来是数组,实际传递的是数组首元素的地址,函数内部操作的是同一块内存。所以
InitBoards能直接修改main中的数组。这也是为什么数组做参数时,函数内部修改会影响调用者的原因。- 如果函数不需要修改数组,可以在参数前加
const,比如const char mine[PAD_ROW][PAD_COL],表示函数不会修改这个数组。这是一个好习惯,可以提高代码的可读性和安全性。
3.2 打印棋盘 PrintBoard
设计思路:
打印棋盘时,需要打印列标和行标,方便玩家输入坐标。只打印中间的 9×9 区域,周围一圈缓冲带不打印。列标从 1 到 9,行标也从 1 到 9,这样玩家看到的坐标和实际输入的坐标一致。因为 PrintBoard 只在 game.c 内部使用,不想暴露给其他文件,所以加 static 修饰。
参数说明:
board:要打印的二维数组,可以是mine或show;row、col:实际有效区域的行数和列数,这里传BOARD_ROW和BOARD_COL。
函数实现:
c
static void PrintBoard(char board[PAD_ROW][PAD_COL], int row, int col) {
printf(" ");
for (int j = 1; j <= col; j++) {
printf("%d ", j);
}
printf("\n");
for (int i = 1; i <= row; i++) {
printf("%d ", i);
for (int j = 1; j <= col; j++) {
printf("%c ", board[i][j]);
}
printf("\n");
}
printf("\n");
}

逐行解释:
printf(" ");打印三个空格,因为行标占两个字符(一个数字加一个空格),列标要对齐;- 第一个
for循环打印列标1到col,每个数字后面跟一个空格; printf("\n");列标打印完后换行;- 外层
for循环i从 1 到row,遍历每一行; printf("%d ", i);打印行标,数字后面跟两个空格,与列标对齐;- 内层
for循环j从 1 到col,遍历每一列; printf("%c ", board[i][j]);打印当前格子的字符,后面跟一个空格;- 内层循环结束后
printf("\n");换行; - 最后再
printf("\n");打印一个空行,让棋盘和后续输出之间有间隔。
注意事项:
static修饰函数表示"内部链接",该函数只能在本源文件game.c内使用,其他文件无法调用。这可以避免不同文件中的函数重名冲突。比如main.c中也可能有一个叫PrintBoard的函数,但因为game.c中的PrintBoard是static的,两者不会冲突。- 打印时使用
%c,因为board中存放的是字符。如果使用%d,打印出来的是字符的 ASCII 码,不是我们想要的。- 行标和列标从
1开始,符合玩家习惯。实际数组下标从0开始,但有效区域是1到9,所以打印时从1开始循环。- 如果棋盘较大,比如 16×16,列标可能需要调整格式,保证对齐。可以用
%2d打印列标,每个数字占两个字符宽度。
3.3 布置地雷 PlaceMines
设计思路:
随机生成坐标,落在中间 9×9 区域,且不能重复。使用 rand() % row + 1 生成 1 到 row 之间的随机数。每成功放置一个雷,placed 加 1,直到达到指定的雷数。如果随机到的位置已经有雷,就跳过,重新生成,直到放满 mineCount 个雷。
参数说明:
mine:存放雷的二维数组;row、col:实际有效区域的行数和列数;mineCount:要布置的雷数。
函数实现:
c
void PlaceMines(char mine[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
int placed = 0;
while (placed < mineCount) {
int x = rand() % row + 1;
int y = rand() % col + 1;
if (mine[x][y] == '0') {
mine[x][y] = '1';
placed++;
}
}
}

逐行解释:
int placed = 0;记录已经放置的雷数;while (placed < mineCount)只要还没放够,就继续循环;int x = rand() % row + 1;生成1到row的随机行号;int y = rand() % col + 1;生成1到col的随机列号;if (mine[x][y] == '0')检查该位置是否还没有雷;mine[x][y] = '1';放置一个雷;placed++;已放置雷数加 1。
注意事项:
- 使用
rand()之前必须在main中调用srand((unsigned int)time(NULL))初始化随机数种子,否则每次运行生成的雷位置都一样。time(NULL)返回当前时间,srand用这个时间作为种子,保证每次运行种子不同。rand() % row生成0到row - 1,加1后变成1到row。比如row = 9,rand() % 9生成0到8,加1后变成1到9。- 如果随机到的位置已经有雷,就跳过,重新生成,直到放满
mineCount个雷。这种"拒绝采样"的方法在雷数远小于总格子数时效率很高。如果雷数接近总格子数,效率会下降,但基础版 9×9 放 10 个雷,效率完全没问题。- 如果
mineCount大于等于row * col,这个循环会变成死循环。实际使用中不会出现这种情况,但严谨的代码可以加一个判断,如果mineCount >= row * col就报错返回。
3.4 统计周围雷数 CountAdjacentMines
设计思路:
遍历当前坐标周围的 8 个方向,统计 '1' 的个数。因为数组扩大了一圈,所以不用单独判断边界,直接循环 x-1 到 x+1、y-1 到 y+1 即可。该函数只在 game.c 内部使用,所以加 static。
参数说明:
mine:存放雷的二维数组;x、y:当前排查的坐标。
返回值:
周围雷的个数,整数。
函数实现:
c
static int CountAdjacentMines(char mine[PAD_ROW][PAD_COL], int x, int y) {
int count = 0;
for (int i = x - 1; i <= x + 1; i++) {
for (int j = y - 1; j <= y + 1; j++) {
if (mine[i][j] == '1') {
count++;
}
}
}
return count;
}

逐行解释:
int count = 0;记录周围雷数;- 外层循环
i从x - 1到x + 1,遍历当前行和上下两行; - 内层循环
j从y - 1到y + 1,遍历当前列和左右两列; if (mine[i][j] == '1')如果该位置是雷;count++;雷数加 1;return count;返回统计结果。
注意事项:
- 循环会包括
(x, y)自己,但mine[x][y]在调用前已经确认不是雷,所以不会误统计。如果担心,可以在循环中加一个if (i == x && j == y) continue;,但这里不需要,因为调用前已经判断过mine[x][y] != '1'。- 因为数组扩大了一圈,
x-1、y-1最小是0,x+1、y+1最大是PAD_ROW-1、PAD_COL-1,不会越界。比如x = 1,x - 1 = 0,合法;x = 9,x + 1 = 10,PAD_ROW - 1 = 10,合法。- 返回的是整数,之后在
PlayGame中通过count + '0'转成字符存入show。比如count = 3,'0'的 ASCII 码是 48,3 + 48 = 51,对应字符'3'。
3.5 排查地雷 PlayGame
设计思路:
这是游戏的主循环。玩家不断输入坐标,程序判断坐标是否合法、是否重复、是否踩雷。如果踩雷,显示所有雷并结束;如果不是雷,显示周围雷数,并增加已打开格子的计数。当已打开的非雷格子数等于总格子数减去雷数时,排雷成功。
参数说明:
mine:存放雷的二维数组;show:存放显示信息的二维数组;row、col:实际有效区域的行数和列数;mineCount:雷的数量。
函数实现:
c
void PlayGame(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
int x = 0;
int y = 0;
int opened = 0;
int totalSafe = row * col - mineCount;
while (opened < totalSafe) {
PrintBoard(show, row, col);
printf("请输入要排查的坐标(行 列):");
if (scanf("%d %d", &x, &y) != 2) {
while (getchar() != '\n') { }
printf("输入格式错误,请重新输入。\n");
continue;
}
if (x < 1 || x > row || y < 1 || y > col) {
printf("坐标越界,请重新输入。\n");
continue;
}
if (show[x][y] != '#') {
printf("该位置已经排查过了,请换一个位置。\n");
continue;
}
if (mine[x][y] == '1') {
printf("很遗憾,你踩到雷了!\n");
for (int i = 1; i <= row; i++) {
for (int j = 1; j <= col; j++) {
if (mine[i][j] == '1') {
show[i][j] = '*';
}
}
}
PrintBoard(show, row, col);
return;
} else {
int count = CountAdjacentMines(mine, x, y);
show[x][y] = count + '0';
opened++;
}
}
printf("恭喜你,排雷成功!\n");
PrintBoard(mine, row, col);
}
逐行详细解释:
int x = 0; int y = 0;存储玩家输入的坐标;int opened = 0;记录已经打开的非雷格子数;int totalSafe = row * col - mineCount;总安全格子数,等于总格子数减去雷数。9×9 共 81 格,10 个雷,安全格子 71 个。while (opened < totalSafe)只要还没有打开所有安全格子,就继续循环;PrintBoard(show, row, col);打印当前棋盘,让玩家看到已排查的信息;printf("请输入要排查的坐标(行 列):");提示玩家输入;if (scanf("%d %d", &x, &y) != 2)检查scanf是否成功读入两个整数。如果用户输入的不是两个整数,比如输入了字母,scanf会返回小于 2 的值;while (getchar() != '\n') { }清空输入缓冲区,把错误输入读掉,避免影响下一次输入;printf("输入格式错误,请重新输入。\n");提示错误;continue;跳过本次循环剩余部分,重新开始下一次循环;if (x < 1 || x > row || y < 1 || y > col)检查坐标是否在1到row、1到col之间;printf("坐标越界,请重新输入。\n");提示越界;continue;重新开始下一次循环;if (show[x][y] != '#')检查该位置是否已经排查过;printf("该位置已经排查过了,请换一个位置。\n");提示重复;continue;重新开始下一次循环;if (mine[x][y] == '1')检查是否踩雷;printf("很遗憾,你踩到雷了!\n");提示踩雷;- 双层
for循环遍历所有格子,把mine中为'1'的位置在show中改成'*',显示所有雷; PrintBoard(show, row, col);打印显示所有雷的棋盘;return;函数返回,游戏结束;else分支处理不是雷的情况;int count = CountAdjacentMines(mine, x, y);统计周围雷数;show[x][y] = count + '0';把数字转成字符存入show;opened++;已打开安全格子数加 1;- 循环结束后,说明所有安全格子都打开了,打印
mine显示所有雷的位置,宣布胜利。
重点知识:
count + '0'是把整数转换成对应字符的常用技巧。例如count = 3,'0'的 ASCII 码是 48,3 + 48 = 51,对应字符'3'。如果count = 0,0 + 48 = 48,对应字符'0'。return在void函数中用于提前结束函数。踩雷后直接return,不再继续循环。- 函数可以嵌套调用:
PlayGame调用了PrintBoard和CountAdjacentMines。scanf的返回值是成功读入的变量个数。scanf("%d %d", &x, &y)如果成功读入两个整数,返回 2;如果只读入一个,返回 1;如果一个都没读入,返回 0;如果遇到输入结束,返回 EOF。getchar()从输入缓冲区读一个字符。while (getchar() != '\n') { }不断读取,直到读到换行符,把缓冲区清空。
4. 完整代码
4.1 game.h
c
#ifndef GAME_H
#define GAME_H
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define EASY_MINE_COUNT 10
#define BOARD_ROW 9
#define BOARD_COL 9
#define PAD_ROW (BOARD_ROW + 2)
#define PAD_COL (BOARD_COL + 2)
void InitBoards(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL]);
void PlaceMines(char mine[PAD_ROW][PAD_COL], int row, int col, int mineCount);
void PlayGame(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL], int row, int col, int mineCount);
#endif
4.2 game.c
c
#include "game.h"
static void PrintBoard(char board[PAD_ROW][PAD_COL], int row, int col) {
printf(" ");
for (int j = 1; j <= col; j++) {
printf("%d ", j);
}
printf("\n");
for (int i = 1; i <= row; i++) {
printf("%d ", i);
for (int j = 1; j <= col; j++) {
printf("%c ", board[i][j]);
}
printf("\n");
}
printf("\n");
}
static int CountAdjacentMines(char mine[PAD_ROW][PAD_COL], int x, int y) {
int count = 0;
for (int i = x - 1; i <= x + 1; i++) {
for (int j = y - 1; j <= y + 1; j++) {
if (mine[i][j] == '1') {
count++;
}
}
}
return count;
}
void InitBoards(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL]) {
for (int i = 0; i < PAD_ROW; i++) {
for (int j = 0; j < PAD_COL; j++) {
mine[i][j] = '0';
show[i][j] = '#';
}
}
}
void PlaceMines(char mine[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
int placed = 0;
while (placed < mineCount) {
int x = rand() % row + 1;
int y = rand() % col + 1;
if (mine[x][y] == '0') {
mine[x][y] = '1';
placed++;
}
}
}
void PlayGame(char mine[PAD_ROW][PAD_COL], char show[PAD_ROW][PAD_COL], int row, int col, int mineCount) {
int x = 0;
int y = 0;
int opened = 0;
int totalSafe = row * col - mineCount;
while (opened < totalSafe) {
PrintBoard(show, row, col);
printf("请输入要排查的坐标(行 列):");
if (scanf("%d %d", &x, &y) != 2) {
while (getchar() != '\n') { }
printf("输入格式错误,请重新输入。\n");
continue;
}
if (x < 1 || x > row || y < 1 || y > col) {
printf("坐标越界,请重新输入。\n");
continue;
}
if (show[x][y] != '#') {
printf("该位置已经排查过了,请换一个位置。\n");
continue;
}
if (mine[x][y] == '1') {
printf("很遗憾,你踩到雷了!\n");
for (int i = 1; i <= row; i++) {
for (int j = 1; j <= col; j++) {
if (mine[i][j] == '1') {
show[i][j] = '*';
}
}
}
PrintBoard(show, row, col);
return;
} else {
int count = CountAdjacentMines(mine, x, y);
show[x][y] = count + '0';
opened++;
}
}
printf("恭喜你,排雷成功!\n");
PrintBoard(mine, row, col);
}
4.3 main.c
c
#include "game.h"
static void Menu(void) {
printf("******************************\n");
printf("******** 1. 开始游戏 *******\n");
printf("******** 0. 退出游戏 *******\n");
printf("******************************\n");
}
static void Game(void) {
char mine[PAD_ROW][PAD_COL];
char show[PAD_ROW][PAD_COL];
InitBoards(mine, show);
PlaceMines(mine, BOARD_ROW, BOARD_COL, EASY_MINE_COUNT);
PlayGame(mine, show, BOARD_ROW, BOARD_COL, EASY_MINE_COUNT);
}
int main(void) {
srand((unsigned int)time(NULL));
int input = 0;
do {
Menu();
printf("请选择:>");
if (scanf("%d", &input) != 1) {
while (getchar() != '\n') { }
printf("输入错误,请重新选择。\n");
input = -1;
continue;
}
switch (input) {
case 1:
Game();
break;
case 0:
printf("退出游戏。\n");
break;
default:
printf("选择错误,请重新选择。\n");
break;
}
} while (input != 0);
return 0;
}
4.4 编译运行说明
Visual Studio:
创建三个文件 game.h、game.c、main.c,把代码分别粘贴进去,直接运行。VS 会自动编译链接三个文件。
GCC:
bash
gcc main.c game.c -o minesweeper
./minesweeper
Clang:
bash
clang main.c game.c -o minesweeper
./minesweeper
5. 数组和函数知识点复盘
结合本讲代码,回顾几个关键点:
-
二维数组做函数参数
char mine[PAD_ROW][PAD_COL]作为形参时,列数不能省略。因为编译器需要知道一行有多少个元素,才能正确计算地址。行数可以省略,但列数必须确定。如果写成char mine[][PAD_COL]也可以,但char mine[][]是错的。 -
形参和实参的值传递
函数里的数组形参看起来是数组,实际传递的是首行地址,函数内部操作的是同一块内存。所以
InitBoards、PlaceMines能直接修改main中的数组。但普通变量如int x是值传递,形参是实参的临时拷贝,改变形参不影响实参。比如Swap(int x, int y)不能交换main中的变量,因为x、y只是拷贝。 -
return的作用CountAdjacentMines返回int,PlayGame在踩雷时用return直接结束函数。每条分支都要有返回值,否则结果不确定。return后面可以是一个值,也可以是一个表达式;return;适合返回类型为void的函数。 -
嵌套调用与链式访问
PlayGame调用PrintBoard和CountAdjacentMines,Game又调用InitBoards、PlaceMines、PlayGame。函数可以嵌套调用,但不能嵌套定义。show[x][y] = count + '0'将函数返回值直接参与表达式,也是一种链式访问。 -
声明和定义分离
game.h放函数声明,game.c放函数定义,main.c只包含头文件。这样编译器在调用前已经"见过"函数原型。函数声明只要交代函数名、返回类型、参数类型,参数名可以省略。 -
static和extern初识PrintBoard、CountAdjacentMines、Menu、Game都加了static,表示它们只在本源文件内可见,不会和其他文件冲突。全局变量和函数默认具有外部链接属性,其他源文件可以通过extern声明后使用;加上static后就变成内部链接属性,只能在本源文件内使用。extern用于声明外部符号,比如一个全局变量在 A 文件定义,在 B 文件想用,就可以在 B 文件写extern int g_val;。 -
随机数
srand((unsigned int)time(NULL))初始化随机数种子,rand() % row + 1生成1到row的随机数。如果不调用srand,每次运行生成的雷位置都一样。time(NULL)返回当前时间,作为种子保证每次运行种子不同。 -
输入校验
scanf的返回值表示成功读入的变量个数。如果用户输入的不是两个整数,scanf会返回小于 2 的值。此时需要清空输入缓冲区,提示重新输入。while (getchar() != '\n') { }是清空缓冲区的常用方法。 -
ASCII 码转换
count + '0'把整数转换成对应字符。'0'的 ASCII 码是 48,count = 3时3 + 48 = 51,对应字符'3'。反过来,ch - '0'把字符转换成整数。 -
数组扩大一圈的技巧
在图像处理、矩阵运算、棋盘类程序中,经常把数组扩大一圈,避免边界判断。这是"空间换时间"的典型例子:多用一点内存,换来代码的简洁和效率。
6. 扩展练习
基础版完成后,可以继续挑战:
-
难度选择
简单 9×9 10 雷;中等 16×16 40 雷;困难 30×16 99 雷。
把行列和雷数做成参数,菜单里让用户选。需要把
BOARD_ROW、BOARD_COL、EASY_MINE_COUNT改成变量,数组大小用最大尺寸定义。 -
递归展开
如果排查位置不是雷,且周围雷数为 0,就自动展开周围一片。
可以用递归或队列实现。注意递归时要判断边界和是否已经打开。这个功能是经典扫雷的核心体验之一。
-
标记雷
增加"标记"功能,比如输入
flag 3 4把某位置标记为!,再次排查时不能打开。标记可以帮助玩家记住疑似雷的位置。 -
计时功能
记录开始和结束时间,显示排雷用时。可以使用
time.h中的time()或clock()。clock()返回程序运行的时钟周期数,除以CLOCKS_PER_SEC得到秒数。 -
排行榜
把最快时间保存到文件,下次启动时读取。可以练习文件操作,比如
fopen、fprintf、fscanf、fclose。 -
优化打印
使用更漂亮的符号,比如
□表示未打开,■表示雷,①到⑧表示数字。需要注意控制台编码,Windows 下可能需要设置chcp 65001切换到 UTF-8。 -
首次点击保护
经典扫雷中,第一次点击永远不会踩雷。可以在玩家第一次输入坐标后,再布置雷,或者如果第一次点击位置有雷,就重新布置。
7. 小结
这一讲我们把数组和函数结合起来,完成了一个完整的控制台扫雷游戏:
- 用两个二维数组分别存放雷和显示信息;
- 用扩大一圈的数组避免边界越界;
- 用多个函数拆分初始化、打印、布置雷、统计、排查;
- 用多文件编程组织代码;
- 用
static限制内部函数作用域; - 所有代码均可直接编译运行。
函数是 C 语言模块化编程的起点。建议把上面的代码逐个敲一遍,再尝试修改参数、增加功能,真正把数组和函数用起来。
留个关注支持一下吧
