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

棋盘就是一张 10 列 20 行的二维表,每格放 0 或 1。 格子坐标和像素坐标只差一个乘法;一维下标和二维下标只差一个 divmod。 七种方块都用 4×4 的字符矩阵表示,统一外框大小是为了旋转时不用改尺寸。

📝 本章和前面几章的关系

前三篇都在搭架子,这一篇开始动真的:棋盘和方块的数据结构。 教材 5.14 到 5.16 节:【游戏区域】【棋盘】【方块】。 三节讲的是「东西存在哪、怎么换算、长什么样」。这三件事定下来,后面就不会返工。 本篇的棋盘、七种形状、坐标换算表,都是真建出来真打印的。

一、棋盘就是一张二维表

10 列 20 行,每格记录「空着」还是「被占」。就这一句话,整个游戏的数据结构就定了。 用什么记录?0 和 1 最省事。 真做的时候会存颜色索引(七种方块七种颜色),但判断「这格有没有人」永远是「非 0」。 为什么不用一个个格子对象? 因为 200 个格子如果每个都是对象,光内存和遍历开销就上去了, 而且你早晚要遍历整个棋盘做碰撞检测和消行判断——越简单越快。

二、格子坐标和像素坐标

格子 (列 c, 行 r) 的左上角像素 = (c × 32, r × 32)。就一个乘法。 演示列了五个点:(0,0) 是 (0,0),(9,0) 是 (288,0),(0,1) 是 (0,32),(3,5) 是 (96,160), (9,19) 是 (288,608)。 这些数字要能心算出来。调界面的时候,你会不停地问「第 5 行在屏幕上多高」, 每次都去查代码就太慢了。

反过来也一样:鼠标点到了 (150, 400),是第几行第几列?除以 32 取整,就是 (4, 12)。 做鼠标操作的时候这一步是必须的。

三、一维和二维能互相换

棋盘可以用二维列表 board[r][c],也可以用一维列表 board[r * 10 + c]。 演示把 0、9、10、53、199 这几个序号来回换了一遍:序号 53 是第 5 行第 3 列,反算 5 × 10 + 3 = 53。 一维的好处是遍历方便、拷贝方便、存起来也方便(存档就是一行数字)。二维的好处是看起来直观。

最省事的做法是「一维修、二维看」:内部用一维,需要打印棋盘的时候用 divmod 换回二维。 这样既快又不难读。

四、七种方块长什么样

七种形状,教材用的写法是「字符矩阵」:X 表示有格子,点表示空。

I:  ....     O:  ....     T:  ....
    XXXX         .XX.         .X..
    ....         .XX.         XXX.
    ....         ....         ....

这种写法的好处是一眼能看出形状,改起来也方便——想加一种新方块,照着画一个就行。 演示把七种都打了出来,还数了每种占几格。 七种都正好是 4 格。这是俄罗斯方块的基本约定,不是巧合。

五、为什么都用 4×4 的框

I 形其实只占 1 行 4 列,O 形只占 2×2,为什么要统一放进 4×4 的框里? 因为旋转。如果每个形状的外框大小不同,旋转的时候就要处理「转完尺寸变了」,位置得跟着调, 代码里会多出一堆特判。 统一 4×4 之后,旋转就是纯矩阵操作,不用管外框。 代价是有些形状周围有一圈空白,判断碰撞的时候要跳过空格子。

这笔买卖很划算:多跳几个空格子的开销可以忽略,少写几十行特判的收益是实打实的。 这也是一种通用的取舍:用一点点浪费换掉一大块复杂度。数据结构上这种选择到处都是——数组留空位、 字符串留 padding、协议留保留字段。

六、出生在负数行

演示里新方块出生在列 3、行 -2。行是负的,意思是「还在棋盘上方,没进来」。 为什么要这样? 因为方块要「从外面掉进来」,一出生就整块可见会显得很突兀。从上方两行开始, 玩家能看到它一点点露出头。 负数行不画出来,但一定要参与碰撞判断——否则方块可以穿过屏幕顶端跑出去, 或者压到刚堆到顶的方块上。

