Python+Pygame实战:连连看游戏核心算法与代码实现
2026/9/9 11:34:55 网站建设 项目流程

简介:QQ连连看单机版(含源码与说明)是一份面向游戏开发初学者和编程爱好者的完整学习资源,解决了想在离线环境体验连连看并研究其实现原理的需求。压缩包共88个文件,包含TGA/BMP图像素材、WAV/MID音频文件、多个DLL动态库、EXE可执行程序及说明文档和地图文件,覆盖游戏画面、音效、动画特效、菜单交互、关卡地图等模块,整体大小约4.44MB。已有323人学习下载。通过这份资源,读者可以深入分析C++风格的小游戏源代码,学习GUI界面设计、动画特效实现、音效播放、多语言支持等关键技术;同时,丰富的素材与分模块DLL能帮助理解游戏框架搭建和资源整合方法。借助说明文档可迅速上手,还能查看地图文件了解关卡设计思路,适合作为游戏开发入门参考。 “QQ连连看单机(含代码和说明)”这个项目,是我压箱底的一个练手作品。最近整理硬盘时翻出来,发现代码注释还算完整,玩法也基本复刻了经典连连看的手感,干脆重新梳理一下发出来。游戏本身并不复杂:一个矩形棋盘,若干对相同图标,玩家点击两个相同的图标,如果它们之间能用不超过两个拐点的水平或垂直折线连通,并且折线没有被其他图标挡住,就可以消除;全部消完就胜利。整个项目只依赖Python和Pygame,非常适合刚学完Python基础、想拿一个小游戏练手的人。

我当初写这个项目,主要就是想搞清楚“连连看到底是怎么判断能不能连的”,后来才发现核心就两个问题:一个是地图数据结构怎么设计,另一个是路径搜索怎么写。这两个点搞明白了,界面反而是体力活。这篇文章我会把项目拆开讲,从数据结构、连通判定、界面交互到无解处理,最后附上踩坑记录,你可以直接照着实现,也可以拿它做二次开发。完整代码是用Python写的,Pygame负责窗口、绘制和事件,算法部分不依赖任何第三方组件,单独拎出来也能测试。

1. 项目落地前的思路拆解

1.1 这个项目到底在解决什么问题

连连看这类消除游戏,看起来是个小玩意,真正写起来会卡住人的地方就一个:路径连通判断。很多人拿到的第一版思路是直接在地图上用BFS搜路,但BFS找的是最短路径,而连连看要求的是折线拐点不超过两个,且路径可以绕到棋盘外圈去走。如果一上来就用通用寻路算法,很快会碰到“明明能连却判断不能连”的边界问题。

所以我在设计时,把地图四周扩出来一圈虚拟空地,让路径可以沿棋盘的边缘绕行。这一圈空地不显示任何图标,只在算法里存在。这样做的好处是:直连、一个拐点、两个拐点的情况,全部可以用同一种搜索逻辑处理,不需要写三套判断。从实现角度看,这比用数学方式枚举拐点更省心,也更容易扩展成不规则地图规则。

1.2 为什么技术栈选Python + Pygame

我选Python不是因为它性能强,而是因为做这种东西,开发效率比性能重要得多。Pygame的上手成本非常低,窗口初始化不到十行代码,绘制图标用blit贴图就行,鼠标事件也是现成的。游戏本身跑在2D平面上,方块数量撑死几十个,每一帧全量重绘也毫无压力。

当然,如果之后想塞进网页里,也可以把核心算法原封不动迁移到JavaScript或C#,因为算法只依赖二维数组和队列,跟界面完全解耦。这也是我强调“算法单独拎出来”的原因,后面给示例代码时,你看到的会是一个纯函数,不掺Pygame。这一步的决策直接决定后面调试是否顺手,所以建议你把逻辑层和显示层分开写。

1.3 文件结构与功能拆解

整个项目我拆成了三个文件,便于阅读和调试:

  • main.py:程序入口,负责初始化Pygame、创建窗口、运行主循环。
  • board.py:棋盘数据、生成地图、检测连通、无解重排等纯逻辑代码。
  • resources/:存放图标素材的目录,也可以改成从assets.py里读字符型图标。

