C++ MFC单机翻牌游戏开发:消息映射与GDI双缓冲实践
简介:基于 MFC 框架实现的单机记忆翻牌游戏源码包,面向希望用完整小项目入门 Windows 桌面开发、巩固 C++ 与 MFC 的初中级开发者。项目围绕卡片对称布局、点击翻牌、记忆匹配、计时回翻、计分与音效等机制展开,涉及对话框界面设计、ON_BN_CLICKED 消息映射、控件动态创建、位图资源加载和多媒体播放等实践点,以及匹配成功后保持翻开、不匹配延时翻回的判定流程,可用于课程设计、自学演练或 MFC 学习参考。压缩包共 419 个文件,约 6.63MB,主要由 384 张 bmp 卡面与界面图片、11 个 h 头文件、9 个 cpp 源文件、5 个 mp3 音效及 sln、vcxproj、rc 等工程配置组成,代码与资源组织较完整。已有 507 人学习,适合按模块阅读界面、匹配逻辑与资源管理实现,也能借鉴其消息处理和计时器用法,并据此梳理从资源导入、卡片初始化到胜负判定与计分更新的完整流程,便于二次改造。
1. 先把它跑起来:一个不联网、不装库的单机记忆翻牌游戏
前段时间帮人看一份课设代码,压缩包解开只有几十个文件,双击 exe 直接进主界面,没有安装向导,没有服务端,也没有任何第三方动态库。它就是一个用 C++ 和 MFC 写的单机记忆翻牌游戏:4×4 的格子铺开,点开两张牌,图案一样就消掉,不一样就盖回去,边上有计时和步数,全部配对完弹一个成绩框。整套逻辑跑在一个对话框窗口里,绘图全靠 GDI,牌面用位图资源或者图形拼出来。
这东西适合两类人。一类是想找一个结构完整、能一口气读完的 MFC 小工程来练手,从消息映射、控件响应、定时器到双缓冲重绘,一条链路全须全尾;另一类是想拿它当壳子,改成别的配对类小游戏,比如单词配对、图标连连看、拼音消消乐。它不依赖数据库,不依赖网络,编译出来一个 exe 拷到别的机器上就能玩,这是它最舒服的地方。下面按「骨架怎么搭 → 数据怎么存 → 交互怎么做 → 编译怎么过 → 怎么改成自己的东西」一路拆下去。
2. 工程骨架拆解:MFC 对话框程序里,一局翻牌游戏靠哪几条消息转起来
MFC 写小游戏这件事,很多人第一反应是"是不是得用文档视图架构"。其实对一个翻牌游戏来说,基于对话框的工程就够了,而且更好改:主窗口就是一张对话框模板,上面摆几个按钮和一块自绘区域,游戏状态全部挂在这个对话框类的成员变量上,不涉及序列化、不涉及多文档,心智负担低。
新建工程时选「MFC 应用」,应用程序类型选「基于对话框」,资源语言保持默认,其余一路下一步。工程生成完,你会拿到一个 CWinApp 派生类和一个 CDialogEx 派生类,前者只负责在 InitInstance 里创建主对话框,后者才是真正要写代码的地方。
2.1 目录与工程文件清单
一个干净版本的工程展开大致是下面这个样子,我按职责标了每个文件管什么。老项目里经常混进一堆Debug/、ipch/、.vs/目录,那是编译中间产物,打包发给别人之前一定要清掉,不然压缩包能大出十几倍。
| 文件 / 目录 | 职责 |
|---|---|
MemoryFlip.sln | 解决方案入口,双击打开 |
MemoryFlip.vcxproj | 工程配置:字符集、MFC 链接方式、平台工具集 |
MemoryFlip.cpp / .h | CWinApp 派生类,只在InitInstance里创建主对话框 |
MemoryFlipDlg.cpp / .h | 主对话框,游戏状态、消息响应、绘制全在这里 |
CardInfo.h | 单张牌的数据结构,独立出来方便复用 |
framework.h / pch.h | 预编译头,包含afxwin.h、afxdialogex.h |
res/MemoryFlip.rc | 资源脚本:对话框模板、按钮、静态文本、位图 |
res/resource.h | 资源 ID 宏定义,IDC_*、IDB_*都在这里 |
res/*.bmp | 牌背与牌面位图,没有的话用纯色 + 文字也能跑 |
.rc和resource.h这两个文件不要手动改,改资源一律走 VS 的资源视图,IDE 会自动同步两边的 ID 定义。我见过有人直接在resource.h里加宏、然后在.rc里手写控件块,结果资源编译器和 C++ 编译器对不上号,报一堆undeclared identifier,排查半天。
2.2 消息映射:四个入口函数撑起全部交互
MFC 不像 Qt 那样用信号槽,它靠消息映射表把 Windows 消息分派到成员函数。这个游戏真正需要的消息只有下面这几个,其余全部由基类默认处理就够了:
// MemoryFlipDlg.cpp —— 消息映射表 BEGIN_MESSAGE_MAP(CMemoryFlipDlg, CDialogEx) ON_WM_PAINT() // 所有绘制入口 ON_WM_ERASEBKGND() // 关掉背景擦除,配合双缓冲 ON_WM_LBUTTONDOWN() // 鼠标左键:翻牌的唯一入口 ON_WM_TIMER() // 计时 + 延时回翻 ON_WM_DESTROY() // 窗口销毁时收掉定时器 ON_BN_CLICKED(IDC_BTN_NEWGAME, &CMemoryFlipDlg::OnBnClickedNewGame) ON_BN_CLICKED(IDC_BTN_RESTART, &CMemoryFlipDlg::OnBnClickedRestart) END_MESSAGE_MAP()ON_WM_PAINT对应OnPaint(),是唯一往屏幕上落像素的地方;ON_WM_LBUTTONDOWN对应OnLButtonDown(UINT nFlags, CPoint point),参数point已经是客户区坐标,不用再ScreenToClient;ON_WM_TIMER对应OnTimer(UINT_PTR nIDEvent),靠 ID 区分是秒表还是翻牌延时;两个按钮走ON_BN_CLICKED,分别对应"新游戏"和"重开本局"。
这四个入口之外,还有OnInitDialog()会在对话框创建完成、显示之前被调用一次,初始化棋盘、发牌、启动计时都塞在这里。它返回TRUE表示让框架继续设置默认焦点,返回FALSE表示焦点已经自己处理完了。这个细节新手经常忽略,返回FALSE又不调SetFocus,结果窗口起来后键盘完全没反应。
2.3 资源视图:对话框模板、占位控件与位图
对话框模板IDD_MEMORYFLIP_DIALOG上摆三类东西:两个按钮(新游戏、重开),两个只读静态文本(IDC_STATIC_TIME显示秒数、IDC_STATIC_MOVES显示步数),还有一块用来圈定棋盘区域的占位控件IDC_BOARD。
占位控件这里有个必须交代的点:如果它是一个可见的CStatic,鼠标点击会被它自己吃掉,主对话框的OnLButtonDown根本收不到消息,表现出来就是"点牌完全没反应"。我一般的做法是把它的Visible属性设为False,只当作一个可以拖动调整的坐标参考;运行时用GetWindowRect拿到它在屏幕上的矩形,再ScreenToClient转成客户区坐标,作为棋盘的绘制基准。这样布局仍然能在资源编辑器里可视化调整,又不会拦截点击。
位图资源按约定命名,IDB_CARD_BACK是牌背,牌面可以是IDB_FACE_00到IDB_FACE_NN一整组。牌少的时候(比如 6 对)直接用文字或几何图形拼牌面更省事,省掉一堆位图文件,也省掉后面换皮肤时的路径问题。
初始化函数长这样,先算棋盘,再发牌,最后启动秒表:
BOOL CMemoryFlipDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_nLevel = 1; // 1 = 普通难度 4x4 m_nFirstSel = -1; // 第一张已翻开的牌下标,-1 表示没有 m_nSecondSel = -1; // 第二张 m_bLocked = false; // 回翻延时期间锁住输入 m_nMoves = 0; m_nSeconds = 0; BuildBoardRect(); // 由 IDC_BOARD 换算棋盘区域 StartNewGame(); // 发牌 + 重启计时器 return TRUE; // 交给框架设置默认焦点 }BuildBoardRect()只做坐标换算,不涉及游戏规则;StartNewGame()负责清空状态、重建牌堆、启动ID_TIMER_GAME定时器。两个函数分开写的好处是后面加"重开本局"按钮时,直接调StartNewGame()就行,不用复制一遍初始化逻辑。
3. 数据层建模:CardInfo 结构、成对发牌与洗牌算法的取舍
翻牌游戏的规则简单,但数据结构选错了后面全是麻烦。常见的错误写法是把牌面图案存进按钮的SetWindowText,靠读按钮标题判断是否配对;或者干脆用二维数组int board[8][8]存图案编号,翻开的牌另开一个bool opened[8][8]。这两种写法在加"已消除"状态、做命中检测、换难度尺寸的时候都要跟着改一大堆地方。
更稳的做法是一维结构体数组,一张牌一个对象,所有状态都挂在对象上。棋盘坐标只是绘制阶段算出来的一个副产品,不参与逻辑判断。
3.1 CardInfo:一张牌要记哪几个字段
// CardInfo.h struct CardInfo { int nFace; // 图案编号 0..nPairs-1,一对牌的两个成员编号相同 bool bFaceUp; // 当前是否翻开(正面朝上) bool bMatched; // 是否已配对消除 CRect rcCard; // 客户区坐标下的卡片矩形,布局时算好 };nFace是配对判定的唯一依据,两个nFace相等就是一对。bFaceUp和bMatched是两个独立状态:配对的牌会先经历"翻开 → 匹配 → 消除",如果只用一个bool表示翻转,消除之后就没法区分"盖着的牌"和"已经消掉的牌",画面上会重新画出一张牌背,玩家以为还能点。
rcCard存的是绝对客户区坐标,不是相对某个控件的坐标。这样PtInRect命中检测可以直接拿鼠标点比,省掉一层换算;代价是窗口尺寸变化时要重算所有牌的矩形,这个代价很小,WM_SIZE里调一次LayoutBoard()就够。
这个结构体 16 字节左右,36 张牌不到 600 字节,随便怎么存都不用心疼内存。真正要心疼的是"改代码时到处找状态"的时间。
3.2 成对发牌与棋盘布局的正交计算
发牌的逻辑是:有nPairs对牌,就循环nPairs次,每次往容器里塞两张nFace相同的牌。塞完之后容器长度必然是偶数,这一点在洗牌和遍历时都可以当作不变式来用。
void CMemoryFlipDlg::InitDeck() { m_cards.clear(); const int nPairs = m_nRows * m_nCols / 2; // 行列乘积必须为偶数 m_cards.reserve(nPairs * 2); for (int i = 0; i < nPairs; ++i) { CardInfo a, b; a.nFace = b.nFace = i; a.bFaceUp = b.bFaceUp = false; a.bMatched = b.bMatched = false; m_cards.push_back(a); m_cards.push_back(b); } ShuffleDeck(); LayoutBoard(); }m_nRows * m_nCols必须是偶数,这是硬约束。如果难度配置里出现 3×3 这种奇数格子,最后会剩一张孤立牌永远配对不上,游戏卡死。稳妥的做法是在读配置的地方直接断言或者取整:
if ((m_nRows * m_nCols) % 2 != 0) { m_nCols -= 1; // 兜底:奇数行时减一列,保证偶数格 }布局计算用的是"先算可用宽高、再取整、最后整体居中"这套正交思路。卡片高度还要跟宽度挂个比例上限,否则窗口一拉宽,卡片就被拉成又扁又长的条,看着很怪:
void CMemoryFlipDlg::LayoutBoard() { const int nGap = 8; CRect rc = m_rcBoard; // 由 IDC_BOARD 换算来的棋盘区域 int nCardW = (rc.Width() - nGap * (m_nCols + 1)) / m_nCols; int nCardH = (rc.Height() - nGap * (m_nRows + 1)) / m_nRows; // 限制长宽比,避免卡片被拉变形;1.4 是我试下来比较顺眼的竖长比例 const int nMaxH = nCardW * 14 / 10; if (nCardH > nMaxH) nCardH = nMaxH; // 整体在棋盘区域内居中 const int nTotalW = nCardW * m_nCols + nGap * (m_nCols - 1); const int nTotalH = nCardH * m_nRows + nGap * (m_nRows - 1); const int nOffsetX = rc.left + (rc.Width() - nTotalW) / 2; const int nOffsetY = rc.top + (rc.Height() - nTotalH) / 2; for (int r = 0; r < m_nRows; ++r) { for (int c = 0; c < m_nCols; ++c) { int idx = r * m_nCols + c; // 一维下标与二维坐标的映射 if (idx >= (int)m_cards.size()) return; m_cards[idx].rcCard.SetRect( nOffsetX + c * (nCardW + nGap), nOffsetY + r * (nCardH + nGap), nOffsetX + c * (nCardW + nGap) + nCardW, nOffsetY + r * (nCardH + nGap) + nCardH); } } }这段代码里idx = r * m_nCols + c是整篇里最重要的一个约定:一维数组下标和二维视觉位置之间的映射只有这一处,其他所有地方(命中检测、绘制、发牌)都依赖它。只要保证LayoutBoard、HitTest、InitDeck用的是同一个m_nCols,格子就不会错位。血泪经验是有人在中途改难度时只改了m_nRows没改m_nCols,结果牌画到一半出界,另一半叠在一起。
3.3 洗牌:rand 手写 Fisher-Yates 还是 std::shuffle
洗牌看起来是随机打乱,实际上要求均匀分布,不能出现某些排列永远洗不出来的情况。最省事的做法是std::shuffle,C++11 起标准库自带,用的是均匀随机源:
#include <random> #include <algorithm> void CMemoryFlipDlg::ShuffleDeck() { std::mt19937 rng{ std::random_device{}() }; // 每次启动都用真随机种子 std::shuffle(m_cards.begin(), m_cards.end(), rng); }但老工程经常用 VC6 或者关闭了 C++11 支持,这时候手写 Fisher-Yates 也就六行。它的正确性关键在"从后往前,每次和[0, i]区间内的随机位置交换",而不是"每次随机取两个位置交换"——后者洗不均匀,某些排列出现的概率会明显偏高,玩家多玩几局就能感觉到"牌老是那几种分布"。
void CMemoryFlipDlg::ShuffleDeck() { // Fisher-Yates:i 从后往前,j 在 [0, i] 中取 for (int i = (int)m_cards.size() - 1; i > 0; --i) { int j = rand() % (i + 1); std::swap(m_cards[i], m_cards[j]); } } // 种子只播一次,放在 OnNewGame 或 InitInstance 里 // srand((unsigned)time(nullptr));用rand()有两个小毛病要知道:一是rand()的最大值RAND_MAX在 Windows 上通常只有 32767,牌数不多时影响有限,但取模会引入轻微偏差;二是种子必须播,不播srand每次运行结果一模一样,玩家第一局和第二局看到的牌序完全相同,会以为是 bug。真要严谨,把rand()换成std::mt19937生成的整数也就一行的事。
洗完之后不要立刻绘制,先让LayoutBoard()把每张牌的rcCard算好,再统一Invalidate(FALSE)触发重绘。顺序反了会画出一批坐标为(0,0,0,0)的牌,屏幕上一片空白。
4. 交互与绘制:命中检测、翻牌状态机与 GDI 双缓冲
到这一层,游戏就"活"起来了。玩家点一下鼠标,程序要判断点的是哪张牌、这张牌能不能翻、翻完之后是不是配对、不配对要多久盖回去、界面什么时候重画。这几件事任何一件顺序搞错,表现出来都是很典型的故障:点了没反应、翻了三张牌、配对的牌又弹回去、画面一直闪。
4.1 从鼠标坐标到卡片下标:HitTest 怎么写才不飘
命中检测最直接的办法是遍历所有牌,用CRect::PtInRect判断:
int CMemoryFlipDlg::HitTest(CPoint pt) const { for (int i = 0; i < (int)m_cards.size(); ++i) { if (m_cards[i].rcCard.PtInRect(pt)) return i; } return -1; // 没点中任何一张 }牌最多 36 张,线性遍历一次不到一微秒,完全够用。有人会想用(pt.x - origin.x) / (w + gap)直接算行列,省掉循环,这在格子完全对齐、间距统一的前提下是对的,但一旦加了圆角、阴影或者不等宽的布局,边界就会漂:点在卡片之间的缝隙里,除法算出来的下标仍然有效,于是"点空白处也能翻牌"。真要做索引法,得在算出行列之后再用PtInRect做一次边界校验,那就跟遍历没区别了。
OnLButtonDown里第一件事不是找牌,而是判断"现在能不能操作":
void CMemoryFlipDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bLocked || m_bFinished) { // 回翻延时中 / 已通关,忽略点击 CDialogEx::OnLButtonDown(nFlags, point); return; } int idx = HitTest(point); if (idx < 0) return; CardInfo& card = m_cards[idx]; if (card.bMatched || card.bFaceUp) return; // 已消除或已翻开,不重复处理 card.bFaceUp = true; ++m_nMoves; UpdateStatusText(); // 刷新步数显示 if (m_nFirstSel < 0) { m_nFirstSel = idx; // 第一张,记下来 } else { m_nSecondSel = idx; if (m_cards[m_nFirstSel].nFace == card.nFace) { // 配对成功:直接标消除,不用等定时器 m_cards[m_nFirstSel].bMatched = true; card.bMatched = true; m_nFirstSel = m_nSecondSel = -1; CheckWin(); } else { // 配对失败:锁住输入,交给定时器在 600ms 后盖回去 m_bLocked = true; SetTimer(ID_TIMER_FLIP, 600, nullptr); } } Invalidate(FALSE); // FALSE:不擦背景,配合双缓冲 CDialogEx::OnLButtonDown(nFlags, point); }这里m_bLocked是整个交互的核心。没有它,玩家在两张错牌盖回去之前的 600 毫秒里连点第三张,m_nFirstSel会被覆盖,第一张牌就永远留在翻开状态,牌面数量对不上,最后无解。这是这个项目里最容易被忽略、也最容易复现的一个 bug。
4.2 翻牌状态机:两个下标 + 一个锁标志
整套翻牌逻辑其实就是一个三状态机:空闲(m_nFirstSel == -1)、等第二张(m_nFirstSel >= 0 && m_bLocked == false)、回翻延时(m_bLocked == true)。定时器到点之后从第三态回到第一态:
void CMemoryFlipDlg::OnTimer(UINT_PTR nIDEvent) { switch (nIDEvent) { case ID_TIMER_FLIP: // 回翻延时到,把两张牌盖回去 KillTimer(ID_TIMER_FLIP); // 一次性定时器,用完就收 if (m_nFirstSel >= 0 && m_nFirstSel < (int)m_cards.size()) m_cards[m_nFirstSel].bFaceUp = false; if (m_nSecondSel >= 0 && m_nSecondSel < (int)m_cards.size()) m_cards[m_nSecondSel].bFaceUp = false; m_nFirstSel = m_nSecondSel = -1; m_bLocked = false; Invalidate(FALSE); break; case ID_TIMER_GAME: // 秒表,每秒加一 ++m_nSeconds; UpdateStatusText(); break; } CDialogEx::OnTimer(nIDEvent); }KillTimer一定要在ID_TIMER_FLIP分支的开头调。SetTimer的第二参数不为 0 时创建的是周期性定时器,不手动KillTimer它会一直触发,每 600 毫秒把m_nFirstSel和m_nSecondSel置一次 -1。表现出来的现象很怪:配对成功的牌有时候会突然盖回去,玩家怀疑自己眼花。
ID_TIMER_GAME是 1 秒周期的长期定时器,在StartNewGame里SetTimer(ID_TIMER_GAME, 1000, nullptr),在OnDestroy里统一KillTimer。窗口关闭时不收定时器,调试器里会看到OnTimer在对象析构之后还被调用,运气不好直接崩在野指针上。
4.3 双缓冲重绘:让翻牌动画不闪
GDI 直接往窗口 DC 上画,每画一张牌就会触发一次刷新,牌一多整个画面像老式日光灯一样抖。解决办法是标准的双缓冲:先在内存 DC 上把整帧画完,最后一次BitBlt贴到屏幕上。
void CMemoryFlipDlg::OnPaint() { CPaintDC dc(this); // 必须构造,负责 BeginPaint/EndPaint CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(&dc); bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rc, RGB(24, 32, 48)); // 统一底色,别指望窗口背景 DrawBoard(memDC); // 所有牌都画在内存 DC 上 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // 恢复,否则位图析构时 DC 还引用着它 }SelectObject恢复旧对象这一步不能省。CBitmap析构的时候如果还挂在 DC 上,GDI 对象不会被释放,画几十次之后句柄表就满了,程序开始画不出东西。
配套还要关掉背景擦除,否则Invalidate会先发WM_ERASEBKGND把整片区域刷成窗口背景色,再走你的双缓冲,两帧之间露白,看起来就是"闪一下":
BOOL CMemoryFlipDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 告诉系统:背景我已经在 OnPaint 里画了,别擦 }注意Invalidate的参数。Invalidate(TRUE)会把区域标记为"需要擦背景",即使OnEraseBkgnd返回了TRUE,某些绘制路径下仍然会出现撕裂;统一写Invalidate(FALSE)更稳。新游戏、翻牌、定时器回翻,全部走这一个参数。
单张牌的绘制按状态分三支,已消除的留底色、翻开画正面、未翻开画背面。翻开的牌如果用品面位图,用StretchBlt缩放贴上去;不想带位图资源,用DrawText画个字母或数字也能玩:
void CMemoryFlipDlg::DrawCard(CDC& dc, const CardInfo& card) { if (card.bMatched) { dc.FillSolidRect(&card.rcCard, RGB(24, 32, 48)); // 消除后留空,视觉上"消失" return; } COLORREF crFill = card.bFaceUp ? RGB(245, 245, 240) : RGB(52, 98, 160); CPen pen(PS_SOLID, 1, RGB(16, 20, 28)); CPen* pOldPen = dc.SelectObject(&pen); CBrush brush(crFill); CBrush* pOldBrush = dc.SelectObject(&brush); dc.RoundRect(&card.rcCard, CPoint(8, 8)); // 圆角矩形做卡片 if (card.bFaceUp) { CString s; s.Format(_T("%c"), _T('A') + card.nFace % 26); // 简单牌面:字母 dc.SetBkMode(TRANSPARENT); dc.SetTextColor(RGB(40, 40, 40)); dc.DrawText(s, &card.rcCard, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } dc.SelectObject(pOldBrush); dc.SelectObject(pOldPen); }RoundRect的圆角半径单位是像素,8 像素在 60×84 的卡片上正好,太大就成胶囊形了。SetBkMode(TRANSPARENT)不加的话文字会带一块白底,压在深色卡片上非常显眼。
4.4 计时与步数:两个定时器各管一摊
ID_TIMER_GAME管秒表,ID_TIMER_FLIP管回翻延时,两个定时器 ID 用#define或const UINT定义在头文件里,不要图省事直接写1和2。MFC 里定时器 ID 是对话框级别的,和控件 ID 共用一套命名空间,撞上了OnTimer会收到意料之外的 ID。
步数统计放在OnLButtonDown里,每翻转一张牌++m_nMoves。这里有个体验上的小分歧:是按"翻一张算一步"还是"翻一对算一步"。两种都有人做,前者数字涨得快、更有紧张感,后者更接近"尝试次数"的语义。我一般按"翻一张算一步",因为配对成功和失败在步数上会有明显差异,玩家能直观感觉到自己在进步。
秒表的显示在UpdateStatusText()里更新,用SetDlgItemInt(IDC_STATIC_TIME, m_nSeconds)或者SetDlgItemText拼成分:秒。不要在这个函数里调Invalidate——它只改两个静态文本控件的内容,控件自己会重画,再触发一次全窗口重绘纯属浪费。
通关判定放在CheckWin()里,遍历所有牌看bMatched是否全为真。全为真就KillTimer(ID_TIMER_GAME)、置m_bFinished = true、弹一个AfxMessageBox显示用时和步数。把m_bFinished单独拿出来而不是复用m_bLocked,是因为两者语义不同:一个是"暂时不能点",一个是"这局结束了",混用会在重开游戏时漏重置某一个标志。
5. 编译与排查:链接错误、点击无响应、画面闪烁的常见坑
代码写得再顺,第一次编译能一次过的概率也不高。MFC 工程的问题分两类:一类是编译链接期的配置问题,报错信息很明确但新手看不懂;一类是运行期的行为问题,程序能跑但表现不对,得靠现象反推。
5.1 工程配置:字符集、MFC 链接方式与子系统
下面这几项是新建工程之后第一件要确认的事,任何一项和实际代码不匹配都会在链接阶段炸出来:
| 配置项 | 位置(工程属性) | 建议值 | 说明 |
|---|---|---|---|
| 字符集 | 配置属性 → 高级 → 字符集 | 使用 Unicode 字符集 | 与代码里的_T()、LPCTSTR保持一致 |
| MFC 使用方式 | 配置属性 → 高级 → MFC 使用方式 | 在共享 DLL 中使用 MFC | 生成的 exe 小,但目标机要有对应运行库 |
| 子系统 | 链接器 → 系统 → 子系统 | Windows (/SUBSYSTEM:WINDOWS) | 选错会报找不到WinMain |
| 平台工具集 | 配置属性 → 常规 → 平台工具集 | 与本机 VS 版本一致 | 从别人机器拷来的工程常在这里报错 |
| 运行库 | C/C++ → 代码生成 → 运行库 | 多线程调试 DLL (/MDd) | 必须与 MFC 动态链接方式匹配 |
如果要把 exe 发给没装运行环境的机器,把「MFC 使用方式」改成「在静态库中使用 MFC」,运行库同步改成/MTd(Debug)或/MT(Release),编译出来的文件会大几百 KB 到一两 MB,但拷过去双击就跑。这是发课设作业最稳的配置。
5.2 五条高频故障:现象 → 原因 → 解决
现象一:链接报LNK2019: 无法解析的外部符号 _WinMain@16。原因基本是子系统选成了控制台,或者工程的「MFC 使用方式」被设成了「使用标准 Windows 库」,导致 MFC 的入口点没被链进来。解决是把子系统改回Windows (/SUBSYSTEM:WINDOWS),MFC 使用方式改回共享或静态 DLL,然后清理解决方案重新生成——只改配置不清理,增量编译有时会沿用旧的 obj。
现象二:程序启动正常,但点卡片完全没反应。两个可能。一是棋盘区域上压着一个可见的CStatic占位控件,鼠标点被它截获,主对话框的OnLButtonDown根本没被调用;二是OnInitDialog里忘了调LayoutBoard(),所有rcCard都是(0,0,0,0),PtInRect永远返回假。前者把占位控件设为不可见、或者干脆删掉改用固定边距;后者在InitDeck()末尾补一次布局调用,并打日志确认第一张牌的矩形不是空矩形。
现象三:翻牌时整片区域闪一下,或者配对的牌短暂变黑。典型的背景擦除和双缓冲打架。检查三处:OnEraseBkgnd是否return TRUE,Invalidate调用是否传了FALSE,以及OnPaint里是否先FillSolidRect再画牌。少任何一处都会露底色。还有一种隐蔽情况是memDC.CreateCompatibleBitmap之后忘了SelectObject就直接画,画出来的内容其实落在默认的 1×1 单色位图上,贴到屏幕就是一片黑。
现象四:偶尔能点开第三张牌,配对状态错乱。m_bLocked用漏了。检查OnLButtonDown开头是否有if (m_bLocked) return;,以及OnTimer的ID_TIMER_FLIP分支里是否把它复位成false。另外确认回翻分支调了KillTimer(ID_TIMER_FLIP),周期性定时器反复触发会把刚翻开的牌又盖回去,看起来就像"点了三张"。
现象五:中文标签变乱码,或者CString格式化出来是问号。字符集不统一。工程设的是 Unicode,代码里却写了"新游戏"这种窄字符串,SetWindowText收到的是按 GBK 解释的字节流,显示出来就是乱码。统一改成_T("新游戏"),Format也改用_T("%d 步")。反过来,如果把一个多字节字符集的.rc拷进 Unicode 工程,资源编辑器会提示转换,一律选"是"。
这五条基本覆盖了我见过的绝大多数"跑不起来"和"跑起来不对"的情况。真遇到怪问题,先在OnLButtonDown和OnTimer里各打一行TRACE,把idx、m_nFirstSel、m_nSecondSel、m_bLocked打出来,对着输出看状态流转,比盯着屏幕猜快得多。
6. 二次开发:换难度、换皮肤、加成绩存档的三个改造点
这份代码最值钱的地方不是它现在能玩,而是它的结构留了改造余地。下面三个方向是我拿到类似工程后最先动的,改动量都不大,但能把它从"课设作业"变成"自己的东西"。
难度参数外置。现在行列写死在m_nLevel的分支里,加一档难度就得改代码重编译。把它挪到同目录的difficulty.ini:
// 读 ini:0=入门 1=普通 2=挑战 3=大师 void CMemoryFlipDlg::LoadLevel(int nLevel, int& nRows, int& nCols) { LPCTSTR pszIni = _T(".\\difficulty.ini"); CString sKey; sKey.Format(_T("Level%d"), nLevel); nRows = ::GetPrivateProfileInt(sKey, _T("Rows"), 4, pszIni); nCols = ::GetPrivateProfileInt(sKey, _T("Cols"), 4, pszIni); if ((nRows * nCols) % 2 != 0) nCols -= 1; // 兜底保护,保证成对 if (nRows < 2 || nCols < 2) { nRows = 4; nCols = 4; } }GetPrivateProfileInt是 Win32 API,不引入任何第三方依赖,读不到文件就返回默认值,容错天然就好。改完 ini 不用重新编译,双击 exe 就能试新难度,调平衡的时候非常省事。
换皮肤。牌面走位图的话,把IDB_CARD_BACK和IDB_FACE_00..NN换成新图,注意尺寸统一,绘制时用StretchBlt缩放到rcCard,比BitBlt更耐尺寸变化。想更彻底一点,把颜色、圆角半径、间距全部收进一个struct Theme,绘制函数只读 Theme 不写死常量,一次改配色全盘生效。这里常见的翻车是位图带 alpha 通道存成 PNG,CBitmap读不了,得先转成 24 位 BMP,或者改用CImage加载再Draw。
成绩存档。每局结束把最佳成绩按难度分别记下来,用WritePrivateProfileString写进同一个 ini 的[Best]段,键名用Level0、Level1,值存秒数,步数。读取时解析一下,主界面显示"最佳:42 秒 / 28 步"。比存二进制文件省心,出问题直接用记事本打开就能看。
改完之后一定要按这份清单手测一遍:洗牌后每张牌都能找到搭档、点卡片间隙不翻牌、两张错牌 600ms 内连点第三张无反应、通关后计时停止、重开本局步数归零、窗口拉大拉小不出现卡片重叠或出界、ini 里把行列写成奇数时不崩。这几条里任何一条挂掉,都说明改动碰到了状态机的边界。
我最早做这类小游戏时吃过一次亏:改难度只改了m_nRows,忘了同步 ini 里的Cols,跑起来一半牌画在窗口外面,剩下的一半叠在一起,肉眼完全看不出问题在哪。从那以后我每次动布局相关的参数,都强制把"发牌数量、布局函数用的列数、命中检测用的列数"三处在代码里搜一遍,确认是同一个变量才敢编译。希望这篇拆解能帮你少走一遍这个弯路——工程不大,但每一层都值得按自己的思路重写一遍。
本文还有配套的精品资源,点击获取