ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

pygame外星人入侵源码解析:事件循环、精灵组与打包避坑

pygame外星人入侵源码解析:事件循环、精灵组与打包避坑

简介:这份 Python「外星人入侵」游戏项目源码包基于经典 Pygame 射击游戏扩展而来,面向已掌握 Python 基础语法、希望完整练手面向对象编程与游戏开发的初、中级学习者。在原版教程基础上,代码补充了飞船上下移动、射击与爆炸音效、Score/High Score/Level 三类计分标签、按键暂停、最高分持久化存储、三次未命中即结束并重现 Play 按钮,以及固定关卡插入高速高级外星人并显示 Boss 血条等功能,可对照理解状态管理、事件循环与文件读写。压缩包共 85 个文件,以 44 张 png 贴图、9 个 py 源码、5 个 wav 与 1 个 mp3 音效、17 个 pyc 字节码缓存及少量 xml 配置为主,整体约 2.96MB;目录将图片素材、音效文件、最高分存档与各功能模块清晰分离,便于按模块阅读。已有 2317 人学习下载,适合作为课程设计、期末大作业或 Pygame 入门后的实战参考。

1. 解压之后先别双击:这个 python 外星人入侵包到底是什么

很多人硬盘里都躺着一个这样的压缩包——python 外星人入侵(附件代码).rar,解压出来一堆 py 文件加一个 images 目录,双击 alien_invasion.py,控制台甩一句ModuleNotFoundError: No module named 'pygame',然后就没下文了。这份东西本身不复杂:一艘飞船、一队外星人、空格发射子弹、打光一队升一级、三条命打完进结算界面,用 pygame 写的纯面向对象小游戏,没有引擎、没有框架、没有第三方依赖。但它恰好把「事件循环、精灵组、碰撞检测、游戏状态机」这四件事串成了一条能跑的线,比看十篇概念教程都直接。适合刚学完类和继承、想找个完整项目练手的人,也适合手上有个 2D 小游戏想法、想要一份干净骨架往上改的人。

2. 解压与分层:把散落的 py 文件还原成六个职责模块

拿到一个不熟悉的源码包,我一般不会先去读代码,而是先看目录,把「谁启动、谁被谁 import」这件事搞清楚。这份包的结构是典型的入门级教学工程:一个入口文件 + 一个配置文件 + 每个游戏对象一个类文件 + 一个装满函数的工具文件。理解了这个分层,后面改任何东西都知道该往哪个文件里下手。

2.1 先认路:谁是入口,谁被谁 import

在解压目录下先跑一遍目录树,把__pycache__这类噪音过滤掉,能立刻看出工程的形状:

# -L 2 只展开两层,-I 忽略缓存目录,避免输出被刷屏 tree -L 2 -I '__pycache__'

常见的输出形态大致是这样,文件名的叫法各家略有出入,但职责划分基本一致:

. ├── alien_invasion.py # 入口:初始化、主循环 ├── settings.py # 全部可调参数集中在这 ├── game_functions.py # 事件处理、更新、绘制,一堆纯函数 ├── ship.py # 飞船类 ├── bullet.py # 子弹类 ├── alien.py # 外星人类 ├── game_stats.py # 分数、剩余命数、游戏状态 ├── scoreboard.py # 计分板绘制 ├── button.py # 开始按钮 └── images/ ├── ship.bmp ├── alien.bmp └── bullet.bmp

看清了这层关系,模块职责就一目了然了。alien_invasion.py是唯一有if __name__ == '__main__'的文件,它只做三件事:初始化 pygame 和屏幕、实例化所有游戏对象、进 while 主循环。game_functions.py是关键的一层,它把「按键该干什么」「位置该怎么更新」「画面该怎么画」三件事拆成独立函数,主循环里只调用不实现。其余文件都是「一个类管一个对象」,互相之间不 import,只通过主循环传参交互。

