☰
用pygame做雷霆战机:Python小游戏练手第14作
2026/11/2 16:00:47 网站建设 项目流程

这是我给练手系列写的第14个Python小游戏,题材是竖版飞行射击,也就是大家熟悉的雷霆战机那种玩法:飞机在屏幕底部左右移动,子弹往上打,敌机往下掉,撞上就少一条命,打掉就加分。前面十几个项目从猜数字、贪吃蛇到打砖块,难度基本停在“能跑起来就行”;到这一作我刻意挑了个看起来简单、实际能把游戏开发大半基础问题一次性过一遍的题目。它不需要3D、不需要物理引擎,但要求你把游戏循环、输入处理、精灵管理、碰撞检测、对象生命周期、难度曲线、状态切换这些环节全部串起来,任何一环偷懒,后面都会以“手感怪异”或者“帧率掉到30”的形式找上门。

如果你刚学完Python基础语法,能看懂类和列表推导,想找个项目把“写脚本”升级成“写程序”,这个题材非常合适;如果你已经写过几个小游戏,也可以拿它练对象池、帧动画、打包发布这些偏工程的东西。下面我按真实开发顺序拆,代码全部是可直接跑的简化版本,参数我给了推导过程,你换成自己的数值也知道为什么这么取。

1. 为什么第14个练手项目选雷霆战机

1.1 一个屏幕就能装下的完整游戏骨架

飞行射击这个题材最大的优势是需求边界极其清晰。你不需要设计关卡编辑器、不需要做存档系统、不需要考虑多人同步,规则四句话能说完,但这四句话背后对应着游戏开发的四根柱子:玩家的实时输入与反馈、持续生成且需要回收的动态对象、空间上的碰撞判定、随时间变化的难度与资源。把这四件事做扎实,你再去做别的类型,脑子里是有框架的。

而且它的调试反馈是可视化的。写爬虫你盯着日志猜,写后端你对着返回码发愁,做射击游戏不一样——子弹飞太快了你看得见,敌机重叠了你看得见,判定框偏了两像素你也能一眼瞅出来。新手最缺的其实不是知识,是“改一行代码立刻知道对错”的闭环,这个项目天生自带这个闭环。

我自己带人练手时有个判断标准:一个项目能不能让人在两小时内看到第一个可玩版本。雷霆战机可以。窗口、飞机、一颗子弹、一个敌人,二三十行核心代码就转起来了,剩下的全是往上叠。

1.2 技术选型:为什么是pygame而不是别的

Python做实时2D游戏,能选的库其实不多,我把常见的几个摆在一起对比过:

方案优势短板适配场景
pygame资料多、API稳定、社区示例海量接口偏底层,很多轮子要自己写教学与独立小游戏首选
arcadeOpenGL加速、API更现代、绘图方便中文资料相对少,打包体积偏大想做粒子效果和大量精灵
pyglet底层可控、事件模型灵活学习曲线陡,新手容易卡在初始化有图形编程经验的人
tkinter Canvas标准库自带、零安装帧率不稳、输入响应有延迟只做静态演示
turtle直观、适合小朋友性能极差,几十个对象就卡教学演示

结论很明确:练手选pygame。它的“底层”恰恰是优点——pygame.sprite.Group、Rect、clock.tick这些东西你亲手用一遍,就明白了游戏引擎内部大概在干什么。以后换Unity或者Godot,你会觉得那些框架把脏活都替你干了,而不是“魔法”。

安装就一行,注意用虚拟环境,别往系统Python里装:

python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate python -m pip install pygame==2.5.2

版本我建议锁在2.5.x。2.x系列的API已经稳定,网上能搜到的教程绝大多数适用;3.x有些绘图参数变了,新手照着老文章抄容易报错。装完验证一下:

import pygame print(pygame.version.ver) # 期望输出 2.5.2

提示:如果你在无桌面环境(比如远程服务器)里跑,pygame.display.set_mode会直接失败,这是正常的,不是代码问题。音频同理,后面我会给静音兜底写法。

2. 工程骨架与核心参数设计

2.1 目录结构:一开始就分文件,别全塞一个main.py

很多人写小游戏喜欢一个文件写到八百行,改到后面自己都找不到玩家类在哪。我建议第一天就分好,哪怕每个文件只有几十行:

