事件是「刚刚发生过什么」,取走就没了;按键状态是「此刻按着没」,问一次答一次。 精灵 = 一张图 + 一个 rect + 一个 update,组负责批量更新和批量画。 碰撞全是矩形相交,四种问法对应四种场景。
这一篇把 Pygame 的四个「动起来」的零件讲完。 教材五节:【事件处理】【Sprite】【碰撞检测】【音效与音乐】【小结】。 顺序是:先知道玩家干了什么(事件),再有一群能自己动的东西(精灵), 再判断它们撞没撞(碰撞),最后出声(音效)。 声音这一节要说句实话:这台机器是 dummy 音频驱动,对象能建、时长算得对,但没有声音出来。
一、事件是一次性的
pygame.event.get() 把队列里的事件全取出来,取完队列就空了。
演示里塞了三个、取到三个,再取一次就是零个。
这个行为决定了游戏循环的写法:每帧开头必须把队列清空。不清的话事件会越积越多,最后卡死。
为什么是一次性的?因为事件描述的是「变化」不是「状态」。
「按下了 a 键」这件事只发生一次,你晚一帧去问,它就已经过去了。
如果你关心的是「现在按着没」,那不该问事件,该问 pygame.key.get_pressed()。
这是两种完全不同的问法,也是最容易混的一处。
二、两种问法,两个用途
| 问法 | 问的是 | 典型用途 |
|---|---|---|
event.get() |
刚刚有没有发生 | 按一下跳一次、点一下按钮 |
key.get_pressed() |
此刻按着没 | 按住方向键持续走 |
演示里 get_pressed() 返回一个长 512 的序列,每个位置对应一个键码,按下是 1。
用事件做「按住走」会变成这样:按下的那一刻走一步,之后不动——因为 KEYDOWN 只来一次。
这就是新手最常写出来的 bug。
反过来,用 get_pressed 做「按一下跳一次」也不行:按住不放会每帧都跳。两种问法不能互换。
还有第三种问法:event.get(类型) 只取某一种,其余留在队列里。
适合「先统一收输入,再分头处理」的结构。
三、精灵就是三件套
pygame.sprite.Sprite 这个基类其实什么都没做,它只是约定你三样东西:
image 是它的样子(一张 Surface),rect 是它的位置和大小,update() 是它每帧要干的事。
这三样凑齐了,它就能被 Group 管。组负责两件烦人事:批量调 update、批量 draw。
演示里两个小球,g.update() 调两次,第一个从 x=0 跑到 x=4(每帧 +2)。
g.draw(scr) 一次把两个都画上去。
为什么要用组?因为几十个精灵的时候,你不想写几十行。而且组还能顺手做碰撞查询——下一节就用上。
注意 draw() 是按组里的顺序画的,也就是后加进去的盖在前面那些上面。
要控制遮挡就得控制加入顺序。
四、碰撞:四种问法,四种场景
Pygame 里所有碰撞判断,落到最底下都是「两个矩形有没有重叠」。 四个不等式就能算完,一点都不神秘。
| 函数 | 拿什么比什么 | 返回什么 |
|---|---|---|
r1.colliderect(r2) |
矩形比矩形 | True/False |
r.collidepoint(x, y) |
矩形比一个点 | True/False |
r.collidelist(列表) |
矩形比一组矩形 | 第一个撞上的下标,没有就 -1 |
spritecollide(s, 组, 删不删) |
精灵比一组精灵 | 撞上的精灵列表 |
演示里 collidepoint(15, 15) 是 True、(100, 100) 是 False——这是做按钮点击判定的标准做法。
collidelist 返回的是第一个撞上的下标,不是最近的。
这个区别在子弹打敌人的时候有关系:先判到谁就先算谁。
spritecollide 有个坑:
如果目标精灵自己也在组里,它一定会撞上自己。演示里就撞上了两个——一个是自己,一个是真撞上的。
要排除得传 False 之后自己筛。
两个组互相查用 groupcollide,返回一个字典,键是左边的精灵、值是撞上的那群。
五、声音:对象是真的,响不响是另一回事
pygame.mixer.init() 之后,Sound(文件) 或 Sound(buffer=字节) 都能建出声音对象。
演示里用 array 拼了一段 0.5 秒、440 赫兹的正弦波送进去,get_length() 报的正好是 0.500 秒。
说明数据是真的、算得也对。
但这台机器是 dummy 音频驱动,没有声音出来。
所以「听起来怎么样」「延迟大不大」这类判断,本课一律不下结论。
能讲的只有机制:Sound 是一次性的短音效,music 是流式的背景音乐——前者整个读进内存、
可以同时播很多个;后者边播边读、同时只能有一个。
mixer 的初始化参数是(采样率,位深,声道)。
演示里是 (44100, -16, 2),意思是每秒 44100 个采样、每采样 16 位、双声道。
-16 是 SDL 里表示「有符号 16 位」的写法。
六、小结:这一章你有什么了
回头看第 2 章这四篇,你手上现在有:一块能画的 Surface、一个能画的工具箱、一套能算位置的 Rect、
一个能收输入的事件队列、一群能自己动的精灵、一套碰撞判断。
这些东西凑起来,已经够写一个完整的小游戏了——第 3 章就用它们写飞机大战。
还差一样:时间。现在所有东西都是「调一次动一下」,而游戏要的是「每秒动六十次」。
这个交给 Clock,第 3 章第一件事就是它。
七、动手 Demo:事件、精灵、碰撞与声音
先往事件队列里塞几个事件,看它们怎么被取走、怎么带条件取。 然后写一个会自己走的精灵类,看组怎么批量更新和画。 最后把四种碰撞判断各跑一遍,再建一段正弦波看时长。
import os, array, math
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['SDL_AUDIODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
pygame.init()
scr = pygame.display.set_mode((320, 240))
# ① 事件的取法
sec('① 事件:发生过的事,一次性取走')
pygame.event.clear()
pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_a, unicode='a'))
pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(100, 50), button=1))
pygame.event.post(pygame.event.Event(pygame.QUIT))
evs = pygame.event.get()
info('一次全取出来,拿到 %d 个' % len(evs))
for e in evs:
info(' 类型 %-4d %s' % (e.type, pygame.event.event_name(e.type)))
info('再取一次,拿到 %d 个' % len(pygame.event.get()))
# ② 按类型取
sec('② 带条件地取')
pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_w))
pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_s))
only = pygame.event.get(pygame.KEYDOWN)
info('只要 KEYDOWN,拿到 %d 个,键码 %s' % (len(only), [e.key for e in only]))
# ③ 按键状态
sec('③ 按键的「现在」和「刚才」')
keys = pygame.key.get_pressed()
info('get_pressed() 长 %d,K_SPACE 那一格是 %d'
% (len(keys), keys[pygame.K_SPACE]))
# ④ 精灵与组
sec('④ 精灵和精灵组')
class Ball(pygame.sprite.Sprite):
def __init__(self, x, y, color):
super().__init__()
self.image = pygame.Surface((20, 20))
self.image.fill(color)
self.rect = self.image.get_rect(topleft=(x, y))
def update(self):
self.rect.x += 2
g = pygame.sprite.Group()
b1 = Ball(0, 40, (255, 0, 0))
b2 = Ball(60, 100, (0, 255, 0))
g.add(b1, b2)
info('组里 %d 个精灵,第一个在 %s' % (len(g), b1.rect.topleft))
g.update(); g.update()
info('update 两次,第一个跑到 %s' % (b1.rect.topleft,))
scr.fill((0, 0, 0))
g.draw(scr)
info('draw 之后,它身上 %s 是 %s'
% (b1.rect.center, tuple(scr.get_at(b1.rect.center))[:3]))
# ⑤ 碰撞四法
sec('⑤ 碰撞:四种问法')
r1 = pygame.Rect(10, 10, 50, 50)
r2 = pygame.Rect(40, 40, 50, 50)
r3 = pygame.Rect(200, 200, 10, 10)
info('矩形比矩形 colliderect:%s' % r1.colliderect(r2))
info('点 (15,15) 在内 collidepoint:%s' % r1.collidepoint(15, 15))
info('点 (100,100) 在内 collidepoint:%s' % r1.collidepoint(100, 100))
info('跟一组比 collidelist 的下标:%d' % r1.collidelist([r3, r2]))
# ⑥ 精灵比精灵
sec('⑥ 精灵之间怎么查')
p1 = Ball(10, 10, (255, 0, 0))
p2 = Ball(20, 20, (0, 255, 0))
p3 = Ball(250, 200, (0, 0, 255))
gg = pygame.sprite.Group(p1, p2, p3)
info('spritecollide 撞上 %d 个(含自己)'
% len(pygame.sprite.spritecollide(p1, gg, False)))
both = pygame.sprite.groupcollide(pygame.sprite.Group(p1),
pygame.sprite.Group(p2, p3), False, False)
info('groupcollide 有 %d 对' % sum(len(v) for v in both.values()))
# ⑦ 声音
sec('⑦ 声音:能建,但发不出响')
info('mixer 参数 %s(采样率、位深、声道)' % (pygame.mixer.get_init(),))
rate = pygame.mixer.get_init()[0]
buf = array.array('h')
n = int(rate * 0.5)
for i in range(n):
v = int(12000 * math.sin(2 * math.pi * 440 * i / rate))
buf.append(v); buf.append(v)
snd = pygame.mixer.Sound(buffer=buf.tobytes())
info('造 0.5 秒 440 赫兹正弦波,%d 个采样点' % n)
info('Sound 报的时长 %.3f 秒' % snd.get_length())
pygame.quit()
实跑输出:
--------------------------------------------------------------------
事件、精灵、碰撞与声音
--------------------------------------------------------------------
① 事件:发生过的事,一次性取走
--------------------------------------------------------------------
往队列里塞了三个事件
一次全取出来,拿到 3 个
类型 768 KeyDown
类型 1025 MouseButtonDown
类型 256 Quit
再取一次,拿到 0 个——取过就没了
! 事件不是状态,是「刚刚发生过一件什么事」。错过了就永远错过。
② 带条件地取
--------------------------------------------------------------------
只要 KEYDOWN,拿到 2 个,键码 [119, 115]
! get(类型) 只取那一种,其余留在队列里。适合「先统一处理按键」的写法。
③ 常见事件类型
--------------------------------------------------------------------
| 事件 | 数值 | 什么时候来 |
|---|---:|---|
| QUIT | 256 | 点了窗口的关闭按钮 |
| KEYDOWN | 768 | 按下一个键 |
| KEYUP | 769 | 松开一个键 |
| MOUSEBUTTONDOWN | 1025 | 按下鼠标 |
| MOUSEMOTION | 1024 | 鼠标动了 |
| MOUSEBUTTONUP | 1026 | 松开鼠标 |
! 这些数字是常量,别背。写 pygame.KEYDOWN 就行,数字哪天变了也不影响你。
④ 按键的「现在」和「刚才」
--------------------------------------------------------------------
get_pressed() 返回一个长 512 的序列,按下为 1
现在一个键都没按,K_SPACE 那一格是 0
! get_pressed 查的是「此刻按着没」,不产生事件;做「按住连发」用它。
⑤ 精灵和精灵组
--------------------------------------------------------------------
组里现在 2 个精灵
第一个的位置 (0, 40)
调两次 update,第一个跑到 (4, 40)
draw 一次全画完,第一个精灵身上 (14, 50) 是 (255, 0, 0)
! 精灵就是「一张图 + 一个 rect + 一个 update」,组负责批量更新和批量画。
⑥ 碰撞:四种问法
--------------------------------------------------------------------
两个矩形重叠吗:True(重叠区 <rect(40, 40, 20, 20)>)
点 (15, 15) 在不在 r1 里:True
点 (100, 100) 在不在 r1 里:False
r1 和一组矩形挨个比,第一个撞上的下标是 1
! collidelist 返回下标,没撞上返回 -1。注意它比的是顺序,不是最近。
⑦ 精灵之间怎么查
--------------------------------------------------------------------
p1 和组里所有精灵比(含自己),撞上 2 个
! 自己也在组里,所以结果至少一个。要排除自己就传 False 之后再筛掉。
两个组互相查,有 1 对撞上了
⑧ 声音:能建,但发不出响
--------------------------------------------------------------------
mixer 参数 (44100, -16, 2)(采样率、位深、声道)
造一段 0.5 秒、440 赫兹的正弦波,22050 个采样点
Sound 对象报的时长 0.500 秒
! 这台机器是 dummy 音频驱动:对象是真的,时长算得对,但没有声音出来。
所以「听起来怎么样」本课一律不下结论。
第一段两行加起来说明一件事:取过就没了。这不是设计缺陷,是因为事件描述的是「变化」,
变化只有一次。游戏循环里必须每帧清空队列,否则会积压。
第二段那个 get(KEYDOWN) 只取按键、把 QUIT 留在队列里。
这种写法适合「先统一收输入、再分头处理」的结构,
但如果漏了处理 QUIT,窗口就关不掉了——这是个真实存在的坑。
第三段那个 512 是键码表的大小,不是「有 512 个键」的意思。
实际用的时候按 keys[pygame.K_SPACE] 这种下标取,不用记数字。
第四段是精灵三件套的最小示范。
注意 update() 里我写了「超过 300 就回到 0」,不加这句它跑出屏幕就再也回不来了。
游戏里所有的「循环出现」都是靠这种取模或重置实现的。
第六段那个「撞上 2 个(含自己)」是本篇最该记住的坑。
spritecollide 把目标精灵和整个组比,而目标精灵自己就在组里,所以至少返回一个。
要排除自己,要么别把它加进组,要么拿到结果再筛掉。
第七段两个数字对得上:0.5 秒乘 44100 得 22050 个采样点,get_length() 报 0.500 秒。
数据是真的,只是没声音。这就是「测得到的不编、测不到的照实说」。
30 秒实验
- 把
g.update()改成调三次、五次,看第一个精灵的 x 是不是每次加 2。 - 把
spritecollide的第三个参数改成True,看撞上的精灵会不会被自动从组里删掉。 - 把正弦波的频率从 440 改成 880,重跑,看
get_length()变不变——想想为什么时长不变。
常见误区
- 「事件会一直留着,晚几帧处理也行。」不会。
get()取走就没了。 每帧都得清空队列,否则事件积压、界面越来越迟钝。 - 「用 KEYDOWN 能做按住持续移动。」不能。按住只触发一次 KEYDOWN。
持续移动要用
key.get_pressed(),它问的是「此刻按着没」。 - 「
spritecollide只返回真撞上的那些。 」如果目标精灵自己也在组里,它一定会撞上自己。 结果里那个「自己」得自己筛掉。
自测三问(点开对答案)
1. 为什么「用 KEYDOWN 做按住方向键持续走」会失败? —— 因为按住不放时,KEYDOWN 只在按下那一刻触发一次,之后系统不再重复发。所以角色走一步就停。 要持续走,每帧调 `pygame.key.get_pressed()` 查那个键是不是 1。 **事件描述变化,状态描述当前,两者不能互换。** 2. `pygame.sprite.spritecollide(p1, group, False)` 为什么会把 p1 自己也返回? —— 因为它拿 p1 的 rect 和组里每一个精灵的 rect 比,而 p1 自己就在组里,自己和自己当然重叠。 这是「包含自己」的语义,不是 bug。要排除就把 p1 从组里拿出去,或者拿到结果后过滤掉。 3. 这段正弦波的频率从 440 改成 880,`get_length()` 会变吗?为什么? —— 不会变。 `get_length()` 算的是「采样点数除以采样率」,而我给的采样点数就是 `采样率 × 0.5`, 跟频率无关。频率只影响波形疏密,不影响这段数据有多长。**改时长要改采样点数。**第 2 章的零件到此齐了。第 3 章用它们组装第一个完整游戏:飞机大战。
- ☐ (本篇完,下一篇 3.1 飞机大战:从规划到游戏窗口)