这种「入口薄、函数层居中、类层最厚」的分法在真实项目里会被批得挺惨(全局状态传递太多),但作为学习骨架它是合格的:想加一种新敌人,只需要新写一个类,然后在game_functions.py里加一个生成函数和一个更新调用,不需要动其他任何文件。这就是分层的价值。

文件职责依赖方向
alien_invasion.py初始化、主循环依赖全部
settings.py参数常量不依赖任何模块
game_functions.py事件、更新、绘制依赖 sprite 和 settings
ship / bullet / alien单个对象的状态与行为只依赖 pygame
game_stats / scoreboard / button游戏状态与 UI依赖 settings

2.2 三条命令跑起来:虚拟环境、依赖与启动目录

跑不起来的新手,八成不是代码问题,是环境问题。系统里可能装着一个老版本 pygame,或者干脆没装。我习惯先隔离出一个虚拟环境,再装依赖,最后从源码根目录启动——最后这点很重要,因为包里加载图片用的是images/ship.bmp这种相对路径,你在别的目录下python 路径/alien_invasion.py,工作目录不对,图片就找不到。

# 1) 建虚拟环境,和系统里的 pygame 版本隔离开 python -m venv .venv # 2) 激活:Linux / macOS 用下面这条 source .venv/bin/activate # Windows 用这条 # .venv\Scripts\activate # 3) 装依赖,pygame 装当前稳定版即可,这份代码没有用到新 API pip install pygame # 4) 一定要在解压出来的根目录里执行,否则相对路径加载图片会失败 cd path/to/alien_invasion python alien_invasion.py

三个参数说明一下。python -m venv .venv里的.venv是目录名,点开头是为了不污染根目录的可见文件列表,用venv或env也一样。pip install pygame不加版本号是有意的——这份代码只用到pygame.sprite、pygame.display、pygame.font这些十年没变过的接口,锁版本反而容易在自己机器上装不上。cd那一步不是废话,pygame.image.load('images/ship.bmp')走的是进程工作目录,而不是脚本所在目录,这两者在 IDE 里经常不一致,在 PyCharm 里点绿三角能跑、在终端里跑就报FileNotFoundError,坑就在这。

如果启动后窗口一闪而过或者直接崩,先别读代码,先在终端里看最后几行报错。ModuleNotFoundError: No module named 'pygame'是环境没激活;pygame.error: Couldn't open images/ship.bmp是工作目录不对;pygame.error: No available video device是少了图形环境,在纯命令行服务器上跑 GUI 必然会撞上,换带桌面的机器即可。

2.3 主循环的四段式骨架与 tick 的位置

把入口文件浓缩一下,主体就是一个 while True,里面四件事按固定顺序排列:收事件、更新状态、重绘屏幕、控帧率。这个顺序不能乱,尤其是事件必须在更新之前收——否则玩家这一帧的按键要等到下一帧才生效,手感会明显发飘。

import pygame from settings import Settings from game_functions import check_events, update_screen from ship import Ship from pygame.sprite import Group # 用一个组统一管理所有子弹和外星人 def run_game(): pygame.init() # 必须在 set_mode 之前 ai_settings = Settings() screen = pygame.display.set_mode( (ai_settings.screen_width, ai_settings.screen_height)) pygame.display.set_caption("Alien Invasion") ship = Ship(ai_settings, screen) # 飞船只实例化一次 bullets = Group() # 空的精灵组,发射时往里 add aliens = Group() clock = pygame.time.Clock() # 控帧用,放在循环外只建一次 while True: check_events(ai_settings, screen, ship, bullets) # 1. 收事件 ship.update() # 2. 更新位置 bullets.update() update_screen(ai_settings, screen, ship, aliens, bullets) # 3. 重绘 clock.tick(60) # 4. 限速 60 帧 run_game()

这段骨架里有两个容易被忽略的细节。第一,Ship和两个Group都在循环外面实例化,循环里只做更新不做创建——把Ship()写进 while 里,结果就是每帧重建一艘飞船,位置每帧归位,按方向键一动不动,这是最常见的翻车点之一。第二,clock.tick(60)放在循环末尾而不是开头,它返回的是上一帧到这一帧的毫秒数,作用是「至少等够 16ms」,保证游戏在快机器上不会跑成 300 帧、飞船瞬移出去。

tick(60)的参数就是目标帧率。调到 30 画面会有肉眼可见的顿感;调到 120,如果你的移动速度参数是按 60 帧调的,飞船就会跑成两倍速——因为代码里位移是「每帧加一个固定值」而不是「每秒加一个固定值」,帧率翻倍速度就翻倍。想改成与帧率无关的写法,得把位移乘上clock.tick()的返回值,但这份代码的整个参数体系都按 60 帧标定,不建议单独改一处,要么全改要么别动。

2.4 Settings 与 screen 的初始化顺序

settings.py里塞的是全部可调参数:屏幕宽高、飞船速度、子弹尺寸和速度、外星人速度、每队外星人的分值、速度随等级提升的倍率。把它们抽出来单独成一个类,好处是调平衡的时候只改一个文件,不用在几千行里翻魔法数字。

初始化顺序上有一条硬规则:pygame.init()必须在pygame.display.set_mode()之前,set_mode()必须在加载任何图片之前。原因是图片加载依赖已初始化的图形子系统,而get_rect()又依赖图片尺寸。顺序错了不会立刻崩,但会拿到一堆莫名其妙的pygame.error,排查起来很浪费时间。

另外Settings应该在任何对象创建之前实例化,因为Ship(ai_settings, screen)这种构造依赖它。我见过把ai_settings = Settings()写在Ship()之后的写法,报NameError,改起来两秒钟的事,但新手经常在这卡半天。

3. 飞船与子弹:键盘状态位、精灵组与每帧一次的 update

这一层是整份代码里最值得抄的部分,因为它用很小的代价解决了两个真实游戏里都会遇到的问题:连续移动怎么处理,以及动态对象怎么统一管理。看懂这两点,后面外星人舰队就只是同样的套路再来一遍。

3.1 为什么用状态位而不是在 KEYDOWN 里直接改坐标

新手最直觉的写法是:按下右键,ship.rect.centerx += 5。写出来能跑,但手感是灾难——按住右键,飞船只动一下,然后卡住不动,因为操作系统的键盘重复率有限,KEYDOWN 事件不是按帧发的。而游戏需要的是「只要键还按着,就每帧都移动」。

所以正确做法是拆成两个状态位:按下时置 True,松开时置 False,移动逻辑放在每帧都会执行的update()里。

# game_functions.py 里的事件分支,只负责改状态,不改坐标 def check_keydown_events(event, ai_settings, screen, ship, bullets): if event.key == pygame.K_RIGHT: ship.moving_right = True elif event.key == pygame.K_LEFT: ship.moving_left = True elif event.key == pygame.K_SPACE: fire_bullet(ai_settings, screen, ship, bullets) def check_keyup_events(event, ship): if event.key == pygame.K_RIGHT: ship.moving_right = False elif event.key == pygame.K_LEFT: ship.moving_left = False

这段代码的价值在于职责切分:事件处理只做「记录意图」,位置计算交给每帧都跑的update()。左右两个标志位是独立的布尔量,不是互斥的枚举,这样玩家同时按住左右时,两个分支都执行、位移互相抵消,飞船原地不动——这个行为符合直觉,比强行规定「最后按下的键优先」要自然。

fire_bullet里还有一层判断,子弹数量到了上限就不再新建,这个上限在settings.py里,通常是 3。别小看这三行判断,少了它玩家按住空格,子弹组会以每秒 60 个的速度膨胀,几秒钟之后画面全是弹幕,内存和帧率一起崩。

