🎮 第5章 《俄罗斯方块》游戏
📄 一句话概括

旋转就是一行公式:新[r][c] = 旧[N-1-c][r],转四次一定回原样。 能不能动,只看下一格有没有人占着——出界和重叠都算「有人」。 消行就是删掉满的行、在顶上补空的,剩下的行整体下移。

📝 本章和前面几章的关系

上一篇把棋盘和方块都摆好了,这一篇让它们动起来,也就是整个游戏的主体控制。 教材 5.17 到 5.19 节:【游戏主体控制】【异常处理】【小结】。 三节合起来是「每一帧做什么、出错了怎么办、这一章学到哪」。 旋转、漂移、碰撞、落地、消行,全是真跑出来的棋盘和数字。

一、旋转就一行公式

顺时针转 90 度:新矩阵的第 r 行第 c 列,等于旧矩阵的第 N-1-c 行第 r 列。N 是 4。 看着绕,其实一句话:先把矩阵转置(行列互换),再把每一行左右翻过来。转置加翻转,两步合成一行。 验一下方向对不对:旧矩阵左上角那一格,转完应该跑到右上角。按公式,新[0][3] = 旧[0][0],对的。 记不住公式的时候,就拿这一格验。

演示把 T 形转了四次,每次都打出来。转一次朝左、两次朝下、三次朝右、四次回原样。

二、四次一圈

演示把七种方块各转四次,全部都能回到原样。这是所有形状的共同性质,也是写旋转代码时最好的自检。 但演示还统计了「转四圈里有几种不同的样子」——除了 O 形只有 1 种,其余六种都是 4 种。 注意这 4 种不等于「四种朝向」。因为简单矩阵旋转会让方块在 4×4 框里挪位置。

三、朴素旋转的毛病:方块会漂

这一段是本篇最该记住的东西,也是教材没细讲的地方。 演示拿 I 形算了每次旋转后占的行列:本来躺在第 1 行, 转两次之后躺到第 2 行去了——整体下移了一格。竖着的时候也从第 2 列飘到第 1 列。 为什么?因为 I 形在 4×4 框里不是居中的,它躺在第 1 行,上面有一行空白、下面有两行空白。 旋转是绕框的中心转的,自然就偏了。

T、S、Z 也都有这个毛病,只是没那么明显——它们的空白分布更均匀一点。 所以真实项目里更常见的做法是:给每个形状单独写一张「四个朝向」的格子偏移表,转的时候查表, 不真的转矩阵。转起来才稳。 教材用矩阵旋转是为了讲清原理,能看懂就行。原理懂了,实现方式可以换。

四、能不能动:四个条件

判定一个位置能不能放,看每个格子:列不能小于 0、不能大于等于 10,行不能大于等于 20, 并且那一格不能已经被占。 演示拿七种情况试了一遍:T 在 (3,0) 能放,T 在 (8,0) 出右边界不行,T 在 (3,16) 能放, T 在 (3,18) 踩到底行不行,O 在 (9,0) 不行,I 横躺在第 18 行也不行。 三种失败原因分别是:出右边界、踩到满行、格子越界。写判定的时候这三条一条都不能少。

还有一个容易漏的:行号为负的时候要允许,因为方块从棋盘上方掉进来。但列号永远不能负。

五、落地就锁死

下落逻辑简单到一句话:能往下就往下,不能往下就锁死。 演示里 T 形从行 0 一路试,掉了 16 次停在第 16 行,再往下一格就撞底了。 锁死做什么?把方块的格子写进棋盘(变成「死的」),然后生成下一个方块。 从这一刻起它不再是方块,只是棋盘上的几个 1。

这个「活动 → 静态」的转换是整个游戏的核心。 搞清楚了它,你就知道为什么棋盘用 0/1 存,而方块用形状矩阵——两者是两个不同的东西,只是长得像。

六、消行:删满的,补空的

演示摆了一个盘:底两行满、倒数第三行 1 格、倒数第四行 1 格。 消行的做法是:把不满的行挑出来,在顶上补足数量的空行。两行一起消,剩下的整体下移两格。 看输出能发现一件有意思的事:消完两行之后,上面那两个孤零零的格子整体往下移了两格。 没有谁去移动它们,是「补空行」这个动作自然导致的。

这就是「删掉 + 补齐」比「逐行下移」好的地方: 少一层循环,也不会搞错顺序。逐行下移最容易犯的错是从上往下移,结果同一行被移了两次。

七、结束判定与越界

新方块出生的时候放不下,就是 Game Over。 演示里第 0 行被占了 4 格,新的 T 形出生在行 -2 就放不下了。 这个判断只能有一个地方。很多人图省事,在下落、旋转、生成三处各写一遍,改的时候改漏一处, 就会出现「游戏该结束却没结束」。 最后一段演示了越界:行号取 20 会抛 IndexError,行号取 -1 却不会——负下标会从末尾倒着数。

