VC++扫雷游戏源码详解:MFC与算法实现的完整实践 简介资源是一份基于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 桌面开发最常见的知识点串成了一条完整链路走通一遍就比看十遍书都实在。本文还有配套的精品资源点击获取