pygame实现俄罗斯方块:从二维数组到消行算法的完整指南
简介基于 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 10Python 里一行就能初始化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边界特征I1x4(0, 240, 240)旋转后宽高互换顶部翻转需额外处理O2x2(240, 240, 0)旋转不变无需旋转逻辑T2x3(160, 0, 240)对称结构旋转中心在中轴S / Z2x3绿 / 红镜像关系旋转时易碰左右墙J / L2x3蓝 / 橙镜像关系标准踢墙场景最多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这些年出现频率很高本质是当前解释器没有可用的预编译 wheelpip 退回去尝试源码编译而源码编译又失败。常见原因和处理方式如下表现象原因处理日志中出现 Building wheel for pygamePython 版本过新或 pygame 版本偏旧先python -m pip install -U pip再装最新版pygame优先用 Python 3.10 ~ 3.12Linux 下编译失败提示缺 SDL 相关头文件系统缺少底层依赖直接sudo apt install python3-pygame用系统包绕开编译装完 import 报 DLL load failed环境混杂或下载来源不正规卸载后用官方 pip 重装不要从第三方站点下 whl这里有一个实训前的建议在第一周就把运行环境统一好把import pygame的验证结果截图放进报告的环境说明章节可以避免答辩现场一半以上的故障。3.2 最小主循环模板set_mode、event.get、flip、tickpygame 的主循环是所有交互程序的骨架最小可用版本只有十几行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这个过程叫 lockdef 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 随机俄罗斯方块的标准计分规则按消除行数线性区分四个等级的分值如下表同时消除行数得分1100230035004800落实成代码可以用一个元组直接查表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 参数和 pytest5.1 手动控制左右移动的 DAS 与 ARR而不是用 set_repeatpygame.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全绿之后再把代码和测试一起放进实训报告附录。报告里的测试章节不要贴整段游戏代码重点贴这段断言配一句“测试覆盖满行消除与行位移”就够了——这比任何运行截图都更能经得起追问。本文还有配套的精品资源点击获取