☰
C语言项目实战:手写扫雷游戏,吃透二维数组与递归
2026/9/28 17:44:08 网站建设 项目流程

1. 为什么扫雷是C语言学习者的“黄金练手项目”

学完C语言的基本语法之后,最常听到的忠告就是:找一个项目从头写到尾。可很多人的第一反应是——写什么呢?我自己的答案是:扫雷。这个游戏听起来简单,做起来却能一次性把二维数组、随机数、递归、状态管理、输入输出这些基础功全练一遍。

网上搜“C语言 扫雷游戏”,能找到一堆源码,但大部分人下载下来能运行,讲不出设计逻辑。这个项目的价值不在于“跑通”,而在于你想清楚每一行代码为什么这么写。尤其是棋盘数据结构的设计、递归展开空白区域的实现、胜利判定的边界条件,这些细节才是真正锻炼编程思维的地方。

如果你是刚学完循环、数组、函数的新手,或者正愁课程设计不知道选什么题,这篇文章就是给你准备的。我会从游戏规则聊到数据设计,从核心算法拆到踩坑实录,最后给出一份可以直接编译运行的完整代码。你不需要懂任何高深的东西,只需要会打开终端、写C、编译C,剩下的跟着思路走就行。

1.1 扫雷项目到底覆盖了哪些C语言知识点

我盘了一下,一个控制台版扫雷,几乎把C语言入门的核心语法全部覆盖了:

第一是二维数组。你要用数组存棋盘上的每个格子,这才是二维数组最典型的应用场景,比教材里的矩阵打印有生命力多了。第二是随机数。地雷的位置需要随机生成,这比单纯调用rand()打印几个随机数更能让你理解随机种子的含义。第三是递归。点开一个空白格子,系统要自动展开周围所有空白区域,这是扫雷的灵魂,也是很多初学者第一次真正感受递归在实际项目里怎么落地。第四是输入处理和状态校验。玩家输入命令打开格子、插旗子,程序要判断命令合不合法、格子当前状态是什么、操作之后游戏有没有结束,这一步可以充分锻炼防御式编程的习惯。

另外,如果愿意再往前走一步,还可以用结构体把棋盘参数封装起来,练习模块化设计。可以说,扫雷这个题目的覆盖范围,远比它看起来要大。

1.2 本文的目标游戏规格

我这次实现的是经典“初级”配置:9行9列的棋盘,10颗地雷。所有交互都在终端命令行完成,玩家通过命令来操作游戏:o 行 列表示翻开某个格子,f 行 列表示在某个格子上插旗或取消旗子,q退出游戏。

为什么会选9x9和10颗雷?因为这是微软扫雷的经典初级参数,棋盘既不会大到打印出来占满屏幕,也不会小到让游戏失去推理空间。代码层面也方便,宏定义一改就能变成16x16或者自定义大小。我后面会提到如何扩展成可选难度,基础版本先把这个规格做扎实。

关于终端的差异,我在macOS和Linux的终端、以及Windows的CMD/PowerShell里都编译运行过。干净的终端界面加上隐藏光标的效果,体验已经非常接近图形版扫雷了。下面的内容,我会一步步带你建出这个游戏。

2. 数据结构设计:棋盘这件事,想清楚再写代码

写代码最忌讳的事,就是数据模型没想清楚就急着动手。扫雷看起来只有一个小小的棋盘,但如果你在数组设计上拍脑袋,后面写逻辑时一定会被自己坑到。我记得第一次做这个项目时,直接在9x9的数组上开干,结果递归展开函数里全是判断边界的if语句,代码又丑又容易出Bug。后来换了一种设计,一下子清爽了。

2.1 为什么需要两张棋盘,而不是一张

常见的新手做法是只用一个二维数组,格子里存什么数字,就显示什么数字。比如初始全是*,翻开后变成数字。听起来没问题,但你仔细想一下:扫雷游戏里,系统知道的信息和玩家看到的信息是不一样的。

系统需要知道每个格子的真实情况:这里是不是雷?如果安全,周围有几颗雷?这些信息是固定的,雷不会因为玩家操作而移动。而玩家看到的是一个不断变化的界面:哪些格子还没打开、哪些格子被插了旗、哪些格子已经显示数字了。如果只用一张棋盘,你会发现“系统判断”和“玩家展示”这两件事互相干扰,比如玩家插个旗,系统判断时还得绕开旗子去查底下的真实值,整个逻辑容易绕晕。

