手写MFC扫雷:算法、界面与状态机深度解析
2026/9/8 6:32:56 网站建设 项目流程

简介:这是一份基于 C++ 与 MFC 框架开发的扫雷游戏完整工程,适合正在学习 Windows 桌面应用开发的初学者,也可作为 C++ 课程大作业的参考方案。工程实现了雷区布局、周围雷数统计、左键挖开、右键标记以及踩雷失败与胜利判定等经典扫雷玩法,覆盖二维数组建模、消息映射、控件交互、异常处理等关键知识点。资源共 31 个文件,其中 17 个 bmp 位图用于雷区数字与状态显示,8 个 h 和 6 个 cpp 文件分别对应头文件与业务逻辑实现,压缩包大小仅 33KB,代码量轻量但结构完整,便于逐模块阅读改造。目前已有 1565 人学习使用,足见其参考价值。通过阅读代码,可以清晰看到 MFC 程序从文档/视图框架到具体游戏逻辑的搭建过程,尤其适合希望快速理解 MFC 消息机制和界面编写的同学对照练习。 手写一个MFC扫雷,最难的其实不是算法,而是怎么让MFC这套老框架乖乖听话。本文从选题逻辑讲起,把布雷算法、MFC绘制方案、交互状态机这些核心模块拆开揉碎,最后再聊聊我调试过程中踩过的坑。如果你也在做C++大作业,或者想在Windows桌面开发里找点练手项目,这篇应该能给你省不少时间。

1. 为什么选MFC做扫雷:大作业选题背后的真实考量

每到C++课程期末,选题就成了最头疼的事情。控制台版的图书管理系统、学生信息管理系统确实好写,但答辩时几个同学往台上一站,做的全是同一个东西,毫无区分度。我当时选MFC扫雷,说白了就是看中它两点:一是界面效果足够直观,评阅老师一眼就能看出"这是个完整的程序";二是MFC框架本身自带窗口、消息、资源管理一整条链,做完这个项目,你对Windows编程的理解会比只看教材深入很多。

选择扫雷这个具体题材还有个现实原因:它的规则足够简单,逻辑复杂度却又恰到好处。一个9x9棋盘、10颗雷的初级局,涉及的核心逻辑包括随机布雷、周边雷数统计、空白格自动展开、胜利/失败判定、计时器管理,再加上鼠标左右键的交互组合。这些功能拆开来看没有一个特别难的,但合在一起,刚好能覆盖MFC里最常见的几类操作:定时器消息、鼠标消息、自绘控件、位图资源加载。换句话说,做完扫雷,你就等于把MFC最常用的那部分API都过了一遍。

如果你用的是VS2013甚至更高版本,新建项目时记得选"基于对话框"而不是"单文档"。扫雷这种纯网格交互的程序,对话框模板比Doc/View架构简单直接得多,不需要跟文档序列化这些概念纠缠。我第一次做的时候选了单文档框架,结果光是被框架自动生成的代码就绕晕了,后来干脆重建项目,改用对话框,清爽太多。

2. 游戏核心逻辑拆解:布雷、计数与自动展开的实现思路

2.1 棋盘的数据结构设计

扫雷棋盘本质是一张二维网格。我的做法是定义两个二维数组,一个存格子状态,一个存格子内容,这样逻辑和表现分离,后面画界面的时候会非常省心。

#define GRID_SIZE 9 // 9x9 棋盘 #define MINE_COUNT 10 // 10 颗雷 enum CellContent { EMPTY, // 空格 MINE // 雷 }; enum CellState { HIDDEN, // 未翻开 REVEALED, // 已翻开 FLAGGED // 已标旗 }; CellContent m_content[GRID_SIZE][GRID_SIZE]; // 地雷分布 CellState m_state[GRID_SIZE][GRID_SIZE]; // 每个格子的显示状态 int m_mineCountAround[GRID_SIZE][GRID_SIZE]; // 周围雷数

注意第三个数组,它是初始化的关键成果:每个格子的周边雷数在布雷阶段就计算好并缓存起来,后面画数字的时候直接取用,不用每次点击都重新计算一遍。

2.2 布雷算法:如何保证第一次点击必不死

很多新手写扫雷,第一步就直接随机布雷。这样带来的问题是:玩家第一次点击就踩雷,体验极差。正规扫雷程序的通行规则是——第一次点击永远不会踩雷。实现方式有两种:一是布雷后检查点击位置,如果踩雷就重新布;二是先响应第一次点击,把点击位置排除掉再布雷。我采用的是后者,逻辑更干净。

