Appearance
第 19 讲:实战——扫雷游戏
⏱️ 本讲 L1 内容建议分 3 次 完成:第 1 次读懂设计,第 2、3 次自己动手把三个文件敲完并跑通。每次约 40~50 分钟。跟着抄一遍和读一遍,收获天差地别。📘 内容与末尾 🔬 拓展可按需跳过。
🎯 学完本讲,你将能够
- 说出扫雷的棋盘为什么用 11×11 而不是 9×9,并解释"扩大一圈"如何避免统计越界
- 解释为什么要设计 mine、show 两个棋盘,以及字符
'0'/'1'/'*'各自的含义 - 独立完成一个多文件项目(
.h放声明、.c放实现、主流程单独一个.c),并用 gcc 或 VS 编译运行 - 讲清"周围雷数"的字符统计原理(含
− 8 * '0'的由来) - 能为游戏添加递归"展开一片"等扩展功能
🔗 先修知识
二维数组(第 5 讲)、函数声明与定义、多文件组织(第 6 讲)、递归(第 9 讲)、随机数 rand/srand(第 4 讲)。
自检三问:
- 二维数组在内存中是怎样存放的?
- 函数声明一般放在什么位置?为什么头文件里要加保护?
rand()生成的随机数范围是什么?如何把它映射到 1~9?
一、游戏要做成什么样
我们要实现一个经典控制台扫雷:
- 菜单选择"开始游戏 / 退出";
- 棋盘为 9×9,随机布置 10 个雷;
- 玩家输入坐标排查:
- 踩到雷 → 游戏失败,亮出全部雷;
- 不是雷 → 显示该格周围 8 格内有几个雷;
- 除 10 个雷之外的 71 个非雷格全部找出来 → 排雷成功。
二、分析与设计(比写代码更重要)
2.1 第一个问题:统计周围雷数会"出圈"
玩家排查 (2, 5) 时,要访问它周围的 8 个格子——都在 9×9 范围内,没问题。可如果排查的是边缘格 (8, 6) 或角落实体,周围 8 格中就有一部分落在棋盘之外,访问即下标越界。
处理办法很巧妙:把数组扩大一圈,定义成 11×11,雷只布在中间 9×9(下标 1~9),最外圈永远不放雷。这样无论统计哪个位置,8 个邻居都在数组内,无需写任何边界判断:
text
0 1 2 … 9 10
0 ░ ░ ░ ░ ░ ░ ░ ░ = 扩大出来的一圈,
1 ░ . . . . . ░ 永远安全(不放雷)
2 ░ . . . . . ░ . = 真正的 9×9 棋盘
…░ . . . . . ░
9 ░ . . . . . ░
10░ ░ ░ ░ ░ ░ ░2.2 第二个问题:雷的信息和排查出的数字放哪儿
如果把"是不是雷"和"周围有几个雷"都存在同一个数组,两类信息会互相打架。解决方案是双棋盘:
| 棋盘 | 用途 | 初始内容 |
|---|---|---|
mine | 存放雷的真实布局 | 全 '0'(非雷),布雷处改 '1' |
show | 存放给玩家看的排查结果 | 全 '*'(未排查,保持神秘) |
两个数组都用 char 类型,就能共用同一套初始化、打印函数;玩家每排查一个安全格,就把周围雷数(一个字符数字)写进 show。
2.3 文件结构:第一次实践多文件项目
| 文件 | 职责 |
|---|---|
game.h | 宏定义、类型与函数声明(被另外两个文件包含) |
game.c | 游戏各函数的具体实现 |
test.c | 菜单、主循环等游戏流程与 main |
三、代码实现
3.1 game.h
🧩 game.h(多文件源文件片段) · 需与 game.c、test.c 放在同一目录一起编译
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);
/* 打印中间 9×9 的棋盘 */
void DisplayBoard(char board[ROWS][COLS], int row, int col);
/* 在中间区域随机布置 EASY_COUNT 个雷 */
void SetMine(char board[ROWS][COLS], int row, int col);
/* 玩家排查雷 */
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);3.2 game.c
🧩 game.c(多文件源文件片段) · 需 #include "game.h" 一起编译
c
#include "game.h"
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(char board[ROWS][COLS], int row, int col)
{
printf("-------- 扫雷游戏 --------\n");
for (int i = 0; i <= col; i++) /* 列号表头:0~9 */
{
printf("%d ", i);
}
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 board[ROWS][COLS], int row, int col)
{
int count = EASY_COUNT;
while (count > 0)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (board[x][y] == '0') /* 该位置还没有雷 */
{
board[x][y] = '1';
count--;
}
}
}
/* 统计 (x,y) 周围 8 格的雷数 */
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');
}
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
int x = 0;
int y = 0;
int win = 0;
int target = row * col - EASY_COUNT; /* 需要排查出的非雷格数:71 */
while (win < target)
{
printf("请输入要排查的坐标:>");
if (scanf("%d %d", &x, &y) != 2) /* 输入异常时直接结束,避免死循环 */
return;
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");
DisplayBoard(mine, row, col);
return;
}
int count = GetMineCount(mine, x, y);
show[x][y] = (char)(count + '0');
DisplayBoard(show, row, col);
win++;
}
printf("恭喜你,排雷成功\n");
DisplayBoard(mine, row, col);
}3.3 test.c
🧩 test.c(多文件源文件片段) · 含 main,与 game.c 一起链接
c
#define _CRT_SECURE_NO_WARNINGS
#include "game.h"
void menu(void)
{
printf("***********************\n");
printf("***** 1. play *****\n");
printf("***** 0. exit *****\n");
printf("***********************\n");
}
void game(void)
{
char mine[ROWS][COLS]; /* 雷的真实布局 */
char show[ROWS][COLS]; /* 给玩家看的棋盘 */
InitBoard(mine, ROWS, COLS, '0');
InitBoard(show, ROWS, COLS, '*');
DisplayBoard(show, ROW, COL);
SetMine(mine, ROW, COL);
FindMine(mine, show, ROW, COL);
}
int main(void)
{
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;
}四、如何编译运行
gcc(命令行)——三个文件放在同一目录,一次链接:
bash
gcc test.c game.c -o mines
./minesVS:新建空项目,把三个文件复制进项目目录,再在【解决方案资源管理器】中"添加 → 现有项"全部纳入,Ctrl+F5 即可。注意 game.h 必须和 .c 文件在同一目录。
游戏初始棋盘实测形如(全部为未排查的 *):
text
-------- 扫雷游戏 --------
0 1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
2 * * * * * * * * *
……(共 9 行)
9 * * * * * * * * *五、可以继续扩展的方向
- 难度选择:简单 9×9/10 雷;中等 16×16/40 雷;困难 16×30/99 雷;
- 空白展开:排查到周围无雷的格子,自动递归展开一片(本讲习题 8);
- 插旗标记:标记/取消标记怀疑是雷的位置;
- 计时:记录并显示排雷用时。
📘 提高(L2):课程原代码的两处缺陷
- 重复排查会"刷进度":原
FindMine不检查格子是否已排查,重复输入安全格会让win反复 +1,可能造成没排完就提示胜利。上面的代码用show[x][y] != '*'拦截。 #define ROWS ROW+2没加括号:宏是文本替换,若以后写出ROWS*2就会变成ROW+2*2。定义数值宏一律加括号:#define ROWS (ROW + 2)。
⚠️ 常见坑与报错表
| 现象 | 原因 | 对策 |
|---|---|---|
game.h: No such file | 三个文件没放在同一目录,或 VS 项目没添加头文件 | 同目录放置;确认已"添加现有项" |
undefined reference to InitBoard | 只编译了 test.c,game.c 没参与链接 | gcc test.c game.c -o mines |
| 雷数统计出来是天文数字 | 漏写 - 8 * '0',得到的是字符 ASCII 之和 | 见 GetMineCount 原理 |
| 统计边缘格程序崩溃 | 数组只定义 9×9,邻居访问越界 | 用 11×11 的扩大棋盘 |
| 每局雷的位置一模一样 | 忘了 srand,或把 srand 写进了布雷循环 | srand 只调用一次,放在 main 开头 |
| 输入字母后提示刷屏 | scanf 匹配失败,非法字符残留在缓冲区 | 判断 scanf 返回值,异常输入直接退出 |
| 排查数字显示成奇怪符号 | count + '0' 后又被当数值处理 | 存入的是字符,显示用 %c |
🛠️ 动手练习
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
第 1 题(知识点:扩大一圈的设计 · 难度:⭐)
棋盘实际只有 9×9,数组却定义成 11×11,主要目的是?
第 2 题(知识点:双棋盘 · 难度:⭐)
show 数组游戏开始时全部初始化为 '*',作用是?
第 3 题(知识点:双棋盘的好处 · 难度:⭐⭐)
把 mine、show 设计成两个独立棋盘,带来了哪些好处?
第 4 题(知识点:胜负判定 · 难度:⭐⭐)
9×9 棋盘共 81 格、10 个雷。玩家需要正确排查出多少个非雷格才算获胜?
空①
第 5 题(知识点:字符统计原理 · 难度:⭐⭐)
GetMineCount 把 8 个邻居的字符相加后,为什么还要减去 8 * '0'?
第 6 题(知识点:原代码缺陷 · 难度:⭐⭐)
课程原始代码中,对同一个安全格反复输入坐标,会出现什么问题?
第 7 题(知识点:多文件编译 · 难度:⭐⭐)
三个文件写好后,用 gcc 生成可执行文件 mines 的正确命令是?
空①
第 8 题(知识点:递归展开 · 难度:⭐⭐⭐)
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
在 L1 代码基础上增加"展开一片"功能:排查到一个周围雷数为 0 的格子时,自动递归展开它周围的格子,直到遇到数字格或边界。
✅ 过关标准:
- 点到空白格,相连的空白区域一次全部显示,数字格正确显示雷数并停止继续穿透;
- 边界、已展开格不会被重复处理(不会无限递归);
- 展开的格子数正确计入 win,胜负判定仍然准确;
- 用 gcc 编译零警告。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 思路(卡住再点开)
这是第 9 讲递归的典型应用(flood fill,泛洪填充)。对一个格子:①越界或已展开则返回;②统计周围雷数,>0 就写数字并返回(不再扩散);③为 0 则标记为 '0',然后对周围 8 格递归调用。win 通过指针在递归中累加。
🧭 步骤
- 在 game.h 增加
ExpandBoard声明(或暂不加,直接在 game.c 中定义于 FindMine 之前); - 编写递归函数,注意先写好所有"停止条件"再写递归;
- 把 FindMine 中"安全格写数字"的分支改为调用 ExpandBoard;
- 用固定小棋盘(如 5×5、3 雷)手工验证,数展开格数。
🔑 点击查看参考答案
在 game.c 中增加下面的函数,并把 FindMine 中安全格分支替换为 ExpandBoard(mine, show, x, y, &win); DisplayBoard(show, row, col);:
c
/* 空白格递归展开,win 通过指针累加已展开格数 */
void ExpandBoard(char mine[ROWS][COLS], char show[ROWS][COLS],
int x, int y, int *win)
{
if (x < 1 || x > ROW || y < 1 || y > COL)
return;
if (show[x][y] != '*') /* 已展开/已处理,防止重复递归 */
return;
int count = GetMineCount(mine, x, y);
(*win)++;
if (count > 0)
{
show[x][y] = (char)(count + '0');
return; /* 数字格:显示后停止扩散 */
}
show[x][y] = '0'; /* 空白格:标记后向 8 个邻居扩散 */
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
if (dx != 0 || dy != 0)
ExpandBoard(mine, show, x + dx, y + dy, win);
}
}
}game.h 中补充声明:
c
void ExpandBoard(char mine[ROWS][COLS], char show[ROWS][COLS],
int x, int y, int *win);FindMine 安全格分支改为:
c
ExpandBoard(mine, show, x, y, &win);
DisplayBoard(show, row, col);实测(gcc 编译,点击到空白区域时一次展开多格,数字格处停止):展开过程与胜负判定均正常,无重复递归。
📝 小结与自测
本讲脉络:
- 扫雷 = 菜单流程 + 双棋盘(mine 存雷、show 给玩家看)+ 排查逻辑。
- 数组 11×11"扩大一圈",让边缘格的 8 邻居统计不越界;雷只布在中间 9×9。
- 字符
'0'/'1'表示非雷/雷,8 邻居字符和减去8*'0'得雷数。 - 多文件项目:
.h声明、game.c实现、test.c主流程;gcc test.c game.c -o mines。 - 扩展:难度、递归展开、标记、计时。
自测三问:
- 如果图省事直接用 9×9 数组,统计雷数时要额外做什么?扩大一圈为什么更优雅?
- 玩家排查一个安全格,数据在 mine、show 之间是怎样流动的?
- 递归展开函数的"停止条件"有哪几个?漏掉其中一个会怎样?
🔑 自测答案
- 必须在统计 8 邻居前逐个判断行列是否在 0~8(或 1~9)内,代码繁琐且容易漏;扩大一圈后所有邻居天然合法、外圈恒为 '0' 不影响计数,把"边界处理"从逻辑问题变成了数据布局问题。
- 玩家坐标先在 mine 中判断:'1' 则失败;否则 GetMineCount 从 mine 统计雷数,把结果字符写入 show 的对应位置,再打印 show。mine 的内容对玩家始终不可见(直到终局)。
- 三个:超出边界、该格已展开(show 不是 '*')、该格是数字格(雷数>0)。漏掉边界判断会越界访问;漏掉已展开判断会无限递归(栈溢出崩溃);漏掉数字格停止会让展开穿透到雷区旁边显示错误。
🔬 选学拓展(L3)
WARNING
以下为进阶内容,供学有余力的同学选学。
拓展 1:布雷效率——重试法 vs 洗牌法
简单版布雷用"随机坐标 + 已占用就重抽",格子少雷稀时很快;但困难模式(480 格放 99 雷)后期越抽越容易撞车,耗时不可预期 🟠。更稳的做法是洗牌法:
📄 L3_shuffle_mine.c · ✅ 完整程序(可直接复制编译,演示 16×30 布雷)
c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define ROW 16
#define COL 30
#define MINE_COUNT 99
int main(void)
{
int cells[ROW * COL];
for (int i = 0; i < ROW * COL; i++)
cells[i] = i; /* 1. 480 个位置排成一列 */
srand((unsigned int)time(NULL));
for (int i = ROW * COL - 1; i > 0; i--) /* 2. Fisher-Yates 洗牌 */
{
int j = rand() % (i + 1);
int tmp = cells[i];
cells[i] = cells[j];
cells[j] = tmp;
}
int placed = 0;
for (int k = 0; k < MINE_COUNT; k++) /* 3. 前 99 个位置放雷 */
{
int pos = cells[k];
printf("(%2d,%2d) ", pos / COL + 1, pos % COL + 1);
placed++;
if (placed % 4 == 0) printf("\n");
}
printf("共布置 %d 个雷\n", placed);
return 0;
}上例只演示"选出 99 个互不重复位置"的过程;正式项目中把这 99 个
(行,列)写入扩大一圈的mine[ROW+2][COL+2]棋盘即可(见 L1 代码)。洗牌法的特点:无论雷多密,工作量恒等于格子数,耗时稳定。
拓展 2:把"展开"改成非递归——用栈消除递归
递归展开在大棋盘上可能层层深入(极端情况下栈深接近格子数)。可以用一个"待处理坐标栈"把递归改写成循环:每次取出一个坐标处理,把新发现的空白格压栈。这正是下一讲"顺序表"和第 22 讲"链表"的实际用武之地——学完数据结构再回看本节,会有豁然开朗之感。
📜 标准卡(L2 选读):本讲相关依据
rand/srand:C11 §7.22.2;随机数的具体序列由实现决定 🟡。- 二维数组传参"退化第一维":C11 §6.7.6.3p7(数组形参调整为指针)。
- 多文件编译与链接属于工具链行为,标准不作规定;声明/定义规则见 C11 §6.2.2(标识符的链接)。