🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:用一个完整的控制台扫雷项目,把二维数组、函数拆分、随机数、嵌套循环、边界控制和多文件工程组织串在一起。
💡 学习目标 :理解为什么要使用
mine与show两个棋盘;掌握 11×11 哨兵边界的设计;会实现初始化、显示、布雷、邻雷统计与排查函数;能看懂game.h + game.c + test.c的职责分工。📌 精修说明:本文沿用课程 PDF 的核心方案,同时修正基础代码中"重复排查同一坐标也会累计胜利次数"的问题,并补充输入校验、函数返回值和测试方法。
文章目录
- 一、为什么用扫雷练习数组和函数
- 二、先定义游戏规则,再开始写代码
- [三、数据模型:为什么需要 mine 和 show 两个棋盘](#三、数据模型:为什么需要 mine 和 show 两个棋盘)
- [四、边界设计:为什么 9×9 棋盘要用 11×11 数组](#四、边界设计:为什么 9×9 棋盘要用 11×11 数组)
- [五、工程结构:game.h、game.c 与 test.c 各做什么](#五、工程结构:game.h、game.c 与 test.c 各做什么)
- 六、game.h:集中管理常量与函数声明
- 七、初始化棋盘:一个函数服务两种数组
- 八、显示棋盘:打印坐标,但隐藏哨兵圈
- 九、随机布雷:重复坐标不能重复计数
- 十、统计邻雷:扫描目标格周围的八个位置
- 十一、排查主循环:输入、判断、显示与胜负
- 十二、test.c:组织菜单与一局游戏
- 十三、完整编译、运行与测试
- 十四、基础版的边界与进阶方向
- 十五、常见误区、高频问题与练习
- 总结
一、为什么用扫雷练习数组和函数
前面分别学习数组和函数时,代码通常只完成一个很小的任务:遍历数组、求和、交换变量,或者调用一个计算函数。扫雷不同,它要求多个知识点同时配合:
- 棋盘状态需要用二维数组保存;
- 雷的位置需要随机生成;
- 每次输入坐标都要检查边界;
- 打开安全格后,要统计周围八格的雷数;
- 玩家会连续操作,所以需要循环;
- 初始化、显示、布雷、统计和排查应该拆成不同函数;
- 项目稍大后,还要用头文件组织声明。
也就是说,扫雷不是在考某一条语法,而是在练习怎样把数据与规则组合成一个完整程序。

本文先完成一个基础版本:玩家输入行列坐标,程序显示该位置周围有几颗雷;踩中雷则失败,打开全部安全格则成功。课程中提到的自动展开、旗帜、计时与难度选择,会放在扩展部分讨论。
二、先定义游戏规则,再开始写代码
基础版规则如下:
| 项目 | 规则 |
|---|---|
| 可见棋盘 | 9 行 × 9 列 |
| 雷的数量 | 10 个 |
| 坐标范围 | 行、列都使用 1~9 |
| 未排查位置 | 显示 * |
| 安全位置 | 显示周围八格的雷数 0~8 |
| 踩中雷 | 显示完整雷盘,本局失败 |
| 胜利条件 | 81 - 10 = 71 个安全格全部打开 |
把规则写清楚很重要,因为代码中的每个常量和判断都来自这些规则:
c
#define EASY_COUNT 10
#define ROW 9
#define COL 9
胜利条件也不是"操作了 81 次",而是第一次成功打开的安全格数量达到:
c
ROW * COL - EASY_COUNT
这里的"第一次"非常关键。如果同一个安全坐标被反复输入,不能反复累加,否则玩家可能只打开少量格子就被错误判定为胜利。
三、数据模型:为什么需要 mine 和 show 两个棋盘
扫雷同时存在两套信息:
- 程序知道哪里有雷,这是隐藏答案。
- 玩家知道哪些位置已经打开,这是当前界面。
如果把两种信息塞进同一个数组,0、1、未打开标记和邻雷数字会混在一起。打印时容易泄露答案,判断时也很难分清一个 1 到底表示"这里有雷"还是"周围有一颗雷"。
因此使用两个同样大小的二维字符数组:
c
char mine[ROWS][COLS];
char show[ROWS][COLS];
mine:后台棋盘,安全格保存字符'0',雷保存字符'1';show:前台棋盘,未打开格保存字符'*',打开后保存字符'0'到'8'。

这样设计有三个直接好处:
mine从不作为普通游戏界面打印,答案不会提前暴露;show只描述玩家进度,不会破坏真实布雷信息;- 两个数组类型相同,初始化函数和显示函数可以重复使用。
这里使用字符而不是整数,是因为控制台最终打印的就是单个字符。一个格子的邻雷数最大为 8,所以可以直接转换为:
c
show[x][y] = (char)('0' + count);
当 count 为 3 时,'0' + 3 正好得到字符 '3'。这是字符编码连续性的常见应用。
四、边界设计:为什么 9×9 棋盘要用 11×11 数组
假设玩家排查 (5, 5),统计邻雷时需要访问:
text
(4,4) (4,5) (4,6)
(5,4) (5,5) (5,6)
(6,4) (6,5) (6,6)
中心格之外正好有八个邻居。位于棋盘内部时,这些坐标都合法;但若玩家排查第 9 行,访问 x + 1 就会来到第 10 行。若数组只有 9×9,代码必须为上、下、左、右以及四个角分别补边界判断。
更简洁的方案是给真实棋盘加一圈不会布雷的哨兵:
c
#define ROW 9
#define COL 9
#define ROWS (ROW + 2)
#define COLS (COL + 2)
真实棋盘使用 1~9,外围的第 0 行、第 10 行、第 0 列和第 10 列只负责接住邻域访问。

于是任何合法位置 (x, y) 都满足:
text
x - 1 >= 0
x + 1 <= 10
y - 1 >= 0
y + 1 <= 10
统计函数可以统一扫描 x-1~x+1、y-1~y+1,不需要在循环里为边角位置写特殊分支。
这种方法叫作哨兵设计 :多使用一小块空间,换取更简单、更统一的算法。要注意,哨兵圈只是实现细节,显示棋盘和生成随机坐标时仍然只处理 1~9。
五、工程结构:game.h、game.c 与 test.c 各做什么
一个源文件也能写完扫雷,但把所有宏、声明、函数实现和菜单都放在一起后,查找与修改会越来越困难。本文按照课程方案拆成三个文件:
| 文件 | 职责 |
|---|---|
game.h |
保存公共宏、所需头文件和函数声明 |
game.c |
实现初始化、显示、布雷、统计和排查逻辑 |
test.c |
提供菜单、创建一局游戏并定义 main |

两个 .c 文件都包含同一个头文件:
c
#include "game.h"
这样,调用者在编译时可以看到正确的函数声明;实现文件也会受到同一份声明约束。若函数参数发生变化,只需先修改 game.h,编译器就能帮助发现所有不匹配的调用。
多文件的目的不是机械地把代码切成几块,而是建立稳定的责任边界。
六、game.h:集中管理常量与函数声明
完整的 game.h 如下:
c
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define EASY_COUNT 10
#define ROW 9
#define COL 9
#define ROWS (ROW + 2)
#define COLS (COL + 2)
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set);
void DisplayBoard(const char board[ROWS][COLS], int row, int col);
void SetMine(char mine[ROWS][COLS], int row, int col);
int GetMineCount(const char mine[ROWS][COLS], int x, int y);
int FindMine(const char mine[ROWS][COLS],
char show[ROWS][COLS],
int row,
int col);
6.1 为什么宏要加括号
c
#define ROWS (ROW + 2)
宏只是文本替换。表达式型宏加括号可以减少它参与更复杂表达式时的优先级问题。这里即使暂时不会出错,也值得从一开始形成习惯。
6.2 为什么读取函数的数组参数仍要写第二维
函数参数中的二维数组:
c
char board[ROWS][COLS]
会调整为"指向一行的指针"。编译器要计算 board[i][j],必须知道每一行有多少列,所以第二维 COLS 不能省略。第一维是否写成 ROWS 不影响参数本质,但保留它能让接口意图更直观。
6.3 const 表示什么
DisplayBoard 和 GetMineCount 只读取棋盘,不应该修改它,因此参数写成:
c
const char board[ROWS][COLS]
const 既向读者说明函数职责,也让编译器阻止意外写入。
七、初始化棋盘:一个函数服务两种数组
mine 要填满字符 '0',show 要填满字符 '*'。两件事只有填充值不同,所以把差异作为参数传入:
c
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set)
{
for (int i = 0; i < rows; ++i)
{
for (int j = 0; j < cols; ++j)
{
board[i][j] = set;
}
}
}
调用时初始化整个 11×11 数组,包括哨兵圈:
c
InitBoard(mine, ROWS, COLS, '0');
InitBoard(show, ROWS, COLS, '*');
这里不能写成:
c
char mine[ROWS][COLS] = {'0'};
这只会把第一个元素设为字符 '0',剩余元素按规则补成数值 0,而不是字符 '0'。数值 0 是空字符 \0,与可打印字符 '0' 完全不同。
双层循环的含义很直接:外层产生行下标,内层产生列下标,组合起来覆盖数组中的每一个元素。
八、显示棋盘:打印坐标,但隐藏哨兵圈
显示函数只打印真实的 9×9 区域:
c
void DisplayBoard(const char board[ROWS][COLS], int row, int col)
{
printf("-------- 扫雷游戏 --------\n");
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");
}
}
注意这里的循环边界与初始化不同:
- 初始化处理整个数组,所以范围是
0 <= i < ROWS; - 显示只处理真实棋盘,所以范围是
1 <= i <= ROW。
两种循环都正确,因为它们操作的逻辑区域不同。阅读数组代码时,不能只看 < 或 <=,还要先确认下标从哪里开始、结束值代表什么。
顶部和左侧的坐标编号让玩家可以直接输入 x y。若不打印坐标,玩家看到的只是星号矩阵,很难可靠地描述目标位置。
九、随机布雷:重复坐标不能重复计数
布置十颗雷的核心代码如下:
c
void SetMine(char mine[ROWS][COLS], int row, int col)
{
int count = EASY_COUNT;
while (count > 0)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (mine[x][y] == '0')
{
mine[x][y] = '1';
--count;
}
}
}
rand() % row 的范围是 0~row-1,再加一后得到 1~row,正好避开第 0 行哨兵。列坐标同理。

