news 2026/10/3 1:14:07

用Python和Pygame从零实现俄罗斯方块:核心算法与代码解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python和Pygame从零实现俄罗斯方块:核心算法与代码解析

如果你是学Python的,八成经历过这种阶段:语法、循环、函数、类都写过了,一到自己动手做项目就卡壳——爬虫要应付反爬,Web要配框架,数据分析还得先搞环境。俄罗斯方块这个练手项目几乎是为这种状态量身定做的:规则三句话说完,但实现起来绕不开二维数组、坐标转换、事件响应、状态管理这些最常用的编程思路,而且做完是真能玩的,成就感来得特别快。

下面这份实现我重新写过好几版,这次是面向初学者的版本:依赖只有一个pygame,核心逻辑拆得比较散,方便你搞清楚每一行的作用。整体不到250行,保存成tetris.py直接就能跑。每个关键设计背后我都会解释一句“为什么这么写”,这样你改起来心里有数。

1. 项目概述与设计思路

1.1 为什么选俄罗斯方块做练手项目

很多人一上来就写爬虫或者Web项目,结果发现大把时间耗在环境配置和框架理解上,编程本身反而没练到。俄罗斯方块这种游戏正好相反,它的核心逻辑非常“纯”——不依赖什么复杂框架,只要把数据结构和状态机吃透,游戏的核心就完成了。

具体来说,它大概覆盖了这些知识点:

  • 基础的面向对象设计:游戏状态、方块状态、玩家操作怎么封装。
  • 二维数组的典型操作:网格既要做碰撞检测,又要做消行删除,还要渲染成画面。
  • 坐标变换:方块旋转本质上就是矩阵旋转,这是很多算法题都会考的思路。
  • 事件驱动和游戏循环映射:pygame的事件队列怎么转成游戏操作。
  • 状态判断:下落、碰撞、锁定、消行、结束,每个流程都要判断清楚。

这套东西学会之后,你转去写贪吃蛇、推箱子、扫雷,其实很多框架是可以直接复用的。俄罗斯方块比它们稍微难一点,但难度刚好卡在“需要动脑、但不至于劝退”的位置上。

1.2 整体架构设计

我的方案是用pygame做渲染和事件处理,游戏逻辑单独写在TetrisGame类里。这么做的好处是逻辑和界面剥离:想改成网页版,把渲染层换掉就行,核心的碰撞、消行、计分代码直接搬。

整个游戏的运行基本就是经典的游戏循环:

  1. 处理键盘输入和退出事件。
  2. 判断当前方块是否该自动下落。
  3. 检查碰撞:能落就落,落不了就锁定到网格。
  4. 锁定后做消行判断,更新分数和等级。
  5. 绘制网格、已固定方块、当前方块、下一个方块、信息面板。
  6. 控制帧率,回到第1步。

这里需要注意一个新手常犯的错:不要在一个循环里既做自动下落,又做按键检测,然后直接操作方块位置。应该把“逻辑更新”和“渲染”分开。逻辑更新里改变状态,渲染阶段只负责把当前状态画出来。这样即使某一帧卡了一下,画面也不会错乱。

1.3 核心数据结构选型

我从一开始就明确了两块数据:

  • 网格(grid):一个GRID_ROWS行、GRID_COLS列的二维列表,固定下来的方块存入对应位置的颜色索引,空位用0。
  • 活跃方块(piece):一个字典,包含形状矩阵、网格坐标、颜色索引。用字典而不是单独建类,是因为方块本质上就是这4个属性,初学者用字典反而更直观。

形状矩阵我直接用二维列表表示,1代表有格子,0代表空。举个例子,T形方块长这样:

[0, 1, 0] [1, 1, 1]

旋转就是对这个矩阵做变换。网格为什么存颜色索引而不是直接存颜色元组?因为Python元组比较起来其实不麻烦,但如果存索引,后续写消行、画格子都更统一,而且想换配色只需要改COLORS列表,不用动网格数据。

2. 核心玩法拆解与技术点解析

2.1 七种方块的形状定义

俄罗斯方块标准七种方块分别是I、O、T、S、Z、J、L。我直接用矩阵把它们定义成常量:

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 [[0, 1, 1], [1, 1, 0], [0, 0, 0]], # Z [[1, 1, 0], [0, 1, 1], [0, 0, 0]], # J [[1, 0, 0], [1, 1, 1], [0, 0, 0]], # L [[0, 0, 1], [1, 1, 1], [0, 0, 0]], ]

