刚玩 Pygame 的朋友,十个里有九个会遇到同一个魔幻场景:窗口画出来了,屏幕上的方块也在动,但是你想按方向键控制它,画面一动不动;把鼠标移进去点两下,也没有任何反应。更尴尬的是,点了右上角关闭,程序直接转圈,跟“假死”了一样。这时候不用怀疑自己的绘图逻辑,问题基本都出在事件处理上。
这篇文章就冲着键盘事件和鼠标事件来的。我会从 Pygame 的事件系统讲起,然后分别拆开键盘和鼠标的 API,最后用一个小巧的 2D 射击游戏把两者串起来:方向键控制左右移动,空格或鼠标左键发射子弹,敌机随机坠落,撞到就结束,按 R 重开。全套代码一百来行,适合刚接触 pygame 的同学照着敲,也适合做过网页、想写本地小游戏的人快速切换思路。
1. 事件系统的内核:Pygame 消息队列与事件循环的工作方式
1.1 事件从哪里来,又到哪里去
先问一个问题:当你按下键盘上的 A 键,Pygame 程序是怎么知道的?
底层链路大概是这样的:操作系统收到鼠标和键盘的硬件输入,把它包装成系统级消息;SDL 库(Pygame 的底层依赖)监听并翻译这些消息;SDL 再把它们转换成 Pygame 层的 event 对象,放进一个先进先出的事件队列里。你的 Python 代码要做的事,就是主动从这个队列里把事件取出来。
这个“取”的动作,通常就是 pygame.event.get()。
python复制import pygame
pygame.init()
screen = pygame.display.set_mode((800, 600))
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
这一段几乎是所有 Pygame 程序的标配,但很多人只是机械地复制,没意识到几件重要的事:
pygame.event.get()会一次性取出当前队列里所有事件,并清空队列。也就是说,如果你在某次循环里连续调用两次get(),第二次拿到的永远是空列表,这不是 API 出问题了,而是队列已经被消费掉了。- 事件对象本身是个类似字典的结构,必带一个
type字段表示事件类型,其他字段则取决于类型。很多新手尝试打印事件对象时被一长串输出吓到,其实它只是把附加信息一股脑列出来了,不用慌。
Pygame 文档里还提供了一个更底层的 pygame.event.poll(),只取出一个事件并把它从队列中移除。对游戏来说,get() 更符合逐帧处理一批输入的需求。
1.2 轮询不是缺点,而是游戏循环的要求
写过网页的同学,可能更熟悉浏览器里那种“回调式”的事件处理:给按钮绑定一个 click 处理函数,事件发生时会自动调用你。为什么 Pygame 不这样做?
因为游戏是一个连续循环。每一帧都要经历“处理输入 → 更新逻辑 → 重新绘制”三个动作,输入只是其中一个环节。如果采用回调模型,回调函数会打断正在执行的绘制逻辑,或者反过来被绘制逻辑打断,代码会变得极其难以控制。尤其是实时交互的手感,很难保证回调时机和当前帧渲染状态一致。轮询模型虽然看起来“原始”,但它让所有事件处理都处在一个确定的帧上下文里,什么时候处理、处理多少,完全由开发者说了算。
这个概念也决定了后面很多写法的选择。比如你会看到一个约定俗成的习惯:事件处理一定放在主循环的开头,而不是中间或结尾。事件在队列里的生命周期很短,如果先把更新逻辑跑完再回头处理事件,用户按下的瞬间到程序响应之间会多出接近一整帧的延迟。在 60 FPS 下感觉不明显,但到高刷新率窗口或操作密集时,手感就会发闷。
1.3 QUIT 处理其实也属于事件
很多人没有把“关闭窗口”当成事件来理解,以为它是窗口系统的“内置功能”。其实窗口右上角的叉号被点击时,系统并不会帮你终止 Python 进程,它只是往事件队列里塞了一个 pygame.QUIT 事件。如果你不处理,程序会继续跑,窗口却已经关了,只剩一个黑屏或卡死的进程。
正确写法里通常会有:
python复制if event.type == pygame.QUIT:
running = False
循环结束后,再调用 pygame.quit() 和 sys.exit() 释放资源。这是一个很容易被忽略的“事件思维”:GUI 里的几乎所有交互,包括鼠标移动、键盘按下、窗口大小变化、游戏手柄输入,全部会被翻译成事件。理解这条链路,之后再学任何输入 API,思路都会顺畅得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 键盘输入的两条路径:脉冲事件与持续状态的取舍
2.1 KEYDOWN/KEYUP:瞬时脉冲信号
键盘相关的核心事件有两个:pygame.KEYDOWN 和 pygame.KEYUP。它们描述的是“某个键被按下去的那一刻”和“某个键被松开的那一刻”。
python复制for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
print("按下", event.key, "unicode:", event.unicode)
elif event.type == pygame.KEYUP:
print("松开", event.key)
event.key 是按键的虚拟键码,比如 pygame.K_SPACE、pygame.K_LEFT、pygame.K_a。event.unicode 只在 KEYDOWN 事件里才有意义,内容是当前键盘布局下映射出来的字符,比如按 A 键,unicode 就是 'a'。同时还有一个 event.mod 字段,表示是否按住了 Ctrl、Shift、Alt 等修饰键,例如检测 event.mod & pygame.KMOD_CTRL 可以判断 Ctrl 是否被按住。
需要注意的是:KEYDOWN 本质上是一个脉冲式信号,它代表“一次按下”。按住方向键不松,会不会重复触发 KEYDOWN?答案取决于操作系统和底层 SDL 的按键重复设置,不同平台表现不完全一致。工程上最稳妥的理解是:不要依赖这种重复触发去做持续移动,否则你的移动逻辑会一顿一顿的。
2.2 get_pressed():按住的状态表
如果要实现“按住左键角色持续向左移动”,正确做法是每帧检查按键当前是否处于按下状态。Pygame 提供了 pygame.key.get_pressed(),它返回一个类似布尔列表的序列,索引就是按键码:
python复制keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
player.rect.x -= 5
if keys[pygame.K_RIGHT]:
player.rect.x += 5
这段代码在每一帧都执行,只要左键被按住,就会一帧帧地修正位置。状态检测的好处是稳定、连贯,移动手感完全由帧率决定。
这里就出现了一个很重要的取舍原则:
- 瞬间动作,比如“按一下空格发射一颗子弹”“按一下 Esc 呼出菜单”,适合用
KEYDOWN事件。 - 持续动作,比如“按住方向键移动”“按住 Shift 加速”,适合用
get_pressed()状态。
如果你反过来用,就会得到两种经典 bug:用 KEYDOWN 做移动,角色移动一卡一顿;用 get_pressed() 做发射,子弹变成“按住就连射”,玩家根本停不下来。
2.3 set_repeat 与真正的文本输入
有些场景需要“按住一个键,不断生成文字”,比如在游戏里输入玩家昵称。这时系统自带的按键重复往往不够用,因为它的延迟和重复速率不受你控制。Pygame 提供了 pygame.key.set_repeat(delay, interval) 来设置事件重复:
python复制pygame.key.set_repeat(500, 30)
设置之后,按住某个键 500ms 后开始重复触发 KEYDOWN,之后每 30ms 触发一次。这样你就能用事件流连续接收字符,配合 event.unicode 拼出一个输入字符串。不过注意,这套机制适合英文和数字等直接映射的字符,中文字符输入依赖输入法,Pygame 本身的 IME 支持有限,所以做中文输入框会相对麻烦。
3. 鼠标读取的完整姿势:坐标、按钮状态与滚轮方向判断
3.1 MOUSEMOTION 里的 pos 和 rel
鼠标比键盘多了一个“位置维度的连续信息”。Pygame 里最常用的鼠标事件是 pygame.MOUSEMOTION,它有两个重要字段:
event.pos:鼠标在窗口二维坐标系里的绝对坐标,左上角是 (0, 0)。event.rel:鼠标从上一帧到这一帧的相对位移,常用于实现第一人称视角的镜头转动、拖拽缩放这种需要“差值”的场景。
举个例子,做一个瞄准线跟随鼠标的效果:
python复制for event in pygame.event.get():
if event.type == pygame.MOUSEMOTION:
aim_x, aim_y = event.pos
如果你的代码风格偏实时检测,也可以直接用 pygame.mouse.get_pos() 和 pygame.mouse.get_pressed(),它们不依赖事件队列,每帧轮询一次获取当前鼠标位置和三个鼠标按钮的按住状态。和键盘事件类似:点击、松开这类瞬间动作建议走事件分支,而持续吸取鼠标坐标则两者皆可,我个人更推荐在大多数游戏里直接用 get_pos() 读取位置,因为坐标本质上就是状态,不是事件。
3.2 MOUSEBUTTONDOWN/UP 与滚轮数值
鼠标按钮事件包括 pygame.MOUSEBUTTONDOWN 和 pygame.MOUSEBUTTONUP,事件里的 event.button 字段表示哪个按钮:
- 1:鼠标左键
- 2:鼠标中键
- 3:鼠标右键
- 4:滚轮向上
- 5:滚轮向下
注意滚轮方向也通过按钮编号传递,它不是一个“角度值”或“偏移量”,只是一个正向还是反向的信号。很多第一次写滚轮切换武器的新手会写成“滚轮滑动多少就切几个武器”,其实方向一次只增加 1 或减少 1,你要自己累加累计值:
python复制if event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 4:
weapon_index = (weapon_index + 1) % len(weapons)
elif event.button == 5:
weapon_index = (weapon_index - 1) % len(weapons)
另外一个常见需求是鼠标拖拽。拖拽的本质是:记录按下按钮时的起点,然后在鼠标移动过程中不断用当前坐标与起点坐标做差值。如果没有自己记录起点,只靠 MOUSEBUTTONUP 事件,你是拿不到拖拽过程的。所以必须结合事件和变量:
python复制dragging = False
start_pos = (0, 0)
for event in pygame.event.get():
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
dragging = True
start_pos = event.pos
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
dragging = False
elif event.type == pygame.MOUSEMOTION and dragging:
dx = event.pos[0] - start_pos[0]
dy = event.pos[1] - start_pos[1]
# 此时 dx, dy 就是拖拽的偏移量
这套写法是鼠标交互的基础模板,扩展成框选、拖动画布、拖动游戏里的物体都靠它。
3.3 用 collidepoint 完成点击判定
很多人刚接触 Pygame 时都有个疑问:我画了一个按钮矩形,怎么知道鼠标“点中了它”?答案是 Pygame 不会自动告诉你,你需要自己判断。好在矩形类自带了 collidepoint 方法:
python复制restart_button = pygame.Rect(350, 300, 100, 40)
for event in pygame.event.get():
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if restart_button.collidepoint(event.pos):
start_new_game()
这就是“事件 + 几何命中检测”的基本思路。浏览器里那个 hover 效果之所以能自动生效,是因为浏览器内部自己做了层级几何计算;而在 Pygame 里,这部分逻辑完全交还给你了。
4. 射入主循环:2D 射击游戏的模块设计与关键代码
4.1 设计约束:对象职责单一
现在把键盘和鼠标的知识合起来,写一个小射击游戏。这个 Demo 不追求花哨,但 I/O 设计是完整的。
整个游戏大约会用到三个类:Player 负责玩家的位置和移动,Bullet 负责子弹的朝向和越界清除,Enemy 负责敌机的生成和下落。每个类只干一件事,这是游戏代码能继续扩展的基础。
python复制import pygame
import random
import sys
pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("2D 射击小游戏")
clock = pygame.time.Clock()
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
GREEN = (80, 220, 120)
RED = (220, 80, 80)
BLUE = (80, 160, 220)
class Player:
def __init__(self):
self.rect = pygame.Rect(WIDTH // 2 - 25, HEIGHT - 80, 50, 40)
self.speed = 6
def move(self, keys):
if keys[pygame.K_LEFT] and self.rect.left > 0:
self.rect.x -= self.speed
if keys[pygame.K_RIGHT] and self.rect.right < WIDTH:
self.rect.x += self.speed
def draw(self):
pygame.draw.rect(screen, GREEN, self.rect)
class Bullet:
def __init__(self, x, y):
self.rect = pygame.Rect(x - 2, y, 4, 12)
self.speed = -8
def update(self):
self.rect.y += self.speed
def draw(self):
pygame.draw.rect(screen, BLUE, self.rect)
class Enemy:
def __init__(self):
self.rect = pygame.Rect(random.randint(0, WIDTH - 40), -40, 40, 30)
self.speed = random.randint(2, 4)
def update(self):
self.rect.y += self.speed
def draw(self):
pygame.draw.rect(screen, RED, self.rect)
def shoot(player, bullets):
bullets.append(Bullet(player.rect.centerx, player.rect.top))
4.2 碰撞检测与列表删除陷阱
碰撞检测直接使用 pygame.Rect.colliderect。子弹和敌人碰撞时,我应该把两个对象都删掉。但这里有个新手必踩的坑:如果一边遍历 bullets 一边 remove 当前元素,会让列表迭代错乱,轻则跳对象,重则直接 IndexError。
更稳妥的先标记后清理:
python复制def handle_collisions(bullets, enemies, player):
for bullet in bullets[:]:
for enemy in enemies[:]:
if bullet.rect.colliderect(enemy.rect):
bullets.remove(bullet)
enemies.remove(enemy)
break
for enemy in enemies[:]:
if enemy.rect.colliderect(player.rect):
return True # 游戏结束
return False
bullets[:] 是对列表的浅拷贝,遍历时不修改原列表,删除操作放到拷贝结果的判定逻辑里,安全得多。如果你嫌两层循环慢,后续可以引入四叉树或者网格,但对于这个体量,完全够用。
4.3 主循环完整代码:事件、更新、绘制
状态机贯穿整个游戏。我用三个常量表示游戏状态,这里先呈现基础版,完整的暂停和重开逻辑放在下一章:
python复制WAIT, PLAY, OVER = 0, 1, 2
state = WAIT
score = 0
bullets = []
enemies = []
player = Player()
enter_timer = 0
running = True
def reset_game():
global bullets, enemies, score, enter_timer
bullets.clear()
enemies.clear()
score = 0
enter_timer = 0
player.rect.x = WIDTH // 2 - 25
while running:
events = pygame.event.get()
for event in events:
if event.type == pygame.QUIT:
running = False
if state == WAIT:
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
state = PLAY
reset_game()
elif state == PLAY:
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
state = WAIT
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
shoot(player, bullets)
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
shoot(player, bullets)
elif state == OVER:
if event.type == pygame.KEYDOWN and event.key == pygame.K_r:
state = PLAY
reset_game()
if state == PLAY:
keys = pygame.key.get_pressed()
player.move(keys)
if random.randint(1, 50) == 1:
enemies.append(Enemy())
for bullet in bullets[:]:
bullet.update()
if bullet.rect.bottom < 0:
bullets.remove(bullet)
for enemy in enemies[:]:
enemy.update()
if enemy.rect.top > HEIGHT:
enemies.remove(enemy)
if handle_collisions(bullets, enemies, player):
state = OVER
screen.fill(BLACK)
if state == WAIT:
# 简易的开始提示
font = pygame.font.Font(None, 48)
text = font.render("Press SPACE to Start", True, WHITE)
screen.blit(text, (WIDTH // 2 - text.get_width() // 2, HEIGHT // 2 - 20))
elif state == PLAY:
player.draw()
for bullet in bullets:
bullet.draw()
for enemy in enemies:
enemy.draw()
elif state == OVER:
font = pygame.font.Font(None, 48)
text = font.render("Press R to Restart", True, RED)
screen.blit(text, (WIDTH // 2 - text.get_width() // 2, HEIGHT // 2 - 20))
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()
这套代码本身就是完整可运行的。方向键控制左右移动,空格键和鼠标左键都能发射,敌机随机生成,撞到玩家游戏结束,按 R 重开。如果现在你跑起来发现某个键“没反应”,可以回去按第一章的排查思路检查事件循环是否被阻塞、事件类型判断是否写错。
5. 事件驱动游戏状态:开始、结束、暂停和重开的切换组织方式
5.1 别用内部循环等输入
我在初学阶段犯过一个很典型的错误:游戏结束后,我为了等玩家按 R,在 OVER 分支里写了一个嵌套的 while 循环,试图在那里不断 pygame.event.wait() 等重开信号。结果窗口立刻卡死,CPU 占用还特别高。
原因在于,pygame.event.wait() 把主循环堵住了。主循环一旦不能继续执行 pygame.display.flip(),窗口就失去了重绘能力,操作系统会误认为程序失去响应。正确的做法是让事件循环始终保持转动,所有状态的切换都通过一个共享的状态变量来完成。
5.2 状态机加事件的正确姿势
把游戏流程抽象成状态机是万能的:WAIT(等待开始)、PLAY(游戏进行中)、PAUSE(暂停)、OVER(结束)。状态机的核心是一个全局整数或枚举,主循环每帧都检查当前状态,决定哪些事件可以被接受、哪些更新逻辑要运行、哪些内容要绘制。
如果要在第 4 章的代码里加暂停,流程会非常清晰:在 PLAY 状态按 P,把 state 改成 PAUSE;在 PAUSE 状态按 P 或 Esc,把 state 改回 PLAY。除了状态变量本身,不需要额外打断任何循环。
python复制if state == PLAY:
if event.type == pygame.KEYDOWN and event.key == pygame.K_p:
state = PAUSE
elif state == PAUSE:
if event.type == pygame.KEYDOWN and event.key == pygame.K_p:
state = PLAY
这样写的好处是,每个状态下的输入响应是互相隔离的。玩家在暂停菜单里按空格不会误发射子弹,因为 PLAY 状态的事件判断根本不会执行。这种“分支隔离”比用一堆布尔标志位去硬控要清晰得多。
5.3 重置游戏时的“清场”细节
重开按钮写好后,一个隐蔽的 bug 是:状态切到了 PLAY,但上一局的子弹、敌人、分数都还在。于是玩家一重开,屏幕上还飘着旧子弹,或者立刻被残留敌人撞死。
reset_game() 要扛起清场任务:
- 清空
bullets和enemies列表。 - 重置
score、enter_timer等一切累计变量。 - 把
player.rect放回初始位置。 - 重置敌人刷新计时器,避免刚重开就刷一堆敌机。
这听起来简单,但真正写大型游戏时,列表不止两个、变量不止三个,漏掉一个就会留下“想不通”的 bug。我个人的习惯是把所有可重置状态集中在一个函数里,这样重开只调用一个函数,而不是在事件分支里手动复位三五个变量。
6. 从安装到流畅运行:wheel 构建失败与输入相关的坑
6.1 failed to build 'pygame' 到底在说什么
很多人在新电脑或新 Python 环境里执行 pip install pygame,会遇到这样一长串报错:
code复制error: failed to build 'pygame' when getting requirements to build wheel
这个报错的本质是:pip 没有找到与当前 Python 版本匹配的预编译 wheel 包,于是尝试从源码包构建 pygame。构建 C 扩展需要本机编译工具链,以及 SDL 相关开发库。Windows 上如果没装 Visual Studio Build Tools,或者 Python 是刚发布不久的新版本(比如某个新的小版本号),很可能直接编译失败。
解决思路按优先级来:
- 换个 Python 版本:社区生态适配往往落后于 Python 官方发版,退回一年以上的稳定版本,通常立刻就有 wheel。
- 强制只使用二进制包:
bash复制pip install --only-binary :all: pygame
如果 PyPI 上有对应版本的 wheel,这个命令可以绕开源码编译。
- 安装 pygame-ce:社区维护版
pygame-ce对新的 Python 版本适配更积极,API 基本兼容,直接在项目里换依赖名即可。
bash复制pip install pygame-ce
- 源码编译:Linux 下需要安装
libsdl2-dev、libsdl2-image-dev、libsdl2-mixer-dev、libsdl2-ttf-dev;Windows 下需要 Visual Studio Build Tools,并且保证 Python 开发头文件齐全。
这套报错直接卡住了不少刚入门的人,其实它和你的代码能力无关,纯粹是环境适配问题。
6.2 事件循环卡死、画面不动的几种常见原因
游戏代码写好了,但运行一会儿窗口开始转圈,按键没反应,大致逃不过这几个原因:
- 事件循环里做了耗时操作:比如在
for event in pygame.event.get():里做了文件读取、网络请求,或者一个很耗时的路径查找。事件循环阻塞期间,窗口无法重绘,系统会认为程序未响应。耗时操作要么提前算好,要么放到线程或异步任务里。 - 对象列表无限增长:子弹和敌机一直
append,却没有清理越界对象。随着对象数量指数上升,每帧碰撞检测越来越慢,最终卡死。这就是为什么代码里必须写if bullet.rect.bottom < 0: bullets.remove(bullet)。 - 忘了
clock.tick():没有限制帧率时,循环会疯狂空转,CPU 占用 100%,屏幕反而因为刷新太快出现闪烁或卡顿。加上clock.tick(60)后问题立刻消失。
6.3 Pygame 与浏览器“鼠标移入”的机制差异
有 Web 开发经验的朋友接触鼠标事件时,会觉得 Pygame 怎么什么都要自己写。在浏览器里,hover、mouseenter 这类效果是浏览器根据 DOM 元素布局自动计算的;元素被移入、移出,浏览器帮你维护状态。但在 Pygame 里,画面只是一堆矩形和颜色,引擎并不知道“哪个矩形是按钮”。鼠标移入一个按钮区域、移出这个区域,这些关系必须由你自己用坐标判断。
所以我一直觉得,在 Pygame 里学事件处理,反而能把“输入”这件事看得更清楚。没有了框架的隐形魔法,你才会真正理解坐标、状态、命中检测这些基础概念。
7. 调手感不能跳过的细节:射速、事件消费与帧率
7.1 用 get_ticks 做射速冷静
如果没有射速限制,按住鼠标左键或空格,子弹会在一帧内疯狂生成,屏幕瞬间被子弹淹没。一个成熟的做法是记录上次射击的时间点,用 pygame.time.get_ticks() 判断是否超过冷却时间:
python复制last_shot_time = 0
SHOT_COOLDOWN = 180 # 毫秒
def try_shoot(player, bullets):
global last_shot_time
now = pygame.time.get_ticks()
if now - last_shot_time >= SHOT_COOLDOWN:
shoot(player, bullets)
last_shot_time = now
这个方法比“每帧把冷却计数减一”更精确,因为 get_ticks() 返回的是绝对毫秒时间,不受帧率波动影响。即使某帧因为卡顿耗时了 200ms,冷却判断依然准确。
7.2 事件队列请只 get() 一次
前面提过,事件队列被取出后会被清空。因此每个主循环周期里,pygame.event.get() 应该且只能调用一次。如果你在处理某个事件的代码里又嵌套调用了一次 get(),外层的事件列表可能会出现遗漏,造成按键偶尔没响应。
最稳妥的写法,是把事件先存到局部变量:
python复制events = pygame.event.get()
for event in events:
# 业务逻辑
这样即便你在事件处理里需要读取“队列里是否还有下一个事件”,也只会操作这份快照,不会再清空底层队列。
7.3 固定帧率与移动速度的一致性
clock.tick(60) 不只是为了限制 CPU 占用,更是为了让移动距离可控。如果帧率忽高忽低,直接用 player.rect.x += 5 会表现得忽快忽慢。固定 60 FPS 后,一秒钟移动 60 次、每次 5 像素,总距离大致稳定。如果机器性能不足掉到 30 FPS,角色速度会肉眼可见地变慢,这时候可以引入 delta time 概念:根据两帧间隔时间修正移动距离,比如每帧移动 speed * dt。Pygame 里可以通过 clock.tick() 的返回值获取上一帧耗时毫秒数,这是后续优化性能时再进阶的话题。
最后再说一个我在双人游戏里踩过的经验:如果你未来想做双人游戏,按键方案最好从一开始就别用事件型 KEYDOWN 去做移动,而是统一用 pygame.key.get_pressed() 检查两套键位状态。因为事件流要兼顾发射、暂停、重开、菜单导航这些“瞬间动作”,如果移动也挤进事件流里,每个事件分支都会互相干扰,调试起来会非常痛苦。把“持续型输入”和“脉冲型输入”分到两条路径里,是让代码干净的关键。
