C语言实战:从零实现扫雷游戏,掌握二维数组与递归
1. 为什么用C语言写扫雷是检验基本功的试金石很多人学完C语言的语法之后会陷入一段迷茫期——变量、循环、函数、数组、指针都过了一遍但真让你从零写一个能跑起来的小项目又不知道从哪下手。扫雷这个题目看起来简单实际上它几乎把C语言入门阶段所有核心知识点都串了一遍二维数组、函数封装、递归、随机数、输入处理、边界判断、状态管理。你把这一个项目吃透比刷一百道语法练习题都管用。我当初第一次写扫雷的时候觉得不就是个棋盘嘛两个二维数组一摆一个存雷一个存显示能有多难结果真正动手才发现坑远比想象的多。比如雷的随机布置怎么保证不重复翻开一个空格之后怎么自动展开周围区域第一次点击怎么保证不会踩雷这些问题在纯语法学习阶段根本不会遇到只有真正做项目才会逼着你去想解决方案。这篇文章我会把整个扫雷项目的实现过程拆开来讲从数据结构设计到核心算法从边界处理到交互优化每一步都会说清楚为什么这么做、不这么做会出什么问题。代码全部基于标准C语言不依赖任何第三方库在Windows和Linux下都能编译运行。适合已经学完C语言基础语法、想通过一个完整项目来巩固知识的读者也适合想拿扫雷当课程设计或者练手项目的同学。先说一下最终要实现的版本9×9的棋盘默认布置10颗雷支持翻开格子和标记雷两种操作翻开空白格自动展开踩雷游戏结束翻开所有非雷格子获胜。这个规模不大不小刚好能把核心逻辑讲清楚又不会让代码量膨胀到难以阅读。2. 棋盘的数据结构设计与两个数组的分工2.1 为什么需要两个二维数组扫雷的棋盘本质上是一个二维网格每个格子有几种状态有没有雷、有没有被翻开、有没有被标记。最直觉的做法可能是定义一个结构体每个格子存一个结构体变量里面放各种标志位。但实际写下来你会发现用两个独立的二维数组反而更清晰、更好操作。我的做法是这样的一个数组叫mine专门存雷的分布信息mine[i][j] 1表示这个位置有雷0表示没有。另一个数组叫show专门存玩家看到的界面状态show[i][j] *表示还没翻开0到8表示翻开后显示周围雷数#表示玩家标记了雷。两个数组各司其职逻辑上不会互相干扰。你可能会问为什么不合并成一个数组因为合并之后每次判断“这个格子能不能翻开”你都要同时检查好几个标志位代码会变得很啰嗦。分开之后mine数组在游戏初始化之后就不再改变show数组随着玩家操作不断更新职责非常明确。2.2 数组大小为什么定义成11×11这里有一个很容易被忽略的细节。如果你定义棋盘是9×9那数组就是mine[9][9]。但问题是当你计算边角格子周围有多少雷的时候需要访问它上下左右的邻居。比如计算(0,0)位置周围的雷数你需要访问(-1,-1)、(-1,0)这些越界的位置。当然你可以在每次访问前加边界判断但那样代码会多出很多if语句又丑又容易出错。我的做法是把数组定义成mine[11][11]实际使用的区域是mine[1][1]到mine[9][9]最外圈留作缓冲区永远不放雷也不显示。这样计算任意格子周围雷数的时候直接遍历它周围8个方向就行不需要任何额外的边界判断。多出来的两行两列内存开销几乎可以忽略不计但代码的简洁度和可读性提升非常明显。#define ROWS 9 #define COLS 9 #define MINES 10 int mine[ROWS 2][COLS 2]; // 雷的分布1表示有雷 char show[ROWS 2][COLS 2]; // 玩家看到的界面注意数组大小用ROWS 2而不是直接写11这样如果以后想把棋盘改成16×16或者自定义大小只需要改宏定义就行不用去代码里到处找硬编码的数字。2.3 初始化函数的实现细节初始化分两步先把两个数组全部清零或者填充初始字符然后随机布置指定数量的雷。清零这一步很多人会偷懒觉得全局数组默认就是0。确实全局数组和静态数组会被自动初始化为0但show数组需要填充*而不是0所以还是得老老实实遍历一遍。布置雷的时候我用一个while循环每次随机生成一个坐标如果这个位置还没有雷就放一颗计数器加一直到放满MINES颗为止。这里有一个潜在的问题如果棋盘很大而雷很少随机碰撞的概率很低效率没问题。但如果棋盘很小而雷很多比如9×9放80颗雷那随机碰撞的概率就会非常高循环可能要跑很多次才能放满。不过对于扫雷这个场景来说雷的密度通常不会超过30%所以这种简单的随机方法完全够用。void init_board() { int i, j; for (i 0; i ROWS 2; i) { for (j 0; j COLS 2; j) { mine[i][j] 0; show[i][j] *; } } int count 0; while (count MINES) { int r rand() % ROWS 1; int c rand() % COLS 1; if (mine[r][c] 0) { mine[r][c] 1; count; } } }随机数种子用srand((unsigned)time(NULL))来设置放在main函数开头调用一次就行。如果你不设种子每次运行游戏雷的分布都是一样的调试的时候可能觉得没问题但玩家一玩就会发现每次布局完全相同体验很差。3. 翻开格子的核心逻辑与递归展开的实现3.1 计算周围雷数的函数这是整个游戏里被调用最频繁的函数之一。给定一个坐标返回它周围8个格子中雷的总数。因为我们在数组外围留了一圈缓冲区所以不需要做任何边界检查直接遍历就行。int count_mines(int r, int c) { int count 0; int dr, dc; for (dr -1; dr 1; dr) { for (dc -1; dc 1; dc) { if (dr 0 dc 0) continue; if (mine[r dr][c dc] 1) count; } } return count; }这个双重循环遍历了周围8个方向跳过自身。逻辑很直白但有一个细节值得注意dr和dc同时为0的时候就是当前格子本身必须跳过否则如果当前格子有雷计数会多算一个。这个错误很隐蔽因为大多数情况下当前格子没有雷不跳过也不会出错但一旦踩到雷就会暴露问题。3.2 递归展开空白区域的原理扫雷最爽的操作就是点一下空白格哗啦一下展开一大片。这个效果靠的就是递归。逻辑是这样的当你翻开一个格子如果它周围没有雷count_mines返回0那就自动翻开它周围的所有格子对于周围每一个被翻开的格子如果它周围也没有雷继续递归展开。void reveal(int r, int c) { if (r 1 || r ROWS || c 1 || c COLS) return; if (show[r][c] ! *) return; if (mine[r][c] 1) return; int count count_mines(r, c); show[r][c] 0 count; if (count 0) { int dr, dc; for (dr -1; dr 1; dr) { for (dc -1; dc 1; dc) { if (dr 0 dc 0) continue; reveal(r dr, c dc); } } } }递归的终止条件有三个坐标越界、格子已经翻开过、格子是雷。这三个条件缺一不可。特别是“已经翻开过”这个判断如果没有它递归会在两个相邻的空白格之间无限循环直接导致栈溢出崩溃。提示递归展开虽然写起来简洁但如果棋盘特别大而且空白区域特别多递归深度可能会很大。对于9×9的棋盘来说完全不用担心但如果你要扩展到几十乘几十的棋盘建议改成用队列实现的广度优先搜索避免栈溢出的风险。3.3 第一次点击保护机制扫雷有一个约定俗成的规则第一次点击永远不会踩雷。这个体验非常重要否则玩家开局就死游戏根本没法玩。实现方式有两种一种是先让玩家点一次然后根据点击位置重新布置雷另一种是布置雷的时候避开玩家第一次点击的位置及其周围。我采用的是第一种方案逻辑更简单。游戏开始时先不布置雷等玩家第一次输入坐标后再调用布置雷的函数把玩家点击的位置和它周围8个格子排除在雷区之外。这样第一次点击必定是安全的而且大概率能展开一片区域游戏体验会好很多。int first_click 1; // 在玩家输入坐标之后 if (first_click) { place_mines(r, c); // 避开(r,c)及其周围布置雷 first_click 0; }place_mines函数和之前的init_board里的布置逻辑类似只是在随机生成坐标的时候多判断一个条件如果生成的坐标在玩家点击位置周围一圈之内就跳过重新生成。4. 游戏交互、胜负判定与那些容易踩的坑4.1 输入处理与界面刷新玩家输入方面我设计了一个简单的命令格式输入r c表示翻开第r行第c列的格子输入r c f表示在该位置标记或取消标记雷。用scanf读取三个值如果第三个值是f就执行标记操作否则执行翻开操作。界面刷新就是遍历show数组把每个字符打印出来。为了让玩家能看清行列号我会在棋盘上方打印列号在每行左侧打印行号。这样玩家输入坐标的时候不容易搞错位置。void print_board() { int i, j; printf( ); for (j 1; j COLS; j) printf(%d , j); printf(\n); for (i 1; i ROWS; i) { printf(%d , i); for (j 1; j COLS; j) { printf(%c , show[i][j]); } printf(\n); } }4.2 胜负判定的两种条件胜利条件很简单所有非雷格子都被翻开。实现方式就是遍历整个棋盘统计还有多少个格子是*状态如果这个数量等于雷的总数说明剩下的全是雷玩家赢了。失败条件更简单玩家翻开的格子是雷直接游戏结束把所有的雷都显示出来。这里有一个容易忽略的细节标记操作不算翻开所以标记的格子仍然是*状态不会影响胜负判定。但如果你在标记的格子上再次输入翻开命令应该允许翻开因为玩家可能标记错了想反悔。4.3 踩坑记录那些我实际遇到过的问题第一个坑是随机数种子的问题。我一开始忘了调srand结果每次运行雷的布局完全一样自己测试的时候没发现因为每次都是同样的位置我以为是对的。后来换了一台机器跑才发现问题。第二个坑是递归展开的边界判断。我最初写reveal函数的时候终止条件只写了“格子是雷”和“已经翻开”忘了判断坐标越界。结果在边角格子展开的时候递归访问到了缓冲区之外的区域程序直接崩溃。后来加上越界判断才解决。第三个坑是输入缓冲区的问题。用scanf读取字符的时候如果之前有残留的换行符可能会导致读取异常。我的解决办法是在需要读取字符的地方先用getchar吃掉一个字符或者在scanf的格式字符串前面加一个空格。第四个坑是关于show数组的字符表示。我一开始用数字0到8来存周围雷数打印的时候发现和*混在一起显示很乱。后来改成用字符0到8来存打印的时候直接输出字符就行不用做类型转换。// 错误做法用整数存 show[r][c] count; // count是int // 正确做法用字符存 show[r][c] 0 count;4.4 代码组织与函数划分建议整个项目我建议至少拆成以下几个函数init_board负责初始化place_mines负责布置雷print_board负责显示count_mines负责计算周围雷数reveal负责翻开格子mark_cell负责标记check_win负责判断胜负。main函数里就是一个主循环不断打印棋盘、读取输入、执行操作、判断胜负直到游戏结束。这样拆分的好处是每个函数职责单一调试的时候可以单独测试。比如你想验证count_mines对不对可以手动设置几个雷的位置然后调用这个函数看返回值是否符合预期不需要跑整个游戏。函数之间的调用关系也很清晰main调用print_board和输入处理输入处理调用reveal或mark_cellreveal内部调用count_mines并递归调用自身。整个流程没有循环依赖逻辑上是单向的。5. 从能跑到好用几个提升体验的改进方向5.1 用颜色区分数字和标记纯字符界面看起来比较单调但如果你在Linux或者支持ANSI转义序列的终端下运行可以给不同的数字加上不同的颜色。比如1是蓝色、2是绿色、3是红色这样玩家一眼就能看出哪些区域雷比较密集。标记的雷可以用一个醒目的符号比如!或者F来表示和未翻开的*区分开。实现方式就是在打印每个字符之前根据字符的值输出对应的颜色转义码。这部分不是必须的但加上之后游戏的观感会提升一个档次。5.2 支持自定义棋盘大小和雷数目前的版本棋盘大小和雷数是写死的宏定义。如果你想让它更灵活可以在游戏开始时让玩家输入行数、列数和雷数然后动态分配数组。这就需要用malloc来创建二维数组或者用一个一维数组模拟二维。动态内存分配对于初学者来说可能有点绕但这是一个很好的练习机会。核心思路是先分配一个指针数组每个指针再指向一行元素。释放的时候反过来先释放每一行再释放指针数组。5.3 防止重复翻开已翻开的格子目前的reveal函数里已经有判断show[r][c] ! *就返回的逻辑所以重复翻开同一个格子不会出问题。但玩家输入的时候如果输入了已经翻开的坐标程序不会有任何提示体验上会让人觉得“我点了怎么没反应”。可以在输入处理的地方加一个判断如果目标格子已经翻开就提示玩家“该位置已经翻开”。5.4 计时器和剩余雷数显示一个完整的扫雷游戏通常会有计时功能和剩余雷数提示。计时器可以用time函数记录开始和结束的时间差剩余雷数就是总雷数减去已标记的数量。这两个功能实现起来都不复杂但能让游戏看起来更专业。计时器的实现是在游戏开始时记录一个时间戳每次打印棋盘的时候计算当前时间和开始时间的差值显示在界面上。剩余雷数就是MINES减去show数组中标记为#的格子数量。5.5 关于代码风格的一点个人建议写这种小项目的时候很多人不太在意代码风格变量名用a、b、c函数名用f1、f2。项目小的时候确实不影响运行但一旦代码超过两三百行可读性就会急剧下降。我的建议是从一开始就养成好习惯变量名用有意义的英文单词函数名用动词开头描述它做什么关键逻辑加上注释说明为什么这么做而不是做了什么。另外宏定义尽量集中放在文件开头不要散落在各个函数里。这样以后要调整参数的时候只需要改一个地方就行。数组的维度、雷的数量、棋盘的大小这些都应该用宏定义来表示而不是在代码里到处写数字。写扫雷这个项目最大的收获不是学会了某个具体的语法点而是学会了怎么把一个模糊的需求拆解成一个个可以独立实现和测试的小模块。这种思维方式在你以后写任何项目的时候都用得上。代码写完之后自己多玩几局你会发现很多在写代码时想不到的问题比如输入格式不对导致程序行为异常、边界情况下递归展开不正确等等。这些只有真正用过才能发现的问题才是最有价值的经验。