简介:这是一份以 Pygame 库开发经典“飞机大战”游戏的实战讲解资料,主要面向刚接触 Python 游戏开发、希望掌握 2D 游戏基础流程的学习者。内容从 Pygame 核心模块初始化、窗口与事件管理等基础入手,依次展开游戏精灵设计、键盘控制、子弹射击、敌我碰撞检测、游戏循环构建、音效播放与简单动画实现,并给出飞机与敌机精灵组碰撞检测等关键代码片段,适合以此作为入门 Pygame 的练手项目。资源以 RAR 压缩包整体发布,体积约 11.13MB,便于下载后对照文中内容逐步实践。目前已有 192 人浏览学习,对于希望快速搭建飞机大战原型、理解精灵组与碰撞检测机制、完善游戏循环结构的开发者来说,这份材料能够帮助节省自行摸索的时间,并进一步提供可运行的思路参考、关键代码解读与后续拓展建议。
1. 为什么会选 Pygame 来做飞机大战
配好 Python 环境之后,很多初学者卡在第一个图形界面项目上:控制台程序跑得很顺,一碰到窗口、图片、键盘响应就不知道从哪里下手。Pygame 是这类需求里最顺手的答案,pygame安装 只需要一条 pip 命令,核心概念只有窗口、事件、图像绘制三件事。飞机大战又是一个天然完整的项目边界:玩家控制、子弹发射、敌机生成、碰撞判定、计分结算,正好把 Pygame 的主要 API 全部覆盖一遍,做完之后对图形编程的整个流程就有了体感。如果你正在找第一个能拿得出手的 Python 小游戏项目,或者想从命令行过渡到带界面的程序,这个组合值得投入。这篇笔记不按教程文档的章节走,而是按我做这个项目时遇到的实际问题来展开,从最小窗口一直到碰撞检测和性能优化,尽量让你照着敲完就能跑起来。
2. 装好 Pygame 并跑通第一个窗口:最小工程与三项核心初始化
2.1 pygame安装 后的环境确认与版本陷阱
我一般不直接pip install pygame就完事,先确认解释器版本和 Pygame 的匹配关系。Pygame 有两个大版本线,旧的 1.9.x 和新的 2.x,两者在接口上高度兼容,但 2.x 对高分辨率屏幕和 Python 3.10+ 的支持明显更好。建议直接用:
python -m pip install pygame --upgrade python -c "import pygame; print(pygame.version.ver)"第二条命令能打印出版本号,同时确认模块真的装进了当前环境。这里常见的翻车点是装了 Pygame 但运行时报ModuleNotFoundError,十有八九是电脑里有多个 Python,pip 装到了一个解释器,跑代码时用了另一个。我习惯把所有 Python 项目都放进虚拟环境,先创建再安装:
python -m venv plane_envWindows 下激活命令是plane_env\Scripts\activate,macOS 和 Linux 是source plane_env/bin/activate。激活后命令行提示符前面会多出(plane_env)字样,这时候再装 Pygame,一定不会出现模块找不到的问题。这条习惯能省掉后面一整类环境级报错。
2.2 最小窗口的逐行拆解:init、set_mode、clock
Pygame 的最小可运行程序只有十几行,但每一行背后都有一个必须要记得的初始化项。先看代码:
import pygame pygame.init() screen = pygame.display.set_mode((480, 700)) pygame.display.set_caption("飞机大战") clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False clock.tick(60) pygame.display.flip() pygame.quit()pygame.init()会一次性把 mixer、display、font 等所有子系统都初始化,对飞机大战这种小项目来说够了,不用刻意去拆开逐个初始化。set_mode的第一个参数是 (宽, 高) 元组,这里的 (480, 700) 是经典竖屏飞机大战的比例,宽度刚好容下飞机左右移动,高度留出上下空间。窗口尺寸会影响后面所有元素的坐标计算,建议在一开始就决定好,之后不要频繁改动。
set_caption设置窗口标题,纯视觉项,不设置也不影响运行。Clock对象是后面控制帧率的关键,tick(60)表示每秒钟最多循环 60 次,也就是帧率上限 60 FPS。不理解帧率没关系,先记住这个数字,后面在撞见“游戏跑得太快”的问题时你会回来找它。
窗口能显示之后,主循环就位。game loop 的经典结构是:处理事件、更新游戏状态、绘制画面、控制帧率。上面的例子四步都齐了,只是还没有游戏状态而已。pygame.display.flip()把绘制好的内容真正送到屏幕上,新手容易忘这一句,后果是窗口一片黑。
2.3 主循环的骨架为什么必须长这样
有些人会把事件处理简写成if pygame.event.get():,这是取巧写法,会漏掉队列里的其他事件,窗口也关不掉。正确姿势是遍历整个事件队列:
for event in pygame.event.get(): if event.type == pygame.QUIT: running = False事件队列是 Pygame 收集键盘、鼠标、窗口消息的地方。pygame.event.get()取出并清空队列,所以这一句必须放在每一帧循环里执行,否则 Windows 认为你的程序没响应,会提示“未响应”。这个细节在开发初期不痛不痒,到后面加了大量精灵和碰撞检测时,如果事件处理位置不对,窗口卡顿和关闭无响应会同时爆发。
主循环里我还会留一个FPS变量,方便统一调整速度:
FPS = 60 clock.tick(FPS)把它提到文件顶部的好处是,后面调试碰撞手感、子弹速度时,只用改一个地方。游戏开发的常见做法是把画布常量、帧率常量集中放在文件开头,做成一整块配置区,而不是散落在各个类里。
3. 用 Sprite 组织飞机和子弹:对象管理与碰撞检测的正确姿势
3.1 为什么选 Sprite 与 Group,而不是自己写 list
飞机大战里至少有玩家飞机、敌机、子弹三类物体,每一类都有数量变化和增删动作。如果你直接用 Python list 来管理,每增加一颗子弹都要手动往列表里 append,子弹飞出屏幕后还要自己遍历删除,代码很快会变成一大坨“先遍历再判断再 pop”的混乱逻辑。Pygame 提供的Sprite和Group就是专门解决这个问题的。
Sprite是游戏对象的基类,它要求子类有两个核心属性:image(物体的图像)和rect(图像在屏幕上的位置矩形)。Group则可以把它看成“精灵的容器”,容器自带添加、删除、绘制、碰撞检测方法。用它们组织代码,飞机大战的逻辑会变得非常直白:
import pygame class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image = pygame.Surface((50, 60)) self.image.fill((0, 255, 0)) self.rect = self.image.get_rect() self.rect.midbottom = (240, 650) self.speed = 5 def update(self): keys = pygame.key.get_pressed() 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这段代码里我直接画了一个绿色的方块代替飞机贴图,开发初期用色块调试速度是最快的方案,等逻辑全跑通了再替换成真正的 PNG 图片。rect.midbottom是定位关键,它表示矩形底边中点的坐标,飞机放在 (240, 650) 正好是窗口底部中间。rect.x直接修改位置,PySide 在 Group 绘制时会自动读取更新后的 rect 位置,不需要额外调用更新方法。
update方法里我用pygame.key.get_pressed()而不是事件循环里的按键事件,区别在于前者能检测“持续按住”的状态,后者只能感知“按下的一瞬间”。移动操作必须用get_pressed,否则按住方向键会变成一格一格地跳。
3.2 用 Group 统一管理精灵对象
定义了类之后,要把它们放进 Group 里统一管理:
all_sprites = pygame.sprite.Group() player = Player() all_sprites.add(player) while running: # 处理事件... all_sprites.update() all_sprites.draw(screen) pygame.display.flip()all_sprites.update()会调用 Group 里每个 Sprite 的 update 方法,all_sprites.draw(screen)会把每个精灵的 image 按 rect 位置绘制到屏幕上。这两行替代了你手写的大部分循环代码。注意顺序:先更新位置再绘制,否则玩家按下方向键那一帧的位移要等到下一帧才显示,手感上慢半拍。
标题里的飞机大战到这里已经跑通了一半:窗口有了,玩家飞机有了,移动也灵了。但只有一架飞机不叫游戏,下一节把子弹加上,飞机大战的核心玩法就会出现。
3.3 子弹生成与碰撞检测的两种写法
子弹适合用一个Bullet类,它和最基础的飞机类唯一的区别是每帧向上移动:
class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.Surface((5, 10)) self.image.fill((255, 255, 255)) self.rect = self.image.get_rect() self.rect.centerx = x self.rect.bottom = y def update(self): self.rect.y -= 10 if self.rect.bottom < 0: self.kill()kill()是 Sprite 的重要方法,它会把自身从所有 Group 中移除。子弹飞出发屏幕之后自动消灭,这正是 list 管理最麻烦的部分,用 Group 一行解决。
生成子弹的地方放在玩家类里而不是主循环里,让 Player 类自己管发射逻辑:
class Player(pygame.sprite.Sprite): def __init__(self): # 前面的代码... self.bullets = pygame.sprite.Group() def shoot(self): bullet = Bullet(self.rect.centerx, self.rect.top) self.bullets.add(bullet)主循环里检测空格键,每隔几帧发射一颗子弹。这里的间隔控制可以用计数器,也可以用后面要讲的自定义事件。碰撞检测用 Group 的groupcollide最省事:
hits = pygame.sprite.groupcollide(bullets, enemies, True, True)四个参数分别是子弹组、敌机组、子弹是否消失、敌机是否消失。两个True表示子弹和敌机同归于尽,返回值hits是一个字典,键是被打中的敌机,值是打中它的子弹列表。如果只希望子弹消失、敌机掉血,就把第四个参数改成 False,配合字典做扣血逻辑。这里就是“能抄作业”的区域,飞机大战的打击判定骨架搭好之后,剩下全是填参数。
4. 把玩法做完整:敌机生成、难度曲线、计分与音效
4.1 用自定义事件替代随机循环生成敌机
很多初学者生成敌机的第一直觉是写一个死循环不断创建敌机对象,这完全错误,游戏主循环本身就是一个死循环,再嵌一个只会把整个程序卡死。Pygame 给出的是自定义事件配合定时器的方式:
CREATE_ENEMY = pygame.USEREVENT + 1 pygame.time.set_timer(CREATE_ENEMY, 1200)pygame.time.set_timer表示每隔 1200 毫秒自动向事件队列投递一个类型为CREATE_ENEMY的事件。主循环里这样处理:
for event in pygame.event.get(): if event.type == CREATE_ENEMY: enemy = Enemy() enemies.add(enemy) all_sprites.add(enemy)敌机类可以继承前面 Player 的结构,只是方向相反、移动速度略慢。这种写法的好处是游戏循环不被阻塞,敌机的生成频率可以独立调整,不会受到画面绘制速度的干扰。飞机大战的节奏感完全由这个定时器的间隔决定,所以要把间隔值抽成变量,而不是写死在set_timer里。
4.2 难度曲线怎么定:一个可调的参数表
敌机生成频率是难度曲线的第一张牌。我的经验是从 1200 毫秒开始,随分数每 100 分减少 50 毫秒,最低别低于 400 毫秒,否则屏幕会被敌机铺满,玩家在低等级时根本没有生存空间。移动速度同理,敌机速度控制在 2 到 6 之间,数值越大越快。下面是我常用的一组参数:
| 参数 | 初始值 | 变化规则 | 下限 |
|---|---|---|---|
| 敌机生成间隔 | 1200ms | 每 100 分减 50ms | 400ms |
| 敌机移动速度 | 3 | 每 200 分 +0.5 | 7 |
| 玩家子弹速度 | 10 | 不变化 | - |
| 玩家移动速度 | 5 | 不变化 | - |
子弹速度和玩家速度不建议随意调快,它们直接影响碰撞手感,速度过快时会穿过敌机,碰撞检测根本来不及触发。普遍做法是保持一个稳定的参考帧率,让所有移动量都基于帧率来计算,后面第五章会展开讲这个坑。
难度曲线的实现位置放在主循环的分数更新逻辑里:
if score % 100 == 0 and score != 0: new_interval = max(1200 - (score // 100) * 50, 400) if new_interval != current_interval: pygame.time.set_timer(CREATE_ENEMY, new_interval) current_interval = new_intervalmax函数保证间隔不会低于 400 毫秒。这段逻辑是游戏后期可玩性的核心,没有难度曲线,玩家三分钟就腻了。难度曲线是飞机大战这个标题区别于“静态演示”的关键一步。
4.3 计分、生命与背景卷轴:数据与视觉的同步
计分用 Pygame 自带的字体模块就能实现,不需要额外字体文件:
font = pygame.font.Font(None, 36) def draw_score(screen, score): text_surface = font.render(f"Score: {score}", True, (255, 255, 255)) screen.blit(text_surface, (10, 10))font.render的第一个参数是文字内容,第二个参数是抗锯齿开关,第三个是颜色元组。将分数绘制到界面左上角。分数在碰撞检测时更新,在 4.1 节的groupcollide返回值上累加。
生命值用一个全局数字即可,减为零时游戏结束,主循环running = False。这里有个容易忽略的细节:游戏结束时不能直接pygame.quit(),应该先显示结束画面,等待玩家按键再退出。常见做法是把主循环状态拆成GAME_RUNNING和GAME_OVER两个变量来控制。
背景卷轴是飞机大战的视觉灵魂。一个简单可靠的方案是两张图片上下拼接滚动:
class Background: def __init__(self): self.image = pygame.image.load("background.png").convert() self.y1 = 0 self.y2 = -700 def update(self): self.y1 += 2 self.y2 += 2 if self.y1 >= 700: self.y1 = -700 if self.y2 >= 700: self.y2 = -700 def draw(self, screen): screen.blit(self.image, (0, self.y1)) screen.blit(self.image, (0, self.y2))y2初始为 -700 意味着它一开始完全在屏幕上方之外,随着滚动逐渐露出底部,形成无缝循环。背景移动速度一般定为 1 到 3,太快会让人头晕,太慢则没有飞行感。背景卷轴与玩家移动速度是两条独立的逻辑线,背景只是视觉层,不影响碰撞判定。
5. Pygame 飞机大战踩坑实录:5 个最容易翻车的位置
5.1 窗口闪一下就自动关闭
现象是运行程序后窗口出现一瞬就消失,代码退出。原因通常是主循环没有正确进入,或者运行到某个异常导致程序崩溃。解决方法是先用最简窗口代码跑通,逐步添加功能。如果已经加了精灵,就在主循环里捕获异常:
try: while running: # ... except Exception as e: print("发生异常:", e) pygame.quit()print会把异常信息输出到控制台,能看到具体是哪一行报错。很多写 Pygame 的新手不开异常捕获,出错了只能看到一个黑框闪过,非常抓瞎。启动时加一句print("game start")作为标记,能快速确认程序是否真的进入了主循环。
5.2 游戏窗口卡死,拖动时显示“未响应”
现象是窗口能显示,但鼠标一拖动就转圈,或者按键没反应。原因多数是主循环里做了耗时操作,比如在循环里加载大图片或执行大循环,导致事件队列来不及处理。解决方法是把耗时操作移到主循环之前完成,主循环里只保留位置更新和绘制。如果主循环里有复杂的碰撞检测导致变慢,可以用金字塔式的局部碰撞检测,即先检测大的 body 矩形,再精确检测图像掩码,不要一开始就对整张图做像素级检测。
5.3 角色移动出现残影
现象是飞机移动时后面拖着一连串的尾巴。原因是上一帧的画面没有清理。解决方法是先填充背景色,再绘制所有精灵:
screen.fill((0, 0, 0)) all_sprites.draw(screen)黑色的fill把旧位置全部盖掉,注意背景卷轴图片存在时不能只fill黑色,要先画背景再画精灵,顺序是背景、玩家、子弹、敌机、UI。如果这个顺序搞反,UI 会被精灵盖住,分数和生命值显示不全。
5.4 碰撞检测不准,子弹穿过敌机
现象是子弹飞过敌机却没有触发打击,或者敌机没碰到飞机就判死亡。原因主要有两类,一是双方移动速度过快,两帧之间物体移动距离超过对方尺寸,发生“穿透”;二是用colliderect检测边界碰撞,但图片有透明区域。解决方法是限制速度上限,保证单帧移动距离小于精灵的最小边长,或者用collide_mask精确到像素:
hits = pygame.sprite.groupcollide(bullets, enemies, True, True, pygame.sprite.collide_mask)collide_mask会基于图片的非透明区域判定,对不规则的 PNG 图形效果好得多。代价是性能比矩形碰撞差,子弹多的时候会卡。所以我的习惯是子弹速度控制在 15 以内,矩形碰撞为主,图片透明度过大时才改用 mask。
5.5 游戏开始时帧率正常,敌机变多后越来越卡
现象是玩到后期屏幕上的敌机和子弹一多,操作明显卡顿。原因有两方面,一是每个精灵都做一次碰撞检测,规模到了 O(N*M) 级别;二是存在大量超出屏幕范围的精灵没有被回收。解决方法是给所有子弹和敌机设置一个“出界即杀”的逻辑,这在前面的 Bullet 类里已经做过一次,要确保敌机类也有同样的判断。全局设置一个max_enemies上限,超过之后暂停生成。另一个有效做法是把groupcollide拆成两步,先检测子弹与敌机,再检测敌机与玩家,玩家相关的检测只用 1 次而不是 N 次。
6. 上线前先做这一步:帧率、对象回收与重复代码的验证技巧
把帧率显示在窗口标题栏上,是我做飞机大战这个项目时保留到比赛前的习惯。标题栏实时显示 FPS,比任何日志都直观:
def get_fps(clock): return str(int(clock.get_fps())) pygame.display.set_caption(f"飞机大战 - FPS: {get_fps(clock)}")放在主循环最后一行,每帧刷新一次,卡顿出现时能立刻看到帧率跌到多少。这比凭感觉去调参靠谱得多,我自己第一版游戏就是靠这个发现后期帧率掉到 30 帧以下才决定加对象回收上限的。
对象回收的逻辑我最后单独抽成了一个函数,而不是散落在各个 update 里:
def clean_offscreen(group): for sprite in group: if sprite.rect.bottom < 0 or sprite.rect.top > 700: sprite.kill()子弹有同样的kill判断但敌机没有,就是因为敌机的回收逻辑后来加在了一个统一函数里。开发过程中这类问题很容易出现,所以建议在写每个精灵类的第一时间就把出界判断写好,后面不要依赖“之后再补”的侥幸心理。
Pygame 里有个不太起眼的sprite.spritecollideany方法,非常适合做玩家和敌机的检测,它返回碰到的第一个敌机对象,比groupcollide更轻量:
hit = pygame.sprite.spritecollideany(player, enemies) if hit: lives -= 1 enemies.remove(hit)这个方法只做“碰没碰到”的判断,不像 groupcollide 那样要生成整个字典,性能开销更小。对于玩家这种每帧只需要一次检测的场景,它是最合适的选项。这个细节是我在优化后期帧率时发现的,替换之后帧率立刻回升了七八帧,属于性价比极高的改动。
标题写到结尾,我最想强调的一句教训是:飞机大战的复杂度上限远比你预期的高,但只要把主循环、精灵更新、碰撞检测三件事彻底写透,你收获的不只是一个能玩的游戏,而是一套能迁移到后续任何 Pygame 项目的骨架。如果你照着这篇笔记搭出了第一个窗口,希望帮到你的是那些我在翻车之后才明白的参数边界和排查顺序,让你少走一遍我走过的弯路。
本文还有配套的精品资源,点击获取