如果你只是想看算法,直接打开board.py即可。下面各节的代码片段都是从这三个文件里抽出来的核心部分,我会在注释里说明功能。这种拆法也方便你后面换UI,比如想做Web版,只需要把board.py保留,把main.py换成前端逻辑。

2. 核心算法与数据结构设计

2.1 地图建模:二维数组与外圈空地

我的棋盘默认是10列×8行,但真正存数据的二维数组是12列×10行,四周各多出一行/列,值全部为0。内部有图案的位置填图标编号,例如1、2、3……,相同编号表示同一种图标。

为什么必须留外圈?因为连连看的连通路径允许“绕出去”。举个最典型的例子:两个相同图标分别贴在棋盘最左边一列和最上边一行,如果不扩圈,从左上角绕行就越界了;扩一圈之后,路径就可以安全地从地图外围的0值区域通过。这个设计是后面所有判断的基础,千万别省。

生成地图时,先准备好所有成对的图标编号。比如要放40个图标,就先造一个列表,让每个编号都出现偶数次,然后用random.shuffle打乱,再逐个填到内部区域。这样可以保证每种图标都是偶数个,避免出现消不完的孤子。如果只是简单地把随机数填进去,很容易出现某一种图标只剩一个的无解局面。

2.2 连通判定:统一用BFS限制拐弯次数

连连看的路径判定,不需要求最短路径,只关心能不能用不超过两次拐弯到达。我直接用BFS扫描,队列里记录“当前位置、当前方向、已经拐了几次弯”。

初始时,从起点的四个相邻方向各走一步,方向就是对应方向,拐弯次数为0。之后每扩展到下一个格子,比较当前方向和新方向是否一致;不一致就代表拐了一次,累计次数加1。只要累计次数超过2就剪掉,不继续扩展。同时用一个三维数组记录每个格子以某个方向进入时的最小拐弯次数,避免重复搜索死循环。这样直连、一个拐点、两个拐点共用同一套逻辑,不需要分支判断。

我写了一个核心函数,先贴出来你会更直观:

from collections import deque def can_connect(grid, a, b): """判断a、b两点是否能在最多两次拐弯内连通。grid中0表示空地,非0表示图标。""" if a == b: return False rows, cols = len(grid), len(grid[0]) dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)] INF = 10 ** 9 # turns[x][y][d] 表示从方向d进入(x,y)时,已经使用的最小拐弯次数 turns = [[[INF] * 4 for _ in range(cols)] for __ in range(rows)] q = deque() for d, (dx, dy) in enumerate(dirs): nx, ny = a[0] + dx, a[1] + dy if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == 0: turns[nx][ny][d] = 0 q.append((nx, ny, d, 0)) while q: x, y, d, t = q.popleft() if (x, y) == b: return True if t >= 2: continue for nd, (dx, dy) in enumerate(dirs): nx, ny = x + dx, y + dy if not (0 <= nx < rows and 0 <= ny < cols): continue # 目标点允许作为终点,但不允许作为穿过的中间点 if grid[nx][ny] != 0 and (nx, ny) != b: continue nt = t + (0 if nd == d else 1) if nt <= 2 and nt < turns[nx][ny][nd]: turns[nx][ny][nd] = nt q.append((nx, ny, nd, nt)) if (nx, ny) == b: return True return False

这个函数有几点需要注意:起点和终点本身不能相同;BFS每次从起点的相邻空地出发,所以最终能绕过起点自己;如果某个邻居正好是终点,即使终点位置有图标,也被允许进入并立即返回。实际调用前,外部要先确认两个格子的值相等且都不为0,否则没必要走这个搜索。

2.3 消除流程:两次点击怎么判断

玩家操作流程很简单:

  1. 第一次点击,记录选中的格子,把图标高亮。
  2. 第二次点击,先判断两个格子是否相同,再判断值是否相等。
  3. 如果都满足且can_connect返回True,就把地图上两个位置改成0,同时触发消除状态。
  4. 如果不满足或不能连通,就把选中状态清空,让玩家重新点。

