$ sudo teach IT
Модуль 4 · Столкновения · Урок 4.3

Выстрелы и перезарядка

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

Теория + практика~35 минутСредний

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

Выстрел — это ещё один спрайт

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

Официально это класс Laser, наследник pygame.sprite.Sprite. Картинки у нас пока нет, поэтому «костюм» — узкий цветной прямоугольник, 6 пикселей в ширину и 24 в высоту. Летит он вверх со скоростью 700 пикселей в секунду, а за верхним краем вызывает kill(), как метеор за нижним.

Python · фрагмент
import pygame

LASER_COLOR = (255, 90, 120)
LASER_SIZE = (6, 24)
LASER_SPEED = 700
OFFSCREEN_MARGIN = 40


class Laser(pygame.sprite.Sprite):
    def __init__(self, x, y):
        super().__init__()
        self.image = pygame.Surface(LASER_SIZE)
        self.image.fill(LASER_COLOR)
        self.rect = self.image.get_rect()
        self.rect.midbottom = (x, y)
        self.pos = pygame.Vector2(self.rect.center)
        self.vel = pygame.Vector2(0, -LASER_SPEED)

    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 < -OFFSCREEN_MARGIN:
            self.kill()

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

  • Laser(x, y) — лазеру при рождении сообщают точку появления.
  • pygame.Surface(LASER_SIZE) и fill(LASER_COLOR) — поверхность 6 на 24, залитая одним цветом.
  • self.rect.midbottom = (x, y) — ставим середину нижней стороны прямоугольника в точку (x, y). Лазер «вырастает» прямо над носом корабля, а не наезжает на него.
  • self.pos = pygame.Vector2(self.rect.center) — точная позиция, как у метеора.
  • self.vel = pygame.Vector2(0, -LASER_SPEED) — скорость вектором. По x ноль, по y минус 700: ось y смотрит вниз, значит, минус — вверх.
  • update(self, dt, *args) — та же сигнатура, что у метеора: клавиши лягут в args и не понадобятся.
  • self.pos += self.vel * dt — шаг за кадр: скорость на время кадра.
  • if self.pos.y < -OFFSCREEN_MARGIN: self.kill() — когда лазер ушёл выше верхней границы с запасом 40 пикселей, спрайт вычёркивается из всех групп.

Перезарядка: класс Cooldown

Если стрелять в каждом кадре, зажатый Пробел даст 60 лазеров в секунду — сплошную стену. Нужна пауза между выстрелами. Аналогия — кофемашина: нажали, подождали, можно снова. У оружия в играх это называется перезарядкой, а по-английски cooldown (время «остывания»).

Устроена перезарядка просто: это таймер обратного отсчёта. Когда стреляем, ставим на таймер 0,25 секунды. Каждый кадр вычитаем dt. Пока на таймере больше нуля, стрелять нельзя; дошёл до нуля — можно снова. Это ещё один класс логики, поэтому живёт он в logic.py, где нет pygame, и проверить его можно без окна.

В отличие от SpawnTimer, который срабатывает сам, Cooldown ждёт, когда его запустят методом start(), и отвечает на вопрос «уже можно?» методом ready().

Python · фрагмент
class Cooldown:
    def __init__(self, duration):
        self.duration = duration
        self.time_left = 0.0

    def start(self):
        self.time_left = self.duration

    def update(self, dt):
        self.time_left = max(0.0, self.time_left - dt)

    def ready(self):
        return self.time_left <= 0

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

  • self.duration — сколько секунд длится перезарядка. Задаём один раз при создании: Cooldown(0.25).
  • self.time_left = 0.0 — сколько ещё ждать. В начале ноль: стрелять можно сразу.
  • start() — «выстрелили»: заводим таймер на полную длительность. Если вызвать его посреди перезарядки, отсчёт начнётся заново.
  • update(dt) — вычитаем время кадра. max(0.0, ...) не даёт значению уйти в минус: таймер останавливается на нуле.
  • ready() — возвращает True, когда ждать больше нечего.

Посмотрим, как он ведёт себя во времени:

Python · фрагмент
from logic import Cooldown

gun = Cooldown(0.25)
print(gun.ready())
gun.start()
print(gun.ready())
gun.update(0.125)
print(gun.ready())
gun.update(0.125)
print(gun.ready())
Вывод
True
False
False
True

Зажатый Пробел и очередь выстрелов

Осталось решить, откуда брать сам Пробел. Есть два способа, которые мы уже знаем: событие KEYDOWN срабатывает один раз в момент нажатия, а pygame.key.get_pressed() показывает, зажата ли клавиша прямо сейчас. Для стрельбы очередью нужен второй. Но ему обязательно нужна перезарядка, иначе получится стена.

СпособЧто получится
KEYDOWN без перезарядкиОдин выстрел на нажатие. Очередью не постреляешь, а если колотить по клавише, скорость стрельбы ничем не ограничена.
get_pressed() без перезарядкиПока клавиша зажата, лазер в каждом кадре: 60 штук в секунду, сплошная стена.
get_pressed() и CooldownЗажали Пробел — ровно 4 выстрела в секунду. Отпустили — тишина. Это наш вариант.

Попадания: groupcollide и словарь

В прошлых уроках мы проверяли одного игрока против группы метеоров через spritecollide. Теперь сталкиваем две группы: любой лазер из lasers может попасть в любой метеор из meteors. Для этого есть pygame.sprite.groupcollide(group1, group2, dokill1, dokill2) — «столкни всех со всеми»: функция проверяет каждого из первой группы против каждого из второй.

Два последних аргумента, как раньше dokill, говорят, удалять ли попавших. True, True означает «и лазер, и метеор убрать сразу»: после попадания лазер исчезает вместе со своей целью.

А что функция возвращает? Тут нужен новый инструмент Python. Бытовая аналогия — гардероб: у каждого номерка (ключа) есть своя вещь (значение). Если знаете номерок, вещь достаёте сразу. Официально такая коллекция называется словарём, по-английски dict. Записывается в фигурных скобках, пары «ключ: значение» через запятую.

Python · фрагмент
coats = {"A12": "красная куртка", "B07": "синее пальто"}

print(coats["A12"])
print(len(coats))

for thing in coats.values():
    print(thing)

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

  • coats = {...} — словарь из двух пар. Ключи "A12" и "B07", значения — строки с описанием.
  • coats["A12"] — достаём значение по ключу, как вещь по номерку. Напечатается красная куртка.
  • len(coats) — количество пар, то есть 2.
  • coats.values() — все значения без ключей; цикл for пройдёт по ним по очереди.

groupcollide возвращает именно такой словарь. Ключ — спрайт из первой группы, то есть лазер, который во что-то попал. Значение — список спрайтов второй группы, которых этот лазер задел, то есть метеоров. Выглядит это как {лазер_1: [метеор_1], лазер_2: [метеор_2, метеор_3]}. Если никто ни во что не попал, словарь пустой.

Python · фрагмент
hits = pygame.sprite.groupcollide(lasers, meteors, True, True)
for hit_meteors in hits.values():
    score += 10 * len(hit_meteors)

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

  • groupcollide(lasers, meteors, True, True) — проверяем все лазеры против всех метеоров. Попавшие удаляются из всех групп, потому что оба аргумента True.
  • hits.values() — нас интересуют только списки задетых метеоров, поэтому ключи (лазеры) пропускаем.
  • len(hit_meteors) — сколько метеоров задел один лазер. Обычно один, но иногда два рядом.
  • score += 10 * ... — по 10 очков за каждый сбитый метеор, а не за каждый лазер.

Считайте метеоры, а не лазеры

Запись score += 10 * len(hits) выглядит похоже, но считает ключи словаря, то есть лазеры. Если один лазер заденет сразу два метеора, вы получите 10 очков вместо 20.

По умолчанию groupcollide сравнивает прямоугольники (rect). Для узкого лазера этого достаточно.

Счёт в заголовке окна

