☰
C++实现连连看自动消除:从窗口识别到鼠标模拟的完整指南
2026/10/14 17:17:28 网站建设 项目流程

1. 这个项目是怎么开始的:从一段无聊时光到桌面自动化练手

大概半年前,我在整理电脑时翻出了很多年前玩过的一款经典休闲游戏安装包,装上去玩了几天,突然发现自己已经过了那种纯靠手速和眼力通关的年纪。玩到后面几关,图块种类多、布局密,眼都花了,鼠标点来点去经常找不到能消的一对,整个人从“放松”变成了“受罪”。

当时正好在研究Windows桌面自动化相关的技术,脑子里突然冒出一个念头:与其对着屏幕干瞪眼,不如写个程序帮我找出所有可消除的图块对,再控制鼠标自动点掉它。说干就干,我用了大概三个晚上写出了第一版能用的C++程序,跑起来的那一刻,看着鼠标自己在屏幕上飞速点击、图块一块块消失,确实很有成就感。

这里要先说明一点:这类程序本质上是一种桌面级自动化辅助工具,它不修改游戏内存、不注入进程、不篡改数据包,只是单纯地“看屏幕——找特征——模拟鼠标操作”。这类技术在很多正常场景下也有广泛应用,比如UI自动化测试、RPA机器人流程自动化、办公软件批量操作等。我写的这个工具也纯粹是为了研究C++图像处理和Windows消息机制,游戏本身我从头到尾没有改动过任何数据。

这篇文章不讨论任何绕过游戏保护、作弊或者破坏他人体验的内容,只从技术学习角度讲讲:怎么用C++完成窗口识别、屏幕截取、图像匹配、路径判断、鼠标模拟这一整套流程。如果你正好对桌面自动化、图像识别或者C++实战感兴趣,这篇文章应该能给你一个完整的参考路径。

2. 整体技术思路:不相交的两条路线

在做这个项目之前,我先想清楚了一件事:像这样的自动消除程序,在技术实现上其实存在两条完全不同的路线。

2.1 内存读写路线(不推荐,也不讨论)

这条路线的思路是:通过系统接口读取游戏进程的内存空间,搜索当前棋盘状态的数据结构,找到所有图块的类型和位置,然后直接算出可消除的对,再模拟点击。这种方案反应速度极快,几毫秒就能完成全盘扫描,而且不受画面分辨率影响。但问题也很明显:

  • 涉及修改或读取第三方进程内存,属于典型的作弊行为。
  • 不同版本的游戏数据结构不一样,一旦游戏更新,程序就废了。
  • 技术上需要有驱动级权限或者调试权限,踩线风险极大,完全不值得碰。

2.2 纯视觉模拟路线(我采用的方式)

我最终采用的方案,可以简单归纳为四个步骤:

  1. 定位游戏窗口,把棋盘区域截取成一张位图。
  2. 对位图做图像处理,识别出每个格子里的图块类型。
  3. 用算法在二维数组中寻找所有可消除的图块对(连通路径不超过两次转折)。
  4. 计算屏幕坐标,模拟鼠标点击完成消除。

这条路线的优点是安全、通用、不改动任何游戏进程,而且每一步涉及的技术点(窗口API、GDI截屏、图像像素遍历、广度优先搜索、鼠标事件模拟)都有极强的复用价值。今天我就按照这个顺序,把每一步的代码思路和踩坑经验都详细写出来。

3. 窗口定位与棋盘提取:一切的前提

写这类程序最容易犯的错,就是一上来就研究算法,结果第一步取屏幕就把坐标搞错了。要知道,程序看到的坐标系和用户眼睛看到的坐标系是两回事。

3.1 找窗口句柄的三种方式

在Windows平台上,要跟窗口打交道,第一件事就是拿窗口句柄(HWND)。三种方式按优先级别如下:

  • 使用FindWindow按窗口标题查找,适用于标题固定的场景。
  • 使用FindWindowEx按窗口类名查找,更精确。
  • 使用EnumWindows枚举所有顶层窗口,通过标题关键字匹配。

我当时用的是前两种组合:先用FindWindow拿到顶层窗口,再用FindWindowEx定位到真正的客户区窗口。这里有个细节容易被忽略——有些程序主窗口里有子窗口,你截屏幕的时候应该截客户区而非整个窗口(否则会带上标题栏和边框,坐标会整体偏移)。