这是 Python 特有的坑。 写边界判断的时候,上下两个方向都要写,光判「小于 0」不够,还得判「大于等于行数」, 而且不能靠异常去兜。

八、动手 Demo:旋转、碰撞、落地与消行

先把 T 形真转四次,看它怎么一圈一圈转回来。 再算 I 形每次旋转后占的行列,你会发现它在框里漂了。 最后试碰撞、下落、消行,把棋盘一张张打出来。

COLS, ROWS = 10, 20

SHAPES = {
    'I': ['....', 'XXXX', '....', '....'],
    'O': ['....', '.XX.', '.XX.', '....'],
    'T': ['....', '.X..', 'XXX.', '....'],
}

def cells(shape):
    return [(r, c) for r in range(4) for c in range(4) if shape[r][c] == 'X']

def rot_cw(shape):
    # 新[r][c] = 旧[N-1-c][r]
    n = len(shape)
    return [''.join(shape[n - 1 - c][r] for c in range(n)) for r in range(n)]

def fits(board, shape, col, row):
    for r, c in cells(shape):
        x, y = col + c, row + r
        if x < 0 or x >= COLS or y >= ROWS:
            return False
        if y >= 0 and board[y][x]:
            return False
    return True

def info(s):
    print('  ' + s)

def sec(t):
    print(); print('  ' + t); print('  ' + '-' * 68)

# ① 转四次
sec('① T 形转四次')
cur = SHAPES['T']
for i in range(1, 5):
    cur = rot_cw(cur)
    info('转 %d 次,跟原来一样:%s' % (i, cur == SHAPES['T']))

# ② 漂移
sec('② I 形在框里漂')
cur = SHAPES['I']
for i in range(1, 5):
    cur = rot_cw(cur)
    cs = sorted({c for _, c in cells(cur)})
    rs = sorted({r for r, _ in cells(cur)})
    info('转 %d 次:列 %d-%d,行 %d-%d' % (i, cs[0], cs[-1], rs[0], rs[-1]))

# ③ 碰撞
sec('③ 能不能放')
board = [[0] * COLS for _ in range(ROWS)]
for c in range(COLS):
    board[19][c] = 1
for col, row in ((3, 0), (8, 0), (3, 16), (3, 18)):
    info('T 在列 %d 行 %d:%s'
         % (col, row, '能放' if fits(board, SHAPES['T'], col, row) else '不行'))

# ④ 下落
sec('④ 落地')
col, row = 3, 0
while fits(board, SHAPES['T'], col, row + 1):
    row += 1
info('T 从行 0 掉到第 %d 行停住' % row)

# ⑤ 消行
sec('⑤ 消行')
board = [[0] * COLS for _ in range(ROWS)]
board[19] = [1] * COLS
board[18] = [1] * COLS
board[17][7] = 1
info('消之前满行 %d 行' % sum(1 for r in board if all(r)))
kept = [r for r in board if not all(r)]
gone = ROWS - len(kept)
board = [[0] * COLS for _ in range(gone)] + kept
info('去掉 %d 行,补 %d 行空的,剩下 %d 行' % (gone, gone, len(board)))
info('那个孤零零的格子在最后一行第 %d 格' % board[19].index(1))

实跑输出:

--------------------------------------------------------------------
旋转、碰撞、落地与消行
--------------------------------------------------------------------

  ① 旋转就是转置加左右翻
  --------------------------------------------------------------------
  公式一行:新[r][c] = 旧[N-1-c][r]。N 是 4。
  T 形转之前:
      ····
      ·█··
      ███·
      ····
  转 1 次之后:
      ·█··
      ·██·
      ·█··
      ····
  转 2 次之后:
      ····
      ·███
      ··█·
      ····
  转 3 次之后:
      ····
      ··█·
      ·██·
      ··█·
  转 4 次跟原来一样吗:一样
  ! 四次一圈,这是所有七种方块的共同性质。写测试就拿它当判据。

  ② 七种方块各转四次
  --------------------------------------------------------------------

| 方块 | 转四圈里有几种样子 | 转第四次 |
|---|:---:|---|
| I | 4 | 回原样 |
| O | 1 | 回原样 |
| T | 4 | 回原样 |
| S | 4 | 回原样 |
| Z | 4 | 回原样 |
| J | 4 | 回原样 |
| L | 4 | 回原样 |

  ! O 只有 1 种,其余六种都是 4 种。
  但这不是「四种朝向」——转的过程中方块还在框里挪了位置,下一段就摊开这件事。

  ③ 朴素旋转的毛病:方块会在框里漂
  --------------------------------------------------------------------
    转 1 次:只占第 2 列,占第 0 到 3 行
    转 2 次:占第 0 到 3 列,只占第 2 行
    转 3 次:只占第 1 列,占第 0 到 3 行
    转 4 次:占第 0 到 3 列,只占第 1 行
  ! I 形本来躺在第 1 行,转两次之后躺到第 2 行去了——整体下移了一格。
  竖着的时候也从第 2 列飘到第 1 列。
  所以真实项目里更常见的做法是:给每个形状单独写一张「四个朝向」的格子偏移表,转的时候查表,
  不真的转矩阵。
  ! 这样方块转起来才稳。教材为了讲清原理用了矩阵旋转,能看懂就行,别照搬进正式项目。

  ④ 能不能放在这儿
  --------------------------------------------------------------------

