C语言学到数组和函数那会儿我总有点“越学越空”的感觉语法都认识练习题能做可一问“你能拿C用什么作品”脑子就一片空白。后来照着经典扫雷游戏写了一个控制台版本情况完全不一样了。这个项目突然把二维数组、随机数、递归、输入输出甚至文件读写这些知识点全串了起来代码量不大但每一步都在解决真实问题。所以今天这篇我就把自己用C语言实现扫雷的整个过程整理出来从数据结构设计、布雷算法到游戏循环、文件存档再到最后“双击exe闪退”“中文乱码”这些常见坑都写清楚。不管你是刚学完C语言基础、想找个综合练习的在校生还是想重新捡起C语言做点小项目的开发者这篇都可以当一份直接复现的工程笔记来用。1. 项目定位与整体设计思路1.1 为什么拿扫雷当C语言练手项目很多人学C语言学到指针和结构体就开始迷茫因为教科书上的例子离“能玩的程序”太远。扫雷恰恰是一个介于练习题和真实软件之间的项目它规则简单但逻辑完整能把C语言里最核心的内容全部用上。我统计了一下自己最终写出来的版本大约三百多行C代码拆成初始化、布雷、数字统计、空白展开、打印棋盘、输入处理这几个模块。每个模块都对应一个清晰的功能点特别适合练手。比如二维数组用来表示棋盘随机数用来布雷递归用来实现“点开空白区域自动展开一大片”文件读写用来保存排行榜。学完这些语法之后你终于能亲眼看到它们组合在一起能做出什么东西。另外扫雷的反馈非常即时。你写完一个函数编译运行立刻能看到棋盘上多了一片数字或者是布雷位置发生了变化。这种“改一行代码、马上看到效果”的正反馈比做题本上的练习题来得直接得多。如果将来你还打算学Python、Java或者Go先用C把这个项目写一遍后面再换语言实现时会发现逻辑思路是通用的只是语法不同而已。1.2 控制台版还是图形版先跑通逻辑再说我在不少学习群里看到过这种情况同学一上来就打算做带按钮、带图标的“Windows窗口扫雷”结果环境配置就花了一周最后连核心逻辑都没写完。我强烈建议第一次写这个项目时用纯控制台版本就好也就是黑底白字、用printf打印棋盘的版本。控制台版本有几个优势。第一不依赖任何第三方图形库只要电脑里有C编译器就能跑学校机房、老笔记本都没问题。第二调试特别方便你用printf把棋盘打出来哪里逻辑有问题一眼就能看到。第三控制台版本已经把扫雷的核心算法全部实现了后面想升级成图形界面只需要把“打印棋盘”这部分替换成绘图代码后面的布雷、展开、判定逻辑完全不用动。如果实在想挑战图形界面我建议也先写一个能跑的控制台版本然后再去学EasyX、SDL2或者Qt。核心玩法先跑通图形只是换一层展示壳别把顺序搞反了。1.3 模块划分别把代码堆在main里我见过不少新手写游戏喜欢把所有代码堆在main函数里一百多行还好写到三百行的时候自己都分不清哪段代码是干什么的。我的做法是拆成三个文件文件职责game.h常量定义、结构体声明、函数声明game.c游戏逻辑实现包括初始化、布雷、统计、展开、打印main.c程序入口调用游戏逻辑核心函数也不要超过十个每个函数只做一件事。我最后拆成了初始化棋盘、布雷、计算周围雷数、递归展开空白、打印棋盘、解析输入、判断胜负这些函数。这样写的好处是出了问题能直接定位到具体函数不用从头翻到尾。我还用一个Game结构体把棋盘状态包起来这样函数之间传参时只需要传一个结构体指针不用同时传一堆数组和变量。学会用结构体组织数据也是这个项目的一个重要收获。2. 核心数据结构与地图初始化2.1 棋盘为什么用二维数组加一圈“安全区”扫雷的棋盘天然适合用二维数组来模拟。我定义了两个数组#define ROWS 9 #define COLS 9 #define MINES 10 char mine[ROWS 2][COLS 2]; char show[ROWS 2][COLS 2];mine数组存的是真实游戏数据*表示地雷0到8表示周围雷数show数组存的是玩家当前能看到的状态#表示未翻开数字表示已经翻开F表示玩家插了旗。两个数组分开是扫雷程序的一个基本设计因为玩家看到的界面不能泄露真实地雷位置。为什么要定义成ROWS 2而不是ROWS因为我在实际写的时候把有效格子放在坐标1到ROWS把0和ROWS1留作边界。这样在计算某个格子周围八个方向是否有雷时不需要写一堆“如果r等于1就别往上看”的判断直接访问坐标r-1、r1就行因为越界的格子永远都是安全值。这是很多游戏开发里的“哨兵”技巧用一点点内存换大量边界判断代码。初始化的时候用memset把mine数组全部填成0把show数组全部填成#。注意字符0和整数0不一样后面打印和计算的时候要区分清楚。2.2 布雷算法从随机死循环到洗牌布雷是扫雷里最需要动脑子的地方。新手最容易想到的写法是循环随机生成行列坐标如果这个格子已经有雷了就重新生成直到凑齐雷数。这个思路在雷少格子多的时候能用但有一个隐患如果地图很小、雷很多生成随机坐标时就会频繁撞上已经布雷的格子极端情况下可能循环很久才能布完。更稳妥的方案是用Fisher-Yates洗牌算法。思路是把所有格子编号成0到total-1然后像洗扑克牌一样打乱这个数组取前mines个编号作为地雷位置。这样时间复杂度是O(total)布雷一定不会死循环而且每个格子被选中的概率完全一样。void place_mines(char mine[][COLS 2], int rows, int cols, int mines) { int total rows * cols; int *cells (int *)malloc(sizeof(int) * total); for (int i 0; i total; i) { cells[i] i; } srand((unsigned)time(NULL)); for (int i total - 1; i 0; i--) { int j rand() % (i 1); int tmp cells[i]; cells[i] cells[j]; cells[j] tmp; } for (int i 0; i mines; i) { int r cells[i] / cols 1; int c cells[i] % cols 1; mine[r][c] *; } free(cells); }这里要特别注意srand只在main里调用一次就行不要在布雷函数里反复调用。如果每次布雷前都srand(time(NULL))程序跑得快的时候两个time返回值相同会导致连续几局地雷位置完全一样调试的时候很容易让人误以为随机数坏了。2.3 周围雷数的统计8个方向要写全布雷完成后下一步是计算每个非雷格子周围八个方向上有几颗雷。这个函数我用了一个双重循环遍历坐标偏移量从-1到1的九个位置跳过自己然后判断对应格子是不是*。int count_mines(char mine[][COLS 2], int r, int c) { int cnt 0; for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; if (mine[r dr][c dc] *) { cnt; } } } return cnt; }边界就是在这里体现出价值的因为数组比实际地图大了一圈即使r是1r-1访问到的也是第0行不会越界。统计返回的是整数但存进mine数组时要转换成字符写成mine[r][c] 0 cnt。忘记做这个转换的话打印的时候会得到非常奇怪的结果这是我第一次写时踩过的坑。3. 游戏循环与核心玩法实现3.1 命令输入解析scanf的坑与fgets方案控制台版扫雷的交互方式我设计成输入三个整数操作码、行号、列号。操作码1表示翻开2表示标记旗帜3表示取消标记。这样设计简单直接不需要额外引入复杂的按键处理。但输入解析这件事初学C语言的人很容易踩坑。直接用scanf(%d %d %d, op, r, c)看起来没问题实际运行起来会发现第一次输入后按回车scanf把整数读走了换行符还留在缓冲区里。如果后面再混用getchar之类读取字符程序会出现“跳过一次输入”的奇怪现象。我后来的标准写法是用fgets读取一整行再用sscanf解析char line[100]; printf(指令格式操作 行 列操作1翻开 2标记 3取消: ); if (!fgets(line, sizeof(line), stdin)) { continue; } int op, r, c; if (sscanf(line, %d %d %d, op, r, c) ! 3) { printf(输入格式不对请重新输入\n); continue; } if (r 1 || r ROWS || c 1 || c COLS) { printf(坐标超出范围\n); continue; }fgets一次吃掉一行的换行符缓冲区就不会残留任何多余字符。然后通过检查sscanf的返回值是否为3可以同时判断用户是不是按要求输入了三个整数解析失败就直接提示并重新输入。这套写法我后来在很多控制台项目里都在用比裸scanf稳得多。3.2 翻开格子与空白区域的递归展开扫雷最好玩的操作就是点到一个数字为0的格子周围一大片格子全部自动翻开。这个效果用递归实现非常简单当前格子周围雷数为0时说明它周围都是安全的就继续对八个方向的邻居执行相同的翻开操作。void reveal(char mine[][COLS 2], char show[][COLS 2], int r, int c, int *opened) { if (r 1 || r ROWS || c 1 || c COLS) { return; } if (show[r][c] ! #) { return; } if (mine[r][c] *) { return; } int cnt count_mines(mine, r, c); show[r][c] (char)(0 cnt); (*opened); if (cnt 0) { for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; reveal(mine, show, r dr, c dc, opened); } } } }这里有两个很重要的判断。第一个是show[r][c] ! #就返回避免已经翻开的格子被重复递归这是防止无限递归的关键。第二个是碰到地雷直接返回避免把地雷格也翻开。opened指针是用来记录翻开格子总数的每翻开一格就加一后面判断胜利条件会用到。递归方案的优点是代码量最小、最容易理解。缺点是如果地图特别大空白区域又连成片递归层数可能很深。标准扫雷的地图规模完全不用担心但如果你自己写了一个100x100的超大地图可以考虑用队列实现广度优先展开思路是先把起点入队然后循环出队并检查八个方向遇到数字为0的格子再入队。逻辑一样只是不占用系统栈。3.3 胜利与失败的判定不止踩雷才结束游戏的状态我定义成RUNNING、WIN、LOSE三种。这个状态机是整个游戏循环的核心。失败判定最简单玩家输入翻开操作时如果目标格子正好是地雷游戏直接结束。但成功判定容易被忽略很多新手会以为“把所有地雷标记出来”才算胜利实际上原版扫雷的胜利条件是“翻开所有非雷格子”。也就是说你不需要标记任何旗帜只要把所有没有雷的格子都翻开就赢了。if (op 1) { if (mine[r][c] *) { state LOSE; break; } reveal(mine, show, r, c, opened); if (opened ROWS * COLS - MINES) { state WIN; break; } }注意这里的opened变量要在初始化时置为0每次翻开成功都通过reveal累加。当翻开格子数等于总格子数减去雷数时说明所有安全格子都已翻开触发胜利。为了方便调试我还会在main函数开头做一个临时打印真实地雷布局的函数开局前用printf把mine数组全部打出来看一眼。确认布雷数量正确后再开始正式游戏这样就不会出现“显示3颗雷但实际布了5颗”还找不到原因的尴尬情况。3.4 标记旗帜与交互提示标记功能是扫雷的重要辅助操作可以帮玩家记下“这里应该是雷”。我给show数组增加了一个状态字符F来代表旗帜。操作逻辑是未翻开的格子执行标记操作后变成F。已经是F的格子执行取消标记操作后变回#。已经翻开的格子不允许标记。实现的时候只要在输入处理分支里加上对F状态的判断做一个简单的状态翻转就行。这里有一个小细节玩家对同一个格子连续输入两次“标记”操作第一次标记、第二次取消是一种很自然的交互方式比强制要求输入“取消”命令更顺手。打印棋盘时我会把行列标号打出来方便玩家输入坐标。每个格子的显示规则是#显示为“ #”F显示为“ F”数字0显示为两个空格数字1到8显示对应数字。用printf配合宽度控制打出来的棋盘基本能对齐。如果你想让界面更接近原版可以把数字0当成空白格显示视觉上会更清爽。4. 进阶功能计时、存档与界面优化4.1 加一个计时器扫雷没有计时器总觉得少了点什么。控制台程序里做计时其实很简单在开局时记录时间戳之后每次操作后计算差值。用time库就行#include time.h time_t start_time time(NULL); // 游戏过程中想显示已用时间 int elapsed (int)(time(NULL) - start_time);如果嫌整秒精度不够可以用clock()函数统计CPU时钟周期换算成秒。但扫雷这种回合制游戏用time(NULL)获取秒级时间完全够用。这里要提醒一下刷新方式。很多人写控制台游戏时习惯每帧调用system(cls)清屏但在循环里频繁清屏会导致屏幕剧烈闪烁观感很差。更好的做法是用Windows API里的光标定位函数把光标移到窗口左上角然后重新打印棋盘不清屏#include windows.h void gotoxy(int x, int y) { COORD pos { x, y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); }这样每次打印前先gotoxy(0, 0)再逐行输出棋盘就不会闪了。这个方法对所有控制台小游戏都适用算是Windows控制台开发的一个通用技巧。4.2 排行榜与文件读写扫雷玩久了总会想留下点成绩。我把排行榜存到本地文件里正好练习一下C语言的文件读写操作。结构体定义成这样的typedef struct { char name[32]; int seconds; int wins; } Record;保存记录用fopen和fprintf写入文本文件void save_record(Record *r) { FILE *fp fopen(rank.txt, a); if (!fp) { perror(打开文件失败); return; } fprintf(fp, %s %d %d\n, r-name, r-seconds, r-wins); fclose(fp); }读取排行榜时用fscanf按相同格式一行一行读出来遍历打印即可。这里我故意用文本文件而不是二进制文件是因为文本文件可以用记事本直接打开检查出错时容易排查。二进制文件fwrite/fread效率更高但可读性差实际项目中要根据需求选。文件读写要注意几个问题fopen返回NULL一定要判断否则后面fprintf会崩溃追加模式a和覆盖模式w的区别要搞清楚排行榜应该用追加模式每次存新纪录不覆盖旧纪录如果文件名带中文路径在旧版Windows控制台下可能因为编码问题打不开建议文件名保持纯英文。4.3 自定义难度与控制台颜色原版扫雷有三种难度初级9x9、10颗雷中级16x16、40颗雷高级16x30、99颗雷。我用枚举和配置数组把它们管理起来typedef enum { EASY 1, MEDIUM, HARD } Level; typedef struct { int rows; int cols; int mines; } Config; Config configs[] { {9, 9, 10}, {16, 16, 40}, {16, 30, 99} };开局前让玩家选择难度然后按照对应的行列值初始化棋盘。动态大小棋盘需要把之前的ROWS、COLS常量改成变量mine数组可以用malloc动态分配或者直接把数组开成最大尺寸。对练手项目来说直接开一个最大尺寸的数组只用其中一部分最简单不容易出错。控制台颜色用Windows API的SetConsoleTextAttribute设置可以让棋盘上不同数字显示不同颜色读起来舒服很多void set_color(int color) { SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), color); }数字1到8分别映射不同颜色比如1用蓝色2用绿色3用红色。打印完棋盘再把颜色恢复成默认值。这属于锦上添花的功能我放在核心逻辑全部跑通之后才加也建议你按照这个顺序来先功能后美化。5. 环境配置与常见问题排查5.1 给自己写的扫雷选一个合适的编译环境写C语言的编译器环境选择直接影响刚开始的体验。我身边常见的有这么几种环境优点缺点适合人群Dev-C安装简单自带编译器调试功能弱界面老旧初学者快速上手Visual Studio功能全面调试强大安装包大新建项目略重Windows平台C/C开发VS Code MinGW-w64轻量配置灵活需要自己配置tasks.json喜欢折腾、长期写代码的人Code::Blocks开源自带编译器界面一般社区更新慢跨平台学习如果选择VS Code步骤大概是先安装MinGW-w64并加入系统PATH然后安装C/C扩展新建一个扫雷文件夹写好代码后用终端里的gcc命令编译或者配置tasks.json后按CtrlShiftB直接编译。配置好一次之后体验比Dev-C好很多。不管用哪个环境我建议都开启编译器的警告选项比如gcc加-Wall。编译器给出的警告信息往往是代码隐患不要忽略能把警告全部清零程序出问题的概率会小很多。5.2 双击exe打不开、闪退怎么处理这个问题网上问得特别多尤其是自己在电脑上编译出一个exe双击运行却“一闪而过”。原因很简单控制台程序的main函数执行完毕后窗口就会自动关闭。如果你的程序最后没有任何等待输入的操作运行结果一闪就消失了看起来就像“打不开”。解决办法有三种。第一种是在main函数返回前加一句system(pause)程序会停在“请按任意键继续”的界面。第二种是在return前加getchar()效果类似。第三种是打开命令提示符cmd手动切到exe所在目录输入文件名运行这样窗口会一直保留。至于Windows系统自带的经典扫雷游戏双击打不开这种情况一般出现在Win7或精简版系统上。排查方向有几个先到“控制面板-程序-打开或关闭Windows功能”里看看“游戏”组件是否被勾选没勾选就勾上系统会自动装回包括扫雷在内的游戏如果系统是精简版游戏组件可能已经被移除需要找完整系统镜像重新安装组件另外检查安全软件是不是把游戏程序拦截了右键以管理员身份运行也可以试试。这些是最常规的检修养护思路不涉及任何系统破解属于基本的软件故障排查。5.3 中文乱码、方块字符与实际雷数对不上等排查中文乱码是控制台C程序里最常见的槽点。根本原因是源文件编码和控制台代码页不一致。Windows中文版控制台默认代码页是936也就是GBK但你的源文件可能是UTF-8无BOM编码printf里的中文字符串存进去是UTF-8字节控制台按GBK解码自然就乱码了。解决办法也很直接要么在main开头调用SetConsoleOutputCP(CP_UTF8)把控制台代码页切到UTF-8要么在源文件里用系统自带的“另存为”把编码改成GB2312。用VS时容易遇到这个问题因为新版VS默认保存为UTF-8。Dev-C则比较多默认GBK反而没这么明显。还有一类问题跟编码无关是逻辑错误。比如你明明设置了10颗雷但游戏结束后显示的地雷数不对。这时最有效的做法是写一个临时调试函数把mine数组完整打印出来数一遍看布雷数量是否等于设定值。如果布雷数量对但显示不对再看show数组的翻开逻辑尤其是递归展开和标记功能有没有误改数据。我把常见的几个问题整理成一张速查表方便对照排查症状可能原因解决方法双击exe后窗口一闪而过程序运行完直接退出main末尾加getchar或system(pause)中文输出乱码源文件编码与控制台代码页不一致SetConsoleOutputCP(CP_UTF8)或另存为GBK翻开格子时程序崩溃数组越界检查坐标范围确认输入前做了边界校验雷数不对srand重复调用或坐标计算错误只调用一次srand用洗牌算法布雷后调试打印输入一次后循环卡住scanf遗留换行符改用fgetssscanf解析输入6. 扩展思路与我的实操感受6.1 如何把它改成图形界面控制台版跑通之后如果你想把它升级成真正的图形界面核心逻辑其实不用重写。我建议把界面层和逻辑层分得再清楚一点所有涉及printf棋盘的地方都抽成独立的“打印接口”图形版本只需要用绘图函数替换这个接口。方案选择上Windows下用EasyX最简单一个绘图库头文件就够鼠标点击坐标换算成行列后调用同一个翻开逻辑就行。SDL2跨平台但环境配置复杂一些。Qt则适合做成完整桌面应用不仅有点击事件还能做右键菜单、菜单栏。不管选哪个最重要的经验是不要在图形版本里重新写一遍布雷和递归展开否则你会同时调试两套逻辑非常痛苦。6.2 想想还能加什么功能基础流程做好以后还有一些很值得加的功能。我第一推荐的是“首点非雷”也就是玩家第一次点击的位置一定不是地雷。实现思路是记录第一次翻开的位置布雷完成后强制把这个位置上的雷挪走或者干脆先不布雷等第一次点击后再布剩下的雷。这个功能能明显提升游戏体验因为谁都不想开局第一步就踩雷。其次是存档功能。把mine数组、show数组、已经翻开的格子数和当前已用时间按顺序写入文件读档时按同样顺序读回来就是一套完整的存档系统。这比单纯存排行榜更有挑战性也能把C语言文件读写练得更扎实。再往后还有“连续游戏记录统计”、“自定义雷数密度”、“暗雷模式”这些脑洞。它们的作用是让项目持续有新鲜感同时逼着你不断重构代码。重构本身就是很好的学习过程。6.3 我踩过的坑和一点个人建议最后聊点个人经验。我写这个扫雷项目时踩过几个很有代表性的坑。第一个是scanf缓冲区问题搞清楚了以后所有控制台项目的交互都舒服了很多。第二个是递归展开时忘记判断“格子已经翻开”结果程序直接栈溢出崩溃让我意识到递归必须有明确的退出条件。第三个是文件读写时忘记判断fopen的返回值遇到只读文件就崩溃。如果你准备自己动手写一遍我的建议是不要一口气写完整个项目。先做一个小目标能打印棋盘再做一个目标调通布雷接着是翻开和展开。每完成一个阶段就编译运行一次确认没问题再干下一件事。这种小步快跑的方式既不会让调试变成地狱也能让你清楚看到每一块代码的作用。扫雷看似是个小游戏实际上把C语言的核心语法几乎全部过了一遍。写完它之后你再看结构体、指针、文件操作这些概念会发现自己终于不是在背语法而是在用工具解决实际问题了。如果你也在找一个能拿得出手的C语言练手项目扫雷绝对是从入门到实践的最佳起点之一。