☰
C++实现自动消除器:图像识别与连连看寻路算法实战拆解
2026/10/14 3:40:15 网站建设 项目流程

把“秒杀挂”三个字放进标题里,第一眼确实容易让人想歪。但如果把视角拉回工程本身,它本质就是一个“自动消除器”:获取屏幕画面、识别棋盘上每个格子的图标、根据连连看的规则找到可以连接的一对、自动完成点击,然后重复到通关。这套链路用 C++ 从头写一遍,会依次碰到窗口截图、像素级图像匹配、坐标换算、二维数组寻路这几个典型问题,是一个非常完整的“视觉 + 算法”小项目。

这篇我按自己的实现顺序来写,把每一步的思路、取舍、代码骨架以及我踩过的坑都放进来。提前说清楚:这只是本地算法验证和学习用途的技术拆解,不涉及任何在线平台的接入,也请别拿它往真实游戏里乱用,原因我放到最后一节专门讲。

1. 需求拆解:一个“秒杀挂”到底由哪几块组成

1.1 没有魔法,就是一条四段式流水线

不管网上把它包装得多么玄乎,这种自动消除程序的业务逻辑其实非常固定,拆开之后只有四件事:

  1. 截取屏幕或指定窗口的画面,拿到一张位图。
  2. 从位图中识别出每个格子里的图标,把图像信息变成“某种类型数字”。
  3. 根据识别结果,构造一个二维棋盘矩阵,并扫描出当前所有可以消除的图标对。
  4. 把“消除哪两个格子”翻译成屏幕坐标,执行点击操作,等待画面刷新后进入下一轮。

最难的部分不是第三步,而是第二步。识别做得准,后面所有逻辑都是顺水推舟;识别稍微偏差一个格子,寻路写得再好也没有用。很多人一上来就拼算法,结果都在“识别错位”这种基础问题上折腾半天。

1.2 为什么用 C++ 而不是 Python

我见过不少同类项目是用 Python + OpenCV 做的,确实开发效率高,写起来也快。但用 C++ 重写一遍,收益点不在运行速度本身,而在下面两个地方:

  • C++ 可以直接调用 Windows 的系统级 API(比如GetDC、BitBlt、GetDIBits),截屏链路里少一层中间封装,理解更透彻。
  • C++ 的内存和循环控制更“露骨”。当你手动管理一块像素缓冲区、手动遍历每个格子去算相似度时,才会真正意识到每张截图背后是什么样的一堆字节。

如果只是追求“能跑”,Python 两天就能搞定。如果想把整个图像处理链路弄明白,C++ 反而是更好的学习载体。做这个项目时,我建议环境是 Windows 10/11 + Visual Studio,C++17,图像部分可以先用 OpenCV 辅助,也可以纯 Win32 实现后面我会分别说。

1.3 先立一条底线:离线优先

我在动手前给自己定了一条规矩:所有识别和寻路逻辑,先在离线棋盘数据上验证;需要用到屏幕截图时,也只从本地演示程序或自己构造的测试位图里取数据。整个项目不写入任何真实在线平台的业务逻辑,更不提供“绕过检测”类的内容。

这条底线不是怕麻烦,而是非常现实的约束。在线游戏里跑第三方自动化脚本,轻则违反用户协议导致封号,重则可能涉及不合规的软件操作,完全得不偿失。技术学习和技术滥用之间的界限,应该在写第一行代码之前就想清楚,而不是等做完了再后悔。

2. 屏幕截取与图像识别:先把棋盘变成数据

2.1 获取窗口画面:GDI 是零基础最好的入口

识别的第一步是拿到屏幕画面。Windows 下最常见的方式是 GDI 的BitBlt,它可以把指定窗口或整个屏幕复制到一张 HBITMAP 位图里。虽然它不是性能最优方案,但代码非常简单,适合作为入门版本。

