ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

用Python和pygame从零实现俄罗斯方块:核心算法与实战解析 1. 项目概述与整体设计思路1.1 为什么选俄罗斯方块练手说实话俄罗斯方块这个经典案例是我见过最适合拿来学习 Python 开发的项目之一。你要说你正在学 Python想写点有反馈、有界面、还能拿得出手的东西做俄罗斯方块几乎是绕不开的选择。原因很简单这个项目覆盖的知识点密度非常高。从最基本的二维数组操作到面向对象设计再到游戏循环、事件处理、状态管理一整套下来你在别的项目里可能要分开好几篇教程才能遇到的东西俄罗斯方块一个项目全给你揉进去了。另外一点很实际——它不像爬虫、Web 开发那样需要依赖一堆外部服务或框架只需要 Python 标准库加一个 pygame 库就能完整跑起来。环境搭建几乎没有门槛适合大多数人边看边写。我这次实现采用的是 pygame 框架选它不是因为它是唯一的选择而是它的学习曲线最平缓。pygame 提供的核心能力就是操控窗口、处理键盘事件、绘制图形和播放声音这几个能力恰好就是做俄罗斯方块需要的全部底层支持再往上的逻辑全得你自己写这反而是一个好事因为真正有学习价值的部分恰好都保留在你自己手写的逻辑里。1.2 技术选型pygame 还是纯终端先聊一下另一个选择。网上也很多人用纯终端方式做俄罗斯方块——就是通过 print 刷新控制台、用 ANSI 转义码控制光标位置实现动态显示。这种做法有它的趣味性也能玩但和 pygame 版本相比缺了很重要的东西实时性、键盘响应手感、以及游戏窗口与渲染逻辑之间的解耦。终端方式做俄罗斯方块最大的硬伤是刷新率不稳定控制台窗口的刷新机制会直接限制游戏运行体验尤其是方块下落速度较快时闪烁和撕裂感非常明显。而 pygame 给出了稳定的帧循环在 60 FPS 下整个动画极其顺滑。还有一点pygame 里自带的 sprite 和 Rect 对象能让你在处理矩形碰撞和位置计算时代码量减少很多。这一点在俄方块里体现得很明显因为整个游戏的核心逻辑本质上是在一块二维矩阵上做状态变更pygame 的 Rect 能帮你天然地把“绘制区域”和“逻辑格子”解耦开。我的最终方案是底层逻辑全部用纯 Python 的二维列表list of list管理不依赖 pygame 的数据结构渲染层用 pygame负责把逻辑数据画到屏幕上。这样划分有一个明显的好处——如果你以后想把游戏逻辑移植到别的渲染框架或者加一个终端调试视图核心逻辑一行都不用改。2. 核心原理与难点拆解2.1 游戏循环与状态流转俄罗斯方块整体是一个典型的状态机。你别觉得“状态机”这个词有多玄乎说白了就是整个游戏在任何一个时刻只会处于几种状态中的一种比如主菜单、游戏中、暂停、游戏结束。每个状态下程序处理的事情不同状态的转换有明确的触发条件。我这次的分法是四个状态READY准备、PLAYING进行中、PAUSE暂停、GAMEOVER结束。其中 PLAYING 是核心状态其余都是辅助状态。代码实现上我用了一个字典加函数回调的方式来管理状态而不是写一堆 if-elseimport sys import pygame class Game: def __init__(self, width400, height600): pygame.init() self.screen pygame.display.set_mode((width, height)) pygame.display.set_caption(Python Tetris) self.clock pygame.time.Clock() self.state READY self.state_handlers { READY: self.handle_ready, PLAYING: self.handle_playing, PAUSE: self.handle_pause, GAMEOVER: self.handle_gameover, } self.running True # 其他属性初始化... def run(self): while self.running: dt self.clock.tick(60) / 1000.0 # 转为秒 handler self.state_handlers[self.state] handler(dt) def handle_ready(self, dt): for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN and event.key pygame.K_SPACE: self.start_game() def handle_playing(self, dt): for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN: self.handle_keydown(event.key) self.update(dt) self.render() def handle_pause(self, dt): for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN and event.key pygame.K_p: self.state PLAYING def handle_gameover(self, dt): for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN and event.key pygame.K_SPACE: self.reset_game() self.state READY核心思想就是游戏主循环只做一件事根据当前状态找到对应的处理方法然后调用。这样当你加功能时只需要增加一个新的状态和一个新的处理方法不会去碰已经稳定的逻辑。这里有个我实际踩过的坑在 READY 状态下按空格进入游戏时如果你后面做按键检测用的是边缘触发而不是状态切换会出现“按一次空格触发了两次事件”的情况。对应到代码里就是事件已经在 READY 状态里被消费了但 buffer 里还有残留的按键状态传到 PLAYING导致开局瞬间方块就跑偏一步。解决办法是进入新状态时清空 pygame.event.get() 的残留队列。2.2 棋盘数据结构二维数组的一切整个俄罗斯方块的最底层地基是一块 10 列 x 20 行的逻辑棋盘。我选择的数据结构非常简单就是一个二维列表里面每个元素要么是 0空格要么是有颜色的方块编号如 1~7。class Board: def __init__(self, cols10, rows20): self.cols cols self.rows rows self.grid [[0] * cols for _ in range(rows)]使用二维列表有几个关键点要特别注意第一生成方式一定要用列表推导式[[0] * cols for _ in range(rows)]千万别用[[0] * cols] * rows。后者生成的是“引用同一个行的 N 份拷贝”到时候你修改 grid[0][0]会导致 grid[1][0]、grid[2][0] 全部跟着变。这个坑我在初学时踩过一次那种神秘 bug 排查起来非常费时。第二为什么棋盘是 10x20这是经典俄罗斯方块的配置10 列宽度是平衡了可玩性和视觉比例的经典选择20 行高度配合 10 列刚好形成接近 1:2 的显示比例。你用 py_game 渲染时每个格子大小可自定义我用的格子尺寸是 30 像素所以游戏区域尺寸是 300x600 像素加上左右留白和右侧信息面板整个窗口设为 400x600。第三棋盘坐标系的习惯我推荐行从上往下递增列从左往右递增。虽然像素坐标也是这个朝向但很多人脑海里会习惯性把行当成数学纵轴导致方块下落逻辑写反。确认一下方向后面读代码时能节省你很多时间。方块在东落的过程中本体也要在棋盘中表示。我用的方案是活动方块和棋盘不混在同一个数组里活动方块单独维护位置和形状索引只有等到方块“落定”之后才写入棋盘的 grid 数组中。这种分离的好处是你在处理移动和旋转时不需要频繁检查棋盘数组的状态只需要在最终写入时做一次碰撞检测。2.3 方块矩阵表示法旋转如何一拍即合俄罗斯方块的七种形状标准做法是用一个四维数组四张矩阵图来表示每种方块的四个旋转状态。但这样写太啰嗦了我采用了一种更精简的方式只定义方块的基础形状旋转时用矩阵转置加翻转来实时计算。具体来讲每个方块由一个二维矩阵表示里面用非 0 数字标出方块占据的格子。例如SHAPES { I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]], L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]], O: [[1, 1], [1, 1]], S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]], T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]], Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]], }旋转的核心操作顺时针旋转等于矩阵转置后按行翻转逆时针旋转等于先按行翻转再转置。我写了一个通用函数def rotate_clockwise(shape): return [list(row) for row in zip(*shape[::-1])] def rotate_counterclockwise(shape): return [list(row) for row in zip(*shape)][::-1]解释一下rotate_clockwiseshape[::-1]是把矩阵的每一行上下颠倒然后zip(*...)做转置得到的就是旋转后的矩阵。这个过程可以在草纸上画一个 2x2 的小矩阵自己去推一遍会比背代码有效得多。这里有一个细节I 形方块比较特殊。因为它是一条直线旋转时会从横着的 4x1 变成竖着的 1x4矩阵尺寸发生变化。如果直接用上面的旋转函数竖着的 I 的包围框会变成 1x4和横着时的 4x4 包围框不同。这本身没有问题只要你的碰撞检测是基于实际矩阵而非固定包围框就行。我实测下来这反而比单独给 I 写特殊逻辑更省事。我建议你把旋转后的新矩阵先在内存中算好再用碰撞检测函数判断新位置是否合法如果合法才真正替换当前方块的形状。不要在碰撞检测函数里顺手改掉方块形状那样调试起来会非常痛苦。2.4 碰撞检测俄罗斯方块的“物理引擎”碰撞检测是整个游戏的判断核心处理得不好实际手感就会有明显差异。我需要检测的情况只有三种边界碰撞、堆叠碰撞、落地检测。我把逻辑封装成一个统一的方法def valid_position(self, board, shape, offset): for r, row in enumerate(shape): for c, cell in enumerate(row): if not cell: continue board_x offset[0] c board_y offset[1] r if board_x 0 or board_x board.cols: return False if board_y board.rows: return False if board_y 0 and board.grid[board_y][board_x] ! 0: return False return True这里 offset 是指方块左上角在棋盘中的坐标列, 行。注意我允许 board_y 小于 0 的情况——这是故意设计的因为方块刚生成时一部分在屏幕上方未进入棋盘区域。但判断碰撞时只要 board_y 在棋盘内就必须检查是否有重叠。一个易忽略的边界点是如果检测到 board_y 等于 rows即方块底边碰到底部此时算合法吗答案是非法——方块已经超出棋盘范围应该被钳制。所以我直接if board_y board.rows: return False。当方块无法向下移动但位置又合法时就触发“固定”操作把方块写入棋盘 grid。写入之后立刻扫描整行看哪些行满了逐行消除并计分。碰撞检测的时机有三个地方会用到左移、右移、下移。旋转时也要用但此时的 offset 保持不变形状矩阵变成旋转后的矩阵。还有一个常见优化是提前判断当前方块是否已经触底触底后直接固定、生成下一个方块无需再等一次下落 tick。这个策略能极大提升游戏手感避免出现“明明碰到下面了还得等一帧才固化”的迟滞感。3. 完整实现与源码解析3.1 工程结构与源码组织方式整个项目我拆成了几个文件虽然标题说“附源码”但我不建议你把代码全塞进一个文件里。拆分的逻辑很简单界面渲染、逻辑数据、游戏控制分别放一层这样改渲染方式不用动游戏逻辑。我的工程目录大致如下tetris/ ├── main.py # 入口初始化游戏并启动主循环 ├── board.py # 棋盘逻辑grid 管理、消行、计分 ├── shapes.py # 方块形状表、旋转函数 ├── tetromino.py # 当前活动方块类移动、旋转、落定 └── game.py # 游戏控制状态管理、事件处理、渲染这种拆法不是标准答案但对这个项目的规模正好。文件再多会显得琐碎再少又会让单个文件超过五百行阅读和维护都不舒服。下面我把几个核心文件的关键实现展开不会把每个文件中每行注释都贴出来但所有核心函数我都会展示并解释。3.2 方块类活动方块的移动与旋转活动方块类负责维护当前位置与形状并且负责执行移动、旋转等操作。我设计了如下结构import random from shapes import SHAPES, rotate_clockwise, rotate_counterclockwise class Tetromino: def __init__(self, shape_key, x0, y0): self.shape_key shape_key self.shape [row[:] for row in SHAPES[shape_key]] self.x x # 列偏移 self.y y # 行偏移 def translate(self, dx, dy): return self.x dx, self.y dy def rotated(self, counterclockwiseFalse): if counterclockwise: return rotate_counterclockwise(self.shape), self.x, self.y return rotate_clockwise(self.shape), self.x, self.y这里我特意把“移动或旋转后的结果”和“实际修改状态”做了分离。移动时先算出新位置用 Board 的校验函数检测新位置合不合法合法才改self.x和self.y。这样做好处很多尤其是当你需要实现“踢墙”逻辑时可以连续尝试几个候选位置选择第一个合法的结果整个过程不会污染当前方块的真实状态。生成新方块时初始 y 偏移设在棋盘顶部上方的负几行。经典做法是如果当前方块 shape 有 4 行就设初始 y-1 或 y-2使得方块的上半部分刚好在屏幕外缓缓露出。初始 x 设为棋盘宽度减去形状宽度后的一半。新方块颜色我直接根据 shape_key 映射到不同的 RGB 值COLORS { I: (0, 204, 204), J: (0, 102, 204), L: (255, 165, 0), O: (255, 255, 0), S: (102, 204, 0), T: (153, 0, 204), Z: (255, 51, 51), }这个颜色表是沿用的经典配色每个方块之间冷暖区分明显颜色像素相近的形状比如 S 和 Z用冷暖色调拉开差异实际游戏时基本不会认错。3.3 棋盘逻辑消行、计分、判定结束棋盘 Board 类的核心方法之一是消行。经典俄罗斯方块是整行满则消行上面的行整体下移一行。这里我建议用“列表推导重建”的方式而不是循环逐行移动后者代码繁琐还容易出边界 bug。class Board: def clear_full_rows(self): new_grid [] removed 0 for row in self.grid: if all(cell ! 0 for cell in row): removed 1 else: new_grid.append(row) # 在顶部补空行 for _ in range(removed): new_grid.insert(0, [0] * self.cols) self.grid new_grid return removed这个写法有一个非常关键的小细节new_grid里面保留的是原 grid 中行的引用而new_grid.insert(0, [0] * self.cols)插入的是新创建的行。因为原 row 不会再被修改所以保留引用完全没问题性能上也更好不用复制整张棋盘。消行之后计分我采用的是经典计分表消 1 行得 100 分2 行得 300 分3 行得 500 分4 行得 800 分。实际使用中多做了一件事把最近一次消行的行数记录下来用于在界面上显示特效文字“Double!”“Triple!”之类的反馈体验感差别挺大。游戏结束判定有两种情况要区分一种是最常见的新方块生成的位置与棋盘已有格子重叠——此时说明棋盘已满游戏结束。实现方式是在新方块生成时调用valid_position检测如果新方块在初始位置就不合法说明游戏结束。另一种是边界的特例方块的一部分还在棋盘上方看不见的区域由于棋盘看不到这部分所以不会触发重叠。这时要判断的是“方块最终是否有一部分能落入棋盘内”。如果棋盘顶部已经堆得极满方块根本进不来也会在生成时被判定为非法。判定过后做一件事用一个变量保存 game over 的原因是堆满还是越界再弹提示时就可以区分是“你输了”还是“棋盘已满”虽然最终的界面文案都是 Game Over但对开发者自己调试时很有帮助。3.4 渲染层pygame 的绘制细节pygame 的渲染部分不复杂但有几个地方处理不好会直接影响视觉质量。首先是网格线的画法。你可以直接用 pygame.draw.line 一条条画出棋盘分隔线也可以用更高效的“整块背景图上画格子”方案。因为 10x20 一共才 200 个格子性能上没必要过度优化我直接用一个二维循环来绘制def draw_board(screen, board, block_size30): for r in range(board.rows): for c in range(board.cols): color board.grid[r][c] if color: rect pygame.Rect( c * block_size, r * block_size, block_size - 1, block_size - 1 ) pygame.draw.rect(screen, color, rect, border_radius4) else: rect pygame.Rect( c * block_size, r * block_size, block_size - 1, block_size - 1 ) pygame.draw.rect(screen, (28, 28, 28), rect, width1)注意我给真正的方块格子做了一点圆角border_radius4同时把尺寸减了 1 像素留出缝隙。这个改动很小但能让画面整体从“实验室代码风格”变成“有时间打磨过的游戏风格”视觉差异非常明显。其次是当前活动方块的绘制。它在逻辑上是独立于棋盘的所以渲染时是画在棋盘绘制之上。但要注意活动方块的 y 坐标可能为负数——此时方块部分区域在棋盘可见区上方。如果你直接用坐标乘以格子尺寸去绘制pygame 会自动裁掉超出窗口的部分这没问题。但如果你的窗口背景色和方块颜色很接近负区域绘制出来的部分可能残留在窗口顶部看不见的地方造成错觉。解决办法是绘制方块前先用背景色把游戏区域整体清一遍。第三个细节是“预览下一个方块”。我是在右侧信息面板绘制下一个方块的形状。因为窗口主区域是 300x60010 列 x 20 行右侧还剩 100 像素宽专门放预览、分数、等级。预览方块绘制时不能简单复用主棋盘的坐标因为右侧面板有自己的原点偏移def draw_next_shape(screen, next_shape, panel_x320, panel_y120, block_size20): for r, row in enumerate(next_shape): for c, cell in enumerate(row): if cell: rect pygame.Rect( panel_x c * block_size, panel_y r * block_size, block_size - 2, block_size - 2, ) pygame.draw.rect(screen, (255, 255, 255), rect, border_radius3)3.5 控制手感按键映射与 DAS 优化俄罗斯方块的控制手感是整个游戏体验最直接的影响因素。我实测了各种按键方案最优的默认布局是这样的方向键左右左右移动方向键下加速下落每 tick 下降 1 格方向键上顺时针旋转Z 键逆时针旋转空格直接硬降到底hard dropP 键暂停R 键重新开始需要特别说的是硬降和加速下落之间的手感差异。加速下落实际是“把下落间隔临时缩短”到极短但你依然能控制方块操作是持续的。硬降是“立即固定方块生成下一个”一次性动作。两者在实现上是完全不同的代码路径千万别把加速下落的代码在 keydown 事件里处理那样按一下下键方块只会挪一格而不是持续下落手感会非常笨。关于持续按键移动DAS即延迟自动移动经典俄方块里有个“按住方向键会连续移动”的机制按一下右移一格按住 0.2 秒后开始连续右移每 0.05 秒再移一格。pygame 的键盘事件默认不支持这种连续触发只会在 keydown/ keyup 时发一次事件要实现 DAS需要在每帧循环里持续检查pygame.key.get_pressed()而不是依赖事件。我的实现里是这样的keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: self.das_counter dt if self.das_counter self.das_delay: try_move(-1, 0) self.das_counter 0 elif keys[pygame.K_RIGHT]: self.das_counter dt if self.das_counter self.das_delay: try_move(1, 0) self.das_counter 0 else: self.das_counter 0第一条移动是立即响应的后续是靠计时器维持连续。按住 0.15 秒开始重复重复间隔我设置成 0.03 秒这样实测在加速下落时也能很丝滑地调整位置。3.6 下落速度等级换算与分数游戏难度靠下落渐快来体现经典版本里每消 10 行升一级。我采用的换算表是LEVEL_SPEEDS { 1: 0.8, # 每帧 0.8 秒下落一格 2: 0.7, 3: 0.6, 4: 0.5, 5: 0.4, 6: 0.3, 7: 0.2, 8: 0.15, }超过第 8 级后会再继续压缩但最低提到每秒 10 格再快人类就反应不过来了。这里有一个容易忽略的细节下降计时器是按帧累计的但帧率并不是严格稳定的 60 FPS。如果直接按“每 0.8 秒累计一格”写一旦掉帧方块下落间隔会变长游戏会突然变慢。更稳的做法是用每一帧的实际耗时 dt 累加到下落计时器上到达阈值才移动一格。这样即使帧率波动下落速度也能保持恒定。我简单列一下下落循环的核心逻辑self.fall_timer dt speed LEVEL_SPEEDS.get(self.level, 0.15) if self.fall_timer speed: self.fall_timer 0 if not try_move(0, 1): self.lock_piece()这样每次调try_move时如果返回 False说明方块下方被挡立即调用lock_piece把方块写进棋盘再生成下一个块。lock_piece 里面还会触发一次消行检查并更新分数和等级。4. 常见问题与排查技巧实录这一部分我梳理了在实际写这个项目时最容易遇到的问题按踩坑概率从高到低排列。4.1 pygame 窗口启动后黑屏无显示这是新手第一个遇到的几乎必然问题。代码逻辑没错但窗口打开后黑屏或者只显示一个白板区域方块看不见。原因通常是两个要么是主循环里忘记调用pygame.display.flip()要么是绘制顺序错了。pygame 的绘制模式是“后台缓冲一次提交”你在渲染函数里画的所有东西都画在缓冲里只有display.flip()才会把缓冲内容提交到屏幕上。检查步骤很简单确认渲染函数末尾调用了pygame.display.flip()确认主循环里clock.tick之后没有提前 return把渲染逻辑跳过确认窗口没被遮挡如果窗口位置不在桌面可视区域画了也看不见。还有一种隐蔽情况如果你在游戏初始化时设置了pygame.display.set_mode((0, 0), pygame.FULLSCREEN)但后续所有绘制坐标还是按窗口尺寸计算大概率会显示错位。我建议小屏幕调试时固定用窗口模式。4.2 旋转时方块“跑偏”或“穿模”这个几乎是每个俄罗斯方块开发者都会遇到的。比如 T 形方块靠墙时按旋转方块直接穿到了墙里或者旋转后位置跳了一大截。核心原因只做了形状旋转没有做旋转后的位置纠正。经典俄罗斯方块里有所谓的“踢墙”机制即旋转时如果目标位置因为贴墙或被遮挡而非法尝试向左、向右、向上偏移若干格子看能不能把方块“挤”进去。我的实现是这样一个列表顺序WALL_KICKS [(0, 0), (-1, 0), (1, 0), (0, -1), (-2, 0), (2, 0)]旋转时依次尝试这些偏移def rotate(self, counterclockwiseFalse): new_shape, x, y self.rotated(counterclockwise) for dx, dy in WALL_KICKS: if board.valid_shape(new_shape, (x dx, y dy)): self.shape new_shape self.x dx self.y dy return True return False实测向左偏移尝试放在向右之前手感上会好一些因为大多数玩家习惯用右墙停方块然后按旋转向右踢的成功率更高。4.3 下落过程出现“双块”或“残影”这个现象是方块下落时屏幕上同时出现两个相同颜色的方块或者旧位置有残影。原因在于渲染时没有清屏。pygame 里如果你不把窗口整体填充成背景色上一帧画的方块会一直留在缓冲区里。你必须在绘制任何东西之前先把整个窗口填上背景色screen.fill((0, 0, 0))如果你的游戏界面有面板区域也最好一次填充清除所有内容再画新的。只清游戏区域而不管右侧信息面板分数字符串叠加后也会形成视觉残影这类问题单靠眼睛排查很吃力统一“每帧全屏清空再绘制”是最简单可靠的方案。4.4 按键响应延迟或失灵这个问题比较诡异很多人把锅甩给 pygame其实问题出在事件循环本身。pygame 的pygame.event.get()会从事件队列里取出所有待处理事件。如果你在一个游戏循环里用了多个地方分别调用pygame.event.get()比如一处处理键盘另一处处理关闭按钮那第一次调用会把所有事件取走第二处什么都拿不到。正确做法是主循环里只调用一次pygame.event.get()然后把结果分发到各个处理函数。我前面贴的状态机代码已经保证了这一点每个状态只调用一次事件获取。还有一个非常实际的优化硬降空格时不要放在KEYDOWN里直接执行而是设置为一个标志位在每帧更新开始时统一处理。原因是有时候按键事件发生在方块下落刻度之后如果硬降函数里直接操作了棋盘那么这一帧可能还没执行自然下落导致硬降后位置偏了一格。把硬降延迟到下一帧开头执行这类时序bug就能从根本上避免。4.5 消行后行数不变或整块消失消行逻辑最容易出现的边界错误是消了一行之后上面的行下移时覆盖了下面未消除的行导致行数变多或变少。我之所以推荐那个“重建 new_grid”的方案就是为了彻底避开这些问题。这个方法的核心思路是不管消除几行最后一定是中间的那些“保留行 顶部补空行”的拼接结果。任何一行都不可能在过程中被意外复制或丢失。但我还遇到过一个衍生问题方块落定时把一个 2x2 的 O 块写入棋盘由于 O 块的两行会横跨“待消除行”和“非消除行”如果消行检查时只检查了当前活动方块的占用行就会漏掉另一行凑满的情况。所以消行必须全盘扫描整张棋盘的 20 行不能只看局部。正确实现应当是先正常落定方块再调用clear_full_rows扫描全部 20 行。4.6 后期性能卡顿说实话10x20 的俄罗斯方块正常情况下绝不会卡顿。如果你发现玩到后期画面明显变慢大部分原因是“幽灵方块”或“预览阴影”的重复计算太多了。我自己曾经写过一个“投影阴影”功能就是实时计算当前方块如果下落到最底部会落定的位置并把这个位置的轮廓画出来。这个计算需要从当前位置一直往下试探每一次试探都做碰撞检测本来也没多少运算量。但如果你在每次下落 tick 里都重新计算一次投影并且绘制时使用了几百个透明 surface掉帧就出现了。性能排查建议是先确认是不是绘制层的问题把draw_shadow注释掉再看帧率如果没问题再去看逻辑层有没有产生大量不必要的矩阵拷贝。比如每次移动、旋转时都对形状矩阵做深拷贝正常情况下也不会太卡但如果你在循环里嵌套调用就会出现问题。4.7 随机生成“连续 7 个方块都一样”的体感问题俄罗斯方块老玩家很在意随机性体验。如果你直接random.choice(SHAPES.keys())真实随机下出现连续 5 个同样的 S 形是完全可能的但玩家会觉得这是 bug。业内常用的做法是“七袋随机法”把七种形状放进一个袋子里随机打乱然后依次取出取完后再重新装袋。这样能保证每 7 个方块中必然出现全部 7 种类型一次。class BagRandomizer: def __init__(self): self.reset() def reset(self): self.bag [] def next(self): if not self.bag: self.bag list(SHAPES.keys()) random.shuffle(self.bag) return self.bag.pop()实测用了七袋随机后游戏中 gerat连击四行的触发频率更稳定玩家不会因为长时间等不到合适的方块而烦躁。4.8 字体渲染中文乱码如果你想在界面上显示中文比如“下一块”“得分”pygame 默认字体不支持中文会显示成方块或乱码。解决办法是使用系统字体pygame.font.SysFont(microsoftyahei, 24)Windows 上我用的是微乳雅黑macOS 上可以用pingfangsc或heitiLinux 上一般有wqy-microhei。如果你用的系统里找不到中文字体双保险方案是把字体文件比如一个 .ttf 文件放在项目目录下通过pygame.font.Font(fonts/round.ttf, 24)加载。这个方式跨平台更稳还不用依赖系统安装过哪些字体。5. 一个值得动手的功能扩展幽灵方块与音效反馈基础版本跑通之后我建议你优先加两个功能幽灵方块和音效。幽灵方块就是前面提到过的投影轮廓。它的实现思路并不复杂但细节决定成败。核心逻辑从当前方块位置开始不断尝试try_move(0, 1)直到不能再往下移动然后把当前方块形状的轮廓绘制成半透明或者虚线边框。我用的实现是def draw_ghost(self, board, screen, block_size30): ghost_y self.y while board.valid_position(self.shape, (self.x, ghost_y 1)): ghost_y 1 for r, row in enumerate(self.shape): for c, cell in enumerate(row): if cell: rect pygame.Rect( (self.x c) * block_size, (ghost_y r) * block_size, block_size - 2, block_size - 2, ) pygame.draw.rect( screen, (128, 128, 128), rect, width2, border_radius4 )这里有一个细节幽灵方块绘制必须在普通方块绘制之后否则会被当前方块盖住导致视觉上分不清哪个是实体哪个是投影。音效方面pygame 自带 mixer 模块可以加载 wav 文件。移动、旋转、消行、硬降各配一个短音效。如果你不想准备音频素材也可以先用 pygame 自带的简单合成音暂时顶替重点是先跑通 mixer 的加载播放流程。pygame.mixer.init() move_sound pygame.mixer.Sound(sounds/move.wav) def play_move_sound(): move_sound.play()音效触发的时机也要注意不要在按键发生时直接播放而是先判断“移动是否成功”。移动成功才播提示音移动失败不播否则玩家会听到一堆无效的按键反馈反而干扰操作。加上这两个功能之后游戏的整体完成度会有非常明显的提升这一步做完你就不只是“写了一个能跑的逻辑”而是“做了一款有完整体验的小游戏”。6. 从源码到沉淀模块化与复盘的意义写到这我想聊点项目之外但更重要的东西。很多人在按教程写完俄罗斯方块之后习惯是把源码丢进文件夹关掉编辑器然后项目就这么结束了。这个习惯会浪费掉整个项目最大的价值。我自己的做法是项目完成后的第一天不看源码用文字把整个游戏运行时的状态流程画出来包括状态怎么切换、方块怎么移动、碰撞后发生了什么。这一步能检测出你究竟是真理解了还是只是照着代码机械化地敲完一遍。然后我可以给每个类的方法做一次“代码走查”——从 Board 类开始逐个读自己写的函数看能不能用一句大白话把每个方法的目的说清楚。说不清楚的地方通常就是设计不合理或者边界有隐患的地方趁热打铁重构掉。俄罗斯方块这个项目最值得你深入思考的点我总结成三个第一个是棋盘数据结构和渲染的分离。你写的 Board 类完全不依赖 pygame往前推一步它甚至可以跑在 Web 端、终端、或者嵌入到任何有二维数组概念的场景里。这种“逻辑与表现分离”的思想在以后写任何游戏或者复杂应用时都会反复用到。第二个是所有游戏动作都走一套统一的合法性校验。移动是尝试旋转是尝试生成也是尝试唯一的差异是尝试成功后的动作不同。把校验集中到一个函数里而不是分散在各处自己写 if 判断整个项目就不容易出现“某个操作漏判边界”的隐性 bug。第三个是状态机管理游戏流程的思想。俄罗斯方块这个规模的项目你用 if-else 硬写也能撑住但一旦加菜单、加设置页、加暂停动画硬写的代码会在十几次改动后彻底失控。状态机让每个状态的逻辑彼此独立加新功能不碰旧代码这才是它真正的价值。如果你能把这三个点想明白写一遍俄罗斯方块得到的成长其实比你机械地刷一百道小算法题来得更扎实。这也是为什么我特别推荐 Python 学习者把俄罗斯方块作为自己的第一个游戏项目——它的体量刚好代码一百多行视觉效果足够有成就感又能覆盖编程中最核心的几个抽象是性价比非常高的练手项目。最后分享一个小技巧如果你之前没有用过 pygame先别急着把整个项目完整复刻我建议你第一天只写一个能在窗口里移动的小方块第二天再加重力下落第三天再补旋转消行第四天整合完整玩法。小步快跑会让你每完成一个阶段都有正向反馈也比一次性写一个五百行的大文件要稳得多。
返回列表