1. 为什么拿Python写俄罗斯方块:一个"过时"项目的三大价值
先说个反直觉的结论:俄罗斯方块可能是你人生第一款游戏的最佳选择,但它绝不是"练手用的玩具项目"。我用Python实现过俄罗斯方块、贪吃蛇、2048、Flappy Bird,回过头来看,俄罗斯方块的技术含量最扎实,踩坑最多,做完之后对编程的理解也最通透。
你可能觉得这游戏1984年就有了,网上源码一大堆,再写一遍有什么意义?这个想法我特别理解,但实际动手之后你会发现,照抄源码和独立实现完全是两码事。自己写一遍俄罗斯方块,至少有三层价值是其他项目替代不了的:
第一层是游戏开发核心机制的系统训练。俄罗斯方块体量小,但五脏俱全:坐标系、碰撞检测、随机算法、实时输入响应、状态机切换、计分规则——这些是任何游戏开发都绕不开的底层逻辑。别小看这几百行代码,它把游戏引擎的基础概念全部覆盖了一遍。
第二层是对"状态管理"的深刻理解。这是我最看重的。俄罗斯方块的核心难点不在算法复杂度,而在于游戏状态特别多:当前方块在动、下一个方块在候场、方块落地后要固定、固定后要消行、消行后上面的行要整体下移……每一帧你都要同时维护这些状态,而且任何一处处理不当,游戏就会崩。这种"一堆状态互相影响"的场景,正是工程开发里最常见也最头疼的问题。
第三层是即时反馈带来的正向循环。写网页后端,你敲半天代码可能看不到任何效果;但写俄罗斯方块,每完成一个模块,游戏立刻就有肉眼可见的变化。这种反馈速度非常有利于坚持学下去,特别适合刚入门Python想找项目练手的朋友。
我这次用的是pygame,一个非常经典的2D游戏开发库。Pygame没有上手难度,装个库就能跑,API设计也很符合直觉。它不负责帮你封装游戏逻辑,只提供基础的窗口、绘制、事件处理能力,所有游戏逻辑都要你自己动脑实现——这正是我们要的效果,不然什么都让框架干完了,学不到东西。
这篇文章我会从游戏设计思路开始讲起,核心代码逐块拆解,最后聊几个我实测遇到的坑和应对方案,难度循序渐进,不管你是刚学完Python语法还是已经能独立写脚本,都能跟得上。
2. 动手前的设计:把"俄罗斯方块"翻译成数据结构
很多人写游戏有个坏习惯,一上来就写代码,结果写到一半发现逻辑乱成一团。我建议你学学写游戏的人的思路:先在草稿纸上把游戏机制理清楚,转化为精确的数学模型,再动手。
2.1 先明确游戏规则
俄罗斯方块的基础规则大家都很熟,从代码角度重新梳理一遍,核心是这几点:
- 游戏区域是一块固定宽高的矩形区域,方块逐行堆叠。
- 方块有7种形状,每种由4个小方格组成,这个叫"四格骨牌"(Tetromino)。
- 方块从顶部出现,自动下落,下落过程中玩家可以左右移动、旋转、加速下坠。
- 方块触底或被已有方块挡住后,固定在该位置,然后生成下一个方块。
- 一行格子被完全填满时,整行消除,上方所有方块整体下移一行。
- 方块堆叠到区域顶部、新方块无法生成时,游戏结束。
看起来简单,但"翻译"成代码的时候就产生了几个关键问题:如何用数据表示方块和游戏区域?如何判断移动/旋转是否合法?落地判定怎么做?消行时数组怎么操作?
2.2 游戏区域的数学建模
游戏区域我定义为一个10 x 20的网格。为什么是10x20?这是经典俄罗斯方块的尺寸,宽度10列、高度20行,保证方块在水平方向有足够的移动空间,同时画面比例舒适。这个尺寸不需要特别精确,你完全可以按自己喜好调整,但建议保持列数 x 行数的形式,方便后续的矩阵操作。
我用一个二维列表来表示网格,初始化时全填0,表示空格:
COLS = 10 ROWS = 20 grid = [[0 for _ in range(COLS)] for _ in range(ROWS)]当一个格子被方块占据时,把0改成该方块的形状编号(或者直接改成一个非零值,我习惯用形状编号,后面绘制颜色时可以直接查表)。这块网格就是整个游戏的"大地图",所有碰撞检测、消行判断都基于它来完成 —— 我称之为"静态网格",因为方块一旦固定进去就不再移动,只参与消行计算。
2.3 定义7种方块的形状数据
7种标准方块分别是 I、O、T、S、Z、J、L。严格来说 I 方块由4个格子排成一条直线,O 方块是2x2的正方形,其余的都是3x2或2x3的"L"、"T"、"S"、"Z"形。
数据怎么存?最直观的方式是矩阵法:用嵌套列表描述每种形状的"占位图",1代表有格子,0代表空位。比如:
SHAPES = { 'I': [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0] ], 'O': [ [1, 1], [1, 1] ], 'T': [ [0, 1, 0], [1, 1, 1], [0, 0, 0] ], # S, Z, J, L 类似 }这种表示方式的最大好处是:旋转操作可以通过矩阵转置加行反转来实现(后面会细说),碰撞检测也只需要检查形状矩阵里非零位置是否越界或和网格冲突。缺点是形状矩阵的长宽不一致,处理时要小心。
我实际项目里用的是另一种坐标法,不是说矩阵法不好,而是坐标法在旋转计算上更直观(虽然代码逻辑更绕)。坐标法把每个方块看成一组"相对坐标":
SHAPES = { 'I': [(0, 0), (1, 0), (2, 0), (3, 0)], 'O': [(0, 0), (1, 0), (0, 1), (1, 1)], 'T': [(1, 0), (0, 1), (1, 1), (2, 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 是行偏移。每个方块生成时都有一个"基准点"(anchor),假设基准点位于游戏区域顶部的某个位置,那么方块实际占用的格子坐标 = 基准点坐标 + 每个格子的偏移量。
两种方案我最终选了坐标法,原因是在写旋转逻辑时可以平移到原点做旋转矩阵乘法,分类讨论的情况更少。不过对刚开始的同学,我建议你从矩阵法入手,理解起来更自然,等项目跑通了再尝试改成坐标法对比一下差异——两种都试一次,你对"数据如何组织影响代码复杂度"这件事会有很深的体感。
2.4 颜色映射表
每个方块形状对应一个颜色,没太多技术含量,纯粹是为了视觉效果,也顺便把你的代码和图形对应起来:
COLORS = { 'I': (0, 255, 255), # 青色 'O': (255, 255, 0), # 黄色 'T': (160, 32, 240), # 紫色 'S': (0, 255, 0), # 绿色 'Z': (255, 0, 0), # 红色 'J': (0, 0, 255), # 蓝色 'L': (255, 165, 0), # 橙色 }颜色值采用RGB元组,pygame绘制时直接用。这个映射表在网格数据存了形状编号之后,绘制阶段就能从编号反查颜色,省事很多。
3. 核心逻辑逐个拆解:移动、旋转、碰撞、消行
这一节是整个项目的核心。我建议你在看代码前,先想清楚"什么是俄罗斯方块每一帧要做的事",再对照代码看你猜对了多少。
3.1 方块类和它的"生命周期"
我先封装一个Block类,每个方块实例代表一个正在活动、还未落地的方块:
class Block: def __init__(self, shape, x, y): self.shape = shape self.x = x # 基准点列坐标 self.y = y # 基准点行坐标 self.cells = SHAPES[shape] # 相对坐标列表 self.color = COLORS[shape] def get_positions(self): """返回当前方块实际占用的所有格子坐标(行, 列)""" positions = [] for dx, dy in self.cells: positions.append((self.y + dy, self.x + dx)) return positions注意这里我把get_positions里坐标写成(行, 列),对应网格的(row, col),这样后面操作grid[row][col]时不需要转换,不容易搞混。基准点的初始位置我一般定为(x=4, y=0)左右,让方块出现在区域顶部中央。
为什么不直接把实际坐标存在方块里,而是要"基准点+相对偏移"再用函数算一次?因为旋转操作本质上只改变相对偏移,强行把它合在一起存,每次移动、旋转都要同步改一大堆坐标,容易出bug。分离基准点和相对坐标之后,左右移动只需要改self.x,下落只需要改self.y,旋转只需要改self.cells,互不干扰。
3.2 碰撞检测:不认真写这里,游戏就各种穿墙
碰撞检测是俄罗斯方块的命门。每一帧方块移动前都要问一句:我想去的那个位置,会不会越界?会不会和静态网格里已有的方块重叠?
写个独立的判断函数:
def valid_move(block, grid, dx=0, dy=0): for row, col in block.get_positions(): new_row = row + dy new_col = col + dx if new_col < 0 or new_col >= COLS: return False if new_row >= ROWS: return False if new_row >= 0 and grid[new_row][new_col] != 0: return False return True这个函数做了三件事:左右边界检查、底部越界检查、和已有静态方块冲突检查。顶部越界我特意没查,因为方块生成时有可能有一小部分在区域外(经典俄罗斯方块允许方块部分隐藏在顶部之外),只要不访问负索引列表就不会崩。
这里有个很关键的设计决策:碰撞检测永远用"假设移动后的坐标"来判断,而不是先移动再回滚。前者叫"试算",后者叫"移动-回滚"。试算的好处是不会污染当前状态,逻辑分支少,不容易出bug。我见过不少初学者写成"先移动,发现碰撞了再移回去",这种思路在帧率不稳定的时候会出现肉眼可见的闪烁和鬼畜移动,实测体验非常差——因为你无法保证"移回去"的时机和你"检测到碰撞"的时机完全同步。
调用方式就非常简洁:
# 向左移动一格 if valid_move(block, grid, dx=-1): block.x -= 1 # 向下移动一格 if valid_move(block, grid, dy=1): block.y += 1 else: # 不能下落了,方块固定 fix_block(block, grid)注意valid_move的grid参数是静态网格,不包含当前活动方块自身。所以判断当前方块和当前方块之间不会产生"自己和自己碰撞"的误判。
3.3 旋转:矩阵运算帮你省掉八种情况
旋转是最容易写崩的部分。7种方块里,O方块转了等于没转,I、T、S、Z、J、L各有各的转法。如果每种情况都手动写一套数据,代码会又长又容易出错。
我用的方案是通用的旋转算法:把方块每个格子的相对坐标绕基准点旋转90度。前提是方块定义用坐标法,旋转90度对应数学公式:
新x = 原y 新y = -原x(这是顺时针旋转,如果方向不对就反过来用)
def rotate(block): new_cells = [] for dx, dy in block.cells: new_dx = -dy new_dy = dx new_cells.append((new_dx, new_dy)) rotated = Block(block.shape, block.x, block.y) rotated.cells = new_cells return rotated但这么写出来实际运行会有问题:旋转后的方块可能超出左右边界,或者撞到静态网格。这就是经典的墙踢(Wall Kick)问题。简单说,旋转发生在靠近墙壁的位置时,光靠换坐标还不够,需要尝试把旋转后的方块往左/右平移一点,看能不能"挤"进去。
我的处理方式很朴素,但足够用——依次尝试5个候选位置:原地不动、左移1格、右移1格、左移2格、右移2格,只要有一个位置合法,就采用:
def try_rotate(block, grid): for dx in [0, -1, 1, -2, 2]: test_block = Block(block.shape, block.x + dx, block.y) test_block.cells = rotate_cells(block.cells) if valid_move(test_block, grid): return test_block return block # 所有位置都冲突,放弃旋转这个方案不算完美,但实际游戏里手感正常,足以覆盖绝大多数旋转撞墙的场景。如果你想要更细腻的手感,可以研究现代俄罗斯方块标准的SRS(Super Rotation System)规范,它会定义每个形状专用的踢墙修正表——但那个复杂度对入门项目来说有点杀鸡用牛刀。
还有一个点值得提醒:I方块长度是4,旋转后横向占用4列,所以它撞边界的概率比其他方块高。实测中I方块用[0, -1, 1, -2, 2]这组偏移可能不够稳,你如果发现I方块贴边旋转不好用,可以加一个dx=3或dx=-3的候选。
3.4 消行:从数组里删行比你想的更讲究
方块固定到网格后,就要检查是否有完整的行。逻辑不复杂:遍历网格每一行,如果该行全为非零值,就说明满行,需要消除。
但"消除"这个操作有讲究。新手最容易写错的方式是:
# 错误示范 for row in grid: if all(cell != 0 for cell in row): grid.remove(row) grid.insert(0, [0 for _ in range(COLS)])这行代码看着没问题,实际会出bug——grid.remove(row)在多个满行同时出现时,可能会因为索引错位导致漏删或误删,而且remove操作本身是 O(n) 的列表查找,效率并不好。
推荐用"收集满行索引+统一重建网格"的方式:
def clear_rows(grid): full_rows = [i for i in range(ROWS) if all(cell != 0 for cell in grid[i])] if not full_rows: return 0 # 保留未满的行 new_grid = [row for i, row in enumerate(grid) if i not in full_rows] # 顶部补空行 while len(new_grid) < ROWS: new_grid.insert(0, [0 for _ in range(COLS)]) for i in range(ROWS): grid[i] = new_grid[i] return len(full_rows)思路是:先把满行过滤掉,剩下的行往上移,然后在顶部补空行。这样即使同时消多行也不会出错,而且代码语义很清楚:消行就是"扔掉满行+顶部插入空行"。
计分规则经典版是这样的:
- 消1行:100分
- 消2行:300分
- 消3行:500分
- 消4行:800分
这个曲线是故意拉大的——一次消4行(拿"Tetris")的收益远大于分4次各消1行,鼓励玩家堆方块追求连消。我代码里就按这个标准写。
3.5 方块下落和游戏主循环
到这里,游戏的基本模块都有了。怎么把它们串起来?答案是游戏主循环。
Pygame的标准循环结构是:
- 处理用户输入事件
- 更新游戏状态(方块下落、消行判定等)
- 绘制画面
- 控制帧率
- 重复
FPS = 60 clock = pygame.time.Clock() while running: dt = clock.tick(FPS) / 1000.0 # 转换为秒 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_LEFT: # 左移逻辑 elif event.key == pygame.K_RIGHT: # 右移逻辑 elif event.key == pygame.K_DOWN: # 加速下落 elif event.key == pygame.K_UP: # 旋转 elif event.key == pygame.K_SPACE: # 硬降到底 ... # 自动下落:按固定时间间隔触发 if time.time() - last_fall_time > fall_interval: if valid_move(current_block, grid, dy=1): current_block.y += 1 else: fix_block(current_block, grid) rows_cleared = clear_rows(grid) score += SCORE_TABLE.get(rows_cleared, 0) spawn_new_block() last_fall_time = time.time() # 绘制 draw_grid(grid) draw_block(current_block) draw_next_block(next_block) draw_score(score) pygame.display.flip()这里fall_interval是方块自动下落的时间间隔,初始值建议设为1.0秒左右,这个速度对新手刚好。后面可以随着消行数增加而缩短下落间隔,游戏越来越快,难度自然上升。我用的公式是:
fall_interval = max(0.1, 1.0 - (level - 1) * 0.05)每升1级,减少0.05秒,最低到0.1秒不再降低,不然到后面物理上就没有操作时间了。
3.6 硬降:直接落底
按空格键让方块瞬间落到底部,这个功能作为"硬降"(Hard Drop)在俄罗斯方块里是标配。实现起来也简单:不断dy=1尝试下落,直到撞到为止。
def hard_drop(block, grid): while valid_move(block, grid, dy=1): block.y += 1 fix_block(block, grid)做完这一下,游戏马上进入"固定方块"的流程。注意硬降之后要立刻pygame.display.flip()刷新一次画面,否则玩家会感觉方块"瞬移"后界面没跟上。
4. 画界面:pygame里不再抽象的"绘制"逻辑
游戏逻辑写完了,但你现在翻开代码,屏幕上还是一片黑。接下来处理渲染,这部分对小白来说反而是最容易有成就感的——因为你写下去的每一行代码,都立刻变成了屏幕上的画面。
4.1 初始化pygame窗口
import pygame import sys pygame.init() CELL_SIZE = 30 # 每个格子边长像素 WINDOW_WIDTH = COLS * CELL_SIZE + 200 # 右侧留200px放预览区 WINDOW_HEIGHT = ROWS * CELL_SIZE screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption("Python 俄罗斯方块")每个格子30像素,10列宽就是300px,20行高就是600px。右侧预留200px的区域用来放"下一个方块"预览和分数显示,这个布局比较经典,玩家一目了然。
4.2 绘制静态网格中的方块
网格数据里存的是数字(形状编号),渲染时要把数字映射为颜色并画成方块:
def draw_grid(screen, grid): for row in range(ROWS): for col in range(COLS): cell_value = grid[row][col] if cell_value != 0: color = SHAPE_COLORS[cell_value] # cell_value是形状字符串如'I' rect = pygame.Rect(col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, color, rect) pygame.draw.rect(screen, (40, 40, 40), rect, 1) # 边框线这个循环本质上就是"遍历二维数组、逐个画格子"。既然网格是二维数组,绘制就用双重循环,每个非零位置画一个矩形。边框线用深色画1像素宽,会让方块看起来有立体感,视觉上更舒服。
4.3 绘制当前活动方块
活动方块不在grid里,所以要单独绘制。逻辑一样,只是用get_positions()获取坐标:
def draw_block(screen, block): for row, col in block.get_positions(): if row < 0: continue # 顶部外的部分不画 rect = pygame.Rect(col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, block.color, rect) pygame.draw.rect(screen, (40, 40, 40), rect, 1)注意row < 0要跳过,因为方块刚生成时可能在顶边界之外,如果你直接画会画到窗口外面,pygame不会报错但画面会闪烁一下。
4.4 绘制"下一个"方块和分数
右侧预览区绘制下一个方块,思路就是把下一个方块的格子画在偏移后的坐标上:
def draw_next_block(screen, next_block): preview_x = COLS * CELL_SIZE + 40 preview_y = 100 for dx, dy in next_block.cells: rect = pygame.Rect(preview_x + dx * CELL_SIZE, preview_y + dy * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, next_block.color, rect) pygame.draw.rect(screen, (40, 40, 40), rect, 1)分数就直接用pygame.font.Font渲染成文本画到右侧区域。
4.5 背景和网格线的绘制细节
为了让游戏界面更清晰,我习惯在每次刷新时先画背景色、再画细网格线、再画方块。细网格线用浅灰色,每格一个小方框,不然玩的时候视觉上不好定位:
def draw_grid_lines(screen): for x in range(0, COLS * CELL_SIZE + 1, CELL_SIZE): pygame.draw.line(screen, (60, 60, 60), (x, 0), (x, ROWS * CELL_SIZE)) for y in range(0, ROWS * CELL_SIZE + 1, CELL_SIZE): pygame.draw.line(screen, (60, 60, 60), (0, y), (COLS * CELL_SIZE, y))这个不是必须的,但实测加上网格线之后,玩家更容易判断方块的落点,游戏体验提升非常明显。作为对比,我见过不少新手项目不画网格线,玩起来总是差那么点意思,就是少了这个细节。
5. 边写边踩的坑:我在编码和运行中遇到的4个经典问题
到这里,你的俄罗斯方块应该已经能玩起来了。但我要提前说说这4个坑,它们是我写这个项目时真实遇到过的,而且每一个都卡了我超过半小时。知道它们的存在,至少能让你少走一半弯路。
5.1 深拷贝陷阱:改了一个方块,所有方块都变了
这是Python新手最容易踩的坑。我在定义Block类时,方块初始化的代码这么写:
class Block: def __init__(self, shape, x, y): self.shape = shape self.x = x self.y = y self.cells = SHAPES[shape]问题在于:SHAPES[shape]返回的是列表对象的引用。所有'I'方块共享同一个列表。当你对一个方块执行旋转时,你修改了self.cells,实际上是在修改所有'I'方块共享的那份数据——等下一个'I'方块生成时,它的初始形状就已经是被旋转过的了。
解决办法是每次创建方块时拷贝一份新列表:
self.cells = [list(pair) for pair in SHAPES[shape]]或者用copy.deepcopy(SHAPES[shape])。这个坑报错很奇怪——游戏逻辑看起来没问题,但方块每次生成后形状越来越离谱,最后游戏直接没法玩。如果你遇到这个现象,优先检查是不是共享数据被修改了。
5.2 消行时的列表索引错乱
前面我提过remove方法的问题。如果你用了边遍历边删除的写法:
for i in range(len(grid)): if all(cell != 0 for cell in grid[i]): grid.pop(i)这里有个致命bug:当你pop掉第 i 行后,原本的 i+1 行变成了新的 i 行,但循环会继续从 i+1 开始,导致原本应该检查的行被跳过了。如果连续两行都满,你只会消掉其中一行,另一行永远消不掉。
所以我才反复强调用"先收集满行索引、再统一重建网格"的方案。这个方案彻底绕开了"在循环中修改被遍历对象"这个经典陷阱。
5.3 帧率与下落速度:你的方块有时会"跳格"
我最初实现自动下落时用了pygame.time.delay()来控制速度,结果在电脑负载变高的时候,方块下落会出现"一顿一顿"的现象,严重时甚至会"跳"过一行直接落到底部。
后来我改用time.time()记录上次下落时刻,每帧检查"现在是否已经过了下落间隔":
current_time = time.time() if current_time - last_fall_time >= fall_interval: # 执行下落 last_fall_time = current_time这样下落速度不再受帧率波动影响——即使某一帧卡了一下,下一帧也会补上应有的下落动作,而不是直接跳过多帧。游戏运行速度由time.time()决定,而不是帧数,这是游戏开发里很基础也很重要的一课。
5.4 硬降后界面"卡住"的假象
按空格硬降后,如果方块落地生成新方块,有时候界面会看起来像卡住了一样,等了半天才有反应。这个问题的根源是:硬降后新方块马上又触发了自动下落,但新方块刚生成时last_fall_time没有重置,导致新方块瞬间又下落了一格,视觉上像游戏"抖了一下"。
解决办法是硬降后强制重置一下last_fall_time = time.time(),让新方块有完整的初始等待时间。还有一个类似的问题是:玩家在方块落地瞬间按了移动键,结果按键事件堆积,新方块一生成就立刻被移动了,给人感觉方块"瞬移"。处理方式是按下键时先判断当前方块是否已经固定,固定了就忽略后续按键事件。
6. 完整源码逻辑汇总与运行效果实测
整个项目的源码结构大概是这样的(完整源码后面可以单独存成.py文件直接跑):
- 常量定义:COLS、ROWS、CELL_SIZE、SHAPES、COLORS - Block类:方块数据 + 当前位置 + 相对坐标 - valid_move(grid, block, dx, dy):碰撞检测 - rotate_cells(cells):旋转坐标计算 - try_rotate(grid, block):带墙踢的旋转 - fix_block(block, grid):把方块固定进静态网格 - clear_rows(grid):消行并返回消行数 - spawn_block():生成新方块(从7种形状中随机选) - draw_grid / draw_block / draw_next_block / draw_score:界面绘制 - main():游戏主循环,整合所有逻辑运行效果我实测过:窗口打开后,方块从顶部匀速下落,左右方向键控制移动、上方向键旋转、下方向键加速、空格硬降。消行时整行消失、上方网格下滑,分数刷新,下一个方块在右侧预览区显示。整体目测帧率稳定在60 FPS,手感流畅。
这里给一个关键的建议:每次改完代码,先跑一轮人工测试再继续往下写。比如你刚写完碰撞检测,就手动控制方块撞左右墙、落地,确认不会穿墙;刚写完消行逻辑,就故意堆一行满行,确认能消掉。别等整个游戏写完了再一口气测试,到时候一堆bug缠在一起,排错会非常痛苦。
7. 三个"很加分"的进阶功能(按需扩展)
如果你基础版跑通了,下面这几个功能可以按兴趣选做,它们每个都能锻炼一个不同的编程方向。
7.1 7-bag随机算法:让方块分布更均匀
我之前直接用random.choice(SHAPES.keys())随机生成方块,玩起来偶尔会出现连续抽到3个I方块的极端情况,体验不太好。经典俄罗斯方块用的是"7-bag"算法:把7种方块随机打乱放到一个袋子里,每次从袋子里取一个,袋子空了再重新洗牌。这样能保证每7个方块中,每种形状至少出现一次,分布最均匀。
import random bag = [] def next_shape(): global bag if not bag: bag = list(SHAPES.keys()) random.shuffle(bag) return bag.pop()这个算法非常简单,但带来的游戏体验提升非常明显,强烈建议加上。
7.2 软降得分和连消奖励
很多俄罗斯方块变体规则里,玩家主动加速下落(按方向键↓)会获得每格1分的小奖励,同时硬降每格2分。这个小机制鼓励玩家积极操作,而不是等着方块自己落下。
连消奖励也可以做成累进式:如果消行发生在一次固定后的短时间内(比如2秒内),分数按"第几次连消"成倍增加。实现上需要一个combo计数器,每次消行时combo += 1,消行失败或超时重置。这个机制能让游戏的高分感飙升,玩起来有很强的正反馈。
7.3 阴影块(Ghost Piece)
在经典俄罗斯方块里,当前方块的底部会显示一个半透明的"阴影",表示这个方块如果硬降会落在哪个位置。实现思路是:用hard_drop的逻辑算出一个"落到最低点"的临时方块,但只用来画半透明图形,不参与实际逻辑。这个功能对新手提升操作精度特别有效,实现成本不高,但看起来很专业。
画阴影的代码大致长这样:
ghost = Block(block.shape, block.x, block.y) while valid_move(ghost, grid, dy=1): ghost.y += 1 # 半透明绘制:pygame.draw.rect时用带透明度的颜色,或者把颜色RGB值乘0.5用pygame.Surface加set_alpha()实现透明度效果,代码大约多写十行,视觉档次立刻上来。
8. 运行环境与快速启动
最后把运行环境说一下,方便你照着搭:
- Python版本:3.6以上都可以,我用的是3.10。
- 依赖库:
pygame。安装命令:pip install pygame。 - 运行方式:把完整源码保存为
tetris.py,在终端执行python tetris.py。
如果安装pygame时速度太慢,可以先换国内源,比如:
pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simpleWindows、macOS、Linux都能跑,没有平台差异性的坑。macOS如果遇到pygame显示窗口黑屏的问题,通常是因为macOS的OpenGL驱动和pygame版本不匹配,升级pygame版本一般能解决。
源码我建议你一定要自己敲一遍,尽量不要复制粘贴。就算觉得理解了,敲的过程中还是会发现很多"以为自己懂、真写起来却不对"的细节——比如某个变量名拼错了、某个缩进级别写错了、某个方法返回值和预期不符。这些错误恰恰是学习编程最宝贵的部分。敲完一遍再对照别人的源码看差异,你会发现每个人都对"同一套规则"有不同的理解,这个对比过程本身就是高级学习。