随机函数可能连续生成相同坐标,所以不能简单循环十次然后每次都写 '1'。如果同一个位置被写了两次,棋盘上的实际雷数就会少于十个。
这段代码只有在当前位置仍为 '0' 时才:
- 把它改成
'1'; - 将剩余雷数
count减一。
因此循环结束时,棋盘中一定成功放入 EASY_COUNT 颗不同的雷。
随机数种子在 main 中设置一次:
c
srand((unsigned int)time(NULL));
不要在 SetMine 的循环中反复调用 srand。播种会重置伪随机序列,短时间内重复使用相同时间值,反而容易得到重复结果。
十、统计邻雷:扫描目标格周围的八个位置
课程代码把八个位置逐项相加,这种写法能够工作,但较长,也容易漏掉某一项。既然我们已经学习二维数组和函数,可以用嵌套循环更直接地表达"扫描 3×3 邻域":
c
int GetMineCount(const char mine[ROWS][COLS], 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 (i == x && j == y)
{
continue;
}
if (mine[i][j] == '1')
{
++count;
}
}
}
return count;
}

内层判断跳过中心格,只检查其余八个位置。理论上,FindMine 只会在中心格不是雷时调用这个函数,所以中心值一定是 '0';仍然显式跳过它,可以让函数意图更清晰,也减少以后修改调用顺序时的隐患。
哨兵圈在这里真正发挥作用。对真实棋盘中的任何坐标,x-1、x+1、y-1、y+1 都仍在 11×11 数组内部。邻雷统计不需要知道目标格是在角落、边缘还是中央。
10.1 为什么不再使用减去 8 * '0'
八项相加版本利用字符数字连续编码:把八个字符的编码值相加,再减去八个 '0'。这种技巧很紧凑,但可读性弱。循环版本直接比较 mine[i][j] == '1',读者一眼就能看出"统计雷",也更容易扩展到不同棋盘表示。
十一、排查主循环:输入、判断、显示与胜负
排查函数是整局游戏的核心。先准备一个内部辅助函数,清理当前输入行的剩余字符:
c
static void ClearInputLine(void)
{
int ch = 0;
while ((ch = getchar()) != '\n' && ch != EOF)
{
;
}
}
完整的 FindMine 如下:
c
int FindMine(const char mine[ROWS][COLS],
char show[ROWS][COLS],
int row,
int col)
{
int opened = 0;
int safe_cells = row * col - EASY_COUNT;
while (opened < safe_cells)
{
int x = 0;
int y = 0;
printf("请输入要排查的坐标(行 列):>");
int result = scanf("%d %d", &x, &y);
if (result == EOF)
{
printf("输入结束,本局提前退出。\n");
return -1;
}
if (result != 2)
{
printf("输入无效,请输入两个整数。\n");
ClearInputLine();
continue;
}
ClearInputLine();
if (x < 1 || x > row || y < 1 || y > col)
{
printf("坐标越界,请输入 1~%d 和 1~%d。\n", row, col);
continue;
}
if (show[x][y] != '*')
{
printf("这个位置已经排查过,请选择其他坐标。\n");
continue;
}
if (mine[x][y] == '1')
{
printf("很遗憾,你踩中雷了。\n");
DisplayBoard(mine, row, col);
return 0;
}
int count = GetMineCount(mine, x, y);
show[x][y] = (char)('0' + count);
++opened;
DisplayBoard(show, row, col);
}
printf("恭喜你,排雷成功!\n");
DisplayBoard(mine, row, col);
return 1;
}

