用Pygame从零实现飞机大战:窗口与碰撞检测完整指南
简介这是一份以 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十有八九是电脑里有多个 Pythonpip 装到了一个解释器跑代码时用了另一个。我习惯把所有 Python 项目都放进虚拟环境先创建再安装python -m venv plane_envWindows 下激活命令是plane_env\Scripts\activatemacOS 和 Linux 是source plane_env/bin/activate。激活后命令行提示符前面会多出(plane_env)字样这时候再装 Pygame一定不会出现模块找不到的问题。这条习惯能省掉后面一整类环境级报错。2.2 最小窗口的逐行拆解init、set_mode、clockPygame 的最小可运行程序只有十几行但每一行背后都有一个必须要记得的初始化项。先看代码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 分减 50ms400ms敌机移动速度3每 200 分 0.57玩家子弹速度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(fScore: {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 项目的骨架。如果你照着这篇笔记搭出了第一个窗口希望帮到你的是那些我在翻车之后才明白的参数边界和排查顺序让你少走一遍我走过的弯路。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →