
简介一套基于MFC框架的扫雷游戏程序设计项目面向需要学习Windows图形界面编程、C与MFC协作开发的初学者或课程设计人员。完整复现经典扫雷核心逻辑包含格子状态判定、地雷随机分布、数字提示、踩雷胜负判断等机制界面模仿经典扫雷风格处理好资源后可直接运行适合作为MFC入门实战或期末课设参考。压缩包含82个文件约22.49MB涵盖cpp/h源码、bmp/ico界面资源、rc资源脚本、Mine.sln/vcxproj工程文件以及Debug目录下的exe可执行程序、pdb调试符号与中间编译产物便于直接查看运行效果或二次修改。目前已有207人学习。项目内部模块划分清晰MineDlg、MineView、Game、Cell等类分别承担界面、视图、逻辑与数据模型阅读源码可掌握用MFC对话框/视图搭建完整程序的方法也能借鉴其逻辑封装和资源管理思路用于独立开发或课程报告整理。1. 基于MFC的扫雷程序设计双击 exe 就能玩的经典界面代码是怎么组织的这篇要拆的是一份用 MFC 重写的扫雷程序不是网页版的 JS 扫雷也不是控制台里打印字符的扫雷脚本而是带对话框界面、鼠标左右键、计时器和经典灰格子界面的 Windows 桌面程序。工程里已经带了一份编译好的 Debug 版 Mine.exe双击就能跑界面布局和经典扫雷几乎一样9x9 雷区、周围雷数数字、右键插旗、踩雷即结束。正在做 MFC 课程设计或程序设计实践的学生可以直接拿它当模板想把 WinForm 或网页逻辑搬回原生 MFC 的老手能在这里看到对话框自绘和消息映射的标准写法只想找个能改着玩的程序的开发者它打开就能编译。我按“工程结构 → 游戏逻辑 → 界面层 → 排错 → 改造”的顺序拆一遍把每个文件、每段关键代码和最容易踩的坑都标出来。2. 工程文件拆解Mine.sln、MineDlg、Game、Cell 到底谁在干活2.1 进入工程前的文件体检.sln、.vcxproj、.rc 的作用拿到解压后的工程第一眼会看到一堆文件。不要一上来就双击 exe先看清楚哪些是入口、哪些是配置、哪些只是缓存。Mine.sln 是解决方案文件VS 双击它就能把整套工程拉起Mine.vcxproj 是工程文件记录编译选项、包含目录、字符集和平台工具集Mine.rc 是资源脚本对话框布局、菜单、图标、字符串表全部写在里面。判断标准很简单.sln、.vcxproj、.rc 这几个文件都在 VS 界面里改不要拿记事本硬编辑。尤其 .vcxproj 本质是 XML标签成对出现手改漏一个闭合标签整个工程就打不开。工程里出现的 RCa04496、RCb04496 是 VS 编译 .rc 资源脚本时的中间产物类似编译出的 .obj手动删除后重新编译会自动生成不影响运行也不该提交到版本库。真正要盯住的是 Debug 目录下的三个文件后面单独说。2.2 核心源码文件清单Dlg、View、Doc、Game、Cell 各管什么把核心文件按“职责”分组看比按字母顺序看更清楚文件类别职责平时改不改MineDlg.h / MineDlg.cpp对话框类主窗口生命周期、消息映射、计时器经常改MineView.h / MineView.cpp视图类文档视图框架下的绘制支持少改MineDoc.h / MineDoc.cpp文档类数据载体实际玩法逻辑不在它基本不动Game.h / Game.cpp核心逻辑布雷、算数、翻格、判胜负常改Cell.h / Cell.cpp格子定义单格状态雷、翻开、标记、邻雷数常改MainFrm.h / MainFrm.cpp主框架菜单、工具栏、状态栏挂载改状态栏时动stdafx.h / targetver.h预编译头固定系统头、SDK 版本、编译开关加头文件才动类之间的调用链一定要理清楚。用户点鼠标的位置落在 MineDlg 上MineDlg 把屏幕坐标换算成棋盘坐标再调用 Game 的 Reveal 或 ToggleFlagGame 内部修改 Cell 的状态数组MineDlg 随后调 Invalidate 触发 OnPaint 重绘。这个单向调用链很关键我在实际项目里见过有人把布雷逻辑写进 MineDlg 的构造函数结果窗口每次重建棋盘就重新来一次计时器和雷区全乱。正确做法是棋盘数据只归 Game 管界面层只负责输入和显示。2.3 预编译头、字符集与 _T 宏为什么改字符串会编译失败stdafx.h 在 MFC 工程里的地位被很多人忽略。它把 Windows.h、afxwin.h、afxext.h 这些几乎不变的系统头文件提前编译一次存在 .pch 里之后每次编译不再重复解析这几百个头文件编译速度能差出数倍。新加 MFC 头文件时往 stdafx.h 塞但不要放经常改的代码。targetver.h 指定目标 Windows SDK 版本决定程序能在哪些系统上跑一般保留默认。字符集是新人最容易翻车的地方。Mine.vcxproj 里有一个 CharacterSet 配置项常见值为 Unicode 或 MultiByte。这份工程用的是 Unicode从字符串都写成_T(扫雷)能看出来。_T()宏在 Unicode 编译下展开为宽字符字符串L扫雷在 MultiByte 下展开为窄字符。如果你自己写新文件时图省事写了L扫雷而工程切成了 MultiByte会报无法从wchar_t*转换到const char*。所以在这份工程里改字符串一律写_T()不要裸写 L 前缀。2.4 Debug 目录里的 exe、pdb、sdf、ipch 分别是什么Debug 目录里的 Mine.exe 是成品程序Mine.pdb 是调试符号文件程序崩溃时显示调用栈全靠它Mine.ilk 是增量链接文件作用是让 VS 每次只重链改动的部分。这三个是“编译产物”删了能重新生成但 pdb 和 exe 版本不匹配时断点会失效。另外一组是 ipch、Mine.sdf、Mine.v11.suo它们分别是 IntelliSense 预编译缓存、类浏览器数据库、VS2012 解决方案用户选项记录上次打开时哪个文件处于激活状态。这三个纯缓存能占到几百 MB删掉不影响源码只是下次打开 VS 要重新索引。还有一件事值得提前知道Mine.exe 是按“在共享 DLL 中使用 MFC”方式生成的换一台没装 MFC 运行库的机器双击可能提示缺少 mfc120u.dll 之类。解决方式有两种装对应版本的 VS 运行库或者把工程属性的“MFC 的使用”改成“在静态库中使用 MFC”重新编译后者生成的 exe 体积变大但拷到哪里都能跑。3. 扫雷核心逻辑九格邻域、随机布雷与递归展开的 C 实现3.1 Cell 格子类与 Game 棋盘状态位与一维数组索引Cell.h 的定义非常精简四个布尔加一个数字就能描述一个格子// Cell.h #pragma once class Cell { public: bool hasMine; // 是否埋了雷 bool isOpen; // 是否已翻开 bool isFlagged; // 是否被右键插旗 int adjacentMines; // 周围八格雷数地雷格记为 -1 Cell() : hasMine(false), isOpen(false), isFlagged(false), adjacentMines(0) {} };逻辑说明hasMine 和 isOpen 是核心一个决定格子内容一个决定显示状态isFlagged 决定左键点击是否被忽略adjacentMines 在翻开时决定显示数字还是留空。把地雷格的 adjacentMines 记为 -1 而不是直接留 0是为了区分“周围零雷的数字格”和“雷格本身”绘制时两个分支完全不同。Game.h 是逻辑层的门面重点看它怎么组织棋盘// Game.h #pragma once #include Cell.h #include vector class Game { public: Game(int width, int height, int mineCount); void Reset(); void Reveal(int x, int y); void ToggleFlag(int x, int y); bool IsValid(int x, int y) const; bool IsGameOver() const { return m_gameOver; } bool CheckWin() const; Cell GetCell(int x, int y) { return m_cells[y * m_width x]; } int GetWidth() const { return m_width; } int GetHeight() const { return m_height; } private: void PlaceMines(); void CalcAdjacentMines(); int m_width; int m_height; int m_mineCount; bool m_gameOver; std::vectorCell m_cells; // 一维数组下标 y * 宽度 x static const int dx[8]; // 八方向行位移 static const int dy[8]; // 八方向列位移 };参数说明构造函数接收宽、高、雷数经典初级就是Game(9, 9, 10)GetCell(x, y)用y * m_width x把二维坐标换算成一维下标这是 MFC 工程里最常见的棋盘存储方式比vectorvectorCell省掉一层嵌套遍历和拷贝都快。dx、dy 是八方向偏移数组在布雷和翻开时共用。3.2 随机布雷srand/rand 与“首点安全”的取舍布雷逻辑写在 PlaceMines 里void Game::PlaceMines() { srand(static_castunsigned int(time(nullptr))); int placed 0; while (placed m_mineCount) { int idx rand() % (m_width * m_height); if (!m_cells[idx].hasMine) { m_cells[idx].hasMine true; placed; } } CalcAdjacentMines(); }逻辑说明用rand() % 格子总数生成随机下标已经埋过雷的格子跳过直到埋满指定数量。srand用当前时间做随机种子保证每次运行雷的位置不同。注意这个版本是“裸布雷”没有做经典扫雷的“首点安全”处理——玩家第一下点上去完全可能踩雷。如果你在意这个体验常见做法是记录第一次点击坐标布雷结束后检查该坐标及周围 3x3 区域若有雷就把雷挪到远处无雷格或者先记下首点坐标把雷区生成限制在排除该区域后的范围里。我一般会在 Game 里加一个excludeIndex参数布雷循环里遇到它及其邻域就跳过改动不大但手感提升明显。3.3 八方向雷数计算边界判断是数字准确的前提布雷之后调用 CalcAdjacentMines给每个非雷格统计周围八格里的雷数const int Game::dx[8] { -1, -1, -1, 0, 0, 1, 1, 1 }; const int Game::dy[8] { -1, 0, 1, -1, 1, -1, 0, 1 }; void Game::CalcAdjacentMines() { for (int y 0; y m_height; y) { for (int x 0; x m_width; x) { int idx y * m_width x; if (m_cells[idx].hasMine) { m_cells[idx].adjacentMines -1; // 雷格不需要数字 continue; } int count 0; for (int i 0; i 8; i) { int nx x dx[i]; int ny y dy[i]; if (nx 0 || nx m_width || ny 0 || ny m_height) continue; // 越界格直接跳过 if (m_cells[ny * m_width nx].hasMine) count; } m_cells[idx].adjacentMines count; } } }逻辑说明外层双重循环遍历整个棋盘内层八方向逐个加偏移量。越界检查nx 0 || nx m_width || ny 0 || ny m_height必须放在访问数组之前否则边缘格会读到数组外的脏数据数字显示就会莫名其妙偏大或偏小。边界格只有 3 个或 5 个邻居这个判断同时解决了“邻居数量不固定”的问题。参数说明dx、dy 的顺序不重要但两个数组必须一一对应比如 i0 时(-1,-1)是左上角i7 时(1,1)是右下角。如果发现某些格的数字总差 1先别怀疑算法检查是不是一组偏移写反了。这是我在调试里看过的真实翻车现场。3.4 递归展开与胜负判定防重入、防踩雷一次说清翻开格子是扫雷体验的核心。在经典扫雷里点到数字为零的格子会一次性展开一大片空白区实现方式就是递归void Game::Reveal(int x, int y) { if (x 0 || x m_width || y 0 || y m_height) return; int idx y * m_width x; Cell cell m_cells[idx]; if (cell.isOpen || cell.isFlagged) return; // 已翻开或被插旗的格子不响应 cell.isOpen true; if (cell.hasMine) { m_gameOver true; return; } if (cell.adjacentMines 0) { for (int i 0; i 8; i) Reveal(x dx[i], y dy[i]); } }逻辑说明递归入口第一道关是越界检查第二道关是isOpen isFlagged防重入。不要小看isOpen这层判断如果没有它两个相邻的零格会互相调用对方造成无限递归栈直接爆掉。isFlagged的判断是为了尊重玩家的插旗操作旗子标记过的格子不允许被左键翻开这是经典扫雷的规则。参数说明adjacentMines 0才继续递归数字格只翻开自己不扩散这是效率控制的关键。一个 9x9 棋盘埋 10 颗雷时点开一个空白角能递归展开三四十个格子递归深度一般不超过十层安全但如果棋盘放大到 30x24稀疏雷区可能让递归深度变深保守做法是把递归改成显式栈循环防止极端布局下爆栈。胜负判定放在 Game 里两个函数配合bool Game::CheckWin() const { for (int y 0; y m_height; y) { for (int x 0; x m_width; x) { const Cell cell m_cells[y * m_width x]; if (!cell.hasMine !cell.isOpen) return false; // 还有非雷格没翻开 } } return true; }逻辑说明胜利条件不是“插旗插对了”而是“所有非雷格都翻开了”。经典扫雷允许你完全不插旗只要把所有安全格点开就算赢这个判定方式更符合规则。界面层在每次 Reveal 后同时检查IsGameOver()和CheckWin()失败优先赢了再弹胜利框顺序不要反否则踩雷瞬间会先弹胜利弹窗体验非常违和。4. MFC 界面层实战对话框自绘、鼠标消息映射与状态栏刷新4.1 为什么用 OnPaint 自绘而不是按钮网格做 MFC 扫雷时最容易走偏的方案是在对话框资源里拖 81 个 Button 控件拼成棋盘。这个方案在布局阶段很直观但运行后问题一堆81 个控件每个都要创建窗口句柄内存和加载时间翻倍刷新雷区时要么挨个 SetWindowText 要么整体重绘性能极差更麻烦的是右键菜单会被按钮控件吃掉还得重写按钮类。我建议的写法是自己接管绘制在 OnPaint 里把整个棋盘画出来。void CMineDlg::OnPaint() { CPaintDC dc(this); const int cellSize 20; for (int y 0; y m_game.GetHeight(); y) { for (int x 0; x m_game.GetWidth(); x) { CRect rect(x * cellSize, y * cellSize, (x 1) * cellSize, (y 1) * cellSize); const Cell cell m_game.GetCell(x, y); if (!cell.isOpen) { dc.FillSolidRect(rect, RGB(190, 190, 190)); // 未翻开灰 if (cell.isFlagged) dc.TextOutW(rect.left 3, rect.top 2, _T(F)); } else if (cell.hasMine) { dc.FillSolidRect(rect, RGB(255, 0, 0)); // 踩雷红 } else { dc.FillSolidRect(rect, RGB(240, 240, 240)); // 已翻开浅灰 if (cell.adjacentMines 0) { CString str; str.Format(_T(%d), cell.adjacentMines); dc.TextOutW(rect.left 6, rect.top 2, str); } } } } }逻辑说明整个窗口只画一个棋盘没有子控件所以鼠标消息必然落在对话框窗口上不会被子窗口截走。CPaintDC是 MFC 对 WM_PAINT 的封装析构时自动处理 BeginPaint/EndPaint 配对。绘制顺序是“先底后字”灰块和红块打底数字和旗帜浮在上面。参数说明cellSize 20直接决定棋盘像素尺寸9x9 棋盘加边框大约 180x180 像素。改大它可以让格子更醒目但记得鼠标坐标换算里的除数和它保持一致后面会看到。TextOutW在宽字符工程里直接可用如果工程是 MultiByte 就要换成TextOutA或统一_T宏驱动。4.2 鼠标消息映射ON_WM_LBUTTONDOWN 与坐标换算MFC 把 Windows 消息转成虚函数声明和映射缺一不可。在 MineDlg 的头文件里声明三个处理函数然后绑定BEGIN_MESSAGE_MAP(CMineDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() END_MESSAGE_MAP()逻辑说明这里的宏做了两件事把 WM_LBUTTONDOWN 路由到OnLButtonDown成员函数把 WM_RBUTTONDOWN 路由到OnRButtonDown。漏掉任何一个鼠标事件就被默认窗口过程丢弃点多少下都没反应。新开工程时这些宏由类向导生成但手写也很常见需要知道每个宏对应的消息名。左键处理的完整逻辑void CMineDlg::OnLButtonDown(UINT nFlags, CPoint point) { int x point.x / 20; // 像素坐标除以格子尺寸 int y point.y / 20; if (!m_game.IsValid(x, y)) { CDialogEx::OnLButtonDown(nFlags, point); return; } m_game.Reveal(x, y); if (m_game.IsGameOver()) { KillTimer(1); MessageBox(_T(踩到雷了), _T(游戏结束), MB_OK | MB_ICONWARNING); } else if (m_game.CheckWin()) { KillTimer(1); MessageBox(_T(全部排掉了), _T(胜利), MB_OK | MB_ICONINFORMATION); } Invalidate(); // 触发 OnPaint 重绘 CDialogEx::OnLButtonDown(nFlags, point); }参数说明point.x / 20是整数除法直接把像素坐标映射到格子坐标例如 point.x45 得到第 2 列。这里 20 必须和 OnPaint 里的 cellSize 配对如果修改格子尺寸忘了改这里点击位置会整体偏移。IsValid做一次范围检查防止对话框客户区外的非棋盘区域触发游戏逻辑。Invalidate放在胜负判断之后再调用避免踩雷瞬间还来不及重绘就弹窗。4.3 右键插旗与计时器OnRButtonDown、SetTimer 的配对使用右键插旗在经典扫雷里是辅助决策的手段实现比左键还简单void CMineDlg::OnRButtonDown(UINT nFlags, CPoint point) { int x point.x / 20; int y point.y / 20; if (m_game.IsValid(x, y)) { m_game.ToggleFlag(x, y); Invalidate(); } CDialogEx::OnRButtonDown(nFlags, point); }逻辑说明ToggleFlag内部翻转isFlagged状态要么插旗要么取消插旗。注意右击已翻开的格子应该无效果这个判断放在 Game 里而不是 Dlg 里更合适界面层只管坐标换算和刷新。很多拿扫雷练手的人把“游戏规则”散落在按钮响应函数里后面加难度选择时到处找规则这是反面教材。计时器负责秒表刷新MFC 里面用SetTimer和KillTimer配对// 游戏开始时启动 SetTimer(1, 1000, nullptr); // 计时器 ID 为 1每 1000 毫秒触发一次 void CMineDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { m_elapsedSeconds; CString str; str.Format(_T(用时 %d 秒), m_elapsedSeconds); m_statusBar.SetPaneText(1, str); } CDialogEx::OnTimer(nIDEvent); } // 游戏结束或重新开始时 KillTimer(1);参数说明SetTimer第一个参数是自定义 ID第二个是间隔毫秒数1000 表示每秒触发一次。OnTimer必须判断nIDEvent因为同一个窗口可能注册多个计时器。KillTimer在结束时调用否则计时器会继续跑造成“游戏结束了秒表还在走”的假死现象。我习惯把启动、停止、重置三个动作都封装成StartTimer()、StopTimer()避免在各个消息函数里散落 KillTimer。4.4 状态栏显示雷数与用时回应“MFC 状态栏怎么显示”的具体做法网上经常有人问“MFC 状态栏怎么显示”核心就是两步先建状态栏再用 SetPaneText 更新文字。状态栏通常挂载在主框架窗口也可以用Create直接挂在对话框底部。指示器数组定义各窗格的 ID// MainFrm 或对话框 OnCreate 里 static UINT indicators[] { IDS_STATUS_READY, // 第 0 格就绪提示 IDS_STATUS_TIME, // 第 1 格用时 IDS_STATUS_MINE // 第 2 格剩余雷数 }; m_statusBar.Create(this); m_statusBar.SetIndicators(indicators, 3); m_statusBar.SetPaneInfo(1, IDS_STATUS_TIME, SBPS_NORMAL, 80);逻辑说明SetIndicators接受指示器数组和个数每个 ID 关联一个窗格。SetPaneInfo可以单独调整某个窗格的宽度和样式比如把时间窗格固定 80 像素防止秒数变化时整个状态栏闪。雷数显示在每次插旗或取消插旗时更新CString str; str.Format(_T(剩余 %d 雷), m_game.GetMineCount() - m_flaggedCount); m_statusBar.SetPaneText(2, str);这里m_flaggedCount可以在 Game 里统计isFlagged true的格子数也可以在 Dlg 层自增自减。从数据一致性角度看放 Game 里更稳因为棋盘数据只允许 Game 修改。我见过有人把m_flaggedCount单独放在 Dlg然后布雷重来时忘了清零状态栏雷数一直不对。4.5 在现有 MFC 工程上弹出新对话框从 DoModal 到传数据热搜里有一条“在现有 vs mfc 工程上增加按钮弹出对话框并显示实时数据图表”这个场景在扫雷工程里同样适用比如加一个“游戏统计”按钮弹窗显示胜率和用时。MFC 的做法是先创建一个对话框资源和对应类然后在按钮响应里实例化并调用 DoModalvoid CMineDlg::OnShowStats() { CStatsDlg dlg; dlg.SetGameData(m_game); // 传入 Game 对象指针 dlg.SetElapsed(m_elapsedSeconds); dlg.DoModal(); // 模态对话框返回后继续 }参数说明DoModal是模态调用弹窗期间主窗口被禁用用户必须先关掉弹窗才能继续玩。SetGameData是把当前游戏数据传进去的常用做法也可以用构造函数传参但指针方式更灵活子对话框能随时读取最新棋盘状态。如果你想做非模态主窗口还能操作的统计面板用CreateShowWindow(SW_SHOW)生命周期管理复杂一些新手先不用碰。5. 编译运行与排查从 VS 打开工程到扫雷跑起来的五个常见问题5.1 打开工程就报一堆 afxwin.h 找不到现象双击 Mine.sln 后编译瞬间刷出几十条fatal error C1083: 无法打开包括文件: afxwin.h。原因这台机器上装的 VS 版本或 VC 工具集与工程创建时的版本不一致MFC 头文件路径没有进入包含目录或者安装 VS 时没勾选“适用于桌面的 MFC”。解决右键工程 → 属性 → 常规 → 平台工具集改成本机已安装的版本确认“MFC 的使用”不是“不使用 MFC”如果没装 MFC 组件用 VS 安装器补装“VC MFC 库”。5.2 字符集不一致资源脚本编译报 RC 错误现象编译时资源脚本报RC2135或某个对话框字符串乱码对话框里中文显示成问号。原因.rc 文件保存的代码页与工程字符集不匹配常见于把工程改为 Unicode 后.rc 里的中文字符串还是 GBK 编码。解决用 VS 打开资源脚本文件文件 → 高级保存选项 → 编码选“Unicode (UTF-8 带签名)”确保对话框里的中文能以宽字符方式写进去同时确认工程属性里字符集是 Unicode字符串统一走_T()。5.3 鼠标点了没反应消息被哪个层吃掉了现象程序能启动、能绘制但左键点棋盘毫无反应。原因最常见的是类向导没生成消息映射函数写了但没进BEGIN_MESSAGE_MAP另一个可能是对话框上有透明的静态文本控件盖住了客户区点击落在 Static 控件上而非对话框窗口。解决先在OnLButtonDown第一行设断点断不住说明消息映射没生效检查映射宏断得住但坐标错乱检查动态创建的控件和客户区偏移必要时用ScreenToClient把CPoint从屏幕坐标转成客户区坐标。5.4 递归展开让程序卡死或栈溢出现象点一个空白格程序先是卡住随后崩溃调用栈停在一长串的Game::Reveal上。原因递归展开漏写了isOpen去重判断两个相邻的零雷格互相调用形成无限递归或者棋盘很大且没有在入口统一做边界检查。解决把if (cell.isOpen || cell.isFlagged) return;放在Reveal入口的开头并在递归前检查adjacentMines 0这两步能挡住绝大多数循环调用。若棋盘超过 20x20建议把递归改成显式栈循环。5.5 Debug 版 exe 拷到别的机器上跑不起来现象本机编译通过把 Debug 目录拷到同学电脑上双击 Mine.exe 提示缺少mfc120u.dll或msvcr120.dll。原因工程默认“在共享 DLL 中使用 MFC”exe 运行时需要对应的 MFC 和 CRT 动态库目标机器没有这些运行库。解决要么在目标机器装 VS 运行库要么改静态链接工程属性 → 常规 → MFC 的使用 → “在静态库中使用 MFC”并把配置的活动解决方案平台切到 Release 重新编译。静态版 exe 能有 4~6 MB但拷到任何 Win7 机器都能直接双击运行。6. 进阶改造难度参数化、状态栏雷数显示与自定义格子皮肤6.1 难度参数化从 9x9 到 16x16 只要改一个构造参数经典扫雷有初级、中级、高级三档对应 9x9/10 雷、16x16/40 雷、30x16/99 雷。这份工程的 Game 构造函数已经支持宽高和雷数所以改造难度很低在 MineDlg 里加一个OnDifficultyChange菜单命令重新构造 Game 对象并 Reset 即可。注意 Reset 里要恢复m_elapsedSeconds 0、调用KillTimer(1)再把状态栏两个窗格清空。很多人只改了棋盘大小忘了把标志位复位结果新局一开局就显示上一局的时间。6.2 剩余雷数联动插旗不只影响棋盘还影响状态栏把剩余雷数显示做成闭环在 Dlg 右键处理里调用ToggleFlag后从 Game 重新统计isFlagged数量刷新状态栏。我一般把这个统计函数直接放进 Game叫CountFlagged()循环遍历m_cells累加。这样布雷重来、难度切换、右键插旗三条路径都会自动一致不需要三处各维护一个m_flaggedCount。血泪经验是不要相信“随手同步一下就行”任何计数都让数据源单独更新。6.3 自定义皮肤用位图替代 FillSolidRect 绘制如果想要更好看的格子把 OnPaint 里的FillSolidRect换成BitBlt就行。在 res 目录放两张 20x20 位图一张未翻开、一张已翻开程序启动时用CBitmap::LoadBitmap加载绘制时按格子状态选择dc.BitBlt(rect.left, rect.top, 20, 20, memDC, 0, 0, SRCCOPY)。数字和旗帜可以在位图上预画也可以继续用TextOutW叠加。注意 LoadBitmap 加载的是 BMP 格式PNG 需要 GDI别在这里绕远路。从那以后我每次拿到别人给的 MFC 工程第一件事不是编译而是先打开 Game.cpp 把布雷、翻格、判胜三条主链路读一遍确认数据的读写顺序没被界面层打乱再去动界面代码。希望帮到你。本文还有配套的精品资源点击获取