☰
Python pygame射击游戏开发:从游戏循环到碰撞检测详解
2026/10/6 13:04:28 网站建设 项目流程

简介:基于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 pygame==2.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_size=50): 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管理所有对象,再配一个活跃 group:

class BulletPool: def __init__(self, max_size=50): 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) return

alive()是 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)} " f"pos=({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 秒,确认帧率、对象数量、玩家位置没有异常,再关掉调试模式交付正式运行。这个习惯帮我挡掉了至少五次因为坐标正负写反导致的“反向射击”事故。希望这次拆解能让你少走同样的弯路,把这份代码真正改造成你自己的项目版本。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询