Корабль уже умеет летать и честно сталкивается с метеорами, но пока он только уворачивается. В этом уроке он начнёт отвечать: по Пробелу будет вылетать лазер, а попавший в метеор лазер уберёт его с экрана и добавит очки. Нужны три кусочка: лазер, пауза между выстрелами и проверка попаданий между двумя группами.
Выстрел — это ещё один спрайт
Представьте выстрел как маленького артиста, у которого одна роль: выйти из носа корабля, пробежать вверх через весь экран и исчезнуть за сценой. Больше он ничего не умеет. Именно так мы и опишем его: как ещё один спрайт, такой же, как метеор, только летящий в обратную сторону.
Официально это класс Laser, наследник pygame.sprite.Sprite. Картинки у нас пока нет, поэтому «костюм» — узкий цветной прямоугольник, 6 пикселей в ширину и 24 в высоту. Летит он вверх со скоростью 700 пикселей в секунду, а за верхним краем вызывает kill(), как метеор за нижним.
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().
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, когда ждать больше нечего.
Посмотрим, как он ведёт себя во времени:
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. Записывается в фигурных скобках, пары «ключ: значение» через запятую.
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]}. Если никто ни во что не попал, словарь пустой.
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, а значения переменных вставляем в фигурные скобки.
score = 30
caption = f"Комета | очки: {score}"
print(caption)
Комета | очки: 30
Практика: стреляем по метеорам
Работаем в проекте «Комета». Меняем три файла: logic.py, sprites.py и main.py.
Шаг 1. Cooldown в logic.py
Откройте 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 добавьте три константы рядом с остальными в начале файла:
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
OFFSCREEN_MARGIN = 40
LASER_COLOR = (255, 90, 120)
LASER_SIZE = (6, 24)
LASER_SPEED = 700
А класс Laser вставьте в конец файла, после Meteor:
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.
from logic import Cooldown, SpawnTimer, wrap
from sprites import Laser, Meteor, Player
Рядом с SPAWN_INTERVAL заведите константу паузы между выстрелами:
SPAWN_INTERVAL = 0.8
SHOT_COOLDOWN = 0.25
Перед игровым циклом, сразу после строки spawn_timer = SpawnTimer(SPAWN_INTERVAL), добавьте группу лазеров, перезарядку и счёт. Строки show_boxes, hits_total и running уже стоят ниже, их оставьте как есть:
lasers = pygame.sprite.Group()
shot_cooldown = Cooldown(SHOT_COOLDOWN)
score = 0
Теперь выстрел. Вставьте блок сразу после keys = pygame.key.get_pressed():
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 добавьте сразу после неё:
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 в конце цикла:
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(список)для каждого значения словаря.
Стрелять мы научились, но жизней, кристаллов и очков на экране ещё нет. В следующем уроке-практикуме добавим жизни с неуязвимостью, кристаллы и табло с текстом.