HWND hwnd = FindWindow(NULL, TEXT("连连看")); if (hwnd == NULL) { // 可能窗口标题不是完全匹配,遍历枚举 }

3.2 客户区坐标与屏幕坐标的区别

拿到HWND之后,用GetClientRect能拿到客户区的宽高。但这里有个大坑:GetClientRect返回的坐标是从窗口客户区左上角为原点的,而后续鼠标点击用的SetCursorPos是以整个屏幕左上角为原点的。两者要换算,公式是:

屏幕绝对坐标 = 窗口客户区对应屏幕坐标 + 客户区内相对坐标

获取窗口在屏幕上的实际位置,不能用GetWindowRect直接完事——这个函数拿到的是整个窗口(包括标题栏、边框)的左上角坐标。在Windows 10及以后版本中,由于窗口阴影和缩放布局的影响,直接算出来的位置往往会有几像素的偏移。稳妥的做法是:

RECT clientRect; GetClientRect(hwnd, &clientRect); POINT leftTop = {0, 0}; ClientToScreen(hwnd, &leftTop); // 关键转换

ClientToScreen会把客户区左上角从窗口坐标系转换到屏幕坐标系,这个值加上客户区内的相对偏移,才是最终用来截图和点击的绝对坐标。

3.3 棋盘格子坐标的标定

拿到客户区位置后,棋盘大小和格子间距需要标定。最原始但最可靠的办法是:

  1. 截取客户区整张图,保存成BMP文件,用画图工具打开。
  2. 鼠标悬停在第一行第一个格子的中心,记录相对坐标。
  3. 鼠标悬停在第二行第一个格子的中心,记录相对坐标。
  4. 两者相减得到行间距,同理可以算列间距。

我当时用的棋盘是十几行乘十几列的布局,通过两张对照图算出每格宽约为45像素、高约为45像素,左上角第一格中心偏移约为(20, 20)。这里强烈建议多取几个点平均一下,避免手动标定的误差被后面图像匹配环节放大。

4. 屏幕截取与图像匹配:连连看的图形识别怎么做

说到图形识别,很多人第一反应是OpenCV、深度学习、模板匹配神经网络这些重型方案。但如果只是识别一款老游戏的固定图块,其实完全不需要那么复杂——直接做像素级特征比对就够了。

4.1 用GDI截取屏幕图像

截屏方案有很多:GDI的BitBlt、DXGI的桌面复制、Windows Graphics Capture。对于静态画面场景,GDI够用且代码量最少:

HDC hdcScreen = GetDC(NULL); HDC hdcMem = CreateCompatibleDC(hdcScreen); HBITMAP hBitmap = CreateCompatibleBitmap(hdcScreen, rectWidth, rectHeight); SelectObject(hdcMem, hBitmap); BitBlt(hdcMem, 0, 0, rectWidth, rectHeight, hdcScreen, leftTop.x, leftTop.y, SRCCOPY);

这里要提醒一点:BitBlt截屏时会丢失一些信息,对于游戏画面可能有画面撕裂的问题。如果是动态画面,建议截两次取稳定的那张做处理。静态回合制游戏则完全不用担心。

4.2 提取每个格子的像素块特征

截到整张棋盘图后,按前面标定的格子尺寸,把棋盘划分成网格,每个格子取中心区域(避开边界线)的像素集合。然后对每个格子做特征提取,我用的方法是:

  • 计算区域内的主色调(出现次数最多的颜色)。
  • 把区域缩小到一个较小的尺度,保留粗略形状特征。
  • 把颜色量化到一定范围内,减少抗锯齿带来的干扰。

把所有这些特征组合成一个签名。每个格子都对应一个签名,棋盘就变成了一个字符串数组,接下来的一切算法操作都基于这个数组,跟图形再无关系。

4.3 消除对匹配的三个层次

匹配消除对的时候,有三个层次的做法:

  • 精确匹配:两个格子的签名完全一致才算一对,简单但有风险——同一类图块可能有细微色彩差异。
  • 哈希近似匹配:把签名哈希化,通过汉明距离判断相似度,容忍小幅色差。
  • 多特征加权匹配:主色调权重加形状特征权重,接近度超过一定阈值就认为是同类。

我实际用的是第二种。把每个格子的64维特征降维成一个整数指纹,然后比较两个指纹的二值差异位数。这个方案在测试中准确率相当高,而且跑起来比任何OpenCV模板匹配都快得多。

5. 连连看的消除算法核心:两点之间最多两次转折的路径搜索

这一步是整个程序真正有技术含量的地方。连连看的规则大家应该都知道:要消除两个相同图块,它们之间的连接路径必须满足——路径只能经过水平和垂直方向,且最多只能转折两次。换句话说,路径分为三种情况:直线、一个折点、两个折点。

5.1 问题建模

把棋盘看成一个二维数组,0表示空格,非0表示不同图块。要判断格子A和格子B是否可消除,等价于在棋盘外框上扩展一圈虚拟空格(因为路径可以绕到棋盘外圈再进来),然后回答一个问题:是否存在一条只经过空格区域的曼哈顿路径,从A到B,且方向变化次数不超过2?

这里的边界扩展很容易被忽略。很多第一次写连连看算法的人,会漏掉“路径可以绕出棋盘边界再折回来”这种情况。实际规则里这是完全合法的。处理方式是在数组四周各加一圈值为0的虚拟格子。

5.2 三种情况逐一判断

判断逻辑分三步走:

第一步:直线连通。若A和B在同一行或同一列,且中间所有格子都是0,则直线可消。

第二步:单转折。尝试枚举转折点C,若C在A和B构成的矩形范围内且C是空格,如果A到C和B到C都是直线连通,则可消。

三步:双转折。枚举两个转折点C和D,通常可以拆解为:先固定一个中间行或中间列,在这个行/列上找一个点C,使得A到C是直线、C到B是单转折。这个思路可以把双转折问题降维成两个单转折判断。

5.3 核心代码思路

双转折路径搜索的常见高效实现是这样的:

bool canPassTwoCorners(int x1, int y1, int x2, int y2) { // 水平方向扩展扫描 for (int row = 0; row < rows; ++row) { // 如果(row, y1)和(row, y2)都能与起点和终点直线连通 // 且这两个中间点在当前条件下可直线连通 // 则形成两条横线+一条竖线的路径 } // 垂直方向扩展扫描同理 }

这个写法虽然暴力,但棋盘规模很小(两三百个格子),在i5级别CPU上跑完整局搜索也就几十毫秒。找到一对可消除的图块后,程序立即记录坐标,继续找下一对,直到找不到为止。

5.4 无解与洗牌策略

如果整个棋盘扫描完都找不到任何可消除对,游戏本身通常会提供洗牌功能。我实现的程序也加了对应处理:检测到无解状态后,用随机打乱数组的方式重新布局当前图块,然后重新开始搜索。这里需要注意一个细节:洗牌后依然要保证所有图块的总数是偶数,否则会永远消不干净——虽然正常游戏中不会出现奇数,但自己写外部工具时,校验一下这个条件成本很低。

6. 自动化点击实现:从坐标计算到鼠标模拟

算法判断出可消除的格子后,下一步是让鼠标真正动起来。这一环节看起来简单,但有几个细节没处理好,程序就会表现得很“智障”:要么点得飞快肉眼可见,要么偶尔点到窗口边缘导致误操作。

6.1 坐标换算公式

前面标定棋盘时已经记录了第一格中心的客户区坐标(startX, startY)和格子间距(cellWidth, cellHeight)。那么第row行、第col列的格子中心的屏幕绝对坐标就是:

int screenX = clientLeftTop.x + startX + col * cellWidth; int screenY = clientLeftTop.y + startY + row * cellHeight;

这段换算完全依赖第3节标定的精度。有个容易出的问题:如果你用了Windows的DPI缩放(比如系统显示设置为125%或150%),ClientToScreen返回的逻辑坐标和SetCursorPos期望的物理坐标可能会不一致。解决办法是调用SetProcessDPIAware()让程序感知DPI,或者干脆把这个调用放在程序入口。

6.2 模拟鼠标点击的三种方式

Windows下模拟鼠标点击,有几种方法,我各说下适用场景:

  • SetCursorPos+mouse_event:移动光标再发送点击事件。实现简单,兼容性好,缺点是容易被实时检测到鼠标轨迹。
  • SendInput:更底层的输入注入接口,现代Windows系统推荐用它,能模拟更真实的硬件输入序列。
  • PostMessage发送WM_LBUTTONDOWN/WM_LBUTTONUP:直接把消息发给窗口,不需要激活窗口,但某些游戏会忽略注入的消息。

我最终选择了SetCursorPos配合SendInput组合,先移动再点击。点到为止,不追求极致的隐蔽性,因为我的目的只是验证自动化流程,不是对抗任何安全检测。如果你做的是UI自动化测试,直接用SendInput就是最标准方案。

SetCursorPos(screenX, screenY); INPUT input = {0}; input.type = INPUT_MOUSE; input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN; SendInput(1, &input, sizeof(INPUT)); input.mi.dwFlags = MOUSEEVENTF_LEFTUP; SendInput(1, &input, sizeof(INPUT));

6.3 点击节奏的控制

这一步的节奏控制也值得说道说道。如果你的程序模拟出每秒十几次点击的节奏,配合极短间隔的双击,跟人工操作差别很大,很容易触发游戏内的行为检测。但如果间隔太长,又会显得效率太低。我做了个动态间隔策略:两次消除操作为一组,组内间隔短、组间间隔随机化,整体模拟“人类手速波动”。这其实跟做UI自动化测试时设计合理操作间隔的思路完全一致——模拟真实用户行为,而不是机械地按固定节拍执行。

如果你只是学习自动化技术,这里没必要过度设计。但如果你想用这类程序替代手工重复操作(比如批量处理办公软件里的固定流程),这个动态节奏的思路反而值得借鉴,因为很多软件的验证码风控会分析操作行为轨迹。

7. 实测表现与瓶颈分析

整套程序跑起来以后,我实际观察到的数据是:单局从截屏到找到第一对可消除图块的平均耗时大约120毫秒,其中截屏与图像匹配占了绝大部分,路径搜索反而不到10毫秒。整局游戏(假设100对图块)大概需要15到20秒完成。

瓶颈非常明确:图像匹配环节。每截一次图,要对上百个格子逐一提取特征并比对,这个循环是重头。我用了个简单的优化:只有当棋盘布局发生变化(即上一次点击后)才重新截屏和匹配,否则沿用上次的结果;同时把特征提取的像素采样间隔扩大一倍(每隔一个像素取一个),匹配准确率几乎没有下降,但耗时就降到了60毫秒左右。

8. 调试过程中遇到的三个典型坑

8.1 窗口最小化导致截屏全黑

这是最大的坑。程序运行过程中,如果用户不小心点了桌面或游戏窗口被遮挡,BitBlt截到的内容就不是完整的棋盘,特征提取后可能匹配出完全错误的图块类型,程序就开始乱点。解决方案是每次准备截屏前检查窗口是否最小化,以及目标矩形区域是否被其他窗口覆盖。

后来我换了个思路:直接把游戏窗口设为永远置顶并且在执行过程中不做任何遮挡,同时每轮操作前校验窗口可见性。如果截到的图像素过于统一(比如全是一种颜色),就判定为异常并放弃本轮操作。

8.2 不同分辨率下的坐标偏移

前面标定的数据只对固定分辨率有效。如果用户切换了屏幕分辨率或DPI缩放,那些写死在代码里的坐标就会失效。后来我在程序里加了实时读取系统DPI并等比缩放坐标的逻辑,才彻底解决这个问题。

8.3 最后一个坑是图块种类过多时的误匹配

当游戏关卡后期出现大量相似颜色的图块(比如深绿、浅绿、青绿),基于单一颜色特征的匹配就容易把不同种类的图块判成同一类。我最后把特征从单色升级成了多区域采样——把每个格子切成九宫格,分别提取每个子区域的主色,组合成完整签名。实验效果提升非常明显,误匹配率从大约5%降到了0.5%以下。

9. 这个项目给我的三点启发

做这个小项目的过程,与其说是写了一个“外挂”,不如说是把C++图像处理、Windows API、搜索算法串起来做了一次综合训练。回头看,有三个收获特别想分享。

第一,技术方案的取舍比堆功能重要得多。我最初也心动过用深度学习来做图块识别,但事后证明,针对固定场景,传统像素特征+哈希匹配已经又快又准,而且代码不过百行。很多问题看起来花哨,其实简单方案已经足够。选方案前先搞清楚边界条件,能省下大量时间。

第二,坐标系的严谨性是桌面自动化的基石。项目中80%的诡异bug都是坐标系换算出错导致的——客户区坐标、窗口坐标、屏幕坐标、DPI缩放,稍不留神就偏移几个像素。每个做Windows桌面开发的人,都应该把“坐标系意识”刻进脑子。

第三,行为模拟层面的细节决定了工具是否可用。纯粹从“判断”到“执行”,中间隔着一整层“模拟自然行为”的功夫。这个道理不止适用于此类自动化工具,做UI测试脚本、做RPA流程,都一样。机器可以算得飞快,但如果执行行为太过机械,在真实生产环境里就容易碰壁。

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

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

立即咨询