MFC扫雷实战:从对话框工程到GDI双缓冲与递归展开

发布时间:2026/10/1 13:22:49
MFC扫雷实战:从对话框工程到GDI双缓冲与递归展开 简介这份资源是基于MFC框架实现的扫雷游戏完整工程面向具备一定C基础、希望借助经典案例入门Windows GUI开发或课程设计的学习者。项目将扫雷核心逻辑与MFC的窗口管理、消息映射、CDC图形绘制、资源管理及状态维护等机制结合帮助读者理解如何把Windows API封装思路落到实际游戏开发中。压缩包共54个文件约2.96MB包含h头文件、cpp源文件、bmp位图、wav音效、ico图标、rc资源脚本以及dsp、dsw工程文件另附obj、pdb等编译中间产物源码与可执行文件兼备便于直接运行或二次修改。目前已有146人学习下载。读者可从中获取完整的MFC扫雷工程结构、消息处理与绘图实现范例并对照源码梳理游戏状态切换、资源加载与界面搭建思路是学习MFC与游戏开发起步的实用参考。1. 从 saolei.rar 说起MFC 扫雷到底在练什么很多人第一次看到saolei.rar_MFC实现扫雷_MFC扫雷这个标题第一反应是「扫雷不是 Windows 自带的小游戏吗有什么好写的」。但真正动手用 MFC 复刻一遍就会发现它几乎是 MFC 里性价比最高的练手项目一个对话框程序把菜单、绘图、定时器、消息映射、资源管理全串了一遍代码量不大却能把 MFC 四大类CWinApp、CFrameWnd/CDialog、CView、CDocument 那一套里最常用的部分摸清楚。它解决的不是「怎么玩游戏」而是「怎么用 MFC 把一块二维网格画出来、响应鼠标、维护状态、控制节奏」。适合已经会一点 C、想从控制台转向 Windows 界面的人也适合想复习 GDI 绘图和消息机制的老手。下面按「先立住原理、再动手复现、最后讲坑」的顺序把这份 MFC 扫雷从零拆到能跑。2. 拆解 MFC 扫雷从对话框工程到网格数据结构2.1 为什么选对话框工程而不是单文档MFC 扫雷最常见的做法是「基于对话框的应用程序」而不是单文档/多文档。原因很直接扫雷只有一个固定大小的棋盘没有文档概念不需要 CView/CDocument 那套序列化和视图框架。对话框工程自带一个CDialogEx派生类资源编辑器里拖一个对话框就能当主窗口消息映射直接挂在对话框类上省掉大量框架代码。在 Visual Studio 里新建项目时选「MFC 应用程序」应用程序类型选「基于对话框」其余默认即可。如果安装 VS 时没勾 MFC 组件会提示找不到 afxwin.h这时候需要回到 Visual Studio Installer 里补装「使用 C 的桌面开发」下的 MFC 选项离线环境则要提前把 MFC 组件包准备好否则工程建不出来。工程建好后资源视图里会有一个IDD_XXX_DIALOG的对话框模板。扫雷的棋盘不建议用一堆按钮控件拼那样控件数量爆炸、刷新也慢。正确做法是对话框上只放一个静态文本或直接留空棋盘完全靠OnPaint里用 GDI 自己画。这样格子数量、大小、样式都自己说了算。2.2 棋盘数据结构一维数组还是二维数组扫雷的核心状态有三层格子是否被翻开、是否是雷、周围雷数。常见做法是用一个结构体数组或者几个并行的数组。我一般用一个一维数组配宽高换算缓存友好遍历也方便// MineCell 表示单个格子的状态 struct MineCell { bool isMine; // 是否是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否插旗 int adjacent; // 周围 8 格雷数0~8 }; class CMineBoard { public: int m_cols 9; // 列数 int m_rows 9; // 行数 int m_mineCount 10; // 雷数 std::vectorMineCell m_cells; // 长度 cols * rows MineCell At(int r, int c) { return m_cells[r * m_cols c]; } bool InRange(int r, int c) const { return r 0 r m_rows c 0 c m_cols; } };这里At用r * m_cols c做一维到二维的映射比vectorvector少一层指针跳转也避免了每行长度不一致的隐患。InRange是后面统计周围雷数和递归展开时反复要用的边界判断单独抽出来能少写很多重复的 if。参数上初级 9×9 配 10 雷中级 16×16 配 40 雷高级 16×30 配 99 雷这是 Windows 扫雷的经典配置。雷数不要超过格子总数的一半否则第一次点击后靠「首点必不是雷」的重新布雷逻辑会很难保证周围有足够空格展开。2.3 布雷与首点保护别让第一下就炸新手最容易翻车的地方是布雷时机。如果程序一启动就把雷布好玩家第一下就可能点到雷体验极差。常见做法是「延迟布雷」第一次左键点击时再随机布雷并且把点击格及其周围 8 格排除在雷区之外保证首点一定展开一片。void CMineBoard::PlaceMines(int safeR, int safeC) { std::vectorint candidates; candidates.reserve(m_rows * m_cols); for (int r 0; r m_rows; r) for (int c 0; c m_cols; c) { // 首点及其 8 邻域不布雷 if (abs(r - safeR) 1 abs(c - safeC) 1) continue; candidates.push_back(r * m_cols c); } // Fisher-Yates 洗牌后取前 m_mineCount 个 for (int i (int)candidates.size() - 1; i 0; --i) { int j rand() % (i 1); std::swap(candidates[i], candidates[j]); } for (int i 0; i m_mineCount i (int)candidates.size(); i) { m_cells[candidates[i]].isMine true; } // 统计每格周围雷数 for (int r 0; r m_rows; r) for (int c 0; c m_cols; c) { if (At(r, c).isMine) continue; int cnt 0; for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; int nr r dr, nc c dc; if (InRange(nr, nc) At(nr, nc).isMine) cnt; } At(r, c).adjacent cnt; } }candidates先把安全区排除再用 Fisher-Yates 洗牌保证随机均匀比「随机取点、重复就重试」在雷数接近上限时更稳。统计周围雷数用双重-1..1循环注意跳过(0,0)自身并用InRange挡边界。rand()在正式项目里可以换成std::mt19937但扫雷这种场景rand()配合srand((unsigned)time(nullptr))足够。3. 用 GDI 把棋盘画出来OnPaint 与双缓冲3.1 在 OnPaint 里画格子而不是堆控件棋盘绘制全部放在对话框的OnPaint里。每个格子根据状态决定填充色和文字未翻开是凸起的灰色翻开是平的浅灰雷是红底黑点旗子是红三角。文字用DrawText居中画周围雷数并按 1 到 8 给不同颜色这是扫雷的经典视觉约定。void CMineDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); // 双缓冲先画到内存 DC再一次性贴回避免闪烁 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOld memDC.SelectObject(bmp); memDC.FillSolidRect(rcClient, RGB(192, 192, 192)); const int cell 24; // 每格像素 for (int r 0; r m_board.m_rows; r) { for (int c 0; c m_board.m_cols; c) { int x c * cell, y r * cell; CRect rcCell(x, y, x cell, y cell); DrawCell(memDC, r, c, rcCell); } } dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }CreateCompatibleDC加CreateCompatibleBitmap是 MFC 里最标准的双缓冲写法。直接在CPaintDC上逐格画格子一多就会闪鼠标移动时尤其明显。内存 DC 画完再BitBlt一次贴回闪烁基本消失。cell设成 24 是经验值太小文字挤太大棋盘超出对话框配合GetClientRect可以动态算格子大小让棋盘自适应。3.2 鼠标消息映射左键翻开、右键插旗扫雷的交互只有两种左键翻开、右键插旗。在对话框类里用类向导添加WM_LBUTTONDOWN和WM_RBUTTONDOWN的响应函数把屏幕坐标换算成行列。void CMineDlg::OnLButtonDown(UINT nFlags, CPoint point) { int c point.x / m_cellSize; int r point.y / m_cellSize; if (!m_board.InRange(r, c)) return; if (!m_bStarted) { // 首点才布雷 m_board.PlaceMines(r, c); m_bStarted true; SetTimer(TIMER_ID, 1000, nullptr); // 启动计时 } RevealCell(r, c); Invalidate(); // 触发重绘 CDialogEx::OnLButtonDown(nFlags, point); }坐标换算是point.x / m_cellSize注意point是客户区坐标如果对话框有边框或滚动条要相应偏移。Invalidate()标记整窗重绘配合前面的双缓冲不会闪。右键同理只是把isFlagged取反并且已翻开的格子不允许插旗。这里有个细节Invalidate()不带参数是整窗重绘格子多的时候可以只InvalidateRect单个格子但扫雷棋盘不大整窗重绘更省心。3.3 递归展开0 格自动扩散怎么写才不爆栈翻开一个周围雷数为 0 的格子时要自动展开周围 8 格这是扫雷的核心手感。常见写法是递归但棋盘大、空白多的时候递归深度可能上千虽然一般不至于爆栈稳妥起见可以用显式队列做广度优先。void CMineDlg::RevealCell(int r, int c) { std::queuestd::pairint,int q; q.push({r, c}); while (!q.empty()) { auto [cr, cc] q.front(); q.pop(); MineCell cell m_board.At(cr, cc); if (cell.isRevealed || cell.isFlagged) continue; cell.isRevealed true; if (cell.isMine) { GameOver(false); return; } if (cell.adjacent 0) { for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) { int nr cr dr, nc cc dc; if (m_board.InRange(nr, nc) !m_board.At(nr, nc).isRevealed) q.push({nr, nc}); } } } CheckWin(); }用queue做 BFS每个格子最多入队一次靠isRevealed去重。if (cell.isMine)说明踩雷直接结束游戏。adjacent 0才继续扩散非零格只翻开不扩散符合扫雷规则。CheckWin统计已翻开格数是否等于总格数减雷数是则胜利。这套逻辑比递归好调试也不会因为棋盘大而担心栈深度。4. 避坑与排查MFC 扫雷最容易翻车的 5 个点4.1 现象界面一片白格子根本没画出来原因通常是OnPaint没被正确调用或者画到了错误的 DC 上。常见误用是自己在OnInitDialog里GetDC画结果窗口一刷新就没了。解决所有绘制逻辑必须放在OnPaint或OnDraw里靠Invalidate/InvalidateRect触发。另外检查对话框类是否真的挂上了WM_PAINT消息映射用类向导添加最稳。4.2 现象鼠标点下去没反应或者坐标偏一格原因多半是坐标换算没考虑客户区偏移或者用了屏幕坐标。OnLButtonDown的point是客户区坐标但如果对话框有标题栏、边框客户区原点在边框内侧一般不用额外减。偏一格通常是整除边界问题比如点在格子右边界上x / cell会落到下一格。解决换算后加InRange校验必要时用(x - 1) / cell或对边界单独判断。4.3 现象程序跑一会儿内存一直涨MFC 里最常见的泄漏源是 GDI 对象没释放。双缓冲里CreateCompatibleDC、CreateCompatibleBitmap、SelectObject都要配对释放SelectObject换回旧对象DeleteDC/DeleteObject在对象析构时自动做但如果把CDC/CBitmap定义成成员变量反复创建就会漏。解决双缓冲对象定义成局部变量靠析构自动回收或者用CDC::FromHandle时注意不要重复创建。调试时可以在OnInitDialog前后打GetGuiResources看 GDI 句柄数。4.4 现象右键插旗后左键还能翻开原因是翻开逻辑没检查isFlagged。RevealCell里第一行if (cell.isRevealed || cell.isFlagged) continue;就是挡这个的。如果漏了插旗的格子会被翻开逻辑就乱了。解决所有改变格子状态的入口都先判断当前状态插旗、翻开、标问号互斥。4.5 现象计时器停不下来游戏结束后还在走SetTimer之后必须在游戏结束踩雷或胜利时KillTimer否则计时器继续触发秒数一直涨。解决在GameOver和CheckWin的胜利分支里都调用KillTimer(TIMER_ID)并把m_bStarted复位。另外对话框关闭时OnDestroy里也补一次KillTimer防止资源残留。5. 进阶技巧把扫雷做成可配置、可验证的版本基础版跑通后真正拉开差距的是可配置和可验证。我一般会把难度做成菜单项切换时重建棋盘并重置计时这样一份代码能覆盖初级到高级。棋盘尺寸和雷数从菜单命令读重建时m_cells.assign(cols * rows, MineCell{})清空状态再Invalidate。计时用SetTimer每秒加一显示在对话框顶部的静态文本上剩余雷数用「总雷数减已插旗数」实时更新。验证方面别只靠手点。可以加一个「调试模式」菜单按下后把所有雷的位置用不同颜色标出来方便确认布雷和周围雷数统计是否正确。更彻底的做法是把CMineBoard的逻辑和界面解耦写一个控制台测试随机布雷一万次检查每格adjacent是否等于实际周围雷数检查首点保护是否生效。逻辑层不依赖 MFC用assert就能跑比在界面里肉眼找 bug 快得多。// 控制台自测验证 adjacent 统计正确性 for (int iter 0; iter 10000; iter) { CMineBoard b; b.m_cols 9; b.m_rows 9; b.m_mineCount 10; b.m_cells.assign(81, MineCell{}); b.PlaceMines(4, 4); for (int r 0; r 9; r) for (int c 0; c 9; c) { if (b.At(r, c).isMine) continue; int cnt 0; for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) { if (!dr !dc) continue; int nr r dr, nc c dc; if (b.InRange(nr, nc) b.At(nr, nc).isMine) cnt; } assert(cnt b.At(r, c).adjacent); } }这段自测把布雷和统计逻辑单独拎出来跑不涉及任何界面几秒钟就能验证一万局。参数上iter可以按需调大PlaceMines(4, 4)固定首点方便复现。踩过的坑是一开始把adjacent统计写在RevealCell里导致每次翻开都重算既慢又容易和布雷时的统计不一致后来统一在PlaceMines里算一次逻辑就干净了。做 MFC 扫雷最大的体会是界面代码和逻辑代码一定要分家否则调一个边界 bug 要在消息映射里绕半天。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询