C语言扫雷游戏课设全解析:二维数组、递归与随机数实战
2026/9/15 5:59:26 网站建设 项目流程

作为一个大二踩过无数坑、期末周靠参考资料续命的学长,我太知道C语言课设选题时的纠结了。贪吃蛇太烂大街,图书管理系统又无聊得让人犯困,最后我选了扫雷。说实话,当时以为这玩意儿不就是几个for循环叠加吗?真正动手写才发现,它几乎把C语言的核心知识点全串起来了:二维数组的灵活操作、随机数的正确使用、递归的巧妙应用、游戏状态机的设计,甚至还有UI渲染排版。代码量不大,但五脏俱全,性价比在课设选题里绝对排第一。这篇我就把完整的设计思路、代码实现和我在调试中踩过的坑全部分享出来,不管你是为了应付课设,还是真想把C语言基础打牢,这份参考都能让你少走很多弯路。

1. 需求拆解:扫雷游戏到底要做什么,难点又在哪里

1.1 从玩家视角梳理功能清单

写程序的第一步永远是搞清楚目标。扫雷这个游戏大家多少都玩过,但要把规则转写成程序逻辑,首先要列一个明确的功能清单。我当时的拆解是这样的:

  • 需要一个矩形棋盘,经典配置是9行9列、埋10颗雷
  • 雷的位置是随机生成的,每局都不同
  • 玩家可以翻开任意一个格子,看到两种情况:如果是雷,游戏结束;如果是数字,显示周围8格中雷的数量
  • 如果翻开的格子周围一个雷都没有,也就是数字0,要自动展开一大片空白区域,这是扫雷最核心的体验
  • 玩家可以给疑似有雷的格子插旗标记,也可以取消标记
  • 游戏胜利条件是:除雷之外的所有格子全部被翻开

这6条看着简单,但落到代码里就涉及了棋盘数据的存储、随机数的生成、递归遍历、输入处理和赢输判断。其中我觉得最关键的是第三条和第四条,它们直接决定了这个游戏“好不好玩”。很多初学者的半成品就是卡在第四条那个连锁展开上。

1.2 技术难点预判:藏在“看起来简单”里的三个坑

在动手写代码之前,我先标记了三个必定会踩的坑,事实证明这三个坑一个都没少踩:

  1. 随机布雷的去重问题:如果用rand() % 81生成一个0到80的随机数代表格子位置,连续随机会出现重复,你必须保证10颗雷落在10个不同格子里。初中级最常见的错误是只随机一次位置,结果游戏里只剩下8颗雷,还有两颗叠在一起了。

  2. 数组越界问题:统计一个格子周围8格的雷数时,最直接的想法是直接访问board[i-1][j-1]这类下标。但棋盘边上的格子,下标会变成-1或者越界访问到不属于本行的数据。这个问题我在第一次写的时候完全没有意识,直到运行时各种乱码,才反应过来是数组越界。

  3. 递归展开的边界条件:翻开空白格时,程序要自动递归地翻开周围的格子,但如果递归条件写得不严谨,要么会陷入无限循环,要么会漏掉应该翻开的格子。这需要深入理解递归的终止条件,不是随便调个函数就能完事的。

这三个坑我在下文都会展开讲具体的解决方案。另外还有一个小细节容易被忽略:玩家输入的坐标是从1开始的(比如输入“3 5”表示第3行第5列),而C语言数组下标是从0开始的,这里必须做一次r--; c--;的转换,不然你会发现怎么点都点不到你想翻的位置,全都是偏移的。

2. 数据结构与棋盘建模:一张棋盘为什么要用两层数组

2.1 为什么不用结构体数组而用两个二维数组

棋盘数据的建模是整个游戏的基石。很多初学者一上来就想到用结构体写一个格子类,里面存“是不是雷”“周围雷数”“是否翻开”“是否插旗”四个字段,然后定义一个二维结构体数组。思路没错,但实现起来代码会变得很啰嗦,每个操作都要访问结构体的不同字段,逻辑容易乱。