我的做法是定义两张二维字符数组。mineMap负责保存真实信息,雷和周边雷数都记在这里,游戏过程中基本不变;showMap负责呈现给玩家的视图,未翻开、插旗、数字这些都写在它上面。所有玩家操作只改showMap,所有胜负判断读mineMap和showMap配合。两张棋盘各司其职,调Bug的时候思路会清晰得多。

2.2 三种格子状态与“边界扩一圈”的小技巧

在showMap里,一个格子只会有三种可见状态:未翻开、插旗、已翻开。分别用#、!和数字字符表示。至于翻开后显示什么数字,直接复制mineMap里对应格子的内容。

mineMap里我约定:M表示地雷,0到8表示周围地雷数量。这样设计的好处是,翻开格子时可以直接把mineMap[r][c]的值原样赋给showMap[r][c],连转换的过程都省了。当值为0时,界面上打印空格而不是显示0,贴近现实扫雷的观感。

接下来是“边界扩一圈”的技巧,这是我强烈建议你采用的。比如逻辑棋盘是9x9,我开数组的时候开成11x11。有效区间是[1][1]到[9][9],第0行、第0列、第10行、第10列全部当作缓冲区,一律不参与游戏逻辑。

很多新手想不明白这有什么意义。你看,扫雷有一个需求量:计算一个格子周围有几颗雷时,要遍历它周边的8个格子。如果当前格子就在棋盘边缘,比如[1][1],那么它的左上角、上方、左侧的邻居坐标都越界了。数组开成11x11之后,这些越界坐标落在缓冲区里,值是初始化的0或#,既不会参与雷数统计,也不会导致数组越界。你可以直接在代码里写mineMap[r+dr][c+dc],完全不用判断边界。

对比一下,如果不加缓冲区,每次遍历邻居前至少要写四五个“如果坐标在边界就跳过”的if。递归展开函数里可能有一堆这样的判断,可读性差不说,少写一个就可能导致程序崩溃。扩一圈缓冲区,本质上是“空间换逻辑简洁”,在棋盘不大的项目里这是最优解。

2.3 用结构体把参数集中管理

项目虽然不大,但棋盘尺寸、雷数、已翻开的格子数、两张棋盘数组,这些参数零零散散地出现在各个函数里,很容易乱。我建议用一个结构体把它们包起来。

typedef struct { int rows; int cols; int mines; int opened; // 已翻开的非雷格子数 char mineMap[MAX_ROW][MAX_COL]; char showMap[MAX_ROW][MAX_COL]; } Game;

初始化的时候赋默认值,后续所有函数都接收一个Game*指针,修改状态非常自然。这里我用的是定长二维数组,宏定义MAX_ROW和MAX_COL,主要考虑是让初学者少碰动态内存分配。如果你已经学过指针,完全可以改成动态分配,在initGame里malloc出棋盘,在游戏结束时free掉,这样就能支持任意大小的棋盘了。后面讲进阶扩展时我会再提这个方向。

还有一个容易被忽视的点:结构体作为参数时,如果传值会整个拷贝一份,性能没必要的浪费,而且修改不了原对象。所以所有函数都传指针,这也是C语言项目里最常见的写法。

3. 核心流程拆解:布雷、计数、展开、胜负判定

数据结构定下来以后,游戏的主流程就清晰了:先初始化棋盘,再随机布雷,然后计算每个安全格子周围的雷数,最后进入循环,等待玩家命令。每次操作后都要判断游戏是否结束。下面我逐个拆解这些问题,每个都是踩过坑之后整理出的要点。

3.1 地雷随机布置的正确姿势

布雷的代码不复杂,但有两个细节特别容易出问题。

第一个是随机种子。很多人会把srand(time(NULL))写在循环里面,或者压根忘了写,结果发现每次运行游戏雷都一样。rand()生成的是伪随机序列,srand()负责给这个序列设定起点。如果你不设置种子,程序每次启动都从同一个起点开始,生成的雷位置自然一模一样。正确做法是在程序启动后只调用一次srand((unsigned)time(NULL)),之后循环里的rand()每次都会产生不同结果。

第二个是防止重复布雷。我的做法很直白:用一个while循环持续生成随机坐标,如果这个格子还不是雷就放雷,计数器加一,直到放满10颗雷。

while (placed < g->mines) { int r = rand() % g->rows + 1; int c = rand() % g->cols + 1; if (g->mineMap[r][c] != 'M') { g->mineMap[r][c] = 'M'; placed++; } }

这里还要提醒一下范围映射。rand() % g->rows得到的是0到8的整数,因为有效棋盘从1开始,所以要再加1。如果忘了加1,雷就会埋在缓冲区或者错位,游戏行为会很诡异。另有一个进阶做法:生成所有格子坐标的数组,随机打乱后取前N个放雷,单次确定不重复,效率更高,但初学用while循环已经足够。

3.2 周边地雷数量统计的两种写法

布雷结束后,要遍历每个非雷格子,统计周围8格中有多少颗雷,并把结果以字符形式写进mineMap。统计周围雷数有两种常见写法,本质是一样的。

第一种是双重循环遍历偏移量:

for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; if (g->mineMap[r+dr][c+dc] == 'M') cnt++; } }

