Столкновения и границы

Сегодня твой CodeChick перестанет улетать в пустоту за краем экрана и научится отскакивать от стен и предметов, как мяч.
Столкновение — это момент, когда два объекта оказались так близко, что должны отреагировать друг на друга. В коде мы ловим этот момент по расстоянию между центрами или по тому, что объект коснулся края холста.

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

Зачем это нужно: мир без стен разваливается

Открой любую игру, где есть мячик, шарик или персонаж — пинбол, аркаду, даже змейку. Что объединяет их все? Объекты не вылетают за экран. Мяч в пинболе бьётся о бортики, герой платформера упирается в стену, шарик в «арканоиде» отскакивает от ракетки. Если убрать эту логику, любая игра за секунду превратится в чёрный экран: всё уедет за край и пропадёт.

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

Вот к чему мы придём в конце урока:

let chick;
let grain;

function setup() {
  createCanvas(600, 400);
  chick = { pos: createVector(100, 100), vel: createVector(3, 2), r: 20 };
  grain = { pos: createVector(300, 200), r: 12 };
}

function draw() {
  background(135, 206, 235);

  // движение
  chick.pos.add(chick.vel);

  // отскок от стен
  if (chick.pos.x < chick.r || chick.pos.x > width - chick.r) {
    chick.vel.x *= -1;
  }
  if (chick.pos.y < chick.r || chick.pos.y > height - chick.r) {
    chick.vel.y *= -1;
  }
  chick.pos.x = constrain(chick.pos.x, chick.r, width - chick.r);
  chick.pos.y = constrain(chick.pos.y, chick.r, height - chick.r);

  // столкновение с зерном
  let d = dist(chick.pos.x, chick.pos.y, grain.pos.x, grain.pos.y);
  if (d < chick.r + grain.r) {
    chick.vel.mult(-1); // отскочил от зерна
  }

  // рисуем зерно
  fill(210, 180, 140);
  noStroke();
  ellipse(grain.pos.x, grain.pos.y, grain.r * 2);

  // рисуем цыплёнка
  fill(255, 215, 0);
  ellipse(chick.pos.x, chick.pos.y, chick.r * 2);
  fill(255, 140, 0);
  triangle(chick.pos.x + chick.r, chick.pos.y, chick.pos.x + chick.r + 8, chick.pos.y - 4, chick.pos.x + chick.r + 8, chick.pos.y + 4);
}

Результат: по голубому небу скользит жёлтый цыплёнок с оранжевым клювом. Долетев до любого края холста, он чётко отскакивает обратно, словно мячик от стены, и никогда не вылетает наружу. А когда он касается бежевого зёрнышка в центре, то резко меняет направление и улетает в другую сторону. Картинка живёт сама по себе и не разваливается.

Сейчас половина строк может выглядеть пугающе. Спокойно — разберём каждый кусок отдельно, и всё встанет на свои места.

Главная идея: столкновение — это про расстояние

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

Два кружка касаются друг друга, когда расстояние между центрами < (радиус первого + радиус второго).

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

Расстояние между двумя точками в p5.js считает функция dist(x1, y1, x2, y2). Она прячет внутри теорему Пифагора, но тебе об этом думать не нужно — просто передай ей две точки и получи число. Кстати, эту же функцию мы уже встречали, когда настраивали притяжение и отталкивание: там расстояние решало, насколько сильно тянуть объект. Сегодня то же расстояние решает другой вопрос — «уже столкнулись или ещё нет?». Один инструмент, разные задачи.

А границы холста — это частный, упрощённый случай столкновения. Стена — это не кружок, а ровная линия края. Тут даже dist не нужен: достаточно сравнить координату объекта с краем. Если x меньше нуля — мы упёрлись в левую стену. Если x больше width — в правую. Просто и быстро.

Чтобы окончательно уложить картину в голове, держи маленькую табличку — кто с кем сталкивается и как мы это ловим:

С чем сталкиваемсяКак проверяемКак реагируем
Левая/правая стенаx < r или x > width - rvel.x *= -1
Верх/низy < r или y > height - rvel.y *= -1
Другой круглый объектdist(...) < r1 + r2развернуть или оттолкнуть скорость

Видишь логику? Стены — это про одну координату и переворот одной части скорости. Объекты — это про дистанцию в любую сторону. Дальше мы по очереди напишем оба случая.

Разбираем по кусочкам

Пример 1. Отскок от стен

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

let chick;

function setup() {
  createCanvas(600, 400);
  chick = { pos: createVector(100, 100), vel: createVector(4, 3), r: 20 };
}

function draw() {
  background(135, 206, 235);
  chick.pos.add(chick.vel); // двигаемся

  // левая и правая стены
  if (chick.pos.x < chick.r || chick.pos.x > width - chick.r) {
    chick.vel.x *= -1;
  }
  // верхняя и нижняя стены
  if (chick.pos.y < chick.r || chick.pos.y > height - chick.r) {
    chick.vel.y *= -1;
  }

  fill(255, 215, 0);
  noStroke();
  ellipse(chick.pos.x, chick.pos.y, chick.r * 2);
}

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

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

  • chick.pos.add(chick.vel) — прибавляем вектор скорости к позиции. Это обычное движение: за кадр объект сдвигается на величину скорости.
  • chick.pos.x < chick.r — проверяем левый край. Мы сравниваем с chick.r, а не с нулём, потому что у цыплёнка есть радиус: его бок касается стены, когда центр находится на расстоянии радиуса от края.
  • chick.pos.x > width - chick.r — то же для правого края: бок коснулся правой стены.
  • chick.vel.x *= -1 — переворачиваем горизонтальную скорость. Была +4 (вправо) — стала -4 (влево). Вертикальную скорость не трогаем, поэтому отскок получается под правильным углом.

Метафора: представь, что скорость — это стрелка-указатель «куда лечу». Удар о левую или правую стену разворачивает только горизонтальную часть стрелки, как зеркало, поставленное вертикально. Пол и потолок разворачивают вертикальную часть. Именно потому что мы трогаем только одну часть скорости, отскок выглядит честным: цыплёнок, летящий по диагонали, после удара о боковую стену продолжает двигаться вниз с той же скоростью, просто теперь летит влево, а не вправо.

Останови анимацию в голове на секунду и проследи за одной осью. Пусть vel.x равна +4. Кадр за кадром цыплёнок едет вправо: 100, 104, 108... Вот его центр дошёл до width - r. Условие срабатывает, vel.x становится -4, и со следующего кадра отсчёт пошёл назад: 580, 576, 572... Скорость по модулю та же — поменялся только знак, а значит, и направление. Это и есть весь отскок, никакой магии.

Пример 2. Удерживаем цыплёнка внутри поля

У примера 1 есть скрытая болячка, которая проявится позже (мы разберём её в подводных камнях). Чтобы цыплёнок точно никогда не застрял в стене и не вылетел, добавим страховку — функцию constrain. Она зажимает число в заданном диапазоне.

function draw() {
  background(135, 206, 235);
  chick.pos.add(chick.vel);

  if (chick.pos.x < chick.r || chick.pos.x > width - chick.r) {
    chick.vel.x *= -1;
  }
  if (chick.pos.y < chick.r || chick.pos.y > height - chick.r) {
    chick.vel.y *= -1;
  }

  // страховка: не пускаем центр за допустимые границы
  chick.pos.x = constrain(chick.pos.x, chick.r, width - chick.r);
  chick.pos.y = constrain(chick.pos.y, chick.r, height - chick.r);

  fill(255, 215, 0);
  noStroke();
  ellipse(chick.pos.x, chick.pos.y, chick.r * 2);
}

Результат: внешне почти то же самое — цыплёнок бодро скачет от стенки к стенке. Но теперь он гарантированно не зайдёт боком за край даже на один пиксель: как только центр попытается выскочить за chick.r от края, constrain мягко вернёт его на допустимую границу.

Как читается constrain(значение, минимум, максимум):

  • если значение меньше минимума — вернётся минимум;
  • если больше максимума — вернётся максимум;
  • если внутри диапазона — вернётся как есть, без изменений.

То есть constrain(chick.pos.x, chick.r, width - chick.r) говорит: «держи центр не ближе радиуса к левому краю и не ближе радиуса к правому». Это как поручни на балконе — гулять можно, а перевалиться за край нельзя.

Пример 3. Столкновение с зерном по расстоянию

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

let chick;
let grain;

function setup() {
  createCanvas(600, 400);
  chick = { pos: createVector(80, 80), vel: createVector(3.5, 2.5), r: 20 };
  grain = { pos: createVector(300, 200), r: 14 };
}