def fire_bullet(ai_settings, screen, ship, bullets): # 只有当前屏幕上子弹数小于上限时才允许发射 if len(bullets) < ai_settings.bullets_allowed: new_bullet = Bullet(ai_settings, screen, ship) bullets.add(new_bullet) # 加进组,组会自动管理更新与绘制

3.2 Ship 类:float 的 center 与整数 rect 的三角关系

飞船速度如果设成 1.5 这种小数,直接往rect.centerx上加减会出问题——rect的所有属性都是整数,每次赋值小数会被截断,1.5 变成 1,实际速度就变成 1;更糟的是连续截断会让位移出现「走三步停一步」的抖动。

标准解法是用一个浮点属性暂存真实位置,每帧算完再同步回rect:

class Ship(Sprite): def __init__(self, ai_settings, screen): super().__init__() # 继承 Sprite 才能加入精灵组 self.screen = screen self.ai_settings = ai_settings self.image = pygame.image.load('images/ship.bmp') self.rect = self.image.get_rect() self.screen_rect = screen.get_rect() # 初始位置:水平居中,底边贴屏幕底 self.rect.centerx = self.screen_rect.centerx self.rect.bottom = self.screen_rect.bottom self.center = float(self.rect.centerx) # 浮点备份,累加用它 self.moving_right = False self.moving_left = False def update(self): # 边界判断用 rect,累加用 center,最后再同步回去 if self.moving_right and self.rect.right < self.screen_rect.right: self.center += self.ai_settings.ship_speed_factor if self.moving_left and self.rect.left > 0: self.center -= self.ai_settings.ship_speed_factor self.rect.centerx = self.center

三个属性各司其职:image是位图,rect是碰撞和绘制的依据(整数),center是浮点累加器。边界判断写在if的条件里而不是事后钳制,好处是飞船贴边后不会「抖动」——如果先加再钳制,贴边那几帧会反复超界再拉回,视觉上有细微颤动。

ship_speed_factor这个参数我一般设在 1.5 到 2.5 之间。低于 1 手感拖沓,像在冰上滑;高于 3 手指轻轻一碰就飞到另一头,打不准。

3.3 Bullet 与子弹组的数量上限

子弹类的构造函数里有一处必须做对:初始位置要对齐飞船顶部中点,而不是飞船左上角。

class Bullet(Sprite): def __init__(self, ai_settings, screen, ship): super().__init__() self.screen = screen # 子弹没有图片,直接用 Surface 画一个矩形,颜色和尺寸从 settings 取 self.rect = pygame.Rect(0, 0, ai_settings.bullet_width, ai_settings.bullet_height) self.rect.centerx = ship.rect.centerx # 对齐飞船水平中线 self.rect.top = ship.rect.top # 从飞船顶部冒出 self.y = float(self.rect.y) # 同样用浮点累加,避免高速丢帧 self.speed_factor = ai_settings.bullet_speed_factor self.color = ai_settings.bullet_color def update(self): self.y -= self.speed_factor # y 减小 = 向上飞 self.rect.y = self.y

子弹用pygame.Rect而不是加载图片,是个聪明的取舍:矩形够用、省一次 IO、颜色尺寸随时可调。self.y的浮点累加同样是为了高速下的位移精度——如果每帧位移是 3.5 而直接加在rect.y上,截断误差累积起来会让子弹轨迹出现轻微的「不均匀」。

子弹组的清理逻辑放在update_bullets里,遍历所有子弹,把飞出屏幕顶部的删掉:

def update_bullets(bullets): bullets.update() # 组会自动调用每个成员的 update # 用 list() 包一层,因为不能在遍历组的同时删组内元素 for bullet in list(bullets): if bullet.rect.bottom <= 0: bullets.remove(bullet)

这里list(bullets)不是可选项,是保命操作。直接for bullet in bullets然后bullets.remove(bullet),在子弹数量多的时候会抛RuntimeError: dictionary changed size during iteration——精灵组内部用字典存成员,边遍历边改就会撞上。这个坑我在第 5 章还会单独说。

