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

基类放位置、速度、重力、边界钳制;子类只放「我这角色特殊的那几行」。 加速度给的是「每秒加多少」,所以一帧加的是加速度乘以时间步长。 跳就是给一个向上的初速度,剩下的交给重力——跟真扔一个球一样。

📝 本章和前面几章的关系

上一篇把地图摆好了,这一篇把八种角色摆上去,让它们动。 教材 7.13、7.14 节:【角色划分】【角色移动】。 两节讲的是同一件事:怎么用一份代码管住八个长得不一样的东西。 加速度、跳跃高度、滞空帧数、边界钳制,都是真跑的。

一、基类放什么,子类放什么

放基类 放子类
位置、速度、宽高 具体移动规则
重力、每帧更新一次的流程 撞墙怎么反应
出边界的钳制 什么时候死
一个 rect(),供碰撞用 长什么样、播什么动画

判断标准很简单:八种角色都要的那就提上去,只有一种要的就留在子类。 提上去的好处不只是少写代码。 改一处,八种一起改。想给所有角色加个「受伤闪红」,改基类一行,八个全有了。 留在子类的好处是不让基类变胖。 基类里塞了「乌龟变壳」这种逻辑,金币和旗杆就得捏着鼻子继承一堆用不上的东西。

二、三个例子

演示派生三个:马里奥 24×30、板栗仔 28×28、金币 20×20。重力分别是 1500、1500、0。 金币的重力设成 0,它就自然待着不动了——一个数,复用了整条更新流程, 不用另写一个「不会动的对象」类。 板栗仔的初速是 -60,意思是「一开始就向左走」。方向写在初速里,不写在逻辑里。

这种「用参数表达差异」的做法,比「写八个 if」强得多。加第九种角色,不用改任何已有代码。

三、加速度和摩擦

演示里加速度 800、摩擦 1200、最高速 200。跑 20 帧,速度从 13.3 一路涨到 200,第 15 帧到顶。 关键在那个 13.3:加速度给的是「每秒钟加多少」,一帧加的是 800 × 1/60 ≈ 13.3。 直接加 800 的话,一帧就到顶了,角色会「瞬间全速」。 摩擦同理,是一秒钟减多少。 松开方向键之后,速度按 1200/秒 往下掉, 大约 0.167 秒停住——这个 0.167 秒就是「手感」里那个「滑一下」。

调手感基本就在调这三个数:加速度决定「起步多快」,摩擦决定「停得多利索」, 最高速决定「跑起来多快」。一个一个调,别一起改。

四、跳:一条抛物线

演示里起跳速度 500、重力 1500。算出三件事:最高点用时 0.333 秒、跳起 83.3 像素、滞空 40 帧。 公式就两条:t = v / g,h = v² / (2g)。中学物理,一点没变。 40 帧滞空意味着什么? 玩家从按下跳到落地,有三分之二秒可以操作。这个数太小会觉得跳得很急,太大又显得飘。

想跳得更高,加初速比减重力好。减重力会让下落也变慢,角色像在月球上。加初速只影响起跳那一下。

五、离散积分的误差

演示那张表有个细节:第 20 帧只到 79.2,理论最高点是 83.3;第 40 帧是 -8.3,不是 0。 因为每帧是先加速度、再加位置。这样算出来的轨迹比真实抛物线「稍微低一点、稍微长一点」。 差几个像素有没有关系? 单看一次跳没有。但平台的高度是按数据定死的——如果跳跃高度算少了 4 像素, 玩家就会「明明看着能跳上去却跳不上」。

想更准有两个办法:把时间步长切小(一帧算两次物理),或者换成「先加速度的一半、再加位置、 再加剩下的半」这种更精细的积分。 实际做法更简单:按理论值设计关卡,留出 10% 的余量。算出来能跳 83 像素,平台高度差就定 70。

六、走不出地图

演示试了五个起点,包括 -50 和 2000 这种明显越界的。更新一次之后,x 被钳到 0 或者地图右边缘。 速度也清零了。 速度清零这一步很多人忘。 不清的话,角色贴在地图边上,速度还是 200, 一松手就往回弹一下——看起来像「撞墙反弹」,其实是速度没清。 钳制要写在基类的 update 里,这样八种角色自动都有。 写在某个子类里,别的角色就会跑出地图,然后碰撞判定拿到一个越界坐标,直接崩。

这就是「提上去」的第二个好处:不是省代码,是防止漏。

七、八种角色各写几行

