尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python pygame消消乐源码拆解:从二维数组到完整游戏实现

Python pygame消消乐源码拆解:从二维数组到完整游戏实现 简介本资源是一套完整的Python游戏开发实战项目——开心消消乐源码包面向Python初学者及中级开发者聚焦图形界面交互、游戏逻辑实现与状态管理等核心能力提升。压缩包共138个文件含92张游戏素材PNG图棋盘、图标、特效、40个音效文件MP3/OGG格式涵盖消除、胜利、失败等场景音效以及2个核心Python源文件主逻辑与场景控制、3个已编译pyc文件和1份README说明文档整体体积5.21MB结构清晰、即开即用。已有570人学习下载资源直接提供可运行的游戏工程包含完整消除判定算法、元素下落填充机制、滑动交换逻辑、得分与步数状态管理模块并辅以多层级BGM与音效支持便于读者深入理解游戏循环、事件响应与GUI交互设计是掌握Python游戏开发全流程的优质实践范例。1. 拿到一份消消乐源码先看什么项目拆解与技术选型1.1 消消乐游戏的本质一个二维数组的增删查改很多新手拿到一份开心消消乐的 Python 源码包解压之后看到一堆.py文件、图片资源和音频文件第一反应往往是这玩意怎么跑起来。但如果你真的把这套代码打开逐行读过会发现一个很有意思的事实消消乐这样一个看起来色彩缤纷、动效丰富的游戏核心逻辑实际上就是一个二维数组的增删查改。玩家在屏幕上看到的是各种形状、各种颜色的小方块但在代码层面每个方块就是一个整数。我们用0、1、2、3、4这五个数字代表五种不同类型的方块整个棋盘就是一个 8 行 9 列的二维列表。所谓三连消除本质上就是在这个二维数组里找到连续三个以上相同的数字然后把它们标记成空位所谓方块下落就是把数组里非空的数字往底部压缩顶部空出来的位置填上新的随机数。理解这层抽象比看懂任何一行具体代码都重要。因为我见过太多人一上来就盯着 pygame 的绘图接口看研究怎么画圆角矩形、怎么让图片旋转结果把最核心的匹配算法搁在一边。等你把矩阵操作想明白了渲染层其实只是把数组状态翻译成屏幕画面而已——这是整个项目里最不需要动脑子的部分。1.2 pygame 还是 tkinter为什么选 pygame做消消乐这种实时交互的游戏选型基本就在 pygame 和 tkinter 之间纠结。我的建议很直接用 pygame别犹豫。tkinter 是 Python 自带的 GUI 库写个计算器、写个待办事项列表都很顺手。但它的渲染模型是控件树每次更新界面都要操作控件属性做方块下落动画的时候要么用after()做定时器要么只能瞬间刷新流畅度很难保证。而且 tkinter 对游戏音频、键盘事件、帧率控制的支持都比较原始想做连消特效、得分飘字这类效果代码会写得很别扭。pygame 则完全是另一套思路。它天生就是一个游戏框架自带主循环、事件队列、Surface 渲染、Sprite 精灵系统、碰撞检测和音频混音。它的工作方式非常符合游戏开发的直觉每一帧清屏、绘制、更新状态、处理事件然后循环往复。消消乐需要的方块移动、闪烁高亮、点击判定pygame 提供的原语几乎就是为这些场景设计的。我之前用 tkinter 写过一版简易消消乐能玩但画面刷新有明显的割裂感。后来改用 pygame 重写同样的逻辑体感完全不一样。对于想通过游戏项目学 Python 的人来说pygame 带给你的正反馈要强得多。1.3 项目结构规划避免写着写着就乱成一锅粥游戏项目特别容易陷入所有代码堆在 main.py的局面。一开始只有几百行还好一旦加了动画、音效、关卡、道具单文件就会膨胀到几千行改一个变量都要搜半天。我在重构这套消消乐源码的时候把项目拆分成了这样happy_eliminate/ ├── main.py # 程序入口主循环 ├── settings.py # 常量配置棋盘尺寸、格子大小、颜色、帧率 ├── board.py # 棋盘数据结构与匹配/下落/填充逻辑 ├── game.py # 游戏状态控制步数、得分、胜负判定 ├── render.py # 渲染相关画格子、画特效、文字 ├── assets/ # 图片、音效素材 └── requirements.txt # 依赖列表pygame这种拆分不是拍脑袋定的。核心原则是把逻辑和表现分开board.py里只处理二维数组的变换完全不关心画面render.py里只负责把数组画出来不处理游戏规则。这样一来你想把 pygame 换成其他渲染库或者想写单元测试验证匹配算法都不用动大手术。settings.py单独拎出来也是实践出来的经验。格子大小、方块类型数量、窗口尺寸、颜色 RGB 值这些常量如果散落在各个文件里后期想调整界面风格会非常痛苦。集中到一个文件里改两行就能全局生效省下的时间远超你当初省事随手写死在代码里省下的那几秒钟。2. 棋盘初始化与渲染让第一屏方块看起来正常2.1 棋盘、格子与数据模型的对应关系先把最基础的数据结构定下来。我用的是一张 8 行 9 列的棋盘这是消消乐类游戏最常见的规格——行数少一点、列数多一点横向匹配的机会更频繁玩家操作手感更顺畅。每个格子的像素尺寸是 60棋盘区域就是 480x540再加上顶部一条 60 像素高的信息栏显示得分和步数整个窗口设为 480x600。对应的常量在settings.py里是这样写的# settings.py WINDOW_WIDTH 480 WINDOW_HEIGHT 600 ROWS 8 COLS 9 CELL_SIZE 60 BOARD_X 0 BOARD_Y 60 NUM_TYPES 5 EMPTY -1数据类型的选择上我直接用 Python 原生的二维列表而不是 numpy 数组。原因很简单棋盘规模很小最多 72 个格子Python 列表的操作完全够快而且二维列表对新手来说更直观。board[row][col]取出来就是方块类型编号写起来跟读起来都是一回事。numpy 在这个体量下没有任何性能优势反而引入额外依赖、增加理解成本。空位用-1表示也是一个细节。如果用0表示空位那和第一种方块的编号就冲突了——因为你很可能把第一种方块的类型编号定为0。用一个不可能出现在正常方块里的负数来标记空位判断逻辑就不用写额外的条件了board[r][c] -1就是空一眼清楚。2.2 初始局面生成如何避免一开局就自爆刚把随机方块铺满棋盘的时候有一个非常隐蔽但必踩的坑随机生成的棋盘天然带有大量已存在的三连。如果你把随机数字直接填进二维数组然后开始游戏玩家还没动手屏幕上就已经自动消了好几轮体验极其诡异。正确做法是逐格生成并且在生成每个格子时检查它是否会和左边两个、上边两个格子形成三连。如果会就重新随机一个类型直到不冲突为止。核心代码是这样import random from settings import ROWS, COLS, NUM_TYPES, EMPTY def generate_board(): board [[EMPTY for _ in range(COLS)] for _ in range(ROWS)] for r in range(ROWS): for c in range(COLS): while True: t random.randint(0, NUM_TYPES - 1) # 左边两个同类型 横向三连 if c 2 and board[r][c-1] t and board[r][c-2] t: continue # 上边两个同类型 纵向三连 if r 2 and board[r-1][c] t and board[r-2][c] t: continue board[r][c] t break return board这个while True的写法值得多说一句。它看起来像死循环但实际最坏情况重试几次就能跳出因为方块类型有 5 种连续随机到同一个冲突类型的概率很低。这种生成后校验的思路比先全随机再扫描修改更简洁也不会引入连锁修改导致的二次冲突。当然这个初始化只保证了开局看起来没有三连。随着玩家不断交换方块后续因为下落产生的新布局是不做这个限制的——那是游戏机制的一部分连消本来就是要被奖励的。2.3 鼠标坐标与棋盘格子的换算游戏一启动玩家要点击方块程序必须把鼠标在屏幕上的像素坐标换算成棋盘的行列索引。这一步很基础但坐标算错会导致点左边方块却选中了右边的非常打击操作信心。换算公式其实就一句话col (mouse_x - BOARD_X) // CELL_SIZE row (mouse_y - BOARD_Y) // CELL_SIZE注意先把棋盘原点偏移减掉再整除格子尺寸。因为我们的棋盘顶部有一个高度 60 的信息栏BOARD_Y 60如果忘记减去这个偏移量点击棋盘第一行时实际算出来的行号可能是 0 或 1 之间错位。拿到行列之后一定要加一个边界判断def cell_at(mouse_x, mouse_y): if not (BOARD_X mouse_x BOARD_X COLS * CELL_SIZE): return None if not (BOARD_Y mouse_y BOARD_Y ROWS * CELL_SIZE): return None col (mouse_x - BOARD_X) // CELL_SIZE row (mouse_y - BOARD_Y) // CELL_SIZE return row, col别小看这个边界判断。没有它玩家点击信息栏区域或者窗口边缘时你会得到一个负数或者超出棋盘范围的索引接下来访问board[row][col]就会抛出IndexError游戏直接闪退。我在早期版本里就因为这个被朋友嘲讽过你这游戏怎么点一下标题栏就崩溃了3. 匹配检测算法三连消除是怎么被看见的3.1 行列扫描用找连续段替代查三连窗匹配检测是整个游戏的核心算法。很多初学者第一次写这个功能时会下意识地用三层循环去检查每个格子向右、向下延伸的三个位置是否一样。这个思路能跑但处理不了一行有四个、五个连续同色的情况——比如一行五个同色按三连窗检查会得到三组匹配结果消除时算三组还是算一组得分怎么计边界情况一大堆。更好的办法是按行、按列扫描出所有连续段然后把连续段覆盖的所有格子收集到一个集合里。伪代码如下def find_matches(board): matched set() rows, cols len(board), len(board[0]) # 横向逐行扫描连续段 for r in range(rows): c 0 while c cols: t board[r][c] if t EMPTY: c 1 continue end c while end 1 cols and board[r][end 1] t: end 1 length end - c 1 if length 3: for k in range(c, end 1): matched.add((r, k)) c end 1 # 纵向逐列扫描逻辑相同 for c in range(cols): r 0 while r rows: t board[r][c] if t EMPTY: r 1 continue end r while end 1 rows and board[end 1][c] t: end 1 length end - r 1 if length 3: for k in range(r, end 1): matched.add((k, c)) r end 1 return matched用set来存匹配格子是另一个关键决定。因为同一个格子可能既处于一个横向匹配段又处于一个纵向匹配段比如 L 形消除的中心格子如果用列表存还得手动去重set天然去重后面的消除逻辑直接遍历这个集合把对应格子置空就行完全不用担心重复处理。3.2 为什么用集合存储匹配结果而不是直接消除有人可能会问既然找到了连续段为什么不直接在这一步就把格子置空省得再遍历一遍集合这里有个重要的工程考量匹配检测和消除执行是两个阶段中间要隔一个渲染帧。玩家期望看到的效果是先看到匹配的方块高亮闪烁一下然后才消失。如果把检测和执行混在一起画面根本来不及展示哪些格子被匹配到了体验就会变成突然少了一块。所以实际流程是检测出匹配集合 - 把匹配格子标记为高亮状态 - 这一帧正常渲染但匹配的格子画上白色描边或者放大效果 - 等一帧或几百毫秒 - 执行消除。这个检测与执行分离的思想在游戏开发里特别常见。很多实时系统都是这个套路先计算再表现最后结算。养成这个习惯之后你会发现加什么特效、弹什么提示都变得很容易因为钩子早就留好了。3.3 交换合法性判断先在草稿上模拟再决定在不在真棋盘上动玩家选中一个方块再点击相邻方块这就是一次交换请求。但并不是所有交换都合法——如果交换之后无法形成任何三连就应该把两个方块弹回原位不能让玩家随意交换。判断合法性的方法有一个非常优雅的实现在棋盘的深拷贝上做模拟交换然后调用匹配检测如果检测结果非空就合法否则非法。import copy def is_valid_swap(board, r1, c1, r2, c2): if abs(r1 - r2) abs(c1 - c2) ! 1: return False # 不是相邻格子直接拒绝 temp copy.deepcopy(board) temp[r1][c1], temp[r2][c2] temp[r2][c2], temp[r1][c1] return len(find_matches(temp)) 0深拷贝的代价在这个规模下完全可以忽略72 个元素的列表拷贝一次微秒级。但它带来的好处非常大——草稿棋盘随便折腾真棋盘纹丝不动。如果直接在原棋盘上交换再检测检测完还得记得换回来一旦中间有异常或者漏了还原分支棋盘状态就乱了。判断相邻格子的条件abs(r1 - r2) abs(c1 - c2) ! 1其实是一个曼哈顿距离的判断。它同时排除了同一个格子和斜对角格子只允许上下左右四个方向一句话搞定不需要写四个if分支。4. 下落与补齐消除之后棋盘如何自我修复4.1 重力下落每列独立压缩把非空格子沉到底部消除完成后棋盘上会出现若干空位。接下来的下落逻辑是整个游戏里最容易写错的部分因为它涉及列这个维度的操作。我的实现思路是逐列处理每列独立进行压缩def apply_gravity(board): rows, cols len(board), len(board[0]) for c in range(cols): # 从下往上扫描把非空格子依次放到该列底部 write_row rows - 1 for r in range(rows - 1, -1, -1): if board[r][c] ! EMPTY: if write_row ! r: board[write_row][c] board[r][c] board[r][c] EMPTY write_row - 1这个算法的精妙之处在于双指针。write_row指向当前应放置方块的目标位置从底部往上移动扫描指针r从底部往上遍历每一行。遇到非空格子就把它搬到write_row指向的位置然后write_row上移。遇到空格子就继续往上找下一个非空格。这个过程不用额外列表直接在原数组上操作空间复杂度 O(1)。而且它对棋盘的行数完全没有假设写几行都能跑。我见过一些实现是把非空格子取出来再在原列前面补上同样的空格数思路一样但多了临时列表的分配和拷贝代码也更绕。4.2 新方块生成从顶部填充保证棋盘永远满格压缩完之后每列的顶部write_row及其上方都是空位。这时候要生成新方块填入。这里有一个体验细节新方块应该是从棋盘上方掉下来的画面效果上看起来像是从天而降而不是凭空刷新。逻辑层的实现很简单def refill(board): rows, cols len(board), len(board[0]) for c in range(cols): for r in range(rows - 1, -1, -1): if board[r][c] EMPTY: board[r][c] random.randint(0, NUM_TYPES - 1)但渲染层要配合做下落动画的话就不能只是简单地在顶部随机填数。我采用的做法是记录每个新方块生成时的逻辑位置然后给它一个初始的出生偏移——比如从-board_height的位置开始渲染每帧向下移动若干像素直到落到目标格子。这个移动和格子的离散跳跃是两回事逻辑棋盘瞬间更新渲染画面插值过渡。这么分离之后动画速度可以随便调不影响任何游戏规则。4.3 连消循环为什么下落的方块还会引发新的三连下落和填充完成后新组成的棋盘完全可能再次出现三连。这个过程叫连消或链式反应是消消乐爽感的核心来源——玩家做了一次操作结果棋盘自动噼里啪啦消了好几轮得分哗哗涨。实现连消的循环结构是这样的def settle_board(board): total_score 0 combo 1 while True: matches find_matches(board) if not matches: break # 记录本轮匹配的格子用于渲染特效 # 这里简化处理直接消除 for r, c in matches: board[r][c] EMPTY apply_gravity(board) refill(board) total_score len(matches) * 10 * combo combo 1 return total_score注意while True用在这里是安全的find_matches返回空集合时就会break不会死循环。每一次循环中匹配消除 - 下落 - 填充 - 再次检测这是一个完整的状态收敛过程。理论上棋盘可能在两次填充之间无限循环但实际上每次消除都会消掉大量方块换入新的随机布局很快就能到达无匹配的稳定态。连消的计分加成combo 1是给玩家的额外奖励。第一次消除每个方块 10 分第二次连消每个方块 20 分第三次 30 分以此类推。这个设计会让玩家觉得这一波赚大了非常有效地提升游戏的爽快感。5. 交互与反馈让游戏手感好起来的细节5.1 点击交换还是拖拽交换两种交互模式的取舍消消乐的玩家操作有两种主流模式。一种是点选两个相邻方块第一次点击选中一个方块第二次点击选中相邻方块然后执行交换。另一种是拖拽按住一个方块往相邻方向滑动。两种模式在触屏设备上体验差距很大但在 PC 上用鼠标操作点选模式已经足够顺手而且实现复杂度低一个量级。我实现的是点选模式状态机很简单selected None # 存储 (row, col) # 事件循环中 if event.type pygame.MOUSEBUTTONDOWN and event.button 1: pos cell_at(*event.pos) if pos is None: continue if selected is None: selected pos else: if abs(selected[0] - pos[0]) abs(selected[1] - pos[1]) 1: try_swap(selected, pos) elif selected ! pos: # 点了一个不相邻的格子重新选中 selected pos else: # 点了同一个格子取消选中 selected None这里的关键是选中态的管理。第一次点击记录位置并在渲染时给这个格子画一个高亮边框第二次点击根据目标格子和选中格子的关系分三种情况处理相邻就交换不相邻就重新选中同一个格子就取消选中。这个逻辑非常贴合直觉玩家不会产生我到底选没选中的困惑。有人会问为什么不直接做成拖拽因为拖拽需要处理鼠标按下、移动、抬起三阶段事件还要判断拖拽方向是否超过阈值代码量至少翻倍。对于学习项目先把点选模式跑通后续想升级拖拽只需要在MOUSEBUTTONDOWN后记录起点MOUSEBUTTONUP时计算方向向量选一个主方向即可不需要改动任何棋盘逻辑。5.2 交换动画与弹回效果别让玩家觉得自己卡了交换合法时两个方块要互换位置非法时要弹回原位。如果动画做得太生硬玩家会以为自己的操作没被识别然后疯狂点击反而触发更多错误操作。我的做法是在try_swap里引入一个简单的动画状态。不引入复杂的补间库就手写一个线性插值def animate_swap(block1_from, block2_from, duration15): # 每帧更新当前绘制位置逐渐从起点逼近终点 ...动画的核心思想逻辑棋盘在交换那一刻就已经更新完了但渲染层记录每个方块的上一帧位置和目标位置每帧按固定步长逼近目标。步长太大方块会像瞬移步长太小动画显得拖沓。实测下来用 15 帧左右完成一次交换比较合适也就是 60 FPS 下大约 0.25 秒节奏明快又不突兀。非法交换的弹回更简单把它视为两次连续动画——先正向交换再反向交换。视觉上看起来就是两个方块碰了一下又回去了玩家立刻明白这步走不了。5.3 得分、步数与失败判定让游戏有一个目标光有消除机制没有目标游戏就没有结束条件玩家玩两分钟就会腻。经典消消乐的核心循环是有限步数内达到目标分数否则失败。我在game.py里维护了三个状态量steps_left剩余步数初始 25score当前得分target_score本关目标比如 1000每次成功交换注意是成功也就是交换后产生了至少一次匹配steps_left - 1。注意不要把非法交换也算进去否则玩家故意乱点就会白白消耗步数这很不公平。每次成功消除后检查得分达到目标则进入过关结算步数归零而分数不够则失败。这里的深一层设计是步数限制决定了玩家必须去规划高收益的交换而不是随便点。如果消除 3 个方块得 30 分一局 25 步最多拿 750 分那 1000 分的关卡就必须依赖连消或者 4 连、5 连的高额奖励。这就迫使玩家思考游戏的可玩性自然就上来了。5.4 高亮、得分飘字与音效低成本高回报的正反馈我见过很多功能完整的消消乐源码但玩起来干巴巴的原因就是缺少反馈。代码层面加反馈其实很廉价但效果立竿见影。匹配检测出结果后先不急着消除而是把匹配格子存到一个highlight_cells列表里渲染时给这些格子画一圈白色粗边框。同时可以在画面中央显示一个消除 3 连 30的飘字效果数字从匹配位置慢慢上浮并淡出。音效方面pygame.mixer 直接播放一个短促的pop音效成本可以忽略。还有一个小细节得分飘字的字体大小和颜色可以随连消次数变化。第一次连消用白色小字第二次用黄色稍大第三次用红色加大并加粗。玩家会从视觉上感受到这一波不简单这种正反馈驱动着玩家继续尝试更复杂的操作。6. 打包发布与踩坑记录从开发机到能发给朋友玩6.1 pygame 程序转 exePyInstaller 的配置细节代码写完在自己电脑上跑得飞起下一步就是打包成可执行文件发给朋友。这里有个从入门到放弃的经典坑组合PyInstaller 打包 pygame 程序大概率一运行就报pygame.error: font not initialized或者找不到图片资源。先说字体问题。pygame 的字体模块默认依赖系统字体打包后的 exe 在别的机器上可能找不到你用的字体。解决办法有两个一是把字体文件比如simhei.ttf放进项目assets目录打包时用--add-data带上运行时通过pygame.font.Font(path, size)指定加载二是用pygame.font.match_font配合系统字体但这依赖目标机器安装了对应字体不稳定。我推荐前者字体文件是自包含的不会翻车。再说资源路径问题。PyInstaller 打包后程序的运行目录和资源文件所在目录可能不一样。如果你的代码用相对路径assets/icon.png加载图片直接打包运行就会FileNotFoundError。需要在代码里加一个路径适配import sys import os def resource_path(relative): if hasattr(sys, _MEIPASS): return os.path.join(sys._MEIPASS, relative) return os.path.join(os.path.abspath(.), relative)这是 PyInstaller 的标准处理方式。打包命令这样写pyinstaller -F -w -n HappyEliminate main.py \ --add-data assets;assets-F打成单文件-w去掉控制台黑窗口--add-data把 assets 目录整个塞进去。注意 Windows 下路径分隔符是分号Linux 和 macOS 下是冒号这个细节卡过不少人。6.2 主循环的性能与稳定性别让帧率忽高忽低消消乐画面简单性能问题不严重但有几个细节还是值得注意。首当其冲的是控制帧率。pygame 的主循环如果不加限制会在高性能机器上跑出上千 FPSCPU 占用直接拉满风扇狂转。正确做法是用pygame.time.Clock对象clock pygame.time.Clock() while running: # ...处理事件、更新状态、渲染... clock.tick(60)tick(60)会把帧率限制在 60 FPS同时它返回上一帧的耗时可以用于动画的帧间位移计算。另一个常见的坑是事件队列溢出。如果主循环里没有及时清空事件队列pygame 会持续积累MOUSEMOTION事件导致内存缓慢增长。解决方式就是确保每帧都调用pygame.event.get()哪怕不处理也要取出来扔掉。这个我在写一个 demo 时踩过挂机一个小时后内存占用涨了 200MB排查半天才意识到是事件队列的问题。6.3 从基础版到进阶版道具、障碍与特殊方块的扩展思路基础版消消乐跑通之后你一定会想加更多玩法。这里分享几个扩展方向以及它们在代码层面的落点。特殊方块四连消除生成直线爆破方块五连生成彩色炸弹。这类方块的本质是给格子增加一个special属性。棋盘数组可以升级成二维对象数组每个对象包含type和special两个字段。匹配检测时只比较type消除时检查special触发额外逻辑。障碍格冰块、锁链、果冻都是一些无法被交换或者需要多次消除才能清掉的特殊格子。实现上就是给棋盘再加一个障碍层的二维数组比如obstacle[row][col]存障碍物剩余生命值。匹配检测时有障碍的格子不参与消除但受到相邻爆炸波及时会减少生命值。步数关卡与目标模式可以设计收集类关卡要求玩家在步数内消除指定数量的红色方块。这就需要在匹配检测时统计各类型方块消除的数量并和关卡目标比对。这些扩展方向都在同一个抽象框架里逻辑层继续用二维数组描述状态渲染层负责把状态翻译成画面。只要这个边界不打破加新玩法就只是增加数据字段和判断分支的事不需要推翻重写。我在实际整理这套源码的时候最大的体会是消消乐看起来是个小游戏但它五脏俱全——数据结构设计、算法实现、事件处理、动画渲染、资源打包每一个环节都踩得到。把它从头到尾吃透你对 Python 的理解和对游戏开发的认识都会比刷一百道练习题来得更扎实。如果你手头也有一份类似的源码包建议不要直接跑起来就完事而是从头实现一遍核心逻辑再对照源码看看自己遗漏了什么这比自己闷头写十遍都管用。本文还有配套的精品资源点击获取
返回列表