3.4 每帧一次 update 与事件队列的关系

pygame 的事件系统是个队列,每一帧你调用pygame.event.get()时,它把自上次调用以来累积的所有事件一次性吐出来。这意味着如果你的帧率掉到 20,两次调用之间会积压 50ms 的按键事件,玩家会感觉「按了没反应,然后突然动一下」。

这也是为什么主循环里必须先check_events再update:事件清空之后,状态位才是最新的,这一帧的位移才跟得上玩家的手。顺序反了会有一帧的延迟,单独看不明显,但快速左右切换时会有黏滞感。

另外一个细节是pygame.event.get()的返回值必须完整遍历,不能只挑自己关心的处理、剩下的不管——那些没被取走的事件会一直堆在队列里,越积越多,最后表现为「窗口拖不动、点了关闭没反应」。标准写法是先全取出来,循环里处理 KEYDOWN / KEYUP,最后统一处理 QUIT:

def check_events(ai_settings, screen, ship, bullets): for event in pygame.event.get(): # 必须全部取出,不能选择性取 if event.type == pygame.QUIT: sys.exit() elif event.type == pygame.KEYDOWN: check_keydown_events(event, ai_settings, screen, ship, bullets) elif event.type == pygame.KEYUP: check_keyup_events(event, ship)

4. 外星人舰队:行距、列距与边缘检测的三个公式

外星人这一层是整份代码里数学含量最高的部分,也是最容易改出 bug 的地方。核心问题只有一个:怎么用尽量少的参数,让一队外星人整齐排布、碰到边缘整队下移、并且随等级提升越来越密。

4.1 Alien 的宽高决定整队的间距公式

最省事的排布规则是「每个外星人占据两倍自身宽度的格子,自己居中」。这样间距天然等于自身宽度,改图片尺寸都不用改间距参数。

class Alien(Sprite): def __init__(self, ai_settings, screen): super().__init__() self.screen = screen self.ai_settings = ai_settings self.image = pygame.image.load('images/alien.bmp') self.rect = self.image.get_rect() # 初始位置无所谓,create_fleet 会挨个重设 self.rect.x = self.rect.width self.rect.y = self.rect.height self.x = float(self.rect.x) # 浮点位置,用来做平滑下移 def check_edges(self): # 只要有任何一边碰到屏幕边界,就返回 True screen_rect = self.screen.get_rect() return self.rect.right >= screen_rect.right or self.rect.left <= 0 def update(self): # 左右移动 = 每帧加上「方向 × 速度」 self.x += self.ai_settings.alien_speed_factor * self.ai_settings.fleet_direction self.rect.x = self.x

check_edges用了>=和<=而不是==,因为外星人是按浮点速度移动的,坐标不会精确落在边界值上,用等号判断永远不会触发,外星人就会一路飞出屏幕。这种「浮点比较不能用等号」的教训,几乎每个写游戏的人都踩过一次。

4.2 create_fleet:一行放几个、一屏放几行

舰队生成分两步:先算一行能放几个,再算一屏能放几行,然后双重循环逐个实例化并定位。

def get_number_aliens_x(ai_settings, alien_width): # 左右各留一个外星人宽度的边距,剩下的按「两倍宽度」一格切分 available_space_x = ai_settings.screen_width - 2 * alien_width return int(available_space_x / (2 * alien_width)) def get_number_rows(ai_settings, ship_height, alien_height): # 上方留 3 行外星人的高度做缓冲区,下方留出飞船的高度 available_space_y = (ai_settings.screen_height - (3 * alien_height) - ship_height) return int(available_space_y / (2 * alien_height)) def create_fleet(ai_settings, screen, ship, aliens): alien = Alien(ai_settings, screen) # 临时对象,只用来量尺寸 number_aliens_x = get_number_aliens_x(ai_settings, alien.rect.width) number_rows = get_number_rows(ai_settings, ship.rect.height, alien.rect.height) for row_number in range(number_rows): for alien_number in range(number_aliens_x): alien = Alien(ai_settings, screen) alien.x = alien.rect.width + 2 * alien.rect.width * alien_number alien.rect.x = alien.x alien.rect.y = alien.rect.height + 2 * alien.rect.height * row_number aliens.add(alien)

