简介:这份资源是面向C++初学者与Windows桌面开发入门者的MFC扫雷完整工程,可直接作为课程大作业或练手项目,帮助读者打通C++语法、MFC框架与图形界面设计之间的衔接。压缩包共31个文件,约33KB,以17个bmp位图资源、8个头文件和6个源文件为主,位图用于界面美化,头文件与cpp文件分别承载视图、文档、主框架及雷区逻辑等模块,结构贴近MFC文档视图架构。已有1571人学习下载,具备一定参考热度。工程完整呈现了二维数组表示雷区、消息映射响应点击、遍历相邻八格计算周围雷数、踩雷判定与状态管理等关键环节,并包含新游戏初始化、标记功能与资源文件加载等实现,读者可据此理解事件驱动编程流程,借助Visual Studio调试工具排查逻辑问题,进而掌握MFC项目从界面搭建到算法落地的完整思路。
1. 从一份能跑起来的 C++/MFC 扫雷说起
很多人学完 C++ 语法,面对课程大作业却不知道从哪下手:控制台程序写腻了,想做个带界面的完整项目,又怕 MFC 太老、坑太多。这份 C++/MFC 扫雷完整程序正好卡在这个位置上——它不是零散的知识点演示,而是一个能编译、能运行、能交作业的桌面程序,把窗口、消息循环、绘图、鼠标交互、游戏逻辑全串了一遍。适合两类人:一是正在找 C++ 大作业参考的学生,二是想借一个小而完整的 MFC 项目摸清 Windows 桌面开发套路、之后做二次开发的开发者。下面我按「这项目怎么组织、怎么跑起来、核心逻辑长什么样、哪里容易翻车、怎么改」的顺序拆一遍。
2. 项目结构与技术选型:MFC 文档视图还是对话框
2.1 为什么是 MFC 而不是 Qt
扫雷这种程序,界面元素少、交互简单,核心是二维网格的状态管理和鼠标点击响应。用 MFC 做,好处是 Windows 原生、不引入第三方库、编译产物小,课程环境里通常装好 Visual Studio 就能直接开;代价是 MFC 的 API 风格偏老,消息映射、GDI 绘图这些概念对新手不友好。这个项目选 MFC,本质是贴合「C++ 大作业」的场景:老师环境统一、不需要额外配 Qt 的编译链,交上去双击 exe 就能跑。
常见做法是用 MFC 的对话框程序(Dialog-based)而不是文档视图(Doc/View)架构。扫雷没有「文档」概念,不需要序列化、不需要多视图,对话框程序更直接:一个主对话框承载整个游戏界面,所有逻辑挂在对话框类里。如果你拿到的工程是 Doc/View 结构,也能改,但会多出一层 View 的转发,初学阶段没必要。
2.2 目录与文件职责
一个典型的 MFC 对话框版扫雷,工程文件大致是这样组织的:
MineSweeper/ ├── MineSweeper.sln // 解决方案文件 ├── MineSweeper/ │ ├── MineSweeper.h/.cpp // 应用类,InitInstance 里创建主对话框 │ ├── MineSweeperDlg.h/.cpp// 主对话框类,游戏逻辑和界面都在这 │ ├── MineSweeper.rc // 资源脚本,对话框布局、菜单、图标 │ ├── resource.h // 资源 ID 定义 │ ├── stdafx.h/.cpp // 预编译头(老版本 VS) │ └── res/ // 图标、位图等资源真正要读的是MineSweeperDlg.h/.cpp这一对文件。头文件里定义网格数据结构、游戏状态枚举、按钮或自绘区域的控制变量;cpp 里是初始化、绘图、鼠标消息处理、胜负判定。资源文件.rc决定对话框上放了什么控件——有的实现用一堆按钮拼网格,有的用一个静态控件自绘整个棋盘,两种思路后面都会讲到。
2.3 两种界面实现路线的取舍
用按钮拼网格,优点是每个格子天然有点击消息,不用自己算坐标;缺点是 9×9 就要 81 个按钮控件,16×16 更是 256 个,资源文件巨大、创建慢,而且按钮样式定制麻烦。自绘路线是在对话框上放一个固定大小的静态控件或直接在主窗口客户区画,鼠标点击时用坐标反推行列,代码更集中、扩展性强,但需要自己处理OnPaint和坐标换算。
我一般推荐自绘,因为扫雷的格子状态多(未翻开、已翻开、插旗、问号、地雷、爆炸),按钮要表达这些状态得频繁改文字和颜色,反而绕。自绘用 GDI 的Rectangle、FillSolidRect、DrawText几个函数就能把状态画清楚。下面核心代码部分按自绘路线讲,如果你手上是按钮版,逻辑层(布雷、翻格、判负)是通用的,只是把绘制换成SetWindowText和EnableWindow。
3. 核心逻辑拆解:布雷、翻格与胜负判定
3.1 数据结构:二维数组怎么存格子状态
扫雷的每个格子要记两件事:是不是雷,以及当前展示状态。常见做法是用两个二维数组,一个存雷的分布(bool或int),一个存界面状态(枚举)。也可以合并成一个结构体数组,可读性更好:
// MineSweeperDlg.h enum CellState { CS_COVERED = 0, // 未翻开 CS_OPENED, // 已翻开 CS_FLAG, // 插旗 CS_QUESTION, // 问号 CS_MINE, // 显示为雷(游戏结束时) CS_WRONG_FLAG // 插错的旗(游戏结束时) }; struct Cell { bool isMine; // 是否是地雷 int adjacent; // 周围 8 格的地雷数,0~8 CellState state; // 当前界面状态 }; const int ROWS = 9; const int COLS = 9; const int MINE_COUNT = 10; Cell m_grid[ROWS][COLS]; int m_openedCount; // 已翻开的安全格数,用于判胜 bool m_gameOver; bool m_win;adjacent在布雷后统一算一次,之后翻格时直接读,避免每次点击都重新数周围。m_openedCount是判胜的关键:当它等于ROWS*COLS - MINE_COUNT时,所有非雷格都翻开了,玩家赢。这个计数比每次遍历整个棋盘判断「是否还有未翻开的非雷格」高效,也不容易写错。
3.2 首次点击保护与布雷
新手写扫雷最容易翻车的地方是「第一下就踩雷」。合格的做法是首次点击后再布雷,并且保证第一下点的格子及其周围 8 格都不是雷。这样第一下必定展开一片,体验才对:
void CMineSweeperDlg::PlaceMines(int safeRow, int safeCol) { // 先把所有格子重置 for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) { m_grid[r][c].isMine = false; m_grid[r][c].adjacent = 0; m_grid[r][c].state = CS_COVERED; } // 随机布雷,跳过安全区(首点及其周围) int placed = 0; srand((unsigned)time(nullptr)); while (placed < MINE_COUNT) { int r = rand() % ROWS; int c = rand() % COLS; if (m_grid[r][c].isMine) continue; if (abs(r - safeRow) <= 1 && abs(c - safeCol) <= 1) continue; // 安全区 m_grid[r][c].isMine = true; ++placed; } // 计算每格周围雷数 for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) { if (m_grid[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, nc = c + dc; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && m_grid[nr][nc].isMine) ++cnt; } m_grid[r][c].adjacent = cnt; } }参数safeRow/safeCol就是玩家第一次点击的行列。abs(...) <= 1圈出 3×3 安全区。注意rand()的随机性一般,做作业够用;如果雷数接近格子总数,这个 while 循环可能反复撞已布点,实际项目里会改成「收集所有可用坐标再洗牌取前 N 个」,避免极端情况下循环次数不可控。计算adjacent时边界判断nr >= 0 && nr < ROWS不能省,否则数组越界,程序直接崩,这是血泪经验。
3.3 翻格与空白区域自动展开
点到一个adjacent == 0的格子,应该自动把周围一圈也翻开,形成连锁。标准写法是递归或队列,递归代码短但深棋盘可能栈溢出(扫雷最大也就几十乘几十,实际不会,但养成用队列的习惯没坏处):
void CMineSweeperDlg::OpenCell(int r, int c) { if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return; Cell& cell = m_grid[r][c]; if (cell.state != CS_COVERED) return; // 已翻开或已插旗,跳过 if (cell.isMine) { // 踩雷,游戏结束 m_gameOver = true; RevealAllMines(); return; } cell.state = CS_OPENED; ++m_openedCount; if (cell.adjacent == 0) { // 空白格,展开周围 8 格 for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) if (dr || dc) OpenCell(r + dr, c + dc); } }if (cell.state != CS_COVERED) return;这一行是防重复展开和防死循环的关键,递归展开时相邻格子会互相指向,没有这个判断会无限递归。dr || dc排除自身。踩雷分支里调RevealAllMines()把所有雷的状态改成CS_MINE,然后触发重绘,让玩家看到全盘雷的位置。
3.4 右键插旗与胜负判定
右键在未翻开的格子上循环切换「插旗 → 问号 → 未翻开」,已翻开的格子不响应。胜负判定放在每次成功翻格之后:
void CMineSweeperDlg::OnRButtonDown(UINT nFlags, CPoint point) { int r, c; if (!HitTest(point, r, c)) return; // 坐标转行列,越界返回 false if (m_gameOver) return; Cell& cell = m_grid[r][c]; if (cell.state == CS_COVERED) cell.state = CS_FLAG; else if (cell.state == CS_FLAG) cell.state = CS_QUESTION; else if (cell.state == CS_QUESTION)cell.state = CS_COVERED; Invalidate(); // 触发重绘 CDialogEx::OnRButtonDown(nFlags, point); } void CMineSweeperDlg::CheckWin() { if (m_openedCount == ROWS * COLS - MINE_COUNT) { m_gameOver = true; m_win = true; // 可在此弹出提示或改标题栏 } }HitTest负责把鼠标客户区坐标换算成行列,公式是(point.x - 棋盘左边距) / 格子宽,注意整除和边界。Invalidate()让整个客户区重绘,格子少无所谓;格子多可以只InvalidateRect脏区域,属于优化项。CheckWin在OpenCell成功翻开后调用一次即可,不要放在重绘里,否则每帧都算。
4. 编译运行与界面绘制:从源码到能点的棋盘
4.1 环境准备与工程打开
这份工程是标准 MFC 项目,需要 Visual Studio(2015 及以上都行,装「使用 C++ 的桌面开发」工作负载,勾选 MFC 组件)。打开.sln后先确认两件事:解决方案平台是 x86 还是 x64,和你的 VS 默认一致;项目属性里「MFC 的使用」设为「在共享 DLL 中使用 MFC」或「在静态库中使用 MFC」,前者 exe 小但目标机要有 MFC 运行库,交作业建议选静态库,拷到别人电脑直接能跑。
编译前如果报stdafx.h找不到,说明工程是老版本预编译头,VS2017 之后默认改成pch.h,把包含改过来、或在项目属性里关掉预编译头即可。这类环境问题占新手卡壳的一大半,先解决再谈逻辑。
4.2 OnPaint 里怎么画格子
自绘路线的核心是OnPaint。每个格子根据state和adjacent决定填充色和文字:
void CMineSweeperDlg::OnPaint() { CPaintDC dc(this); const int cellSize = 24; const int originX = 20, originY = 20; // 棋盘左上角偏移 for (int r = 0; r < ROWS; ++r) { for (int c = 0; c < COLS; ++c) { CRect rc(originX + c * cellSize, originY + r * cellSize, originX + (c + 1) * cellSize, originY + (r + 1) * cellSize); const Cell& cell = m_grid[r][c]; if (cell.state == CS_COVERED || cell.state == CS_FLAG || cell.state == CS_QUESTION) { dc.FillSolidRect(rc, RGB(192, 192, 192)); // 未翻开灰色 if (cell.state == CS_FLAG) dc.DrawText(_T("F"), rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); else if (cell.state == CS_QUESTION) dc.DrawText(_T("?"), rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else { dc.FillSolidRect(rc, RGB(230, 230, 230)); // 已翻开浅色 if (cell.isMine) dc.DrawText(_T("*"), rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); else if (cell.adjacent > 0) { CString s; s.Format(_T("%d"), cell.adjacent); dc.SetTextColor(NumberColor(cell.adjacent)); // 1~8 不同颜色 dc.DrawText(s, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } dc.Draw3dRect(rc, RGB(255,255,255), RGB(128,128,128)); // 立体边框 } } }CPaintDC只能在OnPaint里用,别在别处 new。FillSolidRect填底色,Draw3dRect画凸起/凹陷边框,扫雷的经典观感就出来了。NumberColor是个小函数,按 1 到 8 返回不同 RGB,蓝、绿、红、深蓝……这个细节决定成品像不像样。文字用DT_VCENTER | DT_SINGLELINE才能垂直居中,只写DT_CENTER会贴顶,这是 GDI 绘图的常见坑。
4.3 鼠标消息与坐标换算
左键点击走OnLButtonDown,先HitTest转行列,再判断状态:未翻开就OpenCell,已翻开不响应。HitTest要和OnPaint用同一套cellSize和originX/originY,否则点击位置和画出来的格子对不上——这是自绘路线最典型的错位 bug,改布局时两处必须同步。
bool CMineSweeperDlg::HitTest(CPoint pt, int& r, int& c) { const int cellSize = 24, originX = 20, originY = 20; int x = pt.x - originX, y = pt.y - originY; if (x < 0 || y < 0) return false; c = x / cellSize; r = y / cellSize; return (r >= 0 && r < ROWS && c >= 0 && c < COLS); }把cellSize、originX/originY抽成成员常量或宏,OnPaint和HitTest共用,能省掉大量「改了这边忘了那边」的返工。
5. 避坑与排查:编译、绘制、逻辑三类高频问题
5.1 编译报 MFC 相关头文件找不到
现象:#include <afxwin.h>或stdafx.h报错,提示无法打开源文件。原因通常是 VS 安装时没勾 MFC 组件,或工程用了旧版预编译头而当前 VS 默认pch.h。解决:打开 Visual Studio Installer 补装「适用于最新 v142 生成工具的 C++ MFC」;预编译头问题则在项目属性 → C/C++ → 预编译头里改成「不使用预编译头」,或把stdafx.h统一替换为pch.h。
5.2 棋盘画出来了但点击没反应
现象:界面正常,鼠标点格子毫无变化。原因多半是消息映射没加,或HitTest返回 false。检查对话框类的BEGIN_MESSAGE_MAP里有没有ON_WM_LBUTTONDOWN(),cpp 里有没有对应的OnLButtonDown声明;再在HitTest里打个断点看r/c是否越界。还有一种情况是静态控件盖在上面吃掉了鼠标消息,自绘时把棋盘画在主对话框客户区、不叠控件最省事。
5.3 第一下就踩雷或空白不展开
现象:开局第一下点开就是雷,或者点到空白格只翻开一格。原因:布雷在初始化时就做了、没做首点保护;或OpenCell里漏了adjacent == 0的递归展开分支。解决:把布雷挪到首次左键点击时执行,传入首点坐标做安全区;展开分支确认state != CS_COVERED的提前返回存在,否则递归会重复处理。
5.4 游戏结束后还能继续点
现象:踩雷后棋盘显示所有雷,但再点还能翻格、还能插旗。原因:m_gameOver标志设了但没在所有入口判断。解决:OnLButtonDown、OnRButtonDown开头统一if (m_gameOver) return;,重开一局时把m_gameOver、m_win、m_openedCount全部复位,再清空棋盘状态。复位不彻底会导致第二局计数错乱、赢了不提示,这类状态残留是改游戏逻辑时最容易忽略的。
5.5 高 DPI 下格子错位
现象:在缩放 125% 或 150% 的屏幕上,点击位置和格子对不上。原因:MFC 老工程默认不感知 DPI,系统做了位图拉伸,客户区坐标和绘制坐标比例不一致。解决:在应用清单里声明 DPI 感知,或简单点,在InitInstance里调SetProcessDPIAware();更稳的做法是按GetDeviceCaps(LOGPIXELSX)动态算cellSize,让绘制和命中测试都走同一套缩放系数。
6. 二次开发:把扫雷改成你自己的大作业
拿到这份工程,最省力的改造路线是先动「参数」,再动「玩法」,最后动「界面」,每一步都能独立验证,不至于一改就崩。
第一步改难度。把ROWS、COLS、MINE_COUNT抽成成员变量,加一个菜单或下拉框切换初级 9×9/10 雷、中级 16×16/40 雷、高级 16×30/99 雷。改完记得同步OnPaint和HitTest里的cellSize——格子多了要么缩小格子,要么加大窗口,否则画到客户区外面去了。验证方法:切到高级,随便点几下,看棋盘是否完整、点击是否还对得上。
第二步加计时和剩余雷数。计时用SetTimer每秒刷新一个静态文本,首次点击时启动、游戏结束时KillTimer;剩余雷数 =MINE_COUNT - 已插旗数,每次右键后更新。这两个是扫雷的标配,加上之后完成度立刻不一样。注意计时器 ID 别和 MFC 内部用的冲突,用个自定义常量。
第三步换绘制风格或加音效。把FillSolidRect的纯色换成res/下的位图,用CDC::StretchBlt贴图;音效在翻格、插旗、爆炸三个时机PlaySound。这一步纯属锦上添花,但答辩时观感分很实在。
| 改造点 | 涉及位置 | 验证方式 |
|---|---|---|
| 难度切换 | 成员变量 + 菜单响应 | 三档各开一局,棋盘完整、点击对齐 |
| 计时/雷数 | SetTimer+ 静态文本 | 首点启动、结束停止、插旗数字变化 |
| 位图皮肤 | OnPaint贴图 | 各状态贴图正确、无拉伸模糊 |
| 音效 | PlaySound三处调用 | 翻格/插旗/爆炸各响一次 |
改的时候有个习惯我一直保持:每加一个功能,先在纸上把「状态从哪来、到哪去」写清楚,尤其是m_gameOver、m_openedCount这种跨函数的全局状态,改完必须走一遍「开局 → 翻格 → 插旗 → 踩雷 → 重开」的完整链路。扫雷逻辑不复杂,翻车基本都翻在状态没复位、两处坐标常量不同步这种细节上。从那以后我每次改完绘制或布局,都强制把OnPaint和HitTest的常量对一遍再编译,省下的调试时间比写代码还多。希望这份拆解能帮你把这份 C++/MFC 扫雷顺利跑起来,也改出点自己的东西。
本文还有配套的精品资源,点击获取