你有没有过这样的经历:学了一堆语法,数组也懂了,函数也会写了,但一到"自己动手做个小项目"的时候就大脑空白?这其实特别正常——语法知识和工程能力之间的那条鸿沟,只能靠动手写来填平。

扫雷游戏是一个绝佳的练手项目。它既不像"Hello World"那么走过场,也不会复杂到让你无从下手。更关键的是,它能让你把前面学到的数组、函数、多文件编程、随机数这些知识点像拼图一样一块块拼成一个完整可运行的程序。这个从"知道"到"做到"的过程,就是你编程能力真正成长的时刻。

这一讲我们不只给出代码,而是完整走一遍从需求分析 → 数据结构设计 → 分模块实现 → 测试与调试的开发流程。等你跟着做完,你会发现自己已经具备了一种"拿到需求能自己动手拆解"的能力——这才是做项目最大的收获。

扫雷游戏分析和设计

在写任何一行代码之前,我们先把需求拉个清单。扫雷游戏的规则你应该很熟,但作为开发者,你需要把它翻译成可执行的规格:

  • 棋盘:9×9 的格子(经典尺寸)
  • 雷数:随机布置 10 个雷
  • 排查操作:
    • 如果踩到雷 → 游戏结束,显示所有雷的位置
    • 如果不是雷 → 显示该格子周围 8 个格子里有几个雷
    • 如果点开的是"周围 0 个雷"的空白格子 → 自动展开周围所有连通的空白区域(这是 Windows 扫雷的手感来源,也是本讲的重点之一)
    • 把除 10 个雷之外的所有格子(71 个)都揭开 → 排雷成功
  • 交互:通过控制台菜单选择"开始游戏"或"退出"
  • 界面:排查出的雷数用数字显示,未排查的用 * 隐藏

需求理清楚之后,我们才能回答下一个问题:用什么数据结构来存这些信息?

第一反应肯定是"用一个 9×9 的二维数组不就行了?"——用 1 表示雷,用 0 表示非雷。排查时统计周围 1 的个数就好。这个想法基本正确,但有一个问题:当你排查出某个格子周围有 3 个雷时,这个数字 3 存在哪里? 如果存在布置雷的那个数组里,那 0、1 既表示"非雷/雷"也表示"排查出的雷数",语义就乱了。更好的做法是分离关注点——用两个独立的数据结构各司其职:

  • mine 数组:存储雷的布置信息。'0' 表示无雷,'1' 表示有雷。这个数组对玩家隐藏。
  • show 数组:存储展示给玩家的信息。初始全是 '*'(表示未排查),排查后显示周围雷数。
char mine[11][11] = {0};  // 存放布置好的雷的信息
char show[11][11] = {0};  // 存放排查出的雷的个数信息(向玩家展示)

你注意到数组大小是 11×11 而不是 9×9 了吗?这是一个容易忽略但非常重要的细节。当你要排查角落格子比如坐标 (1,1) 时,需要检查周围 8 个格子——其中包括 (0,0)、(0,1)、(0,2),这些坐标在 9×9 的棋盘上会越界。解决办法很简单:把数组扩大一圈,创建 11×11 的数组,但只在中间 9×9 的区域布置雷和进行游戏。外围一圈始终保持为 '0'(非雷),这样边界格子在统计周围雷数时就不会越界了。

坐标范围含义
mine[0][*] / mine[*][0] / mine[10][*] / mine[*][10]外围保护圈,始终为 '0'
mine[1..9][1..9]实际游戏棋盘,9×9

这个"扩大一圈"的技巧,本质上是用空间换代码的简单性——虽然浪费了 40 个格子(11×11-9×9=40),但让所有边界情况的统计代码都变得统一,不用为边界写特殊判断。在后面的 GetMineCount 和 Unfold 里你会反复体会到它的好处。

在代码中我们用宏来管理这些尺寸,方便以后调整:

#define ROW 9           // 实际棋盘的行数
#define COL 9           // 实际棋盘的列数
#define ROWS ROW+2      // 包含保护圈的总行数
#define COLS COL+2      // 包含保护圈的总列数
#define EASY_COUNT 10   // 雷的数量

为什么用宏?因为后面所有函数都要用到这些尺寸,如果写死成 9 和 11,将来想改成 10×10 就得满文件找数字替换,极易漏改。用宏统一管理,改一行就全改——这正是预处理那一讲说的"宏定义常量"的典型场景。

接下来是文件结构设计。前面学了多文件编程的理论,现在正是实践的好时机。我们把代码拆成三个文件:

  • game.h:头文件,包含宏定义、库文件引用、以及所有函数的声明;
  • game.c:源文件,包含所有函数的实现(定义);
  • test.c:主文件,包含 main 函数和游戏的测试逻辑(菜单、流程控制)。

这种"接口与实现分离"的设计,在后续扩展时你会体会到它的好处——比如你想改棋盘大小,只需要改 game.h 中的宏然后重新编译,不需要在代码里翻来翻去找硬编码的数字。

