☰
基于MFC框架的连连看游戏开发:算法实现与界面设计
2026/9/28 1:18:30 网站建设 项目流程

简介:基于MFC框架的连连看游戏源码,面向正在学习C++ Windows编程、准备课程设计或对桌面休闲游戏实现感兴趣的开发者。项目涵盖完整游戏逻辑:图案连接判定、消除、计时计分与胜负判定,主要算法集中在4个C++源文件中,游戏类结构由7个头文件定义,另有6个位图文件承载界面与图案素材。全部27个文件还包含Visual Studio解决方案、工程配置、图标资源、资源脚本,以及Git版本控制与许可证说明,压缩包仅3.8MB,目录结构清晰易读。开发者既可直接编译运行体验完整对局,也可逐段拆解连通性判断算法、MFC消息映射和位图加载等关键环节,从而掌握用MFC对话框搭建交互式小游戏的基本路径,为后续扩展或迁移到其他图形界面项目提供参考。目前已有100人学习下载,是一份适合入门实践的MFC游戏设计案例。

1. 为什么用MFC做连连看:课程设计里最稳的Windows桌面练手项目

做过Windows课程设计的同学多半绕不开这样一个选题:基于MFC框架的连连看游戏设计源码。它把一个完整游戏拆成三件套——棋盘数据与消除算法、GDI界面绘制、鼠标消息交互,恰好对应MFC里最常用的知识块。用MFC做连连看的价值不只是“交一份能跑的作业”,而是把C++的数组、指针、消息映射、位图资源这几条线一次性串起来。这个方案适合三类人:正在选课程设计题目、想从控制台程序跨进Windows图形界面、需要一份能看懂能改的源码做二次开发。下面按从算法到工程再到排错的顺序讲,新手能跟步骤走,熟手直接看第2章的判定实现和第5章的坑。

2. 连连看核心判定算法:两个图块为什么能消,路径怎么算

2.1 三种连通关系与棋盘边界约定

连连看的基础规则一句话能说清:两个图块相同,且之间存在一条“转弯不超过两次”的空路径,就能消除。转弯不超过两次,对应三种情况:直线连通、一个拐点的L形、两个拐点的之字形或U形。直线连通是另外两种的基础,所以代码里第一个判断永远是“是否在同一行或同一列且中间无障碍”。

棋盘建模是第一步。可玩区域定成 ROW 行 COL 列,但数组声明为 [ROW+2][COL+2],四周围一圈永远是 0 的空边界。为什么加这一圈?因为路径允许从棋盘外部绕。比如(1,2)和(1,5)之间有障碍物,但可以从棋盘顶边绕过去,这时路径经过的(0,2)、(0,5)这些格子属于外圈。不加外圈,每次边界判断都要塞一堆 if;加了之后,边界条件被数据本身吸收,代码干净一半。代价只是多占几十个 int,完全可忽略。

注意:可玩区域的行列范围是 1..ROW 和 1..COL,外圈(0 行、ROW+1 行、0 列、COL+1 列)永远为 0。所有坐标运算都按“带外圈”的完整数组做。

还有一个隐藏约束:可玩区格子总数必须是偶数,否则图块无法两两成对。比如 8×8=64 格没问题,9×9=81 格就必须留一格不放图块,或者改棋盘尺寸。课程设计里直接固定成 8×8、10×10 这类偶数规格最省心。

2.2 棋盘初始化与洗牌:保证开局一定有解

棋盘初始化的目标是“每个图块出现两次,且洗乱之后仍然存在至少一对可消除图块”。很多第一次写连连看的同学会踩一个隐蔽的坑:随机放图块,导致开局死局——整个棋盘没有任何一对能消,玩家第一眼就卡住。这属于概率玄学,但完全可以通过一次全局检查来规避。

常见做法是:先成对生成图块 ID,用 Fisher-Yates 洗牌打乱顺序,再按行优先填进棋盘,最后检查全局是否存在至少一对可消除点。如果不存在,重新洗牌,直到有解为止。

