周末下午我盯着屏幕上那盘已经卡了十几分钟的连连看,两个一模一样的方块明明就在同一行,中间只隔着一片已经清空的区域,可它就是消不掉——因为剩下的那个弯绕不过去。那一刻我突然不想继续用眼睛找了,我想让程序替我找。于是我花了一个周末把单机连连看破解这件事从头到尾捋了一遍,这篇文章就是那两天的完整记录。这里说的“破解”不是去分发什么、绕过什么授权,而是拿一款自己玩的单机休闲游戏当靶子,把它的棋盘读成结构化数据、把规则翻译成算法、把决策回写成鼠标动作——本质是一次小型的逆向工程 + 算法落地练习。
这件事能干什么?直白讲:让程序在几十毫秒内扫完整盘,找出所有可消对,判断当前是否死局,需要重排时提醒你,然后自动把该点的两格点掉。它解决的是“人眼在密集色块里漏看、手在快速点击时点错”的问题。适合谁来参考?三类人最划算:正在学数据结构与算法、想找个能跑起来的小项目的人;对图像识别、坐标映射感兴趣但没找到合适练手场景的人;还有像我这样单纯觉得“把重复劳动交给机器”很爽的人。下面所有内容我按“选型—读盘—算法—决策—执行—排错”的顺序讲,每一步都给出我当时为什么这么选、哪里翻过车。
1. 先确定靶子:连连看破解到底在解什么问题
1.1 规则背后是一道带拐点约束的可达性问题
很多人把连连看当成“找相同的图案”,这只对了一半。真正的难点从来不是“找相同”,而是“判断两个相同图案之间能不能连过去”。它的规则可以压缩成三条:第一,两个格子图案必须属于同一类;第二,连接路径最多只能拐两个弯;第三,路径经过的所有格子必须是空的——注意,这里说的“空”包括棋盘边界之外那一圈虚拟区域,这才是很多人第一次写判定时最容易漏掉的关键点。
把这三条翻译成程序语言,它就是一个带转向次数上限的网格路径可达性问题。路径不是随便走的,只能横竖走,不能斜走;转向次数有配额,最多两次;通道有通行条件,只允许经过空格。你如果学过图的遍历,会立刻想到用 BFS 搜索路径,同时对“已经拐了几次”做状态记录。你如果嫌 BFS 重,也可以按拐点数分类讨论,直接枚举有限个候选拐点,反而更快。
我当时的第一反应是“用 A* 或者 BFS 最稳”,结果实测之后发现没必要——棋盘最多也就十几乘十几,可消对的数量级在几百到几千之间,分类讨论法一次判定只需要几十次数组访问,比启动一次 BFS 快一个数量级。这个认知很关键:规则本身很小,别用大炮打蚊子。
1.2 三条技术路线摆在桌上,优缺点一目了然
在动手前我把能想到的路线全列了一遍,最后归成三类,实际做的时候也是在这三类之间来回横跳的。
| 路线 | 核心做法 | 上手难度 | 稳定性 | 通用性 | 我的评价 |
|---|---|---|---|---|---|
| 读内存直取棋盘 | 用通用调试工具定位存放棋盘数据的数组或对象,直接读出二维结构 | 高,需要对目标程序的内存布局有耐心 | 很高,几乎零误差 | 差,换个版本或换个游戏就要重来 | 学习价值大,但迭代成本高 |
| 截图 + 图像识别 | 截屏,定位格子,逐格识别图案类别 | 中,主要是调参 | 中,受分辨率、缩放、抗锯齿影响 | 好,改改参数能适配同类游戏 | 我最后主选这条 |
| 纯算法模拟 | 自己按规则造棋盘,验证求解器 | 低 | 极高 | 只对模拟盘有效 | 必做,用它给算法做单元测试 |
先说第一条。用调试工具扫内存这条路,技术上完全可行,也挺有教学意义:你能看到游戏是怎么把棋盘存成一个一维数组再按下标换算成行列的,能看到空白格用哪个特殊值表示,有些实现里还会额外存一份“当前是否可消”的标记位。但它的代价是我最不能接受的——不可复现。同一款游戏换个编译版本、换个资源包,偏移全变,你之前所有的笔记都作废。而且这类定位过程需要大量试错,一个下午可能就耗在一个找不到的指针上。
第二条路是截图识别。截一张全屏,定位到棋盘区域,切成 N 个格子,逐格判断它属于哪一类图案。它的好处是完全站在“用户视角”,不关心内部怎么实现,只要画面在,就能读。坏处是脏数据多:动画中的方块有缩放、消除时有粒子特效、鼠标悬停时有高亮边框,这些都会让单帧识别出错。
第三条路是我强烈建议所有人都做的一步:先写一个纯算法的模拟器。自己随机生成棋盘,自己实现消除逻辑,用它来验证连通判定和求解顺序对不对。因为算法是你唯一能完全掌控的部分,如果算法有 bug,你会在真实游戏里浪费大量时间去怀疑识别模块;反过来,如果算法在模拟器里跑了一万盘都没问题,那真实环境里出错就一定出在读盘环节,排查范围立刻缩小一半。
1.3 我最终采用的分层方案
折腾到第二天早上,我定下来的方案是三段式:识别层负责把画面变成矩阵,算法层负责在矩阵上做判定与求解,执行层负责把坐标打回屏幕。三层之间只通过一个数据结构通信——一个二维整数数组,0 表示空格,正整数表示图案类别,未知用 -1 标记。
这么切的好处是每一层都能单独调试。识别层出问题,我可以把矩阵打印出来和截图对照,肉眼就能看出哪一格错了;算法层出问题,我可以把矩阵导出成文本,在模拟器里重放,几十次就能定位到具体哪一对判定错了;执行层出问题,我可以先把点击改成“只打印坐标不真点”,确认坐标正确再放开。
还有一条经验:层与层之间不要共享状态。我一开始图省事,让算法层直接去调用识别层“重新看一眼某个格子是不是空的”,结果引入了时间不一致——算法基于旧帧决策,识别层给的却是新帧的数据,棋盘已经变了,于是点了两个不相干的格子。后来改成每轮固定读一次快照,全流程只用这一份快照,问题立刻消失。
2. 把屏幕变成矩阵:棋盘结构化的四个关键动作
2.1 先量尺寸,别急着写识别
所有识别工作都建立在“格子在哪里”这个前提上。这一步没有捷径,必须手工量一次,而且是精确到像素的量。
我的做法是:截一张干净的图,放大到能看见像素,找到棋盘左上角第一个格子的左上角顶点,记下坐标 (x0, y0);再找到同一行第二个格子的左上角顶点,记下 (x1, y0)。两者相减得到格子宽度 w = x1 - x0。同理纵向找到第一列第二个格子,得到高度 h。行列数可以直接数,也可以由棋盘区域总宽高除以格子宽高反推。
这里有几个必须注意的细节。第一,格子之间通常有 1 到 3 像素的间隔,w 量出来的是“步距”而不是“格子本身的宽度”,识别时要取格子中心附近一小块区域,避开间隔带。第二,如果游戏窗口允许拖动缩放,你的像素值只对当前尺寸有效,所以要么固定窗口,要么在识别前先检测一次窗口尺寸并换算比例。第三,有些实现会对棋盘做整体居中,窗口一变棋盘偏移就变,稳妥的办法是用棋盘背景色做一次连通域分析,自动框出棋盘的外接矩形,再用它反推 x0、y0。
我第一版就是硬编码坐标,结果手一滑把窗口拖大了一点,后面所有识别全错位,程序在那儿对着空气狂点。自动框选只多写了二十行代码,却省掉了之后所有的麻烦。
2.2 三种图案识别思路,按场景选
拿到格子坐标后,每一格需要判断“它是什么图案”。常见做法有三种,我全试过:
模板匹配。给每一类图案截一个干净样本,存成小图,识别时把待测格子的区域和每个模板做归一化互相关,取相似度最高的那一类,低于阈值就判为未知。优点是直观、准确率高,缺点是要人工准备样本,而且如果游戏有缩放,模板尺寸不匹配就会失效——解决办法是先把待测区域缩放到模板尺寸再比。
颜色直方图或颜色聚类。统计每格区域的颜色分布,按主色调聚类。适合图案之间颜色差异大、形状差异小的游戏。缺点是遇到“同色不同图案”或者“图案带渐变高光”就抓瞎,我那款游戏里有两个图案主色都是浅蓝,直方图完全分不开。
特征哈希 / 感知哈希。把格子区域缩成 8x8 灰度,做离散余弦变换取低频,二值化成指纹,用汉明距离比较。优点是抗缩放、抗轻微模糊,存储也省,缺点是对“图案差别只在细节”的情况不敏感。
我最终用的是模板匹配为主、哈希做兜底的组合:先用哈希快速缩小候选范围(通常只剩两三类),再用模板匹配在三类里精准判定。这样既保留了模板的准确率,又把每格比较次数从“类别总数”降到“两三次”,整体耗时下来了。
| 方案 | 抗缩放 | 抗模糊 | 需要样本 | 同色区分能力 | 适用场景 |
|---|---|---|---|---|---|
| 模板匹配 | 弱 | 中 | 是 | 强 | 图案稳定、窗口尺寸固定 |
| 颜色直方图 | 强 | 强 | 否 | 弱 | 图案靠颜色区分 |
| 感知哈希 | 中 | 强 | 是 | 中 | 图案细节丰富 |
| 组合方案 | 中 | 强 | 是 | 强 | 我的实际选择 |
2.3 数据结构设计:外围补一圈零是关键技巧
棋盘在程序里就是一个二维整数数组,但这个数组最好比真实棋盘多一圈。什么意思?真实棋盘是 10 列 × 8 行,那我在数组里建 12 × 10,最外圈全部填 0(表示空格)。
这一圈虚拟空格解决的是规则里最烦人的那条:“路径可以延伸到棋盘外面绕过去”。如果不补这圈,你的连通判定里就要到处写if 行 < 0 or 行 >= 行数这样的边界判断,而且“从左侧出界、从上方绕回右侧”这类情况要单独处理,极容易漏。
补了这圈之后,所有格子都在“内部”,判定时统一按“值为 0 的格子可通行”处理,边界判断彻底消失。这是我从这个项目里学到的、最值得单独拿出来说的一条技巧:与其在逻辑里处理边界,不如在数据结构里消除边界。后来我在别的网格类项目里也一直这么干。
还有一个细节:空格和“未识别”必须是两个不同的值。空格是 0,未识别我用 -1。为什么?因为未识别意味着“我不知道这里是什么,可能还有图案”,它的通行性和空格完全不同——你不能让路径穿过一个未识别的格子。我第一版把未识别也当成 0,结果有一次识别失败把两个方块之间的通道打开了,程序判定可以连,实际根本连不通,点了半天没反应。
2.4 一份完整的棋盘快照该长什么样
我现在用的结构很简单,一个字典就够了:
board_state = { "grid": [[0] * cols for _ in range(rows)], # 已经扩过外圈的二维数组 "rows": rows, # 含外圈的行数 "cols": cols, # 含外圈的列数 "cell_w": 40, # 格子水平步距 "cell_h": 40, # 格子垂直步距 "x0": 320, # 第一个格子中心(真实坐标) "y0": 180, "unknown": [], # 未识别格子的坐标列表,用于二次识别 }unknown这个字段非常有用。第一次识别总会有几格拿不准,这时不要硬猜,把它们记下来,等整盘扫完,对这些格子做一次更精细的识别(换更大的采样区域、换算法、或者用“同类图案数量必须是偶数”这个约束来反推)。我实测下来,第一次识别通常有 2 到 5 格不确定,靠二次识别加奇偶约束基本能补齐,实在补不齐就跳过这一轮,等棋盘变化后重读。
3. 连通判定:整个项目真正的硬骨头
3.1 规则翻译:分类讨论与拐点搜索
前面说了,判定本质是“最多拐两次弯的网格路径是否存在”。落地有两种写法,我都实现过,各有各的用处。
分类讨论法:按拐点数量分三种情况分别枚举。零拐点是同行或同列直线;一个拐点是两个候选折点;两个拐点是沿行或列找中转点。每种情况都能写成独立的判断函数,逻辑清晰,性能极好。
拐点搜索法:把“坐标 + 当前方向 + 已转向次数”作为状态做广度优先搜索,找最少转向次数不超过 2 的路径。代码短,通用性强,扩展到“最多拐三次”只需要改一个常量。
我的建议是:主流程用分类讨论,测试用拐点搜索做交叉验证。两个独立实现跑同一个棋盘,结果不一致就说明至少有一个错了——这种“双实现互验”是我在算法题里常用的手段,比盯着代码看有效一百倍。
3.2 直线连通和单拐点的写法
直线连通的判断很简单:两个格子在同一行,就检查它们之间所有列(不含两端)是不是都为 0;同一列同理。注意“不含两端”很关键,两端的格子本身是有图案的,不能要求它是空的。
def line_free(grid, r1, c1, r2, c2): """两个点必须在同一行或同一列,判断中间是否全空(不含端点)""" if r1 == r2: lo, hi = sorted((c1, c2)) return all(grid[r1][c] == 0 for c in range(lo + 1, hi)) if c1 == c2: lo, hi = sorted((r1, r2)) return all(grid[r] == 0 for r in range(lo + 1, hi)) return False单拐点的情况,候选折点只有两个:(r1, c2) 和 (r2, c1)。这个折点必须本身是空的(值为 0),并且折点到起点、折点到终点这两段各自直线通畅。
def one_turn(grid, r1, c1, r2, c2): for (mr, mc) in ((r1, c2), (r2, c1)): if grid[mr][mc] != 0: continue if line_free(grid, r1, c1, mr, mc) and line_free(grid, mr, mc, r2, c2): return (mr, mc) return None注意这里调用line_free时,端点是折点,而折点此时已经确认是 0,所以“不含端点”的写法不会误判。这个细节很多人写错:如果折点判断和直线判断的顺序反了,就会出现“折点本身有图案却判定通过”的鬼故事。
3.3 双拐点:沿行列枚举中转点
双拐点的结构是“起点 → 沿行或列走到某个中转格 → 换方向再走 → 到终点”。实现上,枚举起点所在行(或列)上的每一个空格作为第一个中转点 P,然后判断 P 到终点是否可以用“直线或单拐点”连通。因为整体已经用掉一个拐,剩下的路径最多只能再拐一次。
def two_turns(grid, r1, c1, r2, c2, rows, cols): # 沿起点所在行枚举 for c in range(cols): if c == c1: continue if grid[r1][c] != 0: continue if not line_free(grid, r1, c1, r1, c): continue if line_free(grid, r1, c, r2, c2) or one_turn(grid, r1, c, r2, c2): return (r1, c) # 沿起点所在列枚举 for r in range(rows): if r == r1: continue if grid[r][c1] != 0: continue if not line_free(grid, r1, c1, r, c1): continue if line_free(grid, r, c1, r2, c2) or one_turn(grid, r, c1, r2, c2): return (r, c1) return None有个优化点值得说:上面的枚举是“从起点出发找中转点”,每一轮都要跑一次line_free做前置剪枝。这个剪枝非常关键,它把大量不可能的中转点直接排除,避免了对每个空格都跑一次完整的子判定。实测下来加了这一行,双拐点判定的耗时降了差不多四成。
还有一个容易忽略的情况:双拐点路径的中转点可能落在外围那一圈虚拟空格上。这正是第 2.3 节补零的价值所在——因为外圈都是 0,枚举范围自然覆盖到了“绕到棋盘外面再回来”的路径,代码里一行额外处理都不用写。
3.4 拐点搜索版本:短小但有两个坑
拐点搜索版本我用来做交叉验证。核心思路是广度优先,状态是(行, 列, 方向, 已转向次数),初始时从起点向四个方向各走一步,转向次数记 0,然后按“同方向走不增加转向数、换方向加一”扩展,遇到终点就返回真。
from collections import deque DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)] def can_link(grid, start, goal, rows, cols, max_turn=2): sr, sc = start gr, gc = goal best = [[[99] * 4 for _ in range(cols)] for _ in range(rows)] dq = deque() for d, (dr, dc) in enumerate(DIRS): nr, nc = sr + dr, sc + dc if 0 <= nr < rows and 0 <= nc < cols and (grid[nr][nc] == 0 or (nr, nc) == goal): best[nr][nc][d] = 0 dq.append((nr, nc, d, 0)) while dq: r, c, d, turn = dq.popleft() if (r, c) == goal: return True for nd, (dr, dc) in enumerate(DIRS): nt = turn if nd == d else turn + 1 if nt > max_turn: continue nr, nc = r + dr, c + dc if not (0 <= nr < rows and 0 <= nc < cols): continue if grid[nr][nc] != 0 and (nr, nc) != goal: continue if best[nr][nc][nd] <= nt: continue best[nr][nc][nd] = nt dq.append((nr, nc, nd, nt)) return False第一个坑:终点本身不是空格,它的值等于图案类别,所以在判断可通行时必须把终点单独放行,否则永远走不到。我第一次写完跑测试,所有合法对都返回 False,查了二十分钟才发现是这个。
第二个坑:初始状态的转向次数怎么算。从起点往四个方向迈第一步,这一步还没发生任何转向,所以记 0;如果有人图省事记成 1,那实际能拐三次的路径会被当成两次放行,产生误判。这类“差一”的错误在拐点类算法里极其常见,写完一定要用几个边界样例手工验一遍:紧邻的两个同类、隔一格的两个同类、需要绕外圈的两个同类、必须拐三次才能连的两个同类(这个应该返回 False)。
3.5 性能账要算清楚,别拍脑袋优化
在真实棋盘上,每轮要找的是“所有可消对”,而不是“某一对能不能消”。所以复杂度跟同类图案的数量分布直接相关。假设棋盘 10 列 8 行、一共 40 类图案每类 2 个,那总对数就是 40,判定 40 次就完了,毫无压力。但如果棋盘更大、类别更少,比如 12 × 10 共 120 格、只有 20 类每类 6 个,那对数就是 20 × C(6,2) = 20 × 15 = 300 对。每对判定最多做一次直线、两次单拐、若干次双拐,双拐最坏要枚举约 20 个中转点乘一次子判定,量级大概几千次数组访问,对现代机器来说是毫秒级的事。
真正会拖慢的是重复判定。如果你每轮都把整盘所有对重算一遍,那 300 对 × 每对几千次访问 = 上百万次操作,一秒钟几百轮下来就有点顶了。我的优化是两个:一是按图案类别分桶,只在同类内部组对,天然砍掉了绝大部分无效比较;二是加一个“上次判定结果缓存”,棋盘没变化就直接复用上一轮的可消对列表。这两条下去,实测单轮耗时从十几毫秒压到一毫秒以内。
提醒一句:缓存一定要带版本号或者哈希,棋盘一变立刻失效。我图省事只用了一个布尔标记,结果重排之后忘了重置,程序拿着一份过期的可消对列表疯狂点击,点了七八次都是无效的。
3.6 死局判定和重排时机
死局判定就是“遍历所有同类对,一个能连的都没有”。这个判断本身开销不大,但它有个前提——你的棋盘必须是准确的。如果识别有错格,很容易出现“其实还有可消对,但因为某格识别错了导致全都判不通”的假死局。我的处理是:判定为死局时,先对unknown列表里的格子重识别一次,再判一次;两次都死局,才认定真的需要重排。
重排的时机也有讲究。有些实现允许在还有可消对的时候主动重排,有些只能在死局时重排。我建议程序在“剩余格子数少于某个阈值”(比如 10 个)时,如果还没死局就继续消;一旦死局立刻提示。别在快消完的时候频繁触发重排,那往往是识别抖动造成的假死局,重排一次等于白干。
4. 求解顺序:贪心为什么经常翻车
4.1 一次贪心的实际效果
最直观的策略是:每轮扫出所有可消对,随便挑一对点掉,重复。我第一版就是这么干的,用的还是“先扫到谁就消谁”。在模拟器里跑,通关率大概有八成多,看起来还行。但换到真实棋盘上,通关率掉到六成出头。
原因不复杂:贪心会制造新的隔离。举个典型场景,两个方块之间唯一的一条通路,需要经过某个空格;如果你先把另一对消掉,而那一对的路径正好横穿这个关键空格,看起来是好事,但实际上你消完之后那个位置可能被别的图案“围住”,导致原本能连的一对反而连不上了。更糟的是,贪心经常把成对的图案“拆散”到棋盘两端,让本来集中在一小块区域、可以连环消除的一组,变成分散的、每对都要绕大圈的状态。
我做过一个对比实验:同一个棋盘,用三种顺序策略跑,每种跑一百次(重排次数随机化),统计平均重排次数:
| 策略 | 平均重排次数 | 平均剩余格数 | 主观手感 |
|---|---|---|---|
| 随机挑一对 | 4.7 | 12.3 | 明显卡顿,经常卡在最后几格 |
| 优先消边缘 | 3.1 | 8.9 | 中间过程顺畅,收尾仍吃力 |
| 边缘优先 + 通路评分 | 1.8 | 3.4 | 大部分盘一次通关 |
这组数据不一定适用于所有棋盘,但方向是明确的:顺序比速度重要得多。
4.2 给每个候选打分:三个有效因子
我最后用的评分函数不长,就三个因子加权:
第一,这对格子是否位于棋盘边缘(含靠外一两圈)。边缘格子的可用通路多,先消它们能逐步“掏空”外围,让内部格子获得更多直线通路。这个因子的权重我给得最高。
第二,这对格子的连通路径长度。路径越短,说明它们挨得越近,消掉之后能立刻缩短同类图案之间的间距。路径长的反而可以放后面,因为它们本来就不占关键位置。
第三,消掉之后,同类图案的剩余集中度会不会提升。这个稍微绕一点:统计消掉这对之后,剩余同类图案两两之间的最短曼哈顿距离,距离变小说明更集中,加分;距离变大,说明被拆散了,减分。
具体实现上我没有做得很复杂,就是把三个因子归一化后线性加权,权重靠手工调。听起来很土,但实测下来确实能把平均重排次数压到 2 以下。
4.3 回退:贪心不行就退一步
更稳的做法是加一层浅回退。具体是:每轮选出评分最高的三到五对作为候选,优先尝试第一对;消掉之后立刻重算一次“当前是否还有可消对”,如果有,继续;如果消完就死局了,而其他候选消完不会死局,那就回退,换第二对。
这个“一步前瞻”实现起来不复杂——你只需要在内存里做一次模拟消除,跑一遍死局判定,然后撤销。成本是多算几轮,收益是避免了大量本可避免的重排。我在模拟器里测,加这一步之后,通关率从八成出头提到了九成五以上。
再往深处做就是完整的搜索树了,但我不建议——棋盘状态空间太大,搜索深度超过三四层就得不偿失,而且真实游戏里局面变化快,算太久反而错过时机。这个项目的甜点区就在“贪心 + 一步前瞻”。
4.4 一个反直觉的发现
有件事我一开始没想到:优先消除“数量最少的图案类别”比优先消除“位置最靠边的”更有效。原因是数量少的类别,可消对本来就少,一旦它们被挤到不好连的位置,很容易变成最后剩下来、怎么都消不掉的“孤儿”。早点处理掉,能显著减少收尾阶段的痛苦。
这条经验后来被我加进了评分函数,作为一个额外的小权重。它带来的收益不算大,但在最后剩几格的时候体现得非常明显。
5. 从算法到鼠标:把决策变成动作
5.1 坐标映射要双向校验
算法给出的是矩阵下标(行、列),执行层需要把它换成屏幕像素坐标。公式很简单:x = x0 + (col - 1) * cell_w,y = y0 + (row - 1) * cell_h。注意这里减 1,因为矩阵扩过一圈,真实棋盘的第一格在索引 1 的位置。
麻烦在于,这个映射一旦有偏差,程序就会稳定地点在格子边缘或者间隔带上,运气好还能触发,运气不好就是狂点无效。我的办法是反向校验:拿到像素坐标之后,立刻回读那一小块区域的图像,和该格的图案模板做一次匹配,相似度低于阈值就报警并跳过这一对。这个校验只需要几十毫秒,但能挡住绝大多数坐标漂移问题。
5.2 点击节奏与容错
真实游戏对点击节奏是有要求的。太快了可能被合并判定(两次点击间隔太短,被当成拖拽或者被忽略),太慢了则浪费时间。我实测下来,两次点击之间留 60 到 120 毫秒比较稳妥,具体值跟游戏的输入处理频率有关,建议先用一个保守值跑通,再逐步压缩。
还有一个细节:点击位置要落在格子中心。有些游戏对点击区域做了一点内缩,点在边缘会判定失败。中心点最安全,误差容忍度也最高。
另外一个坑是我踩得比较惨的:不要在一轮里连续点太多对。我最初的设计是“扫出所有可消对,然后挨个点掉”,结果从第二对开始就错乱——因为消掉第一对之后,棋盘上有些格子下落或者填充,原本算好的第二对坐标已经不对了。正确的做法是点完一对,重读一次棋盘,重算一次。虽然多了几次截图和识别,但稳定性提升是数量级的。
5.3 主循环的完整骨架
把前面所有东西串起来,主循环大概长这样:
def run(max_rounds=200): for round_idx in range(max_rounds): snap = capture_board() # 截图 + 定位 + 识别 if snap is None: log("读取失败,重试") continue grid = snap["grid"] pairs = find_all_pairs(grid) # 分桶 + 连通判定 if not pairs: if is_really_dead(grid, snap): # 二次识别后再判 log("死局,等待重排") wait_for_reshuffle() continue best = pick_best(pairs, grid) # 评分 + 一步前瞻 if best is None: continue p1, p2 = best click_cell(p1) sleep(0.08) click_cell(p2) sleep(0.15) log("结束")这段骨架里有三个点值得强调。第一,find_all_pairs返回空不等于死局,必须走二次识别确认。第二,pick_best里做的一步前瞻是“模拟消除后判死局”,模拟必须在副本上做,不能污染原棋盘。第三,每轮都重读棋盘,不要试图复用上一轮的坐标。
6. 常见问题排查实录
下面这张表是我确实遇到过、并且花了时间解决的问题,按现象归类。
| 现象 | 最可能的原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 稳定地点在格子边缘,偶尔无效 | 坐标映射偏移,x0/y0 量错或窗口尺寸变了 | 把坐标画成框叠加到截图上肉眼对比 | 重新量偏移,或改成自动框选棋盘区域 |
| 某些图案永远识别成未知 | 模板不匹配,缩放或抗锯齿导致 | 打印该格的匹配分数分布 | 重新采样模板,或改用抗缩放的哈希兜底 |
| 算法说有可消对,实际点不通 | 空格判定错误,把未识别当成了空 | 导出矩阵和截图逐格对照 | 未识别单独用 -1 标记,禁止通行 |
| 判定为死局但明明还能消 | 识别有错格,或外圈没补导致边界路径漏判 | 用模拟器重放该矩阵 | 先补外圈,再做二次识别确认 |
| 点完一对后第二对全错 | 棋盘在两次点击之间已经变化 | 打印每轮的快照哈希 | 每轮只点一对,点完重读 |
| 连续点击被游戏忽略 | 间隔太短被合并 | 逐步加大间隔观察 | 保守设 80 到 120 毫秒 |
| 循环飞快但没有任何动作 | 可消对列表为空且死局判定逻辑短路 | 加日志打印每轮的对数 | 检查find_all_pairs的返回值分支 |
| 偶发卡死无响应 | 二次识别进入无限重试 | 给重试加次数上限 | 超过三次直接跳过本轮 |
这张表里我想单独展开两条。
第一条是“空格判定错误”。这个错误最隐蔽,因为它不会报错,只会让你偶尔点不中。我当时的表现是:程序自信满满地说“这一对能连”,点下去却毫无反应。查了两小时,最后把矩阵打印出来,和截图一比,发现有个格子的识别结果是 -1(未识别),但我的通行判断写的是grid[r][c] == 0——理论上 -1 不该通过啊。再往下看,发现我在识别环节有一步把 -1 直接转成了 0,理由是“看起来是空的”。这就是典型的“自作聪明掩盖问题”。改法很简单:未识别就是未识别,不许转 0,让它老老实实当障碍物。
第二条是“点完一对后第二对全错”。这个问题的根因是棋盘在消除后会发生整列下落或者整行平移,这是很多连连看实现的消除动画效果。你从截图看到的是变化前的状态,点的时候用的是变化后的棋盘,自然对不上。解决办法就是每轮重读,这是最笨也最可靠的办法。
一个通用排错习惯:把每一轮的“矩阵、可消对数量、选中的对、点击坐标”全部写进日志文件。事后回看,你能一眼看出问题出在识别、算法还是执行,比在脑子里猜快十倍。
7. 几个省时间的实操心得
写到这儿该说的技术点差不多了,最后分享几件我踩过、但上面不太好归类的事。
先做“只读不点”的版本,跑够一百轮再放开手脚。我一开始就带自动点击,出了问题根本不知道是识别错了还是点错了,因为错误会被后续操作掩盖。后来改成只读模式,屏幕上把判定结果叠加上去,我用眼睛盯着看它找的对不对,五分钟就发现了三个识别 bug。这种“观测优先”的开发顺序,在这个项目里帮我省了至少半天。
给算法写一份固定的测试集。我手工构造了十几个矩阵:紧邻同行、隔一格同列、需要绕外圈、需要拐两次、必须拐三次才能连、全空、只有一个格子。每次改完算法先跑这份测试集,全绿了再去接真实棋盘。这份测试集到现在我还留着,后来改了两次判定逻辑,全靠它兜底。
别追求一次识别全对。真实画面里有动画、高光、悬停边框,你不可能保证每帧都 100% 识别准确。合理的预期是“95% 识别准确 + 不确定的格子单独处理 + 出错时能自恢复”。我把这套思路做进去之后,程序的整体稳定性反而比追求全对的时候高,因为它不会再因为一格识别错就整体卡死。
控制单轮耗时,不要贪心。我一度想着“每轮把所有候选都做一次前瞻”,结果单轮耗时飙到几十毫秒,配合截图和点击,整个节奏慢得像卡带。后来砍成“只对前三名做前瞻”,速度回来了,效果几乎没降。
最后,记得给自己的程序留一个能随时停的开关。自动化的东西跑起来之后,最怕的是它在你不想它动的时候动。我留的是键盘上的一个热键,按下立刻暂停,松开继续。这个小功能在调试期救了我好几次——尤其是它试图去点一些不该点的东西的时候。