VC++ MFC连连看游戏开发实战:从GDI绘图到连通算法

发布时间:2026/7/9 22:01:45
VC++ MFC连连看游戏开发实战:从GDI绘图到连通算法 1. 项目概述从零到一用VC打造你的第一个桌面游戏最近在整理旧硬盘翻出了大学时用Visual C 6.0写的一个连连看小游戏。看着那略显粗糙的界面和现在看来有些“复古”的MFC代码不禁感慨万千。对于很多C初学者尤其是从学校课程或经典教材入门的开发者来说Visual C尤其是搭配MFC框架往往是接触Windows桌面图形编程的第一站。而“连连看”这个游戏规则简单直观逻辑清晰但背后涉及的界面绘制、消息处理、算法实现等知识点却非常全面堪称是练手桌面开发的“Hello World”升级版。这个项目标题“基于Visual C的连连看游戏开发教程”其核心价值在于它不仅仅是一个功能实现指南更是一个完整的、可落地的Windows桌面应用开发实战案例。它解决的不仅仅是“如何写一个连连看游戏”更深层次的是“如何用经典的VC/MFC技术栈独立完成一个包含图形界面、用户交互、业务逻辑和资源管理的完整项目”。这个过程会让你对Windows消息循环、GDI绘图、控件使用、数据结构与算法在具体场景下的应用有一个非常扎实的理解。无论你是正在学习《Windows程序设计》课程的学生还是希望从控制台转向图形界面开发的C爱好者亦或是想重温经典开发模式的老程序员跟着这个思路走一遍收获都会远超一个游戏本身。你会发现那些书本上抽象的概念如“消息映射”、“设备上下文”、“矩形碰撞检测”在连连看的方块连接与消除中都变得具体而生动。接下来我就以现代Visual Studio如VS2019/2022配合MFC为例拆解整个开发过程分享其中关键的技术选型、实现细节以及我当年踩过的那些坑。2. 核心设计思路与架构选型2.1 为什么选择MFC而非其他框架首先必须回答一个问题在2023年为什么还要用看似“过时”的MFC来做这个项目直接使用Qt、wxWidgets甚至Unity不是更现代吗这里的选择背后有很强的教学和实战意义。技术栈的纯粹性与深度Visual C与MFC是微软原生Windows开发技术栈的经典代表。通过它你能最直接地接触到Win32 API和Windows操作系统的核心机制比如窗口过程Window Procedure、消息泵Message Pump、GDI绘图。理解这些底层原理对于你今后使用任何其他高级框架包括Qt其底层也绕不开Win32都有莫大的好处。你会明白一个按钮点击事件到底是怎么从操作系统传递到你的代码里的。开发环境的集成度Visual Studio对MFC的支持是“亲儿子”级别的。资源编辑器Resource Editor可以让你以“所见即所得”的方式设计对话框、菜单、图标这些资源会被自动集成到工程中并通过一套清晰的宏如IDD_MY_DIALOG进行管理。这种从界面设计到代码绑定的流畅体验是理解Windows应用资源管理机制的绝佳范例。项目的复杂度适中连连看不需要复杂的3D渲染或跨平台支持其核心是2D图形和逻辑判断。MFC的文档/视图Doc/View架构对于这个小项目来说可能稍显重量级但我们完全可以采用更简单的“对话框为主应用程序”Dialog-based Application模式这能让我们聚焦于游戏逻辑本身而不被框架的复杂性干扰。注意我强烈建议使用Visual Studio 2019或2022即使你选择使用MFC。新版VS对C标准支持更好代码编辑体验更佳并且创建MFC项目时依然保留经典向导。避免使用古老的VC 6.0它在现代系统上兼容性差且C标准支持落后。2.2 游戏数据模型设计如何表示棋盘这是所有逻辑的基石。连连看的棋盘本质上是一个二维网格每个格子要么是空的要么放置着一种图案。我们需要一个数据结构来在内存中表示它。方案选择二维数组是最直观的答案。我们可以使用一个int类型的二维数组例如int map[ROW][COL]。其中ROW和COL是行数和列数比如10x12。数组中的每个整数代表一种图案类型0通常代表空格子1~N代表不同的图案。为什么不用更复杂的对象或结构体在这个阶段简单就是美。图案的显示图片可以通过这个整数值作为索引去加载对应的位图资源。关于“连通性”判断的预先思考这是连连看算法的核心。在设计数据模型时我们就要为后续算法铺路。我们需要快速判断两个格子是否可以通过一条直线、两条直线或三条直线相连且路径上所有格子为空。这要求我们的数据模型能支持高效的“邻居”访问和状态是否为空查询。二维数组的随机访问时间复杂度是O(1)完美符合要求。一个关键的优化点边界处理。在判断路径时我们经常需要向上下左右四个方向探索。如果不对数组边界进行检查就会导致内存访问越界。一种常见的技巧是在棋盘实际数据区域外额外增加一圈“哨兵”格子Sentinel比如声明一个int map[ROW2][COL2]并将最外一圈的值始终设为“墙”例如一个特殊值-1这样在路径搜索时无需每次都判断坐标是否在[0, ROW-1]和[0, COL-1]范围内只需判断是否碰到“墙”即可代码会更简洁效率也略有提升。这是我当年从一本算法书上学到的技巧在实现后感觉代码清爽了不少。3. 核心模块实现与关键技术点3.1 界面绘制GDI绘图与双缓冲技术游戏界面需要在窗口客户区绘制出棋盘、网格线和各种图案。我们将主要在对话框的OnPaint()消息处理函数中或者在一个自定义的CStatic控件中进行绘制。基本绘图步骤获取设备上下文DC通过CPaintDC dc(this)在OnPaint中或CClientDC dc(this)获取绘图DC。绘制背景和网格使用dc.Rectangle()或dc.FillSolidRect()绘制背景色。然后通过循环用dc.MoveTo()和dc.LineTo()绘制横竖网格线。绘制图案这是重点。我们需要将内存中的map[i][j]值转换为屏幕上的图片。通常的做法是准备一套位图资源Bitmap每个图案一个。在资源视图中导入多个小位图如32x32像素并赋予它们不同的ID如IDB_BITMAP1,IDB_BITMAP2。加载与绘制位图// 通常在类头文件中声明 CBitmap m_bmpFigures[NUM_FIGURE_TYPES]; // 图案位图数组 CDC m_dcMem; // 内存DC用于位图选入 // 初始化函数中加载位图资源 for(int i0; iNUM_FIGURE_TYPES; i) { m_bmpFigures[i].LoadBitmap(IDB_BITMAP1 i); // 假设ID是连续的 } // 创建兼容的内存DC CClientDC dc(this); m_dcMem.CreateCompatibleDC(dc); // 在绘制函数中 for(int i0; iROW; i) { for(int j0; jCOL; j) { int figureType map[i][j]; if(figureType 0) { // 非空格子 // 1. 将对应的位图选入内存DC CBitmap* pOldBmp m_dcMem.SelectObject(m_bmpFigures[figureType-1]); // 2. 计算屏幕坐标 int x j * CELL_WIDTH; int y i * CELL_HEIGHT; // 3. 从内存DC贴图到显示DC dc.BitBlt(x, y, CELL_WIDTH, CELL_HEIGHT, m_dcMem, 0, 0, SRCCOPY); // 4. 恢复内存DC原有位图 m_dcMem.SelectObject(pOldBmp); } } }至关重要的双缓冲技术如果你直接在上面提到的OnPaint里绘制当画面元素较多或刷新频繁时会出现严重的闪烁现象。这是因为屏幕是直接逐元素更新的人眼会看到中间的绘制过程。解决这个问题的标准方案就是双缓冲。双缓冲原理我们不在屏幕DC上直接画而是先在一个“后台”的内存DC上把整个画面画好然后一次性将内存DC的内容“贴”到屏幕DC上。这样屏幕更新只有一次完全消除了闪烁。MFC中的双缓冲实现void CMyGameDlg::OnPaint() { CPaintDC dcScreen(this); // 实际屏幕DC CRect rectClient; GetClientRect(rectClient); // 1. 创建兼容的内存DC和位图 CDC dcMem; CBitmap bmpMem; dcMem.CreateCompatibleDC(dcScreen); bmpMem.CreateCompatibleBitmap(dcScreen, rectClient.Width(), rectClient.Height()); CBitmap* pOldBmpMem dcMem.SelectObject(bmpMem); // 2. 先在内存DC上绘制整个背景和内容 dcMem.FillSolidRect(rectClient, RGB(240, 240, 220)); // 背景色 // ... 这里调用你自己的绘制函数但传入的是 dcMem 而不是 dcScreen DrawGameBoard(dcMem); // 3. 一次性将内存DC内容拷贝到屏幕DC dcScreen.BitBlt(0, 0, rectClient.Width(), rectClient.Height(), dcMem, 0, 0, SRCCOPY); // 4. 清理资源 dcMem.SelectObject(pOldBmpMem); bmpMem.DeleteObject(); dcMem.DeleteDC(); }这个技巧是桌面图形编程的必修课几乎任何涉及动态绘图的MFC程序都需要它。我第一次没用时被闪烁问题折磨了很久。3.2 用户交互鼠标点击与选中状态反馈玩家通过鼠标点击格子来进行选择。我们需要处理WM_LBUTTONDOWN消息。获取点击位置对应的棋盘坐标鼠标消息的坐标是相对于窗口客户区的像素坐标我们需要将其转换为棋盘的行列索引。void CMyGameDlg::OnLButtonDown(UINT nFlags, CPoint point) { // point 是鼠标点击的像素坐标 int col point.x / CELL_WIDTH; int row point.y / CELL_HEIGHT; // 检查坐标是否在有效棋盘范围内 if (row 0 row ROW col 0 col COL) { // 处理格子点击逻辑 ProcessCellClick(row, col); } CDialogEx::OnLButtonDown(nFlags, point); }选中状态的视觉反馈当玩家点击一个有效图案格子时我们需要高亮它通常是在格子周围画一个醒色的矩形框。这需要在绘制函数中增加判断逻辑。void DrawGameBoard(CDC dc) { // ... 循环绘制每个格子 for(int i0; iROW; i) { for(int j0; jCOL; j) { // 绘制图案... // 如果这个格子是当前被选中的第一个或第二个格子 if( (i selectedRow1 j selectedCol1) || (i selectedRow2 j selectedCol2) ) { // 绘制一个高亮矩形框 CPen pen(PS_SOLID, 3, RGB(255, 0, 0)); // 红色3像素宽 CPen* pOldPen dc.SelectObject(pen); dc.SelectStockObject(NULL_BRUSH); // 空心画笔 dc.Rectangle(j*CELL_WIDTH, i*CELL_HEIGHT, (j1)*CELL_WIDTH, (i1)*CELL_HEIGHT); dc.SelectObject(pOldPen); } } } }这里有一个细节绘制高亮框时要注意绘制顺序。通常应该在绘制完图案之后再画框否则框可能会被图案覆盖。或者更稳妥的方法是先画框作为背景再画图案。3.3 核心算法实现连通性判断这是整个游戏的“大脑”。算法需要判断两个格子是否满足“连连看”的规则最多通过两次拐弯即三条直线段相连且路径经过的格子必须全部为空值为0。算法思路广度优先搜索BFS思想 我们可以将问题转化为从起点A出发在只能走直线水平或垂直且只能经过空格子的前提下能否在拐弯次数不超过2次的情况下到达终点B。一个清晰且易于实现的算法是“方向扫描法”或“分类讨论法”它避免了复杂的图搜索更符合人脑判断的直觉代码也相对好写。其核心思想是两个点能连通的情况只有三种直线连通两个点在同一行或同一列且中间所有格子为空。一个拐角连通存在一个中间点C使得A到C直线连通且C到B直线连通。两个拐角连通存在两个中间点C和D使得A到C直线连通C到D直线连通且D到B直线连通。这等价于从A点出发沿着水平或垂直方向走到边界或障碍物得到的“延伸线”与从B点出发得到的“延伸线”有交点且该交点可分别与A、B直线连通。我推荐的实现方式是分步验证 首先实现一个辅助函数bool IsStraightLink(int r1, int c1, int r2, int c2)用于判断两点是否能直线连通中间全为空。 然后在主判断函数中步骤一直接调用IsStraightLink成功则返回真。步骤二寻找一个拐点。遍历所有可能的拐点理论上可以是棋盘所有空格但可以优化检查IsStraightLink(A, C) IsStraightLink(C, B)。步骤三寻找两个拐点。这可以通过检查A点的“水平延伸线”和“垂直延伸线”与B点的“水平延伸线”和“垂直延伸线”是否有交点来实现。具体来说从A点向左、右水平移动直到碰到非空格子或边界记录这条水平线能到达的所有列坐标其实是一个区间。从A点向上、下垂直移动记录这条垂直线能到达的所有行坐标。对B点做同样的操作。然后检查A的水平线 与 B的垂直线 的交点如果存在是否可分别与A、B直线连通或者A的垂直线 与 B的水平线 的交点是否满足条件直线连通判断函数示例bool CGameLogic::IsStraightLink(int r1, int c1, int r2, int c2) { if (r1 r2) { // 同一行 int colStart min(c1, c2); int colEnd max(c1, c2); for (int col colStart 1; col colEnd; col) { if (m_map[r1][col] ! 0) { // 遇到障碍 return false; } } return true; } else if (c1 c2) { // 同一列 int rowStart min(r1, r2); int rowEnd max(r1, r2); for (int row rowStart 1; row rowEnd; row) { if (m_map[row][c1] ! 0) { return false; } } return true; } return false; // 既不同行也不同列 }这个算法虽然不是理论上的最优解时间复杂度稍高但对于10x12的棋盘来说完全足够计算量极小而且逻辑清晰易于调试和扩展例如增加“通道”等特殊规则。在实现时一定要把棋盘边界和“哨兵”的考虑加进去。4. 游戏逻辑整合与功能完善4.1 游戏状态管理与流程控制一个完整的游戏需要状态管理。我们至少需要以下几个状态STANDBY等待第一次点击、FIRST_SELECTED已选中第一个方块、ANIMATING正在播放消除动画、CHECKING_NO_MOVE检查是否无解。我们可以用一个枚举变量来记录当前状态。核心游戏循环由鼠标点击驱动void CMyGameDlg::ProcessCellClick(int row, int col) { if (m_gameState ANIMATING) return; // 动画播放中忽略点击 int cellValue m_map[row][col]; if (cellValue 0) return; // 点击的是空位无效 switch (m_gameState) { case STANDBY: // 选中第一个方块 m_selectedRow1 row; m_selectedCol1 col; m_gameState FIRST_SELECTED; InvalidateRect(GetCellRect(row, col)); // 请求重绘该区域显示高亮 break; case FIRST_SELECTED: // 选中第二个方块 // 1. 检查是否是同一个方块 if (row m_selectedRow1 col m_selectedCol1) { // 点击了同一个取消选中 m_gameState STANDBY; InvalidateRect(GetCellRect(row, col)); break; } // 2. 检查两个方块的图案是否相同 if (m_map[row][col] ! m_map[m_selectedRow1][m_selectedCol1]) { // 图案不同重新选择 InvalidateRect(GetCellRect(m_selectedRow1, m_selectedCol1)); // 取消旧的高亮 m_selectedRow1 row; m_selectedCol1 col; // 状态仍为 FIRST_SELECTED但选中项已更新 InvalidateRect(GetCellRect(row, col)); break; } // 3. 图案相同判断是否能连通 if (IsLinkable(m_selectedRow1, m_selectedCol1, row, col)) { // 可以消除 m_gameState ANIMATING; // 记录第二个选中位置 m_selectedRow2 row; m_selectedCol2 col; // 启动消除动画或直接消除 StartEliminateAnimation(); } else { // 无法连通给个提示比如播放错误音效然后只选中第二个 PlayErrorSound(); InvalidateRect(GetCellRect(m_selectedRow1, m_selectedCol1)); m_selectedRow1 row; m_selectedCol1 col; InvalidateRect(GetCellRect(row, col)); } break; } }InvalidateRect是MFC中触发局部重绘的关键函数它比Invalidate()重绘整个客户区更高效。GetCellRect是一个辅助函数根据行列号计算出该格子对应的屏幕矩形区域。4.2 动画与效果让消除更有感觉直接让方块消失会显得很生硬。加入简单的动画能极大提升游戏体验。最简单的消除动画渐隐或缩放。我们可以用定时器WM_TIMER来实现。当需要消除一对格子时设置游戏状态为ANIMATING。启动一个定时器例如SetTimer(1, 50, NULL)每50毫秒触发一次。在定时器消息处理函数OnTimer中更新一个表示动画进度如透明度或缩放比例的变量。在绘制函数DrawGameBoard中如果格子正在被消除就根据当前动画进度来绘制例如绘制一个逐渐变透明或变小的图案。当动画进度达到100%时清除定时器KillTimer(1)将棋盘上这两个格子的值设为0真正消除游戏状态回归STANDBY并重绘棋盘。连线提示动画在判断连通后在消除前可以绘制一条连接线。这需要你在IsLinkable函数中不仅返回true/false还能返回连线的路径点列表拐点坐标。然后在OnPaint中根据这些点用dc.Polyline或分段画线的方式绘制出来。连线动画也可以配合定时器做成从A点到B点逐渐画出的效果。实操心得动画不宜复杂时间不宜过长200-300毫秒为宜。复杂的动画会占用大量CPU进行重绘。一个优化技巧是在动画期间只对发生变化的区域即两个消除的格子及其连线路径调用InvalidateRect而不是整个窗口。这能显著降低CPU占用。4.3 游戏功能扩展洗牌、提示与倒计时基础功能完成后可以增加一些常见功能来完善游戏。1. 洗牌重排 当玩家觉得当前局面无法继续时可以提供洗牌功能。实现很简单将当前棋盘上所有非零的图案值收集到一个一维数组中然后用std::random_shuffleC14前或std::shuffleC11后进行随机打乱再按顺序填回棋盘。关键点洗牌后必须确保至少存在一对可以消除的方块否则游戏会立即陷入死局。一个保守的做法是洗牌后调用一次“自动解算”函数检查是否存在可消除对如果没有则再次洗牌或微调。2. 提示 提示功能的核心是自动寻找当前棋盘上一对可消除的方块。最直接的方法是暴力遍历对所有非空格子两两组合判断是否连通。对于N个非空格子时间复杂度是O(N²)对于100个左右的格子计算量完全可以接受。找到第一对可连通的就返回它们的坐标并在界面上高亮显示。3. 倒计时与积分 增加一个游戏时间限制如120秒和积分系统。积分可以基于消除速度、连续消除Combo等来计算。这需要用到另一个定时器来每秒更新剩余时间并在时间到或棋盘清空时弹出结算对话框。资源管理与音效 使用MFC的资源文件.rc来管理位图、图标、对话框模板和声音文件WAV格式。音效点击、消除、错误、胜利可以通过MCImciSendString或更现代的PlaySoundAPI来播放。将音效文件导入资源通过MAKEINTRESOURCE宏来引用。// 播放嵌入资源的WAV声音 PlaySound(MAKEINTRESOURCE(IDR_SOUND_ELIMINATE), AfxGetResourceHandle(), SND_RESOURCE | SND_ASYNC);音效能极大增强游戏的沉浸感但要注意音量平衡避免突兀。5. 项目调试、优化与打包发布5.1 常见问题与调试技巧在开发过程中你肯定会遇到各种问题。以下是一些典型问题及排查思路1. 程序运行崩溃特别是在绘图或点击时。可能性最大数组越界。这是C/C新手最常犯的错误。仔细检查所有访问m_map[row][col]的地方确保row和col的索引值在[0, ROW-1]和[0, COL-1]范围内。强烈建议使用ASSERT宏进行调试断言。ASSERT(row 0 row ROW); ASSERT(col 0 col COL); int value m_map[row][col];资源未加载或加载失败。确保位图资源的ID在代码中引用正确并且资源文件.rc已正确包含在项目中。如果使用LoadBitmap失败可以检查其返回值。空指针或未初始化对象。确保所有CDC、CBitmap等GDI对象在使用前已被正确创建CreateCompatibleDC,LoadBitmap等。2. 画面闪烁严重。未使用双缓冲。这是导致闪烁的唯一主要原因。请务必按照3.1节实现完整的双缓冲绘图。无效区域过大。如果你总是调用Invalidate()而不是InvalidateRect()会导致整个窗口区域重绘即使只改变了一个格子。优化绘制逻辑只重绘需要更新的区域。3. 连通性判断算法在某些情况下出错。边界条件处理不当。测试时故意点击棋盘最边缘的方块进行连接看算法是否正确。“哨兵”值冲突。如果你使用了哨兵技巧确保哨兵值如-1不会与正常的图案值1~N冲突并且在所有判断逻辑中如IsStraightLink都正确处理了哨兵。逻辑漏洞。重点测试“一个拐角”和“两个拐角”的情况。可以写一个简单的测试函数用固定的棋盘布局来验证算法的正确性。4. 内存泄漏。GDI对象泄漏是MFC程序常见问题。确保每一个CreateCompatibleDC、CreateCompatibleBitmap、LoadBitmap都有对应的DeleteDC或DeleteObject。遵循“谁创建谁删除”的原则。在对话框的OnDestroy或类的析构函数中清理资源。CMyGameDlg::~CMyGameDlg() { for(auto bmp : m_bmpFigures) { bmp.DeleteObject(); } m_dcMem.DeleteDC(); }5.2 性能优化与小技巧虽然连连看是个小游戏但良好的编程习惯很重要。1. 避免在绘制循环中频繁创建和销毁GDI对象。像画笔CPen、画刷CBrush、字体CFont这些对象如果绘图样式固定应该在初始化阶段如OnInitDialog创建好并作为成员变量保存起来在绘制函数中直接使用。而不是在OnPaint里每次new一个画完就delete。2. 使用局部变量缓存常用计算。例如在绘制棋盘的循环中CELL_WIDTH和CELL_HEIGHT是常量但i * CELL_HEIGHT这样的计算会重复很多次。可以在循环外计算好起始坐标或者在循环内用临时变量存储计算结果。3. 提示算法的优化。暴力遍历O(N²)在格子数多时可能成为瓶颈。一个优化思路是“按图案类型分组”。首先遍历棋盘将相同图案的格子坐标归类到同一个列表std::vectorstd::pairint,int中。然后对于每一种图案只在该图案的格子列表内部进行两两判断。因为不同图案的格子肯定不能消除。这样平均能减少很多无效判断。5.3 项目打包与发布当你完成开发并测试无误后可能想分享给朋友。1. 编译配置在Visual Studio中将解决方案配置从“Debug”切换到“Release”。Release版本经过了编译器优化去除了调试信息体积更小运行更快。在“项目属性” - “C/C” - “代码生成”中确保运行库是“多线程(/MT)”或“多线程DLL(/MD)”。使用/MT会将运行时库静态链接生成单个exe兼容性最好但体积稍大。/MD需要目标机器有对应的VC运行库。2. 依赖项检查如果你的程序使用了/MD或者需要一些特殊的DLL你需要确保目标电脑上有这些库。最简单的办法是静态链接/MT。你可以通过工具“Dependencies Walker”Depends.exe来查看你的exe文件依赖哪些DLL。3. 打包资源确保exe文件与所需的资源文件如图片、声音在同一个目录或者你已经将资源全部编译进了exe就像我们之前用资源文件做的那样。对于图片编译进exe是最干净的方式。对于外部配置文件如关卡文件则需要随exe一起分发。4. 创建一个简单的安装包可以使用免费的安装包制作工具如Inno Setup或NSIS将你的exe、必要的DLL和资源文件打包成一个安装程序并可以创建开始菜单快捷方式和桌面图标显得更专业。从一行代码开始到最终生成一个可以独立运行的、带有图形界面和完整逻辑的桌面应用程序这个过程的成就感是无与伦比的。基于Visual C和MFC的连连看项目就像一把钥匙帮你打开了Windows桌面开发的大门。理解了消息循环、GDI绘图、资源管理和基础算法在这些具体场景下的应用你再去看其他更复杂的框架或项目会发现很多概念都是相通的。编程的乐趣就在于这种从无到有、将想法一步步实现的过程。希望这个详细的拆解能帮你少走一些弯路更快地享受到这种乐趣。如果在实现过程中遇到具体问题不妨回头看看数据边界、双缓冲和算法逻辑这三个最容易出错的环节祝你好运