☰
Python pygame俄罗斯方块实训:从环境搭建到核心机制实现
2026/10/6 12:46:33 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与Python初学者的课程实训项目包,以经典俄罗斯方块小游戏为载体,帮助读者掌握pygame图形界面开发、游戏循环、碰撞检测与事件响应等核心技能,可直接用于课程设计、毕业设计、作业提交或项目立项演示。压缩包共6个文件,约6.88MB,包含py文件主程序、wav与mp3音效素材、txt分数记录、docx设计报告、md说明文档等,覆盖源码、素材与文档全流程。目前已有46人学习下载。项目代码完整且经过测试,功能稳定易复现,设计报告可供参考学习,音效与音乐资源开箱即用;读者既能借鉴整体架构与实现思路,也能在现有代码基础上修改扩展,实现新玩法或功能,适合小白进阶与有基础者二次开发。

1. 从一份课程实训压缩包说起:Python pygame 俄罗斯方块到底能练到什么

很多人第一次接触游戏开发,都是从一份「课程实训-基于 Python pygame 的经典俄罗斯方块小游戏开发」的压缩包开始的。它看起来只是个交作业用的东西,但真正动手跑一遍就会发现,这里面藏着游戏循环、事件处理、碰撞检测、矩阵旋转、消行判定这一整套最基础也最核心的机制。你不需要引擎,不需要美术资源,只要装好 Python 和 pygame,就能在一个窗口里把「方块往下掉」这件事拆成几十行可调试的逻辑。它适合两类人:一类是刚学完 Python 基础语法、想找个能看见结果的项目练手的新手;另一类是想快速验证某个交互逻辑、又不想被 Unity 或 Godot 的工程结构拖住的老手。这一章先把这份实训资料里最值得抠出来的技术点讲清楚,后面几章再一步步落到代码和参数上。

2. 环境搭不起来,后面全是空谈:Python 与 pygame 的最小可跑通配置

2.1 为什么我坚持用 venv 而不是全局装 pygame

课程实训资料里通常会附带一份requirements.txt或者直接写明pip install pygame,但如果你直接装在系统 Python 里,后面一旦要换版本或者同时跑多个实训项目,就会遇到「昨天还能跑,今天 import pygame 就报错」的玄学问题。我一般会先建一个独立虚拟环境,把 Python 版本和 pygame 版本锁死。Windows 下用python -m venv tetris_env,macOS 或 Linux 下用python3 -m venv tetris_env,激活后再装 pygame。这样即使你把整个文件夹拷给同学,只要对方也建了同样的环境,跑起来的概率会高很多。

# 创建虚拟环境,目录名随意,这里叫 tetris_env python -m venv tetris_env # Windows 激活 tetris_env\Scripts\activate # macOS / Linux 激活 source tetris_env/bin/activate # 安装 pygame,建议指定一个稳定版本,避免自动升级到不兼容的新版 pip install pygame==2.5.2

逻辑说明:venv会把 Python 解释器和第三方库隔离在项目目录下,activate之后所有pip install都只影响这个环境。参数上,pygame==2.5.2是我在多个实训项目里验证过比较稳的版本,如果你用的是 Python 3.12 以上,建议不要低于 2.5.0,否则可能遇到pygame.display.set_mode返回空窗口的问题。装完之后用python -c "import pygame; print(pygame.ver)"验证一下,能打印出版本号就说明环境通了。

2.2 最小游戏窗口:五行代码验证 pygame 是否真的在工作

很多新手卡在「pygame.init() 之后窗口一闪而过」,其实是因为没有写事件循环。下面这段代码是俄罗斯方块项目里最底层的骨架,你先把它跑通,再往上加方块逻辑。

import pygame import sys pygame.init() screen = pygame.display.set_mode((300, 600)) # 宽 300,高 600,对应 10 列 x 20 行的经典比例 pygame.display.set_caption("Tetris - 环境验证") clock = pygame.time.Clock() while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() screen.fill((0, 0, 0)) # 黑色背景 pygame.display.flip() clock.tick(60) # 锁定 60 帧,避免 CPU 空转

逻辑说明:set_mode((300, 600))里的宽高不是随便写的,经典俄罗斯方块是 10 列 x 20 行,每个格子 30 像素,所以 300x600 刚好。clock.tick(60)控制主循环频率,如果不加,循环会跑满 CPU,笔记本风扇会狂转。参数上,fill((0,0,0))是 RGB 黑色,后面画方块时每个格子用pygame.draw.rect填充不同颜色即可。这一步跑通,说明你的 pygame 环境和显示驱动都没问题,接下来才能进入方块数据结构的设计。

3. 方块怎么表示、怎么转:俄罗斯方块核心数据结构的选型与实现