// 棋盘数据:0 表示空位,非 0 表示图块种类 ID int g_board[ROW + 2][COL + 2]; // 是否存在至少一对可消除图块 bool HasAnyValidPair() { for (int r1 = 1; r1 <= ROW; ++r1) { for (int c1 = 1; c1 <= COL; ++c1) { if (g_board[r1][c1] == 0) continue; for (int r2 = r1; r2 <= ROW; ++r2) { for (int c2 = (r2 == r1) ? c1 + 1 : 1; c2 <= COL; ++c2) { if (g_board[r2][c2] == 0) continue; if (CanConnect(r1, c1, r2, c2)) return true; } } } } return false; } // 洗牌并保证有解 void ShuffleBoard() { do { int count = ROW * COL; std::vector<int> values; values.reserve(count); for (int i = 0; i < count / 2; ++i) { int type = rand() % kTypeCount + 1; // 图块 ID 范围 1..kTypeCount values.push_back(type); values.push_back(type); // 每个图块出现两次 } // Fisher-Yates 洗牌 for (int i = count - 1; i > 0; --i) { int j = rand() % (i + 1); std::swap(values[i], values[j]); } // 填入可玩区,外圈保持 0 int idx = 0; for (int r = 1; r <= ROW; ++r) for (int c = 1; c <= COL; ++c) g_board[r][c] = values[idx++]; } while (!HasAnyValidPair()); // 无解就重新洗,直到开局有消 }

逻辑说明:values 里先成对填充,保证每个 ID 出现偶数次;洗牌后按顺序填入,图块数量天然满足“每种都能配对”。HasAnyValidPair 做全棋盘扫描,找到任意一对 CanConnect 返回 true。循环用 do-while 保证至少执行一次洗牌,不死循环。这里有个工程取舍:洗牌加全局检查在最坏情况下会重复洗很多轮,但 8×8 棋盘、8 种图块时实测几轮内必出解,性能可以忽略。如果棋盘扩到 16×16,建议把图块种类同步增加,避免图块种类太少、棋盘太密导致解罕见。

参数说明:kTypeCount 是图块种类数,控制游戏难度。8×8 棋盘配 6~8 种,中等难度;想简单就降到 4 种,想难就提到 10 种以上。但种类也不能无限多,后续界面绘制要为每种图块准备一张位图,资源数量会跟着涨。

2.3 连通判定代码:直线、单拐点、双拐点的完整实现

连通判定是整个源码的核心,最容易写错的地方集中在“是否包含端点”和“两个拐点的枚举方向”。我给的实现有一个约定:IsLineClear 只检查两端点之间的格子,不检查端点本身;而两个拐点算法需要额外确认中转点为空。这个约定反过来会简化调用方的逻辑。

// 判断 (x1,y1) 到 (x2,y2) 的直线上是否畅通,不包含两端点 bool IsLineClear(int x1, int y1, int x2, int y2) { if (x1 == x2) { int step = (y1 < y2) ? 1 : -1; for (int y = y1 + step; y != y2; y += step) if (g_board[x1][y] != 0) return false; return true; } if (y1 == y2) { int step = (x1 < x2) ? 1 : -1; for (int x = x1 + step; x != x2; x += step) if (g_board[x][y1] != 0) return false; return true; } return false; // 不在同一行或同一列 } // 一个拐点:拐点候选是 (x2,y1) 和 (x1,y2) bool IsOneCornerConnected(int x1, int y1, int x2, int y2) { if (g_board[x2][y1] == 0 && IsLineClear(x1, y1, x2, y1) && IsLineClear(x2, y1, x2, y2)) return true; if (g_board[x1][y2] == 0 && IsLineClear(x1, y1, x1, y2) && IsLineClear(x1, y2, x2, y2)) return true; return false; } // 两个拐点:枚举中转行或中转列,路径拆成三段直行 bool IsTwoCornerConnected(int x1, int y1, int x2, int y2) { // 枚举中转行 r:(x1,y1) -> (r,y1) -> (r,y2) -> (x2,y2) for (int r = 0; r < ROW + 2; ++r) { if (r == x1 || r == x2) continue; // 退化成单拐或直线,前面已处理 if (g_board[r][y1] == 0 && g_board[r][y2] == 0 && IsLineClear(x1, y1, r, y1) && IsLineClear(r, y1, r, y2) && IsLineClear(r, y2, x2, y2)) return true; } // 枚举中转列 c:(x1,y1) -> (x1,c) -> (x2,c) -> (x2,y2) for (int c = 0; c < COL + 2; ++c) { if (c == y1 || c == y2) continue; if (g_board[x1][c] == 0 && g_board[x2][c] == 0 && IsLineClear(x1, y1, x1, c) && IsLineClear(x1, c, x2, c) && IsLineClear(x2, c, x2, y2)) return true; } return false; } // 对外判定接口:is 可消除 bool CanConnect(int x1, int y1, int x2, int y2) { if (x1 == x2 && y1 == y2) return false; // 同一个格子 if (g_board[x1][y1] == 0 || g_board[x2][y2] == 0) return false; // 空位 if (g_board[x1][y1] != g_board[x2][y2]) return false; // 图块不同 if (IsLineClear(x1, y1, x2, y2)) return true; // 直线 if (IsOneCornerConnected(x1, y1, x2, y2)) return true; // 一个拐点 if (IsTwoCornerConnected(x1, y1, x2, y2)) return true; // 两个拐点 return false; }

