news 2026/10/27 4:27:32

Pygame太空射击实战:游戏循环、碰撞检测与状态机

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Pygame太空射击实战:游戏循环、碰撞检测与状态机

做游戏开发练习时,很多人卡在同一个地方:教程看了一堆,粒子、物理、渲染讲得头头是道,但真要自己搭一个能跑起来的小游戏,还是不知道从哪个文件开始写。这段时间我把一个太空射击小项目「最後の惑星」从零重写了一遍,把里面积累的经验整理成这篇完整实战,包含可直接运行的分文件代码、参数调优思路和线上踩过的坑。本文适合刚学完 Python 基础、想找一个完整项目练手的同学,也适合已经会写脚本、想补一补"游戏循环"和"实体管理"这块短板的开发者。读完你至少能得到三样东西:一套能跑起来的 Pygame 工程骨架、一份可复用的碰撞与状态机实现、以及一套真正会影响手感和性能的参数清单。

1. 项目背景与核心概念

1.1 「最後の惑星」是一个什么样的练手项目

「最後の惑星」直译过来是"最后的行星",在这个项目里它是一款纵版太空射击小游戏:玩家操控一艘守卫飞船,在最后一颗行星的上空持续清理坠落的陨石和被称为「黒魔」(代码里统一写作 KUROMA)的敌方机群。选这个题材不是因为题材本身有多特殊,而是因为它天然覆盖了一个游戏项目最核心的几块基础设施——实时循环、输入响应、实体生成与销毁、碰撞判定、分数与状态切换、资源生命周期管理。

它的规模刚好卡在一个舒服的位置:小到一天之内能写出可玩版本,大到足够暴露真实工程问题。比如为什么子弹飞出去一会儿就消失了、为什么窗口失去焦点再切回来飞船会瞬移、为什么同屏两百个陨石之后帧率开始掉。这些问题在写业务代码时几乎碰不到,但在实时渲染循环里会反复出现,而这正是练手项目最大的价值——它逼你去理解每一帧到底发生了什么。

需要说明的是,这个项目不依赖任何美术资源。飞船、陨石、敌机、子弹全部用pygame.draw的几何图形实时绘制,星空背景是代码生成的视差点阵。这么做有两个好处:一是代码复制到本地就能直接跑,不用去下载图片、处理路径问题;二是你会被迫搞清楚 Surface、Rect 和绘制顺序之间的关系,而不是把一切丢给素材加载。

1.2 为什么选择 Pygame 而不是 Unity 或浏览器引擎

选择技术栈时主要权衡的是"学习曲线"和"对底层机制的暴露程度"。Unity 这类成熟引擎会把游戏循环、碰撞检测、场景管理全部封装好,你写代码时更多是在配置组件,很难看清一帧内部到底怎么调度;而 Pygame 是一个薄得多的封装层,它只负责给你一块画布、一个事件队列、一个时钟,剩下的循环逻辑、坐标系、对象管理都得你自己写。

这对练手项目反而是优势。当你自己实现dt驱动的位移、自己维护Group的增删、自己判断矩形碰撞还是圆形碰撞时,这些概念才算真正长在脑子里,之后换成任何引擎都能迁移。Pygame 的另一个现实好处是环境简单:pip install pygame就能开始,不需要安装几个 G 的编辑器,也不需要处理导出平台和许可证问题。

浏览器方案(Canvas、Phaser)同样是不错的选择,如果你的目标就是做网页小游戏可以直接选它。本文选 Python 是因为它能和后续的数据处理、自动化脚本放在同一个语言生态里,练手完还能顺手接点爬虫、工具类的活儿,学习成本不会被浪费。

1.3 必须先建立的四个概念

在动手写代码之前,有四个概念值得先讲清楚,否则后面的代码看起来会像魔法。第一个是游戏循环:整个程序是一个while循环,每一轮做三件事——处理事件、更新状态、重新绘制。程序不会"等"玩家按键,而是每 1/60 秒检查一次当前按键状态,这和我们熟悉的"点击按钮触发函数"的思维完全不同。

第二个是帧与 delta time。如果你的更新逻辑写成x += speed,那么在 30 帧的机器上物体移动速度只有 60 帧机器的一半,游戏体验直接崩掉。正确写法是x += speed * dt,其中dt是这一帧实际经过的秒数。这样无论帧率怎么波动,位移量都和真实时间挂钩。这是新手最常犯、也最难自己发现的一个错误。

第三个是坐标系与 Rect。Pygame 的坐标原点在左上角,x 向右增大,y 向下增大。这和数学课的笛卡尔坐标系相反,所以"向上移动"是y -= speed而不是y += speed。Rect是一个矩形对象,拥有left/right/top/bottom/centerx/centery/center等属性,它是 Pygame 里一切布局和碰撞的基础。