#include <windows.h> HBITMAP CaptureClientArea(HWND hwnd, int& outW, int& outH) { HDC hdcWin = GetDC(hwnd); HDC hdcMem = CreateCompatibleDC(hdcWin); RECT rc; GetClientRect(hwnd, &rc); outW = rc.right - rc.left; outH = rc.bottom - rc.top; HBITMAP hbm = CreateCompatibleBitmap(hdcWin, outW, outH); HGDIOBJ oldBmp = SelectObject(hdcMem, hbm); BitBlt(hdcMem, 0, 0, outW, outH, hdcWin, 0, 0, SRCCOPY); SelectObject(hdcMem, oldBmp); DeleteDC(hdcMem); ReleaseDC(hwnd, hdcWin); return hbm; }

GetClientRect拿的是客户区大小,正好排除标题栏和边框,后续做坐标换算会省很多事。需要注意,如果窗口使用了 DirectX 全屏独占模式,GDI 抓到的可能是一张黑图,这时需要改用 DXGI Desktop Duplication 或其他截图方式。做本地演示时把目标窗口设置成窗口模式,就能绕开这个问题。

2.2 把 HBITMAP 变成像素数组

拿到 HBITMAP 还只是图形对象的句柄,要处理像素,必须通过GetDIBits把位图内容拷贝到一块普通内存里。这里最容易搞错的是BITMAPINFO的初始化,biBitCount设成 32 会让后续计算简单很多,每四个字节对应一个像素的 BGRA 排列。

std::vector<uint8_t> BitmapToPixels(HBITMAP hbm, int w, int h) { BITMAPINFO bmi{}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = w; bmi.bmiHeader.biHeight = -h; // 负数表示自上而下的行顺序 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; std::vector<uint8_t> pixels(w * h * 4); HDC hdc = GetDC(NULL); GetDIBits(hdc, hbm, 0, h, pixels.data(), &bmi, DIB_RGB_COLORS); ReleaseDC(NULL, hdc); return pixels; }

biHeight设为负数这一点很多人会忽略。如果不加负号,像素行序是自下而上的,后面所有通过行列坐标定位格子的逻辑都会反过来,排查起来头大。另外这里拷贝的是整块屏幕数据,实际项目里可以先计算棋盘所在子区域,再做区域截取,减少内存占用。

2.3 图标识别:模板匹配够不够用

拿到像素数组后,要把每个格子识别成图标类型。最简单可行的方法是模板匹配:预先收集每种卡片的模板图像,把当前位置的格子子图与所有模板做相似度对比,取最相似的模板作为识别结果。

如果使用 OpenCV,matchTemplate一行就能完成相关计算:

cv::Mat boardRegion = GetBoardImage(); cv::Mat tpl = LoadTemplate("tpl_apple.png"); cv::Mat result; cv::matchTemplate(boardRegion, tpl, result, cv::TM_CCOEFF_NORMED); double minVal, maxVal; cv::Point minLoc, maxLoc; cv::minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc);

但我想说的是:模板匹配能做,却并不稳。游戏换一套皮肤、换一种背景色、图标尺寸稍有变化,匹配率就会断崖式下跌。如果你就是想快速验证,可以用它;如果想做得皮实一点,建议采用更粗暴但更可靠的方式:每个图标选一个中心区域,计算颜色分布特征或感知哈希,再对比相似度。图标一般颜色差异明显,这种方式对尺寸缩放不敏感,比全图逐像素匹配更稳。

我当时还加了一个技巧:比较时只取格子的中心 60% 区域,不要带边缘。因为同一图标的边缘会透出不同的背景色或边框,删掉边缘之后,不同主题下的识别稳定性提升很多。

2.4 从像素坐标换算成棋盘行列

识别结束后的结果是“某行某列是什么图标”,可最后点击时需要的又是真实屏幕坐标,所以必须有换算公式。假设棋盘左上角在像素坐标(boardX, boardY),每个格子宽cellW、高cellH,那么:

