MFC扫雷游戏开发全攻略:从消息映射到双缓冲绘制
2026/9/23 14:14:46 网站建设 项目流程

简介:基于MFC框架实现的经典扫雷游戏项目,适合使用VC++的开发者与游戏编程入门者参考,可用来学习Windows图形界面程序设计。项目利用微软基础类库对系统接口的封装,完整演示了创建游戏主窗口、处理鼠标点击消息、绘制棋盘格子、加载位图图标与音效资源,以及在胜利、失败和重新开局等状态之间切换的逻辑处理。压缩包共包含54个文件,整体大小约2.96兆字节,核心内容以C++头文件和源文件为主,并配有位图、图标、音频等界面素材,同时带有可直接运行的可执行程序,以及对象文件、调试信息等编译过程产物,方便对照学习完整构建流程。目录结构接近常规工程布局,便于逐步拆解阅读。目前已有145人学习下载,阅读者可以从中掌握窗口搭建、消息响应、图形绘制和状态管理的落地方法,并进一步修改难度、棋盘尺寸或素材样式,作为二次开发的起点。

1. MFC 扫雷:一个对话框程序就能装下的完整游戏逻辑

拿到一个叫 saolei.rar 的压缩包,解压出来通常是某一个版本的 MFC 扫雷工程。这类项目在网上流传很广,文件名五花八门,但核心都指向同一件事:用微软基础类库(MFC)把 Windows 自带扫雷的规则在自绘窗口里重新实现一遍。扫雷的规则大家都熟,棋盘上藏若干颗雷,点开数字格判断附近雷数,空白格自动展开,给所有雷插上旗子就赢。这个规模用来练 MFC 恰到好处:它要求你处理鼠标左键、右键、定时器、GDI 绘制,但又不涉及文件读写、多线程、数据库这些会分散注意力的东西,一个基于对话框的工程从头写到尾也就是几百行代码的事。

这篇文章我不会装作读过某个固定源码包内部,而是按这类项目最常见的工程写法,把从新建 MFC 对话框工程到跑出一个能完整对局的扫雷程序拆成四块:工程骨架、数据模型、绘制交互、避坑排查。新手可以照着步骤做,已经看过别人代码但没看懂消息映射和绘制流程的人,也能在中间几章找到对应关系。最后收在状态机和难度参数上,算是给这个经典练手项目一个不过度的收尾。

2. 工程选型与消息骨架:对话框程序就够,不必引入 Doc/View

2.1 CDialog 与 SDI 的取舍:为什么扫雷不推荐用文档视图

初学者翻到 MFC 教材,默认路径是“单文档应用程序 + CView::OnDraw”,因为框架会自动处理刷新。但扫雷的绘制触发点非常特殊:棋盘只在鼠标点击、定时器跳秒、游戏结束三种时刻发生变化,不需要滚动、不需要保存文档、不需要多视图。用 SDI 反而多了一层 CView 和 CDocument 的关联逻辑,你还要处理 OnUpdate 的调用时机,纯属给自己找事。

对话框程序的好处是窗口就是对话框本身,OnInitDialog 里初始化棋盘,OnPaint 里画所有格子,鼠标消息直接进对话框的消息映射。整个程序只有一个主窗口、一组按钮和几个静态文本,用 CDialogEx 就够了。我们把这个做法可以当成这类小程序的默认架构,直到你开始想给扫雷加存档、加回放、加皮肤系统,那时候再考虑拆分视图也不迟。

2.2 新建工程时的三个容易选错的选项

在 Visual Studio 里新建 MFC 应用,应用类型选“基于对话框”。有三个选项会影响后面的开发体验:

  • 字符集选“使用 Unicode 字符集”。MFC 新项目默认就是 Unicode,消息映射里用 _T("") 包字符串,坐标系计算用 CPoint 和 CRect,跟宽窄字符没关系,但 CString 格式化数字时建议用 _T("%d"),避免在多字节环境下出现中文乱码的隐性问题。
  • 使用静态库还是共享 DLL。共享 DLL 生成 exe 小,但拷到别的机器上容易缺 MFC 运行库;静态链接会让文件体积涨到几 MB,但对这种玩具级程序来说无所谓,我一般选静态库,省得部署时被“缺少 mfc140u.dll”这种问题绊倒。
  • 是否生成 ActiveX 控件支持。扫雷用不到,把“ActiveX 控件”勾选去掉,生成的工程文件更干净,预编译头也更快。

