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

Круги и точные попадания

Вы научитесь проверять столкновение двух кругов по расстоянию между центрами и подключите это к игре через radius и collide_circle.

Теория + практика~25 минутНовичок

В прошлом уроке корабль научился замечать метеоры, а рамки по F1 показали главный недостаток прямоугольника: удар засчитывается и по пустым углам рамки, где никакой картинки нет. Метеоры у нас круглые, а корабль по форме тоже скорее компактное пятно, чем вытянутая доска. Для таких фигур есть более честная проверка — не коробками, а кругами.

Круг вместо коробки

Положите на стол две монеты. Как понять, что они налезли друг на друга? Не нужно ничего накладывать и мерить углы: достаточно измерить расстояние между их центрами и сравнить с суммой двух радиусов. Если центры ближе, чем «радиус одной плюс радиус другой», монеты перекрываются. Если ровно на этом расстоянии, они только касаются краями. Если дальше, между ними зазор.

Это и есть проверка столкновения кругов. У каждого круга две характеристики: центр (x, y) и радиус (radius) — расстояние от центра до края. Условие столкновения: расстояние между центрами строго меньше суммы радиусов. Слово «строго» то же, что и у прямоугольников: касание краем столкновением не считается.

Чем это лучше прямоугольника? У круглого метеора нет углов, куда прямоугольная рамка лишний раз «выглядывает». Круг прилегает к метеору почти без запаса, а значит, удар засчитывается только тогда, когда картинки действительно сошлись.

Расстояние без корня

Как найти расстояние между центрами? Помните школьную теорему Пифагора: если центры сдвинуты на dx по горизонтали и на dy по вертикали, то расстояние равно корню из dx*dx + dy*dy. Прямая запись условия выглядела бы так: корень из суммы квадратов меньше, чем r1 + r2.

Но корень здесь лишний. Обе части неравенства — неотрицательные числа, а у таких чисел порядок не меняется, если возвести их в квадрат. Поэтому вместо «корень из суммы квадратов меньше суммы радиусов» можно сравнить квадрат расстояния с квадратом суммы радиусов: dx*dx + dy*dy < (r1 + r2) * (r1 + r2). Ответ получится тот же.

Выигрыш двойной. Во-первых, умножение и сложение дешевле извлечения корня; на десятке метеоров разницы не заметите, но привычка не тратить лишнего пригодится в играх, где объектов сотни. Во-вторых, целые числа в квадратах считаются точно, а корень почти всегда возвращает дробь с «хвостом», и сравнение на самой границе касания может дать неожиданный результат. Запишем функцию в logic.py — она, как и остальные, не знает про pygame:

Python · kometa/logic.py
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.

Проверим на трёх случаях: круги налезают друг на друга, ровно касаются и далеко друг от друга.

Python · фрагмент
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.

Python · фрагмент
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 у нас есть с прошлого модуля, а для корабля его надо добавить:

Python · фрагмент
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, функцию из теории:

Python · kometa/logic.py (добавить в конец файла)
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 добавьте новую константу:

Python · kometa/sprites.py (константы в начале файла)
PLAYER_SPEED = 360
PLAYER_RADIUS = 16
OFFSCREEN_MARGIN = 40

В классе Player замените метод __init__ на этот — добавилась одна строка self.radius = PLAYER_RADIUS. Метод update и класс Meteor остаются как были.

Python · kometa/sprites.py (внутри класса Player)
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:

Python · kometa/main.py (константы в начале файла)
BOX_COLOR = (80, 255, 120)
CIRCLE_COLOR = (255, 220, 80)
SHIP_HALF = 24

Теперь сама проверка. В игровом цикле найдите вставленный в прошлом уроке вызов spritecollide и добавьте четвёртым аргументом pygame.sprite.collide_circle:

Python · kometa/main.py (внутри цикла, вместо прошлой проверки)
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: и замените его целиком (строка рисования прямоугольников остаётся, добавились строки с кругами):

Python · kometa/main.py (внутри цикла, вместо старого блока 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, но круга для нашей игры достаточно.

Корабль теперь честно сталкивается с метеорами, но сам пока только уворачивается. Дальше научим его отвечать: стрелять по Пробелу и сбивать метеоры лазером.

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