简介:基于 Python pygame 开发的经典俄罗斯方块小游戏课程实训资源,面向高校计算机相关专业学生、教师及游戏开发初学者,特别适合课程实训、课程设计或 Python 游戏开发入门练习。压缩包共 6 个文件,仅 6.88MB,包含 Python 脚本、Word 设计报告、Markdown 说明文档、wav/mp3 音频素材及文本计分文件,覆盖从代码实现到文档撰写的完整流程。代码经过严格测试,功能完善可稳定运行,能帮助读者清晰理解 pygame 游戏循环、事件处理、方块旋转、碰撞检测与消行计分等核心机制;结合设计报告与说明文档,既能快速完成课程作业,也能在此基础上修改玩法或界面,作为毕业设计、项目初期演示或进阶学习与二次开发的起点。已有 46 人学习/下载,资源体量小、内容完整,适合希望快速掌握 pygame 小游戏开发的学生参考。
1. 既然课程实训要选 Python 项目,pygame 俄罗斯方块为什么是最值得写透的题目
把俄罗斯方块作为 Python 课程实训题,最常听到的评价是“太简单”。但真把第一块方块落到屏幕上就会发现,pygame 的事件循环、方块旋转、落定判定、消行结算,每一环都藏着足够让你熬夜调试的细节——这正是它被反复用作实训题的原因:麻雀虽小,五脏俱全。围绕这套课程实训,你要交付的不只是一段能跑的小游戏,还要有一份说得清设计思路的报告;而报告里的干货,正是你如何组织数据结构、如何用源代码把规则讲明白。下面这套方案按最常见的工程实现来走,不依赖某个现成压缩包,从坐标表到可运行文件完整过一遍。
2. 俄罗斯方块的坐标体系与旋转算法:先建表再写逻辑
2.1 用 20x10 二维数组做游戏区,方块落定就是“写入值”
一个俄罗斯方块游戏区天然适合用二维数组建模。取常量ROWS = 20、COLS = 10,Python 里一行就能初始化:
board = [[0] * COLS for _ in range(ROWS)]这里每个格子存什么取决于习惯:存0表示空,存正整数表示已落定的方块编号,后续渲染时再查颜色表。也有人喜欢直接存(r, g, b)元组,渲染时免一次查询,但那样消行判断时要比较元组,反而不如整数直观。我一般用整数编号,理由有两个:一是all()和if board[r][c]对非零整数天然友好,消行逻辑会简洁不少;二是报告里画状态图时,把二维数组打印成 ASCII 数字看板,比对着颜色调试快得多。
要特别提醒的是初始化写法。[[0] * COLS] * ROWS看起来一样,实际上每一行都是同一个列表对象的引用,改一行会“传染”到所有行。这是 Python 列表最常见的坑,写进报告反而能体现你理解引用语义。
2.2 预定义七种方块:矩阵表示比相对坐标更稳
方块的形状定义有两种主流做法,一种是存每个旋转态的相对坐标列表,另一种是直接用二维矩阵。常见 C 语言实现里,常把每种方块定义成 4x4 位数组;在 Python 里我用矩阵字符串,代码短而且调试时可读性好:
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']], } COLORS = { 'I': (0, 240, 240), # 青 'O': (240, 240, 0), # 黄 'T': (160, 0, 240), # 紫 'S': (0, 240, 0), # 绿 'Z': (240, 0, 0), # 红 'J': (0, 0, 240), # 蓝 'L': (240, 160, 0), # 橙 } def shape_cells(matrix): return [(r, c) for r, row in enumerate(matrix) for c, val in enumerate(row) if val == '1']shape_cells把矩阵里'1'的位置转成相对坐标列表,之后碰撞检测和绘制都基于这份坐标,不再直接操作矩阵。这样做的意义在于把“形状”和“位置”解耦:方块对象只维护一份相对坐标和一个(row, col)原点,逻辑上更接近游戏规则本身。
下面这张表是七种方块的矩阵尺寸、颜色和边界特征,写报告时可以直接做成表格:
| 方块 | 基准矩阵尺寸 | 颜色 RGB | 边界特征 |
|---|---|---|---|
| I | 1x4 | (0, 240, 240) | 旋转后宽高互换,顶部翻转需额外处理 |
| O | 2x2 | (240, 240, 0) | 旋转不变,无需旋转逻辑 |
| T | 2x3 | (160, 0, 240) | 对称结构,旋转中心在中轴 |
| S / Z | 2x3 | 绿 / 红 | 镜像关系,旋转时易碰左右墙 |
| J / L | 2x3 | 蓝 / 橙 | 镜像关系,标准踢墙场景最多 |
2.3 旋转算法:转置加行反转,等价于顺时针 90 度
矩阵旋转不需要引入数学库,三行就能实现:
def rotate_clockwise(matrix): return [list(row)[::-1] for row in zip(*matrix)]拆开看,zip(*matrix)把行变成列,得到转置矩阵;row[::-1]再对每一行做水平翻转。两步合起来,正好是把每个旧坐标(r, c)映射到(c, rows-1-r),也就是顺时针旋转 90 度。
如果用相对坐标表示方块,顺时针旋转的坐标公式是(r, c) -> (c, -r),数学上完全等价,但矩阵法不用操心旋转中心偏移。I 方块在 4x1 和 1x4 之间切换,矩阵法天然成立;O 方块旋转后不变,也不会破坏矩阵尺寸。
旋转后可能出现两个问题:一是新坐标越出左右边界,二是旋转后穿过已落定的方块区。这两类问题统一交给碰撞检测处理,通不过就做“踢墙”——尝试向左或向右平移 1 到 2 格,直到位置合法。这个细节放到第 4 章的游戏类里一起实现。
2.4 碰撞检测:移动、旋转、下落共用同一个函数
def collides(board, cells, pos): for r, c in cells: nr, nc = r + pos[0], c + pos[1] if nc < 0 or nc >= COLS or nr >= ROWS: return True if nr >= 0 and board[nr][nc]: return True return False参数board是当前盘面,cells是方块相对坐标,pos是方块原点。逻辑只有两类判断:出界算碰撞;落在已有方块上算碰撞。顶部nr < 0不算碰撞,因为方块从顶部生成时,部分旋转态可能短暂悬在可见区域外,硬判为碰撞会让旋转在顶部失灵。
注意落定时写盘要再检查一次nr >= 0,防止把负行索引写进数组。这一处是课程作业里常见的隐藏 bug:方块在顶部旋转后立刻落定,程序把board[-1]当成最后一行,整个盘面底部被污染。
3. pygame 主循环与网格渲染:先让窗口跑起来
3.1 安装 pygame 遇到 failed to build wheel 时的排查顺序
课程第一天就卡在依赖上是常事。常规安装命令就两条:
python -m pip install pygame python -c "import pygame; print(pygame.version.ver)"报错error: failed to build 'pygame' when getting requirements to build wheel这些年出现频率很高,本质是当前解释器没有可用的预编译 wheel,pip 退回去尝试源码编译,而源码编译又失败。常见原因和处理方式如下表:
| 现象 | 原因 | 处理 |
|---|---|---|
| 日志中出现 Building wheel for pygame | Python 版本过新或 pygame 版本偏旧 | 先python -m pip install -U pip,再装最新版pygame;优先用 Python 3.10 ~ 3.12 |
| Linux 下编译失败,提示缺 SDL 相关头文件 | 系统缺少底层依赖 | 直接sudo apt install python3-pygame,用系统包绕开编译 |
| 装完 import 报 DLL load failed | 环境混杂或下载来源不正规 | 卸载后用官方 pip 重装,不要从第三方站点下 whl |
这里有一个实训前的建议:在第一周就把运行环境统一好,把import pygame的验证结果截图放进报告的环境说明章节,可以避免答辩现场一半以上的故障。
3.2 最小主循环模板:set_mode、event.get、flip、tick
pygame 的主循环是所有交互程序的骨架,最小可用版本只有十几行:
import pygame pygame.init() screen = pygame.display.set_mode((300, 600)) # 10 x 20 格子,每格 30min clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False screen.fill((10, 10, 10)) pygame.display.flip() clock.tick(60) pygame.quit()这里我把窗口尺寸临时写成了 300x600,实际项目中应该用COLS * CELL和ROWS * CELL计算。pygame.event.get()会取出这一帧的所有事件,逐项判断;display.flip()把后台 buffer 一次性交换到屏幕上,这是 pygame 双缓冲的标准做法;clock.tick(60)把帧率锁在 60 FPS,同时返回上一帧经过的毫秒数,这个返回值是后续做自动下落计时的基础。
需要注意while running和for event的结构不能反。事件必须在每帧开头处理完,再进入渲染,否则按键会出现肉眼可见的延迟。课程报告里描述这个循环时,最好画出“”事件处理 → 更新逻辑 → 渲染”的顺序图。
3.3 逐格绘制网格和方块:先涂填充色,再画 1px 描边
网格渲染最简单可靠的做法是逐格画矩形,不追求任何贴图优化:
CELL = 20 for r in range(ROWS): for c in range(COLS): rect = pygame.Rect(c * CELL, r * CELL, CELL, CELL) pygame.draw.rect(screen, (20, 20, 20), rect) pygame.draw.rect(screen, (40, 40, 40), rect, 1)pygame.draw.rect的第四个参数是描边宽度,传1就只画边框,不传则整块填充。先填充底色再叠加描边,网格线看起来是细线而不是粗框。整个盘面只有 200 个格子,逐格绘制每帧开销完全可以忽略,不需要把盘面预渲染成一张大图。
当前正在下落的方块用同样的方式画到屏幕,坐标加上原点偏移:
for r, c in game.cells: nr, nc = r + game.pos[0], c + game.pos[1] if nr >= 0: rect = pygame.Rect(nc * CELL, nr * CELL, CELL, CELL) pygame.draw.rect(screen, game.color, rect)渲染逻辑的核心原则是“每次重画整个画面”。pygame 不像前端框架有脏矩形机制,全量重绘反而最不容易出残影。只要fill在绘制前执行,就不会有拖影问题。
4. 核心玩法规则:下落、消行、计分与七种方块的随机策略
4.1 自动下落和硬降:一帧一帧判定,而不是用 sleep
自动下落不能用time.sleep(),那会卡住整个事件循环。标准做法是在主循环里累加帧间隔毫秒数,达到阈值才执行一步下落:
fall_timer += dt if fall_timer >= game.fall_interval_ms(): game.move_down() fall_timer = 0硬降(hard drop)则直接循环下落到底,并把移动的格数折算成分数:
def hard_drop(self): distance = 0 while not collides(self.board, self.cells, (self.pos[0] + 1, self.pos[1])): self.pos = (self.pos[0] + 1, self.pos[1]) distance += 1 self.score += distance * 2 self.lock()软降(按方向键下)和硬降的区别在于:软降一格加 1 分,硬降按距离每格 2 分。这个计分规则是游戏业界的通用约定,写在报告里能让评分人看出你参考过真实 Tetris 的规则。
4.2 落定与锁定:坐标写进盘面后再生成下一个方块
当方块无法再下落时,要把当前相对坐标换算成绝对坐标写进board,这个过程叫 lock:
def lock(self): for r, c in self.cells: nr, nc = r + self.pos[0], c + self.pos[1] if 0 <= nr < ROWS: self.board[nr][nc] = 1 # 这里存颜色编号,渲染时查表 cleared = self.clear_full_rows() ... self.spawn()写盘之后紧跟着消行计分,然后生成新方块。如果spawn()时新方块出生点就发生碰撞,游戏结束。这里一个容易踩的坑是 lock 后忘记检查负行,前面 2.4 节已经提过,写盘处的0 <= nr < ROWS是必须的。
4.3 消行算法:从下往上遍历,del 后 insert 保持盘面高度
消行的标准实现是从底部向上扫描,满行删除并在顶部补一行空行:
def clear_full_rows(self): cleared = 0 r = ROWS - 1 while r >= 0: if all(self.board[r]): del self.board[r] self.board.insert(0, [0] * COLS) cleared += 1 else: r -= 1 return cleared为什么用del加insert而不是直接把上方所有行往下移?因为列表自带的方法更简洁,而且del self.board[r]会让数组长度减一,insert(0, ...)再补回顶部,盘面始终是 20 行。注意删除满行后不能立刻r -= 1,因为上面一行已经掉到当前索引位置,要继续检查同一索引;只有当前行不满时才向上移动。
提示:不要把
all(self.board[r])写成0 not in self.board[r],后者同样正确,但all对非零整数数组的意图表达更直接,也更快。
4.4 计分表、等级速度、7-bag 随机
俄罗斯方块的标准计分规则按消除行数线性区分,四个等级的分值如下表:
| 同时消除行数 | 得分 |
|---|---|
| 1 | 100 |
| 2 | 300 |
| 3 | 500 |
| 4 | 800 |
落实成代码可以用一个元组直接查表:
self.score += (0, 100, 300, 500, 800)[min(cleared, 4)]等级影响下落速度,最简单可靠的是线性加速:
def fall_interval_ms(self): return max(100, 800 - (self.level - 1) * 50)也就是每升一级,下落间隔减少 50 毫秒,下限 100 毫秒,防止后期快到无法操作。
七种方块的随机生成不要用每次独立random.choice,那样连出同一种方块的概率偏高,T 块连续出现的频率会明显破坏体验。课程实训里最合适的是 7-bag 随机:
import random def refill_bag(self): if not self.bag: self.bag = list(SHAPES.keys()) random.shuffle(self.bag) def spawn(self): self.refill_bag() name = self.bag.pop() ...7-bag 保证每 7 个方块内七种形状各出现一次,顺序随机。这个机制很容易被答辩老师追问,讲清楚“为什么不用纯随机”,体现的是对玩家体验的思考。
4.5 组装game.py:一个能直接跑起来的俄罗斯方块骨架
综合前面的逻辑,把方块、盘面、规则收进一个Tetris类,主循环只负责事件和渲染:
import random import pygame ROWS, COLS = 20, 10 CELL = 20 SHAPES = { ... } # 见 2.2 COLORS = { ... } # 见 2.2 def shape_cells(matrix): return [(r, c) for r, row in enumerate(matrix) for c, v in enumerate(row) if v == '1'] def rotate_clockwise(matrix): return [list(row)[::-1] for row in zip(*matrix)] def collides(board, cells, pos): for r, c in cells: nr, nc = r + pos[0], c + pos[1] if nc < 0 or nc >= COLS or nr >= ROWS: return True if nr >= 0 and board[nr][nc]: return True return False class Tetris: def __init__(self): self.board = [[0] * COLS for _ in range(ROWS)] self.bag = [] self.score = 0 self.lines = 0 self.level = 1 self.over = False self.spawn() def refill_bag(self): if not self.bag: self.bag = list(SHAPES.keys()) random.shuffle(self.bag) def spawn(self): self.refill_bag() name = self.bag.pop() self.matrix = [list(row) for row in SHAPES[name]] self.cells = shape_cells(self.matrix) self.color = COLORS[name] self.pos = (0, (COLS - len(self.matrix[0])) // 2) if collides(self.board, self.cells, self.pos): self.over = True def move(self, dx): nx = self.pos[1] + dx if not collides(self.board, self.cells, (self.pos[0], nx)): self.pos = (self.pos[0], nx) def rotate(self): rotated = rotate_clockwise(self.matrix) cells = shape_cells(rotated) if not collides(self.board, cells, self.pos): self.matrix, self.cells = rotated, cells return for offset in (-1, 1, -2, 2): # 简单踢墙 if not collides(self.board, cells, (self.pos[0], self.pos[1] + offset)): self.matrix, self.cells = rotated, cells self.pos = (self.pos[0], self.pos[1] + offset) return def move_down(self): nr = self.pos[0] + 1 if not collides(self.board, self.cells, (nr, self.pos[1])): self.pos = (nr, self.pos[1]) return True self.lock() return False def hard_drop(self): distance = 0 while not collides(self.board, self.cells, (self.pos[0] + 1, self.pos[1])): self.pos = (self.pos[0] + 1, self.pos[1]) distance += 1 self.score += distance * 2 self.lock() def lock(self): for r, c in self.cells: nr, nc = r + self.pos[0], c + self.pos[1] if 0 <= nr < ROWS: self.board[nr][nc] = 1 cleared = self.clear_full_rows() self.lines += cleared self.score += (0, 100, 300, 500, 800)[min(cleared, 4)] self.level = min(self.lines // 10 + 1, 15) self.spawn() def clear_full_rows(self): cleared = 0 r = ROWS - 1 while r >= 0: if all(self.board[r]): del self.board[r] self.board.insert(0, [0] * COLS) cleared += 1 else: r -= 1 return cleared def fall_interval_ms(self): return max(100, 800 - (self.level - 1) * 50) def main(): pygame.init() screen = pygame.display.set_mode((COLS * CELL, ROWS * CELL)) pygame.display.set_caption("Tetris") clock = pygame.time.Clock() game = Tetris() fall_timer = 0 while not game.over: dt = clock.tick(60) for event in pygame.event.get(): if event.type == pygame.QUIT: return if event.type == pygame.KEYDOWN: if event.key == pygame.K_LEFT: game.move(-1) elif event.key == pygame.K_RIGHT: game.move(1) elif event.key == pygame.K_DOWN: if game.move_down(): game.score += 1 fall_timer = 0 elif event.key == pygame.K_UP: game.rotate() elif event.key == pygame.K_SPACE: game.hard_drop() fall_timer = 0 fall_timer += dt if fall_timer >= game.fall_interval_ms(): game.move_down() fall_timer = 0 screen.fill((10, 10, 10)) for r in range(ROWS): for c in range(COLS): rect = pygame.Rect(c * CELL, r * CELL, CELL, CELL) pygame.draw.rect(screen, (20, 20, 20), rect) pygame.draw.rect(screen, (40, 40, 40), rect, 1) for r, c in game.cells: nr, nc = r + game.pos[0], c + game.pos[1] if nr >= 0: pygame.draw.rect( screen, game.color, pygame.Rect(nc * CELL, nr * CELL, CELL, CELL)) pygame.display.flip() print("score:", game.score) pygame.quit() if __name__ == "__main__": main()几个容易写错的点已经注释在代码里:clear_full_rows的索引不自减逻辑、lock 前的负行检查、移动和旋转共用碰撞函数。运行这段代码前,先把SHAPES和COLORS两个字典从 2.2 节完整复制进来,缺了它们程序无法启动。
这套类结构的最大好处是:游戏逻辑和 pygame 渲染完全分离,Tetris类里不出现任何pygame对象。后续写单元测试、换 GUI 框架、接入 AI 自动玩,都不用改规则代码。
5. 交作业前的手感与验证:DAS 参数和 pytest
5.1 手动控制左右移动的 DAS 与 ARR,而不是用 set_repeat
pygame.key.set_repeat(delay, interval)虽然能实现按住方向键连续移动,但它会对所有按键生效,软降也会被强制复读,手感生硬。更专业的做法是手动实现 DAS(按键重复延迟)和 ARR(重复移动间隔),这也是从街机 Tetris 开始就存在的参数体系:
DAS = 170 # 按住方向键后,第一次重复移动的延迟,单位毫秒 ARR = 30 # 之后每两次重复移动的间隔 held_left = held_right = False repeat_timer = 0 moved_once = False # 主循环里: keys = pygame.key.get_pressed() if keys[pygame.K_LEFT] and not keys[pygame.K_RIGHT]: if not moved_once: game.move(-1) repeat_timer = 0 moved_once = True elif repeat_timer >= (DAS if repeat_timer == 0 else ARR): game.move(-1) repeat_timer = 0这个实现思路比直接用set_repeat更能体现你对输入系统的理解,答辩时把 DAS 和 ARR 参数表列出来,说明它们分别控制什么,是很加分的细节。需要注意的是repeat_timer每次要累加dt,并且松开按键后要重置moved_once。
5.2 用 pytest 验证消行逻辑,替你把报告里的“测试”部分坐实
课程报告里写“测试通过”没有说服力,放一段可执行的单元测试才有分量。Tetris类不依赖 pygame 渲染,正好可以直接测:
def test_clear_full_rows_removes_only_full_line(): game = Tetris() game.board[ROWS - 1] = [1] * COLS game.board[ROWS - 3][3] = 1 cleared = game.clear_full_rows() assert cleared == 1 assert game.board[ROWS - 1] == [0] * COLS assert game.board[ROWS - 4][3] == 1 # 下方保留的方块下落了一行这段测试模拟底部有一行满行、倒数第三行有一个孤立格子的场景。clear_full_rows只应删掉满行,孤立格子随之上移一行。写assert时要注意索引方向:board[ROWS - 1]是屏幕最底行,删除后原始倒数第三行变成倒数第四行。
运行python -m pytest test_tetris.py,全绿之后再把代码和测试一起放进实训报告附录。报告里的测试章节不要贴整段游戏代码,重点贴这段断言,配一句“测试覆盖满行消除与行位移”就够了——这比任何运行截图都更能经得起追问。
本文还有配套的精品资源,点击获取