ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python pygame射击游戏开发:从游戏循环到碰撞检测详解

Python pygame射击游戏开发:从游戏循环到碰撞检测详解 简介基于J2ME平台开发的手机飞机射击游戏源码面向初学Java游戏编程的读者能帮助理解移动端小游戏从设计到实现的基础流程。压缩包为rar格式共200个文件、4.27MB含138张png图片素材、11个java源文件、22个class编译文件、20个mid音频及jad/jar/EclipseME配置源码、资源与运行配置齐全。已有1950人学习/下载。代码覆盖游戏循环、精灵对象、碰撞检测、用户输入、图形渲染、音频播放和状态管理等核心环节直观展示了一款简单飞行射击游戏的实现思路。通过研读源码与工程结构可以掌握在CLDC/MIDP环境下开发移动游戏的典型方法并了解音效播放、碰撞检测等具体写法的应用。1. 一款简单射击类游戏代码先看它到底能给你什么射击类游戏代码在网上流传很多但能跑起来、又能讲清逻辑的其实不多。这套代码的核心是一架用方向键控制的飞机屏幕上方会不断生成敌机和子弹击中后计分被碰到就结束。它不依赖庞大引擎只用 Python 和 pygame主文件两百行出头拿来改一改就可以当作一门课程设计或者作为第一次接触游戏循环的入门材料。它最大的价值不是“能玩”而是把游戏开发里最绕的几个点——事件循环、坐标移动、碰撞判定——压缩到一个足够小的范围里让新手能看完熟手能直接改。接下来我会把它拆开从运行环境写到碰撞判定再给你几个我实际踩过的坑。2. 先把游戏逻辑啃透事件循环、移动与碰撞判定从这里拆2.1 游戏循环的骨架三个步骤一个时钟任何游戏画面能“动”起来靠的是主循环里反复执行三件事处理输入、更新状态、重绘画布。这套代码的主循环结构非常标准去掉所有装饰之后长这样import pygame def main(): pygame.init() screen pygame.display.set_mode((480, 640)) # 屏幕宽高 clock pygame.time.Clock() running True while running: # 第一步把事件队列里的事件挨个捞出来 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 第二步更新游戏对象位置这里简化成直接赋值 # 第三步画背景、画飞机、刷新屏幕 pygame.display.flip() clock.tick(60) # 控制循环每秒最多跑 60 帧 pygame.quit()这里的clock.tick(60)很关键它像一个节拍器保证循环不会快过每秒 60 次。没有它游戏的运行速度会直接取决于机器的性能老机器慢得像幻灯片新机器快得看不清敌机。我会习惯把60抽成顶层常量因为后文调难度曲线时要反复用到。pygame.display.flip()是把整个绘制缓冲交换到屏幕所有blit操作必须在它之前完成否则画面会出现撕裂或闪烁。2.2 移动逻辑把按键映射到坐标别忘了归一化飞机移动听起来简单就是按下方向键改坐标但直接写容易踩到“斜着跑更快”的经典问题。因为水平和垂直方向各自按同样速度累加斜方向位移量会是单方向的根号 2 倍。本代码的做法是先读键盘状态再把速度向量归一化def move_plane(plane, speed_x, speed_y): keys pygame.key.get_pressed() # 返回所有键的状态 dx, dy 0, 0 if keys[pygame.K_LEFT]: dx - 1 if keys[pygame.K_RIGHT]: dx 1 if keys[pygame.K_UP]: dy - 1 if keys[pygame.K_DOWN]: dy 1 # 归一化同时按两个方向时位移长度为 1 if dx ! 0 and dy ! 0: dx * 0.7071 dy * 0.7071 plane.rect.x dx * speed_x plane.rect.y dy * speed_y # 边界裁剪不让飞机飞出屏幕 plane.rect.clamp_ip(pygame.Rect(0, 0, 480, 640))注意这里用key.get_pressed()而不是event.type pygame.KEYDOWN因为前者能响应“按住”的状态后者必须每按一下触发一次玩起来会有顿挫感。0.7071是根号 2 分之一我一般直接写成1 / 2 ** 0.5减少魔法数字。边界裁剪用clamp_ip它会直接修改矩形位置比手写if x 0: x 0更简洁。这套代码里飞机的实际碰撞区域就是plane.rect所以把它限制在屏幕内也就等于限制了飞机的可见范围。2.3 碰撞判定用矩形而不是像素省事且够用碰撞判定是射击游戏最容易被新手写复杂的部分。像素级检测要读取每个精灵的 mask逐位比较确实准确但对这种简单游戏来说完全没必要。pygame 自带的spritecollide默认基于矩形检测代码里敌机、子弹、玩家飞机都挂接在一个pygame.sprite.Group里直接用现成函数def handle_collisions(player, enemies, bullets): # 子弹与敌机碰撞命中后两者都消失 hit_enemies pygame.sprite.groupcollide(bullets, enemies, True, True) # 玩家与敌机碰撞玩家受伤敌机消失 crash_list pygame.sprite.spritecollide(player, enemies, True) if crash_list: player.hp - 1 player.invincible_until pygame.time.get_ticks() 1000 # 返回值可以被主循环用来更新计分 return len(hit_enemies)groupcollide的第三、四个参数控制碰撞后是否删除子弹和敌机这里都设True表示一次性命中即失效。spritecollide的第三个参数也是删除敌机这样当玩家撞上时那架敌机立刻消失避免同一个敌机反复造成伤害。需要注意这里玩家hp减 1 后没有立即判定死亡而是给了一个 1000ms 的无敌时间这是为了避免碰撞判定在连续几帧内重复触发导致“碰一下扣十滴血”。我之前见过很多人直接写player.hp - 1而不设无敌时间结果玩家碰到敌机瞬间就被秒掉。3. 把代码跑起来环境安装、启动参数与资源目录的约定3.1 环境准备Python 3.8 与 pygame 的版本匹配这份代码对环境要求很低但版本匹配是新手最先翻车的地方。我用的是 Python 3.8 搭配 pygame 2.0 及以上版本。pygame 1.9 的部分接口在 2.0 里有细微变化比如pygame.display.set_mode的vsync参数1.9 不支持2.0 才加入。安装命令很简单pip install pygame2.0.3也可以直接pip install pygame装最新版但如果你是在公司内网或离线环境预先指定版本号更稳妥。装完后强烈建议先跑一条验证命令python -c import pygame; print(pygame.version.ver)如果看到类似2.0.3的输出说明环境没问题。我在教学时发现有不少人装完 pygame 后直接运行主文件结果报ModuleNotFoundError一问才知道是开了多个 Python 环境pip 装到了另一个环境里。这个问题的排查我会在避坑章节详细讲。3.2 启动参数与调试开关主程序的设计偏教学向所以我在入口处放了一组常量开关而不是硬编码行为。你拿到代码后建议优先改这几个参数# config.py SCREEN_WIDTH 480 SCREEN_HEIGHT 640 PLAYER_SPEED 8 BULLET_SPEED -15 # 负值代表向上移动 ENEMY_SPEED_RANGE (2, 5) SHOOT_INTERVAL 200 # 毫秒 BGM_VOLUME 0.6 DEBUG_MODE True # 开启后显示碰撞矩形和帧率BULLET_SPEED -15很关键因为屏幕坐标系的原点在左上角x 轴向右是正方向y 轴向下是正方向所以飞机向上移动要用负值。很多人把子弹速度写成正 15结果子弹一发枪口就往下掉。SHOOT_INTERVAL控制自动开火频率单位是毫秒200 表示每 0.2 秒发一颗放在自动射击模式里感觉比较平衡。DEBUG_MODE如果为真代码会在每个精灵的矩形边框画上红色线条同时在窗口标题显示当前帧率和对象数量。这个开关就是我后面调试时的主要工具。3.3 资源文件怎么放image、sound、config 的默认路径代码默认按相对路径读取资源目录结构约定如下game/ ├── main.py ├── config.py ├── images/ │ ├── player.png │ ├── enemy.png │ └── bullet.png └── sounds/ ├── shoot.wav └── explode.wav如果你下载的压缩包不带这些素材可以先用简单色块绘制临时占位图。我一般会用 pygame 内置的 Surface 代替图片文件避免一上来就缺素材跑不起来。具体做法是def make_placeholder(size, color): surface pygame.Surface(size) surface.fill(color) return surface把pygame.image.load的调用包在一个 try 里找不到图片时就生成占位 Surface。这样即使资源文件缺失游戏也能启动只会在控制台报一句警告。这不算偷懒是为了让逻辑调试和素材处理解耦你不需要等美术资源到位就可以开始验证玩法。4. 避坑指南帧率、坐标系、资源路径的三处硬伤4.1 现象飞机移动一顿一顿子弹看起来像在瞬移一开始我跑这套代码时明明循环里写了clock.tick(60)但飞机在屏幕上依然飘忽不定。检查后发现config.py里定义FPS 60但主循环实际调用的是clock.tick(FPS)没错问题出在我把物理更新写在了pygame.event.get()之后而get()之前有一段sleep(0.01)导致每帧实际耗时超过了 16.7 毫秒帧率被拖低。这个例子里没有 sleep但类似的坑很常见比如在循环里执行文件读取、网络请求甚至print()都会让单帧时间不稳定。解决方法是把耗时操作放到初始化阶段或单独线程游戏循环只保留渲染、输入、更新同时让tick返回上一帧实际耗时dt并基于dt做物理计算而不是假设每帧间隔相同。具体做法是dt clock.tick(60) / 1000.0 # 单位转换为秒 plane.x speed_x * dt如果改成这种写法即使掉到 40 帧飞机速度也不会变化太大体验会平滑很多。但要注意因为代码原版是按帧固定步长写的你如果直接引入 dt还需要把PLAYER_SPEED 8从“每帧 8 像素”改成“每秒 8 * 60 像素”之类的量级否则飞机变得极慢。4.2 现象玩家飞机撞到了敌机但子弹没打中或者反而从敌机身上穿过去了这是坐标系混用导致的。pygame.sprite.Sprite里的rect是精灵的碰撞区域但blit绘制图像时用的是rect的x和y作为左上角。很多人会直接拿图片尺寸之外的区域参与碰撞比如把飞机图像的长宽和rect搞混。具体表现是子弹看起来明明正中敌机但没有任何碰撞反馈。当时的排查过程是这样的先在DEBUG_MODE下画出所有碰撞矩形发现子弹的rect竟然只有 2×2 像素而图像本身是 20×20。查代码后发现有人手动修改了子弹rect的尺寸来“让碰撞更准”结果把碰撞区域改到了图像中心的一小块儿看起来穿透了。解决方法是让rect初始等于图像尺寸再对边缘留白多的图片做inflate或size重塑而不是缩放rect。我一般建议在子弹和飞机图像边缘留出透明像素然后直接使用原始尺寸的rect避免人为缩小碰撞盒导致“隔空命中”的错觉。4.3 现象代码在 Windows 上直接跑报错“找不到 images/player.png”这个坑十有八九是工作目录不对。代码里用的是相对路径images/player.png但如果你在命令行里用python game/main.py启动那么当前工作目录是game的上一级代码就会去上一级找images/player.png必然报错。更稳的是先获取脚本所在目录再拼接路径import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) image_path os.path.join(BASE_DIR, images, player.png)我后来把整个游戏改成用pathlib写路径还顺手解决了一个 Windows 下反斜杠转义的问题。这个改动让代码在中文目录名环境下也能正常运行。除此之外如果你用图形化 IDE 直接运行工作目录会指向项目根目录通常没问题但如果你用任务计划或双击脚本启动环境不一定一样。建议在入口函数的第一行打印os.getcwd()便于定位路径错误。4.4 现象游戏运行一段时间后内存明显上升操作开始卡顿观察到敌机和子弹数量越来越多即使它们已经飞出屏幕外也没有被回收。原代码里虽然用了pygame.sprite.Group但只在碰撞时删除精灵而飞出屏幕的对象没有清理逻辑。于是一挂机两小时group 里攒了几千个精灵。解决方法是加一个简单的屏幕外回收逻辑def clean_offscreen(group, screen_rect): for sprite in group: if not screen_rect.colliderect(sprite.rect): sprite.kill()kill()会把它从所有 group 中移除并释放引用。这个函数每帧调用一次开销很小。需要注意sprite.kill()只能调用一次否则会报错或者清理到重复对象。我见过有人卸载精灵时遍历 group 又同时删除导致迭代器失效、跳过了部分对象正确的姿势是像上面这样收集好要杀的精灵后统一kill或者直接利用remove()返回新 group 再迭代。5. 进阶一点子弹对象池、难度曲线与音效触发5.1 子弹对象池避免频繁创建毁灭性能原代码里每按一次射击就create_bullet()释放时再kill()短时间内创建和销毁大量对象垃圾回收压力大。根本原因是 pygame 的 Sprite 不是轻量对象每个都要生成一个pygame.Rect和一个Surface频繁到后来会肉眼可见地卡顿。我的习惯是改造成对象池class BulletPool: def __init__(self, max_size50): self.bullets pygame.sprite.Group() for _ in range(max_size): bullet Bullet() bullet.add(self.bullets) bullet.kill() # 从所有组移除但保留对象引用 def shoot(self, pos): for bullet in self.bullets: # 此时组里实际上是“空闲”的 pass实际上kill()后对象不在任何 group 中所以无法直接遍历空闲列表。一个更简单的方法是保留一个list管理所有对象再配一个活跃 groupclass BulletPool: def __init__(self, max_size50): self._all [Bullet() for _ in range(max_size)] self.active pygame.sprite.Group() def shoot(self, pos): for bullet in self._all: if not bullet.alive(): bullet.rect.center pos bullet.add(self.active) returnalive()是 sprite 自带的状态判断未 kill 时返回True。当子弹飞出屏幕时clean_offscreen会把它kill()下次射击时就可以复用。这比不断创建新对象平滑很多尤其当屏幕上子弹数量达到上限时超出部分的射击会直接丢弃不会无限增长内存。还有一点池子的容量可以做成可调参数放在配置区里方便做压力测试。5.2 难度曲线每隔 5 秒增加敌方速度与射速简单射击游戏最大的问题是一旦掌握了规律就会觉得无聊。代码里如果能内置一条简单的难度曲线玩起来会更有反馈感。我的做法是记录游戏开始时间定时提升敌机参数。比如每过 5 秒敌方移动速度增加 10%生成间隔减少 50 毫秒。对应的参数调整函数长这样def update_difficulty(start_time, enemies): elapsed pygame.time.get_ticks() - start_time level elapsed // 5000 # 每 5 秒升一级 for enemy in enemies: enemy.speed min(15, ENEMY_BASE_SPEED * (1 0.1 * level)) enemy.spawn_interval max(300, ENEMY_BASE_INTERVAL - level * 50)这里注意不要直接改全局常量而是让每个敌机实例保存自己的当前速度。因为如果你改全局值那么已经生成的旧敌机可能引用同一个速度变量导致所有敌机瞬间同步变速反而显得不自然。参数里的min和max是边界保护防止速度超过 15 或者生成间隔低于 300 毫秒否则后期会变成一堵墙射过来技术上没难度纯粹是反应测试。我测试下来最舒服的梯度是前 30 秒保持基础难度之后每 5 秒微调而不是开局就线性增。5.3 音效触发用混音队列替代每次按键都加载声音这块很多人都忽略子弹射击音效如果每帧都重新加载shoot.wav播放时会有卡顿。正确做法是在初始化时一次性把音效加载到内存然后直接用pygame.mixer.Sound.play()。不过即便这样快速连发时同一个音效也会被反复叠加声音混乱。我会给每个音效设置一个冷却时间class SoundManager: def __init__(self): self.shoot_sound pygame.mixer.Sound(sounds/shoot.wav) self.last_shoot_play 0 def play_shoot(self): now pygame.time.get_ticks() if now - self.last_shoot_play 100: self.shoot_sound.play() self.last_shoot_play now这里设为 100 毫秒冷却配合射速 200 毫秒刚好不会重叠。如果你按住了空格自动射击也避免了一次循环里重复触发声效。还有一个容易忽略的问题pygame 的mixer初始化时如果不指定buffer在部分 Linux 环境下会有延迟或无声。我一般会在pygame.init()后显式初始化pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init()pre_init必须在init之前调用512是 buffer 大小值越小延迟越低但太小可能导致爆音。这些参数在 Windows 下默认也能跑但到了嵌入式设备或老旧机器上就容易翻车。6. 验证与调试用一条日志和三条断言让游戏变透明6.1 用日志追踪帧率与游戏状态游戏写完了最大的问题就是黑匣子。我习惯在DEBUG_MODE开启时把每帧的关键数据通过日志输出到控制台。不要每帧都打印那样会刷屏严重。我一般设置每 30 帧打印一次frame_count 1 if DEBUG_MODE and frame_count % 30 0: fps clock.get_fps() print(f[{frame_count}] fps{fps:.1f} objs{len(all_sprites)} fpos({player.rect.x},{player.rect.y}) hp{player.hp})这段日志可以让你观察到三个关键指标帧率是否稳定、当前场景对象数有没有异常增长、玩家位置是否在预期的轨道上。有一次我改了飞机速度之后日志显示玩家坐标每隔几帧就跳到 80 多明显不对劲后来发现是在update里把速度加到rect.x的同时又在外部做了一次move_ip导致位移翻倍。如果你不打印坐标这种 bug 可能很久都发现不了。记得在发布版里把DEBUG_MODE关掉否则日志写入本身会降低帧率。6.2 三条断言防住最常见的隐形 bug断言适合在开发期做快速校验不合适放在成品里。我会在代码里加三条断言每次启动时自动检查def validate_config(cfg): # 1. 碰撞盒尺寸不能比图片实际矩形还大 assert cfg.PLAYER_HITBOX_W cfg.PLAYER_IMG_W # 2. 屏幕宽高必须为正偶数避免某些渲染问题 assert cfg.SCREEN_WIDTH 0 and cfg.SCREEN_WIDTH % 2 0 # 3. 子弹速度与飞机方向要一致防止反向射击 if cfg.BULLET_SPEED 0: assert cfg.BULLET_DIRECTION up and cfg.BULLET_SPEED 0第一条断言防止有人把碰撞盒调到图片边缘外导致很远的“空气墙”也能撞到。第二条几乎不会出问题但能防止有人把屏幕宽高配置成0或负值造成分母为零的异常。第三条是针对我前面提到的坐标系事件如果子弹速度正负和方向标记冲突立刻报错而不是运行时弹到屏幕外面才懵。这三条断言本质上是把“写代码时容易犯的错”转化为启动时的显式失败省去了事后的排查时间。从我开始用这套代码做教学以来一直保留着一个习惯每次改完参数先启动一轮带DEBUG_MODE的试跑盯着控制台日志看 60 秒确认帧率、对象数量、玩家位置没有异常再关掉调试模式交付正式运行。这个习惯帮我挡掉了至少五次因为坐标正负写反导致的“反向射击”事故。希望这次拆解能让你少走同样的弯路把这份代码真正改造成你自己的项目版本。本文还有配套的精品资源点击获取
返回列表