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

Практикум: жизни и очки

Вы добавите в «Комету» жизни с неуязвимостью и миганием, кристаллы и табло с очками прямо на экране.

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

Корабль летает и стреляет, метеоры падают и исчезают от выстрелов, но удар пока только печатает «Удар!» в терминал, а очки прячутся в заголовке окна. Для настоящей игры не хватает трёх вещей: жизней, которые можно потерять, наград, которые можно поймать, и табло, которое видно на экране. Всё это мы соберём в этом практикуме, а по пути разберём два новых приёма: мигание по таймеру и вывод текста.

Жизни и неуязвимость

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

Нам нужны две переменные: lives — сколько жизней осталось, и invulnerable — сколько секунд неуязвимости ещё осталось. Каждый кадр второе число уменьшаем на время кадра, но не ниже нуля. Удар засчитываем, только когда оно равно нулю:

Python · фрагмент
lives = 3
invulnerable = 0.0
dt = 0.016
hits = ["метеор"]  # для примера: корабль задел один метеор

invulnerable = max(0.0, invulnerable - dt)
if hits and invulnerable <= 0:
    lives -= 1
    invulnerable = 1.5

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

  • lives = 3 — на старте у игрока три жизни.
  • invulnerable = 0.0 — на старте неуязвимости нет, таймер пуст.
  • max(0.0, invulnerable - dt) — вычитаем время кадра; max не даёт числу уйти в минус.
  • if hits and invulnerable <= 0 — удар засчитывается, только если список hits не пуст и таймер дошёл до нуля.
  • lives -= 1 — жизнь потеряна.
  • invulnerable = 1.5 — запускаем полторы секунды передышки.

Заметьте: метеор, задевший корабль, всё равно исчезает (dokill в spritecollide остаётся True), а вот жизнь снимается только один раз за кадр, сколько бы метеоров ни задело корабль, и только вне неуязвимости.

Мигание: корабль то виден, то нет

Игрок должен видеть, что неуязвимость включена. Лучший знак — мигание, как у лампочки. Идея простая: делим оставшееся время на длину одного «мигания» (period) и смотрим, чётное получилось число целых периодов или нет. Чётное — корабль виден, нечётное — спрятан. Пока таймер убывает, корабль переключается между «виден» и «спрятан» каждые 0,1 секунды.

Осталось, сДелим на 0,1Целых периодовКорабль
0,353,53 (нечётное)спрятан
0,252,52 (чётное)виден
0,151,51 (нечётное)спрятан
0,050,50 (чётное)виден

Целую часть числа даёт встроенная функция int(), а чётность проверяет остаток от деления на 2: % 2 == 0. Остаток вы уже видели в звёздах, которые возвращаются наверх. Оформим это функцией в logic.py, чтобы её можно было проверить без окна:

Python · kometa/logic.py (добавить в конец файла)
def blink_visible(time_left, period):
    if time_left <= 0:
        return True
    return int(time_left / period) % 2 == 0

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

  • blink_visible(time_left, period) — принимает оставшееся время и длину одного мигания, возвращает True («рисуем») или False («прячем»).
  • if time_left <= 0 — неуязвимости нет, корабль всегда виден. Эта проверка нужна ещё и потому, что у отрицательных чисел остаток в Python ведёт себя неожиданно.
  • int(time_left / period) — сколько целых периодов осталось.
  • % 2 == 0 — чётное число даёт True, нечётное — False.

Кристаллы: ещё один спрайт

Метеоры сбивают или обходят, а кристаллы ловят: за каждый даётся 25 очков. Кристалл — тот же спрайт, что и метеор, только с тремя отличиями: он нарисован ромбом, падает медленнее (90 пикселей в секунду) и появляется реже, по таймеру SpawnTimer(2.5). Ромб рисует pygame.draw.polygon по четырём точкам: верх, право, низ, лево.

