
简介扫雷游戏与MFC框架结合形成一份适合C初学者和Windows游戏开发入门者的完整示例工程。开发者可从中看到如何用MFC封装Windows API实现经典扫雷玩法包括窗口管理、鼠标消息处理、CDC图形绘制及游戏状态控制同时附带可直接运行的exe程序便于对照验证。压缩包共54个文件约2.96MB以h/cpp源码为主另有bmp位图和wav音频用于界面与音效dsp/dsw工程文件适合在VC环境中直接打开。已有145人学习下载适合正在学习MFC或想用C完成小游戏实践的人群通过阅读源码与资源脚本可以快速掌握基于MFC的窗口继承、消息映射、位图贴图、雷区状态更新等技巧结合菜单、图标等资源定义还能理解Windows桌面应用的完整工程组织方式是一份便于阅读和二次开发的实战样例整个项目体量适中却覆盖了从界面布局到雷区逻辑的完整流程能帮助读者将零散的MFC知识点串联起来。1. MFC扫雷的起点从对话框模板到消息循环很少有人会把扫雷当成一门交叉学科来学。可当你打开 saolei.rar用 MFC 编译一个扫雷工程会同时遇到 C 类继承、Windows 消息循环、GDI 绘图状态和递归展开带来的栈溢出一个都没落下。MFC 扫雷不是把算法跑通就完事它是少数能把窗口生命周期、定时器、自绘控件和内存调试全部串起来的小项目。对刚接触 VC 的人来说它是计算器之外最好的第二个练手项目对写过多年 MFC 的老手它也足够用来检验你对运行库重分布和 DPI 缩放的掌握程度。这个标题里真正值钱的不是“扫雷”两个字而是“MFC 实现”这四个字。MFC 把窗口注册、消息泵和控件创建都用宏和封装类藏了起来安装向导会直接生成一个能跑空的对话框。你要做的不是把 CButton 拖上去而是在类视图里添加消息处理函数再自己维护一份棋盘状态。代码同样可以在 VS2010 到 VS2022 之间编译只要你把项目属性中的字符集统一成 Unicode并注意不同版本对CString内部类型的差异。如果你已经有多年 Windows 开发经验可以跳过基础段落直接看第 2 章的洗牌算法和第 5 章的 DPI 处理。第 5 章里的“静态链接 MFC”和“命令行 -debug 参数”两个小技巧适合直接搬进自己的工具项目里。2. 扫雷核心算法布雷、计数与递归展开在动手画界面之前先把游戏逻辑抽成一个类CSweepMine。它不依赖任何 MFC 类型这样可以独立跑单元测试也方便以后移植到 Qt 或别的地方。成员变量只有宽、高、雷数、一维棋盘数组和方向表。为什么用一维数组而不是二维因为一维数组可以避免vectorvectorint初始化繁琐也便于用memcpy存档索引转换用y * m_width x即可。难度参数如下表对话框里的“初级/中级/高级”下拉框就是把这些值存进变量再传给构造函数。难度宽度高度雷数格子数初级991081中级161640256高级301699480自定义9-309-2410-667最多 720布雷我习惯用std::shuffle洗牌而不是逐个随机并检查重复因为洗牌的时间复杂度是 O(n)且天然均匀。有些老代码用rand() % totalCells然后判断是否已被占用这在雷数接近格子数时会陷入死循环而且随机质量差。shuffle的做法是先构造 0 到 total-1 的序号数组然后取前 mineCount 个作为雷。为了支持首点安全我们把第一个点击位置交换到数组末尾再只在[0, size-2]范围内洗牌。// MineField.h #include vector #include random #include algorithm #include queue class CSweepMine { public: int m_width 9; int m_height 9; int m_mineCount 10; std::vectorint m_board; // -1 表示雷0~8 表示周围雷数 bool inBounds(int x, int y) const { return x 0 x m_width y 0 y m_height; } void placeMines(int excludeIndex) { std::vectorint cells(m_width * m_height); for (int i 0; i (int)cells.size(); i) cells[i] i; // 把 excludeIndex 交换到末尾洗牌时忽略该位置 std::swap(cells[excludeIndex], cells.back()); std::mt19937 rng(std::random_device{}()); // 末尾不参与洗牌保证 excludeIndex 永远安全 std::shuffle(cells.begin(), cells.end() - 1, rng); m_board.assign(m_width * m_height, 0); for (int i 0; i m_mineCount; i) { int idx cells[i]; m_board[idx] -1; } countAllNeighbors(); } static const int dirs[8][2]; private: void countAllNeighbors(); };代码的逻辑说明swap保证 excludeIndex 只在尾部出现然后shuffle的范围用了end() - 1也就是尾部那个位置永远不会被移走。之后循环只取前 mineCount 个作为雷所以 excludeIndex 一定不是雷。countAllNeighbors()在布雷后立刻计算数字这样布局和数字保持一致避免界面阶段再做一重循环。参数说明std::mt19937需要 C11Visual Studio 2010 及以后都支持如果你的工程还是 VC6只能改用std::random_shuffle或rand但建议直接升级编译器。random_device{}()用于产生随机种子在 Windows 上通常用系统熵源安全性足够。2.1 八方向计数与边界检查的写法计数邻格雷数时最容易犯的错是数组越界。先定义方向数组然后遍历所有格子。遇到雷就跳过遇到非雷格子逐个检查 8 个邻居是否为 -1。这里有个优化空间在棋盘四周围一圈“哨兵”格子可以省掉边界判断但为了代码可读性我通常选择直接判断。const int CSweepMine::dirs[8][2] { {-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} }; void CSweepMine::countAllNeighbors() { for (int y 0; y m_height; y) { for (int x 0; x m_width; x) { int idx y * m_width x; if (m_board[idx] -1) continue; int count 0; for (auto d : dirs) { int nx x d[0], ny y d[1]; if (inBounds(nx, ny) m_board[ny * m_width nx] -1) { count; } } m_board[idx] count; } } }参数说明inBounds检查nx和ny必须同时合法顺序是先判断边界再访问避免一维索引越界。这段代码的时间复杂度是 O(8 * W*H)在最大 720 个格子上不到 6000 次循环远小于一次 GDI 刷新耗时。2.2 递归展开的工程化用队列替代递归扫雷的核心交互是点到 0 号空格时要打开周围所有连续的空格直到碰到数字格。很多教程喜欢写递归的 DFS比如if (m_board[y][x] 0)就向八个方向递归调用。这个写法在扫雷棋盘上通常不会爆栈因为棋盘最大只有 720 格递归深度有限。但它有两个隐患一是当展开区域很大时理论上递归帧仍然占用栈空间二是如果后续你在递归函数里直接调用Invalidate刷新界面容易在窗口重绘期间产生重入问题。因此我更推荐用显式队列做广度优先遍历代码更短也不会因为递归深度产生额外压力。void CMineDlg::RevealCells(int startCol, int startRow) { std::queueint q; int startIdx startRow * m_game.m_width startCol; if (m_revealed[startIdx]) return; m_revealed[startIdx] true; q.push(startIdx); while (!q.empty()) { int idx q.front(); q.pop(); int x idx % m_game.m_width; int y idx / m_game.m_width; if (m_game.m_board[idx] ! 0) continue; // 数字格不继续扩展 for (auto d : CSweepMine::dirs) { int nx x d[0], ny y d[1]; if (!m_game.inBounds(nx, ny)) continue; int nIdx ny * m_game.m_width nx; if (m_revealed[nIdx] || m_game.m_board[nIdx] -1) continue; m_revealed[nIdx] true; if (m_game.m_board[nIdx] 0) q.push(nIdx); } } }逻辑说明当前格子数字为 0 时才把邻居入队数字不为 0 的格子只被标记为已打开但不扩张这样就正确地把数字边界挡在外面。m_revealed是对话框类的成员表示是否已翻开而m_game.m_board是雷与数字布局。两者分离的好处是重启一局时只需重置m_revealed不用担心破坏雷的布局。参数说明std::queueint存储一维索引比存坐标对更省内存。在最大 30x16 棋盘上队列最多只有几百个元素性能上没有任何问题。如果你希望复刻经典扫雷的交互这个 BFS 版本不需要递归也不会出现栈溢出的异常对话框。2.3 首点安全与布雷时机首点安全是 MFC 扫雷体验中最重要的规则之一。处理方式是把布雷从“游戏启动时”推迟到“第一次左键按下时”。在OnLButtonDown中检测到游戏状态为 READY就先调用placeMines(index)再执行正常翻开逻辑。这样玩家第一下永远安全否则第一下踩雷会让人异常沮丧。有些实现比这更进一步要求第一次点击位置周围 3x3 区域中也不能有雷。方法是在placeMines的洗牌前把所有安全格子的坐标都交换到数组末尾并且让这些位置不参与随机抽取。代码的逻辑是// 扩展安全区把 (x,y) 周围 3x3 全部排除 for (int dy -1; dy 1; dy) for (int dx -1; dx 1; dx) { int nx x dx, ny y dy; if (m_game.inBounds(nx, ny)) std::swap(cells[ny * m_width nx], cells.back()); }但这个写法有个细节如果多个安全格子连续交换到末尾后来的交换可能把之前排好的安全格子又换回前面。正确做法是先把所有安全格子的下标记录在一个std::set里然后只对“不在安全区的格子”洗牌。不过经典 Windows 扫雷只保证首点不是雷3x3 安全区是后来的变种。我建议把安全区做成一个BOOL m_bSafeStart开关在设置对话框让用户选择默认开启这样复杂度的增加很小手感却明显提升。3. 用MFC自绘扫雷界面按钮、鼠标与计时器3.1 为什么不用 CButton 直接做格子你会看到很多 MFC 扫雷源码用CButton数组每个按钮代表一个格子。这种写法最容易实现但有两个硬伤一是无法同时响应左键和右键因为CButton的点击消息只覆盖左键二是当棋盘达到 480 个格子时创建那么多窗口句柄会让CreateWindow的开销变得明显而且在重绘时容易出现闪烁。因此成熟的 MFC 扫雷通常只用一个主窗口在OnPaint中完成所有格子的绘制再通过鼠标坐标反算出格子位置。这样做还有一个额外好处游戏状态完全由逻辑类维护界面只是逻辑的可视化投影。你甚至可以在不创建任何窗口的情况下先通过命令行把逻辑单元跑通。3.2 在 OnPaint 中集中绘制棋盘主窗口是CDialogEx时重写OnPaint的流程分为三步准备双缓冲、逐格绘制、一次性拷贝。最大棋盘 30x16每格 24 像素整个棋盘只有 720x384 像素远比一个普通窗口小所以双缓冲的消耗可以忽略。void CMineDlg::OnPaint() { CPaintDC dc(this); // 获取窗口 DC CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap *oldBmp memDC.SelectObject(bmp); // 背景填充 memDC.FillSolidRect(rc, RGB(192, 192, 192)); // 绘制所有格子 for (int y 0; y m_game.m_height; y) { for (int x 0; x m_game.m_width; x) { CRect cellRect GetCellRect(x, y); DrawCell(memDC, x, y, cellRect); } } // 一次性把内存画布拷贝到窗口避免闪烁 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(oldBmp); }参数说明GetCellRect需要预先定义棋盘区域的起点m_boardOrigin和每个格子的边长m_cellSize。窗口客户区如果比棋盘大棋盘可以居中绘制。为了保证点击坐标和绘制坐标一致必须在同一个地方计算两者。DrawCell的内部逻辑我一般这样组织void CMineDlg::DrawCell(CDC *pDC, int col, int row, CRect rc) { int idx row * m_game.m_width col; if (m_revealed[idx]) { pDC-FillSolidRect(rc, RGB(220, 220, 220)); // 已打开底色 if (m_game.m_board[idx] -1) { DrawMine(pDC, rc); // 画地雷 } else if (m_game.m_board[idx] 0) { DrawNumber(pDC, m_game.m_board[idx], rc); // 画数字 } } else { pDC-FillSolidRect(rc, RGB(190, 190, 190)); // 未打开底色 pDC-Draw3dRect(rc, RGB(255,255,255), RGB(100,100,100)); // 立体边框 if (m_flagged[idx]) DrawFlag(pDC, rc); // 画旗子 } }逻辑说明Draw3dRect是 MFC 封装绘制立体边框的捷径。未打开格子画亮上左边和暗右下边模拟凸起已打开格子去掉立体边框留下凹陷效果。这个细节虽然微不足道却决定了扫雷界面看起来是否像 Windows 原版。3.2.1 数字颜色与字符绘制扫雷数字颜色的经典设定是1 蓝色2 绿色3 红色4 深蓝5 深红6 青色7 黑色8 灰色。建立表驱动避免写八个 case。为了更贴近原版绘制数字时使用DrawText并指定居中。void CMineDlg::DrawNumber(CDC *pDC, int num, CRect rc) { 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 str; str.Format(_T(%d), num); pDC-SetTextColor(kColors[num]); pDC-SetBkMode(TRANSPARENT); // 避免背景色块遮挡格子底色 pDC-DrawText(str, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }参数说明DT_CENTER | DT_VCENTER | DT_SINGLELINE三个标志让文本水平和垂直居中。SetBkMode(TRANSPARENT)是关键否则DrawText会用当前SetBkColor填充文字背景把已经画好的格子底色覆盖掉。3.3 鼠标左键与右键的消息处理对于自绘棋盘不需要在OnLButtonDown里调用基类默认处理只需要计算坐标并修改游戏状态。扫雷的典型交互是左键打开格子右键切换旗子左右键同时按下可以检测周围雷数。MFC 中处理这些消息可以直接重写OnLButtonDown、OnRButtonDown、OnLButtonDblClk等。鼠标操作消息处理函数游戏逻辑左键单击OnLButtonDown首点布雷翻开格子或失败右键单击OnRButtonDown切换旗子更新剩余雷数左键双击OnLButtonDblClk在数字格周围快速展开移动鼠标OnMouseMove可做悬停高亮非必须左键处理的关键代码void CMineDlg::OnLButtonDown(UINT nFlags, CPoint point) { int col, row; if (!ScreenPointToCell(point, col, row)) { CDialogEx::OnLButtonDown(nFlags, point); return; } if (m_gameState STATE_READY) { m_game.placeMines(row * m_game.m_width col); m_gameState STATE_PLAYING; SetTimer(TIMER_ELAPSED, 1000, nullptr); } if (m_gameState ! STATE_PLAYING) return; int idx row * m_game.m_width col; if (m_revealed[idx] || m_flagged[idx]) return; if (m_game.m_board[idx] -1) { OnGameOver(); return; } RevealCells(col, row); if (CheckVictory()) return; Invalidate(FALSE); }右键处理void CMineDlg::OnRButtonDown(UINT nFlags, CPoint point) { int col, row; if (!ScreenPointToCell(point, col, row)) { CDialogEx::OnRButtonDown(nFlags, point); return; } if (m_gameState ! STATE_PLAYING m_gameState ! STATE_READY) return; int idx row * m_game.m_width col; if (m_revealed[idx]) return; if (m_flagged[idx]) { m_flagged[idx] FALSE; --m_flagCount; } else { if (m_flagCount m_game.m_mineCount) { ::MessageBeep(MB_ICONWARNING); return; } m_flagged[idx] TRUE; m_flagCount; } UpdateFlagText(); // 更新界面右上角的雷数计数器 InvalidateRect(GetCellRect(col, row), FALSE); }参数说明ScreenPointToCell先判断点是否落在棋盘矩形内然后做坐标除法。因为除以m_cellSize取整如果棋盘起点不是 0需要先减去m_boardOrigin.x。Invalidate(FALSE)中的FALSE表示不需要擦除背景交给双缓冲的OnPaint完全重绘即可。3.4 用 SetTimer 实现计时显示扫雷界面的“雷数”和“用时”数字通常用静态文本控件显示。在游戏开始时启动计时器在游戏结束时停止。计时器 ID 用一个枚举定义避免魔数。enum { TIMER_ELAPSED 1, TIMER_MAX };在OnTimer里更新文本void CMineDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_ELAPSED m_gameState STATE_PLAYING) { m_elapsed; if (m_elapsed 999) m_elapsed 999; CString str; str.Format(_T(%03d), m_elapsed); m_elapsedStatic.SetWindowText(str); } CDialogEx::OnTimer(nIDEvent); }参数说明SetTimer的第三个参数为nullptr时使用默认的窗口过程计时器精度约 15ms但对秒级显示足够了。注意m_elapsed超过 999 后需要截断否则会显示四位数字破坏布局。OnTimer中检查游戏状态是为了防止在游戏结束后继续计时。4. 扫雷状态机与胜利判定从逻辑到界面4.1 用枚举管理游戏状态没有状态机的扫雷程序很容易出现“游戏结束后还能继续翻开”或者“双击雷区崩溃”之类的 bug。我会在对话框中定义MineGameState枚举并把状态存储在成员变量中所有鼠标事件的第一步都是检查当前状态是否是合法输入状态。enum MineGameState { STATE_READY 0, STATE_PLAYING, STATE_GAMEOVER, STATE_WIN };状态转换关系如下表当前状态触发条件下一状态STATE_READY第一次左键点击STATE_PLAYINGSTATE_PLAYING左键踩雷STATE_GAMEOVERSTATE_PLAYING所有非雷格子已打开STATE_WINSTATE_GAMEOVER或WIN点击“新游戏”按钮STATE_READY注意在 READY 状态下右键可以标记旗子但不会启动计时器。这符合大多数用户的直觉先观察局面再开始计时。4.2 胜利判定的实现细节胜利判定放在一次翻开操作完成之后。最简单的方法是遍历m_revealed统计未翻开格子的数量。如果未翻开格子数等于总雷数说明所有非雷都已经被打开玩家获胜。bool CMineDlg::CheckVictory() { int unrevealed 0; for (size_t i 0; i m_revealed.size(); i) { if (!m_revealed[i]) unrevealed; } if (unrevealed m_game.m_mineCount) { m_gameState STATE_WIN; KillTimer(TIMER_ELAPSED); // 自动标记所有雷给玩家一个反馈 for (int y 0; y m_game.m_height; y) for (int x 0; x m_game.m_width; x) { int idx y * m_game.m_width x; if (m_game.m_board[idx] -1) m_flagged[idx] TRUE; } Invalidate(FALSE); return TRUE; } return FALSE; }逻辑说明为什么统计未翻开格子数而不是统计已翻开非雷格子数因为当你踩到雷时被打开的数量不动未翻开数量不变但游戏已在失败分支返回只有正常翻开时未翻开数量才会减少。当它减到雷数说明剩下未翻开的格子全是雷胜利条件成立。也有一种做法是同时要求所有雷都被标记但那种方式更难达成通常不采用。4.3 资源管理与内存泄漏排查MFC 程序常见的泄漏不在堆内存上而在 GDI 对象和设备上下文。每创建一个CBrush、CPen、CFont或CBitmap如果它们被选入 DC 后没有恢复即使变量析构也不会立即释放因为 DC 还握着引用。下面这段代码就是典型错误// 错误写法oldBmp 没有被恢复 CPaintDC dc(this); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, 100, 100); dc.SelectObject(bmp); // 没有保存并恢复旧位图因此在OnPaint等函数中务必保持“谁选入谁恢复”。我在绘制函数里统一使用CBitmap *oldBmp memDC.SelectObject(bmp);退出前memDC.SelectObject(oldBmp);。在DrawCell内部临时创建的CBrush也要保证在函数结束前恢复原来的画刷。检查内存泄漏的常规做法是在InitInstance里加上_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);并在调试器输出窗口查看结果。对于扫雷这类小项目最常见的内存泄漏其实是每次点击都new一个CDC对象但没有delete。由于整套代码都使用栈对象这类泄漏基本可以避免。真正的重点在 GDI因为对象泄漏在任务管理器中表现为“GDI 对象”列持续上涨。4.4 棋盘数据与界面数据的同步扫雷程序中有三份数据雷的布局m_board、已打开标记m_revealed、旗子标记m_flagged。很多代码把这三个数组合并到一个结构体比如CellState包含isMine, isRevealed, isFlagged, neighborCount。合并的好处是数据结构紧凑不利之处是重置较麻烦。我倾向于分离因为三份数据的生命周期不同m_revealed和m_flagged每局都要重置而m_board可以在点击后重建。分离后如果要实现“重新开始”只需要用assign将两个布尔数组清零棋盘数组可以留着调试。另一个需要同步的地方是棋盘坐标与窗口坐标。如果对话框允许用户拉伸你必须在OnSize中重算m_cellSize和m_boardOrigin并且调用Invalidate(TRUE)强制重绘。如果棋盘尺寸固定可以直接在OnInitDialog中初始化 24 像素的格子边长。这里牵出一个与工程落地相关的细节在 125% 或 150% 的系统缩放比例下24 像素的格子会被系统拉伸成 30 或 36 像素文字也会发虚这需要我们在第 5 章解决。5. MFC扫雷的发布与兼容性运行库、DPI与调试技巧5.1 解决 Windows 7 双击打不开MFC 运行库缺失“wind7 电脑里的扫雷小游戏双击打不开”这个问题放到你发布的 MFC 扫雷工程里最常见的根因不是代码逻辑错误而是缺少对应该版本编译器的 MFC 运行库。用 VS2019 生成的 Release 版默认依赖mfc140u.dll如果目标机器是干净的 Windows 7且没有安装任何 VC 运行库双击 exe 只会得到一个“丢失 mfc140u.dll”的错误对话框。最省心的发布方式是在项目属性 - 配置属性 - 常规 - MFC 的使用中选择“在静态库中使用 MFC”。同时把“C/C - 代码生成 - 运行库”设置为“多线程 (/MT)”。这样生成的可执行文件会把需要的 MFC 代码和 CRT 初始化代码全部链接进来体积会比动态链接大 1-2MB但换来的是一份不需要用户额外安装运行库的绿色程序。如果选择动态链接则需要随程序一起分发你从本机System32中找到的mfc140u.dll和vcruntime140.dll或者在安装包中带上 Visual C Redistributable for Visual Studio 2015-2022。静态库方式没有这些后续问题所以我个人发布小工具时几乎总是用静态 MFC。5.2 DPI 缩放导致棋盘发虚高分屏下启动旧版 MFC 程序系统默认会对 GDI 绘制内容做位图拉伸结果就是格子边线模糊、数字出现重影。扫雷这种大量使用矩形和文字的界面对此非常敏感。解决办法是让进程自己感知 DPI而不是交给系统拉伸。最简单的写法是在CWinApp::InitInstance第一行调用SetProcessDPIAware()。这个 API 从 Windows Vista 开始存在兼容到 Windows 11。它告诉系统“我的程序已经适配过 DPI你不要拉伸我”。代价是程序窗口在一些旧式截屏工具里可能显示得过小但格子会保持清晰。如果你还想让棋盘格子的大小随系统缩放比例自动调整可以在OnInitDialog中用GetDpiForWindow或GetDeviceCaps拿到当前的 DPI 值再乘以 1.25 或 1.5。例如UINT dpiY GetDeviceCaps(GetDC()-m_hDC, LOGPIXELSY); m_cellSize MulDiv(24, dpiY, 96);参数说明MulDiv避免了浮点误差并且在 DPI 变化时可以配合WM_DPICHANGED重算窗口布局。对于一个扫雷程序做到“不模糊 尺寸可调”就已经足够专业了。5.3 用命令行 -debug 参数快速定位布雷算法最后分享一个调试技巧。MFC 的CWinApp提供了m_lpCmdLine我们可以在InitInstance中检查命令行包含-debug时打开一个全局开关。BOOL CSaoLeiApp::InitInstance() { if (m_lpCmdLine _tcsstr(m_lpCmdLine, _T(-debug))) { m_bDebug TRUE; } ... }调试开关可以做三件事一是在OnPaint里把地雷位置画成红色小圆点方便观察计数算法是否正确二是把布雷随机数种子固定为std::mt19937 rng(12345)让每次开局布局都一样便于复现同事报告的问题三是用OutputDebugString输出每次点击的坐标、翻开后的数字和当前游戏状态。#ifdef _DEBUG CString dbg; dbg.Format(_T(Click (%d,%d), board%d, revealed%d, flag%d\n), col, row, m_game.m_board[idx], m_revealed[idx], m_flagged[idx]); OutputDebugString(dbg); #endifOutputDebugString的输出可以被 Visual Studio 的“输出”窗口或 Sysinternals DebugView 实时捕获不会弹出MessageBox打断游戏节奏。对于第 2 章的递归展开和本章的胜利判定这种日志往往一眼就能看出问题出在边界条件还是状态转换。把-debug参数保留在 Release 版中也不会有安全风险因为它不会显示雷的完整位置只是给开发人员一条快速验证的通道。本文还有配套的精品资源点击获取