Отскоки и столкновения мяча
Учим мяч в Понге отскакивать от стен и от ракетки цыплёнка — и делаем так, чтобы угол отскока зависел от того, в какое место ракетки прилетел мяч.
Коллизия (столкновение) — это ситуация, когда два игровых объекта пересекаются, и игра должна на это отреагировать. В Понге реакция простая: мяч разворачивается и летит обратно.
Зачем это нужно: мяч, который не улетает в космос
В прошлом уроке про мяч и ракетки ты уже сделал главное: на экране есть мяч, который куда-то летит, и две ракетки, которые ездят вверх-вниз. Но если ты запустил тот код и просто смотрел, что будет — мяч спокойно проехал сквозь верхнюю стенку и исчез где-то за пределами canvas. Игра закончилась за полсекунды, причём не в твою пользу.
Это нормально. Мяч пока тупой: он знает только одно правило — «каждый кадр прибавь к своим координатам вектор скорости». Никто ему не сказал, что у поля есть стены, а у цыплёнка на ракетке есть нос, которым он отбивает. В этом уроке мы научим мяч замечать границы и ракетку — и реагировать. После него у тебя будет честный пинг-понг: мяч стучится в верхнюю и нижнюю стенки, отскакивает от ракеток и, если ты промахнулся, улетает за твою сторону. Вот к чему придём:
// мяч живёт по тем же правилам, что и цыплёнок: координаты + вектор скорости
const ball = {
x: 320, y: 200, // центр canvas 640x400
vx: 4, vy: 3, // летит вправо и вниз
r: 8, // радиус мяча
};
function updateBall() {
ball.x += ball.vx;
ball.y += ball.vy;
// отскоки добавим ниже по уроку
}Результат: на canvas размером 640×400 мяч стартует из центра и улетает вправо-вниз по прямой, пробивая угол поля и пропадая с экрана. Пока никаких отскоков — это стартовая точка, от которой будем чинить.
Метафора: мяч как шарик в коробке
Представь, что ты бросил мячик внутрь закрытой коробки и трясёшь её. Мячик летит, пока не врежется в стенку, — и в этот момент он мгновенно меняет направление: летел вправо — полетел влево, летел вниз — полетел вверх. Скорость по величине та же, просто знак развернулся. Вот и весь секрет отскока в Понге: при ударе о стену мы меняем знак у нужной координаты вектора скорости.
У мяча есть vx (насколько он сдвигается вправо за кадр) и vy (насколько вниз). Это и есть вектор скорости — пара чисел, которая говорит мячу, куда лететь. Удар о верхнюю или нижнюю стенку — это про вертикаль, значит разворачиваем vy (было 3, стало -3). Удар о боковую стенку или о ракетку — это про горизонталь, значит разворачиваем vx. Координата, которой ты не касался, остаётся прежней, поэтому мяч не телепортируется, а красиво продолжает движение под зеркальным углом.
Вектор скорости — пара чисел (vx,vy), показывающая, насколько объект сдвигается по горизонтали и вертикали за кадр. Меняешь знак одной из них — мяч поворачивает.
Шаг 1: отскок от верхней и нижней стен
Начнём с самого простого и самого приятного — мяч перестаёт улетать вверх и вниз. Поле у нас высотой 400 пикселей. Верхняя стена — это y = 0, нижняя — y = 400. Мяч круглый, его центр в ball.y, а радиус ball.r. Значит, верхушка мяча находится в ball.y - ball.r, а низ — в ball.y + ball.r.
Мяч коснулся потолка, когда его верхушка дошла до нуля: ball.y - ball.r <= 0. Коснулся пола, когда низ дошёл до высоты поля: ball.y + ball.r >= 400. В обоих случаях разворачиваем вертикальную скорость:
const HEIGHT = 400;
function updateBall() {
ball.x += ball.vx;
ball.y += ball.vy;
// верхняя стена
if (ball.y - ball.r <= 0) {
ball.vy = -ball.vy; // разворачиваем вертикаль
}
// нижняя стена
if (ball.y + ball.r >= HEIGHT) {
ball.vy = -ball.vy;
}
}Результат: мяч теперь не улетает вверх или вниз — долетев до потолка или пола, он отскакивает и идёт под зеркальным углом, как лазерный луч в старых головоломках. По горизонтали он пока всё ещё свободно уезжает за края — это починим на ракетках.
Почему два отдельных if, а не else if
Может показаться логичным написать else if — мол, мяч же не может коснуться потолка и пола одновременно. На маленьком поле и при большой скорости — ещё как может за один кадр оказаться в странном месте. Но дело не только в этом: два независимых if читаются проще и каждый отвечает строго за свою стену. Привыкай разделять проверки — когда столкновений станет больше, такой стиль спасёт тебе нервы.
Шаг 2: сталкиваем мяч с ракеткой (AABB)
Теперь самое интересное — ракетка цыплёнка. Ракетка в Понге это прямоугольник, и мяч тоже удобно считать прямоугольником (его «коробку» со стороной 2 * r). Когда два прямоугольника, стоящих ровно по осям экрана, проверяют на пересечение — это называется AABB, и это самый простой способ обнаружить столкновение в 2D-играх.
AABB — проверка столкновения двух прямоугольников, выровненных по осям. Прямоугольники пересекаются, только если они перекрываются и по горизонтали, и по вертикали одновременно.
Идея AABB на пальцах: возьми два прямоугольника. Они НЕ пересекаются, если один полностью левее другого, или полностью правее, или выше, или ниже. Во всех остальных случаях они налезают друг на друга. Запишем это для левой ракетки цыплёнка. Пусть у ракетки есть x, y (левый верхний угол), w (ширина) и h (высота):
const paddle = { x: 20, y: 160, w: 12, h: 80 };
function hitsPaddle(ball, p) {
return (
ball.x + ball.r >= p.x && // правый край мяча зашёл за левый край ракетки
ball.x - ball.r <= p.x + p.w && // левый край мяча не дальше правого края ракетки
ball.y + ball.r >= p.y && // низ мяча ниже верха ракетки
ball.y - ball.r <= p.y + p.h // верх мяча выше низа ракетки
);
}Результат: функция hitsPaddle возвращает true, когда коробка мяча налезает на прямоугольник ракетки, и false в остальное время. Сама по себе она ничего не рисует — это «датчик касания», который мы сейчас подключим к отскоку.
Подключаем датчик в обновление мяча. Если мяч коснулся ракетки — разворачиваем горизонтальную скорость, чтобы он полетел обратно в поле:
function updateBall() {
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.y - ball.r <= 0) ball.vy = -ball.vy;
if (ball.y + ball.r >= HEIGHT) ball.vy = -ball.vy;
// столкновение с ракеткой цыплёнка
if (hitsPaddle(ball, paddle)) {
ball.vx = -ball.vx; // отбили — летит обратно
ball.x = paddle.x + paddle.w + ball.r; // выталкиваем из ракетки
}
}Результат: когда мяч долетает до ракетки цыплёнка слева, он отбивается и уходит вправо. Строчка с ball.x = ... сразу же выталкивает мяч из ракетки наружу, чтобы он не залип внутри неё (почему это важно — разберём в ошибках ниже). Получился настоящий отбив.
Шаг 3: угол отскока зависит от точки удара
Если оставить как есть, играть будет скучно: мяч отлетает строго под тем же углом, под каким прилетел, как от стенки. В настоящем Понге фишка в том, что можно подкручивать мяч ракеткой: попал краем ракетки — мяч уходит круто вверх или вниз, попал серединой — летит почти прямо. Это и есть главная тактика игры, и сделать её несложно.
Смысл такой: смотрим, в какое место ракетки прилетел мяч. Если в центр — пусть летит ровно. Если ближе к верхнему краю — добавим ему скорости вверх, к нижнему — вниз. Посчитаем «смещение от центра» в долях от половины высоты ракетки: получится число от -1 (самый верх) до +1 (самый низ).
const BALL_SPEED = 5;
if (hitsPaddle(ball, paddle)) {
// центр ракетки по вертикали
const paddleCenter = paddle.y + paddle.h / 2;
// насколько мяч выше/ниже центра, в долях от -1 до +1
const offset = (ball.y - paddleCenter) / (paddle.h / 2);
ball.vx = BALL_SPEED; // всегда летим вправо, в поле
ball.vy = BALL_SPEED * offset; // угол зависит от точки удара
ball.x = paddle.x + paddle.w + ball.r; // выталкиваем наружу
}Результат: теперь отскок живой. Поймал мяч носом цыплёнка (центром ракетки) — мяч уходит почти горизонтально. Поймал самым краем — мяч резко взмывает вверх или ныряет вниз. Можно специально подставлять край ракетки, чтобы запустить мяч под крутым углом и запутать соперника.
Что здесь произошло по шагам
- Нашли центр ракетки:
paddle.y + paddle.h / 2— это вертикальная середина прямоугольника. - Посчитали смещение:
(ball.y - paddleCenter)— насколько мяч выше или ниже центра в пикселях. Делим наpaddle.h / 2, чтобы получить аккуратную долю от-1до+1, не зависящую от высоты ракетки. - Задали новую скорость: горизонталь делаем фиксированной (
BALL_SPEED), а вертикаль — пропорциональной смещению. Край ракетки (offsetблизко к±1) даёт большуюvy, центр (offsetоколо0) — почти нулевую.
Частые ошибки и подводные камни
1. Мяч «прилипает» к стене или дрожит
Самая частая беда. Если просто писать ball.vy = -ball.vy, но не выталкивать мяч из стены, может случиться так: на следующем кадре мяч всё ещё чуть-чуть внутри стены, условие снова срабатывает, скорость снова разворачивается — и так каждый кадр. Мяч начинает мелко дрожать у границы или вовсе залипает. Лечится одной строкой — «выталкиванием»: сразу ставь мяч ровно на границу, например ball.y = ball.r для потолка. Тогда на следующем кадре он уже снаружи, и проверка не срабатывает повторно.
2. Развернул не ту скорость
Очень легко перепутать оси: от верхней стены логично разворачивать vy, а от ракетки слева — vx. Если случайно поменяешь местами, мяч начнёт вести себя как пьяный — отскакивать в стену вместо того, чтобы уходить от неё. Запомни простое правило: горизонтальные преграды (пол, потолок) разворачивают вертикаль vy; вертикальные преграды (стены, ракетки) разворачивают горизонталь vx.
3. Мяч проскакивает сквозь ракетку на большой скорости
Если vx очень большой (скажем, 30 пикселей за кадр), мяч за один кадр может перепрыгнуть всю ширину ракетки: в прошлом кадре он был перед ней, в этом — уже за ней, и условие AABB ни разу не сработало. Это называется «туннелирование». Для нашего учебного Понга достаточно держать скорость разумной (4–8 пикселей), и проблемы не будет. Если когда-нибудь захочешь очень быстрый мяч — придётся проверять не точку, а весь путь мяча за кадр, но это уже отдельная история.
4. Забыл про радиус мяча
Если проверять столкновение по центру мяча (ball.y <= 0), мяч будет залезать в стену ровно наполовину, прежде чем отскочит — выглядит так, будто он немного вдавливается. Всегда учитывай радиус: верх мяча это ball.y - ball.r, низ — ball.y + ball.r. Тогда отскок происходит ровно тогда, когда край мяча касается стены, а не его центр.
5. Менял vx знаком, когда нужно жёстко задать направление
На шаге 2 мы писали ball.vx = -ball.vx, и это нормально для простого отбива. Но как только ты считаешь угол через offset (шаг 3), безопаснее задавать направление явно: ball.vx = BALL_SPEED (вправо, от левой ракетки) — а не разворачивать знак. Иначе при дрожании у ракетки знак может развернуться дважды, и мяч уедет обратно в ракетку. Явное направление надёжнее.
Мини-практика: вторая ракетка и потеря мяча
Сейчас мы отбивали мяч только левой ракеткой цыплёнка. Доделай игру до полноценной дуэли:
- Добавь правую ракетку. Заведи второй объект, например
paddleRight = { x: 608, y: 160, w: 12, h: 80 }, и проверяйhitsPaddle(ball, paddleRight). Только при отбиве справа мяч должен лететь влево:ball.vx = -BALL_SPEED, и выталкивай его наружу влево:ball.x = paddleRight.x - ball.r. - Боковые стены = гол. Если мяч ушёл за левый край (
ball.x - ball.r < 0) или за правый (ball.x + ball.r > 640) — значит, кто-то пропустил. Верни мяч в центр и сбрось скорость:ball.x = 320; ball.y = 200; ball.vx = BALL_SPEED;. - Бонус. Сделай так, чтобы при каждом отскоке от ракетки мяч чуть ускорялся — умножай
BALL_SPEEDна1.05. С каждым ударом темп растёт, и игра становится всё напряжённее.
Не подсматривай сразу — попробуй сам собрать вторую ракетку по образцу первой. Это лучшая проверка, что ты понял идею «развернул горизонталь — вытолкнул наружу».
Итоги и переход к следующему уроку
Ты прокачал мяч от тупой летящей точки до живого участника игры. Теперь он умеет:
- отскакивать от верхней и нижней стен, разворачивая
vy; - замечать столкновение с ракеткой через простую проверку AABB;
- разворачиваться от ракетки, меняя
vxи выталкиваясь наружу, чтобы не залипнуть; - менять угол отскока в зависимости от того, в какое место ракетки прилетел, — а это уже настоящая тактика.
Главное, что ты унёс из урока: отскок — это не магия, а разворот знака у одной координаты вектора скорости плюс маленькое выталкивание из преграды. Звучит почти обидно просто — но именно из таких крошечных правил и собираются большие игры. Эта же идея с AABB и «развернул-вытолкнул» будет работать дальше — когда цыплёнок в платформере встанет на тайл, ударится головой о потолок или столкнётся с врагом. Поменяется только то, что мы делаем после обнаружения столкновения, а сам датчик касания останется тем же.
В следующем уроке мы оживим соперника: научим ракетку напротив сама следить за мячом и двигаться к нему — сделаем простого бота, с которым можно играть в одиночку. А заодно посчитаем счёт и нарисуем его на экране, чтобы Понг стал по-настоящему похож на игру.