Python pygame俄罗斯方块:二维数组、碰撞检测与状态机实战
发布时间:2026/9/19 1:10:18来源:尧图网络
简介这份资源是面向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/1board.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, times1): 绕原点旋转每次 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.choiceimport random class BagRandomizer: 每轮把 7 种方块打乱依次发完再开下一轮 def __init__(self, seedNone): 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 计分表与重力、锁定延迟一次消除行数基础分说明1100单消2300效率明显高于两次单消35004800四消风险收益最高连击加成上次分值 × 连击数连续消行不中断时累加重力必须用时间累加不能用帧计数否则换台电脑帧率一变方块下落速度就跟着变。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_INTERVAL0.5s调小下落更快难度上升LOCK_DELAY0.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_factorylist)每帧只存 4 个整数加一个字符串一局十分钟按 60 帧算也就几万条内存完全不是问题。重放时不要重新跑物理直接按frames里的位置画这样即使后来改了重力参数老录像也不会对不上。发现某一局手感异常把seed和帧序列拿出来就能在测试里复现出完全一样的局面。5.3 导出对局记录与项目文档调试信息攒下来之后总得给别人看。除了导出 JSON很多人会把对局摘要或教程说明整理成 PDF 分发用fpdf2几行就能写出来。# export_pdf.py —— 先 pip install fpdf2 from fpdf import FPDF def export(replay, pathreplay.pdf): pdf FPDF() pdf.add_page() pdf.set_font(Helvetica, size12) # 内置字体不支持中文 pdf.cell(0, 8, fseed{replay.seed}, ln1) 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}, ln1) pdf.output(path)ln1表示写完这一行后光标换行cell的第一个参数传 0 表示宽度铺满页面。需要注意Helvetica是内置字体只能写英文和数字要输出中文得先用add_font加载一个中文 TTF 文件否则遇到中文会直接抛异常。把教程说明和对局记录一起导出案例课的分发材料就齐了——代码在仓库里解释和成绩单在一份 PDF 里接收的人不用再问“这个参数改哪一行”。本文还有配套的精品资源点击获取
网站建设高端定制企业官网