资讯动态

C++扫雷项目实战:EasyX图形库与核心算法详解

发布时间:2026/10/18 5:23:32 来源:尧图企业网站定制
1. 为什么用 C 写扫雷是个被低估的练手项目很多人听到用 C 写扫雷第一反应是这不就是大一课程设计吗。我一开始也这么想直到自己真正动手从零实现了一遍才发现这个看似简单的项目里藏着不少值得琢磨的东西。扫雷这个游戏规则足够简单——点开格子数字告诉你周围有几颗雷靠推理把安全格子全部翻开——但要用代码把它做扎实涉及的知识面其实相当宽二维数组与坐标映射、随机布雷与去重、递归展开的边界控制、鼠标事件与坐标换算、游戏状态机管理、胜负判定逻辑每一个环节都有容易翻车的地方。这篇文章面向的是已经学过 C 基础语法、想找一个麻雀虽小五脏俱全的项目来练手的人。你可能刚学完数组、函数、类看了一堆语法书却不知道能拿这些知识做什么也可能写过一些控制台小程序但没接触过图形界面和事件循环。扫雷正好卡在这个位置上它比打印九九乘法表复杂得多又比写一个完整游戏引擎简单得多是一个能让你把零散知识串起来的绝佳载体。我这次实现选择的是EasyX 图形库原因后面会详细讲。整个项目从设计到跑通大概花了我两个晚上中间踩的坑主要集中在递归展开的栈溢出、鼠标坐标和数组下标的换算、以及首次点击不能踩雷这几个点上。下面我会把完整的设计思路、关键代码、以及那些文档里不会写的经验教训全部摊开来讲你可以直接照着复现也可以按自己的理解改造。需要提前说明的是本文所有代码基于 Windows 平台 EasyX 图形库如果你用的是其他图形库比如 SFML、SDL核心逻辑完全一致只是绘制和事件接口需要替换。逻辑部分我会尽量写得平台无关方便你迁移。2. 动手之前先把这几件事想清楚2.1 图形库选型为什么是 EasyX 而不是别的C 本身没有标准图形库这是新手最容易卡住的地方。你学完了语法想画个窗口出来结果发现标准库里根本没有drawRect这种东西。这时候摆在面前的选择通常有几个EasyX、SFML、SDL、Qt或者干脆用 Windows API 硬写。我选 EasyX 的理由很直接它对新手最友好配置成本几乎为零。EasyX 是一个专门为 C 初学者设计的图形库头文件就一个graphics.h安装就是把几个文件拷到编译器目录下然后#include graphics.h就能用。它的 API 设计非常直白画矩形就是fillrectangle画圆就是fillcircle获取鼠标消息就是getmessage没有复杂的对象模型和生命周期管理。对比一下其他方案SFML 和 SDL 功能更强、跨平台但需要配置链接库、理解事件循环和渲染窗口的分离设计对刚学完语法的人来说门槛偏高Qt 是重量级框架适合做正经应用拿来写扫雷属于杀鸡用牛刀而且信号槽机制又是一套新知识。EasyX 的定位就是让你快速看到图形结果这正好契合扫雷这种小项目的需求。当然 EasyX 也有明显短板只支持 Windows且不支持硬件加速画大量图形时性能一般。但扫雷这种格子数量初级 9×9中级 16×16高级 30×16对性能毫无压力完全够用。如果你后续想把这个项目做成跨平台的逻辑层和渲染层分离好替换渲染部分即可。提示EasyX 官网可以直接下载安装包安装时会自动检测你机器上的 Visual Studio 版本并配置好。如果你用的是 MinGW 或别的编译器需要手动拷贝头文件和库文件稍微麻烦一点。2.2 数据结构设计二维数组还是别的扫雷的核心数据就是一张格子地图。最直觉的做法是用二维数组每个格子存一个结构体包含是否有雷是否已翻开是否被标记这几个状态。我一开始也是这么写的struct Cell { bool hasMine; // 是否是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否插旗 int adjacentMines; // 周围雷数 }; Cell board[ROWS][COLS];这个设计清晰直观但有个细节要注意adjacentMines这个字段应该在布雷完成后一次性算好而不是每次点击时现算。现算的话每次都要遍历周围 8 个格子虽然性能影响微乎其微但代码会变得啰嗦而且容易在边界处理上出错。一次性预计算的好处是逻辑集中后面翻开格子时直接读这个值就行。另一个值得考虑的点是是否把雷和数字分开存储。有些实现会用-1表示雷0~8表示周围雷数用一个int数组搞定。这种写法省内存、代码短但可读性差一些尤其是当你想加插旗问号这些状态时一个int就不够用了。我倾向于用结构体多占的那点内存对现代机器来说完全可以忽略换来的是代码清晰度和可扩展性。2.3 布雷策略随机数怎么用才不出错布雷看起来简单——随机选 N 个格子放雷——但有两个坑必须提前想清楚。第一个坑是随机数种子。如果你不调用srand每次运行程序雷的分布都一模一样玩家玩两把就发现规律了。正确做法是在程序启动时用srand((unsigned)time(nullptr))初始化种子。但要注意time(nullptr)的精度是秒如果你在极短时间内反复启动程序种子可能相同导致雷分布重复。对扫雷来说这不是大问题但如果你追求更随机的效果可以用std::random_device配合std::mt19937这是 C11 之后推荐的随机数方案。第二个坑是去重。你不能简单地循环 N 次、每次随机一个坐标就放雷因为随机出来的坐标可能重复导致实际雷数少于预期。正确做法是循环直到成功放置 N 颗雷为止int placed 0; while (placed MINE_COUNT) { int r rand() % ROWS; int c rand() % COLS; if (!board[r][c].hasMine) { board[r][c].hasMine true; placed; } }这个写法在雷数远小于总格子数时效率很高但如果雷数接近总格子数比如 9×9 放 80 颗雷后期会频繁随机到已占用的格子效率下降。更稳妥的做法是把所有格子编号放进一个数组洗牌后取前 N 个。不过扫雷的雷密度通常不会那么极端前一种写法足够用。2.4 首次点击保护一个容易被忽略的体验细节经典扫雷有一个体验设计第一次点击永远不会踩雷。如果第一次点下去就炸了玩家会觉得莫名其妙游戏体验极差。实现这个功能有两种思路。第一种是事后补救先正常布雷玩家第一次点击时如果点中的是雷就把这颗雷移到别的地方去。这种做法的麻烦在于移动雷之后要重新计算周围数字逻辑比较绕。第二种是延迟布雷程序启动时先不布雷等玩家第一次点击后再在排除掉点击位置及其周围 8 个格子的范围内布雷。这样第一次点击必定安全而且点开后周围一片空白体验很爽。我推荐第二种逻辑更干净。// 首次点击后才布雷排除点击位置周围 void placeMines(int safeR, int safeC) { int placed 0; while (placed MINE_COUNT) { int r rand() % ROWS; int c rand() % COLS; if (board[r][c].hasMine) continue; if (abs(r - safeR) 1 abs(c - safeC) 1) continue; // 排除安全区 board[r][c].hasMine true; placed; } calculateAdjacent(); }注意这里排除的是 3×3 的区域这样第一次点击不仅自己安全周围一圈也安全点开后能展开一片手感很好。3. 核心逻辑的逐块拆解3.1 周围雷数计算边界处理是重灾区计算每个格子周围 8 个方向的雷数是扫雷最核心的逻辑之一。写起来就是一个双重循环加方向数组int dr[] {-1, -1, -1, 0, 0, 1, 1, 1}; int dc[] {-1, 0, 1, -1, 1, -1, 0, 1}; void calculateAdjacent() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (board[r][c].hasMine) continue; int count 0; for (int d 0; d 8; d) { int nr r dr[d]; int nc c dc[d]; if (nr 0 nr ROWS nc 0 nc COLS board[nr][nc].hasMine) { count; } } board[r][c].adjacentMines count; } } }这段代码的关键在于边界检查。如果你忘了nr 0 nr ROWS这个判断数组越界访问就会发生轻则读到垃圾数据导致数字算错重则程序崩溃。这是新手最容易犯的错误之一而且因为越界不一定立刻崩溃调试起来很头疼。我踩过的一个坑是方向数组写错了顺序导致某个方向的偏移量重复结果数字总是偏大。方向数组一定要仔细核对8 个方向分别是左上、上、右上、左、右、左下、下、右下dr和dc要一一对应。建议写完后用一个已知雷分布的小地图手动验证一遍。3.2 递归展开为什么你的程序会栈溢出扫雷最爽的操作就是点开一个空白格然后哗地展开一大片。这个功能靠递归实现如果点开的格子周围雷数为 0就递归展开它周围的 8 个格子。void reveal(int r, int c) { if (r 0 || r ROWS || c 0 || c COLS) return; if (board[r][c].isRevealed || board[r][c].isFlagged) return; board[r][c].isRevealed true; if (board[r][c].hasMine) { /* 游戏结束 */ return; } if (board[r][c].adjacentMines 0) { for (int d 0; d 8; d) { reveal(r dr[d], c dc[d]); } } }这段代码看起来没问题但在高级难度30×1699 颗雷下如果雷分布比较稀疏递归深度可能达到几百层。虽然现代系统的默认栈空间通常 1MB一般能撑住但如果你的编译器栈空间设置较小或者地图更大就可能栈溢出崩溃。更稳妥的做法是用显式栈迭代替代递归或者用广度优先搜索BFS。BFS 的思路是把待展开的格子放进队列逐个处理天然避免了递归深度问题void revealBFS(int startR, int startC) { std::queuestd::pairint,int q; q.push({startR, startC}); while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r 0 || r ROWS || c 0 || c COLS) continue; if (board[r][c].isRevealed || board[r][c].isFlagged) continue; board[r][c].isRevealed true; if (board[r][c].hasMine) continue; if (board[r][c].adjacentMines 0) { for (int d 0; d 8; d) { q.push({r dr[d], c dc[d]}); } } } }BFS 版本还有一个好处它不会重复访问已经入队的格子因为isRevealed检查效率比递归版本更稳定。我实测下来高级难度下 BFS 版本几乎瞬间完成展开没有任何卡顿。注意递归版本里那个isRevealed检查必须在递归调用之前做否则会出现两个相邻的空白格互相递归调用对方导致无限递归。这个 bug 我调了半小时才发现现象是程序直接卡死。3.3 鼠标坐标到数组下标的换算图形界面和逻辑层之间的桥梁是坐标换算。EasyX 里鼠标消息返回的是像素坐标(mx, my)你需要把它转换成数组下标(row, col)。假设每个格子边长CELL_SIZE像素棋盘左上角在(OFFSET_X, OFFSET_Y)那么int col (mx - OFFSET_X) / CELL_SIZE; int row (my - OFFSET_Y) / CELL_SIZE; if (row 0 row ROWS col 0 col COLS) { // 有效点击 }这里有几个细节。第一整数除法是向下取整对于正坐标来说正好符合需求但如果mx OFFSET_X结果会是负数所以边界检查不能省。第二格子边框的处理如果你画了 1 像素的边框点击在边框上时算哪个格子通常的做法是忽略边框直接按格子区域划分边框只是视觉装饰。第三右键插旗需要区分左键和右键消息EasyX 里通过msg.uMsg判断WM_LBUTTONDOWN和WM_RBUTTONDOWN。我踩过的一个坑是没有考虑窗口边框和标题栏的高度。EasyX 的坐标原点在绘图窗口的客户区左上角不包括标题栏所以如果你用GetCursorPos获取的是屏幕坐标需要先转换成客户区坐标。用getmessage拿到的鼠标消息里的坐标已经是客户区坐标了直接用就行。3.4 游戏状态机胜利和失败怎么判定扫雷有两种结束状态踩雷失败或者翻开所有非雷格子胜利。失败判定很简单翻开格子时发现是雷就结束。胜利判定稍微绕一点当已翻开的格子数等于总格子数减去雷数时玩家胜利。int revealedCount 0; // 每次翻开一个格子后 revealedCount; if (revealedCount ROWS * COLS - MINE_COUNT) { // 胜利 }这个判定方式比检查所有非雷格子是否都翻开更高效因为不需要每次遍历整个棋盘。但要注意插旗操作不影响revealedCount只有真正翻开格子才计数。游戏状态我用一个枚举管理enum GameState { PLAYING, WIN, LOSE }; GameState state PLAYING;在PLAYING状态下才响应鼠标点击胜利或失败后点击无效只响应重新开始按钮。这个状态机虽然简单但能让代码逻辑清晰很多避免在游戏结束后还去处理点击导致奇怪的行为。4. 渲染与交互让画面动起来4.1 绘制棋盘从空白到有模有样EasyX 的绘制流程很直白先cleardevice清屏然后遍历所有格子根据格子状态画不同的图形。未翻开的格子画一个凸起的灰色方块翻开的格子画平面并显示数字插旗的格子画一面小旗。void drawBoard() { cleardevice(); for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { int x OFFSET_X c * CELL_SIZE; int y OFFSET_Y r * CELL_SIZE; if (!board[r][c].isRevealed) { setfillcolor(RGB(192, 192, 192)); fillrectangle(x, y, x CELL_SIZE, y CELL_SIZE); // 画凸起效果左上白线右下暗线 setlinecolor(RGB(255, 255, 255)); line(x, y, x CELL_SIZE, y); line(x, y, x, y CELL_SIZE); setlinecolor(RGB(128, 128, 128)); line(x CELL_SIZE, y, x CELL_SIZE, y CELL_SIZE); line(x, y CELL_SIZE, x CELL_SIZE, y CELL_SIZE); if (board[r][c].isFlagged) { // 画旗子 } } else { setfillcolor(RGB(224, 224, 224)); fillrectangle(x, y, x CELL_SIZE, y CELL_SIZE); if (board[r][c].adjacentMines 0) { // 画数字不同数字不同颜色 } } } } }数字颜色是扫雷的经典设计1 是蓝色2 是绿色3 是红色4 是深蓝5 是棕色6 是青色7 是黑色8 是灰色。这个配色方案沿用了几十年玩家已经形成肌肉记忆建议保留。画数字用outtextxy配合settextcolor注意文字要居中可以通过textwidth和textheight计算偏移量。4.2 双缓冲消除画面闪烁如果你直接在主循环里cleardevice然后重绘会发现画面有明显的闪烁尤其是鼠标移动频繁的时候。这是因为每次清屏和重绘之间有一个短暂的空窗期屏幕先变白再画出内容。解决办法是双缓冲先在内存里画好一整帧再一次性贴到屏幕上。EasyX 提供了BeginBatchDraw和FlushBatchDraw来实现这个效果BeginBatchDraw(); while (true) { // 处理消息 // 绘制 FlushBatchDraw(); } EndBatchDraw();BeginBatchDraw之后所有的绘制操作都在后台缓冲区进行FlushBatchDraw把后台缓冲区的内容一次性显示出来。这样画面就不会闪烁了。这个技巧对所有实时刷新的图形程序都适用是 EasyX 里必须掌握的一个点。4.3 消息循环别让程序变成未响应EasyX 的程序需要一个消息循环来接收鼠标和键盘事件。最基础的写法是ExMessage msg; while (true) { if (peekmessage(msg, EX_MOUSE | EX_KEY)) { // 处理消息 } // 绘制 FlushBatchDraw(); }这里用peekmessage而不是getmessage的区别很重要getmessage在没有消息时会阻塞导致绘制不更新peekmessage是非阻塞的没有消息时立即返回程序可以继续执行绘制逻辑。如果你用getmessage程序在等待输入时会卡住窗口拖动、重绘都会出问题。另一个细节是消息类型过滤。peekmessage的第二个参数可以指定只接收鼠标或键盘消息避免处理无关消息。扫雷主要用鼠标键盘可以加一个R键重新开始或者ESC退出。5. 那些只有真正写过才会知道的坑5.1 递归展开的重复访问问题前面提到过递归展开时如果不加isRevealed检查两个相邻的空白格会互相调用导致无限递归。但即使加了检查还有一个隐蔽的问题同一个格子可能在一次展开中被多次入队。假设格子 A 和格子 B 都是空白格且相邻。展开 A 时会把 B 加入队列展开 B 时又会把 A 加入队列。虽然isRevealed检查能防止重复展开但队列里会堆积大量重复项效率降低。更严重的是如果检查时机不对可能导致某些格子被展开两次。我的解决方案是在入队时就标记而不是出队时才标记// 入队前检查并标记 if (!board[nr][nc].isRevealed !board[nr][nc].isFlagged) { board[nr][nc].isRevealed true; // 立即标记 q.push({nr, nc}); }这样每个格子最多入队一次效率最高。但要注意标记为已翻开后绘制逻辑要能正确处理——因为格子可能还没真正处理但状态已经是翻开了。对扫雷来说这不是问题因为翻开状态和显示内容是同步的。5.2 插旗与翻开的互斥关系插旗的格子不能被翻开翻开的格子不能被插旗这是基本规则。但实现时容易漏掉一个情况已经插旗的格子如果玩家再次右键应该取消插旗。这个切换逻辑要写对if (msg.message WM_RBUTTONDOWN) { if (!board[r][c].isRevealed) { board[r][c].isFlagged !board[r][c].isFlagged; } }另一个容易忽略的点是插旗数量不应该超过总雷数。有些实现允许无限插旗导致玩家把所有格子都插上旗游戏无法进行。正确做法是维护一个flagsUsed计数器达到雷数上限后不再允许插旗。这个细节虽然小但影响游戏体验。5.3 胜利判定的边界情况胜利判定用revealedCount ROWS * COLS - MINE_COUNT看起来没问题但有一个边界情况如果玩家踩雷了revealedCount也会增加因为踩雷的格子也被标记为翻开了。所以胜利判定必须在确认不是雷之后再做board[r][c].isRevealed true; if (board[r][c].hasMine) { state LOSE; return; } revealedCount; if (revealedCount ROWS * COLS - MINE_COUNT) { state WIN; }顺序不能反否则踩雷的瞬间可能误判为胜利。这个 bug 很隐蔽因为只有在最后一步踩雷时才会触发测试时容易漏掉。5.4 重新开始时的状态重置游戏结束后点重新开始需要把所有状态重置清空棋盘、重置revealedCount、重置flagsUsed、把state设回PLAYING、重新初始化随机种子可选。最容易漏的是没有清空上一局的雷分布导致新一局还残留着上一局的雷。void resetGame() { memset(board, 0, sizeof(board)); revealedCount 0; flagsUsed 0; state PLAYING; minesPlaced false; // 延迟布雷标记 }用memset清零结构体数组是可行的因为结构体里都是 POD 类型bool 和 int。但如果你的结构体里有std::string之类的非 POD 成员就不能用memset必须手动重置每个字段。这是一个 C 的经典陷阱值得注意。6. 难度分级与可扩展方向6.1 三档难度的参数设计经典扫雷有三档难度参数如下难度行数列数雷数格子边长建议初级991040px中级16164030px高级16309925px这些参数不是随便定的。初级 9×9 放 10 颗雷雷密度约 12%新手能靠推理完成中级 16×16 放 40 颗雷密度约 15.6%需要一定的技巧高级 16×30 放 99 颗雷密度约 20.6%非常考验推理能力。格子边长要根据窗口大小调整保证整个棋盘能完整显示在屏幕上。实现难度切换时把这些参数做成配置结构体切换时重新初始化棋盘即可。注意窗口大小也要跟着变EasyX 里用initgraph重新创建窗口或者用setbkcolor和cleardevice清屏后重绘。6.2 可以继续加的功能基础版本跑通后有几个方向可以继续扩展每一个都能让你学到新东西计时器记录玩家完成一局用了多少秒。实现方式是在主循环里用GetTickCount()获取当前时间减去开始时间。注意计时器要在第一次点击后才启动而不是程序启动就启动。剩余雷数显示用总雷数减去已插旗数显示在界面上。这个数字可以是负数如果玩家插旗超过雷数但通常限制在 0 以上。排行榜把每档难度的最快通关时间存到文件里下次启动时读取。这涉及到文件读写可以用std::ofstream和std::ifstream是练习文件操作的好机会。自定义难度让玩家自己输入行数、列数、雷数。需要做输入验证防止玩家输入非法值比如雷数超过总格子数。音效翻开格子、插旗、踩雷时播放不同的音效。EasyX 本身不支持音频需要配合mciSendString或第三方库。这个稍微复杂但效果很直观。6.3 代码组织建议项目虽小但建议还是分文件组织养成好习惯main.cpp程序入口消息循环game.h/game.cpp游戏逻辑布雷、展开、判定render.h/render.cpp绘制相关config.h常量配置格子大小、颜色、难度参数这样分层的好处是逻辑和渲染解耦以后想换图形库只需要改render部分游戏逻辑完全不用动。我一开始图省事全写在main.cpp里后来想加功能时发现代码已经乱成一团重构花了不少时间。前期多花十分钟分文件后期能省几个小时这个经验在几乎所有项目里都适用。7. 调试扫雷时我常用的几个手段扫雷的 bug 往往不是崩溃而是逻辑不对——数字算错了、展开范围不对、胜利判定不触发。这类问题用调试器单步跟踪效率不高我通常用几个更直接的手段。第一个是打印调试。在关键逻辑处加printf或输出到控制台把棋盘状态打印出来。比如布雷后打印一张地图用*表示雷数字表示周围雷数肉眼扫一遍就能发现数字算错的地方。这个方法虽然原始但对扫雷这种小规模数据极其有效。第二个是固定雷分布。调试时不要用随机布雷而是硬编码一个已知的雷分布这样每次运行结果一致方便复现问题。等逻辑调通了再换回随机布雷。第三个是边界测试。专门测试四个角、四条边上的格子这些位置的周围格子数少于 8 个是边界 bug 的高发区。手动点一遍角落看看数字对不对、展开范围对不对。第四个是极端参数测试。把雷数设成 1 颗、设成总格子数减 1 颗看看程序是否还能正常工作。雷数为 1 时第一次点击后应该直接展开一大片雷数极多时布雷循环的效率问题会暴露出来。这些手段都不需要额外的工具但能覆盖扫雷 90% 以上的 bug。我个人的体会是小项目的调试靠的是对数据的直接观察而不是复杂的调试工具。把中间状态打印出来比在调试器里一步步点要快得多。最后分享一个我在实际写这个项目时的小发现扫雷的递归展开逻辑其实和图像处理里的洪水填充算法是同一个东西。如果你以后要写画图软件里的油漆桶工具或者做迷宫生成、连通区域分析这套 BFS/DFS 展开的代码可以直接复用。一个看似简单的扫雷背后连着的是一类通用的图遍历问题这大概就是为什么它值得认真写一遍的原因。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