Python pygame雷霆战机:2D飞行射击主循环与碰撞判定实战

发布时间:2026/11/2 19:31:36
Python pygame雷霆战机:2D飞行射击主循环与碰撞判定实战

说起 Python 小游戏这个练手系列,写到第 14 个,我原本打算做一个更"炫"的题材,最后还是拐回了雷霆战机这类纵版飞行射击。原因很实在:它几乎把 2D 游戏里所有绕不开的东西都塞进了一个屏幕里——实时主循环、输入响应、对象生成与回收、碰撞判定、难度曲线、音效与粒子。你把这一套吃透,再去做别的类型,会发现底层是同一副骨架。这篇文章不是把代码从头贴到尾,而是围绕我在做这个项目时反复琢磨的几个点展开:哪些结构必须先定死、哪些参数决定了"打起来爽不爽"、哪些坑看起来小但能把人卡一整天。适合已经能跑通一个 Hello World 级别 pygame 程序、想把"能跑"推进到"像个游戏"的读者,也适合做教学演示时需要一个完整案例的人参考。下面所有代码都是这段实践里真正跑过的片段,参数和结构可以直接拿去改。

1. 为什么把"雷霆战机"排到第 14 个练手项目

1.1 这类玩法的骨架其实只有四件事

很多人一上手就想着做 Boss、做弹幕、做道具树,结果做到一半发现底层结构没搭好,加什么都别扭。纵版射击剥到底,其实只有四件事在循环:玩家可控位移、定时生成子弹、按节奏刷出敌机、判定命中并结算。这四件事各自对应一个数据结构,互不干扰——玩家是一个持有坐标和状态的单例,子弹和敌机是两组可复用的实体,命中判定是一个纯函数。

想清楚这一层,代码组织就顺了。我把它拆成Player、BulletPool、EnemyPool和一组无状态的判定函数,主循环里只做四步:读输入、更新、判定、绘制。任何新玩法,比如护盾、僚机、追踪弹,都是往这四个位置上挂东西,而不是改主循环。这个边界一旦立住,后面三天的加料都不会把代码搞成意大利面。

反过来,我见过不少人把逻辑全塞进while循环里,几百行平铺下来,第一天能跑,第三天想加个连击就无从下手。所以这个题材真正的练手价值不在"飞机能打子弹",而在于逼你第一次正经设计一次模块边界。

1.2 没有美术素材时,图形方案怎么挑

自己练手最尴尬的是没图。网上找素材有版权顾虑,自己画又没那个手艺。我试过三种替代方案,各自适用场景差别很大:

方案实现成本视觉效果适合阶段
纯色矩形/圆形极低,几行draw.rect简陋但边界清晰验证逻辑、调参数
多边形拼装中,draw.polygon叠几层有"机型"轮廓想有点样子又不想找图
程序化贴图较高,先画到Surface再缓存接近成品准备发出去给人玩

我最后用的是多边形拼装:机身一个梯形,机翼两个三角形,尾焰一个会闪的三角形。关键是把绘制结果预渲染成一张 Surface 缓存起来,不要每帧重画多边形——一帧画三四十个敌机,每个敌机画五六个多边形,加起来就是两百多次绘制调用,在低配机器上能明显看到帧率波动。

