Притяжение и отталкивание

Сегодня ты научишь цыплёнка чувствовать зерно на расстоянии: вычислишь вектор-стрелку от него к цели и превратишь её в силу, которая мягко тянет 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 и доведи её до ума по шагам — не торопись, делай по одному пункту и каждый раз смотри, что изменилось:

  1. Сделай так, чтобы цыплёнок тормозил и оседал на зерне в режиме притяжения (подбери трение между 0.90 и 0.99 — найди, при каком он садится мягче всего).
  2. Добавь три зерна в фиксированных точках. Каждый кадр находи ближайшее к цыплёнку (через p5.Vector.dist(a, b)) и притягивайся именно к нему. Цыплёнок будет «доедать» зёрна по очереди.
  3. Сделай отталкивание зависящим от расстояния: чем ближе кот, тем сильнее цыплёнок отпрыгивает. Подсказка — раздели силу на расстояние, но не забудь про защиту от деления на ноль из списка ошибок выше.
  4. Для красоты нарисуй за цыплёнком короткий след: рисуй фон не сплошным, а полупрозрачным (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), и ты увидишь, как из трёх простых сил рождается живое поведение толпы.

Проверьте себя
1. Как получить вектор направления от цыплёнка (pos) к зерну (seed)?
Ap5.Vector.sub(pos, seed)
Bp5.Vector.sub(seed, pos)
Cp5.Vector.add(pos, seed)
Dp5.Vector.mult(seed, pos)
2. Что делает с силой строка force.mult(-1)?
AДелает силу в одну сотую слабее
BОбнуляет силу полностью
CРазворачивает силу в противоположную сторону, превращая притяжение в отталкивание
DУдваивает длину вектора
3. Зачем вызывать normalize() перед тем, как задать силу притяжения?
AЧтобы убрать у вектора направление, оставив только длину
BЧтобы укоротить вектор до длины 1 и потом самому задать силу через mult(), сделав движение ровным
CЧтобы цыплёнок мгновенно телепортировался к цели
DЭто обязательная функция p5.js, без неё код не запустится
4. Какая цепочка правильно описывает живое движение с инерцией?
Aсила → позиция
Bпозиция → скорость → сила
Cсила → скорость → позиция
Dскорость → сила → позиция
5. Цыплёнок без остановки разгоняется и улетает за край холста. Что забыли добавить?
AЛишний вызов background()
BПотолок скорости vel.limit(...) и/или трение vel.mult(0.98)
CВторой холст
DФункцию keyPressed()
6. Почему опасно делить силу на расстояние до цели без защиты?
Ap5.js не умеет делить векторы
BКогда цыплёнок ровно на цели, расстояние равно нулю — деление даёт NaN, и фигура пропадает
CЭто слишком медленно и тормозит браузер
DРасстояние всегда отрицательное, поэтому сила сломается