拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python游戏开发实战:用pygame实现外星人入侵全流程解析

Python游戏开发实战:用pygame实现外星人入侵全流程解析

聊到 Python 游戏开发,绕不开的实战项目就是“外星人入侵(Alien Invasion)”。这套项目从《Python 编程:从入门到实践》火到了各个编程教程平台,核心原因就是:它用最朴素的 pygame 把游戏开发里“窗口、事件、精灵、碰撞、状态”这五根支柱全部串了一遍,几百行代码就能跑出一个完整可玩的打飞机游戏。更妙的是它用的是 Python,不用像 C++ 那样和引擎底层搏斗,也不像 Unity 那样被编辑器封装得严严实实,你能清清楚楚看到游戏循环的每一帧在干什么。

很多新手照着教程抄完这一遍,运行起来看到飞船和外星人动起来那一刻确实挺兴奋,但合上书之后还是啥也写不出来。问题不出在代码抄没抄,而是出在没理解每个设计决策背后的动机。所以我这篇不只是复述教程,我会用做项目的视角,把从环境搭建、类设计到碰撞检测、计分、打包 exe 的整个流程重走一遍,并且把我在带学员和自己做项目过程中踩过的坑全部标出来。零基础能跟着一步步跑起来,有点基础的也建议重点关注每节的“为什么”和“坑位提醒”,这部分的经验通常都不会写在官方文档里。

1. 项目概述与整体设计

1.1 为什么选“外星人入侵”作为 Python 游戏开发的第一站

我见过太多新手一上来就冲着“做个《我的世界》”或者“做个元气骑士”去,结果写了两周连场景都没搭起来,直接劝退。游戏开发这件事,最大误区就是低估工程量、高估自己耐心。“外星人入侵”恰恰把复杂度控制在了“两三天能跑通,两周能加完自己的功能”这个甜点区间。

从技术覆盖面上看,它一个类就把游戏开发的核心链路全部打通了:你要创建窗口、管理主循环、处理键盘事件、控制精灵移动、发射子弹、批量生成敌人、做碰撞检测、维护游戏状态、计算并显示分数。这一套东西,换成 Unity 或 Godot 也一样存在,只不过它们把很多底层逻辑封装成了可视化组件。用 pygamel 做的好处是,每一个环节都是裸逻辑,你能看到事件是怎么从键盘流进事件队列,再被你的代码消费掉的,理解了这些,以后用任何引擎都事半功倍。

还有一个容易忽略的点:这个项目的扩展空间足够大。基础版做完之后,你可以加不同速度的外星人、加 BOSS、加音效、加暂停菜单、加最高分存档,甚至重构出一个带关卡配置的完整框架。我当年就是在这个项目基础上加了三四种敌人和连击加分,才真正理解“游戏性”这东西是靠参数调出来的。入门项目最重要的不是大,而是能让你持续有动力往里面添东西,“外星人入侵”这一点做得非常好。

1.2 技术选型:pygame 的优势与边界

做技术选型时,很多人会纠结:现在免费商用的游戏开发引擎那么多,为什么要用 pygame 这种“老古董”?我的观点是,选型要看阶段和目标。pygame 不是一个让你快速做出华丽画面的引擎,它是一套封装很薄的 2D 多媒体库,适合用来学原理、做原型、写教学项目。它的定位有点像是练手用的木工刨——手艺练好了再去上电锯。

对比下来,如果目标是用最短时间做出一个能发布的商业小游戏,Godot 或者 Construct 3 这类引擎确实更高效,它们自带编辑器、动画系统、场景树,还有完善的 UI 工具。但代价是,你学到的更多是“引擎怎么用”,而不是“游戏循环怎么写”。再者,Unity 的 C# 语言门槛和工程体积对刚入门的人并不友好。pygmap 这边所有东西都是 Python 对象,pygame.Rect、pygame.Surface、pygame.Sprite,你打印出来就能看,改起来就能跑,反馈链路极短。

当然,pygame 的边界也必须说清楚:它不适合做复杂的 3D 游戏,UI 控件几乎等于裸奔,粒子特效、骨骼动画这些都要自己造轮子,性能上如果管理不当,几千个精灵就会明显掉帧。但这不是缺点,这是它“克制”带来的好处。你没得偷懒,反而逼着你把对象池、碰撞过滤、状态管理这些基本功练扎实了。至于授权问题,pygame 采用 LGPL 许可,免费商用没问题,发布游戏时注意遵守相关的开源协议要求即可。对于学习项目和中小型独立 2D 作品,它完全够用。

2. 环境准备与工具链搭建

2.1 Python 环境配置的完整细节

环境这块看着简单,但我接手过的私信提问里至少有三分之一卡在装环境上。先说 Python 安装。Windows 用户请到官网下载最新稳定版,安装时一定要勾选“Add python.exe to PATH”,这个选项默认是关闭的,不勾选的话,你在命令行敲python会直接报“不是内部或外部命令”。macOS 用户直接用 Homebrew 安装即可,brew install python@3.11一行搞定。装完后打开终端(Windows 是 PowerShell),输入python --version,能看到输出就说明环境正常。

接下来强烈建议给这个项目建一个独立的虚拟环境。很多新手喜欢直接把库装到全局,一开始确实图省事,但等你要同时维护两个项目的时候就乱套了:A 项目要 pygame 2.0,B 项目要 pygame 2.5,全局只有一个,总有一个跑不起来。虚拟环境就是给每个项目一个独立的“房间”。当前项目目录下执行python -m venv venv,Windows 下激活命令是venv\Scripts\activate,macOS 和 Linux 是source venv/bin/activate。看到命令行前面多出(venv)前缀,就说明环境激活成功了。

再说包管理。默认的 PyPI 下载源在国内访问速度很不稳定,几百 KB 的包经常卡半天。我的做法是一劳永逸地新建一个 pip 配置文件,把下载源换成镜像源:在用户目录下创建pip.ini(Windows 是C:\Users\你的用户名\pip\pip.ini,Linux/macOS 是~/.pip/pip.conf),写入[global]和index-url指向镜像地址。换源之后,pip install pygame基本几秒钟就能下完,不用每次都在命令行加-i参数。

2.2 pygame 安装与开发工具推荐

环境就绪后,在激活了虚拟环境的终端里执行:

pip install pygame

然后跑一个官方自带的小样例验证安装:python -m pygame.examples.starship。如果弹出一个可控的小游戏窗口,说明 pygame 安装完美。这里提醒新手一个坑:有些人电脑上装了多个 Python 版本,直接敲pip装到的可能是全局的旧版 Python 自带的 pip,跟当前虚拟环境不是同一个。稳妥做法是在虚拟环境激活状态下,用python -m pip install pygame这种方式执行,保证装进当前项目环境。

开发工具方面,我不太建议大家一开始就在 IDE 上浪费时间纠结。VS Code 和 PyCharm 都可以,两个都用过的我给个实际建议:如果喜欢轻量、启动快,VS Code 装一个官方 Python 扩展就够了,然后用命令面板搜索“Python: Select Interpreter”,选到你刚才创建的 venv,这样代码补全和运行按钮就都能识别虚拟环境;PyCharm 则更省心,新建项目时直接指定解释器,它会自动帮你创建 venv,社区版免费功能也完全够用。编辑器时代不建议开一大堆插件,先把项目跑起来最重要。素材方面,教程里用到的ship.bmp和alien.bmp,可以从书籍配套资源下载,也可以自己用画图软件做个简单像素图,块状也没关系,关键是先跑通。

3. 核心系统实现逐层拆解

3.1 游戏窗口与主循环框架

老规矩,任何游戏的第一步都是创建窗口和进入游戏循环。这一步先不追求功能,先把“看得见”的部分立起来。

我会建一个settings.py,把所有可调参数集中在一个类里。这个设计是我强烈推荐的,玩过几轮你就会明白为什么:数值全部集中以后,你调游戏手感就是在改几个常量,而不是满项目搜索数字。

class Settings: def __init__(self): self.screen_width = 1200 self.screen_height = 800 self.bg_color = (230, 230, 230) self.ship_speed = 1.5 self.bullet_speed = 1.0 self.bullet_width = 3 self.bullet_height = 15 self.bullet_color = (60, 60, 60) self.alien_speed = 1.0 self.fleet_drop_speed = 10 self.fleet_direction = 1

主程序的骨架是这个样子:

import sys import pygame from settings import Settings class AlienInvasion: def __init__(self): pygame.init() self.settings = Settings() self.screen = pygame.display.set_mode( (self.settings.screen_width, self.settings.screen_height)) pygame.display.set_caption("Alien Invasion") def run_game(self): while True: self._check_events() self._update_screen() def _check_events(self): for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() def _update_screen(self): self.screen.fill(self.settings.bg_color) pygame.display.flip() if __name__ == '__main__': ai = AlienInvasion() ai.run_game()