Python · kometa/sprites.py (фрагмент)
def make_crystal_image(radius):
    image = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
    points = [(radius, 0), (radius * 2 - 1, radius), (radius, radius * 2 - 1), (0, radius)]
    pygame.draw.polygon(image, CRYSTAL_COLOR, points)
    pygame.draw.polygon(image, WHITE, points, 2)
    return image

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

  • pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA) — прозрачный квадрат, в который вписан ромб; углы квадрата остаются прозрачными.
  • points = [...] — четыре вершины: середина верхней стороны, середина правой, нижней и левой. Минус единица нужен, чтобы вершина не вылезла за край картинки.
  • draw.polygon(image, CRYSTAL_COLOR, points) — заливка ромба.
  • draw.polygon(image, WHITE, points, 2) — белая обводка толщиной 2 пикселя, как у корабля.

Ловим кристалл тем же приёмом, что и удар метеора: spritecollide с кругами, и тоже с dokill=True, чтобы пойманный кристалл исчез. У кристалла для этого есть radius. Очки добавляем за каждый пойманный:

Python · фрагмент
caught = pygame.sprite.spritecollide(player, crystals, True, pygame.sprite.collide_circle)
score += CRYSTAL_SCORE * len(caught)

Здесь caught — обычный список, пустой список даёт ноль очков.

Текст на экране

У pygame нет команды «напиши на экране слово». Текст сначала превращают в картинку, а потом кладут её на экран, как наклейку. Шрифт описывает объект pygame.font.Font. Его метод render рисует строку и возвращает готовую поверхность (Surface), а метод blit поверхности окна копирует одну картинку на другую в нужную точку.

Python · фрагмент
font = pygame.font.Font(None, 32)


def draw_text(surface, text, pos, color=WHITE):
    image = font.render(text, True, color)
    surface.blit(image, pos)

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

  • pygame.font.Font(None, 32) — шрифт по умолчанию (None) размером 32 пикселя.
  • def draw_text(surface, text, pos, color=WHITE) — наша функция-помощник; color=WHITE значит «если цвет не указан, возьми белый».
  • font.render(text, True, color) — превращает строку в картинку; True включает сглаживание краёв букв.
  • surface.blit(image, pos) — кладёт картинку на поверхность; pos — точка (x, y), в которую попадёт её левый верхний угол.

Шрифт создаём один раз, до цикла: читать его с диска 60 раз в секунду незачем. А render зовём каждый кадр, ведь текст («Очки: 120») меняется.

Вместо русских букв квадраты

Шрифт по умолчанию обычно умеет кириллицу, но на некоторых системах вместо букв появляются квадратики. Тогда замените строку создания шрифта на font = pygame.font.SysFont("arial", 32) — так pygame возьмёт системный шрифт. Либо положите рядом с игрой файл шрифта и откройте его по пути: pygame.font.Font("myfont.ttf", 32).

Корабль теперь рисуем сами

Группа all_sprites рисует всех подряд одной командой all_sprites.draw(screen) и не умеет пропускать кадры. Для мигания корабль должен то рисоваться, то нет, значит, рисовать его придётся самим. Поэтому player мы убираем из all_sprites и берём на себя две вещи:

  • вызывать player.update(dt, keys) в цикле, потому что группа больше не двигает корабль за нас;
  • рисовать корабль командой screen.blit(player.image, player.rect), но только когда blink_visible(invulnerable, BLINK_PERIOD) возвращает True.

Рамку по F1 для корабля тоже рисуем отдельно. Метеоры, кристаллы и лазеры остаются в all_sprites.

Когда жизни кончились

Экран «Игра окончена» появится позже, в модуле про состояния игры. Пока всё просто: как только lives стало 0 или меньше, печатаем итог в терминал и выходим из цикла, поставив running = False.

Практика: жизни, кристаллы и табло

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

Шаг 1. blink_visible в logic.py

Допишите в конец logic.py функцию из раздела про мигание:

