Круговые столкновения

Учим игру честно ловить столкновения круглых объектов — мячей, монеток и круглых врагов — через расстояние между центрами и сумму радиусов, без лишнего корня.

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

В прошлом уроке про AABB-столкновения ты научил игру замечать, когда пересекаются два прямоугольника. Это быстро и просто, но у прямоугольника есть углы. А что, если твой цыплёнок круглый, монетка круглая и мяч в Понге — тоже? Прямоугольная рамка вокруг круга торчит по углам, и игра засчитает касание там, где визуально ещё ничего не коснулось. Сегодня чиним эту несправедливость: будем проверять столкновение по-честному, как для настоящих кругов.

Зачем это нужно

Представь монетку в платформере. Ты обводишь её прямоугольной рамкой AABB и проверяешь пересечение с цыплёнком. Игрок подводит героя к монетке снизу-сбоку, между ними ещё явный зазор — а игра пиликает «собрано!». Почему? Потому что углы прямоугольной рамки торчат за пределы круглой монетки, и они-то и коснулись цыплёнка. Выглядит как баг, и игрок прав — это баг.

Та же беда в Понге. Если мяч квадратный по столкновениям, он отскакивает «по углам» странно и непредсказуемо. А круглый мяч, который отскакивает ровно от поверхности ракетки, ощущается честно. Игроки не знают слова «AABB», но они мгновенно чувствуют, когда столкновения врут.

К концу урока ты напишешь функцию circleHit(a, b), которая возвращает true ровно тогда, когда два круга действительно соприкоснулись — ни раньше, ни позже. Цыплёнок будет собирать круглую монетку точно в момент касания, а круглый враг будет ловить героя честно, по краю. И всё это — без единого лишнего вычисления квадратного корня, потому что мы применим один красивый математический трюк.

Как понять, что круги столкнулись

Представь двух людей на катке, каждый раскинул руки в стороны. Один человек — это круг: его центр — это он сам, а радиус — длина вытянутой руки. Пока кончики их пальцев не дотянулись друг до друга, люди не столкнулись. Как только пальцы коснулись — всё, контакт.

Переведём это на язык кругов. У каждого круга есть центр (точка x, y) и радиус r — расстояние от центра до края. Кончик «руки» первого круга торчит на r1 от его центра, кончик «руки» второго — на r2 от своего. Значит, их края коснутся, когда расстояние между центрами станет равно r1 + r2. А если центры ещё ближе — круги уже наехали друг на друга.

Круги пересекаются, когда расстояние между центрами меньше суммы радиусов: distance < r1 + r2. Если расстояние ровно равно сумме — они касаются краями. Если больше — между ними ещё зазор.

Вся прелесть в том, что кругу всё равно, как он повёрнут. У прямоугольника есть стороны и углы, и проверка зависит от ориентации. А круг одинаков со всех сторон, поэтому проверка для него — это просто сравнение двух чисел. Осталось понять, как посчитать это самое расстояние между центрами.

Расстояние между центрами — это теорема Пифагора

Возьмём центры двух кругов: (ax, ay) и (bx, by). Разница по горизонтали — это dx = bx - ax, разница по вертикали — dy = by - ay. Эти два отрезка — катеты прямоугольного треугольника, а прямая линия между центрами — его гипотенуза. Та самая теорема Пифагора из школы:

distance = √(dx² + dy²)

В JavaScript квадратный корень считает функция Math.sqrt, а возвести в квадрат можно через Math.pow(dx, 2) или просто dx * dx. Второй способ короче и быстрее, его и будем использовать.

Пример 1. Считаем расстояние между двумя точками

Начнём с самого маленького кирпичика — функции, которая считает расстояние между двумя центрами. Без всяких кругов, просто две точки.

function distance(ax, ay, bx, by) {
  const dx = bx - ax;   // разница по горизонтали (катет 1)
  const dy = by - ay;   // разница по вертикали (катет 2)
  return Math.sqrt(dx * dx + dy * dy);   // гипотенуза = расстояние
}

// центр цыплёнка и центр монетки
console.log(distance(100, 100, 103, 104));  // ?

Результат: в консоль выведется 5. Разница по x равна 3, по y — 4, а √(3² + 4²) = √25 = 5 — классический пифагоров треугольник 3-4-5. Это число и есть длина прямой между центром цыплёнка и центром монетки в пикселях.

Разберём построчно:

  1. dx и dy — насколько центры разнесены по горизонтали и вертикали. Порядок вычитания не важен: если получится отрицательное число, при возведении в квадрат знак всё равно исчезнет.
  2. dx * dx + dy * dy — сумма квадратов катетов. Это и есть dx² + dy² из теоремы Пифагора.
  3. Math.sqrt(...) извлекает корень и возвращает длину гипотенузы — расстояние между точками.

Пример 2. Проверяем столкновение двух кругов

Теперь обернём расстояние в проверку столкновения. Опишем круг как объект с центром и радиусом — те же поля, что у нашего chicken, плюс r.

function circleHit(a, b) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const distance = Math.sqrt(dx * dx + dy * dy);
  return distance < a.r + b.r;   // ближе суммы радиусов — значит, столкнулись
}

const chicken = { x: 200, y: 200, r: 20 };
const coin    = { x: 230, y: 200, r: 15 };

console.log(circleHit(chicken, coin));  // ?

Результат: в консоль выведется true. Центры разнесены на 30 пикселей по горизонтали (230 - 200), а сумма радиусов — 20 + 15 = 35. Раз 30 < 35, круги уже перекрылись на 5 пикселей — столкновение засчитано. Если бы монетка стояла в точке x = 240, расстояние стало бы 40, это больше 35, и функция вернула бы false.

Ключевая строка — return distance < a.r + b.r. Слева расстояние между центрами, справа сумма радиусов. Сравнение возвращает готовый true или false, который ты сразу используешь в игре: «если circleHit(chicken, coin) — забираем монетку».

Пример 3. Убираем корень — сравниваем квадраты

А теперь обещанный трюк. Функция Math.sqrt работает заметно медленнее обычного умножения, а в игре проверка столкновений может вызываться сотни раз за кадр (цыплёнок против каждой монетки, каждого врага, каждой пули). Лишний корень в горячем коде — это потерянные FPS.

Хитрость в том, что нам не нужно само расстояние — нам нужно лишь сравнить его с суммой радиусов. А если два положительных числа сравнивают, то и их квадраты сравниваются точно так же: если a < b, то и a² < b². Значит, можно возвести обе стороны неравенства в квадрат и выкинуть корень совсем:

Вместо √(dx² + dy²) < r1 + r2 сравниваем dx² + dy² < (r1 + r2)².

function circleHit(a, b) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const distanceSquared = dx * dx + dy * dy;       // квадрат расстояния, без корня
  const radiusSum = a.r + b.r;
  return distanceSquared < radiusSum * radiusSum;  // сравниваем квадраты
}

const chicken = { x: 200, y: 200, r: 20 };
const coin    = { x: 230, y: 200, r: 15 };

console.log(circleHit(chicken, coin));  // ?

Результат: в консоль снова выведется true — ответ ровно тот же, что и в примере 2. Квадрат расстояния равен 30 * 30 = 900, квадрат суммы радиусов — 35 * 35 = 1225. Раз 900 < 1225, круги столкнулись. Мы получили тот же результат, но без единого вызова Math.sqrt — чистое умножение, которое процессор щёлкает мгновенно.

Что здесь важно понять:

  • distanceSquared — это dx² + dy², то самое подкоренное выражение, но без извлечения корня.
  • radiusSum * radiusSum — это (r1 + r2)². Внимание: возводить в квадрат надо сумму радиусов, а не каждый радиус по отдельности — (20 + 15)², а не 20² + 15².
  • Сравнение квадратов даёт тот же ответ true/false, что и сравнение самих расстояний, потому что радиусы и расстояния всегда положительны.

Это стандартный приём всех игр: пока тебе нужно только «столкнулись или нет», держи всё в квадратах. Корень доставай, только если тебе реально понадобилась сама длина (например, чтобы оттолкнуть круги ровно на величину перекрытия).

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

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

