ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MFC扫雷源码交付指南:首点保护、空白扩散与计时状态机

MFC扫雷源码交付指南:首点保护、空白扩散与计时状态机

简介:这份扫雷程序源码包面向希望通过完整案例掌握 MFC 框架、Windows 消息机制和 C++ 桌面界面编程的学习者,也可作为课程设计、小型游戏开发与控件事件练习的参考材料。压缩包共 71 个文件、约 2.7MB,内含 7 个 cpp 与 8 个 h 源文件、26 个 bmp 界面素材,以及 ico、rc 资源脚本、dll 运行库和 exe 可执行文件;成品可直接运行,工程也保留了较完整的编译与阅读结构,是学习 MFC 项目组织的实用样本。源码围绕雷区随机生成、左键翻开、右键标记、计时统计、胜负判断和主界面布局展开,自定义对话框、按钮类与资源文件相互配合,便于对照理解消息映射、控件事件处理、图标位图加载方式以及游戏状态更新流程。已有 477 人学习或下载,适合据此拆解 MFC 工程目录、调试扫雷核心逻辑,并进一步扩展难度级别、成绩记录、界面美化或换成其他棋盘规格。

1. 从答辩追问切入:一套 MFC 扫雷源码该讲清哪些交付点

MFC 扫雷这类题目看着老,真到答辩现场,老师往往不问“会不会画按钮”,而是追问首点为什么不会踩雷、空白区为什么能一次翻开、右键标记和计时器状态怎么保持一致。用 MFC 开发的扫雷游戏程序如果只堆一个窗口和一堆按钮,演示能过,源码却很难讲清。这套方案把经典扫雷玩法拆成界面层、规则层和绘制层,围绕布雷、扩散、标记、胜负判定和计时器给出可核对的实现,适合要交课程设计的学生,也适合想用 MFC 练桌面交互的开发者直接对照源码改。

2. 需求与边界:经典扫雷玩法、交互规则和验收口径

一套扫雷源码能不能交,不取决于窗口上画了多少个按钮,而取决于规则有没有闭合。经典扫雷的规则并不复杂:棋盘上有若干雷格,玩家逐格翻开,数字表示周围八格中的雷数;翻到雷判负,所有非雷格翻开判胜。麻烦的是这些规则在 MFC 消息驱动环境里会被拆成鼠标事件、重绘事件和计时器事件,任何一个状态没对齐,演示时就会露馅。拿这套源码做课程设计或毕业设计,先把需求边界写进说明,比上来就改界面更省时间。

2.1 玩法需求:格子状态、首点保护和胜负条件

每个格子至少要有四种可区分状态:未翻开、已翻开、已插旗、地雷。未翻开时只显示底色;已翻开且周围雷数为零时显示空白,大于零时显示数字;插旗格显示旗标;踩雷后所有雷格翻开,踩中的那颗用不同底色标出。棋盘参数通常做成行数、列数、雷数三个整数,入门、中级、高级只是三组预设值,核心逻辑不应该写死成 9×9。

首点保护是答辩里最容易被追问的一条。常见做法有两种:第一种是第一次左键点击后再布雷,布雷时排除以点击点为中心的 3×3 区域;第二种是先生成棋盘,如果首点落在雷上,就把这颗雷挪到其他非雷格。两种都行,但源码里必须能指出具体位置。更省事的做法是延迟布雷,m_firstClick为真时先不生成雷,等Reveal第一次被调用时再按安全区布雷。这样首点必安全,也不会出现“先布雷再判断、判断完忘了重算周围数字”的连锁错误。

胜负条件要写死:翻开所有非雷格即胜,不需要把雷全插旗;踩到雷即负,已经翻开的数字格不再响应左键。插旗数量可以大于雷数,也可以在超过时禁止继续插旗,这属于交互策略,不是胜负条件。常见误区是把“旗子插满”当成胜利,结果玩家插错旗也能过关,验收时一测就穿帮。

格子状态界面表现规则含义
未翻开凸起底色可左键翻开,可右键插旗
已插旗旗标左键不翻开,右键取消
已翻开数字或空白不再响应左键,不参与扩散
地雷雷标仅在失败后显示

注意:首点保护只保护第一次有效左键,不保护第一次右键。右键插旗不应该触发布雷,否则玩家先插旗再左键,安全区会算错。

2.2 交互需求:左键、右键、重开与窗口缩放