游戏循环的核心逻辑就是这三步:处理输入、更新状态、重新绘制。pygame.event.get()每次循环会取走当前累积的所有事件;screen.fill()用背景色刷新整个画面;pygame.display.flip()把绘制好的内容一次性呈现到屏幕上。现在这个版本只有背景色和循环,但它已经是完整可运行的程序了,后续所有功能都是往这个骨架里挂。

这里有个容易被忽略的小点:主循环跑得快慢取决于机器性能,跑得快游戏就快,这不公平也不稳定,所以后续要引入时钟对象clock = pygame.time.Clock(),每帧末尾调用clock.tick(60),意思是把帧率锁在每秒 60 帧。这样移动速度、子弹速度都基于“每帧移动多少像素”来算,表现的节奏就能在各类设备上保持一致。

3.2 事件驱动与玩家控制

游戏和普通程序最大的区别就是实时响应“用户输入”。之前我们只处理了QUIT事件,现在要处理键盘事件。这里有一个很多新手从未意识到、但非常关键的机制:pygame 的事件是一个队列,不是实时状态。也就是说,当你按下方向键左键时,系统把“键被按下”这个事件放进队列,主循环下一次调用event.get()时你才拿到它;你松开按键时,会拿到一个KEYUP事件。如果你在循环里不及时取走事件,事件就会堆积在那里,程序响应就会越来越迟钝,甚至出现“松开按键了还在继续移动”的诡异现象。

从概念上讲,事件队列就是一把排队叫号的“锁”:每次主循环进去,都要把当前窗口期里排队的号全部叫完,一个都不能剩。后续所有事件处理都得包裹在这个“取净、处理净”的流程里,别试图在别的地方用pygame.key.get_pressed()代替,那读的是“当前这一刻哪些键仍然是按住状态”,两者的语义完全不同。

具体到飞船左右移动,我会这样设计:用两个布尔标志记录移动方向状态,按下左键时moving_left = True,松开左键时moving_left = False,右边同理。然后在Ship.update()里根据两个标志决定该往哪移动。为什么不用“按键事件里直接改坐标”?因为那样只能做到按一次动一次,按住不放并不会连续移动。用标志位,逻辑像电灯开关一样,按下开、松开关,干净且稳定。

坐标系统也要特别提一嘴:pygame 的坐标系原点在窗口左上角,x 轴向右增加,y 轴向下增加。也就是说,rect.y越大,对象越靠下。很多新手把外星人放在屏幕顶部后,想让它“向下走”却写了y -= 10,结果越走越往上。记住一句话:在 pygame 里,向下移动是加 y,向上移动是减 y。飞船的左右边界控制是拿rect.left和rect.right跟屏幕宽度比较,一不小心就会让飞船半个身子跑出屏幕外面。

3.3 子弹系统与外星人编队

飞船能动之后,下一步是发射子弹。子弹我建议设计成一个独立的Sprite子类,而不是简单画个矩形。这样你自然就会用到 pygame 的精灵组(Group),精灵组的好处是:可以统一调用组里所有精灵的update()方法、统一绘制、统一碰撞检测,并且自动管理生命周期,游戏逻辑会清爽很多。

from pygame.sprite import Sprite class Bullet(Sprite): def __init__(self, ai_settings, screen, ship): super().__init__() self.screen = screen 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) def update(self): self.y -= self.settings.bullet_speed self.rect.y = self.y def draw_bullet(self): pygame.draw.rect(self.screen, self.settings.bullet_color, self.rect)

这里有个细节值得展开:rect.y是整数,而速度可能是带小数的(比如 1.5),每次累加如果直接写self.rect.y -= 1.5,由于取整误差,实际移动速度会忽快忽慢。正确做法是维护一个浮点型的self.y,每帧计算完浮点结果后再赋给rect.y。这个“浮点坐标与整数坐标分离”的技巧,在 pygame 里几乎是必修课,飞船、外星人移动都会用到,否则调参时会碰到魔幻的性能抖动。

