🎮 第3章 《飞机大战》游戏
📄 一句话概括

四步顺序:收事件、改状态、判碰撞、重画。乱了就会出现「隔空打中」这类怪事。 clock.tick(60) 是「等到这一帧满 16.67 毫秒」,不是「睡 16.67 毫秒」。 convert_alpha 快的十几倍,不是因为算法好,是因为省掉了逐像素换字节顺序。

📝 本章和前面几章的关系

这一篇讲游戏的心脏:那个转不停的循环。 教材 3.5 节下面挂了四个小标题:【事件检测处理】【状态更新】【碰撞检测处理】【屏幕绘制】, 再加上 3.6 节的【加载图片】。 五个小标题合起来就是一句话:每帧按固定顺序做四件事,图提前准备好。 演示里那个「快十几倍」是真测出来的,掩码前后不同,原因能查到根上。

一、四步,顺序不能乱

次序 做什么 乱了会怎样
一 收事件 按键反应慢一拍
二 更新状态 位置和画面不同步
三 判断碰撞 用旧位置算命中,会「隔空打中」
四 重画并 flip 还没改完就画,画面闪

道理只有一条:先知道发生了什么,再改数字,最后按数字画。 第三第四步最容易搞反。 有人喜欢「画完再判碰撞」,结果这一帧画的是旧位置, 碰撞用的是新位置——看起来就是子弹从敌机旁边飞过去却算命中。 还有一处细节:pygame.event.get() 每帧必须调一次,哪怕你一个事件都不处理。 不调的话队列会积压,Windows 会认为你的程序「未响应」。

这四步就是 3.5 节那四个小标题。教材把它们列成四个子节,其实是一段连续的代码。

二、Clock 到底怎么控速

clock.tick(60) 的意思是:从现在起,等到这一帧满 16.67 毫秒再返回。 不是「睡 16.67 毫秒」,是「补到 16.67 毫秒」。这一帧算得快,它多睡一会儿; 算得慢,它立刻返回,不补帧。 演示里跑了 120 帧、掐掉前 10 帧,平均间隔 16.96 毫秒,最短 16.03、最长 17.17。 目标 16.67,稍微多一点是系统的调度抖动。

为什么不补帧?因为补帧会让游戏「快进」,玩家看到的是画面乱跳。 宁可慢一点、稳一点,也不要忽快忽慢。 tick() 还有个返回值:这一帧实际过了多少毫秒。 想按「真实时间」算移动距离(而不是按帧数),就用它。本书为了简单,一律按帧算。

三、一帧到底花了多少

演示里完整跑 120 帧,总共两秒出头,平均每帧 16.83 毫秒。 其中 16.67 是 tick 在等,真正干活只有零点一几毫秒。 也就是说,这个游戏离「算不过来」还差得远。 这个比例很重要。 它告诉你两件事:一是现在的性能绰绰有余,二是如果哪天卡了,一定不是「画得太多」这么简单,得具体测。

顺带说一句:tick 在等的时候,CPU 是真去睡了,不是空转。 所以加了 tick 的游戏反而省电、不烫。

四、图:load 一次,贴一万次

演示里造了一架 48×48 的三角战机,存成 PNG 是 348 字节。 每次 load 一次这个 PNG 要 0.13 毫秒上下。 一帧的时间够 load 一百多次——听着挺多,但你不会只 load 一张图。 一个游戏几十张图,每帧都 load 一遍,一帧就没了。 所以规矩是:开局 load 一次,存进变量或字典,循环里只管贴。

这也解释了为什么要有「资源管理器」这种东西——它不是炫技,是把「只加载一次」这件事管起来, 防止你在循环里不小心又 load 一遍。

五、convert 到底做了什么

这一段是本篇最值钱的地方,因为它把一个「听起来很玄」的优化拆到了字节级。 演示里打印了两次掩码。 刚 load 进来是 (255, 65280, 16711680, 4278190080), convert_alpha 之后变成 (16711680, 65280, 255, 4278190080)。 只有前三个数换了位置,最后那个没动。前三个是红绿蓝各自占哪几个二进制位。 第一组说明红在最低字节,第二组说明蓝在最低字节。

一个是 RGBA 顺序,一个是 BGRA 顺序。而屏幕要的是后者。 所以贴原图的时候,每个像素都要把字节顺序换一遍;转好之后,直接内存拷贝就行。 演示里这一段是 0.0436 毫秒对 0.0030 毫秒,差十几倍。 这不是「算法更聪明」,是「不白搬」。所有「格式转换」类的性能问题,本质都是这一条。