左键负责翻开,右键负责插旗和取消插旗,重开按钮负责清空棋盘并重置计时,关闭窗口时停掉计时器。经典扫雷还有双击快速展开:当某个数字格周围旗数等于该数字时,双击可以翻开周围未插旗的格子。这个功能不是必做项,源码里没有就别在答辩时承诺;如果要做,必须补上“旗数不足或周围有雷时的保护”,否则很容易一键踩雷。

窗口缩放要克制。MFC 对话框默认不是为自适应布局设计的,与其硬做控件重排,不如固定客户区,按格子尺寸计算棋盘左上角偏移。这样鼠标坐标换算简单,绘制也不会因为窗口拉伸而错位。若确实要做缩放,至少把格子边长、棋盘左上角、状态栏高度集中成几个成员变量,所有绘制和命中测试都从这几个变量算,不能一处写死 20 像素、另一处写死 24 像素。

重开流程要包含三件事:清空棋盘数据、把计时秒数归零、把状态栏的剩余雷数和胜负提示复位。只清棋盘不归零计时器,第二局会从上一局的时间继续跑;只归零计时器不重置m_firstClick,第二局首点保护会失效。这类问题在演示时不一定第一眼发现,但验收表里一填就暴露。

2.3 交付边界:源码、资源与可演示操作流程

“含源码”不等于“拿过去就能编译”。一套能交付的 MFC 扫雷工程,至少要有主对话框资源、棋盘绘制代码、游戏规则类、计时器与状态栏更新代码,以及一份简短说明,写清工程入口、棋盘参数在哪里改、资源 ID 对应哪个控件。源码目录里不要把编译中间产物一起打包,也不要只给一个 exe,否则接收的人无法核对实现。

可演示流程要提前排好:冷启动后先左键点一个角,确认首点安全;再点空白区,确认扩散翻开;右键插旗,确认剩余雷数递减;故意踩雷,确认所有雷显示且计时停止;点重开,确认棋盘和计时归零;最后用调试模式或直接改棋盘参数做一局胜利,确认胜利提示出现。流程不用长,但每一步都要能对应到源码里的函数,答辩时才能从界面现象讲回实现。

3. 总体架构设计:界面层、规则层与绘制层的职责划分

MFC 工程最容易写成“所有代码都塞进对话框类”:鼠标消息里改棋盘,OnPaint 里又改状态,计时器里再判断胜负。代码能跑,但一旦要改棋盘尺寸或加难度,牵一发动全身。更合理的做法是把职责拆开:对话框负责接收消息和调度,棋盘类负责规则和数据,绘制函数只读取棋盘状态。哪怕最后没有拆成三个独立类,至少函数边界要清楚,别让OnPaint里出现布雷逻辑。

3.1 工程骨架选择:对话框型还是文档视图型

MFC 扫雷常见两种骨架:对话框型和单文档/文档视图型。对话框型上手快,控件和消息映射直观,适合课程设计体量;文档视图型把数据和显示分得更开,但初学者容易在OnDraw、UpdateAllViews和文档生命周期上绕晕。如果现有源码的主窗口是CDialogEx,就按对话框型讲,别为了显得“架构完整”硬套文档视图;反过来,如果源码已经是文档视图,也没必要为了省事改成对话框。

我一般建议对话框型工程保留三个明确入口:CMainDlg处理鼠标、按钮和计时器;CGameBoard保存格子、布雷、翻开、标记和胜负判断;DrawBoard或CMainDlg::OnPaint只负责把CGameBoard的只读状态画出来。这样答辩时被问“规则在哪里”,可以直接指到CGameBoard,而不是在一堆控件事件里翻。

3.2 数据模型:格子结构体与二维网格

格子结构体不要省字段。mine表示是不是雷,revealed表示是否已翻开,flagged表示是否插旗,adjacent表示周围雷数。棋盘类里再维护行数、列数、雷数、已翻开格数和首点标记。已翻开格数用来判胜,比每次遍历全盘统计更直接;首点标记用来延迟布雷,避免首点踩雷。

#include <vector> struct Cell { bool mine = false; // 是否为地雷 bool revealed = false; // 是否已经翻开 bool flagged = false; // 是否插旗 int adjacent = 0; // 周围八格中的雷数 }; class CGameBoard { public: void Reset(int rows, int cols, int mines); bool Reveal(int r, int c); void ToggleFlag(int r, int c); bool IsWin() const; bool InRange(int r, int c) const; const Cell& At(int r, int c) const; private: void PlaceMines(int safeR, int safeC); void CalcAdjacent(); std::vector<std::vector<Cell>> m_cells; int m_rows = 0; int m_cols = 0; int m_mines = 0; int m_revealed = 0; // 已翻开的非雷格数量 bool m_firstClick = true; // 第一次左键前不布雷 };