对话框模板里默认会有“确定”“取消”两个按钮,直接删掉,换个自己的“重新开始”按钮,ID 设为 IDC_BTN_RESTART。窗口标题改成“扫雷”即可。注意对话框属性里的“系统菜单”可以留着,方便关窗口;“最大化框”去掉,因为棋盘尺寸固定,最大化后格子不跟着放大反而难看。

2.3 消息映射即代码骨架:五类消息一个都不能少

MFC 的消息映射靠宏把 Windows 消息和成员函数绑起来。扫雷主窗口需要响应五类消息,我用的是头文件里声明成员函数、CPP 里做映射的方式。映射表长这样:

BEGIN_MESSAGE_MAP(CSweepDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() ON_WM_CONTEXTMENU() ON_BN_CLICKED(IDC_BTN_RESTART, &CSweepDlg::OnBnClickedRestart) END_MESSAGE_MAP()

这段映射说明几个关键点。ON_WM_PAINT 让我们可以在 OnPaint 里自己画整个棋盘;ON_WM_LBUTTONDOWN 和 ON_WM_RBUTTONDOWN 负责左键翻开、右键插旗;ON_WM_TIMER 用来驱动计时器显示;ON_WM_CONTEXTMENU 必须拦下来,否则右键点击格子时 Windows 会默认弹上下文菜单,把我们的插旗操作盖掉;ON_BN_CLICKED 绑定重新开始按钮。

头文件里对应的成员函数声明像这样:

class CSweepDlg : public CDialogEx { public: CSweepDlg(CWnd* pParent = nullptr); enum { IDD = IDD_SWEEP_DIALOG }; protected: virtual BOOL OnInitDialog(); afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); afx_msg void OnTimer(UINT_PTR nIDEvent); afx_msg void OnContextMenu(CWnd* pWnd, CPoint point); afx_msg void OnBnClickedRestart(); DECLARE_MESSAGE_MAP() };

OnInitDialog 里做三件事:初始化棋盘数组、设置窗口客户区尺寸、把“重新开始”按钮摆到棋盘下方。客户区尺寸是重点,我一般这样算:窗口宽度等于左边距加列数乘格子边长再加右边距,高度等于上边距加行数乘格子边长再加计时器区域高度。格子边长取 32 像素,经典扫雷的视觉比例就回来了。还有一个细节,对话框资源里虽然有固定尺寸,但代码里要再调用一次 SetWindowPos 按算出的尺寸校准,免得资源模板微调后棋盘被挤到窗口外。

3. 雷区数据模型与布雷逻辑:从第一次点击保护开始

3.1 棋盘数据结构:地雷、翻开、插旗三种状态分开存

扫雷的核心是一个二维数组,但实际写代码时每个格子不只“有雷/无雷”两个状态。一个完整格子需要记录四件事:是否地雷、周围八个格子里有几颗雷、是否已被翻开、是否已插旗。

结构体定义比并列数组更清楚:

struct Cell { bool isMine; // 是不是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否已插旗 int number; // 周围雷数,0 表示空格,-1 表示未计算 };

棋盘本体我一开始用 vector 动态分配,方便后面做难度切换:

std::vector<std::vector<Cell>> m_board; int m_rows; int m_cols; int m_mineCount;

为什么不直接用固定数组?因为高级难度 16 行 30 列,初级 9 行 9 列,用固定数组就要写死一个 30×30 的上限,难度参数化时还得处处带着这个上限。vector 的空间开销对几百个格子完全可以忽略,换来的是 InitBoard 可以任意改尺寸:

void CSweepDlg::InitBoard(int rows, int cols, int mines) { m_rows = rows; m_cols = cols; m_mineCount = mines; m_board.assign(m_rows, std::vector<Cell>(m_cols)); for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { m_board[r][c].isMine = false; m_board[r][c].isRevealed = false; m_board[r][c].isFlagged = false; m_board[r][c].number = 0; } } m_started = false; m_gameOver = false; m_leftDown = false; m_rightDown = false; }

