$ sudo teach IT
Модуль 3 · Спрайты · Урок 3.4

Практикум: метеоритный дождь

Вы запустите непрерывный поток метеоров: таймер копит dt и по сигналу создаёт новый метеор.

Практикум~35 минутСредний

В прошлом уроке вы превратили корабль и метеор в спрайты, сложили их в группы и научили метеор исчезать за нижним краем. Но метеоры у нас появляются только пять штук в самом начале, а потом небо пустеет. Сегодня исправим это: заставим метеоры появляться сами, ровными порциями, пока игра идёт.

Копилка времени: как отмерить 0,8 секунды

Допустим, метеор должен появляться каждые 0,8 секунды. В нашем цикле нет часов с будильником: игра знает только, сколько времени прошло с прошлого кадра, это число лежит в dt. Кадр длится около 0,017 секунды, и ровно через 0,8 секунды никакого кадра может и не оказаться.

Представьте капающий кран и ведро. Каждая капля — это очередной dt. Мы не смотрим на часы, а просто ждём, пока ведро наполнится до отметки «0,8 литра». Как только вода дошла до отметки, мы выносим одно ведро (появляется метеор) и выливаем ровно столько, сколько вынесли. Всё, что набежало сверх отметки, остаётся в ведре и идёт в зачёт следующего раза.

Официально такая схема называется накопительным таймером: переменная копит dt кадр за кадром, а когда накопилось не меньше нужного интервала, срабатывает событие, и из копилки вычитается интервал. Посмотрите на простую модель: пять кадров по 0,5 секунды (на реальных 60 кадрах схема та же, просто кадров больше).

Python · фрагмент
timer = 0.0
for dt in [0.5, 0.5, 0.5, 0.5, 0.5]:
    timer += dt
    if timer >= 0.8:
        timer -= 0.8
        print("метеор!", round(timer, 2))
    else:
        print("ждём", round(timer, 2))
Вывод
ждём 0.5
метеор! 0.2
ждём 0.7
метеор! 0.4
метеор! 0.1

Разбор по строкам

  • timer = 0.0 — копилка, сначала пустая.
  • for dt in [0.5, 0.5, 0.5, 0.5, 0.5]: — пять «кадров», в каждом «прошло» по 0,5 секунды.
  • timer += dt — доливаем в ведро время этого кадра.
  • if timer >= 0.8: — проверяем, дошла ли вода до отметки интервала.
  • timer -= 0.8 — выносим одно ведро: вычитаем интервал, а не обнуляем копилку. Остаток (0,2 секунды после первого срабатывания) сохраняется.
  • print("метеор!", round(timer, 2)) — здесь в игре будет создаваться метеор; round нужен только для красивого вывода.

Ловушка: timer = 0

Частая запись: if timer >= 0.8: timer = 0. Она работает, но каждый раз теряет остаток. В нашем примере после первого срабатывания потерялись бы 0,2 секунды, и метеоры шли бы заметно реже, чем вы задали. Чем длиннее кадры, тем больше потери. Поэтому из копилки всегда вычитаем интервал.

Класс SpawnTimer в logic.py

Эту копилку нужно в игре и в будущих уроках: метеоры, кристаллы, перезарядка выстрела. Поэтому вынесем её в отдельный объект, то есть в класс. Класс не рисует ничего и не знает про Pygame, значит, место ему в logic.py. Ещё одно преимущество — такой класс можно проверить обычным Python, без окна: вы так и сделаете в задаче к уроку.

Есть одна тонкость. При долгой паузе — например, вы потянули окно мышью, и игра на секунду замерла — следующий dt окажется огромным, и за один кадр «ведро» может переполниться не один раз, а два или три. Поэтому метод update не отвечает «да/нет», а возвращает число срабатываний: сколько метеоров нужно создать прямо сейчас. В обычный кадр это 0, примерно раз в 0,8 секунды — 1, а после долгой паузы может быть и больше.

Python · kometa/logic.py (добавить в конец файла)
class SpawnTimer:
    def __init__(self, interval):
        self.interval = interval
        self.time = 0.0

    def update(self, dt):
        self.time += dt
        count = 0
        while self.time >= self.interval:
            self.time -= self.interval
            count += 1
        return count