子弹从飞船顶部发出,向上移动,当rect.bottom <= 0时子弹已经飞出屏幕外,应该从组里移除。如果不清理,子弹对象会随着游戏时间越积越多,内存和绘制开销只会越来越大,掉帧就是这么来的。清理逻辑可以放在update_bullets()里,遍历组的副本,把出界的子弹remove()掉。

外星人的设计思路和子弹类似,但更复杂一点:它们要排成方阵(编队),并且整队水平移动,碰到屏幕边缘后整体下移并反向。每个Alien也是一个Sprite,但编队行为是“组级行为”,所以要有一个遍历整个aliens组的循环,逐帧检查每个外星人是否到达了rect.right >= screen_width或rect.left <= 0,一旦有任何一个触边,就设置一个标志,下一帧让整支编队改变方向并下移。

生成编队时经典的写法是双重循环:外层控制行,内层控制列,左右留出边距,外星人之间留下间隔。初版可以先只生成一行 6 个,跑通后再生成为一个 3 行 10 列的方阵。等到方阵出现,你才真正进入游戏开发的“密集型对象管理”话题:上百个精灵同时移动、绘制、碰撞检测,如何保持流畅,这就逼着你学会用精灵组、批量更新、批量绘制。

3.4 碰撞检测、计分与游戏状态

游戏没碰撞就是摆设。python 实现碰撞最常见的做法,是调用pygame.sprite.groupcollide(bullets, aliens, True, True),这一句的意思是:找出子弹组和外星人组之间发生碰撞的所有组合,并且把碰到的子弹和外星人同时从各自的组里移除。两个True参数分别表示“碰撞后销毁子弹”和“碰撞后销毁外星人”。返回值是一个字典,键是被撞的外星人,值是撞上去的子弹列表,可以用它来累加分数。

这里有个新手很容易踩的坑:如果你把两个布尔参数都设成True,当一个外星人被两颗子弹同时打中的时候,两颗子弹都会消失,外星人也只消失一次,这没问题;但如果你设的是True, False,外星人不消失、子弹消失,你会看到子弹打在“透明墙”上毫无效果。反过来就变成子弹不消失、外星人被刷掉。所以参数含义一定要记清楚。为了做得分,我在外星人身上加了一个points属性,打掉之后把分值加到总得分里,再更新 HUD 文本。

游戏状态分三层:进行中、本局结束、游戏结束。我建议用一个game_active布尔值控制主循环的“运行分支”,运行中可以暂停菜单;再维护一个“剩余飞船数”,这一版可以先不做完整的生命系统,只做“飞船被外星人撞到就 game over”。外星人到达屏幕底部的检测也一样简单——判断外星人的rect.bottom >= screen_height,一旦满足就立刻结束游戏,这个条件必须每帧检查,否则就会看到外星人直接“穿出”屏幕底部,视觉上很怪。

计分文字用pygame.font.SysFont(None, 48)渲染成图片再blit到屏幕。分数刷新最忌每帧创建一个新 font 对象,正确做法是开局创建一次,每帧只调用render生成新文本面,然后立即blit。这个细节看起来无所谓,但跑久了你就知道,帧率稳定的游戏和越跑越卡的游戏,差的往往就是这些微小的对象创建开销。

4. 常见问题与避坑实录

4.1 环境与依赖问题速查

我把这几年带新手遇到最多的环境问题整理成一张速查表,每一条都对应一个真实的报警或现象,照着排查比自己瞎试快得多。

现象原因解决方案
命令行敲python提示不存在安装时没勾选 Add to PATH重装 Python 并勾选,或手动添加环境变量
pip install pygame很慢或超时默认下载源网络不稳定配置镜像源,几秒完成下载
ModuleNotFoundError: No module named 'pygame'库没装进当前虚拟环境激活 venv 后重新python -m pip install pygame
运行报pygame.error: Couldn't open images/ship.bmp图片路径写错或文件不存在用绝对路径临时排查,确认文件确实在相对路径下
窗口一闪而过立即关闭代码里没有主循环或事件处理崩溃在 run_game 入口打印日志,看抛错信息
游戏窗口黑屏无内容忘记调用 display.flip() 或绘制顺序不对确认每帧末尾调 flip,绘制放在 flip 之前

环境类问题有个通用排查思路:永远先用最小脚本验证环境,再引入自己的代码。我自己的习惯是先写一个“只有背景色的窗口”跑起来,再逐步加飞船、加外星人、加子弹,这样每一步出问题都能立刻定位到是环境还是代码,不至于把问题混在一锅粥里。

