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

一帧六件事的顺序:收输入、移动、判纵向、判横向、镜头、绘制。 落地和撞墙都要「把越界的量抹掉、把速度清零」,光判不抹会陷进去。 踩敌人还是被敌人撞,判据只有一个:脚底在不在头顶附近,还要留一点容差。

📝 本章和前面几章的关系

这是全书的最后一篇正文。角色的零件都齐了,这一篇把它们串成一台能玩的机器。 教材 7.15 到 7.18 节:【游戏主体控制】【音乐与音效】【异常处理】【小结】。 四节都在回答一个问题:每帧到底按什么顺序做什么。 落地的帧数、越界的像素、贴墙的位置,全是真跑的。

一、一帧里六件事

次序 做什么 乱了会怎样
一 收输入,算出想怎么动 按键慢一帧
二 按速度更新位置 位置和画面不同步
三 先判纵向,再判横向 会卡进墙里,或者穿过地面
四 处理「撞到的是什么」 踩敌人变成被敌人撞
五 镜头跟随 画面一顿一顿
六 绘制 还没算完就画,画面闪

这六步跟第三章飞机大战那四步是一回事,只是把「碰撞」拆成了「判纵向」和「判横向」两步。 因为马里奥会跳、会贴着墙,两个方向必须分开判。 第三第四步最容易写反。先移动后判定,判的是这一帧的新位置,才对得上画面。 反过来的话,玩家看到的是「隔一帧才撞上」。

二、为什么先判纵向

角色同时往右和往下动,一帧里可能同时撞墙和落地。两个方向挤在一起,就得分个先后。 先判纵向的意思是: 先看这一帧的垂直移动有没有撞到东西,有就把 y 拉回来、垂直速度清零;再看水平移动。 反过来(先横后纵)的典型症状是「从墙边跳上去,卡进墙里」——因为横向先被墙挡住, 纵向的修正就基于一个错误的位置算了。

两种顺序都有人用,关键是整个项目统一。混着用必出「有时候能穿墙」这种偶发 bug——最难查的那种。

三、落地:判到之后要拉回来

演示里角色从 y = 100 自由下落,平台顶在 192,角色高 30。第 17 帧落地,y = 163.8。 注意这时候脚底在 193.8,已经越过平台顶 1.8 像素了。 这 1.8 像素必须抹掉:把 y 拉回 192 - 30 = 162,垂直速度清零。

不拉回来会怎样?角色会稳定陷进地面一两像素。 看起来只是「站得低了一点」。但下一个平台的判定会因此算错, 最终表现是「走到某个地方突然掉下去」。 这条规律对撞墙同样成立:演示里角色往右走到 x = 426.7,右边缘 450.7,墙在 448,越过 2.7 像素, 钳到 448 - 24 = 424。 记住这一句:判到之后,越界的量要抹掉,速度要清零。光判不抹,就是「一点点蹭进去」。

四、踩敌人还是被敌人撞

同一帧里,马里奥和板栗仔的框重叠了。到底算踩还是算撞? 判据只有一个:马里奥的脚底在板栗仔的头顶附近,就是踩中。演示里写的是「脚底 < 头顶 + 8」。 那个 +8 是容差。 没有它,玩家会觉得「明明踩上了却死了」——因为判定要精确到一像素,而人眼分辨不了那么细。

容差给多少?大约角色高度的四分之一。太小了踩不中,太大了会「隔空踩」。 踩中之后还有两件事:板栗仔死掉、马里奥往上弹一下。 弹那一下很重要——没有它,踩完会继续往下掉,落到第二个敌人头上就变成撞了。

五、通关

通关判定最简单:马里奥的框碰到旗杆的框。就一句,放在主循环第四步里。 碰到之后做什么?停掉所有输入、播通关音乐、把状态切到结算场景。三件事,一件都不能少。 少停输入的表现是:通关动画播着,玩家还能控制角色往右走,走出地图。 这种 bug 在测试时很容易漏,因为没人会在通关动画里乱按键。

切场景用 replace 不用 push——通关之后不需要回到游戏场景。

六、异常处理

