🎮 第7章 《超级马里奥》游戏
📄 一句话概括

整关是一张字符表,改关卡不用改代码。 摆图就是把表遍历一遍,每个非空格子按 (列×32, 行×32) 贴一张图。 闪烁跟帧动画内核是同一个:时间取模,按余数选图。

📝 本章和前面几章的关系

上一篇把界面摆完了,这一篇进游戏本身:关卡怎么存、怎么摆、怎么动。 教材 7.9 到 7.12 节:【编辑地图】【游戏场景】【闪烁砖块】【图片分割与动画效果】。 四节合起来是「数据 → 画面」这条链路:表怎么来、怎么变成图、图怎么变。 本篇的关卡表、对象计数、闪烁时序、视口范围,都是真跑出来的。

一、地图就是一张字符表

演示里那八行字符串就是一段关卡。点表示空气、G 是地面、B 是砖块、C 是金币、E 是敌人出生点。 一张表说清整个关卡。改关卡不用改一行代码——这就是「数据驱动」。 真项目里这张表是 Tiled 画出来、导出成 JSON 的。但导出来还是这个形状:一张二维表,每格一个编号。 字符还是数字不重要,重要的是「它是一张表」。

为什么用字符演示?因为你能一眼看出关卡长什么样。用数字就得对着图集一格格查。 调关卡的时候,先用字符画草图,再转成数字,快得多。 注意表里那一段连续的 G 中间有个缺口——那就是坑。地图数据里的「坑」不是特殊对象, 就是「这里没有地面」。

二、摆图:一次遍历

for 每一行: for 每一格: 如果是非空格子,就在 (列×32, 行×32) 贴一张图。摆图就这么点代码。 演示算了几个位置:(0, 6) 是地面,像素 (0, 192);(9, 3) 是砖块,像素 (288, 96); (10, 2) 是金币,像素 (320, 64)。 (32, 7) 那格是空气,位置是 (1024, 224)——空气也有位置,只是不贴图。写代码的时候, 「跳过」这一步要放在算位置之前还是之后,都行,但别忘。

遍历一遍整关要贴多少张? 演示数了一段:地面 88 个、砖块 7 个、金币 2 个、敌人点 2 个,一共 99 个对象。 这个数不是好玩的,是性能预算。每个对象都要参与碰撞判定,整关两千多格, 两两比就是几百万次——这就是上一篇讲的「只判屏幕附近」的来历。

三、闪烁砖块:按时间换图

经典马里奥里那种一闪一闪的砖块,做法比想象的简单:两张图来回切。 一个周期 1 秒,前 0.5 秒显示 A 图,后 0.5 秒显示 B 图。 算式是 阶段 = 已过秒数 % 1.0,然后 阶段 < 0.5 就显示 A。 演示跑了 8 个时间点,1.00 秒时阶段回到 0.00,又显示 A。

又是取模。跟背景滚动、帧动画一模一样的套路:时间对周期取余,再按余数分段。 这个套路能复用到所有「周期性变化」的东西上:呼吸的按钮、闪红的血条、上下浮动的道具。 认一次,到处用。

四、闪烁和帧动画的区别

对比项 帧动画 闪烁
循环内容 一串连续的图(8 帧走路) 两张图来回切
周期 每帧 1/12 秒 每半秒换一次
驱动 角色状态(在走才播) 时间,跟角色状态无关

内核是同一个:时间取模,按结果选图。区别只在「一段有几张、每张多久」。 驱动源不一样这点值得说:走路动画是「有输入才播」,停下来要切回站立图; 闪烁是「不管你在干嘛,我就一直闪」。一个是状态驱动,一个是时间驱动。 分清这两类,你加动画的时候就知道该挂在哪:状态驱动的挂在角色逻辑里,时间驱动的挂在场景更新里。 挂错地方,角色死了动画还在闪。

五、图片分割

砖块、金币、角色,全都从图片集里切。切法第六篇算过:一个除法一个取余。 这里多一步:同一类对象共用一张图。所有砖块贴的是同一张,所有金币也是。 不用每个对象存一份图,只存「我用图集里的第几块」。 这是省内存的关键。一百个砖块如果各存一份 32×32 的图,就是一百万像素;共用一张,只有一千像素。 差三个数量级。

六、镜头决定画哪几列

演示里镜头在 x = 400,窗口 800 宽,看得见第 12 到 37 列,剩下 23 列不用画。 跟上一章瓦片地图那套算法完全一致。同一个道理,用在了第二个项目上。 注意「不用画」不只是省绘制,还省了「遍历」。地图越宽,这个比例越夸张。

还有一个细节:镜头移动的时候,新进入视野的那一列要「补画」。 做法就是每帧按新的视口重算一遍范围,不要试图只画「新进来的那一列」——那种增量更新省不了多少, 还容易漏。

七、动手 Demo:关卡表、摆图与闪烁砖块

先看一张字符表怎么描述一段关卡,再把格子坐标换成像素坐标。 然后数一数这一段里有多少对象,想想整关两千格是什么量级。 最后算闪烁砖块的时序,看两张图怎么来回切。

TILE, W = 32, 800

LEVEL = [
    '................................................',
    '................................................',
    '..........C.....................................',
    '.........BBB....................C...............',
    '.............E..........BBBB....................',
    '....................E...........................',
    'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG',
    'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG',
]
NAME = {'.': '空气', 'G': '地面', 'B': '砖块', 'C': '金币', 'E': '敌人点'}

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

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

# ① 摆图
sec('① 格子 -> 像素')
for col, row in ((0, 6), (9, 3), (10, 2), (13, 4), (32, 7)):
    info('(%2d, %d) %s -> 像素 (%d, %d)'
         % (col, row, NAME[LEVEL[row][col]], col * TILE, row * TILE))

# ② 数对象
sec('② 数一数有什么')
cnt = {}
for line in LEVEL:
    for ch in line:
        if ch != '.':
            cnt[ch] = cnt.get(ch, 0) + 1
for ch in ('G', 'B', 'C', 'E'):
    info('%s 共 %d 个' % (NAME[ch], cnt.get(ch, 0)))
info('合计 %d 个对象' % sum(cnt.values()))

# ③ 闪烁
sec('③ 闪烁砖块:时间取模')
for t in (0.0, 0.3, 0.5, 0.7, 1.0, 1.5):
    phase = t % 1.0
    info('经过 %.1f 秒,周期内 %.2f -> 显示 %s'
         % (t, phase, 'A' if phase < 0.5 else 'B'))

# ④ 视口
sec('④ 镜头看得见几列')
cam = 400
info('镜头 %d,窗口 %d -> 第 %d 到 %d 列'
     % (cam, W, cam // TILE, (cam + W - 1) // TILE))
info('整关 %d 列,差 %d 列不用画'
     % (len(LEVEL[0]), len(LEVEL[0]) - W // TILE))

实跑输出:

--------------------------------------------------------------------
地图、场景与闪烁砖块
--------------------------------------------------------------------

  ① 地图是一张字符表
  --------------------------------------------------------------------
  关卡宽 48 格、高 8 格(这里只截一段看)
    第 0 行  ................................................
    第 1 行  ................................................
    第 2 行  ..........C......................................
    第 3 行  .........BBB....................C................
    第 4 行  .............E..........BBBB.....................
    第 5 行  ....................E............................
    第 6 行  GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG
    第 7 行  GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG.....GGGGGGGGGGGGG

| 字符 | 是什么 | 能不能站 |
|---|---|---|
| . | 空气 | 不能 |
| G | 地面 | 能 |
| B | 砖块 | 能 |
| C | 金币 | 不能 |
| E | 敌人出生点 | 不能 |

  ! 一张表说清整个关卡。这就是「数据驱动」——改关卡不用改代码。

  ② 按数据摆图
  --------------------------------------------------------------------
  第 (col, row) 格的像素位置 = (col × 32, row × 32)

| 格子 | 里面是什么 | 像素左上角 |
|---|---|---|
| (0, 6) | 地面 | (0, 192) |
| (9, 3) | 砖块 | (288, 96) |
| (10, 2) | 金币 | (320, 64) |
| (13, 4) | 敌人点 | (416, 128) |
| (32, 7) | 空气 | (1024, 224) |

  ! 摆图就是把这张表遍历一遍,遇到非空格的格子就贴一张图。两百行代码都不用。

  ③ 数一数关卡里有什么
  --------------------------------------------------------------------
    G  共 88 个
    B  共 7 个
    C  共 2 个
    E  共 2 个
  这个片段一共 99 个对象,整关两千多格全是这样摆出来的。
  ! 数这个不是为了好玩——它是性能预算。
  每个对象都要参与碰撞判定,数量直接决定你要不要做「只判屏幕附近」的优化。

  ④ 闪烁砖块:按时间换图
  --------------------------------------------------------------------
  一个闪烁周期 1.0 秒:前 0.5 秒显示 A 图,后 0.5 秒显示 B 图

| 经过秒数 | 周期内位置 | 显示哪张 |
|---:|---:|---|
| 0.00 | 0.00 | A |
| 0.30 | 0.30 | A |
| 0.49 | 0.49 | A |
| 0.50 | 0.50 | B |
| 0.70 | 0.70 | B |
| 0.99 | 0.99 | B |
| 1.00 | 0.00 | A |
| 1.50 | 0.50 | B |

  ! 又是取模。跟背景滚动、帧动画一模一样的套路——时间对周期取余,再按余数分段。

  ⑤ 闪烁和帧动画的区别
  --------------------------------------------------------------------

|  | 帧动画 | 闪烁 |
|---|---|---|
| 循环内容 | 一串连续的图(8 帧走路) | 两张图来回切 |
| 周期 | 每帧 1/12 秒 | 每半秒换一次 |
| 驱动 | 角色状态(在走才播) | 时间,跟角色状态无关 |

  ! 两者内核是同一个:时间取模,按结果选图。区别只在「一段有几张、每张多久」。

  ⑥ 地图上哪个位置该被镜头看见
  --------------------------------------------------------------------
  镜头在 x = 400,窗口宽 800
  看得见的列:12 到 37
  这一段有 25 列是看得见的,剩下 23 列不用画
  ! 跟上一章瓦片地图那套算法完全一致。同一个道理,用在了第二个项目上。

第一段那个 (col * TILE, row * TILE) 是这一篇唯一需要记的算式。 所有摆图、所有切图、所有碰撞,都建立在这一个乘法上。 第二段数出来 99 个对象,整关两千多格就是几十倍。 这就是为什么碰撞要「只判屏幕附近」。数一遍心里有底,比拍到卡了再去找原因强。 第三段那个 t % 1.0 跟背景滚动、帧动画是同一个套路。 周期性变化一律「时间取模 + 分段」,这是通用解法。 第四段那个 25 列跟上一章瓦片地图算的一模一样。 同一个道理用在第二个项目上,说明它真的是「道理」而不是「某个引擎的特性」。 这段代码纯 Python 能跑。地图数据跟引擎无关——换成任何引擎,这张表还是这张表。

30 秒实验

  1. 把 TILE 从 32 改成 16,重跑第一段,看像素坐标全变成多少。
  2. 在 LEVEL 里再加一行全是 G,重跑第二段,看地面数量涨多少。
  3. 把闪烁周期从 1.0 改成 2.0,重跑第三段,看 1.5 秒时显示哪张。

常见误区

  1. 「关卡直接写在代码里,用 if 判断哪里是墙。」加一关就要改代码。用表,加关卡只是加一张表。
  2. 「每个砖块存一份自己的图。」一百个砖块就是一百份图。共用图集,只存编号。
  3. 「闪烁是特殊效果,得单独写一套。」它就是「时间取模 + 两张图」。 跟帧动画同一套代码,只是图少。
自测三问(点开对答案) 1. 关卡表里 (9, 1) 那格是砖块,它的像素左上角在哪? —— (288, 32)。9 × 32 = 288,1 × 32 = 32。**就一个乘法。** 2. 为什么地图要做成数据表,不直接写代码? —— 因为改关卡不用改代码。加一关只是加一张表,不用动逻辑。**这就是「数据驱动」的意义。** 3. 闪烁砖块和走路动画,内核上有什么相同?驱动源有什么不同? —— 相同:都是时间取模、按余数选图。 不同:走路动画由角色状态驱动(在走才播),闪烁由时间驱动(一直在闪)。 **挂错地方会出现「角色死了还在闪」。**

地图有了,下一篇摆角色——一个基类、八种派生,各管各的规则。

相关笔记