角色 只写这几行
马里奥 加速度、摩擦、跳
板栗仔 匀速、撞墙回头
乌龟 同板栗仔,多一个「被踩变壳」
乌龟壳 被踢后加速滑、撞墙反弹
食人花 上下正弦伸缩
金币 重力 0、按时间转
砖块 静止,只响应「被顶」
旗杆 静止,碰到就通关

八种加起来代码并不多——因为公用的部分都在基类里。 这也回答了「什么时候该用继承」这个老问题: 当「八个东西有大量共同行为,只有少量差异」的时候。反过来,如果共同点很少,就该用组合, 别硬凑一个基类。

八、动手 Demo:一个基类派生八种角色

先看基类和子类各放什么,再派生三个例子。 然后跑一遍加速和摩擦,看速度怎么爬到最高。 最后算跳跃的抛物线,以及为什么要给边界做钳制。

TILE = 32

class Actor:
    def __init__(self, name, x, y, w, h):
        self.name, self.x, self.y, self.w, self.h = name, x, y, w, h
        self.vx = self.vy = 0.0
        self.g = 1500.0

    def rect(self):
        return (self.x, self.y, self.w, self.h)

    def update(self, dt, mapw):
        self.vy += self.g * dt
        self.x += self.vx * dt
        self.y += self.vy * dt
        left, right = 0, mapw * TILE - self.w
        if self.x < left:
            self.x, self.vx = left, 0.0
        if self.x > right:
            self.x, self.vx = right, 0.0

class Mario(Actor):
    MAXVX, ACC, FRIC = 200.0, 800.0, 1200.0
    def walk(self, dt):
        self.vx = min(self.MAXVX, self.vx + self.ACC * dt)
    def jump(self, power=500.0):
        self.vy = -power

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

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

dt = 1 / 60.0

# ① 加速
sec('① 加速与摩擦')
m = Mario('马里奥', 100, 300, 24, 30)
for i in range(1, 21):
    m.walk(dt)
    if i % 5 == 0:
        info('第 %2d 帧  速度 %.1f' % (i, m.vx))
info('从 0 到 %.0f 用了 %.2f 秒' % (m.MAXVX, m.MAXVX / m.ACC))

# ② 跳
sec('② 跳')
m2 = Mario('马里奥', 100, 400, 24, 30)
m2.jump()
v, g = 500.0, m2.g
info('初速 %d,重力 %d' % (v, g))
info('最高点用时 %.3f 秒,跳起 %.1f 像素,滞空 %d 帧'
     % (v / g, v * v / (2 * g), int(2 * v / g * 60)))
y0 = m2.y
for i in range(1, 41):
    m2.vy += m2.g * dt
    m2.y += m2.vy * dt
    if i % 10 == 0:
        info('第 %2d 帧  离地 %.1f' % (i, y0 - m2.y))

# ③ 边界钳制
sec('③ 出界会被钳住')
for x in (-50, 100, 2000):
    a = Actor('测试', x, 300, 24, 30)
    a.vx = 100
    a.update(dt, 48)
    info('给的 x = %4d -> 更新后 %.0f,速度 %.0f' % (x, a.x, a.vx))

实跑输出:

--------------------------------------------------------------------
八种角色:一个基类派生出来的
--------------------------------------------------------------------

  ① 基类里放什么
  --------------------------------------------------------------------

| 放基类 | 放子类 |
|---|---|
| 位置、速度、宽高 | 具体移动规则 |
| 重力、更新一次的流程 | 撞墙怎么反应 |
| 出边界的钳制 | 什么时候死 |
| 一个 rect(),供碰撞用 | 长什么样、播什么动画 |

  八种角色里,位置速度宽高和「每帧按重力动一下」是一模一样的。
  ! 能提到基类的东西就提上去。提上去之后,加第九种角色只要写它特殊的那几行。

  ② 派生出来的三个例子
  --------------------------------------------------------------------

| 角色 | 宽×高 | 重力 | 初速 |
|---|---|---:|---:|
| 马里奥 | 24×30 | 1500 | 0 |
| 板栗仔 | 28×28 | 1500 | -60 |
| 金币 | 20×20 | 0 | 0 |

  ! 金币的重力设成 0,它就自然待着不动了——一个数,复用了整条更新流程。

  ③ 走起来:加速度和摩擦
  --------------------------------------------------------------------
  加速度 800,摩擦 1200,最高速 200

