ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MFC扫雷开发实战:从GDI自绘到算法避坑指南

MFC扫雷开发实战:从GDI自绘到算法避坑指南 简介基于MFC开发的扫雷游戏程序完整包含Windows窗口程序源码与可执行文件适合学习MFC框架、消息映射、控件交互及游戏逻辑设计的C初学者或Windows程序设计爱好者。资源共71个文件其中以bmp位图资源26个、h头文件8个、cpp源文件7个为主体辅以工程配置文件dsp/dsw、资源脚本rc/rc2及预编译头文件等压缩包仅2.7MB结构紧凑便于本地调试与研读。已有475人浏览学习。从源码中可清晰看到自定义对话框与按钮类如何实现雷区生成、点击探测、计时统计等核心功能bitmap位图素材覆盖不同数字与旗帜状态配套ReadMe与工程注释有助于快速理解MFC项目组织方式。对于想要通过一个完整小游戏项目掌握MFC可视化编程与经典扫雷算法的人来说这份资源提供了可直接运行、可修改扩展的实践范本。1. 用 MFC 从头写扫雷为什么这个老项目比你想的更值得做提到 MFC很多人的第一反应是过时、晦涩、找工作没人问。但如果你真去动手做过一个带界面、带逻辑、带状态管理的完整小项目会发现 MFC 扫雷几乎是性价比最高的练手对象GDI 自绘、消息映射、定时器、右键菜单、内存管理这些 MFC 的看家本领在扫雷里全都能用到而且每一样都有即时可见的反馈。网上能找到的 MFC 扫雷源码很多但大多要么是纯 API 写法、没有封装要么把逻辑和界面搅在一起、改个格子大小都费劲。这篇文章不基于任何一份现成源码而是顺着「MFC 扫雷」这个方向把从工程创建到地雷算法、从自绘原理到内存泄漏排查的完整路线拆给你新手能照着落地熟手也能在源码组织和参数设计上找到可改进的空间。2. MFC 扫雷的工程骨架先想清楚这几个类再动手2.1 为什么不直接从对话框拖控件而是要用自绘扫雷游戏在网上能找到的代码版本里最常见的错误做法是往对话框上放一堆 Button 控件一个格子一个 CButton游戏初始化时动态创建。这种写法在 9×9 的初级难度下勉强能跑但一到 16×16 甚至 30×16 的高级难度控件数量暴涨窗口句柄吃紧、初始化极慢、内存碎片严重而且按钮的默认外观一眼就能看出是「应付作业」的水平。标准的 MFC 扫雷做法是继承 CWnd 写一个自定义棋盘窗口重写 OnPaint 用 GDI 把格子、数字、地雷、旗帜全部画上去鼠标消息在窗口的 OnLButtonDown、OnRButtonDown 里判断坐标、换算格子索引。这样整张棋盘只需要一个窗口句柄绘制逻辑完全掌握在自己手里后续想换皮肤、加动画、做缩放都容易得多。工程结构上我一般会拆成四个文件层次CGameDoc纯数据层存地雷布局、格子状态、剩余雷数、游戏状态不依赖任何 Windows 类型方便单独测试CGameView从 CWnd 派生的棋盘窗口负责接收鼠标消息、调用 CGameDoc 更新状态、触发 Invalidate 重绘CMainFrame / CChildView框架窗口承载菜单、状态栏、计时器App 类与资源文件程序入口、菜单资源、图标资源把数据层和表现层分开的目的很实际调试递归展开算法时你可以写一个控制台测试程序直接调 CGameDoc不需要启动界面排查内存泄漏时数据层不持有 GDI 对象省掉一大半怀疑对象。2.2 最小可运行框架自定义 CWnd 子窗口的创建流程如果你不用 AppWizard 生成的 SDI 框架而是想从一个干净窗口开始最小流程是先注册窗口类再创建子窗口最后把消息映射挂上。以下代码是一个最简的棋盘窗口创建片段// GameView.h class CGameView : public CWnd { public: BOOL CreateGameView(CWnd* pParent, const CRect rc); protected: afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() }; // GameView.cpp BEGIN_MESSAGE_MAP(CGameView, CWnd) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() END_MESSAGE_MAP() BOOL CGameView::CreateGameView(CWnd* pParent, const CRect rc) { // 注册窗口类CS_HREDRAW 保证横向尺寸变化时触发重绘 CString strClassName _T(MineFieldView); WNDCLASS wc { 0 }; wc.lpfnWndProc AfxWndProc; wc.hInstance AfxGetInstanceHandle(); wc.hCursor ::LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName strClassName; AfxRegisterClass(wc); // 创建窗口注意第一个参数是类名而非标题 return Create(strClassName, _T(), WS_CHILD | WS_VISIBLE, rc, pParent, 0); }这段代码的关键点有三个。第一lpfnWndProc必须用AfxWndProc这样消息才能进入 MFC 的消息泵否则ON_WM_PAINT()永远不会触发。第二AfxRegisterClass需要在创建窗口之前调用而且同一个进程里重复注册同名类会失败所以最好在InitInstance里注册一次不要每次创建都注册。第三Create的第一个参数是窗口类名不是标题这一点和CWnd::Create的常规用法不一样写错的话窗口创建失败返回 FALSE但GetLastError往往看不到具体原因。窗口创建成功后棋盘逻辑就集中在OnPaint和鼠标消息处理里。OnPaint里不要直接调用 GDI 绘图函数要先构造CPaintDC dc(this)否则绘图区域和系统擦除的背景会打架出现闪烁或残影。2.3 CGameDoc 的数据结构设计一维数组比二维数组好用扫雷数据层的核心是一个std::vectorint一维数组长度为rows * cols每个元素用一个整数同时编码三种信息是否地雷、周围雷数、当前显示状态。用位运算来管理这些状态代码会清爽得多// GameDoc.h 核心定义 #define CELL_MINE (1 0) // 该格是地雷 #define CELL_OPENED (1 1) // 已被翻开 #define CELL_FLAGGED (1 2) // 被标旗 #define CELL_QUEST (1 3) // 被标问号 class CGameDoc { public: BOOL InitGame(int rows, int cols, int mineCount, int firstRow, int firstCol); int GetCellState(int row, int col) const; int GetNeighborMineCount(int row, int col) const; BOOL OpenCell(int row, int col, int openedCountOut); void ToggleFlag(int row, int col); BOOL IsWin() const; private: int m_rows 0, m_cols 0, m_mineCount 0; std::vectorint m_cells; std::vectorint m_mineMap; // 只存 0/1BFS 展开时快速判断 };为什么用一维数组因为扫雷里最频繁的操作是取邻居index row * cols col四周八个格子的坐标只要在row和col的边界范围内加减偏移量就能得到不需要vectorvectorint的两次间接取值。二维数组写法在直觉上更好懂但每次访问cells[x][y]要多一次指针解引用而且在memcpy、序列化、批量初始化的时候要写循环嵌套代码冗长但性能没有任何提升。m_mineMap单独用一个只含 0/1 的数组存地雷分布是为了让 BFS 展开时的判断极快if (mineMap[idx] 1) continue;避免在热路径上做位运算判断。位运算状态放在m_cells里只服务于界面和逻辑状态管理两者职责分开排查问题时思路也更清晰。3. 布雷、计数与递归展开扫雷里最容易写崩的三个算法3.1 第一次点击不炸延迟布雷的两种做法扫雷的规则里有一条反直觉的设计玩家第一次点击的格子绝不能是地雷而且如果这个格子周围有雷通常也要保证翻开一片。实现上有两种常见做法一种是在InitGame时就布好雷、但把第一个点击的格子及其邻居的雷强制移除另一种是初始化时不布雷等第一次点击之后再做布雷。前者实现简单但有缺陷——第一次点击后棋盘上的雷数会比设定值少破坏了「剩余雷数」这个计数器的准确性后者更符合真实游戏逻辑代码上也就多一个状态判断。我采用的是延迟布雷InitGame只清空数据把第一次点击坐标暂存布雷函数PlantMines在第一次点击后才被调用并排除点击点周围三乘三区域void CGameDoc::PlantMines(int safeRow, int safeCol) { // 将安全区及其 3x3 邻居标记为不可布雷 std::vectorint blocked(m_rows * m_cols, 0); for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { int r safeRow dr; int c safeCol dc; if (r 0 r m_rows c 0 c m_cols) blocked[r * m_cols c] 1; } } int planted 0; std::srand((unsigned)std::time(nullptr)); while (planted m_mineCount) { int idx std::rand() % (m_rows * m_cols); if (blocked[idx] || m_mineMap[idx]) continue; m_mineMap[idx] 1; planted; } }while循环在雷数接近格子总数时可能死循环所以InitGame里要限制mineCount最多不能超过(rows * cols - 9)的百分之八十否则脚本会卡死。安全区设为三乘三而不是单格是为了让第一次点击大概率展开一块区域体验上更接近 Windows 原版扫雷。std::rand的随机质量在这个场景下足够用不需要上random的mt19937但如果你要写锦标赛级别的扫雷 AI那就另说。3.2 数字计算的边界条件四个角、四条边、中间三种情况布雷完成后需要计算每个非雷格周围的雷数。这一步逻辑不复杂但边界条件最容易漏。常见的写法是在格子周围做一个双层循环用坐标范围和数组下标做双重判断void CGameDoc::CalculateNumbers() { int dirs[8][2] { {-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1} }; for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { int idx r * m_cols c; if (m_mineMap[idx]) continue; int count 0; for (int d 0; d 8; d) { int nr r dirs[d][0]; int nc c dirs[d][1]; if (nr 0 || nr m_rows || nc 0 || nc m_cols) continue; // 越界跳过 if (m_mineMap[nr * m_cols nc]) count; } m_cells[idx] | (count 4); // 雷数存高 4 位 } } }雷数存在高 4 位的操作是个人习惯位 0 到位 3 是状态标记位 4 到到位 7 存 0~8 的雷数这样单个 int 就能完整描述一个格子的所有属性将来做存档、网络同步、AI 推演都只需要一个数组。代价是读雷数时要写((m_cells[idx] 4) 0x0F)多一层移位运算。如果你觉得可读性优先也可以单独用一个short数组存数字两种设计都行但不要边写边改工程上最忌讳数据结构漂移。3.3 递归展开与栈溢出为什么我建议你用队列别用递归翻开一个周围雷数为 0 的格子时扫雷会连锁展开一片空白区域。教科书上最直观的写法是递归void Expand(int r, int c) { if (r 0 || r rows || c 0 || c cols) return; int idx r * cols c; if (cells[idx] CELL_OPENED) return; if (mineMap[idx]) return; cells[idx] | CELL_OPENED; if (GetNeighborMineCount(r, c) 0) { for (每个邻居) Expand(nr, nc); } }这段代码逻辑正确但在地雷稀疏、大面积空白连片的场景下递归深度可能达到几百层甚至上千层Debug 模式下栈溢出直接崩Release 模式下运气好不崩、但调用栈深到调试时完全没法看。而且递归版本里每个格子的CELL_OPENED状态要重复判断性能略差。我一般改用std::queueint做 BFSvoid CGameDoc::ExpandBlankArea(int startRow, int startCol) { std::queueint q; int startIdx startRow * m_cols startCol; if (m_mineMap[startIdx]) return; // 点在雷上不会触发展开由外部逻辑处理 q.push(startIdx); m_cells[startIdx] | CELL_OPENED; while (!q.empty()) { int idx q.front(); q.pop(); int r idx / m_cols; int c idx % m_cols; if (GetNeighborMineCount(r, c) 0) continue; // 数字格只翻开自身不向外扩展 int dirs[8][2] { {-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1} }; for (int d 0; d 8; d) { int nr r dirs[d][0]; int nc c dirs[d][1]; if (nr 0 || nr m_rows || nc 0 || nc m_cols) continue; int nIdx nr * m_cols nc; if (m_mineMap[nIdx]) continue; // 不展开地雷 if (m_cells[nIdx] CELL_OPENED) continue; // 不重复翻开 if (m_cells[nIdx] CELL_FLAGGED) continue; // 玩家标了旗尊重玩家判断 m_cells[nIdx] | CELL_OPENED; q.push(nIdx); } } }BFS 版本的优点是循环深度受队列长度控制不存在调用栈压力缺点是每个格子要额外执行一次GetNeighborMineCount判断不过这个函数本身就是(idx 4) 0x0F的 O(1) 操作代价极小。还有一个容易被忽略的细节BFS 展开时跳过CELL_FLAGGED的格子。原版扫雷里旗帜标记代表玩家确认这里有雷展开算法不应该覆盖玩家的显式判断。3.4 胜负判断一个计数器的复用思路胜局判断有两种方案每次翻开格子后遍历整个棋盘检查所有非雷格是否全部翻开或者维护一个m_openedCount计数器每翻开一个非雷格就加一等于rows * cols - mineCount时判定胜利。后者是 O(1) 判断代码上多一个成员变量但你几乎总是会在别的地方需要这个计数器——比如状态栏上显示「已展开 X / 共 Y 格」。没必要额外遍历直接用计数器就好。败局判断则是在鼠标点击逻辑里如果点击的格子m_mineMap[idx] 1且不是旗帜状态就触发爆炸流程把棋盘上所有雷的位置标出来游戏状态置为GAME_OVER并弹出一个MessageBox。注意爆炸后不要直接把窗口关掉或重置数据应保留现场让玩家看到自己踩到了哪颗雷这是扫雷复盘的基本体验。4. 从 CDialog 到自绘棋盘界面渲染与鼠标交互的落地细节4.1 格子尺寸、棋盘尺寸与窗口尺寸的换算关系自绘棋盘的第一步是把窗口尺寸和格子逻辑坐标对应起来。初级 9×9、雷数 10中级 16×16、雷数 40高级 30×16、雷数 99这是 Windows 扫雷经典参数MFC 扫雷一般直接照搬。格子的像素尺寸我习惯设为 24×24这个尺寸在 96 DPI 下显示数字和旗帜图标恰好清晰太大则高级难度棋盘窗口超过屏幕可用高度太小则鼠标点击精度下降。窗口尺寸的计算公式是int border 8; // 棋盘边框留白 int headerH 48; // 顶部预留计数器/笑脸区域可选 int winW border * 2 cols * m_cellSize; int winH border * 2 headerH rows * m_cellSize;注意不要让窗口客户区和棋盘尺寸完全相等否则窗口边框和菜单栏会把边缘格子裁掉。如果你用CreateGameView创建子窗口父窗口的OnSize或OnInitialUpdate里设置棋盘窗口大小即可。换算逻辑是客户区像素坐标point除以格子尺寸向下取整得到格子行列索引点击的判定区域是格子的像素矩形而不是鼠标的精确落点这样误触边界时体验更宽容。4.2 用 GDI 绘制格子三个避免闪烁和锯齿的实战技巧OnPaint里绘制棋盘时新手最容易犯的错是每次重绘都把所有格子重新画一遍。由于Invalidate默认触发整个客户区重绘格子多的时候画面会明显闪烁。三个实用技巧按优先级排列第一在OnPaint里用CPaintDC dc(this)配合CDC::FillRect绘底色GDI 的矩形填充是硬件加速的比画边框线条快一个量级。第二只在格子状态变化时更新局部区域调用InvalidateRect(rcCell, FALSE)而不是Invalidate()这样重绘范围缩小到单个格子。第三用内存双缓冲创建一个CDC memDC和CBitmap在内存中绘制整张棋盘再BitBlt一次拷贝到屏幕适合整盘重绘的场景比如胜利时全部翻开。双缓冲的代价是每帧多一次全图拷贝但换来的是完全没有闪烁。绘制格子状态的代码大致如下void CGameView::DrawCell(CDC* pDC, int row, int col) { CRect rc(col * m_cellSize, row * m_cellSize, (col 1) * m_cellSize, (row 1) * m_cellSize); int idx row * m_cols col; int state m_doc.GetCellState(row, col); if (state CELL_OPENED) { // 已翻开灰色底有雷绘制黑色地雷有数字绘制数字 pDC-FillSolidRect(rc, RGB(220, 220, 220)); int num (state 4) 0x0F; if (num 0) { CString str; str.Format(_T(%d), num); pDC-SetTextColor(g_numColors[num]); // 经典扫雷的 8 种数字颜色 pDC-DrawText(str, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } // 绘制地雷 if (state CELL_MINE) DrawMine(pDC, rc); } else { // 未翻开凸起效果上方和左方亮线下方和右方暗线 pDC-FillSolidRect(rc, RGB(190, 190, 190)); DrawRaisedBorder(pDC, rc); if (state CELL_FLAGGED) DrawFlag(pDC, rc); else if (state CELL_QUEST) DrawQuestionMark(pDC, rc); } }数字颜色g_numColors是 Windows 扫雷的经典配色1 号蓝色、2 号绿色、3 号红色、4 号深蓝、5 号深红、6 号青色、7 号黑色、8 号灰色。这个细节能把「像扫雷」和「是扫雷」区分开。DrawRaisedBorder用亮暗两条线做 3D 边框亮线 RGB(255,255,255)、暗线 RGB(128,128,128)这是 Windows 经典控件凸起效果的 GDI 等效实现需要裸代码手写MFC 没有专门的接口。4.3 右键标旗、双击展开与消息映射的优先级问题鼠标交互是扫雷 MFC 代码里最容易出 bug 的地方。WM_LBUTTONDOWN负责翻开格子WM_RBUTTONDOWN负责切换旗标这两个消息各司其职。但你还需要处理一个重要交互双击一个数字格时如果周围旗帜数量等于数字则自动翻开周围未标记的格子。这个功能原版扫雷有很多 MFC 移植版却把它漏了。MFC 的消息映射里WM_LBUTTONDBLCLK是独立消息需要在消息映射里单独加ON_WM_LBUTTONDBLCLK()。双击的数字格此时已经被翻开所以处理逻辑是判断点击格是否已翻开如果是数一数它周围 8 格中CELL_FLAGGED的数量若等于该格的数字则对周围非旗帜、非地雷、未翻开的格子执行一次OpenCell。这个过程叫 chord 操作是进阶体验和初级体验的分水岭。鼠标消息映射的另一个坑是WM_LBUTTONDOWN和WM_LBUTTONDBLCLK同时存在时Windows 会先发送WM_LBUTTONDOWN再发送WM_LBUTTONDBLCLK第二次点击时。如果你的OnLButtonDown里有游戏结束或重置逻辑双击会造成一次操作触发两次翻格。处理方式是在OnLButtonDown里加(nFlags MK_LBUTTON)的过滤或者干脆不在OnLButtonDown里做破坏性操作只在OnLButtonUp里执行翻开动作。5. 避坑清单MFC 扫雷开发中的 4 个高频雷区5.1 数字格点击后闪成白板现象点击一个数字格整个棋盘的已翻开格子全部重新绘制画面短暂闪烁后正常但视觉体验很差。原因未使用区域无效矩形。Invalidate()直接让整块客户区重绘OnPaint遍历所有格子即使状态没变也重新填充和描边大量DrawText调用挤在同一帧里GDI 处理不过来就出现闪烁。解决只在格子状态真正变化的代码路径后调用InvalidateRect(rcCell, FALSE)或者在OnPaint里先判断该格状态是否自上次绘制后发生了变化。我通常会在CGameDoc里加一个m_dirtyCell成员OpenCell和ToggleFlag返回发生变化的格子列表CGameView只对列表里的格子做重绘。这个方案兼顾了性能和闪烁问题。5.2 右键先标旗再左键点开把地雷炸了现象玩家对某个格子单击右键标了红旗随后单击左键游戏直接判负——选手明明已经识别出雷却被自己的操作炸死。原因OnLButtonDown里没有检查CELL_FLAGGED状态。右键标旗只是改了视觉状态翻格逻辑没有感知。解决在OpenCell函数开头加一个判断if (m_cells[idx] CELL_FLAGGED) return;然后才能继续执行地雷判定。这个 guard 有一个连带作用当玩家对旗帜格再次执行 chord 双击时也不会误翻旗帜逻辑自洽。5.3 棋盘尺寸固定窗口拉伸后格子错位现象用户拖动窗口边框放大窗口棋盘区域出现黑边或格子之间出现空白条纹点击位置与实际格子不匹配。原因OnPaint绘制格子时使用的是固定的m_cellSize尺寸但窗口客户区尺寸在OnSize里没有做等比缩放或居中处理。解决在OnSize里根据当前客户区尺寸重新计算格子像素大小固定不变的是格子逻辑行列数变了的是像素密度。代码上需要void CGameView::OnSize(UINT nType, int cx, int cy) { CWnd::OnSize(nType, cx, cy); if (m_cols 0 || m_rows 0) return; int newCell min(cx / m_cols, cy / m_rows); if (newCell 12) newCell 12; // 最小尺寸保护 m_cellSize newCell; // 不需要 InvalidateOnSize 之后系统会自动触发 WM_PAINT }如果不想做等比缩放另一个选择是固定窗口大小、禁止缩放在PreCreateWindow里设置样式去除WS_THICKFRAME并在WM_GETMINMAXINFO里固定最小尺寸。两条路都行但你要选一条写死不要在两种方案之间摇摆否则窗口在不同 DPI 环境下会越调越乱。5.4 内存泄漏每局游戏结束时 GDI 对象计数持续上涨现象反复开始新游戏后任务管理器中 GDI 对象数量持续上涨不被释放最终绘制异常或程序崩溃。原因最常见的三个来源CreateFont创建了字体对象但DeleteObject释放不及时CreateSolidBrush画刷在DrawCell里频繁创建、没有统一缓存OnPaint里创建CBitmap双缓冲对象但没DeleteObject。GDI 对象和普通堆内存不同超出进程上限默认 10000 个后所有绘图操作静默失败界面会瞬间冻结很难排查。解决把常驻的字体、画刷、位图放到 CGameView 的成员变量里在OnCreate里创建OnDestroy里统一释放。临时使用的CBitmap在OnPaint里构造、函数结束时DeleteObject。下面是个检查清单// OnDestroy 里的释放顺序 void CGameView::OnDestroy() { if (m_hFont) ::DeleteObject(m_hFont); if (m_hBrushBg) ::DeleteObject(m_hBrushBg); if (m_hBitmapMem) ::DeleteObject(m_hBitmapMem); CWnd::OnDestroy(); }另外一个和 GDI 无关但同样隐蔽的泄漏点是CString::Format配合%s时用了char*而非wchar_t*在 Unicode 工程下会悄悄产生临时转换对象循环里大量调用也不可忽视。调试时用_CrtDumpMemoryLeaks()在InitInstance退出处检查并结合任务管理器观察 GDI 计数两个维度一起验证。6. 把文档剥离出来逻辑层独立后的数据验证与存档扩展6.1 用一次性初始化接口验证算法正确性CGameDoc 分离出界面层后算法验证变得非常方便。我习惯在工程里附带一个DocTest.cpp通过命令行参数直接跑数据层的冒烟测试不需要启动完整界面// DocTest.cpp 片段 int TestExpand() { CGameDoc doc; // 固定随机种子保证每次测试的棋盘一致 std::srand(42); // 5x5 棋盘3 颗雷安全区中心点 if (!doc.InitGame(5, 5, 3, 2, 2)) return 1; doc.PlantMinesFromCenter(2, 2); doc.CalculateNumbers(); doc.OpenCell(0, 0); // 从角上触发展开 // 校验所有格子的复合状态 for (int r 0; r 5; r) for (int c 0; c 5; c) { int st doc.GetCellState(r, c); bool isOpen (st CELL_OPENED) ! 0; bool isMine (st CELL_MINE) ! 0; // 翻开格不应为雷未翻格不要求非雷可能有雷也可能没雷 if (isOpen isMine) { TRACE(_T(格(%d,%d) 被翻开但为雷\n), r, c); return 2; } } return 0; }这一步的价值在于当你改动 CGameDoc 内部实现时只要跑一次DocTest.exe算法正确性就有回归保障。MFC 的界面代码很难做自动化测试但数据层的纯逻辑可以做到接近百分之百的覆盖率。这也是我对「源码」类项目的一个核心观点一个工程里最值得重视的代码不是那些漂亮的界面绘制而是能独立运行、接受输入、断言输出的逻辑核心。6.2 序列化与存档把扫雷做成可保存进度的游戏如果你想继续扩展下一步自然的需求是存档。MFC 里做序列化最原生的是CArchive它配合CFile能够把 CGameDoc 的成员变量写进二进制文件读档时反序列化恢复。这个功能在扫雷里看起来加个菜单项就行但实际编码时有两个参数必须小心m_mineCount和m_openedCount一个代表剩余雷数、一个代表已翻开格数它们决定了游戏进度恢复后是否能继续计算胜负。序列化代码大致是void CGameDoc::Serialize(CArchive ar) { if (ar.IsStoring()) { ar m_rows m_cols m_mineCount; ar.Write(m_cells[0], (UINT)(m_cells.size() * sizeof(int))); ar.Write(m_mineMap[0], (UINT)(m_mineMap.size() * sizeof(int))); } else { ar m_rows m_cols m_mineCount; m_cells.resize(m_rows * m_cols); m_mineMap.resize(m_rows * m_cols); ar.Read(m_cells[0], (UINT)(m_cells.size() * sizeof(int))); ar.Read(m_mineMap[0], (UINT)(m_mineMap.size() * sizeof(int))); } }注意CArchive的和是 MFC 自定义的运算符重载不是标准流不支持直接传std::vector所以要先把数组的data()指针和字节数传给Write/Read。另外文件头建议写一个DWORD magic 0x4D494E45MINE 的 ASCII和int version 1将来升级格式时可以向后兼容。没有 version 字段的存档等你想加新功能时就后悔当初没写。6.3 从自定义棋盘到新玩法参数化是源码扩展的正确姿势扫雷源码之所以值得拥有是因为它天然是参数化的行列数、雷数、格子像素尺寸、数字颜色表、双击展开行为都可以作为InitGame的参数暴露出来。改动两个数字9×9 初级就变成 30×16 高级把m_cellSize从 24 改成 48立刻得到一块适合触屏的棋盘把数字颜色表g_numColors换成渐变配色皮肤就换了一套。更进一步数据层 CGameDoc 可以在不动界面的前提下扩展为「六边形扫雷」——只需要把邻居方向从 8 个改为 6 个、坐标换算从方形映射改成六边形偏移映射OpenCell的 BFS 逻辑完全不需要动CalculateNumbers只需要改方向数组CGameView只需要改DrawCell的填充形状。你会深刻感受到把逻辑层画一条清晰边界线扩展功能时有多舒服。我自己的习惯是每次拿到一个新的 MFC 小游戏源码第一件事不是看界面绘制代码而是找数据层那一层把它和界面层的关系画成一张依赖图。扫雷是这样俄罗斯方块、贪吃蛇也是这样。数据层稳了界面随便重画都不慌这是做了一堆 MFC 小项目之后最想提醒后来者的一点。希望这篇梳理能帮你在自己的扫雷源码上少踩几个坑把真正的重头戏放在算法和数据结构上。本文还有配套的精品资源点击获取
返回列表