【C 语言项目】数组和函数综合实践:从零实现控制台扫雷游戏

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:用一个完整的控制台扫雷项目,把二维数组、函数拆分、随机数、嵌套循环、边界控制和多文件工程组织串在一起。

💡 学习目标 :理解为什么要使用 mineshow 两个棋盘;掌握 11×11 哨兵边界的设计;会实现初始化、显示、布雷、邻雷统计与排查函数;能看懂 game.h + game.c + test.c 的职责分工。

📌 精修说明:本文沿用课程 PDF 的核心方案,同时修正基础代码中"重复排查同一坐标也会累计胜利次数"的问题,并补充输入校验、函数返回值和测试方法。


文章目录


一、为什么用扫雷练习数组和函数

前面分别学习数组和函数时,代码通常只完成一个很小的任务:遍历数组、求和、交换变量,或者调用一个计算函数。扫雷不同,它要求多个知识点同时配合:

  • 棋盘状态需要用二维数组保存;
  • 雷的位置需要随机生成;
  • 每次输入坐标都要检查边界;
  • 打开安全格后,要统计周围八格的雷数;
  • 玩家会连续操作,所以需要循环;
  • 初始化、显示、布雷、统计和排查应该拆成不同函数;
  • 项目稍大后,还要用头文件组织声明。

也就是说,扫雷不是在考某一条语法,而是在练习怎样把数据与规则组合成一个完整程序。

本文先完成一个基础版本:玩家输入行列坐标,程序显示该位置周围有几颗雷;踩中雷则失败,打开全部安全格则成功。课程中提到的自动展开、旗帜、计时与难度选择,会放在扩展部分讨论。


二、先定义游戏规则,再开始写代码

基础版规则如下:

项目 规则
可见棋盘 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 两个棋盘

扫雷同时存在两套信息:

  1. 程序知道哪里有雷,这是隐藏答案。
  2. 玩家知道哪些位置已经打开,这是当前界面。

如果把两种信息塞进同一个数组,01、未打开标记和邻雷数字会混在一起。打印时容易泄露答案,判断时也很难分清一个 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+1y-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 表示什么

DisplayBoardGetMineCount 只读取棋盘,不应该修改它,因此参数写成:

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. 把它改成 '1'
  2. 将剩余雷数 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-1x+1y-1y+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);
}

调用顺序不能随意交换:

  1. 创建两个数组;
  2. 建立可靠初值;
  3. mine 中布雷;
  4. 向玩家显示隐藏状态的 show
  5. 进入排查循环。

若先布雷再初始化,刚放好的雷会被 '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 的初始化阶段,只调用一次;每次进入 gameSetMine 再从伪随机序列中取得新的坐标。

外层 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 110 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~row1~col
  • 只有空位置成功写入雷时,count 才减一吗?
  • 正常游戏是否只显示 show
  • 数组访问之前是否先验证输入和坐标范围?
  • 重复排查是否被拒绝?
  • opened 是否只统计不同的已打开安全格?
  • 邻雷统计是否跳过中心并覆盖其余八格?
  • srand 是否只在程序初始化时调用一次?
  • 踩雷和胜利后是否都能结束当前一局?
  • game.h 中的声明与 game.c 中的定义是否一致?
  • 编译时是否同时传入 test.cgame.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
             ↓
      打开全部安全格后胜利

最值得记住的是下面八点:

  1. mine 保存隐藏答案,show 保存玩家视图;双棋盘让两类状态互不干扰。
  2. 真实棋盘外增加一圈哨兵,可以把角落、边缘和中央统一为同一套邻域算法。
  3. 初始化要覆盖完整 11×11 数组,显示和随机布雷只处理内部 9×9 区域。
  4. 随机坐标允许重复,只有成功占用空位置时才能减少剩余雷数。
  5. 邻雷统计本质上是二维数组上的 3×3 局部遍历。
  6. 数组访问前必须完成输入与范围检查,重复坐标也必须单独识别。
  7. 胜利计数统计的是不同的已打开安全格,不是用户输入次数。
  8. game.h 定义接口,game.c 实现规则,test.c 负责调度;文件结构反映的是责任边界。

当数组不再只是几行初始化代码,而能保存一个持续变化的棋盘;当函数不再只是演示调用,而能共同组织一套游戏规则,我们就真正完成了从"会写语法"到"会搭程序"的一步。下一次面对更大的项目时,也可以继续沿用同样的方法:先确定状态,再划分职责,最后让控制流推动状态稳定地向前变化。

相关推荐
Navigator_Z4 小时前
LeetCode //C - 1254. Number of Closed Islands
c语言·算法·leetcode
Logic1015 小时前
C语言/数据结构滑动窗口题解:替换k个字符后的最长连续相同字符子串(LeetCode 424)
c语言·数据结构·字符串·滑动窗口·时间复杂度·频率统计·算法题
free-elcmacom6 小时前
发际线警告!手撕《C陷阱与缺陷》终极 BOSS:signal 函数与 typedef 魔法
c语言·开发语言·visual studio code·visual studio
haluhalu.7 小时前
初识 Protobuf:微服务跨语言通信的一份契约
java·c语言·开发语言·c++·python
临期冰淇淋7 小时前
Unisoc 展锐平台Camera摄像头分辨率适配
linux·c语言·驱动开发
山下梅子酒2258 小时前
洛谷-入门-B2054
c语言
老当益壮梁奶奶8 小时前
ARM汇编学习笔记(四):i.MX6ULL裸机开发:从汇编点灯到C语言BSP工程管理
c语言·arm开发·单片机·嵌入式硬件·51单片机
傲世仙尊9 小时前
预处理详解-define宏井号条件编译一篇过
linux·c语言
老王爱玩车9 小时前
第6讲:数组和函数实践-----控制台扫雷
c语言·开发语言·学习