用Pygame开发简单射击游戏:从游戏循环到碰撞检测
简介面向j2ME初学者的一款飞机射击小游戏完整代码包基于MIDP与CLDC框架开发覆盖游戏循环、精灵对象、碰撞检测、键盘输入、图形渲染、音频播放及状态管理等核心知识点适合用于入门手机端游戏开发并理解移动应用的基本架构。压缩包共200个文件大小4.27MB其中11个java源码配合22个class文件便于对照阅读20个mid音频提供爆炸与射击音效138个png为飞机、敌机及背景素材jad与jar文件可直接部署到模拟器或真机运行体验。已有1950人学习下载。通过RaidenCanvas、Enemy3456等类的实现可看到敌机编队、玩家操控与碰撞判定的完整写法配合textPlan设计笔记能快速梳理从游戏规划到实际编码的转化过程。整体代码量适中、结构清晰既可作为课程设计参考也能为进阶开发者回顾j2ME游戏性能优化与资源管理提供样例。1. 简单射击类游戏代码一个能跑起来的 Pygame 原型别急着追求特效一款简单射击类游戏代码说白了就是一组能跑起来的脚本让你控制飞船或角色发射子弹打掉敌机、计分、扣命、结束。它常被当作 Python 和 Pygame 的入门项目但别因为“简单”两个字就轻视它——真正把移动手感、连发节奏、碰撞判定和计分状态捏在一起至少要踩三四个坑。这篇文章适合两类人第一次写游戏、只想快速看到成品的学生以及被 2D 游戏画面劝退、想先把游戏循环架构搞明白的开发者。我不写引擎封装不用现成模板就用 Pygame 写一个最小可玩的射击 Demo把每一步拆开先立主循环再填子弹和敌人最后修掉那些让人翻车的陷阱。2. 游戏循环与三大件事件、更新、绘制先把骨架立起来写射击游戏代码和写普通脚本最大的区别在于游戏是持续运行的不是从上到下执行一次就退出的程序。你需要在每一帧里做三件事读取用户的输入事件、更新所有对象的逻辑状态、把状态画到屏幕上。这个结构在我做过的所有小游戏里都一样PyGame 只是把底层窗口和绘图接口交给你其余全靠自己在循环里组织。2.1 主循环与固定帧率为什么要用 while clock.tick所有 Pygame 游戏都从一个while running的循环开始。窗口会不断重绘直到玩家关闭窗口pygame.QUIT事件把running设为 False。常见做法是先初始化窗口再定义屏幕宽高、帧率最后进入主循环。帧率这一步最容易被人当成“玄学”跳过但如果不限制它游戏在 144Hz 显示器和 60Hz 显示器上速度会差一倍移动速度、子弹飞行速度全都不一样。看这段最小骨架import pygame import sys pygame.init() WIDTH, HEIGHT 800, 600 screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(Simple Shooter Demo) clock pygame.time.Clock() FPS 60 running True while running: # 1. 事件处理把窗口事件、键盘事件从队列里取出来 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 2. 更新逻辑等后面填充玩家、子弹、敌人的 update # 3. 绘制先清屏再画物体最后翻转缓冲 screen.fill((20, 20, 30)) pygame.display.flip() # 让循环每 1/60 秒跑一次 clock.tick(FPS) pygame.quit() sys.exit()这段代码的逻辑说明非常直白pygame.event.get()会取出这一帧触发的所有事件QUIT是关窗口的动作clock.tick(FPS)会把循环速度压到每秒 60 次。参数上你要注意两点FPS设 30 是慢动作风格设到 120 对普通 2D 游戏没有意义反而让 CPU 空转pygame.display.flip()是真正把绘制结果推送到窗口的调用漏掉它你看到的永远是黑屏。2.2 事件队列与状态分离让键盘操作不“飘”很多新手会把按键监听代码写成pygame.key.get_pressed()放在事件循环外面这种做法本身没错但如果没有理解事件队列就会出现“按住方向键往前飘、松开还在动”的怪象。get_pressed()返回的是键盘此刻的状态矩阵适合做持续移动event.type pygame.KEYDOWN这种事件只触发一次适合做单发射击、暂停这种需要边沿触发的动作。我一般会把输入处理分成两层第一层在for event in pygame.event.get()里处理KEYDOWN和KEYUP第二层在更新逻辑里用get_pressed()读取方向键。这样玩家按住右键的时候飞船每帧都移动松开后KEYUP事件把标记清除。典型错误是只处理KEYDOWN不处理KEYUP结果飞船会像“按一下就一直跑”调试时我感觉特别像键盘被幽灵按住。moving_right False moving_left False while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_RIGHT: moving_right True elif event.key pygame.K_LEFT: moving_left True elif event.type pygame.KEYUP: if event.key pygame.K_RIGHT: moving_right False elif event.key pygame.K_LEFT: moving_left False # 更新状态 if moving_right: player_x 5 if moving_left: player_x - 5这里的player_x只是示意真正写代码时你会放在玩家对象里。要点是把“按了哪个键”和“移动多少距离”解耦事件只负责记录状态更新逻辑负责消费状态。这样做的好处是后面加暂停功能、加设置界面时不用去改事件监听那一大坨 if。2.3 精灵组的三层结构玩家、子弹、敌人都要对号入座再往下写你会发现所有游戏对象都有相似的属性和方法位置、图片、碰撞范围、更新逻辑。Pygame 提供了pygame.sprite.Sprite类和pygame.sprite.Group类前者给对象一个统一的壳后者用集合的方式管理一堆对象。用 Group 管理精灵最大的好处是碰撞检测和批量绘制短短两行就能完成。常规做法是三个组player_group、bullet_group、enemy_group。把玩家单独放在一组是为了和子弹、敌人做区分子弹和敌人放在不同组是为了用pygame.sprite.groupcollide检测它们之间的碰撞。一个常见的反直觉坑是不要把所有东西塞进同一个all_sprites组。虽然绘制更方便但碰撞检测时会把自己都撞上逻辑会变得一团乱。我来把三层结构落成代码from pygame.sprite import Sprite, Group class Player(Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((40, 30)) self.image.fill((0, 180, 255)) self.rect self.image.get_rect() self.rect.center (x, y) class Bullet(Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((8, 8)) self.image.fill((255, 255, 100)) self.rect self.image.get_rect() self.rect.center (x, y) self.speed -12 def update(self): self.rect.y self.speed if self.rect.bottom 0: self.kill() class Enemy(Sprite): def __init__(self, x, y): super().__init__() self.image pygame.Surface((50, 30)) self.image.fill((255, 80, 80)) self.rect self.image.get_rect() self.rect.center (x, y) self.speed 2 def update(self): self.rect.y self.speed if self.rect.top HEIGHT: self.kill() player_group Group() bullet_group Group() enemy_group Group() player Player(WIDTH // 2, HEIGHT - 60) player_group.add(player)参数说明rect是 Pygame 精灵的核心碰撞检测全部基于rect的矩形范围kill()是 Sprite 自带方法把对象从所有 Group 中移除重要程度相当于给子弹和敌人上“后悔药”——不调用它打空的子弹会永远占着内存帧率慢慢就掉下去。Bullet.speed -12表示往屏幕上方飞正值则是往下敌人向下移动速度 2 是很慢的节奏适合当第一关。3. 玩家控制与子弹发射移动手感、连发限制与按键监听实现骨架有了现在填玩家移动和射击。这个章节决定游戏“好不好玩”同样的代码移动速度、冷却时间、按键响应方式不同手感天差地别。新手最容易把精力放在画面上结果做完发现飞船像溜冰子弹像挤牙膏。这里有一套我自己调过很多次的参数组合直接抄作业就行。3.1 玩家飞船移动方向向量与速度夹逼移动逻辑放在Player.update()里。你要处理两类输入左右方向键以及可选的上上下下很多射击游戏允许玩家纵向小幅移动。为了防止飞船飞出屏幕必须对rect的位置做边界钳制也就是所谓的“夹逼”。我用一个独立的speed_x变量控制横向速度每一帧都重置避免累积加速度。这个设计比“按下方向键直接改 x”更稳因为直接改 x 会让飞船在不同帧率下移动距离不一致而且跳不出“一碰键就瞬移”的僵硬感。def update(self): keys pygame.key.get_pressed() self.speed_x 0 if keys[pygame.K_LEFT] or keys[pygame.K_a]: self.speed_x -PLAYER_SPEED if keys[pygame.K_RIGHT] or keys[pygame.K_d]: self.speed_x PLAYER_SPEED self.rect.x self.speed_x # 夹逼到窗口内 if self.rect.left 0: self.rect.left 0 if self.rect.right WIDTH: self.rect.right WIDTH这里的PLAYER_SPEED建议设为6或7。为什么不是 10因为 Pygame 默认窗口尺寸下rect.x的单位是像素每秒 60 帧速度 7 意味着每帧移动 7 像素横穿 800 像素的屏幕大约需要 1.9 秒这个速度对躲避子弹来说刚刚好。速度调到 12 以上玩家会觉得飞船“发飘”低于 4躲避敌机时又太肉。如果你做了 1280 宽度的窗口速度要线性拉高到 10 左右。3.2 子弹发射按下空格生成一枚有效子弹射击动作是典型的边沿触发事件所以不要用get_pressed()判断空格否则按住空格会变成连发机关枪游戏难度直接归零。标准做法是在事件循环里监听KEYDOWN检测到空格时创建一颗子弹加入bullet_group。子弹的初始位置要结合玩家飞船的位置计算。常见错误是把子弹发射点设为player.rect.center这个位置没有错但会从机腹正中冒出来观感很怪。正确做法是让子弹从玩家机头的两个发射点轮流射出这是老派射击游戏的标准“双翼炮”。def shoot(self): if self.cool_down 0: return left_bullet Bullet(self.rect.centerx - 15, self.rect.top) right_bullet Bullet(self.rect.centerx 15, self.rect.top) bullet_group.add(left_bullet, right_bullet)你可能注意到了我在玩家对象里维护了一个cool_down倒计时。这个变量会在update()里每帧减一减到 0 才允许再次射击。这就是连发限制的核心给下一次射击预留 10 到 15 帧的间隔。3.3 连发限制与冷却让射击手感不崩冷却时间的设定决定了射击节奏。对于一款入门级的简单射击游戏代码我建议cool_down 10帧对应每秒 6 发子弹这是最均衡的手感。如果设成5帧子弹密度太大敌人还没来得及移动就被秒光设成25帧玩家会在危险时刻感到无力。如果你的游戏里后续有“升级武器”的设定可以把冷却值拆成变量从基础值向下递减。完整的冷却逻辑放在玩家类的__init__和update里def __init__(self, x, y): super().__init__() self.cool_down 0 def update(self): if self.cool_down 0: self.cool_down - 1 # 在事件循环里处理空格 elif event.type pygame.KEYDOWN: if event.key pygame.K_SPACE: player.shoot()最后注意一个经常翻车的细节pygame.key.get_pressed()返回的是一个元组它反映的是“当前帧”的键盘状态如果你在事件循环里用了for event in pygame.event.get()然后又立刻调用get_pressed()二者不会互相干扰但不要在KEYDOWN分支里调用get_pressed()来判断另一个键是否同时按下因为事件循环有延迟组合按键状态可能对不上。处理组合键时统一放在update()里读取键盘状态是最可靠的。4. 敌人生成、碰撞检测与计分从子弹打空到得分停不下来游戏的核心循环能跑通后真正的游戏性来自“敌人从哪儿来打中后发生什么”。这一章会覆盖敌人生成、碰撞判定、得分与重开。到这一步你已经拥有一款能玩的射击游戏代码而不是一个会移动的空白画布。4.1 敌人生成定时器与多组刷新敌人不能一次性全刷出来否则要么前期太闲要么后期太挤。常见做法是用 Pygame 的pygame.time.set_timer创建自定义事件每隔一段毫秒数触发一次生成。比如定义pygame.USEREVENT 1作为生成敌人的事件在事件循环里监听它随机在屏幕上方生成一个新敌人。ENEMY_EVENT pygame.USEREVENT 1 pygame.time.set_timer(ENEMY_EVENT, 1800) # 每 1.8 秒生成一个敌人 while running: for event in pygame.event.get(): if event.type ENEMY_EVENT: spawn_enemy() def spawn_enemy(): x random.randint(20, WIDTH - 20) enemy Enemy(x, -30) enemy_group.add(enemy)set_timer的两个参数是事件类型和毫秒间隔。单位是毫秒别写成1.8否则你会看到一个精英敌人大军在 1.8 毫秒内诞生。生成间隔 1800 毫秒适合开局越到后面难度越高的设计我会放在最后一章讲。敌人自身的更新逻辑不只是向下移动还可以加入轻微的水平摆动。这个摆动用正弦函数做最简单每个敌人保存一个随机相位self.phase random.uniform(0, math.pi * 2)在update()里让self.rect.x math.sin(pygame.time.get_ticks() * 0.003 self.phase) * 1。这一行能让敌人轨迹变得像水母漂浮增强可玩性代价是每帧多算一次三角函数几十个敌人时 CPU 完全可以忽略不计。4.2 碰撞检测pygame.sprite 的 groupcollide 与伤害判定Pygame 里检测子弹和敌人碰撞最省心的是pygame.sprite.groupcollide(bullet_group, enemy_group, True, True)。它接受两组精灵和两个布尔参数第一个布尔值表示子弹是否在碰撞后销毁第二个布尔值表示敌人是否销毁。这就是子弹与敌机“同归于尽”的判定。但这种全量检测有一个边界坑子弹速度太快时可能一帧内从敌人左边穿到右边矩形没相交就“穿透”了。解决方式有三种按难度从低到高限制子弹速度不超过 15 像素/帧用更厚的碰撞矩形或者手动做射线步进检测。对你现在这个简单项目我建议把子弹速度控制在-12直接用groupcollide就够。hits pygame.sprite.groupcollide(bullet_group, enemy_group, True, True) if hits: score 10 update_score_display(score)hits是一个字典键是子弹精灵值是被撞到的敌人精灵列表。如果一个子弹同时撞到两个叠在一起的敌人第二个敌人也会被销毁但子弹就没了。这个行为在敌人叠堆时会导致“一颗子弹打掉好几架敌机”的场面是不是 bug 取决于你的设计。若不想让子弹一穿多就把敌人的生成间隔拉长并在碰撞检测里只处理每个键对应列表的第一个元素。4.3 计分与生命值UI 渲染和整局状态没有 score 的射击游戏是不完整的。Pygame 的字体渲染需要先pygame.font.SysFont或pygame.font.Font创建字体对象再渲染成 Surface最后在绘制阶段blit到屏幕上。字体文件路径在 Windows 下经常踩坑SysFont(Arial, 24)在 Win7 之后的系统基本都能找到但 Linux 服务器上常常没有 Arial建议用pygame.font.Font(None, 24)让 Pygame 使用内置默认字体省去路径问题。font pygame.font.Font(None, 28) def draw_score(screen, score): score_surface font.render(fScore: {score}, True, (255, 255, 255)) screen.blit(score_surface, (10, 10))生命值的做法更复杂一点玩家被敌人撞到一次生命减一生命归零时游戏结束。这里最大的坑是“无敌帧”如果不加保护玩家和敌人叠在一起时会持续掉血一秒钟扣光。常见做法是给玩家加一个invincible计时器被撞到后 100 帧内不再受伤同时让玩家半透明闪烁提示。def take_damage(self): if self.invincible 0: return self.lives - 1 self.invincible 100 def update(self): if self.invincible 0: self.invincible - 1 self.image.set_alpha(128 if self.invincible // 10 % 2 0 else 255)invincible 100对应 1.67 秒的无敌时间参数越大保护越强。闪烁的逻辑是 10 帧切换一次透明度这是玩家感知“我还在无敌”的视觉信号没有它玩家会以为撞一次只掉一条命实际却在连续掉血。这条视觉反馈属于游戏设计的“隐性规则”也是你接手别人游戏代码时最容易被删掉但删了必翻车的部分。5. 射击游戏代码常见翻车点帧率、碰撞、路径与依赖问题排查写到这一步你的 Demo 应该能玩了。但根据我给别人看代码的经验十个新手跑同一份代码能跑出十种奇怪的问题。这一章我挑四个高频坑每条按“现象 → 原因 → 解决”的方式记录都是我真实遇见过的血泪经验。5.1 现象运行时报“由于找不到 msvcp140.dll 无法继续执行代码”Windows 用户最容易遇到这个。代码本身没毛病但 Python 环境缺少微软的 C 运行库尤其是你用嵌入式 Python、绿色精简版 Python 或者某些公司统一分发版本的机器上。Pygame 的底层调用了 C 扩展dll 找不到直接拒绝启动。解决方法分两步先装 Visual C Redistributablex64 和 x86 都装上重启终端再运行如果项目里用了 PyInstaller 打包打包时加--add-data把依赖带进去。本质原因是运行环境不是一个完整安装的 Python。如果实在不方便装运行库换个思路用浏览器跑把这段逻辑改成网页游戏后面我再讲怎么发布。排查命令是python -c import pygame; print(pygame.version.ver)如果这条命令能打印版本号但双击.py文件报 dll 错说明是你的启动方式不对改用命令行运行即可。5.2 现象子弹穿透敌人、碰撞后精灵残留打印hits会发现字典有值但敌人没消失。最常见的原因是groupcollide的第二个布尔参数被写成了False或者你直接把参数顺序写反。也可能是敌人精灵被同时放进了enemy_group和all_sprites两个组而你的碰撞只检查了其中一个组另一个组里的敌人仍然留在屏幕上显示。解决时先把所有精灵归位到单一分组再做碰撞。如果你想要敌人撞到子弹后分裂成两个小人那就要把dokill设为False并在碰撞分支里手动创建新的敌人对象。代码上的排查口诀是销毁参数、分组归属、碰撞矩形位置三者按顺序检查。# 正确姿势 hits pygame.sprite.groupcollide(bullet_group, enemy_group, True, True) # 错误姿势一子弹不销毁 hits pygame.sprite.groupcollide(bullet_group, enemy_group, False, True) # 错误姿势二敌人不销毁 hits pygame.sprite.groupcollide(bullet_group, enemy_group, True, False)碰撞矩形位置这个坑更隐蔽self.rect self.image.get_rect()默认矩形在 (0, 0)如果你之后用self.rect.center (x, y)赋值没问题但如果你在__init__里先self.rect.x x再self.rect.y y而图片本身不是正方形碰撞区域会从原点偏移看着画面都撞上了实际矩形没碰着。解决方法是始终以center或topleft为锚点一次性设置。5.3 现象代码文件双击就闪退或者“文本文档怎么运行代码”不少新手把代码复制到记事本保存成game.py.txt后双击系统会用记事本打开而不是执行 Python。另一种情况是文件双击后弹出命令行窗口闪一下就没了程序已经运行完毕或异常退出。解决办法是建立“命令行运行”的习惯。打开 CMD 或 PowerShell进入代码所在目录再运行python game.py。不要双击运行因为闪退时错误信息根本看不到。我在调试的时候都会先跑一遍看红字红字才是排错的关键。如果连python命令都提示不是内部命令说明 Python 没有加入 PATH重装时勾选 “Add Python to PATH”。5.4 现象窗口拖动或最小化后游戏卡死、输入失灵Pygame 的set_mode默认打开窗口时拖动、最小化和切换焦点会在某些版本的 SDL 上阻塞主循环导致画面停住。这不是你的代码死循环而是窗口系统在等待事件处理。更常见的输入失灵是切走窗口再切回来之前按住的键状态没有重置飞船一直向某个方向飞。解决方式是在事件循环里监听WINDOWENTER、WINDOWLEAVE事件或者简单地在pygame.KEYUP分支里把所有方向标记置为 False。还有一个针对跨平台的做法在游戏循环开头调用pygame.event.pump()保持事件队列更新防止窗口管理器认为程序无响应。6. 从原型到可交付状态机、代码规范与打包发布技巧游戏能跑只是第一步真正能交给别人玩还需要处理两个问题游戏状态怎么切换、程序怎么分发给没有 Python 环境的人。这一章我分享三个自己常用的技巧也是把一个简单射击类游戏代码项目变成“作品”的必经之路。第一引入状态机。现在的代码是直线跑的启动就能玩Game Over 后直接退出。改成状态机后你有menu、playing、paused、game_over四个状态每个状态有独立的更新和绘制逻辑。状态机不是高级架构只是一个state变量加 if/elif 分支但会让代码条理清晰很多。别滥用设计模式游戏这种规模用if state playing完全够。state menu while running: if state menu: if event.type pygame.KEYDOWN and event.key pygame.K_RETURN: state playing elif state playing: player.update() enemy_group.update() bullet_group.update() if player.lives 0: state game_over elif state game_over: if event.type pygame.KEYDOWN and event.key pygame.K_r: reset_game() state playing第二代码规范不是什么大道理而是在拆分阶段就做好的事。我在写游戏时会刻意把配置项全部拎到文件顶部PLAYER_SPEED、BULLET_INTERVAL、ENEMY_SPAWN_MS、PLAYER_MAX_LIVES。这样改动手感时不用在整个项目里翻找魔法数字。注释我只写“为什么”不写“是什么”因为self.rect.x 1不需要注释# 速度设为 6 是为了让飞船横穿屏幕约 2 秒才需要写。第三打包发布。玩家不可能都装上 Python 解释器用 PyInstaller 一键打成 exe 是最省心的方案。打包命令pip install pyinstaller pyinstaller -F -w game.py-F生成单文件-w表示不弹控制台窗口。打包后要立刻在一台干净机器上测试因为你的开发机可能刚好装了各种依赖。如果打出来的 exe 报缺少msvcp140.dll就用静态方式编译或者让目标机器安装 VC 运行库。把打包后的文件传到网盘或 Git 仓库像码云、GitHub 都行你就完成了从“自己写的代码”到“能给别人玩的游戏”的转变。最后说一个我自己的习惯每次完成一个功能我会先切到全屏试玩五分钟再回到代码里把没用的测试变量删掉。游戏代码和业务代码不一样它每一帧都在循环运行一点多余的绘制、一次多余的碰撞检测都会被帧率放大。以前我因为贪图省事把碰撞检测写了两遍结果内存里到处是看不见的精灵那一晚上我调得心力交瘁。后来我学到一招在调试阶段永远在窗口标题显示当前帧率和精灵数量这两个数字比任何日志都诚实当你把精灵数量稳定住再关掉调试显示帧率通常就稳了。这个办法简单、直接也希望能帮到你。本文还有配套的精品资源点击获取