ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言写扫雷:数组、递归与控制台实现的完整实战教程

C语言写扫雷:数组、递归与控制台实现的完整实战教程 说实话C语言写扫雷这件事在编程练手圈子里几乎属于“必做项目”。原因很简单它不像九九乘法表那样一两个循环就结束又不像写一个操作系统那样遥不可及。扫雷刚好卡在一个特别舒服的位置——足够小小到一个人周末能写完又足够大大到数组、随机数、递归、输入输出、边界处理这些C语言基础知识点全都能串起来。我自己的这版扫雷是几个星期前整理旧代码时翻出来的纯控制台版没有图形界面总共两百来行C代码但该有的东西一样不缺布雷、数字提示、插旗、标记取消、空白区域自动展开、胜利判定、失败判定还有“第一次点击绝不踩雷”的安全机制。这篇文章我想把整个复刻思路、数据结构设计、完整实现步骤以及我踩过的坑都写下来。不管你是期末大作业选手还是单纯想找个项目练手看完应该都能自己从头写一遍。1. 项目概述与整体设计思路1.1 扫雷到底在练什么很多人以为扫雷就是个“二维数组加随机数”的作业其实真写起来才发现它暗藏了不少东西。最表面的一层是二维数组。棋盘本身就是一个矩阵每个格子要么是雷、要么是数字、要么是空白。你至少需要一个数组来存棋盘内容还需要另一个数组来记每个格子的玩家操作状态——是没翻开、已翻开还是插了旗。这就牵扯到“一个格子有多个维度的信息”怎么表达的问题。再往里一层是随机数。布雷要用rand()但rand()如果不设置随机种子每次运行结果都一样。很多新手第一次跑程序发现雷的位置永远不变就是这个原因。这个坑特别经典我在后面专门讲。深入一层就是递归。当你点到一个周围没有雷的空白格时游戏要把整片空白区域全部展开甚至还要连带展开空白格边缘的数字格。这个逻辑用递归写最直观但递归很容易写崩。边界条件没有判断好直接数组越界程序当场崩溃。除此之外还有输入处理、格式化输出、游戏状态机设计全是C语言里绕不开的基本功。所以扫雷能被当成课程设计题目不是没有道理的它几乎是一个“C语言知识点打包器”。1.2 为什么用控制台而不是图形界面我知道有人在想扫雷做个图形界面不是更帅吗用easyx、SDL或者Qt画格子、贴雷图、加个计时器看起来高端多了。但我的建议是如果你是在练C语言第一版务必写控制台版。原因有三个。第一控制台版把核心逻辑和界面渲染彻底分离。你在控制台里用星号表示未翻开格子、用数字表示周围雷数所有的注意力都会集中在游戏规则本身。图形界面反而会让你分心去处理贴图、鼠标事件、坐标转换这些跟C语言语法关系不大的东西。第二控制台版代码量小调试方便。一个两百行的程序出问题了打印几行棋盘就能定位。图形界面一旦出错连是逻辑问题还是渲染问题都很难分清。第三控制台版才是“硬核练手”的版本。很多人觉得控制台版low实际上把控制台版做到交互流畅、逻辑严谨比套个图形界面难多了。等你把控制台版写明白了再想加图形界面只是换一层皮的事。我这里说的控制台版指的是在终端里运行、通过命令行输入坐标来操作的那种程序。它在Windows的CMD、PowerShell、Linux的终端、macOS的Terminal里都能跑跨平台性也好。1.3 功能清单我最终实现的这版扫雷功能可以列成下面这张清单10x10的棋盘20颗地雷模拟Windows经典扫雷的初级难度。玩家通过输入“行号 列号 操作码”来翻格子或插旗操作码1表示翻开2表示插旗/取消插旗。跳到数字格子时只翻开当前格不会误触周围点到空白格子时自动递归展开整片连通空白区域。支持插旗标记插旗的格子不能被翻开再插一次可以取消标记。第一次点击时布雷保证第一下永远不会踩雷。踩雷后显示所有雷的位置并输出游戏结束信息。翻开所有非雷格子后判定胜利。每次操作后重新打印当前棋盘状态行首列首带坐标编号方便定位。这个功能范围其实已经覆盖了网上大多数“C语言课程设计扫雷”的评分点。如果学校要求再加什么功能在这个基础上扩展也不难。2. 游戏核心机制拆解2.1 棋盘数据结构设计先讲一个关键的设计决策一个格子要同时保存“地图内容”和“玩家状态”到底怎么存最简单的方案是开两个二维数组。一个数组叫map存地图本身的内容-1表示地雷0~8表示这个格子周围八个方向里有几颗雷。另一个数组叫state存玩家视角的状态0表示未翻开1表示已翻开2表示插了旗。有人在写作业的时候喜欢用一个数组搞定比如用负数表示雷、用正数表示数字但这样会把两个维度的信息混在一起代码读起来非常痛苦。我的经验是能用两个数组表达清楚的东西就不要强行压缩成一个。C语言最怕的就是逻辑不清晰宁可多开一个数组也别让代码变成一坨只有自己能看懂的谜语。这两块数据都是全局变量行数和列数用#define常量定义。用宏而不是魔法数字有一个好处以后想改棋盘大小只改一行就够了。#define ROW 10 #define COL 10 #define MINE 20 int map[ROW][COL]; int state[ROW][COL];有人可能问为什么不定义成结构体比如struct Cell { int mine; int open; };。这也是一种做法而且从软件工程的角度看更规范。但对于一个两百行的控制台程序来说两个数组反而更直观循环遍历也方便。你自己写的时候可以根据习惯来重点是逻辑清晰。2.2 布雷与数字计算布雷的核心要求是恰好生成20颗雷且位置随机不重复。实现方式其实很简单用rand()生成随机行号和列号如果当前位置还不是雷就放一颗雷直到放满为止。void placeMines() { int count 0; while (count MINE) { int x rand() % ROW; int y rand() % COL; if (map[x][y] ! -1) { map[x][y] -1; count; } } }这个while循环看起来简单但有一个前提是你必须调用过srand((unsigned)time(NULL))否则每次运行程序生成的随机序列完全一样。注意这个初始化最好放在main函数的最开头而不是放在布雷函数里反复调用。布雷之后就要计算数字。遍历每个非雷格子统计它周围八个方向中雷的数量把结果写进map[i][j]。这里最常规的写法是两层循环枚举偏移量void calcNumbers() { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (map[i][j] -1) continue; int cnt 0; for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { if (dx 0 dy 0) continue; int nx i dx; int ny j dy; if (nx 0 nx ROW ny 0 ny COL map[nx][ny] -1) { cnt; } } } map[i][j] cnt; } } }这段代码里nx和ny越界检查是必须的。棋盘边缘的格子周围没有完整的八个邻居如果不判断越界就会访问到数组外面程序轻则乱码重则崩溃。这里补充一个很多竞赛党喜欢用的优化技巧把棋盘数组开大两圈真正显示的格子索引从1开始外围一圈值固定为0。这样在统计边缘格子时因为外围一圈永远不是雷越界问题直接被“缓冲带”吸收掉了循环里可以少写几个if。我写扫雷的时候依然用的越界判断因为直观但如果你觉得边界判断写起来烦可以试试缓冲带方案。2.3 空白区域自动翻开的递归逻辑扫雷最核心的一个交互是点到一个空白格整片连通的空白区域全部展开。这个逻辑用递归写起来非常漂亮。翻开一个格子的过程可以这样描述如果坐标越界直接返回。如果这个格子已经被翻开过或者插了旗直接返回。将这个格子标记为已翻开。如果这个格子是数字格到此为止不再继续。如果这个格子是空白格递归翻开它周围八个方向的格子。写成代码是这样void openCell(int x, int y) { if (x 0 || x ROW || y 0 || y COL) return; if (state[x][y] ! 0) return; if (map[x][y] -1) { // 踩雷 state[x][y] 1; return; } state[x][y] 1; if (map[x][y] 0) { for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { openCell(x dx, y dy); } } } }这段代码最关键的细节是只有当前格子是空白格时才递归。如果当前格子是数字格只翻开它自己不会蔓延。这保证了数字会像边界一样拦住空白区域的扩散。我见过很多新手写的版本死活不对问题往往出在一开始的边界判断。递归函数里忘记判断坐标越界第一次点到棋盘边缘的空白格就会导致数组越界。所以每写一个递归函数第一步先把所有退出条件列出来这是经验。这个递归展开的过程在算法里有个名字叫泛洪填充它和图像处理里的“油漆桶”填充是同一个思想。如果你以后学广度优先搜索会发现这本质上也是一个搜索问题只不过我用深度优先的方式解决了。2.4 输入交互与安全开局设计扫雷的控制台输入设计我采用的是最简单的三参数方式玩家输入“行号、列号、操作码”操作码1表示翻开2表示插旗或取消插旗。比如想翻开第3行第4列就输入3 4 1想在第5行第2列插旗就输入5 2 2这种交互方式虽然不如键盘方向键加空格那样优雅但在控制台程序里已经足够直观。关键是代码里要把玩家输入的1~ROW坐标转换成数组的0~ROW-1下标也就是x--、y--这个细节特别容易漏。再讲一个比较高级的设计安全开局。经典扫雷里第一次点击是绝对不会踩雷的实现方法有两种。第一种是简单粗暴如果第一次点击踩到了雷就在这个位置重新布雷直到这个位置不是雷为止。第二种更优雅干脆把布雷推迟到第一次点击之后。进入游戏时先不布雷玩家第一次点击后再布雷并且把当前格排除在雷的位置之外。我采用第二种方案。具体做法是在主循环里加一个firstMove标志第一次操作时调用一个修正函数void ensureFirstSafe(int fx, int fy) { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { map[i][j] 0; } } int count 0; while (count MINE) { int x rand() % ROW; int y rand() % COL; if (map[x][y] ! -1 !(x fx y fy)) { map[x][y] -1; count; } } calcNumbers(); }然后在第一次翻开时先调用这个函数再走到递归展开逻辑。这样玩家永远不可能第一下就踩雷体验会好很多。3. 从零到一完整实操记录3.1 环境准备我开发这版扫雷用的编辑环境是VS Code加MinGW的gcc编译器Linux下直接用的gcc两者没有本质区别。如果你在Windows上最省事的方案是装一个VS Code再装C/C扩展插件编译器可以用MinGW-w64。如果你嫌配置麻烦直接用Visual Studio新建一个空项目把下面代码贴进去也能跑起来。Visual Studio里唯一要注意的是scanf可能会提示安全问题可以改成scanf_s或者像我一样在代码文件最顶部加一行宏定义禁用掉这个安全警告。#define _CRT_SECURE_NO_WARNINGS这行宏必须放在#include之前才会生效这是VS平台的一个小坑。我当年第一次在这个报错上卡了半小时后来才知道原来是VS默认把scanf当成了不安全函数。编译命令很简单在终端里执行gcc mine.c -o mine然后运行./mine如果有编译错误优先看第一个错误别急着往下翻。编译器经常因为第一个错误迷失方向后面报一堆连锁反应的错。3.2 主流程设计主程序的流程其实就是一个大循环打印当前棋盘。读取玩家输入。处理输入翻开或插旗。判断是否踩雷、是否胜利。回到第一步循环。这部分你会接触到一个经典的编程概念状态机。整个游戏就是在“进行中、胜利、失败”三种状态之间切换。我的实现里没用枚举类型直接通过返回值和控制流来处理。如果你想更规范一点可以定义一个enum GameStatus { PLAYING, WIN, LOSE }然后在大循环里根据状态跳转。两种写法都能跑但理解状态机思想比具体写法更重要。主函数的骨架大致如下int main() { srand((unsigned)time(NULL)); init(); int firstMove 1; while (1) { printBoard(); int x, y, op; printf(输入: 行 列 操作(1翻开 2插旗) ); scanf(%d %d %d, x, y, op); if (x 1 || x ROW || y 1 || y COL) { printf(坐标超出范围\n); continue; } x--; y--; if (op 2) { if (state[x][y] 0) state[x][y] 2; else if (state[x][y] 2) state[x][y] 0; continue; } if (op ! 1) { printf(未知操作码\n); continue; } if (firstMove) { ensureFirstSafe(x, y); firstMove 0; } if (map[x][y] -1) { revealAll(); printBoard(); printf(踩雷了游戏结束。\n); break; } openCell(x, y); if (checkWin()) { printBoard(); printf(恭喜你排掉了所有地雷\n); break; } } return 0; }这个流程里有个细节要注意插旗的判断必须在踩雷判断之前。否则你想给一个雷插旗程序会先判定你踩雷直接游戏结束那插旗功能就没意义了。3.3 核心功能代码解读初始化函数的职责是把两个数组全部清零然后布雷然后计算数字。void init() { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { map[i][j] 0; state[i][j] 0; } } placeMines(); calcNumbers(); }打印棋盘的时候我会在顶部打印列号左侧打印行号方便玩家对照坐标。void printBoard() { printf(\n ); for (int j 0; j COL; j) { printf(%2d , j 1); } printf(\n); for (int i 0; i ROW; i) { printf(%2d , i 1); for (int j 0; j COL; j) { if (state[i][j] 0) { printf( * ); } else if (state[i][j] 2) { printf( F ); } else { if (map[i][j] -1) printf( * ); else if (map[i][j] 0) printf( ); else printf(%2d , map[i][j]); } } printf(\n); } }这里对三个状态分别处理未翻开显示星号插旗显示字母F已翻开则根据地图内容显示数字或空格。已翻开但是踩雷的格子也显示星号这个会在失败后通过revealAll()把雷的state全部置为1从而暴露出来。胜利判定函数要数一下已经翻开了多少个格子。int checkWin() { int opened 0; for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (state[i][j] 1) opened; } } return opened ROW * COL - MINE; }逻辑很直白把除雷之外的所有格子都翻开了就赢了。这个判断放到每次操作之后执行只要翻开的格子数等于总格子数减雷数胜利。玩一局游戏主流程大概就是上面这些。我把完整的代码贴出来方便你直接对照复现。这个版本没有做图形界面也没有加计时器但作为课程设计或者练手已经足够了。#define _CRT_SECURE_NO_WARNINGS #include stdio.h #include stdlib.h #include time.h #define ROW 10 #define COL 10 #define MINE 20 int map[ROW][COL]; int state[ROW][COL]; void calcNumbers() { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (map[i][j] -1) continue; int cnt 0; for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { if (dx 0 dy 0) continue; int nx i dx; int ny j dy; if (nx 0 nx ROW ny 0 ny COL map[nx][ny] -1) { cnt; } } } map[i][j] cnt; } } } void placeMines() { int count 0; while (count MINE) { int x rand() % ROW; int y rand() % COL; if (map[x][y] ! -1) { map[x][y] -1; count; } } } void ensureFirstSafe(int fx, int fy) { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { map[i][j] 0; } } int count 0; while (count MINE) { int x rand() % ROW; int y rand() % COL; if (map[x][y] ! -1 !(x fx y fy)) { map[x][y] -1; count; } } calcNumbers(); } void init() { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { map[i][j] 0; state[i][j] 0; } } } void printBoard() { printf(\n ); for (int j 0; j COL; j) { printf(%2d , j 1); } printf(\n); for (int i 0; i ROW; i) { printf(%2d , i 1); for (int j 0; j COL; j) { if (state[i][j] 0) { printf( * ); } else if (state[i][j] 2) { printf( F ); } else { if (map[i][j] -1) printf( * ); else if (map[i][j] 0) printf( ); else printf(%2d , map[i][j]); } } printf(\n); } } void openCell(int x, int y) { if (x 0 || x ROW || y 0 || y COL) return; if (state[x][y] ! 0) return; if (map[x][y] -1) { state[x][y] 1; return; } state[x][y] 1; if (map[x][y] 0) { for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { openCell(x dx, y dy); } } } } void revealAll() { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (map[i][j] -1) state[i][j] 1; } } } int checkWin() { int opened 0; for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (state[i][j] 1) opened; } } return opened ROW * COL - MINE; } int main() { srand((unsigned)time(NULL)); init(); int firstMove 1; while (1) { printBoard(); int x, y, op; printf(输入: 行 列 操作(1翻开 2插旗) ); scanf(%d %d %d, x, y, op); if (x 1 || x ROW || y 1 || y COL) { printf(坐标超出范围\n); continue; } x--; y--; if (op 2) { if (state[x][y] 0) state[x][y] 2; else if (state[x][y] 2) state[x][y] 0; continue; } if (op ! 1) { printf(未知操作码\n); continue; } if (firstMove) { ensureFirstSafe(x, y); firstMove 0; } if (map[x][y] -1) { revealAll(); printBoard(); printf(踩雷了游戏结束。\n); break; } openCell(x, y); if (checkWin()) { printBoard(); printf(恭喜你排掉了所有地雷\n); break; } } return 0; }3.4 编译运行与效果演示用gcc编译这个文件的时候正常的终端输出应该是干干净净的。编译通过后运行第一屏会显示1 2 3 4 5 6 7 8 9 10 1 * * * * * * * * * * 2 * * * * * * * * * * 3 * * * * * * * * * * ...看起来就是一张全星号的棋盘。输入坐标翻开第一格后可能一下子展开一大片棋盘上出现一堆数字和空格。这就是泛洪填充在起作用。如果你点到了数字格只会翻开当前格点到空白格整片空白连同边缘数字一起展开。实际体验已经和经典扫雷的逻辑差不多了。如果踩雷程序会打印出所有雷的位置然后用“踩雷了”来收场。如果翻开了所有非雷格子程序会提示胜利。4. 常见问题与排错速查4.1 随机数不随机或者雷数不对最经典的问题每次运行程序雷的位置一模一样。原因几乎都是同一个——没调用srand((unsigned)time(NULL))。在C语言里rand()生成的是伪随机序列默认种子是1所以不换种子就永远是同一串数字。还有另一种情况雷数少于预期。这通常是因为布雷的循环条件写错了。比如用for循环确定次数但同一位置重复布雷导致实际雷数不足。我的建议是统一用while (count MINE)的方式踩到已有雷的位置就跳过直到放满为止。如果雷的数量经常多一颗少一颗建议在init()末尾自己写一个临时循环统计一下map里-1的个数打印出来看看。调试完再删掉这是最快定位问题的方法。4.2 输入卡住或者“吃”字符还有一种很常见的现象输完一次坐标后下一次还没输入程序就好像自动跳过去了或者输入操作码时读不到东西。这个问题几乎都是输入缓冲区里残留的换行符在作怪。scanf(%d, x)会读取整数但会停在换行符前面如果下一次用getchar()读字符读到的就是那个换行符。我在这版里全部用了scanf(%d %d %d, ...)读三个整数一次读干净基本不会踩这个坑。但如果你用了%c读操作码记得在%c前面加一个空格scanf( %c, op)那个空格的作用是让scanf跳过所有空白字符。4.3 一翻开棋盘边缘就崩溃程序运行到一半突然退出或者棋盘上出现乱码数字多半是数组越界了。最常见的越界场景就在递归展开空白区域的时候。如果递归函数里没有检查x和y是否在0到ROW-1、0到COL-1之间点到边缘的空白格时会访问到数组的非法位置。解决方法是把递归函数的第一行写死if (x 0 || x ROW || y 0 || y COL) return;这句边界检查必须放在函数最前面作为递归出口之一。4.4 排错速查表我把平时最容易遇到的一批问题整理成一个表格方便你遇到情况直接查。现象原因解决办法雷的位置每次运行都一样没有设置随机种子在main开头调用srand((unsigned)time(NULL))雷的数量不对布雷时位置重复或循环次数错误用while (count MINE)重复位置跳过翻开空白格时程序崩溃递归缺少边界判断在openCell开头加越界检查输入操作码读不到值缓冲区残留换行符用scanf( %c, op)跳过空白字符棋盘输出对不齐格式化宽度不够统一用%2d并在每个格子前后加空格VS编译报scanf不安全VS默认启用SDL检查文件开头加#define _CRT_SECURE_NO_WARNINGS程序运行完窗口闪退控制台程序结束立即关闭末尾加getchar()等待输入或在IDE里运行翻开数字格周围也展开了递归条件没判断数字格只有map[x][y] 0时才递归这里面“窗口闪退”也算是一个经典问题。如果你在Windows命令行里直接双击运行exe程序一跑完窗口就关了根本看不清结果。解决方案是在return 0之前加一句getchar()暂停或者直接在终端里用命令运行。还有一个调试技巧是我个人特别推荐的临时把棋盘内容打印出来。在开局后加一段// 调试用正式发布前删掉 for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (map[i][j] -1) printf(* ); else printf(%d , map[i][j]); } printf(\n); }这能看到雷的布局和数字计算结果一旦发现数字不对问题基本就锁定在calcNumbers()里了。调试完记得注释掉或者删掉别留在最终代码里。5. 这个项目还能怎么变出花来5.1 功能扩展方向如果上面这版你已经写通了下一步可以做的事情其实非常多。我按难度从低到高列几个方向。第一个是加计时器。用一个全局变量记录操作次数或者用clock()记录游戏开始到结束的时间在胜利或失败时输出用时。这几乎是课程设计里最常被要求的加分项。第二个是加难度选择。把ROW、COL、MINE改成变量进入游戏时让玩家选简单、中等、困难。注意这样你就不能再用#define固定这三个值了二维数组的大小需要用动态内存或者最大尺寸的静态数组来实现。第三个是加“数字快速翻开”功能。经典扫雷里如果某个数字格周围已经插了正确数量的旗左键点击它可以直接翻开周围所有未标记的格子。这个功能的逻辑实现起来还是用搜索但流程更绕很能锻炼思维。第四个是加控制台动画效果。如果你用的是Windows环境可以用SetConsoleCursorPosition、SetConsoleCursorInfo这几个Windows API控制光标位置和可见性让棋盘原地刷新而不是不停往下滚动。这需要引入windows.h平台限制比较大但视觉效果会好很多。再往上就是图形界面了。用easyx画出格子、地雷和数字图标加鼠标点击事件。难度不高就是把现在控制台输出换成了图形绘制核心游戏逻辑基本不用动。我自己试过给这版加了一个简单的计时和难度选择改完以后整个项目看起来就非常像一个能交上去的课程设计了。5.2 我的几点真实体会写这个扫雷项目让我重新意识到一件事很多人学C语言的时候把大量时间花在语法细节上反而忽略了“把一个小东西从头到尾做出来”的过程。扫雷虽然只涉及数组、循环、递归和输入输出但真正把它跑通之后你对“程序是怎么运转起来的”会有一种完全不同的感觉。我在调试过程中印象最深的是第一次写完递归展开时点一下棋盘边缘程序直接崩了。那时候我才真正理解“递归出口条件”不是嘴上说说的理论少一条检查就是真的会崩溃。还有一次是插旗逻辑写错导致插旗的格子被翻开后还能再插旗游戏状态直接乱了。后来的经验是每个功能模块写完立刻测试不要等整个程序都写完再一起测。至少要测这三种边界情况点最角落的格子、点棋盘边缘的格子、在插旗的格子上反复切换插旗状态。把这三类问题测完大部分低级bug都能暴露出来。这个扫雷项目后续也还可以继续扩展。比如做个自动求解的AI用逻辑推理帮你扫雷或者把棋盘改成自定义大小支持更大规模的高级难度再或者加一个排行榜把每局用时存到文件里下次启动时读取。这些方向都不难关键是迈出第一步。说到底C语言的学习不是看出来的是敲出来的。扫雷这个项目正好能让你把知识点变成真正能玩的东西。如果你跟我一样是从零开始把整个项目写出来的跑通那一刻的成就感绝对比看十遍教程都来得实在。
返回列表