Разбор по строкам

  • class SpawnTimer: — описание нового типа объектов — таймеров появления.
  • def __init__(self, interval): — конструктор: интервал в секундах задаёт тот, кто создаёт таймер.
  • self.interval = interval — интервал хранится в объекте как обычный атрибут, поэтому его можно менять на лету: spawn_timer.interval = 0.5. Так позже метеоры станут чаще с ростом уровня.
  • self.time = 0.0 — сама копилка.
  • self.time += dt — доливаем время кадра.
  • count = 0 — счётчик срабатываний за этот вызов.
  • while self.time >= self.interval: — пока в копилке хватает на целый интервал, повторяем тело цикла.
  • self.time -= self.interval — выносим одно ведро, остаток остаётся.
  • count += 1 — запоминаем: одно срабатывание.
  • return count — отдаём вызывающему число срабатываний.

Проверьте таймер руками. Три вызова подряд, как три кадра, из которых последний очень длинный:

Python · фрагмент
timer = SpawnTimer(0.8)
print(timer.update(0.5))
print(timer.update(0.5))
print(timer.update(2.0))
print(round(timer.time, 2))
Вывод
0
1
2
0.6

Первый вызов: в копилке 0,5, интервал не набрался, результат 0. Второй: стало 1,0, один интервал вынесен, остаётся 0,2, результат 1. Третий: 0,2 + 2,0 = 2,2, это два целых интервала (1,6), результат 2 и остаток 0,6.

Подключаем таймер к игре

В игре нам нужно ровно одно: на каждый кадр спросить таймер, сколько метеоров создать, и создать их. Раз update возвращает число, подойдёт цикл for с range: он повторит тело столько раз, сколько вернул таймер. При нуле range(0) пуст, и тело не выполнится ни разу.

Имя _ (одно подчёркивание) вам уже встречалось в цикле со звёздами: это обычная переменная цикла. По традиции так называют переменную, значение которой нам не нужно: мы просто хотим повторить действие нужное число раз.

Python · фрагмент
for _ in range(spawn_timer.update(dt)):
    meteor = Meteor(WIDTH, HEIGHT)
    all_sprites.add(meteor)
    meteors.add(meteor)

Разбор по строкам

  • for _ in range(spawn_timer.update(dt)): — таймер получает время кадра и возвращает число срабатываний; цикл делает столько повторов.
  • meteor = Meteor(WIDTH, HEIGHT) — создаём новый метеор, как в прошлом уроке.
  • all_sprites.add(meteor) — в общую группу: её обновляют и рисуют.
  • meteors.add(meteor) — в группу метеоров: по ней позже будем искать столкновения и считать метеоры.

Одновременно сделаем метеоры разными. Теперь у каждого метеора есть size — размер от 20 до 48 пикселей (поперечник круга) — и radius, радиус, половина размера. Радиус пригодится в модуле про столкновения, а пока он определяет, как нарисовать круг и где поставить метеор над экраном.

Python · фрагмент
class Meteor(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.size = random.randint(METEOR_MIN_SIZE, METEOR_MAX_SIZE)
        self.radius = self.size // 2
        self.image = make_meteor_image(self.radius)
        self.rect = self.image.get_rect()
        self.height = height
        x = random.randint(self.radius, width - self.radius)
        self.pos = pygame.Vector2(x, -self.radius)
        self.vel = pygame.Vector2(0, random.randint(150, 260))
        self.rect.center = (round(self.pos.x), round(self.pos.y))

Разбор по строкам

  • self.size = random.randint(METEOR_MIN_SIZE, METEOR_MAX_SIZE) — случайный размер от 20 до 48. Константы лежат в начале sprites.py.
  • self.radius = self.size // 2 — радиус — целая половина размера (// — деление без остатка).
  • self.image = make_meteor_image(self.radius) — картинка нужного размера: функция из прошлого урока уже умеет рисовать круг любого радиуса.
  • x = random.randint(self.radius, width - self.radius) — метеор появляется целиком внутри ширины окна, не торчит за боковым краем.
  • self.pos = pygame.Vector2(x, -self.radius) — по вертикали центр стоит на радиус выше верхнего края, то есть метеор ещё не виден и выплывает сверху.

Практика: небо, которое не пустеет

Шаг 1. Таймер в logic.py

Откройте logic.py и допишите в самый конец класс SpawnTimer из раздела выше (тот же код, строка в строку). Остальные функции файла не трогаем.

Шаг 2. Новые метеоры в sprites.py

Замените sprites.py целиком. От прошлого урока изменились константы размера (вместо одного METEOR_RADIUS теперь METEOR_MIN_SIZE и METEOR_MAX_SIZE) и конструктор Meteor.

Python · kometa/sprites.py
import random

import pygame

from logic import direction_from_keys, ship_points, step_ship

WHITE = (255, 255, 255)
SHIP_COLOR = (90, 200, 255)
FLAME_COLOR = (255, 170, 60)
METEOR_COLOR = (120, 124, 140)
METEOR_EDGE = (70, 74, 90)
METEOR_MIN_SIZE = 20
METEOR_MAX_SIZE = 48
PLAYER_SPEED = 360
OFFSCREEN_MARGIN = 40


def make_ship_image():
    image = pygame.Surface((48, 40), pygame.SRCALPHA)
    pygame.draw.circle(image, FLAME_COLOR, (24, 35), 4)
    points = ship_points(24, 22, 20)
    pygame.draw.polygon(image, SHIP_COLOR, points)
    pygame.draw.polygon(image, WHITE, points, 2)
    return image


def make_meteor_image(radius):
    image = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
    pygame.draw.circle(image, METEOR_COLOR, (radius, radius), radius)
    pygame.draw.circle(image, METEOR_EDGE, (radius, radius), radius, 3)
    return image


class Player(pygame.sprite.Sprite):
    def __init__(self, x, y, bounds):
        super().__init__()
        self.image = make_ship_image()
        self.rect = self.image.get_rect()
        self.pos = pygame.Vector2(x, y)
        self.bounds = bounds
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, keys):
        left = keys[pygame.K_LEFT] or keys[pygame.K_a]
        right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
        up = keys[pygame.K_UP] or keys[pygame.K_w]
        down = keys[pygame.K_DOWN] or keys[pygame.K_s]
        dx, dy = direction_from_keys(left, right, up, down)
        x, y = step_ship(self.pos.x, self.pos.y, dx, dy, PLAYER_SPEED, dt, self.bounds)
        self.pos.x, self.pos.y = x, y
        self.rect.center = (round(self.pos.x), round(self.pos.y))


