基类放位置、速度、重力、边界钳制;子类只放「我这角色特殊的那几行」。 加速度给的是「每秒加多少」,所以一帧加的是加速度乘以时间步长。 跳就是给一个向上的初速度,剩下的交给重力——跟真扔一个球一样。
上一篇把地图摆好了,这一篇把八种角色摆上去,让它们动。 教材 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 秒实验
- 把
MAXVX从 200 改成 400,重跑第一段,看到顶用几帧。 - 把
jump()的默认值从 500 改成 700,重跑第二段,算算跳起多高。 - 把
g从 1500 改成 750,重跑第二段,看滞空变成几帧——想想这样改跳起来像什么。
常见误区
- 「加速度 800,那就每帧加 800。」一帧就到顶了。加速度要乘时间步长。
- 「跳得不够高就把重力调小。」重力调小,下落也变慢,角色飘起来。加初速更干净。
- 「边界钳制写在马里奥里就行。」别的角色会跑出地图。写在基类的 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. 为什么边界钳制要写在基类里? —— 因为八种角色都要。写在某个子类里,别的角色跑出地图之后,碰撞判定会拿到越界坐标。 **提上去不是省代码,是防止漏。**角色会动了,下一篇把它们塞进主循环:一帧里六件事,顺序决定「踩死还是被撞死」。
- ☐ (本篇完,下一篇 7.5 主循环、碰撞与通关)