void CMinesweeperDlg::InitMines(int firstRow, int firstCol) { // 先把所有位置清空 memset(m_content, 0, sizeof(m_content)); // 把第一次点击的位置排除在外 std::vector<POINT> candidates; for (int r = 0; r < GRID_SIZE; r++) { for (int c = 0; c < GRID_SIZE; c++) { if (r == firstRow && c == firstCol) continue; // 进一步优化:连周边8格也排除,开局体验更友好 if (abs(r - firstRow) <= 1 && abs(c - firstCol) <= 1) continue; candidates.push_back({c, r}); } } // 随机选取 MINE_COUNT 个位置布雷 std::random_shuffle(candidates.begin(), candidates.end()); for (int i = 0; i < MINE_COUNT; i++) { POINT pt = candidates[i]; m_content[pt.y][pt.x] = MINE; } // 计算周围雷数 for (int r = 0; r < GRID_SIZE; r++) { for (int c = 0; c < GRID_SIZE; c++) { m_mineCountAround[r][c] = CountAdjacentMines(r, c); } } }

CountAdjacentMines就是遍历当前格子周围8个方向,统计雷的数量。这里有个细节很多人会忽略:边界上的格子只有3个或5个邻居,如果不做边界检查,数组越界是妥妥的。所以这个函数里最好把边界判断写清楚,或者写一个通用的"是否合法坐标"辅助函数,一劳永逸。

2.3 空白格自动展开:递归与栈的实战应用

扫雷体验最爽的瞬间,就是点到一块空白区域呼啦一下展开一大片。这个逻辑在计算机里叫Flood Fill(洪水填充),是图论里最基础的遍历算法之一。实现上有递归和栈两种方式,我建议用栈。

为什么不用递归?因为递归虽然写起来简洁,但深层的递归调用在Debug模式下可能爆栈——虽然9x9棋盘理论上最大深度不超过81层,但MFC的消息响应本身就在调用栈上,尤其是当你在OnLButtonDown里触发展开时,栈空间已经被占了不少,没必要冒这个险。用显式栈(std::stack或者std::queue)反而更安全,也更好调试。

void CMinesweeperDlg::ExpandBlankArea(int row, int col) { std::stack<POINT> stack; stack.push({col, row}); while (!stack.empty()) { POINT pt = stack.top(); stack.pop(); int c = pt.x; int r = pt.y; // 跳过无效坐标和已翻开/已标旗的格子 if (!IsValidCoord(r, c)) continue; if (m_state[r][c] != HIDDEN) continue; // 翻开当前格子 m_state[r][c] = REVEALED; // 如果是数字格,停止扩散 if (m_mineCountAround[r][c] > 0) continue; // 如果是空格,把周围8个格子入栈 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; stack.push({c + dc, r + dr}); } } } Invalidate(); // 触发重绘 }

一个容易翻车的点是:如果周围8格里有标旗的格子,自动展开时不能把它翻开,否则玩家的红旗白插了。所以上面代码里加了if (m_state[r][c] != HIDDEN) continue;,HIDDEN和FLAGGED两种状态分得很清,标旗的格子永远不会被误翻开。细心的读者可能会问,游戏规则里如果你标错了旗,自动展开时那个格子实际上是安全的吗?这里我选择的是保守策略:只要标了旗就不动它,留给玩家自己去决策。

3. MFC界面层:从网格绘制到自定义按钮的选择

3.1 为什么我不用CButton而是自绘

扫雷网格的一种入门做法是使用9x9共81个CButton控件,每个按钮处理鼠标点击。好处是代码直观,坏处也很明显:一是对话框资源编辑器里手动拖81个按钮太痛苦;二是CButton的自绘(Owner Draw)涉及WM_DRAWITEM消息,处理起来不比完全自绘简单;三是按钮控件无法直接实现"长按左键临时标问号"这种扫雷特有交互。

所以我的方案是在对话框的OnPaint里把整个棋盘一次性绘制出来,完全自绘。这样还有一个额外的好处:棋盘的尺寸、格子大小、间距可以随时调整,只要改一个常量就行,不用动资源编辑器。

3.2 棋盘绘制的三层结构

自绘棋盘我分成了三层逻辑:

第一层是底图。用一个黑色或深灰色Brush填充棋盘背景,然后再用浅色线条画出网格线。这样做的好处是视觉上先确定边界,格子之间的分割感更强。

第二层是格子内容。遍历整个棋盘,根据m_state和m_mineCountAround两个数组决定每个格子画什么:

  • HIDDEN状态:画一个凸起的立体方块。用两个颜色深浅不同的矩形错位叠加,模拟出3D按钮效果。
  • REVEALED状态且周边雷数为0:画一个凹下去的平面,颜色调暗一点,表示已经翻开。
  • REVEALED状态且周边雷数为N:在格子中心画数字N,数字颜色用经典的扫雷配色(1蓝、2绿、3红、4深蓝、5褐、6青、7黑、8灰)。
  • FLAGGED状态:画红旗。
  • MINE且REVEALED:画地雷(踩雷后显示)。

第三层是状态覆盖。比如游戏结束后,用半透明遮罩把整个棋盘盖住,或者直接把雷的位置全部显示出来。这个不是必须的,但做了之后程序会显得更完整。

贴一个绘制单个格子的核心代码片段:

void CMinesweeperDlg::DrawCell(CDC* pDC, int row, int col) { CRect cellRect = GetCellRect(row, col); switch (m_state[row][col]) { case HIDDEN: // 凸起效果:上边和左边亮色,下边和右边暗色 pDC->FillSolidRect(cellRect, RGB(192, 192, 192)); // 这里省略画边框高光的几行代码 break; case REVEALED: pDC->FillSolidRect(cellRect, RGB(220, 220, 220)); if (m_content[row][col] == MINE) { DrawMine(pDC, cellRect.CenterPoint()); } else if (m_mineCountAround[row][col] > 0) { DrawNumber(pDC, m_mineCountAround[row][col], cellRect.CenterPoint()); } break; case FLAGGED: pDC->FillSolidRect(cellRect, RGB(192, 192, 192)); // 画小旗子 DrawFlag(pDC, cellRect.CenterPoint()); break; } }

每次刷新棋盘,直接调用Invalidate触发OnPaint,然后OnPaint里循环调用DrawCell。这套结构非常清晰,后面想要加动画效果、加主题皮肤,都是在DrawCell这个函数里做文章。

3.3 鼠标坐标与棋格的换算关系

界面绘制完成后,下一个核心问题就是:鼠标点下去,怎么知道点的是哪个格子?

这个换算其实很简单。假设棋盘左上角的像素坐标是(boardOriginX, boardOriginY),格子边长是cellSize,那么:

int col = (point.x - boardOriginX) / cellSize; int row = (point.y - boardOriginY) / cellSize; if (col < 0 || col >= GRID_SIZE || row < 0 || row >= GRID_SIZE) { // 点到了棋盘外面,忽略 return; }

有一个细节值得单独提醒:棋盘外边缘要预留一定的padding,否则玩家点到格子边缘线时,区域判断会很尴尬。我通常留出8到10像素的边框,这样视觉上也更舒服。

4. 游戏状态机与交互细节:计时、状态切换和胜负判定

4.1 用状态机管理游戏流转

扫雷程序虽然小,但涉及的状态其实不少:游戏未开始、游戏中、胜利、失败。如果不用状态机,代码会变成一堆if-else互相嵌套,稍微加点功能就乱套。我用一个简单的枚举类型管理当前状态,所有逻辑入口第一件事就是检查当前状态是否允许该操作。

enum GameStatus { GS_READY, // 就绪,等待第一次点击 GS_PLAYING, // 游戏中 GS_WON, // 胜利 GS_LOST // 失败 };

举个例子:在GS_READY状态下,玩家的第一次点击不能布雷——这正好和2.2节里的InitMines联动。第一次点击后调用InitMines并切换到GS_PLAYING状态,同时启动计时器。

在GS_WON或GS_LOST状态下,玩家的所有点击都无效,只能点"重新开始"按钮。这样逻辑判断就清晰了:

void CMinesweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_gameStatus == GS_WON || m_gameStatus == GS_LOST) return; if (m_gameStatus == GS_READY) { m_gameStatus = GS_PLAYING; InitMines(row, col); SetTimer(TIMER_GAME, 1000, NULL); } // ... 省略格子的翻开操作 }

4.2 计时器和雷数显示的更新

计时器的实现很简单,对话框OnInitDialog里用SetTimer设置一个每秒触发一次的定时器,然后在OnTimer消息响应里累加秒数并刷新显示文本。

void CMinesweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_GAME) { m_elapsedSeconds++; CString strTime; strTime.Format(_T("%d"), m_elapsedSeconds); SetDlgItemText(IDC_TIME_DISPLAY, strTime); // 简单倒计时可以在此基础上扩展 } CDialogEx::OnTimer(nIDEvent); }

剩余雷数的显示逻辑也一样:用一个变量记录剩余红旗数(总雷数减去已标记的旗子数),每次插旗/拔旗时更新并刷新显示。

这里有个游戏规则细节值得注意:剩余雷数显示的是"剩余未标记的雷数",它只做一个展示作用,并不影响游戏逻辑。玩家可以乱插旗子,插得比雷总数还多,程序也不应该崩。所以这个数值显示到0以下时要自动卡在0,免得对话框显示负数或者溢出。

4.3 胜负判断的两种触发时机

胜利条件是:所有非雷格子都被翻开。这里有两个实现思路。常规做法是在每次翻开格子后计数,统计已翻开的格子数,当它等于GRID_SIZE * GRID_SIZE - MINE_COUNT时,游戏胜利。

但我测试后发现这种做法有一个小问题:玩家把红旗插错了地方。比如一个数字是8的格子旁边有8颗雷,但有一处玩家插了红旗——红旗插在安全格子上,而真正有雷的格子反而是空白的。如果此时玩家没有踩到雷就把所有安全格子翻开,按上面的逻辑游戏会判定胜利,但实际上有一处雷没有正确标记。

严格来说,扫雷的胜利条件从来不是"把雷都标出来",而是"把所有安全格子翻开"。标红插旗只是辅助手段,插错旗不影响胜利判定——这就是标准扫雷Windows版的实际规则。所以判断条件严格基于"已翻开的非雷格子数",才是正确行为。我在第一个版本里错误地加入了"所有雷都被标记"这个条件,结果导致玩家只要插错一面旗就永远无法获胜,这个Bug困扰了我一整个晚上。

失败判断就简单了:只要翻开的格子内容是MINE,直接进入GS_LOST状态,把踩到的雷标记成红色背景,同时展开所有没被标记的雷的位置。

4.4 右键双击快速翻开(Chord操作)

扫雷老玩家都会用Chord操作:当数字格子周围的旗子数等于该数字时,在这个格子上同时按下左右键(或者双击),会自动翻开周围所有未标记的格子,如果旗子插错了,还能直接引爆。这算是扫雷最核心的效率玩法,虽然大作业不实现也完全说得过去,但实现了会让程序上一个档次。

MFC里对Chord操作的处理比想象中友好。可以用WM_LBUTTONDOWN和WM_RBUTTONDOWN同时检测,或利用WM_MBUTTONDOWN实现完整的Chord逻辑。我这里用的是基于状态数组的方案:

void CMinesweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { // ... 上面处理第一次点击 if (m_state[row][col] == REVEALED && m_mineCountAround[row][col] > 0) { int flags = CountAdjacentFlags(row, col); if (flags == m_mineCountAround[row][col]) { ExpandAroundNumber(row, col); // 自动展开周围所有未标记格子 } return; } // 普通翻开逻辑... }

原理很简单:一个已经翻开的数字格子,如果周围标旗数量等于该数字,就执行自动展开。这个逻辑如果我一开始就设计好,后面积累的代码会更清晰,而不是在已经写好的GameLoop上缝缝补补。

5. 实际踩过的坑:从界面错位到数组越界

5.1 位图资源加载失败的诡异事件

我第一次想做带图标的扫雷,在网上找了一套地雷和红旗的BMP位图,用CBitmap::LoadBitmap加载,结果运行时一直显示空白。后来排查了半天,发现是图片格式的问题:VS的资源编辑器只支持特定格式的位图,我下载的PNG直接改名成BMP后缀,资源加载自然失败。解决办法很简单:用Windows自带的画图软件把图片转成24位BMP,再加入资源。

这个坑看似小,但排查过程很折磨人。好在后来我把绘雷、绘旗的逻辑改成纯代码绘制——用几个圆形和矩形拼出地雷的造型——结果反而更灵活,不用再担心外部资源文件丢失的问题。

5.2 OnPaint里写死COORD导致的高DPI闪退

Debug模式下程序跑得好好的,但是把显示器缩放比例从100%调到150%之后,界面就错乱了。原因是MFC默认不处理DPI缩放,而GetSystemMetrics返回的屏幕尺寸在高DPI下会和CDC的映射模式对不上。解决方法是调用SetProcessDPIAware(),或者在资源文件中声明DPI感知,让程序运行时系统强制缩放。

这个问题在大作业答辩一般不会遇到(多半是用默认100%缩放),但如果你把程序拷到别的电脑上演示,很容易翻车。我在实验室的电脑上演示时就直接错乱了,当场被老师指出,狼狈得很。所以我现在每写一个MFC程序,第一件事就是加上DPI适配。

5.3 棋盘边缘展开时令人抓狂的越界Bug

自动展开的递归/栈算法里,最容易出的问题就是越界。9x9棋盘,右下角格子的坐标是(8, 8),如果展开时访问(9, 8),Debug模式下非常容易崩。这个问题的根源是大多数算法都只检查了"当前格子"的合法性,但忘了周围入栈的格子也可能越界。

解决方案我已经写在上面的ExpandBlankArea函数里了:入栈前不检查合法性,而是弹栈后再用IsValidCoord检查一遍。这样代码更简洁,也避免了重复判断。曾经有同学抄我的代码,把这个检查放在入栈前,结果边界处仍然越界,卡了很久。后来他发现问题后跟我说,弹栈时检查比入栈时检查稳健得多。

5.4 消息响应与控件ID冲突

对话框程序里,如果棋盘上的某个格子恰好覆盖在一个控件上,鼠标消息会被控件拦截,OnLButtonDown根本不会触发。这种情况多发生在"开始新游戏"按钮附近。解决办法有两种:一是布局时避开控件重叠区,二是把所有交互都放在对话框的OnLButtonDown中处理,棋盘区域内的所有控件都设置成WS_EX_TRANSPARENT或者直接不创建。

我实际用的是"只用静态图片控件展示计时和雷数,棋盘区域纯自绘"的方案,这样消息路径非常干净,不需要处理复杂的消息分发。

6. 大作业答辩经验谈:如何让MFC项目显得更完整

如果你的扫雷最终要交给老师验收,除了功能齐全,还有几个细节可以让程序看起来专业很多。个人经验是,这些细节占用的工作量不大,却往往能给评委留下很好的第一印象:

一是菜单栏和快捷键。给对话框加一个菜单栏,放上"游戏 → 新游戏(F2)→ 初级(F5)→ 中级(F6)→ 高级(F7)→ 退出"这些选项。实现起来也很简单:资源编辑器里添加菜单资源,再到OnInitDialog里SetMenu关联即可,半小时就能搞定,但效果是实实在在的。

二是难度选择逻辑。不要只做一张9x9棋盘,把棋盘尺寸和雷数做成可配置项,根据菜单选择动态调整GRID_SIZE和MINE_COUNT。这样项目介绍时就能说"支持三种难度",比单纯做一个固定棋盘有说服力得多。

三是记录最快通关时间。很多同学会忽略这个,但扫雷的计时器一旦实现,记录最好成绩并持久化到文件是水到渠成的事。用简单的CStdioFile写一个文本文件,记录每人难度下的最佳成绩,游戏结束时对比并提示"New Record"。这个功能看似小,但它是整个项目里唯一一个"存档"逻辑,老师问到文件操作时你就有话说了。

第四点特别想提醒:代码注释和命名。说实话,大作业的代码质量不会有人逐行审查,但如果变量名全部是a、b、c这种,注释一行不写,老师翻阅时第一印象就会打折扣。我在写这个项目时,把所有函数分成三个文件:MinesweeperDlg.h/cpp(界面与交互)、MinesweeperLogic.h/cpp(核心算法)、Resource.h(资源ID定义)。逻辑文件完全不依赖MFC头文件,纯C++就能编译运行。这样一来,核心算法可以用控制台程序单测,界面层只是它的一个壳。这种分层思路在答辩时一讲,老师会觉得你有软件工程意识,分数自然不一样。

7. 结语与之后还能做什么

做这个MFC扫雷项目,前前后后大概用了一周多。第一版两天就写完核心逻辑,然后是界面美化、修复边界Bug、添加Chord功能、适配DPI,又花了好几天。写完之后我最大的感受是:MFC虽然老,但对于理解Windows消息循环和GDI绘制这套机制,依然是不可多得的教材。你做扫雷时产生的每一个问题,比如为什么Invalidate只画了部分区域重绘会闪烁、为什么消息会被子控件吞掉、为什么坐标换算在缩放下失效了,放到实际Windows开发里都是真实存在的坑。

如果你做完这个项目还意犹未尽,可以往这几个方向再走一步:把自绘逻辑改成GDI+,让方块看起来更圆润;加一个排行榜窗口,用CListCtrl显示历史记录;或者换一张更大的棋盘,接上鼠标拖拽选择区域的操作,做成一款可玩性更高的扫雷变体。这些扩展没有一个算难,但每一步都能逼着你再深入理解一点Windows编程,收获自然不会差。回头再看,大作业的意义大概就在于此吧。

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

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

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

立即咨询