☰
91行Python代码实现俄罗斯方块:核心逻辑与tkinter实战拆解
2026/10/16 12:57:16 网站建设 项目流程

很多朋友看到“91行代码写一个俄罗斯方块”的第一反应是:这肯定又是一个哗众取宠的标题党。我也这么想过,直到自己动手把完整逻辑压缩了一遍之后才明白,行数少并不代表功能残缺,它只是把重复和废话全部挤掉了。这篇文章就把我用91行代码实现俄罗斯方块的过程、核心思路和踩坑记录完整拆给你看。它适合想搞懂游戏循环、碰撞检测和二维数组用法的人,也适合那些想写一个属于自己的小项目却不知道从哪开始的人。阅读前你只要会一点Python基础,不熟悉tkinter也没关系,后面会解释到。

1. 91行代码的俄罗斯方块,到底长什么样

1.1 这不是砍功能的“乞丐版”,而是逻辑压缩

很多人天然会认为,行数越少功能越简陋。但俄罗斯方块这个游戏,核心规则可以拆得很干净:一个网格、七种方块、四种移动方向、一种旋转、一种下落、一次消行。说白了,只要把这些规则都写出来,剩下的都是循环和刷新。

我在第一次做这个Demo时,按正常习惯写了一百七八十行。里面有不少“防备未来需求”的代码,比如独立的音效类、计分类、菜单类,然后突然意识到:这些跟“俄罗斯方块能不能跑起来”并没有直接关系。于是我开始做减法,把每个功能都压到最小可维护表达。最终去掉所有空行和注释后的源码正好91行。

这里要强调一句:91行并不是一个必须追求的目标,真正有用的是,当你强迫自己减少行数时,你会被迫去审视哪些逻辑可以合并、哪些变量不该存、哪些函数其实只是换了个名字重复做同一件事。这个过程比拿到一份现成的几百行“完整架构”要深刻得多。

1.2 为什么用tkinter而不是其他游戏引擎

这个项目我选的是Python自带的标准库tkinter。原因有三个:

  • 零依赖:任何人装好Python就能跑,不用额外安装第三方库。
  • 代码短:tkinter的Canvas画矩形和绑定键盘事件足够应付俄罗斯方块,不需要复杂的精灵和物理系统。
  • 教学友好:它暴露的是事件驱动模型,正好能解释清楚“点击按键后游戏状态怎么更新”。

如果你更熟悉pygame,其实核心逻辑一模一样,只是绘制和事件部分换掉而已。我不会说tkinter比pygame好,但在这个“91行”的需求下,它是最省事的选择。

1.3 这个Demo适合谁阅读

适合三类人:

  • 刚学完Python基础、想做一个小游戏来巩固语法的同学。
  • 准备面试或笔试,想快速理解碰撞检测、二维数组和状态更新的人。
  • 工作中写业务代码写多了,想找回一点“用代码做东西”的乐趣的开发者。

如果你完全没接触过tkinter,也不用怕,这个项目的tkinter用法只有三个:建立一个窗口、用Canvas画矩形、用bind绑定键盘事件。我会在代码拆解部分逐个讲。

2. 先拆掉游戏的外壳:四个核心机制

2.1 网格:用二维数组表示“游戏世界”

俄罗斯方块的整个游戏世界可以抽象成一张10列20行的网格。每一格只有两种状态:有方块占着,或者没有。我用一个二维数组来表示:

W, H = 10, 20 grid = [[0] * W for _ in range(H)]

注意这里用的是“行在外层、列在内层”,也就是grid[y][x]。为什么要这样?因为消行操作的对象是整行,而Python里删除列表中的一行、再在顶部插入一行都非常方便。如果反过来用grid[x][y],消行时要遍历每一列的同一个位置,操作就会绕很多。

可以把网格想成一张Excel表:行号从上到下递增,列号从左到右递增。方块下落时,y增大,就是往表的下方走。这个坐标系贯穿了整个项目,后面所有位置判断都围绕它展开。

