Частицы и эффекты
Один взмах кода — и в момент, когда цыплёнок хватает монетку, из неё брызжет фонтан искр. Сегодня мы заведём систему частиц.
Частицы (particles) — множество мелких короткоживущих спрайтов, из которых складываются эффекты вроде искр, взрывов, пыли и брызг.
Ты уже умеешь рисовать цыплёнка из спрайт-листа — об этом был урок про анимацию по спрайт-листу. И ты умеешь двигать объекты ровно с любым FPS, потому что разобрался с дельта-временем. Сегодня мы соединим оба навыка и добавим игре то, что отличает живой проект от учебного, — сочность. Ту самую, из-за которой хочется собирать монетки снова и снова.
Зачем вообще нужны частицы
Открой в голове любую игру, в которую ты залипал. Подбираешь монетку в Geometry Dash — вспышка. Ломаешь блок в Minecraft — облако осколков. Стреляешь в шутере — искры от пуль, гильзы, дымок. Лопаешь конфетку в три-в-ряд — взрыв из звёздочек. Убери всё это — и игра станет какой-то пустой, будто ты кликаешь по таблице в Excel. Игроку важно чувствовать каждое действие, а не просто видеть, как одно число превратилось в другое.
Этот «сок» геймдизайнеры называют game feel или juice — отдача от действия. И самый дешёвый, самый мощный её источник — частицы. Технически это смешно просто: куча крошечных точек или картинок, которые появляются в момент события, разлетаются в стороны и быстро гаснут. По отдельности — пиксель. Вместе — фейерверк.
К концу урока твой цыплёнок будет собирать монетку, и из неё в стороны брызнет десяток жёлтых искр: они разлетятся веером, чуть притормозят, побледнеют и исчезнут — ровно как в настоящей аркаде. А секрет в том, что вся эта красота — это один массив и один цикл. Поехали разбираться.
И самое приятное: частицы — это не «новая большая тема», ради которой надо ломать структуру игры. Цыплёнок, монетки, проверка столкновений, игровой цикл из прошлых уроков остаются ровно такими же. Мы добавляем один массив, четыре маленькие функции и подцепляем их к событию, которое у тебя уже происходит. Это снова тот случай, когда несколько строк кода дают огромный визуальный скачок, — и именно за такие моменты геймдев и любят.
Главная идея: частица — это просто объект, который живёт по таймеру
Поймай эту мысль, и дальше всё пойдёт как по маслу. Одна частица — это не картинка и не магия, а маленький объект-данные, у которого есть всего несколько полей: где он (x, y), куда летит (vx, vy — помнишь вектор скорости?) и сколько ему осталось жить (life).
Представь горсть искр от бенгальского огня. Каждая искра рождается в одной точке, вылетает в свою сторону, летит по инерции и через долю секунды гаснет. Никто не управляет каждой искрой отдельно — они просто были подброшены с разной скоростью и догорают каждая в своём темпе. Наша система частиц устроена один в один: подбросили пачку, а дальше каждая частица сама летит и сама убывает.
Частица живёт по трём правилам: двигается (x += vx), стареет (life убывает каждый кадр) и умирает (когда life дошёл до нуля — убираем из массива).
Вся система частиц — это три действия, которые ты уже знаешь по игровому циклу. В момент события — создаём пачку частиц и кидаем в общий массив. Каждый кадр в update — двигаем и старим каждую, а отжившие выбрасываем. Каждый кадр в draw — рисуем то, что ещё живо. Всё. Никаких новых концепций, только аккуратная работа с массивом объектов.
Почему именно массив, а не отдельная переменная на каждую искру? Потому что частиц много и их число всё время скачет: то ноль, то двенадцать, то опять ноль. Заводить под каждую отдельную переменную невозможно — мы же не знаем заранее, сколько их будет. Массив идеально решает задачу: новые искры мы кидаем в него через push, отжившие убираем через splice, а перебрать всех живых разом помогает один цикл. По сути ты уже работал так со списком платформ или врагов — здесь тот же приём, просто объектов больше и живут они недолго.
Пример 1. Заводим частицу и массив для них
Сначала опишем, что такое одна частица, и заведём общий массив, куда они будут складываться. Цыплёнок и монетка у нас уже есть с прошлых уроков — мы их не трогаем.
const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
// общий мешок, в котором живут все частицы сразу
let particles = [];
// фабрика одной частицы: где родилась и куда летит
function makeParticle(x, y) {
return {
x: x,
y: y,
vx: (Math.random() - 0.5) * 6, // разлёт по горизонтали: от -3 до +3
vy: (Math.random() - 0.5) * 6, // и по вертикали
life: 1, // полный «заряд» жизни (1 = 100%)
size: 4, // размер искорки в пикселях
};
}Результат: на экране пока ничего не изменилось — мы только описали данные. Главное здесь — Math.random() - 0.5: Math.random() даёт число от 0 до 1, а вычитание 0.5 сдвигает его в диапазон от −0.5 до +0.5. Умножаем на 6 — получаем скорость от −3 до +3. Так каждая частица полетит в свою случайную сторону, и из одной точки выйдет настоящий веер, а не скучный одинаковый залп.
Поле life: 1 — это «полный бак» жизни. Мы будем потихоньку отнимать от него каждый кадр, а когда он опустеет — частица умрёт. Заодно life удобно использовать как прозрачность: пока он близок к 1 — частица яркая, ближе к 0 — почти прозрачная.
Пример 2. Создаём пачку частиц в момент события
Одна искра — это грустно. Эффект рождается, когда из точки вылетает сразу пачка. Заведём функцию, которая в нужном месте подбрасывает целый фонтан, и вызовем её, когда цыплёнок собрал монетку.
// подбрасываем сразу пачку частиц в точке (x, y)
function spawnBurst(x, y, count) {
for (let i = 0; i < count; i++) {
particles.push(makeParticle(x, y));
}
}
// где-то в логике игры: цыплёнок дотронулся до монетки
function onCoinCollected(coin) {
score += 1;
// фонтан из центра монетки
spawnBurst(coin.x + coin.w / 2, coin.y + coin.h / 2, 12);
}Результат: в тот момент, когда цыплёнок касается монетки, в её центре мгновенно рождаются 12 частиц. Пока мы их не двигаем и не рисуем, так что визуально они ещё не видны — но в массиве particles уже лежит 12 свежих искр, готовых разлететься. Заметь: spawnBurst ничего не знает про монетку — ей дают точку и число, и она работает одинаково и для искр от монетки, и для пыли от прыжка, и для взрыва врага. Одна функция — куча разных эффектов.
Где брать момент события
Момент сбора монетки ты уже ловишь через проверку столкновения (помнишь AABB из урока про коллизии). Прямо там, где ты раньше писал «убрать монетку и прибавить очко», теперь добавляется одна строчка — spawnBurst(...). Эффект цепляется к уже существующему событию, ничего переписывать не нужно. Точно так же можно подбросить пыль в момент прыжка или искры в момент удара по врагу.
Пример 3. Обновляем частицы и убираем отжившие
Теперь — сердце системы. Каждый кадр мы проходим по всем частицам: двигаем их по вектору скорости, чуть состариваем и выбрасываем те, у кого life опустел. Тут есть коварная ловушка с удалением из массива, и мы её сразу обойдём правильно.
function updateParticles(dt) {
// идём с КОНЦА массива — так безопасно удалять на ходу
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
// 1. двигаем по вектору скорости (с учётом дельта-времени)
p.x += p.vx;
p.y += p.vy;
// 2. немного гравитации, чтобы искры падали вниз
p.vy += 0.15;
// 3. стареем: отнимаем кусочек жизни каждый кадр
p.life -= dt * 1.5; // примерно за 0.66 секунды жизнь кончится
// 4. умерла — выкидываем из массива
if (p.life <= 0) {
particles.splice(i, 1);
}
}
}Результат: искры из монетки разлетаются веером, чуть провисают вниз из-за гравитации, тормозят и пропадают примерно через полсекунды. Массив particles не растёт бесконечно: сколько частиц родилось, столько через мгновение и убралось. Если открыть отладчик, видно, как длина массива скачет вверх в момент сбора монетки и плавно падает обратно к нулю.
Почему цикл идёт с конца
Это самая важная строчка урока — for (let i = particles.length - 1; i >= 0; i--). Когда ты удаляешь элемент через splice(i, 1), все элементы после него сдвигаются на одну позицию влево. Если бы мы шли с начала вперёд, то после удаления частицы под индексом i следующая заняла бы её место, а цикл перешёл бы к i + 1 — и мы бы перескочили эту сдвинувшуюся частицу, не обновив её. Идя с конца, мы трогаем только уже пройденные индексы, поэтому сдвиг нам не мешает. Запомни этот приём — он работает для любого удаления объектов из массива на ходу: врагов, пуль, монеток.
Пример 4. Рисуем искры и делаем эффект сбора монетки
Осталось показать частицы на экране. Рисуем каждую как маленький круг или квадрат, а поле life используем как прозрачность — так искры будут красиво угасать, а не пропадать рывком.
function drawParticles() {
for (const p of particles) {
// life от 1 до 0 — прямо готовая прозрачность
context.globalAlpha = Math.max(p.life, 0);
context.fillStyle = '#ffd54a'; // тёплый золотой — под цвет монетки
context.beginPath();
context.arc(p.x, p.y, p.size, 0, Math.PI * 2);
context.fill();
}
// ВАЖНО: вернуть прозрачность на место, иначе всё остальное побледнеет
context.globalAlpha = 1;
}
// в общем игровом цикле всё собирается вместе
function loop(dt) {
context.clearRect(0, 0, canvas.width, canvas.height);
updateChicken(dt);
updateParticles(dt); // двигаем и старим искры
drawLevel();
context.drawImage(chickenSprite, chicken.x, chicken.y, chicken.w, chicken.h);
drawParticles(); // рисуем искры поверх сцены
}Результат: цыплёнок добегает до монетки, она исчезает, а на её месте золотым веером брызгают двенадцать искр. Они разлетаются, чуть проседают вниз, бледнеют и тают в воздухе за полсекунды. Сбор монетки превращается из «счёт +1» в маленький праздник — именно та сочность, ради которой мы всё затеяли.
Обрати внимание на context.globalAlpha: это глобальная прозрачность для всего, что рисуется после. Мы ставим её равной life (от 1 до 0), рисуем искру — и обязательно возвращаем 1 в конце. Забудешь вернуть — и весь экран, включая цыплёнка и уровень, начнёт бледнеть вслед за умирающими частицами.
Частые ошибки и подводные камни
Удаляют частицы циклом с начала. Если идти
for (let i = 0; i < particles.length; i++)и внутри делатьsplice(i, 1), ты будешь пропускать каждую вторую частицу: после удаления соседи сдвигаются, а индекс едет дальше. Искры начнут «застревать» и не исчезать. Всегда удаляй, идя с конца массива.Забыли вернуть globalAlpha в 1. Самый частый визуальный баг. Ты выставил прозрачность для искры и не сбросил её — и теперь весь следующий кадр рисуется полупрозрачным: цыплёнок, платформы, фон мерцают и бледнеют. Правило: меняешь
globalAlpha— в конце возвращай на1.Все частицы летят в одну сторону. Если задать всем одинаковую
vxиvyбезMath.random(), получится не фонтан, а скучная палка из одинаковых точек. Случайный разлёт — то, что делает эффект живым. Добавь хаоса в скорость, размер и время жизни.Частицы не умирают и копятся. Если забыть уменьшать
lifeили не выкидывать мёртвых черезsplice, массивparticlesбудет расти бесконечно, FPS поползёт вниз и игра начнёт лагать. Каждая частица обязана стареть и в какой-то момент покидать массив.Рисуют искры под уровнем. Если вызвать
drawParticles()до того, как нарисованы платформы и фон, эффект спрячется за ними. Частицы — это украшение поверх сцены, поэтому рисуй их в самом конце кадра, после всех объектов.
Мини-проект: добей систему частиц сам
База готова — у тебя уже искрит. Теперь три апгрейда, которые превратят учебную систему в настоящую. Делай по шагам, после каждого смотри, что изменилось на экране.
Облачко пыли при прыжке. Помнишь, у цыплёнка есть прыжок? В момент отрыва от земли вызови
spawnBurst(chicken.x + chicken.w / 2, chicken.y + chicken.h, 6), но сделай частицы серыми и медленными, чтобы получилась пыль из-под ног, а не искры. Подсказка: заведи вmakeParticleаргумент цвета и уменьши разброс скорости.Случайный размер и срок жизни. Сейчас все искры одного размера и гаснут синхронно. Сделай
sizeи стартовыйlifeчуть случайными (например,size: 2 + Math.random() * 3). Эффект сразу станет богаче — настоящие искры все разные.Затухающий цвет. Сделай так, чтобы искра меняла цвет по мере угасания: пока молодая — белая, к концу жизни — оранжевая. Подсказка: выбирай
fillStyleв зависимости от того, большеp.lifeпорога 0.5 или меньше.
Если все три пункта заработали — поздравляю, у тебя система частиц уровня настоящей инди-игры. Покрути цыплёнком, насобирай монеток, попрыгай — и полюбуйся, как игра ожила.
Итоги
Сегодня ты добавил игре сочность — то, что заставляет хотеть собирать монетки снова. Главное, что стоит унести с собой:
Частица — это объект с тремя главными полями: позиция (x, y), вектор скорости (vx, vy) и запас жизни (life).
Эффект — это пачка частиц, подброшенная в момент события одной функцией
spawnBurst, которую можно переиспользовать для искр, пыли и взрывов.Каждый кадр частицы двигаются, стареют (
life -= dt) и умирают — а удалять их из массива нужно циклом с конца, иначе пропустишь соседей.Поле life — готовая прозрачность: ставим
globalAlpha = life, чтобы искры красиво угасали, и обязательно возвращаемglobalAlpha = 1.
В следующем уроке мы добавим к этой картинке звук: тот же момент сбора монетки, который сейчас брызжет искрами, зазвучит коротким «дзынь». Эффект для глаз у тебя уже есть — осталось добавить эффект для ушей, и тогда сбор монетки станет по-настоящему вкусным. До встречи, твой цыплёнок уже копит монетки!