int col = (px - boardX + cellW / 2) / cellW; int row = (py - boardY + cellH / 2) / cellH;

反过来,格子中心坐标就是:

int cx = boardX + col * cellW + cellW / 2; int cy = boardY + row * cellH + cellH / 2;

这里有几个隐藏问题。一是 DPI 缩放,Windows 默认 150% 缩放时,如果程序不声明 DPI 感知,GetClientRect拿到的坐标和鼠标实际坐标可能差了 1.5 倍,点击位置必然偏移。解决办法是在程序启动时调用SetProcessDPIAware(),或使用按显示器缩放的方式。二是棋盘边缘的留白,不能拿整个客户区宽度除以格子数量,否则每格中心会整体偏移。正确做法是等比例推算出boardX,再取几个实际格子像素位置做均值校正。

3. 连连看核心算法:怎么判断“能不能连”

3.1 先把规则翻译成程序语言

连连看的连接规则用一句话说:两个相同图标的格子之间,存在一条只经过空白格子的路径,路径由水平或垂直线段组成,最多转折两次,而且允许从棋盘外侧绕过去。

“允许从棋盘外侧绕”是新手最容易漏掉的条件。棋盘的边界不是墙,路径可以走到棋盘外面再绕回来,所以程序里的棋盘矩阵需要比画面上的棋盘大一圈,外圈全部视为可通行空白。

我把棋盘表示成一个二维vector<vector<int>>,0表示空白,其他数字表示不同图标类型。上下左右各加一圈0作为外圈,这样后续做路径判断时,无需再写大量“越界即不可走”的逻辑。

3.2 基础函数:判断一条水平或垂直线路是否通畅

在判断复杂折线之前,先写两个小函数,专门检查两个格子之间某一行或某一列是否全部为空。

// 检查第 r 行中,从 c1 到 c2 是否全部空白 bool RowClear(const std::vector<std::vector<int>>& board, int r, int c1, int c2) { if (c1 > c2) std::swap(c1, c2); for (int c = c1; c <= c2; c++) if (board[r][c] != 0) return false; return true; } // 检查第 c 列中,从 r1 到 r2 是否全部空白 bool ColClear(const std::vector<std::vector<int>>& board, int r1, int r2, int c) { if (r1 > r2) std::swap(r1, r2); for (int r = r1; r <= r2; r++) if (board[r][c] != 0) return false; return true; }

这里要小心边界顺序。我最初写的时候没有注意坐标交换,结果从下往上扫描时会直接漏掉中间节点。在写路径检查函数时,无论传参顺序如何,统一先排序,能省掉一批诡异 bug。

3.3 两种路径判断法:外绕扫描法和中间线扫描法

有了基础函数,判断两点能否连接就清晰了。核心分三步:

  • 第一步,两点类型不同直接返回false。
  • 第二步,判断 0 折直连:两点在同一行且RowClear,或同一列且ColClear。
  • 第三步,判断 1 折和 2 折。1 折的本质是找一个转折点,让两条直线都通畅;2 折的本质是找一条中间直线,让三条直线都通畅。统一起见,可以用中间线扫描法:
bool CanConnect(const std::vector<std::vector<int>>& board, int r1, int c1, int r2, int c2) { if (board[r1][c1] != board[r2][c2] || board[r1][c1] == 0) return false; // 0 折 if (r1 == r2 && RowClear(board, r1, c1, c2)) return true; if (c1 == c2 && ColClear(board, r1, r2, c1)) return true; int R = (int)board.size(); int C = (int)board[0].size(); // 枚举中间列 for (int c = 0; c < C; c++) { if (RowClear(board, r1, c1, c) && ColClear(board, r1, r2, c) && RowClear(board, r2, c2, c)) return true; } // 枚举中间行 for (int r = 0; r < R; r++) { if (ColClear(board, c1, r1, r) && RowClear(board, r1, r2, r) && ColClear(board, c2, r2, r)) return true; } return false; }

因为棋盘矩阵已经加了外圈,所以c和r遍历时可以从0到最大行列,外圈也在其中自然参与判断。这个写法的优点是逻辑统一,只需要三层判断,不需要单独区分 1 折和 2 折;缺点是枚举量稍大,但棋盘的尺寸一般最多也就十几乘几十,实际跑起来完全没有性能压力。

3.4 全局搜索策略:怎么找可消除的一对

判断单对能连之后,下一步就是遍历整个棋盘找一对。天真写法是两层循环枚举所有格子作为起点,再嵌套找到终点:三重循环,复杂度 O(N³),在 14×18 的棋盘上大约是几十万次判断,性能尚可,但足够浪费。

更好的做法是,按图标类型建桶。相同图标的格子放在同一个vector里,只会桶内两两组合,大大缩小比较范围:

std::unordered_map<int, std::vector<std::pair<int,int>>> buckets; for (int r = 0; r < R; r++) for (int c = 0; c < C; c++) if (board[r][c] != 0) buckets[board[r][c]].push_back({r, c}); for (auto& [type, cells] : buckets) { for (size_t i = 0; i < cells.size(); i++) { for (size_t j = i + 1; j < cells.size(); j++) { if (CanConnect(board, cells[i].first, cells[i].second, cells[j].first, cells[j].second)) { // 找到了第一对可消除的格子 return std::make_pair(cells[i], cells[j]); } } } }

搜索顺序也很关键。如果希望棋盘被“肉眼看上去有序地消除”,可以按行列顺序从上到下、从左到右找;如果只追求速度,就先扫桶再按桶内顺序。实际项目里,我倾向于找到第一对就直接返回,这样刷新速度最快。

4. 自动点击与离线仿真:如何验证整套逻辑

4.1 自动点击:工程上最“轻”的一块

如果一定要做到“自动消除”,最后一步就是模拟鼠标点击。Windows 下常见的方式是SetCursorPos配合mouse_event,或者直接用SendInput。这个环节代码量非常少,文档也好查,但我不想在这里贴完整可用的按键模拟代码——因为这个组合一旦接上真实在线环境,就是完整的外挂行为。

我的建议是:自己本地学习时,把点击封装成一个回调函数。在仿真环境里,这个回调只打印一行日志;即使将来接入界面自动化,也只在可信的本地测试窗口里调用。把这个抽象做好,比拷两行点击代码重要得多。

4.2 强烈建议:先做一个离线仿真测试台

屏幕识别、模板匹配、坐标换算这些环节相互耦合,如果不做隔离,调试一个坐标偏移问题会拖累整条链路。我第二次重做这个项目时的做法是:先写一个不依赖任何真实画面的离线仿真器。

仿真器做的事很简单:自己生成一个随机棋盘,调用第三步的搜索逻辑,打印每一步消除的两个坐标,直到棋盘清空。这个测试台可以在 Visual Studio 里直接跑,不需要窗口截图,也不需要模板图片,纯逻辑运行。等搜索逻辑完全正确之后,再把截图、识别模块一步步接进来。

这样做的价值是巨大的。搜索逻辑有问题时,你不会被“是不是截图偏了”干扰;截图识别有问题时,你又可以快速确定问题只在识别模块。把复杂系统切成几个可独立验证的小模块,是这个项目里我感触最深的一点。

4.3 “秒”字怎么来:延迟优化的三个方向

如果你玩过这类自动消除程序,会发现它真正的爽点在于反应极快。做到“秒”级响应,可以从三处优化:

  • 截图阶段:只截棋盘所在区域,不要整屏截取再裁剪。区域越小,拷贝像素越少,后面的模板匹配也越快。
  • 识别阶段:预先把所有模板缩放到统一尺寸,避免每次resize;识别时先做粗筛,用颜色直方图排除不相似的模板,再做精细匹配。
  • 搜索阶段:使用按类型分桶的方式,避免全棋盘两两组合。

还有一个很容易被忽略的延迟点:点击之后,游戏画面需要一小段时间刷新。如果截图太快,可能拿到的是消除前的旧画面,导致重复识别已消除的格子。稳妥做法是连续消除多对之后,再重新截屏刷新棋盘状态,避免每处理一对就截一次屏。

5. 调试中的高频问题和实测经验

这一节把我在调试中真正撞过的墙列出来。有些问题看着不起眼,但排查起来特别费时间。

现象大概率原因实际处理办法
截图全黑目标窗口使用了 DirectX 全屏独占让窗口运行在窗口模式,或改用 DXGI 截图方式
鼠标点击位置偏到相邻格子DPI 缩放导致坐标不一致程序入口调用SetProcessDPIAware(),按实际 DPI 重新计算坐标
图标识别结果时好时坏模板带背景色或尺寸不一致将所有模板统一缩放,并只取格子中心区域进行匹配
明明能连的路线判断失败没有把棋盘外圈当作空白路径棋盘矩阵上下左右各补一圈0
第一轮能消,后面频繁报错点击后屏幕未刷新就重新截图延迟几十毫秒再截屏,或批量处理多对后再刷新
识别结果整体偏移一列棋盘左上角坐标取错用两个相邻棋子中心坐标动态反推棋盘起点

除了表格里的问题,还有一个直觉型误区想专门说一下。有些人会把“两个格子中间是否有其他格子”简单理解为“直线上一格一格看过去”。这本身没错,但忽略了路径允许绕出棋盘外,也忽略了“转折点本身必须是空格”。判断路径通畅时,除了检查路径经过的格子,转折点也要算进去。如果转折点上恰好有一个未消除的图标,这条路线就不能走,哪怕三段直线本身都看着很通。

还有一个经验是调试模板匹配时多打印信息。不要只看最终识别结果,要把每次匹配的相似度分数,以及第二名和第一名的差值一起输出。差值越小说明越可疑,这时候你就要考虑是不是模板本身长得太像。我遇到过两种水果图案颜色几乎一样的情况,光靠模板很难区分,后来是额外提取了图标中心区域的纹理特征才解决。

6. 边界底线:项目可以玩,但不能越线

写到这里,技术链路已经完整了:截图、识别、矩阵化、寻路、点击交互,每一步都有清晰的实现方案。但我必须把这篇文章最后的空间留给一条更重要的边界。

这种“自动消除器”一旦接上真实在线游戏平台,性质就完全变了。第三方自动化工具绕过游戏本身的操作限制,破坏了规则公平性,这是任何在线服务都无法容忍的。轻则账号被限制,重则可能面对服务方追责。对开发者自己来说,这也不是一条值得走的路,因为技术能力明明可以用在更正当的地方:OCR 工具、自动化测试、无障碍辅助、图像批处理,全都是同一个技术栈的合法应用。

我并不是在说教,而是给出一个很现实的判断:学习图像识别和寻路算法,完全可以通过离线棋盘、自建测试位图来完成;真正把这种能力用于在线环境,收益极小而风险巨大。把项目边界画清楚,技术乐趣才能保持得长久。

最后分享一个我保留至今的习惯:不管做这类项目还是别的图像处理程序,我都会在最外层加一个“数据源抽象层”。识别结果是棋盘矩阵,仿真输入也是棋盘矩阵,点击输出统一封装成回调。核心逻辑不关心输入是来自真实截图还是来自随机生成器,这样任何时候都能单独测试核心算法。我第一次做时,把截图逻辑与消除逻辑耦合在一起,换了一个窗口分辨率就崩;后来彻底拆开,整个项目就再没出现过“找不到问题出在哪”的状态。这个分层习惯,后来我做 OCR 批处理和界面自动化测试时也一直在用,回头看,它比项目本身更值钱。

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

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

立即咨询