MFC连连看开发实战:消息映射、GDI双缓冲与核心算法解析
简介这是一份基于 MFC 框架开发的连连看游戏完整源码面向希望学习 Windows 桌面应用开发或 C 游戏逻辑的初学者与进阶开发者。资源共 27 个文件压缩包约 3.8MB包含 7 个头文件和 4 个 C 源文件用于实现主对话框、消除算法、计时计分等核心逻辑另有 6 个位图资源承载界面图案与背景并带有解决方案、项目配置、版本控制与许可证等文件可在 Visual Studio 中直接打开、编译并运行已有 100 人学习下载。项目覆盖从 MFC 界面搭建、消息响应到连连看连通性判断与绘制的完整链路适合作为课程设计、毕业设计或自学练手参考。通过阅读源码可以掌握 MFC 消息映射、对话框交互、GDI 绘图等关键技巧在此基础上也能继续扩展关卡、音效或道具功能提升综合开发能力。尤其适合作为 MFC 课程设计的起步模板。1. 走一遍 MFC 连连看这个项目到底在做什么MFC 框架下的连连看是一个非常适合用来吃透 Windows 桌面程序完整开发流程的入门项目。它要解决的问题很具体在一个二维棋盘上摆放若干种图案的方块玩家每次点选两个相同图案如果它们之间的连线不超过两个拐点且路径全部为空就消除这对方块直到棋盘全部清空。用 MFC 而不是网页或 Unity是因为 MFC 直接面对 Windows 窗口、消息循环和 GDI 绘图接口做完这个项目你对窗口程序是怎么跑起来的会有切身体会。它适合正在找课程设计、毕设选题或者想补 Windows 原生 GUI 基础的读者也适合想拿一份干净源码改造成自己作品的开发者。2. 从零搭 MFC 游戏骨架CDialog 工程、消息映射与棋盘数据模型2.1 为什么选 MFC 而不选网页或 Unity三种玩法载体的取舍MFCMicrosoft Foundation Classes是微软对 Win32 API 的一层 C 封装它保留了窗口句柄、消息队列和 GDI 设备上下文的直接控制能力。连连看这类游戏没有复杂物理运算和实时渲染核心逻辑是网格状态管理、点击判定、路径搜索以及把棋盘画到窗口上。MFC 在这四件事上的表达非常直白窗口就是 CDialog 或 CFrameWnd点击就是 WM_LBUTTONDOWN 消息画图就是 CDC 的 BitBlt。这也是我在做 mfc 编程入门相关选题时第一个推荐学生动手写的游戏。如果换成网页做你要处理 DOM 布局、事件冒泡、CSS 背景图定位核心算法反而被前端框架的复杂性盖住如果换成 Unity做一个九宫格连线小游戏虽然容易但工程体量、场景管理和打包方式跟 MFC 完全不在一个量级也不适合作为理解 Windows 原生程序原理的载体。MFC 的价值在于看得见消息怎么流转看得见绘图怎么落屏这类游戏设计源码的课后答辩老师最常追问的也正是这两个问题。2.2 对话框工程还是单文档工程CDialog 与 CView 的取舍做 MFC 连连看我一般直接用基于对话框的工程而不是单文档视图。原因有两点第一对话框自带确定、取消按钮和静态文本控件布置剩余时间得分重新开始这些信息很方便第二对话框的 OnPaint 重写路径短打开资源编辑器就能看到界面布局不需要理解 CFrameWnd、CDocument、CView 这一整套文档视图架构。对比项基于对话框 CDialog单文档视图 CView界面布局资源编辑器直接拖控件需要自己在 OnDraw 里画菜单工具栏手工加载较麻烦原生支持适合场景纯游戏界面、课程设计带文档编辑、多视图项目核心算法复用完全通用完全通用如果你做的课程设计要求必须带菜单、工具栏、状态栏那就走单文档视图核心算法代码可以原样搬过去区别只在鼠标坐标如何从窗口客户区映射到棋盘格子。对话框版里我用 m_board[ROWS2][COLS2] 这样的二维数组存棋盘状态0 表示空格非 0 表示图案编号。2.3 初始化棋盘数据随机配对与图案均匀分布连连看开局最关键的一点是每种图案的个数必须是偶数否则游戏根本不可能清空。下面这段代码在 CLLKDlg::OnInitDialog 里调用负责生成一组两两配对的图案序列然后随机打乱填入棋盘。// 棋盘尺寸10 行 x 16 列外层一圈是虚拟空边界 const int ROWS 10; const int COLS 16; int m_board[ROWS 2][COLS 2]; int m_leftCount 0; // 剩余待消除的方块数 void CLLKDlg::InitBoard() { std::vectorint pieces; // 生成 ROWS*COLS/2 对图案保证每种图案出现偶数次 int type 1; // 图案编号从 1 开始 for (int i 0; i ROWS * COLS / 2; i) { pieces.push_back(type); pieces.push_back(type); type (type % 8) 1; // 在 1~8 种图案间轮换 } // 用真随机数打乱避免每次开局图案顺序固定 std::random_device rd; std::mt19937 rng(rd()); std::shuffle(pieces.begin(), pieces.end(), rng); // 写入棋盘1~ROWS 行、1~COLS 列是有效格子 int idx 0; memset(m_board, 0, sizeof(m_board)); for (int r 1; r ROWS; r) for (int c 1; c COLS; c) m_board[r][c] pieces[idx]; m_leftCount ROWS * COLS; }逻辑说明先构造 80 对、共 160 个元素shuffle 之后每种图案刚好出现偶数次memset 把外圈边界置 0后续连通判定可以直接访问第 0 行、第 0 列这些虚拟边界而不需要每次特判越界。参数说明ROWS 和 COLS 决定难度10×16 是中等棋盘新手 3 分钟左右能清完type 用(type % 8) 1循环保证图案种类不超过 8 种且数量基本均匀。如果想把棋盘改成 8×12只要改这两个常量InitBoard 会自动适配。提示如果 ROWS*COLS 是奇数这个配对循环会少一个格子需要单独补一个 0 或者调整棋盘尺寸。游戏棋盘一般设计成偶数格省去这个麻烦。2.4 消息映射与定时器让框架把消息送到你的函数里MFC 对话框自带消息循环不需要像 SDK 编程那样自己写while(GetMessage(...))。你要做的只是在消息映射表里登记要处理的消息然后实现对应函数。连连看里必须处理三组消息绘制 WM_PAINT、鼠标点击 WM_LBUTTONDOWN、倒计时 WM_TIMER。BEGIN_MESSAGE_MAP(CLLKDlg, CDialogEx) ON_WM_TIMER() ON_WM_LBUTTONDOWN() ON_WM_PAINT() ON_BN_CLICKED(IDC_BTN_NEWGAME, CLLKDlg::OnBnClickedNewgame) END_MESSAGE_MAP() BOOL CLLKDlg::OnInitDialog() { CDialogEx::OnInitDialog(); SetTimer(TIMER_GAME, 1000, NULL); // 倒计时定时器1 秒触发一次 m_timeLeft 180; // 180 秒倒计时 InitBoard(); return TRUE; } void CLLKDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_GAME) { m_timeLeft--; CString str; str.Format(L剩余时间%d 秒, m_timeLeft); SetDlgItemText(IDC_STATIC_TIME, str); if (m_timeLeft 0) GameOver(); // 到时结束 } CDialogEx::OnTimer(nIDEvent); // 调用基类版本保持其他定时器正常 }参数说明SetTimer 第一个参数是定时器 ID第二个是毫秒间隔。多个定时器用不同 ID 区分比如 1 号给倒计时2 号给将来可能加的自动提示冷却。OnTimer 里做的是轻量 UI 刷新不要在里面对整个棋盘做死局检测一类的耗时遍历否则消息队列被堵住窗口拖动都会卡。mfc 编程入门阶段最容易犯的错误就是把所有逻辑都塞进一个消息处理函数里后面第 5 章会专门讲这个坑。3. 连连看核心算法直连、单拐、双拐的判定与死局检测3.1 先把规则翻译成程序语言连通路径的数学定义连连看的判定规则用一句话说两个点图案相同且存在一条只经过空格子、拐弯次数不超过 2的折线路径。注意折线可以走到棋盘外圈这就是为什么初始化棋盘时要在有效区域外面留一圈 0 值边界。这条外圈边界让所有边缘方块都能绕着走否则边角上的方块几乎不可能被消除死局概率会高很多。算法上拐点分三类0 个拐点就是同行或同列直连1 个拐点是 L 形拐点在两点构成的矩形的另外两个角上2 个拐点是 Z 形或 U 形需要横向或纵向枚举一条中间通道。下面的三个子函数把这三类情况逐一落地。3.2 直连与单拐点两个干净的辅助判定函数先写两个判断一段路径是否全为空的辅助函数它们是所有拐点判断的基础。// 检查同一行中 c1 到 c2 之间不含两端是否全为空格 bool CLLKDlg::IsRowClear(int row, int c1, int c2) { if (c1 c2) std::swap(c1, c2); for (int c c1 1; c c2; c) if (m_board[row][c] ! 0) return false; return true; } // 检查同一列中 r1 到 r2 之间不含两端是否全为空格 bool CLLKDlg::IsColClear(int col, int r1, int r2) { if (r1 r2) std::swap(r1, r2); for (int r r1 1; r r2; r) if (m_board[r][col] ! 0) return false; return true; }逻辑说明先交换参数保证从小到大遍历然后跳过两端逐格检查是否为 0。两个函数都不访问 m_board 的边界行和边界列所以即使传入的第 0 行、第 ROWS1 行也不会越界因为数组声明时已经多开了两行两列。参数说明调用时传入的行列号要在 0 到 ROWS1 或 0 到 COLS1 之间这样外圈虚拟边界才能参与路径计算。3.3 双拐点判定CanConnect 主函数与扫描策略// 判断两个格子能否消除图案相同且路径拐点不超过 2 个 bool CLLKDlg::CanConnect(int r1, int c1, int r2, int c2) { if (r1 r2 c1 c2) return false; if (m_board[r1][c1] 0) return false; if (m_board[r1][c1] ! m_board[r2][c2]) return false; // 0 个拐点同行直连或同列直连 if (r1 r2 IsRowClear(r1, c1, c2)) return true; if (c1 c2 IsColClear(c1, r1, r2)) return true; // 1 个拐点拐点在 (r1, c2) 或 (r2, c1) if (m_board[r1][c2] 0 IsRowClear(r1, c1, c2) IsColClear(c2, r1, r2)) return true; if (m_board[r2][c1] 0 IsColClear(c1, r1, r2) IsRowClear(r2, c1, c2)) return true; // 2 个拐点横向枚举一条中间行再竖向枚举一条中间列 for (int r 0; r ROWS 1; r) { if (m_board[r][c1] ! 0 || m_board[r][c2] ! 0) continue; // 中间通道的拐点位置必须为空 if (IsColClear(c1, r1, r) IsRowClear(r, c1, c2) IsColClear(c2, r, r2)) return true; } for (int c 0; c COLS 1; c) { if (m_board[r1][c] ! 0 || m_board[r2][c] ! 0) continue; if (IsRowClear(r1, c1, c) IsColClear(c, r1, r2) IsRowClear(r2, c, c2)) return true; } return false; }逻辑说明双拐点的情况本质是让折线先沿纵向走到某一行 r再横穿到目标列再沿纵向走到终点。枚举 r 从 0 到 ROWS1正好覆盖外圈上下两条虚拟边界边缘方块因此能从棋盘外面绕过去。列枚举同理覆盖左右两条虚拟边界。参数说明两个 for 循环里r 或 c 等于 r1 或 r2 时判断中的m_board[r][c1] ! 0或m_board[r][c2] ! 0会把它挡掉因为起点和终点本身不是空格不会误判成双拐路径。这个函数在 10×16 棋盘上的复杂度是 O(ROWSCOLS)每轮点击最多扫描 28 个候选中间行和列对 MFC 程序来说性能完全不构成压力。真正要小心的是死局检测它要遍历全部方块对复杂度会上升到 O((ROWS*COLS)^2)不要每帧都跑。3.4 死局检测与洗牌兜底防止游戏进行到一半无路可走当棋盘上存在至少一对可以消除的方块时游戏才能继续。死局检测的常见做法是遍历所有非空格子对每个格子尝试找一对可连通的同图案方块找到就返回 true。这个函数只在两种时机调用玩家消除一对方块之后、洗牌之后。// 判断棋盘上是否还有至少一组可消除的方块 bool CLLKDlg::HasValidMove() { for (int r1 1; r1 ROWS; r1) for (int c1 1; c1 COLS; c1) { if (m_board[r1][c1] 0) continue; // 只向后遍历避免同一对判定两次 for (int r2 r1; r2 ROWS; r2) for (int c2 (r2 r1) ? c1 1 : 1; c2 COLS; c2) { if (m_board[r2][c2] 0) continue; if (m_board[r1][c1] m_board[r2][c2] CanConnect(r1, c1, r2, c2)) return true; } } return false; }逻辑说明内层循环用r2 (r2 r1) ? c1 1 : 1的技巧保证 j 的起始位置在 i 之后整段遍历不重复、不遗漏。参数说明这个函数在 10×16 棋盘上最坏情况要跑上万次 CanConnect但 CanConnect 本身只有几十次循环所以整体在毫秒级放在点击回调里执行完全没问题。如果棋盘里有大量空格continue 会跳过它们实际耗时更短。配套的洗牌函数 ShuffleBoard 用和 InitBoard 相同的随机逻辑把所有非 0 方块重新打乱。洗牌后要循环调用 HasValidMove若仍无解就再洗直到棋盘上有可用消除对或者剩余方块太少直接结束。血泪经验如果只洗牌不检查死局玩家会碰到提示功能显示无解但棋盘还满着的尴尬局面。4. 把棋盘画出来GDI 贴图、双缓冲与鼠标拾取4.1 用 GDI 还是位图两种绘制方式的选择MFC 里画图案有两条路一是直接用画刷画圆角矩形简单省事课程设计够用二是导入 BMP 位图用 BitBlt 贴图画面效果接近成品游戏。mfc 显示 bmp 图片这个话题在 mfc 类库图相关的讨论里很常见其实核心就是 CBitmap 加载资源然后 CDC::BitBlt 整块拷贝。下面的 DrawPiece 函数是画刷版本每一种图案对应一种颜色void CLLKDlg::DrawPiece(CDC* pDC, CRect rect, int type) { if (type 0) { pDC-FillSolidRect(rect, RGB(245, 245, 245)); return; } // 8 种图案对应 8 种颜色 static COLORREF colors[] { RGB(255, 99, 99), RGB(99, 178, 255), RGB(99, 255, 132), RGB(255, 209, 99), RGB(178, 99, 255), RGB(255, 140, 66), RGB(66, 224, 208), RGB(210, 210, 210) }; CBrush brush(colors[(type - 1) % 8]); CPen pen(PS_SOLID, 1, RGB(60, 60, 60)); CBrush* pOldBrush pDC-SelectObject(brush); CPen* pOldPen pDC-SelectObject(pen); pDC-RoundRect(rect.left 2, rect.top 2, rect.right - 2, rect.bottom - 2, 8, 8); pDC-SelectObject(pOldBrush); pDC-SelectObject(pOldPen); }参数说明CRect 里留了 2 像素内边距圆角矩形看起来不会挤在一起type 减 1 是为了让数组下标从 0 开始。位图版本只需要把 SelectObject 换成 CBitmap再用 BitBlt 把内存 DC 里的位图拷贝到目标 DC代码反而更短。实际项目里我推荐先用画刷版本把逻辑跑通最后再换位图这样调试时颜色状态一目了然。4.2 双缓冲消除画面闪烁最有效的一招直接在 OnPaint 里向窗口 DC 一个一个画方块画面会闪到怀疑人生。原因是每次画一个方块窗口就要刷新一次GDI 在屏幕和窗口之间来回切换人眼看到的就是重影和残迹。双缓冲的做法是先创建一张与窗口等大的内存位图把所有内容画到位图上最后一次性 BitBlt 到窗口 DC。void CLLKDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); // 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 所有绘制都在内存 DC 上完成 DrawBackground(memDC); for (int r 1; r ROWS; r) for (int c 1; c COLS; c) { CRect cell(BOARD_LEFT c * CELL_SIZE, BOARD_TOP r * CELL_SIZE, BOARD_LEFT (c 1) * CELL_SIZE, BOARD_TOP (r 1) * CELL_SIZE); DrawPiece(memDC, cell, m_board[r][c]); } // 一次性拷贝到屏幕 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逻辑说明所有方块绘制都不直接接触屏幕 DC而是画到 memDC 上位图在内存里拼好后再整体上屏。这一步做完窗口闪烁基本消失。如果还想更彻底重写 OnEraseBkgnd 直接返回 TRUE阻止系统在 OnPaint 前擦除背景。参数说明BOARD_LEFT、BOARD_TOP 是棋盘区域的左上角像素坐标CELL_SIZE 是每个格子的边长这三者需要在初始化时统一定义好后面鼠标坐标换算也要用同一组值。4.3 鼠标点击把像素坐标换算成棋盘坐标WM_LBUTTONDOWN 消息里拿到的 point 是窗口客户区坐标必须先换算成棋盘行列号才能判断点到了哪一个格子。换算公式就两行行号等于 (point.y 减棋盘左上角 y) 除以格子高度列号同理。注意要先判断坐标是否落在有效区域内否则换算出来的行列可能越界。void CLLKDlg::OnLButtonDown(UINT nFlags, CPoint point) { int col (point.x - BOARD_LEFT) / CELL_SIZE; int row (point.y - BOARD_TOP) / CELL_SIZE; if (row 1 || row ROWS || col 1 || col COLS) { CDialogEx::OnLButtonDown(nFlags, point); return; // 点在棋盘外忽略 } SelectPiece(row, col); // 交给选中与消除逻辑处理 CDialogEx::OnLButtonDown(nFlags, point); }SelectPiece 里的逻辑是 MFC 连连看源码里最容易被写乱的部分。我习惯用一个结构体保存当前选中的格子struct SEL { bool valid; int row, col; } m_selected; void CLLKDlg::SelectPiece(int row, int col) { if (!m_selected.valid) { // 第一次选中记录位置并等待第二次点击 m_selected.valid true; m_selected.row row; m_selected.col col; InvalidateRect(NULL, FALSE); return; } // 点同一个格子取消选中 if (m_selected.row row m_selected.col col) { m_selected.valid false; InvalidateRect(NULL, FALSE); return; } // 两次图案相同且可连通消除并计分 if (CanConnect(m_selected.row, m_selected.col, row, col)) { m_board[m_selected.row][m_selected.col] 0; m_board[row][col] 0; m_leftCount - 2; m_selected.valid false; m_score 10; UpdateScoreText(); InvalidateRect(NULL, FALSE); // 在这里补充胜利判断和死局检测 } else { // 不连通取消上一次选中把当前点记为新的选中 m_selected.row row; m_selected.col col; InvalidateRect(NULL, FALSE); } }参数说明InvalidateRect 的第二个参数用 FALSE表示不擦除背景配合双缓冲可以进一步降低闪烁。每次操作后都触发重绘棋盘状态才会更新。这个结构里隐藏着一个细节判断不连通之后第二次点击的格子直接变成新选中点而不是让玩家重新点一下实际手感更流畅。4.4 高亮选中格与按钮外观几行代码提升交互感选中状态在视觉上如果不做区分玩家点完第一个格子经常忘记自己点了哪个。在 OnPaint 的双缓冲绘制里对这个格子额外画一个加粗边框即可// 在 OnPaint 的循环绘制里追加高亮边框 if (m_selected.valid m_selected.row r m_selected.col c) { CPen hl(PS_SOLID, 3, RGB(0, 120, 255)); CPen* pOldPen memDC.SelectObject(hl); memDC.SelectStockObject(NULL_BRUSH); memDC.Rectangle(cell.left 1, cell.top 1, cell.right - 1, cell.bottom - 1); memDC.SelectObject(pOldPen); }至于 mfc 中 CButton 按钮颜色设置步骤其实是老生常谈普通按钮不给开发者直接改背景色的接口要么用 Owner-Draw 自绘按钮重写 DrawItem要么干脆用图片按钮贴图。课程设计里最简单的方法是给按钮设一个 Bitmap然后设置 BS_BITMAP 风格把重新开始提示洗牌这几个按钮做成图标按钮视觉上比默认灰按钮好看得多。这个扩展不影响核心逻辑我一般放到最后做。5. 避坑与排查MFC 连连看里的 5 个典型开发问题5.1 窗口闪烁消除方块后整个棋盘残影乱跳现象每次点击消除方块棋盘区域像在抖动拖动窗口边缘时画面残留严重。原因OnPaint 里直接向窗口 DC 绘制每个方块绘制请求都触发一次屏幕刷新同时系统还要先擦除背景两者叠加就产生了闪烁。这是 GDI 绘图里最经典的背景擦除 前景绘制竞态。解决换双缓冲所有内容画到内存 DC最后一次性 BitBlt同时重写 OnEraseBkgnd 返回 TRUE让系统不擦除背景。BOOL CLLKDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景统一在 OnPaint 里画禁止系统擦除 }这条改完闪烁基本能消失 90%。剩下的 10% 发生在鼠标持续快速点击时需要把 InvalidateRect 的擦除参数改成 FALSE减少无谓重绘。5.2 点击按钮没反应消息映射没对上号现象在资源编辑器里加了重新开始按钮运行后点它毫无反应断点根本不进 OnBnClickedNewgame 函数。原因最常见的是按钮 ID 不一致。资源编辑器里改了按钮文字ID 可能被系统重新分配或者手写消息映射时把 IDC_BTN_NEWGAME 写成了另一个按钮的 ID编译器不会报错消息发错地方自然没有响应。解决先打开 resource.h确认按钮 ID 唯一且拼写正确再检查 message map 里的 ON_BN_CLICKED 是不是同一个 ID。如果两者一致在项目里执行重新生成清掉旧资源缓存再跑。还有一个隐蔽情况按钮被声明成局部变量而不是对话框成员销毁后被误用也会表现为点击无响应。5.3 数组越界连通判定时程序直接崩掉现象点击边角位置的方块对时程序崩溃Debug 模式下弹出访问冲突调用栈停在 CanConnect 里的 m_board[r][c] 处。原因棋盘数组只声明成 [ROWS][COLS]而 CanConnect 双拐点扫描会访问第 0 行、第 0 列以及第 ROWS1 行、第 COLS1 列。这些位置在原有数组里越界了读出的数据是随机的轻则路径判定错误重则访问非法内存直接崩溃。解决从 InitBoard 开始就把棋盘声明成int m_board[ROWS 2][COLS 2]有效区域从下标 1 开始。这样外圈虚拟边界天然存在所有边界特判都可以删掉。这个坑我几乎每次写连连看都会踩一遍后来形成了肌肉记忆任何涉及连线判定的棋盘数组一律多开一圈。5.4 开局图案每次都一样随机数没有种子现象连开三局游戏图案排列完全一样关掉程序重开还是同样布局洗牌功能也失效。原因早期版本用std::random_shuffle但从未调用 srand 设置随机种子。C 标准库里的 rand 在未设种子时默认用 1生成的随机序列每次都一样洗牌自然也是同样的结果。解决换用 C11 的 std::random_device 和 std::mt19937 组合随机种子来自系统熵源每次启动都不一样。如果代码必须兼容老编译器就在程序入口调用srand((unsigned)time(NULL))再用基于 rand 的洗牌。注意不要把 srand 放在 InitBoard 里反复调用否则同一秒内多次初始化会得到相同序列。5.5 MFC 静态库下对话框创建失败InitCommonControls 没调现象项目属性里选择在静态库中使用 MFC编译通过运行后 DoModal 直接返回 -1或者弹 Debug Assertion Failed程序根本弹不出主窗口。原因MFC 静态链接时公共控件库的初始化时机和对话框资源加载顺序有差异如果没有提前调用 InitCommonControls 系列函数对话框依赖的公共控件类没有被注册。解决在 CLLKDlg 构造函数之前、或者 _tWinMain 最开始的地方调用一次 InitCommonControlsEx#include afxwin.h BOOL CLLKDlgApp::InitInstance() { INITCOMMONCONTROLSEX icc; icc.dwSize sizeof(icc); icc.dwICC ICC_WIN95_CLASSES; InitCommonControlsEx(icc); // 必须在创建对话框前调用 CLLKDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return FALSE; }参数说明ICC_WIN95_CLASSES 覆盖进度条、工具栏、状态栏等常见控件连连看虽然只用到按钮和静态文本但加上这一行能避免很多诡异问题。这个坑在课程设计里特别常见因为学校机房的 Visual Studio 默认配置可能开了静态链接别人能跑的代码到你这儿就起不来。6. 让代码更像成品道具、音效与最高分存档的扩展方案前五章把 MFC 连连看的核心链路走通了但交上去的源码如果只有点两下消一对答辩时容易被追问你这和实验室作业有什么区别。我建议从三个方向补齐每一块改动量都不大却能明显拉开成品感。提示道具的实现思路就是把死局检测的 HasValidMove 改造成 FindHint让它不仅返回是否有可消对还把第一组可消除的坐标带出来bool CLLKDlg::FindHint(int r1, int c1, int r2, int c2) { for (int i 1; i ROWS; i) for (int j 1; j COLS; j) { if (m_board[i][j] 0) continue; for (int k i; k ROWS; k) for (int l (k i) ? j 1 : 1; l COLS; l) { if (m_board[k][l] ! m_board[i][j]) continue; if (CanConnect(i, j, k, l)) { r1 i; c1 j; r2 k; c2 l; return true; } } } return false; }点击提示按钮时调用 FindHint返回的那对坐标传给 OnPaint 画两帧高亮边框玩家自然知道该点哪里。洗牌道具则直接调用 ShuffleBoard每局限制使用次数。分数建议和倒计时结合消除一对 10 分剩余时间每多 1 秒追加 1 分这样玩家会有快点消的动力。音效用 PlaySound 加载 wav 资源即可消除、失败、胜利各放一个短音注意在项目属性里把声音文件加为自定义资源。最高分存档我用 CStdioFile 写一个 data.dat 文本文件格式简单方便老师检查学生在开发过程中有没有偷懒不做异常处理。最后一次建议开发过程中把 HasValidMove 和 CanConnect 单独抽出来配合断言跑一遍测试把所有边界情况写成用例远比手工点鼠标靠谱。我最初偷懒没测交上去发现有一组 L 形路径判定错误翻车后补了测试用例后面改洗牌和道具就再没出过回归问题。希望帮到你。本文还有配套的精品资源点击获取