第四个是精灵与精灵组。pygame.sprite.Sprite只是一个带image和rect两个属性的约定,Group则是一个能批量更新、批量绘制、批量碰撞的容器。理解"往 Group 里 add 就等于加入游戏世界,调用 kill 就等于从游戏世界移除"这个心智模型,代码结构会立刻清爽很多。

2. 环境准备与版本说明

2.1 运行环境与版本约定

本文代码基于 Python 3 和 Pygame 2.x 编写。这里不锁定具体的小版本号,因为 Pygame 2.x 系列在本文用到的 API(pygame.draw.circle、pygame.transform.rotozoom、sprite.groupcollide、SRCALPHA等)都是长期稳定的,pip install pygame拉取当前稳定版即可。但有一点必须提醒:如果你用的是 Pygame 1.9.x 这类老版本,部分绘制参数和Vector2的行为会有差异,建议直接升到 2.x。

操作系统方面,Windows、macOS、Linux 都可以,代码本身没有平台相关逻辑。唯一的平台差异在字体:中日文字符的渲染依赖系统里存在能显示这些字形的字体文件,所以本文第 4 节的主循环里专门写了一个字体查找逻辑,找不到就回退默认字体。这意味着即使字体缺失,程序也不会崩溃,只是标题会显示成方块,这属于可接受的降级。

如果你打算把这个项目发给别人玩,还需要注意一点:Pygame 程序是窗口程序,不是命令行脚本,双击运行和命令行运行的行为可能有细微差别(比如工作目录不同导致相对路径失效)。本文代码没有读取任何外部文件,所以这个问题在本项目里不会出现,但你在后续扩展加载音乐、图片时一定要注意。

2.2 创建虚拟环境并安装依赖

强烈建议使用虚拟环境,因为游戏项目对依赖版本比较敏感,直接装到全局环境里,将来和其他项目冲突时会很麻烦。下面这套命令在 Windows 的 PowerShell 和 macOS / Linux 的终端里都能用,只有激活那一步不一样。

# 1. 创建项目目录 mkdir last_planet cd last_planet # 2. 创建虚拟环境 python -m venv .venv # 3. 激活虚拟环境 # Windows PowerShell: .venv\Scripts\Activate.ps1 # Windows CMD: # .venv\Scripts\activate.bat # macOS / Linux: # source .venv/bin/activate # 4. 安装 Pygame pip install pygame # 5. 验证安装,能打印版本号就说明环境正常 python -c "import pygame; print(pygame.version.ver)"

最后一条命令会输出类似2.5.x的版本号。如果这一步报ModuleNotFoundError,八成是虚拟环境没激活(注意命令行提示符前面有没有(.venv)前缀),或者pip装到了另一个 Python 解释器里。可以用python -c "import sys; print(sys.executable)"确认当前用的是哪个解释器,再用python -m pip install pygame显式绑定。

依赖清单建议落成一个文件,方便以后复现环境。不需要写死版本号,因为你拉取时用的版本就是当时的最新稳定版:

# 文件路径:requirements.txt pygame>=2.0.0

2.3 项目目录结构

本文的代码分成四个文件,这个粒度是刻意设计的:既不会像单文件那样堆到八百行难以维护,也不会像多模块项目那样让新手在 import 关系里绕晕。目录结构如下:

last_planet/ ├── .venv/ # 虚拟环境(不要提交到版本库) ├── requirements.txt # 依赖清单 ├── settings.py # 全局配置常量 ├── player.py # 玩家飞船 + 子弹 ├── asteroid.py # 陨石 + 黒魔敌机 └── main.py # 星空、场景状态机、主循环

这个划分背后的原则是按"生命周期"分层,而不是按"类型"分层。settings.py只放常量,不引用任何其他模块,所以任何模块都可以放心 import 它,不会出现循环依赖;player.py和asteroid.py是"实体层",只关心自己的状态怎么变化、怎么画自己,完全不知道游戏整体的分数、场景、胜负;main.py是"调度层",负责创建实体、调用更新、处理碰撞、决定什么时候切场景。

这条依赖方向是单向的:main.py→ 实体层 →settings.py。遵守它,后续加新敌人的时候你只需要新建一个实体模块再在main.py里注册,不用回头改动已有代码。

3. 核心机制拆解

3.1 游戏循环与 delta time 驱动

游戏循环是所有实时程序的骨架。它的写法看起来简单,但有几个细节决定成败。标准结构是:先用clock.tick(FPS)让这一帧等待到目标帧率,同时拿到距离上一帧的毫秒数;把它除以 1000 转成秒,就是这一帧的dt;然后依次处理事件、更新、绘制。

