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

文字渲染的代价跟图的面积走:小字号可以每帧重做,大字号必须缓存。 暂停界面就是一张半透明遮罩加一行字,不需要额外窗口。 资源清理写在 finally 里,不然一出错窗口就卡死在屏幕上。

📝 本章和前面几章的关系

这是第 3 章的最后一篇,把界面和兜底补齐。 教材 3.12 节【小部件】下面挂了四个小标题:Logo、记分牌、暂停/恢复按钮、游戏结束提示框; 然后是 3.13【音乐与音效】、3.14【异常处理】、3.15【小结】。 这一篇有两个数特别有意思:文字渲染的代价随字号怎么变, 还有半透明遮罩盖上去之后像素到底变成多少。

一、记分牌:文字渲染有多贵

先纠正一个常见的想当然:很多人以为「每帧重新渲染文字很慢」,所以拼命做缓存。 演示里测了三个字号:28 号的「分数 1200」、48 号的「游戏结束,最终得分 1200」、 72 号的「超级无敌飞机大战 2026」。 结果是 28 号渲染一次 0.0050 毫秒,贴一次 0.0053 毫秒——渲染比贴还快。48 号也是同样的情况。 只有到 72 号、图变成 765×96 的时候,渲染才涨到 0.23 毫秒,是贴的 5 倍多。

为什么?因为渲染的代价跟图的面积走。28 号那张图 128×37,四千多个像素; 72 号那张 765×96,七万多个像素,差十几倍。 结论就出来了:小字号、短文本,每帧重渲染完全没问题;大字号、长文本,才需要缓存。 这个结论比「一律缓存」有用,因为它告诉了你边界在哪。不知道边界在哪的优化,多半是白忙。

二、暂停按钮:一个矩形加一个点判断

按钮画在右上角 (390, 12),78×34。点击判定就一句 btn.collidepoint(x, y)。 演示里点 (420, 30) 返回 True、点 (200, 300) 返回 False。整个按钮功能就这么点东西。 要「按下变暗」就在 MOUSEBUTTONDOWN 里记下「正在按」,画的时候换个颜色,松开再变回来。

别去网上找什么 UI 库。Pygame 里的界面全是「画图 + 判点」,复杂界面也是这几样堆出来的。

三、暂停界面:半透明遮罩

暂停的时候画面要「暗下来」,做法是做一张全屏的黑色 Surface,但 alpha 设成 150 而不是 255, 然后整张贴上去。 演示里先假设画面是 (40, 60, 100) 的深蓝,盖完之后变成 (16, 24, 41)。 这个数能算出来:alpha 150 表示黑色占 150/255,原色占剩下那部分。 40 乘约 0.41 得 16,60 乘约 0.41 得 24,100 乘约 0.41 得 41——对得上。

变暗了但没变全黑,这就是半透明混合。理解这一点,做淡入淡出、做残影、做高亮选中, 都是同一套算法。 然后在这张图上再贴一行「已暂停」,界面就完事了。不需要第二个窗口,不需要切场景。

四、三个音效,各管一件事

飞机大战只需要三个音:发子弹、打中敌机、游戏结束。 演示里用正弦波现造了三个:880 赫兹 0.06 秒、180 赫兹 0.25 秒、120 赫兹 0.5 秒。 频率越低听着越「重」,所以爆炸比射击低,结束比爆炸还低。 音效要短。0.06 秒的发射音,一秒钟能叠十六个也不糊;要是做个 0.5 秒的,连发的时候就变成一团噪音。

这台机器是 dummy 音频驱动,没有声音出来。所以「好不好听」这类判断本课一律不下结论。 能讲的机制是:Sound 一次性读进内存,适合短音效、可以同时播很多个; music 是流式的,适合背景音乐、同时只能放一个。

五、异常处理:崩了也要把窗口关掉

教材 3.14 节讲异常处理,很多人会跳过这一节。但它是这个项目里最实用的一节。 为什么? 因为游戏主循环一崩,最常见的结果不是「程序退出了」,而是「窗口卡在屏幕上、点不掉、进程还在后台」。 原因很简单:异常从 while 里抛出来,pygame.quit() 那一句就没执行,SDL 那边的资源没释放。

解决办法是把清理放进 finally:不管正常结束还是异常退出,finally 都会跑。 演示里跑了一段会抛异常的小程序,输出四行:进 try、接住异常、finally 一定会跑、quit 已调用。 四行都在,说明清理确实执行了。 顺便说一句,except 里别写裸的 except:,要写清楚接哪一种异常。 裸 except 会把 Ctrl+C 也接住,你就再也停不下这个程序了。

六、小结:第 3 章你写出了什么

