В прошлом уроке корабль научился замечать метеоры, а рамки по F1 показали главный недостаток прямоугольника: удар засчитывается и по пустым углам рамки, где никакой картинки нет. Метеоры у нас круглые, а корабль по форме тоже скорее компактное пятно, чем вытянутая доска. Для таких фигур есть более честная проверка — не коробками, а кругами.
Круг вместо коробки
Положите на стол две монеты. Как понять, что они налезли друг на друга? Не нужно ничего накладывать и мерить углы: достаточно измерить расстояние между их центрами и сравнить с суммой двух радиусов. Если центры ближе, чем «радиус одной плюс радиус другой», монеты перекрываются. Если ровно на этом расстоянии, они только касаются краями. Если дальше, между ними зазор.
Это и есть проверка столкновения кругов. У каждого круга две характеристики: центр (x, y) и радиус (radius) — расстояние от центра до края. Условие столкновения: расстояние между центрами строго меньше суммы радиусов. Слово «строго» то же, что и у прямоугольников: касание краем столкновением не считается.
Чем это лучше прямоугольника? У круглого метеора нет углов, куда прямоугольная рамка лишний раз «выглядывает». Круг прилегает к метеору почти без запаса, а значит, удар засчитывается только тогда, когда картинки действительно сошлись.
Расстояние без корня
Как найти расстояние между центрами? Помните школьную теорему Пифагора: если центры сдвинуты на dx по горизонтали и на dy по вертикали, то расстояние равно корню из dx*dx + dy*dy. Прямая запись условия выглядела бы так: корень из суммы квадратов меньше, чем r1 + r2.
Но корень здесь лишний. Обе части неравенства — неотрицательные числа, а у таких чисел порядок не меняется, если возвести их в квадрат. Поэтому вместо «корень из суммы квадратов меньше суммы радиусов» можно сравнить квадрат расстояния с квадратом суммы радиусов: dx*dx + dy*dy < (r1 + r2) * (r1 + r2). Ответ получится тот же.
Выигрыш двойной. Во-первых, умножение и сложение дешевле извлечения корня; на десятке метеоров разницы не заметите, но привычка не тратить лишнего пригодится в играх, где объектов сотни. Во-вторых, целые числа в квадратах считаются точно, а корень почти всегда возвращает дробь с «хвостом», и сравнение на самой границе касания может дать неожиданный результат. Запишем функцию в logic.py — она, как и остальные, не знает про pygame:
def circles_overlap(x1, y1, r1, x2, y2, r2):
dx = x2 - x1
dy = y2 - y1
limit = r1 + r2
return dx * dx + dy * dy < limit * limit
Разбор по строкам
def circles_overlap(x1, y1, r1, x2, y2, r2):— на входе два круга: центр и радиус каждого. Числа, а не спрайты, поэтому функцию легко проверять без окна.dx = x2 - x1иdy = y2 - y1— на сколько центры разошлись по горизонтали и по вертикали. Знак не важен: после возведения в квадрат минус исчезнет.limit = r1 + r2— предельное расстояние: дальше этого круги уже не касаются.return dx * dx + dy * dy < limit * limit— квадрат расстояния строго меньше квадрата предела. Результат —TrueилиFalse.
Проверим на трёх случаях: круги налезают друг на друга, ровно касаются и далеко друг от друга.
from logic import circles_overlap
print(circles_overlap(0, 0, 10, 15, 0, 10))
print(circles_overlap(0, 0, 10, 20, 0, 10))
print(circles_overlap(0, 0, 10, 40, 0, 10))
True
False
False
Разберём. В первой строке центры на расстоянии 15, а сумма радиусов 20: 225 < 400, круги пересекаются. Во второй расстояние 20 равно сумме радиусов: 400 < 400 неверно, это касание, а не столкновение. В третьей расстояние 40: 1600 < 400 неверно, круги далеко.
Один радиус вместо суммы
Типичная ошибка — сравнить расстояние только с радиусом метеора или только с радиусом корабля. Тогда круги, которые явно перекрываются, останутся «незамеченными». Между центрами всегда помещаются оба радиуса: поэтому берём их сумму.
Круги в pygame: radius и collide_circle
Свою функцию мы написали, чтобы понимать формулу и проверять её отдельно от окна. В самой игре, как и с прямоугольниками, пользуемся готовым инструментом pygame. Вспомните: когда мы писали spritecollide(player, meteors, True), функция по умолчанию сравнивала rect корабля с rect каждого метеора. Это как охранник у ворот, который по умолчанию проверяет пропуска «по габаритам». Но ему можно выдать другую инструкцию.
Инструкция передаётся четвёртым аргументом — это функция, которая получает два спрайта и отвечает True или False. В документации такую функцию называют collided («судья столкновения»). Готовый судья для кругов — pygame.sprite.collide_circle.
hits = pygame.sprite.spritecollide(player, meteors, True, pygame.sprite.collide_circle)
Разбор по строкам
pygame.sprite.spritecollide(player, meteors, True, ...)— те же три первых аргумента, что и в прошлом уроке: кого проверяем, с кем, убирать ли задетых.pygame.sprite.collide_circle— четвёртый аргумент: функция-судья. Обратите внимание: без скобок. Мы не вызываем её сами, а передаём как инструкцию, иspritecollideвызовет её для каждого метеора.hits— как и раньше, список задетых метеоров; пустой список означает, что столкновений нет.
Откуда судья берёт круги? Центр круга — это центр rect спрайта, а радиус — значение атрибута radius, если вы его завели. Атрибут — это просто переменная внутри объекта, мы уже делали так с self.pos и self.vel. Для метеора radius у нас есть с прошлого модуля, а для корабля его надо добавить:
import pygame
class Player(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.Surface((48, 40), pygame.SRCALPHA)
self.rect = self.image.get_rect()
self.radius = 16
Одна строка self.radius = 16 говорит: у этого спрайта «зона удара» — круг радиусом 16 пикселей вокруг центра картинки.
А если radius не задан?
Ошибки не будет. По документации pygame в этом случае строит круг, который целиком вмещает rect спрайта: радиус равен половине диагонали прямоугольника. Для нашего корабля (картинка 48 на 40) это около 31 пикселя, почти вдвое больше, чем нужно. Круг получится слишком «щедрым», и вы снова будете получать удары по пустому месту. Поэтому radius лучше задавать всегда.
Какой радиус выбрать
Радиус круга — это не измерение, а решение дизайнера. Для метеора решение простое: он нарисован кругом, значит радиус зоны удара равен радиусу картинки, radius = size // 2. А вот корабль — треугольник, и круг ему можно подобрать по-разному.
Практика игр такая: зону удара игрока делают чуть меньше видимого корабля. Обидно погибнуть от метеора, который, как казалось, пролетел мимо; а если метеор «чиркнул» по краю крыла и ничего не случилось, игрок только порадуется. Поэтому радиус корабля берём 16: это меньше половины высоты картинки (20) и тем более половины ширины (24).
| Спрайт | Картинка | Радиус зоны удара |
|---|---|---|
| Метеор | круг диаметром 20–48 пикселей | size // 2, то есть от 10 до 24 |
| Корабль | прямоугольник 48 на 40 с треугольником внутри | 16, чуть меньше половины |
Без radius | любая | половина диагонали rect, у корабля около 31 |
Рамки по F1 в этом уроке получат помощников: рядом с зелёными прямоугольниками мы нарисуем жёлтые круги зон удара. Тогда выбор радиуса можно проверить глазами, а не на ощупь.
Есть ещё точнее: маски
Если нужна попиксельная точность — удар засчитывается, только когда непрозрачные пиксели картинок действительно совпали, — в pygame есть модуль pygame.mask и судья pygame.sprite.collide_mask. Он точнее, но заметно дороже по вычислениям. Для нашей аркады круга за глаза хватает, поэтому масками пользоваться не будем.
Практика: честные попадания
Переведём столкновения корабля и метеоров с прямоугольников на круги. Правим три файла: logic.py, sprites.py и main.py. У метеора radius уже есть, поэтому в его классе менять ничего не нужно.
Шаг 1. Судья кругов в logic.py
Откройте logic.py и допишите в самый конец файла, после rects_overlap, функцию из теории:
def circles_overlap(x1, y1, r1, x2, y2, r2):
dx = x2 - x1
dy = y2 - y1
limit = r1 + r2
return dx * dx + dy * dy < limit * limit
Проверим в терминале из папки kometa с активированным окружением: сначала круги налезают друг на друга, потом ровно касаются.
python -c "from logic import circles_overlap; print(circles_overlap(0, 0, 10, 15, 0, 10)); print(circles_overlap(0, 0, 10, 20, 0, 10))"
True
False
Эту функцию игра сама вызывать не будет — её работу сделает готовая collide_circle — но формула внутри та же, и теперь вы можете проверять её отдельно.
Шаг 2. Радиус корабля в sprites.py
Наверху файла, среди констант, после PLAYER_SPEED = 360 добавьте новую константу:
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
OFFSCREEN_MARGIN = 40
В классе Player замените метод __init__ на этот — добавилась одна строка self.radius = PLAYER_RADIUS. Метод update и класс Meteor остаются как были.
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))
Шаг 3. Круги в main.py
Сначала цвет для кругов. Рядом с остальными цветами, после BOX_COLOR, добавьте CIRCLE_COLOR:
BOX_COLOR = (80, 255, 120)
CIRCLE_COLOR = (255, 220, 80)
SHIP_HALF = 24
Теперь сама проверка. В игровом цикле найдите вставленный в прошлом уроке вызов spritecollide и добавьте четвёртым аргументом pygame.sprite.collide_circle:
while running:
# ... всё, что выше, остаётся как есть ...
all_sprites.update(dt, keys)
hits = pygame.sprite.spritecollide(player, meteors, True, pygame.sprite.collide_circle)
if hits:
hits_total += len(hits)
print("Удар!")
# ... ниже по-прежнему идёт цикл for star in stars ...
И, наконец, жёлтые круги по F1. Найдите в конце цикла блок if show_boxes: и замените его целиком (строка рисования прямоугольников остаётся, добавились строки с кругами):
while running:
# ... рисование звёзд и спрайтов остаётся как есть ...
if show_boxes:
for sprite in all_sprites:
pygame.draw.rect(screen, BOX_COLOR, sprite.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)
# ... ниже set_caption и flip остаются как есть ...
Разбор по строкам
pygame.draw.circle(screen, CIRCLE_COLOR, player.rect.center, player.radius, 1)— рисуем контур круга: поверхность, цвет, центр, радиус и толщина линии 1. Закрашенным круг был бы при толщине 0, а нам нужен только контур.player.rect.center— центр круга совпадает с центром прямоугольника корабля, ровно как считаетcollide_circle.for meteor in meteors:— то же для каждого метеора. Радиус у каждого свой, поэтому берём его изmeteor.radius.
Запуск и что должно получиться
python main.py
Нажмите F1: вокруг корабля и каждого метеора появятся жёлтые кружки. Они заметно плотнее прилегают к фигурам, чем зелёные прямоугольники, особенно у метеоров. Теперь направьте корабль так, чтобы метеор прошёл мимо крыла: углы рамок могут пересечься, но если жёлтые круги не коснулись, метеор пролетит дальше без удара. А как только два жёлтых круга перекрылись, метеор исчезает, в консоли пишется Удар!, и счётчик в заголовке растёт.
Проверьте себя
- Вокруг корабля и метеоров по F1 видны жёлтые круги, а у метеоров круг совпадает с контуром картинки.
- Метеор, который лишь «зацепил» угол зелёной рамки корабля, больше не вызывает удар, если круги не пересеклись.
- Удар засчитывается, когда жёлтые круги заходят друг на друга; за один пролёт — ровно один раз.
- Поставьте
PLAYER_RADIUS = 40: корабль начнёт «ловить» метеоры издалека, круг на F1 станет огромным. Верните 16. - Уберите четвёртый аргумент из
spritecollide: удары вернутся к прямоугольникам, и по F1 вы сразу увидите разницу. Верните аргумент обратно.
Совет
Если после поправки радиуса игра кажется слишком строгой или слишком лёгкой, меняйте только PLAYER_RADIUS и сверяйтесь с кругами на F1. Другие числа для этого трогать не нужно.
Частые ошибки
Круги не пересекаются, хотя картинки явно налезли
Что видно: метеор проходит сквозь корабль и ничего не происходит, а функция circles_overlap возвращает False там, где круги пересекаются. Почему: расстояние сравнивают с одним радиусом, а не с суммой r1 + r2; либо сравнивают квадрат расстояния с суммой радиусов, забыв возвести сумму в квадрат. Как исправить: сумму радиусов сначала посчитать, потом возвести в квадрат: limit = r1 + r2, limit * limit.
Удары по пустому месту остались
Что видно: метеор ещё в нескольких пикселях от корабля, а удар уже засчитан, и круг на F1 у корабля слишком большой. Почему: у Player нет атрибута radius или метод __init__ не обновлён, и pygame сам построил круг вокруг всего прямоугольника (около 31 пикселя). Как исправить: добавить self.radius = PLAYER_RADIUS внутри __init__ класса Player.
Удары перестали работать после правки
Что видно: метеоры пролетают сквозь корабль, или игра падает с TypeError. Почему: судью написали со скобками: pygame.sprite.collide_circle() — это вызов без аргументов, а не передача функции. Как исправить: убрать скобки: pygame.sprite.collide_circle.
Лишний корень на каждом кадре
Что видно: всё работает, но в своей функции вы пишете math.sqrt(dx * dx + dy * dy) < r1 + r2. Почему: это не ошибка, результат верный, но корень не нужен: достаточно сравнить квадраты. Как исправить: заменить на dx * dx + dy * dy < limit * limit и не тратить время на извлечение корня при каждой проверке.
Что важно запомнить
- Два круга сталкиваются, когда расстояние между центрами строго меньше суммы радиусов; равенство — касание, а не столкновение.
- Корень из расстояния не нужен: сравниваем
dx * dx + dy * dyс(r1 + r2) * (r1 + r2). Результат тот же, а считать быстрее и точнее. circles_overlap(x1, y1, r1, x2, y2, r2)лежит вlogic.pyбез pygame, поэтому её легко проверять отдельно от окна.- Четвёртый аргумент
spritecollide— функция-судья. Для кругов этоpygame.sprite.collide_circle, передаётся без скобок. - Судья берёт центр из
rect.center, а радиус — из атрибутаradius. Если его нет, pygame строит круг вокруг всегоrect, и он выходит слишком большим. - Зону удара игрока делают чуть меньше его картинки:
Player.radius = 16при картинке 48 на 40. У метеораradius = size // 2. - Для попиксельной точности есть
pygame.mask, но круга для нашей игры достаточно.
Корабль теперь честно сталкивается с метеорами, но сам пока только уворачивается. Дальше научим его отвечать: стрелять по Пробелу и сбивать метеоры лазером.