function draw() {
  background(135, 206, 235);
  chick.pos.add(chick.vel);

  // стены
  if (chick.pos.x < chick.r || chick.pos.x > width - chick.r) chick.vel.x *= -1;
  if (chick.pos.y < chick.r || chick.pos.y > height - chick.r) chick.vel.y *= -1;

  // проверяем столкновение с зерном
  let d = dist(chick.pos.x, chick.pos.y, grain.pos.x, grain.pos.y);
  if (d < chick.r + grain.r) {
    chick.vel.mult(-1); // развернулись на 180 градусов
  }

  // зерно
  fill(210, 180, 140);
  noStroke();
  ellipse(grain.pos.x, grain.pos.y, grain.r * 2);

  // цыплёнок
  fill(255, 215, 0);
  ellipse(chick.pos.x, chick.pos.y, chick.r * 2);
}

Результат: цыплёнок летает по холсту и отскакивает от стен, как раньше. Но в центре теперь лежит бежевое зёрнышко, и когда жёлтый кружок дотрагивается до него боком, он резко разворачивается и улетает обратно тем же путём, откуда пришёл.

Сердце примера — три строки:

  1. let d = dist(...) — считаем расстояние между центром цыплёнка и центром зерна. Получаем одно число.
  2. if (d < chick.r + grain.r) — сравниваем это расстояние с суммой радиусов. Если меньше — кружки налезли друг на друга, есть касание.
  3. chick.vel.mult(-1) — переворачиваем весь вектор скорости разом. Это самый простой вид реакции: «отлетел туда, откуда прилетел».

Заметь разницу с границами: для зерна нам важна именно дистанция между двумя точками в любом направлении, поэтому без dist тут не обойтись. А для стен достаточно было сравнить одну координату.

Честно скажу про vel.mult(-1): это не настоящая физика, а её добрая карикатура. В реальном пинболе шарик отскакивает от препятствия под зеркальным углом — как от стены, только стена эта наклонена. Точный расчёт такого отскока требует вектора-нормали и формул отражения, и до них мы доберёмся в курсе позже. Но для живой картинки и большинства аркад «развернись на 180 градусов» работает отлично и выглядит убедительно. Запомни принцип: сначала самая простая реакция, которая работает, и только потом — красивая и точная. Если хочется чуть честнее уже сейчас, можно отталкивать цыплёнка по направлению «от зерна»: вычесть из его позиции позицию зерна, нормализовать получившийся вектор и задать его как новую скорость. Но это уже задачка со звёздочкой для практики.

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

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

1. Залипание в стене (sticky walls)

Самая знаменитая ловушка. Если объект движется быстро, за один кадр он может залететь глубоко за край. На следующем кадре условие x > width - r всё ещё истинно, скорость снова переворачивается на +, потом снова на - — и цыплёнок начинает дрожать и залипать у стены, не в силах вырваться. Лекарство — пример 2: добавь constrain, чтобы насильно вернуть объект на границу, и проверка перестанет срабатывать повторно.

2. Сравниваешь с краем, забыв про радиус

Если написать if (chick.pos.x > width) вместо width - chick.r, отскок случится только когда центр цыплёнка дойдёт до края — а значит, половина тушки уже вылезет за холст. Визуально кажется, что он «проваливается» в стену до середины. Всегда вычитай радиус: стена касается бока, а не центра.

3. Сравниваешь расстояние не с тем числом

Частая путаница: написать if (d < grain.r) или if (d < chick.r) вместо суммы радиусов. Тогда столкновение засчитается, только когда центр одного объекта заберётся внутрь другого, — реакция запоздает, кружки уже будут глубоко в наложении. Правило железное: порог столкновения двух кругов — это сумма их радиусов.

4. Перепутал диаметр и радиус

В p5.js ellipse(x, y, d) рисует круг по диаметру, а в формуле столкновения нужен радиус. Если ты хранишь r = 20, то рисовать надо ellipse(x, y, r * 2). Забудешь умножить на 2 — и нарисованный кружок будет вдвое меньше своей «физической» зоны столкновения: цыплёнок начнёт отскакивать от пустоты вокруг зерна.

5. Скорость не переворачивается, а растёт

Если вместо chick.vel.x *= -1 случайно написать chick.vel.x += -1, скорость не развернётся, а будет медленно уменьшаться и расти в другую сторону — движение станет странным и непредсказуемым. Для честного отскока нужно именно умножение на -1, которое мгновенно меняет знак, сохраняя величину.

6. Залип внутри объекта, как в стене