thunder/ ├── main.py # 入口,只负责初始化与主循环 ├── settings.py # 所有常量参数 ├── sprites.py # Player / Bullet / Enemy / Explosion ├── scene.py # 菜单、游戏中、结算的状态切换 └── assets/ # 图片与音效(可以后期再加)

settings.py单独拎出来这件事,收益比想象中大。游戏调参是个高频动作,速度、间隔、血量你会来回改几十次,全散在代码里改起来极痛苦。把参数集中,你甚至可以在游戏里加个调试面板实时改,改完再写回文件:

# settings.py WIDTH, HEIGHT = 480, 800 FPS = 60 PLAYER_SPEED = 5 PLAYER_LIVES = 3 PLAYER_INVINCIBLE_FRAMES = 90 # 受伤后无敌1.5秒 BULLET_SPEED = 16 FIRE_INTERVAL = 7 # 帧,约0.117秒 ENEMY_BASE_SPEED = 2 ENEMY_SPAWN_INTERVAL = 60 ENEMY_MAX_ALIVE = 12

分辨率选480×800是有讲究的:它是竖屏手机的比例(3:5),后期你想搬到移动端,布局不用重排;同时这个尺寸下像素级绘制不吃力,老笔记本也能稳60帧。用1920×1080做逻辑分辨率是自找麻烦,所有坐标都要乘系数。

2.2 参数不是拍脑袋定的:用帧率和时长反推速度

新手最容易犯的错是“速度写个5试试,感觉不对改成8”。其实这些数字是可以算出来的,而且算出来的手感比试出来的稳。

游戏逻辑跑在固定帧率上,clock.tick(60)意味着每帧约16.7毫秒,一帧移动的像素数乘以60就是每秒像素数。

玩家横向速度:屏幕宽480,我希望从最左边滑到最右边大约1.6秒。那么每秒移动300像素,每帧300÷60=5像素。这就是PLAYER_SPEED = 5的来历。如果你觉得1.6秒太慢,改成1.2秒,那就是480÷1.2÷60≈6.7,取7。

子弹速度:从屏幕底部(y≈750)打到顶部(y=0)大约需要0.8秒,这个时长是“玩家按下开火到命中”的心理预期,太慢了会觉得子弹飘。750÷0.8÷60≈15.6,取16。也就是说BULLET_SPEED = 16。

射击间隔:手感上0.1到0.13秒一发比较舒服,低于0.08秒屏幕会被子弹糊满,高于0.2秒又显得稀疏。0.12秒×60帧≈7帧,所以FIRE_INTERVAL = 7。

敌机速度:普通敌机从顶部到底部,我给它留6到8秒的存活窗口。800÷7÷60≈1.9,取2。快一点的俯冲型取4到5。

参数取值推导依据
逻辑分辨率480×800竖屏比例,后期移植不重排
帧率60与垂直同步对齐,肉眼无顿挫
玩家速度5 px/帧横穿屏幕约1.6秒
子弹速度16 px/帧穿越屏幕约0.8秒
射击间隔7帧约0.117秒,密度适中
敌机基础速度2 px/帧约7秒穿越屏幕
同屏敌机上限12控制判定与绘制开销

实操心得:参数一定要写成“意图”而不是“数字”。我在settings.py里每个常量上面都写一行注释说明它换算成时间是多少,过一个月回来改,两秒钟就能对齐,不用重新试。

2.3 零素材起步:先用几何图形跑通逻辑

我强烈建议第一个版本不要找美术素材。图片一进来,你立刻多出三个变量:分辨率、透明通道、锚点位置。新手常常卡在“为什么我的飞机画出来位置歪了”这种和游戏逻辑毫无关系的问题上,一卡就是两小时。

用pygame.draw画几何图形先跑通全部逻辑,等能玩了你再替换素材,这时候哪怕位置歪了,你也知道是锚点问题,调起来有方向。画一架飞机四行代码:

self.image = pygame.Surface((36, 48), pygame.SRCALPHA) pygame.draw.polygon(self.image, (90, 200, 255), [(18, 0), (36, 40), (18, 30), (0, 40)])

这块SRCALPHA别漏,漏了你就得到一个黑底矩形,背景全糊。它告诉pygame“这个Surface带透明通道”。

3. 从窗口到可操控的飞机

3.1 主循环只干四件事

游戏循环是所有实时程序的骨架,新手把它想复杂了。拆开看就四步,顺序不能乱:

import sys import pygame from settings import WIDTH, HEIGHT, FPS pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("Thunder") clock = pygame.time.Clock() running = True while running: # 1. 吃事件 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 2. 更新状态 # ... # 3. 绘制 screen.fill((8, 12, 28)) # ... # 4. 翻页并控帧 pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit()

四个步骤对应四个坑,我一个个说。

第一,事件必须在每帧清空。pygame.event.get()是消费式读取,你不在这一帧拿出来,事件会在队列里堆积,跑久了内存涨、响应变迟钝。有人为了图省事在别处又调一次get(),结果主循环拿不到事件,窗口关不掉。

第二,更新和绘制必须分离。我见过很多新手把rect.x += 5写在绘制代码里,逻辑和渲染搅在一起,回头想加暂停功能就得翻遍全文件。养成习惯:更新阶段只改数据,绘制阶段只读数据。

第三,display.flip()是翻页,不是清屏。新手常见现象是两个飞机叠在一起拉出一串残影,八成是忘了每帧fill背景。flip()只是把后台缓冲画到屏幕上。

第四,clock.tick(60)放在循环末尾,它做两件事:限制帧率、返回上一帧耗时。位置放错了帧率控不住。

3.2 玩家控制:为什么用get_pressed而不是KEYDOWN

这是新手第一个大坑。用KEYDOWN事件做移动,你会发现按住方向键飞机只动一格就停了,因为键盘自动重复的触发频率由操作系统决定,和你的帧率对不上。正确做法是每帧询问“当前哪些键处于按下状态”:

class Player(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image = pygame.Surface((36, 48), pygame.SRCALPHA) pygame.draw.polygon(self.image, (90, 200, 255), [(18, 0), (36, 40), (18, 30), (0, 40)]) self.rect = self.image.get_rect(center=(WIDTH // 2, HEIGHT - 100)) self.lives = PLAYER_LIVES self.invincible = 0 def update(self): keys = pygame.key.get_pressed() dx = keys[pygame.K_RIGHT] - keys[pygame.K_LEFT] dy = keys[pygame.K_DOWN] - keys[pygame.K_UP] self.rect.x += dx * PLAYER_SPEED self.rect.y += dy * PLAYER_SPEED self.rect.clamp_ip(pygame.Rect(0, 0, WIDTH, HEIGHT)) if self.invincible > 0: self.invincible -= 1

注意keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]这一行。pygame返回的是布尔值,Python里True - False等于1,True - True等于0,False - True等于-1。一行搞定三态方向,比写三个if清爽得多。

clamp_ip是pygame Rect自带的边界钳制,_ip后缀表示原地修改。这比手写四个if判断省事,而且不会漏掉某个角。很多教程会让你手动判断if rect.left < 0: rect.left = 0,能跑但啰嗦,还容易在斜向移动时出边界bug。

3.3 手感:为什么我不建议加加速度

有人一上来就想做“物理感”,按住方向键让飞机先加速再匀速。对雷霆战机这类游戏,我建议不要。理由很实在:射击游戏要求的是精确站位,你希望飞机的位移和手指的意图完全一比一对应。加了加速度,飞机就会带“滑冰感”,你想贴边躲子弹,结果它冲过头撞上去了。

如果你确实想要一点顺滑,正确做法不是加速度,而是缓动插值:记录一个目标位置,每帧让实际位置朝目标靠近一定比例,既顺滑又可控。

# 缓动写法,可选 self.x += (target_x - self.x) * 0.35

0.35这个系数越大越跟手,越小越飘。这个技巧在后面做背景滚动、UI弹出动画时都能复用。

提示:玩家初始位置放在HEIGHT - 100而不是最底部。底部留出的空间是给“手指/视线”的缓冲,飞机贴底边会让玩家有压迫感,也容易被从底部突然出现的敌人偷袭。

4. 子弹、敌机与碰撞:这个项目真正的技术含量

4.1 发射节奏用一个倒计时变量就够了

子弹发射不需要定时器线程,也不需要pygame.time.set_timer,一个帧计数器足矣:

fire_cd = 0 # 在update里 if keys[pygame.K_SPACE] and fire_cd <= 0: bullets.add(Bullet(player.rect.centerx, player.rect.top)) fire_cd = FIRE_INTERVAL if fire_cd > 0: fire_cd -= 1

这个模式叫“冷却计数”,几乎所有游戏都用它:技能冷却、敌人攻击间隔、自动回血,全是同一个套路。理解一次,处处能用。

子弹类的实现有个细节值得说:

class Bullet(pygame.sprite.Sprite): _image = None # 类级缓存,全部子弹共用一张图 @classmethod def get_image(cls): if cls._image is None: surf = pygame.Surface((6, 18), pygame.SRCALPHA) pygame.draw.rect(surf, (255, 230, 120), (0, 0, 6, 18), border_radius=3) cls._image = surf return cls._image def __init__(self, x, y): super().__init__() self.image = self.get_image() self.rect = self.image.get_rect(midbottom=(x, y)) def update(self): self.rect.y -= BULLET_SPEED if self.rect.bottom < 0: self.kill()

类级缓存是关键。如果每发子弹都在__init__里新建一个Surface,一秒打八发,一分钟就是480次内存分配加绘图调用,虽然现代机器扛得住,但这是无谓开销。图片是只读的,全部子弹共用同一张完全没问题,self.image只是引用。

self.kill()是pygame.sprite.Sprite自带的方法,作用是从它所属的所有Group里移除自己。这就是对象生命周期管理的标准做法:自己判断出界,自己退场,不要在外部循环里到处删元素。见过太多人写for b in bullets: if b.rect.bottom < 0: bullets.remove(b),结果漏删或者跳过元素——遍历中删除列表是经典陷阱。

4.2 敌机与难度曲线:用时间驱动,不要用击杀数

难度递增有两种驱动方式:按分数、按时间。我推荐按时间。

按分数有个问题:玩家如果很菜,分数涨不上去,难度永远不涨,他玩十分钟还是那点强度,很快就腻;玩家如果很强,分数飙升,难度突然跳一大截,容易猝死。按时间则是一个相对平滑的斜坡,不管你打得怎么样,一分钟后的强度是确定的,玩家能形成稳定的节奏预期。

import random class Enemy(pygame.sprite.Sprite): def __init__(self, level): super().__init__() size = random.choice([(40, 40), (32, 32), (48, 36)]) self.image = pygame.Surface(size, pygame.SRCALPHA) color = random.choice([(255, 110, 110), (255, 170, 90), (200, 120, 255)]) pygame.draw.rect(self.image, color, (0, 0, *size), border_radius=6) self.rect = self.image.get_rect( midtop=(random.randint(30, WIDTH - 30), -size[1])) self.speed = ENEMY_BASE_SPEED + level * 0.4 + random.uniform(0, 0.8) self.hp = 1 + level // 3 self.score = 10 + size[0] // 4

难度参数这样算:

elapsed_ms = pygame.time.get_ticks() - start_time level = min(elapsed_ms // 10000, 6) # 每10秒升一级,最高6级 spawn_interval = max(ENEMY_SPAWN_INTERVAL - level * 8, 16) # 60帧降到16帧
等级触发时间生成间隔(帧)等效每秒敌机数敌机基础速度
00秒601.02.0
110秒521.152.4
220秒441.362.8
330秒361.673.2
440秒282.143.6
550秒203.04.0
660秒163.754.4

到第6级,敌机速度4.4,穿越屏幕800÷4.4÷60≈3秒,同屏最多堆到十来架,这个强度对人来说是有挑战但不至于绝望的。如果你想更狠,把上限从6提到8,但要注意ENEMY_MAX_ALIVE这个闸门必须留着,否则一旦生成间隔小于清理速度,同屏对象会无限膨胀,帧率断崖式下跌。

4.3 碰撞判定:矩形、圆、像素,选哪个

这是整个项目里最值得展开的部分,因为它直接决定“手感”。三种方案:

方式精度开销适用
矩形判定低,边角不贴合极低方块形敌人、粗筛
圆形判定中,适合圆形目标低子弹、球形单位
像素级判定高,贴合轮廓较高少量关键对象

第一个版本用矩形就行,pygame的Group系列已经封装好了:

# 子弹与敌机的碰撞,命中就删除子弹,敌机由外部扣血 hits = pygame.sprite.groupcollide(bullets, enemies, True, False) for bullet, hit_list in hits.items(): for enemy in hit_list: enemy.hp -= 1 if enemy.hp <= 0: enemy.kill() score += enemy.score spawn_explosion(enemy.rect.center)

注意groupcollide的四个参数顺序:(group1, group2, dokill1, dokill2)。我见过不止一个人把dokill顺序写反,结果敌机没了子弹还在,场面很诡异。

但矩形判定有个体验问题:碰到机翼就算死。玩家的飞机是尖头的,两侧机翼其实“不该算”。解决方法是缩小判定框:

@property def hitbox(self): return self.rect.inflate(-self.rect.width * 0.3, -self.rect.height * 0.35)

注意我写成了属性而不是缓存变量。这是个实操心得:如果你在__init__里存一个self.hitbox,那么每帧移动后必须手动同步它,一旦忘同步,判定框就留在原地,玩家能穿墙。写成属性,每次访问现算,多几次Rect分配的代价完全可以忽略,但省掉一整类bug。

圆形判定适合做“擦弹”手感,也就是子弹擦着过去也算躲开:

def dist2(a, b): dx = a.centerx - b.centerx dy = a.centery - b.centery return dx * dx + dy * dy def circle_hit(s1, s2, r1, r2): return dist2(s1, s2) <= (r1 + r2) ** 2

开平方是没必要调的,比较距离平方和阈值的平方,少一次math.sqrt,在大量判定的场合能省一点。这个技巧在任何需要比距离的地方都通用。

像素级判定用pygame.mask,只在“玩家死亡”这种低频关键判定上值得用:

mask_a = pygame.mask.from_surface(surface_a) mask_b = pygame.mask.from_surface(surface_b) offset = (b.rect.x - a.rect.x, b.rect.y - a.rect.y) if mask_a.overlap(mask_b, offset): ...

from_surface要传带透明的Surface,并且每次图片换了就得重建mask,最好在初始化时缓存好。频率高的地方用它,帧率会翻车。

4.4 受伤后的无敌帧:不写这个游戏没法玩

新手经常忽略的一点。敌机撞你,你在同一帧扣一次血,但如果判定持续,下一帧又撞到,三帧之内直接游戏结束。所以受伤必须有短暂无敌:

if player.invincible <= 0: if pygame.sprite.spritecollideany(player, enemies): player.lives -= 1 player.invincible = PLAYER_INVINCIBLE_FRAMES # 90帧 ≈ 1.5秒 if player.lives <= 0: state = "game_over"

配合闪烁提示:

if player.invincible > 0: player.invincible -= 1 # 每6帧闪一次 player.image.set_alpha(0 if (player.invincible // 6) % 2 == 0 else 255) else: player.image.set_alpha(255)

闪烁这件事别小看,它是玩家理解“我现在无敌”的唯一信号。没有视觉反馈的无敌期,玩家会以为游戏卡了。

提示:spritecollideany只要碰到第一个就返回,比spritecollide快,适合“撞到任意敌机都一样”的场景。返回值是那个精灵或者None,可以直接判断真假。

5. 表现层:特效、音效和界面切换

5.1 爆炸特效:不一定要用图片序列

零素材的解法是用程序画扩散圆。每帧画一个半径变大、透明度变低的圆,十几帧就够,视觉上挺像样:

class Explosion(pygame.sprite.Sprite): def __init__(self, center, radius=24): super().__init__() self.center = center self.frame = 0 self.total = 12 self.max_radius = radius self.image = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA) self.rect = self.image.get_rect(center=center) def update(self): self.frame += 1 if self.frame >= self.total: self.kill() return t = self.frame / self.total r = int(self.max_radius * t) alpha = int(255 * (1 - t)) self.image.fill((0, 0, 0, 0)) if r > 0: pygame.draw.circle(self.image, (255, 200, 80, alpha), (self.max_radius, self.max_radius), r)

有个细节:self.image.fill((0,0,0,0))必须先清空,否则前一帧的圆会叠在新帧上,越来越大像个实心球。另外透明度要写进颜色的第四个分量,前提是Surface带SRCALPHA,并且绘制时用的是直接写到Surface上(而不是blit时的special_flags)。

如果你后面要换图片序列动画,思路是一样的,只是把draw.circle换成blit图集的某一帧:

self.image = sheet.subsurface(pygame.Rect(frame * 32, 0, 32, 32))

subsurface共享父Surface的像素数据,不复制,比blit到新Surface快得多。用它做帧动画是标准做法。

5.2 用一个状态字符串管住三个页面

游戏有菜单页、游戏页、结算页。新手常写成一个大循环里套一堆if,代码很快就糊了。简单办法是用状态变量:

class Game: def __init__(self): self.state = "menu" self.reset() def reset(self): self.player = Player() self.bullets = pygame.sprite.Group() self.enemies = pygame.sprite.Group() self.effects = pygame.sprite.Group() self.score = 0 self.start_time = pygame.time.get_ticks() def handle_event(self, event): if self.state == "menu" and event.type == pygame.KEYDOWN \ and event.key == pygame.K_RETURN: self.reset() self.state = "playing" elif self.state == "game_over" and event.type == pygame.KEYDOWN \ and event.key == pygame.K_RETURN: self.state = "menu" def update(self): if self.state != "playing": return self.player.update() self.bullets.update() self.enemies.update() self.effects.update() # 生成、碰撞、判定…… def draw(self, screen): screen.fill((8, 12, 28)) if self.state == "menu": # 画标题 pass elif self.state == "playing": screen.blit(self.player.image, self.player.rect) self.bullets.draw(screen) self.enemies.draw(screen) self.effects.draw(screen) # 画分数与生命 elif self.state == "game_over": # 画结算 pass

关键在update开头那句if self.state != "playing": return。所有“只在游戏进行时需要跑的逻辑”都在这一句之后,菜单页和结算页自动不更新,一行代码省掉几十个判断。

Group.draw(screen)是批量绘制的捷径,它按加入顺序画。如果你需要控制层级,比如爆炸效果要盖在敌机上面,那就分成不同的Group,按顺序调用draw。

5.3 音效与中文字体:两个必踩的坑

音效加载必须做兜底,因为你不知道运行环境有没有音频设备:

def load_sound(path): try: return pygame.mixer.Sound(path) except Exception: return None shoot_sfx = load_sound("assets/shoot.wav") # 播放时 if shoot_sfx: shoot_sfx.play()

不写这个,换台机器跑或者进了静音模式就崩,报的还是pygame.error: mixer not initialized,看着吓人。

中文字体更麻烦,pygame.font.SysFont在不同系统上名字不一样,写死一个名字在别的机器上就是方块。稳妥写法是挨个尝试,都不行再退回默认字体:

def get_font(size): candidates = ("microsoftyahei", "pingfangsc", "notosanscjksc", "wenquanyizenhei", "arialunicodems") for name in candidates: path = pygame.font.match_font(name) if path: return pygame.font.Font(path, size) return pygame.font.Font(None, size)

match_font会去系统字体目录里找,找不到返回None,所以这个循环是安全的。默认字体Font(None, size)画不了中文,会显示成方块,但至少不崩。

6. 实际开发中踩过的坑与排查方法

6.1 常见问题速查表

这些问题我在写这个项目时基本都遇到过一次,列出来供你对号入座:

现象可能原因解决方式
窗口一闪就没了主循环结束后没sys.exit(),或者主循环条件写错检查running变量与sys.exit()
按住方向键只动一次用了KEYDOWN事件改用pygame.key.get_pressed()
飞机拖出残影每帧忘了screen.fill()在绘制前清屏
子弹打不中/判定偏判定框没跟着移动,或用了缓存的hitbox改成动态计算的属性
敌机越打越多,越来越卡出界后没kill(),或者生成没上限加生命周期判断和ENEMY_MAX_ALIVE
血量一下扣光缺少无敌帧加invincible计数器
帧率慢慢掉下来每帧新建Surface、每帧加载图片图片做类级缓存,convert_alpha()
中文显示成方块字体名在当前系统不存在用match_font多候选查找
打包后图片加载失败路径基于工作目录,打包后变了用sys._MEIPASS拼路径
声音播不出来音频设备不可用或格式不支持统一转wav,加载时try/except兜底

我挑几个展开。

“敌机越打越多”这个坑特别典型。表面上是游戏变卡,根子里是对象没回收。检查两点:敌机update里有没有出界判断,生成时有没有同屏数量上限。我一般写成两道保险:

def update(self): self.rect.y += self.speed if self.rect.top > HEIGHT: self.kill()
if len(self.enemies) < ENEMY_MAX_ALIVE and spawn_cd <= 0: self.enemies.add(Enemy(level)) spawn_cd = spawn_interval

“判定偏了”通常是因为缓存。只要你看到“明明躲开了却死”或者“隔空打死敌机”,九成是判定框没跟主rect同步。解法就是前面说的,判定框写成属性,永远现算。

“帧率慢慢掉”多半是convert_alpha()没加。图片加载后不转换格式,pygame每次blit都要做一次像素格式转换,量大了就拖。规则很简单:所有要绘制的Surface,加载完立刻调.convert()(无透明)或.convert_alpha()(有透明)。前提是display.set_mode已经调用过,顺序不能反。

6.2 几个能让调试效率翻倍的技巧

加一个帧率显示。用clock.get_fps(),每30帧更新一次文字,不需要太频繁:

if frame_count % 30 == 0: fps_text = font.render(f"FPS {clock.get_fps():.0f}", True, (120, 200, 120))

加一个碰撞框显示开关。按F1切换,把所有hitbox用红框画出来。这个功能花你五行代码,能省掉后面几小时的“为什么打不中”的困惑:

if debug: pygame.draw.rect(screen, (255, 0, 0), player.hitbox, 1) for e in enemies: pygame.draw.rect(screen, (255, 0, 0), e.hitbox, 1)

把随机种子固定下来。调难度的时候,随机的敌机位置会让你没法对比。random.seed(42)放在初始化里,每次跑出来的序列一样,你改一个参数就能确定地看出差别。调完再删掉这行。

用pygame.time.get_ticks()而不是frame_count算时间。帧计数在掉帧时会失真,get_ticks是真实毫秒。难度曲线如果是“每10秒升级”,用帧数算的话,卡顿的时候玩家反而变相获得了喘息,用毫秒才是真实的。不过在固定tick(60)下两者差别不大,看你想不想做到跨设备一致。

主循环里加个try/except打印异常。尤其是打包后的版本,闪退没有任何提示,你会一头雾水。开发期不需要,但发布前值得加,把异常写进一个log文件,出问题能查。

7. 打包发布和后续能往上加的东西

7.1 PyInstaller打包:注意资源路径

打包命令本身很简单:

pyinstaller -F -w --add-data "assets;assets" main.py

-F是单文件,-w是不弹控制台窗口。注意--add-data在Windows上用分号分隔,macOS和Linux上用冒号,这个差别坑过不少人。

打包后的致命问题是assets路径。直接写"assets/shoot.wav"在开发环境能跑,双击exe就找不到,因为打包后运行时解压目录变了。标准解法:

import os import sys def resource_path(rel): base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, rel) shoot_path = resource_path("assets/shoot.wav")

sys._MEIPASS只在打包环境存在,getattr的第二个参数保证开发环境走当前目录。

另外,单文件打包启动会慢,因为每次都要解压到临时目录。如果你的资源有几十兆,改成目录模式(去掉-F)体验更好,起码双击就开。

7.2 几处可以往上加的改动

写到这里游戏已经能玩了。想继续往上叠,我给几个我实际加过的方向,按实现成本从低到高排。

敌机走位。现在敌机是直线下落,加个正弦横移立刻生动:

self.rect.x += math.sin(self.rect.y / 40) * 1.5

用y坐标做相位,天然形成波浪,不需要额外状态变量。

道具掉落。敌机死亡时有5%概率掉一个道具,被玩家吃到触发三连发或者护盾。实现上和敌机几乎一样,多一个“被玩家碰到”的判定分支。

武器等级。玩家连续击破一定数量升一级,子弹从单发变双发变扇形。这里注意子弹的偏移量要按角度算,别硬编码坐标:

for angle in (-12, 0, 12): rad = math.radians(angle) ... spawn with direction

然后Bullet的update里用dx = speed * sin(rad),dy = -speed * cos(rad)。

背景星空滚动。用两层不同速度的星星做视差,成本极低但观感提升明显。每帧让星星y加固定值,超过屏幕高度就回到顶部重新随机x。

存档最高分。用json写一个文件存分数,启动时读。要注意文件不存在时给默认值,别直接open崩溃。

手机端触控。把get_pressed的移动换成“触摸位置跟随”,pygame.mouse.get_pos()在多数移动端环境可用。这个改动不大,但你要重新调一遍速度参数,因为手指拖动和按键的手感完全不同。

我在这个项目上最大的体会是,小游戏的价值不在玩法多复杂,在于它是一个闭环。你能在一个下午里经历“设计参数—写代码—运行—发现手感不对—调参—再运行”的完整循环,而这个循环重复几十次,你对“什么参数对应什么手感”就有了肌肉记忆。后面你再做更复杂的东西,这些直觉就是你的底气。

真要说有什么技巧值得留给下一个人,那就是:先把所有会动的对象用几何图形跑一遍,确认逻辑全对,再碰美术素材。这条经验帮我省下的时间,比任何优化技巧都多。

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

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

立即咨询