
简介这份 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 和 settingsship / bullet / alien单个对象的状态与行为只依赖 pygamegame_stats / scoreboard / button游戏状态与 UI依赖 settings2.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: Couldnt 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更糟的是连续截断会让位移出现「走三步停一步」的抖动。标准解法是用一个浮点属性暂存真实位置每帧算完再同步回rectclass 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最后统一处理 QUITdef 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.xcheck_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 * indexindex从 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 图片加载失败报 Couldnt open images/xxx.bmp现象在 IDE 里点运行能出画面切到终端执行python alien_invasion.py就报pygame.error: Couldnt 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 的机器上双击跑一遍再回头看代码——因为「我这能跑」这四个字在交付场景里一文不值。希望这份拆解能帮你少走两趟弯路。本文还有配套的精品资源点击获取