随机布雷是扫雷的核心乐趣之一——每次玩的雷位置都不一样。这要用到 C 语言标准库中的 rand() 和 srand() 函数。rand() 返回一个 0 到 RAND_MAX(通常是 32767)之间的伪随机整数,但如果你只调用 rand(),每次运行得到的"随机数"序列都是一模一样的——因为伪随机数生成器需要一个**种子(seed)**作为起点。我们用当前时间作为种子:

srand((unsigned int)time(NULL));

time(NULL) 返回从 1970 年 1 月 1 日零点到现在的秒数——这个值每秒都在变,作为种子再合适不过。

然后生成 1 到 9 之间的随机坐标来布雷:

int x = rand() % row + 1;  // rand()%9 得到 [0,8],+1 得到 [1,9]
int y = rand() % col + 1;

布雷时需要注意:同一个位置不能重复布雷。所以每次生成坐标后,先判断这个位置是不是 '0'(无雷),是的话才放 '1':

while (count)    // count = EASY_COUNT = 10
{
    int x = rand() % row + 1;
    int y = rand() % col + 1;
    if (board[x][y] == '0')    // 这个位置还没布雷
    {
        board[x][y] = '1';     // 放一个雷
        count--;               // 计数器减1
    }
}

当玩家排查一个非雷格子时,我们需要统计它周围 8 个格子里有多少个雷。mine 数组中 '1' 表示有雷,'0' 表示无雷。这里有一个巧妙的字符运算技巧——把周围 8 个格子的值加起来,再减去 8 个 '0' 的 ASCII 值,就得到了雷的个数:

int GetMineCount(char mine[ROWS][COLS], int x, int y)
{
    return (mine[x-1][y]   + mine[x-1][y-1] + mine[x][y-1] +
            mine[x+1][y-1] + mine[x+1][y]   + mine[x+1][y+1] +
            mine[x][y+1]   + mine[x-1][y+1] - 8 * '0');
}

为什么这样可行?因为字符 '0' 的 ASCII 值是 48,'1' 是 49。8 个格子中如果有 n 个 '1' 和 (8-n) 个 '0',总和就是 (8-n)*48 + n*49,减去 8*48 后等于 n——刚好是雷的个数。这个技巧在数字字符('0'~'9')的运算中非常常用。

得到雷数后,需要把这个数字转成字符存入 show 数组:

show[x][y] = count + '0';   // count 是 0~8 的整数,加上 '0' 就变成对应字符

注意这个转换只对 0~9 成立('0'+0='0','0'+5='5'),而周围雷数最大是 8,所以恰好安全。这也是为什么扫雷棋盘的数字用 char 存而不是 int——既方便打印(%c),又方便字符运算。

怎么知道玩家排雷成功了呢?我们需要一个可靠的方法统计"已揭开的安全格子数"。简单粗暴的做法是维护一个 win 计数器,每排查一格就 win++——但这个做法在我们加入"自动展开"后会出问题:一次展开可能揭开十几个格子,只加 1 就漏了。更稳健的方案是:每轮循环都重新统计 show 数组中已经不是 '*' 的格子数:

int win = 0;
for (int i = 1; i <= row; i++) {
    for (int j = 1; j <= col; j++) {
        if (show[i][j] != '*') {
            win++;
        }
    }
}
// 胜利条件:所有安全格子(总数 - 雷数)都揭开了
if (win == row * col - EASY_COUNT) {
    printf("恭喜你,排雷成功!\n");
}

这个方案的额外好处是:天然免疫"重复排查同一格"带来的计数错误——不管玩家对同一个格子点了多少次,统计出来的已揭开数都不会多算。你会在后面的完整代码里看到它如何工作。

模块一:game.h —— 宏定义与函数声明

#pragma once                  // 防止头文件被重复包含(等同于 #ifndef 写法)
 
#include <stdio.h>            // printf, scanf
#include <stdlib.h>           // rand, srand
#include <time.h>             // time
 
#define EASY_COUNT 10         // 雷的个数
 
#define ROW 9                 // 棋盘行数
#define COL 9                 // 棋盘列数
 
#define ROWS ROW + 2          // 包含保护圈的行数(11)
#define COLS COL + 2          // 包含保护圈的列数(11)
 
// 初始化棋盘:将 board 中每个元素设为 set 指定的字符
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set);
 
// 打印棋盘:只打印中间 row×col 的实际游戏区域
void DisplayBoard(char board[ROWS][COLS], int row, int col);
 
// 布置雷:在 board 的 row×col 区域内随机放置 EASY_COUNT 个雷
void SetMine(char board[ROWS][COLS], int row, int col);
 
// 统计 (x,y) 周围 8 个格子中雷的个数
int GetMineCount(char mine[ROWS][COLS], int x, int y);
 
// 展开空白:点开的格子周围无雷时,递归展开连通的空白区域
void Unfold(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int row, int col);
 
// 排查雷:玩家交互式排查,直到踩雷或排空
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);

