摘要:扫雷把二维数组、函数封装和多文件编程放进一个完整项目。本文从 9×9 棋盘和 10 个雷的需求出发,先定两个字符棋盘和外扩一圈的哨兵边界,再拆出初始化、打印、布雷、统计、排查五个函数,最后处理重复排查、非法输入和胜利判定,并给出展开一片的递归扩展。
文章目录
- [1. 需求先变成棋盘](#1. 需求先变成棋盘)
- [2. 三个文件各放什么](#2. 三个文件各放什么)
- [3. 初始化与打印](#3. 初始化与打印)
- [4. 布雷与统计](#4. 布雷与统计)
- [5. 排查与胜负](#5. 排查与胜负)
- [6. 容易漏掉的几处](#6. 容易漏掉的几处)
- [7. 扩展:展开一片](#7. 扩展:展开一片)
- [8. 小结](#8. 小结)
1. 需求先变成棋盘
控制台版扫雷(Minesweeper)要满足这些条件:
| 需求 | 具体行为 |
|---|---|
| 菜单 | 输入 1 开始一局,输入 0 退出 |
| 棋盘 | 9×9 格,随机放 10 个雷 |
| 排查 | 输入行、列;不是雷就显示周围雷数,是雷就结束本局 |
| 胜利 | 把 10 个雷之外的非雷格全部找出来 |
需求里最先要定的是数据怎么存。棋盘是二维的,每个格子要放"有没有雷"和"周围有几颗雷"两类信息。最直接的想法是一个 9×9 的二维数组,雷存 1,无雷存 0。
这个方案有两个问题。第一,统计边角格子的周围雷数时会越界:排查 (9,9) 要读 (10,10),数组没有这一格。第二,雷的信息和排查结果混在同一个数组里,打印给玩家看时还要区分"这是雷"还是"这是数字"。
对应的处理是:数组外扩一圈,做成 11×11;再用两个数组,一个存雷,一个存给玩家看的视图。
| 要解决的问题 | 方案 |
|---|---|
| 雷和排查结果存在哪里 | 两个 11×11 的字符数组 |
| 统计周围雷数时越界 | 外圈留一圈哨兵,不布雷 |
| 雷的信息和视图互相混淆 | mine 存雷,show 存排查结果 |
| 两套棋盘复用同一套函数 | 都用 char,用 '0'、'1'、'*' 和数字字符 |
| 代码怎么组织 | game.h 声明,game.c 实现,test.c 测试 |

mine 初始全为 '0',布雷的位置改成 '1';show 初始全为 '*',玩家排查后改成雷数对应的字符。两个数组的合法下标都是 0 到 10,真正参与游戏的是 1 到 9,外圈只当挡板。
c
#define ROW 9
#define COL 9
#define ROWS (ROW + 2)
#define COLS (COL + 2)
char mine[ROWS][COLS];
char show[ROWS][COLS];
外圈不布雷,值一直是 '0'。统计 (1,1) 这种角落时,周围 8 格里有 5 格落在外圈,读到的都是 0,不需要为边界单独写 if。这类专门留出来防越界的格子叫哨兵(sentinel)。
坐标按"行 列"输入,2 5 表示第 2 行第 5 列,访问 mine[2][5]。数组的 x 是行号、y 是列号,和数学里横纵坐标的习惯相反,输入提示要写清楚。
2. 三个文件各放什么
项目拆成三个文件:
| 文件 | 职责 | 包含关系 |
|---|---|---|
game.h |
宏、函数声明 | 被 game.c、test.c 包含 |
game.c |
各功能函数的定义 | 包含 game.h |
test.c |
main、菜单、一局游戏的流程 |
包含 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(char board[ROWS][COLS], int row, int col);
void SetMine(char board[ROWS][COLS], int row, int col);
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);
宏体要加括号。#define ROWS ROW + 2 单独当实参没问题,一旦参与运算就会出错:ROWS * COLS 会展开成 ROW + 2 * COL + 2,得到 29 而不是 121。写成 (ROW + 2) 才安全。
在 VS2026 里新建空项目,把 game.h、game.c、test.c 都添加到项目,配置选 Debug,按 Ctrl + F5 运行。三个文件一起编译链接,头文件不单独编译,只被两个 .c 包含。
3. 初始化与打印
初始化要把整个 11×11 填满,不能只填中间的 9×9:
c
// game.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;
}
InitBoard(mine, ROWS, COLS, '0') 和 InitBoard(show, ROWS, COLS, '*') 都传 11。如果只初始化 9×9,外圈就是未初始化的值,统计边角雷数时会把它们加进去,结果直接错乱。
下面这些函数都写在 game.c 里。打印只显示 1 到 9 的内区,外圈不显示:
c
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
printf(" ");
for (int j = 1; j <= col; j++)
printf("%2d", j);
printf("\n");
for (int i = 1; i <= row; i++)
{
printf("%2d ", i);
for (int j = 1; j <= col; j++)
printf("%2c", board[i][j]);
printf("\n");
}
}
初始 show 棋盘输出:
text
1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
2 * * * * * * * * *
3 * * * * * * * * *
4 * * * * * * * * *
5 * * * * * * * * *
6 * * * * * * * * *
7 * * * * * * * * *
8 * * * * * * * * *
9 * * * * * * * * *
表头先打印三个空格,再按 %2d 打列号;行号也用 %2d ,列号和格子才能对齐。棋盘扩到 16×16 时,一位数和两位数宽度不同,统一用 %2d 就不会错位。
4. 布雷与统计
布雷在 1 到 9 的范围内随机取坐标,位置已被占用就重试:
c
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--;
}
}
}
rand() % row 得到 0 到 8,加 1 就是 1 到 9,外圈永远不会被选中。count 只在成功放下一颗雷时减 1,所以不会重复布雷,也不会少放。
随机种子在 main 里设一次:
c
srand((unsigned int)time(NULL));
rand 默认种子是 1,不调用 srand 每次运行布局都一样。time(NULL) 精确到秒,如果把 srand 放进每局游戏,同一秒内连开两局会拿到同一个种子,两局布局完全相同。
统计周围 8 格的雷数:
c
static int GetMineCount(char mine[ROWS][COLS], int x, int y)
{
return mine[x - 1][y - 1] + mine[x - 1][y] + mine[x - 1][y + 1]
+ mine[x][y - 1] + mine[x][y + 1]
+ mine[x + 1][y - 1] + mine[x + 1][y] + mine[x + 1][y + 1]
- 8 * '0';
}
8 个格子里存的是字符 '0' 或 '1'。字符相加得到的是编码值之和,减去 8 * '0' 才得到数字形式的雷数。返回 0 到 8 的整数。
GetMineCount 前加 static,表示只在 game.c 内部使用,其他文件看不到。它依赖外圈全是 '0',所以角落和中间可以共用同一份代码。
5. 排查与胜负
排查是游戏的主循环:
c
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
int x = 0;
int y = 0;
int win = 0;
int c = 0;
while (win < row * col - EASY_COUNT)
{
printf("请输入要排查的坐标:>");
if (scanf("%d %d", &x, &y) != 2)
{
if (feof(stdin))
{
printf("\n输入结束,退出本局\n");
return;
}
while ((c = getchar()) != '\n' && c != EOF)
;
printf("输入无效,请重新输入\n");
continue;
}
if (x < 1 || x > row || y < 1 || y > col)
{
printf("坐标非法,重新输入\n");
continue;
}
if (mine[x][y] == '1')
{
printf("很遗憾,你被炸死了\n");
DisplayBoard(mine, row, col);
return;
}
if (show[x][y] != '*')
{
printf("该坐标已排查,请重新输入\n");
continue;
}
show[x][y] = (char)(GetMineCount(mine, x, y) + '0');
DisplayBoard(show, row, col);
win++;
}
printf("恭喜你,排雷成功\n");
DisplayBoard(mine, row, col);
}
循环条件是 win < row * col - EASY_COUNT,9×9 棋盘、10 个雷,就是 71 个非雷格。win 只在成功揭开一个新格子时加 1:
- 踩到
'1',打印mine棋盘,return结束本局。 - 坐标越界,提示后
continue,不计数。 show[x][y]不是'*',说明之前已经排查过,提示后continue,不重复计数。- 合法且未排查,算出雷数,写入
show,打印,win++。
win 达到 71 时循环结束,打印胜利信息和 mine 棋盘。
输入处理分两层。scanf 返回值不是 2,说明没读到两个整数:如果已经到文件末尾,直接退出本局;否则用 getchar 把这一行剩下的字符读掉,再提示重新输入。只检查坐标范围、不检查 scanf 返回值,输入字母时 x、y 会保持旧值,循环可能一直转。
scanf 在 VS2026 会报 C4996。在 game.h 第一行、任何 #include 之前加 #define _CRT_SECURE_NO_WARNINGS 1,后文代码省略这一行。
test.c 负责菜单和一局游戏的流程:
c
#include "game.h"
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);
FindMine(mine, show, ROW, COL);
}
int main(void)
{
int input = 0;
int c = 0;
srand((unsigned int)time(NULL));
do
{
menu();
printf("请选择:>");
if (scanf("%d", &input) != 1)
{
if (feof(stdin))
break;
while ((c = getchar()) != '\n' && c != EOF)
;
printf("输入无效,请重新选择\n");
input = -1;
continue;
}
switch (input)
{
case 1:
game();
break;
case 0:
printf("退出游戏\n");
break;
default:
printf("选择错误,重新选择\n");
break;
}
} while (input);
return 0;
}
把随机种子固定成 1,依次输入 1、7 5、8 5、0,输出节选:
text
1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
...
9 * * * * * * * * *
请输入要排查的坐标:> 1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
...
7 * * * * 2 * * * *
...
请输入要排查的坐标:>很遗憾,你被炸死了
1 2 3 4 5 6 7 8 9
1 0 0 0 0 0 0 1 0 0
2 0 0 0 0 0 0 0 0 0
3 0 0 0 0 0 0 0 0 0
4 1 0 0 0 0 0 0 0 0
5 0 0 0 0 0 0 0 0 0
6 0 0 0 0 0 0 0 0 1
7 0 0 0 0 0 0 0 0 0
8 0 0 1 0 1 1 1 0 1
9 0 1 1 0 0 0 0 0 0
7 5 周围有 2 颗雷,显示 2;8 5 本身是雷,踩中后打印 mine 棋盘。mine 里的 1 是雷,0 是安全格。
6. 容易漏掉的几处
| 问题 | 现象 | 处理 |
|---|---|---|
| 重复排查 | 同一个坐标反复输入,胜利进度虚增 | 写入前判断 show[x][y] != '*' |
scanf 失败 |
输入字母后旧值不变,反复提示或死循环 | 检查返回值,清掉本行再继续 |
| 宏没有括号 | ROWS * COLS 算成 29 |
宏体写成 (ROW + 2)、(COL + 2) |
| 表头宽度 | 棋盘大于 9 后行列号错位 | 统一用 %2d |
| 菜单输入失败 | 输入字母后直接走 case 0 退出 |
菜单也检查 scanf 返回值 |
这些问题的共同点是"输入和边界没有单独处理"。写游戏时先把输入校验、坐标范围、重复操作三件事列出来,再写主循环,能少走很多弯路。
7. 扩展:展开一片
现在的版本一次只揭开一个格子。扫雷里常见的效果是:如果揭开的格子周围没有雷,就把周围一片一起展开。这正好是递归:
c
static int Expand(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y)
{
if (x < 1 || x > ROW || y < 1 || y > COL)
return 0;
if (mine[x][y] == '1')
return 0;
if (show[x][y] != '*')
return 0;
int count = GetMineCount(mine, x, y);
show[x][y] = (char)(count + '0');
int opened = 1;
if (count == 0)
{
for (int i = x - 1; i <= x + 1; i++)
for (int j = y - 1; j <= y + 1; j++)
opened += Expand(mine, show, i, j);
}
return opened;
}
三个终止条件缺一不可:越出 1 到 9 的内区、踩到雷、格子已经揭开。最后一个条件防止相邻格来回递归。只有当前格雷数为 0 才继续展开 8 个邻居,否则只揭开当前格。
这段按固定 9×9 写;做难度选择时,把 ROW、COL 换成传入的 row、col。
在 FindMine 里把 win++ 换成:
c
win += Expand(mine, show, x, y);
Expand 返回本次新揭开的格子数,胜利判定式不用改。递归的细节在《函数递归》一篇展开。
其他扩展方向:难度选择(改 ROW、COL、EASY_COUNT)、标记雷、计时、第一次点击不炸。改难度时注意表头宽度和雷数上限,16×16 棋盘放 40 个雷,30×16 棋盘放 99 个雷。
8. 小结
- 扫雷用两个字符棋盘:
mine存雷,show存玩家视图;数组外扩一圈,边角统计不用特判。 - 字符
'0'、'1'相加后减8 * '0'得到雷数,显示时再+ '0'转回字符。 SetMine用rand() % row + 1取 1 到 9,位置重复就重试;srand在main里只调一次。- 排查主循环要处理坐标范围、重复排查、
scanf返回值和 EOF,胜利条件是揭开全部非雷格。 - 宏体加括号,
ROWS * COLS才不会展开错;表头用%2d适配两位数。 - 展开一片用递归:越界、踩雷、已揭开都返回,雷数为 0 才继续展开 8 个邻居。
- 项目拆成
game.h、game.c、test.c,声明和实现分开,三个文件一起编译链接。