这里有一个容易忽略的坑:dt必须做上限截断。想象一下用户拖动窗口、切到别的应用、或者系统弹了个模态框,程序会被挂起几百毫秒,恢复时dt可能高达 2 秒。如果直接用这个dt去算位移,飞船会瞬间横穿屏幕,子弹直接飞出地图,严重时甚至能穿过本该碰撞的陨石。所以标准做法是dt = min(dt, 0.05),把单帧最大步长限制在 50 毫秒。

还有一个取舍是"固定时间步"还是"可变时间步"。本文用的是可变时间步(把真实dt直接喂给逻辑),优点是实现简单、动画流畅;缺点是当dt波动大时,物理表现不是完全确定的。如果你的游戏需要精确的物理模拟或者联机同步,就该改成固定步长 + 累加器,逻辑每 1/120 秒跑一次,渲染仍然每帧都刷。对于本项目这种弹幕射击,可变时间步完全够用。

理解这一点之后,你会自然明白为什么"帧率越高游戏越快"是一个经典 bug:它几乎总是因为你把dt忘了,或者在某个分支里漏乘了dt。排查这类问题时,最有效的办法是临时把FPS改成 30 和 120 各跑一次,如果手感明显不同,就说明代码里有没用dt的地方。

3.2 Rect 与精确碰撞的取舍

碰撞检测是射击游戏的核心之一,而它本质上是"精度"和"成本"的博弈。Pygame 提供三档方案,成本从低到高:矩形相交(Rect.colliderect)、圆形距离判定(比较distance_squared和半径和的平方)、像素级掩码(pygame.mask)。

矩形相交最便宜,一次比较四次坐标即可,但它有一个明显问题:对于圆形或三角形,包围盒会比实际图形大一圈。在弹幕游戏里,这会导致玩家觉得"明明没打中却判定死亡",非常影响体验。像素级掩码最精确,但每次碰撞都要逐像素比对,几十个对象尚可,上百个就是性能灾难。

工业界的常见做法是分层过滤:先用矩形或圆形做粗筛,筛出可能的碰撞对,再对这几对做精确判定。本文的代码走的是中间路线——用spritecollide的矩形判定做粗筛,对子弹这类小对象来说误差可以接受;同时在最佳实践一节会给出圆形判定的替换方案,供你在追求手感时使用。

这里还有一个必须提的隐患:spritecollide的第三个参数dokill如果传True,它会在碰撞时直接调用sprite.kill()。如果你的代码在遍历同一个 Group 的过程中又调用了 kill,就可能触发"遍历时修改集合"的问题。本文的写法是先list(self.enemies)复制一份再遍历,就是为了规避这个风险。这是一个非常隐蔽的坑,出现时往往表现为"某个敌机偶尔不掉血"或者抛出RuntimeError: Set changed size during iteration。

3.3 Sprite Group 的分组管理

Group是 Pygame 里最值得吃透的一个类。它本质上是一个集合同步机制:你往组里add(obj),这个对象下一次调用group.update()和group.draw(surface)时就会被自动处理;调用obj.kill(),它会自动从所有所属的组里移除。这个设计的威力在于,销毁逻辑不需要集中管理——子弹飞出屏幕自己在update里调kill()就行了,主循环完全不用关心清理。

分组的粒度也需要设计。本文分了四个组:玩家子弹、敌方子弹、陨石、敌机。这么分不是为了好看,而是因为它们的碰撞目标完全不同:玩家子弹要撞陨石和敌机,敌方子弹要撞玩家,陨石和敌机要撞玩家。如果全部塞进一个all_sprites组,每次碰撞你都得手动判断isinstance,代码会迅速失控。

代价是绘制顺序需要手动控制。Pygame 的绘制是"后画的盖住先画的",所以顺序必须从"最远"到"最近":星空 → 陨石 → 敌机 → 双方子弹 → 玩家 → HUD。这个顺序在draw()方法里固定下来,不要随手调整,否则会出现子弹被陨石遮住、飞船被 HUD 盖住这类视觉问题。

还有一个细节:Group.update()会把所有额外参数原样传给每个精灵的update方法。所以陨石的update(self, dt)可以直接用group.update(dt)批量调用,但敌机的update(self, dt, bullet_group, player_pos)需要额外参数,批量调用就不方便了,本文改为显式for循环。这不影响正确性,只是让不同类型实体的更新签名尽量保持一致会更利于后续维护。

3.4 状态机:标题页 / 游戏中 / 结算页

任何稍微完整的游戏都需要场景概念:标题页、游戏进行中、结算页。最粗糙的写法是用一堆布尔变量(self.is_title、self.is_playing),但很快就会出现"两个状态同时为真"的诡异情况。