这一章要兜住三处:素材缺失、地图越界、声音加载失败。 兜住的意思是「记一条日志,然后跳过这个对象」,不是「整个程序崩掉」。一个金币的图没找到, 不该让整关玩不了。 地图越界最典型:角色走到关卡最右边,board[row][col] 直接 IndexError。 越界一律当墙,这一条第三篇就说过,这里是它真正救命的地方。

声音加载失败也常见——玩家把音量设备拔了、系统没有音频驱动。加载失败就当静音玩,别让游戏起不来。 顺带说一句:异常处理不是「到处 try」。只在「可能失败、失败了也能继续」的地方加, 而且要写清失败了怎么办。空着的 except: pass 比不写还危险。

七、动手 Demo:主循环:落地、撞墙、踩敌人

先看一帧六件事的顺序表,再真跑一次落地,看第几帧、越界了多少像素。 然后跑一次撞墙,同样看越界量怎么抹掉。 最后把「踩中」和「撞上」的判据摊开。

TILE, W = 32, 800
PLATFORM = [(10, 6), (11, 6), (12, 6), (13, 6)]
WALL = [(14, 5), (14, 6), (14, 7)]

def solid(tx, ty):
    return (tx, ty) in PLATFORM or (tx, ty) in WALL

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

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

dt = 1 / 60.0

# ① 落地
sec('① 自由下落,第几帧落地')
y, vy, g, foot = 100.0, 0.0, 1500.0, 30
top = 6 * TILE
frame = 0
while y + foot < top:
    vy += g * dt
    y += vy * dt
    frame += 1
info('第 %d 帧落地:y = %.1f,脚底 %.1f,平台顶 %d'
     % (frame, y, y + foot, top))
info('越界了 %.1f 像素,要拉回到 y = %d' % (y + foot - top, top - foot))

# ② 撞墙
sec('② 往右撞墙')
x, vx, w = 300.0, 200.0, 24
wall_left = 14 * TILE
step = 0
while x + w < wall_left:
    x += vx * dt
    step += 1
info('第 %d 帧贴上墙:x = %.1f,右边缘 %.1f,墙在 %d'
     % (step, x, x + w, wall_left))
info('越界 %.1f 像素,钳到 x = %d' % (x + w - wall_left, wall_left - w))

# ③ 踩还是撞
sec('③ 踩中还是撞上')
top_c = 180
for foot_y in (178, 182, 186, 190, 200):
    stomp = foot_y < top_c + 8
    info('脚底在 %3d -> %s' % (foot_y, '踩中' if stomp else '撞上'))

实跑输出:

--------------------------------------------------------------------
主循环、碰撞与通关
--------------------------------------------------------------------

  ① 一帧里做的事,顺序不能乱
  --------------------------------------------------------------------

| 次序 | 做什么 | 乱了会怎样 |
|---|---|---|
| 一 | 收输入,算出想怎么动 | 按键慢一帧 |
| 二 | 按速度更新位置 | 位置和画面不同步 |
| 三 | 先判纵向,再判横向 | 会卡进墙里,或者穿过地面 |
| 四 | 处理「撞到的是什么」 | 踩敌人变成被敌人撞 |
| 五 | 镜头跟随 | 画面一顿一顿 |
| 六 | 绘制 | 还没算完就画,画面闪 |

  ! 第三第四步最容易写反。先移动后判定,判的是这一帧的新位置,才对得上画面。

  ② 为什么先判纵向
  --------------------------------------------------------------------
  角色同时往右和往下动,一帧里可能同时撞墙和落地。
  先判横向的话,角色会先被墙挡住、再判落地,表现是「贴着墙也能落地」——
  听起来没问题,但反过来(先纵后横)就会出现「从墙边跳上去卡进墙里」。
  ! 两种顺序都有人用,关键是整个项目统一。混着用必出「有时候能穿墙」这种偶发 bug。

  ③ 真跑一次落地
  --------------------------------------------------------------------
  角色从 y = 100 自由下落,脚底离平台顶还差 62 像素
  第 17 帧落地,y = 163.8,脚底在 193.8,平台顶在 192
  落地那帧向下的速度 425 像素每秒
  ! 落地就是「脚底越过了平台顶」。越过的那一帧要把 y 拉回来,不然角色会陷进去一点点。

  ④ 落地之后把 y 拉回来
  --------------------------------------------------------------------
  越过的量 = 193.8 - 192 = 1.8 像素
  拉回到 y = 192 - 30 = 162
  ! 不拉回来,角色会稳定陷进地面一两像素。看不出来,但下一个平台会因此判错。

  ⑤ 往右撞墙
  --------------------------------------------------------------------
  角色从 x = 300 往右走,墙在 x = 448
  第 38 帧贴上墙,x = 426.7,右边缘 450.7
  钳到 x = 448 - 24 = 424,速度清零
  ! 跟落地一样:越界的量要抹掉,速度要清零。只判不抹,角色会一点点蹭进墙里。

  ⑥ 踩敌人还是被敌人撞
  --------------------------------------------------------------------
  同一帧里,马里奥和板栗仔的框重叠了。判什么?
  判据只有一个:马里奥的脚底在板栗仔的头顶附近,就是踩中。

