pygame俄罗斯方块源码解析:矩阵变换与碰撞检测实战
简介利用Python与pygame开发的俄罗斯方块游戏完整源码包面向已有Python基础、希望动手实践pygame游戏开发的初中级学习者。项目共29个文件压缩后约2.1MB轻量易部署。除8个py源码文件外还包含pyc编译版本、wav音效素材、png演示截图、README说明、gitignore配置备份以及一份docx格式的游戏开发报告覆盖需求分析、二维数组数据存储、方块旋转算法、事件循环与性能调试等关键点。通过阅读源码与报告可理清游戏初始化和事件循环、键盘响应、消行判定、计分与关卡升级等完整流程并了解音效集成和异常处理思路。资源内附有菜单交互模块、方块定义和矩阵逻辑等分离文件便于按模块学习与二次扩展。已有83人学习下载适合课程设计、毕业设计或业余项目参考。1. pygame俄罗斯方块一份能跑、能改、能学的完整源码拿到这份「Python游戏编程源码-pygame俄罗斯方块」压缩包时第一反应是它不像教材里的半成品里面除了入口脚本开始.py还有tetrominoes.py方块定义、scores.py计分、kezmenu.py菜单、tests.py测试以及linecleared.wav、gameover.wav、levelup.wav等音效资源。对正在学 Python 和 pygame 的开发者来说这种按职责拆分的目录结构就是最直接的学习路径——每个文件管一类事改动一处不会牵连其他模块。从实用角度看这个项目能回答三个问题俄罗斯方块的形状怎么用二维数组表达旋转为什么是矩阵变换消行判定为什么比想象中简单。它适合刚学完 Python 基础、想用游戏项目练手的入门者也适合需要快速搭一个 pygame 游戏骨架的课程设计和作品集场景。源码里还留着matris.py.bak备份文件说明开发者也经历过边写边改重构的过程这本身就是游戏开发的真实写照。2. 方块形状与旋转算法tetrominoes.py 的矩阵设计俄罗斯方块里七种方块I、O、T、S、Z、J、L的形状用二维数组表达是 pygame 社区最常见的做法。每个方块是一个 N x M 的 0/1 矩阵1 代表有方块格0 代表空位。之所以不用对象和碰撞体是因为旋转、碰撞检测、消行可以复用同一套矩阵运算后续加新形状也不需要改游戏逻辑。2.1 七种方块的形状定义方式在tetrominoes.py里形状通常用字典组织键是方块名值是矩阵列表。一个标准定义大概是这样的# tetrominoes.py TETROMINOES { I: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], ], O: [ [1, 1], [1, 1], ], T: [ [0, 1, 0], [1, 1, 1], [0, 0, 0], ], S: [ [0, 1, 1], [1, 1, 0], [0, 0, 0], ], Z: [ [1, 1, 0], [0, 1, 1], [0, 0, 0], ], J: [ [1, 0, 0], [1, 1, 1], [0, 0, 0], ], L: [ [0, 0, 1], [1, 1, 1], [0, 0, 0], ], }代码逻辑本身不复杂但有几个边界值得注意。I 方块写成 4x4 而不是 1x4是为了旋转时中心点稳定O 方块虽然不需要旋转保留 2x2 矩阵便于统一遍历T、S、Z、J、L 统一为 3x3旋转后仍然落在原包围盒内避免视觉上错位。形状表里每个 1 的位置决定方块初始形态颜色可以单独用一个字典映射到 RGB 值渲染时查表即可。方块名简化形状矩阵尺寸旋转行为I一条横线4x490 度后变竖线O田字格2x2旋转不变T十字少一竖3x3四种朝向S / Z镜像 Z 形3x3两种主要朝向J / L拐角形3x3四种朝向2.2 旋转的本质是矩阵转置加反转方块旋转的经典算法是顺时针旋转等于转置矩阵后把每行反转逆时针旋转则反过来。这个结论在任意二维矩阵上都成立所以不需要为七种方块各写一套旋转逻辑。def rotate_cw(matrix): return [list(row)[::-1] for row in zip(*matrix)] def rotate_ccw(matrix): return [list(row) for row in zip(*matrix)][::-1]zip(*matrix)把每一列组合成新元组相当于完成了转置。第一个函数转置后对每行做[::-1]反转整体效果是顺时针旋转 90 度第二个函数先做转置再反转行序得到逆时针旋转 90 度。两个函数对 I 的 4x4 矩阵和 T 的 3x3 矩阵都适用不需要特判。在真实游戏里旋转不能直接替换当前形状。正确做法是复制一份旋转结果用这份副本做碰撞检测如果旋转后与边界或已有方块重叠常规处理是先尝试原位置再尝试左右偏移各一格这个机制就是俄罗斯方块里的 wall kick。对于这份源码里的方块尺寸偏移一格基本能覆盖全部情况。2.3 活动方块坐标换算把矩阵位置映射到棋盘活动方块在棋盘上的实际位置用一个(row, col)元组表示矩阵左上角所在的网格坐标。渲染、碰撞、消行都需要把形状矩阵里的 1 换算成棋盘上的绝对格子坐标def get_cells(shape, row, col): cells [] for r, line in enumerate(shape): for c, val in enumerate(line): if val: cells.append((row r, col c)) return cells这里row和col是活动块左上角的绝对坐标遍历形状矩阵时把相对偏移累加上去最终返回该方块占用的全部坐标列表。碰撞检测、绘制、消行判断都依赖这个函数所以把它独立出来避免在多处写双重循环。影子方块ghost piece的实现也是基于它复制坐标不断下移直到碰撞为止再绘制一层半透明轮廓。3. 碰撞检测与消行游戏矩阵的核心逻辑俄罗斯方块的游戏区在源码里就是matris.py.bak中那个 10 列 x 20 行的二维数组元素只有 0 和 1。为什么不直接用pygame.Surface画因为消行、判定堆叠、检测游戏结束都需要逐格读取状态纯数据矩阵最灵活也最容易测试。3.1 棋盘初始化与方块落地写回ROWS, COLS 20, 10 def new_board(): return [[0] * COLS for _ in range(ROWS)] def lock_piece(board, cells): for row, col in cells: board[row][col] 1new_board用列表推导式生成 20 行、每行 10 个 0 的棋盘。这里不能写成[[0] * COLS] * ROWS后者会产生 20 个指向同一行对象的引用修改一行等于修改全部行是 pygame 入门最常见的隐性 bug。lock_piece是方块落定后调用把活动块占用的坐标逐格写 1随后新方块从顶部生成。参数说明board是游戏区域矩阵cells是get_cells返回的坐标列表。所有方块的落定都走这一个入口不要在移动逻辑里直接改棋盘否则后续消行和碰撞判定会乱套。这份源码里留着matris.py.bak说明开发者在调整矩阵结构时保留了旧版本这种迭代习惯值得学习。3.2 边界碰撞与堆叠碰撞合并判定碰撞检测最容易写错的位置是只判断左右边界忽略底部和已经堆叠的方块。更稳的写法是把所有条件收敛到一个函数里def collides(board, cells): for row, col in cells: if row 0 or row ROWS: return True if col 0 or col COLS: return True if board[row][col]: return True return False判定顺序有讲究先查行越界因为方块从顶部生成时 row 可能为负再查列越界避免旋转后突出左右边界最后查棋盘上是否已经有方块。三个条件命中一个就返回 True调用方可以直接决定能否移动、旋转、下落不用在每一处操作里各写一遍边界判断。注意顶部和底部的差别方块到达底部时row 等于 ROWS 才判定为碰撞但生成位置如果设置成 row -2方块还没进屏幕就碰撞游戏会直接结束。所以生成位置一般从 0 或 -1 开始生成后立刻做一次碰撞检测用这个结果判断游戏是否终止。3.3 消行、加分、游戏结束判定消行的实现思路是扫描矩阵找出所有全 1 的行删除后从顶部补一行全 0。这个逻辑在代码里只有几行但很容易改坏def clear_full_rows(board): rows_to_clear [i for i, row in enumerate(board) if all(row)] for i in rows_to_clear: del board[i] board.insert(0, [0] * COLS) return len(rows_to_clear)all(row)判断一行是否全部为真1 是真、0 是假只要有一个空位就不会被删。del board[i]删除整行insert(0, ...)在顶部补回空行保证矩阵行数始终是 20。返回值是消除行数用于计分单行 100 分双行 300 分三行 500 分四连消给 800 分所以高手都追求一次消四行。删除多行时rows_to_clear是从上到下排列的索引快照按顺序删除不受影响因为删掉上方行后待删行的索引自动减一而列表推导式已经提前缓存了索引。游戏结束判定在生成新方块时完成调用get_cells得到新块坐标如果用collides检测到与棋盘重叠说明顶部已塞满此时播放gameover.wav并回到菜单。4. 事件循环与主循环pygame 的渲染与输入怎么配合pygame 项目的骨架是事件循环加主循环。事件循环负责接收键盘和窗口事件主循环推进游戏状态并重绘画面两者都在同一个while running:中但职责必须分开。很多新手把移动逻辑写在每帧无条件执行导致按住方向键时方块连续移动、无法单步控制。4.1 键盘事件用 KEYDOWN连续移动要自己计时import pygame running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_LEFT: move_dx(-1) elif event.key pygame.K_RIGHT: move_dx(1) elif event.key pygame.K_DOWN: soft_drop() elif event.key pygame.K_UP: rotate_current()这里监听的是KEYDOWN因为单次按键产生一次KEYDOWN事件松开才产生KEYUP。玩家按一下动一格不需要按住连续响应。要实现按住方向键连续移动pygame 自身不带输入重复常见做法是维护按键状态和上次移动时间每帧判断间隔这套逻辑在kezmenu.py的菜单导航里也有类似实现。参数说明move_dx(-1)表示列坐标减 1 即左移rotate_current旋转当前活动方块。所有移动动作都要先做碰撞检测再写状态避免穿墙。pygame.event.get()每次循环都会清空事件队列一帧内连按多个键会依次触发不会互相覆盖。4.2 渲染顺序先底图、再固定方块、最后活动块pygame 采用每帧全量重绘的机制渲染顺序决定遮挡关系。俄罗斯方块的标准顺序是清屏、画网格线、画已固定方块、画活动方块、画得分面板、最后调用pygame.display.flip()CELL 30 screen.fill((20, 20, 30)) for r in range(ROWS): for c in range(COLS): if board[r][c]: rect pygame.Rect(c * CELL, r * CELL, CELL, CELL) pygame.draw.rect(screen, (0, 120, 200), rect, 0) for row, col in get_cells(current_shape, cur_row, cur_col): rect pygame.Rect(col * CELL, row * CELL, CELL, CELL) pygame.draw.rect(screen, (200, 200, 0), rect, 0) pygame.display.flip()pygame.Rect的四个参数是 x、y、宽、高这里用列号乘格子边长得到 x行号乘格子边长得到 y完成矩阵坐标到屏幕像素的换算。pygame.draw.rect第三个参数传 0 表示填充矩形传 1 或 2 表示描边。网格线通常用描边方式逐格画或者先画边框再画内部隔线避免生成太多矩形对象。性能上更优的做法是维护一张静态背景 Surface固定方块只画一次活动方块单独画在最上层消行时更新背景局部区域即可。对于 10x20 的棋盘全量重绘也完全跟得上帧率不必一开始就做这类优化。4.3 下落速度用时钟累加不用 sleep 控制主循环尾部必须有pygame.display.flip()和pygame.time.Clock().tick(FPS)。tick 的作用是限制每帧耗时不低于1000 / FPS毫秒从而稳定帧率。下落速度不能用time.sleepsleep 会阻塞整个事件循环按键响应立刻变得迟钝。正确做法是每帧累加经过的毫秒数达到阈值才下落clock pygame.time.Clock() fall_time 0 fall_interval 500 while running: delta clock.tick(60) fall_time delta if fall_time fall_interval: if collides(board, get_cells(current_shape, cur_row 1, cur_col)): lock_piece(board, get_cells(current_shape, cur_row, cur_col)) clear_full_rows(board) spawn_new_piece() else: cur_row 1 fall_time 0fall_interval以毫秒为单位500 表示每 0.5 秒下落一行应该随关卡递增缩短常见公式是max(100, 500 - level * 30)让速度平滑提升而不是突变。clock.tick(60)返回的是实际经过的毫秒数它会因帧率波动而变化所以用累加时间而不是对帧数取模这样才能保证低帧率下下落节奏依然准确。5. 菜单、计分与音效让源码从能玩到完整一份完整的 pygame 源码如果只有主游戏循环玩家体验会很单薄。这个项目里有kezmenu.py、scores.py和多个 wav 资源正好补上了开始菜单、最高分存档、消行音效和升级音效。这也是课程设计和作品集里最容易被忽略的部分核心玩法能跑只是第一步菜单和存档才让人觉得这是个游戏而不是功能演示。5.1 kezmenu 菜单系统选项列表和按键响应kezmenu.py实现的是轻量文本菜单不依赖图片资源pygame 默认字体就能画。它的核心职责是维护选项列表和当前选中索引上下键切换回车键触发。典型调用方式如下from kezmenu import KezMenu menu KezMenu( Start Game, High Score, Quit, ) while not started: for event in pygame.event.get(): if event.type pygame.KEYDOWN: if event.key pygame.K_UP: menu.move_up() elif event.key pygame.K_DOWN: menu.move_down() elif event.key pygame.K_RETURN: menu.select() menu.update(event) menu.draw(screen) pygame.display.flip()关键是菜单事件与游戏事件分离。菜单循环里不处理游戏按键避免玩家在菜单界面误触方向键导致游戏状态改变。menu.update(event)让菜单自己处理事件并更新高亮位置绘制层只要调menu.draw(screen)就能显示当前选中项。如果自己实现菜单注意字体初始化。pygame 默认字体不支持中文直接用中文标题会出现乱码需要调用pygame.font.SysFont(SimHei, 24)指定系统中文字体。kezmenu.py里保留英文选项也是为了让默认字体能正常渲染。5.2 scores.py 与 .highscores 的持久化读取计分模块负责当前分数和最高分。当前分数放内存最高分必须落盘否则退出游戏就清零。.highscores是隐藏文件在 Linux 下默认不显示这也是它在项目文件列表里容易看漏的原因。import os HIGHSCORE_FILE .highscores def load_highscore(): if not os.path.exists(HIGHSCORE_FILE): return 0 with open(HIGHSCORE_FILE) as f: return int(f.read().strip() or 0) def save_highscore(new_score): with open(HIGHSCORE_FILE, w) as f: f.write(str(new_score)) def update_highscore(current_score): high load_highscore() if current_score high: save_highscore(current_score) return True return False三个函数分工明确。load_highscore里.strip() or 0是为了兼容空文件避免int()抛ValueError。save_highscore用w模式直接覆盖写入。update_highscore返回布尔值上层拿到True时播放highscorebeaten.wav完成破纪录反馈。注意在 Windows 上.highscores同样可以创建但资源管理器默认隐藏点开头的文件游戏跑完找不到记录文件时别急着怀疑代码先在命令行里ls -a看一下。想省事可以直接把文件名改成highscores.txt。5.3 音效加载顺序与触发时机pygame 加载音效有固定顺序先pygame.mixer.init()再创建Sound对象。直接调用会抛pygame.error: mixer not initialized。源码里四个 wav 分别对应消行、游戏结束、升级、破纪录场景pygame.mixer.init(frequency44100, size-16, channels2) line_clear_sound pygame.mixer.Sound(resources/linecleared.wav) game_over_sound pygame.mixer.Sound(resources/gameover.wav) level_up_sound pygame.mixer.Sound(resources/levelup.wav) highscore_sound pygame.mixer.Sound(resources/highscorebeaten.wav)参数说明frequency44100是 CD 音质标准采样率size-16表示 16 位有符号采样channels2是双声道。这些参数如果和 wav 文件自身格式不匹配播放时会出现音调异常或直接报错。音频文件夹放在resources/下加载路径要写相对路径入口脚本在不同目录启动时容易踩路径坑建议用os.path.join(os.path.dirname(__file__), resources, linecleared.wav)锁定绝对路径。音效触发也要注意频率。Sound.play()会开辟新声道不会阻塞主循环但同一种音效短时间内多次触发会叠加播放听起来像爆音。常见做法是记录上次播放时间间隔小于 150 毫秒就忽略本次触发。6. 测试与排错pygame 项目的调试技巧和常见坑项目里的tests.py和tests_effects.py是容易被忽略但含金量最高的部分。它们不依赖图形界面用assert就能验证旋转和消行的正确性这套思路可以直接迁移到其他 pygame 项目中。6.1 tests.py 里最值得保留的两个测试第一个是旋转回归测试任意方块连续旋转四次应回到原始形状。第二个是消行测试构造一行全 1 的棋盘调用后该行消失顶部补出空行。def test_rotate_back_to_original(): original [[1, 0, 0], [1, 1, 1], [0, 0, 0]] rotated rotate_cw(rotate_cw(rotate_cw(rotate_cw(original)))) assert rotated original def test_clear_full_rows(): board [ [0] * 10, [1] * 10, [0] * 10, ] cleared clear_full_rows(board) assert cleared 1 assert board[0] [0] * 10旋转测试能同时验证旋转函数和数据定义的一致性如果方块矩阵不对称旋转回原测试会直接失败。消行测试要在调用前深拷贝棋盘因为clear_full_rows会原地修改列表避免测试之间互相污染。6.2 安装与运行期三个高频坑pygame 安装失败是最高频的问题在 Windows 上用 pip 编译源码时会报failed to build pygame when getting requirements to build wheel。这表示系统缺编译工具链处理方案是用预编译 wheel 包执行python -m pip install pygame --pre或者访问 pygame 官网下载与当前 Python 版本匹配的 whl 文件。一定要先确认 Python 版本32 位和 64 位的 wheel 不通用。第二个坑是按键重复。pygame 默认不支持按住方向键连续移动不做时间判断的话按住右键只会移动一格。处理方式是记录按键按下时间和上次移动时间每 80 到 120 毫秒触发一次移动。第三个坑是旋转后越界常见解法是 wall kick旋转后先检测碰撞失败则尝试左右偏移一格再失败才放弃本次旋转。验证方法很简单把棋盘宽度临时改成 5 列选 I 方块反复旋转观察是否出现越界。通用调试技巧是在游戏循环里临时加一行print(cur_row, cur_col, collides_result)跑几轮就能定位大部分逻辑问题。pygame 的报错信息通常直接显示在终端里异常发生在哪一行很清楚先修数据再调渲染顺序这个项目的规模完全不需要引入断点调试器。本文还有配套的精品资源点击获取