C语言扫雷项目实战:二维数组与递归函数从零实现
扫雷这个项目几乎是每个C语言学习者绕不开的坎儿。我在刚学数组和函数那会儿照着网课练习题写了不下三个版本的扫雷第一个版本是点开格子之后周围数字死活不显示的半成品第二个版本是能直接把整张雷图打印出来的“透视挂”。后来认真把二维数组和递归思路吃透才写出一份能在命令行里稳定运行的9×9初级扫雷。这篇文章就把我最终沉淀下来的实现思路、完整代码和排错经验整理出来。适合C语言已经学完数组、函数想找一个综合性练习题目的朋友也适合拿它当课程设计或结课作业来交——代码不复杂但每一步都踩在C语言的核心知识点上二维数组、随机数、函数封装、条件判断、递归展开、输入输出处理。1. 项目整体设计与核心思路拆解1.1 把扫雷规则翻译成C语言能处理的东西扫雷的规则其实很简单一个二维网格上随机埋了若干颗雷玩家点开一个格子如果踩雷就输如果没踩雷就显示周围8个格子中有几颗雷如果周围雷数为0就自动把周围格子也一起展开。这个“点开、判断、扩散、判胜负”的过程翻译成C语言核心就是一个二维数组。C语言对二维数组的支持非常直接所以扫雷几乎是给数组、函数、递归量身定做的练手项目。我们不能真的做一个图形界面出来只能用字符模拟棋盘#表示未翻开的格子数字0到8表示周围雷数*表示雷。玩家在命令行输入“行 列”两个数字程序根据坐标去数组里查状态再决定是继续游戏还是结束游戏。整个过程没有图形库依赖任何装了C语言编译器的环境都能跑。这里有一个很关键的思维转换游戏里的“地图”在程序里不只是一张表而是两张表——一张记录真实数据一张记录玩家能看到的内容。这个区别一定要在设计阶段就想清楚否则后面逻辑会越写越乱。1.2 两张地图一张存真相一张给玩家我第一次写扫雷的时候犯过一个典型的错误想用一个数组搞定所有事情既要在里面存雷又要在打印时隐藏雷结果打印函数里到处都是判空、判雷、判翻没翻过的分支改一个显示逻辑就牵一发动全身。后来我改成拆成两个二维数组各管各的mine[ROWS][COLS]内部地图负责记录雷的位置和每个格子周围的雷数。这个数组只在程序内部使用玩家永远不会直接看到它。show[ROWS][COLS]对外地图负责记录玩家已经翻开哪些格子、哪些还没有翻开。未翻开用#表示翻开之后就把mine里的数字内容同步过来。这样设计之后打印函数只需要关心show布雷和计算数字只需要操作mine两者在翻开格子的时候才发生交互。职责拆开以后后面加插旗、加标记、加胜利统计都会轻松很多。你也可以理解为一张是“底牌”一张是“明牌”玩家只能看到明牌程序自己记住底牌。1.3 给地图加一圈围墙把边界问题扼杀在萌芽阶段扫雷里最烦人的问题就是边界处理。玩家点在角落格子时周围其实只有3个邻居但你用代码去遍历8个方向时很容易数组越界。如果直接定义9×9数组每访问一个邻居都要判断下标是否越界代码会变得又长又容易漏。我的办法是给地图加一圈“围墙”实际数组定义成ROWS ROW 2、COLS COL 2也就是11×11但只使用下标从1到9的格子下标0和10这一整圈永远不参与游戏。这样做的好处非常明显不管是计算周围雷数还是递归展开空白区域都不需要额外判断“这个邻居是不是超出棋盘了”。因为即使你访问到下标0或10也只会碰到围墙上的0不会数组越界。用极小的内存开销换来了代码逻辑的大幅简化这笔账非常划算。初学者如果还理解不了可以想象成棋盘外面有一圈空气墙所有角色只能在墙内活动。2. 地图数据准备与随机布雷实现2.1 用宏定义把难度参数抽出来我习惯在写项目时先把常量定义好而不是在代码里到处写死数字。扫雷的经典参数是初级9×9、10颗雷所以我这样定义#define ROW 9 #define COL 9 #define MINES 10 #define ROWS (ROW 2) #define COLS (COL 2)以后想要升级成16×16、40颗雷的中间难度只需要改这三个宏整个程序自动适配。这个习惯在我自己写的很多小项目里都受益尤其是在做课程设计时改成“菜单选择难度”也很方便——把ROW、COL、MINES变成变量就行而不是去满文件搜索所有写死数字的地方。注意ROWS和COLS是加了2之后的实际数组维度整个项目里所有数组定义都用这两个宏避免手写11这种魔法数字。代码的可读性就是这样一点一点抠出来的。2.2 初始化棋盘与打印棋盘初始化函数很简单把两个二维数组全部填上初始字符int i, j; for (i 0; i ROWS; i) for (j 0; j COLS; j) { mine[i][j] 0; show[i][j] #; }这里的mine初始化为0表示每个格子周围有0颗雷布雷之后再逐步计算数字。show初始化为#表示玩家视野里全是未翻开状态。打印函数有些细节值得注意。我打印时会先把列号打出来方便玩家输入坐标时对位置printf(\n ); for (j 1; j COL; j) printf(%d , j); printf(\n); for (i 1; i ROW; i) { printf(%2d , i); for (j 1; j COL; j) { if (show[i][j] 0) printf( ); else printf(%c , show[i][j]); } printf(\n); }这里有个容易被忽略的体验优化0在扫雷里代表周围没有雷视觉上应该是一片空白区域而不是满屏的0字符。所以打印时我会把0显示成两个空格让棋盘更接近真实扫雷。行号用%2d是为了让两位数的行号也能对齐不会出现棋盘整体歪掉的问题。2.3 随机布雷越简单越要讲清楚布雷的经典写法是随机生成坐标直到埋够MINES颗雷int cnt 0; while (cnt MINES) { int x rand() % ROW 1; int y rand() % COL 1; if (mine[x][y] 0) { mine[x][y] *; cnt; } }这里有两个关键点。第一rand() % ROW 1结果是1到9而不是0到8原因就是我们留下了0和10作为围墙可玩区域从1开始。第二调用rand()之前必须先用srand((unsigned)time(NULL))初始化随机种子否则每次程序运行生成的雷区位置一模一样看起来就像“假随机”。这个写法还有一个小缺陷需要知道如果棋盘上雷的数量特别多比如在10×10的棋盘上埋90颗雷while循环可能反复碰撞空位效率变低极端情况下甚至很难找到空位。但在9×9盘面埋10颗雷时碰撞概率非常低重试次数可以忽略不计所以作为入门版本完全够用。如果想把算法写得更工整可以考虑Fisher-Yates洗牌把81个格子编号放到数组里随机打乱取前10个作为雷位雷数固定、没有无限重试风险。我在完整代码里保留了简单版把洗牌版留给想进阶的读者自己实现。2.4 数字统计8个邻居方向的正确打开方式布雷完成之后要计算每个非雷格子周围到底有几颗雷。两种常见做法一是在布雷时就自增周围格子的数字而是布雷结束后遍历整张棋盘统一计算。我更推荐新手用后者逻辑更直白调试时也更容易验证。重要的是千万别写8个重复的if判断来分别处理8个方向那样不仅代码冗长还很容易漏掉某个方向。我习惯用一个方向偏移数组统一处理int dx[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (i 1; i ROW; i) for (j 1; j COL; j) { if (mine[i][j] *) continue; int cnt 0; for (k 0; k 8; k) if (mine[i dx[k]][j dy[k]] *) cnt; mine[i][j] 0 cnt; }因为数组四周有一整圈围墙i dx[k]和j dy[k]永远不会越界。这也是前面加围墙带来的最直接收益计算数字时完全不用做边界判断。3. 核心交互逻辑与递归翻开算法3.1 怎么让程序稳定地读玩家坐标主循环里最基础的一步是读取玩家输入。我使用scanf(%d %d, x, y)来接收“行 列”坐标。这里有一个非常常见的坑如果玩家不小心输入了字母或者符号scanf的返回值就不是2变量x和y不会得到有效更新程序如果不对返回值做判断就会进入死循环刷屏。我在代码里的处理方式是if (scanf(%d %d, x, y) ! 2) { while (getchar() ! \n); printf(输入无效请重新输入。\n); continue; }当scanf返回值不等于2时先用while (getchar() ! \n);把输入缓冲区里残留的脏字符全部清掉再提示重新输入。少了这行清缓冲的代码程序很容易在下一次循环里继续读到垃圾字符看起来就像卡死了。坐标范围检查也不能省。玩家输入10 10这种超出棋盘的值程序应该温和地提示“坐标越界”而不是带着越界下标去访问数组。我把这个判断放在翻开逻辑前面能省掉很多潜在的越界崩溃问题。3.2 翻格子与自动扩散递归函数的正确姿势玩家输入一个坐标后程序先检查这个格子是不是雷。如果是雷直接显示最终结果并退出如果不是雷就调用核心函数openCell去翻开它并处理可能的空白区域扩散。void openCell(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y) { if (x 1 || x ROW || y 1 || y COL) return; if (show[x][y] ! #) return; if (mine[x][y] *) return; show[x][y] mine[x][y]; if (mine[x][y] 0) { int dx[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k 0; k 8; k) openCell(mine, show, x dx[k], y dy[k]); } }这个递归逻辑用生活场景来类比就是倒水你往一个格子上倒一杯水水会向周围8个方向扩散如果某个方向的格子也是0水继续往那个格子的8个方向扩散如果某个方向的格子是有数字的1或2水就在那里停下来留下一个数字标记。三个开头的return是递归的关键顺序不能乱先判断越界再判断是否已经翻开最后防御性地判断是否为雷。这里尤其重要的是第二个判断show[x][y] ! #它保证了每个格子最多被翻开一次既避免了重复处理也杜绝了递归无限循环的可能。9×9棋盘上递归最深不过几十层完全不用担心栈溢出问题。3.3 胜负判断不是“踩完所有雷”才叫赢很多新手会想当然地认为胜利条件是把所有雷都找出来但扫雷的实际规则是把除了雷以外的所有安全格子全部翻开就算赢。所以在程序里判别逻辑是统计show数组中已经翻开的非#格子数量判断它是否等于ROW * COL - MINES。int isWin(char show[ROWS][COLS]) { int i, j, opened 0; for (i 1; i ROW; i) for (j 1; j COL; j) if (show[i][j] ! #) opened; return opened ROW * COL - MINES; }注意这个函数只能在没有插旗功能的基础版本里直接用。如果你后面加了右键标记旗子的功能统计时还要排除掉旗子标记不然赢的条件会算错。4. 完整源码与运行效果演示4.1 可以直接运行的完整C语言源码下面这份代码是我最终整理出来的版本把前文提到的所有函数组合在一起可以直接保存成minesweeper.c编译运行。我在写的时候刻意保持了结构清晰每个函数只负责一件事复习的时候也很好对照。#include stdio.h #include stdlib.h #include time.h #define ROW 9 #define COL 9 #define MINES 10 #define ROWS (ROW 2) #define COLS (COL 2) void initBoard(char mine[ROWS][COLS], char show[ROWS][COLS]) { int i, j; for (i 0; i ROWS; i) { for (j 0; j COLS; j) { mine[i][j] 0; show[i][j] #; } } } void setMines(char mine[ROWS][COLS]) { int cnt 0; while (cnt MINES) { int x rand() % ROW 1; int y rand() % COL 1; if (mine[x][y] 0) { mine[x][y] *; cnt; } } } void calculateNumbers(char mine[ROWS][COLS]) { int i, j, k; int dx[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (i 1; i ROW; i) { for (j 1; j COL; j) { if (mine[i][j] *) continue; int cnt 0; for (k 0; k 8; k) { if (mine[i dx[k]][j dy[k]] *) cnt; } mine[i][j] 0 cnt; } } } void printBoard(char show[ROWS][COLS]) { int i, j; printf(\n ); for (j 1; j COL; j) printf(%d , j); printf(\n); for (i 1; i ROW; i) { printf(%2d , i); for (j 1; j COL; j) { if (show[i][j] 0) printf( ); else printf(%c , show[i][j]); } printf(\n); } } void revealMines(char mine[ROWS][COLS], char show[ROWS][COLS]) { int i, j; for (i 1; i ROW; i) { for (j 1; j COL; j) { if (mine[i][j] *) show[i][j] *; } } printBoard(show); } void openCell(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y) { if (x 1 || x ROW || y 1 || y COL) return; if (show[x][y] ! #) return; if (mine[x][y] *) return; show[x][y] mine[x][y]; if (mine[x][y] 0) { int dx[8] {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k 0; k 8; k) { openCell(mine, show, x dx[k], y dy[k]); } } } int isWin(char show[ROWS][COLS]) { int i, j, opened 0; for (i 1; i ROW; i) { for (j 1; j COL; j) { if (show[i][j] ! #) opened; } } return opened ROW * COL - MINES; } int main() { char mine[ROWS][COLS]; char show[ROWS][COLS]; int x, y; srand((unsigned)time(NULL)); initBoard(mine, show); setMines(mine); calculateNumbers(mine); while (1) { printBoard(show); printf(请输入坐标行 列例如: 3 5 ); if (scanf(%d %d, x, y) ! 2) { while (getchar() ! \n); printf(输入无效请重新输入。\n); continue; } if (x 1 || x ROW || y 1 || y COL) { printf(坐标越界请重新输入。\n); continue; } if (show[x][y] ! #) { printf(这个格子已经翻开了换一个吧。\n); continue; } if (mine[x][y] *) { revealMines(mine, show); printf(踩到雷了游戏结束。\n); break; } openCell(mine, show, x, y); if (isWin(show)) { printBoard(show); printf(恭喜所有安全格子都翻开了游戏胜利\n); break; } } return 0; }代码运行起来之后每次循环都会打印当前棋盘等待输入然后根据输入决定是否翻开、是否触发扩散、是否结束游戏。整个流程没有复杂的图形界面但对C语言核心知识点的覆盖非常完整。4.2 一次对局的运行过程长什么样程序启动后打印出来的棋盘类似下面这样1 2 3 4 5 6 7 8 9 1 # # # # # # # # # 2 # # # # # # # # # 3 # # # # # # # # # 4 # # # # # # # # # 5 # # # # # # # # # 6 # # # # # # # # # 7 # # # # # # # # # 8 # # # # # # # # # 9 # # # # # # # # # 请输入坐标行 列例如: 3 5 如果输入5 5而这个格子不是雷程序会调用openCell。假如它周围有2颗雷那么该格会显示数字2假如它周围没有雷程序会像涟漪一样自动向外扩散一片区域变成空格和数字看起来非常直观。如果踩雷revealMines会把所有*雷位置标出来玩家一眼就能看到自己刚才踩中了哪颗以及之前那些数字暗示的雷位到底对不对。这种“死得明白”的反馈对调试和查错都非常有帮助。4.3 在不同环境下怎么编译运行Linux或macOS下直接在终端执行gcc minesweeper.c -o minesweeper ./minesweeperWindows下如果用Dev-C或Visual Studio新建一个C源文件把代码粘贴进去编译运行即可。如果用VSCode写C语言先确保本机装了MinGW或Visual Studio Build Tools配置好C/C插件后一样能跑。这里提醒一句如果代码里的中文提示在Windows控制台显示成乱码一般是源文件编码和控制台编码不一致导致的最简单的方案是把所有printf里的中文改成英文或者把源文件另存为GBK编码。5. 常见问题与排查技巧实录5.1 输入一个字母后程序像死了一样刷屏这是我在群里看到初学者问得最多的问题。原因就是scanf读到非法字符时不会消费掉这个字符它会一直留在输入缓冲区里程序循环回到scanf时再次读到同一个非法字符于是无限循环。解决方法是检查scanf的返回值并在失败时清空缓冲区。我在主线代码里已经写好了if (scanf(%d %d, x, y) ! 2) { while (getchar() ! \n); printf(输入无效请重新输入。\n); continue; }这里while (getchar() ! \n)的作用是把缓冲区里直到换行符为止的所有字符全部读走相当于一次“大扫除”。不清空的话下次scanf还是会读到残留的垃圾字符。5.2 点开空白区域时部分格子没有正常展开如果递归展开不完整先检查openCell函数开头的边界条件。我见过几种典型错误漏掉越界判断导致访问到负下标把show[x][y] ! #写成了mine[x][y] ! #导致递归永远进不去还有的把递归展开条件写成了mine[x][y] 0但数组里存的是字符0而不是整数0两者在比较时永远不相等——这类字符和数字混淆的小错误用printf打印中间结果最容易发现。5.3 每次运行生成的雷区都一样那一定是在main里忘了写srand或者把srand放在循环内部反复调用。srand只需要在程序启动时调用一次srand((unsigned)time(NULL));time(NULL)的精度是秒如果你在极短的时间内连续启动两次程序确实有可能雷区相同。但在正常手动操作场景下这个概率很低。如果做自动化测试想复现某个雷区可以把time(NULL)换成固定数字比如srand(42)这样每次运行雷区一致方便复现问题。测试完再改回来就好。5.4 手动构造雷区来验证递归逻辑有一个很实用的小技巧当递归展开和数字计算逻辑怎么调都不对时别靠运气去试随机雷区而是临时在setMines完成后手动覆盖固定的雷位置比如把mine[5][5] *、mine[3][3] *强制写死然后跑一遍程序。雷区固定后数字、递归展开、胜利判定都能按预期复现排查问题会容易很多。这比一次次重新随机布雷靠碰运气复现bug要高效得多。5.5 常见问题速查表现象可能原因解决办法每次运行雷的位置都一样没有调用srandmain开头加srand((unsigned)time(NULL))输入字母后程序疯狂刷屏scanf读到非法字符后缓冲区未清理判断scanf返回值用while(getchar()!\n)清空打开空白区域没有扩散递归边界或类型判断写错检查openCell开头三个return的顺序和条件棋盘数字排版歪歪扭扭没有控制输出宽度行号列号用%2d0打印成两个空格点开边界格子程序崩溃数组越界访问使用外圈围墙ROWSROW2只使用1到ROW下标中文输出乱码源文件编码与控制台编码不一致改成英文输出或另存为GBK编码6. 功能扩展方向与个人实操心得6.1 从简单版到能看的扫雷还差这几步基础版本跑通之后如果还想继续打磨有几个很自然的扩展方向。第一是插旗功能。让玩家输入类似f 3 5的命令在指定位置打上#之外的标记字符比如F。这需要修改输入解析逻辑在读取两个整数之前先允许一个命令字符然后在show数组上把标记存下来同时翻开逻辑和胜负统计都要排除标记位置。这个功能能加深对状态机和数据分离的理解。第二是第一次点击保护。真实扫雷中玩家第一次点击永远不能踩雷否则体验很差。实现方式是在第一次输入后如果该位置是雷把雷换到另一个随机空格上。这个功能让随机布雷变得更“友好”也是反作弊设计中很好玩的一个小练习。第三是动态难度选择。把ROW、COL、MINES从宏定义改成全局变量或结构体字段用菜单让玩家选择初级、中级、高级。你能直观感受到代码里哪些地方依赖于编译期常量哪些地方需要改成运行时变量从而对“参数化设计”有更深的体会。第四是清屏与界面优化。可以在每次打印棋盘前调用system(cls)Windows或printf(\033[2J\033[H)终端ANSI转义让棋盘始终占据屏幕顶部别让历史输出越堆越多。这个优化虽然简单但对整体观感提升非常明显。第五是文件存档。如果想更进一步可以用fprintf和fscanf把棋盘、雷位、已翻开状态写进文件下次启动时读档恢复。这个扩展会自然地把C语言文件读写、格式化输出、结构体打包等知识点带进来让项目从“课程练习”变成“能展示的作品”。6.2 写扫雷真正让我记住的几件事这个项目做下来我觉得最值得记住的不是任何一条语法而是三个思维习惯。第一个是“函数的边界意识”。一个递归函数写得好不好除了看中间过程更要看它开头怎么处理边界。越界先返回、已处理先返回、非法状态先返回这三条雷打不动。写扫雷时养成的这个习惯后来写任何复杂点儿的算法都一直在用。第二个是“数据与表现分离”。两张地图分开之后打印、统计、扩展标记功能都不需要动逻辑核心。这种设计理念虽然在这个小程序里看起来有点“杀鸡用牛刀”但体会一次之后再看那些动辄几千行的项目代码就不会觉得无从下手了。第三个是“别怕调试”。我调试扫雷时最常用的工具就是printf在布雷之后把mine数组打印一遍在翻开前后把show数组打印一遍在判断胜利前把已翻开格子数打印一遍。很多东西不用猜打印出来一眼就明白了。等你熟练之后再上调试器断点会顺手很多。如果让我给新手一个建议我会说先别急着抄完整代码而是自己从头写一遍卡住了再看参考。第一次写不出来很正常把错误都踩一遍再回头看那些“为什么数组要外扩一圈”“为什么递归有三个return”你会发现自己已经彻底理解了。