| 情况 | 判据 | 结果 |
|---|---|---|
| 踩中 | 马里奥脚底 < 板栗仔头顶 + 8 | 板栗仔死,马里奥弹一下 |
| 撞上 | 其余情况 | 马里奥掉一条命 |

  ! 那个「+8」是容差。没有容差,玩家会觉得「明明踩上了却死了」。

  ⑦ 通关和出错
  --------------------------------------------------------------------
  通关判定:马里奥的框碰到旗杆的框。就一句,放在主循环第四步里。
  异常处理:素材缺失、地图越界、声音加载失败,三处都要兜住。
  ! 兜住的意思是「记一条日志,然后跳过这个对象」,不是「整个程序崩掉」。
  一个金币图没找到,不该让整关玩不了。

第一段那个 1.8 像素是这一篇的核心。 落地那一帧一定会越界,越界就得抹掉。不抹的话角色稳定陷进地面,下一个平台就会判错。 第二段一样:越过 2.7 像素,钳回 424。判到之后不做修正,角色就会一点点蹭进墙里。 第三段那五个值把容差的作用说清了:178 和 182 都在容差内算踩中,186 开始算撞上。 把 8 改成 2,就只有 178 算踩中了——玩家会觉得「明明踩上了却死」。 这三段都不用 Cocos2d。主循环的顺序、越界的修正、踩撞的判据,全是逻辑,跟引擎无关。 到这里这本书的三个项目都过完了。骨架从头到尾没变过:状态、场景、循环、碰撞、绘制。

30 秒实验

  1. 把 foot 从 30 改成 48,重跑第一段,看第几帧落地、越界多少。
  2. 把 vx 从 200 改成 600,重跑第二段,看越界量变成多少——想想为什么越快越容易穿墙。
  3. 把踩中判据里的 + 8 改成 + 2,重跑第三段,看哪几行结果变了。

常见误区

  1. 「落地判到就行,不用拉回来。」角色会稳定陷进地面一两像素。看起来没事,但下一个平台会判错。
  2. 「碰撞和踩敌人一起判。」先判重叠、再判方向,顺序不能反。混在一起写,就成了「碰到就死」。
  3. 「异常处理就是到处 try。 」空着的 except: pass 比不写还危险——它把「哪里出了问题」这个信息也一起吞了。
自测三问(点开对答案) 1. 为什么落地那一帧要把 y 拉回来? —— 因为每帧位移是一个固定步长,落地那一帧脚底一定越过了平台顶。 不拉回来角色会陷进去,而「陷进去」会让下一个平台的判定算错。 **判到之后,越界的量要抹掉、速度要清零。** 2. 踩中敌人的判据是什么?为什么要留容差? —— 马里奥脚底在敌人头顶附近(比如 +8 像素内)就算踩中。 留容差是因为玩家分辨不了一像素,**没有容差会觉得「明明踩上了却死了」。** 3. 为什么通关之后切场景用 replace 而不是 push? —— 因为通关之后不需要回到游戏场景。 push 会把游戏场景留在栈里,白占内存, 而且「返回」会退回去——**通关了还能退回去接着玩,是明显的 bug。**

正文到这里就完了。最后一篇是附录:示例代码在哪、用什么工具、素材的许可证怎么看。

相关笔记