简介:这份资源是面向Python初学者与想要动手练手的开发者整理的俄罗斯方块游戏开发实战教程,以Pygame图形库为主线,串联游戏项目的完整实现流程。从需求分析、方块类设计、下落与碰撞检测,到行消除、得分机制与游戏结束判定,均给出简化代码示例与思路说明,帮助读者把语法知识落到可运行的小项目上。资源为单个PDF文件,压缩包约164KB,体积轻量,便于随时查阅或打印标注。目前已有204人学习,属于经部分读者验证过的入门参考。通过完整走一遍项目,读者能理清游戏循环、事件响应、渲染刷新与帧率控制等关键环节的处理方式,理解Tetromino类的属性与旋转移动方法如何组织,并在此基础上尝试加入音效、难度选择、高分记录与双人对战等扩展功能,适合希望用一个小项目打通Python与Pygame基础的读者。
1. 从“贪吃蛇跑不动”说起:Python 俄罗斯方块在练什么
很多人把 Python 游戏开发当成练手玩具,直到真正动手写俄罗斯方块,才发现它把二维数组、状态机、渲染循环和碰撞检测全塞进了一个不到 500 行的项目里。它比贪吃蛇多出来的东西很实在:方块要旋转,旋转后要判断能不能站得住,站得住之后还要判断有没有满行可以消,消完之后上方的格子整体下移,每个环节都能单独拆出来写测试。
这个标题对应的场景很具体:你想用 Python 做一个能跑起来的桌面小游戏,或者要讲一节项目开发案例课,需要一个看得见效果、又不必把时间耗在美术资源上的题目。俄罗斯方块正好卡在这个位置,10×20 的棋盘、7 种方块、4 个旋转态,规则边界清晰,代码量可控。
接下来按“先能跑、再手感对、最后能调试”的顺序推进,代码基于 pygame 2.x 和 Python 3.10 以上。核心逻辑会和渲染分离,不装图形库也能对棋盘跑单元测试。
2. 用 pygame 把 10×20 的棋盘先立起来
先把画面跑通,再谈玩法。棋盘没画出来之前,任何关于旋转和消行的讨论都是空转。
2.1 坐标系选 row 在前,后面消行会省一半代码
俄罗斯方块的棋盘是个二维网格,索引顺序只有两种写法:board[row][col]或board[col][row]。选错一次,后面每一处循环都要重新对一遍下标,消行时还得先转置。
常见的做法是用board[row][col],也就是行在前。理由是消行这个操作天然按行做,board的每一个元素就是一行,判断、过滤、删除都用同一条路径。屏幕上向下是 row 增大,向右是 col 增大,和 pygame 的Rect(x, y, w, h)只差一次乘法换算,映射关系直观。
| 方案 | 数据结构 | 消行怎么写 | 适用场景 |
|---|---|---|---|
| 二维列表 | board[r][c]存 0/1 | board.pop(r)后顶部补空行 | 教学、调试、可读性优先 |
| 行位掩码 | 每行压成一个 int | 判断row == FULL_MASK | 追求判定速度,或想接自动玩 |
| 坐标集合 | 存(r, c)元组 | 需要按行分组再计数 | 稀疏棋盘、大型格子 |
提示:一开始就固定索引顺序,整个项目里不要再出现另一种写法。下标混乱是这类小游戏最常见的返工来源。
2.2 一个不装图形库也能跑的最小骨架
把常量集中到一个文件,方便后面调参不动业务代码。下面是全局配置和棋盘渲染部分。
# settings.py —— 全局常量集中放,调参只改这一处 COLS, ROWS = 10, 20 # 棋盘列数、行数 CELL = 30 # 单个格子的像素边长 FPS = 60 # 渲染帧率,与下落速度解耦 GRAVITY_INTERVAL = 0.5 # 下落一格的间隔(秒) LOCK_DELAY = 0.5 # 触底后允许微调的宽限时间(秒)# main.py —— 先只画网格和空棋盘 import pygame from settings import COLS, ROWS, CELL, FPS WIDTH, HEIGHT = COLS * CELL, ROWS * CELL def draw_grid(screen): screen.fill((18, 18, 24)) for r in range(ROWS): for c in range(COLS): rect = pygame.Rect(c * CELL, r * CELL, CELL, CELL) # 1 像素边框当网格线,比单独画线少两次计算 pygame.draw.rect(screen, (40, 40, 50), rect, 1) def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False draw_grid(screen) pygame.display.flip() clock.tick(FPS) # 锁定帧率,避免空转吃满一个核心 pygame.quit() if __name__ == "__main__": main()draw_grid的双重循环是 200 次绘制调用,对这个规模完全够用,不要提前优化成缓存 Surface。clock.tick(FPS)的作用不只是限帧,它返回上一帧到这一帧的毫秒数,后面做重力累加时正好拿这个值当dt,比自己在循环里数帧数靠谱得多。
2.3 渲染和逻辑分开,测试才跑得起来
目录按职责切三块:settings.py放常量,tetromino.py放方块形状和旋转碰撞这类纯函数,main.py只负责事件循环和绘制。棋盘本身就是一个list[list[int]],不依赖 pygame 的任何对象。
这样切的好处是测试文件里import tetromino不需要初始化显示设备,在 CI 或者无桌面的服务器上也能跑。很多人写小游戏把所有逻辑塞进while循环里,最后想加一条断言都找不到入口,问题就出在这里。
3. 方块怎么表示、怎么转、什么时候算撞上
方块和棋盘的关系是这个项目里最容易写歪的部分。写歪的典型症状是:旋转之后方块穿进已有的堆里,或者贴着墙转不动。
3.1 坐标列表比矩阵更好用
7 种方块用相对坐标列表表示,每个元素是(col, row)偏移,原点是旋转中心。
# tetromino.py —— 每种方块用相对坐标表示 SHAPES = { "I": [(0, 1), (1, 1), (2, 1), (3, 1)], "O": [(1, 0), (2, 0), (1, 1), (2, 1)], "T": [(0, 1), (1, 1), (2, 1), (1, 0)], "S": [(0, 1), (1, 1), (1, 0), (2, 0)], "Z": [(0, 0), (1, 0), (1, 1), (2, 1)], "J": [(0, 0), (0, 1), (1, 1), (2, 1)], "L": [(0, 1), (1, 1), (2, 1), (2, 0)], }用坐标列表而不是 N×N 二维矩阵,主要赢在碰撞检测:只需要遍历 4 个有格子的位置,不用处理矩阵里的空位。
| 表示方式 | 旋转实现 | 优点 | 代价 |
|---|---|---|---|
| 相对坐标列表 | 公式变换每个点 | 碰撞检测只查 4 个点 | 形状需要脑补 |
| N×N 二维矩阵 | 转置后翻转 | 形状一眼可见 | 检测要跳过空格 |
| 预生成四个旋转态 | 查表 | 运行期零计算 | 要多存一份数据 |
3.2 旋转公式和一个容易搞反的符号
旋转 90 度的公式本质是坐标变换,两个方向写法只差一个符号。
def rotate(cells, times=1): """绕原点旋转,每次 90 度;times 对 4 取模,转四次回到原样""" out = list(cells) for _ in range(times % 4): out = [(y, -x) for x, y in out] return out(x, y) -> (y, -x)和(x, y) -> (-y, x)分别是两个方向,在屏幕坐标里 y 轴向下,视觉上就是顺时针和逆时针的区别。两种都不影响玩法正确性,影响的是手感,选一种后整个项目统一。times % 4这个取模不能省,否则连续按旋转键会让列表越滚越大,还可能出现浮点式的偏移累积。
3.3 碰撞检测的三个判断条件
def collides(board, cells, ox, oy): """cells 是相对坐标,ox/oy 是方块当前原点位置""" for dx, dy in cells: c, r = ox + dx, oy + dy if c < 0 or c >= COLS or r >= ROWS: return True # 出左右边界或越过底部 if r >= 0 and board[r][c]: return True # 与已固定的格子重叠 return False注意r >= 0——方块刚生成时,有一部分格子在棋盘上方,索引为负,这时候不能去查board,否则 Python 会从列表尾部取值,出现“明明没东西却判定碰撞”的诡异 bug。这个条件单独写一行,比塞进or里更容易被后来的人看见。
3.4 墙踢:转不动的时候往外挪一挪
贴着墙或者贴着地面时原地旋转会失败,这时候试着往旁边或上方挪一格再转,这就是墙踢。
KICKS = [(0, 0), (-1, 0), (1, 0), (0, -1), (-2, 0), (2, 0)] def try_rotate(board, piece): rotated = rotate(piece.cells) for dx, dy in KICKS: if not collides(board, rotated, piece.x + dx, piece.y + dy): piece.cells = rotated piece.x += dx piece.y += dy return True return False # 全部试探都撞,本次旋转作废| 偏移 | 用途 | 典型场景 |
|---|---|---|
(0, 0) | 原地旋转 | 空旷区域 |
(-1, 0)/(1, 0) | 左右平移一格 | I 型横放贴左墙或右墙 |
(0, -1) | 向上挪一格 | 落地后想贴地转 |
(-2, 0)/(2, 0) | 一次挪两格 | I 型竖转横贴墙 |
这个顺序不能随便打乱。先原地,再一格,最后两格,玩家按一次键看到的结果才符合直觉。真正的比赛级游戏有更完整的踢墙表,包含旋转前后的状态组合,但对教学项目来说,这 6 个偏移已经能覆盖九成以上的卡墙场景。
4. 消行、计分和随机:手感差距其实都在这
能跑之后,决定“玩起来像不像俄罗斯方块”的就剩这三件事:什么时候消、给多少分、下一个是什么方块。
4.1 消行判定与行下移
def clear_lines(board): """返回 (新棋盘, 本次消除行数)""" kept = [row for row in board if not all(row)] cleared = ROWS - len(kept) # 顶部补空行,长度始终保持 ROWS return [[0] * COLS for _ in range(cleared)] + kept, cleared列表推导保序,kept里的行顺序和原来一致,所以补的空行必须加在前面。如果写成kept + [...],效果是整个棋盘上下颠倒,而且只在消行时出现,非常难查。
4.2 七袋随机器:为什么不该直接 random.choice
import random class BagRandomizer: """每轮把 7 种方块打乱依次发完,再开下一轮""" def __init__(self, seed=None): self.rng = random.Random(seed) # 固定种子可以复现整局序列 self.bag = [] def next(self): if not self.bag: self.bag = list(SHAPES.keys()) self.rng.shuffle(self.bag) return self.bag.pop()直接random.choice会出现连续五六个 S/Z 的情况,玩家会觉得“这游戏在整我”。七袋算法保证任意连续 7 个方块里每种各出现一次,最长间隔被限制住,难度曲线平稳。带seed的Random实例还有一个副作用:只要种子相同,整局方块的出场顺序完全可复现,调试和回放都靠它。
4.3 计分表与重力、锁定延迟
| 一次消除行数 | 基础分 | 说明 |
|---|---|---|
| 1 | 100 | 单消 |
| 2 | 300 | 效率明显高于两次单消 |
| 3 | 500 | |
| 4 | 800 | 四消,风险收益最高 |
| 连击加成 | 上次分值 × 连击数 | 连续消行不中断时累加 |
重力必须用时间累加,不能用帧计数,否则换台电脑帧率一变方块下落速度就跟着变。
drop_acc += dt if drop_acc >= GRAVITY_INTERVAL: drop_acc = 0 if not collides(board, piece.cells, piece.x, piece.y + 1): piece.y += 1 lock_acc = 0 # 还能往下掉,锁定倒计时清零 else: lock_acc += GRAVITY_INTERVAL if lock_acc >= LOCK_DELAY: lock_piece(board, piece) # 真正落定并生成下一块 drop_acc = lock_acc = 0| 参数 | 起步推荐值 | 调大/调小的效果 |
|---|---|---|
GRAVITY_INTERVAL | 0.5s | 调小下落更快,难度上升 |
LOCK_DELAY | 0.5s | 太短没法微调,太长会有粘滞感 |
| 软降倍率 | ×10 | 按住下键时的加速倍数 |
| 生成延迟 | 0.1~0.2s | 影响连续落块的节奏 |
LOCK_DELAY是新手最常忽略的一个参数。没有它,方块一触底就立刻锁死,玩家没法在最后一刻把它塞进缝里,操作会显得很硬。有了它,再配上“下落成功即清零”,那种可微调的手感才会出来。
5. 调试手感、回放与把项目沉淀成可分发文档
代码跑通之后,剩下的时间基本花在“为什么这一局感觉不对”上。这时候有比盯着屏幕看更高效的办法。
5.1 把核心逻辑写成 pytest 用例
棋盘和方块都是纯数据结构,测试写起来很便宜。
# test_tetromino.py from tetromino import rotate, collides, SHAPES def test_rotate_four_times_is_identity(): cells = SHAPES["T"] assert rotate(cells, 4) == cells # 转四次必须回到原样 def test_i_piece_hits_left_wall(): board = [[0] * 10 for _ in range(20)] # I 型横放,原点 x=-1 时最左格越界,应判碰撞 assert collides(board, SHAPES["I"], -1, 5) is True def test_o_piece_fits_in_open_board(): board = [[0] * 10 for _ in range(20)] assert collides(board, SHAPES["O"], 4, 4) is False这三条覆盖了旋转取模、边界越界和空旷落位三类问题。pytest -q跑一次不到一秒,比每次改完旋转公式都手动开一局快得多。写测试时不要断言绘制结果,只断言棋盘状态,这样测试永远不依赖显示设备。
5.2 用状态快照做回放
把每帧的方块位置、旋转次数和累计消行记下来,配合随机器种子,一局就能完整重建。
from dataclasses import dataclass, field @dataclass class Frame: frame_id: int piece: str # 方块类型,如 "T" x: int y: int rot: int # 旋转次数 0~3 lines: int # 累计消行 @dataclass class Replay: seed: int # 随机器种子,重放时保持一致 frames: list[Frame] = field(default_factory=list)每帧只存 4 个整数加一个字符串,一局十分钟按 60 帧算也就几万条,内存完全不是问题。重放时不要重新跑物理,直接按frames里的位置画,这样即使后来改了重力参数,老录像也不会对不上。发现某一局手感异常,把seed和帧序列拿出来,就能在测试里复现出完全一样的局面。
5.3 导出对局记录与项目文档
调试信息攒下来之后,总得给别人看。除了导出 JSON,很多人会把对局摘要或教程说明整理成 PDF 分发,用fpdf2几行就能写出来。
# export_pdf.py —— 先 pip install fpdf2 from fpdf import FPDF def export(replay, path="replay.pdf"): pdf = FPDF() pdf.add_page() pdf.set_font("Helvetica", size=12) # 内置字体,不支持中文 pdf.cell(0, 8, f"seed={replay.seed}", ln=1) for f in replay.frames[:200]: # 只导出前 200 帧,避免文件过大 pdf.cell(0, 6, f"{f.frame_id} {f.piece} x={f.x} y={f.y} r={f.rot} lines={f.lines}", ln=1) pdf.output(path)ln=1表示写完这一行后光标换行,cell的第一个参数传 0 表示宽度铺满页面。需要注意Helvetica是内置字体,只能写英文和数字,要输出中文得先用add_font加载一个中文 TTF 文件,否则遇到中文会直接抛异常。把教程说明和对局记录一起导出,案例课的分发材料就齐了——代码在仓库里,解释和成绩单在一份 PDF 里,接收的人不用再问“这个参数改哪一行”。
本文还有配套的精品资源,点击获取