ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手写MFC扫雷:算法、界面与状态机深度解析

2026/9/8 6:32:58 拓冰建站 浏览量
手写MFC扫雷:算法、界面与状态机深度解析 简介这是一份基于 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::vectorPOINT 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::stackPOINT 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编程收获自然不会差。回头再看大作业的意义大概就在于此吧。本文还有配套的精品资源点击获取