def make_enemy_surface(size, color): surf = pygame.Surface(size, pygame.SRCALPHA) w, h = size pygame.draw.polygon(surf, color, [(w//2, 0), (w, h), (w//2, h//4), (0, h)]) pygame.draw.line(surf, (255, 255, 255), (w//2, h//4), (w//2, h*3//4), 2) return surf

这个函数只在初始化时调用几次,把结果存进字典,之后每帧只做一次blit。这个"预渲染"的习惯,从这个小项目开始养成,后面做 UI 界面时会省下大量 CPU 时间。

1.3 先定一条能验证的手感基线

做游戏最容易陷入的误区是"边做边调"。参数没有基线,你根本判断不了改一个数值是变好了还是变坏了。所以我开工前先写下这几条作为验收标准:飞机从屏幕底边移到顶边大约 2 秒;连续按住射击键,屏幕上的子弹数量稳定在 8 到 12 颗之间;敌机从出现到飞到底部大约 3 秒;第一次被击中的平均时间在 40 秒以上。

这几条不是拍脑袋定的,它们对应具体的体感:2 秒横穿屏幕,玩家能做出反应但不至于太悠闲;子弹 8 到 12 颗,画面不空也不糊;3 秒存活时间,让玩家有足够时间瞄准。后面所有参数都围绕这几条调,改完就跑一遍计时验证。这套办法听起来笨,但省掉了我无数次"感觉不对但又说不上哪不对"的返工。

2. 主循环与坐标系:先让"能动"这件事不出错

2.1 屏幕 y 轴向下,移动逻辑要跟着翻

从数学课带来的直觉是 y 轴向上,而屏幕坐标系原点在左上角,y 往下增大。这听起来是老生常谈,但它在实际代码里会以非常隐蔽的方式咬人:你写了一个"敌人从下方绕到上方"的路径,画出来发现敌人从屏幕外飞走了;你写"按上键抬头",结果飞机往下走。我第一次写滚动背景时就栽在这,背景图滚动的方向和想象相反,找了好久才发现是y -= speed应该写成y += speed。

处理方法很简单:在代码里统一约定"上方是小 y,下方是大 y",并且把所有速度常量的方向写死在注释里。玩家开火,子弹速度是负的;敌机下压,速度是正的。不写注释的话,隔两天回来再看vy = -620你根本不知道这是往上还是往下。

# 约定:y 轴向下为正。子弹向上飞 => vy 为负值 BULLET_VY = -620 # 像素/秒,向上 ENEMY_VY_BASE = 110 # 像素/秒,向下

2.2 把 dt 传下去,速度单位统一成像素每秒

新手写移动最爱写player.y -= 5,跑在自己机器上没问题,换台机器或者窗口失焦再回来,速度就变了。哪怕同一个机器,后台跑个编译任务掉到 30 帧,游戏立刻变成慢动作。根因是把"每帧多少像素"当成了"每秒多少像素"。

正确做法是在主循环里算出两帧间隔,往下传:

clock = pygame.time.Clock() while running: dt = clock.tick(60) / 1000.0 dt = min(dt, 1/30) # 钳制,防止卡顿后瞬移 player.update(dt, keys)

这里那个min(dt, 1/30)是关键。窗口被拖动、系统忙一下,tick可能返回 800 毫秒,不钳制的话飞机会一步跨过整个屏幕,甚至穿过敌机。把上限压到 33 毫秒,等于告诉游戏"最多按 30 帧的步长走",宁可慢一点也不要瞬移。这个细节几乎所有教程都略过,但它是移动端和低配机器上最明显的体感差异来源之一。

另外注意所有速度常量都改成"像素/秒"的量级。玩家 320、子弹 620、敌机 110 到 200,这样写出来一眼能估出屏幕穿越时间:屏幕高 720,玩家 320/秒大约 2.2 秒横穿,正好对上我前面定的基线。

2.3 事件队列负责"按一下",按键状态负责"按着"

pygame.event.get()给你的是"这一帧发生了什么",pygame.key.get_pressed()给你的是"此刻键盘是什么状态"。这两个东西混用是新手最常犯的错误之一,症状是:按下方向键不动,松开才动一下;或者按住射击只发一颗子弹。

规律很简单。需要"按下瞬间触发一次"的,查事件:暂停、放大招、退出、切武器。需要"持续生效"的,查状态:移动、持续射击。写在代码里就是两条完全分开的路:

for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: paused = not paused keys = pygame.key.get_pressed() # 每帧读一次,别在循环里反复调 player.update(dt, keys)

还有一个细节:key.get_pressed()每帧只读一次,存到变量里传下去。在update内部再调一次,返回的是同一个对象,虽然结果正确,但会让人觉得你在猜测状态,代码可读性反而下降。另外pygame.K_a和pygame.K_LEFT这种双套绑定(方向键 + WASD)要一起写,用or连起来就行,玩家各有所好,多写两个判断而已。

3. 玩家机与子弹:手感的三根支柱

3.1 移动:把"滑"和"飘"区分开

直接"按键即满速"的移动最省事,但打起来很生硬,尤其是需要微调位置躲子弹的时候。我试过三种手感:

第一种是直接位移,按键按下立刻以固定速度移动,松开立刻停。优点是精准,缺点是没有重量感,像在拖一张贴纸。

第二种是加速度 + 阻尼,按下时加速,松开时靠摩擦力减速。这种手感最"顺",但在窄缝里躲弹时容易滑过头,玩家会骂你操作不跟手。

第三种是我最终采用的直接位移 + 极短加速过渡:速度在 0.06 秒内从 0 线性升到满速,松开后 0.04 秒内降到 0。视觉上有了起步和刹车的感觉,实际位置几乎即时响应。

class Player: def __init__(self): self.x, self.y = WIDTH / 2, HEIGHT - 90 self.speed = 320 self.vx = self.vy = 0.0 self.accel_time = 0.06 self.radius = 10 def update(self, dt, keys): tx = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - \ (keys[pygame.K_LEFT] or keys[pygame.K_a]) ty = (keys[pygame.K_DOWN] or keys[pygame.K_s]) - \ (keys[pygame.K_UP] or keys[pygame.K_w]) if tx and ty: tx *= 0.7071 ty *= 0.7071 k = min(1.0, dt / self.accel_time) self.vx += (tx * self.speed - self.vx) * k self.vy += (ty * self.speed - self.vy) * k self.x += self.vx * dt self.y += self.vy * dt self.x = max(self.radius, min(WIDTH - self.radius, self.x)) self.y = max(self.radius + 40, min(HEIGHT - self.radius, self.y))

这里0.7071是斜向移动的归一化系数,不乘的话斜着走会比直着走快 41%,玩家会本能地斜着赶路,看起来很怪。下面那个max/min钳制把飞机锁在屏幕内,注意上边界我留了 40 像素给 HUD,不然飞机顶着分数栏飞,视觉上很别扭。

3.2 射击节奏由冷却计时器控制

射击最容易写错的地方是用帧计数当冷却。if frame_count % 6 == 0: fire()这种写法在 60 帧稳定时没问题,掉到 45 帧射速就降了四分之一,玩家会立刻感觉到"卡壳"。

正确的做法是维护一个剩余冷却时间,每帧减去dt,归零时开火并重置:

self.cooldown = max(0.0, self.cooldown - dt) if self.cooldown <= 0 and firing: self.cooldown = self.fire_interval # 0.11 秒 self.fire()

fire_interval = 0.11意味着每秒 9 发。这个数值我调了很多次:0.2 秒太稀疏,屏幕空得慌;0.06 秒太密,子弹连成一条白线,反而看不清敌机。0.1 到 0.13 之间是比较舒服的区间。

还有一个扩展点:双发和三发的差异化。双发是从机翼两侧各出一颗,三发是中间加一颗直射。双发的间隔要略长一点(比如 0.13 秒),否则总输出太高一味往前冲就行。这里其实是一个平衡公式:总伤害 ≈ 每秒发数 × 每发伤害 × 弹道数,三个参数动一个就要看另外两个,否则平衡立刻崩。

3.3 对象池:遍历中删元素是自找麻烦

写子弹最直觉的写法是一个list,发射时append,飞出屏幕时remove。跑起来也能用,但两个问题会逐渐冒头:其一,列表长度剧烈波动,每秒几十次的增删会让垃圾回收频繁触发,帧时间出现规律性抖动;其二,如果在遍历列表的循环里做删除,会漏掉元素甚至抛异常。

我后来固定用对象池:初始化时一次性开好槽位,每个子弹带一个active标记,发射时找空槽复用,出界时只把标记置假,不销毁对象。池子满了就丢掉这一发,虽然理论上会损失射速,但实际几乎不会触发(除非你同时有几百颗子弹)。

class Bullet: __slots__ = ("x", "y", "vx", "vy", "active") def __init__(self): self.active = False def reset(self, x, y, vx, vy): self.x, self.y, self.vx, self.vy = x, y, vx, vy self.active = True class BulletPool: def __init__(self, size=120): self.items = [Bullet() for _ in range(size)] def spawn(self, x, y, vx, vy): for b in self.items: if not b.active: b.reset(x, y, vx, vy) return def update(self, dt): for b in self.items: if not b.active: continue b.x += b.vx * dt b.y += b.vy * dt if b.y < -20 or b.y > HEIGHT + 20: b.active = False

__slots__这个写法值得单独提一句。给子弹这种数量多、生命短的对象加上__slots__,能省掉每个实例的__dict__,内存占用和属性访问速度都有改善。在我这台老机器上,四百个对象同时存在时,加上__slots__之后帧时间的中位数从 4.1 毫秒降到 3.6 毫秒,提升不算大但确实能测出来。

3.4 一组可以直接抄的基线参数

调参调下来,我把这套数值当成所有飞行射击练手项目的起点,每次开新项目先抄一遍再改:

参数取值说明
玩家速度320 px/s约 2.2 秒横穿 720 高的屏幕
玩家判定半径10 px机身视觉宽约 26,判定缩到三分之一
射击间隔0.11 s每秒约 9 发
子弹速度620 px/s约 1.1 秒从底部到顶部
单发伤害1小怪 1 血、中型 4 血、大型 12 血
敌机基础速度110 px/s约 6.5 秒穿屏

判定半径那条特别值得说。很多人第一次做射击游戏,判定框和图片一样大,结果玩家总是"莫名其妙被擦到",因为机翼的贴图边缘离机身中心很远。把判定缩到视觉的三分之一,玩家会觉得自己在"擦弹",很爽;判定放大则相反,玩家会觉得游戏在耍赖。这一条几乎是所有射击游戏的共识,不是作弊,是体验设计。

4. 敌机生成器与难度爬升

4.1 权重表比一长串 if-else 好维护

第一版我写的是随机数和一堆if:小于 0.6 出小怪,0.6 到 0.85 出蛇形,再往上出重甲。能跑,但想加第四种敌机就得回去改数字边界,加第五种就彻底乱了。

后来改成权重表,每种敌机一行,写清楚权重和血量,生成时做一次加权抽取。加新敌人只要往表里加一行,不影响其他任何逻辑:

SPAWN_TABLE = [ # (类型, 权重, 血量, 速度倍率) ("scout", 60, 1, 1.00), ("zigzag", 25, 3, 0.90), ("tank", 15, 12, 0.65), ] def pick_enemy(): total = sum(row[1] for row in SPAWN_TABLE) r = random.uniform(0, total) acc = 0 for row in SPAWN_TABLE: acc += row[1] if r <= acc: return row return SPAWN_TABLE[0]

这张表还有一个隐藏好处:权重本身就是难度旋钮。前期我可以把scout的权重临时调高到 90,后期再降回 60,改一行数据就能让玩家感受到"敌人变强了",而不是去改生成逻辑。数据驱动和逻辑分离,在这里体现得特别直观。

4.2 难度用时间轴推,别全交给随机

纯随机的难度是灾难。玩家可能开局连撞五个重甲,也可能打了三分钟全是小怪,这两种体验都很糟。我现在的做法是:难度由存活时间驱动,随机只负责挑选类型。

def spawn_interval(elapsed, diff): base = 0.9 / diff # 基础间隔随难度缩短 return max(0.22, base) # 但设一个下限,别变成弹幕地狱 def difficulty(elapsed): # 前 45 秒线性爬升到 1.8 倍,之后缓慢增长 return 1.0 + min(elapsed / 45.0, 1.0) * 0.8 + max(0, elapsed - 45) * 0.004

这套公式的效果是:开局约 0.9 秒出一个敌人,玩家有充分时间适应;45 秒后间隔压到 0.5 秒左右;三分钟时接近 0.3 秒,屏幕开始变热闹;五分钟以后大概 0.22 秒一个,同时敌机血量也涨,玩到这里基本就是极限了。

这里限速下限那条线很关键。max(0.22, ...)看起来只是个保险丝,实际是难度设计的一部分——如果没有下限,十分钟后间隔会趋近于零,屏幕上瞬间堆满敌机,帧率崩了、玩家也看不懂在打什么。给难度设天花板,是对玩家体验负责,也是对程序性能负责。

4.3 三种便宜的敌机行为就够撑起前十分钟

敌机行为千万别一开始就写状态机。我第一版写了七八种,结果调试起来焦头烂额,因为行为之间互相干扰。后来砍到三种,反而玩起来更有层次:

第一种是直冲,速度恒定,沿直线下落,最简单的靶子,用来填充画面。

第二种是蛇形,用正弦函数横向摆动:x = base_x + amp * sin(t * freq + phase)。相位给每只敌机随机一下,看起来就不会整齐划一。这个写法只多两行代码,视觉丰富度提升非常明显。

第三种是俯冲,先慢慢下压到屏幕中段,然后突然加速朝玩家方向冲。这种敌机是紧张感的来源,因为它会改变节奏。实现上就是一个计时器,前 1.5 秒用普通速度,之后速度乘 3 并朝玩家位置做一次归一化。

def update_enemy(e, dt, player_x): e.t += dt if e.kind == "zigzag": e.x = e.base_x + e.amp * math.sin(e.t * e.freq + e.phase) e.y += e.speed * dt elif e.kind == "tank": e.y += e.speed * dt elif e.kind == "dive": if e.t < 1.5: e.y += e.speed * dt else: if not e.locked: dx, dy = player_x - e.x, HEIGHT - e.y d = math.hypot(dx, dy) or 1 e.vx, e.vy = dx / d * 260, dy / d * 260 e.locked = True e.x += e.vx * dt e.y += e.vy * dt

三种行为,覆盖了"填充""干扰""威胁"三个功能位,配合权重表和难度曲线,足够支撑一个十分钟的循环而不觉得单调。

5. 碰撞判定:看起来打到,和真的打到

5.1 判定半径缩小,才有"擦弹"的爽感

前面提过玩家判定缩到视觉的三分之一,敌机也是一样,尤其大型敌机。大块头贴图往往有几十像素的空隙,如果判定框跟贴图一样大,玩家从它旁边飞过去会被莫名其妙判定为撞击,这时候玩家不会觉得是自己失误,只会觉得游戏判定有问题。

具体做法是每个实体带一个radius,碰撞时做圆和圆的距离平方比较,不做开方,省一点计算:

def circle_hit(a, b): dx = a.x - b.x dy = a.y - b.y rr = a.radius + b.radius return dx * dx + dy * dy <= rr * rr

用圆而不是矩形,是因为圆在斜向接触时更符合直觉。矩形判定在斜着擦过角落时会出现"明明没碰到却被判中"的情况,玩家感知特别明显。当然圆也有代价,细长的子弹用圆判定会偏"粗",但子弹本来就小,影响不大。

5.2 高速子弹为什么会穿过去

这个问题我卡了很久。子弹速度 620 像素每秒,60 帧下每帧移动约 10 像素,看起来不会穿。但一旦掉帧到 40 帧,每帧就是 15.5 像素,碰到判定半径 8 的小怪,就有概率直接跨过去——因为判定是位置对位置的比较,前一刻在敌人上方,下一刻在下方,中间那一帧什么都没发生。

解法有两个,我用了第二种。

第一种是子步进:把这一帧的位移拆成若干段,每段都做一次判定。简单可靠,但拆得越细计算越多,子弹一多就吃不消。

第二种是线段判定:记录子弹上一帧的位置和这一帧的位置,判断这条线段到敌人中心的最近距离是否小于半径和。数学上就是点到线段的距离,二十行代码搞定,且开销和子步进拆 10 段差不多:

def swept_hit(px, py, qx, qy, cx, cy, r): vx, vy = qx - px, qy - py wx, wy = cx - px, cy - py L2 = vx * vx + vy * vy if L2 == 0: t = 0.0 else: t = max(0.0, min(1.0, (wx * vx + wy * vy) / L2)) dx = px + t * vx - cx dy = py + t * vy - cy return dx * dx + dy * dy <= r * r

t的钳制到 [0,1] 是重点,它保证你算的是"线段",不是"无限长的直线",否则子弹在敌人身后老远也会被判中。

5.3 同帧多次碰撞的结算顺序

一帧里可能同时发生:子弹打中敌机、敌机撞到玩家、敌机飞出屏幕。这三件事的处理顺序不同,结果会不一样。我踩过一次坑:先处理玩家死亡,再处理子弹命中,结果玩家在"已经死了"的那一帧还把敌机打爆了,分数照加,结算画面看起来就很荒谬。

现在的固定顺序是:先结算敌机与子弹(移除被打死的敌机、标记命中的子弹失效),再结算玩家与敌机,最后清理出界实体。并且加一个if not player.alive: continue的门闩,玩家死后的所有逻辑直接跳过,不再结算。这样顺序就不可动了。

另外要注意:处理"子弹命中敌机"时,一颗子弹只能命中一个目标,命中后立刻把它标记失效并break。不加break的话,一颗子弹可能在同一帧打穿三只敌机,玩家会看到一颗子弹打出三份伤害,逻辑上说不通。

6. 排查链路:四个卡了我半天的实际问题

6.1 越打越卡,问题出在列表长度

症状很典型:开局 60 帧稳稳的,打了两分钟降到 40,四分钟后掉到 25,而且越到后期越明显。一开始我怀疑是绘制调用太多,把敌机数量减半,帧率确实好一点,但问题没根治。

排查方法是分段计时。我在主循环的四个阶段前后打时间戳:输入、更新、判定、绘制。跑一遍发现更新阶段耗时随游戏时长线性增长,而绘制阶段基本稳定。这就锁定了问题不在画面,在数据。

再仔细看,更新阶段唯一和时长相关的就是子弹和敌机列表。问题找到了:我虽然在子弹出界时把active置了假,但敌机的列表用的是append加remove,而且出界的敌机在某些分支里没有被移除——具体是"被子弹打死"的那条分支里,我先扣血、再判断存活,如果血量刚好扣到 0 以下,代码走到了爆炸特效分支,然后就continue了,跳过了后面的移除逻辑。结果这些敌机留在列表里,active是假,但每帧还在被遍历。

修复很直接:把移除逻辑从各个分支里抽出来,统一放到"清理"阶段,只判断状态,不关心它是怎么死的。

# 统一清理,和死因解耦 for e in enemy_pool.items: if e.active and (e.hp <= 0 or e.y > HEIGHT + 60): e.active = False

这个坑给我的教训是:状态回收一定要集中,不能散落在业务分支里。散落的清理逻辑,只要有一条路径漏掉,就会变成缓慢的内存泄漏,而且极难通过读代码发现,只能靠计时定位。

6.2 粘键与失灵:事件和状态混用的后果

症状是:按住方向键不放,飞机走两步就停住,松手再按又走两步;射击更离谱,按住不放只发一颗。

原因是我在update里读了事件队列来做移动判断,写了类似这样的东西:

# 错误示范,别这么写 for event in pygame.event.get(): if event.type == pygame.KEYDOWN: if event.key == pygame.K_LEFT: player.x -= 5

键盘的"自动重复"机制会以系统设定的速率反复触发KEYDOWN,但速率取决于系统设置,不是每帧一次,所以移动看起来一顿一顿的;而按住射击时,因为只有在KEYDOWN时才开火,所以只发一颗。

改法就是把前面 2.3 节说的那条规律落实:移动和射击全部改成读key.get_pressed(),事件队列里只保留"按一下触发一次"的操作。改完之后手感立刻对了,这个问题的排查成本极低,但不知道规律的话可能会去怀疑输入法、怀疑窗口焦点、怀疑 pygame 版本,绕一大圈。

6.3 关窗口卡住与音频初始化的顺序

点右上角关闭,窗口黑了但进程不退出,任务管理器里还挂着。第一次遇到我以为是死循环,加了打印发现循环确实退出了,卡在pygame.quit()之后的退出阶段。

根因在音频。我用的是默认的混音器配置,缓冲区较大,在某些机器上初始化后关闭会等待音频线程收尾。解决方案是在pygame.init()之前用pre_init指定较小的缓冲区:

pygame.mixer.pre_init(44100, -16, 2, 512) pygame.init()

pre_init必须在init之前调用,位置放错就没效果——因为它改变的是初始化时采用的参数。缓冲区从默认值降到 512,除了解决退出卡顿,还顺带把音效延迟从一百多毫秒降到三十毫秒左右,打击音和爆炸画面对得上了,这个改善是能听出来的。

如果某些机器仍然卡,可以在退出前显式停掉音频再退出:

pygame.mixer.music.stop() pygame.mixer.stop() pygame.quit()

这一套写下来,我这边测试了几台机器,没有再出现过退出残留进程。

6.4 画面撕裂与双缓冲

画面撕裂表现为快速移动的子弹边缘出现横向错位,像被切了一刀。原因是一边绘制一边显示。pygame.display.set_mode默认会尝试双缓冲,但如果你自己在绘制过程中调用了flip或者用update局部刷新,就容易出问题。

我的做法是固定成"全屏重绘 + 一次 flip":每帧先screen.fill(背景色)或贴上滚动背景,再依次绘制所有实体,最后统一pygame.display.flip()。中间不调用任何update。这个模式简单粗暴,但对 480×720 这个分辨率完全够用,而且不会出现局部刷新的残影问题。

如果帧率实在上不去,再考虑先把所有东西画到一个离屏Surface,最后一次性blit到屏幕。这就是手动双缓冲,能进一步减少闪烁,代价是多一次全屏拷贝。

7. 打击感的廉价增项:粒子、震屏、音效、连击

7.1 爆炸粒子用 slots 写,几十行就够

粒子系统听起来高级,实际用最朴素的写法就够了:每个粒子有位置、速度、寿命,每帧位置加上速度乘 dt,寿命减少,颜色随寿命从亮到暗。不需要物理引擎,不需要引力,加一点点随机方向就很有样子。

class Particle: __slots__ = ("x", "y", "vx", "vy", "life", "max_life", "color", "active") def __init__(self): self.active = False def reset(self, x, y, color): self.x, self.y = x, y a = random.uniform(0, math.tau) s = random.uniform(40, 220) self.vx = math.cos(a) * s self.vy = math.sin(a) * s self.life = self.max_life = random.uniform(0.25, 0.55) self.color = color self.active = True

关键在粒子数量的克制。一只小怪爆 8 颗,大怪爆 24 颗,池子开到 300 就绰绰有余。我一开始设成每只爆 40 颗,屏幕上同时有几十只敌机被打爆时,粒子数量冲到一千多,帧率直接腰斩。后来砍到 8 到 24 颗,视觉上反而更干净——太密的粒子会糊成一团,看不清爆炸的边界。

颜色上建议用两段式:一开始是接近白色的高亮,后面过渡到橙红,最后暗掉。这个渐变比单一颜色有层次得多,而且几乎不增加成本。

7.2 震屏是性价比最高的一刀

震屏的代码量大概是十行,但它对打击感的提升比粒子还明显。实现方式很简单:维护一个shake值,每次爆炸或受伤时把它加上一个数,每帧衰减,绘制时把所有实体的坐标加上一个随机的偏移。

shake = max(0.0, shake - dt * 60) ox = random.uniform(-shake, shake) oy = random.uniform(-shake, shake) # 绘制时统一加偏移 screen.blit(img, (x + ox, y + oy))

有两个细节决定它好不好用。第一,震幅要分档:小怪爆炸震 2 像素,大型敌人震 6 像素,玩家受击震 10 像素,全都一样大反而没有层次。第二,衰减要快,大约 0.2 到 0.3 秒内归零,慢了会让人晕。还有一个隐蔽问题:如果你只给游戏内容加偏移,没给背景加,会看到内容在抖、背景不动,效果很假;全都加上才是整体在晃。当然背景偏移后边缘会露出底色,记得背景图比屏幕大一圈,或者干脆用纯色背景。

7.3 音效的两个坑:延迟与重复加载

音效这块,我踩过的坑比图形多。第一个是延迟,前面提过,靠pre_init调小缓冲区解决。第二个是重复加载,一开始我在开火函数里写pygame.mixer.Sound("shot.wav").play(),结果打一分钟之后内存飙升、帧率下降,因为每次开火都在从磁盘读文件。

正确做法是初始化时加载一次,存到字典里复用:

SOUNDS = { "shot": pygame.mixer.Sound("assets/shot.wav"), "boom": pygame.mixer.Sound("assets/boom.wav"), "hurt": pygame.mixer.Sound("assets/hurt.wav"), } SOUNDS["shot"].set_volume(0.35)

还有一个实用技巧:给开火音效设一个播放间隔限制,比如 60 毫秒内不重复触发。因为每秒 9 发的射速,音效叠在一起会变成噪音。用一个小计时器控制,只在允许时play(),听感立刻清爽很多。

音量也值得调。开火音是高频重复事件,必须压低(0.3 左右),爆炸音可以稍高,受击音要最响以起到警示作用。全都设成 1.0 的话,音效会盖住背景音乐,而且打久了耳朵疼。

7.4 连击与计分:给玩家一个继续打的理由

纯计分的激励很弱,玩家不知道自己在进步还是退步。加一个连击计数,只要 2 秒内连续击杀就累加,屏幕上用一个放大的数字显示,玩家会不自觉地想"再连一个"。实现上就是两个变量:一个计数、一个剩余时间。

combo_timer -= dt if combo_timer <= 0: combo = 0 else: combo += 1 combo_timer = 2.0 score += base_score * max(1, combo // 3)

计分公式里乘一个和能力挂钩的系数,是为了让"追求连击"有实际收益,而不只是好看。不过这个系数不能太高,否则玩家为了保连击会变得极度保守,反而减少了战斗频率,游戏变得无聊。我最后用的是每三连击加一倍,封顶三倍,测下来节奏比较舒服。

另外建议把最高分写到一个本地文件里,每次开局读出来显示。这个功能十行代码,但对"再来一局"的推动非常大——我自己测试时看到最高分被刷新,会忍不住再打一轮。

def load_best(): try: with open("best.txt", "r", encoding="utf-8") as f: return int(f.read().strip() or 0) except (OSError, ValueError): return 0

用try/except包住是必须的,第一次运行文件不存在,不处理就会崩在启动阶段,这种低级错误特别容易漏。

这套东西做完,一个能玩、有反馈、难度会爬升的纵版射击雏形就齐了。代码总量大概六百行,结构上分成实体、池子、生成器、判定、表现五个部分。真正花时间的地方不在敲代码,而在反复调整那几个数字——速度、间隔、判定半径、震幅,每一个都要跑上十几遍才能找到一个自己满意的值。我个人的体会是,这类小项目最值得记录的从来不是最终代码,而是那些"为什么是这个数"的判断过程,因为下一次做别的类型,思路是通用的,数字却要重来一遍。