IsTwoCornerConnected 里容易困惑的是“为什么枚举中转行时跳过 r == x1 或 r == x2”。原因是路径从起点先纵向走到中转行,再水平走,最后纵向到终点;如果中转行恰好等于起点行或终点行,路径就退化成单拐或直线,那些情况在前面已经被 IsLineClear 和 IsOneCornerConnected 覆盖。跳过它们是去重,不是漏解。中转列同理。

另一个容易忽略的是中转点检查:枚举行时,需要显式确认 g_board[r][y1] 和 g_board[r][y2] 都是 0,因为 IsLineClear 不包含端点。这两个格子是路径转弯处,必须是空的。枚举列时同理。

复杂度上,一次 CanConnect 最坏枚举 ROW+COL 个中转点,每个中转点做三段直行检查,整体是 O((ROW+COL) × max(ROW,COL))。8×8 棋盘完全无压力,但 HasAnyValidPair 要遍历所有格子对,棋盘大时重洗会变慢。课程设计级别不用担心,追求极致可以用“可解棋盘倒退生成”替代重洗,但工程复杂度会明显上升,不推荐新手做。

提示:如果你要改成非矩形棋盘(比如蜂窝形),外圈方案就得重新设计。矩形棋盘加外圈是这个算法能简洁成立的先决条件,别轻易去掉。

3. MFC界面实现:从位图加载到鼠标点击消除

3.1 用CImage加载BMP图块:资源组织和透明处理

算法跑通后,界面层第一步是让棋盘“看得见”。MFC里加载位图最常见的做法是用 CImage,它比 LoadBitmap 灵活,支持 BMP、JPG、PNG,还能直接配合 TransparentBlt 做透明。图块位图统一放在工程的 res 目录下,命名 tile1.bmp、tile2.bmp……编号与棋盘里的图块 ID 一一对应。这里就体现了 mfc 显示 bmp 图片的常规套路:预加载到成员变量,而不是每次绘制时读文件。

// 头文件里的成员声明 CImage m_tiles[kTypeCount + 1]; // 下标从 1 开始,0 号不用 BOOL CLinkGameDlg::LoadTiles() { CString baseDir; // 以 exe 所在目录为基准拼资源路径,避免“当前工作目录”变化导致找不到图 GetModuleFileName(NULL, baseDir.GetBuffer(MAX_PATH), MAX_PATH); baseDir.ReleaseBuffer(); int pos = baseDir.ReverseFind(_T('\\')); baseDir = baseDir.Left(pos + 1) + _T("res\\"); for (int i = 1; i <= kTypeCount; ++i) { CString path; path.Format(_T("%stile%d.bmp"), baseDir, i); if (!m_tiles[i].Load(path)) { AfxMessageBox(_T("加载图块失败: ") + path); return FALSE; } } return TRUE; }

逻辑说明:GetModuleFileName 拿到 exe 完整路径,截掉文件名后拼上 res 子目录。这样无论程序从哪个目录启动,位图路径都稳定。很多私下跑不起来的问题都是因为用相对路径 “res\tile1.bmp”,调试器工作目录一变就加载失败。