2.2 方块形状用坐标列表表达

七种俄罗斯方块分别是I、O、T、S、Z、J、L。每一种都由四个小方格组成,所以用四个坐标点就能完整描述它。

以T型方块为例,可以表示为:

'T': [(0, 1), (1, 0), (1, 1), (2, 1)]

这里的坐标不是绝对位置,而是相对“锚点”的偏移量。锚点就是当前方块在网格中的参考位置,我们用(x, y)表示。那么方块真正占据的格子就是(x + px, y + py),其中(px, py)是坐标列表里的相对偏移。

用坐标列表而不是用图片,最大的好处是轻量、可计算。比如要判断方块是否出界,只需要把每个坐标加进去检查一遍;要移动方块,只需要改变x或y,而不需要重新描述方块本身。下面是我用的七种形状定义:

SHAPES = { 'I': [(0, 0), (1, 0), (2, 0), (3, 0)], 'O': [(0, 0), (1, 0), (0, 1), (1, 1)], 'T': [(0, 1), (1, 0), (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等于0”的原则,否则方块的落点会变得很难预测。

2.3 旋转:小矩阵变换里的数学

旋转是俄罗斯方块里最容易写错的地方。但它的数学本质很简单:把一个二维平面上的点绕原点旋转90度,只需要交换坐标并给其中一个坐标取负值。

因为我们的屏幕坐标中y轴是向下的,所以一个顺时针旋转90度的常用变换是:

(x, y) -> (y, -x)

只看公式可能不太好理解。拿I型方块举例,它的原始形状是水平的四个格子:

(0,0) (1,0) (2,0) (3,0)

套用上面的变换后变成:

(0,0) (0,-1) (0,-2) (0,-3)

符号不同,但事实上这是一条竖直的线。问题来了:坐标里出现了负数,如果直接拿去做碰撞检测,计算结果就是错的。所以旋转后还需要做一次归一化,把所有坐标的x和y都减去对应方向上的最小值,让它们重新回到第一象限:

def rotate(points): rotated = [(y, -x) for x, y in points] min_x = min(p[0] for p in rotated) min_y = min(p[1] for p in rotated) return [(px - min_x, py - min_y) for px, py in rotated]

这个过程就像你把一张写满坐标的纸转了个方向,但读坐标时仍然习惯从纸的左上角开始读。所以转完之后要“重新对齐左上角”,也就是归一化。

2.4 碰撞检测:边界与堆叠双检查

碰撞检测是判断方块“能不能往某个方向移动”的核心函数。理论上只需要检查两种情况:

  • 移过去之后是否超出左右边界或底部边界;
  • 移过去之后是否和已经固定住的方块重叠。

顶部边界可以适当放行,允许方块在还没完全进入画面时就开始下落。因此碰撞检测的写法是:

def valid(piece, px, py): for dx, dy in piece: nx = px + dx ny = py + dy if nx < 0 or nx >= W or ny >= H: return False if ny >= 0 and grid[ny][nx]: return False return True

注意两个细节。

第一,ny >= 0这个条件很关键。当方块还在屏幕上方时,它的部分格子ny可能是负数,此时我们只允许它悬空,不能因为grid[-1]这种越界索引报错,也不能把它判定为“撞到了顶部已有方块”——因为负数行根本还没进入网格。

第二,在检测“是否和已固定方块重叠”前,要保证nx和ny已经在有效范围内,否则直接访问列表会越界。所以我先判断边界,再判断堆叠,顺序不能反。

这个valid函数同时负责左右移动、下落、旋转和硬降四种判断,只不过传入的px、py参数不同。也就是说,所有移动逻辑最后都会汇聚到这一个小函数里,这就是行数能压下来的核心原因。

2.5 消行与计分:先从下往上扫

方块固定之后,要检查哪些行已经满了。实现方式很简单,从下往上扫描每一行,如果这一行的所有格子都不为0,就说明整行满了:

full = [row_idx for row_idx in range(H) if all(grid[row_idx])]

收集到所有满行的下标后,再从下往上删除“旧行”,并在网格顶部插入一个新的空行。为什么要从下往上删?因为如果你先删掉靠下的行,行下标会整体变化,后面记录的行号就错位了。更稳妥的方法是先收集满行,再一次del,但用循环从下往上删也一样能避免错位:

for row_idx in reversed(full): del grid[row_idx] grid.insert(0, [0] * W)

关于计分,我采用的是经典规则:消一行100分,两行300分,三行500分,四行800分。这里的分数不是单纯的“消了几行乘100”,而是鼓励玩家一次消多行。实现上只需要在full列表的长度上做映射:

score += {1: 100, 2: 300, 3: 500, 4: 800}.get(len(full), 0)

这部分看似简单,但如果你在消行时直接改grid的引用来重建列表,会遇到“同一行被反复插入空行”的诡异问题。最稳妥的就是先算清楚哪些行要删,再一次性处理。

3. 从零搭建:核心代码逐段理解

3.1 初始化:控制台窗口和画布

整个游戏只需要一个主窗口和一块画布。我使用tkinter建立窗口后,设置一下画布的大小和背景色,再把网格和方块状态初始化好:

import tkinter as tk import random W, H, C = 10, 20, 30 root = tk.Tk() cv = tk.Canvas(root, width=W * C, height=H * C, bg='white') cv.pack() grid = [[0] * W for _ in range(H)] score = 0 score_var = tk.StringVar(value="score: 0") tk.Label(root, textvariable=score_var).pack()

这里的C是每个格子的像素边长。我取30,这样整个画布是300乘600,窗口尺寸不大不小。如果你的屏幕分辨率较小,也可以改成24或20,但界面和方块都会相应缩小。

然后绑定键盘:

root.bind('<Key>', key_handler)

注意<Key>是小写格式,它会捕获键盘上的大多数普通按键。如果想精确匹配方向键,也可以分别绑定<Left>、<Right>、<Up>、<Down>、<space>,但为了减少行数,统一在key_handler里根据event.keysym分发就行。

3.2 移动、旋转与合法性判断

这一部分集中了方块的核心行为。为了逻辑清晰又省行数,我把状态变量设计成全局可修改的current、shape、px、py:

current = None shape = [] px = py = 0

current保存当前方块的名称,比如'T'或者'S',用于取颜色。shape保存当前方块的坐标列表,这个列表会在旋转时被重新赋值。px、py是锚点在网格中的位置。

移动函数的写法非常直白:

def try_move(dx, dy): global px, py if valid(shape, px + dx, py + dy): px += dx py += dy

为什么这里不直接修改位置,而是先valid判断后再修改?因为如果先加减再判断,一旦判断失败就得把位置改回去,这样既多写代码,又容易漏掉某个分支的还原。先判断、后修改是游戏开发里很省心的习惯。

旋转也是一样的思路:

def try_rotate(): global shape rotated = rotate(shape) if valid(rotated, px, py): shape = rotated

这里有一个重点:如果旋转后的方块位置不合法,千万别强行赋值。否则方块很容易“钻”到墙体里或者和已固定方块重叠。如果想更高级一点,可以做“踢墙”处理,也就是旋转不成功时尝试左右移动一或两格,看看能不能转过去。但这会让代码行数明显增加,所以我在这91行版本里先不引入踢墙。

3.3 消行逻辑:只处理满的那一行

方块落到不能再落时,就会“锁定”到网格里。锁定的过程分三步:把当前方块的四个格子写入grid、消除满行、生成下一个方块:

def lock(): global current, shape, px, py, score for dx, dy in shape: grid[py + dy][px + dx] = current full = [i for i in range(H) if all(grid[i])] for i in reversed(full): del grid[i] grid.insert(0, [0] * W) score += {1: 100, 2: 300, 3: 500, 4: 800}.get(len(full), 0) score_var.set(f"score: {score}") spawn_piece()

注意,我把颜色直接存进grid,而不是只存数字1。这样绘制阶段可以省掉一层“查颜色”的逻辑,从grid[i][j]里取出来的字符串刚好就能当作fill参数。如果你不用颜色,也可以存Union类型,比如0代表空、1代表占用,但这会让画面变成清一色的方块,观赏性会差不少。

顺便说个容易踩的坑:grid里存的是字符串颜色,判断是否为空时不能再用not grid[i][j]这种假设“空为0”的写法,必须明确判断grid[i][j] == 0。虽然Python里空字符串也是假值,但如果你之后新增了一块画布标记逻辑,混用真假值很容易出隐蔽bug。宁可多写几个字符,也把空和非空的分界写清楚。

3.4 绘制与主循环:让画面动起来

有了状态,还需要把状态画出来。draw函数的任务是清空画布,然后逐格绘制已经固定的方块和当前控制的方块:

def draw(): cv.delete('all') for y in range(H): for x in range(W): if grid[y][x]: cv.create_rectangle(x * C, y * C, (x + 1) * C, (y + 1) * C, fill=grid[y][x], outline='gray') for dx, dy in shape: cv.create_rectangle((px + dx) * C, (py + dy) * C, (px + dx + 1) * C, (py + dy + 1) * C, fill=current, outline='gray')

由于grid里存的是颜色名称,所以fill=grid[y][x]这一步非常自然。使用cv.delete('all')清空整块画布再重画,虽然效率不高,但胜在代码简单,对一个小游戏来说完全够用。

主循环用tkinter的after而不是while True,原因很关键:如果你在while True里调用sleep等待下落,整个界面会冻结,按键事件也没法处理。after的意思是“几百毫秒后再执行一次这个函数”,执行完会重新注册自己,形成一个不阻塞界面的事件循环:

def tick(): global py if valid(shape, px, py + 1): py += 1 else: lock() draw() root.after(500, tick)

这里我设置每500毫秒下落一格。想让游戏更难,可以把间隔改短,比如300毫秒。如果你已经实现了计分,也可以让分数每满1000分就自动缩短一次下落间隔,这样更有挑战性。

3.5 怎么把行数压到91行的小技巧

很多人看到“91行”会好奇是怎么压缩出来的。我在写这个小Demo时用了几个实用招数:

  • 把形状和颜色放在同一个字典里,避免两个平行的映射结构。
  • 用valid统一处理移动、旋转、下落、硬降四种合法性检查。
  • 把绘制逻辑和更新逻辑分离,但刻意不建过多的类,而是直接操作几个全局状态变量。
  • 用列表推导代替多行的for append。
  • 把按键分发集中到同一个函数里,一个if/elif链处理完所有按键。

这些技巧并不高深,但它逼着我重新思考哪一行是真正必要的,哪一行只是“为了以后好维护”而加的过度设计。当然,过度压缩也不健康,比如把所有逻辑揉进一个巨型函数会让代码可读性变得非常差。这里压缩到91行刚好处在一个“能看懂、又不啰嗦”的平衡点。

4. 实操中的坑与避坑经验

4.1 坐标归一化容易翻车

旋转后的坐标如果不做归一化,你会看到方块越转越“飘”,甚至会突然跳到网格外面。这个坑我一开始就踩过。原因是旋转公式产生负数后,方块锚点没有跟着调整,导致视觉上方块位置跑偏。

解决方式就是我在2.3里写的rotate函数末尾必须重新对齐最小坐标。每次旋转后都保证方块坐标的最小值是0,这样无论旋转多少次,方块相对锚点的偏移都不会漂移。

还有一个常见误区:(x, y) -> (y, -x)针对的是“以原点为中心的旋转”。如果你把原点设在左上角,当然会得到负数;如果把原点设在方块中心,则不会出现负数,但坐标计算会更复杂。我选择直接在旋转后归一化,逻辑反而更统一。

4.2 按键处理与游戏主循环打架

如果你使用time.sleep来做下落延迟,会发现按方向键时界面“卡住”,松开按键后才突然跳好几格。这是因为sleep阻塞了tkinter的事件循环,键盘事件只能排队等待。

正确的做法是用root.after安排下一帧,而不是用sleep暂停当前线程。按键事件和下落刷新都应该注册到tkinter的主循环里,这样一来,即使下落间隔是500毫秒,方向键也能随时响应,因为按键事件会在主循环的空隙中被处理。

如果发现按键偶尔失灵,还可以在key_handler里最后强制调用一次draw(),让画面立即反馈,而不是等到下一个after周期。这样手感会好很多。

4.3 消行后分数计算的小细节

我在给消行计分时一开始犯过错:把分数写成“每消一行加100”,结果一次消四行只加400分,和经典规则的手感差别很大。后来改成根据满行数量映射不同分值。

另外,要注意score是一个全局变量,在函数里赋值时必须在函数开头声明global score。这听上去像是基本功,但当你把多个状态变量揉在一起时,很容易漏掉某个global。漏掉的后果就是,分数在函数里改了,界面上却永远显示初始值。

为了避免这个问题,可以把所有可变状态放进一个字典或一个小对象里,比如state['score'] += 100就不需要global声明。我为了压行数没有这么做,但如果你自己扩展,建议用对象管理状态。

4.4 常见问题速查表

现象原因解决办法
方块旋转后位置越来越偏旋转后没有做坐标归一化旋转后重新计算最小x和y,并整体平移
方块落到屏幕顶部时直接报错索引了负数行在碰撞检测和锁定前判断ny >= 0,负数行不参与网格写入
按键反应迟钝,像被卡住主循环里用了time.sleep改用root.after做定时刷新,避免阻塞事件循环
消行时只消了部分行删除行的同时,后面的行下标变了先收集满行下标,然后从下往上删除,或使用reversed
新方块一出现就和已有方块重叠生成方块时没有检查初始位置是否合法spawn_piece里调用一次valid(shape, px, py),失败则结束游戏
画面闪烁严重绘制时没有清空画布就叠加每次draw开头调用cv.delete('all'),或使用局部矩形移动

这张表几乎覆盖了新手实现俄罗斯方块时最常见的几个崩溃点。如果你照着91行代码写,卡住了也可以先用这张表排查。

5. 从91行继续扩展的思路

如果你听说“91行”之后,觉得代码还能继续往下压,我的建议是:适可而止。比91行更短,比如压到60行,也不是做不到,但那通常会把函数名改成单字母、逻辑揉成一团,代码的可读性会急剧下降。我我自己实测下来,91行这个量级刚好能让人快速读完,并且能在读的过程中理解游戏的完整骨架。

从功能上说,其实还有一大批可以扩展的地方。比如给方块的旋转增加“踢墙”,让贴着墙时也能旋转;增加下一个方块的预览;增加等级速度,消行越多下落越快;增加暂停和重新开始按钮;再进一步,把画面从Canvas换成更精细的贴图。

我自己的经验是,先把91行版本跑通,再逐项往上加功能,每加一项就在原代码上开刀,看能不能继续保持相对精简。这样你会对每一行代码背后的价值有更具体的感知,而不是看到一个宏大项目就无从下手。

最后再分享一个小习惯:写这类小游戏时,我会在每次改动后把运行结果和截图一起存起来。等你回看时,会发现最初那个91行版本其实已经足够好用,之后每加一个功能,都能明确地知道自己在牺牲多少代码量、换取多少体验提升。这种增减之间的手感,比单纯追求“几行代码”的挑战有意思得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询