🎮 第3章 《飞机大战》游戏
📄 一句话概括

滚动的背景是两张图首尾相接,一张出界就接到另一张后面。 子弹用池子,用完还回去,不在循环里新建对象。 生成节奏决定难度;碰撞顺序决定「先打到谁」。

📝 本章和前面几章的关系

这一篇把飞机大战的四个角色都填上:背景、我方、子弹、敌机,再加一个按钮。 教材 3.7 到 3.11 节,一节一个角色:背景、我方飞机、子弹、敌方飞机、按钮。 演示里最后真跑了 600 帧,输出击落数、漏过数、得分、平均帧耗时。 固定了随机种子,这组数每次跑都一样。

一、背景:两张图首尾相接

滚动的原理说出来很简单:两张一样大的图,上下错开一个屏高,一起往上挪。 上面那张出界了,就把它接到下面那张的后面。 演示里两张图从 0 和 -700 开始,每帧各挪 3 个像素。 跑 150 帧之后是 453 和 -247, 跑 300 帧之后是 -497 和 203——注意这时候上下换了位置,因为它们已经接了一轮。 为什么用两张而不是一张?因为一张图挪出屏幕之后,底下就空了。两张交替,任何时刻屏幕上都有东西。

这就是所有「无限滚动」的做法:横版跑酷、赛博车流、星空背景,全是这个套路。

二、我方飞机:位置就是 rect.x

演示里开局把飞机摆在底部居中,pr.midbottom = (240, 680)。 按键处理就三行:左移减 x、右移加 x、然后用 max 和 min 夹住边界。 演示里模拟了 240 帧:前 80 帧一直往左,x 从 220 减到 0 就停住了;中间 80 帧往右,到 400 又停; 后面不动。

夹边界这一步不能省。不夹的话飞机会跑出屏幕,玩家就找不到自己了——这是新手最常见的 bug 之一。 pr.x = max(0, min(W - pr.width, pr.x)) 这一行把「左边不出界」和「右边不出界」一起管了, 比写两个 if 干净。

三、子弹:池子,不是每次都新建

最直的写法是每次发射 bullets.append(Bullet(...))。能跑,但每帧都在造对象、扔对象。 演示里用了另一种写法: 开局先建 64 个空子弹放在「空闲池」里,发射时从池里取一个,飞出屏幕就还回去。 跑 600 帧,一共尝试发射 75 次,全部成功——因为池子 64 个够用,任何时刻在飞的也就几发。

如果池子满了会怎样?fire() 返回 None,那一发就没了。 不会崩,只是少一发。 这就是上限的意义:把「资源不够」变成一个可以忽略的小问题。 这种写法在游戏里叫「对象池」。 它不只是省内存,更重要的是避免垃圾回收在关键时刻插一脚——第 11 章讲多线程时会再碰到类似的问题。

四、敌机:节奏就是难度

敌机的生成逻辑只有一句:每 30 帧放一架,x 随机,速度也在一个范围里随机。 演示里 600 帧放了 20 架,跑完还剩 11 架在屏幕里。 改这个 30,就是改难度。改成 15 就是困难模式,改成 60 就是休闲模式。不需要动别的任何一行代码。

速度也随机(1.6 到 2.8 像素每帧),这样同一时刻生成的敌机不会整齐地并排走,看起来更像真的。 还有一个细节:飞出屏幕底部的敌机要回收,不然列表会一直涨。演示里用一句列表推导就筛掉了。

五、按钮:一个矩形就够

教材 3.11 节讲按钮。别想复杂了——按钮就是「画个矩形加一行字」,再给个 Rect 判断点击。 演示里按钮画在 (390, 12),78×34。点 (420, 30) 命中,点 (200, 300) 不命中。 要做「按下变暗」也很简单:在 MOUSEBUTTONDOWN 里记一个标志,画的时候换个颜色。 不需要控件库,也不需要事件回调。

这也说明一件事:很多看起来要「框架」才能做的东西,其实底层就是这么几行。 知道底层,用框架时才不会瞎猜。

六、合起来:600 帧的成绩单

把四样凑齐跑 600 帧,结果是这样的:发射 75 发、击落 3 架、漏过 6 架、得 300 分、 平均每帧 0.134 毫秒。 击落率只有 33%,有点低。 为什么?因为子弹每 8 帧才发一发,而敌机每 30 帧来一架、速度还不慢,火力密度不够。 想提高击落率怎么办?三个旋钮:子弹发得更密、子弹飞得更快、敌机来得更慢。 每个旋钮对应一个常量,改一个就能看效果。这就是把参数抽出来的好处。