注意头文件只放声明不放定义——这是多文件编程的基本纪律,还记得编译链接那一讲讲的"头文件里写定义会导致 multiple definition"吗?这里就是活学活用。

模块二:game.c —— 核心游戏逻辑实现

初始化棋盘:InitBoard

#include "game.h"
 
// 初始化棋盘函数
// 遍历 rows×cols 的二维数组,把每个元素都赋值为 set
// mine 用 '0' 初始化(无雷),show 用 '*' 初始化(未排查)
void InitBoard(char board[ROWS][COLS], int rows, int cols, char set)
{
    int i = 0;
    for (i = 0; i < rows; i++)         // 遍历每一行
    {
        int j = 0;
        for (j = 0; j < cols; j++)     // 遍历每一列
        {
            board[i][j] = set;         // 设置为指定字符
        }
    }
}

这个函数把"遍历二维数组并赋值"这件重复的事抽象出来。set 参数让它同时服务两个棋盘:InitBoard(mine, ROWS, COLS, '0') 把雷区全部置为无雷,InitBoard(show, ROWS, COLS, '*') 把展示区全部置为未排查。一个函数两种用途,这是"参数化"设计的魅力。

打印棋盘:DisplayBoard

// 打印棋盘函数
// 打印行号、列号以及 row×col 区域的内容
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
    int i = 0;
    printf("--------扫雷游戏-------\n");
 
    // 打印列号(顶部横坐标)
    for (i = 0; i <= col; i++)
    {
        printf("%d ", i);              // 0 1 2 ... 9
    }
    printf("\n");
 
    // 打印棋盘内容
    for (i = 1; i <= row; i++)         // 从第1行开始,第0行是保护圈不打印
    {
        printf("%d ", i);              // 打印行号(左侧纵坐标)
        int j = 0;
        for (j = 1; j <= col; j++)     // 从第1列开始
        {
            printf("%c ", board[i][j]);// 打印每个格子的内容
        }
        printf("\n");
    }
}

打印时只遍历 1row、1col 的"有效区域",保护圈(第 0 行第 0 列等)不展示给玩家。行号列号让玩家能准确说出"第几行第几列",是交互的基础。

布置雷:SetMine

// 布置雷函数
// 在 1~row, 1~col 范围内随机放置 count 个雷
void SetMine(char board[ROWS][COLS], int row, int col)
{
    int count = EASY_COUNT;            // 需要布置的雷的数量
    while (count)                      // 直到所有雷布完
    {
        int x = rand() % row + 1;      // rand()%9 得到 [0,8],+1 得到 [1,9]
        int y = rand() % col + 1;
        if (board[x][y] == '0')        // 这个位置还没有雷
        {
            board[x][y] = '1';         // 放一个雷
            count--;                   // 剩余雷数减1
        }
    }
}

注意 while (count) 的循环条件——count 从 10 递减到 0,循环 10 次,恰好布 10 个雷。由于用了"如果该位置没雷才放"的判断,即使随机数重复生成到同一位置,也不会多布雷或少布雷。这个 while 循环的退出条件是"成功放了 count 个雷",而不是"执行了 count 次"——如果遇到重复坐标,这次迭代会白跑一轮重新生成,所以循环次数可能大于 10,但最终雷数一定准确。

统计周围雷数:GetMineCount

// 获取周围雷的数量
// 这是内部辅助函数,不在头文件中声明(通过 static 修饰也未尝不可)
int GetMineCount(char mine[ROWS][COLS], int x, int y)
{
    // 周围8个格子的字符值相加,减去 8×'0' 得到雷的个数
    return (mine[x-1][y]   + mine[x-1][y-1] + mine[x][y-1] +
            mine[x+1][y-1] + mine[x+1][y]   + mine[x+1][y+1] +
            mine[x][y+1]   + mine[x-1][y+1] - 8 * '0');
}

这里要特别强调:为什么 (1,1) 这个角落格子调用它不会越界? 因为数组是 11×11 的,mine[0][0] 是合法的(保护圈)。如果数组是 9×9,mine[0][0] 就会越界访问——这就是当初"扩大一圈"的回报。所有边界格子的统计代码都长得一模一样,不需要任何 if (x == 1) 之类的特判。

字符运算的底层原理再讲一遍:'0' 的 ASCII 是 48,'1' 是 49。8 个格子里如果有 n 个 '1' 和 (8-n) 个 '0',总和 (8-n)*48 + n*49 = 384 + n,减去 8*48 = 384,剩下的就是 n。一行代码搞定 8 次比较,这就是把"字符数字"当成"数值"用的技巧——你会看到它同样出现在后面的 Unfold 里。

展开空白:Unfold(本讲重点)

这是 Windows 扫雷"手感"的灵魂功能:当你点开的格子周围没有雷(数字为 0)时,游戏会自动把周围连成片的空白区域全部揭开,直到遇到有数字的格子为止。它的本质是深度优先搜索(DFS),也就是"函数自己调用自己"——递归。

