ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC连连看源码解析:二维数组连通判定与GDI绘制实战

MFC连连看源码解析:二维数组连通判定与GDI绘制实战 简介基于MFC框架的连连看游戏完整源码面向具备基础C语法、希望入门Windows桌面开发的读者也可作为高校MFC课程设计或游戏编程实践参考。项目采用对话框程序结构完整实现了经典连连看的游戏循环、图案连接判定与消除逻辑并包含计时计分等交互功能有助于理解MFC应用程序框架与事件驱动模型。压缩包约3.8MB共27个文件。其中7个.h头文件用于类声明与全局定义4个.cpp源文件承载主对话框、游戏逻辑及核心算法6个.bmp位图提供背景、图案等视觉素材另有.sln、.vcxproj等Visual Studio工程文件以及LICENSE、.gitattributes等版本控制说明方便直接打开编译和进行团队协作。这些文件共同构成一个可直接运行的完整工程便于从代码到界面整体研读。当前已有100人学习下载。项目结构清晰从资源管理、预编译头到位图加载均有覆盖适合对照源码逐模块阅读。通过研读连接判定、消除检测等关键代码可掌握MFC消息映射、绘图与定时器的实际用法为独立开发小型Windows游戏打下基础。1. 基于MFC框架的连连看一个能一次练到Windows编程与算法思维的源码项目如果你在MFC课程设计或简历项目里找过一个“能做出来”的小游戏大概率会撞见连连看这个题目它有界面、有交互、有算法规模比管理系统小但比计算器有内容。真正难的不是把图片贴上去而是三件事——连通性判定写没写对、MFC消息循环和重绘顺不顺、游戏流程有没有漏洞。这套基于MFC框架的连连看游戏设计源码核心价值正在于同时覆盖了二维数组算法、GDI绘制、定时器和状态机。这篇文章会按数据结构、连通算法、MFC落地、完整流程、避坑、验证的顺序拆开讲给出可直接复现的代码块和参数说明。正在做课程设计的学生能一步步照做想快速捡起MFC、给简历项目找素材的开发者也能从中对照自己实现哪里容易翻车。2. 连连看核心玩法拆解二维数组棋盘与三线连通判定怎么做2.1 棋盘数据结构为什么用二维数组而不用链表连连看棋盘是“行列整齐、格子固定”的网格天然适合用二维数组表达。我常用的定义是把可见棋盘设为 8 行 12 列同时在数组四周各留一圈空行空列也就是 10 行 14 列。这多出来的一圈不是给玩家看的而是给连通判定用的——当路径需要绕过棋盘边缘走“外圈”时判定代码不用到处判断row - 1会不会越界。// 可见棋子区域8行 x 12列 const int GAME_ROWS 8; const int GAME_COLS 12; // 内部数组四周各留一圈避免连通判定时碰负下标 const int ROWS GAME_ROWS 2; // 10 const int COLS GAME_COLS 2; // 14 // 业务坐标从 1 开始0 和 ROWS-1 这一圈永远保持 0 int board[ROWS][COLS];这里要让新手理解一个关键点board[row][col]里存的是“图块类型编号”不是图片指针本身。0 表示空格1 到 n 表示不同图案。显示成什么样由绘制阶段拿编号去查位图资源。这样做的好处是洗牌、消除、判定、存档都不需要处理 HBITMAP逻辑层和表现层能分开。为什么不用链表或 vector连连看的主要操作是“按下标访问”和“把某个格子清零”二维数组在两个操作上都是常数时间内存连续缓存友好。链表固然能方便地“删除节点”但连通判定要求随机访问任意格子链表每次都要遍历复杂度反而高。vector 也可以用但固定棋盘大小用普通数组最直白答辩时也最好讲。2.2 三种连通形式直线、单拐点、双拐点的判定思路连连看里的“可消除”指两个图块之间能找到一条路径路径最多拐两次弯且路径经过的所有格子都是空格。起点和终点本身不算障碍拐点所在的格子必须为空。我习惯把判定拆成三层递进第一层是直线连通。两个图块在同一行或同一列中间所有格子为空直接消。相邻格子的情况也可以走这一类因为“中间没有格子”天然为空。第二层是单拐点。假设拐点放在连接线的转角处候选拐点只有两个(r1, c2)和(r2, c1)。以(r1, c2)为例路径分成两段先从(r1, c1)水平走到(r1, c2)再从(r1, c2)垂直走到(r2, c2)。两段都通过直线判定而且拐点本身是空格这个走法就成立。第三层是双拐点。先固定一条“中转行”或“中转列”让路径变成三段从起点垂直移动到中转行再水平移动到终点所在列最后垂直移动到终点。双拐点的两个角点在同一行或者在同一列。实现上扫过所有可能的中转行和中转列逐个验证三段路径是否畅通。很多网上源码“看起来能玩但有的角落消除不掉”问题往往出在两点一是拐点忘了判空二是没留外圈导致经过棋盘边缘的路径被误判为不通。加上 2.1 的外圈设计后这两种问题都能避开。2.3 连通判定函数完整代码、边界处理与时间复杂度先写直线判空的辅助函数。这里有个容易踩的细节循环区间是开区间不含两端因为端点本身的图块状态由调用方负责判断。// 判断 (r1,c1) 到 (r2,c2) 之间是否一路通畅不含两个端点 // 调用前必须保证两点在同一行或同一列 bool IsLineClear(int r1, int c1, int r2, int c2) { if (r1 r2 c1 c2) return false; // 同行从左往右或从右往左扫描 if (r1 r2) { int step (c2 c1) ? 1 : -1; for (int c c1 step; c ! c2; c step) { if (board[r1][c] ! 0) return false; } return true; } // 同列从上往下或从下往上扫描 if (c1 c2) { int step (r2 r1) ? 1 : -1; for (int r r1 step; r ! r2; r step) { if (board[r][c1] ! 0) return false; } return true; } return false; }这个函数的参数含义很直接r1/c1是第一个格子的行列r2/c2是第二个格子的行列。典型的易错写法是把c ! c2写成c c2一旦终点在左边循环直接不执行判定错误。用带方向的 step 写法可以同时兼容正反两个方向。有了直线判定完整的连通判定如下// 判断两个图块是否满足“最多两个拐点”的可消除规则 bool CanConnect(int r1, int c1, int r2, int c2) { // 同一格不能消空格不能消图案不同不能消 if (r1 r2 c1 c2) return false; if (board[r1][c1] 0 || board[r2][c2] 0) return false; if (board[r1][c1] ! board[r2][c2]) return false; // 1. 直线连通 if (IsLineClear(r1, c1, r2, c2)) return true; // 2. 单拐点候选拐点为 (r1,c2) 和 (r2,c1) if (board[r1][c2] 0 IsLineClear(r1, c1, r1, c2) IsLineClear(r1, c2, r2, c2)) return true; if (board[r2][c1] 0 IsLineClear(r1, c1, r2, c1) IsLineClear(r2, c1, r2, c2)) return true; // 3. 双拐点两个拐点在同一行 // 从第 1 行扫到 ROWS-2正好覆盖外圈边界带来的合法路径 for (int r 1; r ROWS - 1; r) { if (r r1 || r r2) continue; // 拐点 1(r, c1)拐点 2(r, c2) if (board[r][c1] 0 board[r][c2] 0 IsLineClear(r1, c1, r, c1) IsLineClear(r, c1, r, c2) IsLineClear(r, c2, r2, c2)) return true; } // 3. 双拐点两个拐点在同一列 for (int c 1; c COLS - 1; c) { if (c c1 || c c2) continue; // 拐点 1(r1, c)拐点 2(r2, c) if (board[r1][c] 0 board[r2][c] 0 IsLineClear(r1, c1, r1, c) IsLineClear(r1, c, r2, c) IsLineClear(r2, c, r2, c2)) return true; } return false; }代码里的双拐点扫描是整份源码最核心的部分。两个for循环分别枚举“中转行”和“中转列”每次枚举都要满足三个条件两个拐点本身为空三段路径分别畅通。IsLineClear是开区间判断所以拐点是否为空必须单独用board[...] 0检查这一点是最容易被遗漏的。时间复杂度方面单次点击判定最坏要扫描 10 行 14 列每段扫面至多走十几个格子整体在百次操作量级对玩家点击而言感知不到延迟。但注意死局检测需要把棋盘上所有同类型图块两两配对调用CanConnect棋盘越大耗时越明显。8x12 的棋盘总共 96 格同类型配对数量不多扫一遍也在几十毫秒内完成可以在 UI 线程直接算。如果棋盘改到 20x30就建议把死局检测丢到工作线程里了。3. 把玩法落到MFC界面消息映射、定时器与点击坐标换算3.1 MFC消息映射从OnLButtonDown到逻辑层的调用链路MFC 程序里鼠标点击不会自动变成“棋盘坐标”它先进入消息映射表。常见的对话框程序在BEGIN_MESSAGE_MAP里通过ON_WM_LBUTTONDOWN把WM_LBUTTONDOWN绑定到OnLButtonDown。这里要区分对话框程序和文档视图程序前者重写OnLButtonDown后者一般在CView派生类里重写。下面的示例基于对话框主界面这也是多数 MFC 连连看源码的选择。BEGIN_MESSAGE_MAP(CGameDlg, CDialogEx) ON_WM_LBUTTONDOWN() ON_WM_TIMER() ON_WM_PAINT() END_MESSAGE_MAP()核心处理函数如下这段代码要回答“玩家点了屏幕上的某个像素它对应棋盘哪一格”void CGameDlg::OnLButtonDown(UINT nFlags, CPoint point) { // point 是客户区坐标不是屏幕坐标 // offsetX/offsetY 是棋盘左上角到客户区左上角的偏移 // CELL_W/CELL_H 是单个图块占用的像素宽高 int col (point.x - offsetX) / CELL_W 1; int row (point.y - offsetY) / CELL_H 1; // 越界保护只有落在可见棋盘 1..GAME_ROWS / 1..GAME_COLS 才继续 if (row 1 || row GAME_ROWS || col 1 || col GAME_COLS) { CDialogEx::OnLButtonDown(nFlags, point); return; } if (!hasFirstClick) { // 第一次点击记录选中格并高亮 firstRow row; firstCol col; hasFirstClick true; InvalidateRect(GetCellRect(firstRow, firstCol)); // 局部刷新 } else { if (CanConnect(firstRow, firstCol, row, col)) { // 消除两个格子置空 board[firstRow][firstCol] 0; board[row][col] 0; hasFirstClick false; // 消除后检查死局和胜负 AfterEliminate(); } else { // 消除失败以当前点击作为新的选中格 firstRow row; firstCol col; } // 用局部重绘代替 Invalidate()减少闪烁 InvalidateRect(GetBoardRect()); } }这段逻辑里有个交互细节值得留意第一次点击没选对时传统做法是“两次点击无效就清空选中状态”让玩家重新点。我这里选择把第二次点击的格子当作新的第一次选择好处是玩家快速连续点两个不同图块时不用每次等两次操作手感更顺。这也是许多做得好的连连看源码默认行为。3.2 用WM_TIMER驱动倒计时与自动打乱倒计时是连连看最基础的时间压力设计。MFC 里启动一个秒级定时器很简单SetTimer(1, 1000, NULL)。第一个参数是定时器 ID第二个参数是毫秒间隔第三个是回调函数指针传 NULL 表示把消息发给窗口。定时器 ID 建议定义成枚举或常量避免和对话框其他功能冲突。const UINT TIMER_GAME 1; const UINT TIMER_HINT 2; void CGameDlg::StartGame() { // 初始剩余时间 90 秒根据难度可以调成 60 或 120 remainSeconds 90; SetTimer(TIMER_GAME, 1000, NULL); }OnTimer里按 ID 区分事务。倒计时只是把整数减一然后更新 UI不能在OnTimer里做阻塞操作否则窗口会假死。这一点是 MFC 初学者最容易犯的错后面避坑章节我会专门展开。void CGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_GAME) { remainSeconds--; if (remainSeconds 0) { KillTimer(TIMER_GAME); // 这里不要弹 MessageBox先置状态再通过界面提示 gameOver true; Invalidate(); return; } UpdateTimeLabel(); } else if (nIDEvent TIMER_HINT) { // 自动提示的闪烁动画每 300ms 切换一次高亮 hintFlash ^ true; InvalidateRect(GetCellRect(hintRow, hintCol)); } CDialogEx::OnTimer(nIDEvent); }注意KillTimer的时机倒计时归零、玩家通关、窗口销毁时都要调用。窗口销毁后定时器还在跑会收到WM_TIMER而操作已释放的资源这是常见的崩溃来源。OnDestroy里统一KillTimer是最稳妥的做法。3.3 重绘流程Invalidate、OnPaint与双缓冲MFC 的绘制不是“想画就画”而是先把客户区标记为无效等到消息循环处理WM_PAINT时再统一绘制。Invalidate()全量刷新InvalidateRect(rect)只刷新指定区域。连连看里消除两个格子后整个棋盘的状态没有全变用局部刷新更合理但前提是你的绘制代码能够正确处理“只重绘某个区域”而不产生脏边。实际项目中我更多用全量重绘加双缓冲原因很简单连连看图块数量不大一次全量绘制损耗可以忽略而局部刷新的坐标计算一旦出错排查成本更高。void CGameDlg::OnPaint() { CPaintDC dc(this); // CPaintDC 在构造时调用 BeginPaint析构时 EndPaint CRect rcClient; GetClientRect(rcClient); // 创建内存 DC 和兼容位图构成后备缓冲 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap *pOldBitmap memDC.SelectObject(bmp); // 先画背景再画棋盘再画图块再画选中框 DrawBackground(memDC, rcClient); DrawBoard(memDC); // 整块缓冲一次性拷贝到屏幕 DC dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 还原旧位图再释放资源 memDC.SelectObject(pOldBitmap); bmp.DeleteObject(); }DrawBackground和DrawBoard是两个自定义方法前者填充客户区底色后者按行列循环画图块。图块在DrawBoard内部的定位公式是offsetX (col - 1) * CELL_W、offsetY (row - 1) * CELL_H和前文点击坐标换算是互逆运算。只要这两个地方的常量一致点击才能和显示对得上。关于资源加载建议把图块位图在OnInitDialog里统一用LoadBitmap加载到成员变量而不是每次OnPaint都加载一次。反复加载位图一是慢二是容易造成 GDI 对象泄漏这个放到避坑章节细说。4. 完整游戏流程洗牌、死局检测、计分与胜负判定4.1 开局洗牌成对图块的随机分布一局连连看的开局要做两件事生成成对的图块、把图块随机打乱并填到棋盘可见区域。常见做法是用一个临时一维数组装满 96 个图块编号然后洗牌再按行优先顺序填入board[1..GAME_ROWS][1..GAME_COLS]。void CGameDlg::NewGame() { // 清理棋盘包括外圈 memset(board, 0, sizeof(board)); // 12 种图案每种 4 对正好 48 对 96 格 const int typeCount 12; const int total GAME_ROWS * GAME_COLS; // 96 // 用数组生成成对编号 // (i / 2) 保证每两个相邻元素成对% typeCount 让图案轮流出现 int* blocks new int[total]; for (int i 0; i total; i) blocks[i] (i / 2) % typeCount 1; // 随机打乱 std::random_shuffle(blocks, blocks total); // 老接口简单直接 // 填入可见区域外圈保持 0 int index 0; for (int r 1; r GAME_ROWS; r) { for (int c 1; c GAME_COLS; c) { board[r][c] blocks[index]; } } delete[] blocks; // 重置状态 hasFirstClick false; remainSeconds 90; score 0; gameOver false; // 万一洗出来的局面没有可消除对直接重排一次 if (!HasValidMove()) ShuffleBoard(); Invalidate(); }这里random_shuffle在 C17 标准里被移除VS2019 之后编译会报警告或错误。如果遇到就用更现代的写法代替std::shuffle(blocks, blocks total, std::mt19937(std::random_device{}()))。这种细节不影响游戏玩法但能帮你避开“源码下下来编译不过”的尴尬。洗牌后没有可消除对怎么办理论上一副完全随机的牌有小概率出现无解开局所以NewGame末尾留了HasValidMove()检查。如果返回假直接再洗一次。这个兜底逻辑虽然简单但能避免玩家刚开局就卡死。4.2 死局检测扫描全盘找可消除对死局检测的逻辑很朴素枚举所有同类型图块配对任意一对能被CanConnect判定为可消除棋盘就没死。为了不重复配对第二层循环的起点取决于第一层循环的行列关系。// 全盘扫描是否还存在至少一对可消除图块 bool CGameDlg::HasValidMove() { for (int r1 1; r1 GAME_ROWS; r1) { for (int c1 1; c1 GAME_COLS; c1) { if (board[r1][c1] 0) continue; for (int r2 r1; r2 GAME_ROWS; r2) { // 同行时第二格从下一列开始避免 (r1,c1)(r2,c2) int c2Start (r2 r1) ? c1 1 : 1; for (int c2 c2Start; c2 GAME_COLS; c2) { if (board[r2][c2] 0) continue; if (board[r1][c1] ! board[r2][c2]) continue; if (CanConnect(r1, c1, r2, c2)) return true; } } } } return false; }这段代码的时间复杂度是 O(格数 × 配对次数 × 单次连通判断)。8x12 棋盘全盘扫一次实测在几十毫秒量级玩家不会有感觉。每次消除后调用HasValidMove()最稳妥但也最费。折中办法是每消除一对后先不扫全盘只统计剩余图块数量等剩余数量减少到某个阈值或者连续几次消除都失败再检查死局。我还是建议初版写每次都检查二十万分之一秒的消耗对桌面程序无所谓正确性优先级更高。如果检测到死局需要做“重排”给玩家后悔药。实现直接void CGameDlg::ShuffleBoard() { // 收集所有剩余图块 std::vectorint remaining; for (int r 1; r GAME_ROWS; r) for (int c 1; c GAME_COLS; c) if (board[r][c] ! 0) remaining.push_back(board[r][c]); // 打乱后重新填回去 std::random_shuffle(remaining.begin(), remaining.end()); int index 0; for (int r 1; r GAME_ROWS; r) for (int c 1; c GAME_COLS; c) if (board[r][c] ! 0) board[r][c] remaining[index]; // 重排后如果还是死的递归重来一次 if (!HasValidMove() !remaining.empty()) ShuffleBoard(); Invalidate(); }注意这里不能把board整个清空重填因为玩家已经消掉了一部分剩余图块的总数必须是偶数。重排只收集非零格数量天然成对所以可以放心。4.3 计分、倒计时与通关判定计分规则可以拆成三个维度消除一对的即时得分、剩余时间奖励、通关后的总分。即时得分是核心我一般用基础分 10 分加路径复杂度加成。路径越多拐点视觉上越“秀”加成越高但这属于玩法设计不是必须。简单版本可以不分拐点数统一加 10 分。void CGameDlg::AfterEliminate() { score 10; // 每次消除后刷新剩余图块数 int remain 0; for (int r 1; r GAME_ROWS; r) for (int c 1; c GAME_COLS; c) if (board[r][c] ! 0) remain; if (remain 0) { // 通关 gameOver true; KillTimer(TIMER_GAME); CString msg; msg.Format(L通关总分 %d剩余时间 %d 秒, score, remainSeconds); MessageBox(msg); return; } // 检查死局 if (!HasValidMove()) { ShuffleBoard(); MessageBox(L没有可消除的图块已重新洗牌); } }一个状态设计上的建议gameOver、hasFirstClick、firstRow、firstCol这些状态变量要集中在头文件里并且命名清楚。我之前见过有的源码把选中状态放在局部 static 变量里第二次点击时 static 值被异常重置整个交互逻辑变成玄学。把这些都做成对话框成员变量初始化逻辑放在NewGame里统一复位是血泪经验换来的习惯。5. 避坑指南:MFC连连看开发中的5个高频翻车点5.1 界面闪烁没有双缓冲造成的重绘抖动现象每次消除后整个棋盘闪白快速连续点击时画面像在“抖动”。原因OnPaint里直接往屏幕 DC 逐块绘图块。每执行一次BitBlt或绘制操作屏幕就会刷新一次局部区域多个绘制操作叠加起来就变成闪烁。解决用双缓冲把全部内容先画到内存 DC最后一次BitBlt到屏幕。第 3.3 节的OnPaint就是标准写法。闪烁严重时还可以配合BeginPaint/EndPaint检查和WM_ERASEBKGND返回 TRUE禁止背景擦除重复触发。如果只消除两个格子用InvalidateRect重绘这两个格子的区域不要每次Invalidate()整个客户区。刷新范围和闪烁程度直接相关这一条值得先试。5.2 点击坐标错位DPI缩放与客户区换算现象同一份代码在缩放 100% 的显示器上点击准确换到 150% 缩放的笔记本上点击位置明显偏右偏下或者高 DPI 屏上完全对不上。原因Windows 的 DPI 虚拟化会把WM_LBUTTONDOWN的坐标映射到虚拟客户区而你的绘制代码拿到的GetClientRect与实际物理像素不一致。棋盘绘制时用了物理像素的CELL_W点击坐标却按虚拟像素算两者差一个缩放系数。解决在程序启动处调用SetProcessDPIAware()并且在模块资源里声明 DPI 感知。MFC 项目里更完整的做法是重写OnWindowPosChanged或WM_DPICHANGED处理动态调整CELL_W和CELL_H。稳妥起见不要把格子尺寸写死成 48建议把所有跟尺寸相关的常量集中定义初始化时乘以GetDpiForWindow(m_hWnd) / 96.0f。少踩一次 DPI 的坑就能省下大量“为什么这台电脑上跑偏了”的排查时间。5.3 GDI资源泄漏HBITMAP加载后没有释放现象游戏运行一段时间后图块变成黑块、花块任务管理器里“GDI 对象”数量不断上涨最后BitBlt失败或画不出来。原因最常见的是在OnPaint里反复LoadBitmap创建新的CBitmap但提前return或异常分支没有走DeleteObject。另一个常见原因是SelectObject把新位图选进内存 DC 后没有把旧位图选回来位图句柄仍被 DC 引用析构时无法释放。解决图块位图只加载一次保存为对话框成员变量OnInitDialog里LoadBitmapOnDestroy里显式释放。需要临时创建兼容位图做双缓冲时严格按照SelectObject(pOldBitmap)恢复再bmp.DeleteObject()最后memDC.DeleteDC()的顺序清理。这个小节牵一发动全身建议在用完临时 DC 的地方用 RAII 对象或者写一个CleanupDC辅助函数避免手动释放遗漏。5.4 UI线程卡死在OnTimer或点击处理里做耗时操作现象点击“提示”按钮或“自动打乱”后窗口标题栏显示“未响应”几秒后才恢复甚至直接被系统判定为卡死。原因在OnTimer、OnLButtonDown等 UI 线程入口函数里直接调用Sleep或者用while循环等待某个动画状态完成。消息循环被阻塞窗口无法处理WM_PAINT、WM_TIMER系统就会认为程序失去响应。解决动画一律用定时器按帧推进。比如“消除动画从 A 移动到 B”不要在一个函数里写完整动画循环而是每 30 毫秒让定时器推进一帧绘制中间状态。死局检测和全盘扫描如果棋盘较大放到工作线程完成后PostMessage给主窗口一个自定义消息更新界面。这里有个经验开发阶段可以在OnTimer里临时加Sleep模拟延迟来调试逻辑但发布前必须删掉不然用户第一眼就是“这软件真卡”。5.5 数组越界点击棋盘边缘格子导致崩溃现象点击最上边、最下边或左右边缘时程序偶发崩溃报错指向board[row][col]访问异常。原因坐标换算公式(point.x - offsetX) / CELL_W对边界外的点击会产生 0 或负数直接用来访问数组就形成越界。有些源码为了省事把数组尺寸和可见区域做成一致边缘格子的双拐点判定又去访问row - 1同样越界。解决两层防护。第一层点击处理开头就做范围判断行列不在1..GAME_ROWS / 1..GAME_COLS直接 return不给后续访问任何越界机会。第二层棋盘数组尺寸做成GAME_ROWS 2和GAME_COLS 2外圈始终为 0连通判定算法里的循环范围写成1..ROWS-2或1..COLS-2既保证不越界又给边缘路径留出了合法通道。这两招一起用边缘格子的问题基本绝迹。6. 验证驱动与后续改造给这套MFC连连看源码加上“自检”能力6.1 加一个“自动对局”测试模式源码能跑通不等于逻辑全对。最简单有效的验证手段是加一个自动对局模式按 F2 触发程序自己扫描棋盘并消除所有可消除对直到通关或死局重排。这样能快速暴露连通判定和死局检测的缺陷。void CGameDlg::OnAutoPlay() { // 只建议在 Debug 版本使用Release 不要绑定这个快捷键 while (!gameOver) { if (!HasValidMove()) { ShuffleBoard(); continue; } bool eliminated false; for (int r1 1; r1 GAME_ROWS !eliminated; r1) { for (int c1 1; c1 GAME_COLS !eliminated; c1) { if (board[r1][c1] 0) continue; for (int r2 r1; r2 GAME_ROWS !eliminated; r2) { int c2Start (r2 r1) ? c1 1 : 1; for (int c2 c2Start; c2 GAME_COLS !eliminated; c2) { if (board[r2][c2] 0) continue; if (board[r1][c1] ! board[r2][c2]) continue; if (CanConnect(r1, c1, r2, c2)) { board[r1][c1] 0; board[r2][c2] 0; eliminated true; } } } } } Invalidate(); // 手动释放一次消息循环让界面不至于完全冻结 MSG msg; while (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { DispatchMessage(msg); } } }这个函数最值得注意的地方是消除后立刻PeekMessage派发消息否则自动模式下界面会卡死。自动模式不追求速度只验证算法。跑几百次自动对局如果每次都能走到通关或死局重排连通判定的基本正确性就有把握了。6.2 后续重构把棋盘逻辑抽成独立类从我接触的 MFC 小游戏源码看最普遍的问题是逻辑和界面纠缠在CGameDlg一个类里。想加单元测试、换界面框架或接存档功能都会牵扯一堆消息处理代码。下一步改造建议是把棋盘数组和相关算法抽成CGameCore。class CGameCore { public: void NewGame(); bool Click(int row, int col); // 返回是否消除成功 bool HasValidMove(); void ShuffleBoard(); // 供界面层查询状态 int GetBlock(int row, int col) const; int GetScore() const; bool IsGameOver() const; private: int board[ROWS][COLS]; bool hasFirstClick; int firstRow, firstCol; int score; bool gameOver; int remainSeconds; };界面层只做三件事把像素坐标换为棋盘坐标、调用CGameCore的接口、把返回值反映到界面上。这么一拆MFC 的部分可以整个替换成 Win32 或 Qt核心逻辑不动。我一直觉得一个课程设计级的 MFC 项目能做到这个程度已经比“能运行”高一个档次了。我在自己写过的小游戏里养成的习惯是先保证算法层可自测再去调界面。以前第一版连连看直接在OnPaint里反复加载位图结果跑十分钟图块就变黑后来才明白是 GDI 句柄泄漏。现在写任何 MFC 小项目我都会先做双缓冲、统一管理位图资源再谈玩法。把这些坑提前堵住后面改功能的时间能省出一大半希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进