这一条很容易漏。 漏了的表现是:方块堆到最上面的时候,新方块会凭空出现在里面,然后游戏直接结束,玩家一脸懵。

七、真画一个棋盘

演示最后摆了三行:最底下一行满的、倒数第二行填了 4 格、倒数第三行孤零零 1 格。 画出来才发现问题:倒数第二行那 4 格全在左边,要填满得靠右边的 6 格。 这种「一眼看出缺口在哪」的能力,是调游戏时的基本功。 打印棋盘还有一个用处:当测试。把某个操作前后的棋盘打出来,比盯着动画看半天可靠得多。

八、动手 Demo:棋盘、坐标换算与七种方块

先把格子坐标、像素坐标、一维下标三者的换算列出来。 再把七种方块的字符矩阵打印出来,数一数各占几格、实际外框多大。 最后真摆一个三行的棋盘,看它长什么样。

COLS, ROWS, CELL = 10, 20, 32

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

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

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

# ① 坐标换算
sec('① 格子坐标 -> 像素坐标')
for c, r in ((0, 0), (9, 0), (0, 1), (3, 5), (9, 19)):
    info('格子 (%d, %d) -> 像素 (%d, %d) -> 序号 %d'
         % (c, r, c * CELL, r * CELL, r * COLS + c))

# ② 反算
sec('② 序号反算行列')
for idx in (0, 9, 10, 53, 199):
    r, c = divmod(idx, COLS)
    info('序号 %3d -> 第 %d 行第 %d 列' % (idx, r, c))

# ③ 七种形状
sec('③ 七种方块')
for name in ('I', 'O', 'T', 'S', 'Z', 'J', 'L'):
    cells = [(r, c) for r in range(4) for c in range(4)
             if SHAPES[name][r][c] == 'X']
    info('%s 形  占 %d 格' % (name, len(cells)))
    for row in SHAPES[name]:
        info('    ' + row.replace('.', '·').replace('X', '█'))

# ④ 画一个棋盘
sec('④ 真画一个棋盘')
board = [[0] * COLS for _ in range(ROWS)]
for c in range(COLS):
    board[19][c] = 1
for c in range(4):
    board[18][c] = 1
board[17][7] = 1
for r in range(ROWS):
    info('  |%s|' % ''.join('██' if v else '··' for v in board[r]))
info('满行 %d 行' % sum(1 for r in board if all(r)))

实跑输出:

--------------------------------------------------------------------
棋盘、坐标换算与七种方块
--------------------------------------------------------------------

  ① 棋盘是 10 列 20 行
  --------------------------------------------------------------------
  格子 32 像素,所以棋盘是 320×640 像素
  格子总数 200

  ② 格子序号和像素坐标怎么换
  --------------------------------------------------------------------
  格子 (列 c, 行 r) 的左上角像素 = (c × 32, r × 32)

| 格子 | 像素左上角 | 一维序号 r×10+c |
|---|---|---:|
| (0, 0) | (0, 0) | 0 |
| (9, 0) | (288, 0) | 9 |
| (0, 1) | (0, 32) | 10 |
| (3, 5) | (96, 160) | 53 |
| (9, 19) | (288, 608) | 199 |

  ! 第 19 行第 9 列是最后一个格子,序号 199——下标从 0 开始,正好 200 个。

  ③ 一维下标和二维下标能互相换
  --------------------------------------------------------------------
    序号   0  ->  第 0 行第 0 列,反算 0×10+0 = 0
    序号   9  ->  第 0 行第 9 列,反算 0×10+9 = 9
    序号  10  ->  第 1 行第 0 列,反算 1×10+0 = 10
    序号  53  ->  第 5 行第 3 列,反算 5×10+3 = 53
    序号 199  ->  第 19 行第 9 列,反算 19×10+9 = 199
  ! 一维存、二维看,是最省事的做法:棋盘用一个长度为 200 的列表就够。

  ④ 七种方块长什么样
  --------------------------------------------------------------------
    I 形:
      ····
      ████
      ····
      ····
    O 形:
      ····
      ·██·
      ·██·
      ····
    T 形:
      ····
      ·█··
      ███·
      ····
    S 形:
      ····
      ·██·
      ██··
      ····
    Z 形:
      ····
      ██··
      ·██·
      ····
    J 形:
      ····
      █···
      ███·
      ····
    L 形:
      ····
      ··█·
      ███·
      ····
  ! 七种形状都是用 4×4 的字符矩阵写的。为什么用 4×4 而不是紧贴边框?

  ⑤ 为什么都用 4×4 的框
  --------------------------------------------------------------------

