Корабль летает и стреляет, метеоры падают и исчезают от выстрелов, но удар пока только печатает «Удар!» в терминал, а очки прячутся в заголовке окна. Для настоящей игры не хватает трёх вещей: жизней, которые можно потерять, наград, которые можно поймать, и табло, которое видно на экране. Всё это мы соберём в этом практикуме, а по пути разберём два новых приёма: мигание по таймеру и вывод текста.
Жизни и неуязвимость
Если метеоры летят густо, три жизни кончились бы за секунду, и играть было бы нечем. Поэтому после удара игра даёт короткую передышку: несколько мгновений корабль «неприкасаемый». Это называется неуязвимостью, а в коде она — обычный таймер на dt, как перезарядка выстрела.
Нам нужны две переменные: lives — сколько жизней осталось, и invulnerable — сколько секунд неуязвимости ещё осталось. Каждый кадр второе число уменьшаем на время кадра, но не ниже нуля. Удар засчитываем, только когда оно равно нулю:
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,35 | 3,5 | 3 (нечётное) | спрятан |
| 0,25 | 2,5 | 2 (чётное) | виден |
| 0,15 | 1,5 | 1 (нечётное) | спрятан |
| 0,05 | 0,5 | 0 (чётное) | виден |
Целую часть числа даёт встроенная функция int(), а чётность проверяет остаток от деления на 2: % 2 == 0. Остаток вы уже видели в звёздах, которые возвращаются наверх. Оформим это функцией в 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 по четырём точкам: верх, право, низ, лево.
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. Очки добавляем за каждый пойманный:
caught = pygame.sprite.spritecollide(player, crystals, True, pygame.sprite.collide_circle)
score += CRYSTAL_SCORE * len(caught)
Здесь caught — обычный список, пустой список даёт ноль очков.
Текст на экране
У pygame нет команды «напиши на экране слово». Текст сначала превращают в картинку, а потом кладут её на экран, как наклейку. Шрифт описывает объект pygame.font.Font. Его метод render рисует строку и возвращает готовую поверхность (Surface), а метод blit поверхности окна копирует одну картинку на другую в нужную точку.
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 функцию из раздела про мигание:
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. Остальное у вас уже было, только константы теперь сгруппированы по смыслу: цвета, размеры, скорости.
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 его уже не захватывает.
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.
Игра уже играбельна, но выглядит как набор цветных фигур. В следующем модуле мы заменим их настоящими картинками из файлов и добавим звук.