六、动手 Demo:游戏循环:四件事,顺序不能乱

先跑 120 帧,量一量 clock.tick(60) 到底把间隔控在多少。 再切到图片:load 一次要多久,convert_alpha 前后差多少。 最后把四步串起来完整跑一遍,看真正干活的时间占多少。

import os, time
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame

W, H = 480, 700

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

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

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

pygame.init()
scr = pygame.display.set_mode((W, H))

# ① Clock 控速
sec('① Clock 是怎么控速的')
clk = pygame.time.Clock()
gaps = []
for i in range(120):
    t0 = time.perf_counter()
    clk.tick(60)
    gaps.append((time.perf_counter() - t0) * 1000)
gaps = gaps[10:]
info('单帧间隔:平均 %.2f,最短 %.2f,最长 %.2f'
     % (sum(gaps) / len(gaps), min(gaps), max(gaps)))
info('目标 1000/60 = %.2f' % (1000.0 / 60))

# ② 造图、存图、读图
sec('② 加载图片:其实是在造图')
art = pygame.Surface((48, 48), pygame.SRCALPHA)
pygame.draw.polygon(art, (90, 180, 255),
                    [(24, 0), (48, 36), (24, 26), (0, 36)])
pygame.image.save(art, 'plane.png')
info('存成 PNG:%d 字节' % os.path.getsize('plane.png'))
t_load = 0.0
for _ in range(9):
    t0 = time.perf_counter()
    pygame.image.load('plane.png')
    d = (time.perf_counter() - t0) * 1000
    t_load = d if t_load == 0 else min(t_load, d)
info('load 一次 %.3f 毫秒' % t_load)

# ③ convert 前后的掩码
sec('③ convert 做了什么')
src = pygame.image.load('plane.png')
fast = src.convert_alpha()
info('原图掩码 %s' % (src.get_masks(),))
info('转后掩码 %s' % (fast.get_masks(),))
t1 = 0.0; t2 = 0.0
for _ in range(199):
    t0 = time.perf_counter(); scr.blit(src, (0, 0))
    d = (time.perf_counter() - t0) * 1000
    t1 = d if t1 == 0 else min(t1, d)
    t0 = time.perf_counter(); scr.blit(fast, (0, 0))
    d = (time.perf_counter() - t0) * 1000
    t2 = d if t2 == 0 else min(t2, d)
info('贴原图 %.4f 毫秒,贴转好的 %.4f 毫秒,差 %.1f 倍'
     % (t1, t2, t1 / t2))

# ④ 完整跑 120 帧
sec('④ 把四步串起来跑')
t0 = time.perf_counter()
for _ in range(120):
    for e in pygame.event.get():
        pass
    clk.tick(60)
    scr.fill((10, 12, 20))
    scr.blit(fast, (216, 600))
    pygame.display.flip()
total = (time.perf_counter() - t0) * 1000
info('120 帧总共 %.0f 毫秒,平均每帧 %.2f 毫秒' % (total, total / 120))
pygame.quit()

实跑输出:

--------------------------------------------------------------------
游戏循环:四件事,顺序不能乱
--------------------------------------------------------------------

  ① 四件事的顺序
  --------------------------------------------------------------------

| 次序 | 做什么 | 乱了会怎样 |
|:---:|---|---|
| 一 | 收事件 | 按键反应慢一拍 |
| 二 | 更新状态 | 位置和画面不同步 |
| 三 | 判断碰撞 | 用旧位置算命中,会「隔空打中」 |
| 四 | 重画并 flip | 还没改完就画,画面闪 |

  ! 顺序的道理只有一条:先知道发生了什么,再改数字,最后按数字画。

  ② Clock 是怎么控速的
  --------------------------------------------------------------------
  clock.tick(60) 的意思是「从现在起,等到这一帧满 16.67 毫秒」
  如果这一帧算得快,它就睡一会儿;算得慢,它就立刻返回,不补帧。
  跑 120 帧、掐掉前 10 帧,单帧间隔:平均 16.95 毫秒,最短 15.92,最长 17.14
  目标间隔是 1000 / 60 = 16.67 毫秒
  ! 平均数贴着 16.67,说明控速生效了。最长那个是系统的抖动,正常。

  ③ 加载图片:其实是在造图
  --------------------------------------------------------------------
  造了一架 48×48 的三角战机,带 alpha 通道
  每像素 32 位,4 字节
  存成 PNG:348 字节
  每次 load 一次 PNG,耗时 0.129 毫秒
  一帧的时间够 load 129 次,装完图就没时间干别的了
  ! 所以图只在开局 load 一次,存进变量或字典,循环里只管贴。

  ④ convert 做了什么
  --------------------------------------------------------------------
  刚 load 进来:32 位,4 字节,掩码 (255, 65280, 16711680, 4278190080)
  convert_alpha 之后:32 位,4 字节,掩码 (16711680, 65280, 255, 4278190080)

