
简介基于MFC框架开发的单机记忆翻牌游戏源码包面向Windows桌面应用入门者与C学习者以经典记忆配对玩法为载体展示对话框程序从界面搭建到交互响应的完整流程。压缩包共419个文件、约6.63MB内含384张bmp卡片底图、5个mp3配音音效、11个h头文件和9个cpp源文件以及工程配置文件图片、音频、代码分层存放便于直接编译调试。游戏涵盖卡片随机布局、翻牌动画、匹配检测、超时翻回、步数计时计分等模块涉及ON_BN_CLICKED消息映射、定时器、CBitmap位图加载等典型MFC技术点。已有504人学习下载适合作为课程设计或自学项目通过阅读源码可掌握MFC程序组织方式、控件动态创建思路及多媒体资源管理方法。1. 单机记忆翻牌游戏用 VC/MFC 做规则简单落地却不容易“记忆翻牌游戏”在 Windows 单机小游戏里属于典型的老面孔棋盘上铺开 N 张卡片玩家每次翻开两张配对成功就消除失败就翻回去。规则讲给新手听不超过两句话可一旦要用 VC/MFC 把它做成一个能编译、能运行、能反复重开的完整程序洗牌算法、GDI 双缓冲绘制、消息映射、定时器和状态管理就全压上来了。这份资源正好把“记忆翻牌游戏”的完整流程封在 VC6/MFC 工程里适合刚离开控制台程序、想在对话框工程里把 C 语法和 Windows 消息机制串起来的开发者也适合需要快速套一套经典交互逻辑的老手。2. 翻牌游戏的核心机制棋盘数据模型、洗牌算法与点击状态机2.1 棋盘数据放在哪二维棋盘、pairId 与状态枚举做 MFC 界面之前先把数据模型定下来。记忆翻牌里每张牌需要两个信息这张牌属于哪一对、当前处于什么状态。我一般把牌放在一维数组里用行列索引换算避免写二维数组时的下标混乱。enum CardState { CARD_HIDDEN 0, // 背面朝上未翻开 CARD_OPEN 1, // 当前翻开等待比对 CARD_MATCHED 2 // 已配对消除 }; struct Card { int pairId; // 配对编号0、1、2... 同一对共享一个值 CardState state; // 当前状态 }; class CBoard { public: BOOL Initialize(int rows, int cols); void Shuffle(); Card GetCard(int row, int col); int GetRows() const { return m_rows; } int GetCols() const { return m_cols; } private: int m_rows; int m_cols; CArrayCard, Card m_cards; // MFC 容器VC6 工程可直接编译 };这里把pairId和state拆开是因为翻牌比对只关心“是不是同一对”而绘制才关心“当前翻没翻”。CArrayCard, Card在 VC6 时代兼容性最好如果项目已经迁到新版 VS换成std::vectorCard没有任何问题棋盘逻辑不受影响。初始化时用total rows * cols算出总数总数必须是偶数否则没法保证每张牌都有配对。我的做法是先填好成对数据再统一洗牌绝不一边随机放牌一边判断“这张放哪”那样棋盘越大越容易卡死。2.2 洗牌算法先成对、再打乱别用“随机摆放”常见的新手做法是循环随机取坐标、随机取图案看这个位置是否为空。这种做法在 4×4 棋盘上勉强能跑到了 6×6、8×8 就会出现“最后一张牌找不到空位”的尴尬而且重复随机次数多性能也难看。更稳妥的思路是先把牌按成对顺序排好然后用一次 Fisher-Yates 洗牌把整个数组打乱。void CBoard::Initialize(int rows, int cols) { if (rows * cols % 2 ! 0) return; m_rows rows; m_cols cols; m_cards.SetSize(rows * cols); for (int i 0; i rows * cols / 2; i) { m_cards[2 * i].pairId i; m_cards[2 * i].state CARD_HIDDEN; m_cards[2 * i 1].pairId i; m_cards[2 * i 1].state CARD_HIDDEN; } } void CBoard::Shuffle() { int total (int)m_cards.GetSize(); srand((unsigned)time(NULL)); for (int i total - 1; i 0; i--) { int j rand() % (i 1); Card tmp m_cards[i]; m_cards[i] m_cards[j]; m_cards[j] tmp; } }Fisher-Yates 从数组尾部开始每次把当前元素和前面随机位置交换rand() % (i 1)保证了每个位置被抽到的概率均等。对比“用rand() % total随便交换很多次”的做法它的交换次数固定为total - 1时间复杂度稳定而且不会出现“有的牌交换两次等于没换”的分布偏差。种子的位置也很关键。srand(time(NULL))应该放在Shuffle()内部或者NewGame()开头不要让整个进程只在启动时种一次。否则玩家连续开两局两局的牌面几乎一样这在记忆翻牌游戏里非常影响体验。2.3 点击判定状态机第一次翻开、第二次翻开与翻回定时器点击卡片的逻辑不适合在OnLButtonDown里一口气写完它本质是一个两阶段状态机第一次点击记录第一张牌第二次点击比对。比对失败后还要隔一小段时间再把牌翻回去让玩家有足够时间记住图案。int m_firstRow -1, m_firstCol -1; // 第一张翻开的位置 int m_pendingRow -1, m_pendingCol -1; // 第二张翻开的位置 BOOL m_bClickLocked FALSE; // 翻回过程中禁止点击 void CMemoryGameDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bClickLocked) return; int row, col; if (!PointToBoard(point, row, col)) return; Card card m_board.GetCard(row, col); if (card.state CARD_OPEN || card.state CARD_MATCHED) return; card.state CARD_OPEN; if (m_firstRow 0) { m_firstRow row; m_firstCol col; InvalidateRect(CardRect(row, col), FALSE); return; } m_pendingRow row; m_pendingCol col; if (m_board.GetCard(m_firstRow, m_firstCol).pairId card.pairId) { card.state CARD_MATCHED; m_board.GetCard(m_firstRow, m_firstCol).state CARD_MATCHED; m_matchedPairs; ResetFirstCard(); } else { m_bClickLocked TRUE; SetTimer(TIMER_FLIP_BACK, 600, NULL); } InvalidateRect(CardRect(row, col), FALSE); UpdateStatusUI(); }这段代码有几个关键点。第一card.state CARD_OPEN要放在判断前面避免玩家快速点击同一张牌时重复统计。第二配对成功后立刻把两张牌都置为CARD_MATCHED并让m_firstRow复位。第三配对失败时不直接翻回去而是通过SetTimer(TIMER_FLIP_BACK, 600, NULL)延迟 600ms利用窗口消息机制在OnTimer里翻转避免点击线程卡顿。我加了m_bClickLocked这把锁原因是玩家常常会在 600ms 内抢点第三张牌如果不锁状态机会立刻进入“下一组的第一次点击”等OnTimer翻回时反而把新翻开的那张错误地翻回去。这把锁是记忆翻牌这种两段式交互最常见的防呆手段。3. 界面渲染实战GDI 双缓冲、卡片绘制与坐标换算3.1 先解决闪屏重写 OnEraseBkgnd 并建立内存 DCMFC 对话框绘制卡片的直观做法是在OnPaint里直接对窗口 DC 画矩形和文字。这样做小棋盘还能看换到 8×8 棋盘后每次InvalidateRect都会先触发WM_ERASEBKGND把整个客户区刷成背景色再逐张画卡片人眼看到的就是持续闪烁。闪屏的根源不在“画得慢”而在于背景擦除和前景绘制分成了两次操作中间间隔留给了视觉残留。解决思路是双缓冲先在内存里把整幅棋盘画好再一次BitBlt到屏幕。BOOL CMemoryGameDlg::OnEraseBkgnd(CDC* pDC) { // 什么都不画直接告诉系统背景已处理 return TRUE; } void CMemoryGameDlg::DrawBoard(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOld memDC.SelectObject(bmp); memDC.FillSolidRect(rcClient, RGB(200, 220, 240)); for (int i 0; i m_board.GetRows(); i) for (int j 0; j m_board.GetCols(); j) DrawCard(memDC, i, j); pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }OnEraseBkgnd屏蔽背景擦除后所有重绘都从内存 DC 统一输出不会出现“先白后画”的中间态。注意CreateCompatibleBitmap的颜色格式要和窗口 DC 一致否则BitBlt输出后颜色会偏。这里有一点需要提前规划内存 DC 和位图最好在对话框初始化时创建一次存为成员变量而不是每次OnPaint都新建CBitmap。频繁创建位图会带来内存碎片在高频刷新下反而让界面变慢。如果只在OnPaint里创建至少要做好SelectObject还原和对象删除防止临时 GDI 对象泄漏。3.2 DrawCard 的绘制分支背面、正面、已消除每一张牌在DrawCard里根据状态走三个分支背面统一画灰色圆角矩形正面画白色底和配对编号已消除的牌画成浅色最好看不出来原图案。这样玩家视觉重心会自然落在还没完成的牌上。void CMemoryGameDlg::DrawCard(CDC* pDC, int row, int col) { CRect rc CardRect(row, col); Card card m_board.GetCard(row, col); if (card.state CARD_HIDDEN || card.state CARD_OPEN) { if (card.state CARD_HIDDEN) { pDC-FillSolidRect(rc, RGB(120, 130, 140)); pDC-Draw3dRect(rc, RGB(255, 255, 255), RGB(60, 60, 60)); } else { pDC-FillSolidRect(rc, RGB(255, 250, 220)); CString strText; strText.Format(_T(%d), card.pairId 1); pDC-SetTextColor(RGB(30, 60, 120)); pDC-SetBkMode(TRANSPARENT); pDC-DrawText(strText, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } else if (card.state CARD_MATCHED) { pDC-FillSolidRect(rc, RGB(230, 240, 230)); } }用pairId 1显示编号既方便调试又不需要额外加载图片资源。如果想把正面换成图标可以在这里改成CImage::Draw或DrawIcon注意提前把图片 ID 和pairId建立映射关系32 对牌就需要 32 张图片。DrawText垂直居中的关键是DT_VCENTER必须配合DT_SINGLELINE缺一个都会向上对齐或换行这是 GDI 文本绘制里非常容易忽略的组合。3.3 卡片尺寸与坐标换算把行列索引变成像素矩形点击坐标和绘制坐标共用一套换算函数避免两处各自写一遍。常见的做法是设定棋盘边距m_boardMargin、卡片间距gap然后根据客户区尺寸算出每张卡的宽和高。void CMemoryGameDlg::CalcBoardLayout() { CRect rc; GetClientRect(rc); int gap 8; int cols m_board.GetCols(); int rows m_board.GetRows(); int usableW rc.Width() - 2 * m_boardMargin; int usableH rc.Height() - 2 * m_boardMargin; m_cardWidth (usableW - (cols - 1) * gap) / cols; m_cardHeight (usableH - (rows - 1) * gap) / rows; m_boardLeft (rc.Width() - m_cardWidth * cols - gap * (cols - 1)) / 2; m_boardTop (rc.Height() - m_cardHeight * rows - gap * (rows - 1)) / 2; } CRect CMemoryGameDlg::CardRect(int row, int col) { int left m_boardLeft col * (m_cardWidth gap); int top m_boardTop row * (m_cardHeight gap); return CRect(left, top, left m_cardWidth, top m_cardHeight); }gap只在卡与卡之间生效左右两边的边距由m_boardMargin统一控制避免出现“左边距 0、右边距 gap”的视觉不对称。m_boardMargin我一般取 20px8×8 棋盘每张卡大约 52px可读性还算好。窗口初始化、棋盘大小变化、难度切换时都要调用CalcBoardLayout()否则切换难度后界面没有刷新最新布局。4. 完整对局流程难度分档、计时计步、胜负判定与重新开局4.1 难度分档行数和列数决定了配对数与挑战强度记忆翻牌的难度本质上由“棋盘大小”决定。行数和列数越多配对数量越大短时记忆压力就越大。资源里最常见的分档是 4×4、6×6、8×8 三档配对数分别是 8、18、32。难度棋盘配对数建议计时简单4×48 对60 秒普通6×618 对120 秒困难8×832 对180 秒创建新局时先调CBoard::Initialize再调Shuffle顺序不能反过来。常见错误是在Shuffle之后才调用Initialize那会把刚洗好的牌全部覆盖回顺序排列。void CMemoryGameDlg::NewGame(int level) { int rows 0, cols 0; switch (level) { case 0: rows 4; cols 4; break; case 1: rows 6; cols 6; break; case 2: rows 8; cols 8; break; } m_board.Initialize(rows, cols); m_board.Shuffle(); m_firstRow m_firstCol -1; m_pendingRow m_pendingCol -1; m_matchedPairs 0; m_elapsedSeconds 0; m_bClickLocked FALSE; m_bGameStarted FALSE; KillTimer(TIMER_FLIP_BACK); KillTimer(TIMER_GAME_CLOCK); CalcBoardLayout(); UpdateStatusUI(); InvalidateRect(NULL, FALSE); }这里我特意加了m_bGameStarted。它的作用是控制计时器启动时机玩家没点击第一张牌之前计时器不启动第一次点击后进入OnLButtonDown时再SetTimer(TIMER_GAME_CLOCK, 1000, NULL)。否则玩家切去倒杯水回来发现时间已经走完体验很差。4.2 计时与计步SetTimer 延时与步数统计的正确位置计时和翻回逻辑都交给OnTimer但两个定时器 ID 必须分开。#define TIMER_GAME_CLOCK 1 #define TIMER_FLIP_BACK 2 void CMemoryGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_GAME_CLOCK) { m_elapsedSeconds; UpdateStatusUI(); } else if (nIDEvent TIMER_FLIP_BACK) { KillTimer(TIMER_FLIP_BACK); m_board.GetCard(m_firstRow, m_firstCol).state CARD_HIDDEN; m_board.GetCard(m_pendingRow, m_pendingCol).state CARD_HIDDEN; m_firstRow m_firstCol -1; m_pendingRow m_pendingCol -1; m_bClickLocked FALSE; InvalidateRect(NULL, FALSE); } CDialog::OnTimer(nIDEvent); }TIMER_GAME_CLOCK用 1000ms 周期累计秒数TIMER_FLIP_BACK用 600ms 延迟翻回。两个定时器互不干扰但翻回发生时也会触发InvalidateRect(NULL, FALSE)这时候整块棋盘重绘是合理的因为前后两张牌的朝向同时变化局部刷新反而会漏掉中间帧。计步数有讲究第一次点击无论结果如何都算一步第二次点击同样是第一步的一部分。所以步数不该在OnLButtonDown每次点击都加一而是应该在“第二次点击完成比对”时加一。具体做法是在“失败翻回”和“配对成功”两个分支里各执行一次m_stepCount这样一步棋等于一次完整翻开两张牌的操作。4.3 胜负判定与重新开局凑对计数、停止计时与资源复位判定胜负最直接的方法是每次配对成功时让m_matchedPairs然后和总配对数比较。也可以用遍历扫描的方式适合在调试时打印整个棋盘状态。bool CMemoryGameDlg::CheckWinByScan() { int total m_board.GetRows() * m_board.GetCols(); for (int i 0; i total; i) if (m_board.GetCard(i / m_board.GetCols(), i % m_board.GetCols()).state ! CARD_MATCHED) return false; return true; }扫描法逻辑清楚但每次配对成功都要遍历一次6×6 棋盘 36 次判断8×8 棋盘 64 次判断纯属浪费。用计数器的话配对成功时m_matchedPairs胜利条件就是m_matchedPairs m_board.GetRows() * m_board.GetCols() / 2零遍历。如果不是为了调试我推荐计数器方案。胜利后的处理要干净利落KillTimer(TIMER_GAME_CLOCK)先停表弹MessageBox或者自定义结算对话框然后等待玩家点“再来一局”触发NewGame()。这里特别留意不要在OnTimer里弹出模态框定时器消息里弹模态框会造成消息重入第二次OnTimer还在队列里容易弹出多个对话框。5. 避坑实测VC/MFC 记忆翻牌游戏最常见的六个坑5.1 翻牌闪屏抖到没法看这是 MFC 手绘棋盘最经典的现象每次点击刷新时窗口先白一下再逐张绘制。原因有三个一是OnEraseBkgnd默认用系统背景色刷了整个客户区二是每次重绘都全量刷新所有牌三是内存 DC 每次都新建没有复用。解决路径也清晰重写OnEraseBkgnd直接返回TRUE屏蔽背景标画把内存 DC 和兼容位图提升为对话框成员变量初始化时创建一次清理无效区域时尽量用InvalidateRect(CardRect(row, col), FALSE)只在状态翻转为已消除时使用全量刷新。实测从三处都改掉后8×8 棋盘点击翻牌完全无残影。5.2 每局开局牌面几乎一样洗牌等于没洗现象连着开三局8×8 棋盘里有十几张牌的位置完全没变。原因多半是srand(time(NULL))只调用了一次而两局之间的时间间隔只有几百毫秒time(NULL)返回的秒数没变随机序列自然完全相同。解决办法是把srand放到Shuffle()里每次开局都按当前绝对时间去种。如果玩家运气爆棚两次开局落在同一秒内时间种子可能依然相同。我一般会写成srand((unsigned)time(NULL) ^ (unsigned)GetTickCount())用毫秒级时钟做混合基本能保证相邻开局的牌面明显不同。5.3 点击卡片没反应或者翻开的牌不是鼠标点的那张现象集中在两种场景对话框上放了控件OnLButtonDown被控件截获或者窗口有边框和标题栏直接用屏幕坐标换算棋盘索引。原因非常统一OnLButtonDown收到的point是客户区坐标但如果你用了GetCursorPos或CPoint pt; GetCursorPos(pt)拿到的就是屏幕坐标。不同坐标系的数值混在一起算索引结果必然错位。解决思路是用 MFC 自带的CPoint point直接传给PointToBoard中间不调用ScreenToClient、不调用MapWindowPoints。如果确实必须从外部消息拿坐标再转调用链一定是屏幕坐标转换成客户区坐标之后再交给PointToBoard。5.4 VC6 老工程换成新版编译器编译报错一片老版 MFC 工程常见到IDC_HAND找不到、min/max宏冲突、new和delete操作符不匹配这几类问题。比如IDC_HAND是新版 SDK 才提供的标准光标资源VC6 时代没有这个 ID而 Windows 头文件里的min/max宏和标准库的std::min冲突编译模板时直接报错。处理办法是把工程里的光标资源统一换成IDC_ARROW并在stdafx.h开头加#define NOMINMAX禁用宏然后重编。另有一部分BYTE、BOOL相关的类型转换差别需要把(BYTE)xxx强制转换补齐老工程升级基本就是这两步来回磨。5.5 GDI 加载图片后整张牌全黑Load 还返回失败很多后续版本会把游戏中的牌面图案换成外部 PNG 图片于是用CImage::Load或 GDI 的Bitmap::FromFile。现象是在开发机调试正常把 EXE 拷到另一台机器上运行图片区域全黑。原因是程序没有做 GDI 初始化。MFC 不会自动启动 GDI 和 CImage必须手动GdiplusStartup拿到 token程序退出时GdiplusShutdown。常见做法是在InitInstance里初始化一次再把 token 存成成员变量。GdiplusStartupInput gsi; ULONG_PTR m_gdiplusToken 0; if (GdiplusStartup(m_gdiplusToken, gsi, NULL) Ok) { CImage img; HRESULT hr img.Load(_T(card_1.png)); if (FAILED(hr)) { TRACE(_T(图片加载失败: card_1.png\n)); } }很多人只检查Load返回值却忘了GdiplusStartup本身没有调用返回码自然是失败的。另有一种隐蔽情况图片路径用了相对路径而 VC6 调试时的工作目录是工程目录发布后工作目录变成 EXE 所在目录尽量用GetModuleFileName拼出完整路径。5.6 棋盘越界数组下标和行列索引混用现象集中在GetCard(row, col)里传入row * cols row这类错位表达式下标超出CArray边界轻则读到脏数据重则中断运行。常见场景是把行和列都从 1 开始计数而数组下标从 0 开始。我的约定是行和列都从 0 开始row表示行索引col表示列索引坐标换算只在这两个变量的基础上加边距和间距。拿到point后先通过PointToBoard判断是否落在棋盘范围内row 0 || row m_rows || col 0 || col m_cols直接返回不给越界机会。如果排查时怀疑越界最省事的办法是给GetCard临时加一段断言ASSERT(row 0 row m_rows); ASSERT(col 0 col m_cols);断言在 Debug 版直接定位Release 版自动消失干净利落。6. 进阶技巧给单机记忆翻牌游戏加存档恢复与“一屏预览”游戏做到能玩能重开只是第一步实际使用场景里“玩一半要关电脑”才是常态。我给这个资源补过一版存档功能把棋盘数据、步数和已用时间都写到 INI 文件下次启动自动恢复。MFC 下用WritePrivateProfileString和GetPrivateProfileInt最省事不用手工解析文本也不需要引入额外的 JSON 库。CString strDir; GetModuleFileName(NULL, strDir.GetBuffer(MAX_PATH), MAX_PATH); strDir.ReleaseBuffer(); int pos strDir.ReverseFind(_T(\\)); strDir strDir.Left(pos 1); CString strPath strDir _T(MemoryGame.ini); // 保存当前局 WritePrivateProfileString(_T(Game), _T(Rows), _T(6), strPath); WritePrivateProfileString(_T(Game), _T(Cols), _T(6), strPath); WritePrivateProfileString(_T(Game), _T(Elapsed), _T(42), strPath); WritePrivateProfileString(_T(Game), _T(MatchedPairs), _T(5), strPath); // 读取恢复 int iRows GetPrivateProfileInt(_T(Game), _T(Rows), 6, strPath); int iCols GetPrivateProfileInt(_T(Game), _T(Cols), 6, strPath); int iElapsed GetPrivateProfileInt(_T(Game), _T(Elapsed), 0, strPath);INI 方案的缺点是不能存复杂二进制结构棋盘数组只能按固定格式逐行写入字符串。更干净的方案是CFile自己写一个结构体序列化先写rows、cols、elapsed、matchedPairs再把每个card.pairId和card.state按顺序写入。恢复时按相同顺序读加上一个版本号字段防错位。数据量不大一个中等棋盘 64 张卡写一次文件也就几 KB没必要引第三方库。存档之外我给调试卡死活局时加过一个“一屏预览”功能按F5时让棋盘所有牌翻开显示 0.5 秒再翻回。实现方式很简单在DrawCard里加一个m_bPreviewAll分支OnKeyDown里置为TRUE并全量刷新同时在OnTimer里加一个 500ms 的定时器把它翻回来。这个功能名义上是给玩家“后悔药”实际调试布局和图案渲染时特别有用不用一张张翻开确认每张卡的位置。这个功能要避免和正式翻牌逻辑打架。预览期间OnLButtonDown直接返回m_bClickLocked也置为 TRUE否则玩家按着 F5 再点牌状态机会乱套。从那以后我每次拿到别人的 MFC 小游戏工程第一件事就是检查OnEraseBkgnd是否被屏蔽、随机种子是否在每局开始前重新初始化、坐标换算里有没有混用客户区坐标和屏幕坐标。这三个检查项能解决八成翻车现场剩下两成就是存档和重开逻辑里过早InvalidateRect导致的界面不刷新。希望帮到你。本文还有配套的精品资源点击获取