三个公式拆开看:第一个用「屏幕宽减去两边各一格」再除以「两格宽」,得到每行最大数量;第二个用「屏幕高减去上方三行缓冲、再减去飞船高度」,除以「两行高」,得到最大行数;定位公式是width + 2 * width * index,index从 0 开始,所以第一个外星人左边留一格宽,后面每格再跳两格宽。

为什么要留三行缓冲?因为舰队会不断下移,缓冲区太薄,玩家几乎没有反应时间就被压到底。这个 3 是可以调的,改成 2 会明显更紧张,改成 4 就更宽松。飞船高度必须参与计算,否则外星人下移到飞船所在行时游戏已经结束了,但你还在算「还能放几行」,纯属浪费。

4.3 check_edges 与 change_fleet_direction 的标志位技巧

触碰边缘的处理,如果写「让每个碰到边的外星人都调头」,会出现队形撕裂:只有最边上那一个掉头,其他还在往原方向走,队形瞬间散掉。

正确做法是「任意一个碰到边,整队掉头」,而且掉头之后要立刻break,否则一次检测会触发多次方向翻转,方向翻转偶数次等于没转。

def check_fleet_edges(ai_settings, aliens): for alien in aliens.sprites(): if alien.check_edges(): change_fleet_direction(ai_settings, aliens) break # 只翻转一次,翻完立刻跳出 def change_fleet_direction(ai_settings, aliens): for alien in aliens.sprites(): alien.rect.y += ai_settings.fleet_drop_speed # 整队下移 ai_settings.fleet_direction *= -1 # 方向取反:1 变 -1,-1 变 1

这里fleet_direction是个存在Settings里的全局方向标志,取值只有 1 和 -1,乘以速度就得到带符号的位移。这种「符号位乘标量」的写法比写 if-else 分支干净得多,也让Alien.update()变得极简——它不需要知道自己是往左还是往右,只管乘上去。

fleet_drop_speed这个参数决定每次碰到边缘下移多少像素。我一般给 5 到 10。给太小,玩家永远不会输,游戏没有张力;给太大,两三次触边就压到飞船,纯属折磨。

4.4 难度曲线:几个真正影响手感的参数

这份代码的难度提升通常靠两处:每清完一队,要么加快外星人速度,要么提高子弹速度、同时提高单只外星人的分值。参数之间是有关联的,乱调会让游戏直接失去可玩性。

参数常见取值作用调过头的后果
ship_speed_factor1.5 ~ 2.5飞船每帧位移大于 3 精准走位变难
bullet_speed_factor3 ~ 10子弹每帧位移太快看不见轨迹
alien_speed_factor0.5 ~ 2外星人横向每帧位移大于飞船速度就无法走位躲
fleet_drop_speed5 ~ 10触边下移像素大于 15 三四个回合就到底
bullets_allowed3 ~ 5屏幕子弹上限超过 8 画面变弹幕,难度骤降
speedup_scale1.1 ~ 1.3每级速度倍率每级乘 1.5,两三级后人类反应不过来

alien_speed_factor有一条隐性红线:不能超过ship_speed_factor。一旦外星人横向移动比飞船还快,玩家就失去了「绕到侧面躲」的战术空间,只能被迫硬刚,游戏体验急转直下。升级速度用乘法而不是加法,也是因为它能形成指数曲线,前几级温和、后面陡峭,比线性加速更有紧张感。

5. 避坑排查:五个让这份代码跑不起来的坑

