扫雷大概是很多人的童年回忆:一个 9×9 的棋盘里藏着 10 颗雷,点开格子看数字,数字告诉你周围八格有几颗雷,把雷全部找出来就赢了。它练逻辑、练细心,也是学 C 语言时非常经典的练手项目------控制台就能跑,但里面该有的设计问题一个不少:数据结构、随机数、边界处理、输入校验、循环控制,全都用得上。
这篇文章把我写这个扫雷游戏的过程完整记录下来:先讲设计思路,再按顺序讲每一步代码是怎么写出来的,最后附上完整代码。跟着文章走一遍,你也能写出属于自己的扫雷。

先想清楚:扫雷到底在玩什么
动手写代码之前,先把规则翻译成程序能理解的需求。扫雷的规则可以拆成几条:
- 棋盘:9 行 9 列,共 81 个格子;
- 布雷:随机在其中 10 个格子里埋雷,玩家不知道位置;
- 排查:玩家输入坐标,翻开一个格子;
- 踩雷:翻开的格子是雷,游戏失败;
- 数字:翻开的格子不是雷,显示它周围 8 格里有几颗雷;
- 胜利:把 81 − 10 = 71 个非雷格子全部翻开,游戏胜利。
把规则翻译成需求,程序要干的事就很清楚了:
- 初始化:把棋盘全部初始化好;
- 布雷:随机埋下 10 颗雷;
- 排查:读入玩家坐标,判断踩没踩雷;
- 统计:没踩雷时数出周围雷数并显示;
- 胜负判断:踩雷失败,翻完 71 格胜利。


