VC++ MFC实战:从零开发经典连连看游戏,掌握Windows桌面开发核心 1. 项目概述从零到一用VC打造经典连连看作为一名在Windows桌面开发领域摸爬滚打了十几年的老程序员我始终认为VC配合MFC框架是理解Windows桌面应用开发精髓的最佳路径之一。它不像现在流行的各种跨平台框架那样抽象而是让你直面消息循环、GDI绘图、资源管理和窗口过程这些底层机制。今天我想和大家分享一个非常经典且极具教学价值的实战项目——基于VC的连连看游戏开发。这不仅仅是一个“玩具”项目它麻雀虽小五脏俱全几乎涵盖了传统Win32/MFC桌面应用开发的所有核心知识点从界面布局、图形绘制、事件响应到复杂的游戏逻辑算法、资源管理和状态控制。网上能找到的源码很多但大多注释不清逻辑晦涩新手看了直呼头疼。我将结合一份完整的、结构清晰的源码带大家逐行解析并补充大量实际开发中才会遇到的“坑”和技巧目标是让你不仅能看懂更能自己动手从头实现一个。这个项目适合谁呢如果你是刚学完C语法想进入Windows桌面开发领域的在校学生或者是习惯了.NET WinForms/WPF想深入了解原生Windows编程的开发者亦或是单纯对游戏逻辑实现感兴趣想找一个复杂度适中的项目练手的朋友那么这个实战解析都会让你大有收获。我们将使用经典的Visual Studio 2019/2022社区版对VC运行库支持良好作为开发环境基于MFC对话框应用程序进行开发。整个过程中你会深刻体会到消息驱动编程、自定义绘图、二维数组与算法、以及如何将枯燥的逻辑代码转化为生动的游戏交互。2. 核心架构与MFC工程搭建2.1 工程创建与基础框架选择启动Visual Studio选择“创建新项目”在模板中找到“MFC应用程序”。给项目起个名字比如“LinkGame”。在“应用程序类型”中我们选择“基于对话框”的模式。这是开发这类小型工具或游戏最快捷的方式它自动生成了主窗口对话框和基本的消息映射框架。对于连连看这种界面元素相对固定、交互集中的应用对话框程序比需要处理视图-文档结构的单文档/多文档程序要轻量、直接得多。在“高级功能”中确保勾选了“公共控件清单”这对于使用较新的控件样式是必要的。至于“自动化”、“ActiveX控件”等选项我们这个项目用不到可以取消勾选以保持工程简洁。点击完成后VS会为我们生成一个标准的MFC对话框工程包含LinkGame.cpp、LinkGameDlg.cpp以及对应的头文件和资源文件。注意很多新手会纠结于用纯Win32 API还是MFC。对于连连看我强烈建议从MFC开始。纯Win32代码量巨大容易让人迷失在窗口注册、消息处理的细节中。MFC封装了这些繁琐步骤让我们能更专注于游戏逻辑本身。等这个项目吃透了你再回头去研究MFC封装背后的Win32原理会事半功倍。2.2 资源设计与界面布局游戏的主界面就在自动生成的对话框IDD_LINKGAME_DIALOG上进行设计。我们需要以下核心UI元素游戏区域一个自定义的控件区域用于绘制连连看的棋盘和方块。我们将通过一个CStatic控件作为容器然后在其上自绘来实现。控制按钮如“开始新游戏”、“重新洗牌”、“提示”、“暂停”等。信息显示用于显示当前得分、剩余时间、游戏等级等文本信息。在资源视图里打开对话框编辑器首先将默认的“TODO在此放置对话框控件”静态文本和“确定”、“取消”按钮删除。然后从工具箱拖拽控件进行布局添加一个Picture Control将其ID改为IDC_GAME_AREAType属性设置为“Rectangle” Color属性设置为“White”。这个控件将作为我们的画布。添加多个Button控件分别设置ID和CaptionIDC_BUTTON_START开始游戏IDC_BUTTON_RESHUFFLE重新排列IDC_BUTTON_HINT提示IDC_BUTTON_PAUSE暂停/继续。添加多个Static Text和Edit Control用于显示信息。例如一个静态文本“得分”后面跟一个ID为IDC_EDIT_SCORE的只读编辑框。布局时考虑使用对话框的网格和对齐工具让界面看起来整齐。一个建议的布局是游戏区域占据对话框左侧大部分空间控制按钮和信息面板纵向排列在右侧。布局完成后使用“类向导”为IDC_GAME_AREA这个Picture Control添加一个控件变量类型为CStatic命名为m_wndGameArea。同时为各个按钮的BN_CLICKED消息添加事件处理函数为编辑框添加CString或int类型的值变量如m_strScore。3. 游戏数据模型与核心逻辑设计3.1 方块数据结构的定义连连看的核心是一个二维棋盘每个格子存放一个“方块”信息。我们需要定义一个结构体或类来代表一个方块。在LinkGameDlg.h中添加如下结构体定义// 方块类型定义 struct GameBlock { int type; // 方块的图案类型用整数表示如0-19代表20种图案 int row; // 方块在棋盘中的行索引 int col; // 方块在棋盘中的列索引 CRect rect; // 方块在屏幕上的绘制矩形区域 bool isVisible; // 方块是否可见未被消除 bool isSelected; // 方块是否被选中 };棋盘本身可以用一个二维数组或向量来表示。在对话框类CLinkGameDlg中声明私有成员变量class CLinkGameDlg : public CDialogEx { // ... private: static const int BOARD_ROWS 10; // 棋盘行数 static const int BOARD_COLS 18; // 棋盘列数 std::vectorstd::vectorGameBlock m_gameBoard; // 棋盘数据 int m_nBlockTypes; // 使用的图案种类数 int m_nScore; // 当前得分 int m_nTimeLeft; // 剩余时间秒 bool m_bGameRunning; // 游戏是否进行中 bool m_bGamePaused; // 游戏是否暂停 // ... };这里使用std::vector而非原生数组是为了更方便地管理内存和进行大小调整虽然本例中棋盘大小固定。m_nBlockTypes决定了游戏的难易度种类越多寻找相同图案的难度可能越大。3.2 棋盘初始化与方块生成算法游戏开始时需要初始化棋盘用随机图案填充每一个格子并确保每种图案的数量是偶数以保证理论上所有方块都能被消除。我们在CLinkGameDlg::OnInitDialog()或一个专门的InitGameBoard()函数中实现。bool CLinkGameDlg::InitGameBoard() { m_gameBoard.clear(); m_gameBoard.resize(BOARD_ROWS, std::vectorGameBlock(BOARD_COLS)); // 计算每种图案需要多少个确保总数为偶数 int totalBlocks BOARD_ROWS * BOARD_COLS; int pairsPerType totalBlocks / m_nBlockTypes / 2; // 创建图案池确保每种图案出现偶数次 std::vectorint blockPool; for (int i 0; i m_nBlockTypes; i) { for (int j 0; j pairsPerType * 2; j) { // 乘以2是因为每对两个 blockPool.push_back(i); } } // 如果因为除法取整还有剩余位置用随机图案补齐再补一个相同的 while (blockPool.size() totalBlocks) { int randomType rand() % m_nBlockTypes; blockPool.push_back(randomType); blockPool.push_back(randomType); // 补一对 } // 打乱图案池 std::random_shuffle(blockPool.begin(), blockPool.end()); // 填充棋盘 auto it blockPool.begin(); for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { m_gameBoard[r][c].type *it; m_gameBoard[r][c].row r; m_gameBoard[r][c].col c; m_gameBoard[r][c].isVisible true; m_gameBoard[r][c].isSelected false; // rect的计算依赖于游戏区域控件的大小需要在OnSize或首次绘制时计算 m_gameBoard[r][c].rect.SetRectEmpty(); } } // 计算每个方块的屏幕矩形需要知道游戏区域客户区大小 CalcBlockRects(); return true; }CalcBlockRects()函数根据m_wndGameArea控件当前的大小计算出每个方块对应的屏幕矩形坐标存储在GameBlock::rect中。这是后续进行点击测试和绘制的基础。实操心得随机数种子。如果你在调试时发现每次游戏开始的图案布局都一样那是因为没有设置随机数种子。可以在InitGameBoard()开头调用srand((unsigned int)time(NULL))。但在正式发布时这可能不是最佳体验玩家会希望每次都是全新的布局。一个折中的办法是使用高精度时间戳或结合用户操作作为种子。3.3 连通性判定算法连连看的灵魂这是整个项目的算法核心。两个方块能否消除需要满足1) 类型相同2) 可以用一条直线连接且这条直线最多转折两次即包含0、1或2个拐点。这就是经典的“三折线”判定。我们可以将问题分解为几种情况按复杂度升序尝试直线连通0折线检查两个方块是否在同一行或同一列且中间所有格子都是空的方块已消除或就是目标方块本身。单折线连通1折线想象两个点构成一个矩形如果能找到一个拐点使得从A到拐点是直线无障碍从拐点到B也是直线无障碍则连通。这个拐点只能是A的行、B的列交点或者A的列、B的行交点。双折线连通2折线如果以上都不行则尝试找两个拐点C和D形成A-C-D-B的路径。这可以通过遍历所有可能的“通道点”即空位置来实现但更高效的方法是以A和B为基准向上下左右四个方向“发射”射线找到所有能直线到达的空位作为候选拐点C然后检查从这些候选拐点C是否能直线到达B这又变成了单折线或直线问题。实际上双折线可以理解为两个单折线的组合。下面给出一个经过优化的连通性检查函数框架// 判断两点是否可以直接相连中间无阻碍 bool CLinkGameDlg::IsStraightLinkable(int r1, int c1, int r2, int c2) { if (r1 r2) { // 同行 int start min(c1, c2); int end max(c1, c2); for (int c start 1; c end; c) { if (m_gameBoard[r1][c].isVisible) return false; } return true; } else if (c1 c2) { // 同列 int start min(r1, r2); int end max(r1, r2); for (int r start 1; r end; r) { if (m_gameBoard[r][c1].isVisible) return false; } return true; } return false; // 既不同行也不同列 } // 判断单折线连通 bool CLinkGameDlg::IsOneCornerLinkable(int r1, int c1, int r2, int c2, CPoint corner) { // 拐点1: (r1, c2) if (!m_gameBoard[r1][c2].isVisible) { // 拐点必须是空位 if (IsStraightLinkable(r1, c1, r1, c2) IsStraightLinkable(r1, c2, r2, c2)) { corner.x c2; corner.y r1; return true; } } // 拐点2: (r2, c1) if (!m_gameBoard[r2][c1].isVisible) { if (IsStraightLinkable(r1, c1, r2, c1) IsStraightLinkable(r2, c1, r2, c2)) { corner.x c1; corner.y r2; return true; } } return false; } // 判断双折线连通 bool CLinkGameDlg::IsTwoCornerLinkable(int r1, int c1, int r2, int c2, CPoint corner1, CPoint corner2) { // 思路从A点向四个方向延伸找到所有能直线到达的空位候选拐点C // 然后对于每个C判断C到B是否满足单折线或直线连通即把C当作新的起点 // 向上延伸 for (int r r1 - 1; r 0; --r) { if (m_gameBoard[r][c1].isVisible) break; // 遇到障碍停止 CPoint cCorner; if (IsOneCornerLinkable(r, c1, r2, c2, cCorner)) { corner1.x c1; corner1.y r; corner2 cCorner; return true; } // 如果从(r, c1)能直线连到B也属于双折线的一种特殊情况C和D重合或路径为直线 if (IsStraightLinkable(r, c1, r2, c2)) { corner1.x c1; corner1.y r; corner2.x c2; corner2.y r2; // 可以设为B点或另一个点绘图时处理 return true; } } // 向下、向左、向右延伸... 代码结构类似 // ... return false; } // 总的连通性判断入口函数 LinkType CLinkGameDlg::CheckLinkability(int r1, int c1, int r2, int c2, CPoint linkPoint1, CPoint linkPoint2) { if (r1 r2 c1 c2) return LINK_INVALID; if (m_gameBoard[r1][c1].type ! m_gameBoard[r2][c2].type) return LINK_INVALID; if (!m_gameBoard[r1][c1].isVisible || !m_gameBoard[r2][c2].isVisible) return LINK_INVALID; if (IsStraightLinkable(r1, c1, r2, c2)) { return LINK_STRAIGHT; // 直线连接 } CPoint corner; if (IsOneCornerLinkable(r1, c1, r2, c2, corner)) { linkPoint1 corner; return LINK_ONE_CORNER; // 单折线 } CPoint corner1, corner2; if (IsTwoCornerLinkable(r1, c1, r2, c2, corner1, corner2)) { linkPoint1 corner1; linkPoint2 corner2; return LINK_TWO_CORNER; // 双折线 } return LINK_INVALID; }这个算法是连连看游戏效率的关键。在棋盘较大时双折线判断的复杂度较高但通过上述“射线延伸”的优化可以避免遍历棋盘上所有空点性能完全可接受。4. 图形绘制与用户交互实现4.1 自定义绘制游戏区域MFC中在控件上绘图通常通过处理WM_PAINT消息实现。我们需要为IDC_GAME_AREA这个CStatic控件派生一个自定义类比如CGameAreaStatic并重写其OnPaint()函数。更简单的方法是在对话框类中处理WM_DRAWITEM消息或者直接让对话框在OnPaint()里绘制整个区域。但为了更好的封装性我推荐使用自定义控件类。创建新类CGameAreaStatic继承自CStatic。在类向导中重写OnPaint()。void CGameAreaStatic::OnPaint() { CPaintDC dc(this); // 设备上下文用于绘制 CRect rectClient; GetClientRect(rectClient); // 1. 绘制背景 dc.FillSolidRect(rectClient, RGB(240, 240, 220)); // 浅色背景 // 2. 绘制棋盘网格线可选 CPen gridPen(PS_SOLID, 1, RGB(200, 200, 180)); CPen* pOldPen dc.SelectObject(gridPen); // ... 计算网格位置并画线 dc.SelectObject(pOldPen); // 3. 绘制所有可见的方块 CLinkGameDlg* pParent dynamic_castCLinkGameDlg*(GetParent()); if (pParent pParent-m_bGameRunning) { const auto board pParent-GetGameBoard(); // 假设有获取棋盘的接口 for (const auto row : board) { for (const auto block : row) { if (block.isVisible) { DrawBlock(dc, block); } } } } // 4. 如果有点选效果或连接线也在这里绘制 // ... }DrawBlock函数负责绘制单个方块。可以使用GDI函数绘制圆角矩形并填充颜色或贴图。为了美观我们可以加载位图资源作为方块图案。void CGameAreaStatic::DrawBlock(CDC dc, const GameBlock block) { CRect rect block.rect; // 绘制方块背景带立体感 if (block.isSelected) { dc.FillSolidRect(rect, RGB(255, 255, 200)); // 选中状态高亮 } else { // 使用渐变或两种颜色模拟立体感 dc.FillSolidRect(rect, RGB(255, 255, 255)); CPen lightPen(PS_SOLID, 2, RGB(240, 240, 240)); CPen darkPen(PS_SOLID, 2, RGB(160, 160, 160)); // 画左上亮边右下暗边 // ... } // 绘制图案 // 方法1使用预加载的位图CBitmap if (m_blockBitmaps[block.type].GetSafeHandle()) { CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap* pOldBmp memDC.SelectObject(m_blockBitmaps[block.type]); dc.BitBlt(rect.left 2, rect.top 2, rect.Width() - 4, rect.Height() - 4, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); } else { // 方法2简单绘制数字或几何图形 CString strType; strType.Format(_T(%d), block.type); dc.SetTextColor(RGB(0, 0, 0)); dc.SetBkMode(TRANSPARENT); dc.DrawText(strType, rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } // 绘制边框 CPen borderPen(PS_SOLID, 1, block.isSelected ? RGB(255, 100, 100) : RGB(100, 100, 100)); CPen* pOldPen dc.SelectObject(borderPen); dc.SelectStockObject(NULL_BRUSH); dc.Rectangle(rect); dc.SelectObject(pOldPen); }位图资源需要在对话框初始化时OnInitDialog加载到m_blockBitmaps数组中。可以使用CBitmap::LoadBitmap加载资源ID。4.2 鼠标点击与游戏逻辑响应用户通过鼠标点击来选中方块。我们需要在自定义控件类CGameAreaStatic中处理WM_LBUTTONDOWN消息。void CGameAreaStatic::OnLButtonDown(UINT nFlags, CPoint point) { // 1. 将屏幕坐标点转换为棋盘行列索引 CLinkGameDlg* pParent dynamic_castCLinkGameDlg*(GetParent()); if (!pParent || !pParent-m_bGameRunning || pParent-m_bGamePaused) { return; } int row -1, col -1; if (!HitTest(point, row, col)) { // HitTest函数判断点中了哪个方块 // 点中了空白处取消当前选中如果有 pParent-ClearSelection(); return; } // 2. 获取点中的方块 GameBlock clickedBlock pParent-m_gameBoard[row][col]; if (!clickedBlock.isVisible) { return; // 点中了已消除的方块忽略 } // 3. 处理选中逻辑 GameBlock* pFirstSelected pParent-GetFirstSelectedBlock(); // 获取之前选中的第一个方块 if (pFirstSelected nullptr) { // 这是第一次选中高亮该方块 clickedBlock.isSelected true; pParent-SetFirstSelectedBlock(clickedBlock); } else { // 这是第二次选中 if (pFirstSelected clickedBlock) { // 点中了同一个方块取消选中 clickedBlock.isSelected false; pParent-ClearSelection(); } else { // 点中了另一个方块尝试连接消除 if (pParent-TryToLink(pFirstSelected-row, pFirstSelected-col, row, col)) { // 连接成功 // 播放音效、加分、绘制连接线动画... pParent-AddScore(10); // 加分 // 消除方块 pParent-m_gameBoard[pFirstSelected-row][pFirstSelected-col].isVisible false; pParent-m_gameBoard[row][col].isVisible false; // 检查游戏是否结束无可见方块或无可消除对 if (pParent-IsGameFinished()) { pParent-OnGameFinished(); } } else { // 连接失败取消前一个的选中状态选中当前这个 pFirstSelected-isSelected false; clickedBlock.isSelected true; pParent-SetFirstSelectedBlock(clickedBlock); // 可以播放一个提示错误的音效 } } } // 4. 强制重绘更新界面 Invalidate(FALSE); CStatic::OnLButtonDown(nFlags, point); }TryToLink函数内部会调用前面提到的CheckLinkability算法。如果连通除了消除方块还应该绘制一条连接线从A到B经过拐点并伴有短暂的延时动画这能极大提升游戏体验。绘制连接线可以在OnPaint中根据一个“当前连接线”的成员变量来进行。4.3 游戏状态管理与辅助功能一个完整的游戏还需要以下功能计时器使用SetTimer设置一个1秒触发一次的定时器在OnTimer中更新m_nTimeLeft并刷新显示。时间到则游戏结束。得分计算消除一对基础分连续快速消除可以设计连击加分。提示功能遍历所有可见方块寻找任意一对可以连接的方块。找到后高亮显示这两个方块或闪烁提示。重新排列洗牌当玩家无路可走时可以将所有剩余方块随机打乱位置保持类型不变。实现时收集所有可见方块的类型到一个列表打乱列表再按顺序填回棋盘。暂停/继续设置m_bGamePaused标志暂停时停止计时器忽略鼠标点击事件。音效使用MCI或PlaySoundAPI播放简单的WAV音效用于选中、消除、错误提示、游戏结束等时刻。5. 性能优化、调试与扩展思考5.1 绘图性能优化当棋盘较大、方块较多时频繁的全区域重绘Invalidate()可能导致闪烁。解决方法双缓冲绘图这是消除闪烁最有效的方法。在OnPaint中先在内存DC上绘制整个场景然后一次性BitBlt到屏幕DC。void CGameAreaStatic::OnPaint() { CPaintDC dcScreen(this); CRect rectClient; GetClientRect(rectClient); // 创建兼容内存DC和位图 CDC dcMem; CBitmap bmpMem; dcMem.CreateCompatibleDC(dcScreen); bmpMem.CreateCompatibleBitmap(dcScreen, rectClient.Width(), rectClient.Height()); CBitmap* pOldBmp dcMem.SelectObject(bmpMem); // 在内存DC上绘制 dcMem.FillSolidRect(rectClient, RGB(240, 240, 220)); // ... 绘制所有游戏元素 // 一次性拷贝到屏幕 dcScreen.BitBlt(0, 0, rectClient.Width(), rectClient.Height(), dcMem, 0, 0, SRCCOPY); dcMem.SelectObject(pOldBmp); }局部更新如果只是某个方块状态改变如被选中或消除可以只重绘该方块所在的区域使用InvalidateRect(blockRect, FALSE)。资源管理图案位图只加载一次保存在数组中避免每次绘制都从资源加载。5.2 常见问题与调试技巧点击不准HitTest函数逻辑错误。确保CalcBlockRects计算正确且考虑了控件的边框、边距。调试时可以在OnPaint中临时绘制每个方块的矩形边框看是否与鼠标点击区域匹配。连接算法漏判这是最复杂的问题。调试时可以临时创建一个“调试模式”当按住Ctrl键点击时在输出窗口打印两个方块的坐标和连通性检查的每一步结果。重点检查边界条件棋盘边缘和障碍物判断逻辑IsStraightLinkable。内存泄漏虽然使用了std::vector但如果你在自定义控件中使用了CBitmap等GDI对象务必在析构函数中正确释放。确保每一个CreateCompatibleDC都有对应的DeleteDC每一个SelectObject都有恢复旧对象的操作。运行时库依赖如果你的程序在别人的电脑上运行提示缺少MSVCP140.dll或vcruntime是因为发布时没有打包VC运行库。可以在项目属性中设置“MT”运行时库/MT将运行库静态链接到你的EXE中这样生成的文件会变大但无需额外安装运行库。或者引导用户安装对应的Visual C Redistributable。5.3 项目扩展方向完成基础版本后你可以尝试以下扩展让项目更具挑战性和简历价值多主题与皮肤设计不同的方块图案集动物、水果、图标允许玩家切换。关卡设计引入不同的地图布局非矩形有空洞、障碍物、需要连接多次才能消除的特殊方块。特效与动画为方块的消除、移动洗牌时、连接线添加平滑的动画效果。这需要用到定时器进行逐帧绘制。网络对战升级为Socket编程项目实现两人在线对战。这涉及到游戏状态同步、网络协议设计等更复杂的知识。AI对手实现一个自动寻路的AI可以作为一个“提示”的增强版或者作为单人解谜游戏的自动求解器。开发这样一个完整的连连看游戏就像搭建一个微型的软件工程。你需要考虑数据层模型、表现层视图、控制层控制器虽然MFC本身是文档-视图架构但我们在对话框应用中也可以很好地实践这种分离思想。把游戏逻辑如CheckLinkability放在独立的类或命名空间中与界面代码CGameAreaStatic解耦会让代码更清晰、更易于测试和维护。希望这份超详细的解析和实战指南能帮你真正吃透VC桌面开发的核心并享受从零创造一款游戏的乐趣。