用Python实现连连看自动求解:从路径搜索到图像识别的完整项目实践
用Python玩连连看听起来像是一句玩笑话。我当初刷到类似视频的时候也没当回事直到某天自己手痒写了个自动求解脚本试跑了一下“效果”——是真的顶。三秒消一对五分钟清完一局连鼠标都不用动。然后我就被在线房间里的其他玩家集体举报了。所以标题里那句“别霍霍别人了”是我对自己说的也送给所有想拿脚本去在线平台找存在感的朋友代码能跑通不代表你可以拿去祸害真实玩家。这个项目的正确打开方式是把它当成一个综合练手Demo——你会接触到棋盘建模、路径搜索、图像识别、状态回溯这些实用技能而且每一步的反馈都很直观特别适合拿来检验自己的Python水平。全文会以我自己的实践过程为线索把每个环节的算法思路、代码实现、踩坑细节都摊开讲。你不需要有很强的图形学基础只要会Python基础语法、知道二维列表怎么用就能跟着把整条链路跑通。最后你会得到一个既能手动玩、又能自动演示的本地连连看小项目。1. 先别急着写代码这个项目真正好玩在哪1.1 标题里那声“别霍霍别人了”是什么意思先说个真实经历。我第一次写完自动连通判定时兴奋得不行直接挂到在线大厅里跑。开局不到三秒钟就开始自动消块旁边玩家发来一串问号。再过了十几秒有人开始在公屏上骂“脚本狗”。那局结束我就被踢出房间了账号还被限制了登录。这事给我提了个醒连连看这类游戏别人玩的是眼力和手速你玩的是算法。拿算法去碾压真人的反应速度本质上属于降维打击人家当然不乐意。而且说实话在线大厅的对抗模式对延时和公平性都有要求脚本一跑整个对局体验就毁了。所以后来我把项目彻底改造成本地单机版——既保留自动求解的乐趣又不影响任何人。真正值得关注的是这个项目涉及的能力链路模块涉及技术解决什么问题棋盘识别截图、图像处理、颜色匹配让程序“看”到当前局面连通判定二维数组、路径搜索判断两个方块能否消除自动求解贪心搜索、回溯、状态去重从当前局面推导下一步怎么做单机DemoGUI事件循环、交互设计把算法包装成能玩的小游戏这条链路不是只能玩连连看。稍微变一下它就能变成自动截图填表工具、游戏AI原型、甚至桌面自动化测试脚本。所以哪怕你以后不走游戏开发方向这一套思路也能迁移到别处。1.2 这个项目适合谁来玩适合三类人。第一类刚学完Python语法、正愁没项目练手的人。连连看的棋盘本质就是一个二维数组判断消除本质就是搜索逻辑量不大但覆盖面广。第二类工作中要做自动化测试或数据录入脚本的人。截图识别棋盘、模拟点击、坐标换算这些技术和UI自动化完全是同一套打法。第三类对“程序如何做决策”感兴趣的人。自动求解器里用到的回溯搜索是很多AI算法的地基。需要准备的依赖也很常见Python3.8以上版本pyautogui、Pillow、opencv-python、pygame这几个库就够了。安装命令无非就是一行pip install的事。我这里提醒一句如果你打算照着我后面的代码试跑请务必将所有针对在线平台的自动化操作全部停掉。自己本地玩或者用单机版验证算法怎么折腾都行。一旦涉及到真实玩家聚集的在线大厅就要把代码收好——这就是我标题里“别霍霍别人”的完整意思。2. 棋盘建模与路径判定连连看“能不能消”的算法本质2.1 棋盘建模从图片到二维数组连连看的棋盘是个矩形网格这是最标准的建模场景。我会把一个N行M列的棋盘存成二维列表board里面的元素是整数0表示空格非0表示不同类型的图案。比如1代表苹果2代表星星3代表方块。不同图案用不同数字区分程序判断起来非常快。# 示例棋盘4行5列0为空1/2/3为三种图案 board [ [1, 2, 3, 1, 0], [2, 0, 1, 3, 2], [0, 3, 2, 1, 0], [1, 2, 3, 0, 0], ] ROWS len(board) COLS len(board[0])这个数据结构是整个项目的地基。后面所有算法都在这个二维数组上进行不涉及任何图形界面。好处是什么调试方便。你可以直接打印数组观察局面也可以把数组截图保存下来整个过程和UI完全解耦。2.2 三种连通方式直线、单拐点、双拐点连连看的消除规则注意力全在“路径”上。两个相同图案的方块要能消除必须满足三个条件中的一个直线连通两个方块在同一行或同一列中间没有其他非空方块阻挡。单拐点连通路径只有一个拐角形状像个“L”。双拐点连通路径有两个拐角形状像“Z”或者“U”。我习惯用生活里的场景解释直线连通就像在走廊里从A教室走到B教室中间不能被任何东西挡住单拐点就是走廊拐了一个弯双拐点就是拐了两个弯。游戏规定了最多只能拐两次弯所以算法只需要处理这三种情况。这里核心的技巧是不管哪一种情况路径中间经过的所有格子都必须是空格。起点和终点本身是方块它们不算阻挡。路径不能穿过其他方块这是判定合法性的唯一规则。2.3 代码实现直线可达与拐点搜索先写一个函数判断两个位置之间能否直线相连def can_connect_direct(board, p1, p2): r1, c1 p1 r2, c2 p2 # 同一行检查水平方向 if r1 r2: step 1 if c2 c1 else -1 for cc in range(c1 step, c2, step): if board[r1][cc] ! 0: return False return True # 同一列检查垂直方向 if c1 c2: step 1 if r2 r1 else -1 for rr in range(r1 step, r2, step): if board[rr][c1] ! 0: return False return True return False这个函数只检查起点和终点之间的格子不包含起点终点本身。注意终点是方块所以如果起点和终点相邻range循环不会执行直接返回True这正好符合规则。然后判断单拐点def can_connect_one_turn(board, p1, p2): r1, c1 p1 r2, c2 p2 corner1 (r1, c2) # 先水平再垂直 corner2 (r2, c1) # 先垂直再水平 for corner in (corner1, corner2): cr, cc corner if board[cr][cc] ! 0: continue if can_connect_direct(board, p1, corner) and can_connect_direct(board, corner, p2): return True return False单拐点的思路很直接两个拐点只可能是(r1, c2)或(r2, c1)这两个候选位置。拐点格子必须是空格否则整个路径就不合法。分别尝试两个拐点只要其中一个能让两段直线都畅通就能消除。双拐点用“可达集合”的思路更舒服。我写一个函数计算从某个位置出发通过最多一次拐弯能到达的所有空格子def get_straight_cells(board, pos): r, c pos cells set() # 向左 for cc in range(c - 1, -1, -1): if board[r][cc] ! 0: break cells.add((r, cc)) # 向右 for cc in range(c 1, len(board[0])): if board[r][cc] ! 0: break cells.add((r, cc)) # 向上 for rr in range(r - 1, -1, -1): if board[rr][c] ! 0: break cells.add((rr, c)) # 向下 for rr in range(r 1, len(board)): if board[rr][c] ! 0: break cells.add((rr, c)) return cells def get_one_turn_cells(board, pos): result set() for cell in get_straight_cells(board, pos): result.update(get_straight_cells(board, cell)) result.discard(pos) return result基于这个集合双拐点判定变得非常优雅def can_connect_two_turn(board, p1, p2): reachable_from_p1 get_one_turn_cells(board, p1) reachable_from_p2 get_one_turn_cells(board, p2) return len(reachable_from_p1 reachable_from_p2) 0为什么集合求交集能判断双拐点因为get_one_turn_cells(p1)穷举了从p1出发拐一次弯能到达的所有空格子同理get_one_turn_cells(p2)穷举了从p2出发拐一次弯能到达的所有空格子。如果存在一个空格子x同时出现在两个集合里那就意味着p1到x最多拐一次弯x到p2最多拐一次弯合起来就是最多两次弯。这个交集点x就是两个拐弯路径的衔接点。我把三种判定再合并成一个总入口def can_link(board, p1, p2): r1, c1 p1 r2, c2 p2 if board[r1][c1] ! board[r2][c2]: return False if board[r1][c1] 0: return False if p1 p2: return False if can_connect_direct(board, p1, p2): return True if can_connect_one_turn(board, p1, p2): return True if can_connect_two_turn(board, p1, p2): return True return False2.4 路径判定里的几个边界细节第一get_straight_cells里遇到第一个非空格子就停但那个非空格子本身没有加入集合。这个设计是有意的路径中间不能穿过方块所以扩展到方块前就要截断。第二双拐点交集法天然兼容了“退化为单拐点或直线”的情况。如果p1和p2本来一拐点就能连通那个拐点大概率也会出现在两个可达集合的交集里返回True不会出错。第三注意排除board[r][c]0的情况。两个空格不能作为一对可消除组合这个判断看起来多余但在后面自动求解器里遍历所有位置时非常关键能避免程序傻乎乎地“消除空气”。3. 让Python睁眼截图识别棋盘与坐标回填3.1 截图与窗口定位算法写好了接下来要让程序“看到”真实棋盘。我用的是最朴素也最实用的方案截屏。先用窗口标题找到游戏窗口获取它的位置和大小然后对这个区域截图。import pyautogui import pygetwindow as gw win gw.getWindowsWithTitle(连连看)[0] if win.isMinimized: win.restore() win.activate() left, top win.left, win.top width, height win.width, win.height screenshot pyautogui.screenshot(region(left, top, width, height)) screenshot.save(board_raw.png)这里有个经验之谈尽量不要对整个屏幕截图再裁切而是直接用窗口句柄定位。因为窗口可能会被拖动位置不是固定的。每次截图前都重新读取窗口坐标能避免很多玄学偏移问题。3.2 从截图里切出棋盘区域拿到窗口截图后第一步是确定棋盘的边界。我实测下来最稳的方案是用“像素投影法”找网格线。所谓投影就是对截图的每一行计算该行上非背景色像素的数量数量突增的那一行很可能就是棋盘的上下边缘同理对每一列做垂直投影可以得到左右边缘。import numpy as np from PIL import Image img Image.open(board_raw.png).convert(RGB) arr np.array(img) # 假设背景色接近某个固定颜色或者用边缘检测确定棋盘外框 # 这里以固定阈值判断为例 mask (np.sum(arr, axis2) 600).astype(np.uint8) # 深色像素视为棋盘 row_sum mask.sum(axis1) col_sum mask.sum(axis0) rows_with_content [i for i, v in enumerate(row_sum) if v 10] cols_with_content [i for i, v in enumerate(col_sum) if v 10] top_edge min(rows_with_content) bottom_edge max(rows_with_content) left_edge min(cols_with_content) right_edge max(cols_with_content) board_img arr[top_edge:bottom_edge 1, left_edge:right_edge 1]这个方案比直接写死网格坐标稳得多。只要棋盘和背景色有明显差异投影法都能正确切出边界。切出棋盘后再根据行数和列数把棋盘均分成格子ROWS, COLS 12, 14 cell_h board_img.shape[0] // ROWS cell_w board_img.shape[1] // COLS取每个格子中心点的像素颜色就得到了一张“颜色矩阵”。有人会问直接取中心点会不会碰到方块边缘所以我说取中心点周围3x3区域的平均值抗干扰能力会好很多。3.3 颜色识别还是模板匹配如果连连看里的图案是纯色块用颜色距离分类就够了。我会预定义几个颜色桶比如红色、蓝色、黄色、绿色然后计算像素颜色和每个桶的欧氏距离选最近的匹配。代码大致长这样def classify_color(pixel): colors { 1: (255, 0, 0), # 红色 2: (0, 0, 255), # 蓝色 3: (255, 255, 0), # 黄色 4: (0, 255, 0), # 绿色 } best_id None best_dist float(inf) for pid, ref_color in colors.items(): dist sum((pixel[i] - ref_color[i]) ** 2 for i in range(3)) if dist best_dist: best_dist dist best_id pid return best_id如果图案比较复杂比如带花纹的卡通图标颜色分类就不够了。这时候我会用模板匹配先截取每种图案的一张小图当模板然后用OpenCV的matchTemplate对每个格子做匹配取相似度最高的模板作为该格子的类型。import cv2 import numpy as np template cv2.imread(template_star.png) cell_img board_img[r * cell_h: (r 1) * cell_h, c * cell_w: (c 1) * cell_w] result cv2.matchTemplate(cell_img, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ cv2.minMaxLoc(result) # max_val 越接近1说明越相似模板匹配的优点是鲁棒缺点是慢。对于标准的桌面连连看颜色识别通常足够用了我实际项目里也是先用颜色方案跑通的。3.4 坐标回填从棋盘格子映射到屏幕点击位置识别出棋盘矩阵后如果后续要模拟点击消除必须把“格子坐标”换算成“屏幕坐标”。换算公式非常直白def cell_to_screen(r, c): screen_x left left_edge c * cell_w cell_w // 2 screen_y top top_edge r * cell_h cell_h // 2 return screen_x, screen_y这一步看似简单但恰恰是很多人出错的地方。如果截图用的坐标基准和点击用的坐标基准不一致换算结果就会整体偏移。比如截图时用了pyautogui.screenshot返回的坐标系统点击时又用了窗口内部的相对坐标那必然点偏。我的建议是从截图到点击全程统一使用屏幕绝对坐标。4. 自动求解器贪心、回溯与死活局处理4.1 贪心求解每次都消第一对能行吗有了can_link函数自动求解的第一步就水到渠成了——遍历所有位置找出所有可消除对然后选一对消掉。def find_all_pairs(board): rows, cols len(board), len(board[0]) positions {} for r in range(rows): for c in range(cols): val board[r][c] if val ! 0: positions.setdefault(val, []).append((r, c)) pairs [] for val, cells in positions.items(): for i in range(len(cells)): for j in range(i 1, len(cells)): if can_link(board, cells[i], cells[j]): pairs.append((cells[i], cells[j])) return pairs贪心策略就是每次从pairs里取第一对来消。这种方案在简单局面下能一路消到底但遇到复杂局面很容易卡死。原因是连连看本质是一个搜索问题当前看起来能消的一对可能会挡住后面需要的路径。消哪个、先消谁会影响整个后续局面。4.2 用回溯保证能找到解为了保证“能解就能找到解”我把求解器升级成回溯搜索。思路是不满足于只消第一对而是逐个尝试每一种可消组合。每一对消掉之后递归求解剩余局面如果某条分支走到死路就回退到上一步换一对再试。def dfs_solve(board, steps, max_depth2000): if len(steps) max_depth: return False if all(v 0 for row in board for v in row): return True for p1, p2 in find_all_pairs(board): v1 board[p1[0]][p1[1]] v2 board[p2[0]][p2[1]] board[p1[0]][p1[1]] 0 board[p2[0]][p2[1]] 0 steps.append((p1, p2)) if dfs_solve(board, steps, max_depth): return True steps.pop() board[p1[0]][p1[1]] v1 board[p2[0]][p2[1]] v2 return False回溯的代价是搜索空间可能很大。所以我会加一个状态去重把当前棋盘状态转成字符串或者元组存进一个visited集合。如果某个状态已经被尝试过且失败后面的分支直接跳过避免重复计算。def board_key(board): return tuple(tuple(row) for row in board) visited set() def dfs_solve(board, steps, max_depth2000): key board_key(board) if key in visited: return False visited.add(key) if len(steps) max_depth: return False if all(v 0 for row in board for v in row): return True for p1, p2 in find_all_pairs(board): v1 board[p1[0]][p1[1]] v2 board[p2[0]][p2[1]] board[p1[0]][p1[1]] 0 board[p2[0]][p2[1]] 0 steps.append((p1, p2)) if dfs_solve(board, steps, max_depth): return True steps.pop() board[p1[0]][p1[1]] v1 board[p2[0]][p2[1]] v2 return False在15x17、6到8种图案的棋盘上加了记忆化的回溯通常几十毫秒内就能出结果偶尔复杂局面会慢一点但作为本地项目完全够用。4.3 无解局面与洗牌策略实际游戏里经常会遇到“当前没有任何一对可以消除”的死局。处理死局的方法是洗牌。洗牌要注意一个细节不能只把剩余方块乱序摆放那样洗完后可能还是死局。我一般会在洗牌后调用find_all_pairs检查一遍如果还是无可消对就继续洗直到出现可消对为止。核心代码import random def shuffle_with_valid_pair(board): pairs [] rows, cols len(board), len(board[0]) while not pairs: cells [] for r in range(rows): for c in range(cols): if board[r][c] ! 0: cells.append(board[r][c]) random.shuffle(cells) idx 0 for r in range(rows): for c in range(cols): if board[r][c] ! 0: board[r][c] cells[idx] idx 1 pairs find_all_pairs(board)如果想把局面做得更高级可以反过来生成“一定有解”的棋盘从空棋盘开始每次随机选两个能够直线连通的位置放上同一种图案。这样填满整个棋盘后本身就隐含了一条合法的消除序列。这种做法适合单机版的“生成关卡”功能不需要依赖洗牌兜底。4.4 实测表现贪心经常卡回溯稳得多我拿本地模拟器跑了大量随机局面做了个对比策略消完一局成功率平均耗时贪心消第一对约65%10ms级别贪心按路径最短优先约78%20ms级别回溯状态去重接近100%前提局面有解50ms级别这个表格不是精确基准但趋势很明显别指望贪心一劳永逸。真要做一个可靠的自动求解器回溯是绕不开的。贪心适合做“提示”功能——给出一个显然合法的消除对省得玩家卡关回溯才适合做“自动通关”。5. 退回本地用pygame做一个自己的连连看Demo5.1 为什么要自己做一个单机版经历完在线平台被举报之后我冷静下来想真正有意思的不是“用脚本赢真人”而是“用一套代码完整实现一个游戏”。于是我把项目改造成本地单机版。这个版本的定位很明确手动能玩自动也能玩所有功能都摆在明面上用来展示算法、调试逻辑。相比直接操作在线平台单机版有四个好处你可以随意打印棋盘状态观察每一步判定结果。你可以点一个按钮让程序自己演示一遍完整解法。你可以调整棋盘大小、图案种类测试算法在不同参数下的表现。你不需要担心封号举报纯粹是学习工具。5.2 pygame版小Demo界面与交互我用pygame写了一个最简可玩的版本。核心是事件循环等待鼠标点击某个格子再点击另一个格子如果两个格子图案相同且路径合法就把它们从棋盘上消掉。import pygame pygame.init() screen pygame.display.set_mode((700, 600)) pygame.display.set_caption(Local Lianliankan Demo) selected None running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN: mx, my event.pos r my // cell_h c mx // cell_w if 0 r ROWS and 0 c COLS: if selected is None: selected (r, c) else: if can_link(board, selected, (r, c)): board[selected[0]][selected[1]] 0 board[r][c] 0 selected None draw_board(screen, board) pygame.display.flip()这个例子省略了贴图加载细节但核心交互就这么简洁。你点击第一个格子时记录位置点击第二个格子时调用can_link做判定合法就消除。棋盘绘图用矩形色块加文字编号就够了没必要一开始就塞一堆图片资源。5.3 把自动求解器接入游戏接入自动求解器也很简单。我在界面上加了一个“自动演示”按钮当它被按下时程序调用dfs_solve得到完整的消除步骤列表然后按顺序每隔300毫秒模拟点击两个对应格子。def auto_play(): steps [] board_copy [row[:] for row in board] dfs_solve(board_copy, steps) for p1, p2 in steps: click_screen(p1) click_screen(p2) pygame.time.wait(300)这里有个细节我操作的是board_copy不是正在显示的真实棋盘。因为求解器会把棋盘消空我需要在副本上计算步骤然后原样播放。等步骤全部播放完再同步更新界面的board。5.4 数据统计跑一万局试试成功率单机版做完之后我又写了一个无界面模拟器用来批量验证算法。流程是这样的随机生成一个有解的棋盘分别用贪心和回溯去求解统计成功率。这一步非常值得做因为你会直观感受到不同策略的差距。我在这里建议你跑这一步的意义不是为了看热闹而是为了理解一个问题为什么一个看起来“总能找到可消对”的贪心策略最终经常剩一堆消不掉的方块因为贪心只关注当前一步而连连看的可消路径之间互相干扰。先消哪一对决定了后续哪些路径能被打开哪些会被堵死。只有回溯这种带“后悔药”的搜索方式才能真正跳出局部选择。6. 实操踩坑记录以及为什么我劝你“别霍霍别人”6.1 总是点偏DPI缩放与多显示器坐标截图识别出来的坐标是对的但点击的时候总是往左上偏一格。这个问题的真正原因是操作系统的DPI缩放。Windows下如果显示缩放比例不是100%pyautogui的截图坐标和鼠标点击坐标会出现比例偏差。解决办法是在程序开头设置DPI感知import ctypes ctypes.windll.shcore.SetProcessDpiAwareness(1)设置完之后系统会把屏幕坐标按物理像素返回截图和点击就对齐了。多显示器环境下更建议提前指定目标窗口所在的屏幕避免窗口跑到另一块不同缩放比例的屏幕上。6.2 截到动画帧导致棋盘识别错乱连连看消除格子时会有闪光特效如果截图刚好截到特效帧中心像素颜色就乱了。我的经验是点击消除之后等300到500毫秒再截下一帧。人眼觉得动画很快程序不吃这一套该等就得等。6.3 双拐点判定里最隐蔽的边界我在写can_connect_two_turn时遇到过一种情况p1和p2其实处于同一个“死胡同”里根本不可能通过两个拐点连通但交集法却返回了True。查了很久才发现问题出在get_one_turn_cells把路径经过的其他方块也算进去了。修复方式就是前面代码里展示的到达集合只收集空格子遇到非空格立即截断。这个边界在在线平台上看不出来但在本地做批量模拟时会被成千上万的样本暴露。6.4 别把性能浪费在重复判断上find_all_pairs每次调用都会遍历整个棋盘并对所有候选对做连通检查。当棋盘有100多个方块时这个开销不小。优化手段有两个一是缓存每个图案的位置列表而不是每次重新收集二是在求解过程中只更新被消除的两个格子周围区域的可达关系而不是全盘重算。单机小棋盘可以不做这些优化但如果你想把同一个架构扩展到更多格子的游戏这些优化迟早要上。6.5 为什么我真心劝你别去在线平台跑脚本最后说回标题。用Python玩连连看效果确实很酷——你展示了“自动识别”“自动决策”“自动操作”一整条链路。但这条链路一旦接上在线平台性质就变了。对真实玩家来说你的脚本剥夺了他们的游戏乐趣对平台来说你的行为破坏了公平性轻则被封号重则连累整个账号体系。真正的技术乐趣在于创造和解决问题而不在于碾压别人。跑到最后你会发现比“脚本赢了”更有成就感的是那份代码背后清晰的结构棋盘建模、路径搜索、状态回溯、图像识别每一块都能独立讲出道理。把这些道理吃透你的Python水平会在不知不觉中上一个大台阶。我实际用下来的体会是这个项目特别适合作为“从语法走向项目”的跳板。你不需要一开始就追求自动通关那样的完整效果可以先只写连通判定再逐步加截图识别、加求解器、加界面。每加一块都会巩固一部分知识。等整套流程打通了你再回头看会发现自己已经能独立完成一个包含算法、图像处理、GUI交互的综合项目了。至于那些在线平台上的“生态位”就让给真正想在游戏里获得乐趣的玩家吧。