尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

C语言项目实战:手写扫雷游戏,吃透二维数组与递归

发布时间:2026/9/28 17:44:11

资讯中心
01
ARTICLE

C语言项目实战:手写扫雷游戏,吃透二维数组与递归

C语言项目实战:手写扫雷游戏,吃透二维数组与递归
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[rdr][cdc]完全不用判断边界。对比一下如果不加缓冲区每次遍历邻居前至少要写四五个“如果坐标在边界就跳过”的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[rdr][cdc] 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); #endifWindows和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 abcscanf返回的值就不等于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 -stdc99 ./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下用SetConsoleTextAttributeLinux/macOS下用\033[xxmANSI转义序列。我后来把数字1到8分别设置了不同颜色比如数字1是蓝色、数字2是绿色雷用红色M标记棋盘的视觉辨识度一下子高了很多。这个改造的代码量不大但对游戏体验的提升是肉眼可见的。再进一步你还可以把字符改成其他符号比如用表示雷、?表示插旗符号含义完全由自己定义。不过别改得太花哨否则玩家容易看花眼。说实话扫雷这个项目做完一遍我最大的体会是写代码前把数据结构设计清楚比上来就动手指要省太多时间。我最初直接开9x9数组、不设边界缓冲导致后面补丁打了一堆后来换成两条棋盘加一圈缓冲区整个程序从结构到实现都清爽了。所以如果你要拿这个项目当练习不要在细节上妥协每一个设计决定都要想清楚为什么这么做。等你把这份代码吃透后面再学链表、队列、二叉树时你会发现自己处理状态和逻辑的能力明显不一样了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。