Притяжение и отталкивание
Сегодня ты научишь цыплёнка чувствовать зерно на расстоянии: вычислишь вектор-стрелку от него к цели и превратишь её в силу, которая мягко тянет CodeChick к еде — а сменой одного знака заставишь его в ужасе удирать.
Главная идея урока: направление «куда двигаться» — это вектор цель минус позиция. Если добавлять этот вектор к ускорению, объект притягивается. Если добавлять его же со знаком минус — отталкивается. Притяжение и отталкивание отличаются ровно одним символом.
Зачем это вообще нужно
Открой любую игру, где есть самонаводящиеся ракеты, рой пчёл за героем или магнит, собирающий монетки. Везде один и тот же фокус: объект знает, где находится цель, и каждый кадр чуть-чуть доворачивает в её сторону. Не телепортируется, не едет по прямой рельсе — а именно тянется, как будто между ним и целью натянута невидимая резинка.
В прошлый раз, когда мы разбирали ускорение, гравитацию и ветер, сила всегда смотрела в одну сторону: гравитация — вниз, ветер — вбок. Это было удобно, но скучно: цыплёнок просто падал. Сегодня сила станет живой — она будет каждый кадр пересчитываться и всегда указывать туда, где сейчас зерно. Передвинул зерно мышкой — цыплёнок развернулся и поскакал за ним.
Подумай, сколько вокруг тебя вещей работает по этому принципу. Лента в соцсети «притягивает» твой палец к кнопке лайка, потому что её специально подсветили. Магнитик на холодильнике липнет тем сильнее, чем ближе ты его подносишь. Собака на прогулке тянет поводок туда, где пахнет вкуснее. Везде есть цель и есть сила, направленная к ней. Сегодня ты научишься выражать эту силу всего парой строк кода — и она заработает для чего угодно: для героя, который бежит к монетке, для камеры, что плавно следует за персонажем, для пузырьков, всплывающих к курсору.
Вот к чему мы придём: жёлтый CodeChick носится по холсту за зёрнышком, которое ты двигаешь курсором, плавно тормозит у цели и кружит рядом. А по нажатию пробела зерно превращается в кота — и цыплёнок с той же скоростью брызгает в противоположную сторону. Один и тот же код, разный знак. К концу урока ты не просто повторишь это, а будешь понимать каждую строку настолько, что сможешь крутить ручки и лепить из неё свои сцены.
Метафора: стрелка от тебя к холодильнику
Представь: ты на диване, а на кухне — пицца. В голове сразу рисуется стрелка «отсюда туда». У неё есть направление (в сторону кухни) и длина (как далеко идти). Именно эту стрелку в коде мы и называем вектором направления.
Как её получить? Очень просто: координаты цели минус координаты объекта. Если пицца в точке (300, 100), а ты в (50, 250), то стрелка — это (300 - 50, 100 - 250), то есть (250, -150). Положительный x — значит «вправо», отрицательный y — «вверх». Стрелка честно показывает: пицца справа и выше.
Запомни формулу:
направление = цель − позиция. Это сердце всего урока. Перепутаешь местами (позиция минус цель) — и стрелка укажет ровно в обратную сторону, цыплёнок поедет от зерна, хотя ты хотел притяжение.
В p5.js пара чисел (x, y) — это p5.Vector, объект, который умеет складываться, вычитаться и измеряться. У него есть удобный метод p5.Vector.sub(a, b): он возвращает новый вектор a − b. Это и есть наша стрелка «от b к a».
Маленький словарик векторных методов
Чтобы дальше не спотыкаться, вот мини-набор приёмов p5.Vector, которые понадобятся сегодня. Они как кнопки на пульте — каждая делает с нашей стрелкой что-то одно:
createVector(x, y) | создать вектор-стрелку с координатами x и y |
p5.Vector.sub(a, b) | вернуть новый вектор a − b — стрелку от b к a |
v.add(other) | прибавить к вектору v другой вектор (например, скорость + ускорение) |
v.mult(n) | растянуть или сжать стрелку в n раз; n = -1 разворачивает её |
v.normalize() | укоротить стрелку до длины 1, оставив только направление |
v.limit(max) | не дать длине вектора превысить max (потолок скорости) |
p5.Vector.dist(a, b) | измерить расстояние между двумя точками-векторами |
Заметь важную тонкость. Когда метод вызывается через объект — force.normalize() — он меняет сам этот вектор на месте. А когда метод вызывается через p5.Vector с большой буквы — p5.Vector.sub(a, b) — он не трогает аргументы, а возвращает новый вектор. Поэтому стрелку-направление мы создаём вторым способом (нам нужен свежий вектор), а укорачиваем и масштабируем — первым (меняем его же).
Пример 1. Стрелка к зерну
Сначала просто нарисуем стрелку от цыплёнка к зерну, чтобы увидеть вектор глазами. Зерно поставим под курсор мыши.
let chick;
function setup() {
createCanvas(400, 400);
chick = createVector(200, 200); // цыплёнок в центре
}
function draw() {
background(135, 206, 235); // небо
let seed = createVector(mouseX, mouseY); // зерно под мышью
// стрелка ОТ цыплёнка К зерну: цель минус позиция
let dir = p5.Vector.sub(seed, chick);
// рисуем зерно
fill(180, 120, 40);
noStroke();
ellipse(seed.x, seed.y, 14, 14);
// рисуем цыплёнка
fill(255, 214, 10);
ellipse(chick.x, chick.y, 40, 40);
// рисуем саму стрелку направления
stroke(255, 80, 80);
strokeWeight(3);
line(chick.x, chick.y, chick.x + dir.x, chick.y + dir.y);
}
Результат: на голубом небе в центре сидит жёлтый кружок-цыплёнок, под курсором — маленькое коричневое зерно. От цыплёнка к зерну тянется красная линия-стрелка. Двигаешь мышь — линия мгновенно перенаправляется, всегда упираясь в зерно. Это и есть вектор dir нарисованный буквально.
Разберём по шагам. createVector(200, 200) создаёт p5.Vector — позицию цыплёнка. Каждый кадр мы лепим вектор зерна из mouseX/mouseY. Затем p5.Vector.sub(seed, chick) считает seed − chick — ту самую стрелку. И наконец line() рисует её, начиная от цыплёнка и заканчивая на chick + dir (что равно ровно seed, поэтому стрелка дотягивается точно до зерна).
Пример 2. Притяжение: цыплёнок едет к зерну
Стрелку видим — теперь превратим её в движение. Вспомни связку из прошлого урока: ускорение меняет скорость, скорость меняет позицию. Нам нужно лишь сделать ускорением нашу стрелку направления.
Но есть нюанс. Если добавлять стрелку как есть, далеко от зерна сила будет огромной (стрелка длинная), а рядом — крохотной. Получится резкий рывок. Чтобы движение было ровным, мы нормализуем вектор — укорачиваем его до длины 1 (метод normalize()), оставляя только направление, а затем сами задаём силу через mult().
let pos, vel, acc;
function setup() {
createCanvas(400, 400);
pos = createVector(60, 60); // цыплёнок стартует в углу
vel = createVector(0, 0); // пока стоит на месте
acc = createVector(0, 0);
}
function draw() {
background(135, 206, 235);
let seed = createVector(mouseX, mouseY);
// направление к зерну
let force = p5.Vector.sub(seed, pos);
force.normalize(); // оставляем только направление, длина = 1
force.mult(0.5); // задаём силу притяжения
acc = force; // ускорение = сила
vel.add(acc); // скорость накапливает ускорение
vel.limit(6); // не даём разогнаться до бесконечности
pos.add(vel); // позиция меняется по скорости
// зерно
fill(180, 120, 40);
noStroke();
ellipse(seed.x, seed.y, 14, 14);
// цыплёнок
fill(255, 214, 10);
ellipse(pos.x, pos.y, 40, 40);
fill(255, 140, 0); // оранжевый клюв
triangle(pos.x + 16, pos.y - 4, pos.x + 16, pos.y + 4, pos.x + 28, pos.y);
}
Результат: цыплёнок с клювиком стартует из левого верхнего угла и плавно разгоняется в сторону зерна под курсором. Долетев, он не замирает, а по инерции проскакивает мимо и закладывает круги вокруг зерна — как мотылёк вокруг лампы. Двигаешь мышь резко в сторону — цыплёнок выписывает широкую дугу, доворачивая к новой цели.
Почему он кружит, а не останавливается? Потому что у него есть vel — накопленная скорость. Сила всегда тянет к центру, но скорость уже несёт мимо, и получается орбита. Это нормально и красиво. vel.limit(6) ставит потолок скорости, чтобы цыплёнок не разогнался в гиперскачок. А force.mult(0.5) — ручка громкости притяжения: больше число — резче рывки, меньше — ленивее тянется.
А где же «знаешь, как тормозить у цели»?
Если хочешь, чтобы цыплёнок не кружил, а аккуратно тормозил у зерна, добавь лёгкое трение — каждый кадр чуть-чуть гаси скорость: vel.mult(0.96); прямо перед pos.add(vel). Тогда орбиты будут стягиваться, и цыплёнок осядет на зерне. Поиграй с числом 0.96 — это как сопротивление воздуха.
Откуда вообще берётся это число? Множитель меньше единицы каждый кадр отъедает у скорости кусочек: при 0.96 остаётся 96% скорости, 4% «съедает воздух». Через десяток кадров от рывка почти ничего не остаётся, и движение само собой затухает. Чем ближе множитель к единице (0.99) — тем дольше цыплёнок скользит и тем больше кругов наматывает; чем дальше (0.85) — тем резче тормозит, будто едет по песку. Это, по сути, тот же приём, что мы видели в уроке про easing: объект приближается к цели частями расстояния и плавно замедляется. Только теперь у нас всё устроено через настоящие силы и скорость, а не через прямое подтягивание координаты к цели.
Пример 3. Отталкивание: меняем один знак
А теперь — самый приятный момент урока. Чтобы цыплёнок не притягивался, а убегал, нам не нужно переписывать логику. Достаточно развернуть силу в обратную сторону. Это делается одним из двух способов: поменять местами цель и позицию в sub, либо умножить силу на -1.
let pos, vel;
let mode = "притяжение"; // переключаем пробелом
function setup() {
createCanvas(400, 400);
pos = createVector(200, 200);
vel = createVector(0, 0);
}
function draw() {
background(135, 206, 235);
let target = createVector(mouseX, mouseY);
let force = p5.Vector.sub(target, pos);
force.normalize();
force.mult(0.5);
if (mode === "отталкивание") {
force.mult(-1); // ВЕСЬ фокус: разворачиваем силу
}
vel.add(force);
vel.limit(6);
vel.mult(0.98); // лёгкое трение
pos.add(vel);
// цель: зерно или кот
noStroke();
if (mode === "притяжение") {
fill(180, 120, 40);
ellipse(target.x, target.y, 14, 14); // зерно
} else {
fill(90, 90, 90);
ellipse(target.x, target.y, 22, 22); // кот-страшилка
}
// цыплёнок
fill(255, 214, 10);
ellipse(pos.x, pos.y, 40, 40);
}
function keyPressed() {
if (key === ' ') {
mode = (mode === "притяжение") ? "отталкивание" : "притяжение";
}
}
Результат: в режиме «притяжение» жёлтый цыплёнок гоняется за коричневым зерном под курсором. Жмёшь пробел — зерно превращается в серый кружок-кота, и цыплёнок мгновенно меняет поведение: теперь он стремглав удирает от курсора, всегда держась на противоположной стороне холста. Жмёшь пробел ещё раз — снова погоня за зерном.
Обрати внимание: вся разница в строке force.mult(-1). Умножение вектора на -1 разворачивает каждую его компоненту: стрелка, что смотрела на цель, теперь смотрит точно от неё. Скорость накапливается уже в новую сторону — и цыплёнок убегает. Притяжение и отталкивание — это буквально один знак.
Пример 4. Резинка: сила, зависящая от расстояния
До сих пор мы укорачивали стрелку через normalize() и сами назначали ей силу — движение получалось ровным, с одинаковой тягой что вблизи, что вдали. Но иногда нужно наоборот: чтобы цыплёнок был как на резинке. Чем дальше оттянули — тем сильнее тянет назад, а у самой цели тяга почти пропадает. Это поведение пружины, и получается оно, если не нормализовать вектор, а просто пригасить его.
let pos, vel;
let anchor; // точка крепления резинки
function setup() {
createCanvas(400, 400);
anchor = createVector(200, 200);
pos = createVector(80, 80);
vel = createVector(0, 0);
}
function draw() {
background(135, 206, 235);
// стрелка от цыплёнка к точке крепления
let force = p5.Vector.sub(anchor, pos);
force.mult(0.02); // НЕ нормализуем: длинная стрелка = сильная тяга
vel.add(force);
vel.mult(0.95); // трение, иначе будет вечно качаться
pos.add(vel);
// рисуем «резинку»
stroke(120);
strokeWeight(2);
line(anchor.x, anchor.y, pos.x, pos.y);
// точка крепления
noStroke();
fill(80);
ellipse(anchor.x, anchor.y, 10, 10);
// цыплёнок
fill(255, 214, 10);
ellipse(pos.x, pos.y, 40, 40);
}
function mousePressed() {
// оттягиваем цыплёнка туда, куда кликнули
pos = createVector(mouseX, mouseY);
vel = createVector(0, 0);
}
Результат: в центре холста — серая точка крепления, к ней тонкой серой линией привязан жёлтый цыплёнок. Кликаешь в углу — цыплёнок прыгает туда, а потом, как на резинке, упруго возвращается к центру, проскакивает, качается туда-сюда с затухающей амплитудой и замирает на якоре. Чем дальше кликнул — тем резче рывок обратно.
Сравни с примером 2: там мы делали force.normalize() и движение было ровным. Здесь мы убрали нормализацию, и сила стала пропорциональна длине стрелки — то есть расстоянию до якоря. Маленький множитель 0.02 делает резинку мягкой; поставь 0.1 — и она станет тугой и злой. Трение vel.mult(0.95) гасит качания: без него цыплёнок болтался бы вечно, как маятник в вакууме.
Частые ошибки и подводные камни
- Перепутал порядок в
sub. Самая частая беда.p5.Vector.sub(seed, pos)— стрелка к зерну (притяжение).p5.Vector.sub(pos, seed)— стрелка от зерна (отталкивание). Если цыплёнок упорно едет не туда — почти наверняка поменяны местами аргументы. - Забыл
normalize(), и движение дёргается. Без нормализации сила пропорциональна расстоянию: издалека цыплёнок стартует ракетой, вблизи еле ползёт. Иногда такой эффект нужен (это «пружина»), но если хочешь ровную скорость — нормализуй и задавай силу сам черезmult(). - Меняешь
posнапрямую вместо скорости. Если написатьpos.add(force)и забыть проvel, исчезнет инерция: цыплёнок будет жёстко прилипать к курсору, без разгона и орбит. Цепочка должна быть сила → скорость → позиция, а не сила → позиция. - Скорость улетает в бесконечность. Без
vel.limit(...)и без тренияvel.mult(0.98)цыплёнок с каждым кадром разгоняется и в итоге исчезает за краем холста со свистом. Всегда ставь потолок скорости. - Деление на ноль у самой цели. Если вдруг будешь делить силу на расстояние (для эффекта «чем ближе, тем сильнее»), в точке, где цыплёнок ровно на зерне, расстояние равно нулю — и значения взорвутся в
NaN, фигура пропадёт. Защищайся: ограничивай минимальное расстояние, напримерlet d = max(dist, 5);.
Где ты встретишь это снова
Сила, направленная к цели, — это не «школьный пример ради примера», а рабочая лошадка реальных проектов. Камера в платформере, что плавно догоняет героя, — это притяжение точки обзора к персонажу с трением. Подсказка-стрелка, которая в шутере поворачивается к ближайшему врагу, — это вектор цель − позиция. Кнопки, что «отскакивают» от курсора на дизайнерских сайтах, — отталкивание со знаком минус. Даже мягкое подтягивание ленты к ближайшему посту, когда ты отпускаешь палец, живёт на пружинной силе из примера 4. Освоив этот урок, ты будешь узнавать приём повсюду — и сможешь повторить любой такой эффект.
Мини-проект: магнит и его кнопка
Теперь твоя очередь. Собери свою сцену на основе примера 3 и доведи её до ума по шагам — не торопись, делай по одному пункту и каждый раз смотри, что изменилось:
- Сделай так, чтобы цыплёнок тормозил и оседал на зерне в режиме притяжения (подбери трение между 0.90 и 0.99 — найди, при каком он садится мягче всего).
- Добавь три зерна в фиксированных точках. Каждый кадр находи ближайшее к цыплёнку (через
p5.Vector.dist(a, b)) и притягивайся именно к нему. Цыплёнок будет «доедать» зёрна по очереди. - Сделай отталкивание зависящим от расстояния: чем ближе кот, тем сильнее цыплёнок отпрыгивает. Подсказка — раздели силу на расстояние, но не забудь про защиту от деления на ноль из списка ошибок выше.
- Для красоты нарисуй за цыплёнком короткий след: рисуй фон не сплошным, а полупрозрачным (
background(135, 206, 235, 40)), и старые кадры будут красиво растворяться.
Поменяй число в force.mult() и посмотри, что будет: 0.1 — ленивый, задумчивый цыплёнок; 2 — нервный, дёрганый. Найди своё значение характера.
Итоги
Сегодня ты освоил приём, на котором держится половина живых анимаций и игр:
- Направление к цели — это вектор
цель − позиция, в p5.js считается черезp5.Vector.sub(). normalize()оставляет от вектора только направление, а нужную силу ты задаёшь сам черезmult().- Цепочка сила → скорость → позиция даёт живое движение с инерцией и орбитами.
- Притяжение и отталкивание отличаются ровно одним знаком:
force.mult(-1)разворачивает силу. - Потолок скорости (
limit) и трение (mult(0.9..)) — твои инструменты, чтобы движение не взорвалось и красиво затухало.
Дальше — самое вкусное. Когда ты умеешь притягивать и отталкивать одного цыплёнка, ничто не мешает применить те же три правила к целой стае. На следующем уроке мы соберём множество цыплят, заставим их держаться вместе, не сталкиваться и лететь в одном направлении — это знаменитый флокинг (boids), и ты увидишь, как из трёх простых сил рождается живое поведение толпы.