news 2026/9/29 1:46:04

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言实战:手写控制台扫雷游戏,掌握数组与递归核心技巧

1. 项目背景:为什么扫雷是C语言练手的经典项目

如果让我来选一个最适合C语言新手作为“阶段综合项目”的游戏,扫雷绝对排得上号。它不是那种随便写几行就能交差的玩具,也不是那种需要图形库、网络编程才能跑起来的重型项目,它刚好踩在C语言核心知识点的“交叉点”上:数组操作、随机数、循环嵌套、函数封装、递归调用、输入输出控制,再加上一点游戏设计的逻辑思维。你只用一个控制台黑窗口,不带任何第三方库,就能写出一个有完整交互体验的小游戏。

很多初学者学C语言学到指针、链表就卡住了,我一直觉得,卡住的原因不是后面的知识点太难,而是前面的基础没有真正“用起来”。扫雷这个项目恰好能把你学过的数组、循环、函数这些东西全部调动起来,让你在写游戏的过程中重新理解它们到底有什么用。比如数组不只是“存一堆数”,它在这里就是棋盘本身;递归不只是“函数调用自己”,它在这里就是扫雷自动展开一片空白区域的利器。

这篇文章里给出一份可以直接复制运行的完整C语言扫雷代码,并把每一步的设计思路、为什么这么写、会遇到哪些坑全部讲透。无论你是刚学完数组的学生,还是在准备计算机二级,或者单纯想做个C语言项目练手,这个项目都值得完整地做一遍。

2. 项目整体设计与核心思路拆解

2.1 数据模型设计:用两个二维数组模拟真实棋盘

扫雷游戏表面上是“一个格子接一个格子地翻开”,但背后需要区分两个层面的信息:一是棋盘的真实状态,也就是地雷分布和每个格子周围的雷数;二是玩家的可见状态,也就是哪些格子已经被翻开、哪些被标记了旗子。

所以这个项目选择用两个二维数组来分别存这两类信息:

  • mine[ROWS+2][COLS+2]:负责存真实状态。取值是-1表示地雷,取值0~8表示该格子周围8格中的地雷数量。
  • show[ROWS+2][COLS+2]:负责存玩家看到的状态。取值0表示未翻开,1表示已翻开,2表示玩家标记了旗子。

为什么不是只用一个数组?因为真实信息和可见信息混在一起会非常乱。比如你翻开一个格子发现它是数字3,你并不知道它下面有没有雷;如果游戏逻辑想要判断“我点的这个格子到底有没有雷”,必须有一个地方记录真实地雷分布。用两个数组各司其职,游戏的每一步操作都能清晰地区分“真实世界”和“玩家视野”,这是扫雷乃至绝大多数游戏开发里的基础思想。

还有一个细节值得专门讲:数组尺寸为什么要写成ROWS+2而不是ROWS?这其实是这个项目里最容易犯错的点。如果你定义一个9x9的棋盘,那么合法的下标范围是0~8,但当你要计算棋盘最角落那个格子周围有多少雷时,你访问的就是mine[-1][-1]、mine[0][-1]这类越界下标。C语言对数组越界不做任何提示,你读到的是一块随机的内存,轻则数字算错,重则程序直接崩溃。

解决办法有两种:要么在计算周围雷数的时候加一堆判断,要么把数组整体扩大一圈,让棋盘实际有效区域是1~9,最外面一圈全部置0。mine[ROWS+2][COLS+2]就是用了第二种方式,代码里不用做任何边界特判,循环直接枚举周围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直接读整数,如果玩家手滑输入了字母a,scanf会读取失败,变量保持原值,然后游戏可能会进入死循环或者跳出一大堆提示。严谨的做法是在读取后检查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改成变量,根据玩家输入生成初级(9x9,10雷)、中级(16x16,40雷)、高级(16x30,99雷)不同棋盘。需要注意的地方是,当棋盘变大之后,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语言的时候,就是因为做了扫雷这个项目,才真正把数组、随机数、递归这些东西变成自己的。看代码和写代码是两回事,动手写一遍,踩一遍坑,再回头看教科书上的知识点,你会有完全不同的感受。如果你在做这个项目的过程中遇到了代码跑不通的情况,不妨把报错信息贴到搜索引擎里查一查,再对照着这篇文章的排查思路逐条检查,多数问题都能自己解决掉。自己动手解决问题,就是学编程最有成就感的时候了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 1:41:49

Java游泳馆管理系统实战:从环境搭建到二次开发避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:40:13

STM32CubeMX 6.14 安装与固件包下载全攻略:从零搭建嵌入式开发环境

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:40:07

n球入m盒建模六问:从可辨性到工程落地的计数本质

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:39:19

BUCK电路从原理到选型调试:开关电源降压设计实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:38:34

VirtualBox 虚拟机启动网卡报错排查:从报错定位到修复的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华