要注意的是I方块我用了4x4矩阵,实际方块只在第二行。这样旋转的时候能套用同样的旋转逻辑,不用给I单独写特例。O方块不用旋转,但保留2x2矩阵后,走统一流程也不会出错。生成随机方块时用random.randint选一个矩阵的索引,然后深拷贝一份,避免后续旋转污染原始定义。

2.2 旋转的数学原理

这是让很多初学者头疼的地方。其实顺时针旋转90度的本质就是对矩阵做两步操作:先把行顺序上下翻转,再转置。代码是这样:

def rotate_clockwise(matrix): return [list(row) for row in zip(*matrix[::-1])]

拆开看:matrix[::-1]把矩阵行顺序反转,zip(*...)完成转置,最后转成列表。比如T形方块:

[0, 1, 0] 行反转 [1, 1, 1] 转置 [1, 0] [1, 1, 1] ----------> [0, 1, 0] ----------> [1, 1] [0, 0, 0] [0, 0, 0] [1, 0]

是不是正好就是T形顺时针转90度的样子?这个技巧在算法题里也经常用,理解了以后做别的东西也用得上。

旋转之后问题就来了:方块贴着墙旋转时,旋转后的形状可能越界。所以旋转函数里不能只旋转,还要做碰撞检测,如果不行就尝试左右偏移一点,这叫“踢墙”。

def rotate_piece(self): old_shape = self.current_piece['shape'] new_shape = rotate_clockwise(old_shape) temp_piece = { 'shape': new_shape, 'x': self.current_piece['x'], 'y': self.current_piece['y'], 'color_idx': self.current_piece['color_idx'], 'shape_idx': self.current_piece['shape_idx'] } if self.valid_position(temp_piece): self.current_piece['shape'] = new_shape return for offset in [1, -1, 2, -2]: temp_piece['x'] = self.current_piece['x'] + offset temp_piece['y'] = self.current_piece['y'] if self.valid_position(temp_piece): self.current_piece['shape'] = new_shape self.current_piece['x'] = temp_piece['x'] self.current_piece['y'] = temp_piece['y'] return

这里我先试原始位置,不行就依次尝试向右偏移1格、向左偏移1格、向右偏移2格、向左偏移2格。这个偏移量列表是经验值,能覆盖绝大多数贴墙场景。对I方块这种宽度为4的,靠墙转90度时偏移2格有时还不够,后面“常见问题”里我会再聊。

2.3 碰撞检测:三层判断逻辑

碰撞检测是整个游戏最关键的函数,上下左右移动、旋转、锁定都要靠它。我写成一个统一函数:

def valid_position(self, piece, dx=0, dy=0): shape = piece['shape'] x = piece['x'] + dx y = piece['y'] + dy for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] == 1: board_x = x + c board_y = y + r if board_x < 0 or board_x >= GRID_COLS or board_y >= GRID_ROWS: return False if board_y >= 0 and self.grid[board_y][board_x] != 0: return False return True

逻辑分三层:

  1. 遍历形状矩阵中所有为1的格子。
  2. 把矩阵坐标转成网格坐标:board_x = piece.x + 列号,board_y = piece.y + 行号。
  3. 判断是否越界,以及是否撞到已固定方块。

有个细节值得说一下:board_y >= 0这个条件。因为方块初始位置在网格顶部,允许board_y小于0,也就是方块可以在可见区域上方“悬空”一部分。碰撞检测时如果board_y小于0,我们不查网格,因为上面根本没有格子。否则方块一开始就误判为碰撞了。但其实方块也不能完全无限往上走,正常玩法中它总是在顶部出现,所以这个判断是合理的。

2.4 消行判定与计分规则

消行是俄罗斯方块的爽点所在,实现起来也不复杂。锁定方块后,检查每一行是不是都非0,如果是,就把它从网格里剔掉,然后在顶部补一个空行。

