Эмиттеры и жизненный цикл

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

Зачем частице вообще умирать

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

Искры никуда не деваются. Они либо висят на экране вечно, либо улетают за край и продолжают жить там, невидимые, но всё ещё занимающие место в массиве. Добавляешь новые — массив пухнет. Сто искр, тысяча, десять тысяч. p5.js честно перебирает их все каждый кадр, скетч тормозит, ноутбук гудит как взлетающий самолёт. Знакомая история? Так же тормозит игра, в которой накопилось слишком много объектов, которые никто не убрал.

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

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

Жизнь частицы — как заряд батареи телефона

Представь заряд батареи в телефоне. Утром — сто процентов. В течение дня он капает вниз: чуть-чуть на каждое действие. Дошло до нуля — телефон гаснет. Жизнь частицы устроена ровно так же.

Заведём у каждой частицы поле life — это её «заряд». Договоримся, что полная жизнь — это 255. Число не случайное: оно идеально совпадает с максимумом прозрачности (alpha), четвёртого параметра цвета. Это позволяет одним и тем же числом управлять сразу двумя вещами:

  • Сколько частице осталось жить. Пока life больше нуля — частица живёт. Дошло до нуля — пора удалять.
  • Насколько она видна. Подставляем life прямо в alpha заливки. Полная жизнь (255) — частица яркая и плотная. Жизнь на исходе (около нуля) — она еле различима, призрак. Так частица не исчезает резко, а плавно растворяется, будто выдох на морозе.
Главный приём урока: одно число life отвечает и за «жива ли частица», и за «насколько она прозрачна». Стареет — значит, бледнеет. Поэтому смерть выглядит не как обрыв, а как мягкое угасание.

А кто рождает частицы? Эмиттер — это просто точка на холсте (а в нашем случае — сам цыплёнок), которая на каждом кадре подбрасывает в массив одну-две свежие частицы с полным зарядом. Старые в это время тихо угасают и уходят. Получается вечный конвейер: на одном конце рождение, на другом — смерть, а в массиве всегда примерно одинаковое число живых частиц. Никакого бесконечного роста.

Разбираем на примерах

Пример 1. Одна частица, которая стареет

Прежде чем плодить стаю, разберёмся с одной-единственной частицей. Дадим ей жизнь и заставим бледнеть.

let life = 255; // полный заряд
let x = 200;
let y = 300;

function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(30, 30, 40);
  noStroke();

  y = y - 1;      // частица медленно всплывает
  life = life - 2; // и стареет на 2 единицы за кадр

  fill(255, 255, 255, life); // 4-й параметр — это life!
  circle(x, y, 30);
}

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

Смотри, что происходит. Каждый кадр мы делаем два маленьких шага. y = y - 1 поднимает частицу на пиксель вверх (у нас экранный игрек растёт вниз, значит, вычитание — это движение вверх). life = life - 2 отщипывает от заряда две единицы. А ключевая строка — fill(255, 255, 255, life): четвёртым параметром мы суём туда же life. Пока заряд высокий, облачко плотное; заряд падает — облачко бледнеет. Когда life уходит в ноль и ниже, alpha становится нулевой, и частицу уже не видно.

Но есть нюанс: частица невидима, однако код всё ещё гоняет её каждый кадр. life уже −300, а мы продолжаем считать. Для одной частицы это пустяк, а для тысячи — та самая тормозящая беда. Значит, мёртвых надо не просто прятать, а по-настоящему удалять. Этим и займёмся.

Пример 2. Частица как объект и метод isDead()

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

class Particle {
  constructor(x, y) {
    this.pos = createVector(x, y);
    this.vel = createVector(random(-1, 1), random(-2, -0.5)); // вверх и чуть в стороны
    this.life = 255; // полный заряд
  }

  update() {
    this.pos.add(this.vel);
    this.life = this.life - 4; // стареет
  }

  show() {
    noStroke();
    fill(255, 255, 255, this.life); // прозрачность = жизнь
    circle(this.pos.x, this.pos.y, 16);
  }

  isDead() {
    return this.life < 0; // true, когда заряд кончился
  }
}

Результат: сам по себе класс ничего не рисует — это чертёж. Но теперь у каждой частицы есть всё своё: pos (где она), vel (куда летит, со случайным наклоном вверх), life (сколько осталось). И три понятных умения: update() — прожить кадр, show() — нарисоваться с учётом прозрачности, isDead() — честно ответить «я уже всё?».

Разберём метод isDead() — он сегодня главный герой. Он не делает ничего сложного: возвращает true, если this.life < 0, и false, пока заряд ещё держится. Это лакмусовая бумажка: спрашиваешь у частицы «ты мертва?» — она отвечает да или нет. А решать, что делать с мёртвой, будет уже эмиттер.

