ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用C语言实现控制台扫雷:二维数组、递归与随机布雷全解析

2026/9/7 22:37:57 拓冰建站 浏览量
用C语言实现控制台扫雷:二维数组、递归与随机布雷全解析 扫雷大概是很多人接触电脑时玩过的第一个小游戏用C语言把它从头写一遍却是一个特别经典的控制台练手项目。数组、随机数、递归、状态机、输入处理、甚至简单的文件读写基本能把C语言的核心知识点串个大半。这篇文章我就用完整的代码和逐步拆解的方式讲清楚扫雷游戏从设计思路到落地实现的全过程适合刚学完指针和函数、正想找一个像样项目练手的初学者也适合想快速复习C语言综合用法的朋友。我会把每一步为什么这么设计、坑在哪里、怎么调试都一次说清楚。1. 项目概述与整体设计思路1.1 扫雷游戏的需求拆解写程序之前先别急着敲代码先把游戏规则翻译成程序需求。扫雷的核心玩法用三句话就能说明白第一棋盘上分布着一定数量的地雷其余位置是安全格第二玩家翻开一个格子如果底下是雷游戏结束如果是安全格会显示周围八个格子中的雷数第三如果翻开的格子周围没有雷系统会自动向外展开一片直到遇到数字边界为止。玩家还可以在怀疑是雷的位置打上标记方便记忆。把需求再往下拆就得到了一组最简功能清单初始化棋盘、随机布雷、计算每个格子周围的雷数、翻开格子并递归展开、标记/取消标记、判断游戏胜负、控制台界面显示、玩家输入处理。这些功能听着不少但用C语言实现时本质上只围绕两个问题转数据怎么存逻辑怎么走。数据问题用二维数组解决逻辑问题用循环和递归解决。1.2 技术选型为什么用C语言练这个项目不少朋友学C语言的时候都有同样的困惑语法学了不少指针、函数、数组好像都见过但一到写项目就不知道从哪里下手。扫雷恰好把这些问题全部串起来了。二维数组天然对应棋盘结构函数划分对应模块化设计随机布雷需要理解rand()和rand()的种子机制0区域自动展开要求递归思维玩家输入和容错处理又是工程上很实际的问题。一个两百行左右的小程序能把C语言最核心的语法过一遍这个性价比在练习项目里非常少见。另外扫雷在算法上有一个很漂亮的特点规则简单但边界情况多。翻开一个格子要考虑边界、考虑是否踩雷、考虑是否重复翻开、考虑周围是否为零布雷要考虑首格安全、要考虑是否有重复雷渲染要考虑每行每列对齐。这些细节逼迫你学会严谨地处理边界条件而这种能力恰恰是很多自学者最欠缺的地方。图形界面版本我后面会提几个思路但这里先从控制台版本做起因为逻辑清晰、不依赖第三方库在任何平台、任何IDE里都能直接编译运行适合大多数人起步。2. 核心数据结构与算法设计2.1 棋盘的数据模型选择扫雷棋盘天然就是一张二维表C语言里最直接的数据结构就是二维数组。很多新手会纠结要不要用结构体要不要动态分配内存我的建议是第一版老老实实用固定大小的二维数组。比如9x9棋盘、10颗雷直接定义两个全局数组就行简单、直观、方便调试。等把这个版本跑通了再考虑怎么封装成结构体、怎么支持动态棋盘大小。选择二维数组还有一个好处访问和遍历非常自然。双循环遍历每个格子8方向邻居用坐标偏移数组处理代码写起来很流畅出错了也容易定位。如果你刚开始写这个项目我强烈建议先不要碰动态内存分配和复杂结构体那是优化阶段的事不是起步阶段的事。一上来就追求复杂设计只会让调试难度翻倍。2.2 双数组方案底层数据与上层状态分离这里有一个关键设计也是我认为扫雷最值得学的地方用一个数组存“真相”用另一个数组存“玩家看到的界面状态”。这两个数组必须分离否则会出现逻辑纠缠不清的麻烦。我用两个全局二维数组来解决这个需求。board数组存储棋盘的真实数据-1代表地雷0~8代表周围雷的数量state数组存储格子的玩家可见状态0表示未翻开1表示已翻开2表示被玩家标记。双层数组的设计让很多问题变得清爽判断是否踩雷时看board判断是否可点击时看state渲染时两个都看但各自职责单一。可能你会问为什么不把两块数据包装成一个结构体数组当然可以但对于这个规模的项目两个全局二维数组已经够用全局变量在控制台小游戏里能大幅度降低传参复杂度。等你后续想扩展到更大的棋盘或更复杂的功能时再自然地迁移到结构体数组、链表、动态数组等方案会顺畅得多。初学阶段能用简单方案解决就不要先套一层复杂方案。2.3 随机布雷与首次安全机制布雷的核心是让每颗雷的位置随机同时要保证不重复、不超出棋盘这是整个游戏里第一个值得认真处理的模块。C语言里的经典做法是先用srand((unsigned)time(NULL))初始化随机数种子再在每次布雷时调用rand() % ROWS和rand() % COLS得到随机坐标。一个容易犯的错误是忽略重复判断。如果随机生成的坐标已经布雷必须重新生成否则棋盘上最终雷数会比预期少。使用while循环配合计数是常见的实现方式。另一个容易忽略的规则是“首次翻开安全”游戏的第一下点击不应该踩雷。工程上通常的处理方案是把第一次点击的坐标作为参数传进布雷函数布雷时跳过这个位置。很多新手也会问rand() % n是否公平严格来说如果RAND_MAX 1不能被n整除生成的余数分布会有一点点偏差但对于扫雷这种规模、这种随机性要求的场景完全够用。更严谨的洗牌式布雷我也在扩展部分会讲这个阶段先保证功能正确。3. 完整代码实现与模块拆解3.1 模块划分与函数接口设计代码不要全堆在main里每个功能应该对应一个逻辑清晰的函数。我推荐的模块划分是这样的initGame()初始化全局变量和两个二维数组。placeMines(safeR, safeC)布雷跳过首次安全坐标。calcNumbers()根据board中的雷位置计算所有格子的数字。reveal(r, c)翻开一个格子如果数字为0则递归展开。toggleMark(r, c)对指定格子进行标记/取消标记。checkWin()根据已翻开格子数判断是否获胜。printBoard()渲染当前棋盘界面。main()主循环读取玩家输入并分发到对应函数。这种划分方式是经典的过程式模块化思想。每个函数只做一件事参数和返回值清晰测试某个功能时可以直接单独调用。我给这套设计的评价是简单、稳定、容易讲清楚。这也是很多人第一次体会到“拆解问题”魅力的地方把一个大游戏拆成七八个小函数代码的复杂度瞬间就降下来了。3.2 初始化、布雷与数字填充实现先把数据初始化和布雷这段代码贴出来。这部分的逻辑是整个游戏的基础后面的展开和判断全都依赖这里的数据是否准确。#include stdio.h #include stdlib.h #include time.h #define ROWS 9 #define COLS 9 #define MINES 10 #define HIDDEN 0 #define UNCOVERED 1 #define MARKED 2 #define MINE -1 int board[ROWS][COLS]; int state[ROWS][COLS]; int openedCount 0; int gameOver 0; void initGame() { for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { board[i][j] 0; state[i][j] HIDDEN; } } openedCount 0; gameOver 0; } void placeMines(int safeR, int safeC) { int placed 0; while (placed MINES) { int r rand() % ROWS; int c rand() % COLS; if ((r safeR c safeC) || board[r][c] MINE) { continue; } board[r][c] MINE; placed; } } void calcNumbers() { int dr[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (board[i][j] MINE) { continue; } int count 0; for (int k 0; k 8; k) { int nr i dr[k]; int nc j dc[k]; if (nr 0 nr ROWS nc 0 nc COLS board[nr][nc] MINE) { count; } } board[i][j] count; } } }这里有两个细节值得展开。第一个是8方向遍历的写法我用两个提前定义好的dr和dc数组然后用for (int k 0; k 8; k)循环访问邻居这比把8个情况一个个写出来要干净得多想扩展到对角线方向也很容易。第二个是边界检查nr 0 nr ROWS nc 0 nc COLS必须在访问数组之前判断这是新手极容易写错的地方如果漏掉边界判断数组就越界了程序的行为会变得完全不可预测这个后面我会专门讲调试。3.3 翻开逻辑0区域自动展开的递归与队列思路翻开一个格子是整个游戏里最核心的逻辑。普通情况下格子下面是什么就显示什么但如果翻开的是0也就是周围没有雷那么周围的8个格子也一定是安全的应该自动翻开这些新翻开的格子如果又出现0又继续往外翻。这正是扫雷那种“哗啦一下打开一大片”的效果的来源。实现这个效果最直观的思路是递归。写递归最需要警惕的是死循环如果翻开一个0格子之后又回到它自己程序就会无限递归直到栈溢出。解决办法其实很简单在递归深入之前先把当前格子的状态改为已翻开这样再次遇到已翻开的格子时直接返回。我经常跟学递归的朋友说递归函数第一件事想清楚“什么时候返回”第二件事想清楚“什么时候递归”想明白这两个问题就不会写出死循环。void reveal(int r, int c) { if (r 0 || r ROWS || c 0 || c COLS) { return; } if (state[r][c] UNCOVERED || state[r][c] MARKED) { return; } state[r][c] UNCOVERED; openedCount; if (board[r][c] MINE) { gameOver 1; return; } if (board[r][c] 0) { int dr[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k 0; k 8; k) { reveal(r dr[k], c dc[k]); } } }展开的实现逻辑很清晰先判断边界再判断状态接着翻开当前格并增加计数最后如果数字为0就继续展开邻居。这里要注意一个细节对已标记的格子如果玩家直接输入翻开命令我的实现里是直接返回也就是不允许翻开被标记的格子。这是一种玩法规约你也可以设计成“标记不影响翻开”或者“翻开前必须先取消标记”只要约定一致就没有问题。如果你不习惯递归或者担心棋盘太大时递归调用栈太深也可以改用队列做广度优先展开思路是先创建一个队列把起点放进去每次从队首取出一个格子如果是0就把未翻开的邻居全部入队。C语言里没有现成的队列容器可以用数组加头尾下标模拟这对理解数据结构和搜索算法也很有帮助。两种方案没有优劣之分递归写起来快队列对深度有更可控的边界推荐有余力的话两种都实现一遍看看差别。3.4 玩家交互、标记与胜负判定控制台扫雷的交互方式直接影响游戏体验。我采用的方式是玩家输入一个命令字符加一个坐标r 3 5表示翻开第3行第5列f 3 5表示标记或取消标记q表示退出。这样做的好处是输入格式简单对新手友好也方便主循环的逻辑分发。标记函数本身很简单关键是想清楚标记状态和翻开状态之间的互斥关系。我的实现是只有未翻开的格子才能标记已翻开的格子不能再打标记如果该格已经是标记状态再次输入f就取消标记。这个逻辑本身不难但很多初学者会把状态关系搞混建议画一个简单的小状态图帮助理清思路。玩家输入的时候如果坐标越界应该给出提示并要求重新输入而不是让程序崩溃。胜负判定我使用一个全局计数器openedCount来统计已翻开的格子总数。赢的条件很直观棋盘上除了地雷以外的所有格子都被翻开也就是说当openedCount等于ROWS * COLS - MINES时玩家获胜。踩雷则由reveal函数中的board[r][c] MINE判断一旦踩雷就把gameOver置为1并结束主循环。这里我强烈建议游戏结束后不要立刻exit而是再调用一次printBoard把所有地雷的位置显示出来这样玩家能直观地看到哪一步踩雷、雷到底在哪复盘体验会好很多。3.5 界面渲染与完整源码拼接界面渲染要注意的点是对齐。如果每个格子的字符宽度不一致棋盘会歪歪扭扭看起来非常难受。所以我统一用固定宽度打印数字和符号每行前列出行号每列上方标出列号这样玩家输入坐标时可以一目了然。void printBoard() { printf(\n ); for (int j 0; j COLS; j) { printf(%2d , j); } printf(\n ); for (int j 0; j COLS; j) { printf(---); } printf(\n); for (int i 0; i ROWS; i) { printf(%2d |, i); for (int j 0; j COLS; j) { if (state[i][j] MARKED) { printf( F); } else if (state[i][j] HIDDEN) { printf( .); } else if (board[i][j] MINE) { printf( *); } else if (board[i][j] 0) { printf( ); } else { printf(%2d , board[i][j]); } } printf(|\n); } printf( ); for (int j 0; j COLS; j) { printf(---); } printf(\n); }有朋友可能会想用system(cls)来清屏让界面更干净。我不建议在初学阶段这么做因为清屏函数在不同平台上的实现不同而且调试时屏幕上的历史记录被清掉反而不利于观察程序输出的变化。直接向下打印新棋盘就行虽然界面会越来越长但对这个阶段的学习和调试完全够用。等后续想做真正好用的扫雷界面可以再去接触图形库或者终端控制库。最后把这些函数组装进main就是一份完整到可以直接编译运行的控制台扫雷程序。以下我给出完整的串接版本前面的每一段代码都可以在这个总结构里定位到自己的位置。#include stdio.h #include stdlib.h #include time.h #define ROWS 9 #define COLS 9 #define MINES 10 #define HIDDEN 0 #define UNCOVERED 1 #define MARKED 2 #define MINE -1 int board[ROWS][COLS]; int state[ROWS][COLS]; int openedCount 0; int gameOver 0; void initGame() { for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { board[i][j] 0; state[i][j] HIDDEN; } } openedCount 0; gameOver 0; } void placeMines(int safeR, int safeC) { int placed 0; while (placed MINES) { int r rand() % ROWS; int c rand() % COLS; if ((r safeR c safeC) || board[r][c] MINE) { continue; } board[r][c] MINE; placed; } } void calcNumbers() { int dr[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (board[i][j] MINE) { continue; } int count 0; for (int k 0; k 8; k) { int nr i dr[k]; int nc j dc[k]; if (nr 0 nr ROWS nc 0 nc COLS board[nr][nc] MINE) { count; } } board[i][j] count; } } } void reveal(int r, int c) { if (r 0 || r ROWS || c 0 || c COLS) { return; } if (state[r][c] UNCOVERED || state[r][c] MARKED) { return; } state[r][c] UNCOVERED; openedCount; if (board[r][c] MINE) { gameOver 1; return; } if (board[r][c] 0) { int dr[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k 0; k 8; k) { reveal(r dr[k], c dc[k]); } } } void toggleMark(int r, int c) { if (state[r][c] HIDDEN) { state[r][c] MARKED; } else if (state[r][c] MARKED) { state[r][c] HIDDEN; } } int checkWin() { return openedCount ROWS * COLS - MINES; } void printBoard() { printf(\n ); for (int j 0; j COLS; j) { printf(%2d , j); } printf(\n ); for (int j 0; j COLS; j) { printf(---); } printf(\n); for (int i 0; i ROWS; i) { printf(%2d |, i); for (int j 0; j COLS; j) { if (state[i][j] MARKED) { printf( F); } else if (state[i][j] HIDDEN) { printf( .); } else if (board[i][j] MINE) { printf( *); } else if (board[i][j] 0) { printf( ); } else { printf(%2d , board[i][j]); } } printf(|\n); } printf( ); for (int j 0; j COLS; j) { printf(---); } printf(\n); } int main() { srand((unsigned)time(NULL)); initGame(); int firstMove 1; while (!gameOver) { printBoard(); printf(输入命令r 行 列 翻开 / f 行 列 标记 / q 退出\n); char cmd; int r, c; scanf( %c, cmd); if (cmd q) { break; } scanf(%d %d, r, c); if (r 0 || r ROWS || c 0 || c COLS) { printf(坐标越界请重新输入。\n); continue; } if (cmd f) { toggleMark(r, c); } else if (cmd r) { if (firstMove) { placeMines(r, c); calcNumbers(); firstMove 0; } reveal(r, c); if (checkWin()) { gameOver 2; } } else { printf(未知命令请重新输入。\n); } } printBoard(); if (gameOver 1) { printf(踩雷了游戏结束。\n); } else if (gameOver 2) { printf(恭喜所有安全格子都已翻开\n); } else { printf(已退出游戏。\n); } return 0; }主循环的逻辑是这样的每次先打印棋盘然后读取一个命令字符和两个坐标。如果是f就调标记如果是r就调翻开但第一次翻开之前要先布雷和计算数字。这个顺序非常关键布雷必须发生在第一次翻开之后否则玩家第一次点击就有可能踩雷。判断胜利的时机放在翻开之后因为每次翻开都可能改变openedCount。整个程序的状态流转其实就是一张很清晰的状态机进行中、胜利、踩雷、退出。4. 常见问题与调试心得4.1 数组越界与脏数据扫雷这个项目里数组越界是最常见也最难排查的问题。典型场景是计算邻居数字或0区域展开时没有先检查nr和nc是否在合法范围内就直接访问board[nr][nc]。越界访问不会立刻崩溃而是会读取或修改数组附近的内存导致出现“翻开一个格子明明没有雷却显示了奇怪数字”等莫名其妙的症状。排查这类问题我推荐一个办法在关键函数入口打印传入坐标和数组下标或者用调试器在可疑位置打断点单步执行观察坐标值的变化。如果发现某个坐标跑到-1或者等于数组长度那就说明边界条件漏了。还有一种更省事的方法写代码的时候就把边界检查当成习惯统一写在数组访问之前。宁可多写几行冗余的判断也不要依赖侥幸。4.2 随机数重复导致雷数不对刚写完布雷模块时容易发现MINES明明设的是10但最终雷数只有8颗。这个问题的根源很简单随机生成的坐标撞上了已经有雷的位置而你没有处理这个情况。解决方法是像我上面代码里那样用continue跳过重复位置重新生成。另一个容易忽略的坑是如果你没有调用srand程序每次运行生成的随机序列是完全一样的所以务必在main开头加上srand((unsigned)time(NULL))。散列分布均匀性其实不需要过度关注但如果想追求更均匀的布雷效果可以用洗牌算法把所有坐标放进一个数组随机打乱后取前MINES个布雷。这个方法代码量也不大而且雷的分布比单纯用rand()循环更稳定推荐给想挑战一下自己的读者。4.3 无限递归与栈溢出展开功能写完后运行程序时可能在翻开某个0格子之后直接崩溃或者输出大量重复内容后停下这多半是递归展开没有正确终止导致的。最常见的原因是在reveal里先递归调用邻居再标记当前格子为已翻开结果已翻开的0格子又会被邻居当作新格子递归进来形成无限循环。解决思路是严格执行“先置状态再处理逻辑”的顺序。在递归函数里任何递归调用都必须发生在状态更新之后而且递归入口处要检查当前状态是否已经处理过。可以把这个规则记成一句话递归函数的第一道关卡是返回条件第二道关卡是已访问标记。这两关都做好了死循环基本不会发生。4.4 输入缓冲残留控制台输入一个经典坑是scanf留下换行符。如果第一次输入r 3 5之后后续某个scanf(%c, cmd)读到了上一次输入末尾的\n程序就会莫名其妙地跳过命令解析。我代码里在%c前面加了一个空格写成scanf( %c, cmd)这个空格的作用是让scanf自动跳过空白字符直接把真正的命令字符读进来这是控制台交互里非常实用的一个小技巧。如果遇到更复杂的输入问题可以考虑写一个清理输入缓冲的函数用一个while (getchar() ! \n);循环把缓冲区里残留的字符全部读掉。但要注意清理缓冲本身也有副作用处理不好会把玩家下次要输入的字符也吃掉。我的建议是先用scanf( %c, cmd)这种带空格的方式解决百分之八十的问题真遇到特殊需求再单独处理。5. 可以继续扩展的方向这个版本跑通之后你会发现它已经是一个结构完整、逻辑闭环的小游戏但距离“玩起来舒服”还差很多。往下走有很多非常有价值的扩展方向每个方向对应不同的C语言进阶知识点。想做图形界面的朋友可以研究用EasyX或ncurses库把控制台替换成真正的窗口和鼠标交互想做文件操作的朋友可以把游戏保存和读取做成存档功能用fprintf和fscanf把棋盘状态写入文件想练数据结构的可以把棋盘升级为动态二维数组支持自定义行列和雷数规格。还有一个我特别推荐的进阶方向是写一个“自动求解器”。扫雷游戏本质上是一个约束推理问题通过已翻开数字推断哪些格子必定是雷、哪些必定安全这是概率论和回溯算法的绝佳练习素材。你甚至可以给程序加一个排行榜模块把玩家通关时间存进文件并排序这又会用到结构体数组、冒泡排序或快速排序。沿着这个项目滚下去C语言的方方面面几乎都能覆盖到。我自己的体会是扫雷这个项目最宝贵的不是“我做出来了一个能跑的游戏”而是你在调试过程中踩过的那几个坑。数组越界让你学会严谨递归死循环让你学会思考终止条件随机数问题让你学会怀疑环境。如果读完这篇文章后你决定动手我建议你先把代码关掉自己从零开始实现一遍卡住了再回来看思路。写一遍和看一遍的差距比想象中大得多。