我选择的是两个二维数组分离开的方案,这也是大多数经典C语言扫雷教材的做法:

  • int mineBoard[ROWS][COLS]:负责存“真相”,也就是雷的分布。数组里存-1表示这个格子是雷,存0到8表示这个格子周围有雷的数量。
  • char showBoard[ROWS][COLS]:负责存“玩家看到的表象”,也就是界面上显示的内容。初始全是'*'表示未翻开,玩家插旗后变成'!',翻开后变成'0''8'的字符。

这样做的好处非常明显:逻辑数据与展示数据分离。游戏的判定逻辑全部基于mineBoard,而界面渲染只读showBoard,两者互不干扰。比如判断胜负时,只需要统计showBoard里被安全翻开的格子数是否等于71(81减10颗雷);判断踩雷时,看mineBoard对应位置是否等于-1即可,完全不需要关心界面到底显示了什么。

2.2 状态编码:-1、0到8的数字和字符如何协同

接下来详细说明这两个数组里每个值的含义和之间的对应关系。

mineBoard使用整数存储,因为后续要直接做数值计算。初始化时全部置为0,布雷时把随机选中的格子置为-1。然后遍历每个非雷格子,统计周围8格中-1的个数,把统计结果直接存到这个格子里。这样mineBoard[i][j]的取值就只有两种类型:要么是-1代表雷,要么是0到8代表周围雷数。这种设计的好处是判断逻辑极简:翻开时,判断mineBoard[r][c] == -1就知道是不是踩雷;显示数字时,直接在mineBoard[r][c]的基础上加上字符'0'的ASCII码转换就能得到对应的字符,一步到位。

showBoard则使用字符数组,因为它是直接渲染到控制台上的。它的状态总共有这么几类:

状态字符含义
未翻开*玩家尚未点击
已插旗!玩家标记为雷
已翻开'0'~'8'显示周围雷数
踩雷显示X游戏失败时显示雷的位置

这里有个非常经典的C语言技巧值得单独提一下:数字与字符的转换。在字符数组中存储数字时,不能直接存整数3,因为字符'3'的ASCII值是51。正确写法是showBoard[i][j] = '0' + mineBoard[i][j];。反过来,如果想把字符'3'转成整数3,用mineBoard[i][j] = showBoard[i][j] - '0';。这个技巧在以后处理字符串和数字混合的场景中会经常用到。

2.3 边界处理:数组越界的根源与三种解法

数组越界是整个C语言学习过程中最让人头疼的BUG之一。在扫雷里,这个问题出现在“统计周围雷数”和“递归展开”两个地方。以统计雷数为例,一个格子周围有8个邻居,按行列偏移量分别是:

(-1,-1) (-1,0) (-1,1) (0,-1) (0,0) (0,1) (1,-1) (1,0) (1,1)

如果你直接写mineBoard[i-1][j-1],当i=0j=0时,下标就变成-1了。C语言允许访问负下标,访问到的是数组前面内存里的不确定数据,结果完全不可控。

解决这个问题通常有三种思路:

  1. 在访问前用if判断下标合法性,合法才访问。这是最容易理解、最直观的方案,代码会多一些分支判断。
  2. 牺牲一圈空间,把棋盘定义为更大一点的数组,比如12行12列,只在中间9行9列放雷和数字,四周留一圈哨兵并将其值设置为0。这样在统计中心区域时,即使访问到-19下标,也仍然在这个大数组的有效范围内,不会越界。代价是浪费了一点内存,而且逻辑上要把中心区域和哨兵区分开。
  3. 用方向数组表驱动,定义两个数组dr[8]dc[8]分别存行偏移和列偏移,然后循环8次,每次通过i+dr[k]j+dc[k]计算邻居坐标,再配合判断函数校验坐标合法性。

我最终采用的是第一种加第三种组合:用方向数组统一代码逻辑,用一个inBounds()函数统一做合法性判断。这样代码既简洁,又不会因为棋盘大小改变而失效。