InitBoard 是全局重置入口,重新开始按钮也调它。注意这里还没布雷,布雷放到第一次鼠标点击之后,这是避免“开局踩雷”的关键设计,下一节展开说。

3.2 布雷时机:点击后随机布雷,并且避开首点周围三乘三

新手最容易犯的错是在 OnInitDialog 里就把所有雷埋好,结果玩家第一次点击就踩雷。专业的做法是延迟布雷:第一次点击那个格子时记下安全坐标,再随机往棋盘上撒雷,保证安全点本身以及它周围一圈都不会有雷。

void CSweepDlg::PlaceMines(int safeRow, int safeCol) { srand(static_cast<unsigned>(time(nullptr))); int placed = 0; while (placed < m_mineCount) { int r = rand() % m_rows; int c = rand() % m_cols; if (m_board[r][c].isMine) continue; if (r >= safeRow - 1 && r <= safeRow + 1 && c >= safeCol - 1 && c <= safeCol + 1) continue; m_board[r][c].isMine = true; ++placed; } CalcNumbers(); }

参数说明有两个容易忽略但很重要的点。第一个是 while 循环而不是 for 循环:因为 rand 可能重复抽到同一格,for 循环写死次数会导致实际雷数不足。第二个是避开范围:不只是 safeRow/safeCol 那一格,而是它周围 3×3 都要空出来。经典扫雷要求玩家第一次点击必须能看到一块能展开的空地,如果只避开单格,仍然可能点开后周围一圈都是雷,体验等于没保护。

有一个特殊情况要注意:如果棋盘很小而雷很多,比如 5×5 盘 20 颗雷,3×3 避让区可能让 while 循环卡死。处理方式是加一个重试上限,超过则从剩余格子中硬挑。但标准扫雷的雷数上限远没到稠密区,常见级别都不会触发,我一般只在代码注释里留一句提醒。

3.3 数字计算:遍历九宫格数雷数

布雷结束后要立刻算每个格子周围的雷数。这一步是所有数字显示的基础,也决定了之后自动展开能不能正常工作。

void CSweepDlg::CalcNumbers() { for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { if (m_board[r][c].isMine) continue; int cnt = 0; for (int dr = -1; dr <= 1; ++dr) { for (int dc = -1; dc <= 1; ++dc) { int nr = r + dr; int nc = c + dc; if (nr < 0 || nr >= m_rows) continue; if (nc < 0 || nc >= m_cols) continue; if (m_board[nr][nc].isMine) cnt++; } } m_board[r][c].number = cnt; } } }

CalcNumbers 的边界检查是防数组越界的唯一防线,不能省。棋盘边缘和四角的格子只会有 3 个或 5 个合法邻格,靠上下限判断跳过越界访问。这个函数在新建棋盘和布雷保护都需要调用一次,逻辑本身没有坑,但调用时机不能错:如果布雷前提前算数字,数字全是 0,地面播放还不一致。

3.4 翻开与自动展开:空格扩散的两种写法

点击一个空格(number=0)时,扫雷会把相邻的连续空白区域全部翻开,直到碰到数字边界。这个操作很多源码用递归写:

void CSweepDlg::RevealEmptyRecursive(int r, int c) { if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return; if (m_board[r][c].isRevealed) return; if (m_board[r][c].isFlagged) return; m_board[r][c].isRevealed = true; if (m_board[r][c].number > 0) return; for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) RevealEmptyRecursive(r + dr, c + dc); }