11.1 输入检查必须发生在数组访问之前
只有 scanf 成功读到两个整数,并且两个坐标都在合法范围内,才能访问:
c
mine[x][y]
show[x][y]
否则,错误输入可能让旧值继续参与判断;越界坐标则会造成未定义行为。
11.2 为什么要检查重复排查
课程基础代码每打开一个非雷位置就执行 win++,但没有先判断该位置是否已经打开。玩家若反复输入同一个安全坐标,计数会不断增加,最终提前满足胜利条件。
本文先检查:
c
if (show[x][y] != '*')
只有仍为 '*' 的格子才是第一次打开,opened 才能加一。这不是界面优化,而是胜利条件正确性的必要保证。
11.3 为什么 FindMine 返回 int
返回值把一局的结果交给调用者:
| 返回值 | 含义 |
|---|---|
1 |
打开全部安全格,胜利 |
0 |
踩中雷,失败 |
-1 |
输入流结束,本局提前退出 |
基础程序暂时不用返回值做积分统计,但接口已经能支持后续扩展。
11.4 一局的状态路径

只要没有踩雷,也没有打开全部安全格,内层循环就继续读取坐标。FindMine 返回后,本局结束,控制权回到外层菜单。
十二、test.c:组织菜单与一局游戏
12.1 一局游戏的生命周期
game 负责创建本局数据并按顺序调用各功能:
c
static void game(void)
{
char mine[ROWS][COLS];
char show[ROWS][COLS];
InitBoard(mine, ROWS, COLS, '0');
InitBoard(show, ROWS, COLS, '*');
SetMine(mine, ROW, COL);
DisplayBoard(show, ROW, COL);
(void)FindMine(mine, show, ROW, COL);
}
调用顺序不能随意交换:
- 创建两个数组;
- 建立可靠初值;
- 在
mine中布雷; - 向玩家显示隐藏状态的
show; - 进入排查循环。
若先布雷再初始化,刚放好的雷会被 '0' 覆盖;若打印 mine 而不是 show,答案会直接暴露。
12.2 菜单与 main
完整 test.c 如下:
c
#include "game.h"
static void ClearInputLine(void)
{
int ch = 0;
while ((ch = getchar()) != '\n' && ch != EOF)
{
;
}
}
static void menu(void)
{
printf("************************\n");
printf("***** 1. play *****\n");
printf("***** 0. exit *****\n");
printf("************************\n");
}
static void game(void)
{
char mine[ROWS][COLS];
char show[ROWS][COLS];
InitBoard(mine, ROWS, COLS, '0');
InitBoard(show, ROWS, COLS, '*');
SetMine(mine, ROW, COL);
DisplayBoard(show, ROW, COL);
(void)FindMine(mine, show, ROW, COL);
}
int main(void)
{
int input = -1;
srand((unsigned int)time(NULL));
do
{
menu();
printf("请选择:>");
int result = scanf("%d", &input);
if (result == EOF)
{
printf("输入结束,退出游戏。\n");
break;
}
if (result != 1)
{
printf("请输入整数选项。\n");
ClearInputLine();
input = -1;
continue;
}
ClearInputLine();
switch (input)
{
case 1:
game();
break;
case 0:
printf("退出游戏。\n");
break;
default:
printf("选择错误,请重新选择。\n");
break;
}
} while (input != 0);
return 0;
}
srand 放在 main 的初始化阶段,只调用一次;每次进入 game,SetMine 再从伪随机序列中取得新的坐标。
外层 do...while 管理多局游戏,内层 FindMine 循环管理当前一局。两层循环拥有不同生命周期:本局结束不等于整个程序退出。
十三、完整编译、运行与测试
13.1 game.c 完整实现
把前面的函数合并后,game.c 内容如下:
c
#include "game.h"
static void ClearInputLine(void)
{
int ch = 0;
while ((ch = getchar()) != '\n' && ch != EOF)
{
;
}
}
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set)
{
for (int i = 0; i < rows; ++i)
{
for (int j = 0; j < cols; ++j)
{
board[i][j] = set;
}
}
}
void DisplayBoard(const char board[ROWS][COLS], int row, int col)
{
printf("-------- 扫雷游戏 --------\n");
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");
}
}
void SetMine(char mine[ROWS][COLS], int row, int col)
{
int count = EASY_COUNT;
while (count > 0)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (mine[x][y] == '0')
{
mine[x][y] = '1';
--count;
}
}
}
int GetMineCount(const char mine[ROWS][COLS], 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 (i == x && j == y)
{
continue;
}
if (mine[i][j] == '1')
{
++count;
}
}
}
return count;
}
int FindMine(const char mine[ROWS][COLS],
char show[ROWS][COLS],
int row,
int col)
{
int opened = 0;
int safe_cells = row * col - EASY_COUNT;
while (opened < safe_cells)
{
int x = 0;
int y = 0;
printf("请输入要排查的坐标(行 列):>");
int result = scanf("%d %d", &x, &y);
if (result == EOF)
{
printf("输入结束,本局提前退出。\n");
return -1;
}
if (result != 2)
{
printf("输入无效,请输入两个整数。\n");
ClearInputLine();
continue;
}
ClearInputLine();
if (x < 1 || x > row || y < 1 || y > col)
{
printf("坐标越界,请输入 1~%d 和 1~%d。\n", row, col);
continue;
}
if (show[x][y] != '*')
{
printf("这个位置已经排查过,请选择其他坐标。\n");
continue;
}
if (mine[x][y] == '1')
{
printf("很遗憾,你踩中雷了。\n");
DisplayBoard(mine, row, col);
return 0;
}
int count = GetMineCount(mine, x, y);
show[x][y] = (char)('0' + count);
++opened;
DisplayBoard(show, row, col);
}
printf("恭喜你,排雷成功!\n");
DisplayBoard(mine, row, col);
return 1;
}
13.2 编译命令
GCC 或 Clang 可以使用:
bash
gcc test.c game.c -std=c11 -Wall -Wextra -Wpedantic -O2 -o minesweeper
Windows 的 MSVC 开发者命令提示符可以使用:
text
cl /std:c11 /W4 test.c game.c
头文件不用单独交给编译器。预处理阶段会通过 #include "game.h" 把它包含进对应源文件。
13.3 怎样测试随机程序
随机程序也必须可测试。调试阶段可以临时把:
c
srand((unsigned int)time(NULL));
改为固定种子:
c
srand(12345U);
在同一运行库实现中,固定种子会产生可重复的序列,便于复现错误。也可以临时在 SetMine 后打印 mine,记录雷的位置,再恢复为只打印 show。
至少覆盖这些路径:
- 输入
0能退出菜单; - 输入未定义菜单项能重新选择;
- 输入字母不会让程序无限失败;
- 输入
0 1、10 2等越界坐标会被拒绝; - 输入同一安全坐标两次,第二次不会增加
opened; - 踩中雷会显示完整棋盘并结束本局;
- 打开全部安全格会进入胜利分支。
十四、基础版的边界与进阶方向
当前版本故意保持简单,因此它还不是完整商业扫雷:
- 邻雷数为 0 时不会自动展开周围区域;
- 不能插旗或取消标记;
- 棋盘大小和雷数写在宏中;
- 第一次点击仍可能踩雷;
- 没有计时、最佳成绩和重新开局统计;
- 控制台每次直接重印整个棋盘。

