纯C语言控制台扫雷游戏实战:从二维数组到递归展开
我从大一学C语言那会儿就发现一个规律凡是能坚持写到期末的练手项目基本就两类——学生管理系统和游戏。而游戏里扫雷是性价比最高的一种。它不像贪吃蛇那样依赖光标定位和实时刷新也不像俄罗斯方块那样需要处理方块旋转和消除联锁逻辑上恰好卡在“数组函数循环”这个段位能把C语言八成的基础知识点串起来。所以每年都有不少人拿它当课设或者自学练手今天这篇就专门聊聊怎么用纯C语言、纯控制台把一个能玩、能赢、能复盘的扫雷游戏写出来。1. 项目整体设计与思路拆解1.1 扫雷藏在C语言里的那些考点先说句实在话扫雷游戏本身不难难的是你想练到什么程度。如果只是想交作业写一个5x5、10个雷的小棋盘跑通“输入坐标→判断踩雷→显示周围雷数→翻开所有空格”这条主线代码量大概在200行上下两三个小时就能搞定。但如果你打算把它当简历项目放进作品集或者准备在面试被问到时能多说几句那就得认真设计数据结构好好组织函数结构甚至考虑扩展性。从C语言教学的角度看扫雷至少覆盖了下面这些点二维数组的定义、初始化、遍历、传参。棋盘本身就是一个二维数组这是整个项目的地基。随机数的生成与种子的设置。布雷要用rand()但要配合srand和time才能做到每次运行雷的位置都不同。循环与分支的综合运用。玩家输入、判断合法性、展开空白格子处处是循环和if。函数的封装与模块化。初始化、布雷、打印、计算雷数、递归展开每一个步骤都应该拆成独立函数而不是全部塞进main。格式控制与用户交互。printf的排版输入坐标的提示边界条件的判断这些细节决定了程序“好不好用”。递归思想。点开一个空格后自动展开周围非雷区域这是扫雷的灵魂也是很多新人第一次接触递归的地方。说白了扫雷就是“二维数组应用”这章的综合实战题比老师布置的作业稍微复杂一点但又完全在掌握范围内属于跳一跳就能够到的难度。1.2 为什么不用图形界面这可能是很多新人的第一个问题为什么不用EasyX或者SDL画一个真正的扫雷界面答案是没必要。在你还没完全掌握数组、指针、函数之前引入图形库只会让调试难度翻倍你搞不清楚是自己的逻辑错了还是图形库的API调用错了最后很容易变成“百度复制代码”。控制台版本的扫雷所有的交互都是文本出了bug之后用printf打印棋盘状态一眼就能看出问题在哪非常适合练手和排错。而且控制台版扫雷的核心算法和图形界面版完全一样。布雷逻辑、雷数统计、展开算法、胜负判断这些在大作业里占的分数至少是七成。将来你如果真想做个有界面的版本把控制台里的逻辑函数原封不动搬过去再换个显示层就行代码能直接复用。先啃硬骨头再包装门面这个顺序不会错。1.3 两个棋盘还是两个数组这是设计初期最容易纠结的问题。我见过不少新手只定义了一个二维数组把雷区、数字区、玩家可见区全部糅在一起结果写出来的代码被if else塞满改一个功能就得动十处。我的建议是定义两个二维数组一个是答案棋盘专门存雷和数字一开始就全部算好玩家永远看不到。一个是显示棋盘只存三种状态未翻开、已翻开、已标记。为什么要拆开因为职责单一。答案棋盘在游戏开始前就固定下来了不会变显示棋盘是玩家视角翻开格子的时候只需要去答案棋盘查一下数值然后把对应的显示状态改一改就行。这样写出来的代码每个函数只干一件明确的事逻辑清楚得多了。具体定义可以是棋盘大小用宏定义比如ROWS和COLS不要写死在函数里。以后想改地图大小只动两个宏整份代码跟着变。雷的数量也用宏定义比如MINES方便调整难度。为了后面统计雷数方便数组一般多开一圈行和列各加2也就是实际定义成[ROWS2][COLS2]。这样在计算边界格子的雷数时就不用单独判断“这个格子是不是在边界上”效率和精神都省了。1.4 游戏状态机的设计还有一个细节是很多教程不会讲的游戏不能只有“流程”还得有“状态”。如果你玩过扫雷就知道一局游戏有三种结局继续玩、踩雷输掉、排雷成功获胜。在代码里这就需要一个变量来记录当前状态我用int类型的gameover来表示0代表游戏继续进行1代表踩雷结束2代表胜出。main函数里用一个while循环死缠着游戏状态只要gameover还是0就一直跑。这样做的好处是玩家中途输掉或者赢了循环自然退出不会出现“赢了还继续让你输入坐标”的尴尬情况。同理每一轮玩家的输入都要先检查坐标是否合法、是否已经翻开过这些判断都汇总在一个单独的输入函数里不要把逻辑堆在main里。2. 棋盘初始化的核心细节2.1 宏定义与全局约定先把骨架搭出来。不管作业要求的多小的棋盘我都建议用宏定义来管理尺寸而不是直接在代码里写数字。比如设定一个9x9的棋盘、10颗雷这是经典扫雷的入门配置也是代写作业时最容易被接受的标准。#define ROWS 9 #define COLS 9 #define MINES 10数组定义用[ROWS2][COLS2]多出的一圈是辅助用的“虚拟边界”方便我们在计算雷数时不用处理角上的特判。你可能会觉得多两个维度有点浪费内存但9x9这种规模多出20个int根本不值一提换来的是逻辑简洁。初始化函数里要把答案棋盘全部置成字符0显示棋盘全部置成字符*。这里我统一用char类型存棋盘元素是因为扫雷里每个格子的取值都是单一的字符——要么是数字要么是星号要么是旗子标记。用字符直观打印的时候直接printf(%c, board[i][j])就行不用做任何类型转换。注意有的教程喜欢用int数组存0~8和-1表示雷这样打印的时候还得先判断代码不直观。字符方案虽然看着“幼稚”但在控制台程序里是最顺手的。2.2 布雷的两种思路布雷逻辑有两条路可走一条是双层循环遍历棋盘每到一个格子就有一定概率放雷另一条是用循环随机生成坐标反复尝试直到雷的数量达到要求。第一种思路优点是简单但缺点也很明显——雷的总数不可控有可能一局下来一个雷都没有或者雷多得离谱。第二种思路虽然要多写几行代码但能保证雷的数量精确等于设定的MINES。我推荐第二种。写法大概是srand((unsigned)time(NULL)); int count 0; while (count MINES) { int row rand() % ROWS 1; int col rand() % COLS 1; if (mineBoard[row][col] ! 1) { mineBoard[row][col] 1; count; } }这里面有两点值得展开讲。第一rand() % ROWS得到的是0~ROWS-1的范围但我给row加上的偏移是1。这是因为我们的有效棋盘从下标1开始下标0那一圈是虚拟边界。所以所有随机坐标都必须落在这个范围里否则布雷可能布到边界圈里后面统计雷数就会出错。第二雷用字符1表示不是数字1。因为整个棋盘都是字符类型检查和赋值都围绕字符进行。这也是很多新人容易犯的错初始化用的0布雷用的1统计雷数时又想当然地做加法最后得到的结果全是0或者乱码。2.3 数字格子的雷数统计布雷完成后就要计算每个非雷格子周围有多少颗雷。这一步是整个扫雷逻辑里最常见的出题点也是很多新人第一次感觉到“二维数组双重循环”组合威力的时候。常规做法是遍历整个有效棋盘如果某个格子本身不是雷就用两层循环扫描它周围的8个格子数一下有几个1然后把结果写回当前格子。for (int i 1; i ROWS; i) { for (int j 1; j COLS; j) { if (mineBoard[i][j] 1) continue; int mineCount 0; for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { if (mineBoard[idx][jdy] 1) { mineCount; } } } mineBoard[i][j] mineCount 0; } }看着挺啰嗦但逻辑其实是直白的周围的8个方向都用相对坐标(dx, dy)来表达从(-1,-1)到(1,1)唯一不需要检查的是(0,0)本身但因为当前格子已经确认不是雷所以就算把(0,0)也扫进去也不会对结果产生影响因此有些代码干脆不排除自己在内。这里就是虚拟边界发挥作用的地方。如果两个数组都是严格按ROWS、COLS定义那么当i1、j1时访问mineBoard[0][0]就越界了虽然C语言不一定会立刻报错但行为未定义很可能读到脏数据。而我们把数组扩大一圈之后下标0、ROWS1这些位置都是合法内存里面存的是初始化的0所以“检查周围8个格子”这步根本不需要写一行边界判断安全又省事。这是一个很经典的“用空间换逻辑简洁”思路在竞赛代码和工程代码里都经常见到建议记在笔记里。2.4 打印棋盘的设计打印这件事看着简单但它直接关系到用户能不能看明白你的游戏。我的做法是专门写一个printBoard函数每一行开头先打印列号每一列用空格隔开棋盘外加一圈横线和竖线做装饰模拟Windows经典扫雷的格子感。void printBoard(char board[ROWS2][COLS2]) { printf( ); for (int j 1; j COLS; j) { printf(%d , j); } printf(\n); for (int i 1; i ROWS; i) { printf(%d , i); for (int j 1; j COLS; j) { printf(%c , board[i][j]); } printf(\n); } }如果你希望显示更自然可以把没翻开的格子显示成#而不是*把标记的格子显示成!这样从视觉上更接近真正的扫雷。核心逻辑不变改的只是打印时的字符映射规则。提示打印棋盘时一定要注意传入的数组维度。C语言的二维数组做函数参数时除了第一维可以留空第二维必须写清楚否则编译器无法计算偏移地址。我见过有人写成printBoard(char board[][])编译直接报错就是这个原因。写成char board[ROWS2][COLS2]是最稳的。3. 核心交互逻辑的实现与踩坑3.1 玩家输入与合法性校验游戏的核心循环里每一轮要做三件事读入玩家输入的坐标、判断坐标是否合法、根据坐标更新棋盘并判断胜负。玩家输入这部分我选择让用户输入两个整数中间用空格或回车分隔例如“3 5”。用scanf(%d %d, row, col)接收然后立刻判断范围。这里的校验必须做三层坐标是否在有效范围内也就是1rowROWS1colCOLS超界直接提示并重新输入。这个格子是否已经被翻开过如果翻开过就应该提示“该位置已翻开”让玩家换个地方。这个格子是否已经被标记成小旗子被标记的格子不能直接翻开否则游戏体验很奇怪。三层校验都通过才进入真正的“翻开”逻辑。我建议把整个输入和校验过程封装成一个函数比如getValidInput循环读取直到拿到合法的坐标再返回。这样main函数里的逻辑会干净很多也不会因为一个非法输入就导致整个游戏崩溃。3.2 踩雷与胜利判断翻开一个坐标之后立刻去答案棋盘查一下这个位置是什么如果是字符1说明踩雷了把gameover置为1游戏失败最后打印一遍答案棋盘让玩家看看雷都在哪。如果是数字0说明这个格子周围没有雷要执行递归展开把周围所有的空格一并翻开。如果是一个数字1到8说明这个格子边缘有雷只翻开当前这一格不展开。胜利判断的逻辑要单独写。每翻开一个非雷格子就把一个计数器safeCount加1。当safeCount等于ROWS*COLS - MINES的时候说明所有非雷格子都已经翻开了玩家获胜把gameover置为2。这里要特别强调一下计数器的位置必须保证同一个格子只被计数一次。递归展开的时候可能一次翻开了很多格子你得在递归函数里每处理一个格子就加一次同时用状态标记它已经被翻开防止重复计数。建议用一个专门的int变量来统计而不是每次胜利前都去扫描一次整个棋盘。后者虽然也行但多了一层O(n^2)的遍历没必要。3.3 递归展开的边界与栈问题递归展开是扫雷里最容易写错也最容易让你觉得“扑朔迷离”的部分。它的核心规则是如果玩家点开的格子是数字0它周围8个格子中凡是没被翻开过且不是雷的全部自动翻开如果周围有格子翻开后仍然是0就对它再进行同样的展开。翻译成C语言就是一个递归函数void expandBlank(char mineBoard[ROWS2][COLS2], char showBoard[ROWS2][COLS2], int row, int col) { if (showBoard[row][col] ! *) return; showBoard[row][col] mineBoard[row][col]; if (mineBoard[row][col] ! 0) return; for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { int nx row dx; int ny col dy; if (nx 1 nx ROWS ny 1 ny COLS) { expandBlank(mineBoard, showBoard, nx, ny); } } } }第一行是防重标记如果一个格子已经被翻开了直接返回不再重复展开。第二行把答案棋盘的值同步到显示棋盘。第三行判断当前格子不是0就停止展开因为数字格子本身就已经是边界了。这里要注意递归的深度。9x9的棋盘规模理论上递归深度最多也就几十层C语言的函数栈完全能扛住。但如果你把棋盘改成了50x50或者地图里大量空白区域连成一片递归深度可能会达到几千层。Windows默认的栈大小是1MB左右每个函数调用栈帧虽然不大但几千层也有风险。如果你真想做大棋盘建议学一下队列BFS的展开方式不过在练手阶段递归完全够用。3.4 标记小旗子的功能怎么加经典的扫雷是有三个操作翻开、标记旗子、取消标记。控制台版如果不加标记功能其实也能玩但一来流程不完整二来写代码的时候少了一个训练“状态修改”的机会。我给玩家的交互命令做了区分输入格式带一个小前缀例如“o 3 5”表示翻开(open)“f 3 5”表示标记旗子(flag)“c 3 5”表示取消标记(clear)。增加这个功能后你的代码会变得更像真实项目玩家的操作不再是单一的scanf而是需要解析命令字符串。这对字符数组的操作是一个很好的练习。标记逻辑本身很简单在显示棋盘上把这个格子从改成!或者从!改回仅此而已。它不影响答案棋盘也不影响雷数统计更不影响递归展开因为展开前已经把被标记的格子排除在可翻开的条件外了。如果展开条件里忘了判断标记状态就会出现“已经插了旗子又被自动翻开”这种很尴尬的bug。3.5 第一次点击的安全保障如果你研究过Windows自带的扫雷会发现一个设计细节第一次点击永远不会踩雷。这个设计不是bug而是避免玩家第一把就死得莫名其妙。如果我们想在C语言复刻这个体验可以加一个逻辑在第一次翻开的格子处如果它本身是雷就把这颗雷挪走换到一个没有雷的格子并且重新计算周围数字。实现方法也很简单用一个全局变量或静态变量记录当前是不是第一次打开如果是则翻开前先检查答案棋盘对应位置是不是1。如果是1就把这个位置改成0再从棋盘里找一个原本不是雷的位置放一颗1然后重新扫描整个棋盘把所有非雷格子的数字重新算一遍。有人可能会觉得麻烦但这是练逻辑的好机会。尤其面试时如果你能主动说出“我做了第一次点击免炸处理”对方会觉得你考虑得比别人多。4. 完整代码结构的组织与实操4.1 函数模块划分建议项目写大了之后最怕的就是所有代码堆在main里600行下来连你自己都不愿意看第二遍。扫雷虽然只有两三百行但好的组织习惯现在就得培养。我建议按下面的模块拆分initGame负责初始化两个棋盘和游戏状态变量。setMines负责布雷返回雷的总数。calculateNumbers负责计算所有非雷格子的数字。printBoard负责显示棋盘。handleInput负责解析玩家输入并调用翻开/标记等操作。expandBlank负责递归展开空白区域。checkWin负责判断胜利条件。main只负责把上述函数串起来控制游戏流程。每个函数控制在30行以内一眼能看到头。这个习惯你写扫雷的时候能体会到好处后面写复杂的课程设计会更明显。4.2 棋盘状态的显示约定我用三种字符表示显示棋盘上的状态*未翻开!玩家标记的旗子数字或空格已翻开的格子这里有个小噱头我打印的时候并没有直接printf整个showBoard而是做了个逐字符输出判断如果是0我就打印一个空格因为数字0在扫雷中可以显示成空白这样视觉上更贴近原版扫雷的空白区域。这个细节做起来很简单但演示给老师或者同学看的时候观感完全不一样显得你用心了。4.3 主循环的设计主循环用while(1)内部通过gameover的值来决定退出时机。每次循环打印当前棋盘。提示玩家输入操作和坐标。调用处理函数更新显示棋盘和游戏状态。如果gameover非0打印结束信息并break。这个流程非常清晰每一步都对应一个独立函数调试的时候你可以单步跟踪随时看棋盘状态变化。我在本地调试的时候习惯在循环末尾额外加一句printf(gameover %d\n, gameover)就是为了每次操作后快速定位是不是状态值没更新对。4.4 不同难度的扩展如果你想拿高分或者说想展示更多能力可以加入难度选择。用switch或者if判断玩家输入把ROWS、COLS、MINES这三个宏替换成变量。不过这里要提醒一个新坑C语言的数组定义在编译期就需要确定大小不能用运行时变量直接定义二维数组。如果坚持用两个矩形棋盘你可以用动态内存malloc或者定义三个不同尺寸的静态数组选难度时用指针切换。这对于新人来说有点超纲所以我也不建议第一版就去改动态数组先写死一个难度逻辑通了之后再考虑扩展。4.5 依赖的文件组织整个项目只用到一个.c源文件就够了不需要头文件和多个模块这是和大型项目最不同的地方。但你依然可以在文件头写清楚作者、日期、简要描述再靠注释分段标出“初始化区”“布雷区”等。注释不是写给老师看的是写给三天后的自己看的——当你发现一个bug重读代码时清晰的注释能让你快速定位问题区域。5. 常见问题与排查技巧实录5.1 输出乱码或者越界这是最常见的问题通常出现在两种情况下一是数组定义小了访问了边界外的内存二是打印棋盘时循环的下标不对把第0行或第0列也打印出来了。排查方法是用printf打印答案棋盘的全部内容包括“虚拟边界”那一圈。如果你看到边界外面的随机值不是0说明初始化就没做完整。我调试的时候有一个习惯会在布雷之后单独写一步“校准打印”就是把整个[0][0]到[ROWS1][COLS1]全部打印一次确认每个字符都是预期内容。一旦发现哪个下标读出来的不是0就能立刻锁定问题出在初始化还是访问范围。5.2 雷数量不对出现这种问题的原因一般是rand()的种子没有设置或者同一个位置的雷被重复统计。没有srand时rand()每次游戏生成的序列都一样看起来好像“雷总在同一个地方”但实际上数量还是对的。更常见的错误是在布雷循环里每随机生成一个坐标就直接把count加1而没有检查这个位置是不是已经有雷了。逻辑修正很简单成功放下雷之后再count。在测试雷数的时候我会写一个临时的统计循环遍历整个棋盘数一下1有多少个和MINES比对确认没问题再继续往后写。5.3 递归展开时出现死循环或者重复展开递归展开最常见的bug是漏掉“已翻开检查”。如果翻开一个数字0的格子后递归调用周围格子周围格子又反过来调用当前格子且当前格子没有标记为已翻开就会形成无限递归最终栈溢出。解决方法是务必在expandBlank函数开头检查showBoard[row][col] ! *一旦发现已经翻开就立刻返回。这个判断必须在所有逻辑之前包括检查雷否则雷周围本来不该展开的格子也会被误翻。调试递归问题的时候打印棋盘是最好的方法。我在关键节点加了调试输出每展开一个格子就输出一次坐标。如果发现某个坐标反复出现就知道递归回路找到了问题基本就定位在防重判断上。5.4 scanf输入残留导致的“跳跃”使用scanf读入整数时换行符会残留在输入缓冲区。如果你后面再用getchar或者再次scanf读取字符可能会直接读到残留的换行导致程序看起来好像“没输入就跳过了”。解决办法是在读字符命令时先getchar把缓冲区里的换行清掉或者用scanf( %c, cmd)在%c前加一个空格表示跳过所有空白字符。这是一个C语言初学者普遍踩的坑很多人都遇到过“明明让我输入程序却直接结束了”的问题。记住这个技巧你写其他小程序也一样用。5.5 编译环境的选择如果在Windows上直接用Dev-C或者Code::Blocks就足够了如果要上Linux可以用gcc编译。C语言对编译环境几乎没什么挑剔唯一要注意的是srand((unsigned)time(NULL))需要include time.h很多人只写了stdio.h和stdlib.h编译报错说time没声明就是这个原因。如果你用的是在线OJ或者学校的虚拟环境记得编译命令加上-lm并不是必须的但如果你用到数学函数就得记得链接数学库。扫雷不需要这个提醒算是以后的坑提前告诉你了。5.6 常见问题速查表我把实操中容易踩的坑整理成了表格方便你排查时快速定位。症状可能原因解决办法雷数比设定的多布雷时重复统计先判断该坐标是否已有雷再计数每次雷的位置都一样忘记srand种子在main开头调用srand((unsigned)time(NULL))边界格子统计雷数不准数组没多开一圈使用[ROWS2][COLS2]并初始化边界展开空白区域卡死递归缺少防重判断函数开头检查showBoard是否已翻开打印时出现奇怪字符数组越界或未初始化全量打印两个棋盘排查输入坐标后程序直接结束scanf残留换行符scanf( %c, cmd)清空白编译报time未声明缺少头文件include time.h5.7 样例测试流程写完代码之后不要只测一次就交作业。我建议按以下顺序跑一遍测试测试空白盘把MINES临时改成0看看展开逻辑能不能把整个棋盘都翻开这是最容易暴露递归问题的场景。测试首步踩雷把MINES临时改成ROWS*COLS把所有格子都放满雷看看第一次点击会不会崩溃。测试边界坐标分别输入最左上角、最右下角、边界线外的坐标检查程序是否妥善处理。测试标记功能连续标记和取消标记若干次确认不会出现状态错乱。测试胜利条件故意把雷数调小比如只放1颗雷然后翻开所有非雷格子确认胜利提示正常。这一套走下来代码里的隐患基本都能暴露出来。你如果真的认真测过交上去被老师挑刺的概率会大幅降低。6. 从练手到深度项目的进阶思考6.1 文件存档功能如果你想在这个项目上多花点时间可以加入存档功能。思路很简单开局前把答案棋盘和显示棋盘分别写入文件玩家中途选择退出时把当前棋盘状态保存下来下次开局前从文件读取并恢复。对C语言来说fprintf和fscanf足够做这件事。这个扩展虽然不大但你等于把文件操作也练了一遍。6.2 计时与自动判负扫雷的另一个经典维度是计时。用time函数记录开局时间每次操作时计算经过的时间超过限制时间就自动判负。这个功能也不复杂但你需要在主循环里加入时间检查逻辑对“游戏状态机”的理解会有帮助。6.3 排行榜在存档的基础上可以记录历史最优完成时间玩家赢下比赛后比较并写入排行榜文件。这不光是对项目功能的扩展更重要的是让你体验一次“枚举-比较-排序”的流程把冒泡排序、选择排序这些知识用起来。6.4 用指针替代固定数组等你对二维数组玩熟之后可以尝试把棋盘改成动态内存分配。用int或者char来替代固定大小的二维数组在运行时根据玩家选择的难度分配内存。这里会涉及到指针的指针、malloc、free的配合是C语言进阶路上非常关键的一块。如果你面试聊到项目可以把这一点写进去显得你对内存管理有概念。7. 写在最后的体会我写扫雷那一年正好是C语言课第一次期中考试之后。那时候指针刚讲了一半很多同学还在纠结“为什么函数里改不了外面的变量”而扫雷恰好逼着我弄懂了传值和传址的区别棋盘是传数组名其实传的是首地址所以函数里改数组外面跟着变。后来我把这版代码不断重构从蜡乱到清晰前前后后大概写了四五个版本。第二个版本加入了标记功能第三个版本加入了第一次点击免炸第四个版本改了动态棋盘第五个版本加了存档。每改一次对C语言的理解就深了一层。如果你也是新人我的建议是不要急着一次写完美先让最基础的版本跑起来能踩雷、能胜利、能展开空白区域就算成功。然后玩几局发现自己哪里别扭再去改代码。扫雷这个项目的好处就是对游戏体验的每一处不满都能翻译成一个具体的功能点而这些功能点都能对应到C语言的某个知识点上。把它从头到尾吃透C语言的基础也就在你手里了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →