☰
俄罗斯方块算法:从棋盘碰撞到AI落子的完整闭环
2026/10/17 6:36:51 网站建设 项目流程

简介:一套面向嵌入式开发与算法学习者的俄罗斯方块算法参考工程,基于MSP430平台实现,完整覆盖方块生成、移动旋转、碰撞检测、行消除与计分等核心逻辑,适合单片机课程设计、游戏编程入门或算法原理研究。压缩包共44个文件,以C源文件(.c)与头文件(.h)为主,承载主体代码;编译生成的obj文件及makefile、ccxml、launch、project等辅助工程配置用于配合CCS环境构建调试,整体约70KB,目录分层清晰,可按模块快速定位。该资源已有566人学习下载。通过阅读工程,读者能理解定时器驱动下的游戏主循环与状态切换,学习用二维数组表示方块网格、碰撞检测中的边界判断以及消行动作的行平移实现;同时也可直接编译运行,在此基础上扩展旋转预览、下落加速、连消计分等进阶功能,加深对嵌入式游戏开发与基础算法的综合掌握。

1. 俄罗斯方块算法:从棋盘碰撞到AI落子的完整闭环

很多人第一次听到“俄罗斯方块算法”,第一反应是“判断一行满没满”,其实这只是最外面的一层皮。真正要写一个能玩的俄罗斯方块,你得先解决方块旋转后会不会越界、能不能叠上去、消行后棋盘怎么压缩;而想让程序自己玩,还要在“所有可能的落点”里做决策。与其东拼西凑找代码,不如把棋盘的数据结构、碰撞检测、旋转系统、消行逻辑和AI评估串成一条线来看。这篇文章不讨论浮于表面的规则,而是从棋盘建模开始,一路走到AI落子的搜索策略,给出可以直接复现的代码和参数。适合两类人:一是刚接触游戏算法的从业者,想搞清基础模块怎么拆;二是已经在做AI的人,想看看别人评估权重和搜索深度是怎么定的。

2. 棋盘与方块的底层表示:位掩码为什么比二维数组更适合算法扩展

2.1 俄罗斯方块的棋盘数据结构:行掩码、形状坐标和统一接口

我在做模拟项目X的时候,第一步不是写界面,而是先把棋盘数据结构和方块形状定下来。后续的碰撞检测、消行、AI评估全部依赖这两个东西,一旦定错,后面全是返工。这里直接给出我会长期沿用的版本:棋盘用“行掩码”表示,一行压成一个整数;方块用“相对坐标列表”表示,避免为每种形状单独写一个类。

WIDTH = 10 HEIGHT = 20 FULL_MASK = (1 << WIDTH) - 1 # 棋盘本身是一维列表,每个元素代表一行,第 0 位对应第 0 列 board = [0] * HEIGHT # 方块用相对坐标表示,(r, c) 表示以方块锚点为基准的偏移 SHAPES = { 'I': [(0, 0), (0, 1), (0, 2), (0, 3)], 'O': [(0, 0), (0, 1), (1, 0), (1, 1)], 'T': [(0, 1), (1, 0), (1, 1), (1, 2)], 'S': [(0, 1), (0, 2), (1, 0), (1, 1)], 'Z': [(0, 0), (0, 1), (1, 1), (1, 2)], 'L': [(0, 2), (1, 0), (1, 1), (1, 2)], 'J': [(0, 0), (1, 0), (1, 1), (1, 2)], }

这里坐标系的含义是:board[0]是游戏区域顶部,board[19]是底部。方块生成时放在顶部,r随着下落逐步增大,所以“往下”就是行号增加。相对坐标里所有r、c都是非负偏移,碰撞检测时再把锚点row、col加进去。

用行掩码而不是二维数组,最大收益是判断“满行”和“空行”只需要一个整数比较,而不是遍历一整行。比如某行恰好全部填满,那么它的值一定是FULL_MASK,也就是(1 << 10) - 1。这一点在消行和AI评估里会被重复调用,省掉不少开销。

def collides(board, shape, row, col): for r, c in shape: rr = row + r cc = col + c if not (0 <= rr < HEIGHT and 0 <= cc < WIDTH): return True if (board[rr] >> cc) & 1: return True return False

碰撞检测的逻辑并不复杂:先把每个相对坐标换算成绝对棋盘坐标,然后判断两件事,一是越界,二是目标格子里是否已经有方块。(board[rr] >> cc) & 1取出的是cc列的二进制位,如果为 1 就说明被占用。

这个函数是整个俄罗斯方块算法最底层的公共接口,移动、旋转、下落、AI搜索都要复用。我建议把所有判断都收敛到一个collides里,不要到处写一堆边界条件,否则后面每一处逻辑都可能出现不一致。

2.2 用位运算统计空洞和消除行:AI评估的前置条件

空洞是俄罗斯方块AI里最核心的负向指标。有些实现用二维数组也能数空洞,但位运算版本更简洁,而且和你后续的棋盘表示完全一致。空洞的定义是:某个格子是空的,但在同一列更上方已经存在方块。换句话说,这个格子被上面悬空的方块“盖住”了,很难再被填平。

def count_holes(board): filled_above = 0 holes = 0 for row in board: # 从顶部往底部遍历 empty = FULL_MASK ^ row # 本行所有空位 holes += (filled_above & empty).bit_count() filled_above |= row # 记录上面已经有哪些列被占用 return holes

简单解释一下这段逻辑:filled_above维护的是“当前行上方所有已填充列”的并集。每遍历一行,先用empty找出本行哪些位置是空的,再与filled_above做与运算。如果某个空位同时出现在filled_above里,说明这一列上面已经有方块压着,那这个空位就是一个洞。

这里要特别注意遍历方向。洞必须从上往下看,所以board[0]一定得是顶部。如果你把行反转成从底部往上数,统计出来的结果会完全不对,AI会做出很多灾难性决策。这个坑我在后面避坑章节还会展开。

消行用位掩码更直接。一个满行的特征就是row == FULL_MASK,所以只需要筛选出所有不等于FULL_MASK的行,再在最前面补足空行就行。

def clear_lines(board): remaining = [row for row in board if row != FULL_MASK] removed = HEIGHT - len(remaining) if removed: board[:] = [0] * removed + remaining return removed

board[:]这段会原地修改同一个列表对象,这样外部持有的棋盘引用仍然有效。[0] * removed生成的是空行,放在最前面,剩下的行向底部靠拢。因为空行在游戏区域顶部,所以视觉上就像是所有方块整体往下掉了一截。

如果这一层没有做对,AI后续的消行收益会被反复算错。更常见的问题是同一个方块锁定时同时消掉两行、三行甚至四行,返回值removed必须给到计分和AI评估,不能只在clear_lines内部自己算一遍就丢掉。

3. 核心逻辑实现:碰撞、SRS旋转与消行的最小可运行版本

3.1 方块旋转:坐标旋转加墙踢,先解决越界再谈手感

旋转是俄罗斯方块算法里最容易被写歪的部分。很多人直接交换宽高,结果发现T、L、J这些形状转完之后锚点位置变了,方块像在“漂移”。正确做法是二维坐标旋转公式(r, c) -> (c, -r),再做一次归一化,把所有坐标的最小值平移到 0。

def rotate_cells(cells): rotated = [(c, -r) for r, c in cells] min_r = min(r for r, c in rotated) min_c = min(c for r, c in rotated) return [(r - min_r, c - min_c) for r, c in rotated]

为什么需要min_r和min_c归一化?因为旋转后的坐标可能是负的,如果不平移到非负区域,后续碰撞检测时锚点位置和视觉位置会对不上。以I块为例,横向状态是[(0,0),(0,1),(0,2),(0,3)],旋转后要先变成[(0,0),(1,0),(2,0),(3,0)]这类纵向排列,坐标本身不会出负数,但T和S、Z这类不规则形状就很容易偏移。

旋转之后还不能直接放到 game 里,因为贴着墙旋转时可能越界。实际操作中我会先尝试原地旋转,如果碰撞,就向左或向右平移一到两格,这也就是俗称的“墙踢”。

def try_rotate(board, shape, row, col): rotated = rotate_cells(shape) for dx in (0, -1, 1, -2, 2): if not collides(board, rotated, row, col + dx): return rotated, col + dx return shape, col

这个dx偏移表是简化版墙踢,已经能覆盖大多数边界情况。完整SRS的墙踢规则要复杂得多,特别是I块在贴墙旋转时可能需要两格偏移,J、L、T、S、Z各自都有不同方向的偏移方案。如果只是做算法原型或AI验证,用这个简化表没问题;但如果目标是做一个完整的仿“官方手感”的端游,建议把每种方块的四个旋转状态和标准墙踢表都预置成数据,不要在运行期靠数学慢慢推。

3.2 落锁与消行:同一次消四行的判定和计分

碰撞检测通过后,方块需要固定在棋盘上。这个动作我一般叫“落锁”。落锁不是单纯把方块的格子置为1,还要立刻检查有没有满行,并把已消行从棋盘里移除。

def lock_piece(board, shape, row, col): for r, c in shape: board[row + r] |= 1 << (col + c) removed = clear_lines(board) return removed

注意|= 1 << (col + c)是按位写入。因为落锁前已经用collides验证过所有目标格子为空,所以这里不需要再做一次碰撞判断。真正容易出错的是消行计分:一次锁定最多能同时消掉四行,但有些实现会错判成“四行分开消”,导致分数只加一次100而不是一次800。

if removed == 1: score += 100 elif removed == 2: score += 300 elif removed == 3: score += 500 elif removed == 4: score += 800

计分表的具体数值可以随便定,真正重要的是把removed当成交互单位。AI在搜索时也会依赖这个返回值来决定要不要选择某个落点,所以计分逻辑和AI评估最好共用一个clear_lines,避免两边行为不一致。

3.3 主循环与游戏状态:把算法串成一个可玩原型

有了棋盘、碰撞、旋转、消行,就可以把它们封装成一个最小核心类。这里先不做渲染,只做状态推进,方便你后续接文本界面或图形界面。

class TetrisCore: def __init__(self, rng=None): self.board = [0] * HEIGHT self.score = 0 self.rng = rng or random.Random() self.shape = None self.row = 0 self.col = WIDTH // 2 - 2 def spawn(self): self.shape = list(self.rng.choice(list(SHAPES))) self.row = 0 self.col = WIDTH // 2 - 2 return not collides(self.board, self.shape, self.row, self.col) def move(self, dc): if not collides(self.board, self.shape, self.row, self.col + dc): self.col += dc return True return False def tick(self): if not collides(self.board, self.shape, self.row + 1, self.col): self.row += 1 return True lock_piece(self.board, self.shape, self.row, self.col) return self.spawn()

这个类里没有硬编码随机数,而是接收一个rng参数。这样做是为了后续可复现:AI测试时只要固定种子,就能反复回放同一局,判断某个决策改动到底有没有变好。spawn的返回值是游戏是否结束,如果新方块一出生就和已有方块重叠,棋牌已经顶到顶部,直接返回False。

tick是普通的一步下落。如果下一行碰撞,就落锁并刷新下一块。这里的tick每调用一次只推进一格,适合做定时器驱动;AI玩家通常会用“硬降”来代替连续按多次下移,也就是直接从当前位置一路插到底。

def hard_drop(self): while not collides(self.board, self.shape, self.row + 1, self.col): self.row += 1 lock_piece(self.board, self.shape, self.row, self.col) return self.spawn()

硬降是AI落子的最后一步。真正做AI决策时,先通过模拟得到目标位置,再执行hard_drop,代码上会比“一格一格软降”更稳妥,少产生中间态的入队时序问题。

4. AI决策算法:候选落点枚举、评估函数与搜索深度怎么配

4.1 先枚举所有最终落点:旋转状态和列位置怎么组合

想让AI自己玩,核心思想是:把当前方块的所有旋转状态拿到所有列上去试一遍,计算每个候选位置落下去之后的棋盘形状,再给这个形状评分。评分最高或损失最小的那个位置就是本回合的目标落点。

def drop_row(board, shape, col): row = 0 while not collides(board, shape, row + 1, col): row += 1 return row def all_placements(board, shape): placements = [] seen = [] states = [shape] for _ in range(4): rotated = rotate_cells(states[-1]) if rotated not in states: states.append(rotated) for state in states: for col in range(WIDTH): row = drop_row(board, state, col) if not collides(board, state, row, col): placements.append((state, row, col)) return placements

这段代码里有个隐蔽点:不是每种形状都有4个不同旋转状态。O块只有1个,I块横向和纵向各1个,T、L、J、S、Z各有4个。if rotated not in states就是为了去重,避免同一形态重复枚举。

drop_row做的事情是:从第0行开始,只要下一行不碰撞就继续往下掉,直到撞到棋盘或边界。返回的row就是最终落地行。最后还要用collides判断一下当前row本身是否合法,因为某些列对该形状来说一开始就是越界的,比如一个宽度为4的横向I块放在第9列。

所有候选落点的数量会直接决定搜索深度。纯贪心模式下,常见形状加列数大概是30个候选左右;两层搜索就是30乘30,大概900到1200个组合。这个量级用Python配合位掩码棋盘完全跑得动。

4.2 评估函数:空洞、高度、平整度和已消行如何加权

AI好不好,不在候选枚举,而在评估函数。俄罗斯方块AI历史上被反复验证有效的主要是四个特征:空洞数、堆叠高度、棋盘平整度、以及本次决策消除的行数。

def column_heights(board): heights = [] for col in range(WIDTH): h = 0 for r, row in enumerate(board): if (row >> col) & 1: h = HEIGHT - r break heights.append(h) return heights def evaluate_board(board, cleared=0): heights = column_heights(board) aggregate_height = sum(heights) holes = count_holes(board) bumpiness = sum(abs(a - b) for a, b in zip(heights, heights[1:])) return (cleared * 120 - holes * 35 - aggregate_height * 8 - bumpiness * 2)

cleared是本次落子消掉的行数,权重给得很高,因为AI必须学会主动追求消行,而不是只求不叠高。holes的权重最重,因为一个洞一旦形成,后续极难完全填平,很多局面最终死亡就是洞太多。aggregate_height惩罚的是整体堆高,越高越危险。bumpiness惩罚的是相邻列的高度差,高度差大会让方块落下时形成更多悬空位。

这些权重值一开始不需要精确,能让AI先活过几百步就行。我一般先用cleared=120, holes=-35, height=-8, bumpiness=-2这个组合起跑,然后再用后面第6章说的批量跑分去调。注意所有特征方向必须一致:得分越高,局面越好。所以洞、高度、平整度都取负号,消行取正号。

4.3 搜索深度:从贪心到两层前瞻,控制候选数量

贪心AI只评估当前方块放完后的棋盘。两层搜索则是:当前方块选一个位置放下去,然后假设再出现一个已知的下一块,模拟下一步的最优放法,用最终的棋盘反过来评价当前这一步。

def choose_action(board, current, next_piece, depth=1): best_score = -float('inf') best = None for state, row, col in all_placements(board, current): sim_board = list(board) for r, c in state: sim_board[row + r] |= 1 << (col + c) cleared = clear_lines(sim_board) if depth == 1: score = evaluate_board(sim_board, cleared) else: next_best = -float('inf') for n_state, n_row, n_col in all_placements(sim_board, next_piece): sim_board2 = list(sim_board) for r, c in n_state: sim_board2[n_row + r] |= 1 << (n_col + c) n_cleared = clear_lines(sim_board2) score = evaluate_board(sim_board2, cleared + n_cleared) if score > next_best: next_best = score score = next_best if score > best_score: best_score = score best = (state, row, col) return best

这段代码里有个特别容易翻车的点:两层搜索时,当前动作的回报不能只看下一步结束后的棋盘,还要把当前这一步已经消掉的行数累加进去。所以我用cleared + n_cleared作为最终参评的清行数。如果漏掉cleared,AI会倾向于选择当前不消行、下一步才消行的位置,导致它明知道马上能消四行却不消,先去叠一个奇怪的坑,表现甚至不如单层贪心。

list(board)是浅拷贝。因为行掩码是整数,所以修改sim_board[r] |= ...不会影响原棋盘,这个在这里是安全的。真正要小心的是clear_lines会原地修改sim_board,所以第二次搜索的all_placements必须基于已经消过行的sim_board,而不是最开始的board。

深度3的做法是把next_piece后面再加一个“第三块”的模拟。候选量会从1200涨到三万左右,Python本身还能扛,但再往上就容易出现一帧卡顿。对大多数入门AI来说,两层前瞻已经足够产生明显的行为差异。

5. 俄罗斯方块算法避坑:旋转偏移、空洞统计失真与搜索发散的排查

5.1 方块旋转后“漂移”或卡进墙里:坐标归一化和墙踢没做对

现象是方块在中间旋转时位置不对,贴墙旋转时直接插进墙面,或者旋转后悬空一格。

原因大多是旋转公式没做归一化。(r, c) -> (c, -r)会产生负数坐标,如果不整体平移回非负范围,同样的形状在不同朝向之间就会发生偏移。另一个原因是墙踢偏移表里的dx顺序不合适,正常的尝试顺序应该是先原地,再向左一格、向右一格,最后才向左两格、向右两格。

解决方式是先把rotate_cells里的min_r、min_c归一化做对,再给try_rotate加一个两格以内的墙踢序列。如果你打算完整支持官方旋转系统,最好的办法不是靠运行期推导,而是把每个朝向的坐标写成常量数据。AI搜索里也可以直接复用同一组常量,保证和玩家手感一致。

5.2 空洞数越统计越离谱:遍历方向搞反了

现象是AI明明判断某个落点空洞很少,可能只有2个洞,落下去之后棋盘上却出现一大片悬挂空洞,没过多久就死了。

原因在于空洞统计的方向。有些人习惯把棋盘底部当成索引0,从下往上遍历。这个方向对普通填充判断没影响,但空洞统计依赖“上面是否已经有压住的东西”。从下往上遍历时,filled_above累积的方向反了,会把上方所有空位都被算成洞,或者反过来少算下方真正的洞。

解决方法是统一约定board[0]是顶部,count_holes严格从列表头遍历到列表尾。如果现有棋盘是底部为0的表示,可以在计算前先反转行列表。代码里不要自己脑补“底部为零”的坐标系,AI算法一旦和视觉坐标脱节,这类问题会非常隐蔽。

5.3 两层搜索反而比一层更蠢:奖励函数把当前立即消行弄丢了

现象是深度2的AI出现了“该消不消”的行为,比如明明横着一放就能消行,它偏要把方块竖着插到旁边,然后等下一块再消。

原因在于两层搜索的得分只用了next_best,也就是第二步结束后的棋盘评分,却没有把当前第一步已经消掉的行数传进去。如果最终棋盘相同,下一步的总分可能掩盖了当前清行的价值,AI自然会被“未来可能消行”所吸引,而牺牲眼前的确定性收益。

解决方式就是evaluate_board(sim_board2, cleared + n_cleared)。把当前步和下一步的消行数都累加起来,才能让AI在长短博弈之间做出更合理的取舍。做三层搜索时,这一条的累计逻辑会变得更重要。

5.4 同一个候选反复被选中,程序看起来左右横跳甚至原地打转

现象是AI连续好几步落点一样,或者在左右移动时反复横跳,观察者会觉得AI在抖动。

原因是评估函数是确定性的,且列号、旋转状态都完全相同时,如果存在多个候选得分相等,代码会一直选第一个。下一步的新方块到来后,这个“相同列”又变成最优,于是形成一个重复循环。

解决方式是给评估分加一个极小的随机噪声,比如score += rng.random() * 1e-6,让相等分候选之间产生随机扰动。噪声很小,不会影响真正的优劣排序,但能打破死循环。另一种做法是维护一个最近落点黑名单,如果同一个位置连续出现三次且得分差不超过0.1,就强制换到次优位置。实际调试时我建议优先用随机噪声,因为它实现最简单。

5.5 消行计分偶尔少算一行:一边遍历一边删除导致索引错位

现象是明明画面里同时消掉了两行,计分却只按一行算,AI评估里也少算了一条清行收益。

原因是在clear_lines里用for i, row in enumerate(board)遍历,一旦遇到满行就直接del board[i],列表长度变短,后续行的索引全部前移,循环会跳过原本应该检查的下一行。

解决方法是不要边遍历边删除,先构造“保留的非满行”列表,再一次切片替换原棋盘。第2章给出的[row for row in board if row != FULL_MASK]就是安全写法。这个原则同样适用于AI搜索里的模拟消行,所有clear_lines都必须返回removed,并且不能在原列表上边遍历边操作。

6. 用可复现回放和批量跑分来调评估权重

6.1 固定随机种子,让每一次AI表现都可比较

评估函数里的4个权重参数,光靠肉眼随机玩几十局是看不出优劣的。我习惯在TetrisCore里传入固定随机种子,然后把AI每一步的输入、输出、最终得分都记录下来,形成一份回放。之后再改任何整数权重,都可以用同一批种子重跑,对比平均存活步数、消行分布和死亡原因。

def run_benchmark(weights, seeds, depth=2): total_steps = 0 total_best = 0 for seed in seeds: rng = random.Random(seed) game = TetrisCore(rng) while game.spawn(): # 这里用一个假想的下一块序列做两层搜索 next_piece = game.rng.choice(list(SHAPES)) state, row, col = choose_action(game.board, game.shape, next_piece, depth) game.shape, game.row, game.col = state, row, col game.hard_drop() total_steps += 1 total_best = max(total_best, game.score) avg_steps = total_steps / len(seeds) return avg_steps, total_best

这段代码是模拟事件驱动的AI回合,不是完整的主循环。真正接游戏时,需要把“当前块”和“下一块”分别管理,但基准测试的核心思路是固定种子、批量跑、用统计指标说话。

我自己的实测习惯是:先跑10局看平均存活步数,步数稳定在500以上再往下看消行分布。权重不是一次调出来的,而是两三个参数互相配合出来的。空洞权重太高,AI会过度追求低洞,反而把所有方块都堆在两边,导致中间凹槽越来越深;空洞权重太低,AI又会为了消行把板面堆出大量死亡位。调整幅度不要一次超过20%,每次只动一个参数。

6.2 回放日志里看三个关键指标:洞率、顶高和四消次数

调参时最怕AI分数涨了,但并不是真变强,只是运气好。看回放日志要重点看三个指标。一是“洞率”,也就是每百行落块后新增空洞的均值,这个值超过1就说明局面在恶化;二是“平均顶高”,当顶高持续接近18时,说明离游戏结束不远了;三是“四消次数”,四消是突破高层板面的关键操作,次数太少通常意味着AI只求稳,不主动制造机会。

每次调完权重,用同样10个种子回放对比。不要只对比总分,因为总分受随机方块顺序影响很大;对比存活步数和四消次数更稳定。这个方法同样适合判断要不要加深搜索深度。如果深度2比深度1的存活步数提升了不到5%,但耗时翻了一倍,那不如保持深度1,把节省下来的算力放到实时随机模拟上。

我的经验是:评估函数权重比搜索深度敏感得多。一个合理的权重组合配贪心,往往能跑赢一个乱配权重的三层搜索。最后说句实在话,俄罗斯方块算法最大的坑不是某个函数写不出来,而是拿一个不可复现的测试环境去瞎改参数。只要你能做到固定种子、批量跑分、回放日志这三点,剩下的就是耐心试参。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询