Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~20 мин +50 XP

Частицы и эффекты

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

Содержание урока · 10 разделов

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

Частица: struct с временем жизни#

Каждая частица — запись из шести полей: позиция x и y, скорость vx и vy, остаток жизни life и полный запас ttl. Числа берутся float32: ровно такие ждут функции пакета vector, и конвертации в горячем цикле не нужны. Цвет — color.NRGBA со всеми четырьмя каналами: альфа понадобится для затухания. Позиции и скорости — дробные: целые пиксели дали бы дёрганый рой, а float32 хранит доли без лишней памяти. Пара life и ttl — сердце модели: при рождении они равны, каждый тик логики уменьшает life, а отношение life к ttl даёт долю оставшейся жизни — из неё считаются и прозрачность, и размер. Формулы движения — те же, что в уроке про движение и дельту, только скорость здесь меняется ещё и сама: множитель-сопротивление тормозит частицу, гравитация тянет вниз.

Отдельных типов «частица-взрыв» и «частица-дым» заводить не нужно: различие живёт в стартовых параметрах — скорости, цвете, ttl. Одна структура, разные константы рождения: так таблица параметров в конце урока описывает пять эффектов без единого нового типа. ttl — константа рождения, и хранить его рядом с life выгодно: не нужен параллельный массив с параметрами, а формула альфы остаётся локальной парой полей.

Структура частицы и взрыв

GO
type Particle struct {
        x, y   float32
        vx, vy float32
        life   float32 // остаток жизни, тиков
        ttl    float32 // полный запас жизни
        clr    color.NRGBA
}

func (g *Game) spawnExplosion(x, y float32) {
        for i := 0; i < 24; i++ {
                angle := rand.Float64() * 2 * math.Pi
                speed := rand.Float64()*3 + 1 // от 1 до 4
                g.parts = append(g.parts, Particle{
                        x: x, y: y,
                        vx:  float32(math.Cos(angle) * speed),
                        vy:  float32(math.Sin(angle) * speed),
                        life: 45, ttl: 45,
                        clr: color.NRGBA{R: 255, G: 160, B: 40, A: 255},
                })
        }
}

Частица — маленькая структура из шести полей: позиция, скорость, остаток жизни, полный запас и цвет. ttl равен life при рождении, дальше life тает, и отношение life к ttl — доля оставшейся жизни для альфы. Круговой разлёт считается из случайного угла: косинус и синус переводят его в vx и vy, скорость берётся в диапазоне. append растит срез по мере надобности — на прототипе нормально, пул появится ниже.

Что происходит с частицей, когда life доходит до нуля?

Эмиссия: взрыв и след героя#

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

Источник случайности — math/rand/v2: rand.Float64 даёт дробь от нуля до единицы, rand.IntN — целое в диапазоне, и сидировать генератор не нужно. Правило эмиссии то же, что у всего состояния прошлого урока: спавнит только Update. Диапазоны скоростей задавайте базой и шириной разброса, а не парой магических чисел: поменять ширину — то же самое, что поменять характер эффекта. При роях на много врагов держите бюджет: двадцать частиц на эффект и не больше сотни рождений за тик. Draw, встретив пустой срез, честно ничего не рисует; если взрыв случился — он случился в тике логики, и никакая отрисовка не порождает объекты задним числом.

Обновление: drag, гравитация, жизнь

GO
func (g *Game) updateParticles() {
        alive := g.parts[:0]
        for i := range g.parts {
                p := &g.parts[i]
                p.vx *= 0.95 // drag: сопротивление воздуха
                p.vy += 0.15 // гравитация тянет вниз
                p.x += p.vx
                p.y += p.vy
                p.life--
                if p.life > 0 {
                        alive = append(alive, *p)
                }
        }
        g.parts = alive
}

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

Траектория искры

Прочитайте код и выберите, что он выведет:

// При рождении: vx = 0.5, vy = -3 (вверх)
// Update каждый тик:
p.vx *= 0.95 // drag
p.vy += 0.15 // гравитация
p.x += p.vx
p.y += p.vy

Рендер: альфа по остатку жизни#

Рисование частиц — самый дешёвый код урока: цикл по срезу, счёт альфы, один вызов vector. Доля жизни frac := p.life / p.ttl умножается на 255 и обрезается до uint8 — цвет частицы тает вместе с ней: свежий взрыв плотный оранжевый, умирающий прозрачный. Цвет копируется из записи перед изменением альфы: color.NRGBA — значение, и исходная частица среза не портится. Форма — прямоугольник vector.DrawFilledRect три на три пикселя или круг vector.DrawFilledCircle: для десятков частиц сглаживание выключают, antialias false, потому что каждая истина стоит лишнего прохода.

Размер — второй параметр эффекта: искры три на три, дым восемь на восемь с низкой альфой, и варьируется он константой рядом с ttl, без новых полей. Координаты частиц — мировые: если мир скроллится, применяйте камеру из урока про камеру так же, как для спрайтов, — иначе взрыв останется висеть в углу экрана, пока мир уезжает. Частица не знает про камеру ничего: это свойство отрисовки, а не данных. Здесь работает тот же закон прошлого урока: Draw читает срез, но не пишет ни в одно поле — частицы в кадре не рождаются и не умирают. Затухание тут не зависит от частоты кадров: доля жизни считается из тиков логики, поэтому эффект тает одинаково на 60 и на 144 герцах.

drawParticles: прямоугольники, тающие с жизнью

GO
func (g *Game) drawParticles(screen *ebiten.Image) {
        for i := range g.parts {
                p := &g.parts[i]
                frac := p.life / p.ttl
                clr := p.clr
                clr.A = uint8(255 * frac)
                vector.DrawFilledRect(screen, p.x, p.y, 3, 3, clr, false)
        }
}

