文字渲染的代价跟图的面积走:小字号可以每帧重做,大字号必须缓存。
暂停界面就是一张半透明遮罩加一行字,不需要额外窗口。
资源清理写在 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 秒实验
- 把遮罩的 alpha 从 150 改成 80 和 220,各跑一次,看那两个数字怎么变,跟算式对得上吗。
- 把
finally改成普通语句(不放在finally里),重跑,看那两行还出不出来。 - 把音效的频率从 880 改成 220,重跑,看时长变不变——再想想「为什么频率不影响时长」。
常见误区
- 「文字一定要缓存,每帧渲染太慢。」演示里 28 号渲染 0.005 毫秒,比贴还快。 小字号根本不用缓存,大字号才需要。 先测再优化。
- 「暂停要开新窗口或者切场景。」一张全屏半透明图加一行字就够了。 同一个窗口里就能做出「暂停」的样子。
- 「异常处理是给大项目用的,小游戏不用。 」小游戏才最需要——崩了窗口卡死在屏幕上、进程还在后台,这个体验比报错还糟。
自测三问(点开对答案)
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 把这一章你亲手写的那些活,全都接过去了。 先得说清楚一件事——它在这台机器上跑不起来,原因很具体。
- ☐ (本篇完,下一篇 4.1 Cocos2d 入门与 Hello World)