第二种是提前定义好方向数组:

int dir[8][2] = {{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}}; for (int k = 0; k < 8; k++) { int nr = r + dir[k][0]; int nc = c + dir[k][1]; if (g->mineMap[nr][nc] == 'M') cnt++; }

两种写法我都用过,后者在需要遍历方向的地方可以重复复用这段逻辑,代码更紧凑。因为我们在设计棋盘时已经扩了一圈缓冲区,这里完全不需要判断nr和nc是否越界,缓冲区里的格子都是初始值0,不会干扰统计。统计完成后,把数字转成字符存起来:g->mineMap[r][c] = (char)('0' + cnt);。字符'0'到'8'正好对应整数0到8,这个转换用得很频繁。

3.3 递归展开空白区域:扫雷的灵魂

扫雷之所以叫扫雷,是因为当你翻开一个周围没有雷的格子时,游戏会自动展开一大片区域。这个交互全靠递归实现,也是整个项目里最值得研究的部分。

我把展开逻辑写成expand函数。它的规则很简单:当前格子已经翻开了,如果它显示的数字是0,也就是周围没有地雷,那么就检查它四周的8个邻居;对每个邻居进行如下处理:如果这个邻居还没有翻开(showMap中的值是#),并且它不是雷(mineMap中的值不是M),就把它翻开,把mineMap中的数字抄到showMap上,已翻开计数加1;如果这个邻居翻开后也是0,就继续递归展开。

void expand(Game *g, int r, int c) { for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = r + dr; int nc = c + dc; if (g->showMap[nr][nc] != '#') continue; if (g->mineMap[nr][nc] == 'M') continue; g->showMap[nr][nc] = g->mineMap[nr][nc]; g->opened++; if (g->mineMap[nr][nc] == '0') { expand(g, nr, nc); } } } }

新手最容易在这里犯错的地方是:递归条件里漏掉“还没有翻开”的判断。没有这个判断,两个相邻的0号格子就会互相递归调用对方,陷入无限循环直到栈溢出。我后面会在调试章节再细说这个坑怎么排查。另外,showMap[nr][nc] != '#'这个条件也包含了已插旗的格子,插旗的格子不会被自动翻开,这是合理的行为,因为玩家插旗意味着他怀疑这里是雷。

3.4 胜利判定:别用“插旗数等于雷数”这种坑

胜利条件这个事,网上很多错误写法,最常见的是:玩家插旗的数量等于地雷总数就判定胜利。这个逻辑听起来合理,但实际上有一个致命漏洞——插旗只是玩家的猜测,插错位置的情况太常见了。你随便点击几个格子,把所有旗子插满10个,哪怕全插错位置,这种判定也会触发“胜利”,这明显不符合游戏规则。

正确的判定方式只有一个:所有非雷格子全部被翻开。我维护一个opened计数器,每成功翻开一个非雷格子就加1,每次操作后检查一下opened == rows * cols - mines是否成立。这个条件的含义是:棋盘上除了地雷以外的格子全部翻开了,自然就是胜利。

