Система частиц: фейерверк

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

Зачем тебе целая толпа частиц

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

В прошлом уроке ты собрал класс Particle: у него были методы update() (сдвинуть и чуть погасить) и show() (нарисовать кружок). Ты создавал одну частицу, она летела и угасала — мило, но одиноко. Одна искра — это не салют, это грустная спичка над цыплёнком.

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

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

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

Главная идея: журнал частиц и команда «все сразу»

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

Разложим по полочкам, что вообще должна уметь система частиц:

  • Хранить много частиц. Заводим массив, например let particles = []; — пока пустой журнал.
  • Рождать новые частицы. Когда нужно (по клику, каждый кадр, при событии), создаём объект new Particle(...) и кладём его в массив через particles.push(...) — «записать нового ученика в журнал».
  • Обновлять и рисовать всех. В draw() пробегаем по массиву циклом и для каждой частицы зовём update() и show() — «все обновились, все нарисовались».
  • Хоронить мёртвых. Искра не должна жить вечно: когда её запас жизни кончился, мы вынимаем её из массива, чтобы журнал не разрастался до бесконечности. Об этом будет отдельный разговор — это место, где спотыкаются почти все.

Заметь красоту подхода: твой код ни в одном месте не привязан к конкретному числу искр. Ты нигде не пишешь «нарисуй частицу номер 1, потом номер 2, потом номер 3». Ты говоришь циклу «пройдись по всем, кто есть в массиве» — и он сам разберётся, сколько их сейчас: ноль, десять или пятьсот. Добавил искр — цикл стал длиннее. Убрал — короче. Один и тот же код тянет и одинокую искорку, и грандиозный салют. Именно поэтому профессиональные эффекты в играх устроены ровно так же, как твой учебный скетч, — отличается лишь количество и красота отдельной частицы.

Запомни ритм системы частиц: рождаем — кладём в массив — в цикле обновляем и рисуем. Этот же ритм потом приведёт нас к дыму, стае и снегу. Меняется только то, как выглядит и живёт одна частица, а каркас остаётся.

Освежаем класс Particle

Чтобы дальше говорить предметно, держим перед глазами частицу из прошлого урока — слегка причешем её под фейерверк. Главное новшество — поле life (запас жизни), которое тает с каждым кадром.

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

  update() {
    this.pos.add(this.vel); // летим
    this.life -= 4;          // потихоньку гаснем
  }

  show() {
    noStroke();
    fill(255, 220, 60, this.life); // жёлтая искра, alpha = life
    circle(this.pos.x, this.pos.y, 8);
  }
}

Результат: сам по себе этот код ничего не рисует — это чертёж одной искры. Но он уже умеет всё, что нужно: помнит, где находится (pos), куда летит (vel) и сколько ей осталось жить (life). Поле life мы хитро используем дважды: и как прозрачность искры (четвёртый параметр fill), и как таймер её жизни. Когда life дойдёт до нуля — искра станет полностью прозрачной и исчезнет.

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

Пример 1. Фонтан искр из массива

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

let particles = [];

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

function draw() {
  background(20, 24, 60); // тёмное ночное небо

  // 1. рождаем новую искру и кладём в массив
  particles.push(new Particle(200, 300));

  // 2. в цикле обновляем и рисуем КАЖДУЮ
  for (let i = 0; i < particles.length; i++) {
    particles[i].update();
    particles[i].show();
  }
}

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

Разберём по косточкам. Массив particles объявлен снаружи функций, чтобы жить между кадрами и не обнуляться. В setup() мы его не трогаем — он стартует пустым. Главное происходит в draw(): сначала particles.push(new Particle(200, 300)) добавляет в конец массива свежую искру с центром (200, 300). Потом цикл for идёт по индексам от 0 до particles.length (длина массива — сколько искр сейчас в журнале) и для каждой зовёт сперва update() (сдвинуть и погасить), затем show() (нарисовать). Обрати внимание: цикл не знает заранее, сколько частиц — он каждый кадр спрашивает particles.length. Добавили искру — цикл стал на один шаг длиннее. Вот она, сила связки «массив + цикл».

