ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于MFC实现扫雷:掌握Windows消息机制与GDI绘制的经典练手项目

基于MFC实现扫雷:掌握Windows消息机制与GDI绘制的经典练手项目 简介这是基于MFC开发的经典扫雷游戏源码工程面向学习Windows桌面应用、MFC框架与简易游戏逻辑的开发者。压缩包共45个文件约2.85MB其中以17个bmp位图素材、6个cpp源码、7个头文件为主体并包含ico图标、rc资源脚本、README说明、可执行exe及完整VC工程文件解压后可直接打开编译也方便对照代码理解菜单、对话框、文档视图结构。目前已有315人学习下载。代码本身不复杂但界面直观、操作清晰核心覆盖鼠标点击控制、雷区生成、布雷判断与扫雷交互同时保留资源文件与工程配置适合逐模块拆解MFC程序的消息映射、资源管理和GDI绘图过程。对想快速上手MFC游戏开发或完成课程设计的人来说是一套轻量完整、可直接运行的参考样例。1. 基于MFC实现的扫雷不只是大作业更是Windows消息机制最好的入门练手“基于MFC实现的扫雷游戏”听起来像C课程设计里压轴的题目但真把它从零写完你会发现自己把Windows程序最核心的几件事全碰了一遍消息映射、客户区坐标、GDI重绘、定时器、状态栏联动。先说一个反直觉的结论不要用对话框模板做扫雷对话框把消息分发和绘图细节藏得太深做完只能得到一个“能玩”的黑匣子用单文档MFC工程把棋盘画在视图类里你才能看懂MFC到底怎么把Windows消息变成成员函数调用的。这篇文章按“工程搭建→雷区逻辑→绘制与交互→排错→进阶优化”的顺序给出一份可直接复现的实现路径适合刚学MFC的同学、准备课程设计的人以及想借着这个小项目把C应用层机制理顺的开发者。2. 搭建MFC工程与消息映射先让窗口能响应鼠标、绘制和计时扫雷的所有交互都建立在一个前提上窗口能收到鼠标消息、能定时收到时钟消息、能在需要时主动重绘。这三件事在MFC里全部由消息映射表驱动。这一章先把工程骨架和消息链路搭起来后面写逻辑才不会返工。2.1 为什么选单文档而不是对话框MFC扫雷的框架选型常见的做法是用Visual Studio的“MFC应用程序”向导生成一个单文档工程然后说“扫雷没有文本要编辑文档类可以简化”。但我个人建议保留文档/视图结构的视图类来做全部绘制因为CView自带OnPaint入口天然适合持续重绘的棋盘场景。对话框工程的问题在于CDialog的重绘消息和控件重叠会干扰你对坐标的理解新手很容易分不清“窗口客户区”和“控件矩形”。而单文档工程里工具栏、状态栏、客户区的层次很清晰状态栏归CMainFrame管棋盘归视图类管。这样后面把雷数显示到状态栏、在弹出的对话框里设置难度改动点都非常明确。另外还有一个选型点项目向导里“使用文档/视图架构”可以保留但文档序列化、打印这些功能对扫雷没意义可以取消。工程字符集选“使用Unicode库”否则后面CString和DrawText的字符串处理会有一堆兼容性麻烦。2.2 创建工程的向导设置打开Visual Studio新建项目搜索“MFC Application”填好项目名后进入向导。关键配置如下向导项推荐选择理由应用程序类型单个文档视图类承担绘制和交互结构清晰项目样式MFC标准不用Visual Studio样式减少无关代码使用Unicode库是文本绘制和CString统一宽字符生成的类CMyMineSweeperView所有游戏逻辑写在视图类里文档/视图架构保留但不用文档避免改框架代码生成工程后在解决方案资源管理器里找到视图类通常是CMyMineSweeperView。后续的棋盘数据、绘制、鼠标响应都写在这个类里菜单和状态栏相关代码写在CMainFrame里。这样划分之后扫雷的核心代码不会超过三个文件View的头文件、View的CPP文件、MainFrame的状态栏更新函数。一个容易犯的错是频繁改动CMyMineSweeperApp::InitInstance里的框架创建逻辑。这里默认就够了不要为了“精简”去手动创建窗口MFC的文档类结构一旦破坏消息路由会到处出错。2.3 消息映射表让Windows消息找到视图类MFC不使用Win32里那种巨型switch-case窗口过程而是用静态消息映射表把Windows消息分发给类的成员函数。扫雷需要的几个映射如下// MyMineSweeperView.cpp 里的消息映射表 BEGIN_MESSAGE_MAP(CMyMineSweeperView, CView) ON_WM_PAINT() // 窗口需要重绘时调用 OnPaint ON_WM_LBUTTONDOWN() // 鼠标左键按下时调用 OnLButtonDown ON_WM_RBUTTONDOWN() // 鼠标右键按下时调用 OnRButtonDown ON_WM_TIMER() // 计时器到点时调用 OnTimer ON_WM_ERASEBKGND() // 背景擦除消息返回 TRUE 可以避免闪烁 END_MESSAGE_MAP()对应在头文件里声明成员函数// MyMineSweeperView.h 中的公开成员函数声明 public: afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnRButtonDown(UINT nFlags, CPoint point); afx_msg void OnTimer(UINT_PTR nIDEvent); afx_msg BOOL OnEraseBkgnd(CDC* pDC);这里的afx_msg是空宏只用于标记消息处理函数让阅读者一眼看出它与普通成员函数的区别。重点在于映射表和函数声明必须成对出现。少写了ON_WM_TIMER()定时器消息就不会进你的OnTimer这种情况编译器不报错表现是“代码明明写了却一直不触发”非常折磨人。理解这条链路后扫雷的交互模型就清楚了鼠标按下的坐标从OnLButtonDown的CPoint参数里拿定时刷新从OnTimer触发重绘统一走OnPaint。后面的逻辑代码只需要往这几个函数里填东西。2.4 初始化时机OnInitialUpdate与成员变量棋盘数据不能放在构造函数里因为视图构造时窗口还没创建完成拿不到客户区尺寸。常见做法是在OnInitialUpdate里做初始化void CMyMineSweeperView::OnInitialUpdate() { CView::OnInitialUpdate(); m_rows 9; // 初级难度9 行 m_cols 9; // 9 列 m_mines 10; // 10 颗雷 m_cellSize 32; // 每个格子 32 像素 m_bFirstClick false; // 第一次点击前不布雷 m_gameOver false; m_minesLeft m_mines; m_elapsedSeconds 0; // 棋盘数据用动态数组后面按实际行列申请空间 }注意OnInitialUpdate可能被多次调用所以初始化尽量做成“重置”而不是“一次性设置”。如果后面加难度选择菜单只需要改m_rows、m_cols、m_mines后重新调用这个函数即可。格子大小m_cellSize决定棋盘占多少像素区域窗口尺寸能不能装下就是另一个经典的坑后面第四章专门讲坐标换算时会再提。3. 扫雷逻辑拆解落雷、数字计算与空白自动展开把扫雷的玩法翻译成代码核心是三件事用二维数组存棋盘布雷点开空白格子时自动展开。这章把三个部分拆开讲并给出每个部分的边界条件和防坑写法。3.1 用动态二维数组存棋盘三个数组各管一件事棋盘原始数据用一个int二维数组地雷记为-1其余格子存周围地雷数量0到8是否翻开用bool数组是否插红旗再用一个bool数组。三个数组分开管理代码会清爽很多。// MyMineSweeperView.h 中的成员变量 std::vectorstd::vectorint m_board; // -1 表示地雷0~8 表示数字 std::vectorstd::vectorbool m_revealed; // true 表示已翻开 std::vectorstd::vectorbool m_flagged; // true 表示插了红旗为什么不把“翻开”和“插旗”塞进int的一位里因为MFC的调试器和内存检查工具对bool数组更友好而且判断逻辑写起来直白。更关键的是扫雷的右键插旗和左键翻开是互斥操作两个状态分开后判断点击合法性只需要两行if (m_revealed[r][c]) return; // 已翻开的格子不能再左键点 if (m_flagged[r][c]) return; // 插旗的格子不能左键翻开初始化时使用assign一次性分配空间并清零比循环push_back更高效且不易出错void CMyMineSweeperView::ResetBoard() { m_board.assign(m_rows, std::vectorint(m_cols, 0)); m_revealed.assign(m_rows, std::vectorbool(m_cols, false)); m_flagged.assign(m_rows, std::vectorbool(m_cols, false)); }这里有个C细节vectorvectorint的连续内存不一定是整体的但扫雷棋盘最多几百个格子性能完全不敏感不需要优化成int* m_board这种扁平数组。追求代码可读性就是在这个规模下最正确的取舍。3.2 布雷算法随机种子与首次点击保护布雷逻辑放在InitBoard函数里firstR和firstC是第一次点击的行列坐标。扫雷有一个体验上的硬性规则第一次点击绝不能踩雷。所以布雷时必须把点击点周围九宫格排除在外。void CMyMineSweeperView::InitBoard(int firstR, int firstC) { ResetBoard(); if (m_mines m_rows * m_cols - 9) // 雷太多会挤爆安全区 m_mines m_rows * m_cols - 9; int placed 0; while (placed m_mines) { int r rand() % m_rows; int c rand() % m_cols; if (m_board[r][c] -1) // 已布过雷 continue; if (abs(r - firstR) 1 abs(c - firstC) 1) continue; // 第一次点击周围不放雷 m_board[r][c] -1; placed; } for (int r 0; r m_rows; r) for (int c 0; c m_cols; c) if (m_board[r][c] ! -1) m_board[r][c] CountAdjacentMines(r, c); }逻辑说明while循环不断随机生成行列遇到已经布雷的格子就continue重试直到布够m_mines颗。雷很多时比如50x50棋盘放500颗雷这个循环依然很快因为每次随机命中空地的概率不低。但如果雷数逼近格子总数循环可能退化成无限等待所以加了if (m_mines ... - 9)的保护。关于随机种子直接rand()不设置的话每次程序启动的雷位都一模一样这个问题第五章会专门讲。一个更稳的做法是用C11的随机数引擎#include random std::mt19937 gen(std::random_device{}()); int r gen() % m_rows;std::mt19937的分布比rand() % m_rows均匀得多。有一个细节值得注意rand() % n在n很大的时候低位随机性并不好但在9x9、16x16棋盘上影响几乎不可见。所以初级扫雷用rand()也够不必过度设计。3.3 邻域数字计算与边界检查非雷格子的数字是它周围八格中地雷的数量。这个函数会被InitBoard在布雷后调用也可能会在以后扩展功能时用到所以独立成一个方法int CMyMineSweeperView::CountAdjacentMines(int r, int c) { int count 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; int nc c dc; if (nr 0 || nr m_rows || nc 0 || nc m_cols) continue; // 越界跳过 if (m_board[nr][nc] -1) count; } } return count; }这段代码里最值得讲的是边界检查。很多新手会把越界判断写在m_board[nr][nc]访问之后这在C里是未定义行为轻则读到错误数据重则崩溃。正确的顺序永远是先检查nr和nc是否在数组范围内再去访问m_board。一组if判断四个方向的组合非常高效初学时就该写对这种“防御性顺序”。如果你决定用固定数组而非vector比如int m_board[64][64]那边界检查就更加重要因为数组越界不会像std::vector的调试模式那样主动报错而是悄悄破坏相邻内存最终导致雷数和格子显示错乱。自己排查这种问题会非常痛苦。3.4 空白展开的实现递归洪泛与防重入扫雷里最爽的一刻就是点开大片空白。它的原理很简单如果一个格子上的数字是0说明周围没雷那就把它周围八个格子也翻开如果周围又有格子是0继续递归展开直到碰见非0数字或边界。void CMyMineSweeperView::RevealCell(int r, int c) { if (r 0 || r m_rows || c 0 || c m_cols) return; // 边界 if (m_revealed[r][c] || m_flagged[r][c]) return; // 防重入 m_revealed[r][c] true; if (m_board[r][c] ! 0) return; // 数字格子停下不再展开 for (int dr -1; dr 1; dr) for (int dc -1; dc 1; dc) if (dr ! 0 || dc ! 0) RevealCell(r dr, c dc); }这里有一个练手时必踩的坑递归调用前必须先置m_revealed[r][c] true。如果先递归再置标记相邻两个空白格子会互相调用对方形成无限递归最后栈溢出。把标记放在递归之前等于给每个格子发了一张“我已处理过”的免死金牌。展开完成后需要刷新视图。一个粗糙的做法是调用Invalidate()让整个窗口重绘。但对于大棋盘来说整窗重绘会带来明显的闪动。更好的做法是用InvalidateRect只刷新被展开的矩形区域CRect rc(c * m_cellSize, r * m_cellSize, (c 1) * m_cellSize, (r 1) * m_cellSize); InvalidateRect(rc, FALSE);注意InvalidateRect的刷新区域是逐格计算的展开大片空白时最好用一个RECT累加所有被更新的格子或者直接对整块展开区域求最小包围矩形。实际体验下来9x9棋盘整窗重绘也不会慢但提前养成局部刷新的习惯后面做16x30的高级难度时会少折腾一次。4. GDI绘制与鼠标交互让扫雷格子“看得见、点得动”数据逻辑和界面绘制在MFC里是两套业务。棋盘数据是抽象的二维数组显示则靠GDI在客户区画矩形和文字。这一章解决两件事格子坐标怎么换算绘制内容怎么组织。4.1 格子大小与客户区坐标换算Windows窗口的坐标原点在客户区左上角x向右增长y向下增长。MFC的鼠标消息CPoint point已经是客户区坐标不需要再做ScreenToClient转换。把像素坐标换算成行列的方法是整除格子大小int c point.x / m_cellSize; int r point.y / m_cellSize; if (r 0 || r m_rows || c 0 || c m_cols) return; // 点击在棋盘外忽略这段代码看似简单却是最容易翻车的位置之一。新手可能手动调用ScreenToClient再做一次转换结果坐标被减了两次造成“点哪个格子都偏”。我的习惯是凡是在OnLButtonDown、OnRButtonDown里直接用参数里的point从不取GetCursorPos再转换。高级别扫雷棋盘较大格子像素值可以做成成员变量方便调整。初级32像素、中级28像素、高级24像素一屏大小适中。如果格子尺寸太小比如小于20像素误触概率会显著上升不建议把界面做太“精致”。4.2 OnPaint绘制流程先画边框再画数字绘制集中在OnPaintvoid CMyMineSweeperView::OnPaint() { CPaintDC dc(this); for (int r 0; r m_rows; r) { for (int c 0; c m_cols; c) { CRect rc(c * m_cellSize, r * m_cellSize, (c 1) * m_cellSize, (r 1) * m_cellSize); DrawCell(dc, rc, r, c); } } }每个格子的绘制逻辑单独放在一个函数里便于根据状态扩展void CMyMineSweeperView::DrawCell(CDC dc, CRect rc, int r, int c) { if (!m_revealed[r][c]) { // 未翻开浅灰底插旗时画红旗 dc.FillSolidRect(rc, RGB(200, 200, 200)); if (m_flagged[r][c]) { dc.SetTextColor(RGB(255, 0, 0)); dc.DrawText(_T(F), rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } else if (m_board[r][c] -1) { // 踩雷或游戏结束显示地雷 dc.FillSolidRect(rc, RGB(255, 120, 120)); dc.SetTextColor(RGB(0, 0, 0)); dc.DrawText(_T(*), rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else if (m_board[r][c] 0) { dc.FillSolidRect(rc, RGB(220, 220, 220)); dc.SetTextColor(GetDigitColor(m_board[r][c])); CString str; str.Format(_T(%d), m_board[r][c]); dc.DrawText(str, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else { // 数字0空白格只填底色 dc.FillSolidRect(rc, RGB(220, 220, 220)); } }GetDigitColor是扫雷经典配色的实现1蓝、2绿、3红、4深蓝、5棕、6青、7黑、8灰。数字颜色不是必须的但加上了会明显提升游戏质感。一个容易被忽视的细节是DrawText需要DT_SINGLELINE否则很窄的格子里文本可能换行变成两行。如果想显示小旗子、地雷符号还可以用dc.TextOut搭配字体设置不过字符方案实现最快。上面所有FillSolidRect每次重绘都刷整块区域性能没问题。真正影响画面流畅度的不是绘制次数而是擦除背景的时机这在下一节讲。4.3 鼠标消息处理左键翻开、右键插旗OnLButtonDown负责展开格子它还需要处理“第一次点击才布雷”的逻辑void CMyMineSweeperView::OnLButtonDown(UINT nFlags, CPoint point) { if (m_gameOver) return; int c point.x / m_cellSize; int r point.y / m_cellSize; if (r 0 || r m_rows || c 0 || c m_cols) return; if (m_flagged[r][c]) return; // 插旗的格子不能左键点开 if (!m_bFirstClick) { InitBoard(r, c); // 布雷区避开点击点 m_bFirstClick true; SetTimer(1, 1000, NULL); // 开局计时 } if (m_board[r][c] -1) { GameOver(); // 踩雷 return; } RevealCell(r, c); // 展开格子 CheckWin(); // 判断是否胜利 Invalidate(FALSE); // 刷新画面FALSE 表示不擦除背景 }右键消息OnRButtonDown实现插旗和取消插旗void CMyMineSweeperView::OnRButtonDown(UINT nFlags, CPoint point) { if (m_gameOver) return; int c point.x / m_cellSize; int r point.y / m_cellSize; if (r 0 || r m_rows || c 0 || c m_cols) return; if (m_revealed[r][c]) return; m_flagged[r][c] !m_flagged[r][c]; m_minesLeft m_flagged[r][c] ? -1 : 1; UpdateStatusBar(); // 状态栏显示剩余雷数 Invalidate(FALSE); }这里nFlags参数可以判断是否按下了Ctrl或Shift用来实现“双击排雷加展开周围”的高级玩法但初级版本可以先不处理。4.4 局部重绘与刷新策略为什么不用整窗InvalidateMFC里Invalidate()会让整个客户区进入重绘状态配合默认的背景擦除逻辑会产生严重的闪烁。因为擦除背景时系统拿旧背景色覆盖窗口之后才画新格子一擦一画之间人眼就看到了白闪。而Invalidate(FALSE)只把客户区标记为“需要重绘但不要擦除背景”跳过了擦除这一步画面回稳定很多。// 刷新策略对比 Invalidate(); // 整窗重绘 擦背景闪 Invalidate(FALSE); // 整窗重绘不擦背景轻微闪 InvalidateRect(rc, FALSE); // 只刷新局部最快我的建议是先全部用Invalidate(FALSE)把功能跑通稳定之后再考虑局部刷新优化。因为局部刷新需要维护脏矩形集合逻辑复杂度会上升。对初级9x9棋盘来说Invalidate(FALSE)已经足够流畅没必要一上来就做双缓冲。5. MFC扫雷常见翻车点五条踩坑记录与修复方法这章把最容易让项目卡住的五个问题集中写一遍。所有表面上的“玄学”问题背后都有明确的内存或消息机制原因按“现象→原因→解决”排查即可。5.1 画面闪烁和残影现象鼠标点开空白格时整个窗口明显闪白快速翻格子时画面出现残影。原因Invalidate()触发整窗重绘同时MFC默认的OnEraseBkgnd会用底色填充整个客户区。擦除和绘制之间有时间差显示器刷新率跟不上时就出现了闪烁。残影则是重绘区域没有覆盖所有窗口几何导致。解决把所有Invalidate()改成Invalidate(FALSE)并响应WM_ERASEBKGND消息直接返回TRUEBOOL CMyMineSweeperView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 禁止擦除背景交给OnPaint整体重绘 }如果做完这两步仍闪说明绘制内容耗时高需要上双缓冲这部分在最后一章给方案。5.2 点击坐标错位现象明明点了第2行的格子程序却翻开第4行棋盘右下角的格子怎么都点不到。原因大多数情况是对CPoint point多做了坐标转换。OnLButtonDown传入的坐标已经基于客户区原点如果写成CPoint pt point; ScreenToClient(pt); // 错point本来就是客户区坐标就会变成“客户区坐标再减一次窗口位置”偏移量等于窗口边框和标题栏的尺寸。棋盘右下角的格子算出来的行列超出边界自然响应不了。解决直接用传入参数point做整除不要调用ScreenToClient。如果确实需要通过GetCursorPos获取鼠标位置才需要ScreenToClient两者混用就会翻车。最好的防御办法是封一个函数统一处理坐标换算断点检查行列值是否落在预期范围。5.3 每次开局雷的位置一样现象重启程序后第一次点击雷的位置和上一局完全一样。原因使用了rand()但没有设置随机种子。C库的rand()默认种子是1生成的序列在每次程序启动时都相同。解决在InitBoard里先设置种子#include ctime srand((unsigned int)time(NULL));更推荐直接替换随机源用MFC生态里也常配合的std::mt19937。注意不要每次循环都调用srand那样反而会得到重复序列。只在程序启动或新开局时播种一次。5.4 自动展开大片空白时程序崩溃现象点开一个空白格程序无响应或直接弹出栈溢出错误。原因递归RevealCell时如果先递归再设置m_revealed[r][c] true两个相邻的0格子会无限互相调用最终耗尽栈空间。解决递归进入函数后第一件事就是检查并设置标记顺序必须是if (m_revealed[r][c]) return; m_revealed[r][c] true; // 先做标记再继续展开如果棋盘特别大且空白区域连成一片比如30x30棋盘中间全是0每次递归深度可能达到几百层默认栈空间足够但保险起见也可以在展开时限制递归深度或用队列做广度优先。初级工程里用递归是最清晰的做法没必要换迭代实现把标记顺序写对即可。5.5 窗口打开后棋盘一片灰什么都没有现象程序能运行但客户区只有默认底色完全没看到格子。原因最常见的是OnPaint没有被触发或绘制代码里使用了未正确分配大小的m_board。另一个高频原因是在视图类的构造函数里调用InitBoard此时窗口资源还没准备好绘制函数执行时数组为空导致异常被忽略。解决把所有初始化逻辑放到OnInitialUpdate中它是视图创建后第一个适合初始化的入口。确保m_board已经assign分配过再调用Invalidate()。如果仍空白在OnPaint第一行加TRACE输出确认窗口是否进入了重绘流程没进说明消息映射表里没有ON_WM_PAINT()检查头文件是否漏了afx_msg void OnPaint()声明。6. 进阶优化双缓冲、状态栏与自定义关卡扩展功能跑通后再往前迈半步把体验拉到一个可以长期玩的水准。这章给三个方向上最实用的技巧双缓冲解决最后一丁点闪烁状态栏让游戏信息一目了然自定义关卡把程序从定死难度里解放出来。6.1 双缓冲绘制彻底消除闪烁的后悔药如果做完“不擦背景”仍然闪那就不要直接在DC上逐格绘制而是先在内存里把整幅棋盘画好再一次拷贝到屏幕。这叫双缓冲MFC里实现非常短void CMyMineSweeperView::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 在memDC中绘制所有格子和普通绘制一模一样 DrawBoard(memDC); // 一次性拷回屏幕 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 恢复旧对象避免GDI泄漏 }代码里的SelectObject用完必须恢复这是MFC的GDI对象管理中常见血泪经验不恢复旧位图memDC销毁时可能析构异常长时间运行后GDI对象耗尽。双缓冲的本质是只做一次屏幕刷新把所有绘制工作压在内存位图上肉眼自然看不到中间过程。6.2 状态栏显示剩余雷数与计时MFC框架自带状态栏在CMainFrame::OnCreate里已经完成了m_wndStatusBar.Create。向状态栏写文字的方法是封装一个更新函数void CMainFrame::UpdateMineInfo(int minesLeft, int elapsed) { CString str; str.Format(_T(剩余雷数%d 用时%d 秒), minesLeft, elapsed); m_wndStatusBar.SetPaneText(0, str); }视图里调用时需要拿到主框架指针CMainFrame* pFrame (CMainFrame*)AfxGetMainWnd(); if (pFrame ! NULL) pFrame-UpdateMineInfo(m_minesLeft, m_elapsedSeconds);如果状态栏只默认显示一个窗格SetPaneText(0, ...)就够了。计时器每秒触发一次OnTimer在里面调用上面这个函数游戏信息就会实时刷新。6.3 从“能玩”到“好扩展”自定义关卡与菜单联动难度扩展最直接的方式是加菜单项三个命令分别设置初级、中级、高级参数再重置棋盘。更优雅的做法是弹出一个对话框让用户输入行列数和雷数这也正好用上“在现有MFC工程上增加按钮弹出对话框”的典型套路。我的一个习惯是在菜单触发处先判断m_bFirstClick是否已经发生过。如果游戏已开局弹提示问用户是否重置避免误点菜单把进度丢掉。时间充裕的话还可以在主框架的标题栏显示当前难度名称这些小细节加起来扫雷就从“教学项目”变成了“能拿去演示的作品”。总结我做完这个项目最大的教训MFC扫雷的难度从来不在扫雷算法上而在消息映射、坐标体系、GDI对象生命周期这些Windows底层机制里。先按单文档工程把消息链路走通再用双缓冲和状态栏把体验补齐是最稳妥的节奏。希望帮到你。本文还有配套的精品资源点击获取
返回列表