简介:资源是一份基于VC++与MFC框架开发的扫雷游戏完整源程序,面向C++学习者、Windows桌面应用开发者和游戏逻辑爱好者,可帮助深入理解经典扫雷玩法的编码实现与工程调试分析。压缩包内含54个文件,大小约1.95MB,涉及C++源文件和头文件、BMP图像资源、WAV音效文件以及工程配置与编译生成文件,目录结构较完整,便于直接打开工程查看运行效果。目前已有94人学习下载。通过阅读源程序,可重点理解二维数组雷区建模、周边雷数统计算法、递归展开空白格子、右键标雷等核心逻辑;同时也能学习MFC中对话框、按钮、消息映射与鼠标事件处理,掌握Windows图形界面的基本构建方式与常见交互技巧。压缩包内附可执行文件,便于对照运行结果进行调试,适合作为C++项目实践或课程设计的参考资料。 很多刚开始学 VC++ 的朋友,做的第一个图形界面程序不是计算器就是扫雷。我当年也是这路线,但说实话,扫雷这个小东西一点都不简单。它要把二维数组操作、鼠标消息分发、游戏状态管理、GDI 自绘这些 Windows 桌面开发的核心知识点全串在一起,一套能完整跑起来的 VC++ 扫雷源程序写下来,你对 GUI 程序的整体认识会明显上一个台阶。这篇文章我直接用一套可运行的 VC++ 扫雷源程序来讲,从数据结构、布雷算法、翻开逻辑,到 MFC 界面实现和最后的发布配置,一步步拆开聊,适合刚学完 C++ 基础、想拿真实项目练手的读者,也适合需要快速参考完整源码的老鸟。
1. 项目概述:为什么扫雷是最值得手写的 VC++ 练手项目
1.1 扫雷的核心博弈规则
扫雷的规则大家都熟:一块棋盘上藏着若干地雷,左键点开格子,点到地雷游戏结束;点开数字格,数字代表周围 8 格里的地雷总数;如果点开的是空块,系统会自动向四周翻开,直到碰到有数字的边界为止。玩家通过数字推断哪些格子一定是雷,用右键标上旗子,把所有非雷格子全部翻开就算胜利。
从程序设计的角度来看,这套规则其实拆成三个独立模块:棋盘数据模型(哪些格子有雷、翻没翻、标没标旗)、交互逻辑(鼠标点在了哪里、应该翻开还是标记)、画面渲染(每个格子最终画成什么样)。三个模块各自独立,只通过小接口通信,一个小游戏就能做出工程化的结构感。这也是我说它“麻雀虽小五脏俱全”的原因——做过一遍扫雷,等于把 Windows GUI 编程的主干流程走通了一遍,而且这个项目规模刚好不会让人放弃。
1.2 技术选型:MFC 对话框程序是目前最快的落地方案
Windows 上用 C++ 写桌面程序,常见路线就三种:纯 Win32 SDK、MFC、以及 Qt/WTL 这类第三方框架。这里我选 MFC 的“基于对话框”程序,不是因为它技术先进,而是它能帮我们把窗口创建、消息循环、控件布局这些重复性工作一次性打包好,让精力集中在游戏逻辑和绘制上。Visual Studio 里新建一个 MFC 应用程序,选“基于对话框”,几分钟就能得到一个可运行的空壳子。
如果你更愿意折腾,当然可以用纯 SDK 手动注册窗口类、写窗口过程,那对理解 Windows 消息机制帮助更大。但本文以“能用、能跑、能改”为目标,选 MFC 就好。MFC 对扫雷这种单窗口小游戏来说,既不重,又恰好覆盖了绘图、消息、定时器这些常用机制。
2. 核心数据结构与算法设计
2.1 棋盘建模:结构体数组就够了
先定一个原则:这个项目的核心是“游戏逻辑”和“界面表现”分离。我把棋盘放进一个独立的游戏逻辑类 CMineGame,对话框类只管显示和鼠标消息。逻辑层不依赖界面,以后想换皮肤、改成 Qt 版本,或者单独写控制台测试,游戏逻辑一行都不用改。
每个格子的信息用结构体表达:
struct CellInfo { BOOL bMine; // 是否是雷 BOOL bRevealed; // 是否已翻开 BOOL bFlagged; // 是否标了红旗 BOOL bQuestion; // 是否标了问号 int nAdjMine; // 周围8格雷数 };棋盘声明:
CellInfo m_board[20][30];行数和列数先取一个比较大的最大值,实际用到的范围由 m_nRow、m_nCol 这两个成员控制。虽然有点浪费空间,但换来了两件事:一是代码简单,二是不用担心越界,对教学和练手项目来说这个取舍非常划算。每个格子用四个成员而不是一个复合状态值,是因为翻开、标旗、问号三种状态在实际运行时可能交替出现,拆开写更容易理解和排查。
网上很多历史源码喜欢用一个大整数数组、用位运算去压缩状态,那是内存紧张时代的产物。如今开发环境下,清晰远比那一点空间重要。结构体方案一眼就能看懂,调试时把成员值直接打印出来也方便。
2.2 布雷算法:随机、不重复、避开首点
布雷有两个关键点:地雷位置随机且不重复,以及第一步点下去不能被炸死。这也是现代扫雷的基本用户体验,绝不能省。
void CMineGame::InitMines(int firstRow, int firstCol) { srand((unsigned)time(nullptr)); int placed = 0; while (placed < m_nMineCount) { int r = rand() % m_nRow; int c = rand() % m_nCol; // 避开第一次点击的位置 if (r == firstRow && c == firstCol) continue; if (!m_board[r][c].bMine) { m_board[r][c].bMine = TRUE; placed++; } } CalcAdjMineCount(); }用 while 循环“碰运气”布置,9×9 的棋盘、10 个雷,最多多试几次就能全布完,性能毫无压力。两个细节值得特别注意:一是srand(time(nullptr))不能漏,否则每次运行rand()给出的序列一模一样,别人扫雷靠推理,你测试扫雷靠背板;二是“避开首点”的判断要在“检查重复”之前写,顺序不能反。
周围雷数的计算单独封装:
int CMineGame::CountAdjacentMines(int row, int col) { int count = 0; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = row + dr; int nc = col + dc; if (nr >= 0 && nr < m_nRow && nc >= 0 && nc < m_nCol) { if (m_board[nr][nc].bMine) count++; } } } return count; }这段代码我建议直接记下来,后面做生命游戏、连连看、迷宫寻路,都会遇到一模一样的“八邻域遍历 + 边界判断”模式,扫雷只是第一次遇到它的地方。
2.3 翻开逻辑:递归泛洪与边界安全
扫雷最有技术含量的部分,是点到数字为 0 的空格时,要自动向外扩展翻开,扩展过程中可能又碰到新的 0 格,继续翻。这是典型的泛洪填充算法,DFS 递归实现最直观:
void CMineGame::RevealCell(int row, int col) { if (!IsValid(row, col)) return; CellInfo& cell = m_board[row][col]; if (cell.bRevealed || cell.bFlagged || cell.bMine) return; cell.bRevealed = TRUE; m_nOpened++; if (cell.nAdjMine == 0) { for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; RevealCell(row + dr, col + dc); } } } }这里有两个设计取舍。一是被旗子标记的格子不能被强迫翻开,符合常规玩法;二是递归深度在标准棋盘上最多几十层,完全不用考虑栈溢出。如果日后把棋盘扩大到超大尺寸,再改成 BFS 队列写法也不迟,逻辑等价,只是把“递归函数”换成“while 队列非空”。
胜利判定同样简单:m_nOpened达到“总格子数 - 雷数”即胜利。
3. MFC 界面与交互实现
3.1 棋盘自绘:从 GetCellRect 到双缓冲
棋盘不用按钮控件,而是整个客户区自己画。好处是掌控力强,数字颜色、地雷样式、凹凸效果都能自定义。在对话框的 OnPaint 里遍历所有格子:
void CMineSweeperDlg::DrawBoard(CDC* pDC) { for (int r = 0; r < m_game.m_nRow; r++) { for (int c = 0; c < m_game.m_nCol; c++) { CRect rc = GetCellRect(r, c); DrawCell(pDC, r, c, rc); } } }格子的像素尺寸建议用 24×24 或 32×32,格子之间留 1 像素缝隙,效果最接近经典扫雷。坐标映射函数:
CRect CMineSweeperDlg::GetCellRect(int row, int col) { return CRect( m_ptBoardOrigin.x + col * m_nCellSize, m_ptBoardOrigin.y + row * m_nCellSize, m_ptBoardOrigin.x + (col + 1) * m_nCellSize - 1, m_ptBoardOrigin.y + (row + 1) * m_nCellSize - 1 ); }单个格子的绘制逻辑:
void CMineSweeperDlg::DrawCell(CDC* pDC, int row, int col, CRect rc) { const CellInfo& cell = m_game.GetCell(row, col); if (cell.bRevealed) { pDC->FillSolidRect(rc, RGB(220, 220, 220)); if (cell.bMine) pDC->TextOut(rc.left + 8, rc.top + 5, _T("*")); else if (cell.nAdjMine > 0) { CString strText; strText.Format(_T("%d"), cell.nAdjMine); pDC->TextOut(rc.left + 8, rc.top + 5, strText); } } else { pDC->Draw3dRect(rc, RGB(255, 255, 255), RGB(128, 128, 128)); if (cell.bFlagged) pDC->TextOut(rc.left + 8, rc.top + 5, _T("F")); else if (cell.bQuestion) pDC->TextOut(rc.left + 8, rc.top + 5, _T("?")); } }直接用*、F、?这些字符是临时方案,真实项目可以换成字体或位图,但先把逻辑跑通最重要。另一个必须处理的是闪烁问题:如果每次鼠标点击都全棋盘Invalidate(),画面会明显闪。我建议用一个“内存 DC 双缓冲”方案,先把全部格子画到内存位图,再一次性BitBlt到屏幕:
void CMineSweeperDlg::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, m_rcBoard.Width(), m_rcBoard.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); DrawBoard(&memDC); dc.BitBlt(m_rcBoard.left, m_rcBoard.top, m_rcBoard.Width(), m_rcBoard.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); bmp.DeleteObject(); }这套双缓冲代码在任何 GDI 绘图项目里都能复用,建议单独收藏。
3.2 鼠标消息:左键翻开、右键标旗的三态循环
鼠标处理是整个交互核心。对话框需要响应WM_LBUTTONDOWN、WM_RBUTTONDOWN。先做坐标换算:
void CMineSweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { int row, col; if (!ScreenToCell(point, row, col)) { CDialogEx::OnLButtonDown(nFlags, point); return; } if (m_game.HandleLeftClick(row, col)) { // 游戏结束或胜利,停止计时 KillTimer(1); // 可以弹出结果提示 } InvalidateRect(GetCellRect(row, col)); CDialogEx::OnLButtonDown(nFlags, point); }ScreenToCell里最容易踩坑的点:鼠标消息传给我们的CPoint是客户区坐标,不需要再调ClientToScreen,但前提是棋盘绘制原点m_ptBoardOrigin也基于客户区。另外必须判断点击是否落在棋盘矩形内,否则用户点到窗口边上的空白区会算出非法行列号,直接数组越界。
BOOL CMineSweeperDlg::ScreenToCell(CPoint point, int& row, int& col) { if (!m_rcBoard.PtInRect(point)) return FALSE; col = (point.x - m_ptBoardOrigin.x) / m_nCellSize; row = (point.y - m_ptBoardOrigin.y) / m_nCellSize; return TRUE; }右键标记我推荐做成三态循环:无标记 → 旗子 → 问号 → 无标记。标旗时剩余雷数减 1,取消时加回。这里有个细节:在RevealCell中已经限制了“被旗子标记的格子不会被翻开”,所以用户先标旗再反悔、或者左键误点到旗子时,状态不会乱。
3.3 游戏状态机与计时刷新
一个扫雷程序至少包含准备中、游戏中、胜利、失败四个状态。我建议在CMineGame里放一个枚举:
enum GameState { STATE_READY, STATE_PLAYING, STATE_WIN, STATE_LOSE };所有操作进入前先判断状态。比如游戏失败后,点击棋盘的任何格子都不该再改变数据;胜利后同样锁死。这个状态机用 switch 分支处理非常清晰,也方便以后加“暂停”状态。计时用SetTimer(1, 1000, nullptr),在OnTimer里更新显示,注意只在STATE_PLAYING时递增秒数。重新开局时KillTimer、清空棋盘、重置标签计数器,再重新布雷。
4. 核心源码框架与工程配置
4.1 类职责划分:逻辑层与界面层解耦
项目文件不复杂,核心就两个类。CMineGame是游戏逻辑层,不依赖任何 MFC 界面类,包含棋盘数据、布雷、翻开、标记、胜利判断,所有操作都以成员函数形式暴露。CMineSweeperDlg是对话框窗口,负责自绘、鼠标消息、定时器、重新开始按钮。两个类之间的接口很小:对话框拿到行列号,调用CMineGame的HandleLeftClick/HandleRightClick,再通过GetCell读取格子状态来刷新画面。
这个“界面和逻辑分层”的习惯,强烈建议从第一个小游戏就开始养。因为一旦逻辑层不依赖界面,你就能单独写一个控制台测试程序对算法反复验证,甚至以后用 Qt 或者网页版本复刻同一个逻辑层,成本都很低。
4.2 在 Visual Studio 2017 中创建 MFC 游戏工程
创建步骤很简单:
- 新建项目 → Visual C++ → MFC 应用程序。
- 应用程序类型选“基于对话框”,其余保持默认。
- 项目属性 → 常规 → 字符集,确认是“使用 Unicode 字符集”,避免中文乱码。
- 在对话框资源编辑器里,删除默认控件,添加一个“重新开始”按钮和两个静态文本,分别显示剩余雷数和已用时间。
- 为对话框类添加
WM_PAINT、WM_LBUTTONDOWN、WM_RBUTTONDOWN、WM_TIMER的消息处理函数。 - 把
CMineGame作为对话框类的成员变量,在OnInitDialog里初始化m_nRow、m_nCol、m_nMineCount并调用ResetGame()。
这里提醒一句:MFC 向导生成的代码版本比较多,不同 VS 版本生成的文件名或函数签名略有差异,但总体流程一致。如果在资源编辑器里找不到类向导,直接右键对话框类 → 属性 → 消息图标,也能快速添加消息处理函数。
4.3 发布运行:VC++ 运行库版本与位数匹配
编译成功后,把 exe 拷到别的机器上运行,经常遇到“缺少 VCRUNTIME140.dll”之类的报错。这是因为程序依赖了 VC++ 运行库,目标机器上没装对应组件。解决办法有两个方向:
- 动态链接运行库:在目标机器上安装对应版本的 Visual C++ Redistributable。常见的是 VS2015-2022 一体包,分 x86 和 x64。这里最容易踩坑的是位数不匹配:编译出的程序是 x86 就必须装 x86 版本运行库,x64 就装 x64。
- 静态链接运行库:在项目属性 → C/C++ → 代码生成里改“运行库”为“多线程 /MT”,exe 会变大,但不再依赖外部运行库文件。
做安装包时,把运行库安装程序一起打进去是最省心的方案。网上偶尔有人遇到的“vc++ runtime repair tool”,本质就是检查并修复这些 DLL 文件缺失、被覆盖、版本冲突的问题。对开发者来说,看好位数、选对版本、静态或动态二选一,基本用不到这类修复工具。
5. 常见问题排查与扩展方向
5.1 排查实录:三个必踩的坑
我把自己实测中遇到最多的三个问题整理成速查表:
| 症状 | 原因 | 解法 |
|---|---|---|
| 点击边缘格子后崩溃 | 八邻域遍历没做边界检查 | 所有邻域操作先调IsValid,见 2.3 节 |
| 鼠标点击位置对不上格子 | 拿到屏幕坐标直接当客户区坐标用 | 确保CPoint已经基于客户区,棋盘原点偏移正确 |
| 翻动时界面闪烁严重 | 整窗口频繁Invalidate()导致反复重绘 | 双缓冲,见 3.1 节代码,或只InvalidateRect(GetCellRect(row,col)) |
另外还有一个很隐蔽的问题:srand用time(nullptr)做种子后,如果同一秒内连续两次ResetGame(),第二次布雷会得到和第一次完全一样的布局,测试时很难暴露随机性不足。所以srand只在程序启动时调用一次,重开不用重复设种子。
5.2 从经典到进阶:扫雷还能怎么改
代码跑通之后,扩展空间非常大。我身边的朋友拿这套源程序做过的改造包括:三种难度切换(初级 9×9/10 雷、中级 16×16/40 雷、高级 30×16/99 雷)、排行榜持久化、自定义皮肤、音效,还有人把CMineGame抽取成独立 DLL 复用,直接跨进了软件工程的领域。
也有朋友顺着扫雷里的 GDI 绘制,去研究图像处理程序,像《VC++图像处理程序设计》那类书里的缩放、灰度化、边缘检测都是同一个套路:先有像素数据,再通过 GDI 画出来,跟扫雷画格子的原理是一样的。从一个小游戏出发,能延伸出这么多岔路,也是我推荐认真做一遍扫雷的原因。
最后聊一点个人体会:我刚写扫雷时,上面几个坑一个不落全踩过,尤其是边缘数组越界,调了一晚上才发现是少写了一个边界判断。能把扫雷这种小项目写顺手,后面面对真正的业务系统,至少不会被消息处理、数据建模和状态管理这些基本功卡住。这套源程序的价值不在于代码本身有多高级,而在于它把 Windows 桌面开发最常见的知识点串成了一条完整链路,走通一遍,就比看十遍书都实在。
本文还有配套的精品资源,点击获取