14.1 难度选择
经典难度可以抽象为三项数据:行数、列数、雷数。
text
简单:9×9,10 个雷
中等:16×16,40 个雷
困难:16×30,99 个雷
若要在运行时切换大小,固定宏数组就不够灵活。可以先为最大棋盘预留固定容量,或学习动态内存后再根据输入分配。
14.2 零区域自动展开
当一个格子的邻雷数为 0 时,可以继续打开相邻安全格。这本质上是从一个起点向周围扩散,后续可以用递归深度优先搜索或队列广度优先搜索实现。
无论采用哪种方法,都要先把格子标记为"已打开",再继续访问邻居,否则相邻的零格会互相反复调用。
14.3 旗帜标记
show 目前只有未打开 '*' 和数字两种状态。加入旗帜后,还需要一个字符(例如 'F')表示"玩家认为这里有雷"。操作命令也要从单纯的坐标扩展为"打开 / 标记 + 坐标"。
14.4 首步保护
体验更友好的扫雷通常保证第一步不会踩雷。可以在第一次点击后再布雷,并排除第一次选择的坐标;也可以先布雷,若首格是雷就移动它。前一种方案的数据流更自然。
14.5 可测试性
把随机种子、布雷位置或输入来源抽象成参数,可以让自动测试直接构造固定棋盘。项目越复杂,越不能只依赖人工多玩几局来判断正确性。
十五、常见误区、高频问题与练习
15.1 常见误区
误区一:9×9 棋盘就必须声明为 char board[9][9]
显示区域是 9×9,不代表底层存储也只能是 9×9。多一圈哨兵可以简化所有边缘邻域判断。
误区二:一个数组既能保存雷,又能保存显示数字
技术上可以设计复杂编码,但会把隐藏答案与玩家状态耦合在一起。初学项目使用双棋盘更清晰。
误区三:char board[11][11] = {'0'} 会把整个数组填成字符 0
它只显式初始化第一个元素,剩余元素补数值 0。要填满字符 '0',应使用嵌套循环或合适的填充函数。
误区四:调用十次 rand 就能得到十个不同坐标
随机结果允许重复。只有成功占用一个空位置时才能减少剩余雷数。
误区五:每次生成坐标前都调用 srand
srand 是重置序列,不是生成随机数。通常只在程序初始化时调用一次。
误区六:边缘格不能扫描 3×3,所以要写八套分支
哨兵圈已经保证相邻下标合法。统一循环比按角落、边缘、中央分类更简洁。
误区七:重复打开同一安全格也可以让 opened 加一
opened 表示已打开的不同安全格数量。重复坐标必须被拒绝,否则胜利条件会提前成立。
误区八:scanf 读入失败后,下一轮自然会恢复
不匹配的字符可能仍留在输入缓冲区,使下一次读取继续失败。需要清理当前输入行,或者使用 fgets + strtol 完成更严格的解析。
误区九:数组传给函数后仍能用 sizeof 求元素个数
数组形参会调整为指针,sizeof board 得到的不是完整二维数组大小。行列数量应该通过宏或参数明确传递。
误区十:显示 mine 只是方便,不会影响规则
mine 是完整答案,只应在调试、失败或胜利后显示。正常排查过程必须打印 show。
15.2 高频问题
问题 1:为什么真实坐标从 1 开始,而不是 0?
第 0 行和第 0 列被留作哨兵。使用 1~9 还能让控制台坐标更符合玩家直觉。
问题 2:为什么数组要初始化整个 11×11,而显示只遍历 1~9?
哨兵圈虽然不显示,但邻雷统计会读取它,所以也必须拥有确定值 '0'。
问题 3:mine 与 show 为什么都使用 char?
状态都能用单字符表达,两个数组同类型后还可以共用初始化与显示函数。
问题 4:一个格子周围最多有几颗雷?
最多 8 颗,对应中心格之外的八个邻居。因此 '0' + count 一定落在字符 '0' 到 '8'。
问题 5:为什么胜利条件是 row * col - EASY_COUNT?
总格数减去雷数,就是必须打开的安全格数量。雷格不需要也不能被普通打开。
问题 6:SetMine 为什么使用 while,而不是固定十次的 for?
因为随机坐标可能重复,循环次数无法预先确定;结束条件应该是"成功放入十颗不同的雷"。
问题 7:函数参数中的 const 有什么价值?
它保证函数只能读取对应棋盘,防止显示或统计函数意外修改游戏状态。
问题 8:为什么 GetMineCount 可以放心访问 x-1?
合法真实坐标最小为 1,所以 x-1 最小为 0,仍在 11×11 数组内;其他三个方向同理。
问题 9:为什么一局结束后还能重新开始?
FindMine 只结束内层游戏循环,返回到 game,再回到外层菜单循环。两者管理不同生命周期。
问题 10:怎样让随机问题能够复现?
调试时使用固定种子,或直接注入固定雷盘;发布版本再恢复时间种子。
15.3 排查清单
-
mine是否初始化为字符'0'? -
show是否初始化为字符'*'? - 是否初始化了完整的
ROWS × COLS,包括哨兵圈? - 布雷坐标是否严格落在
1~row、1~col? - 只有空位置成功写入雷时,
count才减一吗? - 正常游戏是否只显示
show? - 数组访问之前是否先验证输入和坐标范围?
- 重复排查是否被拒绝?
-
opened是否只统计不同的已打开安全格? - 邻雷统计是否跳过中心并覆盖其余八格?
-
srand是否只在程序初始化时调用一次? - 踩雷和胜利后是否都能结束当前一局?
-
game.h中的声明与game.c中的定义是否一致? - 编译时是否同时传入
test.c和game.c?
15.4 建议练习
练习一:显示剩余安全格
每次成功打开格子后,输出 safe_cells - opened。
练习二:使用八项相加实现 GetMineCount
写出课程中的字符求和版本,再比较它与嵌套循环版本的可读性和可维护性。
练习三:增加难度菜单
让用户选择简单、中等和困难,并验证雷数不能超过格子总数。
练习四:加入旗帜
设计打开、标记、取消标记三种操作,保证标记不会错误增加 opened。
练习五:实现零区域展开
当邻雷数为 0 时自动打开周围安全格,并防止重复访问。
练习六:首步不踩雷
把布雷时机调整到第一次输入坐标之后,排除首格再随机布雷。
练习七:记录用时
在一局开始和结束时记录时间,输出玩家完成本局的秒数。
练习八:固定棋盘单元测试
手工构造一个雷盘,验证角落、边缘和中央格的邻雷统计结果。
练习九:改用 fgets 与 strtol
把菜单和坐标输入改为整行读取,严格拒绝尾随字符、溢出和空输入。
练习十:拆分 UI 与规则
让核心函数不直接打印,而是返回状态;由界面层决定输出文字,为图形界面改造做准备。
15.5 参考资料
- 本文依据课程 PDF《第7讲:数组和函数实践:扫雷游戏》整理。
- 课程给出的扩展方向包括难度选择、零区域展开、雷标记与计时显示。
- 本文在课程基础版本上补充了重复排查保护、输入失败恢复、
const接口和可复现测试思路。
总结
扫雷真正值得学习的,不是某一行随机数代码,而是整个程序的数据与控制结构:
text
main 初始化随机序列
↓
外层菜单决定开始或退出
↓
game 创建 mine 与 show
↓
初始化完整 11×11 棋盘
↓
SetMine 在真实 9×9 区域放入 10 颗不同的雷
↓
FindMine 循环读取坐标
├── 输入失败或越界:提示后重试
├── 重复坐标:不累计 opened
├── 踩中雷:显示答案并失败
└── 安全格:统计邻雷、更新 show
↓
打开全部安全格后胜利
最值得记住的是下面八点:
mine保存隐藏答案,show保存玩家视图;双棋盘让两类状态互不干扰。- 真实棋盘外增加一圈哨兵,可以把角落、边缘和中央统一为同一套邻域算法。
- 初始化要覆盖完整 11×11 数组,显示和随机布雷只处理内部 9×9 区域。
- 随机坐标允许重复,只有成功占用空位置时才能减少剩余雷数。
- 邻雷统计本质上是二维数组上的 3×3 局部遍历。
- 数组访问前必须完成输入与范围检查,重复坐标也必须单独识别。
- 胜利计数统计的是不同的已打开安全格,不是用户输入次数。
game.h定义接口,game.c实现规则,test.c负责调度;文件结构反映的是责任边界。
当数组不再只是几行初始化代码,而能保存一个持续变化的棋盘;当函数不再只是演示调用,而能共同组织一套游戏规则,我们就真正完成了从"会写语法"到"会搭程序"的一步。下一次面对更大的项目时,也可以继续沿用同样的方法:先确定状态,再划分职责,最后让控制流推动状态稳定地向前变化。