Пример 2. Взрыв по клику — настоящий фейерверк

Фонтан красивый, но фейерверк — это залп: много искр рождается разом в одной точке. Сделаем так, чтобы по клику мышкой в месте клика вспыхивал сноп из полусотни искр.

let particles = [];

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

function draw() {
  background(20, 24, 60);

  for (let i = 0; i < particles.length; i++) {
    particles[i].update();
    particles[i].show();
  }
}

function mousePressed() {
  // залп: рождаем сразу 50 искр в точке клика
  for (let i = 0; i < 50; i++) {
    particles.push(new Particle(mouseX, mouseY));
  }
}

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

Здесь два цикла в двух разных местах, и важно их не путать. Цикл рождения живёт в mousePressed() — это встроенная функция p5.js, которая срабатывает один раз на каждый клик мышью. Внутри неё мы пятьдесят раз делаем push(new Particle(mouseX, mouseY)), где mouseX и mouseY — координаты курсора в момент клика. Так за один клик в массив прилетает сразу полсотни искр из одной точки. А цикл показа по-прежнему в draw() и каждый кадр обновляет-рисует всех, кто сейчас в массиве. Рождение и показ разнесены: mousePressed() наполняет журнал, draw() крутит его кадр за кадром.

Пример 3. Фейерверк над цыплёнком + уборка мёртвых искр

Соберём всё вместе и добавим героя. Внизу сидит цыплёнок, а ты салютуешь в его честь. И решим важную проблему: мёртвые искры (у которых life упал до нуля) надо убирать из массива, иначе он будет расти бесконечно и скетч начнёт тормозить.

let particles = [];

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

function draw() {
  background(20, 24, 60);

  drawChick(); // наш герой снизу

  // идём ОТ КОНЦА к началу, чтобы безопасно удалять
  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update();
    particles[i].show();
    if (particles[i].life <= 0) {
      particles.splice(i, 1); // выкинуть мёртвую искру
    }
  }
}

function mousePressed() {
  for (let i = 0; i < 50; i++) {
    particles.push(new Particle(mouseX, mouseY));
  }
}

function drawChick() {
  noStroke();
  fill(255, 221, 51);          // жёлтое тело
  circle(200, 360, 70);
  fill(255, 140, 0);           // оранжевый клюв смотрит вверх
  triangle(200, 330, 194, 345, 206, 345);
}

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

Ключевая хитрость этого примера — цикл задом наперёд: for (let i = particles.length - 1; i >= 0; i--). Почему не как обычно, с нуля вперёд? Потому что мы удаляем элементы прямо во время прохода методом splice(i, 1) («вырезать 1 элемент начиная с индекса i»). Когда удаляешь элемент, все, кто стоял правее, сдвигаются на одну позицию влево. Если идти вперёд, после удаления ты перескочишь через соседа и пропустишь его. А двигаясь от конца к началу, ты трогаешь только те индексы, что уже позади, — сдвиг впереди тебя не задевает. Это золотое правило: удаляешь из массива в цикле — иди с конца. Функцию drawChick() мы вынесли отдельно просто для чистоты: рисуем героя, а вся возня с частицами остаётся в своём цикле.

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

Системы частиц спотыкаются об удивительно одинаковые грабли. Пробеги глазами — сэкономишь себе час отладки.

1. Объявили массив внутри draw()

Классика. Человек пишет let particles = []; прямо в начале draw().

function draw() {
  let particles = []; // обнуляется КАЖДЫЙ кадр!
  particles.push(new Particle(200, 300));
  // ...рисуем...
}

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

2. Забыли убирать мёртвые частицы

Если не делать splice(), искры с нулевой жизнью остаются в массиве навсегда — невидимые (прозрачные), но цикл всё равно их обновляет и рисует каждый кадр. Через минуту в журнале тысячи призраков, и скетч начинает заметно тормозить и дёргаться. Лекарство — проверять life <= 0 и вырезать такие искры, как в примере 3.

3. Удаляют в цикле, который идёт вперёд

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

3+. push() кладёт ОДНУ искру, когда хотел залп