Ближайшую точку находят, «зажимая» центр круга в границы прямоугольника по каждой оси через Math.max и Math.min. Прямоугольник зададим левым верхним углом x, y и размерами w, h — ровно как в уроке про AABB.

function circleRectHit(circle, rect) {
  // ближайшая к центру круга точка внутри прямоугольника
  const nearestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.w));
  const nearestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.h));

  const dx = circle.x - nearestX;
  const dy = circle.y - nearestY;
  // если эта точка ближе радиуса — круг задел прямоугольник
  return dx * dx + dy * dy < circle.r * circle.r;
}

const chicken   = { x: 70, y: 50, r: 20 };
const platform  = { x: 0, y: 60, w: 200, h: 30 };

console.log(circleRectHit(chicken, platform));  // ?

Результат: в консоль выведется true. Ближайшая к центру цыплёнка точка платформы — (70, 60) (по x центр уже внутри, по y зажат к верхнему краю платформы). Расстояние от центра (70, 50) до этой точки равно 10, что меньше радиуса 20, — цыплёнок коснулся платформы. Здесь мы тоже сравниваем квадраты (10² = 100 < 20² = 400), так что и эта проверка обходится без корня.

Разберём логику:

  • Math.min(circle.x, rect.x + rect.w) не даёт точке вылезти за правый край, а Math.max(rect.x, ...) — за левый. В итоге nearestX прижат к ближайшему краю по горизонтали (или равен circle.x, если центр уже внутри по этой оси).
  • То же самое по вертикали даёт nearestY. Вместе (nearestX, nearestY) — самая близкая к центру круга точка прямоугольника.
  • Дальше — обычная проверка «точка внутри круга»: расстояние от центра до этой точки сравниваем с радиусом, всё в квадратах.

Частые ошибки и подводные камни

На круговых столкновениях спотыкаются даже те, кто уверенно сделал AABB. Вот грабли, на которые наступают чаще всего.

1. Сравнивать расстояние с одним радиусом, а не с суммой

Очень частая ошибка — написать distance < a.r или distance < b.r, забыв, что у обоих кругов есть радиус. Тогда игра засчитает столкновение, только когда центр одного круга уже залезет внутрь другого, — то есть слишком поздно, с заметным «проваливанием». Сравнивать надо именно с суммой: distance < a.r + b.r.

2. Возводить в квадрат каждый радиус по отдельности

Переходя к сравнению квадратов, новички пишут a.r * a.r + b.r * b.r вместо (a.r + b.r) * (a.r + b.r). Это разные числа: (20 + 15)² = 1225, а 20² + 15² = 625. Сумму радиусов надо сначала сложить, и только потом возводить в квадрат целиком.

3. Тащить лишний Math.sqrt в каждую проверку

Корень сам по себе не ошибка — функция из примера 2 работает правильно. Но если у тебя на экране сотня монеток и десяток врагов, и каждый кадр ты считаешь корень для каждой пары, FPS начнёт проседать. Для простой проверки «столкнулись или нет» всегда предпочитай сравнение квадратов из примера 3.

4. Путать радиус с диаметром

Если ты рисуешь круг и берёшь его «размер» как ширину спрайта (например, 40 пикселей), то это диаметр, а радиус вдвое меньше — 20. Подставишь диаметр вместо радиуса в проверку — и круги начнут «сталкиваться» с расстояния вдвое большего, чем нужно, ловя касания в пустоте. Радиус — это половина ширины круга.

5. Сравнивать через расстояние, посчитанное по одной оси

Иногда хочется схитрить и проверить только Math.abs(dx) < a.r + b.r, забыв про вертикаль. Но это проверяет лишь горизонтальную близость: два круга, стоящие точно друг над другом далеко по вертикали, ложно «столкнутся». Расстояние всегда считаем по обеим осям сразу — и по dx, и по dy.

Мини-проект: цыплёнок собирает монетки