注意opened只有在“成功翻开非雷格子”时才增加,翻开已经翻开的格子、插旗操作都不能让它增加,否则会造成计数虚高。这个细节我在最初写代码时吃过亏,后来干脆把计数逻辑集中在openCell和expand函数里,什么地方修改了showMap,什么地方就同步递增,这样不容易算错。

失败判定就简单了:玩家翻开一个mineMap中是M的格子。这时候要把完整地图展示出来,然后结束游戏。有一点体验上的细节:失败时如果玩家曾经在某些雷格子上插过旗,展示地雷时用醒目的M打印出来,让玩家知道自己猜对了没有,这是让复盘体验更好的小设计。

4. 输入交互与显示:让控制台游戏也有好体验

控制台游戏最容易被人忽视的部分就是输入和显示。很多初学者把整个注意力放在算法上,结果交互界面做得很难受:玩家输入错误命令程序就崩溃,棋盘也没有行列号看得眼花缭乱。其实输入和显示的代码量不大,只要花一点心思,体验能上一个台阶。

4.1 命令格式与输入校验

扫雷需要两种基本操作:翻开和插旗。我设计的命令格式是:

  • o 3 5表示翻开第3行第5列的格子
  • f 3 5表示在第3行第5列插旗或取消旗子
  • q表示退出游戏

为什么用字母加空格加坐标,而不是一次性输入三个数?因为命令可读性强,也方便扩展未来功能。在解析输入时,我用fgets读一整行,再用sscanf拆解,而不是直接用scanf。这个选择是踩过坑之后换的:scanf配合交互式输入时,缓冲区里的换行符、非法字符很容易让后续输入陷入死循环。fgets一次读一行,天然处理掉了换行问题,sscanf的任务就纯粹多了。

char line[100]; fgets(line, sizeof(line), stdin); char cmd[16]; int r = 0, c = 0; int n = sscanf(line, "%s %d %d", cmd, &r, &c);

输入校验要做三层:第一层检查sscanf的返回值,判断命令格式是不是三个部分;第二层检查坐标是否在1到9的范围内;第三层根据命令类型检查格子的当前状态。比如翻开一个已经翻开的格子,要提示“已经翻开过了”;翻开一个插旗的格子,要提示“先取消旗子”。这些提示看起来琐碎,但对用户来说非常友好,也防止了程序在意外状态下继续运转。

4.2 清屏、隐藏光标与格式化显示

每次操作后都重新打印棋盘,但如果不清屏,终端里会堆积一堆历史画面,观感很差。我在printMap前面添加清屏操作:

#ifdef _WIN32 system("cls"); #else system("clear"); #endif

Windows和Linux/macOS的清屏命令不一样,用条件编译处理最省事。隐藏光标的代码也不复杂,在Windows下可以这样写:

#ifdef _WIN32 CONSOLE_CURSOR_INFO cci; cci.dwSize = 1; cci.bVisible = FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cci); #endif

在Linux/macOS下可以用printf("\033[?25l");隐藏光标。这两段代码都是在“提升终端交互体验”时用到的小技巧,值得加进你的工具库。

棋盘打印格式我费了一点心思。顶部先打印列号:

1 2 3 4 5 6 7 8 9 1 * * * * * * * * * 2 * * * * * * * * * ...

行号用两位宽度打印,列号也占两位,这样9x9的棋盘在终端里排列整齐。未翻开的格子用*展示,插旗用!,翻开的数字直接显示,如果数字是0就打印空格。把数字0显示为空格,视觉效果更接近真实扫雷,也有助于玩家更快识别空白区域。

5. 调试实录:几个新手必踩的坑与排查思路

写这个项目的时候,我踩过的坑可以列一张单子。这些坑几乎每个初学者都会遇到,如果你的代码运行结果不对,别急着从头看起,先对照这部分逐个排查,大概率能找到问题。

5.1 坐标偏移和数组越界:我最常见的第一类连坐Bug

第一种坑是坐标偏移。比如布雷时用的是1~9的坐标,显示时却从0开始遍历,结果翻开第1行第1列的格子,棋盘上显示的是别的位置。这类Bug的根源是没有在全局统一坐标约定。