3.1 用二维列表还是坐标元组?我选二维列表的理由

俄罗斯方块有 7 种方块(I、O、T、S、Z、J、L),每种方块在不同旋转状态下形状不同。常见做法有两种:一种是用四个坐标点表示,另一种是用一个二维列表(矩阵)表示。课程实训资料里大多用二维列表,因为旋转操作可以直接用zip(*matrix)或者手动转置来实现,代码更直观。我一般会定义一个SHAPES字典,每个键对应一个二维列表,列表里 1 表示实心格子,0 表示空。

SHAPES = { 'I': [[1, 1, 1, 1]], 'O': [[1, 1], [1, 1]], 'T': [[0, 1, 0], [1, 1, 1]], 'S': [[0, 1, 1], [1, 1, 0]], 'Z': [[1, 1, 0], [0, 1, 1]], 'J': [[1, 0, 0], [1, 1, 1]], 'L': [[0, 0, 1], [1, 1, 1]], }

逻辑说明:每个二维列表的行列数决定了方块占用的网格范围。I是 1x4,O是 2x2,其他大多是 2x3 或 3x2。参数上,你不需要额外记录方块的宽高,直接用len(matrix)和len(matrix[0])就能算出来。旋转的时候,对矩阵做一次顺时针旋转就是list(zip(*matrix[::-1])),但要注意zip返回的是元组,需要转成列表才能继续用。

3.2 旋转的边界处理:为什么你的方块会穿墙

旋转本身不难,难的是旋转之后如果超出左右边界或者撞到已有方块怎么办。我见过太多实训代码在这里翻车:方块在墙边一旋转就直接嵌进墙里,或者和堆叠的方块重叠。常见做法是「尝试旋转,如果碰撞就回退」,也就是先保存当前形状,旋转后检测碰撞,如果碰撞就恢复原状。下面是一个可复用的旋转函数和碰撞检测函数。

def rotate(matrix): """顺时针旋转 90 度""" return [list(row) for row in zip(*matrix[::-1])] def check_collision(board, shape, offset): """ board: 20x10 的二维列表,0 为空,非 0 为已固定方块 shape: 当前方块矩阵 offset: (x, y) 当前方块左上角在 board 中的位置 """ off_x, off_y = offset for y, row in enumerate(shape): for x, cell in enumerate(row): if cell: board_x = off_x + x board_y = off_y + y if board_x < 0 or board_x >= 10 or board_y >= 20: return True if board_y >= 0 and board[board_y][board_x]: return True return False

逻辑说明:rotate先用matrix[::-1]把行倒序,再zip(*...)转置,得到顺时针旋转结果。check_collision遍历方块每个实心格子,换算成 board 坐标后判断是否越界或与已有方块重叠。参数上,offset的y可能为负数,表示方块刚生成时有一部分在屏幕上方,所以判断board_y >= 0才检查 board 内容。这个函数在左右移动、下落、旋转时都要调用,是整套逻辑里最频繁的守卫。

3.3 消行判定与分数计算:一行代码写错,整局游戏就废了

消行是俄罗斯方块里最让人有成就感的部分,但也是最容易写出 bug 的地方。常见错误是「消行后没有把上面的行下移」,导致中间出现空行。正确做法是从下往上遍历,遇到满行就删除,并在顶部插入一个空行。

def clear_lines(board): """返回消除的行数和新的 board""" lines_cleared = 0 new_board = [row[:] for row in board] # 深拷贝,避免修改原 board y = 19 while y >= 0: if all(new_board[y]): # 该行全部非 0 del new_board[y] new_board.insert(0, [0] * 10) lines_cleared += 1 # 不递减 y,因为删除后当前索引已经是新的一行 else: y -= 1 return lines_cleared, new_board

逻辑说明:all(new_board[y])判断一行是否全部为真(非 0)。删除后insert(0, [0]*10)在顶部补空行,保持 board 始终 20 行。参数上,lines_cleared可以用来算分,常见规则是消 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分。注意这里用while而不是for,因为删除行之后索引会变化,for循环容易漏掉连续满行的情况。

4. 主循环与事件驱动:把下落、移动、旋转串成一条时间线

4.1 下落计时器:为什么不能用 sleep

很多新手会想用time.sleep(0.5)来控制方块下落速度,但这样会阻塞整个游戏循环,导致按键没反应、窗口卡死。正确做法是用pygame.time.get_ticks()或者clock.tick()配合一个累加器。我一般会在主循环里维护一个fall_time变量,每帧加上clock.tick(60)的返回值,超过阈值就下落一格。