3. 布雷逻辑与数字统计:随机性背后隐藏的工程问题

3.1 随机数种子的坑:为什么结果每次都一样

布雷的第一步是生成随机位置,这里就遇到了C语言随机数最经典的坑。

函数rand()生成的是伪随机数序列,它本身是确定的,每次程序运行时生成的序列完全一样。如果不做任何处理,你第一次运行游戏和第十次运行游戏,雷的位置一模一样。解决办法是调用srand((unsigned)time(NULL));给随机数生成器设置一个种子,这个种子取自当前时间,程序每次启动时时间不同,种子就不同,产生的随机序列也就不同了。

这个原理我多说两句。伪随机数生成器可以理解为一个数学公式的递推过程,给定相同的初始值(种子),它永远会生成相同的序列。time(NULL)返回自1970年1月1日以来经过的秒数,每次运行都不同,所以拿它当种子可以让随机序列“看起来”不可预测。如果多局连续游玩,秒级时间戳在快速重开时可能相同,但课设这个场景完全够用了。

3.2 布雷去重:while循环里面藏着的小陷阱

确定随机种子之后,接下来要放雷了。我的实现是这样一个循环:

int placed = 0; while (placed < MINE_COUNT) { int r = rand() % ROWS; int c = rand() % COLS; if (mineBoard[r][c] != -1) { mineBoard[r][c] = -1; placed++; } }

逻辑很简单:随机生成坐标,如果这个位置还不是雷,就把雷放进去,计数加一;如果已经是雷了,就再随机一次,直到放满10颗为止。这个循环理论上可能一直转下去,但实际上在9乘9的棋盘放10颗雷,重复概率并不高,循环迭代几次就能找到空位,性能完全没有问题。

这里我犯过的错误是:placed++放在了if外面,导致即使落在已经有雷的位置,计数依然增加,最后只放了几颗雷,剩余几次都在重复位置上“凭空消耗”了。这种逻辑错误编译器不会报错,程序也能运行,但结果就是雷的数量不对。所以在写循环时,一定要把“成功放入”作为自增的条件,而不是把“尝试了一次”作为自增条件。

3.3 方向数组:八方向遍历的工程化写法

布雷完成后,要给每个非雷格子计算“周围8格有几个雷”。最直白的写法是写8个if判断,像这样:

if (mineBoard[i-1][j-1] == -1) cnt++; if (mineBoard[i-1][j] == -1) cnt++; if (mineBoard[i-1][j+1] == -1) cnt++; if (mineBoard[i][j-1] == -1) cnt++; if (mineBoard[i][j+1] == -1) cnt++; if (mineBoard[i+1][j-1] == -1) cnt++; if (mineBoard[i+1][j] == -1) cnt++; if (mineBoard[i+1][j+1] == -1) cnt++;

这种写法虽然能实现功能,但既冗长又容易出错,少写一个方向的if很难发现。更优雅的是用方向数组表

int dr[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k = 0; k < 8; k++) { int ni = i + dr[k]; int nj = j + dc[k]; if (inBounds(ni, nj) && mineBoard[ni][nj] == -1) { cnt++; } }

本质上就是一个坐标偏移表,把8个方向统一成循环中的8次迭代。这种做法带来的好处不止是代码短,更重要的是方向集合可以复用。在后面的递归展开逻辑中,我用了完全相同的方向数组来遍历周围8格,一处定义,多处复用,逻辑保持一致。

3.4 棋盘渲染:把二维数组变成看得懂的界面

数据准备完毕,还要让玩家能看得见棋盘。printBoard()函数的设计是输出一个带行列号的表格。简单的两层for循环遍历showBoard,每行前面打印行号,列表头打印列号。一次普通的控制台输出,但有几个细节值得注意:

void printBoard() { system("clear"); printf(" 1 2 3 4 5 6 7 8 9\n"); printf(" +---------------------------+\n"); for (int i = 0; i < ROWS; i++) { printf("%d |", i + 1); for (int j = 0; j < COLS; j++) { printf(" %c ", showBoard[i][j]); } printf("|\n"); } printf(" +---------------------------+\n"); }

第一行是列号,后续每行开头是行号,都用空格和|做对齐。输出效果的整齐程度直接影响游戏体验。我在写第一版时没有加列号,玩起来完全靠数格子,难受得很。另外system("clear")用于清屏,在Windows上要换成system("cls"),这是平台差异,很多人会在这里栽跟头。

4. 翻开与递归展开:扫雷交互的核心链路

4.1 一次点击背后的三种分支

玩家输入命令r 3 5表示翻开第3行第5列的格子。这个操作的处理函数里必须考虑三种情况,逻辑如下:

if (op == 'r') { if (showBoard[r][c] == '!') { printf("该格子已插旗,无法翻开!\n"); continue; } if (mineBoard[r][c] == -1) { // 踩雷了,游戏结束 for (int i = 0; i < ROWS; i++) for (int j = 0; j < COLS; j++) if (mineBoard[i][j] == -1) showBoard[i][j] = 'X'; gameOver = 1; win = 0; } else { revealCell(r, c); if (checkWin()) { gameOver = 1; win = 1; } } }

第一种情况是目标格子已经插旗了,那就不允许点击翻开,玩家必须先取消旗子再翻。第二种情况是踩中雷,这时要遍历mineBoard把所有的雷位置在showBoard中标记为'X'并显示给玩家,宣告游戏结束。第三种情况是安全格子,调用revealCell翻开它,再检查是否胜利。

这三个分支的顺序很重要。一定要先判断是否插旗,再判断是否踩雷,因为插旗的格子下面如果是雷,玩家必须先拔旗才能翻,拔旗后才会踩雷。

4.2 从“翻开空白格”到“递归展开”的必然性

revealCell是整个程序最核心的函数,也是递归思想最典型的应用场景。先看代码:

void revealCell(int r, int c) { if (!inBounds(r, c)) return; if (showBoard[r][c] != '*') return; if (mineBoard[r][c] == -1) return; showBoard[r][c] = '0' + mineBoard[r][c]; revealedCount++; if (mineBoard[r][c] == 0) { int dr[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k = 0; k < 8; k++) { revealCell(r + dr[k], c + dc[k]); } } }

为什么非要递归?因为扫雷的规则是:翻开的格子如果周围没有雷,自动把周围8个格子翻开;而周围8个格子中如果又有0,继续以它为中心向外展开。这个“某个格子的变化引发周围格子连锁变化”的过程,天然就是递归的形态。

头三个if是递归的终止条件和防重入保护,它们缺一不可。第一个if防止访问棋盘外的坐标;第二个if防止重复翻开同一个格子,这个检查是阻止无限递归的关键;第三个if防止递归过程中误碰到雷区,因为雷格子不能通过递归自动翻开,只能等玩家主动踩中。

中间两行是核心动作:把当前格子标记为已翻开,并同步增加已翻开计数。

最后一个if是整个逻辑的开关:只有当前格子数字为0,也就是周围没有雷时,才会去递归展开周围8个格子;如果数字是3,说明周围有3颗雷,已经能看到数字提示了,就不需要再展开。这个设计保证了递归的深度是有限的,不会无限扩散。最坏情况下棋盘中一片大的0区域会让递归调用非常深,但9乘9的棋盘深度完全在栈的安全范围内,不用过于担心栈溢出。

4.3 插旗操作:状态机的两段切换

插旗功能本质上是一个状态切换器。同样一个格子,在未翻开状态和插旗状态之间来回切换:

void toggleFlag(int r, int c) { if (!inBounds(r, c)) return; if (showBoard[r][c] == '*') { showBoard[r][c] = '!'; } else if (showBoard[r][c] == '!') { showBoard[r][c] = '*'; } }

这个逻辑可以用一句话描述:如果当前是未翻开,就变成插旗;如果当前是插旗,就变回未翻开;如果已经是显示数字了,什么都不做。这种两态切换的模式在游戏开发中很常见,理解清楚后以后写类似功能会轻松很多。

5. 胜负判断与游戏循环:让整个程序“活”起来的骨架

5.1 胜负判断的两种策略:计数法与状态法

判断游戏胜利,我用的策略是计数法。维护一个revealedCount变量,每安全翻开一个非雷格子就加一。当这个计数等于总格子数减去雷数(81减10等于71)时,说明所有非雷格子都被安全翻开,玩家获胜。

int checkWin() { return revealedCount == ROWS * COLS - MINE_COUNT; }

这个判断方式的好处是非常直观,时间复杂度是O(1),不需要扫描整个数组。还有一种思路是状态法:遍历整个showBoard,检查是否所有非雷位置都已经变成数字。这种方法也能实现,但每次判断都要扫描81个格子,代码也更长。在课设阶段,我推荐计数法,它把判断逻辑分散到了“翻开格子”这个动作里,在主循环中只需要一行调用。

这里有一个容易忽略的细节:revealedCount只在成功翻开新格子时才加一。插旗和取消插旗不会影响它,重复翻开会因为revealCell中的showBoard[r][c] != '*'检查而直接返回,也不会重复计数。所以这个计数永远是准确的。

5.2 游戏循环结构:输入-处理-渲染的闭环

主循环是任何游戏程序的灵魂。我的实现是一个while (!gameOver)循环,里面依次做三件事:打印当前棋盘状态、读取玩家输入、根据输入更新游戏状态。这是一个经典的“输入-处理-渲染”循环模型,所有实时游戏程序本质上都是这个循环的变体。

while (!gameOver) { printBoard(); printf("操作说明:\n"); printf(" 翻开格子: r 行 列(如 r 3 5)\n"); printf(" 插旗/取消: f 行 列(如 f 3 5)\n"); printf(" 退出游戏: q\n"); printf("请输入: "); char op; scanf(" %c", &op); if (op == 'q') break; if (op != 'r' && op != 'f') { printf("无效操作!\n"); continue; } int r, c; scanf("%d %d", &r, &c); r--; c--; if (!inBounds(r, c)) { printf("坐标越界!\n"); continue; } // 根据 op 类型分别处理翻开或插旗 }

这里我踩过最大的坑是scanf的输入缓冲区问题。第一次写时我用scanf("%c", &op);读取操作字符,结果发现程序莫名其妙跳过输入,或者多执行了几次循环。原因在于上一次输入坐标后,回车产生的换行符\n还残留在输入缓冲区中,下次scanf("%c")读到这个换行符,就把它当作了op的值,导致判断失败。

解决方案是在%c前面加一个空格:scanf(" %c", &op);。这个空格的作用是让scanf自动跳过输入缓冲区中所有的空白字符(包括空格、换行、制表符),直接读取第一个非空白字符。这个坑在C语言输入处理中非常普遍,处理文件读取、命令行交互时都会遇到,学会这个技巧能省很多事。

5.3 拆解main函数:从初始化到游戏结束的完整流程

整个main函数的流程是:

int main() { srand((unsigned)time(NULL)); initBoard(); placeMines(); calculateNumbers(); int gameOver = 0; int win = 0; while (!gameOver) { // ... 渲染、输入、处理 ... } printBoard(); if (win) { printf("恭喜获胜!\n"); } else if (gameOver) { printf("踩雷了,游戏结束。\n"); } return 0; }

这个流程是经典的“初始化-循环-收尾”三段式。初始化阶段设置随机种子、初始化数组、布雷、算数字;循环阶段维持游戏运行;收尾阶段打印最终棋盘并给出一句结果提示。无论游戏是怎么结束的(踩雷、获胜、退出),最终都会走到统一的收尾逻辑。

我给读者一个阅读代码的建议:不要从第一行顺序往下读,而是先看main函数的整体流程,理解三个大阶段分别做了什么,再去看每个被调用的函数。这样由宏观到微观,代码结构会更清晰。

6. 完整代码与实战调试经验:可直接编译运行的版本

6.1 完整源代码

把上面所有模块拼在一起,就是完整可运行的扫雷游戏。我贴出完整代码,注释已经写得比较详细,方便你对照前文的理解直接跑起来。

#include <stdio.h> #include <stdlib.h> #include <time.h> #define ROWS 9 #define COLS 9 #define MINE_COUNT 10 int mineBoard[ROWS][COLS]; // -1表示雷,0~8表示周围雷数 char showBoard[ROWS][COLS]; // '*'未翻开,'!'插旗,'0'~'8'已翻开 int revealedCount = 0; void initBoard() { for (int i = 0; i < ROWS; i++) for (int j = 0; j < COLS; j++) { mineBoard[i][j] = 0; showBoard[i][j] = '*'; } revealedCount = 0; } void placeMines() { int placed = 0; while (placed < MINE_COUNT) { int r = rand() % ROWS; int c = rand() % COLS; if (mineBoard[r][c] != -1) { mineBoard[r][c] = -1; placed++; } } } void calculateNumbers() { int dr[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { if (mineBoard[i][j] == -1) continue; int cnt = 0; for (int k = 0; k < 8; k++) { int ni = i + dr[k]; int nj = j + dc[k]; if (ni >= 0 && ni < ROWS && nj >= 0 && nj < COLS && mineBoard[ni][nj] == -1) cnt++; } mineBoard[i][j] = cnt; } } } int inBounds(int r, int c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; } void revealCell(int r, int c) { if (!inBounds(r, c)) return; if (showBoard[r][c] != '*') return; if (mineBoard[r][c] == -1) return; showBoard[r][c] = '0' + mineBoard[r][c]; revealedCount++; if (mineBoard[r][c] == 0) { int dr[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int k = 0; k < 8; k++) { revealCell(r + dr[k], c + dc[k]); } } } void toggleFlag(int r, int c) { if (!inBounds(r, c)) return; if (showBoard[r][c] == '*') { showBoard[r][c] = '!'; } else if (showBoard[r][c] == '!') { showBoard[r][c] = '*'; } } int checkWin() { return revealedCount == ROWS * COLS - MINE_COUNT; } void printBoard() { system("clear"); // Windows下改成 system("cls"); printf(" 1 2 3 4 5 6 7 8 9\n"); printf(" +---------------------------+\n"); for (int i = 0; i < ROWS; i++) { printf("%d |", i + 1); for (int j = 0; j < COLS; j++) { printf(" %c ", showBoard[i][j]); } printf("|\n"); } printf(" +---------------------------+\n"); } int main() { srand((unsigned)time(NULL)); initBoard(); placeMines(); calculateNumbers(); int gameOver = 0; int win = 0; while (!gameOver) { printBoard(); printf("操作说明:\n"); printf(" 翻开格子: r 行 列(如 r 3 5)\n"); printf(" 插旗/取消: f 行 列(如 f 3 5)\n"); printf(" 退出游戏: q\n"); printf("请输入: "); char op; scanf(" %c", &op); if (op == 'q') { printf("已退出游戏\n"); break; } if (op != 'r' && op != 'f') { printf("无效操作!\n"); continue; } int r, c; scanf("%d %d", &r, &c); r--; c--; if (!inBounds(r, c)) { printf("坐标越界!\n"); continue; } if (op == 'r') { if (showBoard[r][c] == '!') { printf("该格子已插旗,无法翻开!\n"); continue; } if (mineBoard[r][c] == -1) { for (int i = 0; i < ROWS; i++) for (int j = 0; j < COLS; j++) if (mineBoard[i][j] == -1) showBoard[i][j] = 'X'; gameOver = 1; win = 0; } else { revealCell(r, c); if (checkWin()) { gameOver = 1; win = 1; } } } else if (op == 'f') { toggleFlag(r, c); } } printBoard(); if (win) { printf("恭喜获胜!你排掉了全部 %d 颗雷。\n", MINE_COUNT); } else if (gameOver) { printf("踩雷了,游戏结束。\n"); } return 0; }

这个版本在Linux和macOS的终端里直接gcc minesweeper.c -o minesweeper就能编译通过,Windows下用Dev-C++或VS都可以,只要把system("clear")改成system("cls")

6.2 我实际调试中踩过的坑

调试这台程序时,我踩过的坑比预想的要多,挑几个最典型的说说,给你排雷。

第一个是清屏函数放错位置。我把system("clear")放在printBoard的末尾而不是开头,结果每次打印完棋盘就被清掉,整个界面闪烁不定。放在开头才是对的:先清屏、再打印新棋盘,视觉上才连贯。

第二个是坐标转换漏掉会导致整个游戏没法玩。玩家输入r 1 1想翻左上角第一个格子,但数组的(0,0)对应的才是左上角。如果少写了r--; c--;,你以为在翻左上角,实际上翻的是第二行第二列,整个棋盘所有操作都会偏移一格。这种BUG特别隐蔽,因为程序不会报错,但操作总是不对,非常摧毁自信心。

第三个是踩雷后没有把雷显示出来。第一版踩雷后我直接打印“游戏结束”就退出了,玩家根本不知道自己踩中的雷在哪里。后来改成踩雷后遍历mineBoard,把所有雷位置在showBoard中标记为'X'再打印一次最终棋盘,体验就好多了。这个细节也提示了一个通用思路:游戏结束时,最好把“真相”也展示给玩家,是一种用户体验上的基本尊重。

第四个是关于数字0的处理。我在第一次写revealCell时没有加mineBoard[r][c] == 0的判断,导致翻开任意一个格子都会递归所有方向,直到整个棋盘全部被翻开。后来加了判断条件,逻辑才正确。理解这个判断条件的含义是:只有空白格(周围无雷)才需要连锁展开,有数字的格子翻开一个就够了,它的数字本身已经提供了信息,不需要再往外扩。

6.3 后续可以扩展的方向

如果你做完基础版觉得不够过瘾,想给课设加分,这里有几个我后续研究过的扩展方向,按难度从低到高排列:

  • 首次点击不踩雷:在placeMines时先记录玩家第一次翻开的坐标,如果该坐标及周围8格有雷,就把雷移走重新安置。实现上需要调整布雷逻辑的时机,比如先读入第一次操作坐标再布雷。
  • 难度选择:把ROWSCOLSMINE_COUNT改成变量,根据用户输入的难度等级(初级9x9/10雷、中级16x16/40雷、高级16x30/99雷)重新初始化棋盘。
  • 计时功能:在游戏开始时用time()记录开始时间,胜利或失败时用difftime()计算用时并显示在结果中。
  • 拓展的剩余雷数显示:维护一个flagCount变量,记录玩家已插旗数量,在棋盘上方显示剩余雷数: 10 - flagCount
  • 图形界面版:用EasyX图形库或者SDL2库替换控制台输出,把棋盘渲染成格子图片,用鼠标点击替代命令行输入。

我个人最推荐做首次点击不踩雷难度选择这两个扩展,因为它们改动量不大,但能明显提升游戏的可玩性和程序的完整性,在课设答辩时也是很好的加分项。

C语言扫雷这个项目,我从开始动笔到完全跑通,前后用了大概一个周末的下午加晚上。中间踩过的那些坑,现在回头看其实都是非常典型的基础知识点:数组下标、随机数、递归、循环结构、函数封装。正是这些看起来“小题大做”的细节,把C语言最核心的底层逻辑扎扎实实地过了一遍。如果你也正在写这个项目,或者准备动手写,建议千万别只抄代码交差,把自己的思路理一遍,把每个函数为什么这样设计想清楚,收获会完全不一样。

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

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

立即咨询