整体设计:三个文件 + 两块棋盘
模块划分:三个文件各管一摊
写之前先想清楚怎么组织代码。我把整个程序拆成三个文件,职责清清楚楚:
| 文件 | 职责 |
|---|---|
| test.c | 主函数与菜单:负责和玩家交互,选择开始游戏或退出 |
| game.h | 头文件:宏定义(棋盘大小、雷数)和函数声明,是整个程序的"约定" |
| game.c | 游戏逻辑实现:初始化、布雷、打印、排查雷 |
一句话总结:game.h 定约定,game.c 干活,test.c 喊开始。
两块棋盘:把"真相"和"展示"分开
这个程序里最核心的设计,是用两块棋盘。
- mine(布雷数组):记录雷的真实位置,'0' 表示没雷,'1' 表示有雷,只给程序自己看;
- show(展示数组):记录玩家看到的内容,初始全是 '*',翻开的格子会变成数字。
为什么不用一块棋盘?因为玩家的视角和真相是两回事:show 要给玩家看,mine 要保密。把"真相"和"展示"分开,逻辑才清楚------排查时从 mine 读雷,显示时写 show,互不干扰。
3. ## 关键设计:为什么棋盘要开成 11×11
还有一个非常关键的设计:棋盘不是开 9×9,而是开 11×11(ROWS = ROW + 2)。
原因在统计周围雷数:点开 (x, y) 后,要检查 (x−1..x+1, y−1..y+1) 一共 8 个格子。如果棋盘只有 9×9,最外一圈格子(比如 (1, 1))检查时会越界访问。把棋盘四周多围一圈"哨兵",初始都是 '0',任何格子都能统一用 8 方向遍历,边界格子也不会出错。
游戏只在 1, 9 这个范围内布雷、排查;第 0 行、第 10 行这些边界格子永远不参与游戏,只负责"兜底"。
程序跑起来的整体流程
3. # 一步步实现
第一步:game.h,先把约定定下来
头文件是整个程序的"契约"。棋盘多大、几颗雷、每个函数长什么样,都先在这里定好;game.c 和 test.c 只要 include 它,就知道该怎么配合。
arduino
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define ROW 9
#define COL 9
#define ROWS ROW+2
#define COLS COL+2
#define EASY_COUNT 10
//初始化棋盘
void InitBoard(char board[ROWS][COLS], int row, int col, 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);
//统计周围雷的个数
int CountMine(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y);
几个宏的含义:ROW、COL 是玩家看到的棋盘 9×9;ROWS、COLS 是带边界的实际数组大小 11×11;EASY_COUNT 是雷数 10。后面所有函数都按 ROWS、COLS 声明,实际游戏范围用 ROW、COL。
第二步:test.c,菜单和主循环
主程序做两件事:打印菜单,然后根据玩家选择进入游戏或退出。用一个 do...while 循环,保证先执行一次再判断,正好符合"先问再走"的游戏流程。
这里有个容易忽略的细节:scanf 读入失败(比如玩家输入了字母)时,缓冲区里会残留内容,导致循环卡死。所以用 if (scanf(...) != 1) 判断读入是否成功,失败时用 while (getchar() != '\n') 把缓冲区清空,再 continue 重新选。
arduino
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include "game.h"
menu()
{
printf("**************************\n");
printf("***** 1. 扫雷游戏 *******\n");
printf("***** 0. 退出游戏 *******\n");
printf("**************************\n");
}
void game()
{
char mine[ROWS][COLS];//布雷数组
char show[ROWS][COLS];//排查出雷的信息
//初始化棋盘
InitBoard(mine, ROWS, COLS, '0');
InitBoard(show, ROWS, COLS, '*');
//打印棋盘
DisplayBoard(show, ROW, COL);
//DisplayBoard(mine, ROW, COL);
//布雷
SetMine(mine, ROWS, COLS);
//DisplayBoard(mine, ROW, COL);
//排查雷
FindMine(mine, show, ROW, COL);
}
int main()
{
int input = 2;
do
{
menu();
printf("请选择:>");
if (scanf("%d", &input) != 1)
{
printf("输入错误,请重新输入!\n");
while (getchar() != '\n'); // 清空输入缓冲区
continue;
}
switch (input)
{
case 1:
game();
break;
case 0:
printf("退出游戏\n");
break;
default:
printf("选择错误,只能输入0或1,请重新选择!\n");
break;
}
} while (input);
return 0;
}
-
第三步:InitBoard,初始化棋盘
初始化没什么技巧,双循环把每个格子填成指定的字符:mine 填 '0',show 填 '*'。把"填什么"也作为参数传进来,一个函数就能初始化两块棋盘,不用写两遍。
ini
void InitBoard(char board[ROWS][COLS], int row, int col, char set)
{
for (int i = 0; i < row; i++)
{
for (int j = 0; j < col; j++)
{
board[i][j] = set;
}
}
}
-
第四步:DisplayBoard,把棋盘打印出来
打印棋盘要解决一个阅读问题:玩家输入坐标时,怎么知道 (3, 5) 是哪一格?所以在棋盘上方和左侧打印行列号:先打印一行列号,然后每一行开头打印行号,再打印格子内容。
ini
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
int i = 0;
int j = 0;
printf("-------扫雷游戏--------\n");
for (j = 0; j < col + 1; j++)
{
printf("%d ", j);
}
printf("\n");
for (i = 1; i < row + 1; i++)
{
printf("%d ", i);
for (j = 1; j < col + 1; j++)
{
printf("%c ", board[i][j]);
}
printf("\n");
}
}
注意打印时循环从 1 开始、到 row 和 col 结束,正好跳过最外圈的边界行,玩家看到的就是一个干净的 9×9 棋盘。
第五步:SetMine,随机布雷
布雷用 rand() 生成随机坐标。这里有个容易踩的坑:rand() 是伪随机,如果不设置随机种子,每次运行生成的"随机"序列是完全一样的------也就是每次玩,雷的位置都一样。所以 main 里要先调用一次 srand((unsigned)time(NULL)) 初始化种子(头文件里已经 include 了 time.h)。
另外布雷时用 while (count) 循环:每成功埋下一颗雷,count 就减一,直到 10 颗雷全部埋完;如果随机到的格子已经有雷,就重新随机,保证不重复。
ini
void SetMine(char board[ROWS][COLS], int row, int col)
{
int count = 10;
while (count)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (board[x][y] == '0')
{
board[x][y] = '1';
count--;
}
}
}
-
第六步:CountMine,统计周围雷数
这是"数字"的来源:数一数 (x, y) 周围 8 个格子里有几个 '1',周围就有几颗雷。因为棋盘有边界圈,这里可以放心地遍历 x−1 到 x+1、y−1 到 y+1,不会越界。
ini
int CountMine(char mine[ROWS][COLS], char show[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 (mine[i][j] == '1')
{
count++;
}
}
}
return count;
}
-
第七步:FindMine,把排查雷串起来(核心)
最后把所有零件组装起来。FindMine 是游戏的主循环,逻辑是这样:
- 玩家输入坐标 (x, y);
- 先校验坐标是否在 1~9 范围内,不在就提示重输;
- 在范围内就查 mine:是 '1' 就踩雷了,打印雷区,游戏结束;
- 不是雷,再看 show 对应位置是不是 '*':已经翻过就提示重复;没翻过就调用 CountMine 算出周围雷数,把数字写进 show,win 加一;
- win 达到 71(81 − 10),所有非雷格子都翻完了,恭喜通关。
ini
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
int x = 0;
int y = 0;
int win = 0;
while (win < (row * col - EASY_COUNT))
{
printf("请输入排查雷的坐标:>");
scanf("%d %d", &x, &y);
if (x >= 1 && x <= row && y >= 1 && y <= col)
{
if (mine[x][y] == '1')
{
printf("很遗憾,你被炸死了!\n");
DisplayBoard(mine, row, col);
break;
}
else
{
if (show[x][y] == '*')
{
show[x][y] = CountMine(mine, show, x, y) + '0';
DisplayBoard(show, row, col);
win++;
}
else
{
printf("该位置已经排查过了,请重新输入!\n");
continue;
}
}
}
else
{
printf("输入坐标有误,请重新输入!\n");
}
if (win == (row * col - EASY_COUNT))
{
printf("恭喜你,排查成功!\n");
DisplayBoard(mine, row, col);
}
}
}
注意这句:showxy = CountMine(mine, show, x, y) + '0';------CountMine 返回的是整数,加上字符 '0' 就变成了对应的数字字符,可以直接存进字符数组。这就是用字符存棋盘的好处。
几个值得回味的细节
- 字符 '0'、'1' 的小聪明:棋盘用字符而不是整数,'1' − '0' = 1,整数转字符只需要 + '0',打印时也直接 %c 输出,省去一堆类型转换;
- 边界圈的含金量:没有 ROWS、COLS 的设计,CountMine 就要写一堆 if 判断边界,代码瞬间变丑;
- scanf 输入缓冲:读入失败要清空缓冲区,否则循环卡死;
- 记得 srand:rand 不设种子,每次游戏雷的位置都一样,这就不是"扫雷"而是"背答案"了;
- 小瑕疵提醒:menu() 没写返回类型(C 语言默认按 int 处理,会出警告),建议写成 void menu();CountMine 的 show 参数其实没用到,可以去掉。