我的排查经验是:在游戏初始化后,单独写一段临时打印mineMap完整内容的代码,把缓冲区也打印出来,肉眼核对雷的位置和计数是否和预期一致。如果发现雷都在对角线或者整体往左上偏移了一个格子,不用调逻辑代码,先检查所有坐标范围是不是从1开始。

数组越界也是这个项目里很常见的崩溃原因。尤其是不加缓冲区直接遍历邻居,边缘格子的坐标很容易变成-1或10。一旦出现“程序跑着跑着突然报段错误”的情况,第一嫌疑就是数组越界。用重开一个调试棋盘打印中间结果的方式,可以快速定位是哪个格子触发了问题。

5.2 随机数“看似随机”:为什么连续几局雷的位置一样

有学生问我:为什么他的扫雷程序每次启动都是同样的棋局?问题几乎都出在随机种子。C语言的rand()函数是基于种子计算伪随机序列的,如果种子固定,序列就固定。time(NULL)每秒只变化一次,如果程序启动太快,或者srand放在循环里反复调用,也可能出现雷的布局不变的情况。

正确做法是main函数的开头只调用一次srand((unsigned)time(NULL))。不要在每个函数里单独调,也不要在布雷循环里每放一颗雷就重设一次种子。后一种情况常常导致生成的雷位置高度重复,因为time在极短时间内的返回值可能是一样的。

5.3 递归展开的无限递归与栈溢出

有一个让很多人摸不着头脑的Bug:翻开空白格后,程序卡死或直接崩了。原因往往是递归展开时没有判断“当前格子是否已经翻开”。两个相邻的0格子会互相调用对方,形成无限递归,最终导致栈溢出。

排查方法是在expand函数开头加一行打印,输出当前递归到达的坐标和深度计数值。如果发现某个坐标明明已经被处理过,又再次进入递归,那基本就是递归终止条件漏了。修正方式就是我在5.3节代码里写的那行if (g->showMap[nr][nc] != '#') continue;,它保证每个格子最多被翻开一次。

值得注意的是,经典9x9棋盘的地雷分布不至于让递归深度很大,但如果把棋盘做到30x30的大型地图,递归栈深度可能过多。到那时候可以改成用队列做广度优先展开,不过对初学者来说,递归版已经足够清晰。

5.4 输入残留和scanf的坑

输入这块的坑特别隐蔽。早期的版本我直接用scanf("%s %d %d", cmd, &r, &c)读玩家命令,看起来能用,但一旦玩家输入了一个非法命令,比如o abc,scanf返回的值就不等于3,而后续的输入残留会导致程序的每次循环都读到同一个错误数据,陷入死循环。

后来我统一改成fgets读行:不管玩家输入什么,一次只取一行,利用sscanf解析。如果解析失败,打印提示之后进入下一轮循环重新读,绝对不会出现“卡死”的现象。输入校验还要注意,如果玩家输入o 0 5或o 10 5这样越界的坐标,要有明确的提示而不是让程序默默崩溃。我见过不少版本在处理这种输入时直接进入未定义行为,这不是好习惯。

6. 完整代码与运行效果

这一部分给你一份完整的、可以直接编译运行的C语言扫雷代码。代码里包含了前面讲的全部设计思想,注释也写得比较细致。你可以把文件命名为mine.c,然后按章节后面的编译命令运行。

6.1 代码模块总览

整个程序分成几个模块:结构体定义、初始化、布雷、计算雷数、打印棋盘、翻开格子、递归展开、插旗、胜负判断和主循环。函数职责单一,代码可读性不错。如果你想去掉结构体,改成全局变量也可以,但结构体版本更接近工程化写法,也方便将来扩展。

  • initGame:初始化结构体,填充棋盘初始状态
  • placeMines:随机布雷
  • calcNumbers:计算所有非雷格子的周边雷数
  • printMap:打印当前棋盘
  • openCell:处理翻开操作,返回是否踩雷
  • expand:递归展开空白区域
  • toggleFlag:处理插旗和取消旗子
  • revealAll:游戏结束时展示完整雷区
  • win:判断是否胜利