第二步里有个容易忽略的细节:第二次点击如果点的是同一个格子,不应该触发消除,直接忽略就行。另外,两个格子值都为0时也不应该进入判定,因为空位置没有意义。判断顺序建议先判断坐标相同,再判断值非0,再判断值相等,最后才做路径搜索,这样能省下很多无效计算。别小看这个顺序,地图快消完时大量格子都是0,如果先做路径搜索会很浪费。

3. 实操:搭建游戏主流程

3.1 初始化Pygame窗口和棋盘

主程序的第一步是创建窗口、加载图标、生成地图。这里我以字符图标代替图片资源,方便没有美术素材的人直接跑。比如用星号、圆形、方形这些符号显示到格子里,代码里用一个字典做索引。

import pygame import random pygame.init() COLS, ROWS = 10, 8 CELL = 50 MARGIN = 30 WIDTH = MARGIN * 2 + (COLS + 2) * CELL HEIGHT = MARGIN * 2 + (ROWS + 2) * CELL screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("QQ连连看单机版") # 图标编号和显示字符的映射 ICON_CHARS = {1: "★", 2: "●", 3: "■", 4: "◆", 5: "▲", 6: "♥", 7: "♦", 8: "♣", 9: "♠", 10: "☆"} def create_board(cols, rows, kinds=10): # 外面留一圈0 grid = [[0] * (cols + 2) for _ in range(rows + 2)] items = [] cell_count = cols * rows each = cell_count // kinds # 每种图标个数,需要是偶数 for kind in range(1, kinds + 1): items += [kind] * each random.shuffle(items) idx = 0 for r in range(1, rows + 1): for c in range(1, cols + 1): grid[r][c] = items[idx] idx += 1 return grid

这段代码里each = cell_count // kinds,存在一个问题:如果格子数不能被种类数整除,最后items数量不等于cell_count,填充时会越界。所以要么选择能够整除的棋盘尺寸,要么在造列表后统一补成偶数对。我在自己代码里用的方案是:先算出需要的总格子数,然后按照“每种图标出现偶数次”的原则生成列表,再random.shuffle,这样无论如何都不会留下落单的图标。

3.2 绘制棋盘和选中高亮

绘制时把grid里非0的格子按坐标画出来,同时要把行列偏移算对。因为棋盘外层还有空地,实际绘制区域是从第1行第1列开始的,不能直接把行列当像素坐标用。

def draw_board(grid, selected=None): screen.fill((30, 30, 30)) for r in range(1, len(grid) - 1): for c in range(1, len(grid[0]) - 1): val = grid[r][c] if val == 0: continue rect = pygame.Rect(MARGIN + c * CELL, MARGIN + r * CELL, CELL, CELL) pygame.draw.rect(screen, (60, 60, 60), rect, 2) if selected and (r, c) == selected: pygame.draw.rect(screen, (255, 215, 0), rect, 4) font = pygame.font.SysFont("simsun", 30) text = font.render(ICON_CHARS[val], True, (255, 255, 255)) screen.blit(text, text.get_rect(center=rect.center)) pygame.display.flip()

字体这里用了常见的中文字体名,不同系统可能不一样。如果直接运行报错找不到字体,可以把SysFont的参数改成系统里存在的字体,或者直接用Pygame默认字体。图标本身是符号,不是图片,所以不需要处理资源路径,这对第一次接触Pygame的人很友好。

3.3 主循环和点击事件

主循环的核心就是等Pygame事件,拿到鼠标坐标后换算成棋盘行列。这里特别容易遇到坐标算错的问题:必须减去MARGIN再除以CELL,而且因为棋盘外面有一圈空位,行列还要加1。