fall_time = 0 fall_speed = 500 # 毫秒,数值越小下落越快 while True: dt = clock.tick(60) # 返回上一帧到现在的毫秒数 fall_time += dt if fall_time >= fall_speed: fall_time = 0 # 尝试下落一格 if not check_collision(board, current_shape, (current_x, current_y + 1)): current_y += 1 else: # 固定方块到 board,检查消行,生成新方块 lock_piece(board, current_shape, current_x, current_y) lines, board = clear_lines(board) score += calculate_score(lines) current_shape, current_x, current_y = spawn_new_piece()

逻辑说明:clock.tick(60)既控制帧率又返回时间差,fall_time累加达到fall_speed就触发一次下落。参数上,fall_speed=500是中等难度,想加难度可以降到 200 甚至 100。注意lock_piece要把当前方块写入 board,然后才能消行和生成新方块,顺序不能乱。

4.2 键盘事件:左右移动、旋转、硬降的键位映射

pygame 的键盘事件用pygame.KEYDOWN捕获,常见键位是方向键左右移动、上旋转、下加速、空格硬降。我一般会在事件循环里用event.key判断,而不是用pygame.key.get_pressed(),因为后者会导致按住键时连续触发太快,不好控制。

for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.KEYDOWN: if event.key == pygame.K_LEFT: if not check_collision(board, current_shape, (current_x - 1, current_y)): current_x -= 1 elif event.key == pygame.K_RIGHT: if not check_collision(board, current_shape, (current_x + 1, current_y)): current_x += 1 elif event.key == pygame.K_UP: rotated = rotate(current_shape) if not check_collision(board, rotated, (current_x, current_y)): current_shape = rotated elif event.key == pygame.K_DOWN: if not check_collision(board, current_shape, (current_x, current_y + 1)): current_y += 1 elif event.key == pygame.K_SPACE: while not check_collision(board, current_shape, (current_x, current_y + 1)): current_y += 1

逻辑说明:每次移动或旋转前都调用check_collision,只有不碰撞才更新坐标或形状。空格硬降用while一直下落到底,然后主循环里的下落计时器会触发固定。参数上,pygame.K_UP对应上箭头,如果你想让 W 键也能旋转,加一个or event.key == pygame.K_w即可。

4.3 渲染顺序:先画背景、再画已固定方块、最后画当前方块

渲染顺序错了会出现「当前方块被已固定方块盖住」或者「消行后残影」。正确顺序是:填充背景色、画网格线(可选)、画 board 里所有非 0 格子、画当前方块、刷新显示。每个格子用pygame.draw.rect(screen, color, (x*30, y*30, 30, 30))绘制,颜色可以根据方块类型从字典里取。

COLORS = { 'I': (0, 255, 255), 'O': (255, 255, 0), 'T': (128, 0, 128), 'S': (0, 255, 0), 'Z': (255, 0, 0), 'J': (0, 0, 255), 'L': (255, 165, 0), } def draw_board(screen, board): for y, row in enumerate(board): for x, cell in enumerate(row): if cell: pygame.draw.rect(screen, COLORS.get(cell, (255, 255, 255)), (x*30, y*30, 30, 30)) def draw_piece(screen, shape, offset, color): off_x, off_y = offset for y, row in enumerate(shape): for x, cell in enumerate(row): if cell: pygame.draw.rect(screen, color, ((off_x + x)*30, (off_y + y)*30, 30, 30))

逻辑说明:draw_board遍历整个 board,draw_piece只画当前方块。参数上,30是格子像素大小,和set_mode((300, 600))对应。颜色字典的键要和SHAPES的键一致,这样lock_piece时把方块类型写入 board,渲染时就能自动取到对应颜色。

5. 避坑与排查:实训代码里最容易翻车的 5 个地方

5.1 现象:窗口打开后无响应,点关闭按钮没反应

原因:事件循环里没有处理pygame.QUIT,或者pygame.event.get()被其他逻辑阻塞。解决:确保主循环每帧都调用pygame.event.get(),并且在QUIT事件里调用pygame.quit()和sys.exit()。如果你用了time.sleep,立刻删掉,换成clock.tick()。

5.2 现象:方块旋转后位置偏移,甚至跑到屏幕外

原因:旋转函数没有处理矩阵转置后的行列变化,或者旋转后没有重新计算 offset。解决:旋转后先用check_collision检测,如果碰撞就尝试左右微调(wall kick),微调失败就放弃旋转。常见做法是依次尝试(x, y)、(x-1, y)、(x+1, y)、(x-2, y)、(x+2, y)。

5.3 现象:消行后分数加了,但方块堆叠错位