递归写法本身没问题,9×9 的盘最深也就几十层,完全不会爆栈。但有些扩展过的代码会拿这个逻辑跑大尺寸地图,递归深度会随空白区域大小线性上涨,我见过有人把扫雷改成 100×100 后点一下中间空白处直接卡死。稳妥做法是用队列做广度优先展开,代码量差别不大,但循环代替递归,栈深度彻底变为常数:

void CSweepDlg::RevealCell(int row, int col) { if (row < 0 || row >= m_rows || col < 0 || col >= m_cols) return; if (m_board[row][col].isRevealed) return; if (m_board[row][col].isFlagged) return; if (m_board[row][col].isMine) { m_board[row][col].isRevealed = true; m_gameOver = true; KillTimer(m_timerId); return; } std::queue<std::pair<int, int>> q; m_board[row][col].isRevealed = true; q.push({row, col}); while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int dr = -1; dr <= 1; ++dr) { for (int dc = -1; dc <= 1; ++dc) { int nx = x + dr; int ny = y + dc; if (nx < 0 || nx >= m_rows) continue; if (ny < 0 || ny >= m_cols) continue; if (m_board[nx][ny].isRevealed) continue; if (m_board[nx][ny].isFlagged) continue; m_board[nx][ny].isRevealed = true; if (m_board[nx][ny].number == 0) { q.push({nx, ny}); } } } } }

这段是整局游戏的核心逻辑,需要解释一下细节。队列里只放空格(number == 0),数字格只翻开不扩散,但数字格邻接的空白区会继续入队。遍历时跳过已翻开和已插旗的格子,保证不会重复处理,也防止展开时把玩家标记的旗子盖掉。如果点到地雷,直接掘开这一格并且标记游戏结束,不用立刻把整片雷show出来,视觉细节交给 OnPaint 处理。

4. GDI 绘制与鼠标交互:把二维数组变成能点开的棋盘

4.1 自绘棋盘:双缓冲与数字颜色的对应表

MFC 对话框的绘制在 OnPaint 里完成。扫雷的棋盘是规则网格,可以用控件数组实现,每个格子一个 CButton,但 16×30 就要 480 个按钮,资源管理太臃肿。我更推荐自绘:一个 OnPaint 把整个棋盘画出来,配合鼠标消息计算行列,代码集中且灵活。

双缓冲是老生长谈,但扫雷里闪烁特别明显,因为每次点击都要重绘整块区域。先画到内存 DC,再一次 BitBlt 上屏:

void CSweepDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); 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)); for (int r = 0; r < m_rows; ++r) { for (int c = 0; c < m_cols; ++c) { CRect cellRect( kLeftMargin + c * kCellSize, kTopMargin + r * kCellSize, kLeftMargin + (c + 1) * kCellSize, kTopMargin + (r + 1) * kCellSize); if (m_board[r][c].isRevealed) { DrawRevealedCell(memDC, cellRect, r, c); } else { DrawHiddenCell(memDC, cellRect, r, c); } } } dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }

双缓冲的关键在最后两步:DrawRevealedCell 和 DrawHiddenCell 都画到 memDC 上,全部画完再整幅上屏,屏幕不会出现半边旧半边新的撕裂感。数字颜色按经典扫雷的约定,这个表建议直接抄:

数字颜色RGB 值
1蓝色RGB(0, 0, 255)
2绿色RGB(0, 128, 0)
3红色RGB(255, 0, 0)
4深蓝RGB(0, 0, 128)
5棕色RGB(128, 0, 0)
6青色RGB(0, 128, 128)
7黑色RGB(0, 0, 0)
8灰色RGB(128, 128, 128)

DrawRevealedCell 里画完底色后,用 DrawText 居中输出数字。字号用系统默认字体就行,如果你觉得 32 像素格子里的数字太细,可以用 CreateFont 建一个 16 磅粗体全局字体,在 OnInitDialog 里创建、析构里删除,不要在 OnPaint 里反复创建,那是内存泄漏的温床。

4.2 鼠标命中换算:左键翻开、右键插旗

鼠标消息到达时,对话框只知道屏幕坐标,需要把它换算成棋盘的行列。换算公式固定是“坐标减边距,除以格子边长”,然后必须做越界检查,因为客户区边缘多出来的那几个像素会算出负数或超范围下标。