6.2 完整源码(可直接编译运行)

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> #define ROWS 9 #define COLS 9 #define MINES 10 #define MAX_ROW (ROWS + 2) #define MAX_COL (COLS + 2) typedef struct { int rows; int cols; int mines; int opened; char mineMap[MAX_ROW][MAX_COL]; char showMap[MAX_ROW][MAX_COL]; } Game; void initGame(Game *g) { g->rows = ROWS; g->cols = COLS; g->mines = MINES; g->opened = 0; memset(g->mineMap, '0', sizeof(g->mineMap)); memset(g->showMap, '#', sizeof(g->showMap)); } void placeMines(Game *g) { int placed = 0; while (placed < g->mines) { int r = rand() % g->rows + 1; int c = rand() % g->cols + 1; if (g->mineMap[r][c] != 'M') { g->mineMap[r][c] = 'M'; placed++; } } } void calcNumbers(Game *g) { for (int r = 1; r <= g->rows; r++) { for (int c = 1; c <= g->cols; c++) { if (g->mineMap[r][c] == 'M') continue; int cnt = 0; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; if (g->mineMap[r + dr][c + dc] == 'M') cnt++; } } g->mineMap[r][c] = (char)('0' + cnt); } } } void printMap(const Game *g) { printf("\n "); for (int c = 1; c <= g->cols; c++) printf("%2d", c); printf("\n"); for (int r = 1; r <= g->rows; r++) { printf("%2d ", r); for (int c = 1; c <= g->cols; c++) { char ch = g->showMap[r][c]; if (ch == '#') printf(" *"); else if (ch == '!') printf(" !"); else if (ch == '0') printf(" "); else printf(" %c", ch); } printf("\n"); } } void revealAll(const Game *g) { printf("\n完整的雷区布局:\n"); printf(" "); for (int c = 1; c <= g->cols; c++) printf("%2d", c); printf("\n"); for (int r = 1; r <= g->rows; r++) { printf("%2d ", r); for (int c = 1; c <= g->cols; c++) { if (g->mineMap[r][c] == 'M') printf(" M"); else if (g->mineMap[r][c] == '0') printf(" "); else printf(" %c", g->mineMap[r][c]); } printf("\n"); } } void expand(Game *g, int r, int c) { for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = r + dr; int nc = c + dc; if (g->showMap[nr][nc] != '#') continue; if (g->mineMap[nr][nc] == 'M') continue; g->showMap[nr][nc] = g->mineMap[nr][nc]; g->opened++; if (g->mineMap[nr][nc] == '0') { expand(g, nr, nc); } } } } int openCell(Game *g, int r, int c) { if (g->showMap[r][c] == '!') { printf("该格子被旗子标记了,先用 f 命令取消标记。\n"); return 0; } if (g->showMap[r][c] != '#') { printf("这个格子已经翻开过了。\n"); return 0; } if (g->mineMap[r][c] == 'M') { printf("踩雷了!游戏结束。\n"); return -1; } g->showMap[r][c] = g->mineMap[r][c]; g->opened++; if (g->mineMap[r][c] == '0') { expand(g, r, c); } return 1; } void toggleFlag(Game *g, int r, int c) { if (g->showMap[r][c] == '#') { g->showMap[r][c] = '!'; } else if (g->showMap[r][c] == '!') { g->showMap[r][c] = '#'; } else { printf("已经翻开的格子不能再插旗。\n"); } } int win(const Game *g) { return g->opened == g->rows * g->cols - g->mines; } int main(void) { srand((unsigned)time(NULL)); Game game; initGame(&game); placeMines(&game); calcNumbers(&game); char line[100]; printf("============== 扫雷游戏 ==============\n"); printf("命令说明:\n"); printf(" o 行 列 翻开格子\n"); printf(" f 行 列 插旗 / 取消旗子\n"); printf(" q 退出游戏\n"); while (1) { printMap(&game); printf("请输入命令:"); if (fgets(line, sizeof(line), stdin) == NULL) break; char cmd[16]; int r = 0, c = 0; int n = sscanf(line, "%s %d %d", cmd, &r, &c); if (n == 1 && strcmp(cmd, "q") == 0) { printf("已退出游戏,再见。\n"); break; } if (n != 3) { printf("命令格式错误,示例:o 3 5 或 f 1 2\n"); continue; } if (r < 1 || r > game.rows || c < 1 || c > game.cols) { printf("坐标越界,有效的行范围是 1~%d,列范围是 1~%d\n", game.rows, game.cols); continue; } if (strcmp(cmd, "o") == 0) { if (openCell(&game, r, c) == -1) { revealAll(&game); break; } } else if (strcmp(cmd, "f") == 0) { toggleFlag(&game, r, c); } else { printf("未知命令,支持 o / f / q\n"); continue; } if (win(&game)) { printMap(&game); printf("恭喜你,所有非雷格子都翻开了,本局获胜!\n"); break; } } return 0; }

