Круговые столкновения
Учим игру честно ловить столкновения круглых объектов — мячей, монеток и круглых врагов — через расстояние между центрами и сумму радиусов, без лишнего корня.
Два круга столкнулись тогда и только тогда, когда расстояние между их центрами меньше суммы их радиусов. Это вся теорема урока — остальное лишь про то, как посчитать это расстояние быстро и не наврать.
В прошлом уроке про 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. Это число и есть длина прямой между центром цыплёнка и центром монетки в пикселях.
Разберём построчно:
dxиdy— насколько центры разнесены по горизонтали и вертикали. Порядок вычитания не важен: если получится отрицательное число, при возведении в квадрат знак всё равно исчезнет.dx * dx + dy * dy— сумма квадратов катетов. Это и естьdx² + dy²из теоремы Пифагора.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 как основу.
- Опиши цыплёнка и монетки кругами. Цыплёнок — объект
{ x, y, r: 20 }, а монетки — массив объектов{ x, y, r: 12, taken: false }. Разбросай 5-6 монеток по холсту с разными координатами. - Каждый кадр проверяй столкновения. В
update()пройди по массиву монеток и для каждой не собранной (!coin.taken) вызовиcircleHit(chicken, coin). Если вернулосьtrue— ставьcoin.taken = trueи прибавляй очко. - Рисуй только не собранные монетки. В
draw()рисуй круги черезcontext.arc(coin.x, coin.y, coin.r, 0, Math.PI * 2), пропуская те, у которыхtaken === true. Собранная монетка должна исчезать с экрана. - Покажи счёт. Через
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и проверяем, ближе ли она радиуса.
Главный принцип, который ты унесёшь: для круга столкновение — это сравнение двух чисел, расстояния и суммы радиусов. А раз нам важно только сравнение, корень можно выкинуть и работать в квадратах. Простая математика — а игра сразу ощущается честнее.
В следующем уроке мы перейдём от «столкнулись или нет» к реакции на столкновение: научим мяч и цыплёнка отскакивать друг от друга, разворачивая вектор скорости. Ловить контакт ты уже умеешь — пора сделать так, чтобы после контакта что-то происходило.