简介:一份基于Pygame库实现的“飞机大战”游戏开发示例,面向正在学习Python游戏编程的入门与进阶开发者,帮助理解面向对象、事件驱动和图形界面设计等核心概念。压缩包大小约1.66MB,包含Python源码及必要素材,整体精简,适合快速上手与二次修改。已有394人学习下载。围绕飞机对象、子弹碰撞、敌机生成、音效播放和分数系统,完整演示了Pygame窗口初始化、游戏循环、图像加载与屏幕绘制等关键流程;同时涵盖矩形碰撞检测、mixer音效模块和游戏状态控制等实现细节。借助这份资源,读者可以对照代码掌握Python游戏开发的基本框架,并在此基础上扩展道具、关卡或敌方AI,逐步打造属于自己的飞行射击作品。 学完Python基础语法之后,最大的问题往往不是看不懂代码,而是不知道拿这门语言干点什么。写爬虫吧,还没学到requests;做数据分析吧,numpy还没弄明白。这时候最合适的过渡项目,就是动手写一个飞机大战。这个项目用到的都是最基础的东西:变量、循环、条件、函数、类,但你又必须把它们组合起来解决真实问题,整个写下来,对语法的理解和写代码的信心都会上一个台阶。
很多人觉得飞机大战太老土,现在谁还玩这种像素游戏。但换个角度看,它恰好是一个"麻雀虽小五脏俱全"的完整项目:事件处理、游戏循环、碰撞检测、精灵管理、音效播放、界面绘制,这些知识点往后做任何图形界面程序、小游戏,甚至入门游戏引擎,全都用得上。本文不打算只丢一堆代码让你复制粘贴,而是把每一步为什么这么写、哪里容易踩坑一起讲清楚,让你写完还能自己改出花来。
1. 这个经典练手项目到底能练什么
1.1 它是"最小可用游戏"的完整范本
飞机大战的规则一句话就能说完:玩家移动飞机,发射子弹,消灭敌机,别让自己被撞到。规则简单不代表实现简单,它背后覆盖了一个游戏程序最基本的几个模块:
- 事件响应:键盘按下、松开、窗口关闭,这是所有交互程序的根基
- 状态更新:飞机坐标变化、子弹移动、敌机生成,本质是每帧重新计算所有对象的属性
- 渲染输出:把更新后的画面绘制到窗口上,靠的是循环刷新
- 资源管理:图片、音效、字体,怎么加载、怎么释放
- 碰撞判定:矩形与矩形的相交检测,这是游戏逻辑的核心
可以说,几乎所有2D游戏都是在这个框架上加内容。你以后想做打砖块、贪吃蛇、俄罗斯方块,底层逻辑和飞机大战是完全一样的,只是换了个壳。把这个项目吃透,等于掌握了2D小游戏的标准套路。
1.2 为什么选pygame而不是别的方案
刚入门的时候,很多人会在pygame、tkinter、Pygame Zero之间犹豫。我在带新手的过程中发现,选错了库会把大量精力浪费在跟游戏逻辑无关的事情上。
tkinter是Python自带的GUI库,做表单、做工具挺顺手,但它并不是为实时游戏设计的。它的刷新机制、绘图性能、事件模式都偏桌面应用,强行做飞机大战,到后面弹幕多了会明显变卡,而且代码写起来也别扭。
Pygame Zero是pygame的进一步封装,设计目标是给编程教学用,代码确实简洁,但对底层的暴露不够,出了诡异问题很难排查,等你项目跑到后面想加复杂功能,反而不知道怎么下手。
pygame站在中间的位置:它封装了SDL库的底层细节,你不用自己处理像素缓冲和硬件加速,同时又保留了游戏开发的核心概念,精灵、分组、碰撞检测这些机制都是公开的,学完这一套,将来切换到Unity或者Godot,理解成本会低很多。所以我的选择一直是pygame,这也是绝大多数Python游戏教程的默认选项。
| 方案 | 适合场景 | 实时性能 | 学习成本 |
|---|---|---|---|
| tkinter | 桌面工具、表单界面 | 偏低 | 低,但思路偏GUI |
| Pygame Zero | 少儿编程、快速原型 | 中等 | 极低,封装过度 |
| pygame | 2D小游戏、游戏入门 | 较高 | 适中,概念完整 |
2. 环境准备:把Python和pygame一次配好
2.1 Python安装里最容易被忽略的一步
飞机大战项目用纯Python编写,唯一的第三方依赖就是pygame,所以环境搭建并不复杂,但很多新手第一步就卡住了。
在Windows上安装Python时,安装向导第一页有一个复选框:Add Python to PATH,默认是不勾选的。这个勾如果不选,后面在命令行里输入python会提示找不到命令,然后就会陷入"我明明装了Python为什么运行不了"的困惑。我的建议是第一页就把这个勾选上,如果忘了,也可以去"系统属性-环境变量"手动把Python安装路径和Scripts目录加进去,但新手直接用安装器重装一次更快。
版本选择上,建议下载官方最新的Python 3.x稳定版,不要在32位和64位之间纠结,现在的电脑基本都是64位,默认选64位安装包即可。装完在命令行输入python --version,能正常输出版本号,这一步就算过了。
我对新手还有一个额外建议:装完Python后,顺手把pip的源切换成国内镜像。这个操作不做也能装包,但速度差距非常大。具体做法是在用户目录下创建一个pip.ini文件,写入国内镜像源地址,这样后面pip install就是秒下。这一步虽然跟飞机大战没有直接关系,但能让后面的步骤顺畅很多。
2.2 安装pygame与验证
在命令行执行:
pip install pygame正常情况下会看到进度条,装完以后可以用一行命令验证是否成功:
python -m pygame.examples.aliens运行之后如果弹出一个可以玩的外星人游戏窗口,说明pygame已经完整可用。这一步非常推荐做,因为它同时验证了Python环境、pip安装结果、图形窗口显示和pygame自身运行状态,任何一个环节有问题都会在这里暴露出来。
如果执行报错,最常见的就两类:一是pip命令找不到,那大概率是PATH问题;二是externally-managed-environment这样的提示,这是新版Python在部分系统上对外部包管理做的限制,解决办法是用虚拟环境或者给pip加--break-system-packages参数,但新手我更推荐直接用python -m venv创建项目独立的虚拟环境,一劳永逸。
2.3 编辑器:VSCode还是PyCharm
编辑器这个话题经常引起争论,我直接说结论:写飞机大战这个体量的项目,VSCode完全够用,而且启动快、配置简单。在插件市场装上Python扩展和Pylance,打开项目文件夹选好解释器,就能获得完整的代码补全和错误提示。
PyCharm社区版也是免费的,对初学者更友好的一点是它把虚拟环境、解释器选择这些概念做成了图形界面,基本不用记命令。缺点是启动比较慢,项目多的时候内存占用也高。我的真实建议是:哪个已经装了就先用哪个,别在这个环节花太多时间选择困难。真正重要的是你开始写代码。
3. 先把代码骨架立起来:窗口、主循环和事件
3.1 面向对象的整体设计
很多教程一上来就堆了一大段顺序结构的代码,窗口初始化、变量定义、while循环全揉在一起。短时间能跑,但稍微一加功能,代码就乱成一团。我更推荐从一开始就用面向对象的方式组织代码,把整个游戏拆成几个类:
Game:负责窗口创建、主循环、全局状态管理Player:玩家飞机,负责移动和射击Bullet:子弹,负责飞行和消失Enemy:敌机,负责出现、移动和撞击
这样拆分的好处是每个类只操心自己的事,互相之间通过方法调用和数据传递配合,后期想加一个新敌方类型,只要再写一个类,往分组里一丢就行,不需要改动主循环的逻辑。
第一次接触的读者不用紧张,我下面给的代码会一步步来,你不需要一开始就理解全部,先照着敲,跑起来再看每个类的职责。
3.2 游戏主循环为什么是"灵魂"
飞机大战这类实时游戏,本质上是一个无限循环:每一帧重复做三件事,处理输入、更新状态、绘制画面。这个循环就是整个程序的发动机。
import pygame import sys class Game: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((480, 700)) pygame.display.set_caption("飞机大战") self.clock = pygame.time.Clock() self.running = True def handle_events(self): for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False def update(self): pass def draw(self): self.screen.fill((0, 0, 0)) pygame.display.flip() def run(self): while self.running: self.handle_events() self.update() self.draw() self.clock.tick(60) pygame.quit() sys.exit() if __name__ == "__main__": game = Game() game.run()这段代码跑起来以后,屏幕上会出现一个480乘700像素的黑色窗口,然后一直保持,直到你点关闭按钮。窗口大小480x700是有讲究的,这是从早年经典飞机大战素材里继承下来的比例,接近手机竖屏,飞机从上往下飞有足够的纵向空间。你也可以改成自己喜欢的尺寸,但后面所有坐标和素材尺寸都要跟着适配。
pygame.time.Clock对象配合tick(60),作用是让循环每秒钟最多执行60次,也就是60帧。没有这行控制,程序会以电脑能跑多快就跑多快的速度空转,画面动画速度就不固定,在性能好的电脑上天上的子弹会快到看不见。加了这个限制,同样的代码在不同电脑上运行效果才一致。
可以试着把tick(60)改成tick(10)再运行,你会发现窗口操作明显变得"钝",这就是帧率对游戏体验最直接的感受。
4. 玩家飞机、子弹和敌机的核心玩法实现
4.1 玩家飞机:从键盘到屏幕坐标
接下来创建玩家类。我建议在还没有素材图片的时候,先用一个矩形色块代替飞机,保证逻辑先跑通,图片后面再换。这样做的好处是调试阶段不会因为找不到图片而卡住。
class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image = pygame.Surface((60, 70)) self.image.fill((0, 255, 0)) self.rect = self.image.get_rect() self.rect.midbottom = (240, 680) self.speed = 6 def update(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 < 480: self.rect.x += self.speed if keys[pygame.K_UP] and self.rect.top > 0: self.rect.y -= self.speed if keys[pygame.K_DOWN] and self.rect.bottom < 700: self.rect.y += self.speedpygame.sprite.Sprite是pygame内置的精灵基类,它要求子类必须有image和rect两个属性。rect是一个矩形对象,记录了精灵的位置和尺寸,pygame所有碰撞检测和绘制操作都是围绕这个矩形进行的。
移动逻辑里的每一句判断都在防止飞机飞出屏幕:按左键时,先判断左边有没有超出边界,如果left > 0才允许移动,否则按键无效。这种"先判断再行动"的写法,比让飞机飞出去再拉回来要干净得多。如果把上下左右四个方向的边界判断都删掉,你会看到飞机直接跑出屏幕再也找不回来,这就是边界条件的重要性。
在Game.update里,需要传入当前按键状态:
def update(self): keys = pygame.key.get_pressed() self.player.update(keys)这里用的是pygame.key.get_pressed(),它返回当前所有按键的状态元组,适合用于"按住持续移动"的场景。它和事件队列里KEYDOWN的区别在于,事件方式是瞬间触发一次,适合射击,而按键状态是持续性的,适合移动。
4.2 子弹发射:控制频率比控制速度更难
子弹类的核心与玩家类似,但它不需要响应键盘,只需要向上飞行,飞出屏幕就自我销毁。
class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.Surface((6, 14)) self.image.fill((255, 255, 0)) self.rect = self.image.get_rect() self.rect.centerx = x self.rect.bottom = y self.speed = -12 def update(self): self.rect.y += self.speed if self.rect.bottom < 0: self.kill()注意speed是负值,因为屏幕坐标系的y轴是向下增长的,子弹要往上飞,坐标就得减小。kill()是精灵组提供的方法,作用是把这个精灵从所有包含它的精灵组里移除。子弹飞出屏幕后如果不移除,它会一直存在于内存里,每帧还在执行坐标更新,游戏跑得越久,子弹对象堆积越多,内存就越来越大,最终导致卡顿。这种"对象生命周期管理"的意识,写大项目时会非常重要。
子弹发射不能每帧都按一次,否则一次按下会像机关枪一样疯狂喷射,而且完全不受控制。通常的解决办法是记录上一次发射的时间,每次发射前判断间隔是否已超过冷却时间:
def shoot(self): now = pygame.time.get_ticks() if now - self.last_shot > 200: self.last_shot = now bullet = Bullet(self.rect.centerx, self.rect.top) bullets.add(bullet)这里get_ticks()返回的是游戏启动以来的毫秒数,用差值判断而非绝对时间,好处是这个逻辑不受游戏是否卡顿影响,实现起来最直观。200毫秒的间隔意味着每秒最多射出5发,这是经过试玩后手感比较舒服的节奏。想加强火力,把新子弹加进bullets分组,然后在Game.update里调用bullets.update(),其余交给分组自动处理。
4.3 敌机生成:用计时器代替"等够时间"
敌机需要有规律地出现,同时还不能全部挤在同一帧。最简单可靠的方式是设置一个事件定时器,在初始化时注册:
ENEMY_SPAWN = pygame.USEREVENT + 1 pygame.time.set_timer(ENEMY_SPAWN, 1000)然后在事件循环里处理:
if event.type == ENEMY_SPAWN: enemy = Enemy() enemies.add(enemy)set_timer会每隔1000毫秒向事件队列推送一次自定义事件,相当于游戏内的"闹钟"。用USEREVENT + 1是为了自定义事件编号不跟pygame内置事件冲突。
敌机类的实现同样简单:
class Enemy(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image = pygame.Surface((50, 50)) self.image.fill((255, 0, 0)) self.rect = self.image.get_rect() self.rect.x = random.randint(0, 480 - self.rect.width) self.rect.y = -self.rect.height self.speed = random.randint(3, 6) def update(self): self.rect.y += self.speed if self.rect.top > 700: self.kill()敌机从窗口上方出现,初始y坐标是负数,意思是它从屏幕外慢慢飞进来,这样视觉效果更自然。横向位置用random.randint随机生成,飞行速度也有随机波动,这样敌机不会排列成整齐的队伍,游戏更有变化。
关于为什么用set_timer而不是在update里用get_ticks判断,这里说句实话:两种方式都可以,但set_timer的代码更直白,语义清晰,适合新手快速上手。等以后想做更精细的波次管理,比如第一波结束后增强敌机属性,那时候再用get_ticks手动管理也不迟。
4.4 碰撞检测与计分
pygame精灵组提供了现成的碰撞检测方法,处理"子弹击中敌机"这个场景只需一行代码:
hits = pygame.sprite.groupcollide(enemies, bullets, True, True)groupcollide(enemies, bullets, True, True)的意思是对两个精灵组做矩形重叠检测,后面两个True分别表示:有碰撞时删除敌机、有碰撞时删除子弹。返回的字典里,键是被删除的敌机,值是撞到它的子弹列表。每检测到一次碰撞,就加一次分:
for enemy in hits: score += 10玩家被敌机撞到的检测更简单,用spritecollide,只销毁玩家自己:
if pygame.sprite.spritecollide(player, enemies, False): self.running = FalseFalse的意思是碰撞时不要从enemies分组里删除敌机,因为此时玩家都已经挂了,敌机删不删没有意义,保持默认逻辑少干扰。
默认的碰撞检测是基于矩形区域的,也就是说飞机和敌机的图片边缘是纵向尖角的形状,也可能按矩形判定为碰撞,偶尔会出现"明明没碰到却判定撞了"的情况。要不要更精确的碰撞,看你的取舍,这个我在第五章专门讲。
计分文字用pygame自带的字体功能绘制:
font = pygame.font.Font(None, 36) score_surface = font.render(f"Score: {score}", True, (255, 255, 255)) self.screen.blit(score_surface, (10, 10))到这里,一个能玩的基础版飞机大战就完成了:玩家可以移动,可以射击,敌机会从天而降,击中加分,撞到结束。整个项目加起来不到150行,这就是第一个可交付的里程碑。
5. 从"能玩"到"好玩":手感优化的几个大方向
5.1 碰撞检测的精度取舍
矩形碰撞快,但不够精确。飞机两边的机翼位置实际是空的,矩形判定却把空的位置也算进去了,结果就会产生"没碰到但爆炸了"的冤枉感。
换圆形碰撞检测可以改善。原理是把每个精灵缩小成一个圆心和半径,然后计算两个圆心之间的距离,距离小于半径之和就判定碰撞。pygame的Sprite类没有内置圆形碰撞,但你可以写一个简单的方法封装。
圆形碰撞的计算量比矩形大一些,但在飞机大战这种对象数量不超过几十个的游戏里,性能完全不是问题。在我实际测试中,60帧运行时有50个对象在场,矩形和圆形的性能差距根本感知不到。所以我建议,如果你的素材图片形状比较尖,比如飞机有细长的机翼,就优先用圆形碰撞;如果素材本身接近方形,比如陨石、方块敌人,矩形碰撞完全够用,不要为了优化而优化。
5.2 帧率波动的处理方法
代码里tick(60)只是把帧率限制在60,但万一某个帧计算量巨大,比如同时出现了30个敌机,实际帧率可能掉到40甚至更低。帧率掉下来之后,物体移动速度是"每帧移动多少像素"的逻辑,结果就是整个游戏变慢,手感变得黏糊。
解决办法有两种。第一种是把移动量按帧间隔时间放大或缩小,pygame的Clock可以提供一个dt值(上一次tick到现在的秒数),物体每帧移动speed * dt,这样即使帧率波动,物体每秒移动的总距离是恒定的。第二种更简单粗暴:提前做好性能预算,避免在同一帧做大量重复计算,比如敌机生成时做数量上限控制,超过30个就暂停生成。
对新手来说,先做好上限控制就够了。等将来游戏复杂度上去了,再引入dt也不迟。
5.3 图片、音效资源怎么找
先用色块把游戏逻辑跑通之后,下一步就是换成真正的游戏素材。网上很多免费素材站提供像素风飞机素材,搜plane sprites或者shoot'em up assets就能找到一大堆。注意看素材的授权协议,个人练习用基本都没问题,但要商用的话必须逐条核对。
加载图片时有个非常实用的建议:用pygame.image.load加载后,立即调用convert_alpha()方法转换像素格式:
self.image = pygame.image.load("player.png").convert_alpha()这一步能让图片的加载和绘制速度提升不少。如果不做转换,pygame每帧绘制时需要做像素格式转换,图片一多性能就会下降。
音效同理,pygame.mixer.Sound支持wav和ogg格式,枪声、爆炸声、背景音乐都可以从免费音效站找。加载音效的代码放在初始化阶段,不要在游戏主循环里反复加载,否则每播放一次就做一次磁盘IO,会造成明显的卡顿。
5.4 加一点"仪式感":爆炸效果与结束界面
基础玩法完成后,你会发现游戏体验最突兀的地方是:敌机被击中后直接消失,玩家被撞击后黑屏退出,整个过程没有任何反馈。加一个小爆炸效果就能让游戏观感提升一大截。
简单的实现方式是在敌机被击中的位置生成一个特效对象,播放几帧扩散的圆环或粒子,然后自动销毁。复杂度不高,但对"手感"的提升非常明显。玩家被撞后的结束界面也是必须的,至少显示最终得分和"按任意键重新开始"的提示,否则游戏结束后只能关掉窗口重新运行,体验太生硬。
6. 写完之后大概率会碰到的几个坑,我提前帮你们排掉
6.1 图片显示不出来的经典原因
明明文件就在项目文件夹里,pygame.image.load("player.png")却报错FileNotFoundError。这个坑我见得太多了,十有八九是运行脚本时的工作目录问题。你需要知道的是,程序运行时相对路径是相对于"当前工作目录"的,不是相对于代码文件位置的。如果你用VSCode直接运行代码,工作目录通常是打开的文件夹;如果你在终端用python执行,工作目录是当前所在的终端路径。
排查方法很简单:在代码里打印os.getcwd(),看看当前工作目录到底在哪,然后把图片路径改成绝对路径,或者用sys.path[0]这种"代码文件所在目录"来构建路径。千万别用写死的绝对路径,换台电脑就废了。
6.2 游戏用久了越来越卡,表情变"慢动作"
原因前面提过:子弹和敌机飞出屏幕后没有销毁,对象越积越多,每帧更新列表越来越长。排查方法是在运行中打印当前精灵组的长度,看看数量是不是一直涨。解决方案就是把四个方向的出界判断都补上,并且用kill()及时移除。
还有一个隐蔽的卡顿来源是每个循环里都在加载图片或音效。我见过有人把pygame.image.load放在Player.update里的,每次调用都重新从磁盘读文件,帧率直接被拉到15。记住一个原则:所有资源在初始化时加载一次,之后只做引用,绝不在循环里做IO操作。
6.3 点关闭按钮没反应,或者退出后终端卡住
这个问题的根因通常是对事件循环的理解不够。pygame.QUIT事件是点窗口关闭按钮时产生的,必须通过pygame.event.get()把它取出来处理,程序才知道你要退出。如果事件循环里忘了判断QUIT,窗口确实会关掉,但程序主循环还在后台空转,终端就"卡住"了。
另外要注意pygame.quit()和sys.exit()的区别。前者只是卸载pygame模块并释放资源,不会终止程序;后者才是真正退出Python进程。正确的收尾写法是两行都调用,先取消pygame的初始化,再退出进程。顺序反了有可能出现"窗口关不掉"的诡异现象。
6.4 打包成exe时找不到图片素材
用pyinstaller -F game.py打包后发给别人,对方双击运行,第一帧就报错找不到图片。原因在于打包后程序被解压到一个临时目录,原来的相对路径全失效了。这个问题在csdn和知乎上被反复提问,解决思路是在代码里用sys._MEIPASS判断打包环境,资源路径动态切换。
如果只是想练手,我建议先把打包放一放,不要让它打断你写游戏主逻辑的节奏。等游戏功能完整了,再回头研究打包,那个阶段心态会好很多。
最后再分享一个我自己的习惯:每完成一个小功能,就运行一次、玩一分钟。不要闷头写200行再集中调试,那样一旦出问题,定位成本会高到你怀疑人生。飞机大战虽然是个练手项目,但用增量开发的方式走一遍,你感受到的会不是一个"能跑的程序",而是一整套"如何把需求拆小、再逐个击破"的做事方法。这份经验,才是这个老项目到今天依然值得一写的原因。
本文还有配套的精品资源,点击获取