☰
C++扫雷项目实战:EasyX图形库与核心算法详解
2026/10/18 5:23:31 网站建设 项目流程

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,这是 C++11 之后推荐的随机数方案。

第二个坑是去重。你不能简单地循环 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×16,99 颗雷)下,如果雷分布比较稀疏,递归深度可能达到几百层。虽然现代系统的默认栈空间(通常 1MB)一般能撑住,但如果你的编译器栈空间设置较小,或者地图更大,就可能栈溢出崩溃。

更稳妥的做法是用显式栈(迭代)替代递归,或者用广度优先搜索(BFS)。BFS 的思路是把待展开的格子放进队列,逐个处理,天然避免了递归深度问题:

void revealBFS(int startR, int startC) { std::queue<std::pair<int,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 展开的代码可以直接复用。一个看似简单的扫雷,背后连着的是一类通用的图遍历问题,这大概就是为什么它值得认真写一遍的原因。

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

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

立即咨询