ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言实战:手写控制台扫雷游戏,掌握数组与递归核心技巧

C语言实战:手写控制台扫雷游戏,掌握数组与递归核心技巧 1. 项目背景为什么扫雷是C语言练手的经典项目如果让我来选一个最适合C语言新手作为“阶段综合项目”的游戏扫雷绝对排得上号。它不是那种随便写几行就能交差的玩具也不是那种需要图形库、网络编程才能跑起来的重型项目它刚好踩在C语言核心知识点的“交叉点”上数组操作、随机数、循环嵌套、函数封装、递归调用、输入输出控制再加上一点游戏设计的逻辑思维。你只用一个控制台黑窗口不带任何第三方库就能写出一个有完整交互体验的小游戏。很多初学者学C语言学到指针、链表就卡住了我一直觉得卡住的原因不是后面的知识点太难而是前面的基础没有真正“用起来”。扫雷这个项目恰好能把你学过的数组、循环、函数这些东西全部调动起来让你在写游戏的过程中重新理解它们到底有什么用。比如数组不只是“存一堆数”它在这里就是棋盘本身递归不只是“函数调用自己”它在这里就是扫雷自动展开一片空白区域的利器。这篇文章里给出一份可以直接复制运行的完整C语言扫雷代码并把每一步的设计思路、为什么这么写、会遇到哪些坑全部讲透。无论你是刚学完数组的学生还是在准备计算机二级或者单纯想做个C语言项目练手这个项目都值得完整地做一遍。2. 项目整体设计与核心思路拆解2.1 数据模型设计用两个二维数组模拟真实棋盘扫雷游戏表面上是“一个格子接一个格子地翻开”但背后需要区分两个层面的信息一是棋盘的真实状态也就是地雷分布和每个格子周围的雷数二是玩家的可见状态也就是哪些格子已经被翻开、哪些被标记了旗子。所以这个项目选择用两个二维数组来分别存这两类信息mine[ROWS2][COLS2]负责存真实状态。取值是-1表示地雷取值0~8表示该格子周围8格中的地雷数量。show[ROWS2][COLS2]负责存玩家看到的状态。取值0表示未翻开1表示已翻开2表示玩家标记了旗子。为什么不是只用一个数组因为真实信息和可见信息混在一起会非常乱。比如你翻开一个格子发现它是数字3你并不知道它下面有没有雷如果游戏逻辑想要判断“我点的这个格子到底有没有雷”必须有一个地方记录真实地雷分布。用两个数组各司其职游戏的每一步操作都能清晰地区分“真实世界”和“玩家视野”这是扫雷乃至绝大多数游戏开发里的基础思想。还有一个细节值得专门讲数组尺寸为什么要写成ROWS2而不是ROWS这其实是这个项目里最容易犯错的点。如果你定义一个9x9的棋盘那么合法的下标范围是0~8但当你要计算棋盘最角落那个格子周围有多少雷时你访问的就是mine[-1][-1]、mine[0][-1]这类越界下标。C语言对数组越界不做任何提示你读到的是一块随机的内存轻则数字算错重则程序直接崩溃。解决办法有两种要么在计算周围雷数的时候加一堆判断要么把数组整体扩大一圈让棋盘实际有效区域是1~9最外面一圈全部置0。mine[ROWS2][COLS2]就是用了第二种方式代码里不用做任何边界特判循环直接枚举周围8个格子就行省心又安全。这个扩大边界的思路在很多算法题里也特别好用值得记下来。2.2 布雷与数字计算从随机数到“扫地雷”的双层循环布雷的逻辑看起来简单在棋盘上随机找10个不重复的位置标记为地雷。但写的时候有几个细节要注意。第一是随机数的生成。C语言的rand()生成的其实是一个伪随机数序列如果你不在程序开始的时候调用srand((unsigned)time(NULL))设置不同的种子那么每次运行程序生成的雷区分布都是一模一样的这就很扫兴了。用当前时间做种子能保证每次运行雷区都不一样。第二是随机数范围的控制。棋盘有效区域是1~ROWS所以rand() % ROWS 1可以得到1~ROWS的随机整数。这里有个新手容易踩的坑rand() % ROWS得到的是0~ROWS-1加1之后才是1~ROWS。如果没有加1你就会在下标0的区域布雷然后又因为数组扩大了一圈而不会立刻报错排查起来非常抓狂。第三是去重。布雷需要保证同一个位置不能被重复布雷否则10个雷可能实际只布了9个。代码里用一个while循环只要还没达到地雷总数就继续生成随机坐标如果该位置已经有雷了就重新生成。这种方式在雷数远小于棋盘格子数时效率很高9x9棋盘放10个雷基本不会出现多次碰撞。布雷完成后就要计算每个非雷格子周围有多少雷。两天时间里我看过好几个初学版本有人用8个if一个一个判断有人用两重循环枚举周围坐标。两重循环其实是更清晰的做法外层遍历-1到1内层也是-1到1跳过(0,0)本身然后检查对应位置是不是雷是就计数加一。这个双层循环本质上就是在“扫”一个3x3的小区域正好和游戏名呼应上了。2.3 递归展开理解扫雷“翻一片”的背后原理玩过扫雷的都知道当你点到一个没有相邻地雷的空白格子时系统会自动把周围一大片空白和边缘数字格子全部翻开这个效果非常爽快实现起来靠的是递归。递归的展开逻辑是这样的当玩家翻开(r,c)时先检查这个格子的“雷数”是不是0。如果不是0说明它显示数字翻开它本身就可以结束了。如果是0说明它周围没有雷那就需要继续检查它的8个邻居如果某个邻居也是未翻开的、并且也不是雷就继续递归地翻开它。这个过程就像在水面丢下一颗石子波纹一层层向外扩散直到碰到数字边界才停下来。这段代码是理解递归非常好的素材。递归终止条件有三个坐标越界、格子已经被翻开、格子是地雷。这三个条件缺一不可。特别是判断“已经被翻开”这条如果没有它两个相邻的空白格子会互相递归调用形成无限循环程序直接栈溢出崩溃。我第一次写的时候就是漏了这个条件一运行就黑窗口无响应后来加了一个打印语句才发现一直在来回调用。另外需要说明的是这种递归展开方式在9x9小棋盘上完全没问题但如果以后你把这个游戏改造成20x30的大棋盘递归深度可能增加不少理论上存在栈溢出的风险。进阶方案是用队列做广度优先遍历或者用显式的栈来做深度优先遍历不过那就是后续优化的话题了现阶段先把递归写明白更重要。2.4 胜负判断与游戏主流程别让“赢了”藏到最后一刻游戏主循环的逻辑非常简单一直重复“打印棋盘 - 读玩家输入 - 处理操作 - 判断胜负/生死”直到游戏结束。但胜负判断有一个容易忽略的地方。扫雷的胜利条件是“所有非雷格子全部被翻开”而不是“所有雷都被标记”。这两个条件在大多数情况下会同时满足但在玩家瞎标旗子的时候就会出现偏差。比如玩家把某个非雷格标记成旗子又把某个雷成功标了出来这时候地雷确实全标对了但棋盘上还有没翻开的非雷格子游戏不应该判定胜利。正确做法是统计show数组中已经翻开的格子数量如果这个数量等于“总格子数减去地雷数”说明所有非雷格子都已经翻开游戏胜利。这个判断方式不依赖玩家标记得对不对逻辑上非常干净。主循环里还有两个输入细节值得说明。一是用scanf分别读入操作类型和坐标但要注意scanf对非法输入的容忍度很低如果玩家输入了一个字母程序可能陷入死循环。为了保持代码简洁这个版本没有做太严谨的输入校验但在后面的“扩展建议”里可以提到如何处理。二是玩家误踩地雷时游戏可以直接结束也可以给玩家展示完整的雷区地图再结束。我选用的是先完整打印一次棋盘再提示游戏结束这样玩家能看到自己到底踩到了哪颗雷也方便排查是不是布雷逻辑出了问题。3. 完整C语言扫雷代码3.1 代码结构与核心函数说明在贴完整代码之前先带大家梳理一下每个函数的作用这样看代码的时候思路能更清晰init()初始化两个数组把内存里残留的旧数据全部清零。placeMines()布雷保证10个雷位置互不重复。calcNumbers()统计每个非雷格子周围的雷数填入mine数组。printBoard()按行列打印当前棋盘状态未翻开显示.标记显示F翻开后显示数字或空格。expand()递归展开空白区域。checkWin()判断是否所有非雷格子都已翻开。这里有几个设计决策值得说一下。mine数组里用-1表示地雷数字0~8表示周围雷数这样做的好处是打印棋盘时mine[r][c]的值可以直接拿到不需要再临时算一遍。棋盘行列号从1开始而不是从0开始这样显示给玩家看的时候更直观同时配合扩大一圈的数组访问边界格子不会出错。3.2 可直接复制运行的完整代码#include stdio.h #include stdlib.h #include time.h #define ROWS 9 #define COLS 9 #define MINES 10 int mine[ROWS 2][COLS 2]; int show[ROWS 2][COLS 2]; void init() { for (int i 0; i ROWS 2; i) { for (int j 0; j COLS 2; j) { mine[i][j] 0; show[i][j] 0; } } } void placeMines() { int placed 0; while (placed MINES) { int r rand() % ROWS 1; int c rand() % COLS 1; if (mine[r][c] 0) { mine[r][c] -1; placed; } } } void calcNumbers() { for (int i 1; i ROWS; i) { for (int j 1; j COLS; j) { if (mine[i][j] -1) { continue; } int cnt 0; for (int di -1; di 1; di) { for (int dj -1; dj 1; dj) { if (di 0 dj 0) { continue; } if (mine[i di][j dj] -1) { cnt; } } } mine[i][j] cnt; } } } void printBoard() { printf( ); for (int j 1; j COLS; j) { printf(%2d , j); } printf(\n); printf( ); 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) { if (show[i][j] 0) { printf( . ); } else if (show[i][j] 2) { printf( F ); } else if (mine[i][j] -1) { printf( * ); } else if (mine[i][j] 0) { printf( ); } else { printf( %d , mine[i][j]); } } printf(|\n); } printf( ); for (int j 1; j COLS; j) { printf(---); } printf(\n); } void expand(int r, int c) { if (r 1 || r ROWS || c 1 || c COLS) { return; } if (show[r][c] ! 0) { return; } if (mine[r][c] -1) { return; } show[r][c] 1; if (mine[r][c] 0) { for (int di -1; di 1; di) { for (int dj -1; dj 1; dj) { if (di 0 dj 0) { continue; } expand(r di, c dj); } } } } int checkWin() { int opened 0; for (int i 1; i ROWS; i) { for (int j 1; j COLS; j) { if (show[i][j] 1) { opened; } } } return opened ROWS * COLS - MINES; } int main() { srand((unsigned)time(NULL)); init(); placeMines(); calcNumbers(); int r, c, op; int gameover 0; while (!gameover) { printBoard(); printf(操作: 1翻开 2标记/取消标记: ); scanf(%d, op); printf(输入行列(如 5 5): ); scanf(%d %d, r, c); if (r 1 || r ROWS || c 1 || c COLS) { printf(输入越界请重新输入。\n); continue; } if (op 2) { if (show[r][c] 0) { show[r][c] 2; } else if (show[r][c] 2) { show[r][c] 0; } else { printf(该位置已翻开无法标记。\n); } continue; } if (op ! 1) { printf(无效操作。\n); continue; } if (show[r][c] 2) { printf(该位置已标记请先取消标记。\n); continue; } if (show[r][c] 1) { printf(该位置已翻开请重新选择。\n); continue; } if (mine[r][c] -1) { printBoard(); printf(踩到地雷了游戏结束\n); gameover 1; break; } expand(r, c); if (checkWin()) { printBoard(); printf(恭喜你排雷成功\n); gameover 1; } } return 0; }这就是全部代码保存为minesweeper.c直接编译就能跑。为了让代码更清晰我省略了Windows下的清屏操作因为不同编译环境的清屏函数不一样加上去反而会让代码失去跨平台兼容性。你可以按照下面的步骤运行它。4. 编译运行与实测记录4.1 编译运行步骤如果你用的是Visual Studio直接新建一个空项目把minesweeper.c添加进去编译运行即可。如果你用的是Dev-C新建源文件粘贴代码点编译运行。如果你用的是命令行环境比如Windows下装了MinGW或者macOS/Linux环境那么打开终端进入代码所在目录执行下面的命令gcc minesweeper.c -o minesweeper ./minesweeper有一点需要特别注意代码里的提示信息是中文的。Windows自带的命令行终端默认编码是GBK而很多现代编辑器保存文件时默认是UTF-8这就会导致中文提示乱码。解决办法有三个第一在代码最开始包含#include windows.h然后在main函数开头调用system(chcp 65001);第二使用Visual Studio或VS Code这类自带终端编码适配的编辑器第三把代码里的中文提示全部改成英文。为了项目简洁我保留了中文如果你运行发现乱码用前两种办法即可。4.2 实测运行记录我自己在Windows的MinGW环境下实际跑了一遍下面是第一次进入游戏的完整过程1 2 3 4 5 6 7 8 9 --------------------------- 1| . . . . . . . . . | 2| . . . . . . . . . | 3| . . . . . . . . . | 4| . . . . . . . . . | 5| . . . . . . . . . | 6| . . . . . . . . . | 7| . . . . . . . . . | 8| . . . . . . . . . | 9| . . . . . . . . . | --------------------------- 操作: 1翻开 2标记/取消标记: 1 输入行列(如 5 5): 5 5如果我运气好这次的(5,5)格子是空白会看到它周围的区域瞬间打开一大片棋盘会变成类似下面这样1 2 3 4 5 6 7 8 9 --------------------------- 1| . . . . . . . . . | 2| . . . . . . . . . | 3| . . . . . . . . . | 4| . . 1 1 2 . . . . | 5| . . 1 1 2 . . . | 6| . . 2 1 2 . . . . | 7| . . . . . . . . . | 8| . . . . . . . . . | 9| . . . . . . . . . | ---------------------------这就是递归展开在起作用。你可以看到中心空白区域向外扩散直到触碰到带有数字的格子才停下来。整个运行过程很流畅没有越界报错没有卡顿。5. 踩坑记录与常见问题排查5.1 新手最容易踩的四个坑第一个坑是数组越界。这个问题在初学C语言时几乎人人都会遇到它最大的迷惑性在于就算你越界了程序也可能不会立刻崩溃而是读出一堆莫名其妙的数字。我之前调试的时候遇到过calcNumbers计算出来的数字变成负数或者棋盘第一行突然出现一个奇怪的-858993460这些都是越界访问的典型症状。如果你遇到了类似情况优先检查所有数组下标是否都在合理范围内特别是有没有对行和列进行合法性校验。第二个坑是rand()的种子问题。忘了调用srand程序每次运行生成的雷区都一样。还有更隐蔽的情况如果在循环体内反复调用srand由于time(NULL)返回的是秒级时间戳在一秒之内多次调用会得到完全相同的随机序列导致布雷结果反而不是随机的。正确姿势是在main函数开头调用一次srand后面全部交给rand就行。第三个坑是递归展开死循环。不少读者把expand抄到自己代码里之后发现一运行就闪退这种大概率是递归缺少终止条件。展开时如果忘记检查show[r][c] ! 0两个空白格子就会互相调用无限递归下去最终栈溢出。记住递归必须有终止条件而且要保证每层递归都能让状态朝着“终止”的方向变化。第四个坑是输入校验不足。我用scanf直接读整数如果玩家手滑输入了字母ascanf会读取失败变量保持原值然后游戏可能会进入死循环或者跳出一大堆提示。严谨的做法是在读取后检查scanf的返回值或者改用fgets加sscanf的方式做输入解析。目前这个版本为了保持代码简单没有做这个处理我在后面扩展建议里再细说。5.2 常见问题速查表现象可能原因解决方案中文显示乱码源文件编码与终端编码不一致设置终端为UTF-8编码或改用英文提示每次运行的雷区一样缺少srand((unsigned)time(NULL))在main开头设置随机种子踩雷后游戏没有输雷的判断逻辑写反了检查是否用-1表示雷比较时不要写反翻开空白区域没有扩散递归展开条件写错检查是否在mine[r][c] 0时才递归数字显示异常或为负数数组越界检查数组下标合法性考虑扩大边界程序运行后闪退递归无限调用导致栈溢出检查递归终止条件是否完整标记后还能翻开操作逻辑分支错误在处理“翻开”操作前先检查show是否为2这张表里的问题我基本都实际遇到过尤其是前两行不管是新手还是写了几年代码的人换了一台新电脑仍然可能踩到。排查思路其实很固定先怀疑输入输入再怀疑数组边界最后怀疑递归逻辑按这个顺序来基本上很快就能定位。6. 扩展方向与后续建议6.1 从这个版本出发可以继续加哪些功能这份代码是一个很干净的骨架往上加功能非常容易。我最推荐尝试的扩展方向有三个。第一个是难度选择。可以把ROWS、COLS、MINES改成变量根据玩家输入生成初级9x910雷、中级16x1640雷、高级16x3099雷不同棋盘。需要注意的地方是当棋盘变大之后mine数组的最大尺寸要提前规划好比如用#define MAX_ROWS 30和#define MAX_COLS 30来定义数组大小实际使用ROWS和COLS控制范围。第二个是计时功能。在游戏开始后记录时间玩家胜利或失败后显示用时。在C语言里可以用clock()函数它返回的是从程序启动到当前时刻的CPU时钟计数除以CLOCKS_PER_SEC就能换算成秒。这个功能实现成本很低但能显著提升游戏体验让人觉得这是一个“真正”的游戏。第三个是更友好的输入方式。用fgets加上sscanf代替裸scanf可以避免因为输入字母导致程序崩溃的问题。核心写法是先用fgets读取一行字符串再用sscanf从这个字符串里解析两个整数。好处是即使解析失败fgets也已经把脏数据从缓冲区里读走了不会污染后续的输入。还有一点如果你对界面有追求可以尝试引入一些简单的棋盘美化比如用不同数字显示不同颜色。在Windows下可以用system(color)或者SetConsoleTextAttribute实现不过这个就不是纯C标准库能做到的了移植性会差一些。6.2 完成这个项目之后下一阶段学什么我之前常说C语言学到数组、函数、指针、结构体基本语法就算入门了但“入门”和“学会”之间还差着大量实践。扫雷这个项目做完之后你会对数组和递归有一个非常直观的理解。接下来可以试试再写两个方向完全不同的项目来巩固一个方向是学生成绩管理系统用来练习结构体和文件操作。这个项目和扫雷相比数据模型完全不同需要你处理“多条记录”“查找”“排序”“持久化存储”这些实际问题对工程化思维的帮助很大。另一个方向是贪吃蛇用来练习链表和动态内存管理。贪吃蛇的蛇身长度是动态变化的天然就是一个链表比死记硬背链表概念要生动得多。老实说我当时在大学里学C语言的时候就是因为做了扫雷这个项目才真正把数组、随机数、递归这些东西变成自己的。看代码和写代码是两回事动手写一遍踩一遍坑再回头看教科书上的知识点你会有完全不同的感受。如果你在做这个项目的过程中遇到了代码跑不通的情况不妨把报错信息贴到搜索引擎里查一查再对照着这篇文章的排查思路逐条检查多数问题都能自己解决掉。自己动手解决问题就是学编程最有成就感的时候了。
返回列表