本文用一个字符串常量做状态机,只有三个状态:title、playing、over。核心思路是状态决定行为——update()里如果状态不是playing就直接返回,只有draw()会根据状态画不同的画面。这样既保证了游戏逻辑不会在结算页偷偷跑(比如陨石在后台继续下坠),又保证了视觉层能单独处理每个状态的渲染。

状态切换全部集中在两个地方:handle_events()里响应回车/空格切到playing,update()里检测到玩家生命归零切到over。把所有入口集中,是避免状态混乱最有效的手段。

一个容易被忽略的点是重开时的清理。从over切回playing,如果你只改了状态而没重建实体组,上一局残留的陨石会继续往下掉。本文把初始化逻辑抽成reset()方法,在构造时和每次开局时都调用一次,保证每次开局都是干净的初始状态。这也是"状态机 + reset"这套组合比散落的布尔变量更好维护的原因。

4. 完整实战:从零实现「最後の惑星」

4.1 编写全局配置 settings.py

配置文件的价值在于把所有"可调参数"和"业务逻辑"隔离开。游戏开发里调参是高频操作,如果你的速度、间隔、颜色散落在各个文件里,每次微调都要全局搜索,效率极低。这个文件不引用任何其他模块,所以它是整个依赖树的根。

# 文件路径:settings.py """「最後の惑星」全局配置常量。 把可调参数集中在这里:调难度、调手感、调配色,都只改这个文件。 """ # ---------- 窗口与帧率 ---------- SCREEN_WIDTH = 960 SCREEN_HEIGHT = 600 FPS = 60 TITLE = "最後の惑星" # ---------- 配色 ---------- COLOR_BG = (8, 10, 24) COLOR_PLAYER = (120, 220, 255) COLOR_PLAYER_CORE = (255, 255, 255) COLOR_BULLET = (255, 230, 120) COLOR_ASTEROID = (150, 150, 165) COLOR_ASTEROID_EDGE = (70, 70, 85) COLOR_ENEMY = (200, 60, 90) COLOR_ENEMY_BULLET = (255, 130, 160) COLOR_TEXT = (235, 240, 255) COLOR_TEXT_DIM = (140, 150, 180) # ---------- 玩家 ---------- PLAYER_SPEED = 320.0 # 像素 / 秒 PLAYER_RADIUS = 16 PLAYER_MAX_HP
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 4:27:30

Docker容器化实战:环境一致性、镜像构建与部署排障

1. Docker 到底在解决什么问题我最早认真对待 Docker 容器化技术,是在一个前后端联调反复出问题的项目上。同一份代码,在开发同学的机器上跑得好好的,到了测试环境就报找不到某个动态库;换到另一台机器,连运行时版本都…

作者头像 李华
网站建设 2026/10/27 4:27:25

权重生成与评价模型:从原理到实践应用

1. 权重生成与评价模型概述在数据分析与决策支持领域,权重生成与评价模型是构建科学评价体系的核心工具。这套方法论通过量化各指标的相对重要性,为复杂决策问题提供客观依据。我在金融风控和运营分析项目中多次应用这类模型,发现其价值不仅在…

作者头像 李华
网站建设 2026/10/27 4:27:15

GitHub双因素认证迁移指南:浏览器插件2FA数据转移

1. GitHub双因素认证迁移实战:浏览器插件版2FA数据转移指南刚给新电脑装完开发环境,突然发现GitHub登录需要双重验证(2FA),而旧电脑上的Authenticator插件数据没迁移过来?这种场景开发者几乎都会遇到。作为每天要提交几十次代码的…

作者头像 李华
网站建设 2026/10/27 4:24:51

端到端测试落地方案:基于Playwright的主链路回归配置

这几年但凡带过测试团队,或者自己写过接口自动化的人,都会遇到一个尴尬:单元测试覆盖率很美,接口自动化也跑得勤,可上线前产品点两下就崩了。问题往往不在某个函数,也不在某个接口,而在用户真实…

作者头像 李华
网站建设 2026/10/27 4:24:51

Redis淘汰key机制全拆解:过期删除与内存淘汰

缓存命中率从 97% 掉到 61%,接口 P99 从 40ms 冲到 300ms,值班的同学甩过来一张内存曲线图,说"内存打满了,是不是有人在刷 key"。这是我这些年遇到过最典型的一类线上问题。翻配置一看,maxmemory是设了&…

作者头像 李华
网站建设 2026/10/27 4:24:16

PRD文档管理与版本控制实践指南

1. 项目概述:PRD文档下载的必要文件管理在项目管理领域,PRD(Product Requirements Document)作为产品需求的核心载体,其相关文件的系统化管理直接影响团队协作效率。最近接手的一个电商后台系统升级项目中,…

作者头像 李华