原因:clear_lines里用了浅拷贝,删除行时影响了原 board,或者insert的位置不对。解决:用[row[:] for row in board]做深拷贝,删除后始终在索引 0 插入空行。另外注意while循环里删除行后不要递减y,否则会跳过下一行。

5.4 现象:游戏越玩越卡,帧率明显下降

原因:每帧都在创建新的 Surface 或者重复加载图片、字体。解决:把字体、颜色、形状矩阵这些不变的对象在循环外初始化,循环内只做绘制和事件处理。如果你用了pygame.font.Font,不要在循环里反复Font()。

5.5 现象:打包成 exe 后运行报错,提示找不到 pygame

原因:PyInstaller 没有把 pygame 的资源文件打进去,或者虚拟环境里装的 pygame 和打包时用的不是同一个。解决:用pyinstaller -F --add-data "tetris_env/Lib/site-packages/pygame;pygame" main.py显式指定 pygame 目录,或者直接用--collect-all pygame。打包前先在干净虚拟环境里跑一遍,确认没有缺失依赖。

6. 从能跑到好用:给俄罗斯方块加一个「下一个方块预览」和难度曲线

6.1 下一个方块预览的实现思路

预览区通常放在主窗口右侧,宽度 120 像素左右。你需要维护一个next_shape变量,在当前方块固定后,把next_shape赋给current_shape,再随机生成一个新的next_shape。渲染时在预览区画一个 4x4 的网格,把next_shape居中画进去。注意预览区的坐标原点和主 board 不同,需要单独计算偏移。

def draw_next_piece(screen, shape, start_x=320, start_y=50): """在右侧预览区绘制下一个方块""" for y, row in enumerate(shape): for x, cell in enumerate(row): if cell: pygame.draw.rect(screen, (255, 255, 255), (start_x + x*30, start_y + y*30, 30, 30))

逻辑说明:start_x=320是因为主窗口宽 300,右侧留 120 像素预览区,从 320 开始画比较居中。start_y=50是给标题留空间。参数上,你可以根据next_shape的实际宽高动态调整start_x,让方块在预览区居中,而不是固定左上角。

6.2 难度曲线:每消 10 行提速一次

固定下落速度玩久了会无聊,加一个简单的难度曲线能明显提升体验。我一般会在消行后判断总消行数,每满 10 行就把fall_speed减少 50 毫秒,最低不低于 100 毫秒。

total_lines = 0 fall_speed = 500 # 在消行后更新 total_lines += lines_cleared if total_lines // 10 > (total_lines - lines_cleared) // 10: fall_speed = max(100, fall_speed - 50)

逻辑说明:total_lines // 10计算当前达到的「十行档位」,如果这次消行后档位比之前高,就提速。max(100, ...)防止速度降到 0 导致方块瞬间落地。参数上,50是每次提速的幅度,你可以改成 30 或 80,根据自己手感调。

6.3 一个我踩过的坑:预览区方块颜色和主区不一致

早期我图省事,预览区直接用白色画所有方块,结果玩家分不清下一个是 I 还是 O。后来改成从COLORS字典里按方块类型取色,但next_shape只存了矩阵,没存类型。解决办法是同时维护next_type变量,生成新方块时把类型也记下来。这个改动很小,但体验提升很明显。

# 生成新方块时 next_type = random.choice(list(SHAPES.keys())) next_shape = SHAPES[next_type] # 预览时 draw_next_piece(screen, next_shape, COLORS[next_type])

逻辑说明:random.choice(list(SHAPES.keys()))从 7 种方块里等概率选一个。参数上,如果你想让某种方块出现概率低一点,可以用random.choices配合权重,但经典俄罗斯方块是等概率的,不建议改。

6.4 验证方法:用固定随机种子复现同一局

调试的时候,每次随机方块不一样很难定位问题。我一般会在开头加random.seed(42),这样每次运行生成的方块序列完全一致。等你确认逻辑没问题了,再把种子去掉或者改成random.seed()用系统时间。这个技巧在排查「为什么第 37 个方块必现碰撞 bug」时特别有用,省得你反复重开游戏碰运气。

import random random.seed(42) # 调试时固定序列,上线前删掉或改成 random.seed()

逻辑说明:random.seed(42)让随机数生成器从固定状态开始,后续所有random.choice结果可预测。参数上,42 只是习惯用法,任何整数都行。注意如果你用了random.shuffle或者其他随机函数,种子同样会影响它们。

这套东西我从第一次照着实训资料跑通,到后来自己重写了好几遍,最大的体会是:俄罗斯方块看起来简单,但每一个边界条件都能让你调半天。我现在的习惯是先把碰撞检测和消行逻辑用固定种子跑 100 局,确认没有数组越界和死循环,再去调手感和界面。希望帮到你。

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

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

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

立即咨询