def lock_piece(self): shape = self.current_piece['shape'] color_idx = self.current_piece['color_idx'] x = self.current_piece['x'] y = self.current_piece['y'] for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] == 1: board_y = y + r board_x = x + c if board_y < 0: self.game_over = True return self.grid[board_y][board_x] = color_idx lines_cleared = 0 new_grid = [] for row in self.grid: if all(cell != 0 for cell in row): lines_cleared += 1 else: new_grid.append(row) if lines_cleared > 0: for _ in range(lines_cleared): new_grid.insert(0, [0] * GRID_COLS) self.grid = new_grid line_scores = {0: 0, 1: 100, 2: 300, 3: 500, 4: 800} self.score += line_scores.get(lines_cleared, 1200) self.lines += lines_cleared self.level = self.lines // 10 + 1 self.fall_speed = max(80, 500 - (self.level - 1) * 35) self.current_piece = self.next_piece self.next_piece = self.spawn_piece() if not self.valid_position(self.current_piece): self.game_over = True

注意新网格的构建顺序:保留没有消掉的行,再在顶部插入空行。这样网格的行数始终保持GRID_ROWS。计分用字典映射,一次消1行100分,2行300分,3行500分,4行800分,这个经典规则很多版本都在用。等级根据累计消行数计算,每消10行升一级,下落速度从500毫秒逐级加快,最快控制在80毫秒。

3. 完整源码与逐段实现

3.1 环境准备与运行

运行这个项目只需要pygame。安装命令:

pip install pygame

装完把源码保存成tetris.py,在命令行运行:

python tetris.py

我用的是Python 3.8+,理论上3.6以上都能跑。pygame有中文显示的老毛病,具体说就是默认字体不支持中文字符串,所以我把界面提示全部用了英文,省得折腾字体路径。

3.2 常量与全局配置

游戏界面的尺寸尺寸我定义成常量,而不是散落在代码里到处写数字:

CELL_SIZE = 30 GRID_COLS = 10 GRID_ROWS = 20 PLAY_AREA_LEFT = 20 PLAY_AREA_TOP = 40 SCREEN_WIDTH = PLAY_AREA_LEFT + GRID_COLS * CELL_SIZE + 20 SCREEN_HEIGHT = PLAY_AREA_TOP + GRID_ROWS * CELL_SIZE + 10 INFO_PANEL_WIDTH = 180 INFO_LEFT = SCREEN_WIDTH + 15

这里每一步都算得很清楚。游戏区宽是10 * 30 = 300,左边距20,右边留20,所以窗口宽是340 + 180 = 520。游戏区高是20 * 30 = 600,顶部留40像素画标题,底部留10像素,窗口高就是650。这样改格子大小或者行列数,其他区域会自动跟着变。

颜色也集中管理:

COLORS = [ (0, 0, 0), (0, 240, 240), # I (240, 240, 0), # O (180, 0, 240), # T (0, 240, 0), # S (240, 0, 0), # Z (0, 100, 255), # J (255, 140, 0), # L ]

索引0是网格空格,索引1到7对应七种方块的颜色。前面说网格里存的是颜色索引,画格子时通过索引到COLORS里取颜色,这样想调颜色只改这一处。

3.3 游戏核心类:移动、旋转、锁定

我把游戏主体封装成TetrisGame类。构造方法里初始化pygame、创建窗口、设置字体,并重置一局游戏。

class TetrisGame: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((SCREEN_WIDTH + INFO_PANEL_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Python Tetris") self.clock = pygame.time.Clock() self.font = pygame.font.Font(None, 32) self.small_font = pygame.font.Font(None, 22) self.reset() def reset(self): self.grid = [[0] * GRID_COLS for _ in range(GRID_ROWS)] self.score = 0 self.level = 1 self.lines = 0 self.game_over = False self.paused = False self.fall_time = 0 self.fall_speed = 500 self.current_piece = self.spawn_piece() self.next_piece = self.spawn_piece()

spawn_piece生成一个随机方块,初始化位置通过(GRID_COLS - len(shape[0])) // 2计算,这样不同宽度的方块都能居中。

移动的逻辑很简单,移动前先检查目标位置是否合法:

def move_left(self): if self.valid_position(self.current_piece, dx=-1): self.current_piece['x'] -= 1 def move_right(self): if self.valid_position(self.current_piece, dx=1): self.current_piece['x'] += 1 def soft_drop(self): if self.valid_position(self.current_piece, dy=1): self.current_piece['y'] += 1 self.score += 1 return True else: self.lock_piece() return False def hard_drop(self): while self.valid_position(self.current_piece, dy=1): self.current_piece['y'] += 1 self.score += 2 self.lock_piece()

软降每移动一格加1分,硬降每落一格加2分,这个细节很多版本都保留,算是对玩家操作的小奖励。

3.4 渲染模块:网格、方块、信息面板

画格子是基础操作,所有绘制都复用一个函数:

def draw_cell(self, surface, col, row, color): rect = pygame.Rect( PLAY_AREA_LEFT + col * CELL_SIZE, PLAY_AREA_TOP + row * CELL_SIZE, CELL_SIZE - 1, CELL_SIZE - 1 ) pygame.draw.rect(surface, color, rect)

这里CELL_SIZE - 1会让格子之间留1像素缝隙,视觉上比整块糊在一起清晰得多。画网格线用pygame.draw.line,每条横线和竖线依次画出来。

已固定的方块绘制很简单——遍历整个网格,非0位置就画对应颜色的格子。当前方块则遍历形状矩阵,把矩阵里的1映射到网格坐标。

信息面板画在窗口右侧,包括分数、等级、消行数、下一个方块预览和操作提示。下一个方块的预览我单独写了一个函数,把方块按18像素的尺寸放大画在面板里,绘制逻辑和主网格类似,只是坐标偏移到信息区域。

游戏结束或暂停时,我会在半透明黑色遮罩上叠加大字提示,这样玩家能一眼看出当前状态。

3.5 主循环与事件处理

主循环是游戏的心脏:

def run(self): running = True while running: dt = self.clock.tick(60) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_r and self.game_over: self.reset() elif not self.game_over: if event.key == pygame.K_p: self.paused = not self.paused elif not self.paused: self.handle_key(event.key) if not self.game_over and not self.paused: self.fall_time += dt if self.fall_time >= self.fall_speed: self.fall_time = 0 if self.valid_position(self.current_piece, dy=1): self.current_piece['y'] += 1 else: self.lock_piece() self.draw() pygame.display.flip() pygame.quit()

clock.tick(60)返回上一帧的毫秒数,累加到fall_time上。当累计时间超过当前下落速度fall_speed,方块就下落一格。用累计时间而不是简单计数器的好处是,即使某个瞬间卡顿,时间不会丢失,游戏不会因此变得忽快忽慢。

按键处理写成一个单独函数,方便阅读:

def handle_key(self, key): if key == pygame.K_LEFT: self.move_left() elif key == pygame.K_RIGHT: self.move_right() elif key == pygame.K_DOWN: self.soft_drop() elif key == pygame.K_UP: self.rotate_piece() elif key == pygame.K_SPACE: self.hard_drop()

3.6 完整源码

把上面所有片段拼起来,就是完整可运行的版本。我直接放出全文,复制到本地就能玩:

import pygame import random # ---------- 基础常量 ---------- CELL_SIZE = 30 GRID_COLS = 10 GRID_ROWS = 20 PLAY_AREA_LEFT = 20 PLAY_AREA_TOP = 40 SCREEN_WIDTH = PLAY_AREA_LEFT + GRID_COLS * CELL_SIZE + 20 SCREEN_HEIGHT = PLAY_AREA_TOP + GRID_ROWS * CELL_SIZE + 10 INFO_PANEL_WIDTH = 180 INFO_LEFT = SCREEN_WIDTH + 15 # ---------- 颜色 ---------- BLACK = (18, 18, 18) DARK_GRAY = (35, 35, 35) GRID_LINE = (55, 55, 55) WHITE = (235, 235, 235) RED = (255, 80, 80) COLORS = [ (0, 0, 0), (0, 240, 240), (240, 240, 0), (180, 0, 240), (0, 240, 0), (240, 0, 0), (0, 100, 255), (255, 140, 0), ] SHAPES = [ [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], [[1, 1], [1, 1]], [[0, 1, 0], [1, 1, 1], [0, 0, 0]], [[0, 1, 1], [1, 1, 0], [0, 0, 0]], [[1, 1, 0], [0, 1, 1], [0, 0, 0]], [[1, 0, 0], [1, 1, 1], [0, 0, 0]], [[0, 0, 1], [1, 1, 1], [0, 0, 0]], ] def rotate_clockwise(matrix): return [list(row) for row in zip(*matrix[::-1])] def random_piece(): idx = random.randrange(len(SHAPES)) shape = [row[:] for row in SHAPES[idx]] return { 'shape': shape, 'x': (GRID_COLS - len(shape[0])) // 2, 'y': 0, 'color_idx': idx + 1, 'shape_idx': idx, } class TetrisGame: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((SCREEN_WIDTH + INFO_PANEL_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Python Tetris") self.clock = pygame.time.Clock() self.font = pygame.font.Font(None, 32) self.small_font = pygame.font.Font(None, 22) self.reset() def reset(self): self.grid = [[0] * GRID_COLS for _ in range(GRID_ROWS)] self.score = 0 self.level = 1 self.lines = 0 self.game_over = False self.paused = False self.fall_time = 0 self.fall_speed = 500 self.current_piece = random_piece() self.next_piece = random_piece() def valid_position(self, piece, dx=0, dy=0): shape = piece['shape'] x = piece['x'] + dx y = piece['y'] + dy for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] == 1: board_x = x + c board_y = y + r if board_x < 0 or board_x >= GRID_COLS or board_y >= GRID_ROWS: return False if board_y >= 0 and self.grid[board_y][board_x] != 0: return False return True def rotate_piece(self): old_shape = self.current_piece['shape'] new_shape = rotate_clockwise(old_shape) temp_piece = { 'shape': new_shape, 'x': self.current_piece['x'], 'y': self.current_piece['y'], 'color_idx': self.current_piece['color_idx'], 'shape_idx': self.current_piece['shape_idx'] } if self.valid_position(temp_piece): self.current_piece['shape'] = new_shape return for offset in [1, -1, 2, -2]: temp_piece['x'] = self.current_piece['x'] + offset temp_piece['y'] = self.current_piece['y'] if self.valid_position(temp_piece): self.current_piece['shape'] = new_shape self.current_piece['x'] = temp_piece['x'] self.current_piece['y'] = temp_piece['y'] return def move_left(self): if self.valid_position(self.current_piece, dx=-1): self.current_piece['x'] -= 1 def move_right(self): if self.valid_position(self.current_piece, dx=1): self.current_piece['x'] += 1 def soft_drop(self): if self.valid_position(self.current_piece, dy=1): self.current_piece['y'] += 1 self.score += 1 return True self.lock_piece() return False def hard_drop(self): while self.valid_position(self.current_piece, dy=1): self.current_piece['y'] += 1 self.score += 2 self.lock_piece() def lock_piece(self): shape = self.current_piece['shape'] color_idx = self.current_piece['color_idx'] x = self.current_piece['x'] y = self.current_piece['y'] for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] == 1: board_y = y + r board_x = x + c if board_y < 0: self.game_over = True return self.grid[board_y][board_x] = color_idx lines_cleared = 0 new_grid = [] for row in self.grid: if all(cell != 0 for cell in row): lines_cleared += 1 else: new_grid.append(row) if lines_cleared > 0: for _ in range(lines_cleared): new_grid.insert(0, [0] * GRID_COLS) self.grid = new_grid line_scores = {0: 0, 1: 100, 2: 300, 3: 500, 4: 800} self.score += line_scores.get(lines_cleared, 1200) self.lines += lines_cleared self.level = self.lines // 10 + 1 self.fall_speed = max(80, 500 - (self.level - 1) * 35) self.current_piece = self.next_piece self.next_piece = random_piece() if not self.valid_position(self.current_piece): self.game_over = True def handle_key(self, key): if key == pygame.K_LEFT: self.move_left() elif key == pygame.K_RIGHT: self.move_right() elif key == pygame.K_DOWN: self.soft_drop() elif key == pygame.K_UP: self.rotate_piece() elif key == pygame.K_SPACE: self.hard_drop() def draw_cell(self, surface, col, row, color): rect = pygame.Rect( PLAY_AREA_LEFT + col * CELL_SIZE, PLAY_AREA_TOP + row * CELL_SIZE, CELL_SIZE - 1, CELL_SIZE - 1 ) pygame.draw.rect(surface, color, rect) def draw_grid(self): for row in range(GRID_ROWS + 1): pygame.draw.line( self.screen, GRID_LINE, (PLAY_AREA_LEFT, PLAY_AREA_TOP + row * CELL_SIZE), (PLAY_AREA_LEFT + GRID_COLS * CELL_SIZE, PLAY_AREA_TOP + row * CELL_SIZE) ) for col in range(GRID_COLS + 1): pygame.draw.line( self.screen, GRID_LINE, (PLAY_AREA_LEFT + col * CELL_SIZE, PLAY_AREA_TOP), (PLAY_AREA_LEFT + col * CELL_SIZE, PLAY_AREA_TOP + GRID_ROWS * CELL_SIZE) ) def draw_piece(self, piece): shape = piece['shape'] color = COLORS[piece['color_idx']] for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] == 1: self.draw_cell(self.screen, piece['x'] + c, piece['y'] + r, color) def draw_info(self): self.draw_text("Score", INFO_LEFT, 30, self.small_font) self.draw_text(str(self.score), INFO_LEFT, 55, self.font) self.draw_text("Level", INFO_LEFT, 100, self.small_font) self.draw_text(str(self.level), INFO_LEFT, 125, self.font) self.draw_text("Lines", INFO_LEFT, 170, self.small_font) self.draw_text(str(self.lines), INFO_LEFT, 195, self.font) self.draw_text("Next", INFO_LEFT, 240, self.small_font) shape = self.next_piece['shape'] color = COLORS[self.next_piece['color_idx']] offset_x = INFO_LEFT + 20 offset_y = 270 for r in range(len(shape)): for c in range(len(shape[0])): if shape[r][c] == 1: pygame.draw.rect( self.screen, color, (offset_x + c * 20, offset_y + r * 20, 18, 18) ) controls = [ "Left/Right: move", "Up: rotate", "Down: soft drop", "Space: hard drop", "P: pause", "R: restart", ] for i, line in enumerate(controls): self.draw_text(line, INFO_LEFT, 380 + i * 28, self.small_font) def draw_text(self, text, x, y, font=None, color=WHITE): if font is None: font = self.font self.screen.blit(font.render(text, True, color), (x, y)) def draw(self): self.screen.fill(BLACK) pygame.draw.rect( self.screen, DARK_GRAY, (PLAY_AREA_LEFT, PLAY_AREA_TOP, GRID_COLS * CELL_SIZE, GRID_ROWS * CELL_SIZE) ) self.draw_grid() for row in range(GRID_ROWS): for col in range(GRID_COLS): if self.grid[row][col] != 0: self.draw_cell(self.screen, col, row, COLORS[self.grid[row][col]]) if not self.game_over: self.draw_piece(self.current_piece) self.draw_info() if self.game_over: overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) self.screen.blit(overlay, (0, 0)) self.draw_text("GAME OVER", PLAY_AREA_LEFT + 55, PLAY_AREA_TOP + 260, self.font, RED) self.draw_text("Press R to restart", PLAY_AREA_LEFT + 40, PLAY_AREA_TOP + 300, self.small_font) elif self.paused: self.draw_text("PAUSED", PLAY_AREA_LEFT + 70, PLAY_AREA_TOP + 260, self.font) def run(self): running = True while running: dt = self.clock.tick(60) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_r and self.game_over: self.reset() elif not self.game_over: if event.key == pygame.K_p: self.paused = not self.paused elif not self.paused: self.handle_key(event.key) if not self.game_over and not self.paused: self.fall_time += dt if self.fall_time >= self.fall_speed: self.fall_time = 0 if self.valid_position(self.current_piece, dy=1): self.current_piece['y'] += 1 else: self.lock_piece() self.draw() pygame.display.flip() pygame.quit() if __name__ == '__main__': TetrisGame().run()

这个版本我实测过,能正常运行。如果想改一些数值,比如下落速度、格子大小、颜色搭配,直接在常量区修改就行。

4. 常见问题与调试实录

4.1 方块越界与旋转穿墙

我最早写碰撞检测的时候,漏掉了board_x < 0这个判断,结果方块能钻出左墙。后来把三个边界条件都补上就正常了。更隐蔽的问题是旋转时靠墙,原始位置检测不过就直接放弃旋转,玩家会觉得方块“转不动”。

解决办法就是前面提到的踢墙逻辑。但它有一个局限:我给的偏移量列表是[1, -1, 2, -2],对大部分场景够用,但对I方块这种宽4格的形状,贴墙旋转时可能需要偏移3格。如果你想做得更严谨,可以把偏移量扩展成[1, -1, 2, -2, 3, -3]。不过对初学者来说,先保持这个简单版本就好。

4.2 消行判断失效

有朋友跑代码时会遇到一种情况:明明一行已经满了,就是不消。排查下来发现他用了row.count(0) == 0判断,按理说没问题,问题可能出在方块落定前就把方块“嵌”进了网格。所以在lock_piece里,必须先遍历形状矩阵把当前方块的格子写入grid,再检查消行。

还有一个小坑:消行后如果直接在原self.grid上pop再insert,循环遍历时容易跳过行。我的做法是先构建new_grid列表,再整体替换,这样最安全。

4.3 下落节奏不稳定

游戏跑起来有时一卡一卡的,尤其方块刚落下时。我排查过,主要是主循环里用pygame.time.wait或者直接time.sleep控制速度导致的。正确做法是用clock.tick(60)返回的毫秒数累加,这样不管帧率怎么波动,每秒下落的速度都是固定的。

如果你发现自动下落会出现“一下掉两格”的现象,多半是没把self.fall_time清零。累加器一定要在触发下落后归零,否则剩余时间会被带到下一帧。

4.4 键盘响应与手感调优

pygame.KEYDOWN事件每次按键只触发一次,长按不会连续移动。真机玩的时候,按住左右方向键只有第一下响应,体验很差。改进的办法是记录按键状态,在逻辑更新里连续起作用:

keys = pygame.key.get_pressed() if keys[pygame.K_LEFT] and self.move_timer >= 120: self.move_left() self.move_timer = 0

如果你想朝这个方向改,建议做一个简单的DAS(Delayed Auto Shift)系统:按住方向键后等待大约150毫秒的延迟,然后开始每隔50毫秒移动一次。这是俄罗斯方块手感的关键,也是从“能玩的demo”到“手感不错”的重要一步。

4.5 常见问题速查表

问题可能原因解决方式
方块穿墙碰撞检测少了边界判断检查board_x和board_y边界条件
贴墙旋转失灵没有踢墙逻辑旋转失败后尝试左右偏移
消行不成功消行前方块没写入网格先写入grid再判断满行
自动下落忽快忽慢用sleep控制速度用clock.tick(60)返回的时间累加
长按方向键没反应只用KEYDOWN处理移动改用pygame.key.get_pressed()或DAS
游戏一开局就结束新方块与已固定方块重叠生成新方块后检查valid_position
方块锁在屏幕顶部锁定位置board_y < 0检测到越顶就置game_over

5. 扩展方向与个人体会

5.1 进阶玩法与功能建议

这个版本是能玩的,但离完善还有不小距离。我自己后来改进时优先加了这几样:

第一个是幽灵方块。就是当前方块正下方显示一个半透明轮廓,让你知道它会落在哪。实现方式也简单——复制当前方块,不断向下尝试合法位置,直到碰壁,然后把这个位置画出来。加了这个以后,游戏体验提升非常明显。

第二个是消行动画。消行那一瞬间如果直接消失,看着有点生硬。可以先把满行闪白一两帧,再执行删除。这需要改draw流程,加一个“待消除行”列表。

第三个是音效和背景音乐。pygame的pygame.mixer.Sound就能实现,难点不大,但能让游戏完整度再上一个台阶。

第四个是暂停界面和成绩保存。把最高分写进一个文本文件或JSON,下次启动读出来显示,算是把文件操作也练了。

如果想追求更硬核的玩法,可以去研究俄罗斯方块准则(Tetris Guideline)里定义的SRS旋转系统、7-bag随机法、锁定延迟、无限旋转等概念,这些才是俄专业玩家讨论的东西。我们这套基础代码离准则还有距离,但恰好是个钻研的好起点。

5.2 如何高效消化这个项目

拿到代码先别急着跑。第一遍通读,把valid_position、lock_piece、rotate_piece这三个函数看懂,这是核心。第二遍自己动手删掉一些逻辑,比如把碰撞检测改成只检测边界、把消行注释掉,观察会发生什么,这样你能快速理解每一部分的作用。

第三遍再尝试改东西。我推荐三个循序渐进的改造目标:把网格改成12列15列、给方块换一套颜色、加一个下一个方块的预览。这三个都改通了,这个项目基本就算吃透了。

根据我个人的经验,俄罗斯方块这种项目每隔几个月重新写一遍,你的写法都会不一样。第一版可能全是函数堆在一起,第二版开始用类,第三版会想着怎么拆分模块。每一步都说明编程水平在进步。所以我更愿意把这个项目当成一个“反复回炉”的练手项目,而不是写完就算交差的作业。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 1:14:06

超市信息管理系统数据库设计:从需求到触发器完整复现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:14:02

本地跑通开源大模型:LLM、Llama与Ollama入门指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:13:35

Android Studio实战:打造轻量级对话机器人App全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:12:22

编译原理实战:从词法分析到中间代码生成,手写一个最小编译器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:11:28

STM32F103自定义HID开发全指南:从USB枚举到WinUSB免驱通信

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华