简介基于MFC框架实现的经典扫雷游戏项目适合使用VC的开发者与游戏编程入门者参考可用来学习Windows图形界面程序设计。项目利用微软基础类库对系统接口的封装完整演示了创建游戏主窗口、处理鼠标点击消息、绘制棋盘格子、加载位图图标与音效资源以及在胜利、失败和重新开局等状态之间切换的逻辑处理。压缩包共包含54个文件整体大小约2.96兆字节核心内容以C头文件和源文件为主并配有位图、图标、音频等界面素材同时带有可直接运行的可执行程序以及对象文件、调试信息等编译过程产物方便对照学习完整构建流程。目录结构接近常规工程布局便于逐步拆解阅读。目前已有145人学习下载阅读者可以从中掌握窗口搭建、消息响应、图形绘制和状态管理的落地方法并进一步修改难度、棋盘尺寸或素材样式作为二次开发的起点。1. MFC 扫雷一个对话框程序就能装下的完整游戏逻辑拿到一个叫 saolei.rar 的压缩包解压出来通常是某一个版本的 MFC 扫雷工程。这类项目在网上流传很广文件名五花八门但核心都指向同一件事用微软基础类库MFC把 Windows 自带扫雷的规则在自绘窗口里重新实现一遍。扫雷的规则大家都熟棋盘上藏若干颗雷点开数字格判断附近雷数空白格自动展开给所有雷插上旗子就赢。这个规模用来练 MFC 恰到好处它要求你处理鼠标左键、右键、定时器、GDI 绘制但又不涉及文件读写、多线程、数据库这些会分散注意力的东西一个基于对话框的工程从头写到尾也就是几百行代码的事。这篇文章我不会装作读过某个固定源码包内部而是按这类项目最常见的工程写法把从新建 MFC 对话框工程到跑出一个能完整对局的扫雷程序拆成四块工程骨架、数据模型、绘制交互、避坑排查。新手可以照着步骤做已经看过别人代码但没看懂消息映射和绘制流程的人也能在中间几章找到对应关系。最后收在状态机和难度参数上算是给这个经典练手项目一个不过度的收尾。2. 工程选型与消息骨架对话框程序就够不必引入 Doc/View2.1 CDialog 与 SDI 的取舍为什么扫雷不推荐用文档视图初学者翻到 MFC 教材默认路径是“单文档应用程序 CView::OnDraw”因为框架会自动处理刷新。但扫雷的绘制触发点非常特殊棋盘只在鼠标点击、定时器跳秒、游戏结束三种时刻发生变化不需要滚动、不需要保存文档、不需要多视图。用 SDI 反而多了一层 CView 和 CDocument 的关联逻辑你还要处理 OnUpdate 的调用时机纯属给自己找事。对话框程序的好处是窗口就是对话框本身OnInitDialog 里初始化棋盘OnPaint 里画所有格子鼠标消息直接进对话框的消息映射。整个程序只有一个主窗口、一组按钮和几个静态文本用 CDialogEx 就够了。我们把这个做法可以当成这类小程序的默认架构直到你开始想给扫雷加存档、加回放、加皮肤系统那时候再考虑拆分视图也不迟。2.2 新建工程时的三个容易选错的选项在 Visual Studio 里新建 MFC 应用应用类型选“基于对话框”。有三个选项会影响后面的开发体验字符集选“使用 Unicode 字符集”。MFC 新项目默认就是 Unicode消息映射里用 _T() 包字符串坐标系计算用 CPoint 和 CRect跟宽窄字符没关系但 CString 格式化数字时建议用 _T(%d)避免在多字节环境下出现中文乱码的隐性问题。使用静态库还是共享 DLL。共享 DLL 生成 exe 小但拷到别的机器上容易缺 MFC 运行库静态链接会让文件体积涨到几 MB但对这种玩具级程序来说无所谓我一般选静态库省得部署时被“缺少 mfc140u.dll”这种问题绊倒。是否生成 ActiveX 控件支持。扫雷用不到把“ActiveX 控件”勾选去掉生成的工程文件更干净预编译头也更快。对话框模板里默认会有“确定”“取消”两个按钮直接删掉换个自己的“重新开始”按钮ID 设为 IDC_BTN_RESTART。窗口标题改成“扫雷”即可。注意对话框属性里的“系统菜单”可以留着方便关窗口“最大化框”去掉因为棋盘尺寸固定最大化后格子不跟着放大反而难看。2.3 消息映射即代码骨架五类消息一个都不能少MFC 的消息映射靠宏把 Windows 消息和成员函数绑起来。扫雷主窗口需要响应五类消息我用的是头文件里声明成员函数、CPP 里做映射的方式。映射表长这样BEGIN_MESSAGE_MAP(CSweepDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() ON_WM_CONTEXTMENU() ON_BN_CLICKED(IDC_BTN_RESTART, CSweepDlg::OnBnClickedRestart) END_MESSAGE_MAP()这段映射说明几个关键点。ON_WM_PAINT 让我们可以在 OnPaint 里自己画整个棋盘ON_WM_LBUTTONDOWN 和 ON_WM_RBUTTONDOWN 负责左键翻开、右键插旗ON_WM_TIMER 用来驱动计时器显示ON_WM_CONTEXTMENU 必须拦下来否则右键点击格子时 Windows 会默认弹上下文菜单把我们的插旗操作盖掉ON_BN_CLICKED 绑定重新开始按钮。头文件里对应的成员函数声明像这样class CSweepDlg : public CDialogEx { public: CSweepDlg(CWnd* pParent nullptr); enum { IDD IDD_SWEEP_DIALOG }; protected: virtual BOOL OnInitDialog(); afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); afx_msg void OnTimer(UINT_PTR nIDEvent); afx_msg void OnContextMenu(CWnd* pWnd, CPoint point); afx_msg void OnBnClickedRestart(); DECLARE_MESSAGE_MAP() };OnInitDialog 里做三件事初始化棋盘数组、设置窗口客户区尺寸、把“重新开始”按钮摆到棋盘下方。客户区尺寸是重点我一般这样算窗口宽度等于左边距加列数乘格子边长再加右边距高度等于上边距加行数乘格子边长再加计时器区域高度。格子边长取 32 像素经典扫雷的视觉比例就回来了。还有一个细节对话框资源里虽然有固定尺寸但代码里要再调用一次 SetWindowPos 按算出的尺寸校准免得资源模板微调后棋盘被挤到窗口外。3. 雷区数据模型与布雷逻辑从第一次点击保护开始3.1 棋盘数据结构地雷、翻开、插旗三种状态分开存扫雷的核心是一个二维数组但实际写代码时每个格子不只“有雷/无雷”两个状态。一个完整格子需要记录四件事是否地雷、周围八个格子里有几颗雷、是否已被翻开、是否已插旗。结构体定义比并列数组更清楚struct Cell { bool isMine; // 是不是雷 bool isRevealed; // 是否已翻开 bool isFlagged; // 是否已插旗 int number; // 周围雷数0 表示空格-1 表示未计算 };棋盘本体我一开始用 vector 动态分配方便后面做难度切换std::vectorstd::vectorCell m_board; int m_rows; int m_cols; int m_mineCount;为什么不直接用固定数组因为高级难度 16 行 30 列初级 9 行 9 列用固定数组就要写死一个 30×30 的上限难度参数化时还得处处带着这个上限。vector 的空间开销对几百个格子完全可以忽略换来的是 InitBoard 可以任意改尺寸void CSweepDlg::InitBoard(int rows, int cols, int mines) { m_rows rows; m_cols cols; m_mineCount mines; m_board.assign(m_rows, std::vectorCell(m_cols)); for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { m_board[r][c].isMine false; m_board[r][c].isRevealed false; m_board[r][c].isFlagged false; m_board[r][c].number 0; } } m_started false; m_gameOver false; m_leftDown false; m_rightDown false; }InitBoard 是全局重置入口重新开始按钮也调它。注意这里还没布雷布雷放到第一次鼠标点击之后这是避免“开局踩雷”的关键设计下一节展开说。3.2 布雷时机点击后随机布雷并且避开首点周围三乘三新手最容易犯的错是在 OnInitDialog 里就把所有雷埋好结果玩家第一次点击就踩雷。专业的做法是延迟布雷第一次点击那个格子时记下安全坐标再随机往棋盘上撒雷保证安全点本身以及它周围一圈都不会有雷。void CSweepDlg::PlaceMines(int safeRow, int safeCol) { srand(static_castunsigned(time(nullptr))); int placed 0; while (placed m_mineCount) { int r rand() % m_rows; int c rand() % m_cols; if (m_board[r][c].isMine) continue; if (r safeRow - 1 r safeRow 1 c safeCol - 1 c safeCol 1) continue; m_board[r][c].isMine true; placed; } CalcNumbers(); }参数说明有两个容易忽略但很重要的点。第一个是 while 循环而不是 for 循环因为 rand 可能重复抽到同一格for 循环写死次数会导致实际雷数不足。第二个是避开范围不只是 safeRow/safeCol 那一格而是它周围 3×3 都要空出来。经典扫雷要求玩家第一次点击必须能看到一块能展开的空地如果只避开单格仍然可能点开后周围一圈都是雷体验等于没保护。有一个特殊情况要注意如果棋盘很小而雷很多比如 5×5 盘 20 颗雷3×3 避让区可能让 while 循环卡死。处理方式是加一个重试上限超过则从剩余格子中硬挑。但标准扫雷的雷数上限远没到稠密区常见级别都不会触发我一般只在代码注释里留一句提醒。3.3 数字计算遍历九宫格数雷数布雷结束后要立刻算每个格子周围的雷数。这一步是所有数字显示的基础也决定了之后自动展开能不能正常工作。void CSweepDlg::CalcNumbers() { for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { if (m_board[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; int nc c dc; if (nr 0 || nr m_rows) continue; if (nc 0 || nc m_cols) continue; if (m_board[nr][nc].isMine) cnt; } } m_board[r][c].number cnt; } } }CalcNumbers 的边界检查是防数组越界的唯一防线不能省。棋盘边缘和四角的格子只会有 3 个或 5 个合法邻格靠上下限判断跳过越界访问。这个函数在新建棋盘和布雷保护都需要调用一次逻辑本身没有坑但调用时机不能错如果布雷前提前算数字数字全是 0地面播放还不一致。3.4 翻开与自动展开空格扩散的两种写法点击一个空格number0时扫雷会把相邻的连续空白区域全部翻开直到碰到数字边界。这个操作很多源码用递归写void CSweepDlg::RevealEmptyRecursive(int r, int c) { if (r 0 || r m_rows || c 0 || c m_cols) return; if (m_board[r][c].isRevealed) return; if (m_board[r][c].isFlagged) return; m_board[r][c].isRevealed true; if (m_board[r][c].number 0) return; for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) RevealEmptyRecursive(r dr, c dc); }递归写法本身没问题9×9 的盘最深也就几十层完全不会爆栈。但有些扩展过的代码会拿这个逻辑跑大尺寸地图递归深度会随空白区域大小线性上涨我见过有人把扫雷改成 100×100 后点一下中间空白处直接卡死。稳妥做法是用队列做广度优先展开代码量差别不大但循环代替递归栈深度彻底变为常数void CSweepDlg::RevealCell(int row, int col) { if (row 0 || row m_rows || col 0 || col m_cols) return; if (m_board[row][col].isRevealed) return; if (m_board[row][col].isFlagged) return; if (m_board[row][col].isMine) { m_board[row][col].isRevealed true; m_gameOver true; KillTimer(m_timerId); return; } std::queuestd::pairint, int q; m_board[row][col].isRevealed true; q.push({row, col}); while (!q.empty()) { auto [x, y] q.front(); q.pop(); for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { int nx x dr; int ny y dc; if (nx 0 || nx m_rows) continue; if (ny 0 || ny m_cols) continue; if (m_board[nx][ny].isRevealed) continue; if (m_board[nx][ny].isFlagged) continue; m_board[nx][ny].isRevealed true; if (m_board[nx][ny].number 0) { q.push({nx, ny}); } } } } }这段是整局游戏的核心逻辑需要解释一下细节。队列里只放空格number 0数字格只翻开不扩散但数字格邻接的空白区会继续入队。遍历时跳过已翻开和已插旗的格子保证不会重复处理也防止展开时把玩家标记的旗子盖掉。如果点到地雷直接掘开这一格并且标记游戏结束不用立刻把整片雷show出来视觉细节交给 OnPaint 处理。4. GDI 绘制与鼠标交互把二维数组变成能点开的棋盘4.1 自绘棋盘双缓冲与数字颜色的对应表MFC 对话框的绘制在 OnPaint 里完成。扫雷的棋盘是规则网格可以用控件数组实现每个格子一个 CButton但 16×30 就要 480 个按钮资源管理太臃肿。我更推荐自绘一个 OnPaint 把整个棋盘画出来配合鼠标消息计算行列代码集中且灵活。双缓冲是老生长谈但扫雷里闪烁特别明显因为每次点击都要重绘整块区域。先画到内存 DC再一次 BitBlt 上屏void CSweepDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOld memDC.SelectObject(bmp); memDC.FillSolidRect(rcClient, RGB(192, 192, 192)); for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { CRect cellRect( kLeftMargin c * kCellSize, kTopMargin r * kCellSize, kLeftMargin (c 1) * kCellSize, kTopMargin (r 1) * kCellSize); if (m_board[r][c].isRevealed) { DrawRevealedCell(memDC, cellRect, r, c); } else { DrawHiddenCell(memDC, cellRect, r, c); } } } dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }双缓冲的关键在最后两步DrawRevealedCell 和 DrawHiddenCell 都画到 memDC 上全部画完再整幅上屏屏幕不会出现半边旧半边新的撕裂感。数字颜色按经典扫雷的约定这个表建议直接抄数字颜色RGB 值1蓝色RGB(0, 0, 255)2绿色RGB(0, 128, 0)3红色RGB(255, 0, 0)4深蓝RGB(0, 0, 128)5棕色RGB(128, 0, 0)6青色RGB(0, 128, 128)7黑色RGB(0, 0, 0)8灰色RGB(128, 128, 128)DrawRevealedCell 里画完底色后用 DrawText 居中输出数字。字号用系统默认字体就行如果你觉得 32 像素格子里的数字太细可以用 CreateFont 建一个 16 磅粗体全局字体在 OnInitDialog 里创建、析构里删除不要在 OnPaint 里反复创建那是内存泄漏的温床。4.2 鼠标命中换算左键翻开、右键插旗鼠标消息到达时对话框只知道屏幕坐标需要把它换算成棋盘的行列。换算公式固定是“坐标减边距除以格子边长”然后必须做越界检查因为客户区边缘多出来的那几个像素会算出负数或超范围下标。void CSweepDlg::OnLButtonDown(UINT nFlags, CPoint point) { int col (point.x - kLeftMargin) / kCellSize; int row (point.y - kTopMargin) / kCellSize; if (row 0 || row m_rows || col 0 || col m_cols) return; if (m_gameOver) return; m_leftDown true; if (!m_started) { m_started true; PlaceMines(row, col); m_timerId SetTimer(1, 1000, nullptr); } if (!m_board[row][col].isFlagged) { RevealCell(row, col); InvalidateRect(nullptr, FALSE); } }逻辑顺序要特别留意。第一次点击时先布雷再翻开这样 RevealCell 才能基于完整雷区判断结果。m_started 状态标志保证只布雷一次随后 SetTimer 开始走秒。至于为什么用 SetTimer(1, 1000) 而不是 GetTickCount、再在 OnPaint 里取时间差这里的计时精度要求只有 1 秒WM_TIMER 足够不需要高精度计时。右键插旗void CSweepDlg::OnRButtonDown(UINT nFlags, CPoint point) { int col (point.x - kLeftMargin) / kCellSize; int row (point.y - kTopMargin) / kCellSize; if (row 0 || row m_rows || col 0 || col m_cols) return; if (m_gameOver) return; if (!m_started) return; if (m_board[row][col].isRevealed) return; m_rightDown true; m_board[row][col].isFlagged !m_board[row][col].isFlagged; InvalidateRect(nullptr, FALSE); }插旗只对未翻开格子有效这点不能漏。如果对手已翻开的数字格右键要么忽略要么留给“双击地雷周围数字”的快捷操作后一种做法在 4.3 展开。4.3 经典扫雷的“双击展开”左右键同时按下时的处理顺序Windows 自带扫雷有一个效率操作在已经翻开的数字格上同时按左键和右键如果这个数字周围插旗数量等于数字本身就自动翻开剩余未标记且未翻开的格子。这个操作在 MFC 里不能依赖 OnLButtonDblClk因为扫雷识别的是左右键同时按下而不是一次双击左键。我用两个布尔成员变量跟踪按键状态。OnLButtonDown 里把 m_leftDown 置真OnRButtonDown 里把 m_rightDown 置真然后在左键抬起时检查void CSweepDlg::OnLButtonUp(UINT nFlags, CPoint point) { m_leftDown false; int col (point.x - kLeftMargin) / kCellSize; int row (point.y - kTopMargin) / kCellSize; if (row 0 || row m_rows) return; if (col 0 || col m_cols) return; if (m_gameOver) return; if (!m_board[row][col].isRevealed) return; if (m_board[row][col].number 0) return; if (m_leftDown m_rightDown) { ChordReveal(row, col); } m_rightDown false; InvalidateRect(nullptr, FALSE); }ChordReveal 的实现是把当前格周围所有未翻开、未插旗的格子逐一调用 RevealCell同时在 RevealCell 中自动处理踩雷的情况。这里有一个顺序陷阱必须先检查 m_leftDown 和 m_rightDown 都为真再在清空状态如果先清 m_rightDown后面的判断就永远不成立。4.4 计时器与状态显示秒数从哪来雷数怎么显示WM_TIMER 的响应函数很简单每秒把计时器变量加 1然后更新到对话框上某个静态文本框void CSweepDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { m_elapsedSeconds; CString strTime; strTime.Format(_T(%d), m_elapsedSeconds); SetDlgItemText(IDC_STATIC_TIME, strTime); } CDialogEx::OnTimer(nIDEvent); }雷数显示用剩余的未标记雷数 总雷数 - 已插旗数每次右键插旗后更新这个文本框。注意扫雷允许玩家插比实际雷数更多的旗这个数字可能变成负数显示上按 0 处理即可。计时器从第一次点击开始游戏结束后要在 RevealCell 踩雷分支或胜利判断里调用 KillTimer否则秒数会一直跳。5. MFC 扫雷的避坑排查右键菜单、递归爆栈与内存泄漏5.1 右键插旗时弹出系统上下文菜单现象在棋盘上点击右键插旗确实画出来了但松开鼠标后窗口旁边弹出一个默认的弹出式上下文菜单里面是“剪切”“复制”“粘贴”。原因MFC 的对话框和控件默认处理 WM_CONTEXTMENU。OnRButtonDown 处理完点击后Windows 接着发出 WM_CONTEXTMENU 消息默认 handler 就把菜单弹出来。解决在消息映射里加 ON_WM_CONTEXTMENU并让处理函数什么都不干void CSweepDlg::OnContextMenu(CWnd* pWnd, CPoint point) { // 让默认菜单不弹出来插旗逻辑已在 OnRButtonDown 中完成 }这段代码也能放到 CDialogEx::OnContextMenu 的覆盖里效果相同。大多数网上源码会忽略这一步但你没写这条反而怪。类似的还有右键菜单弹出的时机问题处理函数留空不要调用基类即可。5.2 递归展开空格时栈溢出或界面假死现象在某个中等大小的棋盘上点击空白区域程序卡住不动任务管理器显示 CPU 占用接近单核满。把格子改成动态数组、棋盘调大后更频繁。原因递归的 RevealEmpty 在空白区域大时调用深度等于可连接区域的格子数。比如 30×30 的盘有一大块连续空地递归层数上千即便不爆栈频繁的函数调用和重复边界检查也会让单次点击耗时上百毫秒手感变卡。解决回到 3.4 节的 RevealCell 改写用队列代替递归。这是“看着优雅但实际激进递归”的典型案场扫雷区域展开用广搜是完全等价且更稳的。5.3 敌人翻开的格子周围显示不出数字或者数字全部是 0现象点击格子后除了点击位置有数字整片空地区域全都翻开了但紧挨着地雷的那一圈格子没有显示数字全部空白。原因说明 CalcNumbers 没有在布雷后执行或者 place mines 之后忘记了重新计算。有些变体代码一开始布雷后续重新开始游戏时只重置了数组而没完全初始化就会 odl state code notch.解决把布雷和数字计算放在同一个函数里并且保证 ResetInitBoard清空所有字段。我习惯在 InitBoard 末尾加一行注释列出状态种子清空、isMine 清 false、number 清 0这三样少一样新开的棋盘就会继承旧数据。5.4 每隔几局出现的内存泄漏任务管理器里内存慢慢涨现象反复点“重新开始”几十次进程内存一点一点增加Debug 版输出窗口出现 Detected memory leaks。原因常见有三处。第一OnPaint 里每次 CreateFont 但没 DeleteObject 或没选回原对象第二CMemDC 局部变量析构时位图没释放第三SetTimer 在重新开始时没有先 KillTimer导致计时器句柄重叠。对于这种小游戏最容易查的就是在 OnPaint 里创建 CDC 和 GDI 对象却不还原。解决OnPaint 里的 memDC 和 bmp 都声明为局部变量函数退出时自动析构释放 GDI 对象。字体对象声明为成员变量在 OnInitDialog 里 CreateFont析构函数里 DeleteObject。重新开始按钮里加一句if (m_timerId ! 0) { KillTimer(m_timerId); m_timerId 0; }Debug 模式下跑完程序在 InitInstance 退出时观察输出窗口有没有 memory leak dump有就直接双击那行看文件行号MFC 的 leak report 定位到具体分配点通常很准确。5.5 游戏结束后还能继续点开格子计时器也还在走现象踩到地雷游戏结束画面上所有雷都显示出来了但再点击其他未翻开的格子仍然能翻开或者插旗还能继续插秒表数字还在一秒一秒涨。原因消息处理函数只判断了 m_gameOver 却没有 return或者胜利/失败分支里没调用 KillTimer也没有把按钮和棋盘交互直接堵死。解决在 OnLButtonDown、OnRButtonDown 的入口统一加两行if (m_gameOver) return; if (!m_started) return;这是最简单的防御。重点是胜利判断所有非雷格全部翻开时置 m_gameOver true 并 KillTimer这个分支要和踩雷分支对接同一个终止逻辑。整局游戏结束的方式只有“踩雷”和“扫完”两种两条路径最终都走同一个 FinishGame(bool won) 函数状态就不会漏。6. 收尾技巧用状态机把代码整理利落并做难度切换与自测6.1 用枚举替代布尔组合逻辑更清晰布尔变量 m_started 和 m_gameOver 组合起来有四种状态但真正的游戏只有四种等待开局IDLE、游戏中PLAYING、胜利WON、失败LOST。用两个布尔不直观而且容易出现“既有 started 又有 gameOver”的组合让人困惑。改成枚举后每个消息处理函数开头只需要一个 switch 或 if 判断enum GameState { STATE_IDLE 0, STATE_PLAYING, STATE_WON, STATE_LOST };在这个状态机里OnLButtonDown 的逻辑变为IDLE 状态下首次点击 - 布雷并进入 PLAYINGPLAYING 状态下点击数字格 - 正常翻开若踩雷则进入 LOSTWON 和 LOST 状态下所有点击直接忽略。计时器只在 PLAYING 状态跑游戏结束进入终态后 KillTimer。重构量不大但代码的可读性明显提升后续加排行榜、加动画都只需要新增状态值。6.2 难度参数化一张表切换初级到高级InitBoard 已经接收行、列、雷数三个参数剩下就是定义三个预设配置。用一个结构体存配置struct GameConfig { int rows; int cols; int mineCount; }; const GameConfig kConfigs[] { { 9, 9, 10 }, { 16, 16, 40 }, { 16, 30, 99 } };切换难度本质上就是调一次 InitBoard(rows, cols, mineCount) 再更新按钮文本和窗口尺寸。窗口尺寸在 OnInitDialog 里根据当前配置计算切难度后重新计算并 SetWindowPos 即可。这里有一个容易忘的细节难度切换后计时器归零、插旗数归零、数组全部重建否则从高级切回初级时旧雷区还会残留。6.3 验证方法与一个常用技巧你是不是把布雷逻辑写对了不用人肉反复玩几十遍。最简单的验证方法是临时加一个调试按键按下后把所有地雷位置设为已翻开并刷新直接对比画面上的雷数与配置的雷数是否一致。另一个验证手段是写一个固定的随机数种子比如 srand(1)在 debug 下打印布雷坐标手工核对数字计算的正确性。这两种方法测试通过后再换回正常随机种子清理临时代码。自测时还要专门验证几个边界场景第一次点击四角是否能正常展开插旗数量超过实际雷数时剩余雷数是否显示为 0游戏失败后重新开始是否完全清空且下一次布雷依旧避开首点。把这些场景列成一个清单每改一次代码就跑一遍比反复手动点随机开局靠谱得多。说起验证我个人的习惯是在 InitBoard 里临时加一行 TRACE(_T(rows%d cols%d mines%d\n), rows, cols, mines)在 VS 的输出窗口看每次初始化的参数。这个小习惯帮我查过好几次难度切换后参数没更新的问题。MFC 扫雷这个项目不大但消息映射、GDI 双缓冲、状态流转都是 Windows 桌面开发的基础能把它调到没有任何内存泄漏、切难度不卡、首点不踩雷这套基本功基本就扎实了。希望这篇能帮你把这个经典练手项目做得干净利落。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
冲压机构与送料机构课程设计:从选型计算到运动仿真全流程 简介:这份说明书面向机械原理课程设计的学习者,聚焦板料冲制机中冲压机构与送料机构的设计全流程,帮助读者完成从需求分析到方案落地的完整课程设计任务。内容涵盖设计要求与设计分析、方案选择与对比、最优方案的分析设计、送料机构设计以及… · 2026/9/23 14:14:44
基于PIN管的大功率限幅器设计:从原理选型到100W工程实现 简介:这份PDF文档面向射频微波电路设计人员与通信系统硬件工程师,聚焦大功率限幅器的工程实现方法。内容以PIN二极管为核心,采用反射式三级级联结构,覆盖2-6GHz工作频段,借助HFSS与ADS分别完成电磁场仿真和电路仿真&am… · 2026/9/23 14:14:38
移动端框架避坑指南:新手3步跑通首行代码 移动端框架避坑指南:新手3步跑通首行代码 复制来的代码直接粘贴到 IDE 里,运行键一按,满屏红色的报错信息瞬间让人头大。那种“我明明照着教程写的,为什么就是不行”的无力感,是每个入门开发者的噩梦。别急,这通常不是你的错,而是移动端框架环境… · 2026/9/23 18:26:55
拆解新型 AI Agent 自动化框架:多步骤推理中的死循环检测与 Token 熔断 拆解新型 AI Agent 自动化框架:多步骤推理中的死循环检测与 Token 熔断随着 2026 年自主式 AI Agent(Autonomous Agents)在复杂代码重构、全自动化运维和端到端数据分析场景的广泛落地,多步骤工具调用(ReAct / Tool-Us… · 2026/9/23 18:26:49
YOLOv11零件表面缺陷检测实战:从数据准备到小目标优化 简介:面向工业质检与目标检测落地场景的YOLOv11零件表面缺陷检测实战教程PDF,从传统人工质检的痛点切入,系统讲解基于深度学习的高效检测方案,适合有一定基础、正在推进智能质检项目的算法工程师和研究人员。资源包含1个PDF文件&a… · 2026/9/23 18:26:43
Pocket Flow 批量翻译实战:用 100 行 LLM 框架让 Agent 并行完成多语言文档翻译 人工智能大模型AI Agent工作流自动化RAG 【免费下载链接】PocketFlow Pocket Flow: 100-line LLM framework. Let Agents build Agents! 项目地址: https://gitcode.com/gh_mirrors/poc/PocketFlow 点击查看 免费下载 Pocket Flow 是一个仅 100 行的极简 LLM 框架&… · 2026/9/23 18:26:43
虎山中学博客搭建:3种方案对比帮新手避坑 虎山中学博客搭建:3种方案对比帮新手避坑 刚啃完Python或Java的语法书,对着空白的编辑器发呆,是不是觉得脑子很清晰,手却很笨?这就是典型的 学会语法却不知怎么搭项目… · 2026/9/23 18:26:36
035、基于CM的RDMA连接建立实战:客户端与服务端 035、基于CM的RDMA连接建立实战:客户端与服务端
从一次诡异的连接超时说起
上周调试一个分布式存储项目,两台机器之间用RDMA做数据通道。代码逻辑看起来没问题,ibv_create_qp、ibv_modify_qp都返回成功,但客户端就是连不上服务端。抓包一看,CM连接请求根本没发出去。查了… · 2026/9/23 18:26:36
3招搞定手机怎么下载微信面试难题实战项目解析 3招搞定手机怎么下载微信面试难题实战项目解析 面试被问“手机怎么下载微信”背后的原理,90%的人答不上来。别笑,这看似弱智的问题,实则是考察你对移动应用分发机制、安全校验及网络协议理解的试金石。我带过不少校招新人,他们背了八股文,却连一个A… · 2026/9/23 0:00:03
你有新短消息请注意查收:3个新手避坑指南搞定消息系统选型 你有新短消息请注意查收:3个新手避坑指南搞定消息系统选型 面试被问“高并发下如何保证消息不丢失”,你张口就是“用Redis”,结果面试官追问“如果Redis宕机了怎么办”,你瞬间卡壳。这种场景太常见了,很多新手在背八股文时,只记住了技术名词… · 2026/9/23 0:00:29