简介:面向C++与MFC初学者的扫雷游戏完整项目,适合用作课程大作业、Windows桌面开发入门练习或MFC框架学习案例。项目把扫雷核心逻辑与图形界面结合起来,涉及二维数组雷区、相邻雷数计算、鼠标点击消息响应、标记与重新开局、踩雷判定等常见知识点;工程中可看到主框架、视图、文档与游戏逻辑类之间的协作,便于理解C++类设计、资源加载和MFC消息映射机制。压缩包共31个文件,以17个bmp位图、8个头文件和6个cpp源文件为主,位图用于界面素材,头文件与源文件构成文档/视图结构及游戏逻辑模块,整体约33KB,目录结构清晰且便于导入Visual Studio查看与调试。该资源已有1571人学习,适合希望从完整工程中梳理MFC单文档程序组织方式、事件处理流程,并对照实现雷区初始化、数字提示与失败状态更新的读者参考。
1. 从「点第一下就炸」说起:一份能直接交作业的 C++ MFC 扫雷
期末前三天,室友把一份 C++/MFC 扫雷工程拷过来,VS 一编译跳出一屏找不到CDialogEx的错,好不容易跑起来,鼠标点第一格直接踩雷,再点右键整个窗口白成一片。这类大作业的翻车点几乎都在固定几个位置:首点保护没做、双缓冲没写、KillTimer忘了调。这份完整程序把这些坑提前填好了——对话框框架、布雷与邻居计数、递归展开、插旗与计时、胜负判定、难度切换全在里面,代码量控制在能一口气读完、又能改得动的范围。适合两类人:赶 C++ 课程设计要交一个能演示、能讲清原理的作业;或者 MFC 只用过控件拖拽,想拿个小项目真正过一遍消息映射和 GDI 绘图的人。
2. 工程骨架拆解:一个对话框程序怎么装下整个游戏
2.1 文件清单与职责划分
双击.sln打开,工程结构比想象中干净。MFC 向导会生成一堆样板文件,真正跟游戏逻辑相关的只有三四个。我一般按下面这张表来认文件,先认清哪个文件该放什么,后面改代码就不会到处乱塞。
| 文件 | 职责 | 改它的频率 |
|---|---|---|
Minesweeper.h/.cpp | CWinApp派生类,进程入口,InitInstance里创建主对话框 | 极低,只在启动时播随机种子 |
MinesweeperDlg.h/.cpp | 全部游戏逻辑:数据、布雷、点击、绘制、计时 | 高,九成改动在这里 |
Minesweeper.rc | 对话框模板、菜单、图标、位图资源 | 中,加菜单项、换皮肤时动 |
resource.h | 资源 ID 宏定义,由 VS 自动维护 | 低,手改容易冲突 |
pch.h / framework.h | 预编译头,afxwin.h等 | 极低 |
选对话框程序(Dialog based)而不是单文档(SDI),理由很实际:扫雷只有一个固定尺寸的窗口,没有文档、没有视图切换、没有保存提示,SDI 那套CDocument/CView架构纯属给自己加戏。对话框程序的消息循环简单,OnInitDialog一进来就能初始化数据,OnPaint里直接画,少一层OnDraw的转发,对交大作业的人来说少一个要解释的概念。
2.2 消息映射表:鼠标、定时器、菜单都挂在哪
MFC 跟 Win32 SDK 最大的差别就在消息处理。SDK 时代你得在WndProc里写一个巨大的switch,MFC 换成了消息映射宏。这套宏看起来玄学,本质是两张静态表:一张记录「消息 ID → 处理函数指针」,一张记录基类映射表,派发时沿继承链往上找。类向导(Class Wizard)会自动帮你加,手写也不难,但宏的位置和函数签名必须对得上。
// MinesweeperDlg.cpp —— 消息映射表,注意宏顺序与函数签名严格对应 BEGIN_MESSAGE_MAP(CMinesweeperDlg, CDialogEx) ON_WM_PAINT() // -> afx_msg void OnPaint(); ON_WM_LBUTTONDOWN() // -> afx_msg void OnLButtonDown(UINT, CPoint); ON_WM_RBUTTONDOWN() ON_WM_LBUTTONDBLCLK() // 双击 = chord 连开 ON_WM_TIMER() // -> afx_msg void OnTimer(UINT_PTR); ON_WM_DESTROY() // 关窗时收尾,别把定时器留着 ON_COMMAND(ID_GAME_NEW, &CMinesweeperDlg::OnGameNew) ON_COMMAND(ID_LEVEL_BEGINNER, &CMinesweeperDlg::OnLevelBeginner) ON_COMMAND(ID_LEVEL_INTERMEDIATE, &CMinesweeperDlg::OnLevelIntermediate) ON_COMMAND(ID_LEVEL_EXPERT, &CMinesweeperDlg::OnLevelExpert) END_MESSAGE_MAP()ON_WM_XXX系列的宏不需要你写消息 ID,MFC 内部用WM_常量做偏移;而菜单命令用ON_COMMAND,第一个参数是资源 ID。常见的翻车是手写函数忘了在头文件里加afx_msg前缀、或者漏了DECLARE_MESSAGE_MAP(),编译报「无法解析的外部符号」,查半天以为是链接器设置问题。其实只要记住:.h里声明afx_msg,.cpp里BEGIN/END_MESSAGE_MAP之间加一行,两边名字必须一模一样。
还有一个容易忽略的点:对话框默认会响应IDOK和IDCANCEL。扫雷里按下回车会直接关窗,玩家一脸懵。解决办法是在OnOK()里重写空实现,或者把对话框模板上「确定」按钮删掉、把默认按钮 ID 改掉。
2.3 从 OnInitDialog 到 OnPaint:一次启动的完整时序
程序启动的时序是:CWinApp::InitInstance里srand播种子,然后构造CMinesweeperDlg并DoModal。进入DoModal后,框架创建窗口、加载对话框模板、发WM_INITDIALOG,对应到OnInitDialog。这个函数返回TRUE表示「我自己设置焦点」,返回FALSE表示「让框架设置」,扫雷里没有输入框,返回TRUE就行。
BOOL CMinesweeperDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_nCellSize = 20; // 单格边长(像素),高级难度下 30 列 = 600px,1080p 屏够放 m_nMargin = 16; // 棋盘四周留白,同时是状态栏的高度预算 m_nRows = 9; m_nCols = 9; m_nMines = 10; ResizeWindowToBoard(); // 按行列数反算窗口客户区尺寸 CreateBackBuffer(); // 一次性建好内存 DC 和位图,别在 OnPaint 里反复建 ResetGame(); // 清空棋盘、复位计数、停掉定时器 return TRUE; }这里有两个设计取舍值得说。第一,窗口尺寸必须跟着棋盘反算,MoveWindow里要带上标题栏和边框的高度(GetSystemMetrics(SM_CYCAPTION)那一套),否则高级难度棋盘会被截掉最后一列。我一般直接算客户区大小,再用AdjustWindowRectEx反推窗口矩形,比手写常量靠谱。第二,内存 DC 的创建放在OnInitDialog而不是OnPaint,因为位图资源创建是重操作,每秒重绘几十次时反复CreateCompatibleBitmap很容易把 GDI 句柄耗光,任务管理器里能看到 GDI 对象数一路涨。
OnInitDialog返回之后框架会发一次WM_PAINT,此时棋盘数据已就绪,OnPaint里把 9×9 的格子画出来,玩家看到的就是满屏凸起的方块。整个流程没有任何异步操作,所以不存在「绘制时数据还没初始化」的时序问题——这也是对话框程序比多线程架构省心的地方。
3. 核心数据结构与布雷算法:二维数组、随机与首点保护
3.1 一个 Cell 结构体胜过三个平行数组
新手很容易写成bool mine[24][24]; bool open[24][24]; int around[24][24];三份数据。这样写能跑,但后面加一个「问号标记」状态就得再开一个数组,加一个「动画状态」又得开一个。更麻烦的是函数传参要传三份,漏传一个就出灵异现象。
// MinesweeperDlg.h —— 单格状态全放在一个结构体里 const int MAX_ROWS = 24; const int MAX_COLS = 30; struct Cell { bool bMine; // 是否地雷 bool bOpen; // 是否已翻开 bool bFlag; // 是否插旗 int nAround; // 周围 8 格的地雷数,0~8;是雷时该值无意义 }; Cell m_board[MAX_ROWS][MAX_COLS];用固定大小的二维数组而不是std::vector<std::vector<Cell>>,是考虑到大作业场景:棋盘最大 24×30,栈上或成员变量里放一个 720 元素的数组完全没压力,访问时也没有两层间接寻址。如果要做「无限尺寸」的自定义难度,再换成std::vector<Cell>一维数组,索引用r * m_nCols + c。参数说明一下:MAX_ROWS/MAX_COLS只决定数组上限,实际生效的是m_nRows/m_nCols,所有循环和边界判断必须用后者,混用就会在初级难度下读到脏数据。
结构体方案的另一个好处是遍历时缓存友好,for (r) for (c)扫一遍内存是连续的,720 个格子几十微秒就完事,重绘时逐格取状态完全不用担心性能。
3.2 首点保护:为什么不能在 OnInitDialog 里就把雷布好
经典的错误做法是在ResetGame里一次性把雷撒完,然后等玩家点。这样有两个后果:一是第一下可能直接踩雷,玩家刚开局就死,体验极差;二是如果玩家点的是空白格,递归展开一瞬间掀开大半个棋盘,运气成分太重。
正确做法是「延迟布雷」:等玩家第一次左键点击,拿到那个坐标,再布雷,并且把这个坐标周围 3×3 排除在雷区之外。这样保证首点必是空白格,展开一片,开局有信息量。
void CMinesweeperDlg::PlaceMines(int safeRow, int safeCol) { // 1. 清空所有雷标记(重开一局时复用同一份数组) for (int r = 0; r < m_nRows; ++r) for (int c = 0; c < m_nCols; ++c) m_board[r][c].bMine = false; // 2. 收集所有"允许放雷"的格子下标,首点周围 3×3 排除 std::vector<int> candidates; candidates.reserve(m_nRows * m_nCols); for (int r = 0; r < m_nRows; ++r) for (int c = 0; c < m_nCols; ++c) if (abs(r - safeRow) > 1 || abs(c - safeCol) > 1) candidates.push_back(r * m_nCols + c); // 3. 洗牌式抽取:抽一个删一个,天然不会重复 for (int i = 0; i < m_nMines && !candidates.empty(); ++i) { int k = rand() % (int)candidates.size(); int idx = candidates[k]; m_board[idx / m_nCols][idx % m_nCols].bMine = true; candidates[k] = candidates.back(); // 用尾元素填坑,O(1) 删除 candidates.pop_back(); } // 4. 全盘重算邻居数 for (int r = 0; r < m_nRows; ++r) for (int c = 0; c < m_nCols; ++c) m_board[r][c].nAround = CountAround(r, c); }参数safeRow/safeCol就是首点坐标,函数内部不判断合法性,调用方保证过。第 3 步的「抽一个删一个」比「随机取坐标、重复就重试」稳得多:高级难度 16×30 有 480 格,99 颗雷,重试法在最后几十颗时命中率已经掉到 20% 以下,虽然也能跑完,但极端情况下会卡顿一下;洗牌法每次都是 O(1),480 格抽 99 次毫无压力。
srand只需在InitInstance里调一次,srand((unsigned)time(NULL))。血泪经验:有人在每次点击时都调srand,同一秒内连点两下,雷型一模一样,测试时还以为自己写的随机有问题。
3.3 邻居计数与展开:为什么把递归换成队列
CountAround是标准的八方向扫描,重点在边界判断。这里不用在循环里写四个if去判断r-1 >= 0之类的,而是抽一个InBoard(r, c)内联函数,循环里if (!InBoard(nr, nc)) continue;,代码短且不容易漏。
int CMinesweeperDlg::CountAround(int r, int c) const { int n = 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 (!InBoard(nr, nc)) continue; // 边界格直接跳过 if (m_board[nr][nc].bMine) ++n; } return n; } inline bool CMinesweeperDlg::InBoard(int r, int c) const { return r >= 0 && r < m_nRows && c >= 0 && c < m_nCols; }展开逻辑是扫雷的灵魂。规则很简单:翻开的格子如果周围雷数为 0,就继续翻开它周围 8 格;如果大于 0,只翻自己,不扩散。教科书上一般教递归写法,代码五六行很漂亮。但我建议改成队列,原因很实在:递归深度等于空白区域连通块的大小,标准难度下最多几百层,没事;一旦有人把自定义难度开到 100×100 全空白,那就是一万层递归,Windows 默认 1MB 栈空间直接爆掉,程序「无响应」然后消失,没有任何错误提示,查起来非常难受。
void CMinesweeperDlg::OpenCell(int r, int c) { std::queue<CPoint> q; q.push(CPoint(r, c)); while (!q.empty()) { CPoint p = q.front(); q.pop(); if (!InBoard(p.x, p.y)) continue; Cell& cell = m_board[p.x][p.y]; if (cell.bOpen || cell.bFlag || cell.bMine) continue; // 三态过滤 cell.bOpen = true; ++m_nOpened; // 胜利判定靠这个计数器 if (cell.nAround == 0) // 只有空白格才向外扩散 { for (int dr = -1; dr <= 1; ++dr) for (int dc = -1; dc <= 1; ++dc) if (dr || dc) q.push(CPoint(p.x + dr, p.y + dc)); } } }三个过滤条件缺一不可:bOpen防止重复入队造成死循环(空白格互相入队),bFlag尊重玩家标记(插旗的格子不该被自动翻开),bMine防止把雷翻开——虽然正常流程下空白格周围不会有雷,但OpenCell也会被 chord 连开调用,防御性判断必须有。
4. 交互与渲染:点击定位、双缓冲与胜负判定
4.1 像素坐标到行列:一次整除搞定点击定位
鼠标消息给的point是客户区像素坐标,要换算成棋盘行列。这里唯一的坑是「整除对负数向下取整」:如果玩家点在棋盘左上方留白区域,x = pt.x - m_nMargin是负的,直接除以m_nCellSize会得到负数,再拿去索引数组就是越界读,轻则花屏重则崩溃。
bool CMinesweeperDlg::HitTest(CPoint pt, int& r, int& c) const { int x = pt.x - m_nMargin; int y = pt.y - m_nMargin; if (x < 0 || y < 0) return false; // 先判负,再整除 c = x / m_nCellSize; r = y / m_nCellSize; return (r >= 0 && r < m_nRows && c >= 0 && c < m_nCols); }r和c用引用传出,调用方先声明int r, c;再if (!HitTest(point, r, c)) return;。这种写法比返回CPoint好读,也不会因为坐标系约定(x 是行还是列)搞混。注意r对应 y、c对应 x,二维数组m_board[r][c]的第一个下标是行,画图时行对应纵坐标,这两处千万不要写反,写反了的表现是棋盘转置 90 度,看得出但排查时容易怀疑人生。
4.2 双缓冲绘图:把闪烁压到看不见
直接在每个格子上Rectangle+TextOut,第一次运行你会看到明显的闪烁:框架先用背景色擦一遍客户区(WM_ERASEBKGND),再让你画,两次绘制之间有个白屏瞬间。棋盘越大越明显,高级难度下几乎没法看。
标准解法是内存 DC 双缓冲:所有绘制先画到一张跟客户区等大的位图上,最后一次性BitBlt到屏幕。这样屏幕只被刷一次,视觉上没有中间态。
void CMinesweeperDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); // 复用成员变量里的内存 DC,避免每次重绘都新建 GDI 对象 CDC* pMem = GetBackBufferDC(rc.Width(), rc.Height()); if (pMem == nullptr) return; pMem->FillSolidRect(&rc, RGB(192, 192, 192)); // 经典灰底 DrawBoard(*pMem); // 只在内存里画 DrawStatus(*pMem); // 剩余雷数 / 计时 dc.BitBlt(0, 0, rc.Width(), rc.Height(), pMem, 0, 0, SRCCOPY); }GetBackBufferDC内部判断尺寸变化才重建CBitmap,否则一直复用。另外在OnEraseBkgnd里直接return TRUE,把背景擦除挡掉,配合Invalidate(FALSE)(参数FALSE表示不擦背景),闪烁基本绝迹。这条组合拳值得记牢:OnEraseBkgnd返回 TRUE +Invalidate(FALSE)+ 内存 DC,三件套一起用,少一件都会有效果残留。
DrawBoard里画格子用Draw3dRect模拟立体按钮:未翻开的格子左上白、右下灰,是凸起;翻开后反过来,左上灰、右下白,是凹陷。数字颜色按经典配色来,1 蓝、2 绿、3 红、4 深蓝、5 深红、6 青、7 黑、8 灰。文字绘制前记得SetBkMode(TRANSPARENT),否则数字后面会拖一个不透明色块,盖住格子的立体边框。
4.3 左键翻开、右键插旗与 chord 连开
左键的逻辑顺序很重要:先判断是不是「第一次点击」,是就布雷并启动计时器;再判断该格是否插旗(插旗不能翻);再判断是不是雷(是雷就 game over);最后才走OpenCell。
void CMinesweeperDlg::OnLButtonDown(UINT nFlags, CPoint point) { int r, c; if (!HitTest(point, r, c)) { CDialogEx::OnLButtonDown(nFlags, point); return; } if (!m_bGameRunning) return; // 结束后点击不响应 if (m_board[r][c].bFlag) return; // 插了旗不许翻 if (!m_bMinesPlaced) // 首点才布雷,保证第一下安全 { PlaceMines(r, c); m_bMinesPlaced = TRUE; SetTimer(TIMER_ID, 1000, NULL); // 1 秒一跳 } if (m_board[r][c].bMine) { GameOver(r, c); return; } OpenCell(r, c); CheckWin(); Invalidate(FALSE); CDialogEx::OnLButtonDown(nFlags, point); }右键插旗要处理旗子数量上限和「已翻开的格子不能插旗」两个约束。旗子数等于雷数时不允许再插,否则玩家可以插满一屏旗子把雷数统计搞乱,剩余雷数显示成负数。
void CMinesweeperDlg::OnRButtonDown(UINT nFlags, CPoint point) { int r, c; if (!HitTest(point, r, c) || !m_bGameRunning) return; Cell& cell = m_board[r][c]; if (cell.bOpen) return; // 翻开过的不给插 if (!cell.bFlag && m_nFlagsUsed >= m_nMines) return; // 旗子用光了 cell.bFlag = !cell.bFlag; // 插/拔 来回切 m_nFlagsUsed += cell.bFlag ? 1 : -1; Invalidate(FALSE); // 只重绘,不擦背景 }chord 连开是老玩家的核心手感:在一个已翻开的数字格上同时按下左右键(或者中键、双击),如果周围旗子数正好等于这个数字,就把周围没插旗的格子一次性翻开。加不加这个功能,是「能交作业」和「像个真游戏」的分水岭。实现上抽一个CountFlagAround,数量对上再循环调OpenCell,注意中途踩雷要立刻GameOver并返回。
4.4 计时器与胜负判定
计时器用SetTimer(TIMER_ID, 1000, NULL),WM_TIMER里自增秒数。两个细节:一是秒数封顶 999,三位数码管显示不下四位数;二是更新数字时只InvalidateRect状态栏那一小条,别整窗重绘,否则每秒闪一下很廉价。
void CMinesweeperDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_ID && m_bGameRunning) { if (m_nElapsed < 999) ++m_nElapsed; CRect rcStatus = GetStatusRect(); InvalidateRect(&rcStatus, FALSE); // 局部刷新,省掉整窗重画 } CDialogEx::OnTimer(nIDEvent); }胜负判定用「已翻开格子数 == 总格数 - 雷数」。为什么不检查「所有非雷格都翻开」?因为那需要每帧遍历 720 个格子,虽然也不慢,但计数器是 O(1) 的,写起来更干净。雷在OpenCell里已经被bMine过滤掉,所以m_nOpened只会统计安全格,等式成立就是赢。
void CMinesweeperDlg::CheckWin() { if (m_nOpened == m_nRows * m_nCols - m_nMines) { m_bGameRunning = FALSE; KillTimer(TIMER_ID); // 必须停,否则赢了还在走表 FlagAllMines(); // 自动把剩下的雷插上旗,视觉上收个尾 AfxMessageBox(_T("扫雷成功!")); } }GameOver则反过来:停表、把所有雷显示出来、把玩家插错的旗打叉、禁用点击。这两个函数的收尾动作要对称,谁忘了KillTimer,谁就会在关闭窗口后留下一个还在跑的定时器。
5. 避坑与排查:五个我真实踩过的坑
5.1 第一下就踩雷,或者每次重启雷型完全一样
现象:开局点第一格直接爆炸;或者关掉程序重开,雷的位置跟上一局一模一样。
原因:前者是在ResetGame里提前把雷布好了,没有延迟到首点之后;后者是srand没调,或者调在了PlaceMines里面,time(NULL)精度只到秒,同一秒内两次调用种子相同。
解决:布雷挪到首次左键之后,并把首点 3×3 排除;srand((unsigned)time(NULL))只在InitInstance里出现一次,全局仅此一处。改完可以用「连点两次新游戏」来验证,两局雷型必须不同。
5.2 窗口一切换就白屏,重绘闪烁严重
现象:拖动窗口、切换 Alt+Tab 回来,棋盘整片变白;快速点击时格子一闪一闪。
原因:背景擦除和前景绘制分两次完成,中间有空档;另外OnPaint里每次都CreateCompatibleBitmap,GDI 对象只增不减,久了会出现绘制失败。
解决:OnEraseBkgnd直接返回TRUE挡掉背景擦除,所有Invalidate调用传FALSE,内存 DC 与位图作为成员变量复用。想确认有没有泄漏,任务管理器开「GDI 对象」列,玩几分钟看数字是否持续上涨。
5.3 计时器越点越快,关窗后进程还在
现象:连点几次「新游戏」,计时器速度翻倍;关闭窗口后任务管理器里还能看到进程残留。
原因:每次ResetGame都调了一次SetTimer,但没先KillTimer。同一个 ID 重复调用会创建多个定时器,WM_TIMER来一条加一秒,看起来就是加速。进程残留则是OnDestroy里没清理定时器和 GDI 资源。
解决:ResetGame开头先无条件KillTimer(TIMER_ID),再在真正开局时SetTimer;OnDestroy里再兜一次底,同时把内存 DC 和位图DeleteObject。这类「创建前先销毁」的习惯,能省掉一大半资源类 bug。
5.4 边界格子数字算错或者直接崩溃
现象:最上面一行、最左一列的格子,周围雷数比实际少;偶尔在棋盘边缘点击时程序闪退。
原因:CountAround里对r-1越界没有判断,C++ 不检查数组下标,越界读到的是相邻行的内存,数值看着像雷数但其实是垃圾数据。点击闪退通常是HitTest对负数坐标做了整除,得到的负索引拿去访问数组。
解决:所有八方向遍历统一走InBoard(nr, nc)判断,不做任何「反正差一不会出事」的假设;HitTest里先判x < 0 || y < 0再整除。调试时打开「越界检查」不方便,可以在InBoard里加一句ASSERT,Debug 版一越界就断下来。
5.5 中文乱码与资源 ID 冲突
现象:状态栏上的「剩余雷数」显示成方块或乱码;新加的菜单项点了没反应。
原因:项目字符集是 Unicode,代码里却写了"新游戏"这样的窄字符串,编译能过但显示异常;菜单项和已有的资源共用了一个 ID 值,ON_COMMAND命中了另一个处理函数。
解决:所有字符串字面量用_T("...")包起来,AfxMessageBox、SetWindowText同理。加菜单项之后去resource.h看一眼新 ID 有没有跟已有值重叠,VS 偶尔会复用被删掉的 ID。改完 Clean 一次再 Rebuild,资源文件不参与增量编译,改动容易被忽略。
6. 二次开发改造:把固定难度做成可配置、可存档
原始工程的难度是写死的三个菜单分支,m_nRows = 9这类赋值散落在各处。想加「自定义难度」,不要把新数字塞进第四个else if,先抽一张配置表。
// 难度配置集中到一张表,菜单项只负责传下标 struct LevelCfg { const TCHAR* szName; int nRows, nCols, nMines; }; static const LevelCfg g_levels[] = { { _T("初级"), 9, 9, 10 }, { _T("中级"), 16, 16, 40 }, { _T("高级"), 16, 30, 99 }, }; BOOL CMinesweeperDlg::ApplyLevel(int nIndex) { if (nIndex < 0 || nIndex >= _countof(g_levels)) return FALSE; const LevelCfg& cfg = g_levels[nIndex]; // 校验:格子数必须能容下首点保护的 3×3 区域 if (cfg.nMines >= cfg.nRows * cfg.nCols - 9) return FALSE; if (cfg.nRows > MAX_ROWS || cfg.nCols > MAX_COLS) return FALSE; m_nRows = cfg.nRows; m_nCols = cfg.nCols; m_nMines = cfg.nMines; ResetGame(); ResizeWindowToBoard(); Invalidate(FALSE); return TRUE; }参数说明:nMines >= rows*cols - 9这个上界不是拍脑袋,首点保护要空出 3×3 共 9 格,雷数顶到上限时布雷函数会抽不满,棋盘上雷数少于设定值,胜负判定跟着出错。ResizeWindowToBoard要在ResetGame之后调,因为它依赖新的行列数。
存档功能比想象中简单:把m_board整个数组按二进制写出去,加一个文件头做校验。文件头里放魔数、版本号、行列数、雷数、已用时间、已翻开数,读档时先校验魔数再决定要不要信后面的数据。这样以后改了Cell结构体,旧存档会被版本号挡住,不会读出一堆乱七八糟的状态。排行榜则用「秒数 + 难度」两个字段排序,写在同一个文件末尾或者单独的 ini 里都行,注意写文件时先写临时文件再MoveFileEx替换,避免中途崩溃把记录写坏。
还有一个提升手感的小改造:给未翻开的格子加悬停高亮。ON_WM_MOUSEMOVE里算出当前行列,跟上一次比较,变了就局部刷新这两个格子的矩形区域。看起来不起眼,但点起来「有反馈」和「没反馈」完全是两个游戏。
最后说个我自己的习惯。每次改完布雷、展开、胜负判定这三块中的任何一块,我都会强制走一遍固定流程:新游戏 → 点四个角 → 点中心 → 快速连点右键插满旗 → 高级难度赢一局 → 关窗看进程有没有残留。这六步里任何一步出问题,都说明状态机有没复位干净的变量。早年我图省事跳过这套流程,结果交作业前一天发现「赢一局之后开新游戏,已翻开计数器没清零,一点就提示胜利」,熬到凌晨三点才定位到ResetGame里漏了一行m_nOpened = 0。这份工程里相关状态都集中在ResetGame一个函数里复位,就是为了避免那种后悔药没处买的情况。希望帮到你。
本文还有配套的精品资源,点击获取