简介:这是一份基于MFC框架实现的经典扫雷游戏完整工程,面向具备C++基础、正在学习MFC桌面应用开发或需要课程设计参考的读者。项目将经典扫雷逻辑封装于游戏核心类,并借助MFC文档视图结构完成界面绘制与交互,模仿了Windows经典扫雷的视觉风格。资源包共82个文件,压缩包大小22.49MB,包含sln/vcxproj工程文件、10个h头文件与8个cpp源码文件,可完整打开编译;附带2个exe可执行程序,能直接运行查看效果;另有bmp位图、ico图标及调试生成的pdb、obj等中间文件,方便对照分析程序结构。目前已有207人学习浏览,适合作为MFC项目入门与综合实践的参考。通过研读MineDlg、MineView、Game、Cell等类的关系,可理解消息映射、对话框交互、游戏逻辑与界面刷新等关键实现,也可以在此基础上扩展难度、计时或排行榜功能,提升MFC实战能力。
1. 基于MFC的扫雷程序设计:不是算法难,是窗口这座山要先翻过去
第一次把 MFC 扫雷跑起来时,我对着那个粗糙的灰色对话框发了好一会儿呆:地雷算法用 C 语言写也就几十行,可一旦套上 MFC 这套窗口框架,按钮刷新、消息映射、资源加载全变成了一座座小山。做课程设计、程序设计实践或者练手项目的人,多半卡在同一个地方——不是不会布雷,而是不知道左键点到格子上,代码该怎么从 Windows 的消息循环走到自己的雷区数组里。这篇笔记就从工程落地的角度,把基于 MFC 的扫雷程序设计拆开:先定整体架构,再写核心算法,最后处理界面交互和那些藏在细节里的坑。适合正在做 C++ 课程设计、想完整走一遍 MFC 开发流程的读者,也适合想把自己写的扫雷升级成能稳定交货的从业者。
2. 总体设计与消息流转:为什么先选对话框程序,再谈布雷算法
2.1 三种 MFC 方案选型:按钮阵列、文档视图、纯自绘
基于 MFC 做扫雷,常见做法有三条路线,我建议你先想清楚用哪条,再动手建工程,因为中途换架构的代价比想象中大。
第一种是对话框加按钮阵列:直接在对话框模板上摆几十个 CButton 控件,或者用 Create 动态生成。这是课程设计里出现频率最高的做法,代码直白,Button 自带的按下弹起效果能省掉不少绘制工作。缺点是雷区稍大(比如 30x16 的专家级)就有近 500 个控件,对话框资源创建慢,运行内存也难看。
第二种是文档视图结构:用 CView 的 OnDraw 画格子,消息用 OnLButtonDown 处理。这种方式跑大雷区很稳,重绘逻辑集中,但需要你理解 MFC 的 Document/View 架构,对刚入门的人来说,ClassWizard 生成的一堆文件容易让人发懵。
第三种是对话框加自绘:对话框上不放几百个按钮,只放一个静态控件或直接在主窗口客户区绘制,双击判定用坐标换算实现。我一般会用这条路做完整项目——扫雷的“翻开”“插旗”“问号”三种状态,用按钮控件去表达会别扭,自绘反而简单。
表格对比一下三条路线的取舍:
| 方案 | 实现难度 | 重绘控制力 | 适合场景 |
|---|---|---|---|
| 对话框 + CButton 阵列 | 最容易 | 弱,按钮样式切入麻烦 | 课程设计速成 |
| 文档视图 + OnDraw | 中等 | 强 | 雷区大、想学 MFC 架构 |
| 对话框 + 自绘 | 中等偏上 | 强 | 完整项目、界面要好看 |
选对话框还是文档视图,核心判断标准是:你打算在界面层花多少时间。如果目标是快点把游戏逻辑跑通,就选对话框;如果想把扫雷当作自己第一个“完整 Windows 程序”来做,自绘更值得投入。
2.2 消息路由与格点映射:从鼠标坐标到数组下标的换算
无论选哪条路线,都要处理同一个问题:鼠标点下去,程序怎么知道点的是第几行第几列。
如果是 CButton 阵列,每个按钮控件有自己的 ID,资源编辑器里把 ID 编号和行列约定好,用 ON_CONTROL_RANGE 或者 OnBnClicked 处理时把 ID 减掉起始值就能拿到行列。如果是自绘,就需要做坐标换算:
// 已知雷区左上角基准坐标为 (m_rectBoard.left, m_rectBoard.top) // 每格宽度 m_nCellSize,格子间距 m_nGap int nRow = (point.y - m_rectBoard.top) / (m_nCellSize + m_nGap); int nCol = (point.x - m_rectBoard.left) / (m_nCellSize + m_nGap); // 越界保护 if (nRow < 0 || nRow >= m_nRows || nCol < 0 || nCol >= m_nCols) { return; // 点到边界外,直接忽略 }这段逻辑是点击准确性的关键。要注意的是,m_nCellSize + m_nGap必须和绘制时画格子使用的尺寸完全一致,否则会出现“点到格子上方空白区域却触发了下一格”的错位。血泪经验是:绘制时如果用Rectangle画格子边线,边线的 1 像素也会让人产生偏差感,所以设计尺寸时最好把格子间距留 2 像素以上,鼠标容错会好很多。
另一个隐蔽问题是窗口缩放。如果对话框允许用户拉伸,坐标换算会失效,需要在OnSize里重新计算m_rectBoard并限制最小尺寸。不想处理这个麻烦,就把对话框的Border属性设为固定边框,禁用最大化按钮,这是常见做法。
2.3 最小工程骨架:两类文件,把算法和界面分开
我见过不少扫雷项目的雷区算法全写在 Dlg 类里,几百行代码混在一起,改一个功能要翻半天。更推荐的做法是把游戏逻辑抽成一个独立类,比如CMineField,界面层只管消息和绘制。
// MineField.h #pragma once class CMineField { public: CMineField(); void Reset(int nRows, int nCols, int nMines, int nSafeRow, int nSafeCol); bool IsMine(int nRow, int nCol) const; int CountAdjacentMines(int nRow, int nCol) const; void OpenCell(int nRow, int nCol); bool IsWin() const; bool IsLose() const; private: int m_nRows, m_nCols, m_nMines; std::vector<std::vector<bool>> m_bMine; // 雷图 std::vector<std::vector<int>> m_nHint; // 数字图 std::vector<std::vector<int>> m_nState; // 0未翻开 1翻开 2插旗 3问号 };这个类里不包含任何 CWnd 或 CDC 相关的东西,保证算法可以在纯控制台环境里测试——这一点在第 6 章会讲到,它价值很大。界面类CMineSweeperDlg持有CMineField的实例,在初始化时调用Reset,在鼠标消息里调用对应操作,然后Invalidate触发重绘。
这样做的好处是,算法逻辑独立,单元测试好写,而且如果哪天你想把这个扫雷从 MFC 迁移到 Qt 或者 Win32,算法部分一行都不用改。工程文件组织上,MineField.h/.cpp放算法,MineSweeperDlg.h/.cpp放界面,Resource.h管资源 ID,足够清晰了。
提示:UML 类图或者其它结构说明,思维负担很重且未必有人看。反倒是那个“点击坐标换算”的公式值得在代码注释里写清楚,因为它是界面和算法之间唯一的桥梁。
3. 雷区生成与展开算法:首点避雷、数字计算与泛洪展开
3.1 布雷算法与首点避雷
布雷最简单的实现是“随机坐标 + 去重”,但有一个细节必须处理:首次点击绝不能踩雷。否则用户第一下就炸开失败,体验非常糟糕。
void CMineField::Reset(int nRows, int nCols, int nMines, int nSafeRow, int nSafeCol) { m_nRows = nRows; m_nCols = nCols; m_nMines = nMines; // 初始化容器 m_bMine.assign(m_nRows, std::vector<bool>(m_nCols, false)); m_nHint.assign(m_nRows, std::vector<int>(m_nCols, 0)); m_nState.assign(m_nRows, std::vector<int>(m_nCols, 0)); // 布雷时排除安全点及其 8 邻域,避免首点出现数字空格 int nPlaced = 0; std::srand(static_cast<unsigned int>(std::time(nullptr))); while (nPlaced < nMines) { int r = std::rand() % m_nRows; int c = std::rand() % m_nCols; if (m_bMine[r][c]) continue; if (std::abs(r - nSafeRow) <= 1 && std::abs(c - nSafeCol) <= 1) continue; // 去掉这块保护区域,见下方说明 m_bMine[r][c] = true; nPlaced++; } }参数说明:nSafeRow和nSafeCol就是用户第一次点击的坐标,在首点触发时传入,再生成雷区。注意这里我把安全范围设成了 9 宫格,因为如果首点附近有雷,翻开时数字会出现,另外,更贴近 Windows 扫雷做法的是首点及其邻域全部不在雷区,保证点击处为 0,从而触发一个区域的连锁展开,体验更接近原版。
3.2 邻居雷数计算:边界判断优先于循环优化
雷区生成结束后,要计算每个格子周围 8 格的地雷数量。这个逻辑放在 CountAdjacentMines 里会被反复调用,展开空白区时每个格子用一次,所以把它写成独立函数:
int CMineField::CountAdjacentMines(int nRow, int nCol) const { int nCount = 0; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = nRow + dr; int nc = nCol + dc; if (nr < 0 || nr >= m_nRows || nc < 0 || nc >= m_nCols) continue; // 边界越界,跳过 if (m_bMine[nr][nc]) nCount++; } } return nCount; }边界判断要放在访问数组之前,这是一个容易翻车的点:如果先访问m_bMine[nr][nc]再判断越界,Debug 模式下直接断言报错,Release 模式下读的是越界内存,地雷数会变成随机值。
CountAdjacentMines 和布雷放一起,用来生成 Hint 数字图。每次翻开一个格子,界面要显示的数字就是m_nHint[row][col]的值,0 表示空白,1-8 是数字,9 或者更大的约定值表示地雷。这里不用 0 表示雷,因为 0 已经被“无雷无数字”占用了——这个约定每个扫雷项目都会重新发明一次,但很少有人解释为什么。
void CMineField::RefreshHint() { for (int r = 0; r < m_nRows; r++) { for (int c = 0; c < m_nCols; c++) { if (m_bMine[r][c]) { m_nHint[r][c] = 9; } else { m_nHint[r][c] = CountAdjacentMines(r, c); } } } }把这段逻辑独立出来的理由是,CountAdjacentMines在展开空白区时还会被反复调用,没必要每次重新做 8 邻域循环——把结果缓存到m_nHint里,显示和判定都直接查表,性能好,代码也清晰。
3.3 空白区泛洪展开与翻开状态机
扫雷的核心体验是一次点击空白格,周围一片无雷区一起翻开,直到遇到数字边界。这个动作在数据结构上叫泛洪填充(Flood Fill),常见的写法是递归:
void CMineField::FloodOpen(int nRow, int nCol) { // 越界、已翻开、插旗的格子不处理 if (nRow < 0 || nRow >= m_nRows || nCol < 0 || nCol >= m_nCols) return; if (m_nState[nRow][nCol] != 0) // 0 表示还未翻开 return; if (m_bMine[nRow][nCol]) return; m_nState[nRow][nCol] = 1; // 标记为翻开 // 如果当前格不是空白(有数字),就不再扩散 if (m_nHint[nRow][nCol] > 0) return; // 对 8 邻域递归展开 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; FloodOpen(nRow + dr, nCol + dc); } } }递归实现足够清晰,但有一个隐藏风险:当雷区极大、空白区域成片时,递归深度可能达到几百甚至上千层,栈溢出在 Debug 模式下偶尔会冒出来。常见补救办法是把递归改成显式栈循环:
void CMineField::FloodOpen(int nRow, int nCol) { std::vector<std::pair<int, int>> stack; stack.push_back(std::make_pair(nRow, nCol)); while (!stack.empty()) { std::pair<int, int> p = stack.back(); stack.pop_back(); int r = p.first, c = p.second; if (r < 0 || r >= m_nRows || c < 0 || c >= m_nCols) continue; if (m_nState[r][c] != 0) continue; if (m_bMine[r][c]) continue; m_nState[r][c] = 1; if (m_nHint[r][c] > 0) continue; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; stack.push_back(std::make_pair(r + dr, c + dc)); } } } }参数说明:m_nState用三个值表示格子状态,0 表示未翻开,1 表示翻开,2 表示插旗,3 表示问号。递归版适合初学者理解,显式栈版适合直接交付使用。这段里的状态检查顺序也很要紧:先查越界,再查状态,最后再查雷,顺序反了会出逻辑 bug。
3.4 胜利判断与失败处理
翻开一个格子后要立刻判断游戏是否结束,失败条件简单——翻开的是雷。胜利条件则要小心:不是所有格子都被翻开,而是“所有无雷格子都被翻开”。
bool CMineField::IsWin() const { for (int r = 0; r < m_nRows; r++) { for (int c = 0; c < m_nCols; c++) { if (!m_bMine[r][c] && m_nState[r][c] != 1) { return false; // 还有一个无雷格子没翻开 } } } return true; }这个判断逻辑用“遍历所有格子”的方式完成,时间复杂度 O(rows×cols),每次点击都检查一次,对 30x16 的 480 个格子来说完全无压力。没必要用一个计数器动态维护已翻开数——那确实快一点,但代码复杂度上去了,收益很小。我见过有人把“插了所有旗子”当作胜利条件,这是错的:插旗只是标记,真正决定胜负的是翻开状态。
OpenCell接口要做一件事:判断点击的是雷则标记失败并翻开雷区。这里有个细节,失败时要展示所有地雷位置,我一般会额外加一个RevealAllMines()方法,在失败后调用一次,把所有雷的 State 设为翻开,用红底或雷图区分。
4. 界面绘制与玩家交互:按钮贴图、右键插旗与点击手感
4.1 用 CButton 还是自绘控件:状态内存表是关键
用 CButton 阵列做扫雷的方便之处在于,控件本身有 3D 边框,按下状态变化是现成的;麻烦之处在于,你要给每个按钮维护“是否被插旗”和“是否被翻开”两套视觉状态,还要在翻开后把按钮的按下效果关掉。所有格子共用一个视觉样式时,CButton 要修改颜色、字体、背景,代码量会失控。
所以我更建议在对话框上摆一个 Static 控件,把整块雷区画在一个CMemDC上。绘制逻辑可以放在 Dlg 的 OnPaint 里,流程是:
void CMineSweeperDlg::DrawCell(CDC* pDC, int nRow, int nCol) { CRect rcCell = CellRect(nRow, nCol); // 行列 -> 屏幕坐标 if (m_game.GetCellState(nRow, nCol) == 1) { // 已翻开:画土黄色背景,再画数字 pDC->FillSolidRect(rcCell, RGB(220, 215, 205)); int nHint = m_game.GetHint(nRow, nCol); if (nHint > 0) { // 画数字,颜色按 1红 2绿 3蓝 4紫 5黑 … 约定 pDC->SetTextColor(m_crNumber[nHint]); pDC->DrawText(CString((TCHAR)(_T('0') + nHint)), rcCell, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } else { // 未翻开:画按钮凸起效果,再画插旗 DrawRaisedBorder(pDC, rcCell); if (m_game.GetCellState(nRow, nCol) == 2) { // 画红旗,可以用系统图标或自己画三角形+细杆 DrawFlag(pDC, rcCell); } } }这里m_game是CMineField的对象,GetCellState直接读算法类的m_nState。界面代码不允许直接修改状态,只能通过 OpenCell、MarkFlag 这些接口来改,避免在绘制时偷偷改数据导致逻辑和数据不一致。
“状态内存表”这个概念贯穿整个界面:界面每次重绘都从m_nState读状态,而不是从按钮控件问“你被按过吗”。这样绘制和逻辑彻底解耦,刷新任何区域都能保证画面一致。
4.2 位图资源的加载与格点对齐
雷区里的地雷、红旗、数字这些素材,用 GDI 画可以,但想达到 Windows 经典扫雷的观感,还是用位图更稳。加载位图要用 MFC 的CImage或者LoadBitmap,这里有个容易踩的坑:尺寸对齐。
// 在 OnInitDialog 里加载位图 m_bmpFlag.LoadBitmap(IDB_FLAG); // 自己加一个位图资源 m_bmpMine.LoadBitmap(IDB_MINE); // 绘制时把位图缩放或平铺到格子区域 void CMineSweeperDlg::DrawFlag(CDC* pDC, CRect rcCell) { CDC dcMem; dcMem.CreateCompatibleDC(pDC); CBitmap* pOldBmp = dcMem.SelectObject(&m_bmpFlag); BITMAP bmpInfo; m_bmpFlag.GetBitmap(&bmpInfo); int nW = bmpInfo.bmWidth; int nH = bmpInfo.bmHeight; // 居中绘制,不拉伸 int x = rcCell.left + (rcCell.Width() - nW) / 2; int y = rcCell.top + (rcCell.Height() - nH) / 2; pDC->BitBlt(x, y, nW, nH, &dcMem, 0, 0, SRCCOPY); dcMem.SelectObject(pOldBmp); }参数说明:IDB_FLAG是你在资源编辑器里添加的位图资源 ID。位图尺寸建议和格子尺寸一致,比如格子是 20x20 像素,位图也做 20x20,这样 BitBlt 时不需要缩。如果要响应不同 DPI 的显示器,可能需要用StretchBlt做缩放,但那会引入锯齿,不如直接准备多套图片。课程设计阶段,画一个简单的旗子图形用不上位图,但完整的工程做到后面,你会发现位图比手绘省心得多——手绘图形每加一个平台就要排查一遍坐标细节。
资源 ID 和图片文件名习惯上放在 Resource.h 里统一管理,不要在代码里写死路径,也不要依赖工作目录。MFC 的资源机制会把位图编译进 exe,这是比读外部文件更稳的方式。
4.3 右键插旗、左右键连击与计时器
右键插旗是扫雷的标配操作。在 MFC 中,鼠标消息分左右键处理:
void CMineSweeperDlg::OnRButtonDown(UINT nFlags, CPoint point) { int nRow, nCol; if (!GetCellFromPoint(point, nRow, nCol)) return; // 点在雷区外 int nState = m_game.GetCellState(nRow, nCol); if (nState == 0) { m_game.MarkFlag(nRow, nCol, 2); // 未翻开 -> 插旗 } else if (nState == 2) { m_game.MarkFlag(nRow, nCol, 3); // 插旗 -> 问号 } else if (nState == 3) { m_game.MarkFlag(nRow, nCol, 0); // 问号 -> 取消标记 } InvalidateRect(CellRect(nRow, nCol)); // 只刷新这一个格子,避免全屏重绘 CDialog::OnRButtonDown(nFlags, point); }右键循环“未翻开 → 插旗 → 问号 → 未翻开”是经典扫雷的行为,问号状态可以不做,但做了手感更细腻。注意刷新时用InvalidateRect只刷改动的格子,而不是整片雷区,这样在低分辨率电脑上也不会卡。
计时器有两个常见实现:WM_TIMER消息和GetTickCount查表。扫雷这种精度要求不高的游戏,WM_TIMER足够:
SetTimer(1, 1000, nullptr); // 每秒触发一次 void CMineSweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_bGameRunning) { m_nElapsed++; SetDlgItemInt(IDC_TIME_DISPLAY, m_nElapsed); } CDialog::OnTimer(nIDEvent); }参数说明:1000表示 1 秒间隔,IDC_TIME_DISPLAY是对话框上一个静态文本框的 ID。启动时机选在“首次点击非旗子格子”之后,不要在一开始就计时,避免用户还没开始玩时间就走了。游戏结束后要KillTimer(1)停掉计时器,否则消息一直挂着会浪费系统资源。
左键双击或者“左右键同时按下打开 3×3 区域”这类高级交互,核心判定在OnLButtonDown里用GetAsyncKeyState(VK_RBUTTON)检测:
void CMineSweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 如果右键也是按下状态,说明是“双键连击” if (GetAsyncKeyState(VK_RBUTTON) & 0x8000) { ChordOpen(point); // 检查周围旗子数量,数量够就翻开其余格子 } // 普通左键逻辑 }Chord 操作不是必需的,但加上能让游戏体验提升一个档次,也向“完整程序”迈了一步。
4.4 双缓冲刷新:避免扫雷变成“闪雷”
自绘控件最大的问题是闪屏。OnPaint 里直接FillSolidRect画整片雷区时,如果不做双缓冲,拖拽窗口边缘或者快速重绘时,画面会被系统清成白底再重绘,看起来就是“闪雷”。
MFC 里做双缓冲的常见套路是内存 DC 画好再一次性贴回:
void CMineSweeperDlg::OnPaint() { CPaintDC dc(this); // 创建内存 DC,和显示 DC 兼容 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(&dc, m_rcBoard.Width(), m_rcBoard.Height()); CBitmap* pOldBmp = memDC.SelectObject(&memBmp); // 在内存 DC 上画全部格子 DrawBoard(&memDC); // 一次性贴回显示 DC dc.BitBlt(m_rcBoard.left, m_rcBoard.top, m_rcBoard.Width(), m_rcBoard.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明:先创建兼容内存 DC 和一张内存位图,把格子画在位图上,最后整块 BitBlt。因为不是直接画到屏幕上,Windows 不会再插一段抹白背景,闪烁就没了。
提示:创建内存位图用的是
m_rcBoard.Width()和Height(),不是整个对话框的尺寸。如果屏幕上有别的窗口盖住可能也不用重绘全部,但OnPaint里画整片雷区省心,现代电脑性能完全扛得住。这里只刷新雷区,不要把对话框上的按钮也画进去,否则资源管理器的消息会和你的绘制打架。
5. 避坑与常见问题:从编译到交作业,五个高频翻车点
5.1 对话框创建失败:静态链接 MFC 与资源 ID 冲突
现象:Debug 编译没问题,一运行就弹“Debug Assertion Failed”或者对话框直接不显示,程序在DoModal里卡住。
原因:常见的有两类。一是新建 MFC 工程时选择了“在静态库中使用 MFC”,但目标机器缺少对应的运行库,程序启动时资源加载失败。二是对话框模板 ID 写错——默认生成的工程对话框 ID 可能和你自己新建的IDD_MINE_DIALOG不一致,OnInitDialog里第一行代码会失败。
解决:先在工程设置的“常规”页把 MFC 使用方式改成“在共享 DLL 中使用 MFC”,保证开发机和自己机器上跑都没问题。然后查Resource.h里的IDD_*和你DoModal()用的资源 ID 是否一致——这一项我见过至少三次翻车,全部是复制粘贴时带了旧文件里的 ID。
5.2 数组下标与控件 ID 换算错位
现象:点击第 3 行第 4 列的格子,程序翻开的是第 2 行第 4 列,或者插旗时把一个已经点开的格子标成旗子。
原因:数组下标习惯从 0 开始,而对话框上生成的控件 ID 从IDC_GRID_0到IDC_GRID_N也是从 0 开始,但 MFC 的资源编辑器会给你加上一个固定的基数偏移,比如IDC_GRID_0实际是 1000。如果直接用IDC_GRID_0 + index做处理,换一台机器或者改一次资源文件,这个偏移就变了。
解决:约定一个宏或者常量#define IDC_GRID_BASE 1000,从 ID 换算行列用nIndex = nID - IDC_GRID_BASE,从行列换算 ID 用nID = IDC_GRID_BASE + nRow * nCols + nCol,全工程只用这两个方向做转换,不要在消息处理函数里出现第二个基准值。我用自绘方案后就没再踩过这个坑,但如果是 CButton 阵列方案,这一条是必查项。
5.3 首次点击就踩雷:换雷逻辑写不对
现象:玩家第一下点到雷上,游戏直接炸开,体验极差。有的程序把“重新布雷直到首点安全”做成了无限循环,雷区接近满时(比如 9x9 里布 60 个雷)会卡死。
原因:地雷生成机制没有考虑“首点保护区”,或者保护区的实现只排除了首点一格,没排除周围 8 格。
解决:在Reset里传入安全点坐标,布雷时跳过abs(r - nSafeRow) <= 1 && abs(c - nSafeCol) <= 1的范围。如果后续又支持了“点击数字周围自动展开”功能,首点保护范围最好扩到 5x5,避免首点旁边全是数字、连锁展开打不开的局面。换雷时不要用递归或循环等待随机数生成器“碰巧”避开,要像上面代码那样在布雷循环里显式判断。
5.4 计时器不准:暂停、暂停恢复与刷新频率
现象:游戏进行中,计时器显示的时间比实际秒数快或慢,切窗口回来时间直接停住了。
原因:WM_TIMER是低优先级消息,窗口被遮挡、拖动、系统繁忙时,消息会因为排队而被延迟甚至合并,时间自然跑不准。计时器事件里做文件 IO 或者重绘大区域,又会进一步加剧延迟。
解决:把计时器当作“触发刷新的信号”,真正的时间用GetTickCount64()记录:
if (m_bGameRunning) { ULONGLONG nNow = GetTickCount64(); m_nElapsed = static_cast<int>((nNow - m_nStartTick) / 1000); SetDlgItemInt(IDC_TIME_DISPLAY, m_nElapsed); }启动时记m_nStartTick = GetTickCount64(),暂停时记录暂停时刻并做偏移,恢复时调整m_nStartTick补偿暂停时间段,这样窗口最小化再回来,时间依然准。
5.5 UpdateData 误用导致界面卡死
现象:在OnTimer或者OnPaint里调用UpdateData(FALSE)刷新文本框,程序运行一段时间后界面变得非常卡,点击格子半天才有反应。
原因:UpdateData(FALSE)会把对话框上所有控件的值同步到成员变量,涉及大量消息传递,在每帧刷新里调用等于每帧全量读写一遍所有控件,效率极低;如果在OnPaint里调用,还可能触发重绘重入,造成死循环。
解决:只更新需要更新的控件,用SetDlgItemInt或SetDlgItemText直接操作目标控件,绕开整表同步。绘制相关的数据都不走UpdateData,雷区数据只存成员变量,手动Invalidate驱动重绘。这是个设计习惯,养成之后就再也遇不到经典的“界面卡死 + 主窗口没响应”组合了。
6. 验证与进阶:把这版扫雷做成能验收的工程
6.1 算法自检:脱离开 UI 跑一万局
界面和算法分离之后,可以写一个纯控制台入口来压力测试雷区逻辑。这一步的价值在于交作业前已经把“随机布雷 + 展开 + 胜负判定”跑了上万次,而不是等用户点开才发现一堆逻辑 bug。
// TestMineField.cpp —— 不链接 MFC 也能编译的部分 #include "MineField.h" #include <cstdio> int TestOneGame(int nRows, int nCols, int nMines) { CMineField game; game.Reset(nRows, nCols, nMines, 0, 0); // 首点保护 (0,0) // 模拟翻开首点 if (game.IsMine(0, 0)) return -1; // 这里不应该发生 game.OpenCell(0, 0); // 模拟所有剩余无雷格子逐个翻开 for (int r = 0; r < nRows; r++) { for (int c = 0; c < nCols; c++) { if (!game.IsMine(r, c)) game.OpenCell(r, c); } } return game.IsWin() ? 0 : -1; } int main() { for (int i = 0; i < 10000; i++) { int ret = TestOneGame(16, 30, 99); if (ret != 0) { printf("round %d failed\n", i); return 1; } } printf("all passed\n"); return 0; }逻辑说明:如果IsWin()在“把所有无雷格子都翻开”之后返回假,说明胜利判定有问题——这一测就能暴露。第一轮测试只做无雷全开,不会触发雷区的失败分支,所以还应该补一段“踩雷后IsLose()返回真”的用例。压测用例结束时要打印 pass/fail 信息,系统性的回归测试比依赖人工手测有说服力得多。
6.2 用自动解题器当验收工具
当核心逻辑稳定后,可以让程序自己玩:写一个简单的策略函数,优先翻数字周围的未翻开格子,概率推断可以留到最后。自动解题器本质不是一个游戏功能,而是用来验证“随机生成的局面是否一定有解”——有些布雷方式可能产生无法靠逻辑推理突破的死局,经典扫雷里这种情况通过“双键概率猜测”解决,但在课程设计里,我一般建议排除这种局面:生成雷时加一个约束,保证首点出发的泛洪展开覆盖率达到所有非雷格子的 80% 以上,剩下的 20% 用户点数字能退出来,不依赖猜。
// 简单策略:找“数字格周围旗子树 == 数字值”的格子 // 如果满足条件,就翻开该数字周围的未标记格子 void AutoPlay(CMineField& game, int nRows, int nCols) { bool bProgress = true; while (bProgress) { bProgress = false; for (int r = 0; r < nRows; r++) { for (int c = 0; c < nCols; c++) { if (game.GetCellState(r, c) != 1) continue; int nHint = game.GetHint(r, c); if (nHint <= 0) continue; int nFlag = CountFlagAround(game, r, c); if (nFlag == nHint) { // 翻开周围非旗子格子 OpenSafeNeighbors(game, r, c); bProgress = true; } } } } }这说明“可判定性”与算法实现强相关。如果自动求解器能在一局里完成 90% 以上的翻开,你的状态机和数字计算就是可信的。
6.3 存档、排行榜与分辨率适配:额外加分项
课程设计做到能玩、能判胜负只是及格线,如果要冲高分,我建议加一个“最佳时间”存档:游戏结束后把用时写入 INI 文件,下次启动读取显示在标题栏或对话框上。MFC 里读写 INI 有现成的GetPrivateProfileInt和WritePrivateProfileString,不需要额外依赖:
// 保存玩家用时 CString strSection = _T("MineSweeper"); CString strKey = _T("BestTime"); int nBest = GetPrivateProfileInt(strSection, strKey, 9999, m_strIniPath); if (m_nElapsed < nBest) { CString strValue; strValue.Format(_T("%d"), m_nElapsed); WritePrivateProfileString(strSection, strKey, strValue, m_strIniPath); }参数说明:m_strIniPath一般用GetModuleFileName获取 exe 同目录的绝对路径,再拼上config.ini,不要用相对路径,否则工作目录变化就写不进去了。
分辨率适配方面,最省事方案是把对话框的Border设为 Fixed,固定窗口大小不缩放。如果一定要支持拉伸,只能在OnSize里按比例重算雷区矩形并强制重绘,同时要处理“窗口拉太小导致格子重叠”的最小尺寸限制。这个属于性价比比较低的功能,时间不够就别碰了,交给用户固定尺寸窗口反而是更体面的设计。
做完整套测试后,我的习惯是保留TestMineField.cpp并在代码注释里写一句测试用例:初级 9x9 布 10 雷,中级 16x16 布 40 雷,专家级 30x16 布 99 雷,全部用自动化解题跑满五局无崩溃。这样等以后想改成联网排名或者加音效时,改动逻辑前先回归一遍,心里有底。MFC 扫雷最大的难点始终不在“如何数清八个方向的雷”,而在“如何让一个 Windows 程序稳定地呈现一次点击的完整体验”——把算法和界面拆开、用状态表驱动绘制、把边界和重绘问题一项项钉死,这版扫雷就不再是作业,而是拿得出手的程序设计实践作品了。希望帮到你。
本文还有配套的精品资源,点击获取