def get_cell_from_pos(pos): x, y = pos c = (x - MARGIN) // CELL r = (y - MARGIN) // CELL if 1 <= r <= ROWS and 1 <= c <= COLS: return r, c return None selected = None grid = create_board(COLS, ROWS) running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN: cell = get_cell_from_pos(event.pos) if cell is None: continue if selected is None: selected = cell else: r1, c1 = selected r2, c2 = cell v1, v2 = grid[r1][c1], grid[r2][c2] if selected == cell: selected = None elif v1 != 0 and v1 == v2 and can_connect(grid, selected, cell): grid[r1][c1] = 0 grid[r2][c2] = 0 selected = None draw_board(grid, selected) pygame.quit()

这个循环已经能玩起来了,但还缺has_solution做无解判断和重排。这也是连连看必须处理的问题,不然随机地图很容易卡死。在你做出来的第一版里,可以先不加重排,但一定要保留后路,不然玩家中途一定会遇到无解局面。

3.4 无解检测与一键重排

随机生成的地图不一定每一步都有解,尤其是图标种类多、布局散的时候。我的做法是专门写一个has_solution(grid)函数,遍历地图里所有非0的格子对,两两调用can_connect,只要找到一对能连通就返回True,全部找不到才返回False。

def has_solution(grid): cells = [] for r in range(len(grid)): for c in range(len(grid[0])): if grid[r][c] != 0: cells.append((r, c)) for i in range(len(cells)): for j in range(i + 1, len(cells)): a, b = cells[i], cells[j] if grid[a[0]][a[1]] == grid[b[0]][b[1]]: if can_connect(grid, a, b): return True return False

这个双重循环最多也就几十个格子,量级很小,不用担心性能。如果没有解,就把所有非0图标重新收集、打乱、再填回去,然后再次检查;如果连续几次依然无解,说明图标种类和棋盘比例有问题,需要调整。实际项目中,我会在每次消除后都调用一次has_solution,无解就自动重排,并且给一个提示,不然玩家会以为程序卡死了。

4. 开发中踩过的坑与排查技巧

4.1 路径判断里最隐蔽的坑:终点被当成障碍

我第一次写BFS时,把“格子值为0才能走”写成了硬性条件,结果发现所有消除都失败。原因是终点位置有图标,值不为0,BFS在扩展时永远不把终点加入队列,自然永远返回False。后来我在进入邻居判断时加了“目标点允许作为终点”的例外,并把“到达终点立即返回”放在扩展逻辑里,才算解决。

这个坑其实很好理解:路径搜索时,终点虽然占用了一个图标格子,但在“是否能连通”的判断里,终点是允许被落子的,只是不允许被穿过。所以代码里要单独放行,而其他非0格子依旧不能走。如果你自己写多拐点判断,也要注意这个逻辑,否则很容易出现“两个相同图标能连却消不掉”的诡异现象。

4.2 外圈空地怎么处理:索引越界和地图尺寸

如果代码里严格限制0 <= nx < rows,实际上把外圈也算进去了,所以棋盘grid的尺寸必须是(rows + 2) × (cols + 2),而不是rows × cols。刚开始我图省事,只申请了内部大小,坐标换算时非常别扭,要么频繁判断边界,要么漏掉绕行路线。老老实实把外圈留出来之后,算法简单了很多。

这里给一个自查方法:如果你发现两个贴着边缘的图标连不上,十有八九是外圈没有留出来或者绘制时把外圈也算进去了,导致坐标错位。你可以打印一下grid的尺寸和坐标,看看边缘格子的行列是否落在预期范围内。

4.3 字体和图标资源问题

用字符画图标省事,但跨平台时字体渲染可能有差异。Windows上宋体、黑体一般都有,macOS上就未必。建议直接用Pygame自带字体,或者把你想要的图标用图片保存到resources目录。我后来改成用数字加颜色区分图标,测试时最稳定,因为不依赖字体。

如果你坚持用字符图标,可以在初始化字体时做一个回退:先尝试中文字体,失败就用默认字体,至少保证程序不崩。另外,使用非ASCII字符时,文件开头最好加# -*- coding: utf-8 -*-,避免在某些环境下出现编码报错,虽然Python 3默认已经是UTF-8,但养成习惯没有坏处。

