Столкновения и границы
Сегодня твой 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 - r | vel.x *= -1 |
| Верх/низ | y < r или y > height - r | vel.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);
}Результат: цыплёнок летает по холсту и отскакивает от стен, как раньше. Но в центре теперь лежит бежевое зёрнышко, и когда жёлтый кружок дотрагивается до него боком, он резко разворачивается и улетает обратно тем же путём, откуда пришёл.
Сердце примера — три строки:
let d = dist(...)— считаем расстояние между центром цыплёнка и центром зерна. Получаем одно число.if (d < chick.r + grain.r)— сравниваем это расстояние с суммой радиусов. Если меньше — кружки налезли друг на друга, есть касание.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 двигаться вместе, не врезаясь друг в друга, и превратим разрозненные точки в живую стаю. Увидимся в следующем уроке!