资讯动态

MFC扫雷实战:从saolei.rar拆解Win32消息、GDI绘图与递归展开

发布时间:2026/10/1 19:21:16 来源:尧图企业网站定制
简介这份资源是面向C初学者与Windows GUI开发入门者的MFC扫雷游戏完整工程帮助读者通过一个可运行的小项目理解MFC框架如何组织窗口、消息与绘图逻辑。压缩包共54个文件约2.96MB包含6个cpp与8个h源文件、6个obj与6个sbr编译中间文件、7个bmp位图、3个wav音效、1个ico图标及rc资源脚本另有dsp、dsw工程文件与exe可执行程序覆盖从源码到成品的完整链路。已有146人学习下载。读者可从中获得可直接编译运行的VC工程观察CWnd窗口管理、消息映射处理鼠标点击、CDC绘制格子、资源文件加载位图与音效等具体实现并参考DlgHero、DlgCustom、DlgNewRecord等对话框类理解游戏状态与记录管理适合作为学习MFC类库与游戏逻辑结合的实践起点。1. 从 saolei.rar 说起MFC 扫雷到底在练什么很多人第一次看到saolei.rar_MFC实现扫雷_MFC扫雷这个标题脑子里冒出的问题是都 2025 年了为什么还要用 MFC 写扫雷答案很直接——扫雷是检验 Win32 消息机制、GDI 绘图、对话框资源管理这三件事的最小闭环项目。它不像记事本那样涉及大量文本缓冲也不像计算器那样逻辑单薄扫雷同时要求你处理鼠标左键右键的区分、定时器、动态控件创建、二维数组与界面坐标的映射。MFC 作为微软基础类库把这些 Win32 API 封装成CWnd、CDC、CDialogEx等类让你用 C 类的方式去操作窗口。如果你正在学 MFC或者手头有一个老项目的维护需求扫雷是一个能在两三天内跑通全流程的练手标的。这篇文章不讲空泛的 MFC 教程而是把saolei.rar这类工程拆开从建工程、画格子、埋雷、递归展开到避坑一步步说清楚怎么落地。2. MFC 扫雷的工程骨架对话框、消息与绘图2.1 为什么选对话框程序而不是单文档MFC 向导提供三种典型结构单文档SDI、多文档MDI、基于对话框Dialog-based。扫雷的界面是固定大小的网格没有菜单栏、工具栏、状态栏的复杂需求用对话框程序最省事。基于对话框的工程会自动生成一个CDialogEx派生类比如CMyMineDlg你所有的界面逻辑都挂在这个类里。常见做法是在资源视图里拖一个对话框资源设置好宽高然后在上面放一个静态文本或者直接自绘。我一般不会在对话框上摆一堆按钮控件来做格子因为 9×9 就是 81 个按钮16×16 是 256 个控件数量一多消息映射和刷新都会变慢。更稳的方案是只放一个自绘区域用OnPaint统一画格子。选对话框程序的另一个理由是资源编辑器友好。你可以在.rc文件里直接调整对话框的Style去掉WS_THICKFRAME防止用户拖拽改变大小去掉WS_MAXIMIZEBOX避免最大化后格子错位。这些在单文档程序里要改PreCreateWindow麻烦得多。2.2 用 OnPaint 和双缓冲画格子扫雷的格子本质上是矩形区域每个格子有三种状态未翻开、已翻开、插旗。用 GDI 画就是Rectangle加FillSolidRect数字用TextOut或者DrawText。但直接画会遇到闪烁问题因为OnPaint里每次Invalidate都会先擦背景再画格子一多就闪。解决办法是双缓冲先在内存 DC 里画好整张图再一次性BitBlt到屏幕 DC。void CMyMineDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(rect); // 内存 DC 与位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先填充背景 memDC.FillSolidRect(rect, RGB(192, 192, 192)); // 遍历二维数组画格子 for (int row 0; row m_nRows; row) { for (int col 0; col m_nCols; col) { int x col * CELL_SIZE; int y row * CELL_SIZE; DrawCell(memDC, row, col, x, y); } } // 一次性贴到屏幕 dc.BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }这段代码的关键参数是CELL_SIZE我一般设 20 或 24 像素太小了数字看不清太大了屏幕占不下。DrawCell是自定义函数根据m_board[row][col]的状态决定画凸起方块、凹陷方块还是数字。注意CreateCompatibleBitmap必须用屏幕 DC 创建不能用内存 DC否则位图颜色深度不对画出来是黑白的。双缓冲的代价是多占一块内存位图9×9 的格子也就几百 KB可以忽略。2.3 鼠标消息左键翻开右键插旗扫雷的核心交互是鼠标。MFC 对话框默认响应WM_LBUTTONDOWN和WM_RBUTTONDOWN你需要在类向导里添加OnLButtonDown和OnRButtonDown。左键用来翻开格子右键用来插旗或取消旗子。这里有个容易翻车的点右键消息在 MFC 里默认会触发上下文菜单如果你不处理WM_CONTEXTMENU右键可能没反应。解决办法是在OnRButtonDown里直接处理逻辑并确保对话框没有绑定右键菜单。void CMyMineDlg::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) { if (!m_bFlagged[row][col] !m_bRevealed[row][col]) { RevealCell(row, col); Invalidate(); // 触发重绘 } } CDialogEx::OnLButtonDown(nFlags, point); }point是客户区坐标除以CELL_SIZE就得到行列索引。注意边界检查否则点到对话框边缘会数组越界。RevealCell里判断是不是雷是雷就游戏结束不是雷就显示数字如果数字是 0 就递归展开周围八个格子。递归展开是扫雷的灵魂但递归深度在 16×16 里最多也就几十层不会爆栈。3. 埋雷、递归展开与胜利判定逻辑层怎么写3.1 雷区数据结构与随机布雷逻辑层用一个二维数组m_board存每个格子的状态另一个二维数组m_mine存是不是雷。常见做法是int m_board[16][16]0 表示空-1 表示雷1 到 8 表示周围雷数。布雷的时机有两种一种是程序启动就埋好另一种是第一次点击后再埋保证第一下不会踩雷。我推荐后者用户体验好实现也不复杂。void CMyMineDlg::PlaceMines(int safeRow, int safeCol) { srand((unsigned)time(nullptr)); int placed 0; while (placed m_nMineCount) { int r rand() % m_nRows; int c rand() % m_nCols; // 避开第一次点击的格子及其周围 if (m_mine[r][c] 0 !(abs(r - safeRow) 1 abs(c - safeCol) 1)) { m_mine[r][c] 1; placed; } } // 计算每个格子周围的雷数 for (int r 0; r m_nRows; r) { for (int c 0; c m_nCols; c) { if (m_mine[r][c] 1) continue; int count 0; for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) { int nr r dr, nc c dc; if (nr 0 nr m_nRows nc 0 nc m_nCols) count m_mine[nr][nc]; } m_board[r][c] count; } } }m_nMineCount是雷数初级 9×9 设 10中级 16×16 设 40。safeRow和safeCol是第一次点击的位置避开它和周围八格保证第一下点开是一片空白。rand()的随机性在 Windows 上够用但如果你想要更均匀的分布可以用std::mt19937不过扫雷对随机性要求不高没必要上重型武器。3.2 递归展开0 格子的连锁反应当点开的格子周围雷数为 0 时应该自动展开周围八个格子如果周围还有 0继续递归。这个逻辑用递归写最直观但要注意不要重复展开已经翻开的格子否则会无限递归。void CMyMineDlg::RevealCell(int row, int col) { if (row 0 || row m_nRows || col 0 || col m_nCols) return; if (m_bRevealed[row][col] || m_bFlagged[row][col]) return; m_bRevealed[row][col] true; if (m_mine[row][col]) { // 踩雷游戏结束 GameOver(false); return; } if (m_board[row][col] 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); } } }m_bRevealed和m_bFlagged是两个bool数组分别记录翻开和插旗状态。递归的终止条件是越界、已翻开或已插旗。注意在递归前先标记m_bRevealed否则会死循环。这个函数在 16×16 的棋盘上最多递归 256 次性能没问题。3.3 胜利判定翻开格子数等于总格子数减雷数胜利条件很简单所有非雷格子都被翻开。你可以在每次RevealCell后检查m_nRevealedCount是否等于m_nRows * m_nCols - m_nMineCount。如果是就弹出胜利提示并停止计时器。void CMyMineDlg::CheckWin() { int revealed 0; for (int r 0; r m_nRows; r) for (int c 0; c m_nCols; c) if (m_bRevealed[r][c]) revealed; if (revealed m_nRows * m_nCols - m_nMineCount) { KillTimer(TIMER_ID); MessageBox(_T(你赢了), _T(扫雷), MB_OK); } }TIMER_ID是你在OnInitDialog里SetTimer时用的 ID用来更新计时器显示。胜利后记得KillTimer否则计时器还在跑。这个判定每次翻开都遍历整个棋盘9×9 也就 81 次循环完全不影响性能。4. 避坑与排查MFC 扫雷最容易翻车的五个地方4.1 现象右键插旗没反应反而弹出上下文菜单原因MFC 对话框默认处理WM_CONTEXTMENU右键会触发系统菜单。解决在OnRButtonDown里处理插旗逻辑并重写OnContextMenu为空函数或者直接在对话框属性里去掉WS_CONTEXTMENU样式。我一般是在OnRButtonDown里直接return不调用基类。4.2 现象格子画出来闪烁严重拖动窗口时更明显原因OnPaint里先擦背景再画格子两次绘制之间屏幕刷新。解决用双缓冲先在内存 DC 画完再BitBlt。另外在OnEraseBkgnd里直接返回TRUE阻止系统擦背景。4.3 现象递归展开时程序卡死或崩溃原因递归没有正确终止或者数组越界。解决在RevealCell开头做边界检查先标记m_bRevealed再递归。如果还是崩检查m_board和m_mine的维度是否一致m_nRows和m_nCols是否在OnInitDialog里正确初始化。4.4 现象第一次点击就踩雷原因布雷在第一次点击之前就完成了没有避开点击位置。解决把PlaceMines放到第一次OnLButtonDown里调用用m_bFirstClick标志位控制。第一次点击时只记录位置不翻开等布雷完成后再调用RevealCell。4.5 现象计时器不更新或更新太快原因SetTimer的时间间隔单位是毫秒设 1000 才是 1 秒。解决在OnTimer里累加秒数更新静态文本控件。注意OnTimer的nIDEvent要匹配别和其他定时器冲突。5. 进阶技巧用资源文件换肤与状态持久化扫雷写完之后如果想再往前走一步有两个方向值得做换肤和存档。换肤的本质是把DrawCell里的FillSolidRect换成StretchBlt从资源里加载 BMP 图片。你可以在.rc里导入几张 20×20 的小图分别对应未翻开、旗子、雷、数字 1 到 8。加载时用LoadBitmap或者CImage注意CImage需要atlimage.h在 VS 里默认可用。void CMyMineDlg::DrawCell(CDC* pDC, int row, int col, int x, int y) { if (!m_bRevealed[row][col]) { // 未翻开画凸起效果 pDC-Draw3dRect(x, y, CELL_SIZE, CELL_SIZE, RGB(255,255,255), RGB(128,128,128)); if (m_bFlagged[row][col]) { // 画旗子图标 pDC-DrawIcon(x 2, y 2, m_hFlagIcon); } } else { // 已翻开画凹陷效果 pDC-Draw3dRect(x, y, CELL_SIZE, CELL_SIZE, RGB(128,128,128), RGB(255,255,255)); if (m_mine[row][col]) { pDC-DrawIcon(x 2, y 2, m_hMineIcon); } else if (m_board[row][col] 0) { CString str; str.Format(_T(%d), m_board[row][col]); pDC-SetTextColor(GetNumberColor(m_board[row][col])); pDC-SetBkMode(TRANSPARENT); pDC-DrawText(str, CRect(x, y, x CELL_SIZE, y CELL_SIZE), DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } }Draw3dRect是 MFC 自带的画 3D 边框函数两个颜色参数分别是左上和右下交换一下就能做出凸起和凹陷的视觉效果。GetNumberColor根据数字返回不同颜色经典扫雷里 1 是蓝色、2 是绿色、3 是红色这个习惯保留下来用户看着舒服。状态持久化则是把当前棋盘、雷区、计时、插旗状态序列化到文件。MFC 有CArchive和Serialize函数你可以在CDialogEx派生类里重写Serialize把m_board、m_mine、m_bRevealed、m_bFlagged四个数组写进去。注意CArchive需要配合CFile使用写的时候用运算符读的时候用。这个功能对扫雷来说有点重但如果你想把项目改造成可保存进度的版本这是最标准的做法。我自己的习惯是先把双缓冲和递归展开写稳再去折腾换肤。因为换肤只是画法变了逻辑层不动如果逻辑有 bug换肤后更难排查。另外saolei.rar这类工程往往是在 VS 2010 或更早版本创建的如果你用 VS 2022 打开注意把Platform Toolset改成v143字符集改成Unicode否则_T宏和CString会报一堆错。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