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

俄罗斯方块的玩法一句话:七种方块往下掉,摆满一行就消掉,堆到顶就结束。 第二个项目照旧先列清单、先把状态集中,再动手写代码。 场景栈还是那四个动作,但这次的深度从头到尾不超过 2。

📝 本章和前面几章的关系

上一章认完了 Cocos2d 的一套零件,这一章拿它们拼第二个完整游戏。 教材第 5 章开头三节:【项目介绍】【项目规划】【游戏窗口与菜单场景】。 跟飞机大战那章一样,这一篇一行游戏逻辑都不写,只把「做什么、分几块、状态放哪」定下来。 本篇的数字是真算的:窗口与棋盘各占多少像素、一屏能放几格、需要几个方块填满一行。

一、玩法一句话能说完

七种形状的方块从顶上往下掉,你可以左右挪、可以转。落地就固定住。 某一行被填满就消掉,上面的整体往下落。方块堆到顶就结束。 玩法越简单,越考验「组织」而不是「创意」。这个项目要练的不是玩法设计, 是把一个完整程序拆开再拼起来。 跟飞机大战比,它多了一个东西:状态是一张二维表。 飞机大战里所有东西都在动,俄罗斯方块里有一大半格子是不动的。这个差别决定了代码结构完全不同。

二、拆成七块

模块 管什么 教材哪节
状态 分数、等级、消行数、下一个方块 5.4
窗口与场景 开窗、菜单到游戏的切换 5.3
背景 平铺、滚动 5.5
菜单 开始、暂停、重开 5.6 到 5.8
参数 格子大小、速度、配色 5.9
棋盘 10 列 20 行的格子 5.11、5.12
方块 七种形状、下落、旋转、消行 5.13、5.14

跟飞机大战那张表比,多了「棋盘」少了「角色」,因为这里没有一个一个会动的精灵,只有一张会变的表。 清单最大的用处还是排错。画面不对:是背景没铺好,还是棋盘画错了,还是方块坐标算错了? 照着表往下查,比盯着屏幕发呆快得多。

三、状态放一处

这个项目的状态比飞机大战多:分数、累计消行、等级、下一个方块是什么、有没有暂停。 演示里做了一个和上次一模一样的动作:消一次四行,然后拍快照,把分数改成十万、等级改成 99, 再把快照填回去——一切回到拍快照那一刻。 为什么反复强调这件事?因为这个项目里「重开一局」是常用功能。 状态散在五个变量里的时候,重开就得手动清五遍,漏一个就会出现「重开了分数还是上一局的」。

还有一层:下一块是什么(那个 next)也要在状态里。 很多新手把它藏在一个局部变量里,结果暂停再继续,下一块就变了。

四、场景栈:比飞机大战还简单

演示跑了六步:push 主菜单、replace 游戏、push 暂停、pop、replace 结算、replace 主菜单。 注意一件事:栈的深度从头到尾没超过 2。俄罗斯方块的流程就这么点。 对照飞机大战的流程你会发现,两个游戏的场景结构几乎一样:菜单、游戏、暂停、结算。 这套四件套是所有小游戏的通用骨架。

既然一样,就可以把它抽出来做成模板。 以后做任何小游戏,先把这四个场景搭好,再往里填各自的内容——这是做第二个项目最该带走的经验。

五、窗口与棋盘各占多大

演示算了这套尺寸:窗口 480×800,格子 32 像素,棋盘 10 列 20 行,也就是 320×640。 右边剩 480 - 320 = 160 像素给 HUD,上下各留 80 像素的余量。

区域 尺寸 占窗口的比例
棋盘 320×640 53.3%
HUD 160×640 26.7%
上下留白 480×160 20.0%

三个数加起来刚好 100%,说明这套尺寸是配平的,没有哪块挤着。 这些数要在写代码之前定下来。定完再写,所有坐标都是这四五个数字的算式; 没定就写,你会得到一堆散落在各处的魔法数字。

六、一屏能放多少格

棋盘 200 格。一个方块 4 格,一次最多消 4 行也就是 40 格。 要填满一行需要 10 格,最少 3 个方块(每个 4 格,12 格,多出来 2 格会溢出到别的行)。 所以节奏大致是「两三块填一行」。 这个手感很重要。它决定了游戏的难度曲线——如果一行只要 2 块就能填满,游戏会太快;要 5 块,会太慢。

10 列这个数字不是随便定的,它是「一屏能看清、又不至于太挤」的折中。 老版俄罗斯方块就是这个数,几十年没变过。

七、动手 Demo:俄罗斯方块:先规划,再搭场景

先把七块列出来,再把状态集中到一个字典里试试快照。 然后真跑一遍场景栈的六步,看栈的深度。 最后把窗口、棋盘、HUD 的尺寸算清楚,看它们加起来是不是配平。

import math

W, H = 480, 800
COLS, ROWS, CELL = 10, 20, 32

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

def note(s):
    print('  ! ' + s)

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

# ① 状态放一处
sec('① 状态放一处')
state = {'score': 0, 'lines': 0, 'level': 1, 'next': 'I', 'paused': False}
info('开局:%s' % state)
state['score'] += 800
state['lines'] += 4
info('消了一次四行:%s' % state)
snap = dict(state)
state['score'] += 99999
state['level'] = 99
info('被改乱:%s' % state)
state.update(snap)
info('读档回来:%s' % state)

# ② 场景栈
sec('② 场景栈')
stack = []
def push(s):
    stack.append(s); info('push(%s)  栈 %s' % (s, stack))
def pop():
    info('pop()  弹出 %s,栈 %s' % (stack.pop(), stack))
def replace(s):
    old = stack.pop(); stack.append(s)
    info('replace(%s)  换掉 %s,栈 %s' % (s, old, stack))

push('主菜单'); replace('游戏'); push('暂停'); pop()
replace('结算'); replace('主菜单')

# ③ 尺寸预算
sec('③ 尺寸预算')
bw, bh = COLS * CELL, ROWS * CELL
info('窗口 %dx%d,格子 %d 像素' % (W, H, CELL))
info('棋盘 %d 列 x %d 行 = %dx%d' % (COLS, ROWS, bw, bh))
info('右边 HUD 宽 %d,上下余量 %d' % (W - bw, H - bh))
info('三块加起来占窗口 %.1f%%'
     % ((bw * bh + (W - bw) * bh + W * (H - bh)) * 100.0 / (W * H)))
info('一行要 %d 格,最少 %d 个方块'
     % (COLS, int(math.ceil(COLS / 4.0))))

实跑输出:

--------------------------------------------------------------------
俄罗斯方块:先规划,再搭场景
--------------------------------------------------------------------

  ① 拆成七块
  --------------------------------------------------------------------

| 模块 | 管什么 | 教材哪节 |
|---|---|---|
| 状态 | 分数、等级、消行数、下一个方块 | 5.4 记录状态 |
| 窗口与场景 | 开窗、菜单到游戏的切换 | 5.3 |
| 背景 | 平铺、滚动 | 5.5 背景 |
| 菜单 | 开始、暂停、重开 | 5.6 到 5.8 |
| 参数 | 格子大小、速度、配色 | 5.9 参数设置 |
| 棋盘 | 10 列 20 行的格子 | 5.11、5.12 |
| 方块 | 七种形状、下落、旋转、消行 | 5.13、5.14 |

  ! 跟飞机大战一样先列清单:出问题时能定位「是哪一块没接上」。

  ② 状态放一处
  --------------------------------------------------------------------
  开局:{'score': 0, 'lines': 0, 'level': 1, 'next': 'I', 'paused': False}
  消了一次四行:{'score': 800, 'lines': 4, 'level': 1, 'next': 'I', 'paused': False}
  被改乱:{'score': 100799, 'lines': 4, 'level': 99, 'next': 'I', 'paused': False}
  读档回来:{'score': 800, 'lines': 4, 'level': 1, 'next': 'I', 'paused': False}
  ! 跟飞机大战那套一模一样。这个项目状态更多,更该集中放。

  ③ 场景栈:菜单、游戏、暂停、结算
  --------------------------------------------------------------------
    push(主菜单)  压进去,栈 ['主菜单']
    replace(游戏)  换掉 主菜单,栈 ['游戏']
    push(暂停)  压进去,栈 ['游戏', '暂停']
    pop()  弹出 暂停,栈 ['游戏']
    replace(结算)  换掉 游戏,栈 ['结算']
    replace(主菜单)  换掉 结算,栈 ['主菜单']
  ! 注意到没有:从头到尾栈的深度没超过 2。俄罗斯方块就这么点流程。

  ④ 窗口与棋盘各占多大
  --------------------------------------------------------------------
  窗口 480×800,格子 32 像素
  棋盘 10 列 × 20 行 = 320×640
  右边留给 HUD 的宽度:480 - 320 = 160
  上下的余量:800 - 640 = 160,上下各 80

| 区域 | 尺寸 | 占窗口的比例 |
|---|---|---:|
| 棋盘 | 320×640 | 53.3% |
| HUD | 160×640 | 26.7% |
| 上下留白 | 480×160 | 20.0% |

  ! 先算清尺寸再写代码。摆不下的界面,改起来比重写还费劲。

  ⑤ 一屏到底能放多少格
  --------------------------------------------------------------------
  棋盘一共 200 格,开局全空
  一个方块 4 格,一次最多消 4 行 = 40 格
  要填满一行需要 10 格,也就是至少 3 个方块(每个 4 格)
  ! 这些数是设计手感:一行 10 格、一个方块 4 格,所以一行大约两三块。

第一段那五行状态,是这一章所有代码的地基。注意 next 也在里面——它是游戏状态,不是临时变量。 第二段要看的不是流程,是栈的深度。 全程没超过 2,说明这个游戏的场景结构非常浅,浅到你手写一个二十行的状态机就能替掉它。 第三段那个百分比加起来是 100.0%,不是巧合——尺寸就是按这个配平关系推出来的。 先定窗口,再定格子,剩下的自然分给 HUD。 这段代码不需要 Pygame,也不需要 Cocos2d,纯 Python 就能跑。 规划阶段的代码本来就该跟引擎无关,换引擎不用重写。

30 秒实验

  1. 把 CELL 从 32 改成 24,重跑,看棋盘变成多大,HUD 还剩多少。
  2. 把 ROWS 从 20 改成 16,重跑,看上下余量变成多少。
  3. 在场景栈那里多加一步 push('提示') 再 pop(),看栈的变化。

常见误区

  1. 「俄罗斯方块没什么好规划的,直接写。」写得动,但改成「加个下一块预览」时就不知道往哪塞了。 清单不是为了好看,是为了改动有地方落。
  2. 「状态就那么几个变量,不用集中。 」五个变量的时候还扛得住,加一个「连击数」、一个「消行动画进度」就开始漏了。
  3. 「窗口尺寸写死成 480 就行。」写死的不是窗口,是「480 这个数字出现在十几个地方」。 集中成常量,改一次全生效。
自测三问(点开对答案) 1. 为什么「下一块是什么」也要放进状态字典? —— 因为暂停、重开、存档都要用到它。 放在局部变量里,暂停再继续时它会重新随机,玩家看到的预览和实际掉下来的对不上。 **凡是跨帧要记住的东西,都算状态。** 2. 棋盘 320×640、窗口 480×800,HUD 有多宽?上下各留多少? —— HUD 宽 480 - 320 = 160。上下余量 800 - 640 = 160,上下各 80。 这三个数加起来正好是整块窗口,说明配平了。 3. 为什么说俄罗斯方块的场景结构比飞机大战还简单? —— 因为流程只有菜单、游戏、暂停、结算四站,栈的深度从头到尾不超过 2, 没有飞机大战那种「游戏里弹设置、设置里弹确认」的多层嵌套。

骨架搭好了,下一篇填肉:平铺的背景、滑进来的菜单,还有那张集中放参数的表。

相关笔记