У зерна та же беда, что и у стен из пункта 1, только коварнее. Цыплёнок влетел в зерно, скорость развернулась, но за один кадр он не успел выйти из зоны касания. На следующем кадре dist всё ещё меньше суммы радиусов — скорость переворачивается опять, и опять, и цыплёнок начинает вибрировать внутри зерна, не в силах вырваться. Простое лекарство: переворачивать скорость только если объекты ещё и сближаются, а не уже разлетаются. Но для урока хватает и того, что зёрна у нас редкие и маленькие, так что застрять почти невозможно. Просто держи этот баг в голове — встретишь его обязательно.

Мини-практика: арена для CodeChick

Собери свою маленькую арену на базе примера 3 и прокачай её. Меняй по одному числу за раз и смотри, что получается — это и есть креативное кодирование.

  • Добавь несколько зёрен. Сделай массив grains из трёх-четырёх зёрнышек со случайными позициями и проверяй столкновение с каждым в цикле for. Цыплёнок будет метаться между ними, как шарик в пинболе.
  • Ускорь при отскоке от зерна. Вместо chick.vel.mult(-1) попробуй chick.vel.mult(-1.1) — каждый удар о зерно будет чуть-чуть разгонять цыплёнка. Только не увлекайся, иначе он улетит в космос.
  • Сделай зерно «съедобным». Пусть при касании зерно не отталкивает, а исчезает (например, уезжает за экран в (-100, -100)) и засчитывается очко. Это уже половина настоящей мини-игры.
  • Дорисуй клюв. Добавь цыплёнку оранжевый треугольник-клюв сбоку, как в финальном примере вверху урока, чтобы было видно, куда он летит.

Цель-максимум: получить маленькую живую арену, где жёлтый CodeChick носится, честно отскакивает от стен и зёрен и ни разу не вылетает за поле. Когда добьёшься — считай, что освоил азы игровой физики.

Итоги

Сегодня ты дал своему миру стены и научил героя в нём реагировать.

  • Столкновение двух кругов ловится по расстоянию: dist(...) < r1 + r2.
  • Границы холста — это упрощённое столкновение со стеной: сравниваем координату с краем, не забывая вычесть радиус.
  • Отскок — это переворот соответствующей части скорости: vel.x *= -1 для боковых стен, vel.y *= -1 для пола и потолка.
  • constrain удерживает объект внутри поля и спасает от залипания в стене.
  • Главные ловушки — забытый радиус, сравнение не с той величиной и путаница диаметра с радиусом.

Теперь у тебя есть мяч, который не улетает, и объекты, которые чувствуют друг друга, — фундамент любой аркады. Дальше мы пойдём ещё интереснее: научим целую толпу маленьких CodeChick двигаться вместе, не врезаясь друг в друга, и превратим разрозненные точки в живую стаю. Увидимся в следующем уроке!

Проверьте себя
1. Как понять, что два круглых объекта столкнулись?
AКогда расстояние между их центрами стало меньше суммы их радиусов
BКогда их координаты x стали равны
CКогда расстояние между центрами больше суммы радиусов
DКогда оба объекта вышли за край холста
2. Что делает строка chick.vel.x *= -1 при ударе о левую или правую стену?
AОстанавливает цыплёнка
BПереворачивает горизонтальную скорость, не трогая вертикальную — получается отскок
CУдваивает скорость по обеим осям
DПеремещает цыплёнка в центр холста
3. Почему край проверяют как chick.pos.x > width - chick.r, а не chick.pos.x > width?
AТак быстрее работает программа
BЧтобы цыплёнок отскакивал боком, а не наполовину вылезал за холст
CЭто случайный выбор, разницы нет
DЧтобы цыплёнок стал больше
4. Зачем в коде нужна строка с constrain(chick.pos.x, chick.r, width - chick.r)?
AЧтобы задать цвет цыплёнка
BЧтобы ускорить движение
CЧтобы зажать позицию в допустимых границах и спасти от залипания в стене
DЧтобы посчитать расстояние до зерна
5. Почему круг рисуют как ellipse(x, y, chick.r * 2), а в проверке столкновения используют chick.r?
AЭто ошибка, везде должен быть chick.r
Bellipse рисует по диаметру, а в формуле столкновения нужен радиус
CУмножение на 2 задаёт прозрачность
Dellipse рисует по радиусу, а столкновение по диаметру
6. Какой функцией в p5.js считают расстояние между двумя точками для проверки столкновения с зерном?
Aconstrain(...)
Brandom(...)
Cdist(x1, y1, x2, y2)
DcreateVector(...)