| 方块 | 占几格 | 实际外框 | 占用的行 |
|---|---:|---:|---|
| I | 4 | 1×4 | 1 到 1 |
| O | 4 | 2×2 | 1 到 2 |
| T | 4 | 2×3 | 1 到 2 |
| S | 4 | 2×3 | 1 到 2 |
| Z | 4 | 2×3 | 1 到 2 |
| J | 4 | 2×3 | 1 到 2 |
| L | 4 | 2×3 | 1 到 2 |

  ! 统一 4×4,旋转的时候不用改外框大小,代码少一半分支。
  占几格都是 4 格——这是俄罗斯方块所有形状的共同点。

  ⑥ 方块出生在哪
  --------------------------------------------------------------------
  出生位置:列 = 3(棋盘正中偏左),行 = -2(在棋盘上方)
  行是负数,意思是「还没进棋盘」。这样方块一出生就能被看见一部分
    行 -2 |······████████······|
    行 -1 |······████████······|
    行  0 |····················|
    行  1 |····················|
  ! 负数行不画出来,但要参与碰撞判断——否则方块能穿出屏幕顶端。

  ⑦ 真画一个棋盘出来
  --------------------------------------------------------------------
  先摆三行,最后一行满了、倒数第二行差 6 格:
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |····················|
    |··············██····|
    |████████············|
    |████████████████████|
  满行 = 1 行
  ! 下一节就把它消掉。

第一段那张换算表要能心算。 (9, 19) 是 (288, 608)——这两个数就是棋盘右下角那一格,也就是 320×640 减去一格。 第二段那个 divmod 一次给出商和余数,正好是行和列。 写 idx // 10 和 idx % 10 两遍也行,但 divmod 更清楚。 第三段把七种形状都打出来了。 建议你对着 I 形那四行看一遍:它躺在第 1 行,上面和下面各留了一行空白。 这个空行在旋转的时候会惹麻烦,下一篇讲。 第四段那个棋盘只有三行有东西,其余十七行全是空的。 打印出来才看得出「缺口在右边」——看代码是看不出来的。 这一整段不依赖任何游戏库,纯 Python 就能跑。数据结构的验证从来不需要引擎。

30 秒实验

  1. 把 CELL 改成 24,重跑,看第一段的像素坐标全变成多少。
  2. 给 SHAPES 加一个新形状(比如一个 2×3 的长条),重跑,看它占几格。
  3. 把棋盘摆成「中间一行满、上下各空一行满」,看 满行 那个数变成几。

常见误区

  1. 「棋盘用对象存,每个格子一个类。」200 个对象,每帧遍历一遍,开销白白多出来。 用 0/1 的二维表,够用又最快。
  2. 「每个形状的外框按实际大小来。」旋转时代码里会多出一堆特判,统一 4×4 能省掉整片分支。
  3. 「负数的行不用管,反正看不见。 」不参与碰撞判断的话,新方块会凭空出现在堆满的区域里,游戏直接结束。
自测三问(点开对答案) 1. 第 7 行第 2 列的格子,像素左上角在哪?一维序号是多少? —— 像素 (64, 224),序号 72。像素是 (2×32, 7×32),序号是 7×10+2 = 72。 2. 为什么七种方块都要放进 4×4 的框里? —— 为了让旋转变成纯矩阵操作。 如果外框按实际大小,旋转后尺寸会变,位置得跟着调,代码里要多出一堆特判。 **用一圈空白换掉这些特判,划算。** 3. 新方块为什么出生在行 -2 而不是行 0? —— 为了让它「从外面掉进来」,一出生就整块可见会显得突兀。 同时负数行必须参与碰撞判断,否则方块能穿出屏幕顶端。

棋盘和方块都摆好了,下一篇让它们动起来:下落、旋转、撞上、消行。

相关笔记