简介:基于Python pygame的经典俄罗斯方块小游戏开发资源包,面向计算机相关专业高校学生与Python游戏开发初学者,可完成课程实训、课程设计或期末作业,也可作为毕设或项目立项演示。资源共6个文件,包含tetris.py主程序、wav与mp3背景音乐素材、设计报告docx、说明文档md、scores.txt分数记录等,整体仅6.88MB,结构紧凑便于快速部署运行。代码经过严格测试,功能完善且稳定运行,已有46人学习下载。借助设计文档与说明资料,读者能快速搭建环境,清晰理解游戏循环、方块生成、碰撞检测与消行逻辑;还可在此基础上扩展计分系统、难度升级或自定义音效,适合从入门到进阶逐步完善。若运行环境配置遇阻,亦可获得远程指导与技术交流支持。无论课设演示还是自学练手,都能从中获得完整可运行的实践样本。
1. 课程实训里的俄罗斯方块:一个 pygame 项目值不值得从零手写一遍
打开那个“课程实训-基于Python pygame的经典俄罗斯方块小游戏开发”的zip,里面多半是源代码、一份报告和一堆资料截图。很多学校的Python课程实训把这个题目当样板,原因很简单:pygame的俄罗斯方块同时压着逻辑、事件、渲染和打包,一个小项目就能把你前两年学的大部分知识点串起来。但老实说,那份zip只是起点,真正值钱的是你把它拆开后自己再组装一遍。适合谁?适合正在做Python课程设计、想用pygame写游戏入门、以及打算从“复制代码”过渡到“自己写”的同学。写得越深你越会明白:俄罗斯方块的核心不是图像,是一张10x20的网格。
2. 用 pygame 做俄罗斯方块前,先把游戏逻辑和渲染拆开:环境准备与文件结构
2.1 Python 与 pygame 环境:先装对版本再动手
我见过太多实训同学卡在环境上,游戏代码还没开始写,安装pygame就报错。建议从虚拟环境开始,别直接往系统Python里塞。
python3 -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate python -m pip install --upgrade pip pip install pygame装完检查版本:
python -c "import pygame; print(pygame.version.ver)"pygame 2.x 是目前主力,支持 Python 3.7 以上。如果你在装Python时没勾选“Add Python to PATH”,那先去找一份新的python安装教程把PATH配好,否则命令会直接变成“python不是内部或外部命令”。在PyCharm里配置python环境时,记得把虚拟环境解释器指到venv/bin/python(Windows 是venv\Scripts\python.exe),别选系统默认的那个。这一步踩对,后面少一半坑。
2.2 实训项目的源码结构:逻辑层与渲染层分离
课程实训的源码包如果只有一个大main.py,那叫能跑,不叫可维护。我会按职责拆成五个文件:
tetris_edu/ ├── main.py # 入口与主循环 ├── settings.py # 常量:网格大小、颜色、帧率 ├── game.py # 游戏逻辑:网格、方块、消行 ├── blocks.py # 七种方块的定义与旋转 ├── renderer.py # pygame 绘制 └── report.md # 课程报告(建议自己写)为什么一定要拆?因为俄罗斯方块本质是“离散网格上的状态变化”,这些变化完全不依赖pygame。你把逻辑层拆出来,就可以不启动窗口直接测试碰撞、消行和得分。这也是你和普通抄源码同学拉开差距的地方。
settings.py里放所有可调参数:
# settings.py COLS = 10 ROWS = 20 CELL = 30 # 每个网格像素尺寸 FPS = 60 # 游戏主循环帧率 BG_COLOR = (20, 20, 20) GRID_COLOR = (40, 40, 40)COLS和ROWS决定网格大小,CELL直接决定窗口尺寸,改一个参数就能适配手机竖屏或大屏。FPS不要拍脑袋填 60,先弄明白主循环里clock.tick(FPS)的意义:它控制每帧最大循环频率,并不是游戏运行得越满越好,俄罗斯方块这类逻辑游戏 30 到 60 都行,但键盘手感会受帧率影响,下面会专门说。
3. 核心逻辑:网格、方块旋转与碰撞检测,这是游戏的黑匣子
3.1 用 10x20 网格和 7 种方块定义游戏世界
很多人一上来就画界面,界面画完却不知道怎么处理方块堆叠。正确顺序是先定义数据结构。
网格我推荐用list of list:
grid = [[0 for _ in range(COLS)] for _ in range(ROWS)]0表示空,非 0 值存方块颜色索引。这样渲染层直接查COLORS[cell],逻辑层则只看这个格子是否为 0。
七种方块不必用二维数组硬编码,用坐标列表更灵活:
# blocks.py SHAPES = { 'I': [(0, 1), (1, 1), (2, 1), (3, 1)], 'O': [(0, 0), (1, 0), (0, 1), (1, 1)], 'T': [(0, 0), (1, 0), (2, 0), (1, 1)], 'S': [(1, 0), (2, 0), (0, 1), (1, 1)], 'Z': [(0, 0), (1, 0), (1, 1), (2, 1)], 'J': [(0, 0), (0, 1), (1, 1), (2, 1)], 'L': [(2, 0), (0, 1), (1, 1), (2, 1)], }坐标按(x, y)存,x是列,y是行,原点在方块列的左上。为什么不用三维数组?因为旋转操作本质是坐标变换,列表比矩阵好写,也容易做碰撞检测。这里要注意O方块只有一种旋转状态,I方块要特殊处理,后面避坑会说。
活动方块还需要独立状态:
class Block: def __init__(self, kind): self.kind = kind self.coords = [list(p) for p in SHAPES[kind]] self.x = COLS // 2 - 1 # 生成位置 self.y = 0 # 顶行x和y是方块整体在网格中的偏移,网格里实际坐标是(self.x + px, self.y + py)。这个偏移量是碰撞检测和移动的基础。
3.2 旋转矩阵与墙踢(Wall Kick):最容易翻车的一环
旋转不是简单地改数组,而是对坐标做矩阵变换。顺时针旋转的变换是(x, y) -> (y, -x):
def rotate(coords, times=1): for _ in range(times): coords = [(y, -x) for x, y in coords] return sorted(coords)这个公式对大部分方块没问题,但转完之后的坐标可能带负数,必须先检查再落格。更麻烦的是旋转后贴墙,比如I方块竖起来时,如果原位置靠近墙,直接旋转就穿模了。常见的做法是“墙踢”(Wall Kick):旋转后若碰撞,尝试左右平移 1 格、2 格,直到能放下。
def try_rotate(block, grid): rotated = rotate(block.coords) for dx in (0, -1, 1, -2, 2): tmp_coords = rotated.copy() if not collide(block.x + dx, block.y, tmp_coords, grid): block.coords = tmp_coords block.x += dx return True return False偏移表(0, -1, 1, -2, 2)是经验值,先试原位置,不行再左移右移。墙踢做不好,玩家会在墙壁附近一转方块就“被吞掉”,这是俄罗斯方块手感崩坏的头号原因。注意collide函数要同时检查越界和重叠:
def collide(offset_x, offset_y, coords, grid): for x, y in coords: nx, ny = offset_x + x, offset_y + y if nx < 0 or nx >= COLS or ny >= ROWS: return True if ny >= 0 and grid[ny][nx]: return True return Falseny >= 0是为了允许方块在顶行以上生成,顶部可以暂时越界,但不能在已有方块里重叠。很多人把ny < 0也判成碰撞,结果新方块一生成就爆,这就是黑匣子没打开过的表现。
3.3 消行与得分:分数表怎么定才像经典版
消行的逻辑比旋转简单,但边界条件要写对。我一般这么写:
def clear_rows(grid): rows_cleared = 0 new_grid = [row for row in grid if any(cell == 0 for cell in row)] rows_cleared = ROWS - len(new_grid) while len(new_grid) < ROWS: new_grid.insert(0, [0 for _ in range(COLS)]) return new_grid, rows_cleared核心是:保留所有“不是全满”的行,然后在顶部补空行。注意列表推导里any(cell == 0 for cell in row)的判断,如果一行全是非 0,any为False,这一行就会被删掉。补空行用insert(0, ...),让新空行出现在顶部,而不是底部,很多人写反了,导致消行后网格倒过来。
得分表建议直接套经典版:
| 一次消行数 | 得分 |
|---|---|
| 1 | 100 |
| 2 | 300 |
| 3 | 500 |
| 4 | 800 |
SCORE_TABLE = {1: 100, 2: 300, 3: 500, 4: 800} score += SCORE_TABLE.get(lines, 0)为什么不是 100/200/400?经典NES分值会鼓励玩家一次堆四行消长条,游戏节奏感更好。课程报告里如果说明这个分数表来自经典版本,会比随便写个固定加分更有说服力。
4. 用 pygame 把逻辑画出来:渲染循环、事件与音效
4.1 main loop:帧率、时钟与事件队列
逻辑层做完,再来碰pygame。入口文件里最忌讳把所有代码塞进一个while循环。主循环只做三件事:收事件、更新状态、重绘画面。
import pygame from settings import * def main(): pygame.init() screen = pygame.display.set_mode((COLS * CELL, ROWS * CELL)) pygame.display.set_caption("Tetris") clock = pygame.time.Clock() game = Game() # 来自 game.py renderer = Renderer(screen) running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: game.handle_key(event.key) game.update() # 下落、消行、判定结束 renderer.draw(game) pygame.display.flip() clock.tick(FPS) pygame.quit() if __name__ == "__main__": main()pygame.event.get()会清空事件队列,所以每帧必须立刻处理,否则键盘输入会堆积。pygame.display.flip()把绘制好的内容一次性换到屏幕上,比pygame.display.update()更省心,后者适合局部刷新。clock.tick(FPS)放在循环末尾,控制循环频率;如果放在draw前面,会导致绘制耗时也计入帧间隔,游戏在主菜单时快,游戏里方块多时慢。
4.2 画网格和活动方块:pygame.Rect 和 Surface 的配合
渲染器不该知道方块长什么样,它只按网格数据画方块。最简单的实现:
def draw(self, game): self.screen.fill(BG_COLOR) for y, row in enumerate(game.grid): for x, cell in enumerate(row): if cell: rect = pygame.Rect(x * CELL, y * CELL, CELL, CELL) pygame.draw.rect(self.screen, COLORS[cell], rect) for x, y in game.current.coords: px = (game.current.x + x) * CELL py = (game.current.y + y) * CELL pygame.draw.rect(self.screen, COLORS[game.current.kind], (px, py, CELL, CELL))这里用到pygame.Rect,它支持.x、.y、.width、.height等属性,也可以直接传元组。网格背景建议先画一次底色,再用GRID_COLOR画格子线,否则方块之间没有间隔,视觉上会糊成一团。格子线可以用pygame.draw.line循环画:
for x in range(COLS + 1): pygame.draw.line(self.screen, GRID_COLOR, (x * CELL, 0), (x * CELL, ROWS * CELL)) for y in range(ROWS + 1): pygame.draw.line(self.screen, GRID_COLOR, (0, y * CELL), (COLS * CELL, y * CELL))如果你要用图片素材,注意pygame.image.load之后要用.convert()或.convert_alpha()转换表面格式,否则每帧画图都会慢到肉眼可感知。俄罗斯方块用纯矩形就够了,素材反而是多余。
4.3 键盘控制、硬降与软降:按键的抖动处理
pygame 的键盘事件和重复触发机制是个隐藏坑。直接处理KEYDOWN,按住方向键时事件会按操作系统配置重复触发,导致方块一下子滑出好几格。解决方式是用pygame.key.set_repeat控制重复延迟:
pygame.key.set_repeat(200, 50) # 200ms 后开始重复,每 50ms 触发一次这个参数要放在主循环外设置一次。左右移动的响应速度由它决定,200 毫秒初始延迟比较接近经典手感的区间,太小会飘,太大按着不动不跟手。
按键逻辑放game.handle_key里:
def handle_key(self, key): if key == pygame.K_LEFT: self.move(-1) elif key == pygame.K_RIGHT: self.move(1) elif key == pygame.K_UP: self.try_rotate() elif key == pygame.K_DOWN: self.soft_drop() # 软降:下移一格并加分 elif key == pygame.K_SPACE: self.hard_drop() # 硬降:落到底并锁定软降和硬降是两种手感。软降每次 1 格,但要给每格加分,鼓励玩家用下键加速;硬降落到底,逻辑上要先把y增加到不能下移的位置,再调用锁定函数,否则会穿过已有方块。锁定时注意先固化到网格,再检查消行,再生成新方块。
音效部分,pygame 的官方指令是pygame.mixer.Sound,但音频资源文件在课程实训里常常被忽略。我一般建议先用pygame.mixer.Sound(buffer)生成一段正弦波作为消行音效,不依赖外部文件,报告里也好解释。混音器初始化位置要放在pygame.init()之后,否则可能无声。
5. 俄罗斯方块开发避坑指南:从逻辑测试到打包的 5 个常见问题
5.1 方块旋转后“穿墙”或“卡死”
现象:方块在贴近左墙时按上键,旋转后有一部分出现在墙外,或者直接消失。
原因:旋转函数只做了坐标变换,没有检查变换后的结果是否越界。个别实现用了二维数组作为方块形状,旋转后数组尺寸变化,没有相应修正偏移量,导致方块整体错位。
解决:旋转后先计算所有点的网格坐标,再统一做碰撞检测。用墙踢偏移表尝试左右平移,如果dx到±2仍不能落格,就放弃本次旋转。注意I方块旋转时,坐标范围从横向 4 格变成纵向 4 格,需要优先保证它离墙至少 1 格,所以要给它单独的旋转轴心,不然奇数格的墙踢表会不对称。
5.2 按键连发导致方块瞬间掉底
现象:玩家轻轻按一下方向键,方块连续移动好几格;按一下下键,方块直接到顶。
原因:pygame 的事件队列里,按住按键产生的KEYDOWN是按系统键盘重复率排入的,你的handle_key每个事件都执行一次移动,形成了“连发”。
解决:全局设置一次pygame.key.set_repeat(200, 50),这样重复事件就有 200ms 延迟。如果你希望左右移动和软降有不同的手感,可以在事件里区分重复事件类型。更彻底的做法是自己在主循环里跟踪按键状态,不依赖KEYDOWN的重复事件,比如每个update()判断pygame.key.get_pressed(),但那是另一套手感,没必要在课程实训里强行上。
5.3 消行闪烁和暂停时的状态丢失
现象:一行消掉后画面闪一下,或者按 P 暂停后,再恢复方块直接掉到底。
原因:消行后没有正确重建网格,clear_rows里补行位置写错,导致画面出现短暂错位。暂停时主循环还在调用game.update(),后台逻辑继续跑,只是画面没刷新,恢复后时间已经走了很远。
解决:消行逻辑单独测试,确保clear_rows返回的新网格行数永远是ROWS。暂停时用一个state变量判断,PAUSED状态下不调用game.update(),只渲染一个半透明遮罩。课程报告里可以把“暂停状态机”单独画一个表,三种状态:PLAYING、PAUSED、GAME_OVER,每个状态允许的事件不同,这是很好的工程点。
5.4 中文字体在 pygame 里显示为方块
现象:用pygame.font.render显示“分数”“暂停”等中文,屏幕上变成一排小方框。
原因:pygame 自带的默认字体没有中文字形,必须指定系统中文字体。
解决:Windows 上可以用微软雅黑路径:
font = pygame.font.Font("C:/Windows/Fonts/msyh.ttc", 24)macOS 用/System/Library/Fonts/PingFang.ttc。如果你要打包到别的机器,最好在settings.py里写一个字体路径列表,按系统逐个尝试,找不到就降级到默认字体。不要用pygame.font.SysFont("microsoftyahei", 24),SysFont的内部映射在不同系统行为不一致,直接给文件路径最稳。
5.5 pygame 窗口在 macOS Retina 屏模糊
现象:在 Retina 屏上运行,文字边缘发糊,方块线条不够锐利。
原因:pygame 2 的窗口默认不感知高 DPI 缩放,系统把画布放大却没用整数倍。
解决:创建窗口时加pygame.SCALED标志:
screen = pygame.display.set_mode((WIDTH, HEIGHT), pygame.SCALED)它会按系统缩放比例自动分配内部分辨率。注意pygame.SCALED需要 pygame 2.0 以上,并且不能和pygame.FULLSCREEN以外的模式随意组合。Windows 上如果遇到高 DPI 模糊,可以在main()最前面添加:
import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass这两处是平台相关的,代码要放在pygame.init()之前,才能真正改变渲染行为。
6. 用报告之外的方式验证你的项目:写一个自动测试脚本,验收更硬核
课程实训交一份报告和能跑的代码只是及格线。想让老师刮目相看,也让自己真正放心,我建议再写一个自动化测试脚本,专门验证核心逻辑。俄罗斯方块最难的就是旋转和消行,这两个函数一旦写错,手感是灾难。你可以用pytest,也可以用最朴素的assert。
# test_game.py from game import Game def test_rotate_i_block(): game = Game() game.spawn_block('I') original = game.current.coords.copy() game.try_rotate() after = game.current.coords # I 方块横向时有一格在 (0,1),旋转后应变成纵向 assert original == [(0, 1), (1, 1), (2, 1), (3, 1)] assert after == [(1, 0), (1, 1), (1, 2), (1, 3)] def test_clear_row(): game = Game() game.grid[19] = [1] * 10 new_grid, lines = game.clear_rows() assert lines == 1 assert new_grid[0] == [0] * 10运行pytest -q,如果全绿,你的逻辑层就是可信的。别小看这个动作,它能让你在高分榜里“刷分”时,不怀疑消行统计漏行。另一个更直观的验证技巧是写一个录像功能:每次按键和方块坐标变化都记录到日志,然后写一个回放脚本定时重放。这样即使逻辑诡异,你也能看到是哪个按键触发了什么状态。
我自己的教训是:当年做课程实训,报告里大谈“功能健全”,但游戏玩到第三关总会偶发闪退,最后发现是硬降到边界时坐标变成负数,列表索引越界却没被 Python 报错,因为负数下标正好指向了另一行的空格。后来我把所有方块操作前的坐标都打印出来,用日志对比,问题十分钟就定位了。你如果也遇到“偶尔出 bug”的玄学问题,第一件事就是打开日志,而不是盯着黑匣子猜。希望帮到你。
本文还有配套的精品资源,点击获取