尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
MFC扫雷源码实战:消息映射、双缓冲与二次开发
简介这份扫雷游戏程序面向希望借助经典项目入门 Windows 桌面开发与 MFC 框架的 C 学习者提供可直接运行、调试的工程和完整源码帮助理解界面搭建、消息映射、雷区随机生成、计时统计与点击交互等核心机制。压缩包共 71 个文件约 2.7MB涵盖 bmp 图像资源、h 与 cpp 源码、obj/pdb/ilk 等编译调试中间文件、dll 运行库、exe 可执行程序以及 rc 资源脚本和工程配置既方便还原开发环境也便于按模块对照阅读。已有 477 人学习下载作为 MFC 练手案例具有一定参考价值。读者可据此梳理对话框、自定义按钮与游戏视图的协作方式观察位图资源如何参与界面呈现并借助工程配置和调试信息排查运行问题进而掌握 C 代码与 Windows 消息机制结合的基本思路。1. 从一块会闪烁的棋盘说起MFC 扫雷源码能改到什么程度解压之后能看到sln、vcxproj、.rc资源脚本和几个.cpp用 VS 打开按 F5直接起一个 9×9 的扫雷窗口左键挖格、右键插旗数字颜色和系统自带那版几乎是同一个配色。这份源码的定位很清楚一份能编译、能玩、逻辑完整的小体量 MFC 工程棋盘数据、消息映射、GDI 绘制、计时器四块都齐了没有第三方依赖也没有绕来绕去的封装。它适合两种人一种是想找一个“能跑起来的最小 MFC 项目”练手的把消息映射、文档视图、资源脚本一次看明白另一种是手里有别的 MFC 老工程想拿一个结构干净的样板对照着改。想拿去当商业产品不现实但把难度、皮肤、判定规则拆开重写一遍能把手感对上是完全够用的。2. 工程骨架与消息映射从 WinMain 到 OnDraw 的启动链路MFC 工程看着文件多真正决定“这程序怎么跑起来”的只有三步应用类建主框架、主框架挂视图、视图靠消息映射接住鼠标和绘制请求。扫雷这种只需要一个窗口、一块画布的程序用单文档结构最省事——文档类放棋盘数据视图类管绘制和交互主框架只负责窗口外壳和菜单。这一节把目录结构和启动链路拆开看。2.1 文件清单每个文件改它是为了什么先看目录标准 MFC 单文档工程大概是这个形状文件职责你以后会改它的场景MineApp.h/.cppCWinApp派生类InitInstance里创建主框架改启动窗口尺寸、改注册表键名、加命令行参数MainFrm.h/.cppCFrameWnd派生装菜单、工具栏、状态栏加“新游戏”“难度”菜单项状态栏显示剩余雷数MineDoc.h/.cppCDocument派生持有CBoard棋盘对象存档读档、难度配置持久化MineView.h/.cppCView派生绘制与鼠标交互换配色、换皮肤、改格子大小Board.h/.cpp、MineCell.h纯逻辑层不依赖 MFC 窗口改规则、加无猜模式、加撤销Mine.rc、resource.h菜单、图标、位图、字符串表换程序图标、换笑脸位图、加菜单pch.h/stdafx.h预编译头引入新头文件加速编译这套分层的价值在第 6 章会体现棋盘逻辑独立在Board里以后加撤销、加自动解题、把 MFC 界面换成别的框架都不用动视图以外的代码。很多网上流传的扫雷源码把所有逻辑塞进OnLButtonDown里能跑但改一个规则要翻三百行血泪经验就是别学那种写法。2.2 消息映射的三段式ON_WM_宏到底绑定了什么MFC 不用回调函数指针表而是用一组宏把 Windows 消息翻译成成员函数调用。一个消息要接通必须三处对齐头文件里afx_msg声明、cpp 里BEGIN_MESSAGE_MAP注册、函数实现。少任何一处都会在链接期或运行期翻车。// MineView.h class CMineView : public CView { DECLARE_DYNCREATE(CMineView) protected: CMineView(); DECLARE_MESSAGE_MAP() // 第一处告诉编译器这个类有消息表 public: virtual void OnDraw(CDC* pDC); // OnDraw 是虚函数不走消息表 afx_msg void OnLButtonDown(UINT nFlags, CPoint point); // 第二处声明处理函数 afx_msg void OnRButtonDown(UINT nFlags, CPoint point); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnTimer(UINT_PTR nIDEvent); };// MineView.cpp BEGIN_MESSAGE_MAP(CMineView, CView) ON_WM_LBUTTONDOWN() // 对应 afx_msg void OnLButtonDown(UINT, CPoint) ON_WM_RBUTTONDOWN() // 对应 afx_msg void OnRButtonDown(UINT, CPoint) ON_WM_ERASEBKGND() // 对应 afx_msg BOOL OnEraseBkgnd(CDC*) ON_WM_TIMER() // 对应 afx_msg void OnTimer(UINT_PTR) END_MESSAGE_MAP() void CMineView::OnLButtonDown(UINT nFlags, CPoint point) { // point 是客户区坐标单位是像素原点在视图左上角 CView::OnLButtonDown(nFlags, point); }关键在于这些ON_WM_xxx宏对函数名和签名是硬绑定的ON_WM_LBUTTONDOWN展开后内部会做一次静态类型检查参数类型写成CPoint或者函数名少个On编译期直接报“无法从成员函数指针转换”。这算 MFC 留给后人的一点善意比运行期莫名其妙不响应强。参数含义也固定nFlags携带 Ctrl、Shift 这些修饰键状态point是相对视图客户区的坐标跟屏幕坐标差一个ClientToScreen。注意OnDraw不属于消息映射它是CView的虚函数由框架在WM_PAINT处理过程中回调。所以它不在BEGIN_MESSAGE_MAP里出现也不要加afx_msg。2.3 编译配置静态链接 MFC 与 Unicode 字符集同一份源码在别人机器上打不开八成是工程属性里的两个开关不一样。第一个是 MFC 的链接方式PropertyGroup LabelConfiguration ConfigurationTypeApplication/ConfigurationType UseOfMfcStatic/UseOfMfc !-- 静态链接 MFC免分发运行库 -- CharacterSetUnicode/CharacterSet !-- 全工程走 wchar_t字符串统一 _T() -- PlatformToolsetv143/PlatformToolset !-- 按本机装的工具集改别硬抄 -- WindowsTargetPlatformVersion10.0/WindowsTargetPlatformVersion /PropertyGroupUseOfMfc设成Static之后exe 从两三百 KB 涨到四 MB 左右换来的是拷到任意一台 Windows 上双击就能跑不用先装运行库。设成Shared DLL体积小但目标机器缺对应版本的运行库时会弹“计算机中丢失 xxx.dll”对发给别人试玩的场景很不友好。我自己发小工具一律用静态。CharacterSet设成Unicode之后所有CString走宽字符_T(扫雷)不会因为代码文件编码问题变成乱码。如果你从网上抠了一段 ANSI 时代的老代码贴进来strcpy、sprintf那套会报警告甚至编译失败统一换成_tcscpy_s、_stprintf_s或者干脆CString::Format。2.4 第一次编译按顺序检查这几项拿到源码别急着 F5按下面这个顺序过一遍能省掉大半“打不开”的问题解压路径别有中文和空格D:\code\mine这种最稳。某些资源编译步骤对路径里的非 ASCII 字符处理得不好。右键解决方案 → 重定解决方案目标让工具集跟本机装的版本对上别保留原作者的环境。检查.rc里引用的位图、图标路径。常见写法是res\\Mine.ico如果你把资源目录改过名这里要同步。确认pch.h或stdafx.h名字跟工程属性里“预编译头”设置一致MFC 老工程用stdafx.h新工程模板用pch.h混用会报C1010。F7 编译先看警告。C4244size_t转int这类在扫雷里大量出现不影响运行但说明下标类型不统一改代码前先把它们清掉。F5 运行如果窗口出来了但一片空白跳到第 5 章的排查清单。3. 核心数据结构位掩码格子、首点保护布雷与空白展开扫雷的逻辑量不大但坑集中在细节格子有几种状态要同时记住、第一次点击不能炸、点开空白要连片展开。这三件事的处理方式直接决定代码是三百行还是八百行。这一章按数据、布雷、展开、判胜的顺序走一遍。3.1 一个 BYTE 存三种状态位掩码比三个 bool 更省事每个格子要记的信息其实只有四项是不是雷、有没有被翻开、有没有插旗、周围八格雷数。前三个都是布尔量用一个字节的三个 bit 就能压住剩下一个字节存 0 到 8 的数字。// MineCell.h #pragma once class CMineCell { public: void Reset() { m_byState 0; m_nNeighbor 0; } // bit0: 是雷 bit1: 已翻开 bit2: 已插旗 bool IsMine() const { return (m_byState 0x01) ! 0; } void SetMine(bool b) { b ? (m_byState | 0x01) : (m_byState ~0x01); } bool IsRevealed() const { return (m_byState 0x02) ! 0; } void SetRevealed(bool b) { b ? (m_byState | 0x02) : (m_byState ~0x02); } bool IsFlagged() const { return (m_byState 0x04) ! 0; } void ToggleFlag() { m_byState ^ 0x04; } int NeighborCount() const { return m_nNeighbor; } void SetNeighborCount(int n) { m_nNeighbor static_castBYTE(n); } private: BYTE m_byState 0; // 三个状态位 BYTE m_nNeighbor 0; // 周围雷数合法范围 0~8 };这么做的好处不只是省内存。用位操作之后“翻开一个格子”和“插旗”互不干扰插旗时只翻转 bit2翻开时只置 bit1不会出现三个bool成员互相覆盖、忘了同步的情况。另一个隐性收益是ToggleFlag用异或右键一下插旗、再右键取消一行搞定不用写 if-else。NeighborCount用BYTE而不是int是因为它的取值域被死死限制在 0 到 8。这一点在调试时很有用如果你在调试器里看到某个格子的邻居数是 47说明CountMinesAround的下标算错了直接定位到布雷函数不用满工程找。棋盘本体用一维数组存索引公式是r * m_nCols c// Board.h class CBoard { public: void Reset(int nRows, int nCols, int nMines); int Rows() const { return m_nRows; } int Cols() const { return m_nCols; } bool InRange(int r, int c) const { return r 0 r m_nRows c 0 c m_nCols; } CMineCell At(int r, int c) { return m_cells[r * m_nCols c]; } const CMineCell At(int r, int c) const { return m_cells[r * m_nCols c]; } private: std::vectorCMineCell m_cells; int m_nRows 9, m_nCols 9, m_nMines 10; };一维存储相比vectorvectorCMineCell少一层指针跳转整块内存连续在Reset时一次assign就完成清空不用双重循环。9×9 的时候感觉不出来等你把棋盘开到自定义 50×50一次性重置的耗时差距就明显了。3.2 首点保护与布雷把安全区从 1 格扩到 9 格经典扫雷有一条隐性规则第一次点击永远不会踩雷。实现上有两种思路一种是在第一次点击之前根本不布雷等点击来了再排除那个格子随机撒雷另一种是开局就布雷如果第一下点中了雷就把这颗雷挪走。前者更干净因为不用处理“挪走之后邻居数要重算”的连锁问题。void CBoard::Reset(int nRows, int nCols, int nMines) { m_nRows nRows; m_nCols nCols; m_nMines nMines; m_cells.assign(nRows * nCols, CMineCell()); m_nRevealed 0; m_bFirstClick true; // 还没布雷等着第一次点击 m_bGameOver false; m_bWin false; } void CBoard::PlaceMines(int nSafeRow, int nSafeCol) { // 1. 收集所有可以放雷的位置把首次点击的 3×3 区域排除掉 std::vectorint pool; pool.reserve(m_nRows * m_nCols); for (int r 0; r m_nRows; r) { for (int c 0; c m_nCols; c) { bool bInSafeZone (abs(r - nSafeRow) 1 abs(c - nSafeCol) 1); if (!bInSafeZone) pool.push_back(r * m_nCols c); } } // 2. 洗牌取前 m_nMines 个位置放雷 std::mt19937 rng(std::random_device{}()); std::shuffle(pool.begin(), pool.end(), rng); int nPlaced min(m_nMines, static_castint(pool.size())); for (int i 0; i nPlaced; i) m_cells[pool[i]].SetMine(true); // 3. 重算所有格子的邻居雷数 for (int r 0; r m_nRows; r) for (int c 0; c m_nCols; c) At(r, c).SetNeighborCount(CountMinesAround(r, c)); m_bFirstClick false; }安全区取 3×3 而不是只有点击的那一格是为了游戏体验如果只保护单格点了之后周围八个数字全是 1 或 2第一下就得开始推理不舒服。把周围八格一起排除掉第一次点击大概率是空白连片展开手感更接近系统自带那版。这个细节如果原文没写常见做法就是扩到 3×3我自己也一直用这个范围。std::shuffle需要一个随机源std::mt19937配std::random_device是标准组合random_device负责取种子mt19937负责生成质量够好的序列。写rand() % n也能跑但相邻两局的雷分布会呈现出肉眼可见的规律尤其是重开速度快的场景。CountMinesAround就是标准的八方向遍历int CBoard::CountMinesAround(int r, int c) const { int nCount 0; for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; // 跳过中心格 int nr r dr, nc c dc; if (InRange(nr, nc) At(nr, nc).IsMine()) nCount; } } return nCount; }dr和dc的双重循环是扫雷里出现频率最高的写法八方向的边界检查全靠InRange兜住。这里最容易翻车的点是InRange写成r 0 r m_nRows多了一个等号于是边缘格子的邻居数读到了数组外的一块内存Debug 下可能不崩Release 下拿到随机值表现为“数字偶尔不对重开一把又好了”——这种玄学 bug 我调过一个下午。3.3 空白区域展开为什么我不用递归点开一个邻居数为 0 的格子要把连片的 0 以及 0 周围的一圈数字全部翻开。教科书上的写法是递归// 不推荐大棋盘上会爆栈 void CBoard::RevealRecursive(int r, int c) { if (!InRange(r, c)) return; CMineCell cell At(r, c); if (cell.IsRevealed() || cell.IsFlagged()) return; cell.SetRevealed(true); m_nRevealed; if (cell.NeighborCount() 0) return; for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) if (dr || dc) RevealRecursive(r dr, c dc); }9×9 用递归完全没问题最大深度也就几十层。但一旦把棋盘改成自定义 100×100挖开一大片空白时递归深度能到上千默认 1MB 的线程栈撑不住直接访问冲突崩掉。而且递归版本在 Debug 下帧率掉得厉害因为每一层都要重新算一遍边界。改写成显式栈代码量差不多但没有深度上限void CBoard::Reveal(int r, int c) { if (!InRange(r, c)) return; if (At(r, c).IsMine()) { return; } // 踩雷由上层单独处理 std::vectorCPoint stack; stack.push_back(CPoint(c, r)); while (!stack.empty()) { CPoint p stack.back(); stack.pop_back(); CMineCell cur At(p.y, p.x); if (cur.IsRevealed() || cur.IsFlagged() || cur.IsMine()) continue; // 出栈时再判一次因为可能被重复压入 cur.SetRevealed(true); m_nRevealed; // 只有邻居数为 0 的格子才继续向外扩散 if (cur.NeighborCount() 0) continue; for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; int nr p.y dr, nc p.x dc; if (InRange(nr, nc) !At(nr, nc).IsRevealed()) stack.push_back(CPoint(nc, nr)); // CPoint 是 (x, y)注意顺序 } } } }两处细节值得说。第一CPoint的构造是(x, y)而棋盘索引习惯是(row, col)压栈时写反了不会报错只会得到一张歪着展开的棋盘现象是“点左上角右下角亮了一片”。第二出栈之后必须再判一次IsRevealed因为同一个格子可能被多个邻居压进栈不复判就会重复计数m_nRevealed最后提前判定胜利。这个坑很隐蔽胜利条件算的是已翻开格子数重复计数会让游戏在你还没挖完的时候就弹出“你赢了”玩家一头雾水。3.4 胜负判定与剩余雷数显示胜利条件有个常见误写要求“所有非雷格翻开”并且“所有雷都插旗”。后者是多余的因为插旗是辅助记忆不该成为游戏规则的一部分玩家完全可以不插一面旗把整盘挖完。正确的判定只看翻开数量bool CBoard::CheckWin() const { // 非雷格全部翻开即算胜旗子插不插不影响 return m_nRevealed m_nRows * m_nCols - m_nMines; } int CBoard::RemainingMines() const { return m_nMines - m_nFlagCount; // 显示在界面上的剩余雷数可能是负数 }RemainingMines允许返回负数因为玩家可以插超过雷数的旗。界面上显示成-3就行不要用max(0, ...)夹一下——夹了之后玩家就不知道自己多插了几面旗反而误判。失败判定放在点击入口里做踩雷之后要把所有雷的位置暴露出来并把踩中的那颗标红bool CBoard::HandleLeftClick(int r, int c) { if (m_bGameOver || !InRange(r, c)) return false; CMineCell cell At(r, c); if (cell.IsRevealed() || cell.IsFlagged()) return false; if (m_bFirstClick) PlaceMines(r, c); // 第一次点击才布雷安全区含 3×3 if (cell.IsMine()) { cell.SetRevealed(true); m_bGameOver true; // 视图据此重画所有雷显示、踩中那颗标红 return true; } Reveal(r, c); if (CheckWin()) { m_bWin true; m_bGameOver true; } return true; }返回值是“棋盘状态是否变化”视图拿它决定要不要Invalidate。这样点已翻开的格子、点旗子、游戏结束后点击都不会触发重绘少刷几次屏也避免了游戏结束后还能继续操作的 bug。4. 绘制与交互双缓冲 GDI 加鼠标坐标反算的完整闭环逻辑层写完之后剩下的工作全部集中在视图把CBoard的状态画成棋盘把鼠标像素坐标换算回行列号。用 GDI 直接画这种由几十个小方块拼成的界面会遇到两个经典问题——闪烁和坐标算错。这一章把绘制、交互、计时器三块串起来。4.1 双缓冲先画到内存 DC再一次性贴回屏幕不用双缓冲的话WM_ERASEBKGND擦白一次、OnDraw再逐格画一次中间那一帧是空白肉眼看到的就是棋盘抖。尤其点开一片空白的时候几十个格子同时重绘闪得没法看。void CMineView::OnDraw(CDC* pDC) { if (pDC nullptr) return; // 打印预览时 pDC 为空要挡一下 CBoard board GetDocument()-GetBoard(); CRect rcClient; GetClientRect(rcClient); // 1. 建一块和客户区等大的内存位图 CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(pDC); bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 全部内容先画到 memDC 上 DrawBoard(memDC, board); DrawHeader(memDC); // 笑脸、剩余雷数、计时 // 3. 一次 BitBlt 贴回屏幕中间不会出现半成品 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }三步缺一不可CreateCompatibleDC建内存 DCCreateCompatibleBitmap建与屏幕色彩格式一致的位图SelectObject把它选进去。最后那个SelectObject(pOldBmp)千万别忘位图对象被 DC 占着的时候CBitmap析构会失败表现为 GDI 对象句柄数持续上涨玩一会儿程序就卡死——这类资源泄漏在 MFC 里非常常见。光有双缓冲还不够WM_ERASEBKGND默认会把客户区刷成背景色这一刷仍然会闪。所以还得拦一刀BOOL CMineView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 告诉框架背景我自己处理跳过默认擦除 }因为OnDraw里我们用BitBlt覆盖了整个客户区不存在“没画到的地方露出背景”的问题直接返回TRUE最干净。返回FALSE也可以但某些显卡驱动下仍能看到一帧白玄学问题不如一律TRUE。4.2 鼠标坐标反算一个减号决定点得准不准视图拿到的point是客户区像素坐标要先减掉棋盘在窗口里的偏移上面那块显示笑脸和计时的头部高度再除以格子边长void CMineView::OnLButtonDown(UINT nFlags, CPoint point) { int nRow, nCol; if (!HitTest(point, nRow, nCol)) { CView::OnLButtonDown(nFlags, point); return; } CMineDoc* pDoc GetDocument(); if (pDoc-GetBoard().HandleLeftClick(nRow, nCol)) { // 第一次有效点击才启动计时器 if (m_bTimerStarted false pDoc-GetBoard().IsFirstClickDone()) { SetTimer(IDT_GAME_TIMER, 1000, nullptr); m_bTimerStarted true; } Invalidate(FALSE); // FALSE 不擦背景配合 OnEraseBkgnd 完全不闪 } CView::OnLButtonDown(nFlags, point); } BOOL CMineView::HitTest(CPoint pt, int nRow, int nCol) const { CBoard board GetDocument()-GetBoard(); const int nSize m_nCellSize; // 比如 24 像素 // 先减去棋盘左上角在客户区里的偏移 int x pt.x - m_ptBoardOrigin.x; int y pt.y - m_ptBoardOrigin.y; if (x 0 || y 0) return FALSE; nCol x / nSize; nRow y / nSize; return board.InRange(nRow, nCol); }Invalidate(FALSE)的参数是bErase传FALSE表示不产生背景擦除消息。这是双缓冲的标配写法两个机制叠起来才不会闪。HitTest里那句if (x 0 || y 0) return FALSE也不能省整数除法对负数是向零取整-3 / 24得到 0于是点在棋盘左边空白处也会被算成第 0 列点一下莫名其妙挖开一个格。右键插旗同理只是调用换成ToggleFlagvoid CMineView::OnRButtonDown(UINT nFlags, CPoint point) { int nRow, nCol; if (HitTest(point, nRow, nCol)) { CMineDoc* pDoc GetDocument(); if (pDoc-GetBoard().ToggleFlag(nRow, nCol)) Invalidate(FALSE); } CView::OnRButtonDown(nFlags, point); }ToggleFlag应该拒绝两种情况格子已经翻开、游戏已结束。这两个判断放在CBoard里而不是视图里是为了让逻辑层自己保证状态一致性——将来加撤销功能时你只需要重放操作不用担心界面层的判断漏了哪一条。4.3 计时器一个 ID一处启动一处销毁SetTimer的第三个参数是间隔毫秒数第四个是回调函数指针。传nullptr表示走WM_TIMER消息由OnTimer接住。扫雷需要秒级刷新间隔写 1000。void CMineView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent IDT_GAME_TIMER) { CMineDoc* pDoc GetDocument(); if (pDoc-GetBoard().IsGameOver()) { KillTimer(IDT_GAME_TIMER); // 游戏结束立刻停表否则计数还会涨 m_bTimerStarted false; return; } if (m_nElapsed 999) m_nElapsed; // 三位数显示上限封顶不再涨 // 只重画头部那一小块不重画整个棋盘 CRect rcHeader(0, 0, m_ptBoardOrigin.y, ClientRect().Width()); InvalidateRect(rcHeader, FALSE); } CView::OnTimer(nIDEvent); }三个点值得强调。第一计时器不能在构造函数里启动必须等第一次有效点击之后否则窗口一打开秒表就在跑手感不对。第二游戏结束时必须KillTimer否则秒表继续走玩家会看到“已经炸了但时间还在涨”。第三InvalidateRect只刷头部区域比整块Invalidate省得多300 秒的时候也不会有任何掉帧感。4.4 数字配色用DrawText还是用位图扫雷数字有固定配色1 蓝、2 绿、3 红、4 深蓝这套颜色是玩家形成肌肉记忆的一部分改乱了会非常别扭。void CMineView::DrawNumber(CDC* pDC, const CRect rc, int n) { static const COLORREF kColors[9] { RGB(0, 0, 0), RGB(0, 0, 255), RGB(0, 128, 0), RGB(255, 0, 0), RGB(0, 0, 128), RGB(128, 0, 0), RGB(0, 128, 128), RGB(0, 0, 0), RGB(128, 128, 128) }; CString s; s.Format(_T(%d), n); CRect rcText(rc); // DrawText 需要非 const 指针先拷一份 CFont* pOldFont pDC-SelectObject(m_fontNumber); pDC-SetBkMode(TRANSPARENT); // 不画文字背景否则每个数字带个灰底 pDC-SetTextColor(kColors[n]); pDC-DrawText(s, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); pDC-SelectObject(pOldFont); }SetBkMode(TRANSPARENT)这行漏了的话每个数字后面会带一块和默认背景色相同的矩形格子的立体边框被盖住一截看起来像贴了补丁。DrawText的第二个参数类型是LPRECT从const CRect直接取地址过不了编译常规做法就是像上面那样拷一份再传别用const_cast强转容易在后面被DrawText修改内容时踩到未定义行为。如果要做皮肤把DrawText换成CDC::DrawState贴位图就行数字 0 到 8 一共九张 24×24 的小图放在res目录里拷出来用图片编辑器改完再编回去比改代码快。m_fontNumber建议在视图构造时一次创建、析构时销毁不要在OnDraw里反复CreateFont——那是每秒几十次 GDI 对象创建销毁句柄数会飘。5. 避坑与排查五处能让扫雷跑偏的细节这份代码不大但从“能编译”到“玩着顺手”中间隔着一堆边角问题。下面五条是我自己改这类工程时真实撞过的按现象、原因、解决三段写撞上了直接对号入座。5.1 棋盘能显示但点击完全没反应现象窗口出来了格子也画好了左右键点下去毫无变化连踩雷爆炸都没有。原因九成是消息映射没接上。要么BEGIN_MESSAGE_MAP里漏了ON_WM_LBUTTONDOWN()要么OnLButtonDown的签名被改过比如把CPoint写成CPoint宏展开时静态断言没通过但这个错误在某些配置下被当成警告放过去了。还有一种可能视图窗口根本没拿到焦点鼠标消息被某个覆盖在上面的子控件吃掉了。解决在OnLButtonDown第一行下断点用调试器确认函数是否被调用。没进断点就回到消息映射查三处对齐进了断点但棋盘不变就看HitTest的返回值多半是m_ptBoardOrigin算错坐标落在棋盘外。5.2 点第一下就炸而且重开还会再炸现象开局随手一点直接 Game Over重开一局再点还是那个位置附近炸。原因布雷时机搞错了。如果在Reset里就把雷撒好m_bFirstClick只用来做显示上的判断逻辑上并没有真正排除第一次点击的格子那踩雷是必然的——9×9 十颗雷第一下踩中的概率超过一成。另一种写法是判断了但没重算邻居数结果是雷挪走了周围一圈数字还是旧值。解决确认PlaceMines只在第一次有效点击时调用一次且调用之后m_bFirstClick置为false。布雷完成后必须整体重算一遍NeighborCount不能只更新被移动的那几格。判断是不是这个问题可以在PlaceMines里下条件断点看它是不是被调用了两次。5.3 计时器越点越快秒数跳得离谱现象玩了几局之后秒表一秒跳两三下或者同一局里时间偶尔跳两秒。原因SetTimer被重复调用而旧的没销毁。每次开局如果只调SetTimer不先KillTimer同一 ID 的计时器会被替换这一点还好但如果代码用了不同 ID 或者重复进入某个分支就会挂上好几个计时器每个都在给m_nElapsed加一。另一种情况是OnTimer里忘了处理游戏结束分支KillTimer从来没被调用过。解决把SetTimer和KillTimer成对管理最稳的写法是在StartNewGame里先无条件KillTimer(IDT_GAME_TIMER)再按需SetTimer把“新开一局”当成一次完整的资源重置。秒表计数用一个独立成员别和显示用的字符串混在一起。5.4 Debug 下一切正常Release 下偶发崩溃或数字错乱现象F5 调试跑一整天都没事切到 Release 双击 exe 玩两分钟就闪退或者边缘格子的数字莫名其妙。原因数组下标越界。std::vector::operator[]在 Release 下不做边界检查越界读到的是相邻内存的垃圾值Debug 下因为堆分配器填了特定字节通常是0xCD反而“看起来正常”。这类问题的源头往往是InRange的判断条件写成了或者是Reveal展开时没有对每个邻居单独做边界检查只在最外层做了一次。解决临时把At()里的operator[]换成at()让它在越界时抛std::out_of_range崩的位置一目了然。定位完之后再换回来或者保留at()并用_HAS_ITERATOR_DEBUGGING控制代价是一点性能扫雷这个体量完全无所谓。5.5 高分辨率屏上格子糊成一团点击位置偏一格现象在 150% 缩放的笔记本上棋盘被系统拉伸格子边缘发虚鼠标点上去总是差半格甚至整格。原因工程没有声明 DPI 感知Windows 就把整个窗口位图放大 1.5 倍OnDraw里画的 24 像素到了屏幕上变成 36 像素的模糊块而鼠标坐标仍然是逻辑坐标两者对不上。另一种情况是用了GetSystemMetrics拿到的固定值去算格子大小缩放后这个值没跟着变。解决在InitInstance里调一次SetProcessDpiAwarenessContext或者往清单文件里加 DPI 感知声明让程序自己按物理像素渲染格子边长不要写死 24用GetDpiForWindow拿当前 DPI 再乘一个系数算出来。改造完记得把m_nCellSize和m_ptBoardOrigin一起按新尺寸重算只改一个会导致点击偏。6. 二次开发加一套撤销栈和自定义棋盘我一般这么下手这份源码最值得利用的一点是逻辑层CBoard和视图层已经分开了所以二次开发优先往CBoard上加东西视图只负责把新状态画出来。我改这类项目一般先从三个方向下手按投入产出排序。第一个方向是加撤销。扫雷的后悔药其实很好实现因为每一步操作对棋盘的改动都可以用“受影响的格子集合 原状态”记录下来。给CBoard加一个操作栈// 一次操作涉及的所有格子及其原状态 struct CBoardMemento { std::vectorstd::pairint, CMineCell cells; // 下标 - 旧状态 bool bWasGameOver false; bool bWasWin false; }; void CBoard::PushMemento() { m_undoStack.push_back(CBoardMemento()); m_undoStack.back().bWasGameOver m_bGameOver; m_undoStack.back().bWasWin m_bWin; if (m_undoStack.size() 50) // 只留最近 50 步防止内存涨 m_undoStack.erase(m_undoStack.begin()); } void CBoard::RecordCell(int r, int c) { if (m_undoStack.empty()) return; int idx r * m_nCols c; auto list m_undoStack.back().cells; // 同一个格子重复记录没有意义只留第一次的旧状态 for (auto kv : list) if (kv.first idx) return; list.push_back({ idx, At(r, c) }); } bool CBoard::Undo() { if (m_undoStack.empty()) return false; CBoardMemento m m_undoStack.back(); for (auto kv : m.cells) m_cells[kv.first] kv.second; // 整格还原不用逆向推导 m_bGameOver m.bWasGameOver; m_bWin m.bWasWin; m_undoStack.pop_back(); return true; }关键是“整格存旧值”这个思路不要试图去逆向计算“翻开一片空白”怎么撤回直接存快照撤回时整格覆盖。格子对象只有两个字节50 步快照的内存开销可以忽略换来的是逻辑上绝对正确。注意在HandleLeftClick里每改动一个格子之前都调一次RecordCellReveal的展开循环里也要调漏掉就会撤出半截棋盘。第二个方向是自定义棋盘。难度从三档写死改成读取配置尺寸和雷数从菜单里输入每次Reset后按新尺寸重算m_ptBoardOrigin和窗口大小。这里有个顺带的好处棋盘大到 30×16 之后第 3 章那种递归展开会开始暴露问题如果你前面已经改成显式栈就不用再返工了。第三个方向是外观。DrawBoard里所有颜色和边框全是可以替换的点做一套位图资源把FillSolidRect和Draw3dRect换成StretchBlt贴图就行。真要动这块建议先加一层CSkin抽象把“取哪张图”和“画在哪”分离否则以后每换一次皮肤都要重新读一遍绘制代码。我自己的习惯是每加一个新功能之前先把对应的状态写进调试输出用TRACE打一行跑一遍确认状态变化符合预期再回头做界面。从那以后我每次改这类棋盘逻辑都强制先把CBoard的单元测试写出来再动视图——曾经因为一个计数重复的 bug 排查了整晚那种查到最后发现只是m_nRevealed多执行了一次的感受不想再有第二次。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

