简介:这是一份基于C++与MFC框架开发的扫雷游戏完整工程,主要面向需要完成Windows桌面应用大作业的高校学生,以及希望上手MFC界面编程的初学者。工程涵盖游戏逻辑、界面交互与消息处理等核心模块,可帮助读者理解二维数组雷区建模、周围雷数计算、鼠标事件响应及对话框资源管理。压缩包共31个文件,包含17张bmp图标与界面位图、8个h头文件、6个cpp源文件,源码结构清晰,主程序、视图类、文档类和雷区类均有独立文件,便于对照学习。资源大小仅33KB,体量精简但功能完整,已有1566人学习查看,适合作为课程设计参考或MFC入门实践模板。通过阅读源码可掌握MFC消息映射机制、自定义类的设计以及扫雷核心算法的具体实现。
1. MFC 扫雷大作业能给你什么:一个把 C++ 基础串起来的完整项目
MFC 扫雷这类题目几乎是 C++ 大作业里的标配:它不像学生管理系统那样只有增删改查,也不像算法题那样只能往黑屏字符里输出结果。一个 9×9 面板、十颗雷、右键插旗、左键翻开,麻雀虽小,却把 MFC 的消息循环、GDI 绘制、定时器、随机数、递归这些 C++ 课里最常考的点全串起来了。如果你正在找 C++ 小游戏代码交课程作业,或者想用 MFC 写一个能真正玩起来的 C++ 游戏,扫雷是性价比最高的选题——逻辑复杂度可控,界面不难看,答辩时还能讲出很多细节。这篇文章就把我从头到尾做这个程序的过程、参数和踩过的坑写出来,照着做,你也能在本地跑通并讲清楚每一段代码。
2. 工程骨架先立住:对话框工程、棋盘数据与消息映射
2.1 为什么选 MFC 对话框工程,而不是单文档
MFC 里能承接扫雷的工程模板有三种:单文档(SDI)、多文档(MDI)和对话框。大作业最常见的误区是选单文档,理由是“看起来更正规”。单文档确实有菜单栏、工具栏和状态栏,但它的视图刷新、文档序列化、命令路由对初学者来说全是额外的负担——扫雷根本不需要文档模板那套机制,你只要一个窗口、一张位图、一堆方块。
我一般建议选对话框工程。对话框天生适合这种“一个面板 + 少量交互”的程序:资源编辑器里拖两下就能搞定尺寸,消息映射直接挂到 Dialog 类上,启动时 OnInitDialog 就是天然的初始化入口,关窗时 OnDestroy 又能干净地回收资源。雷盘、计分、重置按钮全都画在客户区里,不需要切分视图,调试时也只需盯着一个类。
工程创建时,注意 Visual Studio 里的“桌面应用程序”向导中要勾选 MFC 相关组件。新版 VS 默认不会装 MFC 库,离线安装包也要手动勾选,否则新建项目时压根看不到 MFC 模板。如果你习惯用 VSCode 配 C/C++ 环境,建议把话先说清楚:VSCode 写 C++ 源码没问题,但 MFC 依赖 Windows 下的 ATL/MFC 头文件和链接库,必须走 Visual Studio 的 C++ 桌面开发组件,单靠 VSCode 的扩展是编译不了 MFC 工程的。
2.2 棋盘数据结构:用二维数组还是类封装
扫雷的数据模型非常固定:每个格子只有“是不是雷、周围有几颗雷、有没有翻开、有没有被标记”四个属性。最直白的做法是四个二维数组:
// GameData.h #pragma once #define ROWS 9 #define COLS 9 #define MINES 10 #define CELL_SIZE 32 class CMinesweeperDlg; struct CellData { bool isMine; int adjacentMines; // 0~8 bool revealed; int markState; // 0 无标记 / 1 插旗 / 2 问号 };这里CellData把四个状态绑在一起,比四个散落的数组好维护得多——尤其在你后续要加“踩雷动画”或者“剩余雷数高亮”这类功能时,只改一个结构体就够了。数组的索引map[row][col]对应屏幕上第 row 行、第 col 列的格子,坐标换算公式写在绘制函数里统一管理。
有人喜欢把它封装成MineBoard类,我认为对大作业来说属于过度设计。两个理由:第一,扫雷的棋盘操作只有布雷、翻开、标记、判胜四种,每个函数七八行,全局函数配合对话框成员变量已经够清晰;第二,MFC 对话框类本身是把界面和状态揉在一起的地方,你强行分离数据层,反而要在对话框和 Board 类之间来回传指针,答辩时解释起来绕圈子。
2.3 消息映射:先理清谁处理鼠标、谁处理重绘
MFC 程序的一切行为都由消息驱动,扫雷的核心消息就三个:鼠标左键按下、鼠标右键按下、定时器到点。在对话框类里用消息映射宏挂接:
BEGIN_MESSAGE_MAP(CMinesweeperDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() ON_WM_TIMER() ON_WM_DESTROY() END_MESSAGE_MAP()ON_WM_PAINT会映射到OnPaint函数,整块客户区的重绘都走这里。ON_WM_LBUTTONDOWN和ON_WM_RBUTTONDOWN分别拿到左键右键按下的坐标,你在这个函数里把屏幕坐标换算出格子行列,再决定是翻开还是插旗。ON_WM_TIMER是每秒触发一次,用来更新计时显示。
这里有个被问过很多次的细节:为什么不用按钮控件(CButton)来做 81 个格子?因为 CButton 的点击消息是基于控件 ID 分发的,81 个按钮你就要准备 81 个ON_BN_CLICKED映射,或者动态创建后自己处理反射消息,代码量立刻翻倍。更直接的问题在重绘——你翻开一个格子时要重绘那个按钮,要调用RedrawWindow,而 MFC 按钮的重绘样式默认是系统风格,做不到扫雷那种立体边框效果。所以我选择干脆不用控件,整个客户区当成一张画布,鼠标坐标自己算,翻一格就重绘一帧,这里虽然增加了 GDI 绘制的代码量,但换来的是完全可控的视觉效果和清爽的消息流。
3. 游戏核心逻辑:布雷、翻开、标雷与胜负判定
3.1 布雷:为什么别用单纯的 rand 取模
布雷是扫雷的第一个坑。初学者最自然的写法是循环 10 次,每次rand() % 81得到格子下标,放下雷。但这样存在两个问题:一是重复,同一个格子被选两次后循环次数就不够;二是rand()内部是线性同余生成器,虽然分布尚可,但一旦忘记写srand(time(NULL)),每次启动游戏的雷盘就完全一样,答辩现场翻车概率极高。
更稳的做法是 Fisher-Yates 洗牌:把 81 个格子编号放进去,随机交换后取前 10 个作为雷位。这样既不会重复,又天然均匀:
void CMinesweeperDlg::InitMines() { // 先把所有格子状态清空 for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) { m_board[r][c] = CellData{}; } // Fisher-Yates 洗牌:只做一次 O(n) 遍历,不需要反复碰撞重试 std::vector<int> cells(ROWS * COLS); for (int i = 0; i < ROWS * COLS; ++i) cells[i] = i; std::random_device rd; std::mt19937 gen(rd()); for (int i = ROWS * COLS - 1; i >= ROWS * COLS - MINES; --i) { std::uniform_int_distribution<> dist(0, i); int j = dist(gen); std::swap(cells[i], cells[j]); } // 前 MINES 个下标就是雷位 for (int i = 0; i < MINES; ++i) { int idx = cells[i]; int r = idx / COLS; int c = idx % COLS; m_board[r][c].isMine = true; } }注意两点:std::random_device用于取真正的随机种子,std::mt19937是梅森旋转伪随机算法,质量和性能都远好于rand();swa的操作是从数组尾部往头部收缩,已经交换过的位置不会再次参与选择,因此雷位不会重复,循环次数也恰好是雷数。如果你所在的编译环境不允许 C++11 标准库头文件<random>,退而求其次用srand((unsigned)time(nullptr)) + rand()也能交差,但要记住把种子放在InitMines里,而不是OnInitDialog里——否则每次新游戏都会延续上一次相同的雷盘序列。
3.2 翻开格子:深度优先递归的边界条件
雷盘上的数字是扫雷游戏的“地图信息”,必须在布雷结束后立刻全部算好。对每个非雷格子,统计周围 8 个邻居中雷的数量,然后写入adjacentMines。这个统计最容易被数组越界坑到,下一章再细说。数字算好以后,重点就变成了“翻开”逻辑。
玩家点开的格子周围没有雷时,游戏需要自动扩散翻开一片空白区域——这是深度优先遍历(DFS)的经典场景。很多教材里的例子是用递归实现,但递归函数必须带上棋盘边界判断和“已翻开”判断,否则就会无限递归或越界崩溃:
void CMinesweeperDlg::RevealCell(int row, int col) { // 边界保护:任何递归前先检查行列 if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return; // 已经翻开过的格子直接返回,否则会陷入死递归 if (m_board[row][col].revealed) return; // 插旗的格子不允许被左键翻开(这是 Windows 扫雷的约定行为) if (m_board[row][col].markState == 1) return; m_board[row][col].revealed = true; m_board[row][col].markState = 0; // 翻开时清除问号标记 // 数字为 0 才继续向四周扩散;数字非 0 表示边界格子,停下即可 if (m_board[row][col].adjacentMines == 0) { for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) { if (dr == 0 && dc == 0) continue; RevealCell(row + dr, col + dc); } } }这段函数体虽然短,却包含了扫雷递归的所有关键决策。先做边界检查再访问数组,这是防止越界的唯一可靠办法,不要试图在访问后用异常捕获来兜底。markState == 1表示玩家已经插旗,这一格是玩家认定有雷的位置,左键不应该把它翻开,保持和传统扫雷一致的行为。空白格子的判定依据是adjacentMines == 0,因为只有空白格才会向四周继续扩散。
对大作业的规模来说,讨论递归改为栈是没必要的,9×9 的棋盘即使整片空白,递归深度也不会超过几十层,远达不到栈溢出阈值。但如果你扩展到了 30×60 的大地图,就要考虑用std::stack模拟 DFS 或者用队列做 BFS——这是答辩加分项,后面扩展章节再提。
3.3 右键标雷、双击翻开与胜负判定
右键的逻辑比较简单:格子未翻开时,标记状态在“无标记 → 插旗 → 问号 → 无标记”之间循环。这里需要注意个小问题,Windows 扫雷里问号状态会在 2000 年以后的版本中被取消,但课程设计保留它反而更容易讲解状态机,所以代码里保留三态:
void CMinesweeperDlg::OnRButtonDown(UINT nFlags, CPoint point) { int row, col; if (!GetCellFromPoint(point, row, col)) return; CellData& cell = m_board[row][col]; if (cell.revealed) return; // 状态迁移:0 -> 1 -> 2 -> 0 cell.markState = (cell.markState + 1) % 3; if (cell.markState == 1) { m_flagCount++; } else if (cell.markState == 2) { m_flagCount--; // 问号不占旗子数 } UpdateMineDisplay(); // 刷新顶部剩余雷数 Invalidate(); // 触发重绘 }这里m_flagCount用来跟踪当前插了多少旗,顶部显示的“剩余雷数”始终是MINES - m_flagCount。至于双击,很多扫雷实现里左键双击数字格子会翻开周围一圈,但对于 9×9 的棋盘来说优先级不高,我一般把它作为扩展功能,放在基础功能写完并且稳定之后再决定加不加。
胜负判定要放在每次翻开操作之后立刻执行。失败条件很简单:翻开的格子isMine == true。胜利条件则要算“所有非雷格子都被翻开”,写成统计剩余未翻开格子的数量比较直观:
bool CMinesweeperDlg::CheckWin() { int unrevealed = 0; for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) { if (!m_board[r][c].revealed) unrevealed++; } // 未翻开的格子必须全部是雷,才算赢 return unrevealed == MINES; }注意这个判定里不能统计“错误插旗”的情况——玩家可能把旗插在安全格子上,但那个格子并没有翻开,如果插旗数量加上已翻开格子正好等于总数,并不代表胜利。安全起见,更严格的写法是遍历所有格子判断“非雷格子是否全部 revealed”,也就是revealed 的数量 == ROWS * COLS - MINES,推荐采用后者,逻辑上没有歧义。
4. 界面与交互落地:双缓冲绘图、计时与剩余雷数
4.1 布局计算:把屏幕坐标换算成格子坐标
画界面之前先把布局地图固定下来。整个客户区从左上角开始算:顶部留 48 像素高度放笑脸和雷数,棋盘左上角定在(12, 60),随后每个格子占CELL_SIZE = 32像素,格子之间留 1 像素的间隙,雷盘总宽就是COLS * 32 + (COLS - 1) * 1。这些常量集中在文件头部定义,后续要改成 16×30 的大雷盘时,只需要改ROWS/COLS/MINES三个常量即可。
坐标换算有个最容易错的细节:鼠标消息里的坐标是相对于窗口客户区的,不是相对于屏幕;对话框的OnPaint使用的坐标同样相对客户区。所以拿鼠标坐标计算格子时,先用两次ScreenToClient确认坐标一致,再减掉棋盘偏移,然后整除CELL_SIZE:
bool CMinesweeperDlg::GetCellFromPoint(CPoint pt, int& row, int& col) { CRect rcClient; GetClientRect(&rcClient); // 验证点击点在客户区内部 if (!rcClient.PtInRect(pt)) return false; int x = pt.x - BOARD_OFFSET_X; int y = pt.y - BOARD_OFFSET_Y; if (x < 0 || y < 0) return false; row = y / CELL_SIZE; col = x / CELL_SIZE; if (row >= ROWS || col >= COLS) return false; return true; }如果返回 false,直接对OnLButtonDown里的点击不做任何处理。很多人会把棋盘绘制区下面空出来的背景也当成可点击区域,然后用if (row < 0 || col < 0)之类的半吊子判断兜底,不如这里统一返回有效性,调用方干净很多。
4.2 双缓冲绘制:避免整块屏幕闪烁的必做操作
MFC 对话框中直接CClientDC dc(this)然后用dc.Rectangle(...)逐格绘制,会有一个明显的问题:每次刷新都先清空客户区再重画,眼睛能明显看到闪烁,尤其在快速连续左键翻开多个格子时。原因在于Invalidate()之后,系统先发送WM_ERASEBKGND把背景擦成灰色,再执行OnPaint画内容,中间那一帧是空的。
解决办法是双缓冲:先在内存里创建一张与客户区同尺寸的位图,所有格子、图标、数字都在内存 DC 上画完,最后一次性BitBlt复制到屏幕。MFC 里写法如下:
void CMinesweeperDlg::OnPaint() { CPaintDC dc(this); // 设备上下文:用于最终输出 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, m_clientWidth, m_clientHeight); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 1. 画背景底色 memDC.FillSolidRect(0, 0, m_clientWidth, m_clientHeight, RGB(192, 192, 192)); // 2. 画笑脸和雷数/计时(顶部区域) DrawStatusArea(memDC); // 3. 逐格画雷盘 for (int r = 0; r < ROWS; ++r) { for (int c = 0; c < COLS; ++c) { DrawCell(memDC, r, c); } } // 4. 一次性输出到屏幕 dc.BitBlt(0, 0, m_clientWidth, m_clientHeight, &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }DrawCell单独抽成一个函数,让它根据格子的状态挑不同的画法;画格子的具体代码里,未翻开的格子画凸起边框(用三条亮线和三条暗线模拟立体按钮),翻开的格子画凹陷边框并填充灰色,有数字的数字文本居中,插旗的格子画一面小红旗。这里我不建议在代码里拼位图资源——一个是初学不熟悉CImage的加载,另一个是用 GDI 画矩形和线段更容易控制大小,放大缩小都不失真。
“闪烁”这种问题,如果你不做双缓冲,只能靠屏蔽背景擦除消息来缓解,但那样会留下拖影,治标不治本。双缓冲的代价是每次重绘多一次内存拷贝,对于 9×9 扫雷每秒钟重绘一两次完全感觉不到开销,是必须做对的基础操作。
4.3 定时器、剩余雷数与笑脸重置
计时器是 MFC 里特别容易写错的点。SetTimer可以放在OnInitDialog里,但更好的做法是放进“新游戏初始化”函数,因为玩家中途点击笑脸重置后必须停止旧计时、重新从 0 开始。正确流程是:先KillTimer(1),把秒数清零,再SetTimer(1, 1000, NULL)重新开始计时,最后Invalidate()刷新界面。
ON_WM_TIMER的函数体很短:
void CMinesweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { m_seconds++; if (m_seconds > 999) m_seconds = 999; // 上限,防止位数撑破布局 Invalidate(FALSE); // FALSE 表示不清除背景,避免闪一下 } CDialogEx::OnTimer(nIDEvent); }关于Invalidate(FALSE):传FALSE是告诉系统这次重绘不要擦除背景。在双缓冲绘图下作用不大,但在非双缓冲的版本里可以显著降低闪烁。要注意的是,SetTimer的第二个参数是毫秒值,1000毫秒触发一次。如果填写100而不自知,计时器每秒会触发十次,界面显示的秒数会飞一样地涨,这是调试计时器时最先检查的参数。
笑脸重置区域的判定放在OnLButtonDown顶部,先判断点击坐标是否落在笑脸矩形内,落在里面就调用RestartGame(),否则才走棋盘格子的翻开逻辑。RestartGame里还应该把失败状态、胜利状态都归零,因为任何一次重新开始都必须彻底重建雷盘,这个函数是全局状态的总重置入口。
5. MFC 扫雷避坑记录:五个最容易翻车的细节
5.1 雷盘外围数字计算越界:访问了下标 -1,程序直接崩
现象:点开第一行第一个格子,程序在CountAdjacentMines函数里崩溃,调用栈显示访问了mineMap[-1][-1]。 原因:统计周围雷数时直接写了mineMap[r + dr][c + dc],没有检查r + dr和c + dc是否落在0 ~ ROWS-1和0 ~ COLS-1范围内。C++ 不会帮你拦住越界,它会读取这个非法地址里的垃圾数据,随机表现为崩溃、乱码数字或者假雷。 解决:统计雷数的循环里连边界检查和格子坐标检查一起做,宁可多写几行判断,也不要依赖后续“反正棋盘边上是数字不会越界”的暗示。把CountAdjacentMines(r, c)改成独立函数,内部用dr/dc遍历 8 个方向,每个方向都先做范围校验再访问数组。这个坑在布雷算法验证时最容易暴露,因为测试人员往往习惯先点边角。
5.2 随机数不随机:每次都打开一模一样的雷盘
现象:程序重启后,第一局雷的位置总是固定那几格,完全没有“新游戏”的体验。 原因:有人用了rand()却没有调用srand(time(NULL)),或者把srand写在了OnInitDialog里但InitMines里用的是rand()的默认种子。rand()的默认种子是 1,产生序列固定不变,等于每次游戏用的都是同一个雷盘序列。 解决:最直接的做法是换成std::random_device+std::mt19937,代码见 3.1。如果不想引入<random>头文件,也可以把srand((unsigned)time(nullptr))放到InitMines函数最前面,增加重开一局时不重样。注意这个坑在调试中还表现为“按下重置后雷还是原样”,因为重置时没有重新布雷,只是把 show 数组清了——这实际上是另一层问题,RestartGame里必须先调用InitMines,再清空翻开和标记状态。
5.3 MFC 字符串拼接类的内存泄漏误报
现象:程序关闭时 Visual Studio 的调试输出窗口显示 “Detected memory leaks”,定位到CString对象或GetBuffer调用附近。 原因:CString本身管理内存不会漏,漏的是你调用了GetBufferSetLength(len)后忘记ReleaseBuffer()。GetBuffer返回内部的字符指针,期间 CString 不知道外部持有了它的缓冲区,不调用ReleaseBuffer就析构或重新赋值,会直接把内部状态弄乱,甚至把泄漏算到CString头上。 解决:凡是用GetBuffer改过字符串,必须在同一个作用域内成对调用ReleaseBuffer。我在扫雷里通常根本不用CString::GetBuffer——数字转字符串直接用std::to_string,再CString(str.c_str())构造,绕开这个接口,省掉一整类隐患。在 MFC 工程里std::to_string可用,但要记得#include <string>。
5.4 重绘闪烁与残留重影:是双缓冲没做对
现象:快速点击格子后,界面上出现上一帧的残留图案,或者整个客户区闪烁得厉害。 原因:绘制不在OnPaint里完成,而是直接在OnLButtonDown里用CClientDC画了一次,然后Invalidate()又让OnPaint画了第二次。两套绘制逻辑没有统一入口,第二次绘制如果画错了状态,残留的重影就会一直留在屏幕上。 解决:把所有对棋盘的可视改动统一收口成一句话:修改数据成员,然后调用Invalidate(),让OnPaint成为唯一绘制入口。不要在鼠标处理函数里直接画格子,也不要同时用CClientDC和CPaintDC混合绘制。如果闪烁仍然存在,再检查OnPaint是否用了内存 DC 和BitBlt,以及Invalidate是否带FALSE参数。总体上,遵守“数据改动在响应函数里完成,绘制统一在 OnPaint 里完成”这一条,扫雷的界面问题能消掉九成。
5.5 鼠标点击无效:点在了对话框的空洞区域
现象:棋盘四周留白很多,点击棋盘外侧空白区域程序无反应,但点击棋盘最外圈时偶尔失灵。 原因:OnLButtonDown里拿到的是窗口坐标,却被直接按CELL_SIZE整除换算成格子,没有先减去棋盘偏移量BOARD_OFFSET_X和BOARD_OFFSET_Y。当你把雷盘画在(12, 60)位置时,点击第 0 行第 0 列的格子,坐标大约在(13, 61),除以 32 后恰好得到 0 和 1,看起来还能用;但点击棋盘最后一行时,坐标比理论值多了偏移量,换算出的行列已经超出ROWS/COLS,于是被边界判断弹掉。 解决:严格按照 4.1 的GetCellFromPoint写换算——先减偏移量再整除。调试时可以在OnLButtonDown里加一行TRACE(_T("pt=(%d,%d) row=%d col=%d\n"), pt.x, pt.y, row, col);输出到输出窗口,凡是点击最外圈格子但输出的行列不符,基本就是坐标换算里忘了偏移量。这个坑不崩程序,但会让玩家觉得“鼠标对不齐”,属于体验类 bug,答辩演示时放大到投屏上格外丢分。
6. 答辩前最后一遍:验证路径、扩展方向与代码注释习惯
6.1 三个必测场景和一段演示话术
程序写完不代表能答辩。我每次交这类大作业前,会按固定顺序做三遍验证:第一遍,第一局游戏连续点左上角三格,确认 DFS 翻开范围为空白区域且数字正确;第二遍,右键三下循环插旗、问号、取消,确认剩余雷数同步变化且不会出现负数;第三遍,踩中一颗雷后确认游戏结束弹窗,再点笑脸重置,确认计时器归零且雷盘完全重排。这三遍都过了,代码里的低级错误基本被过滤干净。
答辩时不要从头讲代码逐行过,而是先打开程序玩一局,边说操作边引出背后的实现——点开空白区引出 DFS,插入旗子引出状态机,数字显示引出 GDI,计时器刷新引出定时器。这比念变量名有说服力得多。准备一张“雷盘 9×9/10 雷”的常量表放在注释里,评委问“为什么不设计难度选择”时,你就回答“所有布局参数都集中在同一个头文件,改三个常量即可扩展”,比现场改代码轻巧。
6.2 可以加分的三个扩展方向
如果你想在基础版上多拿点分,优先做这三个,按性价比排序:第一个是难度菜单,用对话框上的三个单选按钮切换初级/中级/高级,核心改动只在ROWS/COLS/MINES三个常量以及棋盘偏移坐标;第二个是“问号标记”的保留,把它做成右键三态循环,讲解状态机时有个完整闭环;第三个是“双击数字翻开周围格子”,即踩在已翻开的数字格上时,若周围旗子数等于该数字,则自动翻开剩下未标记的格子,这是扫雷进阶玩家最需要的操作,也是算法上有点难度的部分——它要再走一遍RevealCell但跳过已插旗格子。
6.3 注释习惯:大作业代码也要留“后悔药”
我踩过最疼的一次坑是答辩前三天想改雷盘尺寸,结果发现CELL_SIZE散落在一堆绘制代码里,全局搜索替换把逻辑都改坏了。后来我在所有地方统一用CELL_SIZE常量,并且给每个函数头部写三行注释:输入参数是什么、返回什么、边界条件在哪。扫雷这种规模的项目不需要长篇注释,但关键判断(比如markState == 1跳过翻开)必须写一句为什么,否则两周后你自己也看不懂。
这段代码我也会刻意保留一份命令行调试用的命令行打印版本:在InitMines之后可以输出雷盘数字矩阵到控制台或者输出窗口,方便不跑界面直接验证算法。这一步在答辩现场特别救命——如果 GUI 突然出问题,你能当场切换到“调试数据视图”证明算法正确,把问题定位到界面层,留给评委的印象比埋头乱改强很多。
扫雷这个题目最好的地方在于,它给你足够的边界去练习“工程感”:先定数据结构,再写算法,再处理界面,最后回归测试。按这个顺序走完一遍,面试官问起 C++ 项目经历时,你不需要背八股,直接说清楚这四层关系就够让人信服。希望这篇文章能帮到你,也能让你在答辩时少一点紧张,多一点从容。
本文还有配套的精品资源,点击获取