Теперь твоя очередь. Собери маленькую сцену, где круглый цыплёнок ездит стрелками (управление ты уже умеешь) и собирает рассыпанные круглые монетки. Возьми функцию circleHit из примера 3 как основу.

  1. Опиши цыплёнка и монетки кругами. Цыплёнок — объект { x, y, r: 20 }, а монетки — массив объектов { x, y, r: 12, taken: false }. Разбросай 5-6 монеток по холсту с разными координатами.
  2. Каждый кадр проверяй столкновения. В update() пройди по массиву монеток и для каждой не собранной (!coin.taken) вызови circleHit(chicken, coin). Если вернулось true — ставь coin.taken = true и прибавляй очко.
  3. Рисуй только не собранные монетки. В draw() рисуй круги через context.arc(coin.x, coin.y, coin.r, 0, Math.PI * 2), пропуская те, у которых taken === true. Собранная монетка должна исчезать с экрана.
  4. Покажи счёт. Через context.fillText выведи количество собранных монеток в углу холста.

Подсказки, чтобы получилось:

  • Цыплёнок круглый, поэтому его центр — это chicken.x, chicken.y. Если рисуешь его прямоугольником из прошлых уроков, помни, что у прямоугольника x, y — это угол, а у круга — центр; держи координаты согласованными.
  • Чтобы нарисовать заливной круг: context.beginPath(); context.arc(x, y, r, 0, Math.PI * 2); context.fill();.
  • Когда заработает — добавь круглого врага { x, y, r: 18 }, который гоняется за цыплёнком, и проверяй circleHit(chicken, enemy): коснулся — конец игры. Это уже почти аркада.

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

Итоги

Сегодня ты научил игру честно ловить столкновения круглых объектов. Вот что у тебя теперь в арсенале:

  • Главное правило: круги столкнулись, когда расстояние между центрами меньше суммы радиусов — distance < r1 + r2.
  • Расстояние между центрами считается по теореме Пифагора: √(dx² + dy²), где dx и dy — разницы координат.
  • Трюк без корня: для проверки «столкнулись или нет» сравнивай квадраты — dx² + dy² < (r1 + r2)². Это быстрее и даёт тот же ответ.
  • Круг против прямоугольника: находим ближайшую к центру круга точку прямоугольника через Math.max/Math.min и проверяем, ближе ли она радиуса.

Главный принцип, который ты унесёшь: для круга столкновение — это сравнение двух чисел, расстояния и суммы радиусов. А раз нам важно только сравнение, корень можно выкинуть и работать в квадратах. Простая математика — а игра сразу ощущается честнее.

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

Проверьте себя
1. Когда два круга считаются столкнувшимися?
AКогда расстояние между центрами больше суммы радиусов
BКогда расстояние между центрами меньше суммы радиусов
CКогда радиусы кругов равны
DКогда центры кругов совпадают
2. Как считается расстояние между центрами двух кругов (ax, ay) и (bx, by)?
A(bx - ax) + (by - ay)
BMath.sqrt(dx * dx + dy * dy), где dx и dy — разницы координат
CMath.abs(bx - ax)
Ddx * dy
3. Почему для проверки столкновения часто сравнивают квадраты, а не сами расстояния?
AКвадраты дают более точный ответ
BMath.sqrt медленнее умножения, а для сравнения корень не нужен
CБез корня результат всегда true
DТак требует синтаксис canvas
4. Как правильно записать правую часть сравнения квадратов для суммы радиусов r1 и r2?
Ar1 * r1 + r2 * r2
B(r1 + r2) * (r1 + r2)
Cr1 * r2
Dr1 + r2
5. В проверке круга и прямоугольника зачем используют Math.max и Math.min?
AЧтобы найти ближайшую к центру круга точку внутри прямоугольника
BЧтобы посчитать площадь прямоугольника
CЧтобы извлечь квадратный корень
DЧтобы сравнить радиусы
6. Спрайт круглой монетки имеет ширину 40 пикселей. Какой радиус подставлять в circleHit?
A40 — это и есть радиус
B20 — радиус равен половине ширины
C80 — удвоенная ширина
DРадиус не важен для круга