4.4 常见问题速查表

现象可能原因解决办法
点击两个相同图标不能消除终点被当成障碍,路径搜索不到检查can_connect里是否对终点格子放行
边缘图标永远连不上地图外圈没有留空地grid扩成(rows+2)×(cols+2)
随机地图很快无解图标种类/数量配比不合适has_solution检测,无解时重排
点击坐标错位换算时没加外圈偏移先减MARGIN再除以CELL,行列再加1
中文或特殊符号显示为方框系统没有对应字体换成图片图标或系统默认字体
窗口关闭后进程不退出没调用pygame.quit()主循环结束后释放Pygame资源

这张表是我实际开发时反复踩过的问题,对照着检查,大多数问题都能在十分钟内定位。尤其是终点放行的问题,第一版几乎必踩,写的时候就要有意识避开。

5. 后续还能怎么扩展

5.1 增加计分、计时和关卡

现在版本只有消完即胜,玩起来没有压力。你可以加一个全局计时器,每消除一对按剩余时间和连击数加分,全部消除后结算总分。连连看的乐趣在于限时和连击,这两点直接决定游戏手感。

实现计时很简单,主循环里用pygame.time.get_ticks()记录开局时刻,剩余时间等于目标时长减去已用时间;每消除一对就加分,如果两次成功消除之间的间隔小于某个阈值,连击数加一,分倍率提高。计分面板可以直接绘制在窗口顶部,不用额外UI库,用font.render画一行文字就行。

5.2 最高分保存到本地文件

这个项目的评分数据很适合做本地持久化,用Python内置的文件读写就够了。比如第一次启动时读score.txt,没有文件就创建;游戏结束时如果当前分数高于历史最高分,就把新分数写回文件。这样每次打开游戏还能看到之前的纪录,玩起来更有目标。

写文件时注意编码问题,建议用utf-8,否则在Windows上中文注释可能乱码。如果你想更规范一点,可以直接用json模块保存一个字典,里面存最高分、通关时间、游戏局数等。这样后续想扩展统计功能,也不用再改数据结构。

5.3 加动画和音效

消除的瞬间如果不做任何反馈,会显得很生硬。最简单的方式是淡出效果:把被消除的图标从完全不透明逐步变成透明,或者用缩放动画让图标逐渐变小消失。Pygame实现这类动画并不难,把动画状态放进一个列表,每帧更新alpha或大小,绘制时先画棋盘再画动画对象。

音效可以用Pygame的mixer播放短wav文件,比如点击、消除、胜利三个音效,素材可以在一些免费音效站找,注意版权。音效虽然只是锦上添花,但加上游戏完成度会高很多。我第一次加音效时没有初始化mixer,结果播放没有声音,记得先调用pygame.mixer.init()

5.4 换皮肤、自定义头像和网格大小

最后是一个很推荐的方向:把数据结构里的图标编号和界面资源解耦,做一套皮肤系统。比如同样是编号1,在默认皮肤里是星星,在卡通皮肤里是猫咪,在数字皮肤里就是数字1。这样用户可以通过放不同图片进resources目录来换皮肤。

如果你想让游戏支持更多玩法,把COLSROWSCELL改成可配置参数即可,核心算法完全不用动。我试过把棋盘调到14×10,帧率依然很稳,瓶颈完全不在地图大小,而在图标素材的绘制方式上。一个小建议:改成图片图标后,最好在加载时统一缩放成CELL × CELL,避免因为素材尺寸不一致导致选中框错位。

最后说一句个人体会:写连连看这种小项目,最大的收获不是学会Pygame,而是搞懂了“规则”如何转化为“算法”。我一开始画了无数张草稿,想用数学方式判断拐点,最后发现BFS加次数限制反而最简单。完整代码虽然只有几百行,但每部分都值得细读。如果你也照着做,建议先只跑通can_connect,再用假数据测试各种路径,最后再接界面,这样定位问题会快很多。

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

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

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

立即咨询