Draw только читает срез: одна итерация, никаких изменений. Альфа считается из доли жизни, uint8 принимает 0..255, поэтому 255 умножается на дробь и обрезается. Цвет копируется, чтобы не портить исходную запись среза: color.NRGBA — значение. Прямоугольники три на три без сглаживания — частиц много, и каждая обязана быть дешёвой; для круглых искр есть DrawFilledCircle.

Эффект и его параметры рождения#

ЭффектЧастицСкоростьttl, тиковГравитация
Взрыв врага20-301-4, круговой30-600.1-0.2, оранжевый
След героя1-2 за тик0.1-0.315-250, цвет героя
Искры удара5-82-6, веером10-200.3, жёлтый
Дым двигателя1 за тик0.2 против движения40-80минус 0.05, вверх
Попадание в героя152-5250.15, красный

Где правильно порождать новые частицы взрыва?

Пул частиц: фиксированный срез без аллокаций#

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

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

Правила частиц одним списком#

  • Частица — struct из позиции, скорости, life, ttl и цвета color.NRGBA
  • life рождается равным ttl и тает на тик; доля life к ttl даёт альфу
  • Спавн — событие в Update: столкновение, выстрел, след за движением
  • Случайный разлёт — угол rand.Float64 на два пи и скорость в диапазоне
  • Update двигает: drag множителем, гравитация слагаемым, позиция плюс скорость
  • Draw только читает: альфа по остатку жизни, без изменений среза
  • Пул — фиксированный срез с переиспользованием ячеек: ноль аллокаций в тике
  • Параметры эффекта — константы рождения: скорость, ttl, гравитация, цвет

Соберите отрисовку частиц

Восстановите формулу альфы и имя векторной функции.

func (g *Game) drawParticles(screen *ebiten.Image) { for i := range g.parts { p := &g.parts[i] frac := p.life / p.ttl clr := p.clr clr.A = uint8(255 * ) vector.(screen, p.x, p.y, 3, 3, clr, false) } }

Тик обновления частиц

Соберите updateParticles: физика и жизнь.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Баланс и отладка эффектов#

Числа из таблицы — стартовые, а финальные подбираются игрой: у взрыва должно быть время дочитаться, у следа — не замылить экран. Два ползунка дают девяносто результата: ttl и число частиц. Много короткоживущих частиц читаются как искра, мало долгоживущих — как дым; сбалансированный взрыв — среднее между ними с большим разбросом скорости. Отладке помогает счётчик живых частиц в углу HUD: если после десяти взрывов число не вернулось к нулю, где-то живёт вечная частица — ищите пропуск уменьшения life или отказ от отсеивания.

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

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

Термины урока

Термин

Частица

нажмите, чтобы перевернуть
Определение

Маленькая запись с позицией, скоростью, life, ttl и цветом; из роя таких собираются взрывы, следы и искры.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Частица — struct с позицией, скоростью, life, ttl и цветом; различие эффектов — параметры рождения.
  • Спавн — событие в Update: взрыв за один вызов, след — пара частиц за тик.
  • Update двигает частицы: drag тормозит множителем, гравитация тянет слагаемым, life тает на тик.
  • Альфа считается из доли life к ttl: цвет тает вместе с частицей.
  • Draw только читает срез: частицы не рождаются и не умирают в отрисовке.
  • Пул — фиксированный срез с переиспользованием ячеек: ноль аллокаций, сборщик мусора молчит.
  • Таблица параметров описывает взрыв, след, искры и дым одной структурой без новых типов.

Домашнее задание

Добавьте в проект срез частиц и структуру Particle из урока. Повесьте spawnExplosion на попадание снаряда во врага из урока про коллизии: двадцать частиц, скорость от единицы до четырёх, ttl от тридцати до шестидесяти. Сделайте след героя: одна частица за тик позади кормы при движении, ttl пятнадцать, гравитация ноль. В Draw рисуйте прямоугольники с альфой по доле жизни и следите за счётчиком живых — после серии взрывов он обязан возвращаться к нулю. Затем замените append фиксированным пулом на тысячу ячеек с компоновкой живых на месте и сравните поведение на серии из сотни взрывов. В конце подберите параметры под таблицу урока: каждый эффект должен отличаться от соседнего на глаз, без подписей.

Личные заметкиПусто
Хранятся только в вашем браузере (Ebitengine · Частицы и эффекты)

Вопросы и ответы по уроку

Почему float32, а не float64?

Пакет vector принимает координаты float32: DrawFilledRect и DrawFilledCircle ждут именно его, и конвертации в каждом кадре не нужны. Точности float32 для частиц хватает с запасом: значения порядка сотен пикселей с долями дают погрешность в копейки за всю короткую жизнь частицы. float64 оставьте большим мирам и камере, где накопление ошибки заметнее.

Как сделать взрыв объёмным?

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

Нужны ли частицам тесты?

Логике — да: спавн и обновление — чистые функции над срезом, и тест без окна проверяет, что взрыв рождает заявленное число частиц, а после ttl тиков срез пуст. Рендер тестировать бессмысленно: это цикл по срезу и один вызов vector. Пул заслуживает отдельного теста на переполнение: породите больше максимума и убедитесь, что игра не падает, а старые ячейки переиспользуются.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Chromedp25 уроков~11 чпрактика25 уроков уже доступны

Chromedp в Go: автоматизация Chrome без Selenium

Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.

  • Запускать Chrome из Go и управлять страницами
  • Находить элементы селекторами и ждать их появления
  • Кликать, заполнять формы и извлекать данные
  • Делать скриншоты, PDF и выполнять JavaScript