| 方块 | 列 | 行 | 判定 |
|---:|---:|---:|---|
| T | 3 | 0 | 能放 |
| T | 8 | 0 | 不行 |
| T | 3 | 16 | 能放 |
| T | 3 | 18 | 不行 |
| O | 0 | 0 | 能放 |
| O | 9 | 0 | 不行 |
| I | 0 | 18 | 不行 |

  ! 不行的三种原因:出右边界(列 8、列 9)、踩到最底下那行满的、
  以及 I 形横躺时那四个格子正好落在第 19 行。

  ⑤ 落地就锁死
  --------------------------------------------------------------------
  T 形从行 0 开始往下掉,掉了 16 次,停在第 16 行
  再往下一格就撞底了:不行
  ! 落地之后把方块写进棋盘,再从棋盘上消失——从「活动的」变成「死的」。

  ⑥ 消行:满的删掉,上面的往下挪
  --------------------------------------------------------------------
  消之前:
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |██··················|
    |··············██····|
    |████████████████████|
    |████████████████████|
  消之后(满了 2 行):
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |██··················|
    |··············██····|
  ! 两行一起消,剩下的行整体下移 2 行。上面那个孤零零的格子跟着往下走了。

  ⑦ 什么时候算结束
  --------------------------------------------------------------------
  新的 T 形出生在行 -2、列 3,这时候能放吗:不能
  ! 放不下就是 Game Over。判断的地方只有一个,别在别处再抄一遍。

  ⑧ 越界为什么会炸
  --------------------------------------------------------------------
    bd[20][0]  ->  IndexError: list index out of range
    bd[0][10]  ->  IndexError: list index out of range
  负下标不报错,它从末尾倒着数:bd[0][-1] = 0
  ! 行号 20 越界会炸,列号 -1 不炸但要的是最后一格。判边界时两个方向都要写。

rot_cw 那个列表推导式写得紧,拆开就是两层循环:对每个 (r, c),取旧的 [N-1-c][r]。 能把这一行看懂,旋转就没问题了。 第二段是本篇最值钱的地方:I 形转两次之后从第 1 行跑到第 2 行。真项目里不会这么干,会查表。 但你得先知道朴素做法错在哪,才知道表是干什么用的。 第三段那四种情况是真判出来的。 注意 (3,18) 为什么不行:T 形占两行,行 18 加上它自己的两行就踩到第 19 行,而第 19 行是满的。 方块的「行」是相对的。 第五段那个「补空行」是这一篇最漂亮的一笔。kept 一取、前头一补,整块东西自然就落下去了。 不用写循环去挪,也不会搞错顺序。 最后一行那个 .index(1) 是现算的,不是写死的。 能看到它跟着下移了两格,说明补空行真的起作用了。

30 秒实验

  1. 把 rot_cw 里的 n - 1 - c 改成 c,重跑,看旋转变成顺时针还是逆时针。
  2. 在第三段的用例里加一个 (0, 19),看判定是不是「不行」,想想为什么。
  3. 把第五段的棋盘改成只满一行,重跑,看那个孤零零的格子下移几格。

常见误区

  1. 「旋转直接用矩阵转就行,反正转四次会回来。」回得来,但中间会漂。 I 形转两次整体下移一格,玩家会觉得方块不听话。
  2. 「碰撞只判棋盘边界就行。」还得判「有没有压到已有的方块」。漏了这条,方块会直接穿进堆里。
  3. 「消行就从上往下把每一行往下挪一格。」这样写同一行会被挪两次。 用「挑出不满的行 + 顶上补空的」,一次到位。
自测三问(点开对答案) 1. 顺时针转 90 度的公式是什么?怎么验它方向对不对? —— 新[r][c] = 旧[N-1-c][r],N 是 4。 验法:旧矩阵左上角那格转完应该在右上角,也就是新[0][3] 应该等于旧[0][0]。 **拿一格验,比记公式可靠。** 2. 为什么 I 形转两次会整体下移一格? —— 因为它在 4×4 框里不居中——躺在第 1 行,上面 1 行空白、下面 2 行空白。 旋转绕框的中心转,空白不对称,转完位置就偏了。**这也是真实项目改用「四朝向查表」的原因。** 3. 消行为什么推荐「挑出不满的行 + 顶上补空行」,而不是逐行下移? —— 逐行下移要处理顺序,从上往下改会把同一行挪两次。 挑出来再补空行是一次性重建,**顺序问题根本不存在。**

俄罗斯方块到这儿就完整了:棋盘、方块、下落、旋转、消行、结束判定。 下一篇换到 Cocos2d 的进阶功能——碰撞、瓦片地图、自定义动作、帧动画、粒子。

相关笔记