☰
Python连连看实战:BFS路径连通性与自定义图集实现
2026/10/18 20:40:12 网站建设 项目流程

简介:这是一份面向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=直线,1=1次拐弯,2=2次拐弯) 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 运行命令与首局验证:确认环境、观察日志、识别成功信号

确保已安装 Pillow(Tkinter 是 Python 标准库,无需额外装):

pip install Pillow

然后进入连连看/目录,执行:

python main.py

成功启动的标志有三个,缺一不可:

  1. 窗口标题栏显示Python 连连看 - 当前分数: 0
  2. 游戏区域完整显示 96 张图标(8×12),无缺失、无拉伸、无黑块
  3. 控制台输出类似以下日志(注意最后两行):
    [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.png

main.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_ok=True) 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, mask=icon.split()[-1]) icon = bg icon.save(f"assets/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": f"assets/icon_{icon_id:02d}.png" } row.append(cell) self.board.append(row)

关键点:icon_id是 0~95 的整数,它直接映射到assets/下的文件名。visible控制是否绘制该图标(消除后设为False),selected标记是否被玩家点中(用于高亮和二次点击判断)。这种设计让“重置某格”“批量隐藏”等操作只需改字典字段,无需操作 Canvas 对象,大幅降低耦合。

3.3 路径连通性判定:BFS 如何在 2 次拐弯内找到通路

这是整个项目最易被误解的部分。很多人以为“连连看路径”是几何直线,其实它是网格路径:只能沿上下左右四个方向移动,且路径上所有格子必须为空(visible=False)。main.py中can_connect(r1, c1, r2, c2)函数(约第 320 行)是核心:

# main.py(BFS 路径搜索主干) 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 # 步骤2:BFS 队列,每个元素为 (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_dir=3;再向下走到 (1,1),d_idx=1 ≠ 3,new_turns从 0 变 1。visited的状态是(row, col, turns)三元组,而非单纯(row, col),因为同一位置在不同拐弯数下可达性不同(如 (5,5) 在 1 拐弯时不可达,但在 2 拐弯时可达)。这就是为何MAX_TURN=2时,算法仍能高效剪枝。

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_MODE=True下悬停也无坐标显示

原因: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(不可见)当作可通行。但实际游戏中,“空格”是visible=False,所以条件应为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_id(0~95),保证每对数字出现且仅出现两次;pairs是预设匹配对,用于教学提示。接着修改main.py的init_board()函数:

# main.py(新增 load_level 函数) def load_level(self, level_file): with open(level_file, "r", encoding="utf-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": f"assets/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_TURN=2会让后期关卡过于简单。我们引入“剩余步数衰减”机制:初始MAX_TURN=2,每消除一对,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_TURN=2,11~20 对降为 1,21 对起降为 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}", fill="red", font=font) draw.text((50, 100), f"最终分数:{self.score}", fill="blue", font=font) draw.text((50, 150), f"总步数:{self.moves_count}", fill="green", font=font) draw.text((50, 200), f"最高连击:{self.max_combo}", fill="orange", font=font) # 截取当前游戏板快照(需先渲染到内存) # (此处省略 Canvas 截图逻辑,实际可用 self.canvas.postscript() 转 PNG) report.save("victory_report.png") print("[INFO] 通关报告已保存为 victory_report.png")

调用时机放在show_victory_dialog()末尾。这张图包含关卡名、分数、步数、连击数,且格式统一,可直接发群分享。

从那以后我每次交付小游戏类项目,都强制走一遍“三关验证”:① 换一张新图,确认切图、加载、匹配全链路通;② 故意制造一个MAX_TURN=0的局,验证直线连通性;③ 修改config.py的BOARD_ROWS=6,看是否精准报错并提示期望尺寸。这三步花不了五分钟,却能提前拦截 90% 的现场翻车。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询