简介:一份基于Pygame实现经典飞机大战的完整入门资源,适合Python游戏开发初学者和对2D游戏原理感兴趣的学习者。资源围绕Pygame核心用法展开,系统介绍窗口与显示管理、事件循环、精灵类封装、飞机移动控制、敌机生成、子弹射击、组间碰撞检测、音效与动画切换等关键模块,并给出可运行的代码示例和道具、升级、积分等扩展思路,便于读者从零搭建一款可玩的基础飞机大战游戏。压缩包采用rar格式,总大小11.13MB,文件总数显示为0(上游未提供具体文件明细),结构与代码逻辑清晰,可直接作为独立练习或课程设计参考。目前已有192人浏览学习,对希望快速上手Pygame项目的新手具有不错的参考价值。
1. 为什么用Pygame写飞机大战:先跑通再说进阶
用Pygame写飞机大战,是很多Python学习者第一次把循环、事件和对象状态揉在一个程序里的体验。它不算最简单的入门项目,却是性价比最高的中型demo:窗口初始化、精灵管理、碰撞检测、音效播放,每个环节都能立刻看到反馈,改一行代码屏幕上就有肉眼可见的变化。这里把整套实现拆开讲,从环境搭建到最后的音效和道具,每个步骤都会交代参数含义和常见翻车点。适合已经会基本Python语法、想做出第一个「能玩」的程序的人,也适合要快速用Python验证游戏原型的开发者;如果你只想跑通一遍,跟着前四章走就够了。
2. 环境搭建与Pygame核心模块:先让窗口转起来
2.1 安装与版本辨认:pygame下载时最容易踩的坑
安装Pygame,最直接的方式是pip install pygame。如果你习惯从pygame官方下载页拿安装包,注意选对应Python版本的wheel文件——Windows下装错版本,会在import阶段直接报错,最常见的是ModuleNotFoundError或DLL load failed。装完后跑一句检查:
import pygame print(pygame.version.ver)能打印出版本号,说明环境正常。这里有个细节值得多说一句:pip install pygame安装的是当前解释器对应的版本,如果你机器上同时有Python 3.8和3.11,务必确认pip属于哪个解释器,否则会出现「明明装了却import不到」的尴尬。
还有一类问题是装到了虚拟环境外。用PyCharm或VS Code创建项目时,如果解释器指向的是venv,而pygame装进了全局环境,同样会import失败。我一般会先在终端执行python -m pip install pygame,指定用当前项目的python解释器来装,再从编辑器里跑一条import确认,两步都过了再开始写代码。
2.2 初始化与窗口管理:init() 为什么不能省
Pygame的init()会一次性初始化display、mixer、font等所有子模块,省去逐个调pygame.display.init()、pygame.mixer.init()的麻烦。实践中你会发现,忘记调用init()直接set_mode,会抛pygame.error: video system not initialized。所以第一段代码永远是这两行:
import pygame pygame.init() screen = pygame.display.set_mode((800, 600)) pygame.display.set_caption("飞机大战")set_mode接收一个元组参数,这里是800×600,决定窗口尺寸;第二个参数可以传pygame.RESIZABLE或pygame.FULLSCREEN,但飞机大战用固定窗口更省事。set_caption是窗口标题,Windows下还会出现在任务栏,建议给一个中文名或者英文名,调试时能一眼认出是哪个进程。
这段代码执行后,屏幕上会出现一个黑色窗口,但还没有画面刷新。Pygame的所有绘制都是先画在后备缓冲区,再通过flip或update一次性呈现,这样设计是为了避免绘制过程中出现画面撕裂,后面主循环里会看到。
这里还有一个和资源管理相关的建议:把图片、音效统一放在项目根目录的assets文件夹下。Pygame对资源路径没有强制要求,但你往后加功能时会发现,路径混乱是大型项目跑不起来的头号原因之一。
2.3 事件循环的第一印象:event.get() 是个队列
窗口能打开还不够,还需要让程序响应关闭、按键等操作。Pygame内部维护一个事件队列,pygame.event.get()会取出队列中所有事件。飞机大战里最基础的事件是QUIT,也就是用户点窗口右上角的关闭按钮:
running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False这段代码的意思很直白:循环里不断查询事件队列,一旦遇到QUIT就退出循环。注意pygame.event.get()会把队列清空,同一批事件如果被读取两次,第二次拿到的会是空列表,这个特性在第五章会成为一个典型的坑。
事件类型不止QUIT一种。飞机大战中最常用的是三个:
pygame.QUIT:窗口被关闭。pygame.KEYDOWN:键盘被按下,事件里有event.key,对应K_UP、K_DOWN、K_SPACE等常量。pygame.MOUSEBUTTONDOWN:鼠标按下,event.pos是点击坐标。
按键事件有一个细节:每个按键事件在按下时只触发一次,不会重复触发。如果想让飞机在按住方向键时持续移动,用事件驱动是做不到的,需要用pygame.key.get_pressed()。第3章的完整飞机类里会给出两种方式对比,这里先记住事件是一次性的。
到这里,一个能开窗口、能正常退出的Pygame程序已经成立。很多人急着写游戏逻辑,但我建议先把窗口拖动、最小化、恢复这些操作跑两三分钟,确认基础行为正常。窗口异常往往是显示设置或Pygame版本导致,和自己代码关系不大,尽早发现可以少排查一堆无关问题。
3. 精灵类设计:把飞机、子弹、敌机都封装成Sprite
3.1 飞机精灵:image 与 rect 是 Sprite 的约定
Pygame的pygame.sprite.Sprite是一个基础类,它要求子类至少有两个属性:image用来存放绘制的表面,rect用来记录位置和碰撞范围。官方文档里把这两个属性叫「约定」,因为你创建精灵后,把对象丢进Group,draw方法会读取image在rect位置绘制。自己动手写的时候,建议用一个独立的方法来加载图片,而不是直接在__init__里硬编码路径:
import pygame class Plane(pygame.sprite.Sprite): def __init__(self, img_path, center_pos=(400, 500)): super().__init__() self.image = pygame.image.load(img_path) self.rect = self.image.get_rect() self.rect.center = center_pos self.speed = 10 def update(self, events): for event in events: if event.type == pygame.KEYDOWN: if event.key == pygame.K_UP: self.rect.y -= self.speed elif event.key == pygame.K_DOWN: self.rect.y += self.speed这里get_rect()会根据图片的宽高生成一个矩形,rect.center用于在屏幕上定位飞机的中心点。speed控制每次按键移动的像素数,10这个值对于800×600的窗口来说手感比较适中,如果想更灵敏可以改到15,但要注意改完后飞机移动距离变大,横向躲避时的精度会下降。
但这里有个明显的缺陷:按一次键移动一次,而且没有处理左右移动。以上面的竖版飞机大战为例,左右移动可以用K_LEFT和K_RIGHT来处理。不过更好的做法是把移动放到update里用按键状态持续检测,而不是依赖一次性事件:
def update(self, events, keys_pressed): if keys_pressed[pygame.K_LEFT]: self.rect.x -= self.speed if keys_pressed[pygame.K_RIGHT]: self.rect.x += self.speed if keys_pressed[pygame.K_UP]: self.rect.y -= self.speed if keys_pressed[pygame.K_DOWN]: self.rect.y += self.speed self.rect.clamp_ip(screen_rect)keys_pressed来自pygame.key.get_pressed(),返回一个布尔数组,持续按住时对应元素为True。clamp_ip会把rect限制在screen_rect范围内,防止飞机飞出窗口后找不回来。我个人习惯在移动逻辑加完后再限制边界,这样视觉上卡在边缘,而不是直接消失。
把两种方式对比一下就能看到:事件驱动适合做菜单、暂停这类「按一下响应一次」的场景;持续检测适合玩家操控的主角。飞机大战里这两个用法会同时出现,玩家操控用持续检测,开火按键可以两种方式都行,看你是要单发还是连发。
3.2 子弹与敌机:三个类的职责边界
飞机有了,子弹和敌机就是两个独立的精灵类。子弹负责向上移动,敌机负责向下移动,两者都不需要接受按键事件,所以它们的update方法更简单:
class Bullet(pygame.sprite.Sprite): def __init__(self, img_path, start_pos): super().__init__() self.image = pygame.image.load(img_path) self.rect = self.image.get_rect() self.rect.center = start_pos self.speed = 15 def update(self): self.rect.y -= self.speed if self.rect.bottom < 0: self.kill()敌机类似,只是y方向向下。区别是敌机的出生位置通常在窗口上方,子弹的出生位置在飞机机头附近。为了保证敌机出现的随机感,可以在生成时把x坐标取窗口宽度内的随机值:
import random class Enemy(pygame.sprite.Sprite): def __init__(self, img_path): super().__init__() self.image = pygame.image.load(img_path) self.rect = self.image.get_rect() self.rect.center = (random.randint(40, 760), -20) self.speed = random.randint(3, 7) def update(self): self.rect.y += self.speed if self.rect.top > 600: self.kill()这里random.randint(40, 760)生成初始x坐标,预留了左右边缘各40像素的缓冲,避免敌机紧贴屏幕边缘,视觉上不自然。speed取3到7的随机值,让同样一波敌机有快有慢,比所有敌机匀速下降更有层次感。
注意子弹和敌机在移出屏幕边界时都会调用kill(),这个方法的含义是将自己从所属的所有SpriteGroup中移除。如果不在边界处销毁,子弹和敌机会一路飞出屏幕,但对象仍然存在于Group中,数量越来越多,最终拖慢游戏帧率。这是新手最容易忽略的「内存泄漏」——在游戏里通常叫对象未回收。
3.3 精灵组:为什么把对象交给 Group 而不是自己管理列表
把对象交给Group管理,核心价值在于update和draw。group.update()会依次调用组内每个精灵的update方法,group.draw(screen)会把每个精灵的image绘制到屏幕。这意味着你的主循环可以写成:
player_group = pygame.sprite.Group(Plane('assets/player.png')) enemy_group = pygame.sprite.Group() bullet_group = pygame.sprite.Group()后面每一帧只需要调一次player_group.update(events, keys_pressed),所有精灵的状态都会同步推进,不需要自己写for循环遍历。Group还提供add()和remove()方法,可以在运行时动态增减成员。敌机生成、子弹发射、爆炸销毁,这些操作背后都是同一个机制:
bullets.add(Bullet('assets/bullet.png', (player.rect.centerx, player.rect.top)))上面这行代码把一颗新子弹加入bullet_group,start_pos取的是玩家飞机当前中心点的x坐标和机头顶部的y坐标,这样子弹看起来是从机头射出的,而不是从飞机中心射出。如果你偷懒传成player.rect.center,子弹会从飞机正中间冒出来,视觉上会穿模。
4. 游戏主循环与碰撞检测:把「能跑」变成「能玩」
4.1 主循环的骨架:事件、更新、绘制三步走
现在把精灵和组串起来。一个完整的飞机大战主循环由事件处理、状态更新、画面绘制三部分构成,这是Pygame的标准骨架:
clock = pygame.time.Clock() running = True while running: events = pygame.event.get() for event in events: if event.type == pygame.QUIT: running = False # 更新游戏状态 keys_pressed = pygame.key.get_pressed() player_group.update(events, keys_pressed) enemy_group.update() bullet_group.update() # 绘制游戏画面 screen.fill((0, 0, 0)) player_group.draw(screen) enemy_group.draw(screen) bullet_group.draw(screen) pygame.display.flip() clock.tick(60) pygame.quit()这里有几个细节值得展开。event.get()只调用一次,把结果存进events变量,再传给player_group.update(events, keys_pressed),这样避免同一事件被处理多次。keys_pressed是独立于事件系统的按键状态查询,持续按住方向键时返回True,配合主角的移动逻辑每帧都检查,比事件驱动的手感顺滑得多。fill((0,0,0))用RGB值填充背景,纯黑是0,0,0;如果你要星空背景,可以在资源里准备一张背景图,在fill之后用screen.blit把图片画上去。
clock.tick(60)是整个循环的节拍器,它让每帧耗时至少为1/60秒。没有这行代码,游戏会以最高帧率运行,不同电脑上速度完全不同,子弹飞得像闪电,敌机快得看不清,这就是「帧率绑定问题」。
提示:Clock对象的
tick()返回值是上一帧到当前帧的毫秒数,调试性能时可以打印它。稳定在16到17毫秒说明帧率正常。
4.2 碰撞检测:groupcollide 的参数是重点
碰撞检测是飞机大战的核心机制。Pygame提供两个常用接口:pygame.sprite.spritecollide用于单个精灵与一组精灵碰撞,pygame.sprite.groupcollide用于两组精灵之间的碰撞。飞机大战里子弹打敌机、敌机撞飞机,都是组与组的碰撞:
collisions = pygame.sprite.groupcollide(bullet_group, enemy_group, True, True) for hit_enemies in collisions.values(): for enemy in hit_enemies: score += 10groupcollide的签名是groupcollide(group1, group2, dokill1, dokill2),前两个参数是要检测的两组,后两个参数控制碰撞发生后是否销毁对应组内的精灵。这里传True, True,表示子弹打中敌机后,子弹销毁、敌机也销毁。如果有一个True一个False,就会出现子弹穿过敌机或者敌机挂着子弹继续飞的怪象。
返回值是一个字典,键是第一组中的精灵(被撞的子弹),值是第二组中被撞到的精灵列表。上面代码里,每次碰撞代表一颗子弹可能同时击中多架敌机,所以score按击中的敌机数量加分,而不是按碰撞次数加。这里还有一个排序问题:遍历字典时顺序不固定,但对加分结果没影响。
如果用spritecollide,常见场景是玩家飞机和敌机碰撞:
hits = pygame.sprite.spritecollide(player, enemy_group, True) if hits: lives -= 1spritecollide的第一个参数是单个精灵,第二个是组,第三个True表示碰撞到的敌机从组中移除。返回值是碰撞到的精灵列表,这里只要列表非空就扣一条命。如果你不做无敌帧处理,玩家会在碰撞后的连续几帧里反复掉血,我一般在扣血后把玩家设为短暂无敌,用计时器控制。
4.3 分数与游戏状态:从无限循环到有胜负的循环
有碰撞之后,还缺一个游戏结束的条件。最简单的方式是给玩家增加生命值,被撞一次减一,生命归零就结束循环。这里用几个变量控制状态:
score = 0 lives = 3 game_state = "playing"然后把碰撞处理和状态切换放到同一段逻辑里。生命归零时不再是running = False直接退出,而是把game_state改成"gameover",循环继续绘制最后一帧画面,用户可以看清自己的分数后按任意键退出。这样设计比直接黑屏退出友好得多,体验上更像一个成品游戏。
分数和生命值放在循环外定义为整数,主循环里只做修改。变量需要跨多帧保持,不能定义在while内部,否则每帧都会重置。很多翻车案例正是因为把running定义在循环里面,导致quit事件改变不了循环条件,程序点关闭按钮没反应,这个坑在第五章对应案例里会再讲一遍。
5. 飞机大战避坑指南:最常见的5个翻车点
5.1 事件被重复获取:飞机按键失灵,敌机动一下停一下
现象:飞机移动时有时灵有时不灵,按一次方向键会连续跳动,或者完全没反应;敌机和子弹也卡顿。
原因:主循环里既调用了pygame.event.get(),又在player_group.update()里重新调用了一次。第一次调用已经把事件队列清空,第二次拿到的是空列表,按键事件根本没有传到update方法。
解决:event.get()只调用一次,把返回值存进变量共用:
events = pygame.event.get() for event in events: ... player_group.update(events, pygame.key.get_pressed())5.2 帧率不锁:敌机在慢机器上飞得飞快
现象:在自己电脑上正常的游戏,拷贝到另一台机器上敌机快得像开了加速,子弹也看不清;配置差的机器上反而卡成幻灯片。
原因:主循环没有限定帧率,而敌机的移动是每帧固定加几像素。高帧率机器每秒执行的帧数多,敌机每秒位移量就大。这是典型的frame-rate dependent问题,排查的时候最容易觉得是玄学,其实看一眼clock.tick有没有写就清楚了。
解决:引入pygame.time.Clock,在循环末尾调用clock.tick(60),把帧率锁在60。如果游戏逻辑复杂导致达不到60,改成tick(30)并重新调整速度和血量即可,不要只调速度不调帧率。
clock = pygame.time.Clock() while running: ... clock.tick(60)5.3 图片加载报错:路径和初始化顺序
现象:启动时抛出pygame.error或FileNotFoundError,程序直接退出。
原因:原因有两种。一是图片路径写错,相对路径依赖当前工作目录,直接从不同目录启动脚本时会找不到文件。二是图片加载发生在set_mode之前,pygame.image.load需要display已经初始化,否则报video system not initialized。
解决:把资源文件夹固定为项目根目录下的assets,用os.path.join拼接路径,避免手写反斜杠转义;程序入口处先执行pygame.init()和set_mode,再加载图片和音频。最好的后悔药是提前统一路径,别等到项目里几十张图片再来整理。
import os image_path = os.path.join("assets", "player.png")5.4 groupcollide参数顺序记反:子弹穿过敌机
现象:子弹打中敌机没有反应,或者子弹消失了敌机还在,更严重的是玩家碰到敌机后自己一直在掉血。
原因:groupcollide后面两个dokill参数顺序是固定的,第一组的精灵销毁由dokill1控制,第二组由dokill2控制。很多人只记得「第一组死、第二组死」,把组顺序放反了,导致本该销毁的敌机活了下来。
解决:每次写碰撞检测都在注释里标明组的含义:
# groupcollide(子弹组, 敌机组, 子弹销毁, 敌机销毁) pygame.sprite.groupcollide(bullet_group, enemy_group, True, True)玩家与敌机的碰撞同理,spritecollide的第三个参数是敌机是否销毁,设False的话敌机会一直贴着玩家,帧帧判定。
5.5 音效不出声:mixer 初始化时机
现象:画面正常、动画正常,但pygame.mixer.Sound.play()没有声音。
原因:mixer没有初始化。pygame.init()在多数系统上会初始化mixer,但部分平台或某些版本的SDL实现会跳过,需要手动调用pygame.mixer.init()。
解决:在pygame.init()之后补一行pygame.mixer.init(frequency=44100, size=-16, channels=2),再加载音频。文件格式上,Sound类对WAV和OGG支持最稳定,MP3在Windows下可能出现无法播放或卡顿。
pygame.mixer.init(frequency=44100, size=-16, channels=2)最后提醒一句:避坑章节里的这些问题,很多不是Pygame特有的,而是游戏开发里通用的设计习惯。比如帧率绑定问题,换成Unity和Godot同样存在;事件队列被重复消费,在框架里对应的是「事件是否已完成处理」的状态管理。把这几类问题想清楚,再回头看这套飞机大战代码,你会知道哪些地方是「功能」、哪些地方是「性能」。
6. 进阶验证:音效、帧动画与最简单的道具系统
前面把骨架跑通以后,剩下的是给游戏加手感。先讲帧动画——飞机和敌机的形象不动,游戏会很「死板」。常见做法是准备多张连续帧图片,在精灵内部维护一个帧索引和时间积累,每隔一定帧数切换一张。Pygame没有内置动画系统,但用Clock的tick计数实现起来很直接:
class AnimatedEnemy(pygame.sprite.Sprite): def __init__(self, image_paths): super().__init__() self.frames = [pygame.image.load(p) for p in image_paths] self.index = 0 self.counter = 0 self.image = self.frames[0] self.rect = self.image.get_rect() self.speed = 3 def update(self): self.counter += 1 if self.counter >= 3: self.counter = 0 self.index = (self.index + 1) % len(self.frames) self.image = self.frames[self.index] self.rect.y += self.speed if self.rect.top > 600: self.kill()这里每3帧切换一次image,飞机看起来就有轻微摆动的动态效果。counter和index两个变量是帧动画的常规组合,前者控制切换频率,后者循环读取帧列表。继承之前的Enemy类可以少写重复代码,但独立写出来更直观。
音效方面要注意音频格式。pygame.mixer.Sound支持WAV和OGG,MP3在部分平台上有问题,建议资源统一转成WAV再加载。播放时不要每帧调用play(),射击音效要记录上次发射时间,间隔超过0.2秒才允许再次播放,否则连续按键会炸成一团噪声。这个「冷却时间」的思路也适用于道具系统。
道具系统可以做最简单的「双倍火力」。实现思路是:敌机被击毁时以一定概率生成PowerUp精灵,放进单独的道具组;玩家飞机与道具组碰撞后,进入持续10秒的双倍子弹状态。状态切换用power_active和power_timer两个变量,每帧递减timer,归零时恢复普通子弹。这套逻辑会大量用到前一章的groupcollide,只是把碰撞的两组换成玩家和道具。
验证方法很简单:把游戏完整跑一轮,敌机从生成到被击毁、玩家从满血到game over,全程观察终端有没有报错;再把tick改为30和60各跑一次,确认子弹和敌机的移动速度没有明显差异。从那以后,我每次给这个项目加新功能,都会先固定tick(60)跑一遍确认帧率稳定,再往下写逻辑——这个习惯帮我挡掉了大量「看起来是玩法bug、其实是性能问题」的排查,希望帮到你。
本文还有配套的精品资源,点击获取