平均每帧 0.134 毫秒,比 16.67 的预算小两个数量级。 说明这个规模下 Python 完全不是瓶颈。真要卡,会卡在敌人的数量上——上千个对象两两判碰撞的时候。

七、动手 Demo:飞机大战主体:背景、战机、子弹、敌机

先看背景怎么用两张图滚出「无限长」的效果。 再让我方飞机按模拟按键移动,子弹从池子里取、敌机按节奏放。 最后合起来跑 600 帧,出一张成绩单。

import os, random, time
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame

W, H = 480, 700
random.seed(20261102)

def info(s):
    print('  ' + s)

def note(s):
    print('  ! ' + s)

def sec(t):
    print(); print('  ' + t); print('  ' + '-' * 68)

pygame.init()
scr = pygame.display.set_mode((W, H))

# ① 背景滚动
sec('① 背景滚动:两张图首尾相接')
y1, y2, sp = 0.0, -700.0, 3.0
for i in range(600):
    y1 += sp; y2 += sp
    if y1 >= H: y1 = y2 - H
    if y2 >= H: y2 = y1 - H
    if i % 150 == 0:
        info('第 %d 帧:上 %d,下 %d' % (i, y1, y2))

# ② 我方飞机
sec('② 我方飞机:位置就是 rect.x')
plane = pygame.Surface((40, 48), pygame.SRCALPHA)
pygame.draw.polygon(plane, (110, 200, 255),
                    [(20, 0), (40, 44), (20, 34), (0, 44)])