4.2 逻辑、性能与“事件锁”排查

逻辑层最常见的问题都出在对象生命周期管理上。很多新手做完基础版后会发现:游戏越玩越卡、子弹发射没反应、外星人打到后面消失得莫名其妙。我依次说排查路径。

先看子弹清理。发射子弹后,子弹一直在组里累积,即使出界也不删除,最后生成的子弹对象数量可能涨到几千个,每次遍历和绘制都在空转。你说怪不怪,明明只剩 5 发子弹在屏幕上,组里却藏着 5000 个隐形对象。排查方法很简单:在update_bullets()末尾打一行print(len(self.bullets)),你会发现数字只增不减,那就是清理逻辑没生效。最常见的原因是遍历组时直接remove当前元素,导致遍历索引错乱漏删,解决办法是遍历self.bullets.copy()。

再看“事件锁”类的反应迟钝问题。这个概念就在 3.2 节提到的事件队列机制里。当你的主循环每帧处理逻辑耗时太长(比如一次处理了 1000 个外星人的碰撞检测),事件队列在这期间就会积累大量按键事件,你的游戏表现为“按一下方向键,半秒后才响应”。排查性能瓶颈用cProfile一跑就知道,但最常见的元凶就是:每帧重复创建对象(font、surface、临时列表)、对不需要碰撞检测的精灵全量检测、遍历组时进行昂贵操作。优化方向优先选“减少不必要的遍历”和“复用对象”,这比盲目调低分辨率有效得多。

外星人编队还有一个特殊问题:当编队左右移动时,经常出现某个外星人“悬空”或者“插入”另一个外星人的现象。原因是单个外星人的update()和整体编队的update()都改动了坐标,两套逻辑打架。我的经验是把职责分清楚:Alien类只负责自身坐标同步,编队移动逻辑全部放到外星人组的控制器里,统一计算水平位移和垂直下移,不要让单个外星人自己“乱跑”。

4.3 打包发布与后续扩展

游戏做完总有想发给朋友的时候。用 PyInstaller 打包 pygame 项目是成熟方案,命令我推荐这样写:

pyinstaller --onefile --windowed alien_invasion.py

--onefile是把所有依赖捆绑成单个 exe,方便分发;--windowed是去掉控制台黑窗口,因为游戏不需要命令行交互。第一次打包可能会遇到防病毒软件误报,这是 PyInstaller 打包程序的常见情况,可以加白名单处理,不展开。

打包最大的坑是资源路径。代码里写的images/ship.bmp在源码目录下能正常读取,但打成一捆后,相对路径的基准变了,程序就找不到图片了。解决方案是运行时判断是否处于 PyInstaller 的临时解包环境,用固定的资源路径定位函数取到真实的图片目录。碰到“打包成功但运行时提示找不到图片”的,几乎都是这一步没处理。正确写法是:

import sys, os def resource_path(relative_path): base_path = getattr(sys, '_MEIPASS', os.path.abspath(".")) return os.path.join(base_path, relative_path)

所有加载图片、字体、音效的地方都通过这个函数来拼接路径,打包前后都不会出问题。

功能扩展是把这个项目变成自己作品的关键一步,我个人推荐优先级如下:先给不同外星人类型设不同速度和分值,实现“越往下越难”的动态难度;再加一个音效管理器,用pygame.mixer.Sound在射击、爆炸、碰撞时播放音效,游戏沉浸感立刻不一样;然后再做最高分持久化,把分数存到本地文件,下次启动读取;等你对这些都熟练了,甚至可以加一个简单的主菜单和暂停功能,一个完成度极高的独立小游戏就出来了。我始终认为,把一个入门项目扩展成“自己的游戏”,比追着教程抄十个项目要有价值得多。

实际跑完这个项目之后,我最大的感受是:游戏开发的门槛真不在“写代码”本身,而在于抽象思维——你要学会把画面中的每个物体建模成对象,把变化抽象成状态,把复杂交互拆成组件之间的消息传递。这些能力靠看教程看不出来,只有亲手把飞船飞起来、外星人动起来、分数涨起来之后,才会在某一个瞬间突然打通。如果你卡在某个环节,别急着怀疑自己,按我上面列的排查顺序一步步来,多半很快就能找到问题。接下来要做的,就是打开编辑器,把第一行代码敲下去。

返回列表