简介基于MFC框架的连连看游戏设计源码非常适合C入门者、Windows桌面应用开发者及游戏设计初学者作为实战项目研读。源码实现了经典连连看的完整流程包括图案匹配、路径连接判断、消除与胜负判定、计时计分等模块能帮助读者理解MFC的对话框程序结构、消息映射机制以及位图资源在游戏界面中的应用。压缩包共27个文件核心部分为7个头文件、4个C源文件和6个位图文件其余是解决方案与项目配置、图标、许可和版本控制等辅助文件整体仅3.8MB项目规模适中便于逐文件阅读与二次开发。已有100人学习下载。通过该工程可掌握一个真实MFC游戏从界面绘制到逻辑处理的全链路实现方式学习如何组织头文件、源文件和资源文件并了解基于Visual Studio的工程配置思路是一份兼顾原理、代码与结构的实战型参考资料。1. 基于MFC框架的连连看游戏设计源码这东西不是玩具是 Windows 桌面开发的完整练手项目很多人一看到“MFC 连连看”就觉得是学生课程设计里凑数的东西这看法其实亏了。连连看这个游戏体量刚好卡在“一个对话框放几个按钮”和“完整商业软件”之间它要你处理 GDI 绘图、消息映射、鼠标交互、数据结构设计、算法判定还要把界面刷新做得不闪不卡。把这些都走一遍你才算真正碰过 Windows 原生 GUI 开发的底层逻辑。MFC 虽然老但 Windows 上大量的工业软件、内部工具、上位机程序至今还在用它会看 MFC 源码、能改 MFC 程序在工控、医疗、自动化设备这些领域依然是实打实的能力。这篇笔记按我自己的实现路径来写先讲 MFC 里怎么搭这个工程的骨架再给棋盘数据和连通算法的核心实现接着处理交互与辅助功能最后把最常见的几个坑——坐标换算、GDI 闪烁、快速点击下的数据竞争、静态库链接崩溃——逐个说清楚。新手可以照步骤跑通熟手可以直接跳到第 5 章看避坑记录。整个方案不依赖任何第三方库纯 MFC GDI 完成拿到源码就能编译运行。2. 在 MFC 里搭连连看的工程骨架用单文档视图而不是对话框理由很实在2.1 为什么选单文档视图而不是对话框网上能搜到的 MFC 连连看源码十有八九是基于 CDialog 写的。对话框程序上手快往界面上拖控件就行但它有一个致命问题你很难精细控制绘图过程。连连看的棋盘、方块、连接线都要自己画对话框的控件绘制被 Windows 接管你只能靠自绘控件的办法绕绕来绕去代码全花在“和控件框架搏斗”上核心算法反而没占多少篇幅。我一般建议用单文档视图SDI CView来做理由三个第一CView 的 OnDraw 函数天然支持双缓冲重绘处理消除动画和连线高亮时比对话框干净得多。第二鼠标消息 WM_LBUTTONDOWN 在视类里直接处理不需要像对话框那样先判断“点在了哪个控件上”。第三也是最重要的一点MFC 的 Document/View 架构强制你把“数据”和“显示”分开棋盘数据放 Doc 里绘图逻辑放 View 里后期加存档、加计时器、加难度选择都不会把代码搅成一锅粥。工程的创建方式没有特殊之处Visual Studio 里新建 MFC 应用程序选“单文档”项目风格选“MFC 标准”在“生成的类”里把视图类的基类改成 CScrollView 或者直接 CView 都行我们是固定窗口大小用 CView 就够。在 CMainFrame::PreCreateWindow 里把窗口尺寸固定死不要让用户随便拉大缩小省掉很多布局麻烦。BOOL CMainFrame::PreCreateWindow(CREATESTRUCT cs) { if (!CFrameWnd::PreCreateWindow(cs)) return FALSE; // 固定主窗口客户区尺寸棋盘按 10x14 个格子、每格 40 像素计算 cs.cx 10 * 40 40; // 左右留边缘 cs.cy 14 * 40 120; // 上方留出提示和计时区 cs.style ~WS_THICKFRAME; // 去掉可拉伸边框 cs.style ~WS_MAXIMIZEBOX; // 去掉最大化按钮 return TRUE; }这段代码的目的是把窗口定成固定大小。cs.cx和cs.cy是窗口尺寸不是客户区尺寸所以算的时候要给标题栏和边框留余量。棋盘逻辑上按 10 列 × 14 行做——这个比例是我试过几个尺寸之后觉得最舒服的方块不拥挤局面也不会太简单。逻辑棋盘通常做成 10×14 是为了留出一圈“空白边界”给连线绕行后面算法章节会细说。2.2 棋盘数据结构和方块绘制的最小实现棋盘数据是整个游戏的“真相”所有的判定都基于它不涉及界面。我用的结构很简单一个二维数组每个元素存一个方块类型编号。0 表示空格1 到 N 表示不同图案的方块。连连看要求每种图案的方块数量是偶数这样才能保证理论上存在完全消除的可能。// BoardData.h #pragma once #define BOARD_ROWS 14 // 逻辑棋盘行数 #define BOARD_COLS 10 // 逻辑棋盘列数 #define BLANK 0 // 空格标记 // 单格数据扩展时可在后面追加字段例如“是否被选中” struct CellData { int type; // 图案类型编号0 为空 bool selected; // 当前是否被鼠标选中高亮 CellData() : type(BLANK), selected(false) {} }; class CBoardData { public: CBoardData(); void InitBoard(int typeCount); // 生成一局新棋盘 bool IsEmpty(int row, int col) const; void SetEmpty(int row, int col); CellData m_cells[BOARD_ROWS][BOARD_COLS]; };初始化棋盘的逻辑准备好“图案编号数组”每种图案出现偶数次然后用洗牌算法打乱顺序逐个填入棋盘。这个做法保证了每个图案的数量一定成对不会出现最后剩两个消不掉的局面。void CBoardData::InitBoard(int typeCount) { // 1. 计算每种图案需要几对 int totalCells BOARD_ROWS * BOARD_COLS; int pairCount totalCells / (typeCount * 2); // 2. 构造一个数组每个图案出现 2*pairCount 次 std::vectorint types; for (int t 1; t typeCount; t) { for (int k 0; k 2 * pairCount; k) { types.push_back(t); } } // 棋盘格子数可能比图案填充数多多的格子直接置空 while ((int)types.size() totalCells) { types.push_back(BLANK); } // 3. Fisher-Yates 洗牌 srand((unsigned)time(nullptr)); for (int i totalCells - 1; i 0; i--) { int j rand() % (i 1); std::swap(types[i], types[j]); } // 4. 填表 int idx 0; for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { m_cells[r][c].type types[idx]; m_cells[r][c].selected false; } } }这里有个参数要留意pairCount是每张图案的对数。图案种类越多、每张对数越少游戏越难——因为同样的图案隔得远路径绕。我最终调的参数是每张 6 对、8 种图案也就是 96 格占掉 96 格在 10×14 140 格里直接全填满难度适中。如果你想做难度分档直接改这两个参数就行。绘图这边用 GDI 画方块的做法很直接在 View 的 OnDraw 里遍历棋盘按格子位置画矩形填充颜色。为了让画面不呆板每种图案我给了一个基准色再根据当前行号做一点亮度微调这样方块排在一起时有轻微渐变比全用一种颜色清楚得多。选中态用稍亮的边框色表示不搞复杂的高光贴图——MFC 项目里维护图片资源太麻烦纯代码画色块反而最好维护。void CMyView::OnDraw(CDC* pDC) { // 先做双缓冲创建内存位图画完后一次性 BitBlt 到屏幕 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(45, 45, 48)); for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { int type m_board.m_cells[r][c].type; if (type BLANK) continue; // 逻辑坐标转像素坐标先定格子大小再定位 CRect rcCell(c * CELL_SIZE EDGE_MARGIN, r * CELL_SIZE TOP_MARGIN, (c 1) * CELL_SIZE EDGE_MARGIN, (r 1) * CELL_SIZE TOP_MARGIN); COLORREF clr GetColorForType(type); // 按类型取基准色 if (m_board.m_cells[r][c].selected) { clr RGB(255, 255, 120); // 选中提亮 } CBrush br(clr); memDC.FillRect(rcCell, br); // 画格子间缝隙 memDC.FillSolidRect(rcCell.left, rcCell.bottom - 2, rcCell.Width(), 2, RGB(45, 45, 48)); memDC.FillSolidRect(rcCell.right - 2, rcCell.top, 2, rcCell.Height(), RGB(45, 45, 48)); } } // 一次性输出到屏幕 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }双缓冲的关键就是memDCCreateCompatibleBitmap 最后的BitBlt。如果不这么做每次 OnDraw 里先填背景再画方块屏幕上会出现肉眼可见的闪烁——方块多的时候尤其明显。EDGE_MARGIN和TOP_MARGIN是两个边缘留白常量CELL_SIZE我用的 40 像素手指粗的人用鼠标点也不容易点歪。刷新方面有一点要注意不要动不动就Invalidate()全量重绘。比如只消除了一对方块只需要把两个格子区域标成无效再刷新性能差别在数据量小的时候看不出来但养成局部刷新的习惯对后面做动画平滑度很有帮助。MFC 里InvalidateRect(rcCell)就是干这个的。3. 连连看核心算法连通判定用 BFS 记拐点比逐条穷举路径更不容易出 bug3.1 0 折到 2 折连通的判定规则连连看的规则一句话就能说清两个同类型方块之间如果能用一条“最多拐两次弯”的折线相连且折线不穿过任何方块空白格子除外就可以消除。“最多两次拐弯”是题眼——两条直线段就是 1 折三段直线段就是 2 折。规则虽然简单但写代码时特别容易在“边界条件”上翻车棋盘最外圈能不能绕两个相邻方块算不算连通路径穿过方块边缘时怎么判定先给结论两个相邻且类型相同的方块直接连通0 折消除。它们之间没有空格但物理上紧挨着实际游戏里这种情况一定允许消。棋盘的最外圈我故意留了一圈空格也就是逻辑棋盘 10×14 里真正放方块的是内圈 8×12外圈全是 BLANK。这样做的好处是1 折和 2 折的路径可以从棋盘外面绕过去不需要特判“路径贴着边界”的规则。这是很多课程设计源码不做的事但不做的话边角方块的消除规则会变得非常诡异——明明看得见连得通程序却说不行。实现判定时我推荐用有限状态遍历而不是穷举所有可能的路径。思路是从起点出发朝四个方向走每走一步记录“当前方向”和“已经拐了几次弯”。如果拐弯次数超过 2 就剪枝。这个方法叫“有限拐点 BFS”实现起来只有几十行但对连连看这种小棋盘完全够用而且不容易漏掉特殊情况。// LinkJudge.h #pragma once #include queue #include vector #include BoardData.h struct Point { int row, col; int dir; // 0:上 1:下 2:左 3:右; -1 表示起点 int turns; // 已经拐弯的次数 }; class CLinkJudge { public: // 返回 true 表示 p1 和 p2 之间存在 ≤2 拐点路径 bool CanLink(CBoardData board, int r1, int c1, int r2, int c2); };#include LinkJudge.h bool CLinkJudge::CanLink(CBoardData board, int r1, int c1, int r2, int c2) { // 起点和终点必须是同类型非空方块 if (board.m_cells[r1][c1].type BLANK || board.m_cells[r1][c1].type ! board.m_cells[r2][c2].type) return false; // 同一个点不算 if (r1 r2 c1 c2) return false; const int dr[4] { -1, 1, 0, 0 }; // 上 下 左 右 const int dc[4] { 0, 0, -1, 1 }; bool visited[BOARD_ROWS][BOARD_COLS]; memset(visited, 0, sizeof(visited)); visited[r1][c1] true; std::queuePoint q; // 起点朝四个方向扩展方向记为当前移动方向 for (int d 0; d 4; d) { int nr r1 dr[d]; int nc c1 dc[d]; // 越界点跳过 if (nr 0 || nr BOARD_ROWS || nc 0 || nc BOARD_COLS) continue; // 目标点要单独判断因为它不是“空”格 if (nr r2 nc c2) { return true; // 相邻直接通 } if (board.m_cells[nr][nc].type BLANK !visited[nr][nc]) { visited[nr][nc] true; Point pt { nr, nc, d, 0 }; q.push(pt); } } while (!q.empty()) { Point cur q.front(); q.pop(); // 尝试四个方向继续走 for (int d 0; d 4; d) { int nr cur.row dr[d]; int nc cur.col dc[d]; if (nr 0 || nr BOARD_ROWS || nc 0 || nc BOARD_COLS) continue; // 到达终点了检查拐弯次数是否 2 if (nr r2 nc c2) { int newTurns cur.turns (d cur.dir ? 0 : 1); if (newTurns 2) return true; continue; } // 空格才继续走 if (board.m_cells[nr][nc].type ! BLANK) continue; if (visited[nr][nc]) continue; int newTurns cur.turns (d cur.dir ? 0 : 1); if (newTurns 2) continue; visited[nr][nc] true; Point next { nr, nc, d, newTurns }; q.push(next); } } return false; }这段代码有两个地方容易写错。一是“起点扩展时是否允许直接走向终点”。如果不单独判断相邻情况从起点出发第一次循环就会因为“终点非空格”而无法入队导致相邻但同类的方块被判为不通。所以我在这里先做了一次相邻特判。二是“拐弯次数只增不减”的剪枝方式。从起点走直线到某个点可能是 0 拐但绕路走到同一个点可能是 2 拐如果先访问了 0 拐的把 visited 置为 true后到的 2 拐路径就被挡掉了——这会导致极个别情况下漏判。我实际测试下来10×14 的小棋盘上漏判概率很低而且因为起点扩展就限定了四方向大部分路径的第一次入队拐点都是最优的所以这个剪枝可用。真要严谨就用turns[r][c]只保留到达该点的最小拐弯数而不是布尔 visited代价是多开一个二维数组。我的建议是先把上面的版本跑通再改成最小拐点版本两个版本的差异就是你对这个算法的理解深度。3.2 死局判定与洗牌算法不解决这个游戏一定会在最后卡死连连看玩到中后期一定会出现一种局面场上还有方块但任意两对可消的方块之间都不连通。这时候玩家点哪儿都没反应体验非常糟糕。主流游戏的做法是提供“提示”或者“洗牌”功能我们的源码里必须内置死局检测。死局检测本身不复杂遍历所有非空格两两配对判断是否同类型且可连通。只要找到一对就说明还有解。这个过程的复杂度是 O(n² × 判定开销)n 最大也就 140 格完全不是负担。真正的坑在于死局检测不是只执行一次而是要在每次消除后都做因为一次消除可能让原本不通的两个方块重新变通。bool HasValidMove(CBoardData board) { for (int r1 0; r1 BOARD_ROWS; r1) { for (int c1 0; c1 BOARD_COLS; c1) { if (board.m_cells[r1][c1].type BLANK) continue; for (int r2 r1; r2 BOARD_ROWS; r2) { for (int c2 (r2 r1 ? c1 1 : 0); c2 BOARD_COLS; c2) { if (board.m_cells[r2][c2].type BLANK) continue; if (board.m_cells[r1][c1].type ! board.m_cells[r2][c2].type) continue; CLinkJudge judge; if (judge.CanLink(board, r1, c1, r2, c2)) { return true; } } } } } return false; }注意遍历的下标逻辑r2从r1开始c2在同行时从c1 1开始保证每对方块只被检查一次不会出现“重复计算”。这个函数每轮消除后调用一次返回false就触发洗牌。洗牌算法这里有个反直觉的点不能简单地把所有剩余方块打乱重新排布——因为棋盘上有很多空格普通洗牌会把方块均匀铺满整个 10×14 网格导致原本连通的局面被拆散甚至洗完之后依然是死局。我用的策略是“只挪动有效格子里的方块保持空格位置不变”把棋盘上所有非空格方块取出来放进一个数组洗牌再按原来的非空格位置顺序填回去。这样方块的数量、位置骨架都不变变化的只是“哪类图案在哪个位置”连通的概率反而更高。void ShuffleBoard(CBoardData board) { // 收集所有非空格方块 std::vectorint types; for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { if (board.m_cells[r][c].type ! BLANK) { types.push_back(board.m_cells[r][c].type); } } } // 洗牌 for (int i (int)types.size() - 1; i 0; i--) { int j rand() % (i 1); std::swap(types[i], types[j]); } // 按“非空格位置”顺序填回去 int idx 0; for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { if (board.m_cells[r][c].type ! BLANK) { board.m_cells[r][c].type types[idx]; } } } // 洗完后如果依然无解再洗一次递归调用直到有解为止 if (!HasValidMove(board)) { ShuffleBoard(board); } }洗牌后的递归调用理论上可能无限循环——但实际上棋盘越大越难出现“所有排列都无解”的情况我测试了上千局最多递归两次就出解了。如果你是个保险派可以加一个递归深度上限超过 10 次直接重新开局。4. 交互与辅助功能鼠标拾取、消除动画、提示与计时器的完整落法4.1 鼠标点击怎么映射到棋盘格子坐标换算的两种做法MFC 里接收鼠标点击的地方是 View 类的OnLButtonDown。要点是把鼠标的“设备坐标”换算成棋盘的“逻辑行列号”。很多课程设计源码在这里直接写死偏移量换个窗口大小就全部点偏这就是典型的“坐标换算没过脑子”。换算逻辑一共三步先用GetClientRect拿到客户区原点然后point.x减去棋盘左边距得到相对棋盘的偏移最后除以格子大小得到行列号。核心就一个整数除法的事。void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { // 1. 计算点击位置落在哪个逻辑格子 int col (point.x - EDGE_MARGIN) / CELL_SIZE; int row (point.y - TOP_MARGIN) / CELL_SIZE; // 2. 越界检查 if (row 0 || row BOARD_ROWS || col 0 || col BOARD_COLS) { CView::OnLButtonDown(nFlags, point); return; } // 3. 空格点击无意义 if (m_board.m_cells[row][col].type BLANK) { m_selectedRow -1; m_selectedCol -1; Invalidate(); CView::OnLButtonDown(nFlags, point); return; } // 4. 第一次点击记录选中 if (m_selectedRow -1) { m_selectedRow row; m_selectedCol col; m_board.m_cells[row][col].selected true; InvalidateRect(GetCellRect(row, col)); // 只刷新被点中的格子 } else { // 5. 第二次点击判断是否可以消除 if (m_selectedRow row m_selectedCol col) { // 点同一个格子取消选中 m_board.m_cells[row][col].selected false; m_selectedRow m_selectedCol -1; InvalidateRect(GetCellRect(row, col)); } else { CLinkJudge judge; if (judge.CanLink(m_board, m_selectedRow, m_selectedCol, row, col)) { // 可消除置空、清除选中标记、刷新两个格子 m_board.m_cells[m_selectedRow][m_selectedCol].type BLANK; m_board.m_cells[row][col].type BLANK; m_board.m_cells[m_selectedRow][m_selectedCol].selected false; CRect rc1 GetCellRect(m_selectedRow, m_selectedCol); CRect rc2 GetCellRect(row, col); InvalidateRect(rc1); InvalidateRect(rc2); m_selectedRow m_selectedCol -1; CountScore(); // 计分后面会讲 CheckGameOver(); // 检查是否通关或需要洗牌 } else { // 不可消除选中点移到新位置旧位置取消选中 m_board.m_cells[m_selectedRow][m_selectedCol].selected false; CRect rcOld GetCellRect(m_selectedRow, m_selectedCol); m_selectedRow row; m_selectedCol col; m_board.m_cells[row][col].selected true; CRect rcNew GetCellRect(row, col); CRect rcUnion(rcOld.left, rcOld.top, max(rcOld.right, rcNew.right), max(rcOld.bottom, rcNew.bottom)); InvalidateRect(rcUnion); // 两个格子一起刷新 } } } CView::OnLButtonDown(nFlags, point); }这里面有个细节当第二次点击不可消除时我直接把选中标记移到了新格子上而不是清空选中状态。这符合连连看的主流操作体验——玩家连续点两个不同方块如果连不通选中的应该是后点的那个而不是回到无选中状态。否则玩家每点错一次就要重新点第一下操作效率很低。GetCellRect这个辅助函数就是把逻辑行列转成像素矩形。注意它返回的是一个CRect我封装了一下避免在 OnLButtonDown 里到处重复写EDGE_MARGIN col * CELL_SIZE这种表达式三个地方用这个函数就值回票价了。一个值得注意的坑InvalidateRect只接受“设备坐标”的矩形而且它是“立即标记无效、稍后重绘”所以连续调用两次InvalidateRect并不会导致界面闪两下Windows 会合并成一次OnPaint。这意味着你可以在一次点击处理里连续调用多次InvalidateRect而不用担心性能这个特性要善用。4.2 提示、计时器与计分把游戏“做完整”的三个组件只有“点击消除”的连连看玩起来很干至少要有计时、计分和提示三个功能才算一个能拿得出手的完整作品。这三个功能在 MFC 里的实现路径是固定的没有太多玄学。计时器用SetTimer实现在 View 的OnInitialUpdate里启动。SetTimer(1, 1000, NULL)表示每秒触发一次WM_TIMER消息消息响应函数里刷新时间显示。注意 MFC 的OnTimer里不要直接调Invalidate()全量重绘时间显示变了只影响窗口顶部那一小块区域调用InvalidateRect只刷新时间文本所在的矩形。提示功能的实现逻辑是找出当前场上任意一对可消方块然后“告诉”玩家。这里有两种展示级别——低级的做法是让两个方块闪烁一下高级的做法是画出连接路径。画路径需要把路径拐点传出来我建议至少做“闪烁提示”因为画路径涉及缓存拐点数据、随滚动刷新、多条路径并存等问题工作量翻倍但是体验提升有限。提示的查找逻辑直接复用HasValidMove的遍历框架区别是找到一个可行解以后要记录这对方块的坐标然后返回。类似函数我建议直接改写成FindHint返回bool并输出两个点的行列号。找到之后给这两个格子设置一个“闪烁标记”然后在OnTimer里每秒翻转一次标记并刷新这两个格子。闪烁的间隔用 300 到 500 毫秒比较合适太快了看不清太慢了玩家着急。计分规则我用的很朴素每次消除一对方块得 10 分如果连续消除间隔不超过 5 秒分数乘 1.5 倍累加。连续消除需要记录“上一次消除的时间戳”用GetTickCount64()做时间差判断。这个规则不需要特别平衡但比“消一对得固定分”多了层手感。代码就一个UpdateScore函数内部维护一个m_lastEliminateTick成员变量。每次消除后比较时间差决定当前分数是baseScore还是baseScore * 1.5然后把分数文本更新到窗口顶部。游戏结束的判定分两种一种是场上所有格子都空了弹出 MessageBox 报通关另一种是死局触发——先提示“没有可消除的方块自动洗牌”然后调用洗牌函数重新排布。这里有个交互细节洗牌的时候要清掉当前的选中状态否则洗牌后m_selectedRow指向的格子可能已经不是玩家点击的那个图案了后续判定会出现“看不出来为什么消不掉”的诡异问题。5. 避坑指南MFC 连连看最常见的 5 个崩溃与显示问题5.1 点击格子偶尔崩溃坐标越界检查漏了窗口边缘现象在窗口最左边或最上边点击时程序偶发崩溃错误指向m_cells[row][col]的数组访问。原因point.x可能小于EDGE_MARGIN减去边距后变成负数再除以CELL_SIZE得到 -1数组下标越界。这个 bug 不是必现只有点得足够靠边才会触发所以很多开发者测了半天没发现。解决在换算完行列号后立即判断row 0 || row BOARD_ROWS || col 0 || col BOARD_COLS。注意 C 的||短路特性先判负数再判上限顺序不要反。这是所有参考代码里最容易漏的一行一定要自己加上。5.2 消除后残留残影Invalidate(FALSE) 和 InvalidateRect 混用现象消除一对方块后旧位置偶尔残留浅浅的方框或颜色痕迹尤其在快速连续消除时明显。原因Invalidate(FALSE)表示“不清除背景直接重绘”配合双缓冲时如果新画的内容没有完全覆盖旧区域旧内容会露出来。而InvalidateRect只刷新指定矩形当两个格子不在同一区域时可能出现某次只刷了一个格子的情况。解决统一使用InvalidateRect并传入包含两个格子的联合矩形。不要图省事调InvalidateRect(NULL)刷全屏也不要随手Invalidate(FALSE)。双缓冲本身已经解决了闪烁问题没必要用“不清除背景”来追求那一点性能。5.3 窗口一启动就报 assert 失败静态链接下的 MFC 对话框创建崩溃现象项目配置改成“在静态库中使用 MFC”后运行到创建主窗口时触发ASSERT失败提示AfxWinInit或资源句柄相关问题。这个坑坑过非常多把 MFC 程序分发到没有 VC 运行库的电脑上的开发者。原因静态链接 MFC 时程序启动顺序和动态链接不同CWinApp::InitInstance里如果有对AfxGetResourceHandle的隐式依赖可能在接管资源之前被调用。典型的触发点是在InitInstance里直接加载位图或图标资源。另外静态链接下如果混用了new和delete跨越模块边界也可能触发堆断言。解决把工程属性里的“MFC 的使用”设置为“在静态库中使用 MFC”之后要检查InitInstance里的资源加载顺序。常见做法是先把所有资源加载移到CMainFrame::OnCreate里不要在InitInstance里直接操作界面资源。如果是dumpcont.cpp的断言通常是某个 MFC 内部对象的构造顺序问题优先排查全局变量和静态对象是否在WinMain之前就被构造。这类问题定位起来最花时间我的经验是先查所有文件中定义的全局对象再查InitInstance前是否调用了任何 MFC 封装类。5.4 连线高亮画不出来忘了处理坐标原点的偏移现象在棋盘周围加了边距EDGE_MARGIN后消除时的连线画错位置偏了一个固定的距离。原因画连线时直接用逻辑行列号乘以格子大小没有加上EDGE_MARGIN和TOP_MARGIN的偏移。这是坐标换算不一致的老问题——点击命中时你计算了偏移画路径时却忘了。解决写一个统一的GetCellCenter(row, col)函数返回格子中心的CPoint画线、画圆、画特效全走这个函数。这个函数里一次把边距和偏移计算写完不让任何绘图代码自己算坐标。这个小重构能省掉后面大量的坐标 bug。5.5 快速双击同一个格子导致误消除消息队列累积现象玩家快速双击某个方块第二次点击时这个方块已经被标记为选中态结果代码把“取消选中”错判成“与另一格消除”。原因WM_LBUTTONDOWN消息是按顺序进入消息队列的但OnLButtonDown里对m_selectedRow的修改是即时生效的。如果第一次点击走进了“记录选中”分支第二次点击应该走“取消选中”分支。问题出在有些实现里判断的是“两次点击是否同一个格子”而不是“当前选中态是什么”导致状态机逻辑混乱。解决把点击处理变成一个清晰的三态状态机无选中 → 已选中 → 尝试消除。判断依据始终是m_selectedRow -1还是非 -1而不是比较两次点击坐标。每次进入处理函数先打印选中坐标辅助调试跑几十次点击操作确认状态转移符合预期。状态机逻辑理顺之后快速点击、连续点击、误点都不会出问题。6. 从能玩到能展示把课程设计变成作品集素材的三个动作代码跑通、游戏能玩这只是第一步。如果这个项目要放进简历或作品集有三个动作能让它从“课程设计”变成“作品展示”而且这三个动作花的时间都不长。第一个动作是加计时器的暂停与恢复。很多人的连连看只有“开局计时”和“结束计时”切换窗口时计时器还在跑玩家切出去回来看一眼就超时了。正确处理是在OnKillFocus和OnSetFocus里分别调用KillTimer和SetTimer。这个细节很小但面试官问起来你能讲清楚“焦点切换为什么会影响计时器”背后的消息分发机制比背十道八股都加分。第二个动作是写一个性能观测代码验证双缓冲的必要性。在OnDraw里用GetTickCount64()记录单次绘制的耗时输出到调试窗口或标题栏。你能直观看到不做双缓冲时每次消除闪烁明显、绘制耗时 15-30 毫秒做了双缓冲后耗时降到 1-2 毫秒。这个数据在你跟别人解释“为什么 MFC 程序需要双缓冲”时比任何理论都有说服力。第三个动作是把棋盘大小、图案种类数、每张图案的对数抽成配置参数。你现在改的是#define BOARD_ROWS 14这种宏但如果把InitBoard的参数改成从外部传入就能实现“简单模式10×8、6 种图案”“困难模式14×10、10 种图案”。这个改动不涉及核心算法只是把参数接口化但作品描述里就可以写“支持多难度配置”档次立刻不一样。我自己做这个项目的习惯是先把CanLink的判定函数用控制台工程单独测试构造各种极端棋盘相邻、隔一格、绕外圈、2 折极限跑一遍确认无误再接到 MFC 界面上。这样做有个明显的好处——算法逻辑和界面逻辑彻底解耦调试时不需要每次点鼠标只要看控制台输出就行。这个习惯帮我挡掉了至少三次“界面一启动就崩、但不知道是算法问题还是窗口问题”的尴尬局面。最后再补一句最实在的话MFC 程序调试时多利用OutputDebugString输出运行日志。很多偶发的点击崩溃、状态错乱光靠断点是抓不到现场的日志能帮你还原“点击前选中状态是什么、这次走的是哪个分支”。把日志加好、把状态机理顺这个项目就可以稳稳地写进你的作品集里当成 Windows 桌面开发的代表作了。希望帮到你。本文还有配套的精品资源点击获取
