🎮 第2章 Pygame
📄 一句话概括

事件是「刚刚发生过什么」,取走就没了;按键状态是「此刻按着没」,问一次答一次。 精灵 = 一张图 + 一个 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 秒实验

  1. 把 g.update() 改成调三次、五次,看第一个精灵的 x 是不是每次加 2。
  2. 把 spritecollide 的第三个参数改成 True,看撞上的精灵会不会被自动从组里删掉。
  3. 把正弦波的频率从 440 改成 880,重跑,看 get_length() 变不变——想想为什么时长不变。

常见误区

  1. 「事件会一直留着,晚几帧处理也行。」不会。get() 取走就没了。 每帧都得清空队列,否则事件积压、界面越来越迟钝。
  2. 「用 KEYDOWN 能做按住持续移动。」不能。按住只触发一次 KEYDOWN。 持续移动要用 key.get_pressed(),它问的是「此刻按着没」。
  3. 「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 章用它们组装第一个完整游戏:飞机大战。

相关笔记