Пример 3. Эмиттер: рождаем и удаляем мёртвых

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

let particles = [];

function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(30, 30, 40);

  // 1. ЭМИТТЕР: рождаем новые частицы у курсора
  particles.push(new Particle(mouseX, mouseY));
  particles.push(new Particle(mouseX, mouseY));

  // 2. обновляем и рисуем всех — идём С КОНЦА массива
  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    particles[i].show();

    // 3. мёртвых вынимаем из массива
    if (particles[i].isDead()) {
      particles.splice(i, 1);
    }
  }
}

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

Тут три части, и каждая важна. Первая — две строки particles.push(...): это и есть эмиттер, он каждый кадр подсыпает в массив две свежие частицы прямо в точку курсора. Вторая — цикл, который обновляет и рисует каждую. Третья — проверка isDead() и splice(i, 1): эта команда вырезает из массива один элемент с индексом i. Мёртвую — за борт.

Обрати особое внимание на странность: цикл идёт задом наперёд, от particles.length - 1 к нулю. Это не каприз, а защита от коварного бага — про него отдельно в подводных камнях. Пока запомни как мантру: удаляешь из массива в цикле — иди с конца.

Пример 4. Дымок за цыплёнком

Соберём всё ради чего затевали. Пусть эмиттером будет не курсор, а сам бегущий цыплёнок: куда он, туда и шлейф дыма.

let particles = [];
let chickX = 0;
let chickY = 250;

function draw() {
  background(135, 206, 235); // небо

  chickX = chickX + 2;          // цыплёнок бежит вправо
  if (chickX > width) chickX = 0; // и снова слева

  // ЭМИТТЕР у хвоста цыплёнка
  particles.push(new Particle(chickX - 25, chickY));

  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    particles[i].show();
    if (particles[i].isDead()) {
      particles.splice(i, 1);
    }
  }

  // сам цыплёнок поверх дыма
  noStroke();
  fill(255, 221, 51);
  circle(chickX, chickY, 50);
  fill(255, 140, 0);
  triangle(chickX + 22, chickY, chickX + 38, chickY - 5, chickX + 38, chickY + 5);
}

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

Единственное, что изменилось по сравнению с примером 3, — координата эмиттера. Вместо mouseX, mouseY мы рождаем частицы у хвоста: chickX - 25, chickY. Эмиттер «приклеен» к движущемуся герою. Вся остальная машинерия — рождение, старение, угасание, удаление мёртвых — работает один в один. В этом и красота: один раз написал жизненный цикл, а дальше просто переставляешь точку рождения куда захочешь.

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

Жизненный цикл частиц — место, где новички спотыкаются особенно часто и обидно. Разберём грабли по порядку.

1. Цикл по массиву идёт вперёд, а ты удаляешь элементы

Самая коварная ошибка. Кажется логичным писать обычный цикл for (let i = 0; i < particles.length; i++) и внутри вызывать splice. Но как только ты удалил элемент i, все элементы после него сдвигаются на одну позицию влево. Счётчик i++ прыгает на следующий индекс — и одну частицу ты перескочил, не обработав. На экране это выглядит как мерцание: часть частиц моргает или удаляется через раз.

Лекарство простое и его надо просто запомнить: когда удаляешь из массива внутри цикла, иди с конца к началуfor (let i = particles.length - 1; i >= 0; i--). Тогда сдвиг происходит позади курсора, среди уже обработанных элементов, и ничего не теряется.

2. Забыли удалять мёртвых — массив пухнет

Если оставить update() и show(), но выкинуть проверку isDead(), частицы перестанут исчезать из массива. Видно их уже не будет (alpha ушла в ноль), но p5.js продолжит обновлять и рисовать тысячи невидимых призраков каждый кадр. Сначала скетч просто чуть подлагивает, потом начинает заметно тормозить, а через минуту превращается в слайд-шоу. Правило: родил — позаботься похоронить. Эмиттер и чистильщик всегда работают в паре.

3. Прозрачность не работает, потому что фон не перерисовывается

Частица тает за счёт alpha, но если в начале draw() нет background(...), старые кадры не стираются. Полупрозрачные облачка накладываются друг на друга слой за слоем и в сумме дают плотную белую кашу вместо нежного дыма. Угасание просто не видно. Всегда начинай draw() с заливки фона — это стирает прошлый кадр начисто.

4. life уменьшается слишком быстро или слишком медленно

Шаг старения — это вкусовая настройка, и её легко проскочить. Вычитаешь по 20 за кадр — частицы гаснут почти мгновенно, дым выглядит рваным и жидким. Вычитаешь по 0.3 — живут так долго, что их накапливается тьма, и эффект превращается в плотное молоко. Покрути это число руками: начни с 4, как у нас, и подгоняй под вкус. Помни связь: чем дольше life, тем больше живых частиц одновременно в массиве.