Текст на экране появится позже, а пока покажем очки в заголовке окна, обновляя его каждый кадр через pygame.display.set_caption. Строку соберём f-строкой: перед кавычками ставим букву f, а значения переменных вставляем в фигурные скобки.

Python · фрагмент
score = 30
caption = f"Комета | очки: {score}"
print(caption)
Вывод
Комета | очки: 30

Практика: стреляем по метеорам

Работаем в проекте «Комета». Меняем три файла: logic.py, sprites.py и main.py.

Шаг 1. Cooldown в logic.py

Откройте logic.py и допишите в самый конец файла класс:

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

    def start(self):
        self.time_left = self.duration

    def update(self, dt):
        self.time_left = max(0.0, self.time_left - dt)

    def ready(self):
        return self.time_left <= 0

Проверьте его отдельно, без окна:

Терминал
python -c "from logic import Cooldown; g = Cooldown(0.25); g.start(); print(g.ready()); g.update(0.25); print(g.ready())"
Вывод
False
True

Шаг 2. Laser в sprites.py

В sprites.py добавьте три константы рядом с остальными в начале файла:

Python · kometa/sprites.py (константы в начале файла)
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
OFFSCREEN_MARGIN = 40
LASER_COLOR = (255, 90, 120)
LASER_SIZE = (6, 24)
LASER_SPEED = 700

А класс Laser вставьте в конец файла, после Meteor:

Python · kometa/sprites.py (добавить в конец файла)
class Laser(pygame.sprite.Sprite):
    def __init__(self, x, y):
        super().__init__()
        self.image = pygame.Surface(LASER_SIZE)
        self.image.fill(LASER_COLOR)
        self.rect = self.image.get_rect()
        self.rect.midbottom = (x, y)
        self.pos = pygame.Vector2(self.rect.center)
        self.vel = pygame.Vector2(0, -LASER_SPEED)

    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 < -OFFSCREEN_MARGIN:
            self.kill()

Шаг 3. Стрельба и попадания в main.py

Сначала подключите новое: в строках импорта добавьте Cooldown и Laser.

Python · kometa/main.py (импорты)
from logic import Cooldown, SpawnTimer, wrap
from sprites import Laser, Meteor, Player

Рядом с SPAWN_INTERVAL заведите константу паузы между выстрелами:

Python · kometa/main.py (константы в начале файла)
SPAWN_INTERVAL = 0.8
SHOT_COOLDOWN = 0.25

Перед игровым циклом, сразу после строки spawn_timer = SpawnTimer(SPAWN_INTERVAL), добавьте группу лазеров, перезарядку и счёт. Строки show_boxes, hits_total и running уже стоят ниже, их оставьте как есть:

Python · kometa/main.py (перед циклом)
lasers = pygame.sprite.Group()
shot_cooldown = Cooldown(SHOT_COOLDOWN)
score = 0

Теперь выстрел. Вставьте блок сразу после keys = pygame.key.get_pressed():

Python · kometa/main.py (внутри цикла, после чтения клавиш)
while running:
    # ... события и keys = pygame.key.get_pressed() остаются как есть ...
    keys = pygame.key.get_pressed()

    shot_cooldown.update(dt)
    if keys[pygame.K_SPACE] and shot_cooldown.ready():
        nose_x, nose_y = player.rect.midtop
        laser = Laser(nose_x, nose_y)
        all_sprites.add(laser)
        lasers.add(laser)
        shot_cooldown.start()

    # ... ниже спавн метеоров и all_sprites.update(dt, keys) ...

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

  • shot_cooldown.update(dt) — перезарядка стареет каждый кадр, даже когда не стреляем. Без этой строки таймер не дойдёт до нуля.
  • nose_x, nose_y = player.rect.midtop — нос корабля: середина верхней стороны его прямоугольника.
  • all_sprites.add(laser) и lasers.add(laser) — один объект в двух группах: первая двигает и рисует, вторая нужна для попаданий.
  • shot_cooldown.start() — следующий выстрел будет через 0,25 секунды.

Попадания проверяем после all_sprites.update(dt, keys), рядом с проверкой корабля и метеоров. Проверка корабля с метеорами с print("Удар!") из прошлого урока остаётся, а groupcollide добавьте сразу после неё:

