滚动的背景是两张图首尾相接,一张出界就接到另一张后面。 子弹用池子,用完还回去,不在循环里新建对象。 生成节奏决定难度;碰撞顺序决定「先打到谁」。
这一篇把飞机大战的四个角色都填上:背景、我方、子弹、敌机,再加一个按钮。 教材 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 秒实验
- 把敌机的生成间隔从 30 改成 15,重跑,看击落数和漏过数怎么变。
- 把子弹的发射间隔从 8 改成 4,再看击落率——是不是翻倍了?
- 把
pr.x = max(0, min(...))那一行注释掉,重跑,看 x 跑到多少去了。
常见误区
- 「背景用一张很大的图往下挪就行。」挪完就空了。 两张交替才是「无限」的写法,一张图只能滚一个屏高。
- 「子弹每发都是新对象,反正 Python 有垃圾回收。」能跑,但每帧都在造和扔。 池子把「分配」这件事从循环里挪出去了。
- 「敌机越快越难。」难度的旋钮有三个:来得多快、飞得多快、子弹有多密。 只调一个往往调不出你想要的手感。
自测三问(点开对答案)
1. 为什么滚动的背景要用两张图,一张不行吗? —— 一张图只能覆盖一个屏高。往下挪一段之后,图的底部会露出空白。 两张图错开一个屏高,一张快出界时另一张正好补上,出界的那张立刻接到后面去,屏幕上永远有内容。 2. 子弹池满了,`fire()` 返回 `None`,接下来应该怎么处理? —— 直接跳过这一次发射就行——那一发没打出去,玩家不会察觉。 **千万别抛异常**,因为「池子满」在弹幕游戏里是正常状态,不是错误。 如果发现经常满,再考虑把池子开大。 3. 演示里击落率只有 33%,是代码写错了吗? —— 不是。子弹每 8 帧发一发、敌机每 30 帧来一架,火力密度本来就不够。 想提高击落率就调三个常量:发射间隔、子弹速度、敌机生成间隔。 **这是设计参数的问题,不是 bug。**四个角色都会动了,最后一篇补齐界面和兜底:记分牌、暂停框、音效、异常处理。
- ☐ (本篇完,下一篇 3.4 小部件、音乐与异常处理)