5. Проверяют life > 0 вместо life < 0 при alpha

Тонкость: alpha не уходит «в минус» — значения ниже нуля p5.js просто считает нулём. Поэтому частица с life от 0 до примерно −4 уже невидима, но формально ещё «жива», если ты проверяешь life <= 0 слишком строго. Ничего страшного не ломается, но иногда удобнее дать частице досчитать чуть за ноль (isDead() через life < 0), чтобы наверняка пройти полное угасание до прозрачной. Главное — будь последователен: где уменьшаешь, там и проверяй один и тот же порог.

Мини-проект: костёр под цыплёнком

Пора собрать свой эмиттер. Возьми за основу пример с дымком и преврати его в живой костерок, у которого греется цыплёнок. Чек-лист:

  1. Поставь цыплёнка неподвижно по центру снизу, а эмиттер — прямо перед ним, где «горит огонь». Каждый кадр рождай 3–4 частицы из одной точки.
  2. Огненный цвет. Вместо белого сделай частицы тёплыми: fill(255, random(120, 200), 0, this.life) — смесь красного, случайного зелёного и нуля синего даст оттенки от красного до оранжевого. Огонь заиграет.
  3. Разлёт. Задай скорость так, чтобы искры летели вверх и чуть в стороны (вспомни createVector(random(-1, 1), random(-3, -1))), — пламя станет узким снизу и широким сверху.
  4. Размер от жизни. Сделай диаметр частицы зависящим от life: молодая искра крупнее, угасающая — мельче. Подставь в circle() что-то вроде this.life / 20 вместо фиксированного числа.
  5. Бонус — ветер. Добавь всем частицам каждый кадр крошечный сдвиг вбок (this.pos.x += 0.3), и пламя «поведёт» в сторону, будто дует ветер. Поиграй с величиной и знаком.

Запусти и поменяй пару чисел — скорость старения, цвет, разлёт. Почувствуй, как одни и те же три шага (родить, состарить, удалить мёртвых) дают то дым, то огонь, то искры. Ты только что построил настоящий эмиттер, на котором держатся эффекты в любой игре.

Итоги

Сегодня твои частицы научились жить и умирать по-настоящему. Главное:

  • Поле life — это заряд частицы. Полная жизнь — 255, чтобы то же число шло в alpha. Каждый кадр уменьшаем его: частица стареет и одновременно бледнеет.
  • Угасание через прозрачность делает смерть мягкой: fill(255, 255, 255, life) — и частица растворяется, а не исчезает рывком.
  • Мёртвых надо удалять из массива, а не просто прятать. Метод isDead() отвечает «жива ли я», а splice(i, 1) вынимает покойника — иначе массив пухнет и скетч тормозит.
  • Удаляешь в цикле — иди с конца: for (let i = particles.length - 1; i >= 0; i--), иначе после splice перескочишь соседнюю частицу.
  • Эмиттер — точка, которая каждый кадр подбрасывает новые частицы. Перенеси её к цыплёнку — получишь шлейф, к огню — пламя, к месту взрыва — искры.

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

Проверьте себя
1. Зачем частице поле life и почему его удобно делать равным 255 в начале?
A255 — это максимум alpha, поэтому одним числом управляешь и жизнью, и прозрачностью частицы
B255 — это максимальное число кадров в секунду
CЭто случайное число, можно взять любое другое без последствий
D255 задаёт радиус частицы в пикселях
2. Что произойдёт, если обновлять и рисовать частицы, но не удалять мёртвых из массива?
AНевидимые частицы продолжат накапливаться, и скетч постепенно начнёт тормозить
BЧастицы станут ярче с каждым кадром
Cp5.js сам удалит их через секунду
DНичего, мёртвые частицы не влияют на скорость
3. Почему цикл для обновления и удаления частиц идут с конца массива к началу?
AПосле splice элементы сдвигаются влево, и при ходе вперёд счётчик перескочил бы соседнюю частицу
BС конца цикл работает быстрее
Cp5.js рисует частицы только в обратном порядке
DИначе частицы рисуются вверх ногами
4. Что такое эмиттер в системе частиц?
AТочка, которая каждый кадр рождает новые частицы и добавляет их в массив
BФункция, которая удаляет мёртвые частицы
CЦвет, которым закрашивается частица
DМассив, хранящий все частицы
5. Дым из частиц превратился в плотную белую кашу, и угасание не видно. Что забыли?
AПерерисовывать фон через background() в начале draw()
BЗадать частицам скорость
CСоздать массив particles
DВызвать createCanvas()
6. Что делает метод isDead() у частицы?
AВозвращает true, когда life опустилась ниже нуля, — сигнал, что частицу пора удалить
BУменьшает life частицы на каждом кадре
CРисует частицу с учётом прозрачности
DСоздаёт новую частицу в массиве