class Meteor(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.size = random.randint(METEOR_MIN_SIZE, METEOR_MAX_SIZE)
        self.radius = self.size // 2
        self.image = make_meteor_image(self.radius)
        self.rect = self.image.get_rect()
        self.height = height
        x = random.randint(self.radius, width - self.radius)
        self.pos = pygame.Vector2(x, -self.radius)
        self.vel = pygame.Vector2(0, random.randint(150, 260))
        self.rect.center = (round(self.pos.x), round(self.pos.y))

    def update(self, dt, *args):
        self.pos += self.vel * dt
        self.rect.center = (round(self.pos.x), round(self.pos.y))
        if self.pos.y > self.height + OFFSCREEN_MARGIN:
            self.kill()

Шаг 3. Таймер в main.py

Замените main.py целиком. Что изменилось: импорт SpawnTimer, константа SPAWN_INTERVAL = 0.8, создание spawn_timer, цикл появления метеоров в игровом цикле вместо пяти метеоров перед циклом. Заодно строка keys = pygame.key.get_pressed() поднялась выше появления метеоров: теперь порядок кадра такой — события, клавиши, появление новых спрайтов, all_sprites.update, звёзды, рисование. Его мы сохраним до конца модуля. Остальное осталось как в прошлом уроке, в том числе рамки по F1 и счётчик в заголовке окна.

Python · kometa/main.py
import random

import pygame

from logic import SpawnTimer, wrap
from sprites import Meteor, Player

WIDTH = 960
HEIGHT = 640
FPS = 60
TITLE = "Комета"
BG_COLOR = (12, 14, 32)
WHITE = (255, 255, 255)
BOX_COLOR = (80, 255, 120)
SHIP_HALF = 24
SHIP_MIN_Y = HEIGHT // 2
SHIP_MAX_Y = HEIGHT - 40
SHIP_BOUNDS = (SHIP_HALF, SHIP_MIN_Y, WIDTH - SHIP_HALF, SHIP_MAX_Y)
STAR_SPEED = 40
SPAWN_INTERVAL = 0.8

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()

stars = []
for _ in range(120):
    stars.append([random.randint(0, WIDTH), random.randint(0, HEIGHT), random.randint(1, 3)])

player = Player(WIDTH // 2, HEIGHT - 80, SHIP_BOUNDS)
all_sprites = pygame.sprite.Group()
meteors = pygame.sprite.Group()
all_sprites.add(player)
spawn_timer = SpawnTimer(SPAWN_INTERVAL)

show_boxes = False
running = True
while running:
    dt = clock.tick(FPS) / 1000

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            elif event.key == pygame.K_F1:
                show_boxes = not show_boxes

    keys = pygame.key.get_pressed()

    for _ in range(spawn_timer.update(dt)):
        meteor = Meteor(WIDTH, HEIGHT)
        all_sprites.add(meteor)
        meteors.add(meteor)

    all_sprites.update(dt, keys)
    for star in stars:
        star[1] = wrap(star[1] + STAR_SPEED * star[2] * dt, 0, HEIGHT)

    screen.fill(BG_COLOR)
    for x, y, size in stars:
        pygame.draw.circle(screen, WHITE, (x, y), size)
    all_sprites.draw(screen)
    if show_boxes:
        for sprite in all_sprites:
            pygame.draw.rect(screen, BOX_COLOR, sprite.rect, 2)
    pygame.display.set_caption(TITLE + " | метеоров: " + str(len(meteors)))
    pygame.display.flip()

pygame.quit()

Шаг 4. Запуск

Включите виртуальное окружение и запустите игру из папки kometa:

Терминал
python main.py

Сначала в небе тихо, примерно через 0,8 секунды сверху выплывает первый метеор, а дальше они сыплются ровным потоком, все разного размера и с разной скоростью. Корабль по-прежнему управляется стрелками и WASD. В заголовке окна число после слова «метеоров» растёт первые секунды, а затем колеблется где-то между тремя и семью: старые метеоры улетают за нижний край и исчезают по kill(), новые появляются сверху. Если число растёт без остановки, значит, метеоры не удаляются: загляните в раздел с ошибками.

Проверьте себя

  • Метеоры появляются сами, примерно раз в 0,8 секунды, и небо не пустеет.
  • Число в заголовке окна перестаёт расти и колеблется около одного значения.
  • Метеоры разного размера, и ни один не торчит за левый или правый край в момент появления.
  • Нажмите F1: у каждого метеора рамка совпадает с его картинкой.
  • Поставьте SPAWN_INTERVAL = 0.2: метеоров станет около полутора десятков, но число всё равно перестанет расти. Верните 0,8.

Совет

Экспериментируйте с SPAWN_INTERVAL. Зависимость простая: чем меньше интервал, тем плотнее дождь. На уровнях игры мы будем менять именно его.

Частые ошибки

Метеоров на экране всё меньше и меньше, потом пусто

Что видно: метеоры появляются, но реже, чем должны, или пропадают. Почему: копилку обнуляют (self.time = 0) вместо вычитания интервала, остатки теряются; либо цикл спавна стоит внутри if и выполняется не каждый кадр. Как исправить: в таймере пишите self.time -= self.interval, а цикл с spawn_timer.update(dt) держите прямо в игровом цикле.

Метеоры создаются, но на экране их нет

Что видно: число в заголовке растёт, а метеоров не видно (или, наоборот, видно, а число стоит на нуле). Почему: метеор добавлен только в одну группу: без all_sprites его никто не рисует и не двигает, без meteors его не считает счётчик. Как исправить: после Meteor(...) всегда две строки: all_sprites.add(meteor) и meteors.add(meteor).

Метеоры появляются у самого края и торчат наполовину

Что видно: метеор возникает частично за левой или правой границей окна. Почему: случайный x выбран от 0 до width, а центр метеора не должен подходить к краю ближе радиуса. Как исправить: random.randint(self.radius, width - self.radius).

Метеоры не пропадают, число в заголовке растёт

Что видно: число метеоров увеличивается без конца, игра постепенно тормозит. Почему: в update метеора пропал вызов self.kill() или условие проверяет не ту границу. Как исправить: сверьте условие с кодом выше: self.pos.y > self.height + OFFSCREEN_MARGIN.

NameError: name 'SpawnTimer' is not defined

Что видно: ошибка при запуске. Почему: класс дописан в logic.py, но не указан в строке from logic import SpawnTimer, wrap в main.py. Как исправить: добавьте имя в импорт.

Что важно запомнить

  • Накопительный таймер: каждый кадр прибавляем dt в копилку; хватило на интервал — событие, и из копилки вычитаем интервал.
  • Нельзя обнулять копилку при срабатывании: остаток времени пропадёт, и всё пойдёт медленнее.
  • SpawnTimer(interval).update(dt) возвращает число срабатываний (целое), потому что после долгого кадра их может быть больше одного. Атрибут interval можно менять в любой момент.
  • SpawnTimer живёт в logic.py без Pygame, поэтому его проверяют обычным Python.
  • Новый метеор всегда добавляется в две группы: all_sprites (рисуем и обновляем) и meteors (считаем и проверяем столкновения).
  • Метеор рождается над верхним краем по центру на радиус выше окна и целиком внутри ширины, а умирает за нижним краем: число метеоров на экране само устанавливается около постоянного.

Метеоры теперь падают сами, но пока пролетают прямо сквозь корабль. В следующем модуле научим игру замечать столкновения.

На следующий урок