Agentic AI与提示工程如何革新推荐系统

Agentic AI与提示工程如何革新推荐系统

1. 为什么Agentic AI提示工程正在重塑推荐系统最近半年,我观察到推荐系统领域出现了一个明显的技术拐点。传统基于协同过滤和深度学习的推荐算法,正在被一种更灵活、更具解释性的技术路径所补充——这就是Agentic AI与提示工程的结合体。这种组合不仅解决…

📅 2026/10/29 4:53:56
Qwen大模型Linux部署与Docker集成实战指南

Qwen大模型Linux部署与Docker集成实战指南

1. 开源AI工具链实战:从Qwen模型到Linux系统部署全指南在AI技术爆发式增长的当下,开源社区涌现出大量值得关注的工具和框架。作为一名长期混迹技术社区的开发者,我最近完整走通了从Qwen大模型部署到Linux系统优化的全流程,过程中积…

📅 2026/10/29 4:48:56
YOLO11与MAN-Faster在智能安防中的实战应用

YOLO11与MAN-Faster在智能安防中的实战应用

1. 项目概述:当YOLO遇上安防监控入室盗窃检测一直是智能安防领域的核心痛点。传统方案主要依赖红外感应和运动检测,误报率高得让人头疼——刮风窗帘动一下、宠物跑过都可能触发警报。我们团队基于最新发布的YOLO11架构,融合了MAN-Faster改进策…

