
如果让我给C语言阶段的练手项目排个名扫雷一定在我心里的前三名。别看它只是个黑白方块的小游戏却把二维数组、随机数、递归、模块化函数、输入输出控制全部串在了一起代码量不大但每一步都有讲究。很多学校的课程设计、OJ题库里也常出现“扫雷游戏c”这道题——有的只需要你根据地雷分布输出数字矩阵有的则要求做一个完整可玩的交互版本。我这次把两种玩法都梳理清楚重点讲完整版控制台扫雷的设计思路、完整源码以及我实际写代码时踩过的坑希望能帮你把这道经典题彻底吃透。1. 项目整体设计与思路拆解扫雷从规则到C语言落地1.1 扫雷规则简化成程序能懂的描述扫雷的经典玩法初级模式是 9 行 9 列的棋盘里面随机埋 10 颗雷玩家每次输入一个坐标翻开格子。翻开之后如果踩雷游戏结束如果翻开的格子周围有雷格子上会显示一个数字比如“3”表示周围 3 格以内有 3 颗雷如果周围一颗雷都没有这个格子会显示为空白并且自动把周围的格子一起翻开这种连锁反应会像水波一样扩散直到遇到数字边界才停下来。把所有非雷格子都翻开游戏胜利。听起来不复杂但转成程序语言要拆成几件事棋盘怎么存、雷怎么布、数字怎么算、翻开时的连锁展开怎么做、什么时候算赢。我这版控制台扫雷会先把这几个核心问题逐个解决再拼装成完整游戏。1.2 为什么用C语言来写扫雷用 C 语言写扫雷最大的价值是纯粹。不需要引入图形库不需要处理鼠标事件只用标准输入输出就能把核心逻辑跑通。棋盘天然对应二维数组每个格子就是数组里的一个元素地雷的随机分布要理解 rand() 和种子空白格的连锁展开是递归的经典应用。这么一套流程走下来C 语言课上最容易“学了不会用”的几个知识点全都有了实际落脚点。另外这个项目非常适合作为课程设计或者实验报告的选题。代码量控制在两三百行结构清晰注释写足之后老师一眼就能看出你掌握了函数封装、数组操作和基本算法思想比起单纯打印几个图形更有说服力。1.3 模块划分先拆碎再组装我习惯在动手写代码之前把整个程序画成一张“任务清单”。扫雷可以拆成这些模块棋盘初始化把雷盘和安全盘都置成初始状态布雷随机挑选坐标放指定数量的地雷数字计算统计每个非雷格子周围八格的雷数递归展开当翻开的格子周围无雷时自动扩散棋盘打印把玩家可见的棋盘输出到控制台胜负判断检查是否所有非雷格都被翻开主循环负责接收玩家输入调用上述模块控制游戏流程每个函数尽量只做一件事单独拿出来都可以测试。我写的时候会先保证模块各自正确再碰主循环调试成本会小很多。1.4 网上的“扫雷游戏c”题目先分清你要做哪一种搜“扫雷游戏c”会看到两类完全不同的需求。一种是题目版给你一张地雷分布图要求输出每个格子周围的雷数或者把数字矩阵打印出来这种题本质上就是“统计八邻域”的数组练习代码很短。另一种是完整版要能玩有输入交互、有游戏流程、有胜负判断。完整版肯定更有意思但题目版也经常会考。所以我先把题目版的核心思路放出来遍历每个格子如果当前格子是雷就输出星号否则数一数它周围八个格子里有多少颗雷把数字输出。关键是边界处理最简单的方法是在原数组外面包一圈‘0’让数组变成 (n2) 行 (m2) 列遍历的时候就不用担心下标越界。#include stdio.h #define MAXN 105 int main() { int n, m; char grid[MAXN][MAXN] {0}; scanf(%d %d, n, m); for (int i 1; i n; i) scanf(%s, grid[i] 1); for (int i 1; i n; i) { for (int j 1; j m; j) { if (grid[i][j] *) { printf(*); } else { int cnt 0; for (int di -1; di 1; di) for (int dj -1; dj 1; dj) if (grid[i di][j dj] *) cnt; printf(%d, cnt); } } printf(\n); } return 0; }这个版本没有真的“包边”只是利用 C 语言数组未初始化的部分默认为 0而 grid[0] 和 grid[n1] 都是空字符所以判断时不会误判。如果你用的是动态数组记得在初始化时把边界清空。2. 核心功能模块从二维数组到递归展开的实现要点2.1 两个棋盘数组是C语言扫雷的关键设计我第一次写扫雷的时候只用了一个数组结果翻到数字和空白格之后玩家信息和工作信息混在一起怎么改都别扭。后来看了一些开源思路才明白标准做法是用两个二维字符数组mineMap专门存地雷分布只有游戏内部能看到地雷位置用‘1’表示安全格用‘0’表示gameMap玩家看到的棋盘未翻开用‘-’表示翻开的数字用‘0’到‘8’表示两个棋盘都是char类型原因是方便打印一个字符就是一格而且 char 数组在内存里连续分布用 memset 一次性清空非常方便。数组大小我建议定义成[ROWS 2][COLS 2]也就是比实际棋盘宽一圈。这样计算某个格子周围八个方向时不需要每步都判断“下个坐标会不会越界”因为边缘那圈永远是空的状态不会参与实际游戏却能把所有真正棋盘的邻居都包含进来。这个技巧很多 C 语言题目都用得上尤其是迷宫、扫雷、黑白棋这类需要频繁访问相邻格子的程序。初始化代码也很简单void initBoards() { memset(mineMap, 0, sizeof(mineMap)); for (int i 1; i ROWS; i) for (int j 1; j COLS; j) gameMap[i][j] -; }2.2 布雷算法随机数用不好雷每次都长一样布雷要用到rand()但只调用rand()会有一个大坑如果不设置随机种子每次运行程序生成的雷区位置完全一样。正确做法是在 main 函数开头调用srand((unsigned)time(NULL))用当前时间做种子这样每次运行雷的位置才会变化。布雷的朴素实现是“随机重试法”每次随机生成一个行坐标和列坐标如果这个位置还没有雷就放一颗直到放满MINES颗雷。这个方法在 9x9 棋盘上完全够用因为雷只有 10 颗随机到重复位置的概率不高while 循环不会空转太久。但如果你把难度改成高级模式比如 30 行 16 列 99 颗雷重复率会上升循环次数增加不过也能接受。void placeMines(int safeRow, int safeCol) { int placed 0; while (placed MINES) { int r rand() % ROWS 1; int c rand() % COLS 1; if (r safeRow c safeCol) continue; if (mineMap[r][c] 0) { mineMap[r][c] 1; placed; } } }这里传入safeRow和safeCol是为了实现“首次点击绝对安全”。玩家第一次点下去的格子不管怎样都不能是雷所以在布雷时直接跳过这个坐标。想更讲究的话可以用“概率扫描法”替代随机重试遍历所有格子维护一个minesLeft变量每到一个可放雷的格子就按“剩余雷数/剩余格子数”的概率决定是否放雷。这样时间复杂度线性而且雷的分布更均匀代码可以留作扩展。2.3 洪泛展开周围没雷的格子为什么会自己打开这是扫雷程序里最有技术含量的一步。玩家翻开的格子如果周围有雷显示数字就可以停了但如果是空白格反应应该是“这一片都很安全”程序需要把周围八个格子都翻开而这些格子里如果有空白格又要继续扩大展开范围。我采用的是递归实现看起来非常贴合游戏逻辑void reveal(int row, int col) { if (row 1 || row ROWS || col 1 || col COLS) return; if (gameMap[row][col] ! -) return; if (mineMap[row][col] 1) return; int n countMines(row, col); gameMap[row][col] 0 n; if (n 0) { for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) if (dr ! 0 || dc ! 0) reveal(row dr, col dc); } }递归的终止条件有三个坐标越界、格子已经被翻开、格子是雷。前两个条件是为了防止程序在空白区域里无限循环。被翻开这个判断尤其重要因为一片空白区域里的格子会被多个方向反复“发现”如果没有gameMap[row][col] ! -这个拦截递归就会像在迷宫里绕圈一样反复调用自己最终把栈撑爆。在 9x9 棋盘里空白区域最大也就是几十个格子递归深度最深也就几十层栈完全够用。但如果做一个 100x100 的大棋盘这种递归写法可能会压力很大到时候可以改成队列加循环的广度优先展开。我在后续扩展部分会说这个思路。2.4 胜负判断越简单越好别自己绕进去判断胜利有一个很朴素的思路游戏赢的条件是所有非雷格子都被翻开。直接遍历整个游戏区如果发现一个格子既没有被翻开、又不是雷说明还没赢遍历完都没找到那就是赢了。int checkWin() { for (int i 1; i ROWS; i) for (int j 1; j COLS; j) if (gameMap[i][j] - mineMap[i][j] 0) return 0; return 1; }有些同学会用一个已翻开格子计数器每次 reveal 时加一最后判断是否等于ROWS * COLS - MINES。这种方案效率更高但要注意“重复翻开”的情况稍不注意就会多加。初学者用遍历判断更直观逻辑也更好验证。3. 完整代码与实操把扫雷游戏跑起来3.1 完整源码从上到下拼进一个 .c 文件就能编译我在这里给出一个完整可运行的版本。注释放在关键逻辑旁边方便你对照理解。程序从上到下分别是头文件、宏定义、全局变量、初始化、布雷、数字统计、递归展开、棋盘打印、胜负判断、主函数。#include stdio.h #include stdlib.h #include time.h #include string.h #define ROWS 9 #define COLS 9 #define MINES 10 char mineMap[ROWS 2][COLS 2]; char gameMap[ROWS 2][COLS 2]; int firstClick 1; void initBoards() { memset(mineMap, 0, sizeof(mineMap)); for (int i 1; i ROWS; i) for (int j 1; j COLS; j) gameMap[i][j] -; } void placeMines(int safeRow, int safeCol) { int placed 0; while (placed MINES) { int r rand() % ROWS 1; int c rand() % COLS 1; if (r safeRow c safeCol) continue; if (mineMap[r][c] 0) { mineMap[r][c] 1; placed; } } } int countMines(int row, int col) { int cnt 0; for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) if (mineMap[row dr][col dc] 1) cnt; return cnt; } void reveal(int row, int col) { if (row 1 || row ROWS || col 1 || col COLS) return; if (gameMap[row][col] ! -) return; if (mineMap[row][col] 1) return; int n countMines(row, col); gameMap[row][col] 0 n; if (n 0) { for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) if (dr ! 0 || dc ! 0) reveal(row dr, col dc); } } void displayBoard() { printf(\n ); for (int j 1; j COLS; j) printf(%2d , j); printf(\n ); for (int j 1; j COLS; j) printf(---); printf(\n); for (int i 1; i ROWS; i) { printf(%2d |, i); for (int j 1; j COLS; j) printf( %c , gameMap[i][j]); printf(|\n); } printf( ); for (int j 1; j COLS; j) printf(---); printf(\n); } int checkWin() { for (int i 1; i ROWS; i) for (int j 1; j COLS; j) if (gameMap[i][j] - mineMap[i][j] 0) return 0; return 1; } int main() { srand((unsigned)time(NULL)); initBoards(); int row, col; while (1) { displayBoard(); printf(输入坐标行 列); scanf(%d %d, row, col); if (row 1 || row ROWS || col 1 || col COLS) { printf(越界了重新输入。\n); continue; } if (gameMap[row][col] ! -) { printf(这个位置已经翻开换一个。\n); continue; } if (firstClick) { placeMines(row, col); firstClick 0; } if (mineMap[row][col] 1) { printf(踩雷了游戏结束。雷区分布\n); for (int i 1; i ROWS; i) { for (int j 1; j COLS; j) printf(%c , mineMap[i][j] 1 ? * : .); printf(\n); } break; } reveal(row, col); if (checkWin()) { printf(排雷完成你赢了\n); break; } } return 0; }这个版本没有做右键标旗因为我希望把核心逻辑控制得尽量短。标旗功能其实是纯显示层的操作和地雷逻辑没有关系加一个游戏状态就能实现我在扩展部分会给思路。3.2 关键参数解析改三个宏就能换难度程序里的三个宏是扫雷的灵魂参数难度行数列数雷数雷占比初级9910约12%中级161640约16%高级163099约21%你不需要改任何其他代码只把ROWS、COLS、MINES三个宏替换掉就能切换难度。这也是宏定义的优势配置集中逻辑不动。但中级以上的棋盘面积变大后如果仍用“随机重试法”布雷雷的数量占棋盘比例提升重复碰撞的概率上升布雷时间会有轻微变长但肉眼基本看不出来。注意到我的mineMap大小是ROWS 2行COLS 2列实际游戏范围是从第 1 行到第 9 行、第 1 列到第 9 列。外圈那层是“保护带”它的作用是让countMines函数统一扫八个邻居不需要写一堆“如果邻居坐标合法才加”的判断。这属于典型的空间换代码复杂度建议你形成肌肉记忆以后处理矩阵类问题都先想想能不能扩一圈。3.3 编译运行与实测记录在 Linux 或 macOS 终端里编译和运行的命令是gcc minesweeper.c -o minesweeper ./minesweeper在 Windows 上如果你用的是 VS Code MinGW 环境同样在终端执行gcc minesweeper.c -o minesweeper.exe然后运行即可。如果双击 exe 发现窗口一闪而过可以在 main 函数 return 之前加一行#ifdef _WIN32 system(pause); #endif实测时优先测试几个关键场景第一次点中间的格子看大片空白是否自动展开故意往角落点看边界数字是否正确把非雷格全部翻开看胜利判断是否触发。我测试时发现节奏非常顺畅唯一要适应的是输入坐标时先输入行再输入列和游戏里习惯的“先列后行”正好相反但控制台程序约定好就行。4. 常见问题与排查技巧扫雷开发中的坑和扩展思路4.1 递归导致程序崩溃是不是算法有问题很多同学第一次写递归展开时运行一会儿就“程序无响应”或者直接栈溢出。绝大多数原因不是递归太深而是少写了“已翻开”检查。reveal函数每次进入时第一件事要确认当前格子没有被处理过否则一片空白区域内格子 A 会调用 BB 又会调用 A两边反复互调光 9x9 棋盘也能造成真实的内存风暴。排查方法也不难在递归函数入口处打印当前坐标观察输出是否出现大量重复坐标。如果你看到坐标在两个格子之间来回跳说明状态拦截缺失如果看到深度越来越大直到崩掉可能是递归终止条件写错了。只要保证“越界、已翻开、踩雷”三个条件都拦截这个递归在初级棋盘上的深度最多几十层非常安全。4.2 每次运行雷的位置一样怎么解决这是最经典的随机数翻车现场。你写了rand() % ROWS但忘了srand导致每次启动程序都用同一个默认种子生成序列雷区自然一模一样。解决办法是在 main 开头加srand((unsigned)time(NULL));而且只要加一次。有些人会在每局开始前都调用一次 srand这反而可能导致同一秒内重置种子后生成完全相同的雷区尤其是快速连续开新局的时候。正确用法是程序启动时设置一次种子后面一直用 rand() 取随机值。如果你想让雷区分布更均匀可以把“随机重试”改成“概率扫描”。基本思想是维护一个剩余雷数变量遍历每个格子时以“剩余雷数 / 剩余格子数”的概率放雷。这样布雷过程是单次扫描完成的也天然避免了重复问题棋盘越大优势越明显。4.3 边界格子数字总是算错原因百分之百是越界我自己第一次写countMines的时候直接在原始数组上遍历邻居然后出现了一堆负数下标和莫名其妙的数字。后来把数组扩大成ROWS 2和COLS 2问题立刻消失。核心原因是对第 1 行第 1 列的格子来说它的“上邻居”是第 0 行“左邻居”是第 0 列如果数组没有这一层访问就是越界行为轻则数值错误重则直接崩溃。这也是我为什么反复强调扩圈把保护带全部初始化成 0它们永远不会被玩家看到但会让所有坐标计算变得统一且安全。如果你不想扩圈那就只能每个方向都写一次边界判断比如“如果 ni 0 ni n nj 0 nj m”代码会变得很长而且容易漏掉某条边界路径。4.4 首次点击就踩雷游戏体验太差怎么处理正式扫雷游戏里有一个不成文的规定玩家第一次点下去的格子必须是安全格这也是所有扫雷程序都应该保证的体验。实现方式就是我上面代码里的safeRow和safeCol参数。布雷函数会跳过这两个坐标保证第一次必安全。如果你还想做得更完善可以在玩家第一次点击后把那个格子的地雷状态强制置为 0同时如果它周围的地雷密度过高可以把部分地雷挪到更远的位置。不过对于课程设计来说只跳过安全格已经完全够用判断逻辑简单也不会引入“怎么挪雷才合理”的复杂问题。4.5 还能怎么扩展让这个项目直接升级成高分作业基础版本能跑之后有几个很容易加的功能每一个都有明确的学习价值。第一个是标记地雷。给 gameMap 增加一个状态比如用‘!’代表玩家标旗输入坐标后可以选择“翻开”还是“标记”这会锻炼你设计游戏状态机的能力。第二个是计时。游戏开始时记录 time(NULL)结束时就输出用时甚至保存最佳时间这能让你接触到文件操作。第三个是界面优化。Windows 下可以用 system(cls) 清屏每次输入后重绘棋盘玩起来更像真正的游戏。第四个是难度菜单。在 main 开头打印一个菜单根据输入修改 ROWS、COLS、MINES注意这需要用变量替代宏或者用条件编译难度不高但很考察代码组织能力。如果你有条件接触图形库还可以用 raylib 或者 SDL 把控制台版改成鼠标点击版算法核心完全不用动只要把输入输出层替换掉。扫雷这个项目最有价值的地方就在这里逻辑和界面是分离的只要你把核心模块写清楚换界面只是换一层皮。我实际写这个项目最大的体会是不要急着一次把主循环写完。先把placeMines单独跑通打印一看再把reveal单独测试用一个写死的雷区验证展开结果。每块都确定没问题最后组装时才不会陷入“改了这头坏那头”的循环。扫雷代码量不吓人但逻辑依赖关系比较强模块化开发和循序渐进测试比任何技巧都重要。如果你也能按这个节奏把这个小游戏完整写出来C 语言的核心知识点真的就摸得差不多了。