最近闲下来重刷手机里的水排序谜题,卡在某个高难关卡时手一抖把整管水倒错,越倒越乱。于是我做了一件在朋友看来很浪费时间的事:不玩了,先给游戏写一个“物理外挂”。这里的物理外挂不是破解安装包,也不是改存档,而是完全靠外部设备看屏幕、算解法、再模拟点击,把现实中的视觉识别、搜索算法和触发流程串成一条自动扫关的生产线。
如果你也玩这类游戏,或者正在找图像处理和搜索算法的小练习,这篇记录应该对你有用。我是把这个休闲游戏拆成了状态空间、颜色识别和动作闭环三个部分,每个部分单独看都不难,但组合起来后踩坑无数。下面我把整个过程、代码思路和实测教训完整写出来,尽量让从零开始的人也能跟着搭一版自己的。
1. 先从“能不能自动解”下手:把倒水动作变成状态搜索
很多人的第一反应是:直接学算法库里的“水排序求解器”不就行了?但真正动手时你会发现,网上流传的解法大概率只处理“标准长度试管 + 无额外约束”的简化模型,而实际 App 的规则往往有细节差异。与其到处找现成代码,不如从规则开始,把游戏变成一个自己完全可控的搜索问题。
1.1 一管水在代码里就是一段数组
水排序谜题的核心规则很朴素:有若干根试管,每根试管有固定容量,里面装着不同颜色的水。目标是把同色水归到同一根试管,每根试管要么全空,要么只有一种颜色且正好装满。
我习惯把每根试管建模成一个小数组,数组底部在左边、顶部在右边。
["红", "蓝", "红"]表示从下往上依次是红、蓝、红。- 数组的末尾是当前试管顶部的颜色,也就是下一次倒水时会被倒走的部分。
- 整局游戏就是一组列表,例如
(("红","蓝","红"), ("蓝","红","蓝"))。
这一步非常关键。只有把颜色抽象成可比较的符号,后面才能用 BFS/DFS 去枚举一切可能性。你不需要关心屏幕上试管长什么样、颜色具体是什么 RGB 值,那是后面视觉识别要做的事。先让状态干净起来。
1.2 合法动作从哪里来,闭环怎么避免
两个试管之间能倒水,要满足两个条件:
- 目标试管要么为空,要么顶部颜色和源试管顶部颜色相同。
- 目标试管尚未满。
满足条件后,一次移动会把源试管顶部的一段“连续同色水”全部倒过去,直到源试管顶部颜色变化或者目标试管装满。这里有个细节:每次转移的量不是一格,而是当前顶部连续色段的长度。所以一次动作可能同时移动两格甚至更多。
刚开始我按“一格一格倒”来写走法,结果求出来的步骤经常和真实游戏对不上,因为真实游戏一次就把整段同色顶都倒光。修正这个逻辑后,搜索到的动作序列和手动操作完全一致。
再处理闭环问题。假设状态 A 中从试管 1 倒到试管 2,紧接着又从试管 2 倒回试管 1,这会让搜索在同一个状态上打转。简单的剪枝原则是:禁止和上一步完全相反的移动。更严谨的做法是维护一个已访问集合,任何重复状态直接跳过。不要小看这个重复状态的清理,分支一多,少一秒重复都能省下几万次状态展开。
1.3 用 BFS 求最短解,但别天真地铺满所有状态
搜索算法的选择上,我推荐优先用 BFS 来理解问题,因为 BFS 第一次到达目标状态时的路径就是最短操作序列,可以用来验证答案是否合理。
但 BFS 要小心内存。水排序的分支因子并不小,实际关卡里十几根试管很常见,每根试管都可能是源或者目标,一次状态展开可能产生几十个合法动作。如果每个状态都完整存下试管数组,状态一多,内存直接爆炸。
我用的优化方法有两个:
- 状态压缩:把颜色符号映射为数字,再将试管数组转成元组,最后序列化成字符串作为 key。
- 反向剪枝:如果某个试管本身已经是单一颜色且装满,就不要再把它作为源试管去倒。这种试管倒出去只会破坏已完成的状态。
实测下来,绝大多数普通关卡用 BFS 能在几百毫秒内出结果。但如果遇到颜色种类特别多、可自由分配的空试管多到离谱的关卡,BFS 会非常吃力。我的应对方案是换用迭代加深 DFS:先预设一个最大深度 10,搜不到就加深到 20,依次递增。它不像纯 DFS 那样容易困在某个分支里,也不像 BFS 那样爆炸内存。
一个简化版的迭代加深搜索核心逻辑大概长这样:
def is_goal(state): return all(len(set(tube)) <= 1 for tube in state) def gen_moves(state, cap): for i, src in enumerate(state): if not src: continue for j, dst in enumerate(state): if i == j: continue if len(dst) >= cap: continue if dst and src[-1] != dst[-1]: continue yield i, j def apply_move(state, i, j): # 移动顶部连续同色段,细节根据实际规则调整 state = [list(t) for t in state] color = state[i][-1] n = 0 while state[i] and state[i][-1] == color: state[i].pop() n += 1 state[j].extend([color] * n) return tuple(tuple(t) for t in state) def dfs(state, path, cap, depth, visited): if is_goal(state): return path if len(path) >= depth: return None key = str(state) if key in visited: return None visited.add(key) for i, j in gen_moves(state, cap): ns = apply_move(state, i, j) res = dfs(ns, path + [(i, j)], cap, depth, visited) if res: return res return None def iddfs(init, cap): for depth in range(1, 120): res = dfs(init, [], cap, depth, set()) if res: return res return None要注意,上面这段只是展示了核心思路,没有加入“禁止重复状态”之外的更多剪枝。真实使用时我还会把“同一颜色所有试管的位置互换”这种对称状态也做归一化,能再砍掉一批重复搜索。
2. 搜索只能给解,程序还得长眼睛:屏幕状态识别
搜索算法需要拿到一盘棋的完整状态。那么问题来了:谁来把这些状态输入进去?手动输入颜色太慢,而且容易错。所以我把游戏停在主界面,用一台外接摄像头对着手机屏幕,程序自动读取出每个试管里有什么颜色、水位在什么位置。
2.1 为什么选择摄像头 + 视觉方案
有人可能会问:为什么不直接截屏读取,非要绕一圈用摄像头?因为直接截屏这条路在很多场景下会遇到系统权限限制,反而更复杂。摄像头是纯被动视觉,不碰系统底层接口,兼容性高,也更有“物理外挂”的感觉。
实际搭建环境很简单:一台 USB 摄像头,一个小支架固定角度,保证整个屏幕区域尽量平整地落在画面里。识别过程不需要太复杂的硬件,手机分辨率高反而会成为噪声来源,我会先把图像缩小到宽 600 像素左右再处理。
我后续项目默认的识别策略是:
- 先做透视变换,把斜着拍的手机屏幕矫正成正视图。
- 通过预留的标定点找到每个试管在图像中的位置。
- 对每个试管区域,自上而下提取颜色段分割信息,得到高度和颜色。
这些步骤看起来多,但每一步都不复杂。
2.2 屏幕定位与透视变换
透视变换是“物理外挂”里最容易忽略的一步。摄像头不可能严丝合缝地和屏幕平行,一旦角度偏了,试管顶部和底部在图像里的宽度不一致,直接按矩形裁剪就会得到歪斜区域。
我采用的方案是找屏幕四角,然后计算单应矩阵,将整个屏幕区域映射到一个固定矩形。具体来说,我用颜色阈值先提取屏幕边框,再用轮廓检测近似四边形,拿到四个顶点。随后用 OpenCV 的getPerspectiveTransform把源四点映射到目标四点,比如映射到0,0到480,900的区域。
这套流程在屏幕背景为深色时效果最好。如果游戏背景太花,屏幕边框又不清晰,就得在手机和摄像头之间放一张纯色背景板,把屏幕轮廓逼出来。第一次调试时我没加背景板,程序把桌面上的水杯轮廓也识别成了手机屏幕,直接导致整张颜色表错位。
2.3 颜色不是采样,是“颜色段的合并”
试管区域定位好之后,接下来要找出每一段颜色。大多数人的第一版写法是:取试管中间某一列的像素,从上往下找颜色突变的点。
这个写法在光线均匀、颜色对比强烈时能用,但一遇到下面这些情况就崩:
- 试管壁的反光会让某段区域出现一条亮白线。
- 两种相近颜色边界处有中间过渡色。
- 气泡或动画光效会让某个局部像素变色。
我的做法是,把试管区域转成一张近似“颜色直方图”,再基于颜色距离做分段聚类。也就是不找突变点,而是把整根试管的像素聚成几簇,每个簇对应一截颜色段。
具体思路是这样的:
- 取试管内部一个长条区域的像素,保存为 RGB 数组。
- 转换成 HSV,防止亮度波动影响判断。
- 按照色相和饱和度的不同,用连通区域或者聚类把所有像素合并成若干段。
- 每段的高度比例就是水位的相对高度。
这样做的好处是抗干扰能力强。哪怕试管壁有一两条高光,聚类后亮白像素也会单独成为一个噪声簇,只要过滤掉面积过小的簇,剩下的就是真实颜色段。
2.4 从一帧图像拼出完整状态
识别出单根试管后,还要把所有试管拼成完整状态。这一步需要建立试管编号和位置号的对应关系。我的做法是:先对透视变换后的图像做固定网格切分,每个网格中心作为试管候选区域,再对该区域做边缘检测,确认这里确实有一个试管。
试管之间的间隔必须足够大,否则候选框会互相覆盖。实际游戏里试管往往是紧密排列的,这时我会加大分辨率,以像素精度做试管轮廓分割:先用边缘检测找到每个试管的左右边界,再根据边界宽度判断是否为候选试管。
一旦识别出 N 根试管,读取颜色后,我用一个朴素校验来确认状态是否合法:
- 每根试管长度是否在正常范围内。
- 所有试管的总“水量”是否等于颜色总数。
- 颜色种类是否和配置的比色卡一致。
如果校验不通过,我不会直接开始搜索,而是重新抓一帧继续识别。宁可多等 200 毫秒,也不要在错误状态上跑出一个看似正确但实际无法执行的答案。
3. 把解法翻译成物理点击
有了状态和解法序列,剩下的工作是把“第 3 步:从试管 5 倒到试管 8”变成一次真实的屏幕触摸。这一步是自动化闭环的核心,也是最容易出 bug 的地方。
3.1 坐标系变换的坑
相机画面里的坐标和最终执行点击的屏幕坐标是两套坐标系。如果我们只在摄像头画面里看到手机屏幕,那么点击坐标必须通过之前计算好的单应矩阵反变换回屏幕位置。
这里我栽过一个很经典的跟头:我在透视变换后的正视图里计算试管中心点,然后用正视图坐标发送点击命令。结果摄像头稍微一抖,点就偏了一个试管的距离,程序在屏幕上乱点。
正确做法是:先把试管中心点映射到原始摄像头图像坐标,再换算成手机屏幕上的物理坐标。换算关系需要按屏幕实际像素尺寸标定一次。我更推荐在执行点击前,让程序在屏幕上打一个可以正常阅读的高亮圆圈,人工确认圆圈位置正确后再开启全自动模式。
3.2 半自动与全自动两条路线
这套系统我实现过两个档位。
第一档是“半自动模式”:程序只负责识别和计算解法,把“从哪管倒到哪管”显示在屏幕上,手动点击操作。这个模式看起来不够“外挂”,但格外稳定,而且可以当作校验器——点错一步时,程序会重新扫描并重新计算路径,像有一个智能提示在旁边。
第二档才是“全自动模式”:程序自己模拟点击。点击事件本身不难构造,真正难的是节奏控制。如果连续点击太快,游戏动画还没播完,下一次识别就会把中间动画当成正式状态,导致整个状态错乱。
我给两个档位设计的差异是,全自动模式每次点击后必须做一次“预期状态比较”:搜索器给出动作 [3 -> 7],执行点击后重新识别,检查试管 3 的顶部颜色是否减少、试管 7 是否增加对应颜色。匹配成功才继续下一步,不匹配就立即停顿,进入安全模式。
3.3 等待动画、抽帧回调与状态校验
水排序的动画大约持续几百毫秒。由于动画期间画面是动态的,直接抓帧会得到模糊的中间态。我采取的做法是:
- 点击后等待固定时长,比如 600 毫秒。
- 连续抓两帧,比较结果是否一致。
- 一致则认为是稳定状态,进行下一步。
- 多次不一致则可能是动画卡住或自动点击失败,至少等待 1.5 秒后重试一次。
还有个细节:有的关卡把“完成”视为一种胜利动画,屏幕会出现彩色光效。此时拿普通试管识别逻辑去检测,会把光效识别成一堆新颜色。我给识别模块加了一个finished标志:如果所有试管都满足“全空或单一颜色”,就不再继续识别其他区域,直接宣告完成。
4. 实测踩坑:光照、透明试管与颜色相近
这套方案跑通很容易,真正花时间的地方是鲁棒性。以下是我实测中遇到频率最高的四类问题,每一类都伴随一个印象很深的调试过程。
4.1 透明底座和背景会让 HSV 阈值失灵
水排序谜题的试管通常是透明玻璃质感,底座、阴影、高光都会形成半透明区域。我第一次做颜色分割时,直接用一个固定 HSV 范围提取颜色,结果粉色区域经常被拆成两段,因为高光处色相不变但饱和度和亮度变化极大。
后来我把判定从“像素级阈值”改成“试管内部纵坐标方向的一条采样带”。对采样带中的每一像素,计算它与已知颜色库的距离,取最小距离对应的颜色作为该像素的初步分类,再对分类结果做平滑。这样就避免了单点亮度影响。
4.2 试管挨得太近导致候选框漂移
多根试管并排时,中间只有一条缝。边缘检测很容易把两根试管误认为一根,导致候选框宽度变大、中心点偏移。我的解决方式是先固定游戏内试管位置在水平方向是均匀排列的,用“等距峰值检测”替代“任意轮廓检测”。具体做法是:计算画面中每一列的平均边缘强度,得到一张水平方向的投影曲线,曲线上的峰值位置就是每根试管的中心。
这个方法对等距排列的试管特别有效,但前提是透视变换必须准确。如果变换得不够好,试管在画面中不严格垂直,投影峰值就会变宽,这时候需要返回第一步重新标定摄像头角度。
4.3 全局亮度突变与半透效果的补偿办法
白天室内光线变化,会让颜色识别结果出现周期性的漂移。摄像头自动白平衡在黄炽灯和日光之间切换时,原本识别为浅蓝的颜色会变成淡青色。我的补偿手段是把这个漂移当作全局变化:每次识别开始时,先识别一个“已知颜色基准块”,比如游戏内固定不变的深色背景,用基准块的色偏来修正所有试管像素的颜色。
一个更简单但不那么优雅的办法是:不依赖绝对颜色,而只依赖颜色段之间的相对关系。比如识别出某段颜色和相邻段不同,虽然不知道它具体是什么颜色,但能确定它是两种不同颜色中的哪一种。如果把整局游戏的颜色种类作为输入限制,相对关系也足够完成求解。
4.4 失败恢复比正确识别更重要
实话说,无论怎么优化,物理外挂方案都有出错的可能。摄像头被手挡住、手机弹出通知栏、屏幕突然变暗,都会让识别结果瞬间失效。所以我把“错误恢复机制”看得比识别精确度更重。
每当一次点击后状态校验失败,我会:
- 暂停自动执行。
- 保存当前已走过的动作序列和当前屏幕图像。
- 重新识读屏幕状态。
- 如果当前状态仍可解,则继续搜索后续动作。
- 如果当前状态不可解,说明上一次点击可能执行错了,回退到最近一个已知正确状态重新执行。
这套机制虽然不能保证永不失败,但至少不会出现一连串错误点击把整盘水彻底打乱的情况。
5. 这套“物理外挂”到底值不值得做
做完整个项目之后,我最大的体会是:它解决的是这个游戏里的“自动操作”问题,但真正有价值的部分不在“挂”,而在“物理”。
摄像头读取真实屏幕、搜索算法计算最短路径、自动化点击完成操作,这三件事分别对应计算机视觉、状态空间搜索和控制系统设计。单独哪一块都算不上新颖,但组合起来之后,你会被迫处理大量工程细节:坐标系对齐、状态校验、失败恢复、动画节奏。这些细节在教科书里没人讲,只有真正跑起来才会遇到。
我也得说点实际的边界。这类自动化工具适合用在自己本地的单机休闲关卡里做实验,图个乐子或者验证算法,但不太适合放到在线竞技、排位或者有明确规则禁止脚本的场景里。如果在用户协议明令禁止第三方自动化的应用里使用,轻则账号受限,重则抹掉存档。不要为了“全自动通关”去冒不必要的风险。我的习惯是,每写完一个自动化项目,只在本地方环境做完验证就跑下一个项目。
后续如果还想扩展,可以往两个方向走:一是把搜索算法换成更聪明的启发式,让它在极难关卡下也能秒出结果;二是把摄像头识别改成实时视频流处理,减少手动暂停触发识别的次数。甚至可以把同样的流程移植到其他类似的排色或解谜游戏上,只要把游戏规则的数据结构换一换,核心框架完全通用。
这一个周末项目的最终产物,是一个能把手机屏幕上的水排序谜题,从头到尾自动解完的小设备。它没有改动任何游戏文件,也没有获取任何系统内部权限,只是看着屏幕、想清楚步骤、然后动手。对我来说,这才是“外挂”最有意思的形态——不依赖后门,不钻空子,只靠算法和视觉就能解决实际看到的问题。