void CSweepDlg::OnLButtonDown(UINT nFlags, CPoint point) { int col = (point.x - kLeftMargin) / kCellSize; int row = (point.y - kTopMargin) / kCellSize; if (row < 0 || row >= m_rows || col < 0 || col >= m_cols) return; if (m_gameOver) return; m_leftDown = true; if (!m_started) { m_started = true; PlaceMines(row, col); m_timerId = SetTimer(1, 1000, nullptr); } if (!m_board[row][col].isFlagged) { RevealCell(row, col); InvalidateRect(nullptr, FALSE); } }

逻辑顺序要特别留意。第一次点击时先布雷再翻开,这样 RevealCell 才能基于完整雷区判断结果。m_started 状态标志保证只布雷一次,随后 SetTimer 开始走秒。至于为什么用 SetTimer(1, 1000) 而不是 GetTickCount、再在 OnPaint 里取时间差:这里的计时精度要求只有 1 秒,WM_TIMER 足够,不需要高精度计时。

右键插旗:

void CSweepDlg::OnRButtonDown(UINT nFlags, CPoint point) { int col = (point.x - kLeftMargin) / kCellSize; int row = (point.y - kTopMargin) / kCellSize; if (row < 0 || row >= m_rows || col < 0 || col >= m_cols) return; if (m_gameOver) return; if (!m_started) return; if (m_board[row][col].isRevealed) return; m_rightDown = true; m_board[row][col].isFlagged = !m_board[row][col].isFlagged; InvalidateRect(nullptr, FALSE); }

插旗只对未翻开格子有效,这点不能漏。如果对手已翻开的数字格右键,要么忽略,要么留给“双击地雷周围数字”的快捷操作,后一种做法在 4.3 展开。

4.3 经典扫雷的“双击展开”:左右键同时按下时的处理顺序

Windows 自带扫雷有一个效率操作:在已经翻开的数字格上同时按左键和右键,如果这个数字周围插旗数量等于数字本身,就自动翻开剩余未标记且未翻开的格子。这个操作在 MFC 里不能依赖 OnLButtonDblClk,因为扫雷识别的是左右键同时按下,而不是一次双击左键。

我用两个布尔成员变量跟踪按键状态。OnLButtonDown 里把 m_leftDown 置真,OnRButtonDown 里把 m_rightDown 置真,然后在左键抬起时检查:

void CSweepDlg::OnLButtonUp(UINT nFlags, CPoint point) { m_leftDown = false; int col = (point.x - kLeftMargin) / kCellSize; int row = (point.y - kTopMargin) / kCellSize; if (row < 0 || row >= m_rows) return; if (col < 0 || col >= m_cols) return; if (m_gameOver) return; if (!m_board[row][col].isRevealed) return; if (m_board[row][col].number == 0) return; if (m_leftDown && m_rightDown) { ChordReveal(row, col); } m_rightDown = false; InvalidateRect(nullptr, FALSE); }

ChordReveal 的实现是把当前格周围所有未翻开、未插旗的格子逐一调用 RevealCell,同时在 RevealCell 中自动处理踩雷的情况。这里有一个顺序陷阱:必须先检查 m_leftDown 和 m_rightDown 都为真,再在清空状态;如果先清 m_rightDown,后面的判断就永远不成立。

4.4 计时器与状态显示:秒数从哪来,雷数怎么显示

WM_TIMER 的响应函数很简单,每秒把计时器变量加 1,然后更新到对话框上某个静态文本框:

void CSweepDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { ++m_elapsedSeconds; CString strTime; strTime.Format(_T("%d"), m_elapsedSeconds); SetDlgItemText(IDC_STATIC_TIME, strTime); } CDialogEx::OnTimer(nIDEvent); }

雷数显示用剩余的未标记雷数 = 总雷数 - 已插旗数,每次右键插旗后更新这个文本框。注意扫雷允许玩家插比实际雷数更多的旗,这个数字可能变成负数,显示上按 0 处理即可。计时器从第一次点击开始,游戏结束后要在 RevealCell 踩雷分支或胜利判断里调用 KillTimer,否则秒数会一直跳。

