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

Столкновения прямоугольников

Вы поймёте, когда два прямоугольника пересекаются, напишете для этого функцию и научите корабль замечать метеоры.

Теория + практика~30 минутСредний

Метеоры падают, корабль летает — а друг сквозь друга они проходят, как привидения. Игре не хватает вопроса «эти двое коснулись или нет?». Научим её отвечать на него для прямоугольников: сначала на пальцах, потом функцией в logic.py, потом средствами pygame.

Когда две коробки пересекаются

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

Теперь переведём это на язык pygame. Прямоугольник описывается четырьмя числами: x и y — левый верхний угол, w — ширина, h — высота. Помните: x растёт вправо, а y растёт вниз. Поэтому правый край прямоугольника лежит в x + w, а нижний — в y + h. Пересечение областей двух объектов в играх называют столкновением. Назовём прямоугольники A и B и запишем четыре условия, которые должны выполниться одновременно:

УсловиеЧто оно говорит
ax < bx + bwлевый край A левее правого края B: A не стоит целиком правее B
ax + aw > bxправый край A правее левого края B: A не стоит целиком левее B
ay < by + bhверх A выше низа B: A не стоит целиком под B
ay + ah > byниз A ниже верха B: A не стоит целиком над B

Если все четыре верны, прямоугольники пересекаются; одно неверное — и зазор есть.

Касание краем — не столкновение

Знаки в таблице строгие: только < и >, без равенства. Если правый край A ровно совпал с левым краем B, условие ax + aw > bx не выполняется, и столкновения нет. Так же ведёт себя pygame: две коробки, поставленные вплотную, друг на друге не стоят.

Функция rects_overlap в logic.py

Превратим четыре условия в функцию-судью: ей отдают два прямоугольника, она отвечает True (пересекаются) или False. Прямоугольники будем передавать кортежами (x, y, w, h). Кортеж — это как список, только в круглых скобках и неизменяемый: после создания его содержимое не поменять. Для нас важно другое: это просто четыре числа, и pygame для них не нужен. Поэтому судья живёт в logic.py — рядом с clamp и wrap — и его можно проверять отдельно от окна.

