В прошлом уроке вы превратили корабль и метеор в спрайты, сложили их в группы и научили метеор исчезать за нижним краем. Но метеоры у нас появляются только пять штук в самом начале, а потом небо пустеет. Сегодня исправим это: заставим метеоры появляться сами, ровными порциями, пока игра идёт.
Копилка времени: как отмерить 0,8 секунды
Допустим, метеор должен появляться каждые 0,8 секунды. В нашем цикле нет часов с будильником: игра знает только, сколько времени прошло с прошлого кадра, это число лежит в dt. Кадр длится около 0,017 секунды, и ровно через 0,8 секунды никакого кадра может и не оказаться.
Представьте капающий кран и ведро. Каждая капля — это очередной dt. Мы не смотрим на часы, а просто ждём, пока ведро наполнится до отметки «0,8 литра». Как только вода дошла до отметки, мы выносим одно ведро (появляется метеор) и выливаем ровно столько, сколько вынесли. Всё, что набежало сверх отметки, остаётся в ведре и идёт в зачёт следующего раза.
Официально такая схема называется накопительным таймером: переменная копит dt кадр за кадром, а когда накопилось не меньше нужного интервала, срабатывает событие, и из копилки вычитается интервал. Посмотрите на простую модель: пять кадров по 0,5 секунды (на реальных 60 кадрах схема та же, просто кадров больше).
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, а после долгой паузы может быть и больше.
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— отдаём вызывающему число срабатываний.
Проверьте таймер руками. Три вызова подряд, как три кадра, из которых последний очень длинный:
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) пуст, и тело не выполнится ни разу.
Имя _ (одно подчёркивание) вам уже встречалось в цикле со звёздами: это обычная переменная цикла. По традиции так называют переменную, значение которой нам не нужно: мы просто хотим повторить действие нужное число раз.
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, радиус, половина размера. Радиус пригодится в модуле про столкновения, а пока он определяет, как нарисовать круг и где поставить метеор над экраном.
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.
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 и счётчик в заголовке окна.
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(считаем и проверяем столкновения). - Метеор рождается над верхним краем по центру на радиус выше окна и целиком внутри ширины, а умирает за нижним краем: число метеоров на экране само устанавливается около постоянного.
Метеоры теперь падают сами, но пока пролетают прямо сквозь корабль. В следующем модуле научим игру замечать столкновения.