Python+pygame复刻经典连连看:路径查找算法与游戏实现
简介一款QQ连连看单机版游戏资源包包含可运行程序、源代码与说明文档无需网络即可体验经典连连看的消除乐趣也适合想研究小游戏实现的开发初学者的优质入门选择。压缩包内共88个文件以图像素材、位图图片、音乐音效和动态链接库为主整体仅4.44MB其中动态库分别负责动画、卡片、菜单、帮助及声音播放等游戏功能并包含多语言支持模块目录结构清晰。结合说明与源码可学习基于C的Windows小游戏开发流程理解界面布局、关卡地图编辑、消除特效与道具系统的实现思路并掌握如何整合图形、音频和多种库文件来构建完整项目。该资源已有324人学习适合刚入门游戏开发或希望快速认识完整单机游戏构成的读者是一份紧凑且可实操的参考资料。1. 一款能离线自嗨的QQ连连看为什么我选择复刻这套经典玩法周末想打发时间翻遍应用商店发现每款小游戏都要联网领体力干脆自己动手写一个离线自嗨的连连看。这款QQ连连看单机版把经典连连看玩法完整搬到了本地桌面开局自动生成棋局点击两个相同图标只要路径拐角不超过两次就能消除连击还有加分。它内置了计分、倒计时、提示、重排、暂停这些常用功能全套源码和说明文档打包在一起不用配服务器装好依赖直接跑。对新手来说它是练路径查找算法和事件循环的好素材对想自娱自乐或快速做个游戏 Demo 的开发者这套单机版完全够玩。2. 技术选型与工程结构从零搭一套可运行的 Python 小游戏2.1 为什么选 Python pygame 做单机连连看连连看这类 2D 休闲游戏核心需求是窗口绘制、鼠标事件和图片资源加载。Python 搭配 pygame 是常见做法语法直白事件循环和精灵模块几乎就是为这类小游戏准备的改完代码立刻跑起来看效果。相比 C# 或 Java 桌面方案省去了编译环节也省去了繁琐的窗口句柄管理对阅读源码的人更友好。另一个原因是这套项目把游戏逻辑和界面绘制分开了棋盘生成、连击判定、死局检测这些纯逻辑部分不依赖 pygame可以单独写单元测试。这说明文档里给了清晰的分工约定后面二次开发时也更容易替换主题皮肤或增加新玩法。2.2 工程目录与文件职责整个项目结构并不复杂目录大致长这样project_root/ ├── main.py # 程序入口负责初始化 pygame 窗口和主循环 ├── game_logic.py # 棋盘生成、消除判定、死局检测、洗牌重排 ├── ui.py # 绘制棋盘、选中高亮、分数和倒计时 ├── config.py # 棋盘行列数、图案种类、时间限制等参数 ├── assets/ │ ├── images/ # 图案图标建议使用透明背景 PNG │ ├── sounds/ # 消除音效、点击音效 │ └── fonts/ # 分数显示用的字体文件 └── 说明文档.md # 环境搭建、运行方式、扩展建议main.py 是典型入口先加载配置创建棋盘然后进入 while 主循环处理事件与重绘。game_logic.py 不引用任何 pygame 代码这样逻辑层可以脱离界面独立验证比如直接在命令行跑批量测试。ui.py 负责把二维数组里的图案画到屏幕上。配置项集中在 config.py 里棋盘行数、列数、图案种类数、倒计时秒数都放在这里。想改难度最基本的做法是调整ROWS、COLS和TYPES后面扩展关卡系统时也是把这几个参数做成动态配置。2.3 棋盘的数据结构二维列表与图案编号逻辑层最重要的数据结构是棋盘矩阵常见做法是board[row][col]存储一个整数0 代表空位非 0 代表图案编号。比如图案编号范围为 1 到 12那么相同编号的格子就是同一类图标。初始化棋盘时棋盘的总格子数必须是图案种类的偶数倍。假设棋盘是 10 行 18 列总共有 180 个格子图案种类设置为 15 种那么每种图案出现 12 次刚好匹配。import random def create_board(rows: int, cols: int, types: int) - list: total rows * cols per_type total // types # 每种图案出现的次数必须为偶数 pool [] for t in range(1, types 1): pool.extend([t] * per_type) while True: random.shuffle(pool) board [pool[r * cols:(r 1) * cols] for r in range(rows)] if is_solvable(board): return boardcreate_board先构造一个元素池里面每个图案出现次数一致然后随机打乱。打乱后必须调用is_solvable检查开局是否有可消除的对子如果死局就重新洗牌直到出现一个可正常游玩的开局。这里的检查函数后面会细讲但开局就做一次合法性验证非常有必要否则玩家面对的第一局可能就是僵局。3. 核心算法拆解从棋盘初始化到无障碍路径查找的完整实现3.1 消除判定的核心最多两个拐角的路径算法连连看最关键的规则不是“两个图案相同”而是“相同且能用不超过两次拐角的折线连通折线路径上不能有非空格子挡路”。这里有个常见的理解误区路径不一定只在棋盘内部走棋盘外圈也被视为可以通过的区域。所以判断路径时要在棋盘四周做一层“虚拟空格”处理。完整判定分成三层递进情况条件说明直接连通同行或同列中间全部为 0最简单优先判断单拐角连通两个点能通过一个直角相连等价于检查两条直线同时为空双拐角连通两条平行线通过一个中间点搭桥需要扩展扫描四个方向3.2 直线连通与拐点扫描的实现先写最基础的直线判定函数它被上层统一调用def is_straight_clear(board: list, x1: int, y1: int, x2: int, y2: int) - bool: # 这里 x 是列索引y 是行索引board[y][x] 访问格子 if x1 x2: step 1 if y2 y1 else -1 for y in range(y1 step, y2, step): if board[y][x1] ! 0: return False return True if y1 y2: step 1 if x2 x1 else -1 for x in range(x1 step, x2, step): if board[y1][x] ! 0: return False return True return False函数参数x1, y1和x2, y2是两个格子的行列坐标循环范围用range(y1 step, y2, step)这样会跳过起点和终点本身只检查中间的格子。循环判断一旦发现非空格子立即返回 False如果全程没有障碍说明两点之间直线畅通。单拐角的情况本质是找到一个拐点让起点到拐点、拐点到终点两条直线都畅通def is_one_corner_clear(board: list, x1: int, y1: int, x2: int, y2: int) - bool: # 拐点1与起点同行与终点同列 if board[y1][x2] 0: if is_straight_clear(board, x1, y1, x2, y1) and is_straight_clear(board, x2, y1, x2, y2): return True # 拐点2与起点同列与终点同行 if board[y2][x1] 0: if is_straight_clear(board, x1, y1, x1, y2) and is_straight_clear(board, x1, y2, x2, y2): return True return False这里(x2, y1)和(x1, y2)是两个候选拐点。前提是拐点本身必须是空格再分别检查两段直线。因为is_straight_clear已经跳过了端点所以拐点为空这个条件要用单独的board[y][x] 0来判断。两个拐角的情况稍微复杂需要向四个方向做投影扫描。假设从起点向右延伸如果延伸到的中间位置(mid_x, y1)到终点的垂线方向和水平方向都畅通就说明能通过这个中间点搭桥def is_two_corner_clear(board: list, x1: int, y1: int, x2: int, y2: int) - bool: rows, cols len(board), len(board[0]) # 从起点向上扫描 for y in range(y1 - 1, -1, -1): if board[y][x1] ! 0: break if is_straight_clear(board, x1, y, x2, y) and is_straight_clear(board, x2, y, x2, y2): return True # 从起点向下扫描同样逻辑 for y in range(y1 1, rows): if board[y][x1] ! 0: break if is_straight_clear(board, x1, y, x2, y) and is_straight_clear(board, x2, y, x2, y2): return True # 向左、向右扫描逻辑对称 for x in range(x1 - 1, -1, -1): if board[y1][x] ! 0: break if is_straight_clear(board, x, y1, x, y2) and is_straight_clear(board, x, y2, x2, y2): return True for x in range(x1 1, cols): if board[y1][x] ! 0: break if is_straight_clear(board, x, y1, x, y2) and is_straight_clear(board, x, y2, x2, y2): return True return False向上的循环从y1 - 1开始沿途每一格都必须是空格一旦遇到非空格就终止该方向的扫描。到达某个候选行y后检查从(x1, y)到(x2, y)是否水平畅通再检查从(x2, y)到(x2, y2)是否垂直畅通两条路都通就说明两个拐角成立。最终can_connect组合三层判断并过滤掉“同一个格子”和“图案类型不一致”这两种情况def can_connect(board: list, x1: int, y1: int, x2: int, y2: int) - bool: if (x1, y1) (x2, y2): return False if board[y1][x1] ! board[y2][x2]: return False if board[y1][x1] 0: return False return (is_straight_clear(board, x1, y1, x2, y2) or is_one_corner_clear(board, x1, y1, x2, y2) or is_two_corner_clear(board, x1, y1, x2, y2))注意棋盘外圈的处理双层边界时路径可能贴着棋盘外侧走所以扫描循环中向上可以到-1向左可以到-1代表虚拟空格。设计is_two_corner_clear时扫描边界没有严格掐死到 0而是用-1作为终止边界配合 board 外圈逻辑路径可以绕到棋盘外面再绕回来。3.3 死局检测与棋盘重排死局检测全量遍历剩余棋子对只要存在任意一对可以连通就说明局面还能继续。如果遍历完都找不到就需要触发重排。def is_solvable(board: list) - bool: rows, cols len(board), len(board[0]) for y1 in range(rows): for x1 in range(cols): if board[y1][x1] 0: continue for y2 in range(rows): for x2 in range(cols): if (x1, y1) (x2, y2): continue if board[y1][x1] board[y2][x2]: if can_connect(board, x1, y1, x2, y2): return True return False时间复杂度是O(n^4)棋盘在 10×18 规模下每轮检测大约要跑多次can_connect性能勉强够用。如果棋盘再大一倍建议先按图案类型分组只比较同类型的格子对能减少很多无效计算。重排函数把剩余非空格子收集起来重新随机打乱回填def shuffle_board(board: list) - list: rows, cols len(board), len(board[0]) remaining [] for y in range(rows): for x in range(cols): if board[y][x] ! 0: remaining.append(board[y][x]) random.shuffle(remaining) idx 0 for y in range(rows): for x in range(cols): if board[y][x] ! 0: board[y][x] remaining[idx] idx 1 return board洗牌后如果依然死局我一般会做重试计数器最多尝试 10 次超过上限就保留当前局面并提示玩家手动点击“提示”。实际开发中遇到过棋盘出现剩余一张单牌的情况这种死局无法通过重排解决所以棋盘初始化时就要保证总格子数是图案种类的整数倍且每类图案数量为偶数。4. 界面交互与游戏循环鼠标响应、消除动画与倒计时的协作4.1 pygame 事件循环与坐标换算pygame 的主循环结构固定通常每个循环处理三件事捕获事件、更新逻辑、重绘界面。鼠标点击是玩家的主要输入MOUSEBUTTONDOWN事件里会收到屏幕像素坐标必须把它换算到棋盘格子的行列索引。def mouse_to_grid(mouse_x: int, mouse_y: int) - tuple: # margin_top 和 margin_left 是棋盘左上角在窗口中的像素偏移 grid_x (mouse_x - margin_left) // cell_size grid_y (mouse_y - margin_top) // cell_size return grid_x, grid_y如果窗口里有标题栏、分数栏或者棋盘周围有留白这步直接决定点击是否准确。换算后还要判断grid_x和grid_y是否在有效范围内防止玩家点击窗口空白区域时数组越界。主循环的骨架长这样running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: x, y mouse_to_grid(*pygame.mouse.get_pos()) if 0 x cols and 0 y rows: handle_click(x, y) update_timer() draw_all(screen) pygame.display.flip()handle_click维护选中状态第一次点击记录格子坐标并高亮第二次点击判断与上一格是否满足消除条件满足则标记待消除不满足则切换选中到新格子。高亮样式通常是绘制一个半透明白色矩形或加粗边框。4.2 消除动画与选中态的刷新消除动画如果直接修改棋盘数组界面会瞬间少两个图标缺少反馈感。常见做法是把动画状态与逻辑状态分开棋盘数组里的格子保留只是在渲染层增加一个“消失中”的标记动画播放结束后才把数组置为 0。animations [] # 每个元素是 {x: x, y: y, start_time: t, duration: 500} def start_remove_animation(x1, y1, x2, y2): now pygame.time.get_ticks() animations.append({x: x1, y: y1, start_time: now, duration: 300}) animations.append({x: x2, y: y2, start_time: now, duration: 300}) def update_animations(): now pygame.time.get_ticks() for anim in animations: if now - anim[start_time] anim[duration]: board[anim[y]][anim[x]] 0 animations[:] [a for a in animations if now - a[start_time] a[duration]]动画期间棋盘逻辑不更新只做缩放或淡出效果。等动画列表清空后再检查是否触发死局检测。这个设计避免了“动画进行到一半时被玩家点击干扰”的玄学问题逻辑和渲染互不干扰。4.3 积分与倒计时的联动计分规则用到连击倍率连续消除时每次加分比上一次多中断后恢复基础分。倒计时则用帧计数控制frame_count 0 FPS 60 time_limit 180 # 单位秒 def update_timer(): global frame_count frame_count 1 time_left time_limit - frame_count // FPS if time_left 0: game_over()用frame_count // FPS计算剩余秒数比直接用time.time()更稳定因为即使主循环某一帧卡顿累积帧数误差也不大。暂停时只需要停止累加frame_count恢复时继续逻辑简单清晰。分数计算我会把“连击次数”单独记录每次消除时连击加 1断连重置score 10 combo * 5这个递增规则不复杂玩家能直观感受到连击带来的正反馈后续改成“消除越多分数越高”的曲线也很容易。5. 避坑手册坐标偏移、透明图片与死局误判的排雷记录5.1 点击无反应棋盘行列换算踩坑现象窗口能正常打开棋盘也画出来了但点击任何格子都没反应偶尔还会消除到旁边的格子。原因mouse_to_grid 里忘记减去棋盘左上角偏移量。pygame 的坐标原点是窗口左上角而棋盘通常居中或者顶部有分数栏直接拿窗口坐标去除格子尺寸得到的是偏移后的错误行列。解决先确认棋盘的margin_left和margin_top具体值再套用(mouse_x - margin_left) // cell_size。调试阶段建议在每次点击时把原始像素坐标和换算后的行列坐标同时打印出来对照窗口位置检查差值是否等于预期偏移。这个坑在第一次写界面时几乎必然遇到只要用打印信息对照一次就能定位。5.2 图标白底黑边透明 PNG 加载翻车现象图标边缘出现白色方块或黑色边框把背景盖得很难看像打了补丁。原因直接pygame.image.load()加载图片后没有处理透明通道JPG 或者不带 alpha 的 PNG 图片会把不透明像素直接画上去。解决加载后统一调用image.convert_alpha()同时把素材存成带透明通道的 PNG不要用 JPG。所有图标默认尺寸保持一致比如 64×64中心留 2 像素透明边距避免相邻图标之间显得过于拥挤。5.3 死局误判导致无限洗牌现象游戏运行几分钟后突然开始疯狂洗牌刚重排完下一帧又洗了一次甚至开局阶段就在无限重排。原因is_solvable 里随机挑一对判断发现可连就返回 True把这个结果当成“整个局面可解”。随着棋盘空格增多某些路径被新空位“打通”后确实可解但另一些局部区域已经成了死区整体判断需要全量遍历。解决把 is_solvable 改成遍历所有未消除格子对只要发现任意一对可连立即返回 True全部检查完没找到才判定为 False。洗牌加一个重试上限比如 10 次超过就保留原局面并弹出“提示”避免陷入无限循环。开局生成棋盘后也走一遍相同检查确保玩家第一局不是死局。5.4 倒计时加速或暂停失灵现象明明设置 180 秒实际不到一分钟就结束了暂停后倒计时还在继续走。原因倒计时基于事件数量累加或者直接使用time.time()pygame 窗口被拖动或最小化时事件频率不稳定导致计时漂移。暂停时没有停止计时器更新逻辑混乱。解决改用帧计数法frame_count // FPS得到整秒。暂停时记录paused_frame_count恢复时重新同步。这个方法不依赖系统时间稳定性和可读性都比时间戳方案好。6. 二次开发把单机版扩展成带关卡与小道具的进阶玩法6.1 关卡系统怎么改把 config.py 里的棋盘尺寸、图案种类数、倒计时秒数改成关卡配置字典每关递进难度。难度曲线一般体现在三个方面棋盘更大、图案种类更多、时间更短。LEVELS [ {rows: 8, cols: 12, types: 8, time: 180}, {rows: 10, cols: 14, types: 10, time: 150}, {rows: 10, cols: 18, types: 12, time: 120}, ] def load_level(level: int): cfg LEVELS[level] return create_board(cfg[rows], cfg[cols], cfg[types]), cfg[time]进入下一关时用pygame.time.get_ticks()重新计时清除所有动画状态重新生成棋盘。通关条件是剩余时间为 0 且棋盘清空实际开发中我会把“剩余棋子数”作为主指标时间只是辅助限制避免玩家卡关。6.2 道具系统提示、重排与炸弹道具系统是对新手最友好的扩展点。提示道具就是扫描一对可消除的格子并高亮重排道具直接调用 shuffle_board炸弹道具则指定一个格子并消除它周围 3×3 范围内的棋子。def get_hint(board: list) - tuple: for y1 in range(len(board)): for x1 in range(len(board[0])): if board[y1][x1] 0: continue for y2 in range(len(board)): for x2 in range(len(board[0])): if board[y1][x1] board[y2][x2]: if can_connect(board, x1, y1, x2, y2): return x1, y1, x2, y2 return -1, -1, -1, -1每个道具做成一个按钮对象点击按钮进入“道具模式”再点击棋盘格子执行对应逻辑。按钮模块可以复用 pygame 的Rect碰撞检测事件循环里判断button.collidepoint(mouse_x, mouse_y)即可。道具次数建议放在 config 里统一管理每次使用后减 1剩余次数越小按钮越暗。6.3 主题皮肤与音效替换换主题最省事的方式是准备两套图片目录在 config.py 里增加THEME字段THEME classic ASSETS_DIR fassets/images/{THEME}启动时按ASSETS_DIR批量加载图片到字典键用图案编号后续绘制完全不需要关心具体图片路径。音效同理消除时播放素材文件载入时统一pygame.mixer.Sound。这套小游戏我改过不少次每次都会强制先跑一遍棋盘死局检测确认开局可解再进入界面联调。从那以后我写任何带随机生成功能的小游戏都会先验证边界情况和终止条件确保递归和循环不会失控。毕竟小游戏代码量不大但算法边界一旦出错玩家体验会瞬间崩塌。这份单机版连核心逻辑带界面都拆清楚了改起来方向明确希望帮到你。本文还有配套的精品资源点击获取