
简介这是一份基于MFC框架实现的扫雷游戏完整工程面向正在学习C与Windows GUI编程的开发者尤其适合想通过经典小游戏理解MFC类库与消息机制的初学者。压缩包共54个文件约2.96MB包含6个cpp源文件与8个h头文件承载核心逻辑7个bmp位图、3个wav音效和1个ico图标构成视觉与听觉资源另有dsp、dsw工程文件及exe可执行程序可直接运行或导入VC继续开发。已有146人学习下载。资源完整呈现了窗口管理、鼠标消息映射、CDC图形绘制、资源加载与游戏状态维护等关键环节读者可对照源码理解CWnd、CDC、CObject等类的实际用法并借助现成工程快速调试与二次开发是入门MFC与游戏开发的实用案例。1. 从一份 saolei.rar 说起MFC 扫雷源码到底能拿来干什么很多人第一次接触 Windows 桌面开发都是从一份扫雷源码开始的。saolei.rar 里这套 MFC 实现扫雷核心价值不在于游戏本身而在于它把 MFC 的窗口管理、消息映射、CDC 绘图、资源管理这几块最常被问到的内容压缩进了一个能跑、能改、能断点调试的完整工程里。你拿到的不只是“一个扫雷”而是一套可复用的 MFC 单文档/对话框混合结构参考MineWnd 负责主窗口DlgHero、DlgCustom、DlgNewRecord 三个对话框分别处理英雄榜、自定义难度和记录录入MineDefs.h 里放的是格子状态和雷区常量。适合谁正在学 MFC 教程但看完就忘的人、需要交课程设计但不想从零搭框架的人、以及想找一个体量适中十几个源文件的 C 工程来练手消息映射和 GDI 绘图的人。它不炫技但每一处都能对上 MFC 的经典套路。2. 拆开工程看骨架MineWnd 与消息映射怎么撑起一局扫雷2.1 从 Mine.dsw 到 MineWnd.cpp工程文件各自管什么把 saolei.rar 解压后你会看到一批 VC 6.0 时代的工程文件。别被 .dsw、.dsp、.clw、.ncb、.opt、.aps、.plg 这些后缀吓到它们分工其实很清楚。Mine.dsw 是工作区文件Mine.dsp 是项目文件两者配合决定编译哪些源文件、用什么配置。Mine.clw 是 ClassWizard 的类信息库Mine.ncb 是浏览数据库Mine.opt 保存的是本地 IDE 状态Mine.aps 是资源符号的二进制缓存Mine.plg 是编译日志。真正要读的只有 .h 和 .cpp。文件作用是否要改Mine.dsw / Mine.dsp工作区与项目配置换环境时可能要调MineWnd.h / MineWnd.cpp主窗口类承载雷区绘制与鼠标消息核心逻辑在这里Mine.h / Mine.cpp应用类负责启动和初始化一般不动MineDefs.h雷区常量、格子状态定义调难度时看这里DlgHero / DlgCustom / DlgNewRecord三个辅助对话框改 UI 时动StdAfx.h / StdAfx.cpp预编译头加库时动resource.h / Mine.rc资源 ID 与对话框、菜单、图标加控件时动常见做法是先用 Visual Studio 打开 Mine.dsw让它自动转换工程格式。转换后如果提示fatal error C1083: Cannot open precompiled header file不是代码坏了而是预编译头路径没对上重新生成一次 StdAfx.cpp 即可。这一步是很多新手第一个翻车点以为源码不完整其实只是 IDE 版本差异。2.2 消息映射鼠标左键、右键是怎么变成翻开和插旗的MFC 扫雷的输入处理全靠消息映射。MineWnd 继承自 CWnd 或 CFrameWnd类里会有一张BEGIN_MESSAGE_MAP表把 Windows 的鼠标消息接到自己的成员函数上。典型写法如下// MineWnd.cpp 中的消息映射片段 BEGIN_MESSAGE_MAP(CMineWnd, CWnd) ON_WM_LBUTTONDOWN() // 左键翻开格子 ON_WM_RBUTTONDOWN() // 右键插旗/取消旗 ON_WM_PAINT() // 重绘雷区 ON_WM_LBUTTONUP() END_MESSAGE_MAP() void CMineWnd::OnLButtonDown(UINT nFlags, CPoint point) { // 把像素坐标换算成行列号 int row point.y / CELL_SIZE; int col point.x / CELL_SIZE; if (row 0 row m_nRows col 0 col m_nCols) { RevealCell(row, col); // 翻开逻辑 } CWnd::OnLButtonDown(nFlags, point); }这段代码的关键在坐标换算。CELL_SIZE 是每个格子的像素边长point 是客户区坐标除一下就是行列号。参数 nFlags 里带着 Ctrl、Shift 等修饰键状态如果你要做“双击快速展开周围”这种进阶操作就得在这里判断。逻辑说明MFC 不会自动帮你把点击落到格子上它只给你一个像素点行列换算和边界判断必须自己写。失败时先看point是不是负数或超出客户区再看 CELL_SIZE 和实际绘制用的尺寸是否一致——这两处不一致点击就会整体偏移是血泪经验里最常见的一类 bug。2.3 用 CDC 画雷区OnPaint 里到底该重绘什么扫雷的视觉部分靠 CDC。OnPaint 被触发时你拿到一个 CPaintDC 对象然后按格子状态逐个绘制。常见做法是准备几张位图资源未翻开、已翻开、旗子、雷、数字 1 到 8。资源在 Mine.rc 里以 IDB_ 开头注册用LoadBitmap载入再用CDC::BitBlt或StretchBlt贴到对应矩形。void CMineWnd::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); // 双缓冲减少闪烁 for (int r 0; r m_nRows; r) { for (int c 0; c m_nCols; c) { int x c * CELL_SIZE; int y r * CELL_SIZE; // 根据 m_cells[r][c] 的状态选位图 if (m_cells[r][c].bRevealed) dc.BitBlt(x, y, CELL_SIZE, CELL_SIZE, memDC, 0, 0, SRCCOPY); else dc.BitBlt(x, y, CELL_SIZE, CELL_SIZE, memDC, 0, 0, SRCCOPY); } } }参数说明CreateCompatibleDC建的是内存设备上下文配合位图做双缓冲否则每点一下整个窗口闪一次体验很差。BitBlt的六个参数依次是目标左上角 x、y、宽、高、源 DC、源左上角 x、y、光栅操作码。注意这里只是示意实际工程里每个状态对应不同位图需要先SelectObject把位图选进 memDC。如果你发现格子画出来是黑的先查位图有没有正确载入、SelectObject有没有调用、以及DeleteDC有没有漏——GDI 对象泄漏在 MFC 里不会立刻报错但跑久了会出问题。3. 把扫雷逻辑接进 MFC布雷、翻开、胜负判定与对话框联动3.1 布雷与首点保护随机数怎么用才不坑扫雷的雷区生成通常放在InitBoard或NewGame里。核心是随机选若干个格子标记为雷然后给每个非雷格子算周围雷数。MFC 里用rand()配合srand((unsigned)time(NULL))就行但有两个坑一是首点不能踩雷二是雷数要按难度区分。void CMineWnd::InitBoard(int rows, int cols, int mines) { m_nRows rows; m_nCols cols; m_nMines mines; m_cells.assign(rows, std::vectorCell(cols)); srand((unsigned)time(NULL)); int placed 0; while (placed mines) { int r rand() % rows; int c rand() % cols; if (!m_cells[r][c].bMine) { m_cells[r][c].bMine true; placed; } } // 计算每个格子周围雷数 for (int r 0; r rows; r) for (int c 0; c cols; c) m_cells[r][c].nAround CountAround(r, c); }逻辑说明rand() % rows产生 0 到 rows-1 的行号循环直到放满 mines 个雷。参数 mines 来自难度选择初级常见 9x9 放 10 雷中级 16x16 放 40 雷高级 16x30 放 99 雷——这些数值在 MineDefs.h 里通常有常量定义。首点保护的做法是第一次点击时如果该格是雷就把它挪到另一个非雷格或者重新布雷。很多课程设计版本偷懒不做首点保护结果玩家第一下就炸体验直接崩。CountAround 遍历八邻域注意边界判断别越界访问。3.2 翻开与连锁展开递归展开怎么写才不栈溢出点开一个周围雷数为 0 的格子时要自动展开相邻格子这是扫雷的核心手感。常见做法是递归或队列。递归写起来短但大棋盘上可能栈溢出队列更稳。void CMineWnd::RevealCell(int r, int c) { if (r 0 || r m_nRows || c 0 || c m_nCols) return; Cell cell m_cells[r][c]; if (cell.bRevealed || cell.bFlagged) return; cell.bRevealed true; if (cell.bMine) { GameOver(); return; } if (cell.nAround 0) { // 八邻域递归展开 for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) if (dr ! 0 || dc ! 0) RevealCell(r dr, c dc); } CheckWin(); Invalidate(); // 触发重绘 }参数说明dr、dc 是行、列偏移组合起来覆盖八个方向。Invalidate()让窗口失效并触发 OnPaint这是 MFC 里刷新界面的标准做法。注意递归前先判断bRevealed否则会无限递归。如果你发现点一下整个棋盘全开多半是 nAround 计算错了把所有格子都当成 0。CheckWin 判断已翻开格子数是否等于总格子数减雷数成立就弹胜利对话框。3.3 三个对话框怎么接DlgCustom、DlgHero、DlgNewRecordDlgCustom 负责自定义难度里面通常有行、列、雷数三个编辑框点确定后把值传回主窗口。DlgHero 显示英雄榜DlgNewRecord 在破纪录时弹出让玩家输入名字。MFC 里对话框和主窗口通信靠DoModal返回值加成员变量。void CMineWnd::OnMenuCustom() { CDlgCustom dlg; dlg.m_nRows m_nRows; // 把当前值传进去 dlg.m_nCols m_nCols; dlg.m_nMines m_nMines; if (dlg.DoModal() IDOK) { InitBoard(dlg.m_nRows, dlg.m_nCols, dlg.m_nMines); Invalidate(); } }逻辑说明DoModal阻塞直到对话框关闭返回 IDOK 表示用户点了确定。成员变量在对话框类的头文件里用DDX_Text绑定到编辑框控件这样值会自动同步。参数校验要在 DlgCustom 的OnOK里做比如雷数不能超过行列乘积减一否则 InitBoard 会死循环。DlgNewRecord 和 DlgHero 的联动一般是游戏胜利后算用时如果进前三就弹 DlgNewRecord写入后再刷新 DlgHero 的列表。这套流程是 MFC 对话框编程的典型套路改一改就能用到别的项目上。4. 避坑与排查编译、绘图、逻辑三类问题逐个拆4.1 编译报错预编译头与字符集现象打开 Mine.dsw 后编译报fatal error C1083: Cannot open precompiled header file: Debug/Mine.pch。原因VC 6.0 的工程换到新版 Visual Studio 后预编译头输出路径和配置对不上。解决在项目属性里把“预编译头”改为“不使用预编译头”或者对 StdAfx.cpp 单独执行一次“创建预编译头”。另一个常见报错是error C2664: cannot convert parameter多半是字符集从 MBCS 换成了 Unicode而代码里用的是char*。解决项目属性里把字符集改回“使用多字节字符集”。4.2 界面闪烁与点击偏移现象每点一下整个雷区闪一下或者点击位置和实际翻开的格子差半格。原因闪烁是因为没有双缓冲直接在 CPaintDC 上逐格绘制偏移是因为 CELL_SIZE 和实际位图尺寸不一致或者窗口有边框、菜单栏导致客户区坐标原点不在雷区左上角。解决用内存 DC 做双缓冲先画到 memDC 再一次性 BitBlt 到屏幕坐标换算前用GetClientRect确认客户区大小必要时减去边距。我一般会在 OnPaint 开头打印一次客户区尺寸和行列乘 CELL_SIZE 对一下差几个像素立刻能看出来。4.3 递归展开导致栈溢出现象高级难度下点开一片空白区域程序直接崩。原因递归深度等于空白区域格子数16x30 的棋盘最坏情况递归几百层默认栈空间扛不住。解决把递归改成显式队列或栈用std::vector存待处理坐标循环取出。或者限制递归深度超过阈值就转队列。这个坑在课程设计里很常见因为测试时都用初级 9x9根本触发不了。4.4 资源载入失败与 GDI 泄漏现象格子画出来是黑的或者程序跑一段时间后界面卡死。原因位图资源 ID 写错、LoadBitmap返回 NULL 没检查、SelectObject选进去的位图没选回来、DeleteDC漏调用。解决每次LoadBitmap后断言非空用CDC的SaveDC/RestoreDC成对出现内存 DC 和位图在析构时统一释放。GDI 对象泄漏不会立刻报错但任务管理器里 GDI 对象数会一直涨看到这个信号就要查。4.5 胜利判定漏判或误判现象明明翻完了所有非雷格游戏不结束或者刚点两下就提示胜利。原因CheckWin 里统计的已翻开格子数把旗子也算进去了或者总格子数用了错误的行列值。解决只统计bRevealed !bMine的格子和rows * cols - mines比较。每次 InitBoard 后把计数器归零别用全局变量攒着。5. 进阶玩法把这份 MFC 扫雷改成你自己的练手项目这份源码最大的价值是它足够小小到你可以在一个下午里把它拆完再拼回去。我一般会建议按三条线做进阶第一条是换皮把 Mine.rc 里的位图资源替换成自己画的顺便练资源编辑器和LoadBitmap的配合第二条是加功能比如加一个计时器SetTimer/OnTimer、加一个状态栏显示剩余雷数对应热搜里“mfc状态栏怎么显示”那类问题、或者加一个“禁止拖动窗口大小”的样式在PreCreateWindow里改cs.style第三条是换框架对比把同样的扫雷逻辑用 Qt 重写一遍你会立刻明白 MFC 的消息映射和 Qt 的信号槽各自在解决什么问题这也是“桌面软件开发用 MFC 还是 Qt”这类讨论里最实在的体感来源。验证改动是否成功别只看界面。我习惯在InitBoard后加一段断言雷数等于设定值、每个格子的 nAround 等于实际八邻域雷数、已翻开数为零。这三条过了逻辑基本就稳了。然后再手动跑一局初级、一局中级重点看首点、连锁展开、插旗、胜利判定这四个路径。如果时间允许用rand()固定种子跑一百局自动翻开统计有没有异常这比人眼靠谱。从那以后我每次拿到一份老 MFC 工程都强制先做三件事确认字符集和预编译头配置、把 GDI 对象的创建和释放成对找一遍、用固定种子跑一轮逻辑断言。这三步走完后面改什么都心里有底。希望这份拆解能帮到你少走几个我当年踩过的坑。本文还有配套的精品资源点击获取