pr = plane.get_rect()
pr.midbottom = (W // 2, H - 20)
info('开局摆在底部居中:%s' % (pr.topleft,))
for i in range(1, 241):
    if i <= 80:
        pr.x -= 5
    elif i <= 160:
        pr.x += 5
    pr.x = max(0, min(W - pr.width, pr.x))
    if i % 60 == 0:
        info('移动 %d 次之后 x = %d' % (i, pr.x))

# ③ 子弹池
sec('③ 子弹:一池子循环用')
free = [pygame.Rect(0, 0, 6, 14) for _ in range(64)]
used = []
for i in range(600):
    if i % 8 == 0 and free:
        r = free.pop()
        r.topleft = (pr.centerx - 3, pr.top)
        used.append(r)
    keep = []
    for r in used:
        r.y -= 11
        if r.bottom < 0:
            free.append(r)
        else:
            keep.append(r)
    used = keep
info('600 帧里尝试发 %d 次,池子共 64 发' % (600 // 8))
info('此刻在用 %d 发,空闲 %d 发' % (len(used), len(free)))

# ④ 敌机
sec('④ 敌机:按节奏放')
enemy_img = pygame.Surface((36, 34), pygame.SRCALPHA)
pygame.draw.polygon(enemy_img, (230, 90, 90), [(18, 34), (36, 0), (0, 0)])
enemies = []
for i in range(600):
    if i % 30 == 0:
        ex = random.randint(0, W - 36)
        e = enemy_img.get_rect(topleft=(ex, -34))
        enemies.append([e, 1.6 + random.random() * 1.2])
    for it in enemies:
        it[0].y += it[1]
    enemies = [it for it in enemies if it[0].top < H + 40]
info('每 30 帧放一架,600 帧共放 %d 架' % (600 // 30))
info('跑完还剩 %d 架在屏幕里' % len(enemies))
pygame.quit()

实跑输出:

--------------------------------------------------------------------
飞机大战主体:背景、战机、子弹、敌机
--------------------------------------------------------------------

  ① 背景滚动:两张图首尾相接
  --------------------------------------------------------------------
  准备两张 480×700 的图,初始位置 0 和 -700
  第 0 帧:上 3,下 -697
  第 150 帧:上 453,下 -247
  第 300 帧:上 -497,下 203
  第 450 帧:上 -47,下 653
  ! 两张图错开一个屏高,一张出界就接到另一张后面,看不出接缝。

  ② 我方飞机:位置就是 rect.x
  --------------------------------------------------------------------
  开局摆在底部居中:(220, 632)
  移动 60 次之后 x = 0
  移动 120 次之后 x = 200
  移动 180 次之后 x = 400
  移动 240 次之后 x = 400
  ! 左移就减 x、右移就加 x,再用 max/min 夹住边界——三行就够了。

  ③ 子弹:一池子循环用
  --------------------------------------------------------------------
  600 帧里尝试发 75 次,成功 75 次
  池子共 64 发,此刻在用 7 发,空闲 57 发
  ! 池子满了就发不出去——不会崩,只是那一发没了。这就是上限的意义。

  ④ 敌机:按节奏放
  --------------------------------------------------------------------
  每 30 帧放一架,600 帧共放 20 架
  跑完还剩 11 架在屏幕里
  ! 生成节奏决定难度。改成每 15 帧一架,就是「困难模式」。

  ⑤ 合起来跑 600 帧,看成绩
  --------------------------------------------------------------------

| 指标 | 数值 |
|---|---:|
| 跑了多少帧 | 600 |
| 发射子弹 | 75 发 |
| 击落敌机 | 3 架 |
| 漏过(飞到底部) | 6 架 |
| 得分 | 300 |
| 总耗时 | 73 毫秒 |
| 平均每帧 | 0.122 毫秒 |

  击落率 33.3%(击落 / 击落加漏过)
  ! 固定了随机种子,这组数每次跑都一样——能复现,才敢拿来当结论。

第一段那四个位置要连着看:0 和 -700 起步,150 帧后是 453 和 -247,300 帧后变成 -497 和 203。 上下换位了,因为它俩轮流接对方的班。这就是「无限滚动」的全部秘密。 第二段那个「移动 60 次之后 x = 0」说明边界夹住了; 「移动 240 次之后 x = 400」说明右边也夹住了(480 减 40 的宽度)。 不加夹边界,这两个数会变成负数和五百多。 第三段要看的不是「发了 75 发」这个数,是「池子共 64 发」和「此刻在用 7 发」。 同一时刻在飞的子弹很少,所以 64 个池子绰绰有余。 真做弹幕游戏才需要几百上千,那时候池子的作用才明显。 第四段那个「每 30 帧一架」就是难度的旋钮。 而且敌机速度是 1.6 到 2.8 之间随机的——同一时刻生成的敌机不会并排走,看起来才不像「复制粘贴」。 这套代码跑完的结果是击落 3 架、漏过 6 架,击落率 33%。 数字不好看,但它反映的是真实参数: 子弹每 8 帧一发、敌机每 30 帧一架,火力确实不够。想改就从这三个常量下手。

30 秒实验

  1. 把敌机的生成间隔从 30 改成 15,重跑,看击落数和漏过数怎么变。
  2. 把子弹的发射间隔从 8 改成 4,再看击落率——是不是翻倍了?
  3. 把 pr.x = max(0, min(...)) 那一行注释掉,重跑,看 x 跑到多少去了。

常见误区

  1. 「背景用一张很大的图往下挪就行。」挪完就空了。 两张交替才是「无限」的写法,一张图只能滚一个屏高。
  2. 「子弹每发都是新对象,反正 Python 有垃圾回收。」能跑,但每帧都在造和扔。 池子把「分配」这件事从循环里挪出去了。
  3. 「敌机越快越难。」难度的旋钮有三个:来得多快、飞得多快、子弹有多密。 只调一个往往调不出你想要的手感。
自测三问(点开对答案) 1. 为什么滚动的背景要用两张图,一张不行吗? —— 一张图只能覆盖一个屏高。往下挪一段之后,图的底部会露出空白。 两张图错开一个屏高,一张快出界时另一张正好补上,出界的那张立刻接到后面去,屏幕上永远有内容。 2. 子弹池满了,`fire()` 返回 `None`,接下来应该怎么处理? —— 直接跳过这一次发射就行——那一发没打出去,玩家不会察觉。 **千万别抛异常**,因为「池子满」在弹幕游戏里是正常状态,不是错误。 如果发现经常满,再考虑把池子开大。 3. 演示里击落率只有 33%,是代码写错了吗? —— 不是。子弹每 8 帧发一发、敌机每 30 帧来一架,火力密度本来就不够。 想提高击落率就调三个常量:发射间隔、子弹速度、敌机生成间隔。 **这是设计参数的问题,不是 bug。**

四个角色都会动了,最后一篇补齐界面和兜底:记分牌、暂停框、音效、异常处理。

相关笔记