| 贴的是什么 | 单次耗时 | 说明 |
|---|---:|---|
| load 出来的原图 | 0.0422 毫秒 | 格式跟屏幕不一致 |
| convert_alpha 之后 | 0.0029 毫秒 | 快了 14.6 倍 |

  ! 位深都是 32,掩码却换了位置:原图 R 在最低字节,转完 B 在最低字节。
  屏幕要的是后一种。所以贴原图那一下,每个像素都得换一次字节顺序。
  这就是那 14.6 倍差距的来源——不是算法,是白搬。

  ⑤ 把四步串起来跑
  --------------------------------------------------------------------
  完整跑 120 帧,总共 1997 毫秒,平均每帧 16.64 毫秒
  其中约 16.67 毫秒是 clock.tick 在等,真正干活的时间很少
  ! 这就是控速的意义:活干得快,就多睡一会儿,省电也不发热。

第一段那个「平均 16.96、最短 16.03、最长 17.17」很有代表性:平均值贴近 16.67,但两端有抖动。 操作系统的调度不是实时的,差零点几毫秒完全正常,差几毫秒才说明有问题。 第二段那个 0.13 毫秒要放在心上:一帧只够 load 一百多次。 听起来很多,可你有几十张图、还要画几百个对象。「开局加载一次」不是优化技巧,是常识。 第三段是全篇的重点。两行掩码只有前三个数换了位置——第一组红在最低字节,第二组蓝在最低字节。 这就是 RGBA 和 BGRA 的区别,也是那十几倍差距的全部原因。 第四段把四步串起来,平均每帧 16.83 毫秒,其中 16.67 是 tick 在等。 真正干活不到零点二毫秒。这个比例说明现在的性能余量极大,也说明「卡顿」不可能来自这一小段。

30 秒实验

  1. 把 tick(60) 改成 tick(120),重跑,看平均间隔是不是变成 8.33, 再想想为什么最短最长那两个数的差也变小了。
  2. 把 convert_alpha() 那一行删掉,直接用 src 贴,看倍数变成多少。
  3. 在完整循环里加一句 scr.fill 之外的绘制(比如画 200 个矩形), 看平均每帧涨多少——验证「干活时间」是不是真的能测出来。

常见误区

  1. 「clock.tick(60) 是让程序睡 16.67 毫秒。」它是补到 16.67 毫秒。 这一帧已经算了 5 毫秒,它只睡 11.67 毫秒。
  2. 「先画再判碰撞,反正下一帧就对了。 」这一帧画的是旧位置、判的是新位置, 玩家会看到「没打中就爆了」或者「明明打中却没反应」。 顺序不能换。
  3. 「convert 只是转个格式,可有可无。」转不转在演示里差了十几倍。 不转就是每贴一个像素换一次字节顺序,这是白干的活。
自测三问(点开对答案) 1. 游戏循环里四步的顺序为什么不能把「重画」放到「判碰撞」前面? —— 因为重画用的是「这一帧最终的状态」。 如果先画再判碰撞,画出来的是碰撞之前的位置,而这一帧的碰撞结果要下一帧才反映到画面上。 玩家会看到子弹穿过去了才算命中,或者没碰到就爆了。 2. `clock.tick(60)` 里,如果这一帧的计算花了 20 毫秒,会发生什么? —— 它会立刻返回,不会等——因为已经超过 16.67 了。所以这一帧就变成 20 毫秒,帧率掉到 50。 **它只负责「不让你太快」,不负责「把你拉回来」。**掉帧了就得自己找原因,`tick` 帮不了。 3. `convert_alpha` 之前和之后都是 32 位,为什么贴图速度差十几倍? —— 位深一样不代表内存排布一样。 原图的掩码说明红在最低字节(RGBA 顺序),屏幕要的是蓝在最低字节(BGRA 顺序)。 贴原图时每个像素都要换一次字节顺序,转换之后两边一致,直接内存拷贝就行。

循环转起来了,下一篇往里填内容:滚动的背景、会动的飞机、一池子子弹、按节奏来的敌机。

相关笔记