// 展开空白函数
// 若 (x,y) 周围无雷,递归展开周围 8 个格子,直到碰到有数字的格子为止
// 用 show[x][y] != '*' 作为"是否已经展开过"的标记,防止重复展开和死循环
void Unfold(char mine[ROWS][COLS], char show[ROWS][COLS],
            int x, int y, int row, int col)
{
    // 1. 边界检查:超出有效棋盘范围就返回
    if (x < 1 || x > row || y < 1 || y > col) {
        return;
    }
 
    // 2. 重复检查:这个格子已经展开过了,返回(防止无限递归)
    if (show[x][y] != '*') {
        return;
    }
 
    // 3. 雷格不展开(正常情况下不会走到,防御性检查)
    if (mine[x][y] == '1') {
        return;
    }
 
    // 4. 统计周围雷数,把数字填入 show 数组
    int count = GetMineCount(mine, x, y);
    show[x][y] = count + '0';
 
    // 5. 如果周围有雷:只显示数字,不再向周围扩散
    //    (这是"展开停止"的边界条件)
    if (count != 0) {
        return;
    }
 
    // 6. 周围没有雷:递归展开周围 8 个格子
    Unfold(mine, show, x - 1, y - 1, row, col);
    Unfold(mine, show, x - 1, y,     row, col);
    Unfold(mine, show, x - 1, y + 1, row, col);
    Unfold(mine, show, x,     y - 1, row, col);
    Unfold(mine, show, x,     y + 1, row, col);
    Unfold(mine, show, x + 1, y - 1, row, col);
    Unfold(mine, show, x + 1, y,     row, col);
    Unfold(mine, show, x + 1, y + 1, row, col);
}

逐条理解这个函数:

  • 递归是什么:Unfold 函数体里又调用了 Unfold 自己。调用链是"从当前格子向邻居扩散,邻居再向它的邻居扩散……"像水波一样一圈圈荡开。
  • 终止条件(防止死递归):三个 return 就是递归的"刹车"——(1)越界就停;(2)已经展开过的格子就停(这是最关键的一个,防止 A 展开 B、B 又回来展开 A 的无限循环);(3)雷格不展开。这三个条件缺一个,程序都会栈溢出崩溃。
  • 扩散条件:只有 count == 0(周围没雷)才继续扩散;count > 0 的格子是"数字边界",显示数字后停下。这就保证了展开的区域是"连通的空白区 + 一圈数字边界",和 Windows 扫雷完全一致。
  • 为什么不会展开到雷:一个雷格周围通常有数字格包围(雷格自身 count 可能是任意值,但我们第 3 步直接挡掉了)。更重要的是,展开是"从 count==0 的格子向外扩散",而雷格附近的格子 count 必然 ≥1,会在第 5 步停下,不会继续深入雷格。

用一个小例子感受展开过程。假设 3×3 区域只有 (2,2) 一个雷,玩家点开 (1,1):

雷区(示意):
* * *
* 雷 *
* * *

点开 (1,1),count = 1(因为 (2,2) 是雷在它的 8 邻居里)
→ (1,1) 显示 '1',count != 0,不扩散。

换个场景:雷在 (3,3),玩家点开 (1,1):
(1,1) 的 8 邻居里没有雷 → count = 0 → 向 8 个方向扩散
(1,2) count=0 → 继续扩散 ...
(2,2) count=0 → 继续扩散 ...
直到扩散到 (2,3)/(3,2)/(3,3) 附近:这些格子 count=1,显示数字后停下。

没学递归怎么办? 如果你还没学到递归那一讲,先别慌。你只需要把 Unfold 当成"一个函数,它会自己调用自己"来理解:它要做的事只有一件——"如果这个格子周围没雷,就把周围 8 格也按同样规则处理"。学完递归那一讲再回来看这段代码,你会豁然开朗。如果实在想绕开递归,扩展部分会给你一个"用循环 + 待处理队列"的非递归思路。

排查雷:FindMine

// 排查雷函数(核心交互逻辑)
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
    int x = 0;
    int y = 0;
 
    while (1)   // 每轮循环:检查胜利 → 输入 → 校验 → 排查
    {
        // === 胜利判断:统计已展开的格子数 ===
        // 用统计而不是累加,是因为 Unfold 一次可能展开很多格子
        int win = 0;
        for (int i = 1; i <= row; i++) {
            for (int j = 1; j <= col; j++) {
                if (show[i][j] != '*') {
                    win++;
                }
            }
        }
        if (win == row * col - EASY_COUNT) {
            printf("恭喜你,排雷成功!\n");
            DisplayBoard(mine, row, col);   // 展示完整雷图
            break;                           // 跳出循环,游戏结束
        }
 
        // === 输入坐标(带非法输入处理)===
        printf("请输入要排查的坐标(行 列):>");
 
        // scanf 返回成功读取的变量个数;不是 2 说明输入了非数字
        // 此时清空输入缓冲区,让玩家重新输入,避免死循环
        while (scanf("%d %d", &x, &y) != 2) {
            while (getchar() != '\n');       // 把缓冲区里的垃圾字符全部读掉
            printf("输入格式错误!请输入两个整数(行 列):>");
        }
        while (getchar() != '\n');           // 吃掉行尾残留的换行符
 
        // === 坐标合法性校验 ===
        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);    // 展示所有雷的位置
            break;
        } else {
            // 非雷:调用 Unfold 展开(自动处理空白区域的连环展开)
            Unfold(mine, show, x, y, row, col);
            DisplayBoard(show, ROW, COL);    // 展示当前棋盘状态
        }
    }
}