参数说明:CString 的 GetBuffer 之后必须 ReleaseBuffer,否则 CString 内部长度信息不对,截出来的路径可能带乱码。路径里如果含中文,工程字符集必须是 Unicode;用“多字节字符集”的老工程,CImage::Load 走 ANSI 路径,中文目录名会直接翻车。

透明处理方面,最简单可靠的是画出位图时用 TransparentBlt,指定一种颜色作为透明色。制图时把背景填充成纯品红 RGB(255,0,255),游戏里这些像素不会画上去。需要链接 msimg32.lib,在项目属性“链接器-输入-附加依赖项”里加上;也可以直接在源码里用#pragma comment(lib, "msimg32.lib"),后者更直观,不会漏配。

3.2 OnPaint双缓冲绘制:从棋盘数组到屏幕像素

MFC对话框的绘制入口是 OnPaint。直接在每个格子绘制时调用 CImage::Draw 也能显示,但窗口拖动、刷新时会疯狂闪烁。原因是 GDI 绘制直接写屏幕,擦除背景和重绘之间有空隙,人眼看到的就是闪。解决方式是双缓冲:先在内存位图上画完一帧,再一次性 BitBlt 到屏幕。

void CLinkGameDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); // 双缓冲:内存 DC + 内存位图 CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(&dc); memBmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&memBmp); // 背景 memDC.FillSolidRect(&rc, RGB(240, 240, 240)); // 逐格绘制图块 for (int r = 1; r <= ROW; ++r) { for (int c = 1; c <= COL; ++c) { int tileId = g_board[r][c]; if (tileId != 0) DrawTile(&memDC, r, c, tileId); } } // 一次绘制到屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 恢复旧位图,让析构安全 }

DrawTile 里用格子坐标换算像素坐标。棋盘左上角设定为固定偏移 (kBoardLeft, kBoardTop),每个格子边长 kTileSize 像素。行列 1..ROW、1..COL 对应像素位置:

void CLinkGameDlg::DrawTile(CDC* pDC, int row, int col, int tileId) { int px = kBoardLeft + (col - 1) * kTileSize; int py = kBoardTop + (row - 1) * kTileSize; CDC tmpDC; tmpDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, kTileSize, kTileSize); CBitmap* pOld = tmpDC.SelectObject(&bmp); // 原图若是大图,这里只按格大小缩放绘制 m_tiles[tileId].Draw(tmpDC.GetSafeHdc(), 0, 0, kTileSize, kTileSize); // 用透明色抠掉背景 ::TransparentBlt(pDC->GetSafeHdc(), px, py, kTileSize, kTileSize, tmpDC.GetSafeHdc(), 0, 0, kTileSize, kTileSize, RGB(255, 0, 255)); tmpDC.SelectObject(pOld); }

逻辑说明:先把图块位图画进一块同尺寸的临时 DC,再用 TransparentBlt 把品红背景抠掉。为什么不直接 TransparentBlt 源图?因为 CImage 的 HDC 句柄在循环里反复 GetDC/ReleaseDC 开销略高,先复制到内存格的临时 DC 便于统一缩放与后续扩展(比如选中状态叠加边框)。

双缓冲的关键是“只向屏幕 BitBlt 一次”。只要发现界面闪烁,优先检查是不是逐格直接往 CPaintDC 里画;这是 MFC 游戏最常见的翻车原因,没有之一。

3.3 鼠标消息与消除链路:OnLButtonDown里的命中和状态切换

MFC对话框里响应鼠标点击,重写 OnLButtonDown。难点不是收到消息,而是把客户区像素坐标换算成棋盘行列,并管理“先选中一个,再选中第二个”的状态机。我维护两个成员 m_selRow、m_selCol,-1 表示当前没有选中。