Чтобы получить взрыв, push надо вызвать в цикле много раз. Если написать particles.push(new Particle(mouseX, mouseY)) один раз в mousePressed(), по клику родится всего одна искорка — грустный «фейерверк» из единственной точки. Заверни push в цикл for (let i = 0; i < 50; i++), и получишь полноценный сноп.

4. Перепутали, где рождать, а где рисовать

Если случайно засунуть push в draw() вместо mousePressed(), искры будут литься непрерывно, а не по клику. А если, наоборот, цикл показа окажется в mousePressed(), частицы нарисуются один раз и замрут. Держи в голове разделение труда: рождение — там, где наступает событие; обновление и показ — всегда в draw().

Мини-проект: салют в честь цыплёнка

Теперь твоя очередь. Возьми пример 3 за основу и доведи салют до праздничного. Чек-лист:

  1. Запусти базу: цыплёнок снизу, клик рождает залп из 50 искр, мёртвые искры убираются. Убедись, что после десятка залпов скетч не тормозит.
  2. Разноцветные искры. Передавай в конструктор Particle ещё и цвет (например, случайный из набора праздничных) и используй его в fill(). Один залп — один цвет, как у настоящего салюта.
  3. Гравитация. Добавь искрам лёгкое притяжение вниз: в update() каждый кадр чуть увеличивай this.vel.y (например, на 0.1). Искры будут красиво осыпаться вниз, к цыплёнку, а не разлетаться идеальным шаром.
  4. Авто-салют. Сделай, чтобы раз в пару секунд (по frameCount) залп запускался сам в случайной точке неба — даже когда ты не кликаешь.
  5. Супербонус — реакция цыплёнка. Пусть в момент залпа цыплёнок чуть подпрыгивает или его клюв ещё выше тянется к небу. Герой радуется салюту в свою честь.

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

Итоги

Сегодня ты перешёл от одной частицы к управляемой толпе. Главное:

  • Система частиц — это массив объектов плюс цикл, который обновляет и рисует всех сразу. Размер толпы коду безразличен.
  • Рождение — это particles.push(new Particle(...)): добавить новую частицу в журнал. Для залпа зови push в цикле много раз.
  • Показ — это цикл по массиву в draw(), где для каждой частицы вызываешь update() и show().
  • Мёртвые частицы надо убирать через splice(), а цикл с удалением вести от конца к началу, иначе пропустишь соседей.
  • Поле вроде life служит и таймером жизни, и прозрачностью — частица угасает естественно.

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

Проверьте себя
1. Из чего в основе состоит система частиц в p5.js?
AИз массива объектов-частиц, которые в цикле обновляются и рисуются
BИз одной частицы, нарисованной много раз подряд
CИз готовой функции p5.js fireworks()
DИз картинки салюта, загруженной с диска
2. Как добавить новую частицу в массив particles?
Aparticles.push(new Particle(x, y))
Bparticles.add(new Particle(x, y))
Cparticles = new Particle(x, y)
Dnew particles[Particle(x, y)]
3. Почему массив particles объявляют снаружи функций, а не внутри draw()?
AЧтобы он жил между кадрами и копил частицы, а не обнулялся каждый кадр
BЧтобы код был короче
CИначе p5.js выдаст ошибку компиляции
DЧтобы частицы рисовались поверх фона
4. Почему при удалении частиц через splice() цикл ведут от конца массива к началу?
AПотому что удаление сдвигает элементы влево, и при ходе вперёд можно пропустить соседа
BПотому что так цикл работает быстрее
CПотому что splice() работает только с конца массива
DЭто не важно, можно идти как угодно
5. Что произойдёт, если никогда не убирать частицы с life <= 0?
AМассив будет расти бесконечно, и скетч начнёт тормозить
BЧастицы станут ярче
CЦыплёнок перестанет рисоваться
DНичего, p5.js удалит их сам
6. Где правильно рождать залп искр по клику мыши?
AВ функции mousePressed(), вызвав push в цикле много раз
BВ setup(), один раз при старте
CВ конструкторе класса Particle
DВ цикле показа внутри draw()