这个函数集中体现了本讲要教的输入健壮性。逐点说明:

  1. scanf 失败处理:scanf("%d %d", &x, &y) 返回成功匹配的个数。如果玩家输入了"abc"这种非数字,scanf 返回 0,此时 x、y 根本没被赋值,而且"abc"还留在缓冲区里——如果直接让它继续,下一轮 scanf 又会读到同一个"abc",死循环。所以标准做法是:while (scanf(...) != 2) { while (getchar() != '\n'); ... }——内层 while (getchar() != '\n') 把缓冲区的垃圾全部吃掉,外层再让玩家重输。(更严谨的写法是 while ((ch = getchar()) != '\n' && ch != EOF); 以覆盖"从文件重定向输入时到达文件末尾"的极端情况——交互式终端输入不会遇到,但知道这个细节能帮你写出更健壮的代码。)
  2. 行尾残留换行:scanf("%d %d") 只读走两个整数,玩家按下回车产生的 \n 会留在缓冲区。虽然对 %d 没影响(%d 会跳过空白),但养成清掉它的习惯可以避免未来用 %c/fgets 时的坑。
  3. 坐标范围校验:越界输入直接 continue,不进入排查逻辑。
  4. 重复排查校验:show[x][y] != '*' 说明这格已经揭开。这个检查在原版扫雷教程里经常被漏掉——漏掉的结果是玩家反复点同一格也不会崩,但配合"累加式 win++"的胜利判断会产生假胜利(同一格点 71 次就赢了)。我们用了"统计式胜利判断"+ 这个显式检查,双保险。

模块三:test.c —— 菜单与游戏流程

#include "game.h"
 
// 显示游戏菜单
void menu()
{
    printf("***********************\n");
    printf("*****   1. play   *****\n");
    printf("*****   0. exit   *****\n");
    printf("***********************\n");
}
 
// 游戏主流程函数
void game()
{
    // 创建两个棋盘(都包含保护圈)
    char mine[ROWS][COLS];   // 存放布置好的雷
    char show[ROWS][COLS];   // 存放排查出的雷的信息(展示给玩家)
 
    // 初始化棋盘
    InitBoard(mine, ROWS, COLS, '0');  // mine 全部初始化为 '0'(无雷)
    InitBoard(show, ROWS, COLS, '*');  // show 全部初始化为 '*'(未排查)
 
    // 打印初始棋盘(只打印 show,mine 是隐藏的)
    DisplayBoard(show, ROW, COL);
 
    // 布置雷(在 mine 数组中随机放 10 个 '1')
    SetMine(mine, ROW, COL);
 
    // 开始排查(玩家在 show 上看信息,在 mine 上验证)
    FindMine(mine, show, ROW, COL);
}
 
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);                     // input 为 0 时退出循环
 
    return 0;
}

game() 函数的调用顺序就是整个游戏的执行流程:初始化两个棋盘 → 打印初始界面 → 布雷 → 进入排查循环。mine 和 show 都声明在 game() 内部(局部变量),游戏结束函数返回后自动销毁,下一局重新创建——天然实现了"新的一局从零开始"。

main 用 do-while 保证"先显示一次菜单",用 input 作为循环条件——选 0 退出,选其他值继续。srand 放在 main 里只调用一次,而不是放在 game() 里——如果每局都重新 srand(time(NULL)),而玩家在两局之间间隔不到 1 秒,两局的种子相同,雷的位置会一模一样!种子只需要设置一次,这是个经典的坑。

编译与运行

编译时注意——如果使用 GCC 命令行编译,需要把所有 .c 文件一起编译:

gcc test.c game.c -o minesweeper.exe

在 Visual Studio 中创建一个控制台项目,将三个文件添加到项目中直接编译运行即可。

运行后的游戏画面大致是:

--------扫雷游戏-------
0 1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
2 * * * * * * * * *
3 * * * * * * * * *
4 * * * * * * * * *
5 * * * * * * * * *
6 * * * * * * * * *
7 * * * * * * * * *
8 * * * * * * * * *
9 * * * * * * * * *
请输入要排查的坐标(行 列):>

玩家输入"5 5"后,如果该格周围无雷,你会看到一片区域被自动展开:

--------扫雷游戏-------
0 1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
2 * * * * * * * * *
3 * * * 1 1 1 * * *
4 * * * 1 0 1 * * *
5 * * * 1 0 0 1 * *
6 * * * 1 1 1 * * *
7 * * * * * * * * *
8 * * * * * * * * *
9 * * * * * * * * *

看到 0 了吗?0 表示"周围没雷",在标准扫雷里 0 通常显示为空白,这里为了教学保留了字符 '0'——你可以把 DisplayBoard 里的 printf("%c ", board[i][j]) 改成 if (board[i][j] == '0') printf(" "); else printf("%c ", board[i][j]); 来模拟 Windows 扫雷的空白显示。

常见问题与调试提示

(1)"游戏没玩几局,雷的位置总是一样":srand(time(NULL)) 放错位置了——如果它在 game() 内部且两局间隔不足 1 秒,就会复现。把 srand 放到 main 开头调用一次。

(2)"输入字母就死循环":输入校验没做,scanf 失败后垃圾字符残留缓冲区。用 while (scanf("%d %d", &x, &y) != 2) { while (getchar() != '\n'); } 模式处理。

(3)"重复点同一个格子,胜利判断异常":原因和修复见 FindMine 的"重复排查校验"——顺带一提,这也是调试那一讲的好素材:用 VS 在 win 统计处打断点,观察"重复点同一格"时 win 值的变化。

(4)"Unfold 之后程序崩溃(栈溢出)":几乎可以肯定是递归缺少终止条件——检查三个 return 是否齐全,尤其是 show[x][y] != '*' 那个"已展开标记"。没有它,A→B→A→B 的互相展开会无限递归下去,栈很快爆掉。调试时打开"调用堆栈"窗口,如果看到几千层 Unfold,就是它了。

(5)用调试器验证展开逻辑:在 Unfold 的第 4 步(show[x][y] = count + '0')打断点,监视 x、y、count 三个变量,F5 连续跑几次,观察递归的调用路径是否符合预期。或者给 show 数组加监视表达式 show,11 直接看整个棋盘的变化。

扫雷游戏的扩展

当前版本是一个功能完整的基础扫雷,但还有很多可以优化的地方。以下是几个值得尝试的扩展方向:

难度选择:用变量替代宏中的固定值,在游戏开始前让玩家选择棋盘大小和雷数。

小提示:上面这个片段只完成了"选难度"这一半。别忘了 mine 和 show 数组是按宏 ROWS × COLS(11×11)创建的死尺寸——如果选了 16×16 或 30×16 的棋盘,固定数组会越界。真正落地时,要么把数组开成最大尺寸(比如 32×32)并按当前难度只使用左上角区域,要么在选择了难度之后再按尺寸创建数组(C99 变长数组,或后续学了动态内存用 malloc)。

// game.h 中增加
#define EASY_COUNT   10
#define MEDIUM_ROW   16
#define MEDIUM_COL   16
#define MEDIUM_COUNT 40
 
// test.c 中增加难度选择菜单
void ChooseDifficulty(int *row, int *col, int *mine_count)
{
    int choice = 0;
    printf("请选择难度:\n");
    printf("1. 简单(9×9, 10个雷)\n");
    printf("2. 中等(16×16, 40个雷)\n");
    printf("3. 困难(30×16, 99个雷)\n");
    printf("请选择:>");
    scanf("%d", &choice);
 
    switch (choice)
    {
    case 1:
        *row = 9;  *col = 9;  *mine_count = 10;
        break;
    case 2:
        *row = 16; *col = 16; *mine_count = 40;
        break;
    case 3:
        *row = 30; *col = 16; *mine_count = 99;
        break;
    default:
        *row = 9;  *col = 9;  *mine_count = 10;  // 默认简单
        break;
    }
}

非递归的展开实现:如果你不想用递归(或者还没学到递归),可以用"队列"思路模拟展开——维护一个"待处理格子"的清单,从起点开始:取一个格子 → 若是 0 且未展开,把它的 8 个邻居加入清单 → 重复直到清单为空。用数组模拟队列大概十几行,等学了数据结构"队列"那一讲,你会理解得更深:

// 非递归展开的思路框架(用数组模拟队列)
// 队列 qx/qy 存放"待处理的格子坐标"
// head 指向队首,tail 指向队尾
void UnfoldIterative(char mine[ROWS][COLS], char show[ROWS][COLS],
                     int sx, int sy, int row, int col)
{
    // 队列容量要按最坏情况开:一个空白格最多把 8 个邻居入队,
    // 而同一个格子可能被多个邻居重复入队,所以容量乘 8 才保险
    int qx[ROWS * COLS * 8], qy[ROWS * COLS * 8];
    int head = 0, tail = 0;
 
    qx[tail] = sx; qy[tail] = sy; tail++;    // 起点入队
 
    while (head < tail)                      // 队列非空
    {
        int x = qx[head], y = qy[head];      // 取出队首
        head++;
 
        if (x < 1 || x > row || y < 1 || y > col) continue;
        if (show[x][y] != '*') continue;     // 已展开,跳过
        if (mine[x][y] == '1') continue;     // 雷格跳过
 
        int count = GetMineCount(mine, x, y);
        show[x][y] = count + '0';
 
        if (count == 0)                      // 空白格:8 个邻居入队
        {
            qx[tail] = x-1; qy[tail] = y-1; tail++;
            qx[tail] = x-1; qy[tail] = y;   tail++;
            qx[tail] = x-1; qy[tail] = y+1; tail++;
            qx[tail] = x;   qy[tail] = y-1; tail++;
            qx[tail] = x;   qy[tail] = y+1; tail++;
            qx[tail] = x+1; qy[tail] = y-1; tail++;
            qx[tail] = x+1; qy[tail] = y;   tail++;
            qx[tail] = x+1; qy[tail] = y+1; tail++;
        }
    }
}