📅 2026/10/29 4:48:56
MORE NEWS

更多资讯

📰

链游2.0:构建可持续的区块链游戏经济模型与技术架构

1. 链游行业现状与困境剖析链游(区块链游戏)作为GameFi领域的重要分支,近年来经历了从狂热到冷静的市场周期。2021年Axie Infinity的爆发让市场看到了"Play to Earn"模式的潜力,但随后大量仿盘项目的崩盘也暴露了行业深…

📰

从print大法到线上问题排查:caveman调试法为什么至今仍是有效底牌

那天晚上快12点,监控群里连着弹了三条报警,内容是线上某个接口的P99延迟从300毫秒直接飙到3秒,可用性掉了一个点。我登上生产环境看了半天,没有分布式追踪、没有链路ID、日志也只有零星几条,整个服务就像一口黑井。折腾…

📰

AI Agent任务完成微信通知实现方案

1. 为什么AI Agent需要任务完成通知?当我们在开发AI Agent自动化流程时,最常遇到的痛点就是:不知道任务什么时候跑完。想象一下,你部署了一个耗时较长的数据分析Agent,或者一个定期爬取数据的Agent,总不能一…

📰

基于CANN架构的智能视频分析系统设计与优化

1. 项目概述在AI技术快速落地的今天,智能视频分析已成为计算机视觉领域最具商业价值的应用方向之一。而CANN(Compute Architecture for Neural Networks)作为专为神经网络计算设计的异构计算架构,正在为这一领域提供强大的底层支持…

📰

营销技能体系拆解:从执行到操盘手的能力清单

做营销这些年我踩过最大的坑,就是以为营销 skills 就是会写文案、会投广告、会做活动。直到我带团队、带项目,看着一个又一个方案从立项到扑街,才慢慢想明白一件事:营销这个行当,表面上拼的是创意和资源,实…

📰

hyperframes参考架构实战:从GPU利用率到分布式训练优化

最近圈里好几个群都在聊 hyperframes,有人拿它跟传统 AI 训练方案比,有人拿它跟云上容器化方案比,还有人直接问“这东西到底是不是又一个套壳框架”。我因为工作原因,正好带团队做过一轮相关的性能验证和落地评估,从硬…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