这份包的代码量不大,但因为它横跨了「文件 IO、精灵组、碰撞检测、状态机」四块内容,出错点分布得很散。下面五条是我在实际折腾中撞过、也在别人机器上见过多次的问题,全部按「现象 → 原因 → 解决」记录。

5.1 图片加载失败,报 Couldn't open images/xxx.bmp

现象:在 IDE 里点运行能出画面,切到终端执行python alien_invasion.py就报pygame.error: Couldn't open images/ship.bmp,或者FileNotFoundError。

原因:pygame.image.load('images/ship.bmp')解析的是进程当前工作目录,不是脚本所在目录。在终端里从上一级目录执行python alien_invasion/alien_invasion.py,工作目录就是上一级,images/自然找不到。

解决:先cd到脚本所在目录再执行,这是最省事的做法。如果要长期规避,把加载路径改成基于脚本位置的绝对路径:

import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) image_path = os.path.join(BASE_DIR, 'images', 'ship.bmp') self.image = pygame.image.load(image_path)

os.path.abspath(__file__)拿到脚本的绝对路径,dirname取所在目录,后面拼接就永远对得上。这行代码在打包之后会失效,第 6 章会讲怎么处理。

5.2 边遍历边删除,抛 RuntimeError

现象:游戏运行几秒后崩掉,报RuntimeError: dictionary changed size during iteration,报错位置在update_bullets或check_bullet_alien_collisions里。

原因:精灵组内部用字典存成员。for bullet in bullets拿到的是字典的迭代器,循环体里执行bullets.remove(bullet)就改动了字典尺寸,迭代器立刻失效。

解决:遍历前先转成列表快照,把「遍历的对象」和「修改的对象」分开:

# 错:遍历过程中改容器 for bullet in bullets: if bullet.rect.bottom <= 0: bullets.remove(bullet) # 对:先快照,再遍历快照、改原容器 for bullet in list(bullets): if bullet.rect.bottom <= 0: bullets.remove(bullet)

一条更彻底的路是用bullets.update()让每个子弹自己判断存活,配合kill()标记,最后统一清理。但这份代码整体是「函数式操作组」的风格,用list()快照最贴合原结构,改动也最小。

5.3 帧率掉到个位数,飞船一顿一顿

现象:刚启动还好,玩到第三四队外星人时画面明显卡顿,子弹变少反而更卡。

原因:九成是图片没做convert()或convert_alpha()。pygame 加载的原始位图是未优化的像素格式,每次 blit 都要现场转换格式,对象一多就是灾难。另一个可能是每帧都在pygame.font.SysFont()新建字体对象——字体解析很贵,一帧一次足以拖垮帧率。

解决:加载完立刻转换格式,字体对象提到循环外只建一次:

# 不透明位图用 convert(),带透明通道的用 convert_alpha() self.image = pygame.image.load('images/ship.bmp').convert_alpha() # 字体在初始化时建一次,放进 Settings 或全局,别在 draw 里建 self.font = pygame.font.SysFont(None, 48)

补充一点:convert()必须在set_mode()之后调用,因为它依赖显示表面的像素格式。顺序反了会报pygame.error: cannot convert without video mode。

5.4 一发子弹打掉一整列外星人,分数翻好几倍

现象:子弹明明只碰到最下面那只外星人,结果它正上方一列全没了,分数还按整列结算。

原因:pygame.sprite.groupcollide(bullets, aliens, True, True)的返回值是一个字典,键是被撞的子弹,值是「和这颗子弹同时发生碰撞的所有外星人列表」。当子弹穿过一列外星人时,这一列都在碰撞检测范围内,返回的列表长度大于 1。如果计分时按「碰撞次数」算而不是按「碰撞到的外星人数量」算,分数就会错得离谱。

解决:遍历字典的 values,按列表长度累加分数,同时确认两个True参数的含义是「碰撞后双方都删除」:

collisions = pygame.sprite.groupcollide(bullets, aliens, True, True) if collisions: # values() 里每个元素是一组被同一颗子弹命中的外星人 for aliens_hit in collisions.values(): stats.score += ai_settings.alien_points * len(aliens_hit) sb.prep_score() # 整队清空则升级:提高速度、重新生成舰队 if len(aliens) == 0: bullets.empty() ai_settings.increase_speed() create_fleet(ai_settings, screen, ship, aliens)

如果想让「一颗子弹只杀一个外星人」,把第三个参数改成False(子弹不删),然后在循环里手动bullet.kill()。但要注意别在字典遍历中删子弹,同样的RuntimeError在等你。

5.5 游戏结束后按空格还在发射,飞船还能动

现象:三条命打完,屏幕显示「Play」按钮,但此时按方向键飞船还在跑,按空格还在出子弹,有时候还能把外星人打掉加分。

原因:check_events里只判断了「按了什么键」,没判断「现在是不是游戏进行中」。游戏状态存在stats.game_active里,但事件分支没读它。

解决:在check_keydown_events里加一层守卫,非活跃状态下只处理「点击 Play 重开」这一种输入:

def check_keydown_events(event, ai_settings, screen, ship, bullets, stats): if event.key == pygame.K_RIGHT and stats.game_active: ship.moving_right = True elif event.key == pygame.K_LEFT and stats.game_active: ship.moving_left = True elif event.key == pygame.K_SPACE and stats.game_active: fire_bullet(ai_settings, screen, ship, bullets)

同时ship.update()也建议加上if not stats.game_active: return的短路。这类「状态门禁」在真实项目里通常会抽成一个状态机类统一管理,但在这份代码的规模下,几处and stats.game_active就够了,没必要过度设计。

6. 从能跑到能发:打包成单文件与资源路径的最后一公里

代码在自己机器上跑通,和把它发到别人机器上能跑,是两件不同的事。这份包最典型的翻车场景是:你把它打包成一个 exe 发给同事,对方双击后窗口闪一下就消失——因为images/目录没被打进去,或者打进去了但运行时路径变了。

解决这件事只需要一个函数。PyInstaller 在单文件模式下会把所有资源解压到一个临时目录,路径存在sys._MEIPASS里;源码运行时这个属性不存在,得回退到脚本目录:

import os import sys def resource_path(relative_path): """兼容源码运行与 PyInstaller 打包两种场景的资源定位""" try: # 打包后,PyInstaller 会把资源解到临时目录,路径记录在 _MEIPASS base_path = sys._MEIPASS except AttributeError: # 源码运行时没有这个属性,回退到当前工作目录 base_path = os.path.abspath('.') return os.path.join(base_path, relative_path) # 所有 load 都换成这个形式,路径参数写成相对资源根目录的形式 self.image = pygame.image.load(resource_path('images/ship.bmp')).convert_alpha()

打包命令里有两个必须显式指定的东西:--add-data把资源目录打进去,--windowed去掉控制台黑窗。

# 单文件 + 无控制台窗口 + 带上 images 目录 pyinstaller --onefile --windowed --add-data "images;images" alien_invasion.py

--add-data的格式是「源路径;目标路径」,Windows 用分号分隔,Linux 和 macOS 用冒号,这一条写错就直接报错退出。--windowed在 Windows 上等价于--noconsole,作用是运行时不开那个黑框;但代价是print出来的调试信息全部消失,所以建议先不加它打一版、确认能跑,再加。打完的产物在dist/目录下。

还有一个容易被忽略的点:打包后帧率可能比源码运行时低一截,尤其是--onefile模式——每次启动都要把整个包解压到临时目录,冷启动慢两三秒是正常的,不是你的代码有问题。如果在意启动速度,改成--onedir模式,牺牲「单文件」的整洁换启动速度。

从那以后我每次交付这类 pygame 项目,都会先在另一台没装过 Python 的机器上双击跑一遍,再回头看代码——因为「我这能跑」这四个字,在交付场景里一文不值。希望这份拆解能帮你少走两趟弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进