两种实现的效果完全一样,差别只在思维方式:递归是"编译器帮我们管理待处理清单(函数调用栈)",循环队列是"我们自己用数组管理"。理解了递归版,再对照看循环版,你会对两种思想都有更深的理解。

标记雷:增加"插旗"功能,让玩家标记怀疑是雷的格子,避免误踩。需要新增一个"标记/取消标记"的输入分支(比如输入 2 x y),把 show 里的 '*' 换成 '!' 表示插旗,并让已插旗的格子不再接受普通排查。

计时器:用 clock() 函数记录游戏耗时,结束时显示用时。

首次点击不踩雷:真正的扫雷游戏保证第一次点击永远不会踩雷。实现思路:SetMine 把玩家第一次输入的位置(及其周围)排除在布雷区域外,或者如果第一次就踩雷,把该位置的雷挪到别处。

为什么用字符而不是整数? 你可能好奇为什么 mine 和 show 要用 char 而不是 int。三个原因:一,两个数组类型一致,可以用同一套 InitBoard 和 DisplayBoard 函数处理;二,char 占 1 字节,int 占 4 字节,节省内存;三,打印字符时直接用 %c 格式,不需要额外类型转换——而且 GetMineCount 的字符运算技巧也依赖 char 存储。


这个扫雷项目虽然不大,但它踩到了实际 C 语言开发中的很多关键点:需求分析驱动数据结构设计、数组边界的防御性编程(保护圈)、多文件的接口分离模式、随机数的正确使用(种子只设一次)、输入健壮性(scanf 校验)、以及递归展开这种"把复杂问题分解成同构子问题"的思想。如果你跟着代码敲了一遍并且跑通了——恭喜你,你已经完成了从"学语法"到"写项目"的第一步跨越。接下来试着加几个扩展功能,你会发现真正学会编程的路就是一行一行代码铺出来的。

思考题

  1. 为什么 mine 和 show 要用 11×11 而不是 9×9?如果去掉保护圈,GetMineCount 在角落格子 (1,1) 会怎样?
  2. GetMineCount 用字符加减算出雷数,它的数学原理是什么?如果棋盘改用 int 数组存雷(1 表示雷),这个函数该怎么改?
  3. Unfold 的递归为什么不会无限循环?三个 return 分别扮演什么角色?删掉任何一个会怎样?
  4. 为什么胜利判断用"统计 show 中非 '*' 的格子数"而不是"维护 win 计数器"?在什么场景下计数器的方案会出错?
  5. 玩家输入 "abc" 时,不处理 scanf 返回值会发生什么?while (getchar() != '\n') 起什么作用?
  6. srand(time(NULL)) 为什么要放在 main 里只调用一次?如果每局游戏都调用它,会有什么问题?
  7. 如果雷的个数改成 15(EASY_COUNT 15),胜利条件 row * col - EASY_COUNT 会变成多少?哪些地方需要跟着改?
  8. 试着把 Unfold 递归版改成循环队列版(参考扩展部分),或者反过来——体会两种实现的思维方式差异。

参考答案与详解

1. 为什么用 11×11 而不是 9×9?去掉保护圈后 GetMineCount 在 (1,1) 会怎样?

排查一个格子时,GetMineCount 要访问它周围 8 个格子。像角落格子 (1,1),它的 8 个邻居里包含 (0,0)、(0,1)、(0,2)、(1,0)、(2,0) ——这些坐标在 9×9 的棋盘上全部越界。索性把数组扩成 11×11,只在中部 9×9 布线,外围一圈保持 '0'。这样针对边界格子的统计代码和其他格子写地一模一样,不用写 if (x==1) 这类特判——用空间换代码的简单性。

如果去掉保护圈直接用 9×9,GetMineCount 在 (1,1) 会去读 mine[0][0] 等越界地址:那里是数组之外的未知内存,读出的值是垃圾,导致统计结果错误,甚至可能读到不该读的地方引发崩溃。

2. GetMineCount 字符运算的数学原理?改用 int 数组怎么改?

'0' 的 ASCII 是 48,'1' 是 49。8 个格子里有 n 个 '1'、8-n 个 '0',求和后 = (8-n)×48 + n×49 = 384 + n,再减去 8×48 = 384,剩下的正好是 n——雷的个数。这利用了"字符数字与数值只差一个 '0'(48)"的事实。

