首页 / 资讯中心 / 文章详情

MFC扫雷大作业完整工程:从消息映射、GDI自绘到算法实现

MFC扫雷大作业完整工程:从消息映射、GDI自绘到算法实现 ★ FEATURED ARTICLE
简介基于C与MFC框架开发的扫雷游戏完整工程是面向C课程大作业和Windows桌面应用初学者的实践项目完整演示了从雷区数据结构设计、消息映射绑定到图形界面刷新的整个流程可帮助解决“MFC项目不知如何组织代码”的常见问题。资源共31个文件压缩包仅33KB其中17个BMP图片素材用于数字、状态图标等界面表现8个头文件与6个C源文件分别承担类声明、游戏逻辑、文档/视图框架和消息响应实现整体结构清晰、便于阅读和二次修改。已有1566人学习下载。读者拿到的是可编译运行的完整MFC扫雷程序不仅包含二维数组雷区构建、周围雷数统计、左键翻开、右键标记、踩雷判定及重新开始等核心算法也涵盖项目文件与数字图片资源的组织方式既可直接作为C大作业提交也能在此基础上增加计时、排行榜或难度选项适合对照代码深入理解C与MFC的协作机制。1. 这份MFC扫雷大作业代码不是玩具是可答辩的完整工程每年课设季都能看到一批控制台小游戏代码推箱子、贪吃蛇、计算器轮着交。MFC扫雷不一样它把消息映射、自绘控件、GDI 绘制、随机算法、图搜索、状态机这六样东西串在同一个工程里答辩时任何一个点都能展开讲五分钟——这是它作为 C 大作业最值钱的地方。我拆的这份完整程序雷区不是几百个按钮堆出来的而是自定义 CWnd 子窗口整块自绘布雷、数字计算、胜负判断都从 UI 里抽了出来行数、列数、雷数在代码里改三个变量就能调难度。新手能照着一步步把工程搭起来熟手能直接拿它开工改造加难度档、存档、音效都有现成的挂载位置。2. 程序骨架先立住对话框宿主 自定义 CWnd 雷区控件拿到题目第一件事不是写布雷而是定框架。MFC 里做扫雷有两条路单文档/视图SDI和基于对话框。SDI 把雷区画在CView::OnDraw里数据放 Document架构对称但代价是文档模板、框架窗口、视图重绘这一整套机制对一个单窗口棋类游戏来说全是成本。基于对话框则把雷区放在子窗口里数据直接做成对话框类的成员变量代码量少一半。我当时选基于对话框除了省事还有一个答辩时说得出理由的点扫雷的状态只存在一份没有理由用“文档—视图”两份拷贝对话框模型更贴需求。2.1 创建工程基于对话框 手动挂 MFC 依赖具体创建步骤我记在这里照做就能得到一个干净的 MineGame 工程打开 Visual Studio新建项目选“MFC 应用”项目名填MineGame。应用程序类型选“基于对话框”语言用 C生成的类名改成CMineDlg基类保持CDialogEx。如果装 VS 时没选 MFC 组件打开 Visual Studio Installer勾选“使用 C 的桌面开发”右侧勾选“适用于最新 v143 生成工具的 C MFCx64”。生成后的解决方案里有MineGameDlg.cpp、MineGameDlg.h、资源文件MineGame.rc这就是全部战场。这里有个值得提前说的边界MFC 是微软私有框架这套代码只能在 Visual Studio 的 MSVC 工具链里编。用 VSCode 配好 C/C 环境的人看代码、改代码没问题但想编译运行还是得回到 VS 里别浪费时间折腾 VSCode 的编译器路径。另外如果目标机器上没有开发环境需要把vc_redist.x64.exe对应 Visual C Redistributable一起带上或者直接在项目设置里选“静态链接 MFC”这样生成的 exe 拷到别的 Windows 机器上就能跑。2.2 雷区用自定义 CWnd 子窗口别用按钮数组工程建好之后最核心的决策是雷区用什么控件承载。最容易想到的做法是每个格子放一个CButton初级 9×9 是 81 个按钮高级 16×30 就是 480 个按钮。每个按钮要分配资源 ID、要处理左键右键、要画按下/弹起状态消息映射表能写到一百多行而且按钮画不出扫雷那种“凸起/凹陷”的 3D 浮雕效果。我一般绕过这套控件直接自定义一个CWnd子类。// MineGrid.h class CMineGrid : public CWnd { public: BOOL Create(CWnd* pParent, UINT nID, int rows, int cols, int cellSize); protected: afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() private: int m_rows 9; // 行数默认初级 int m_cols 9; // 列数 int m_cell 36; // 格子边长单位像素 CRect m_rectGrid; // 网格区域留 2px 边框 };// MineGrid.cpp BOOL CMineGrid::Create(CWnd* pParent, UINT nID, int rows, int cols, int cellSize) { m_rows rows; m_cols cols; m_cell cellSize; // 窗口宽度 格子数 * 格子边长 两边各 2 像素边框 CRect rc(0, 0, cols * cellSize 4, rows * cellSize 4); return CWnd::Create(NULL, _T(), WS_CHILD | WS_VISIBLE, rc, pParent, nID); }CWnd::Create使用的窗口类是 MFC 内置的AfxWnd不是STATIC所以WM_PAINT、WM_LBUTTONDOWN这些消息能正常进到我们的消息映射里。窗口尺寸的计算公式是列数 × 格宽 4多出的 4 像素是边框否则边缘格子会贴到窗口边界上画凸起效果时会盖掉一部分。在CMineDlg::OnInitDialog里创建这块雷区BOOL CMineDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_grid.Create(this, IDC_MINE_GRID, 9, 9, 36); // 9x9 初级10 雷 m_grid.SetWindowPos(NULL, 24, 70, 0, 0, SWP_NOSIZE); return TRUE; }SetWindowPos把雷区放到对话框客户区的(24, 70)位置宽度让Create决定。cellSize取 36 在 1080P 屏幕上刚好——格子里要塞下数字和地雷图标太小看不清太大又让窗口整体超过屏幕如果你要在 4K 屏上用改成 48 更舒服。2.3 消息映射表MFC 的事件分发核心MFC 的BEGIN_MESSAGE_MAP宏展开后是一张静态消息表Windows 把消息送进窗口过程后框架在这里查表匹配。它的优点是只映射你写了的消息不用的消息不占额外开销比把所有WM_*都写成虚函数干净。BEGIN_MESSAGE_MAP(CMineGrid, CWnd) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() END_MESSAGE_MAP()这里我只挂了三条消息WM_PAINT负责画网格和数字WM_LBUTTONDOWN处理左键翻开WM_RBUTTONDOWN处理右键标旗。注意没有ON_WM_ERASEBKGND——为什么要单独提这个第 5 章避坑里会讲背景擦除是扫雷闪烁的最大来源。CMineGrid只负责两件事接受鼠标输入、把格子画出来。鼠标点下去之后子窗口把坐标换算成行列再用自定义消息发给父窗口CMineDlg由对话框去改游戏数据最后调用Invalidate让雷区重新绘制。这样层层分离之后CMineGrid不需要知道布雷规则CMineDlg不需要关心像素坐标第 6 章的自动压测就是建立在“逻辑独立于 UI”这个前提上的。3. 布雷与数字生成随机算法、边界检测与安全点保护布雷是扫雷的核心算法也是代码里最容易出“看起来能跑、一细看全是问题”的部分。常见的问题有三个第一用rand() % n撒雷模偏差导致雷的分布不均第二玩家第一脚踩雷只能重开体验极差第三数字计算时边界判断漏条件数组越界。3.1 数据存储一维 vector 替代二维数组翻开、标旗、是否埋雷这些状态我全部存到std::vectorchar里用一维索引访问索引算出来是row * cols col。一维化的好处有两个内存连续CPU 缓存命中率高同时容器自己管理生命周期析构函数里不用写delete。// CMineDlg 的成员 std::vectorchar m_mine; // 1 该格是雷0 安全 std::vectorchar m_state; // 0 未翻开1 已翻开2 标旗 std::vectorchar m_adj; // 该格周围 8 格的雷数雷格固定为 -1 int m_rows 9; int m_cols 9; int m_mineCount 10; int m_openCount 0; // 已安全翻开的格子数 BOOL m_bGameOver FALSE;char在这里够了占内存比int小一个 30×16 的高级雷区三个数组加起来还不到 1500 字节。用vector而不是裸new char[]除了免内存泄漏还有一个实际好处调试时鼠标悬停就能看到数组内容不用自己数偏移量。3.2 安全点保护首次点击后才布雷扫雷的通行规则是“第一步永远不能踩雷”。最粗暴的实现是开局就布雷玩家第一脚踩雷就重开但正经游戏没这么干的。正确做法玩家第一次按下鼠标时才布雷并且把踩中的格子以及它周围一圈共 9 格全部排除在雷区之外。void CMineDlg::GenerateMines(int safeRow, int safeCol) { std::vectorchar excluded(m_rows * m_cols, 0); // 排除安全点周围 3x3 区域 for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { int r safeRow dr; int c safeCol dc; if (IsValidCell(r, c)) { excluded[r * m_cols c] 1; } } } // 收集可布雷的格子 std::vectorint candidates; for (int i 0; i m_rows * m_cols; i) { if (!excluded[i]) { candidates.push_back(i); } } // 用梅森旋转算法洗牌取前 m_mineCount 个作为雷格 std::mt19937 rng(std::random_device{}()); std::shuffle(candidates.begin(), candidates.end(), rng); m_mine.assign(m_rows * m_cols, 0); for (int i 0; i m_mineCount; i) { m_mine[candidates[i]] 1; } ComputeAdjacent(); }先圈出安全区域再在安全区域外收集候选格子shuffle之后取前m_mineCount个这样的分布是均匀的。为什么不用rand() % 总数循环抽第一模运算在总数不整除时会有偏差第二rand()的周期只有 2^31而且受srand(time(NULL))影响同一秒内多次调用会得到重复序列——这个坑在第 5 章会专门展开。IsValidCell是边界检查的唯一入口bool CMineDlg::IsValidCell(int row, int col) const { return row 0 row m_rows col 0 col m_cols; }所有访问m_mine[i]之前必须先用行列坐标做合法性判断。把这段判断抽成函数而不是在每个循环里手写if(row 0 row rows ...)是防止越界的核心手段后面避坑章里有对应的翻车案例。3.3 邻域雷数计算偏移数组代替八个 if数字是扫雷的信息来源每格显示的数字代表周围 8 格中雷的数量。计算逻辑就是遍历每一个非雷格数一圈雷。我用了偏移数组把八个方向写成一个表代码一眼能看全const int kDx[8] { -1, -1, -1, 0, 0, 1, 1, 1 }; const int kDy[8] { -1, 0, 1, -1, 1, -1, 0, 1 }; void CMineDlg::ComputeAdjacent() { m_adj.assign(m_rows * m_cols, 0); for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { int idx r * m_cols c; if (m_mine[idx]) { m_adj[idx] -1; // 雷格标记 -1 continue; } int cnt 0; for (int k 0; k 8; k) { int nr r kDx[k]; int nc c kDy[k]; if (IsValidCell(nr, nc) m_mine[nr * m_cols nc]) { cnt; } } m_adj[idx] cnt; } } }kDx/kDy的顺序是“上、下、左、右、左上、右上、左下、右下”无所谓只要 8 个方向齐就行。注意先判断IsValidCell(nr, nc)再访问数组顺序不能反否则坐标已经越界了才想起来检查。这里m_adj在布雷时就固定下来了之后玩家点击翻开时直接查表不需要每次点击重新算整个雷区——这是性能上很划算的提前量。GenerateMines与ComputeAdjacent分离还有一个隐藏的好处排错方便。如果数字显示错误先单独调ComputeAdjacent用控制台把格子的值打出来对比肉眼数出来的结果很快能定位是布雷错了还是计数错了而不是在 UI 层里瞎猜。4. 交互与状态机左键翻开、右键标旗、递归展开的时序控制扫雷的交互核心是状态机每个格子有未翻开、已翻开、标旗三种状态雷区有进行中、失败、胜利三种状态。消息处理里最常翻车的地方就是“状态没有在进入分支前统一检查”——玩家点了一下已经翻开的格子或者游戏结束后还继续点各种混乱就来了。4.1 左键按下先查状态再决定是布雷还是翻开子窗口CMineGrid收到WM_LBUTTONDOWN后把鼠标坐标换算成行列通过::SendMessage发给父窗口void CMineGrid::OnLButtonDown(UINT nFlags, CPoint point) { int col point.x / m_cell; int row point.y / m_cell; if (row 0 row m_rows col 0 col m_cols) { ::SendMessage(GetParent()-GetSafeHwnd(), WM_APP 1, row, col); } CWnd::OnLButtonDown(nFlags, point); }WM_APP 1是自定义消息UM_CLICK_GRIDwParam传行lParam传列。父窗口那边的处理逻辑才是重头戏LRESULT CMineDlg::OnGridClicked(WPARAM wParam, LPARAM lParam) { int row (int)wParam; int col (int)lParam; // 状态机检查游戏结束直接忽略 if (m_bGameOver) return 0; int idx row * m_cols col; // 已翻开的或已标旗的格子左键无效 if (m_state[idx] 1 || m_state[idx] 2) return 0; // 第一次左键先布雷保证安全 if (!m_bStarted) { m_bStarted TRUE; GenerateMines(row, col); m_dwStart GetTickCount64(); SetTimer(1, 1000, NULL); // 计时器每秒刷新一次 } // 踩雷 if (m_mine[idx]) { m_bGameOver TRUE; RevealAllMines(); InvalidateGrid(); return 0; } // 安全格翻开并统计新翻开数量 int opened OpenCell(row, col); m_openCount opened; if (CheckWin()) { m_bGameOver TRUE; KillTimer(1); // 在这里弹胜利提示 } else { UpdateMineCounter(); } InvalidateGrid(); return 0; }细心的人会发现我一开始就判断了“游戏是否结束”以及格子自身的状态。这看似多余但非常重要MFC 的消息不是原子操作玩家在极端情况下的快速连点会连续发送多个消息如果不在入口处统一拦截可能出现“第一次点雷已经置m_bGameOver第二次点击仍然走进了m_mine[idx]分支继续执行”这种情况。状态机的第一原则是每个入口都先检查状态而不是到分支深处再检测。4.2 翻开与递归展开BFS 队列替代递归扫雷点到一个数字为 0 的格子时周围一圈会连锁展开直到碰到数字格才停。教科书写法是递归但我在 MFC 工程里用迭代 BFS原因很实际MFC 默认栈大小 1MB在高级 16×30 的地图上空白区域连成片可能一次展开上千格递归深度一旦上百就有栈溢出风险。用std::queue改成迭代既稳又能在答辩时讲一句“数据结构的工程应用”。int CMineDlg::OpenCell(int row, int col) { int idx row * m_cols col; if (m_state[idx] ! 0) return 0; // 已翻开或已标旗跳过 if (m_mine[idx]) return 0; // 雷格不在安全展开范围内 int opened 0; std::queueint q; q.push(idx); m_state[idx] 1; opened; while (!q.empty()) { int cur q.front(); q.pop(); int r cur / m_cols; int c cur % m_cols; // 数字格只翻开自己不继续向四周扩散 if (m_adj[cur] ! 0) continue; for (int k 0; k 8; k) { int nr r kDx[k]; int nc c kDy[k]; if (!IsValidCell(nr, nc)) continue; int nidx nr * m_cols nc; if (m_state[nidx] ! 0) continue; if (m_mine[nidx]) continue; m_state[nidx] 1; opened; q.push(nidx); } } return opened; }m_state[idx] ! 0这个判断同时解决了队列里的重复入队和已标旗格子的问题标旗状态下格子不会参与展开玩家需要先右键取消标旗才能点击。这里有个容易写错的地方有人会把m_state[nidx] ! 0写成m_state[nidx] 1意思是“只跳过已翻开的”结果标旗的格子被当成未翻开重新入队翻开数量统计就乱了。统一用! 0更安全。4.3 右键标旗与胜负判定数翻开格子而不是数旗子右键按下切换标旗状态LRESULT CMineDlg::OnGridRightClicked(WPARAM wParam, LPARAM lParam) { int row (int)wParam; int col (int)lParam; if (m_bGameOver || !m_bStarted) return 0; int idx row * m_cols col; if (m_state[idx] 1) return 0; // 已翻开的格子不能标旗 m_state[idx] (m_state[idx] 2) ? 0 : 2; // 标旗与取消标旗切换 UpdateMineCounter(); InvalidateGrid(); return 0; }剩余雷数的显示我直接由m_mineCount减去当前旗子数得到并不去校验旗子的位置对不对。这是刻意的Windows 自带扫雷也是这样旗子只是玩家的标记工具不是程序判断胜负的依据。胜利判定有个常见的错误想法检查所有旗子是否插在了雷上。那如果玩家给一个安全格标了旗给一个雷格没标按此逻辑永远无法胜利哪怕他已经把所有安全格都翻开了。正解是数已翻开的安全格数量BOOL CMineDlg::CheckWin() { int safeTotal m_rows * m_cols - m_mineCount; return m_openCount safeTotal; }只要翻开的安全格数达到了总格数减雷数就赢了跟旗子插得对不对无关。这个判定逻辑简单、不会被玩家的误操作干扰配合 4.1 里的m_bGameOver整个游戏的状态机闭环就出来了进行中 - 失败踩雷或者进行中 - 胜利翻开数达标。4.4 计时器与界面刷新用差量而不是累计计时器的实现也有讲究。简单写法是SetTimer(1, 1000, NULL)后在OnTimer里给一个m_nSeconds自增。但这有个问题Windows 在系统睡眠、调试断点等场景下WM_TIMER消息会暂停或丢弃累计计数就失真了。我一般只保留开始时刻ON_WM_TIMER()void CMineDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { ULONGLONG elapsed (GetTickCount64() - m_dwStart) / 1000; CString strTime; strTime.Format(_T(%03d), (int)elapsed); SetDlgItemText(IDC_STATIC_TIME, strTime); } CDialogEx::OnTimer(nIDEvent); }GetTickCount64()返回的是系统开机以来的毫秒数用它做差量只要程序不退出就始终准确。注意格式化用%03d保证三位数显示秒数超过 999 也能继续显示只是位数变多不影响逻辑。结束一局游戏时记得KillTimer(1)否则WM_TIMER会一直跑哪怕对话框已经锁了界面。5. MFC 扫雷避坑实录绘制、内存、随机种子与越界的五个翻车点这章是我实际拆代码、改作业时踩过的坑每一条都是“现象 - 原因 - 解决”的完整链路按发生率排序先看前两条基本能解决大半人的问题。5.1 雷区白屏或闪烁——OnPaint 没双缓冲而且 Invalidate 用错了参数现象程序跑起来雷区要么是白底一片刷新不出来要么点一下闪一下像老式电视花屏。原因第一个原因是OnPaint里没有用双缓冲重绘时先擦背景再画前景两个操作之间屏幕会短暂露出空白第二个原因是调用刷新时写了Invalidate(TRUE)这个参数TRUE表示“擦除背景”等于强制多一次无意义的清屏操作。解决OnPaint里先创建一个内存位图所有格子画到内存 DC 上一次性BitBlt拷到屏幕刷新时统一用Invalidate(FALSE)让系统只重绘客户区不擦背景。核心代码长这样void CMineGrid::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, m_cols * m_cell 4, m_rows * m_cell 4); CBitmap* pOld memDC.SelectObject(bmp); // 先铺底色再画凸起格子 memDC.FillSolidRect(0, 0, m_cols * m_cell 4, m_rows * m_cell 4, RGB(192, 192, 192)); for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { DrawCell(memDC, r, c); } } dc.BitBlt(0, 0, m_cols * m_cell 4, m_rows * m_cell 4, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }CreateCompatibleBitmap的尺寸必须和雷区客户区一致否则BitBlt拷不全。另外OnPaint开头不要调用CDialog::OnPaint或者CWnd::OnPaint基类版本基类的默认背景擦除会把刚画的东西抹掉。5.2 C6011 警告和运行时崩溃——GetDlgItem 返回空指针现象Debug 模式下静态分析报C6011: 取消对 NULL 指针的引用编译能过但运行到某一步直接崩。原因代码里写GetDlgItem(IDC_STATIC_TIME)-SetWindowText(...)如果资源 ID 不存在或者控件还没创建GetDlgItem返回NULL然后空指针调用方法就炸了。很多教材代码为了省行数都这么写编译器不报错但静态分析能查出来。解决要么先判空要么直接用SetDlgItemText这类封装接口CWnd* pWnd GetDlgItem(IDC_STATIC_TIME); if (pWnd ! nullptr) { pWnd-SetWindowText(strTime); } // 更稳妥的写法直接用对话框的封装 SetDlgItemText(IDC_STATIC_TIME, strTime);顺带说一下CString的GetBuffer用法CString::GetBuffer()返回内部缓冲区指针使用后必须配对ReleaseBuffer()否则CString的长度信息不会更新还会在调试输出里出现Detected memory leaks。我的习惯是能用CString::Format解决的不用GetBuffer非要操作底层字符时一个函数内GetBuffer和ReleaseBuffer必须成对出现。5.3 快速点击“新游戏”两局雷一模一样现象连续点“重新开始”按钮如果两次点击落在同一秒内生成的雷局完全相同怀疑随机函数没生效。原因代码里写了srand(time(NULL))放在按钮点击事件里。time(NULL)精确到秒同一秒内调用两次srand会把随机种子重置成同一个值后面rand()产生的序列自然一样。最常见的错误写法长这样void CMineDlg::OnNewGame() { srand(time(NULL)); // 错同秒内种子相同 // ... 布雷 }解决srand只应该在程序启动时调用一次放在OnInitDialog里。更进一步放弃 C 标准库的rand改用 C11 的随机数引擎// CMineDlg 成员 std::mt19937 m_rng{ std::random_device{}() }; // 布雷时直接用成员引擎洗牌 std::shuffle(candidates.begin(), candidates.end(), m_rng);std::random_device从操作系统熵池取种子std::mt19937周期长达 2^19937重叠序列的可能性几乎为零。这也是为什么第 3 章布雷代码里用的是mt19937而不是rand——那一版已经是绕开这个坑之后的样子。5.4 递归展开导致栈溢出或 CtrlC 崩溃现象地图调大之后点开一块大面积空白区域程序直接退出调试输出显示Stack overflow。原因翻开逻辑用递归函数一层层调用MFC 程序默认栈大小约 1MB空白区域连续展开上千格时递归深度超过栈容量就爆了。解决用第 4 章的 BFS 队列版本替代递归。核心区别是把“函数调用栈”换成“显式队列”每格最多入队一次内存占用 O(n) 且可控。如果实在想用递归记得在项目属性里把栈保留大小调大Linker - System - Stack Reserve Size设为 4MB但终归不如迭代版本省心。5.5 数字计算越界边角格子数字变成负数现象翻开角落格子时显示的数字有时是-3、-7这种怪值偶尔还伴随崩溃。原因计算相邻雷数时判断条件少写了一边比如只判断了row 0而漏了col m_cols导致数组下标越界读到了内存里的脏数据。典型错误代码if (nr 0 nr m_rows) { // 少了列判断 if (m_mine[nr * m_cols nc]) cnt; }解决所有边界判断统一走IsValidCell(nr, nc)把行列检查一次做完if (IsValidCell(nr, nc) m_mine[nr * m_cols nc]) { cnt; }我在这个坑上栽过一次之后就强制要求自己凡是出现下标运算的地方前面必须能看到越界检查检查里行列必须成对出现谁都不许省。这也解释了第 3 章为什么把IsValidCell单独抽出来共用——边角格子最容易出事而边角恰恰是IsValidCell逻辑最密集的地方。6. 交作业前的验证技巧把算法抽出来跑一万局扫雷这种带随机性的程序最怕的是“演示时刚好没事换一台机器就炸”。给老师演示一局根本覆盖不到所有路径。我的习惯是在写界面之前先把布雷和翻开的逻辑抽成一个不依赖 MFC 的纯逻辑类然后用命令行程序跑上万局用断言来证明逻辑没有隐藏问题。// GameLogic.h —— 与 UI 完全无关 class GameLogic { public: void Init(int rows, int cols, int mines); void GenerateMines(int safeRow, int safeCol); int OpenCell(int row, int col); // 返回新翻开数踩雷返回 -1 int AdjacentAt(int row, int col) const;// 查询数字 private: std::vectorchar m_mine; std::vectorchar m_adj; std::vectorchar m_state; int m_rows, m_cols, m_mineCount; };// test.cpp —— 进界面之前先跑这个压测 #include GameLogic.h #include cassert #include random int main() { std::mt19937 rng(12345); for (int t 0; t 10000; t) { GameLogic game; game.Init(16, 30, 99); int safeR 7, safeC 7; // 安全点固定为 (7,7) game.GenerateMines(safeR, safeC); // 断言 1雷的数量准确 int mineCnt 0; for (int r 0; r 16; r) for (int c 0; c 30; c) if (game.AdjacentAt(r, c) -1) mineCnt; assert(mineCnt 99); // 断言 2非雷格的数字等于周围实际雷数随机抽查 100 格 for (int i 0; i 100; i) { int r (int)(rng() % 16); int c (int)(rng() % 30); int adj game.AdjacentAt(r, c); if (adj ! -1) { assert(adj 0 adj 8); } } // 断言 3安全点翻开必定成功且数量大于 0 int opened game.OpenCell(safeR, safeC); assert(opened 0); } return 0; }GameLogic与 UI 的分离是这套压测能跑起来的前提。在 MFC 工程里我的做法是把CMineDlg里所有操作数据的代码迁到GameLogic中对话框只保留消息处理、绘制、定时器这些 UI 职责。压测里三组断言分别验证的是布雷数量、数字计算、翻开逻辑。assert的好处是失败时直接告诉你第几个断言挂了结合错误定位非常快。这套压测跑通之后再进的界面开发阶段。从那以后我每次写图形程序都强制自己先做逻辑压测再加界面——逻辑不先验证界面做得再花哨心里都没底。代码包里带的 MineGame 工程就采用了这个结构GameLogic.cpp里还留了#define LOGIC_TEST的开关打开后可以直接在命令行跑压测适合答辩现场演示“这套算法经过万局验证”。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站