这里用std::vector<std::vector<Cell>>而不是二维new,主要是为了避免析构遗漏和复制时的浅拷贝问题。Reset里按行列重新分配,m_revealed归零,m_firstClick置真;At返回常量引用,绘制时只读,不改状态。InRange是后面所有边界判断的公共入口,扩散、插旗、鼠标命中都调它,别在每个函数里手写一遍r >= 0 && r < rows。

3.3 消息映射与绘制入口:谁负责响应,谁负责画

MFC 的消息映射决定了鼠标和计时器从哪里进来。左键、右键、重绘、计时器、重开按钮各走各的入口,入口里只做三件事:把坐标换算成行列、调用棋盘类、请求重绘。绘制函数读取棋盘状态,不调用Reveal,也不改flagged。这条边界守住,后面加难度、换皮肤、做胜负动画都不会把规则搅乱。

BEGIN_MESSAGE_MAP(CMainDlg, CDialogEx) ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_PAINT() ON_WM_TIMER() ON_BN_CLICKED(IDC_BTN_RESTART, &CMainDlg::OnBnClickedRestart) END_MESSAGE_MAP() void CMainDlg::OnLButtonDown(UINT nFlags, CPoint point) { int r = (point.y - m_boardTop) / m_cellSize; int c = (point.x - m_boardLeft) / m_cellSize; if (m_board.InRange(r, c) && !m_gameOver) { m_board.Reveal(r, c); if (m_board.IsWin()) { m_gameOver = true; KillTimer(1); } Invalidate(FALSE); // 只请求重绘,不在消息里直接画 } CDialogEx::OnLButtonDown(nFlags, point); }

m_boardTop、m_boardLeft是棋盘左上角在客户区里的像素偏移,m_cellSize是格子边长。坐标换算必须先减偏移再除边长,否则鼠标点在第 1 格却命中第 2 格,这类错位在右键插旗时最明显。Invalidate(FALSE)只标记重绘,真正绘制在OnPaint里做;KillTimer(1)在胜利时停表,失败时同样要停。消息映射里不要漏ON_WM_TIMER,否则计时器 ID 对不上,状态栏永远不动。

4. 核心模块实现:布雷、扩散翻开、标记与计时状态机

规则层的实现顺序很关键:先重置棋盘,再在首次左键时布雷并计算周围数字,然后处理翻开和扩散,最后判胜负与停表。顺序反了就会出现“雷还没布,周围数字已经算完”或者“已经踩雷,计时器还在跑”的问题。下面四段代码分别对应四个核心动作,参数和调用点都可以直接对照源码改。

4.1 布雷与首点保护:随机数落点后如何挪雷

布雷用随机数落点,但要排除首点周围的 3×3 安全区。常见写法是循环抽格子编号,抽到已布雷或安全区就跳过,直到雷数放满。雷放完后统一计算每格周围雷数,不要在每次放雷时增量更新,否则容易把安全区外的数字算重。雷数接近格子总数时,随机循环可能长时间抽不到空位,所以参数要在合理范围内,别把 9×9 棋盘配 70 颗雷。

#include <random> #include <cstdlib> void CGameBoard::PlaceMines(int safeR, int safeC) { std::mt19937 rng(std::random_device{}()); std::uniform_int_distribution<int> dist(0, m_rows * m_cols - 1); int placed = 0; while (placed < m_mines) { int idx = dist(rng); int r = idx / m_cols; int c = idx % m_cols; if (m_cells[r][c].mine) continue; // 首点周围 3x3 不布雷,保证第一次左键安全 if (std::abs(r - safeR) <= 1 && std::abs(c - safeC) <= 1) continue; m_cells[r][c].mine = true; ++placed; } CalcAdjacent(); // 雷布完后统一计算周围雷数 }

m_mines是目标雷数,safeR、safeC是第一次左键的行列。CalcAdjacent遍历每个非雷格,统计八邻域里的mine数量写入adjacent。如果棋盘很大,可以把安全区排除改成“先放雷再检查首点,若首点有雷则与随机非雷格交换”,但课程设计规模下,排除法更直观,答辩也好讲。

4.2 空白扩散:用队列替代递归更稳

空白扩散是扫雷的核心体验:翻开一个周围雷数为零的格子后,应该继续翻开它周围八格;如果周围格也是零,再继续向外扩。递归写起来短,但深棋盘或参数异常时容易栈溢出,MFC 调试版下直接闪退。用队列做广度优先更稳,也方便在扩散过程中跳过已翻开格和插旗格。

#include <queue> #include <utility> bool CGameBoard::Reveal(int sr, int sc) { if (!InRange(sr, sc)) return false; if (m_firstClick) { PlaceMines(sr, sc); m_firstClick = false; } std::queue<std::pair<int, int>> q; q.push({sr, sc}); while (!q.empty()) { auto [r, c] = q.front(); q.pop(); if (!InRange(r, c)) continue; Cell& cell = m_cells[r][c]; if (cell.revealed || cell.flagged) continue; if (cell.mine) return false; // 踩雷,交给界面做失败处理 cell.revealed = true; ++m_revealed; if (cell.adjacent == 0) { // 只有空白格才继续向外扩,数字格到此为止 for (int dr = -1; dr <= 1; ++dr) { for (int dc = -1; dc <= 1; ++dc) { if (dr == 0 && dc == 0) continue; q.push({r + dr, c + dc}); } } } } return true; }

Reveal的返回值表示这次左键有没有踩雷。踩雷时不要在这里直接改m_gameOver,让对话框层根据返回值停表、显示所有雷,规则层只负责判断。队列里会压入重复坐标,靠revealed和flagged跳过,所以不需要额外的visited数组。adjacent == 0才继续扩散,数字格不扩散,这条判断写错就会出现“点一下全盘翻开”的假象。

4.3 标记与胜负判定:右键循环、剩余雷数和胜利条件

右键插旗只改未翻开格,已翻开格和已插旗格按交互策略处理。常见做法是右键在“无标记”和“旗标”之间切换,不做问号标记;如果源码做了问号,就要把问号也算作非旗状态,剩余雷数不能重复扣。剩余雷数一般用“总雷数减已插旗数”显示,但胜负判断仍然看已翻开的非雷格数量,不能看旗数。

void CGameBoard::ToggleFlag(int r, int c) { if (!InRange(r, c)) return; Cell& cell = m_cells[r][c]; if (cell.revealed) return; // 已翻开的格子不能再插旗 cell.flagged = !cell.flagged; } bool CGameBoard::IsWin() const { // 所有非雷格都翻开即胜利,与插旗数量无关 return m_revealed == m_rows * m_cols - m_mines; }

m_revealed只在Reveal成功翻开非雷格时递增,插旗和取消插旗都不改它。剩余雷数可以在状态栏更新函数里算:m_mines - FlagCount()。如果插旗数超过雷数,可以选择禁止继续插旗,也可以允许负数显示,但要在说明里写清楚。验收时重点看两种局面:只插旗不翻开不能胜利;所有非雷格翻开后,即使还有雷没插旗,也要提示胜利。

4.4 计时器与重开:状态复位别留下脏数据

计时器用SetTimer起一个一秒间隔的定时器,OnTimer里递增秒数并更新状态栏。重开时先KillTimer再重置棋盘,最后重新SetTimer,避免同一 ID 被重复设置导致回调次数叠加。胜利和失败都要停表,否则玩家踩雷后秒数还在涨,答辩演示时很尴尬。

void CMainDlg::StartGame() { KillTimer(1); // 防止重复 SetTimer m_board.Reset(9, 9, 10); // 行、列、雷数按难度替换 m_seconds = 0; m_gameOver = false; SetTimer(1, 1000, nullptr); // 1 秒一次 UpdateStatusBar(); Invalidate(FALSE); } void CMainDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && !m_gameOver) { ++m_seconds; UpdateStatusBar(); // 只更新状态栏,不整窗重绘 } CDialogEx::OnTimer(nIDEvent); }

OnTimer里不要调用Invalidate全窗口重绘,扫雷棋盘格子多时一秒一次全量重绘会闪。状态栏用SetDlgItemInt或SetWindowText单独更新即可。StartGame里先KillTimer是关键,MFC 对同一窗口的同一计时器 ID 重复SetTimer不会自动去重,回调会叠加,秒数越跑越快,这就是常见的“计时器玄学”。

5. 常见问题排查:MFC 扫雷最容易翻车的五类现场

扫雷代码量不大,但状态多、坐标多、消息多,翻车点很集中。下面五类问题在课程设计验收里出现频率很高,每一类都按“现象、原因、解决”拆开写,方便对着源码排查。调试时别只看界面,先把棋盘数据打印出来,很多问题一眼就能定位。

5.1 首点仍踩雷:布雷时机和坐标换算错位

现象:玩家第一次左键点下去就踩雷,或者点角落安全、点中间不安全。原因通常有两个:一是先布雷后判断,首点保护只在OnLButtonDown里判断了一次,但Reveal内部又调用了布雷;二是鼠标坐标换算错了,传给PlaceMines的safeR、safeC和实际点击格不是同一个。解决:把布雷统一收口到Reveal的m_firstClick分支里,OnLButtonDown只负责坐标换算和调用;在PlaceMines入口加一条调试断言,确认safeR、safeC在棋盘范围内,再把安全区打印出来对照点击位置。

5.2 空白区扩散卡死或闪退:递归深度与边界判断

现象:点开一个空白格后程序卡住,或者调试版直接闪退。原因多是把扩散写成递归,且没有在递归前判断revealed,导致相邻格互相调用;也有的是八邻域循环没有跳过dr == 0 && dc == 0,格子反复压入队列。解决:用队列做广度优先,压入前不判断、弹出后统一判断InRange、revealed、flagged;八邻域循环里明确跳过自身。调试时限制棋盘为 5×5、1 颗雷,点一次空白格,看队列弹出次数是否收敛。

5.3 右键标记串格:命中测试与客户区坐标没统一

现象:右键点 A 格,旗子插到 B 格;窗口移动后偏移更明显。原因通常是OnRButtonDown里用了屏幕坐标,或者棋盘左上角偏移在绘制和命中共用了两个不同变量。解决:鼠标消息统一用客户区坐标point,先减m_boardLeft、m_boardTop,再除m_cellSize;绘制时也用同一组偏移。可以在OnRButtonDown里加一行调试输出,打印point、换算后的r、c,对照界面格子位置,基本一次就能看出偏差。

5.4 计时器越跑越快:SetTimer 重复调用与生命周期

现象:重开几次后秒数一秒跳两三下,或者胜利后计时还在涨。原因:StartGame里没有先KillTimer,同一 ID 的计时器被重复设置;或者胜利/失败分支只改了m_gameOver,没有KillTimer。解决:所有开始入口先KillTimer(1)再SetTimer(1, 1000, nullptr);胜负判定后立即停表;窗口销毁时也停一次。OnTimer里加if (nIDEvent != 1) return;的防御,避免其他计时器事件误入。

5.5 发布版能跑,换机器报错:资源路径与字符集不一致

现象:在自己机器上调试正常,拷到另一台机器上提示找不到资源,或者中文状态文字乱码。原因:资源文件用了绝对路径,或者工程字符集设置不一致,源码里窄字符串和宽字符串混用。解决:资源统一用工程内相对路径,图片和图标随工程目录一起拷贝;字符串统一用_T()或宽字符,状态栏更新不要混用char和wchar_t。交付前在一台没装过开发环境的机器上跑一次 Release,确认资源、字体和对话框布局都正常。

6. 验收与答辩:用可复现步骤把源码讲成自己的方案

验收时我一般不让同学直接演示“随便点几下”,而是按一张固定表走。表格不用多,五行就够,每行都能对应到源码函数。答辩老师顺着表问,回答就不会散。

验收项操作通过标准
首点安全冷启动后点棋盘中心第一次左键不踩雷,空白区正常扩散
标记与计数右键插旗、再右键取消旗标切换正确,剩余雷数同步变化
失败流程故意点一颗雷所有雷显示,计时停止,重开可复位
胜利流程改小棋盘或调试模式翻开所有非雷格出现胜利提示,计时停止
换机运行拷贝 Release 到另一台机器资源不缺失,中文显示正常

答辩时先讲规则层,再讲消息入口,最后讲绘制。规则层只讲Reset、Reveal、ToggleFlag、IsWin四个函数;消息入口讲左键、右键、计时器三条映射;绘制讲“只读棋盘状态、不改数据”。被问到“为什么不用递归”就答队列避免深棋盘栈溢出,被问到“首点保护在哪”就指m_firstClick和PlaceMines的安全区判断。从那以后我每次交这类桌面小项目,都强制自己先关掉调试输出、换一台没装过环境的机器跑一遍,再拿着验收表逐项演示。希望帮到你。

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

返回列表