如果改用 int 数组存(1 表示雷、0 表示无雷),就不需要减 8 个 '0' 了,直接把 8 个元素相加即可:

int GetMineCount(int mine[ROWS][COLS], int x, int y)
{
    return mine[x-1][y]   + mine[x-1][y-1] + mine[x][y-1]   + mine[x+1][y-1]
         + mine[x+1][y]   + mine[x+1][y+1] + mine[x][y+1]   + mine[x-1][y+1];
}

用 char 的好处是:类型一致可用同一套初始化/打印函数、省内存、打印用 %c 方便、且能配合上面的字符运算技巧。(如果两种数组都用 char 存 '0'/'1',就能用同一个 DisplayBoard。)

3. Unfold 递归为什么不会无限循环?三个 return 各自扮演什么角色?

  • 越界检查(if (x<1 || x>row || y<1 || y>col) return;):防止递归跑到保护圈/棋盘外访问非法下标;
  • 已展开检查(if (show[x][y] != '*') return;):最关键的刹车。防止 A 展开 B、B 又反过来展开 A 的相互扩散,也防止同一个格子被 8 个邻居重复递归;
  • 雷格检查(if (mine[x][y] == '1') return;):隔离雷格,防御性地保证雷永远不会被展开盖掉。

任何一个都不能删——删越界检查会数组越界;删已展开检查会死递归(A→B→A→B……无限套娃)直到栈溢出;删雷格检查会错误展开雷区。最致命的是第二个,所以递归必须先写终止条件。

4. 为什么用"统计非 * 的格子数"而不是"维护 win 计数器"?

Unfold 一次可能连锁展开十几个格子,如果只在点到的那一格 win++,实际揭开的数就严重偏少,胜利判断会失灵(甚至永远判不到胜利)。而"统计式"每轮重新数一遍 show 中非 '*' 的格子数,无论一次展开多少格都能准确反映真实进度。

统计式还有个额外好处:天然免疫"重复排查同一格"造成的假计数。如果配合"累加式 win++"且不做"重复排查校验",玩家反复点同一格 71 次也能凑够胜利——这就是经典的假胜利 bug。统计式 + 显式重复校验双保险。

5. 玩家输入 "abc" 时不处理 scanf 返回值会怎样?while (getchar() != '\n') 干什么?

scanf("%d %d", &x, &y) 返回成功读入的变量个数。玩家输入 abc 时匹配失败,返回 0(≠2),此时 x、y 根本没被赋值,而垃圾字符串 abc 还残留在输入缓冲区里。如果不处理,下一轮循环 scanf 又读到同一个 abc,再次失败……如此反复,就陷进死循环。

while (getchar() != '\n') 的作用是把缓冲区里直到换行的所有残留字符逐个读掉、清空。配合 while (scanf(...) != 2) 的外层循环,先清空垃圾、再提示玩家重新输入,从而跳出死循环、恢复到正常输入交互。

6. srand(time(NULL)) 为什么放 main 里只调用一次?每局都调用会怎样?

srand 用种子初始化随机数序列,而 time(NULL) 返回从 1970 年到现在的秒数,每秒变化一次。如果每局 game() 里都调用 srand(time(NULL)),而玩家两局之间间隔不足 1 秒,time(NULL) 返回的是同一个秒数 → 种子相同 → 这一局生成的随机雷位和上一局一模一样。

所以种子要放到 main 里只调用一次:程序启动时用当前时间作为整条随机序列的起点,之后 rand() 持续往后走,每局自然不同。

7. 雷数改成 15,胜利条件 row*col - EASY_COUNT 变多少?哪些地方要跟着改?

row*col = 9×9 = 81,胜利条件变成 81 - 15 = 66(需要揭开 66 个非雷格)。需要同步确认的地方:① game.h 里的 #define EASY_COUNT 15;② 确保 SetMine 用的就是同一个 EASY_COUNT(它从宏取 count,改宏即自动生效);③ 若同时把棋盘尺寸改大(如 16×16),固定的 ROWS×COLS = 11×11 数组就不够用了,需要把数组按最大尺寸开(如 32×32 只在左上角用当前尺寸),或改用变长数组/动态内存——否则数组越界。

8. 递归版 vs 循环队列版的差异

两者对同一棋盘、同一入口会展开出完全相同的结果。差别只在"待处理格子清单由谁来管理":

  • 递归版:编译器帮你维护调用栈,每个栈帧隐式记着"(当前格子, 下一步该扩散到哪个邻居)",代码最短、最贴合问题的递归结构;
  • 循环队列版:自己用数组 qx/qy + head/tail 显式管理待处理格子,先放起点,取出队首处理,若为 0 再把 8 个邻居入队,直到队列空。规模可控、不会栈溢出。

理解递归版后再对照循环版,你会发现"递归 = 编译器替我们管栈",两者是同一件事的两种写法。