6.3 编译命令与体验建议

在Linux或macOS的终端里,用gcc编译:

gcc mine.c -o mine -std=c99 ./mine

在Windows下,用Git Bash、MinGW或Visual Studio的开发者命令行都可以,编译命令保持一致。如果你用的是Windows自带的CMD,中文注释可能会显示乱码,这是终端编码的问题,不影响程序运行。把源文件另存为ANSI或GB2312编码可以缓解显示乱码。

第一次玩的时候,我建议先随便翻几个中间区域的格子,让空白区域自动展开,然后再根据边缘数字插旗。比如一个格子显示数字1,它的周围8个格子里恰好有1颗雷,结合已有旗子和已翻开的格子做推理,就能一步步把所有雷找出来。这个过程能帮你验证程序逻辑有没有问题:如果真的按推理插了旗,最后点开周围格子时不会踩雷;如果程序判定错误,那一定是某处计数逻辑写错了。

7. 从“能用”到“好玩”:我建议你尝试的进阶改造

把这个简单版本跑通之后,你会发现其实还能玩出很多花样。这里整理几个我亲自做过的进阶方向,按性价比排序,供你挑感兴趣的试一下。

7.1 首次点击保护,游戏的体验大不同

现在的版本有一个体验问题:第一手直接点到雷的概率大约是10/81,有一定概率刚开局就结束。真实扫雷游戏几乎都有“首次点击保护”机制,不管第一下点哪里,都不会踩到雷。实现思路也不难:如果第一次翻开就碰到雷,把雷从这个格子移走,随机放到另一个非雷且未被翻开的位置,然后重新计算周边雷数。或者在玩家点第一下之前压根不布雷,点完第一下确定安全区域后再布雷。相比前者,后者的逻辑更符合真实扫雷的规则,但代码改动稍微大一点。

我实际做的时候选择了第一种:游戏流程保持不变,只是在openCell里判断这是不是第一次点开,如果是且目标是雷,就调用一个relocateMine函数把雷换走。这个功能加上以后,游戏体验会大幅提升。

7.2 计时、排行榜与存档

图形版扫雷都有计时功能,控制台版也能做,而且做法很简单:游戏开始时获取时间戳,游戏结束时再获取一次,两者相减就是本局耗时。把耗时和游戏难度一起存储到文件里,做个简单的排行榜,下次启动时读取并显示,算是练到了文件读写。

存档功能会麻烦一点,因为你需要把mineMap、showMap、opened这些信息全部序列化到文件里。好在这些数据本质上是字符数组,直接按字节写文件就行。这个项目非常适合练习fwrite和fread,我曾经用这个功能做了个暂停后继续玩的版本,成就感还是很足的。

7.3 命令行彩色与符号自定义

控制台里的字符也可以有颜色。Windows下用SetConsoleTextAttribute,Linux/macOS下用\033[xxmANSI转义序列。我后来把数字1到8分别设置了不同颜色,比如数字1是蓝色、数字2是绿色,雷用红色M标记,棋盘的视觉辨识度一下子高了很多。这个改造的代码量不大,但对游戏体验的提升是肉眼可见的。

再进一步,你还可以把字符改成其他符号,比如用@表示雷、?表示插旗,符号含义完全由自己定义。不过别改得太花哨,否则玩家容易看花眼。

说实话,扫雷这个项目做完一遍,我最大的体会是:写代码前把数据结构设计清楚,比上来就动手指要省太多时间。我最初直接开9x9数组、不设边界缓冲,导致后面补丁打了一堆;后来换成两条棋盘加一圈缓冲区,整个程序从结构到实现都清爽了。所以如果你要拿这个项目当练习,不要在细节上妥协,每一个设计决定都要想清楚为什么这么做。等你把这份代码吃透,后面再学链表、队列、二叉树时,你会发现自己处理状态和逻辑的能力明显不一样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询