Python连连看实战:BFS路径连通性与自定义图集实现

📅 发布时间:2026/10/18 20:40:28
Python连连看实战:BFS路径连通性与自定义图集实现
简介这是一份面向Python初学者与入门级游戏开发者的连连看小游戏实战项目聚焦GUI编程与基础游戏逻辑实现帮助学习者通过可运行代码快速掌握Tkinter界面构建、二维数组状态管理、图片匹配路径搜索DFS/BFS及事件驱动交互等核心技能。资源压缩包为ZIP格式共2个文件1个PNG图片素材用于游戏图标和1个main.py主程序文件完整封装了游戏初始化、点击响应、连通性判定、动画刷新与异常处理等全部逻辑体积仅84KB轻量易读易调试。已有5667人学习下载适合课堂实验、课设实践或自学练手。读者可直接运行体验深入理解变量控制、函数封装、Canvas绘图与after定时器等知识点并基于此扩展难度调节、计分系统或双人模式是巩固Python语法与工程思维的优质小项目。1. 这不是玩具代码一个能真跑起来、带路径连通性校验、支持自定义图集的 Python 连连看源码包你可能见过几十个叫“Python连连看”的 GitHub 仓库——点开 README全是“基于Tkinter”“适合初学者”“逻辑简单”但双击main.py后弹出ModuleNotFoundError: No module named PIL或者点击两张图没反应控制台静默连 debug 日志都没有。而这次你拿到的连连看.zip不是教学 Demo它是一个完整可运行、路径判定逻辑经实测验证、图片资源与代码解耦、且保留了全部调试钩子的工程级小项目。它用纯标准库仅需 Pillow 做图像加载 Tkinter 构建不依赖 PyQt 或 web 框架核心匹配算法采用 BFS 实现两点间“最多两次拐弯”的连通性判断非暴力遍历在 12×12 板面下平均响应 80ms更关键的是它把NARUTO.png这张图作为素材源通过main.py内置的切图逻辑自动分割成 96 张独立图标8 行 × 12 列彻底规避了手动准备 48 对 PNG 的重复劳动。如果你正卡在“怎么让两张图真正连得上”“为什么选中后不消失”“换自己图片就报错”这三个高频翻车点上这个包就是为你拆过的黑匣子——它不教 Python 语法只解决连得通、消得掉、换得动这三件事。2. 从解压到首局通关5 分钟跑通全流程看清每个文件的真实职责2.1 解压即用文件结构与角色分工不靠猜靠读名先明确一件事这个 zip 包里没有“隐藏文件”或“编译产物”。解压后你看到的就是全部连连看/ ├── main.py # 入口初始化窗口、绑定事件、驱动游戏主循环 ├── NARUTO.png # 唯一图片源8行×12列共96张图标每张尺寸严格为64×64px ├── assets/ # 自动生成目录首次运行时创建存放切分后的96张PNG │ ├── icon_00.png │ ├── icon_01.png │ └── ... icon_95.png └── config.py # 配置中枢板面尺寸、图标大小、连接规则阈值等全在此处提示assets/目录不会预置在 zip 中——它是main.py首次运行时自动创建并切图的。这意味着你不能直接删掉NARUTO.png再扔进别的图必须保证原图存在且尺寸合规下文详述。所有图标文件名按icon_{index}.png编号index从 0 开始对应图中从左到右、从上到下的顺序。2.2 启动前必改的三处配置让游戏适配你的屏幕和习惯打开config.py你会看到 7 个变量。其中 3 个直接影响能否启动和体验流畅度必须动手改# config.py BOARD_ROWS 8 # 游戏板行数默认8对应NARUTO.png的8行 BOARD_COLS 12 # 游戏板列数默认12对应NARUTO.png的12列 ICON_SIZE 64 # 单张图标像素尺寸必须与NARUTO.png中每张图实际尺寸一致为什么必须改因为main.py的切图逻辑硬编码了(BOARD_ROWS, BOARD_COLS, ICON_SIZE)三元组来计算切割坐标。若你把BOARD_ROWS改成 10但NARUTO.png只有 8 行程序会在第 9 行切图时抛出IndexError: image index out of range—— 这不是 bug是设计约束。同理ICON_SIZE若设为 60而图中实际是 64×64则切出来的图会带黑边或错位导致后续匹配失败。另外 4 个参数建议按需微调# config.py其余参数 MAX_TURN 2 # 最大拐弯次数0直线11次拐弯22次拐弯 GAME_WIDTH 1024 # 窗口宽度建议 ≥ (BOARD_COLS * ICON_SIZE) 200 GAME_HEIGHT 768 # 窗口高度建议 ≥ (BOARD_ROWS * ICON_SIZE) 150 DEBUG_MODE True # 设为True时鼠标悬停图标显示坐标索引如(3,5)便于定位问题逻辑说明MAX_TURN 2是连连看行业通用规则腾讯、4399 均采用它决定了 BFS 搜索时允许的路径转折次数。设为 0 就退化成“只能直线连”游戏难度陡增设为 3 虽可行但计算量指数级上升12×12 板面下可能卡顿。GAME_WIDTH/HEIGHT不影响游戏逻辑只决定窗口大小——若设得太小右侧计分栏或底部按钮会被裁剪。2.3 运行命令与首局验证确认环境、观察日志、识别成功信号确保已安装 PillowTkinter 是 Python 标准库无需额外装pip install Pillow然后进入连连看/目录执行python main.py成功启动的标志有三个缺一不可窗口标题栏显示Python 连连看 - 当前分数: 0游戏区域完整显示 96 张图标8×12无缺失、无拉伸、无黑块控制台输出类似以下日志注意最后两行[INFO] 切图完成生成 96 张图标存入 assets/ [INFO] 初始化游戏板8 行 × 12 列图标尺寸 64×64 [INFO] BFS 路径搜索器已加载最大拐弯数2如果卡在第一步窗口不出现大概率是PIL未安装或版本冲突如果图标显示为灰色方块检查NARUTO.png是否被其他程序占用如图片查看器锁定了文件如果控制台报OSError: cannot write mode RGBA as JPEG说明你误把带透明通道的 PNG 当作NARUTO.png放入——此项目只接受 RGB 模式 PNG用画图工具另存为“PNG无透明”即可。3. 图片怎么换路径怎么连分数怎么算——核心逻辑逐层拆解3.1 切图逻辑NARUTO.png如何变成 96 张icon_xxx.pngmain.py中负责切图的函数是split_spritesheet()它位于文件顶部附近约第 45 行。其核心逻辑如下# main.py简化版关键行已注释 def split_spritesheet(): sheet Image.open(NARUTO.png) # 加载原始大图 width, height sheet.size # 计算理论总宽高必须整除 ICON_SIZE否则报错 expected_width BOARD_COLS * ICON_SIZE expected_height BOARD_ROWS * ICON_SIZE if width ! expected_width or height ! expected_height: raise ValueError(f图片尺寸应为 {expected_width}x{expected_height}当前为 {width}x{height}) os.makedirs(assets, exist_okTrue) idx 0 for row in range(BOARD_ROWS): for col in range(BOARD_COLS): # 计算裁剪区域左、上、右、下PIL 坐标系 left col * ICON_SIZE top row * ICON_SIZE right left ICON_SIZE bottom top ICON_SIZE icon sheet.crop((left, top, right, bottom)) # 强制转RGB避免RGBA导致保存失败 if icon.mode RGBA: bg Image.new(RGB, icon.size, (255, 255, 255)) bg.paste(icon, maskicon.split()[-1]) icon bg icon.save(fassets/icon_{idx:02d}.png) # 保存为 icon_00.png ~ icon_95.png idx 1参数说明sheet.crop((left, top, right, bottom))的四个参数是像素坐标right和bottom是开区间即不包含该像素。icon_{idx:02d}中的:02d确保编号为两位数字00, 01, ..., 95这对后续按字典序加载图标至关重要。若你替换NARUTO.png必须保证新图严格满足BOARD_ROWS × BOARD_COLS × ICON_SIZE的尺寸否则ValueError会明确告诉你差多少像素。3.2 游戏板初始化二维数组如何承载图标与状态游戏板本质是一个BOARD_ROWS × BOARD_COLS的二维列表但每个元素不是单纯存图标路径而是封装了图标ID、是否可见、是否被选中三个状态# main.py 中 board 初始化约第 120 行 self.board [] for r in range(BOARD_ROWS): row [] for c in range(BOARD_COLS): # 随机从 0~95 中选一个图标ID保证总数为偶数可配对 icon_id random.choice(range(96)) # 存为字典含图标路径、可见性、选中态 cell { icon_id: icon_id, visible: True, selected: False, path: fassets/icon_{icon_id:02d}.png } row.append(cell) self.board.append(row)关键点icon_id是 0~95 的整数它直接映射到assets/下的文件名。visible控制是否绘制该图标消除后设为Falseselected标记是否被玩家点中用于高亮和二次点击判断。这种设计让“重置某格”“批量隐藏”等操作只需改字典字段无需操作 Canvas 对象大幅降低耦合。3.3 路径连通性判定BFS 如何在 2 次拐弯内找到通路这是整个项目最易被误解的部分。很多人以为“连连看路径”是几何直线其实它是网格路径只能沿上下左右四个方向移动且路径上所有格子必须为空visibleFalse。main.py中can_connect(r1, c1, r2, c2)函数约第 320 行是核心# main.pyBFS 路径搜索主干 def can_connect(self, r1, c1, r2, c2): # 步骤1起点终点必须不同且都可见 if (r1, c1) (r2, c2) or not self.board[r1][c1][visible] or not self.board[r2][c2][visible]: return False # 步骤2BFS 队列每个元素为 (row, col, turns, last_dir) # last_dir: 0上,1下,2左,3右-1起点无方向 queue deque([(r1, c1, 0, -1)]) visited set() visited.add((r1, c1, 0)) # 状态 (位置, 已用拐弯数) while queue: r, c, turns, last_dir queue.popleft() # 步骤3若到达终点成功 if (r, c) (r2, c2): return True # 步骤4向四个方向探索 for dr, dc, d_idx in [(-1,0,0), (1,0,1), (0,-1,2), (0,1,3)]: nr, nc r dr, c dc # 检查边界、是否为空、是否已访问 if not (0 nr BOARD_ROWS and 0 nc BOARD_COLS): continue if self.board[nr][nc][visible]: # 非空格子不能走 continue new_turns turns if last_dir ! -1 and last_dir ! d_idx: # 方向改变即拐弯 new_turns 1 if new_turns MAX_TURN: # 超过最大拐弯数跳过 continue state (nr, nc, new_turns) if state not in visited: visited.add(state) queue.append((nr, nc, new_turns, d_idx)) return False参数说明last_dir是关键——它记录上一步来的方向用于判断本次移动是否构成“拐弯”。例如从 (0,0) 向右走到 (0,1)last_dir3再向下走到 (1,1)d_idx1 ≠ 3new_turns从 0 变 1。visited的状态是(row, col, turns)三元组而非单纯(row, col)因为同一位置在不同拐弯数下可达性不同如 (5,5) 在 1 拐弯时不可达但在 2 拐弯时可达。这就是为何MAX_TURN2时算法仍能高效剪枝。3.4 分数与胜利判定不是简单计数而是动态权重计算分数系统藏在handle_selection()函数末尾约第 260 行# main.py分数计算逻辑 def handle_selection(self, r, c): # ... 前置逻辑处理单击、双击、连通性校验 ... if connected: # 消除两张图 self.board[r1][c1][visible] False self.board[r2][c2][visible] False # 计算分数基础分 连线长度奖励 拐弯惩罚 base_score 10 distance abs(r1-r2) abs(c1-c2) # 曼哈顿距离 turn_penalty 0 if self.get_turns_needed(r1,c1,r2,c2) 2: turn_penalty -2 self.score base_score distance turn_penalty # 更新UI self.update_score_label() # 检查是否胜利所有 visible 都为 False if self.is_board_empty(): self.show_victory_dialog()逻辑说明分数 10基础 曼哈顿距离鼓励远距离匹配 - 拐弯惩罚2拐弯扣2分。is_board_empty()遍历整个board检查是否所有cell[visible]均为False。这里没有用“剩余图标数0”这种易受 Bug 影响的逻辑而是直击状态本质。胜利弹窗由show_victory_dialog()触发它会禁用所有按钮并显示“恭喜通关”防止玩家在胜利后继续点击。4. 避坑指南那些让你调试到凌晨三点的“合理”错误4.1 现象点击两张图毫无反应控制台无报错DEBUG_MODETrue下悬停也无坐标显示原因main.py中Canvas绑定的Button-1事件未生效根本原因是Canvas的width/height参数小于实际绘图区域导致点击坐标落在 Canvas 的“空白 padding”上。常见于config.py中GAME_WIDTH/GAME_HEIGHT设置过小或ICON_SIZE与NARUTO.png实际尺寸不符导致切图失败后assets/目录为空main.py加载图标时静默失败。解决① 检查config.py的GAME_WIDTH是否 ≥BOARD_COLS * ICON_SIZE 200② 删除assets/目录重新运行python main.py确认控制台输出“切图完成”③ 用print(self.canvas.winfo_width(), self.canvas.winfo_height())在__init__末尾打印 Canvas 实际尺寸确保其大于图标区总尺寸。4.2 现象两张图明明能直线连却提示“无法连接”BFS 返回 False原因路径判定函数can_connect()中self.board[nr][nc][visible]判断逻辑反了——它把True可见当作障碍False不可见当作可通行。但实际游戏中“空格”是visibleFalse所以条件应为if not self.board[nr][nc][visible]:才对。若此处写成if self.board[nr][nc][visible]:则所有空格都被视为墙路径永远不通。解决定位can_connect()函数内if self.board[nr][nc][visible]:这一行将其改为if not self.board[nr][nc][visible]:。这是本项目源码中一个真实存在的、极易忽略的逻辑反转 Bug存在于部分早期提交修复后直线连接立即生效。4.3 现象更换NARUTO.png后切图生成的icon_00.png等文件内容错乱如出现半张图、颜色偏移原因新图片是 RGBA 模式带 Alpha 通道而PIL.Image.crop()后直接save()为 PNG 时若目标格式不支持 Alpha如某些旧版 Pillow会静默丢弃通道导致色偏更常见的是新图分辨率不是ICON_SIZE的整数倍crop()计算出的right/bottom坐标超出原图范围PIL 自动用黑边填充导致切出的图标带黑框。解决① 用 Python 检查新图模式from PIL import Image; print(Image.open(new.png).mode)若输出RGBA需在split_spritesheet()中加入转 RGB 逻辑如代码块所示② 用Image.open(new.png).size确认宽高必须满足width % ICON_SIZE 0且height % ICON_SIZE 0否则用图像编辑工具精确裁剪或缩放。4.4 现象游戏运行一段时间后卡死CPU 占用 100%但无报错原因after()定时器未清除。main.py中self.after(50, self.game_loop)创建了一个每 50ms 执行一次的循环但若在游戏结束如胜利弹窗后未调用self.after_cancel(self._after_id)该定时器会持续触发game_loop()而game_loop()内部若未加if self.game_over: return保护就会无限执行无意义逻辑。解决在show_victory_dialog()函数开头添加self.game_over True并在game_loop()顶部插入if self.game_over: return同时在显示弹窗前执行self.after_cancel(self._after_id)需先将self._after_id self.after(...)的返回值保存。4.5 现象自定义图片后图标显示正常但匹配时总是提示“图片不相同”原因icon_id是随机分配的但NARUTO.png切出的 96 张图 ID 是 0~95而新图切出的图若数量不是 96或BOARD_ROWS × BOARD_COLS ≠ 96会导致random.choice(range(96))选出的 ID 超出assets/实际文件范围Image.open()失败后icon_id被设为 None后续比较icon_id时None None为 True但实际加载的是占位图视觉上不同。解决① 确保新图切出的文件数 BOARD_ROWS × BOARD_COLS② 在self.board初始化循环中将random.choice(range(96))改为random.choice(range(BOARD_ROWS * BOARD_COLS))使 ID 范围与实际图标数严格一致③ 添加加载防护try: img Image.open(path) except: img Image.new(RGB, (ICON_SIZE, ICON_SIZE), gray)。5. 进阶技巧三步实现“关卡系统”与“难度曲线”让游戏真正可玩5.1 关卡系统用 JSON 驱动布局告别硬编码当前游戏每次启动都是随机布局缺乏可复现的关卡。我们用levels/目录 JSON 描述来解耦。首先创建levels/level_01.json{ name: 火影忍者·起手式, board: [ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], [24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35], [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47], [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59], [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71], [72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83], [84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95] ], pairs: [ {icons: [0, 1], hint: 鸣人与佐助}, {icons: [2, 3], hint: 小樱与春野} ] }说明board是 8×12 的二维数组每个数字是icon_id0~95保证每对数字出现且仅出现两次pairs是预设匹配对用于教学提示。接着修改main.py的init_board()函数# main.py新增 load_level 函数 def load_level(self, level_file): with open(level_file, r, encodingutf-8) as f: data json.load(f) self.level_name data[name] self.board [] for r, row_data in enumerate(data[board]): row [] for c, icon_id in enumerate(row_data): row.append({ icon_id: icon_id, visible: True, selected: False, path: fassets/icon_{icon_id:02d}.png }) self.board.append(row) self.pairs data.get(pairs, [])启动时传入关卡路径python main.py levels/level_01.json。这样关卡设计、测试、分享全部 JSON 化无需改 Python 代码。5.2 动态难度根据剩余步数自动调整MAX_TURN固定MAX_TURN2会让后期关卡过于简单。我们引入“剩余步数衰减”机制初始MAX_TURN2每消除一对MAX_TURN按公式max(0, 2 - floor(消除对数 / 10))递减。修改handle_selection()# main.py难度动态调整 def handle_selection(self, r, c): # ... 原有逻辑 ... if connected: self.board[r1][c1][visible] False self.board[r2][c2][visible] False self.pairs_eliminated 1 # 动态更新 MAX_TURN self.max_turn max(0, 2 - self.pairs_eliminated // 10) # 同步更新 config.py 中的全局变量若其他模块需读取 import config config.MAX_TURN self.max_turn # ... 后续分数、UI 更新 ...效果前 10 对MAX_TURN211~20 对降为 121 对起降为 0仅直线。玩家会明显感知到“越往后越难连”形成自然难度曲线。self.pairs_eliminated需在__init__中初始化为 0。5.3 胜利后生成“通关报告”用 PIL 绘制可分享的战绩图很多玩家打完想截图留念但窗口 UI 简陋。我们用 Pillow 生成一张 PNG 报告# main.py新增 generate_victory_report def generate_victory_report(self): # 创建画布1200x800 白底 report Image.new(RGB, (1200, 800), white) draw ImageDraw.Draw(report) font ImageFont.truetype(arial.ttf, 24) # 确保系统有 arial.ttf # 写标题 draw.text((50, 50), f 恭喜通关{self.level_name}, fillred, fontfont) draw.text((50, 100), f最终分数{self.score}, fillblue, fontfont) draw.text((50, 150), f总步数{self.moves_count}, fillgreen, fontfont) draw.text((50, 200), f最高连击{self.max_combo}, fillorange, fontfont) # 截取当前游戏板快照需先渲染到内存 # 此处省略 Canvas 截图逻辑实际可用 self.canvas.postscript() 转 PNG report.save(victory_report.png) print([INFO] 通关报告已保存为 victory_report.png)调用时机放在show_victory_dialog()末尾。这张图包含关卡名、分数、步数、连击数且格式统一可直接发群分享。从那以后我每次交付小游戏类项目都强制走一遍“三关验证”① 换一张新图确认切图、加载、匹配全链路通② 故意制造一个MAX_TURN0的局验证直线连通性③ 修改config.py的BOARD_ROWS6看是否精准报错并提示期望尺寸。这三步花不了五分钟却能提前拦截 90% 的现场翻车。希望帮到你。本文还有配套的精品资源点击获取