| 第几帧 | 速度 |
|---:|---:|
| 1 | 13.3 |
| 4 | 53.3 |
| 8 | 106.7 |
| 12 | 160.0 |
| 16 | 200.0 |
| 20 | 200.0 |

  到第 15 帧到达最高速 200
  ! 加速度给的是「每秒钟加多少」,所以一帧加的是 800 × 1/60 ≈ 13.3。别直接加 800。

  ④ 跳起来:一条抛物线
  --------------------------------------------------------------------
  起跳速度 500(向上),重力 1500
  最高点用时 500 / 1500 = 0.333 秒
  跳起高度 500² / (2 × 1500) = 83.3 像素
  滞空 2 × 0.333 = 0.667 秒,也就是 40 帧

| 第几帧 | 离起跳点多高 |
|---:|---:|
| 5 | 35.4 |
| 10 | 60.4 |
| 15 | 75.0 |
| 20 | 79.2 |
| 25 | 72.9 |
| 30 | 56.2 |
| 35 | 29.2 |
| 40 | -8.3 |

  ! 这就是「跳」的全部实现:给一个向上的初速度,剩下的交给重力。跟真扔一个球一样。
  ! 注意第 20 帧是 79.2 而理论最高点是 83.3,第 40 帧是 -8.3 而不是 0——因为每帧先加速度再加位置,
  离散积分的误差会累积一点点。想要更准就把时间步长切小,或者用「半隐式」更新顺序。

  ⑤ 走不出地图
  --------------------------------------------------------------------

| 给的 x | 更新一次后 x | 速度 |
|---:|---:|---:|
| -50 | 0 | 0 |
| 0 | 2 | 100 |
| 100 | 102 | 100 |
| 1568 | 1512 | 0 |
| 2000 | 1512 | 0 |

  ! 钳制放在基类的 update 里,八种角色自动都有。放到某个子类里,别的角色就会跑出去。

  ⑥ 八种角色的差别在哪
  --------------------------------------------------------------------

| 角色 | 只写这几行 |
|---|---|
| 马里奥 | 加速度、摩擦、跳 |
| 板栗仔 | 匀速、撞墙回头 |
| 乌龟 | 同板栗仔,多一个「被踩变壳」 |
| 乌龟壳 | 被踢后加速滑、撞墙反弹 |
| 食人花 | 上下正弦伸缩 |
| 金币 | 重力 0、按时间转 |
| 砖块 | 静止,只响应「被顶」 |
| 旗杆 | 静止,碰到就通关 |

  ! 八种加起来代码并不多——因为公用的部分都在基类里。

第一段那个 13.3 是这一篇最容易被忽略的数。 加速度是「每秒加多少」,不是「每帧加多少」。写错了角色会瞬间全速,怎么调都觉得手感不对。 第二段那三个数(0.333 秒、83.3 像素、40 帧)不是背的,是算出来的。 改初速或者重力,这三个数自己会变——记住公式比记住数有用。 第三段那个 update() 里的钳制,写一次八个角色都有。 这就是「提到基类」真正的价值:不是省代码,是防止漏。 注意钳制的时候速度也清零了。只钳位置不清速度,角色会贴着边「抖」。 这段代码纯 Python 能跑。角色的物理跟引擎无关——换成任何引擎,这套加减乘除都一样。

30 秒实验

  1. 把 MAXVX 从 200 改成 400,重跑第一段,看到顶用几帧。
  2. 把 jump() 的默认值从 500 改成 700,重跑第二段,算算跳起多高。
  3. 把 g 从 1500 改成 750,重跑第二段,看滞空变成几帧——想想这样改跳起来像什么。

常见误区

  1. 「加速度 800,那就每帧加 800。」一帧就到顶了。加速度要乘时间步长。
  2. 「跳得不够高就把重力调小。」重力调小,下落也变慢,角色飘起来。加初速更干净。
  3. 「边界钳制写在马里奥里就行。」别的角色会跑出地图。写在基类的 update 里。
自测三问(点开对答案) 1. 加速度 800、最高速 200,多少秒到顶?一帧加多少? —— 0.25 秒到顶,一帧加 800 × 1/60 ≈ 13.3。**加速度的单位是「每秒增加多少像素每秒」。** 2. 起跳初速 500、重力 1500,跳多高?滞空几帧? —— 跳起 500² / (2 × 1500) = 83.3 像素,滞空 2 × 500 / 1500 = 0.667 秒, 也就是 40 帧(60 帧每秒)。 3. 为什么边界钳制要写在基类里? —— 因为八种角色都要。写在某个子类里,别的角色跑出地图之后,碰撞判定会拿到越界坐标。 **提上去不是省代码,是防止漏。**

角色会动了,下一篇把它们塞进主循环:一帧里六件事,顺序决定「踩死还是被撞死」。

相关笔记