5. MFC 扫雷的避坑排查:右键菜单、递归爆栈与内存泄漏

5.1 右键插旗时弹出系统上下文菜单

现象:在棋盘上点击右键,插旗确实画出来了,但松开鼠标后窗口旁边弹出一个默认的弹出式上下文菜单,里面是“剪切”“复制”“粘贴”。

原因:MFC 的对话框和控件默认处理 WM_CONTEXTMENU。OnRButtonDown 处理完点击后,Windows 接着发出 WM_CONTEXTMENU 消息,默认 handler 就把菜单弹出来。

解决:在消息映射里加 ON_WM_CONTEXTMENU,并让处理函数什么都不干:

void CSweepDlg::OnContextMenu(CWnd* pWnd, CPoint point) { // 让默认菜单不弹出来,插旗逻辑已在 OnRButtonDown 中完成 }

这段代码也能放到 CDialogEx::OnContextMenu 的覆盖里,效果相同。大多数网上源码会忽略这一步,但你没写这条反而怪。类似的还有右键菜单弹出的时机问题,处理函数留空不要调用基类即可。

5.2 递归展开空格时栈溢出或界面假死

现象:在某个中等大小的棋盘上点击空白区域,程序卡住不动,任务管理器显示 CPU 占用接近单核满。把格子改成动态数组、棋盘调大后更频繁。

原因:递归的 RevealEmpty 在空白区域大时,调用深度等于可连接区域的格子数。比如 30×30 的盘有一大块连续空地,递归层数上千,即便不爆栈,频繁的函数调用和重复边界检查也会让单次点击耗时上百毫秒,手感变卡。

解决:回到 3.4 节的 RevealCell 改写,用队列代替递归。这是“看着优雅但实际激进递归”的典型案场,扫雷区域展开用广搜是完全等价且更稳的。

5.3 敌人翻开的格子周围显示不出数字,或者数字全部是 0

现象:点击格子后,除了点击位置有数字,整片空地区域全都翻开了,但紧挨着地雷的那一圈格子没有显示数字,全部空白。

原因:说明 CalcNumbers 没有在布雷后执行,或者 place mines 之后忘记了重新计算。有些变体代码一开始布雷,后续重新开始游戏时只重置了数组而没完全初始化,就会 odl state code notch.

解决:把布雷和数字计算放在同一个函数里,并且保证 Reset(InitBoard)清空所有字段。我习惯在 InitBoard 末尾加一行注释列出状态:种子清空、isMine 清 false、number 清 0,这三样少一样,新开的棋盘就会继承旧数据。

5.4 每隔几局出现的内存泄漏,任务管理器里内存慢慢涨

现象:反复点“重新开始”几十次,进程内存一点一点增加,Debug 版输出窗口出现 Detected memory leaks。

原因:常见有三处。第一,OnPaint 里每次 CreateFont 但没 DeleteObject 或没选回原对象;第二,CMemDC 局部变量析构时位图没释放;第三,SetTimer 在重新开始时没有先 KillTimer,导致计时器句柄重叠。对于这种小游戏,最容易查的就是在 OnPaint 里创建 CDC 和 GDI 对象却不还原。

解决:OnPaint 里的 memDC 和 bmp 都声明为局部变量,函数退出时自动析构释放 GDI 对象。字体对象声明为成员变量,在 OnInitDialog 里 CreateFont,析构函数里 DeleteObject。重新开始按钮里加一句:

if (m_timerId != 0) { KillTimer(m_timerId); m_timerId = 0; }

Debug 模式下跑完程序,在 InitInstance 退出时观察输出窗口有没有 memory leak dump,有就直接双击那行看文件行号,MFC 的 leak report 定位到具体分配点通常很准确。

5.5 游戏结束后还能继续点开格子,计时器也还在走

现象:踩到地雷游戏结束,画面上所有雷都显示出来了,但再点击其他未翻开的格子仍然能翻开;或者插旗还能继续插,秒表数字还在一秒一秒涨。

原因:消息处理函数只判断了 m_gameOver 却没有 return,或者胜利/失败分支里没调用 KillTimer,也没有把按钮和棋盘交互直接堵死。

解决:在 OnLButtonDown、OnRButtonDown 的入口统一加两行:

if (m_gameOver) return; if (!m_started) return;

这是最简单的防御。重点是胜利判断:所有非雷格全部翻开时置 m_gameOver = true 并 KillTimer,这个分支要和踩雷分支对接同一个终止逻辑。整局游戏结束的方式只有“踩雷”和“扫完”两种,两条路径最终都走同一个 FinishGame(bool won) 函数,状态就不会漏。

6. 收尾技巧:用状态机把代码整理利落,并做难度切换与自测

6.1 用枚举替代布尔组合,逻辑更清晰

布尔变量 m_started 和 m_gameOver 组合起来有四种状态,但真正的游戏只有四种:等待开局(IDLE)、游戏中(PLAYING)、胜利(WON)、失败(LOST)。用两个布尔不直观,而且容易出现“既有 started 又有 gameOver”的组合让人困惑。改成枚举后,每个消息处理函数开头只需要一个 switch 或 if 判断:

enum GameState { STATE_IDLE = 0, STATE_PLAYING, STATE_WON, STATE_LOST };

在这个状态机里,OnLButtonDown 的逻辑变为:IDLE 状态下首次点击 -> 布雷并进入 PLAYING;PLAYING 状态下点击数字格 -> 正常翻开,若踩雷则进入 LOST;WON 和 LOST 状态下所有点击直接忽略。计时器只在 PLAYING 状态跑,游戏结束进入终态后 KillTimer。重构量不大,但代码的可读性明显提升,后续加排行榜、加动画都只需要新增状态值。

6.2 难度参数化:一张表切换初级到高级

InitBoard 已经接收行、列、雷数三个参数,剩下就是定义三个预设配置。用一个结构体存配置:

struct GameConfig { int rows; int cols; int mineCount; }; const GameConfig kConfigs[] = { { 9, 9, 10 }, { 16, 16, 40 }, { 16, 30, 99 } };

切换难度本质上就是调一次 InitBoard(rows, cols, mineCount) 再更新按钮文本和窗口尺寸。窗口尺寸在 OnInitDialog 里根据当前配置计算,切难度后重新计算并 SetWindowPos 即可。这里有一个容易忘的细节:难度切换后计时器归零、插旗数归零、数组全部重建,否则从高级切回初级时旧雷区还会残留。

6.3 验证方法与一个常用技巧

你是不是把布雷逻辑写对了,不用人肉反复玩几十遍。最简单的验证方法是临时加一个调试按键,按下后把所有地雷位置设为已翻开并刷新,直接对比画面上的雷数与配置的雷数是否一致。另一个验证手段是写一个固定的随机数种子,比如 srand(1),在 debug 下打印布雷坐标,手工核对数字计算的正确性。这两种方法测试通过后,再换回正常随机种子清理临时代码。

自测时还要专门验证几个边界场景:第一次点击四角是否能正常展开,插旗数量超过实际雷数时剩余雷数是否显示为 0,游戏失败后重新开始是否完全清空且下一次布雷依旧避开首点。把这些场景列成一个清单,每改一次代码就跑一遍,比反复手动点随机开局靠谱得多。

说起验证,我个人的习惯是在 InitBoard 里临时加一行 TRACE(_T("rows=%d cols=%d mines=%d\n"), rows, cols, mines),在 VS 的输出窗口看每次初始化的参数。这个小习惯帮我查过好几次难度切换后参数没更新的问题。MFC 扫雷这个项目不大,但消息映射、GDI 双缓冲、状态流转都是 Windows 桌面开发的基础,能把它调到没有任何内存泄漏、切难度不卡、首点不踩雷,这套基本功基本就扎实了。希望这篇能帮你把这个经典练手项目做得干净利落。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询