void CLinkGameDlg::OnLButtonDown(UINT nFlags, CPoint pt) { // 像素坐标 -> 棋盘行列 int col = (pt.x - kBoardLeft) / kTileSize + 1; int row = (pt.y - kBoardTop) / kTileSize + 1; // 超出可玩区或点击到空位,直接忽略 if (row < 1 || row > ROW || col < 1 || col > COL) return; if (m_selRow == -1) { // 还没有选中:点击必须落在图块上才算选中 if (g_board[row][col] != 0) { m_selRow = row; m_selCol = col; Invalidate(); // 触发重绘,画出选中框 } } else { // 已经选中一个,分三种情况 if (row == m_selRow && col == m_selCol) { // 点同一个格子:取消选中 m_selRow = m_selCol = -1; Invalidate(); return; } if (CanConnect(m_selRow, m_selCol, row, col)) { // 消除成功:置空、加分、重绘 g_board[m_selRow][m_selCol] = 0; g_board[row][col] = 0; m_selRow = m_selCol = -1; m_score += 10; UpdateScoreText(); Invalidate(); if (IsBoardEmpty()) { AfxMessageBox(_T("恭喜通关!")); } } else { // 消除失败:把新点击的格子作为新的选中 m_selRow = row; m_selCol = col; Invalidate(); } } CDialogEx::OnLButtonDown(nFlags, pt); }

逻辑说明:pt 在 MFC 消息里已经是客户区坐标,所以直接用棋盘偏移量做除法。这里有个新手容易错的地方:拿 ScreenToClient 或 ClientToScreen 来回换算,反而搞错坐标系。OnLButtonDown 收到的就是客户区坐标,直接算。

参数说明:kBoardLeft、kBoardTop 控制棋盘在窗口里的位置,kTileSize 是单格像素边长。三者必须和 OnPaint 里 DrawTile 的换算公式完全一致,否则“显示位置”和“点击命中位置”错位。如果窗口尺寸变化,可以动态重算这三个值,但课程设计固定尺寸即可。IsBoardEmpty 就是遍历棋盘看是否全为 0,代码很简单,不再展开。

4. 把源码跑起来:MFC工程搭建与文件组织

4.1 从新建工程开始:MFC组件安装与工程类型选择

拿到任何一份基于MFC框架的连连看源码,第一步是让开发环境能编译。不少同学卡在“新建项目里搜索不到 MFC 应用”,原因是 VS 默认安装没勾选 MFC 组件。用 Visual Studio Installer 进入“单个组件”,勾选“适用于最新 v143 生成工具的 C++ MFC (x86 和 x64)”。离线环境装组件,可以提前在联网机器上把安装包缓存下载好,再通过安装器的离线安装功能部署——这一步和装其他 VS 组件是同一套流程,没有特殊捷径。

工程类型选择上,连连看这种单窗口小游戏用“MFC 应用程序”里的“基于对话框”就能跑,无需文档/视图架构。对话框工程生成后自带一个主对话框类、一个资源文件、一个 stdafx 编译头,结构最简。不建议选“单文档”类型,文档视图序列化在这个项目里用不上,反而增加理解负担。

创建完成后,把之前写的棋盘逻辑单独拆成独立模块,对话框类只管界面和消息。这样后期调试时可以把棋盘逻辑抽出来做无界面测试,详情见第6章。

4.2 源码文件怎么划分:逻辑层与界面层分离

一次课程设计的源码,最怕所有代码堆在一个 Dlg 类里,两千行之后自己都找不到函数。我习惯把工程拆成两层:界面层和逻辑层。连连看里逻辑层就是棋盘数组和连通判定,界面层是位图、绘制、消息响应。

文件归属职责
stdafx.h编译头包含 MFC 公共头,预编译加速
LinkGame.h / LinkGame.cpp界面层主对话框类,消息处理、绘制、计时器
BoardLogic.h / BoardLogic.cpp逻辑层棋盘数组、洗牌、连通判定,不依赖任何 MFC 窗口类
Resource.h / 资源文件资源层对话框模板、图标、字符串
res 目录资源层tile1.bmp..tileN.bmp 图块位图

逻辑层不包含任何窗口相关代码是关键约束。BoardLogic 里只操作 int 二维数组和标准库容器,连 CString 都不要用,这样后续可以单独写一个 Console 测试入口调用 CanConnect,不用启动窗口。这套分层对源码阅读、运行、答辩讲解都有帮助,老师问“你算法怎么测的”时可以直接演示测试程序。

4.3 从点击到消除的完整链路与关键成员

把第3章的界面响应和第2章的算法串起来,一次完整消除的调用链是:

OnLButtonDown 收到客户区坐标 → 换算成 (row, col) → 用 m_selRow/m_selCol 保存第一个选中点 → 第二次点击时调用 CanConnect → 返回 true 就把两格置 0 → 调用 Invalidate 触发 OnPaint → OnPaint 遍历棋盘绘制剩余图块。

关键成员集中在主对话框类里:

class CLinkGameDlg : public CDialogEx { private: int m_board[ROW + 2][COL + 2]; // 棋盘数据,逻辑层直接操作 CImage m_tiles[kTypeCount + 1]; // 图块位图缓存 int m_selRow; // 当前选中的行,-1 表示无选中 int m_selCol; int m_score; // 当前得分 UINT_PTR m_timerHint; // 提示功能定时器 ID };

注意棋盘数组作为成员放在对话框类里,逻辑层函数用指针或引用访问它。另一种做法是把棋盘数组全局化,课程设计里常见但不够干净。全局数组写起来快,可一旦要复刻到其他工程、或者加个“新游戏”功能,全局状态清理很头疼。我建议成员变量方式,新游戏就是清零加洗牌,一个函数搞定。

5. 连连看开发避坑指南:五个必踩的坑与排查记录

5.1 现象:Debug版正常,Release版一点击就崩溃

Debug 下跑得好好的,切换到 Release 编译就崩,这是 MFC 程序里最常见的血泪经验。原因通常是数组越界,Debug 的 MSVC 运行库会在栈上下方加校验,越界时触发 ASSERT 窗口直观报出来;Release 没有这些检查,越界后悄悄写坏相邻内存,直到某个时刻崩溃。

解决:先在代码里搜所以数组访问,重点看 (col - 1) * kTileSize 这类坐标换算和 IsLineClear 的循环边界。再开 /W4 编译告警,把未初始化成员、类型转换告警全部处理掉。最后可以在逻辑层加一个越界断言函数,在 CanConnect 入口校验坐标范围,Release 下用 VERIFY 宏保留检查。

5.2 现象:窗口拖动或刷新时棋盘疯狂闪烁

闪烁的本质是重复擦除和绘制。MFC 对话框默认有背景擦除消息 WM_ERASEBKGND,用户拖动窗口时系统先用背景色擦一遍,再触发 OnPaint,两个操作之间屏幕表现出“闪白”。解决就是双缓冲,详见3.2节。如果用了双缓冲还闪,检查是不是有子控件覆盖在棋盘区,或者某处代码直接调用了 CWnd::Invalidate 且参数带了 TRUE(表示擦除背景),改成 Invalidate(FALSE) 只重绘不擦除,能进一步减少闪烁。

5.3 现象:退出程序时输出窗口报 Detected memory leaks

MFC 应用退出时,VS 的“输出”窗口打印内存泄漏诊断,地址指向某次 CString 操作。常见原因有两个:一是调用 GetBuffer 后没有 ReleaseBuffer,导致 CString 内部长度缓存与真实缓冲区状态不一致,某些路径下销毁时判定异常;二是自定义线程里直接传递 CString 对象,跨线程没有深拷贝协调。解决:所有 GetBuffer 必须和 ReleaseBuffer 成对出现,中间不要用 return 提前跳出;跨线程传字符串尽量用 std::wstring 或经过临界区复制,不让 CString 离开创建它的线程。

5.4 现象:静态链接MFC的Release版在其他机器上对话框创建失败

工程属性选了“在静态库中使用 MFC”,拷贝到没装 VS 的机器上运行,可能弹“无法定位程序输入点”或对话框直接创建失败。原因分两层:静态链接 MFC 确实不再依赖 MFC DLL,但 C 运行库仍有依赖,运行库方式没设成 /MT 时,目标机缺少对应的 MSVCP 运行库;另外对话框资源若是按 Unicode 编译,系统区域设置异常也会造成资源加载失败。

解决:优先改用“在共享 DLL 中使用 MFC”,并随程序带上对应版本的 VC++ 运行库;如果必须静态链接,把“代码生成-运行库”改成“多线程 (/MT)”,并在干净的虚拟机里做一次启动测试。

5.5 现象:快速双击第二个图块时,消除总是没反应

单次点击正常,连点两下时第一次选中、第二下却不生效。原因是 Windows 把快速连击识别成 WM_LBUTTONDBLCLK,而不是两个 WM_LBUTTONDOWN。对话框默认的 DblClk 处理里没有你写的逻辑,消息被吞掉或走了另一个分支。

解决:在类向导里重写 OnLButtonDblClk,直接调用 OnLButtonDown 的同一处理函数;或者干脆在 PreTranslateMessage 里拦截两个消息统一派发。我一般把点击逻辑抽成一个私有函数 OnCellClicked(row, col),OnLButtonDown 和 OnLButtonDblClk 都调它,一处逻辑,两条消息入口。

6. 把基础版做深:提示、得分与连通性自测

6.1 提示功能:复用CanConnect自动找一对可消图块

玩法上玩家最需要的功能是“提示”。它本质上就是一次全局扫描,找出第一对 CanConnect 返回 true 的坐标,再画一个闪烁框。代码直接复用 HasAnyValidPair 的遍历逻辑,只是把匹配到的坐标存下来返回。

bool FindValidPair(int& outR1, int& outC1, int& outR2, int& outC2) { for (int r1 = 1; r1 <= ROW; ++r1) for (int c1 = 1; c1 <= COL; ++c1) { if (g_board[r1][c1] == 0) continue; for (int r2 = r1; r2 <= ROW; ++r2) for (int c2 = (r2 == r1) ? c1 + 1 : 1; c2 <= COL; ++c2) { if (CanConnect(r1, c1, r2, c2)) { outR1 = r1; outC1 = c1; outR2 = r2; outC2 = c2; return true; } } } return false; }

找到后用一个短定时器(500ms)交替切换一个 m_hintFlash 布尔值,OnPaint 里对这两格额外画一个红色边框。提示要扣分或消耗次数,这个由规则定,工程上只需要在调用处减分即可。

6.2 得分与连击参数设计

基础版每一对消 10 分太单调。我一般加三个规则:连续消除不中断时,连击数叠加,基础得分乘以连击倍数;使用提示功能后连击清零;通关时按剩余时间奖励额外分数。这些规则改动都在界面层完成,不碰 BoardLogic,规则是产品逻辑,棋盘算法是数学逻辑,分开维护。

在 OnTimer 里维护一个倒计时变量,每秒减 1,归零时游戏结束。定时器在这里承担两个职责:全局倒计时和提示框闪烁,用两个不同的事件 ID(如 kTimerCountDown 和 kTimerHint)区分。注意 OnTimer 里不要做重活,只修改状态变量然后 Invalidate,重绘交给 OnPaint。

6.3 用一段自测代码验证算法边界

我每次改完算法都会做一个无窗口的验证程序,把棋盘逻辑编译成控制台测试。做法是在 BoardLogic.cpp 里加条件编译块,单独编译一个带 main 的测试文件,直接调用 CanConnect 验证四类边界:外圈绕行路径、两拐点在棋盘角落、障碍紧贴端点、同一格子。测试数据不编造复杂场景,用 3×3 的小棋盘手工摆数组,每种情况算一次。

#ifdef _TEST_MODE int main() { // 布局一个 3x3 棋盘,外圈为 0 memset(g_board, 0, sizeof(g_board)); g_board[1][1] = 1; g_board[1][3] = 1; // 中间 (1,2) 为空,应当直线连通 bool ok = CanConnect(1, 1, 1, 3); printf("直线连通: %s\n", ok ? "PASS" : "FAIL"); return 0; } #endif

逻辑说明:这个测试不需要创建任何窗口,链接时也不依赖 MFC 的界面部分,专门验证算法本身。如果你要交课程设计,把测试代码文件一起放工程里,答辩时直接运行说明“这是连通判定的回归测试”,比口头讲算法有力得多。

到这个阶段,我自己的习惯是把所有边界用例固化成一张表:直线、单拐、双拐、外圈绕行、非法点击、空位点击,每个用例一行输出。后续加新功能前先跑一遍测试,确认没有改坏核心判定。这样做比反复“手工点点点验证”高效得多,也少了很多次让人脸红的重构翻车。

如果你正卡在“算法通了但界面起不来”或者“界面有了但判定不对”,按这个顺序排查:先跑自测代码验证算法,再用双缓冲解决显示,最后接鼠标消息。一条线走下来,基于MFC框架的连连看游戏设计源码就从一个标题变成了真正能跑、能演示、能扩展的工程。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询