简介这是一份基于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::vectorstd::vectorbool m_bMine; // 雷图 std::vectorstd::vectorint m_nHint; // 数字图 std::vectorstd::vectorint 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::vectorbool(m_nCols, false)); m_nHint.assign(m_nRows, std::vectorint(m_nCols, 0)); m_nState.assign(m_nRows, std::vectorint(m_nCols, 0)); // 布雷时排除安全点及其 8 邻域避免首点出现数字空格 int nPlaced 0; std::srand(static_castunsigned 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::vectorstd::pairint, int stack; stack.push_back(std::make_pair(nRow, nCol)); while (!stack.empty()) { std::pairint, 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_castint((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 程序稳定地呈现一次点击的完整体验”——把算法和界面拆开、用状态表驱动绘制、把边界和重绘问题一项项钉死这版扫雷就不再是作业而是拿得出手的程序设计实践作品了。希望帮到你。本文还有配套的精品资源点击获取