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::vectorstd::vectorCell m_cells; int m_rows 0; int m_cols 0; int m_mines 0; int m_revealed 0; // 已翻开的非雷格数量 bool m_firstClick true; // 第一次左键前不布雷 };这里用std::vectorstd::vectorCell而不是二维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_distributionint 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::queuestd::pairint, 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的安全区判断。从那以后我每次交这类桌面小项目都强制自己先关掉调试输出、换一台没装过环境的机器跑一遍再拿着验收表逐项演示。希望帮到你。本文还有配套的精品资源点击获取