Python · kometa/logic.py (добавить в конец файла)
def blink_visible(time_left, period):
    if time_left <= 0:
        return True
    return int(time_left / period) % 2 == 0

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

Терминал
python -c "from logic import blink_visible; print(blink_visible(0.25, 0.1), blink_visible(0.15, 0.1), blink_visible(0, 0.1))"
Вывод
True False True

Шаг 2. Crystal в sprites.py

Замените sprites.py целиком. Новое здесь: три константы кристалла (CRYSTAL_COLOR, CRYSTAL_RADIUS, CRYSTAL_SPEED), функция make_crystal_image и класс Crystal, похожий на 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)
CRYSTAL_COLOR = (120, 255, 210)
LASER_COLOR = (255, 90, 120)
METEOR_MIN_SIZE = 20
METEOR_MAX_SIZE = 48
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
CRYSTAL_RADIUS = 14
CRYSTAL_SPEED = 90
LASER_SIZE = (6, 24)
LASER_SPEED = 700
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


def make_crystal_image(radius):
    image = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
    points = [(radius, 0), (radius * 2 - 1, radius), (radius, radius * 2 - 1), (0, radius)]
    pygame.draw.polygon(image, CRYSTAL_COLOR, points)
    pygame.draw.polygon(image, WHITE, points, 2)
    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.radius = PLAYER_RADIUS
        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()


class Crystal(pygame.sprite.Sprite):
    def __init__(self, width, height):
        super().__init__()
        self.radius = CRYSTAL_RADIUS
        self.image = make_crystal_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, CRYSTAL_SPEED)
        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()


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 целиком

Теперь главный файл целиком. Прибавились: импорты blink_visible и Crystal, константы жизней, неуязвимости и очков, шрифт с draw_text, группа crystals с таймером, переменные lives и invulnerable, обработка ударов, кристаллов и конца игры, табло. Исчезли hits_total, print("Удар!") и счёт в заголовке окна, а вместе с ними строка all_sprites.add(player): корабль больше не в группе (почему — в разделе выше), его обновляет и рисует сам цикл. Клавиша F1 теперь рисует рамку корабля отдельной строкой, потому что цикл по all_sprites его уже не захватывает.

Python · kometa/main.py
import random

import pygame

from logic import Cooldown, SpawnTimer, blink_visible, wrap
from sprites import Crystal, Laser, Meteor, Player

WIDTH = 960
HEIGHT = 640
FPS = 60
TITLE = "Комета"
BG_COLOR = (12, 14, 32)
WHITE = (255, 255, 255)
BOX_COLOR = (80, 255, 120)
CIRCLE_COLOR = (255, 220, 80)
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
CRYSTAL_INTERVAL = 2.5
SHOT_COOLDOWN = 0.25
START_LIVES = 3
INVULNERABLE_TIME = 1.5
BLINK_PERIOD = 0.1
METEOR_SCORE = 10
CRYSTAL_SCORE = 25

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


def draw_text(surface, text, pos, color=WHITE):
    image = font.render(text, True, color)
    surface.blit(image, pos)


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()
crystals = pygame.sprite.Group()
lasers = pygame.sprite.Group()
spawn_timer = SpawnTimer(SPAWN_INTERVAL)
crystal_timer = SpawnTimer(CRYSTAL_INTERVAL)
shot_cooldown = Cooldown(SHOT_COOLDOWN)

score = 0
lives = START_LIVES
invulnerable = 0.0
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()
    invulnerable = max(0.0, invulnerable - dt)

    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()

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

    player.update(dt, keys)
    all_sprites.update(dt, keys)

    hits = pygame.sprite.spritecollide(player, meteors, True, pygame.sprite.collide_circle)
    if hits and invulnerable <= 0:
        lives -= 1
        invulnerable = INVULNERABLE_TIME

    caught = pygame.sprite.spritecollide(player, crystals, True, pygame.sprite.collide_circle)
    score += CRYSTAL_SCORE * len(caught)

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

    if lives <= 0:
        print("Игра окончена. Очки:", score)
        running = False

    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 blink_visible(invulnerable, BLINK_PERIOD):
        screen.blit(player.image, player.rect)
    if show_boxes:
        for sprite in all_sprites:
            pygame.draw.rect(screen, BOX_COLOR, sprite.rect, 2)
        pygame.draw.rect(screen, BOX_COLOR, player.rect, 2)
        pygame.draw.circle(screen, CIRCLE_COLOR, player.rect.center, player.radius, 1)
        for meteor in meteors:
            pygame.draw.circle(screen, CIRCLE_COLOR, meteor.rect.center, meteor.radius, 1)
    draw_text(screen, "Очки: " + str(score), (16, 12))
    draw_text(screen, "Жизни: " + str(lives), (16, 44))
    pygame.display.flip()

pygame.quit()

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

  • crystals, crystal_timer — своя группа и свой таймер на 2,5 секунды.
  • player больше не в all_sprites, поэтому рядом player.update(dt, keys) и ручная отрисовка с blink_visible.
  • if hits and invulnerable <= 0 — одна жизнь за кадр и 1,5 секунды передышки.
  • if lives <= 0 — печать итога и выход из цикла.

Шаг 4. Запуск

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

Результат: слева вверху надписи «Очки: 0» и «Жизни: 3». За сбитый метеор прибавляется 10 очков, за пойманный светло-зелёный ромб — 25. После столкновения жизней на одну меньше, а корабль полторы секунды мерцает. Когда жизни кончаются, окно закрывается, а в терминале появляется строка:

Вывод
Игра окончена. Очки: 140

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

  • После удара жизней стало 2, а корабль мигает около полутора секунд.
  • Во время мигания новый удар жизней не отнимает, а метеор при этом исчезает.
  • Кристалл падает медленнее метеоров, а при касании исчезает и даёт 25 очков.
  • Если выставить START_LIVES = 1, игра закончится после первого же удара.

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

Жизни сыплются каждый кадр

Что видно: одно касание метеора, и три жизни пропали за долю секунды.

Почему так: нет проверки invulnerable <= 0 или после удара не запускается таймер.

Как исправить: проверять таймер в условии и ставить invulnerable = INVULNERABLE_TIME сразу после потери жизни.

Корабль неуязвим навсегда

Что видно: после первого удара жизни больше не убывают.

Почему так: таймер не уменьшают: нет строки invulnerable = max(0.0, invulnerable - dt).

Как исправить: вычитать dt каждый кадр.

Корабль не мигает или нарисован дважды

Что видно: при неуязвимости корабль виден всегда, или на экране два корабля.

Почему так: player остался в all_sprites, и группа рисует его независимо от blink_visible.

Как исправить: убрать all_sprites.add(player) и рисовать корабль вручную, как в проекте.

Корабль замер на месте

Что видно: метеоры падают, а стрелки ничего не делают.

Почему так: корабль убрали из группы, а player.update(dt, keys) не добавили, и больше его никто не двигает.

Как исправить: вызывать player.update(dt, keys) каждый кадр.

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

  • Неуязвимость — число секунд invulnerable: уменьшаем на dt и засчитываем удар, только когда оно равно нулю.
  • blink_visible(time_left, period) делит остаток времени на период и смотрит на чётность: так корабль мигает без отдельных таймеров.
  • Кристалл — такой же спрайт, как метеор: своя картинка, своя скорость, свой SpawnTimer и radius для кругов.
  • Текст на экране делается в два шага: font.render(...) создаёт картинку, surface.blit(...) кладёт её на экран.
  • Спрайт, который нужно рисовать по условию, выносим из группы и рисуем сами, не забыв вызывать его update.

Игра уже играбельна, но выглядит как набор цветных фигур. В следующем модуле мы заменим их настоящими картинками из файлов и добавим звук.

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