简介:一份基于Python+Pygame的桌面端消消乐游戏项目,定位为期末大作业/课程设计,面向计算机专业学生、初学者以及需要项目实战练习的开发者,可解决选题难、源码不完整、文档缺失等常见问题。压缩包共120个文件,包含4个Python主程序、70个PNG图片素材、20个MP3与20个OGG音频,以及YAML配置、README说明等,包体约9.57MB,目录区隔清楚,便于按资源类型查找。目前已有140人学习下载。项目不仅能运行,还提供了完整文档说明,覆盖Pygame游戏循环、事件响应、消除判定、音效播放等关键环节,适合用来理解游戏开发的基本架构,也可作为课程设计答辩的演示素材。读者可在此基础上扩展计分逻辑、特效动画或关卡编辑器,通过实际修改与调试提升独立开发能力。
1. 基于python+pygame的桌面端消消乐游戏项目复盘
很多人在期末拿到这个题目时,第一反应是去找现成的源码直接交差。但真正用 pygame 写完一个能跑、能玩、能演示的消消乐之后,你会发现这个项目最值钱的不是「消消乐」三个字,而是从二维数组建模、到事件驱动渲染、再到交互动画这一整条 Python 游戏开发链路。作为一个在 git 仓库里见过几十个消消乐作业的人,我可以告诉你一个反直觉的事实:90% 的同类项目不是死于算法难度,而是死于「数据结构混乱 + 动画阻塞主循环 + 打包后资源路径丢失」这三个坑。对于打算拿它应付期末答辩的读者,或者是想拿 Python 做第一个图形界面项目的初学者,这篇博文会从棋盘建模讲起,一直讲到 PyInstaller 打包成单文件 exe,覆盖这个题目最常见的需求和选型思路。
2. 消消乐游戏核心逻辑设计与数据结构选型
2.1 用二维数组表示棋盘,而不是面向对象硬套
消消乐的本质是「二维网格上的状态机」。棋盘上一共有多少种颜色、多少行多少列,用一张二维表就能表达清楚。绝大多数期末项目的棋盘规模是 8×8 或 9×9,颜色类型 5 到 6 种。这个规模下最合适的数据结构就是 Python 内置的 list of list,也就是嵌套列表。
import random ROWS, COLS = 8, 8 # 8x8 棋盘 NUM_TYPES = 5 # 5 种方块类型,编号 0-4 def create_board(rows=ROWS, cols=COLS, num_types=NUM_TYPES): board = [[random.randint(0, num_types - 1) for _ in range(cols)] for _ in range(rows)] # 初始化时避免直接出现可消除组合 for r in range(rows): for c in range(cols): while has_match_at(board, r, c): board[r][c] = random.randint(0, num_types - 1) return board代码逻辑并不复杂:第一行用列表推导式生成随机方块的棋盘;随后遍历每一个格子,检查当前位置在第一张棋盘上是否已经构成三连,如果构成了就重新随机一个值。这里的has_match_at是核心检测函数,在后面马上实现。注意不要用 numpy 的二维数组,尽管它索引方便,但会引入额外依赖,在课程设计答辩时反而会被老师追问「为什么用 numpy 而不用标准库」,徒增解释成本。
2.2 消除检测:暴力的横向纵向三连判断就够了
消除检测是所有匹配消除游戏的地基。不少初学者一上来就想着用 Flood Fill 或者 BFS 做连通域检测,但实际上消消乐的规则通常只是「横向或纵向连续 3 个及以上相同方块」,并不要求 L 形或 T 形等复杂组合。因此最简单的实现方式是:逐行检查横向连续段,逐列检查纵向连续段,记录所有满足条件的坐标,放入一个待消除集合。
def find_matches(board): matched = set() rows, cols = len(board), len(board[0]) # 横向扫描 for r in range(rows): c = 0 while c < cols - 2: if board[r][c] == board[r][c + 1] == board[r][c + 2]: start = c while c < cols and board[r][c] == board[r][start]: matched.add((r, c)) c += 1 else: c += 1 # 纵向扫描,逻辑与横向完全对称 for c in range(cols): r = 0 while r < rows - 2: if board[r][c] == board[r + 1][c] == board[r + 2][c]: start = r while r < rows and board[r][c] == board[r][start]: matched.add((r, c)) r += 1 else: r += 1 return matched这段代码的核心是用 while 循环找出连续段而不是用 for 加 if,因为 for 循环在跳过重复项时索引不好控制。匹配算法的时间复杂度是 O(R × C),对于 9×9 棋盘是 81 次操作,视觉上不会有任何卡顿。获得matched集合之后,消除只需要把对应坐标的方块标记为 -1 或者直接删除,进入下一步「掉落补位」。
消消乐里还有一个关键约束是「死局检测」:当整个棋盘没有任何可交换后形成的匹配时,要洗牌重开。常见做法是把棋盘上所有相邻交换全部模拟一遍,每模拟一个位置就调用一次find_matches,如果全部失败,说明当前是死局。这个暴力检测的复杂度是 O(R × C × 4),对于作业规模完全够用,不需要引入任何剪枝优化。
def has_legal_move(board): rows, cols = len(board), len(board[0]) for r in range(rows): for c in range(cols): # 向右交换 if c + 1 < cols: board[r][c], board[r][c + 1] = board[r][c + 1], board[r][c] if find_matches(board): board[r][c], board[r][c + 1] = board[r][c + 1], board[r][c] return True board[r][c], board[r][c + 1] = board[r][c + 1], board[r][c] # 向下交换 if r + 1 < rows: board[r][c], board[r + 1][c] = board[r + 1][c], board[r][c] if find_matches(board): board[r][c], board[r + 1][c] = board[r + 1][c], board[r][c] return True board[r][c], board[r + 1][c] = board[r + 1][c], board[r][c] return False注意交换后检测完必须立即换回来,否则棋盘状态就被污染了。这是期末作业里最常见的数据完整性错误之一。
2.3 掉落补位:重力模型与空位压缩
消除后的方块从上方掉下来,是消消乐视觉体验的核心,也是一个容易写崩的地方。逻辑上要分两步做:第一步,把每一列的方块按「从下往上遍历、非空方块下沉」的方式压缩;第二步,在列顶生成新的随机方块。下面给出一个在二维列表上原地操作的例子。
def apply_gravity(board): rows, cols = len(board), len(board[0]) for c in range(cols): # 从下往上收集非空值 column_vals = [] for r in range(rows - 1, -1, -1): if board[r][c] != -1: column_vals.append(board[r][c]) # 从下往上重新填回 for r in range(rows - 1, -1, -1): if column_vals: board[r][c] = column_vals.pop(0) else: board[r][c] = random.randint(0, NUM_TYPES - 1) return board-1在这里代表空气或空位,是前面消除时写入的标记。上述代码的处理逻辑是逐列重建,从上往下读,从下往上写,空位由新随机块补齐。如果方块需要按「行」掉落,而不是按列,即横向掉落模式,做法类似,循环方向换一下即可。它解决的问题是:消除后留下洞,洞上方的方块需要下落到底部,这是所有三消游戏的公共底盘。如果处理不好,后续动画都会基于错误的位置,导致闪烁和穿模。
3. pygame 桌面端窗口、事件循环与鼠标交互
3.1 初始化窗口与主循环骨架
pygame 的窗口管理很直白:pygame.display.set_mode创建窗口,pygame.event.get()捕获输入,pygame.display.flip()刷新画面。一个健壮的桌面端游戏主循环必须包含固定帧率控制和退出事件处理。
import pygame CELL_SIZE = 64 GRID_X_OFFSET = 40 GRID_Y_OFFSET = 40 WINDOW_WIDTH = COLS * CELL_SIZE + GRID_X_OFFSET * 2 WINDOW_HEIGHT = ROWS * CELL_SIZE + GRID_Y_OFFSET * 2 def run_game(): pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption("消消乐 Desktop Match-3") clock = pygame.time.Clock() board = create_board() running = True while running: # 事件处理:关闭窗口、鼠标点击 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN: handle_click(event.pos, board) # 绘制棋盘与方块 draw_board(screen, board) pygame.display.flip() clock.tick(60) pygame.quit()需要特别注意clock.tick(60)这一行,它把主循环的帧率限制在 60 FPS,同时天然充当了 time.sleep 的角色。很多教程会误导新手用pygame.time.delay做动画延迟,这会在 UI 线程里阻塞事件处理,造成窗口拖拽时无响应。正确做法是永远用主循环的 tick 控制节奏。窗口尺寸的计算公式是棋盘格数量 × 格子边长 + 两倍的偏移量,这个公式是桌面端布局的基础,改成其他数值会直接导致绘制越界或者窗口留白过多。
3.2 从像素坐标到棋盘坐标的映射
鼠标点击的event.pos给出的是像素坐标,而棋盘逻辑操作的是行列坐标,所以需要一个转换函数。这个函数在整个游戏里被频繁调用,必须写得短且严谨。
def pos_to_cell(mouse_pos): mx, my = mouse_pos col = (mx - GRID_X_OFFSET) // CELL_SIZE row = (my - GRID_Y_OFFSET) // CELL_SIZE if 0 <= row < ROWS and 0 <= col < COLS: return row, col return None代码做了两件事:先通过差值除以格子边长得到棋盘坐标;再用边界检查确认点击没有落在棋盘外侧。这里有个隐蔽的坑:如果不加上后三行的边界判断,用户点击窗口边缘时计算出的 row 或 col 可能是负数或超出范围,后续访问board[row][col]会直接触发 IndexError。在期末演示时,老师多半会故意点边缘来测试健壮性,这个防护是必须的。
3.3 选中、交换与非法操作的视觉反馈
一个完整的交互序列是:第一次点击选中方块,第二次点击相邻方块则交换,交换后检测匹配;如果匹配,则进入消除;如果不匹配,则交换回去。为了实现选中状态的可视化,需要一个selected变量保存当前被选中的格子坐标。
selected = None def handle_click(mouse_pos, board): global selected cell = pos_to_cell(mouse_pos) if cell is None: return if selected is None: selected = cell else: row1, col1 = selected row2, col2 = cell # 检查是否相邻,不含斜对角 if abs(row1 - row2) + abs(col1 - col2) == 1: board[row1][col1], board[row2][col2] = board[row2][col2], board[row1][col1] if find_matches(board): selected = None else: # 交换后无匹配,立即换回 board[row1][col1], board[row2][col2] = board[row2][col2], board[row1][col1] selected = None else: # 点击非相邻格子,重新选中新格子 selected = cell相邻判断用了曼哈顿距离公式abs(r1-r2) + abs(c1-c2) == 1,它天然排除了斜对角的情况。交换后的处理逻辑必须遵循「先模拟后提交」原则:先在棋盘上交换,调用find_matches做检测,有匹配就保留交换结果,没有就换回来。这里如果把检测和交换的顺序搞反,会出现用户明明交换了却毫无反应的 bug,排查起来非常费时。
绘制选中状态时,最简单的做法是在目标格子周围画一个高亮边框,用 pygame 的pygame.draw.rect即可,不需要额外加载资源。
4. 动画实现、像素级渲染与性能优化
4.1 消除动画与掉落动画不要用 while 循环
很多初学者写动画时会陷入一个误区:在检测到匹配后,立刻用一个 while 循环把所有格子一步步移动到目标位置,每步调用pygame.display.flip()。这会导致整个程序在动画播放期间无法响应任何鼠标点击,甚至窗口关闭按钮都点不动。正确思路是把动画状态机放入主循环,用「帧计数或时间差」来驱动位置插值。
animating = False animation_progress = 0.0 def update_animation(dt): global animation_progress, animating if animating: animation_progress += dt * 8 # 速度控制,8 秒内完成整个动画(可调) if animation_progress >= 1.0: animation_progress = 0.0 animating = False在run_game的主循环里,把dt设置为clock.tick(60) / 1000.0,也就是上一帧到这一帧的真实秒数。这样动画速度在不同性能的机器上表现一致,不会出现高端机上快如闪电、低端机上慢如卡死的情况。动画期间的事件处理仍然执行,但可以选择忽略鼠标点击请求,也可以把点击事件排队等待动画结束。
对于消除特效,更轻量的做法是「闪烁 + 缩放消失」:方块在被消除前先闪白 2 帧,然后缩小到 0。pygame 的pygame.transform.smoothscale可以完成缩放,但要注意它每次都创建新 surface,频繁调用会产生大量垃圾对象。优化手段是预先创建好各尺寸的图像缓存,用字典保存:
scaled_cache = {} def get_scaled_image(img, size): if size not in scaled_cache: scaled_cache[size] = pygame.transform.smoothscale(img, size) return scaled_cache[size]4.2 绘制函数中的性能细节
draw_board是每帧都执行的函数,它内部的效率直接决定游戏的流畅度。最容易踩的坑是在 for 循环里反复加载图片文件,这会让磁盘 I/O 成为瓶颈,帧率直接掉到个位数。正确做法是把所有方块图片在初始化阶段加载到内存中,绘制时只做 blit 操作。
def load_images(): images = [] for i in range(NUM_TYPES): img = pygame.Surface((CELL_SIZE - 4, CELL_SIZE - 4)) # 这里可以用 pygames 绘制不同颜色方块,也可以用真实图片替代 img.fill((40, 80 + i * 30, 160)) images.append(img) return images def draw_board(screen, board, images): screen.fill((30, 30, 30)) for r in range(ROWS): for c in range(COLS): if board[r][c] >= 0: x = GRID_X_OFFSET + c * CELL_SIZE + 2 y = GRID_Y_OFFSET + r * CELL_SIZE + 2 screen.blit(images[board[r][c]], (x, y))加载单据的方案里,注释提到也可以用真实图片替代。如果你使用自己的 png 图片,需要确保图片原始尺寸接近格子大小,否则放大缩小都会产生性能问题。这里的绘制函数内部没有做任何条件判断分支,每帧固定遍历 64 个格子,绘制顺序与棋盘索引一一对应,稳定性最高。
pygame.Surface.fill和screen.blit的比例是 1:1,在低配机器上如果出现卡顿,优先检查是否在循环中创建了新 Surface。pygame 的文档里明确提到 Surface 的创建是重量级操作,应该被移到初始化阶段。
4.3 提升画面质感的几个渲染技巧
如果不满足于纯色方块,可以在格子交接处绘制浅色分隔线,增强棋盘的结构感。另一个常用技巧是给选中的方块增加一个呼吸光晕效果,即边框的亮度随时间变化。这个效果可以通过math.sin(pygame.time.get_ticks() * 0.005)生成一个 -1 到 1 之间的振荡值,再映射到 RGB 上。
import math def draw_selected_square(screen, selected): if selected is None: return r, c = selected x = GRID_X_OFFSET + c * CELL_SIZE y = GRID_Y_OFFSET + r * CELL_SIZE glow = int(128 + 127 * math.sin(pygame.time.get_ticks() * 0.005)) pygame.draw.rect(screen, (glow, glow, 0), (x, y, CELL_SIZE, CELL_SIZE), 3)光晕取的是正弦函数的绝对值或原值,颜色通道会随时间在 1 到 255 之间循环,视觉上像是呼吸。这种做法不需要任何额外素材,代码量也极小,但对于答辩演示的观感提升明显,属于性价比极高的渲染增强手段。
音频方面,pygame 的pygame.mixer.Sound适合播放消除音效,但加载音频文件同样要注意只初始化一次。消除时调用sound.play()不会阻塞主循环,因为它是在后台混音线程播放的。如果不需要音频,可以在初始化时跳过pygame.mixer.init(),避免某些没有音频设备的机器上报错。
5. 打包发布与课程设计文档写作要点
5.1 用 PyInstaller 打包成免 Python 环境的桌面端 exe
期末大作业通常需要现场演示,而演示机器上未必安装了 Python 和 pygame 环境。用 PyInstaller 把项目打包成独立的 Windows 可执行文件是一个成熟的方案。首先确保安装了 PyInstaller:
pip install pyinstaller如果pip install pygame在安装时卡住,通常是因为默认的 PyPI 源在国外。这时候把 pip 源换到清华镜像或者阿里云镜像是最快的解决方案,代价只是修改一个配置项,本质上是为 Python 环境管理省时间。
打包命令如下:
pyinstaller --onefile --windowed --name match3 main.py--onefile表示生成单个 exe 文件,--windowed表示不显示命令行黑窗口,--name指定生成文件名。如果你在代码里用了外部图片或音频资源,必须用--add-data参数把它们打包进去,否则 exe 会报找不到文件:
pyinstaller --onefile --windowed --add-data "assets;assets" --name match3 main.py这里的assets;assets是 Windows 下的源路径与目标路径的对应关系,中间用分号分隔。注意在main.py代码里,资源路径最好用sys._MEIPASS适配,这是 PyInstaller 运行时解压临时目录的环境变量。不处理这个问题的话,打包后的 exe 在双击运行时可能会因为相对路径变化而加载不到资源,这是最常见的打包失败原因。
5.2 课程设计文档的结构与关键图表
期末大作业的评分标准通常包括需求分析、系统设计、核心算法说明、测试和总结。文档不需要面面俱到,但要突出你做了哪些决策。例如,在「核心算法」一节里,重点解释find_matches的时间复杂度是 O(R×C),与棋盘规模是线性关系,说明消消乐在高分辨率棋盘上的表现趋于常量开销。画一个简单的流程图比写一百字更能说明问题,但考虑到文档里的流程图通常是用文字描述的,建议描述为「消除检测分为横向连续段扫描与纵向连续段扫描两个阶段」。
在「测试分析」部分,列出几组实际运行的边界场景,比如「所有方块均相同」「棋盘已无可用交换」「用户连续快速点击多个格子」等,并写出预期结果与实际结果是否一致。这些场景不需要自动化测试框架,手动记录即可。
如果在文档中明确划分模块,例如board.py负责棋盘逻辑、gui.py负责渲染与交互、main.py负责装配与启动,那么答辩时老师一眼就能看出你对项目结构的把握。代码文件数量不用多,三到四个模块拆分就足够支撑起整个项目的清晰度了。
5.3 答辩演示时容易加分的操控细节
一个可交互的桌面应用在演示时,操作节奏与视觉流畅度会影响印象分。建议在代码里保留一个隐藏的调试快捷键,例如按下 D 键时在窗口标题栏显示当前帧率 FPS 和棋盘匹配数。这样老师提问「性能怎么样」时,你当场就能展示一个可量化的指标,而不是回答「我觉得挺流畅的」。
if event.type == pygame.KEYDOWN and event.key == pygame.K_d: fps = clock.get_fps() pygame.display.set_caption(f"消消乐 - FPS: {fps:.1f}")另外,如果你的消消乐支持「提示」功能,即高亮显示任一可用的合法交换,这是很加分的设计。实现方法是在has_legal_move函数的暴力遍历过程中,把第一个满足交换条件的位置缓存下来,然后绘制时把这个位置描边即可。这个功能实现成本极低,但演示效果极佳,尤其是当棋盘进入死局边缘时,它能直观展示你的程序「有智商」。
最后,打包后的 exe 文件体积在 30MB 到 80MB 之间是正常的,因为 Python 运行时和 pygame 库都被塞进了单文件里。如果要求交付源码,记得把dist/match3.exe与src源码目录分别放好,在文档中写清楚运行环境为 Python 3.10 及以上版本、pygame 2.x 系列,这能省去评分老师配置环境的精力,也避免因为环境版本不一致导致运行报错。
本文还有配套的精品资源,点击获取