回头看这四篇,你手上有一个完整的、能跑的游戏:背景会滚、飞机会动、子弹会飞、敌机会落、能打中、 能记分、能暂停、崩了能退干净。 这已经不是一个「例子」了,是一个真项目。它的结构和商业小游戏没本质区别,只是内容少。 教材 3.15 节的小结很短。 我给它补一句:这一章真正教的是「组织」,不是「语法」。语法你在第 2 章就用完了, 第 3 章全是「怎么把零件摆成一个能长大的东西」。

第 4 章要换个框架了。 有意思的是,你会发现 Cocos2d 替你做的,正是这一章你亲手写的那些活:循环、场景、碰撞调度。 先自己写一遍,再看框架,才知道它省了你什么。

七、动手 Demo:小部件、音效与异常处理

先测三个字号的文字渲染代价,看「要不要缓存」的边界在哪。 再画一个暂停按钮和半透明遮罩,验证像素怎么变。 最后造三个音效,并演示 finally 怎么保证资源释放。

import os, array, math
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['SDL_AUDIODRIVER'] = '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)

def clock(fn, repeat=199):
    fn(); best = 1e9
    for _ in range(repeat):
        import time
        t = time.perf_counter(); fn()
        best = min(best, (time.perf_counter() - t) * 1000)
    return best

pygame.init()
scr = pygame.display.set_mode((W, H))
FONT = 'C:/Windows/Fonts/msyh.ttc'

# ① 三个字号的渲染代价
sec('① 记分牌:文字渲染有多贵')
for size, txt in ((28, '分数 1200'),
                  (48, '游戏结束,最终得分 1200'),
                  (72, '超级无敌飞机大战 2026')):
    f = pygame.font.Font(FONT, size)
    img = f.render(txt, True, (255, 255, 255))
    tr = clock(lambda: f.render(txt, True, (255, 255, 255)))
    tb = clock(lambda: scr.blit(img, (10, 10)))
    info('%-3d号 图%-12s render %.4f 贴 %.4f 比 %.1f'
         % (size, str(img.get_size()), tr, tb, tr / tb))

# ② 暂停按钮
sec('② 暂停按钮:一个矩形就够')
btn = pygame.Rect(W - 90, 12, 78, 34)
pygame.draw.rect(scr, (60, 70, 90), btn, border_radius=6)
info('按钮画在 %s,尺寸 %dx%d' % (btn.topleft, btn.width, btn.height))
info('点 (420, 30) 命中:%s' % btn.collidepoint(420, 30))
info('点 (200, 300) 命中:%s' % btn.collidepoint(200, 300))

# ③ 半透明遮罩
sec('③ 暂停态:画面变暗再写字')
scr.fill((40, 60, 100))
before = tuple(scr.get_at((240, 350)))[:3]
over = pygame.Surface((W, H), pygame.SRCALPHA)
over.fill((0, 0, 0, 150))
scr.blit(over, (0, 0))
after = tuple(scr.get_at((240, 350)))[:3]
info('盖之前 %s,盖之后 %s' % (before, after))

# ④ 音效
sec('④ 音效:三个音,各管一件事')
rate = pygame.mixer.get_init()[0]
def tone(hz, sec):
    buf = array.array('h')
    for i in range(int(rate * sec)):
        v = int(9000 * math.sin(2 * math.pi * hz * i / rate))
        buf.append(v); buf.append(v)
    return pygame.mixer.Sound(buffer=buf.tobytes())

for tag, hz, sc in (('发子弹', 880, 0.06), ('打中敌机', 180, 0.25),
                    ('游戏结束', 120, 0.5)):
    info('%-6s %d 赫兹,%.2f 秒' % (tag, hz, tone(hz, sc).get_length()))

# ⑤ 异常处理
sec('⑤ 异常处理:崩了也要把窗口关掉')
try:
    info('进 try')
    raise RuntimeError('假装这里读素材失败了')
except RuntimeError as e:
    info('接住:%s' % e)
finally:
    info('finally 一定会跑')
    pygame.quit()
    info('pygame.quit() 已调用')

实跑输出:

--------------------------------------------------------------------
小部件、音效与异常处理
--------------------------------------------------------------------

  ① 记分牌:文字渲染有多贵
  --------------------------------------------------------------------

| 字号 | 做出来的图 | render 一次 | 贴一次 | render 除以贴 |
|---|---|---:|---:|---:|
| 28 号 | (128, 37) | 0.0052 毫秒 | 0.0055 毫秒 | 0.9 倍 |
| 48 号 | (558, 64) | 0.0198 毫秒 | 0.0286 毫秒 | 0.7 倍 |
| 72 号 | (765, 96) | 0.2978 毫秒 | 0.0409 毫秒 | 7.3 倍 |

  ! 小于 1 的意思是「渲染比贴还快」——这两个尺寸上,渲染的开销小到可以忽略。
  字号一大、字一多,渲染就压过贴图了——72 号那行是 5 倍多。
  ! 所以「文字要不要缓存」没有统一答案:看你的字号和字数。

  ② 暂停按钮:一个矩形就够
  --------------------------------------------------------------------
  按钮画在 (390, 12),尺寸 78×34
  点 (420, 30) 命中吗:True
  点 (200, 300) 命中吗:False
  ! 点击判定不需要什么控件库,一个 Rect 加 collidepoint 就完事。

  ③ 暂停态:画面变暗再写字
  --------------------------------------------------------------------
  做一张全屏半透明黑,alpha = 150
  盖之前 (240, 350) 是 (40, 60, 100),盖之后 (16, 24, 41)
  ! 变暗了但没变全黑——这就是半透明混合:原色按比例留下一点。
  ! 半透明遮罩加一行字,就是「暂停」界面。不需要额外窗口。

  ④ 音效:三个音,各管一件事
  --------------------------------------------------------------------