Python · kometa/main.py (внутри цикла, после all_sprites.update)
while running:
    # ... всё выше, включая проверку игрока с метеорами, остаётся как есть ...
    hits = pygame.sprite.spritecollide(player, meteors, True, pygame.sprite.collide_circle)
    if hits:
        hits_total += len(hits)
        print("Удар!")

    shots = pygame.sprite.groupcollide(lasers, meteors, True, True)
    for hit_meteors in shots.values():
        score += 10 * len(hit_meteors)

    # ... ниже по-прежнему идёт цикл for star in stars ...

И последнее: заголовок окна теперь показывает очки. Замените строку с set_caption в конце цикла:

Python · kometa/main.py (внутри цикла, перед flip)
while running:
    # ... рисование звёзд, спрайтов и рамок остаётся как есть ...
    pygame.display.set_caption(f"{TITLE} | очки: {score} | ударов: {hits_total}")
    pygame.display.flip()

Запуск и что должно получиться

Терминал
python main.py
  • Зажмите Пробел: из носа корабля вылетают красные палочки, ровно 4 в секунду, а не сплошная стена.
  • Лазер долетает до верхнего края и пропадает.
  • Попавший в метеор лазер исчезает вместе с метеором, а в заголовке окна очки растут на 10.
  • Включите рамки клавишей F1: у лазеров тоже появятся зелёные рамки, ведь они в all_sprites.

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

  • Что произойдёт, если убрать строку shot_cooldown.start()?
  • Почему счёт растёт на 10 за метеор, а не за лазер?
  • Что изменится, если заменить 0.25 в SHOT_COOLDOWN на 0.1?
  • Попробуйте найти ситуацию, когда один лазер сбивает сразу два метеора, и убедитесь, что очки вырастут на 20.

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

Лазеры идут сплошной стеной

Что видно: стоит зажать Пробел, и экран заливает красная полоса. Почему: нет проверки shot_cooldown.ready() или после выстрела забыли shot_cooldown.start(), поэтому оружие всегда готово. Как исправить: стрелять только при keys[pygame.K_SPACE] and shot_cooldown.ready() и сразу после выстрела вызывать start().

Выстрел один и больше не повторяется

Что видно: первый выстрел есть, дальше тишина. Почему: перезарядку запустили, но не вызывают shot_cooldown.update(dt) каждый кадр, и она не уменьшается. Как исправить: поставить update(dt) в цикл вне условия стрельбы.

Лазеров не видно или они стоят на месте

Что видно: попадания засчитываются, но на экране пусто, или лазер висит на носе. Почему: лазер добавили только в lasers и не добавили в all_sprites, а именно эта группа обновляет и рисует. Как исправить: добавлять каждый новый лазер в обе группы.

Очки считаются по числу лазеров

Что видно: один лазер задел два метеора, а очков прибавилось 10. Почему: написано score += 10 * len(hits), а len словаря равна числу ключей, то есть лазеров. Как исправить: пройти по hits.values() и сложить len каждого списка метеоров.

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

  • Выстрел — обычный спрайт: Laser летит вверх со скоростью 700 пикселей в секунду и сам вызывает kill() за верхним краем.
  • Cooldown(duration) лежит в logic.py: start() заводит таймер, update(dt) вычитает время кадра, ready() говорит, что можно стрелять.
  • Зажатая клавиша плюс перезарядка дают равномерную очередь. Одна клавиша без перезарядки даёт либо ничего, либо стену.
  • Новый лазер добавляем в две группы: all_sprites (движение и рисование) и lasers (проверка попаданий).
  • groupcollide(lasers, meteors, True, True) возвращает словарь «лазер: список задетых метеоров», а попавшие спрайты удаляет.
  • Очки считаем по метеорам: 10 * len(список) для каждого значения словаря.

Стрелять мы научились, но жизней, кристаллов и очков на экране ещё нет. В следующем уроке-практикуме добавим жизни с неуязвимостью, кристаллы и табло с текстом.

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