Python实战:用Pygame从零开发飞机大战游戏

发布时间:2026/10/21 18:26:59
Python实战:用Pygame从零开发飞机大战游戏
1. 从零构建飞机大战为什么选择Pygame而不是别的框架很多人第一次接触Python游戏开发脑子里冒出来的第一个问题就是我该用什么库市面上能选的方案其实不少比如Pygame、Arcade、Pyglet甚至用Tkinter硬画。但如果你问一个写过几年Python小游戏的人大概率会告诉你——从Pygame开始。原因不复杂。Pygame本质上是对SDLSimple DirectMedia Layer的一层Python封装SDL是一套非常成熟的底层多媒体库处理图形渲染、声音播放、事件循环这些事已经干了二十多年。Pygame把它的门槛降到了“会写Python函数就能用”的程度同时又没有把底层逻辑完全藏起来。这意味着你在写飞机大战的过程中能真正理解游戏循环、帧率控制、碰撞检测这些核心概念是怎么运转的而不是调一个黑盒API就完事。另一个现实因素是资料丰富度。Pygame的社区存在时间足够长你遇到的大多数问题——比如窗口不响应、图片加载失败、碰撞检测不准——都能找到大量讨论。Arcade虽然API更现代但遇到冷门问题时排查成本明显更高。对于飞机大战这种2D竖版射击游戏来说Pygame的能力完全够用甚至可以说绰绰有余。还有一点容易被忽略Pygame对新手友好体现在它的“容错性”上。你不需要理解渲染管线、着色器、GPU加速这些概念只需要知道“每一帧我把图片画到某个坐标上”就行。这种心智模型和飞机大战的游戏逻辑天然契合——飞机在动、子弹在飞、敌机在往下压本质上就是一堆坐标在每一帧里发生变化。如果你之前完全没碰过游戏开发建议先把Pygame的官方文档里“入门”那一页过一遍重点看display.set_mode、event.get、Surface.blit这三个函数的用法。不用背知道有这么回事就行后面写代码时会反复用到。2. 搭建开发环境与项目骨架2.1 安装Pygame与验证环境安装本身没什么好说的一条命令的事pip install pygame但这里有个坑值得提前说如果你用的是macOS或者某些Linux发行版系统自带的Python版本可能比较老或者pip指向的不是你预期的那个Python。我的习惯是先用python --version和pip --version确认两者指向同一个环境然后再安装。安装完之后跑一段最小验证代码import pygame pygame.init() screen pygame.display.set_mode((480, 700)) pygame.display.set_caption(飞机大战) running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False pygame.quit()如果能看到一个空白窗口并且能正常关闭说明环境没问题。这段代码虽然简单但它包含了Pygame游戏的完整骨架初始化、创建窗口、事件循环、退出清理。后面所有的功能都是在这个骨架上长出来的。2.2 项目目录结构的设计思路很多人写小游戏习惯把所有代码塞进一个文件里刚开始确实方便但写到后面——尤其是加入敌机、子弹、碰撞、音效、分数统计之后——一个文件会膨胀到七八百行改一个地方要在里面翻半天。我的建议是从一开始就按职责拆分目录plane_game/ ├── main.py ├── settings.py ├── player.py ├── enemy.py ├── bullet.py ├── resources/ │ ├── images/ │ └── sounds/ └── utils.pysettings.py放所有常量——窗口尺寸、帧率、速度、颜色值。这个文件看起来不起眼但当你需要调整游戏难度时只改这一个文件就够了不用去各个模块里翻参数。main.py只负责游戏主循环和状态管理具体的对象行为交给各自的模块。这种拆分方式在项目还小的时候可能显得“过度设计”但飞机大战一旦加入敌机生成逻辑和碰撞检测代码量会迅速上升提前拆好能省很多事。2.3 游戏主循环的帧率控制游戏主循环里有一个关键角色pygame.time.Clock。它的作用是控制游戏帧率让循环每秒执行的次数稳定在一个固定值上。飞机大战一般设60帧clock pygame.time.Clock() FPS 60 while running: clock.tick(FPS) # 处理事件、更新状态、绘制画面为什么帧率控制这么重要因为如果不加clock.tick循环会以CPU能跑多快就跑多快的速度执行不同机器上的游戏速度完全不一样。加了之后每帧的时间间隔被限制在约16.7毫秒游戏逻辑里所有“每帧移动多少像素”的参数才有意义。你可以把clock.tick理解成游戏世界里的“节拍器”所有物体的运动都跟着这个节拍走。3. 玩家飞机从加载图片到键盘操控3.1 图片加载与Rect对象的配合使用Pygame里加载图片用pygame.image.load()返回一个Surface对象。但Surface本身只包含像素数据不包含位置信息。要让飞机出现在窗口的某个位置需要配合Rect对象self.image pygame.image.load(resources/images/player.png) self.rect self.image.get_rect() self.rect.centerx SCREEN_WIDTH // 2 self.rect.bottom SCREEN_HEIGHT - 30get_rect()会自动根据图片尺寸生成一个矩形宽高和图片一致。之后所有关于位置的运算——移动、碰撞检测、边界判断——都基于这个Rect来做。这里有一个容易犯的错误直接修改rect.x和rect.y来移动飞机但忘记同步更新rect.centerx和rect.centery。Rect对象内部维护了多个属性x、y、centerx、centery、left、right、top、bottom等修改其中一个会自动影响其他相关的属性但前提是你修改的是Rect提供的属性而不是直接改Surface的位置。3.2 键盘事件与持续按键的处理Pygame处理键盘输入有两种方式事件驱动和状态查询。事件驱动用pygame.event.get()捕获KEYDOWN和KEYUP事件适合处理“按一次触发一次”的操作比如发射子弹。状态查询用pygame.key.get_pressed()获取当前所有按键的按下状态适合处理“按住持续移动”的操作keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: self.rect.x - self.speed if keys[pygame.K_RIGHT]: self.rect.x self.speed if keys[pygame.K_UP]: self.rect.y - self.speed if keys[pygame.K_DOWN]: self.rect.y self.speed这两种方式不能混用。如果你用事件驱动来处理移动会发现按住方向键时飞机只移动一下因为KEYDOWN事件只在按下的瞬间触发一次。反过来如果你用状态查询来处理发射子弹会发现按住空格时子弹像机关枪一样往外冒因为每一帧都检测到空格被按下。所以移动用状态查询射击用事件驱动这是飞机大战里最自然的搭配。3.3 边界限制为什么不能简单用if判断飞机不能飞出窗口这个逻辑看起来很简单if self.rect.left 0: self.rect.left 0 if self.rect.right SCREEN_WIDTH: self.rect.right SCREEN_WIDTH但实际写的时候要注意一个细节如果你先移动再判断边界飞机在边界处会出现“抖动”——因为下一帧它又试图移出去然后又被拉回来。正确的做法是在移动之前就判断或者移动之后立即修正。另外上下边界也要处理尤其是底部飞机不应该能移到屏幕外面去。我一般会把边界判断封装成一个方法在update()里调用这样代码更清晰。实测下来把边界判断放在移动逻辑之后、绘制之前是最稳妥的。这样即使某一帧移动幅度比较大比如速度设得很快也不会出现飞机部分超出屏幕的情况。4. 子弹系统发射、移动与回收4.1 子弹类的设计继承Sprite还是自己写Pygame提供了pygame.sprite.Sprite类自带image和rect属性还能直接加入Group进行批量管理。对于子弹这种“数量多、行为简单、需要频繁创建和销毁”的对象用Sprite是更省事的选择class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image pygame.image.load(resources/images/bullet.png) self.rect self.image.get_rect() self.rect.centerx x self.rect.bottom y self.speed -10 def update(self): self.rect.y self.speed if self.rect.bottom 0: self.kill()kill()方法会把这个Sprite从所有它所在的Group中移除同时释放引用。这是Pygame里回收对象的标准做法比手动维护列表要干净得多。4.2 发射频率控制与子弹冷却如果每一帧都检测空格键并发射子弹60帧的游戏里一秒钟会射出60颗子弹画面会变成一条弹幕。所以需要加一个冷却机制self.last_shot 0 self.shot_cooldown 200 # 毫秒 now pygame.time.get_ticks() if keys[pygame.K_SPACE] and now - self.last_shot self.shot_cooldown: self.last_shot now bullet Bullet(self.rect.centerx, self.rect.top) bullets.add(bullet)pygame.time.get_ticks()返回自游戏启动以来经过的毫秒数。用当前时间减去上次发射时间如果超过冷却值就允许发射。这种“时间戳差值”的冷却方式比用帧计数更可靠因为帧率可能会波动但时间不会骗人。4.3 子弹与敌机的碰撞检测Pygame提供了两种碰撞检测方式spritecollide和groupcollide。前者检测一个Sprite和一个Group之间的碰撞后者检测两个Group之间的碰撞。子弹打敌机属于后者collisions pygame.sprite.groupcollide(bullets, enemies, True, True)这行代码的意思是检测bullets组和enemies组之间的碰撞碰撞发生后子弹和敌机都从各自的组里移除两个True参数。返回值是一个字典键是碰撞的子弹值是被撞的敌机列表。如果你需要根据碰撞做额外处理——比如加分、播放爆炸动画——可以遍历这个字典。但这里有一个性能上的注意事项groupcollide默认使用矩形碰撞检测也就是两个Rect有重叠就算碰撞。对于飞机大战来说这完全够用因为子弹和敌机的形状都比较规整。如果你追求更精确的碰撞可以用pygame.sprite.collide_circle或者自定义碰撞函数但那样计算量会上升在子弹数量多的时候可能影响帧率。5. 敌机生成与运动逻辑5.1 敌机生成的时间间隔控制敌机不能一次性全冒出来也不能生成得太慢让玩家无事可做。常见的做法是用一个计时器控制生成间隔self.enemy_timer 0 self.enemy_interval 800 # 毫秒 self.enemy_timer clock.get_time() if self.enemy_timer self.enemy_interval: self.enemy_timer 0 self.spawn_enemy()clock.get_time()返回上一帧到当前帧经过的毫秒数。累加到enemy_timer上超过阈值就生成一架敌机并重置计时器。这种方式的优点是生成频率和帧率无关不管你的电脑跑60帧还是30帧敌机出现的节奏都是一样的。5.2 敌机运动轨迹的多样性最基础的敌机就是直线往下飞。但玩久了会腻所以可以加入几种简单的变体类型运动方式实现思路直线型垂直下落rect.y speed斜线型左下或右下rect.x dx; rect.y speed正弦型左右摆动rect.x base_x amplitude * sin(frequency * time)追踪型缓慢向玩家靠拢计算与玩家的方向向量按比例移动正弦型敌机的实现稍微复杂一点需要记录初始x坐标和已经过的时间self.base_x self.rect.x self.amplitude 50 self.frequency 0.005 self.elapsed 0 def update(self): self.elapsed 16 # 大约每帧16毫秒 self.rect.x self.base_x self.amplitude * math.sin(self.frequency * self.elapsed) self.rect.y self.speed这种敌机在视觉上更有趣但要注意它的碰撞检测仍然基于Rect而Rect是轴对齐矩形所以摆动幅度大的时候可能会出现“看起来没碰到但判定碰到了”的情况。如果介意可以把摆动幅度控制在合理范围内。5.3 敌机超出屏幕后的回收敌机飞出屏幕底部后必须销毁否则它们会一直存在于内存中而且碰撞检测的开销会越来越大。回收逻辑很简单def update(self): self.rect.y self.speed if self.rect.top SCREEN_HEIGHT: self.kill()但这里有一个容易忽略的点如果敌机是从屏幕上方生成的在它完全进入屏幕之前rect.top可能是负数。所以判断条件要用rect.top SCREEN_HEIGHT而不是rect.bottom SCREEN_HEIGHT确保敌机完全离开屏幕后才销毁。6. 游戏状态管理开始、进行、结束6.1 用状态机管理游戏流程飞机大战至少有三个状态开始界面、游戏进行中、游戏结束。如果不做状态管理代码里会充斥大量的if game_started和if game_over判断逻辑会变得很乱。更好的做法是用一个状态变量STATE_READY 0 STATE_PLAYING 1 STATE_GAMEOVER 2 self.state STATE_READY然后在主循环里根据状态决定执行哪些逻辑if self.state STATE_READY: self.draw_ready_screen() elif self.state STATE_PLAYING: self.update_game() self.draw_game() elif self.state STATE_GAMEOVER: self.draw_gameover_screen()这种写法让每个状态的逻辑独立开来添加新状态比如暂停时只需要加一个分支不会影响已有逻辑。6.2 游戏结束的判定与重新开始游戏结束的触发条件是玩家飞机与敌机碰撞。检测方式和子弹打敌机类似但这次是玩家和敌机组之间的碰撞if pygame.sprite.spritecollide(self.player, self.enemies, True): self.state STATE_GAMEOVER注意这里第三个参数是True表示碰撞后敌机销毁。玩家飞机不需要销毁但应该停止响应键盘输入并且显示“游戏结束”的提示。重新开始的处理方式是重置所有游戏对象——清空子弹组、敌机组把玩家飞机放回初始位置分数归零状态切回STATE_PLAYING。我见过不少人在重新开始时只重置了分数和玩家位置忘记清空子弹和敌机结果一按重新开始满屏的子弹和敌机还在那里。所以重置逻辑一定要写全最好封装成一个reset_game()方法。6.3 分数统计与显示分数系统本身很简单每击毁一架敌机加10分。但显示分数需要用到字体渲染font pygame.font.Font(resources/fonts/simhei.ttf, 36) score_text font.render(fScore: {self.score}, True, (255, 255, 255)) screen.blit(score_text, (10, 10))font.render的第一个参数是文本内容第二个是抗锯齿开关第三个是颜色。中文字体需要指定字体文件路径否则可能显示成方块。如果不想打包字体文件也可以用pygame.font.SysFont调用系统字体但不同系统上的字体名称可能不一样跨平台时容易出问题。我的建议是直接打包一个开源中文字体省去兼容性麻烦。7. 音效与视觉反馈让游戏“有感觉”7.1 音效加载与播放的注意事项Pygame播放音效用pygame.mixerpygame.mixer.init() shoot_sound pygame.mixer.Sound(resources/sounds/shoot.wav) shoot_sound.play()但这里有几个坑。第一pygame.mixer.init()必须在pygame.init()之后调用否则可能报错。第二音效文件格式建议用WAV或OGGMP3在某些平台上支持不好。第三如果音效播放太频繁比如每帧都发射子弹可能会出现声音重叠或者延迟这时候需要控制播放频率或者用pygame.mixer.set_num_channels()增加声道数。7.2 爆炸动画的简单实现爆炸动画本质上就是一组图片按顺序播放。你可以准备几张爆炸效果的图片在敌机被击毁的位置依次显示class Explosion(pygame.sprite.Sprite): def __init__(self, center): super().__init__() self.images [ pygame.image.load(fresources/images/explosion{i}.png) for i in range(5) ] self.index 0 self.image self.images[self.index] self.rect self.image.get_rect(centercenter) self.timer 0 def update(self): self.timer 1 if self.timer % 5 0: self.index 1 if self.index len(self.images): self.kill() else: self.image self.images[self.index]每5帧切换一张图片播完就销毁。这种实现方式简单直接不需要引入额外的动画库。7.3 屏幕震动与闪烁效果屏幕震动是一种成本很低但效果很明显的反馈。实现方式是在绘制所有内容之前对整个画面做一个偏移if self.shake_timer 0: offset_x random.randint(-5, 5) offset_y random.randint(-5, 5) self.shake_timer - 1 else: offset_x offset_y 0 # 绘制时加上偏移 screen.blit(player.image, (player.rect.x offset_x, player.rect.y offset_y))震动持续时间不要太长一般10到15帧就够了太长会让玩家觉得画面不稳定。触发时机可以是玩家被击中或者敌机被击毁的瞬间。8. 性能优化与常见问题排查8.1 图片加载的缓存策略pygame.image.load()每次调用都会从磁盘读取文件如果每创建一个子弹对象就加载一次子弹图片在子弹数量多的时候会明显拖慢帧率。正确的做法是在模块级别或者游戏初始化时加载一次然后在所有对象之间共享# 在模块顶部加载 BULLET_IMAGE pygame.image.load(resources/images/bullet.png) class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image BULLET_IMAGE self.rect self.image.get_rect() # ...这样图片只在游戏启动时加载一次之后所有子弹对象都引用同一个Surface。Pygame的Surface是可以安全共享的因为绘制操作不会修改Surface本身。8.2 帧率下降的常见原因飞机大战跑着跑着变卡通常有以下几个原因对象泄漏子弹或敌机飞出屏幕后没有调用kill()导致Group里的对象越来越多碰撞检测的开销线性增长。重复加载图片每次创建对象都调用image.load()磁盘IO成为瓶颈。碰撞检测过于频繁如果子弹和敌机的数量都很大groupcollide的开销会明显上升。可以考虑先用距离做粗筛只对距离近的对象做精确碰撞检测。绘制顺序不合理每帧都重新绘制背景、重新渲染文字而这些内容其实可以缓存成Surface。排查帧率问题最直接的方法是在主循环里打印clock.get_fps()观察帧率变化。如果帧率在对象数量增加后明显下降基本可以确定是对象管理或碰撞检测的问题。8.3 窗口无响应与事件堆积有时候游戏窗口会显示“无响应”尤其是在Windows上。这通常是因为主循环里做了耗时操作比如大量计算或者文件读写导致Pygame没有及时处理系统事件。解决办法是把耗时操作拆分成小步骤分散到多帧里执行或者用线程处理。但对于飞机大战这种规模的项目只要不在主循环里做奇怪的事情一般不会遇到这个问题。另一个相关的问题是事件堆积。如果你在每一帧只处理一个事件而系统产生了大量事件比如快速移动鼠标事件队列会越来越长。正确的做法是在每一帧里处理完所有事件for event in pygame.event.get(): # 处理事件pygame.event.get()会返回当前队列里的所有事件遍历完就清空了。不要用pygame.event.wait()它会阻塞主循环直到有事件发生导致游戏画面卡住。9. 从能跑到好玩难度曲线与细节打磨9.1 难度递增的实现方式一个固定难度的飞机大战玩几分钟就会腻。让难度随时间递增是延长游戏寿命的有效手段。最简单的做法是让敌机生成间隔逐渐缩短self.enemy_interval max(200, 800 - self.score * 2)分数越高生成间隔越短但设置一个下限防止难度失控。也可以让敌机速度逐渐加快或者在高分阶段引入新的敌机类型。关键是变化要平滑不要让玩家突然感到“怎么一下子这么难”。9.2 玩家生命值与无敌时间一碰就死的设定对新手不太友好。加入生命值系统可以让游戏更耐玩self.lives 3 self.invincible False self.invincible_timer 0 # 被击中时 if not self.invincible: self.lives - 1 self.invincible True self.invincible_timer 120 # 2秒无敌 # 每帧更新 if self.invincible: self.invincible_timer - 1 if self.invincible_timer 0: self.invincible False无敌期间可以让飞机闪烁给玩家一个视觉提示。闪烁的实现方式是每隔几帧切换一次飞机的可见性if self.invincible and (self.invincible_timer // 5) % 2 0: # 不绘制飞机 pass else: screen.blit(self.image, self.rect)9.3 道具系统的扩展思路道具系统是飞机大战里很自然的扩展方向。常见的道具有三种加火力同时发射多颗子弹、加生命、加护盾。实现方式是在敌机被击毁时有一定概率生成道具道具向下飘落玩家碰到就生效if random.random() 0.1: # 10%概率掉落 powerup PowerUp(enemy.rect.centerx, enemy.rect.centery) powerups.add(powerup)道具的碰撞检测和敌机类似但碰到后不销毁玩家而是修改玩家的某个属性。比如加火力就是把bullet_count从1变成2或3发射时根据bullet_count决定生成几颗子弹以及它们的偏移量。道具的掉落概率需要反复调整。太高了游戏太简单太低了玩家感受不到成长。我一般从10%开始试然后根据实际体验微调。另外道具最好有存在时间限制比如5秒后自动消失否则屏幕上会堆满没人捡的道具。10. 打包与分享让朋友也能玩到你的游戏10.1 用PyInstaller打包成可执行文件写完之后想让别人玩总不能让人家装Python和Pygame。PyInstaller可以把Python脚本打包成独立的可执行文件pip install pyinstaller pyinstaller --onefile --windowed main.py--onefile把所有依赖打包成一个文件--windowed去掉控制台窗口。但这里有一个关键问题打包后的程序运行时资源文件的路径会发生变化。PyInstaller会把资源解压到一个临时目录你需要用sys._MEIPASS来获取正确的路径import sys import os def resource_path(relative_path): if hasattr(sys, _MEIPASS): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.abspath(.), relative_path)然后在所有加载图片和音效的地方用resource_path(resources/images/player.png)代替直接写路径。这个细节如果不处理打包后的程序会找不到资源文件直接崩溃。10.2 跨平台兼容性的几个注意点Windows、macOS、Linux在字体渲染、音频播放、文件路径分隔符上都有差异。字体方面尽量用打包的字体文件而不是系统字体。音频方面某些Linux发行版默认的音频驱动可能和Pygame不兼容可以在pygame.mixer.init()之前设置os.environ[SDL_AUDIODRIVER] pulseaudio试试。文件路径方面统一用os.path.join而不是手写斜杠避免Windows上出现路径错误。10.3 分享前的自测清单在把游戏发给朋友之前建议过一遍这个清单窗口标题是否正确显示所有图片和音效是否正常加载键盘操作是否流畅有没有按键冲突游戏结束后能否正常重新开始连续玩五分钟以上是否出现卡顿或内存增长在不同分辨率屏幕上窗口是否居中显示打包后的可执行文件在另一台电脑上能否直接运行这些检查看起来琐碎但每一条都对应着实际分享时可能遇到的问题。尤其是最后一条很多人打包完在自己电脑上跑得好好的发给别人就报错多半是资源路径或者依赖库的问题。写飞机大战这个项目代码量不大但涉及的知识点很全面——图形渲染、事件处理、碰撞检测、状态管理、音效播放、性能优化、打包发布。把这一套走通之后再去做其他2D游戏你会发现底层逻辑是相通的。真正花时间的往往不是写代码本身而是调参数、修边界情况、打磨手感。这些东西没有捷径只能一边跑一边改改到你自己玩着觉得舒服为止。