简介:利用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 | 一条横线 | 4x4 | 90 度后变竖线 |
| 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.sleep,sleep 会阻塞整个事件循环,按键响应立刻变得迟钝。正确做法是每帧累加经过的毫秒数,达到阈值才下落:
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(frequency=44100, size=-16, channels=2) 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')参数说明:frequency=44100是 CD 音质标准采样率,size=-16表示 16 位有符号采样,channels=2是双声道。这些参数如果和 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 的报错信息通常直接显示在终端里,异常发生在哪一行很清楚,先修数据再调渲染顺序,这个项目的规模完全不需要引入断点调试器。
本文还有配套的精品资源,点击获取