资讯动态

MFC连连看游戏开发:棋盘算法、双缓冲绘图与消息映射实战

发布时间:2026/9/28 4:50:04 来源:尧图企业网站定制
简介基于MFC框架的连连看游戏设计源码面向初学Windows桌面应用开发的学生及对游戏编程感兴趣的C开发者旨在通过一个完整的经典益智游戏案例演示MFC应用程序从界面搭建到逻辑实现的全过程。压缩包共收录27个文件大小约3.8MB核心代码由7个头文件与4个C源文件构成覆盖主对话框类、游戏业务逻辑及框架预编译配置另有6个BMP位图资源负责背景、图案等视觉元素并包含解决方案(.sln)、项目工程(.vcxproj)、资源脚本(.rc)等Visual Studio标准工程文件便于直接编译运行与二次修改。资源同时附有版本控制与许可声明文件体现规范的项目管理方式。目前已有100人学习浏览适合用于课程设计、毕业设计参考或个人MFC自学代码组织清晰便于按模块学习。通过阅读源码可掌握图形界面控件布局、消息响应机制、连通路径判断算法及计时计分功能的实现思路还可学习到位图资源加载、预编译头使用及多文件工程组织等实用技巧是一份兼顾学习与娱乐的实战型代码资源。1. 别急着画界面MFC连连看先要解决的三个问题用 MFC 框架做连连看游戏设计源码最容易翻车的地方不是贴图而是棋盘数据结构、连通判断和消息分发。很多人打开 Visual Studio 先拖控件然后找一堆图片往按钮上贴结果鼠标点下去不知道坐标该归谁消除逻辑也没地方放最后代码全挤在对话框类里改一个变量都要全局搜索。这个方向其实适合第一次接触 MFC 的人也适合课程设计、期末项目这类要“快速出成果”的场景。你只需要想清楚三件事格子怎么存、两个格子能不能连上、点击之后怎么反馈。这篇笔记就按“能跑、能玩、能交”的顺序把整套做法拆开讲。2. 基于对话框还是文档视图把 MFC 工程骨架先立住MFC 里的“框架”两个字不是空话它决定了你的代码往哪放。连连看这种单窗口、固定交互的游戏如果一开始就选了文档视图结构会为了“文档序列化”“视图更新”这些用不上的机制写一堆模板代码。常见做法是直接用基于对话框的 MFC 应用主界面一个 Dialog游戏状态放在对话框成员变量里绘制用 OnPaint鼠标点击用消息映射整体最顺手。2.1 MFC 四大类与连连看最常用到的那几个MFC 四大类经常被教程挂在嘴边CWinApp 是应用程序类CDialog 和 CWnd 是窗口类CDC 是设备上下文类CView 是视图类。连连看这里真正高频用到的其实是前三个里的部分CWinApp 负责程序启动和消息循环CDialogEx 负责主窗口和资源管理CDC/CPaintDC 负责把棋盘画出来。先看最小骨架这个结构是所有 MFC 对话框程序的地基// MyApp.h #include afxwin.h class CMyApp : public CWinApp { public: virtual BOOL InitInstance(); }; // MyApp.cpp #include MyApp.h #include MainDlg.h // 进程唯一应用对象MFC 通过它启动消息循环 CMyApp theApp; BOOL CMyApp::InitInstance() { // 模态对话框阻塞在这里直到窗口关闭 CMainDlg dlg; m_pMainWnd dlg; dlg.DoModal(); return FALSE; // 对话框关闭后退出消息循环 }这段代码逻辑很直接theApp是全局对象程序入口先构造它然后进入InitInstance。CMainDlg是CDialogEx的派生类DoModal会弹出一个模态窗口并把消息循环交给 MFC 内部处理窗口关掉后返回FALSE进程结束。参数说明里最容易忽略的是m_pMainWnd。如果不指定主窗口指针某些依赖主窗口的消息路由会出问题比如WM_QUERYENDSESSION、AfxGetMainWnd()拿不到有效窗口。模态对话框场景下不是绝对崩溃但交课程设计时容易被老师点出来。建议都写上。2.2 对话框程序 vs 文档视图程序连连看为什么选对话框如果是做一个类似记事本的多文档编辑器文档视图结构是合理的因为要处理打开/保存/多窗口。连连看不是这种模型它的“文档”就是棋盘数组不需要落盘不需要多视图联动用文档视图反而多一层概念负担。下面这张对比表我一般会直接放在设计说明里写课程设计文档时能省不少字对比项基于对话框文档/视图界面资源对话框模板直接摆控件需要 Frame、View、Doc 多个类配合消息处理集中在对话框类适合按钮/鼠标事件分布在 View 和 Frame容易绕晕数据保存成员变量直接持有要往 Document 里塞序列化接口重绘模型OnPaint 自绘或控件刷新View 的 OnDraw还得处理文档更新适合场景桌面小游戏、工具类界面编辑器、报表、多标签文档连连看项目选对话框还有一个隐藏好处MFC 向导生成的代码里已经包含CWinApp::InitInstance、CDialogEx派生类、resource.h映射起步代码量小。你只要往对话框类里加棋盘数组和绘制函数就够了。2.3 先把编译环境补上Visual Studio 离线安装 MFCMFC 不是 Visual Studio 默认必装的组件。常见翻车现场是双击打开同学的.sln编译报错fatal error C1083: 无法打开包括文件: afxwin.h然后卡在环境配置上一个下午。做这个项目之前先确认你本机装没装 MFC。联网状态下打开 Visual Studio Installer选择修改勾选“使用 C 的桌面开发”然后在单个组件里搜索 MFC勾选对应版本的“适用于最新 v143 生成工具的 C MFC”。如果所在环境不能联网需要提前在能联网的机器上下载离线安装包启动 vs_installer.exe 时带--layout参数生成离线缓存装到内网机器后再指定--add添加 MFC 组件。具体组件 ID 随 VS 版本变化安装器界面里勾选最稳妥。建工程时搜索“MFC 应用”应用类型选“基于对话框”视觉样式随便项目生成后先空编译一次确认编译链完整再写代码。这里有一个少有人提前提醒的点项目属性里的字符集默认是 Unicode后续加载 BMP 图片、处理CString时全都要按 Unicode 来别用以前控制台教程里那种char*习惯硬套。3. 棋盘数据与连通判断复制到工程就能跑的 C 核心算法连连看算法看起来玄学拆开只有三件事直连、单拐、双拐。先把棋盘数据结构和这三个判断函数写出来后面所有界面代码都围绕它们转。3.1 棋盘如何存二维数组加上外圈墙我见过不少初学者用二维CButton*数组每个格子放一个按钮控件以为这样点击方便。实际跑起来很狼狈几十个窗口控件抢消息重绘一层层闪连通判断还得在每个按钮里取文本再比对性能没一点优势。正确做法是数据层只维护一个int二维数组界面层按数组内容绘制。定义棋盘尺寸时要留出外圈墙。#define ROW 10 // 实际棋盘 8 行 上下两行墙 #define COL 14 // 实际棋盘 12 列 左右两列墙 int m_board[ROW][COL]; // 0 表示空格非 0 表示图片类型 ID int m_cellSize; // 每个格子的像素边长窗口变化时重算初始化棋盘时外圈全部置 0内层两两配对填入类型 ID#include vector #include algorithm #include random void InitBoard() { // 外圈 0 当作空白墙双拐寻路时要用 for (int r 0; r ROW; r) for (int c 0; c COL; c) m_board[r][c] 0; // 每种牌出现两次保证可完全消除 std::vectorint cards; int innerCount (ROW - 2) * (COL - 2); for (int typeId 1; typeId innerCount / 2; typeId) { cards.push_back(typeId); cards.push_back(typeId); } std::random_device rd; std::mt19937 g(rd()); std::shuffle(cards.begin(), cards.end(), g); int idx 0; for (int r 1; r ROW - 1; r) for (int c 1; c COL - 1; c) m_board[r][c] cards[idx]; }逻辑说明先填墙再填牌。ROW和COL包含墙的行列数所以实际可视棋盘是(ROW-2) 行 * (COL-2) 列。innerCount必须是偶数否则无法两两配对。参数说明typeId从 1 开始0 留给空格和墙。外圈墙不是装饰它是双拐路径的“兜底通道”没有这圈 0贴边路径会越界。提示棋盘数组一定要比实际显示区域多一圈。外圈的作用是给双拐寻路兜底千万别省略。3.2 直连、单拐、双拐判定三个函数一次讲清连通判断是连连看的核心也是老师最常翻看的代码。先实现两个基础检查函数分别判断水平线段和垂直线段中间是否全空// 同一行 r 上从 c1 到 c2 之间的格子是否全为空 bool IsHorizontalClear(int r, int c1, int c2) { if (c1 c2) std::swap(c1, c2); for (int c c1 1; c c2; c) if (m_board[r][c] ! 0) return false; return true; } // 同一列 c 上从 r1 到 r2 之间的格子是否全为空 bool IsVerticalClear(int c, int r1, int r2) { if (r1 r2) std::swap(r1, r2); for (int r r1 1; r r2; r) if (m_board[r][c] ! 0) return false; return true; }这两个函数只检查“中间段”不判断端点因为端点可能是被选中的牌也可能是后面提到的空拐点。接下来是直连判断。直连最容易同行或同列且中间全空即可// 直连同行或同列中间无障碍 bool DirectConnect(int r1, int c1, int r2, int c2) { if (r1 r2 IsHorizontalClear(r1, c1, c2)) return true; if (c1 c2 IsVerticalClear(c1, r1, r2)) return true; return false; }单拐判断是在两个牌之间找“拐点”。拐点只有两种可能位置在 (r1, c2) 或 (r2, c1)。只要拐点为空并且拐点与两枚牌之间的两段都直连就成立// 单拐路径只有一个转折点 bool OneCornerConnect(int r1, int c1, int r2, int c2) { // 情况一拐点在 (r1, c2) if (m_board[r1][c2] 0 IsHorizontalClear(r1, c1, c2) IsVerticalClear(c2, r1, r2)) return true; // 情况二拐点在 (r2, c1) if (m_board[r2][c1] 0 IsVerticalClear(c1, r1, r2) IsHorizontalClear(r2, c1, c2)) return true; return false; }双拐稍微麻烦需要枚举一个中间折点。折点可能在某一列上也可能在某一行上// 双拐路径有两个转折点需要枚举中间折点 bool TwoCornerConnect(int r1, int c1, int r2, int c2) { // 两个折点在同一列也就是中间经过一条公共竖线 for (int c 0; c COL; c) { if (m_board[r1][c] 0 m_board[r2][c] 0 IsHorizontalClear(r1, c1, c) IsVerticalClear(c, r1, r2) IsHorizontalClear(r2, c2, c)) return true; } // 两个折点在同一行也就是中间经过一条公共横线 for (int r 0; r ROW; r) { if (m_board[r][c1] 0 m_board[r][c2] 0 IsVerticalClear(c1, r1, r) IsHorizontalClear(r, c1, c2) IsVerticalClear(c2, r, r2)) return true; } return false; }最后把它们串起来// 对外接口判断两枚牌是否能连通 bool CanConnect(int r1, int c1, int r2, int c2) { // 同一个位置不能自连 if (r1 r2 c1 c2) return false; // 空格不能被选中 if (m_board[r1][c1] 0 || m_board[r2][c2] 0) return false; // 两张牌类型必须相同 if (m_board[r1][c1] ! m_board[r2][c2]) return false; return DirectConnect(r1, c1, r2, c2) || OneCornerConnect(r1, c1, r2, c2) || TwoCornerConnect(r1, c1, r2, c2); }逻辑说明判断顺序是直连优先单拐次之双拐兜底。这样写的优势是短路评估前一步成功就不再走后面的循环。双拐函数里两个for循环分别枚举列折点和行折点复杂度是O(ROWCOL)对 8x12 的棋盘来说完全可以忽略。参数说明所有函数操作的都是带墙的坐标也就是说棋盘内左上角第一个有效格子的数组坐标是(1,1)不是(0,0)。这个约定和界面坐标换算要一致否则后面点击命中会差一格。3.3 消除后的无解检测和自动洗牌棋盘上“有路可走”时游戏继续没路时要自动重排。FindHint函数负责扫描第一对能连通的位置// 查找任何一对可消除的牌 bool FindHint(int r1, int c1, int r2, int c2) { for (r1 0; r1 ROW; r1) for (c1 0; c1 COL; c1) { if (m_board[r1][c1] 0) continue; for (r2 r1; r2 ROW; r2) for (c2 (r2 r1 ? c1 1 : 0); c2 COL; c2) { if (m_board[r2][c2] 0) continue; if (m_board[r1][c1] ! m_board[r2][c2]) continue; if (CanConnect(r1, c1, r2, c2)) return true; } } return false; }自动洗牌时把剩余非空牌收集起来重新洗乱再放回去。洗牌后可能再次无解所以要用循环保证洗到有解为止void ShuffleRemain() { std::vectorint cards; for (int r 1; r ROW - 1; r) for (int c 1; c COL - 1; c) if (m_board[r][c] ! 0) cards.push_back(m_board[r][c]); std::mt19937 g(std::random_device{}()); std::shuffle(cards.begin(), cards.end(), g); int idx 0; for (int r 1; r ROW - 1; r) for (int c 1; c COL - 1; c) if (m_board[r][c] ! 0) m_board[r][c] cards[idx]; // 如果洗牌后依然无解再洗一次 int dummyR1, dummyC1, dummyR2, dummyC2; if (!FindHint(dummyR1, dummyC1, dummyR2, dummyC2)) ShuffleRemain(); }参数说明FindHint的嵌套循环只扫一半组合避免同一对牌被检查两次。这里的坐标也是带墙坐标所以外圈 0 不会进入候选。ShuffleRemain的递归洗牌写法最坏情况也不会有几次因为牌数和排列空间足够大。4. 用 MFC 把棋盘画出来坐标换算、双缓冲与点击命中算法层就绪后界面层只剩两个问题怎么把数组坐标变成屏幕像素怎么让点击位置反算出数组坐标。这一章是“看起来简单但做起来容易歪”的部分。4.1 坐标换算客户区到棋盘行列的映射棋盘要居中显示需要在窗口尺寸变化时重算格子边长和偏移量。常见做法是先留出 margin再取宽高比的最小值作为格子边长void CMainDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (cx 0 || cy 0) return; int margin 20; int availW cx - 2 * margin; int availH cy - 2 * margin; // 取宽高方向缩小后的值保证棋盘完整显示 m_cellSize min(availW / (COL - 2), availH / (ROW - 2)); m_offsetX (cx - m_cellSize * (COL - 2)) / 2; m_offsetY (cy - m_cellSize * (ROW - 2)) / 2; }参数说明margin是棋盘到窗口边缘的留白取 20 像素足够。m_cellSize是每个格子的像素边长必须大于等于 1。m_offsetX和m_offsetY是棋盘左上角第一个有效格子的起点位置。点击换算反过来把鼠标坐标减去偏移量再除以格子边长。因为数组里带了墙要加 1 得到内层坐标// 鼠标客户区坐标 - 棋盘数组坐标成功返回 TRUE BOOL CMainDlg::PositionToCell(CPoint pt, int r, int c) { int x pt.x - m_offsetX; int y pt.y - m_offsetY; if (x 0 || y 0) return FALSE; r y / m_cellSize 1; c x / m_cellSize 1; // 点在墙或棋盘范围外 if (r 0 || r ROW - 1 || c 0 || c COL - 1) return FALSE; // 点中的必须是有效牌空格不响应 if (m_board[r][c] 0) return FALSE; return TRUE; }这段代码里最容易犯错的是1那一步。如果你在画棋盘时从(m_offsetX, m_offsetY)开始画内层但数组循环从r0开始坐标就会错开一行一列。统一约定绘制内层时循环r1; rROW-1换算时同样加 1。4.2 mfc 显示 bmp 图片CImage 贴图与双缓冲连连看棋盘最好直接用图片比画几何图形更有“游戏感”。MFC 里显示 BMP 图片常见做法是用CImage它底层封装了 GDI加载和缩放都方便。在OnInitDialog里把每种类型的图片加载到成员数组#include atlimage.h CImage m_images[64]; // 前提类型 ID 不超过 63 for (int i 1; i typeCount; i) { CString path; path.Format(_T(res\\pic%d.bmp), i); m_images[i].Load(path); }这里typeCount是(ROW-2)*(COL-2)/2。CString在 Unicode 项目里就是宽字符串所以_T宏不能省否则宽窄字符转换会出问题。绘制时用内存 DC 双缓冲避免窗口闪烁void CMainDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(dc); bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 背景色 memDC.FillSolidRect(rc, RGB(235, 235, 235)); // 画内层棋盘 for (int r 1; r ROW - 1; r) { for (int c 1; c COL - 1; c) { int id m_board[r][c]; if (id 0) continue; int x m_offsetX (c - 1) * m_cellSize; int y m_offsetY (r - 1) * m_cellSize; m_images[id].StretchBlt(memDC.m_hDC, x, y, m_cellSize - 2, m_cellSize - 2, SRCCOPY); } } // 一次性拷到屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }逻辑说明先创建与屏幕 DC 兼容的内存 DC把所有绘制都画在memDC上最后一次性BitBlt到窗口。这样窗口不会在擦背景和画格子之间暴露中间状态闪烁自然消失。参数说明StretchBlt的最后一个参数SRCCOPY表示直接拷贝不做透明处理。如果图片是 32 位带 Alpha 的 BMP这里会显示成黑底或花边最省心的办法是统一转成 24 位 BMP。m_cellSize - 2留 2 像素缝隙视觉效果更好也让相邻格子边界更清晰。提示CImage 加载失败返回E_FAIL光标图片路径时先检查res目录是否和 exe 在同一级。这是 MFC 图片显示最常见的翻车原因。4.3 消息映射与消除/重绘流程鼠标点击通过ON_WM_LBUTTONDOWN进入消息映射。MFC 的消息路由不复杂但你必须把BEGIN_MESSAGE_MAP和END_MESSAGE_MAP放在对话框类里否则编译器不报错点击就是没反应。按下鼠标后第一次选中记录位置第二次点击尝试消除void CMainDlg::OnLButtonDown(UINT nFlags, CPoint point) { int r1, c1; if (!PositionToCell(point, r1, c1)) { CDialogEx::OnLButtonDown(nFlags, point); return; } // 当前没有选中任何牌 if (!m_bFirst) { m_selR r1; m_selC c1; m_bFirst TRUE; Invalidate(FALSE); return; } // 第二次点击同一枚牌取消选中 if (r1 m_selR c1 m_selC) { m_bFirst FALSE; Invalidate(FALSE); return; } // 能连通则消除 if (CanConnect(m_selR, m_selC, r1, c1)) { m_board[m_selR][m_selC] 0; m_board[r1][c1] 0; m_score 10; UpdateScore(); m_bFirst FALSE; } else { // 不能连通把第二次点击的牌作为新的选中项 m_selR r1; m_selC c1; } Invalidate(FALSE); CDialogEx::OnLButtonDown(nFlags, point); }逻辑说明m_bFirst是选中状态标志m_selR/m_selC记录第一枚牌。消除成功后要把数组对应位置清 0再刷新界面。如果消除失败直接换选新牌而不是保留旧牌这个交互符合大多数连连看玩家的习惯。参数说明Invalidate(FALSE)的FALSE表示不擦除背景配合双缓冲可以进一步减少闪烁。如果你在OnEraseBkgnd里什么都没做这里用TRUE也不会闪但用FALSE更稳妥。为了看到选中反馈在OnPaint里画完图片后对选中格加一个边框if (m_bFirst m_selR r m_selC c) { CBrush br(RGB(255, 0, 0)); memDC.FrameRect(CRect(x, y, x m_cellSize, y m_cellSize), br); }这样整个交互闭环就成立了数组状态改动 → 界面重绘 → 玩家看到结果。5. MFC连连看避坑创建失败、闪烁、内存泄漏和Unicode路径这一章是实战里最容易卡住人的地方。问题不大但每个都能耗掉半天。5.1 对话框创建失败静态库下尤其容易翻车现象程序能编译运行后DoModal返回 -1窗口没弹出来。Debug 输出里能看到Dialog creation failed或者资源找不到的断言。原因最常见的是 MFC 静态链接时对话框资源句柄被定位错。还有一个高频原因是CMainDlg构造函数里的对话框 ID 与.rc资源里不一致。比如向导生成的类是IDD_MAIN_DIALOG你手写后改成了IDD_DIALOG1但资源文件里没同步改。解决先检查CMainDlg::CMainDlg(CWnd* pParent)构造函数里的CDialogEx::CDialogEx(IDD_MAIN_DIALOG, pParent)确认和资源编辑器里的 ID 一致。再检查项目属性里“MFC 的使用”是“在共享 DLL 中使用 MFC”还是“在静态库中使用 MFC”客户端环境缺 DLL 就选静态否则选共享。两头都对还失败在InitInstance里调用AfxEnableControlContainer()某些对话框控件容器场景需要它。提示如果老师要求一键运行建议公共机房用静态链接省得拷一堆 MFC DLL。代价是 exe 变大但稳定性好很多。5.2 重绘闪烁不双缓冲就是满屏残影现象每次消除后窗口闪闪闪像老式电视雪花了。原因Windows 默认绘制顺序是先擦背景再执行OnPaint。如果不做双缓冲每擦一次背景露一次白底再贴一遍图片视觉上就是连续闪烁。尤其是棋盘格子很多、图片较大的时候闪烁更明显。解决严格按照前面 4.2 的做法所有绘制放在内存 DC 里最后BitBlt一次拷贝。再配合OnEraseBkgnd提前返回TRUE禁止系统擦背景BOOL CMainDlg::OnEraseBkgnd(CDC* /*pDC*/) { // 背景在 OnPaint 里和棋盘一起画这里不擦 return TRUE; }参数说明OnEraseBkgnd返回TRUE表示背景已被处理系统不会再填背景色。如果你的OnPaint里用了FillSolidRect画背景这里就可以放心拦截。如果OnPaint没画背景窗口会留下上一次的残影那就不要改OnEraseBkgnd。5.3 CString 内存泄漏Debug 输出里的“假报警”和真泄漏现象程序关闭后Visual Studio 输出窗口出现Detected memory leaks!甚至指向某个CString操作。原因CString 本身有引用计数正常析构不会泄漏。真正泄漏的是两种写法第一CString::GetBuffer拿到缓冲区后没有调用ReleaseBuffer第二用new CString分配了对象但忘记delete。还有一种容易误判的情况是CString内部缓存池在 CRT 检测时被标记但不影响实际运行。解决养成配对习惯GetBuffer之后必须有ReleaseBufferCString str; TCHAR* buf str.GetBuffer(128); // 向 buf 写入内容 // 结束前必须调用 ReleaseBuffer str.ReleaseBuffer();参数说明GetBuffer(128)请求至少 128 个TCHAR的空间ReleaseBuffer()不带参数时按字符串实际长度更新内部状态。如果写入了二进制数据要传长度ReleaseBuffer(len)否则内部状态会读到第一个\0。排查时先看所有GetBuffer的配对情况再全局搜索new CString。MFC 程序里的内存泄漏报告经常是虚惊但排查顺序对了就能把真问题找出来。5.4 Unicode 下图片资源路径打不开现象程序编译正常运行后棋盘全是空白或者弹窗报Load失败。原因项目默认 Unicode但图片路径用的是窄字符串字面量或者res目录没有放到 exe 工作目录。CImage::Load接收的是LPCTSTR在 Unicode 工程里是宽字符指针传res\\pic1.bmp可能编不过传char*则会被截断成乱码路径。解决统一用_T宏或CStringCString path; path.Format(_T(res\\pic%d.bmp), i); m_images[i].Load(path);如果图片不是 BMP建议先转成 BMP。CImage 虽然能加载 PNG但 32 位 PNG 的 Alpha 混合处理要比 BMP 麻烦得多课程设计没必要在这里死磕。目录结构固定后右键项目属性里把“工作目录”设置成$(ProjectDir)避免调试时找不到res。5.5 点击命中偏差棋盘坐标换算的边界现象鼠标点某个格子结果高亮的是它左边一个或者点棋盘边缘时完全没反应。原因三种情况最普遍。第一把OnLButtonDown的CPoint当成屏幕坐标用了窗口位置只要不在屏幕左上角偏差就跟着出现第二OnSize里计算偏移量用的是窗口宽高不是客户区宽高标题栏引入了固定误差第三PositionToCell里没有限制r/c范围导致越界后读到了野值。解决OnLButtonDown收到的point本来就是客户区坐标不需要再ScreenToClient。OnSize里用形参cx和cy不要用GetWindowRect。最后在PositionToCell中加上范围判断if (r 0 || r ROW - 1 || c 0 || c COL - 1) return FALSE;这个边界和墙的范围一致双击墙或者双击棋盘外都不会选中任何牌。全部统一后鼠标命中就稳定了。6. 从“能玩”到“能交”提示、重排、计时和功能验证游戏核心跑通后离“可以交”还差几个锦上添花的功能。老师一般不会只看棋盘能不能消还会看交互完整度。6.1 提示、重排、计时按钮怎么补提示按钮的逻辑最简单直接调用之前写好的FindHint把找到的两个位置记录成选中状态void CMainDlg::OnBnClickedBtnHint() { int r1, c1, r2, c2; if (FindHint(r1, c1, r2, c2)) { m_selR r1; m_selC c1; m_hintR r2; // 成员变量OnPaint 里高亮第二处 m_hintC c2; m_bFirst FALSE; } Invalidate(FALSE); }重排按钮和自动洗牌共用ShuffleRemain只是按钮响应函数里直接调用一次。计时用SetTimer(1, 1000, nullptr)在OnTimer里更新静态文本窗口销毁时KillTimer(1)。这些功能都不需要新增算法复用现成的FindHint和ShuffleRemain就够。6.2 用 Debug 输出和内存检测验证“能交”最后交代码之前我会把验证步骤固定成一套。先玩一遍直连、单拐、双拐三种路径每种都手动消除一组然后连续点两次不同的牌确认不匹配时不会被消除再点一次已选中的牌确认能取消选中。棋盘快空时故意让FindHint找不到触发自动重排确认程序不崩。内存检测在InitInstance最前面加一行_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);这样关闭窗口后输出窗口会直接报告内存泄漏。MFC 框架本身有些延迟释放的资源偶尔会被误报重点看有没有指向自己代码的分配记录。最后一步我把项目从 Debug 切到 Release再完整跑一遍。Release 和 Debug 的时序不同某些 Debug 下被掩盖的静态变量初始化问题Release 下会暴露。我习惯把最终 exe 和res文件夹拷到一个干净目录模拟老师的评测环境跑一轮。这个习惯帮我拦下过好几次“在我机器上明明能跑”的尴尬。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