Python · kometa/logic.py
def rects_overlap(a, b):
    ax, ay, aw, ah = a
    bx, by, bw, bh = b
    return (ax < bx + bw and ax + aw > bx
            and ay < by + bh and ay + ah > by)

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

  • def rects_overlap(a, b): — функция принимает два кортежа: a и b.
  • ax, ay, aw, ah = a — распаковка: четыре числа кортежа раскладываются по четырём переменным. Мы так уже делали в цикле по звёздам: for x, y, size in stars.
  • bx, by, bw, bh = b — то же самое для второго прямоугольника.
  • return (ax < bx + bw and ax + aw > bx … — четыре условия из таблицы, соединённые словом and: True получится, только если верны все четыре. Скобки позволяют перенести длинное выражение на вторую строку.

Проверим судью на четырёх примерах. logic.py не импортирует pygame, поэтому проверку можно запустить в обычной консоли Python.

Python · фрагмент
from logic import rects_overlap

box = (0, 0, 100, 100)

print(rects_overlap(box, (50, 50, 100, 100)))
print(rects_overlap(box, (100, 0, 100, 100)))
print(rects_overlap(box, (300, 300, 50, 50)))
print(rects_overlap(box, (10, 10, 20, 20)))
Вывод
True
False
False
True

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

  • box = (0, 0, 100, 100) — квадрат 100 на 100 в левом верхнем углу; его правый и нижний края лежат на отметке 100.
  • Первый вызов — квадрат сдвинут на 50 по обеим осям, углы перекрываются: True.
  • Второй вызов — квадрат начинается ровно в x = 100, то есть касается краем: False.
  • Третий вызов — квадрат далеко справа и внизу: False.
  • Четвёртый вызов — маленький квадрат целиком внутри большого: True. Пересечением считается и такой случай.

Готовые средства pygame

Rect.colliderect

В pygame прямоугольник — это объект pygame.Rect, он уже знаком вам по спрайтам: именно он лежит в sprite.rect. У него есть метод colliderect(other), и делает он ровно то же, что наша rects_overlap: возвращает True, если прямоугольники пересекаются, и False, если нет или если они только касаются краем.

Python · фрагмент
import pygame

a = pygame.Rect(0, 0, 100, 100)
b = pygame.Rect(50, 50, 100, 100)
c = pygame.Rect(100, 0, 100, 100)

print(a.colliderect(b))
print(a.colliderect(c))
Вывод
True
False

Зачем тогда своя функция? Теперь вы знаете, что внутри colliderect нет магии, а ещё работает главный приём курса — логика отдельно от отрисовки: функцию на кортежах можно проверить без окна. В самой игре будем пользоваться готовыми методами pygame.

spritecollide: сразу всех из группы

В игре метеоров много, и сравнивать корабль с каждым вручную в цикле неудобно. Представьте охранника у ворот: вы говорите ему «проверь всю очередь и назови тех, кто задел корабль; если надо, сразу выведи их с площадки». В pygame эту роль играет функция pygame.sprite.spritecollide(sprite, group, dokill). Она берёт sprite.rect, сравнивает его с rect каждого спрайта из группы group и возвращает список задетых спрайтов. Если никто не задет, список пустой.

Третий параметр, dokill («убить ли»), отвечает за судьбу задетых. При True каждый из них сразу получает kill() и исчезает из всех групп, а значит, и с экрана. При False все остаются на месте.

Python · фрагмент
hits = pygame.sprite.spritecollide(player, meteors, True)
if hits:
    print("Удар!")

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

  • pygame.sprite.spritecollide(player, meteors, True) — проверить корабль player против каждого метеора из группы meteors; задетые метеоры сразу удалить.
  • hits = … — результат — список задетых метеоров. Если метеоров задето два, в списке будет два элемента, и len(hits) вернёт 2.
  • if hits: — пустой список в условии считается «нет», непустой — «да». Так мы спрашиваем: «был ли хоть один удар?».
  • print("Удар!") — пока просто сообщаем в консоль. Минус жизнь и очки придут позже.

Осторожно с dokill=False

Метеор перекрывает корабль не один кадр, а несколько. При dokill=False он находился бы в каждом кадре, до 60 раз в секунду, и один пролёт превратился бы в десятки ударов. Пока оставляем True.

Почему прямоугольник немного нечестный

Корабль — треугольник, но его rect прямоугольный и охватывает его целиком, так что углы рамки пустые. Метеор круглый, а его rect квадратный. Когда угол квадрата метеора заходит в пустой угол рамки корабля, colliderect честно отвечает True, хотя на экране между фигурами зазор. Игрок скажет: «Это нечестно, я не касался!».

Для простых игр это допустимо: прямоугольники считаются очень дёшево. Но круглым метеорам нужна проверка точнее, и в следующем уроке мы возьмём круги.

Практика: метеоры больше не привидения

Сделаем так, чтобы корабль «чувствовал» метеоры. Правим два файла: logic.py и main.py. В sprites.py менять ничего не нужно: и у корабля, и у метеоров уже есть rect, а это всё, что нужно для spritecollide.

Шаг 1. Судья в logic.py

Откройте logic.py и допишите в самый конец файла, после SpawnTimer:

Python · kometa/logic.py (добавить в конец файла)
def rects_overlap(a, b):
    ax, ay, aw, ah = a
    bx, by, bw, bh = b
    return (ax < bx + bw and ax + aw > bx
            and ay < by + bh and ay + ah > by)

Проверим судью в терминале (из папки kometa, с активированным окружением). Первый вызов — перекрытие, второй — касание краем:

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

Шаг 2. Счётчик ударов

В main.py между строками show_boxes = False и running = True (до главного цикла) заведите счётчик ударов:

Python · kometa/main.py (до цикла)
show_boxes = False
hits_total = 0
running = True

Шаг 3. Проверка столкновений

Внутри цикла найдите строку all_sprites.update(dt, keys) и сразу после неё вставьте проверку. Порядок важен: сначала все двигаются, потом мы смотрим, кто с кем пересёкся, и только потом рисуем.

Python · kometa/main.py (внутри цикла, после update)
while running:
    # ... всё, что выше, остаётся как есть ...
    all_sprites.update(dt, keys)

    hits = pygame.sprite.spritecollide(player, meteors, True)
    if hits:
        hits_total += len(hits)
        print("Удар!")

    # ... ниже по-прежнему идёт цикл for star in stars ...

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

  • spritecollide(player, meteors, True) — ищем метеоры, пересекающиеся с кораблём, и сразу убираем их из всех групп.
  • if hits: — блок выполняется только в кадрах, где список не пустой.
  • hits_total += len(hits) — прибавляем к счётчику столько ударов, сколько метеоров задето в этом кадре (обычно один, но иногда два сразу).
  • print("Удар!") — сообщение в консоль, чтобы увидеть момент столкновения.

Шаг 4. Счётчик в заголовке окна

Число метеоров в заголовке нам больше не нужно, выводим удары. Найдите строку с set_caption(TITLE + " | метеоров: " …) и замените её:

Python · kometa/main.py (внутри цикла, вместо старого set_caption)
while running:
    # ... рисование звёзд, спрайтов и рамок остаётся как есть ...
    pygame.display.set_caption(TITLE + " | ударов: " + str(hits_total))
    pygame.display.flip()

Запуск и что должно получиться

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

Направьте корабль под метеор: в момент касания метеор исчезает, в консоли появляется Удар!, а число в заголовке растёт на единицу. Пролетевшие мимо метеоры остаются невредимыми.

Теперь нажмите F1: зелёные рамки покажут настоящие прямоугольники столкновений. Пусть угол рамки метеора едва зайдёт в угол рамки корабля: удар засчитается, хотя картинки не коснулись. Это та самая «нечестность» прямоугольников.

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

  • При касании метеора кораблём в консоли появляется Удар!, метеор исчезает, а число в заголовке растёт.
  • За один пролёт сквозь корабль удар засчитывается ровно один раз, а не десятки раз подряд.
  • С F1 видно, что рамка корабля больше самого треугольника: удар засчитывается и по пустым углам рамки.
  • Поставьте в spritecollide False вместо True и пролетите сквозь метеор: счётчик взлетит на много единиц. Верните True.

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

Касание краем считается ударом

Что видно: в тестах или при проверке на примерах ровно прижатые друг к другу прямоугольники дают True. Почему: в условиях стоит <= или >= вместо строгих < и >. Как исправить: оставить строгие знаки, как у Rect.colliderect.

Проверили только одну ось

Что видно: удар засчитывается, когда метеор ещё высоко над кораблём. Почему: в функции остались условия только по x; без условий по y любой метеор в том же вертикальном столбце считается задетым. Как исправить: все четыре условия через and.

dokill=False, а метеор не убирается

Что видно: один пролёт метеора сквозь корабль добавляет десятки ударов. Почему: метеор остаётся в группе и находится в каждом кадре, пока перекрывается с кораблём, а кадров в секунду 60. Как исправить: передавать True третьим аргументом, чтобы задетый метеор исчезал сразу.

NameError: name 'hits_total' is not defined

Что видно: игра падает на первом же столкновении или на выводе заголовка. Почему: счётчик hits_total = 0 не добавлен перед циклом. Как исправить: завести его рядом с show_boxes, до while running:.

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

  • Прямоугольники (x, y, w, h) пересекаются, когда верны четыре условия сразу: по два на каждую ось. Достаточно одного неверного — и зазор есть.
  • Касание краем не считается столкновением: знаки строгие, < и >. Так же устроен Rect.colliderect.
  • rects_overlap(a, b) в logic.py работает на кортежах без pygame, поэтому её удобно проверять отдельно от окна.
  • pygame.sprite.spritecollide(sprite, group, dokill) возвращает список задетых спрайтов; пустой список в if означает «ударов нет».
  • С dokill=True задетые спрайты исчезают сразу; с False они остаются, и удар будет находиться в каждом кадре.
  • Прямоугольник быстрый, но неточный: он засчитывает удар и по пустым углам.

Дальше — круги: как проверить столкновение по расстоянию между центрами и почему так честнее для круглых метеоров.

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