| 什么时候响 | 频率 | 时长 |
|---|---:|---:|
| 发子弹 | 880 赫兹 | 0.06 秒 |
| 打中敌机 | 180 赫兹 | 0.25 秒 |
| 游戏结束 | 120 赫兹 | 0.50 秒 |

  ! 音效越短越好。发射音 0.06 秒,一秒钟能叠十几个也不糊。

  ⑤ 异常处理:崩了也要把窗口关掉
  --------------------------------------------------------------------
  进 try
  接住:假装这里读素材失败了
  finally 一定会跑
  pygame.quit() 已调用
  ! 资源清理写在 finally 里。不然一出错,窗口卡死、进程还在后台。

第一段那三行要横着看:28 号和 48 号那两行,比值小于 1——渲染比贴还快,这时候做缓存纯属白忙。 72 号那行比值到了 5 倍多,缓存才开始有意义。 为什么分界在中间?因为渲染的代价跟图的面积走。 28 号的图 128×37(四千多像素),72 号 765×96(七万多像素),差十几倍。 面积涨十倍,时间就涨十倍左右,很线性。 第二段那个按钮说明了 Pygame 里界面的本质:画个矩形、写行字、判个点。没有控件、没有事件回调、 没有样式表。你要更漂亮的按钮,就画更好看的图,判定还是那一句。 第三段那两个颜色值能互相算出对方:alpha 150 意味着黑色占 150/255,原色剩 105/255 约等于 0.41。 40 × 0.41 ≈ 16,60 × 0.41 ≈ 24,100 × 0.41 ≈ 41。算得出来,说明你懂了。 第五段那四行输出是重点:异常抛出来、被接住、finally 照样跑、quit() 确实执行了。 如果没有 finally,第三第四行就永远不会出现,窗口会卡在屏幕上,只能去任务管理器里杀进程。

30 秒实验

  1. 把遮罩的 alpha 从 150 改成 80 和 220,各跑一次,看那两个数字怎么变,跟算式对得上吗。
  2. 把 finally 改成普通语句(不放在 finally 里),重跑,看那两行还出不出来。
  3. 把音效的频率从 880 改成 220,重跑,看时长变不变——再想想「为什么频率不影响时长」。

常见误区

  1. 「文字一定要缓存,每帧渲染太慢。」演示里 28 号渲染 0.005 毫秒,比贴还快。 小字号根本不用缓存,大字号才需要。 先测再优化。
  2. 「暂停要开新窗口或者切场景。」一张全屏半透明图加一行字就够了。 同一个窗口里就能做出「暂停」的样子。
  3. 「异常处理是给大项目用的,小游戏不用。 」小游戏才最需要——崩了窗口卡死在屏幕上、进程还在后台,这个体验比报错还糟。
自测三问(点开对答案) 1. 为什么 28 号字的渲染比贴图还快,72 号字就要慢五倍? —— 因为渲染的代价跟图的**面积**成正比。28 号字的图是 128×37,四千多像素; 72 号的是 765×96,七万多像素,面积差十几倍,时间也就差十几倍。 贴图的代价涨得慢一些,所以比值从不到 1 变成 5 倍多。 2. alpha = 150 的黑色遮罩盖在 (40, 60, 100) 上,结果为什么是 (16, 24, 41)? —— 因为半透明混合是加权平均:黑色占 150/255,原色占 105/255,后者约等于 0.41。 40 × 0.41 ≈ 16,60 × 0.41 ≈ 24,100 × 0.41 ≈ 41。 **每个通道单独算**,透明度对三个通道是一样的。 3. 为什么 `pygame.quit()` 一定要放在 `finally` 里? —— 因为 `while` 循环里抛异常时,如果 `quit()` 只是循环后面的一句普通代码,它就永远执行不到, SDL 的资源不释放,窗口会卡在屏幕上点不掉。 `finally` 不管正常结束还是异常退出都会执行,是最合适的清理位置。

第 4 章换框架:Cocos2d 把这一章你亲手写的那些活,全都接过去了。 先得说清楚一件事——它在这台机器上跑不起来,原因很具体。

相关笔记