Содержание урока · 10 разделов
Пуля попала во врага — и за один кадр на экране должен разлететься взрыв, а герой обязан оставлять за собой след. Спрайтами такое не делают: картинок на каждый случай не напасёшься. Частицы решают вопрос математикой: маленький прямоугольник с позицией, скоростью и временем жизни, размноженный два-три десятка раз со случайным разбросом, глаз читает как взрыв. Частицы игры — не библиотека и не подсистема движка, а обычный срез ваших структур: Ebitengine даёт тик и холст, всё остальное — цикл по срезу. Урок собирает полный мини-эмиттер: рождение частиц по событию из урока про коллизии, обновление с гравитацией и сопротивлением, отрисовку с затуханием альфы и пул, после которого эффекты перестают стоить аллокаций. Дешевизна — главный аргумент: частица не тянет ни текстуры, ни коллизий, ни кадров анимации, а мощность эффекта масштабируется числом записей в срезе.
Частица: struct с временем жизни#
Каждая частица — запись из шести полей: позиция x и y, скорость vx и vy, остаток жизни life и полный запас ttl. Числа берутся float32: ровно такие ждут функции пакета vector, и конвертации в горячем цикле не нужны. Цвет — color.NRGBA со всеми четырьмя каналами: альфа понадобится для затухания. Позиции и скорости — дробные: целые пиксели дали бы дёрганый рой, а float32 хранит доли без лишней памяти. Пара life и ttl — сердце модели: при рождении они равны, каждый тик логики уменьшает life, а отношение life к ttl даёт долю оставшейся жизни — из неё считаются и прозрачность, и размер. Формулы движения — те же, что в уроке про движение и дельту, только скорость здесь меняется ещё и сама: множитель-сопротивление тормозит частицу, гравитация тянет вниз.
Отдельных типов «частица-взрыв» и «частица-дым» заводить не нужно: различие живёт в стартовых параметрах — скорости, цвете, ttl. Одна структура, разные константы рождения: так таблица параметров в конце урока описывает пять эффектов без единого нового типа. ttl — константа рождения, и хранить его рядом с life выгодно: не нужен параллельный массив с параметрами, а формула альфы остаётся локальной парой полей.
Структура частицы и взрыв
GOtype 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, гравитация, жизнь
GOfunc (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: прямоугольники, тающие с жизнью
GOfunc (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-30 | 1-4, круговой | 30-60 | 0.1-0.2, оранжевый |
| След героя | 1-2 за тик | 0.1-0.3 | 15-25 | 0, цвет героя |
| Искры удара | 5-8 | 2-6, веером | 10-20 | 0.3, жёлтый |
| Дым двигателя | 1 за тик | 0.2 против движения | 40-80 | минус 0.05, вверх |
| Попадание в героя | 15 | 2-5 | 25 | 0.15, красный |
Где правильно порождать новые частицы взрыва?
Пул частиц: фиксированный срез без аллокаций#
append на прототипе честен, но у него есть цена: рост среза аллоцирует новый массив, старый уходит сборщику мусора, и на сотнях частиц за тик GC напоминает о себе паузами. Решение — пул: фиксированный срез на максимум частиц, например тысячу, и индекс живого диапазона. Мёртвые записи не удаляются, а переиспользуются: спавн берёт следующую ячейку и перезаписывает её поля, компоновка живых делается обменом с последним элементом диапазона. Аллокаций ноль после старта — срез создан один раз, и сборщику мусора нечего забирать. Реализация пула начинается с make на максимум частиц при старте игры: ёмкость выделена один раз, и дальше ни один спавн не трогает аллокатор; спавн пишет в следующую ячейку живого диапазона, а отсеивание обменивает мёртвую ячейку с последней живой и укорачивает диапазон — обе операции это присваивание пары полей.
Компромисс пула — потолок: частиц больше максимума не бывает, и spawnExplosion при переполнении либо пропускает рождение, либо перезаписывает самую старую запись. Для аркады потолок в тысячу — не ограничение, а защита: даже залипшая на коллизии пара не съест память. Подробный разбор стоимости аллокаций и пауз ждёт в уроке про производительность — там же профилировщик покажет, сколько выигрывает пул. Пока запомните правило: горячий цикл без аллокаций — это фиксированный срез, а не append.
Симптом легко пропустить: на машине разработчика всё летает, а на слабом ноутбуке раз в несколько секунд кадр дёргается — сборщик мусора собирает тысячу умерших массивов. Причина — append к срезу частиц каждый тик без ёмкости: рост, копирование, мусор. Второй симптом — вечная частица: мёртвые записи забыли отсеять, срез растёт до бесконечности, Draw честно рисует невидимое. Лечится оба раза одинаково: фиксированный пул, компоновка на месте, длина среза равна числу живых. Третий симптом тише всех: память в диспетчере задач растёт между двумя взрывами — срез не вернулся к прежней длине, значит, где-то пережили мёртвые записи.
Правила частиц одним списком#
- Частица — struct из позиции, скорости, life, ttl и цвета color.NRGBA
- life рождается равным ttl и тает на тик; доля life к ttl даёт альфу
- Спавн — событие в Update: столкновение, выстрел, след за движением
- Случайный разлёт — угол rand.Float64 на два пи и скорость в диапазоне
- Update двигает: drag множителем, гравитация слагаемым, позиция плюс скорость
- Draw только читает: альфа по остатку жизни, без изменений среза
- Пул — фиксированный срез с переиспользованием ячеек: ноль аллокаций в тике
- Параметры эффекта — константы рождения: скорость, ttl, гравитация, цвет
Соберите отрисовку частиц
Восстановите формулу альфы и имя векторной функции.
Рисование в окно и пакет vector доступны только в локальном Go — в песочнице сайта окна нет. Зато логика частиц — чистая математика над срезом: тематическая программа урока крутит updateParticles в песочнице и печатает по тикам позиции и остатки жизни — видно, как drag тормозит рой и гравитация тянет дугу. Перенос на экран — пара вызовов из этого урока в вашем проекте.
Тик обновления частиц
Соберите updateParticles: физика и жизнь.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Баланс и отладка эффектов#
Числа из таблицы — стартовые, а финальные подбираются игрой: у взрыва должно быть время дочитаться, у следа — не замылить экран. Два ползунка дают девяносто результата: ttl и число частиц. Много короткоживущих частиц читаются как искра, мало долгоживущих — как дым; сбалансированный взрыв — среднее между ними с большим разбросом скорости. Отладке помогает счётчик живых частиц в углу HUD: если после десяти взрывов число не вернулось к нулю, где-то живёт вечная частица — ищите пропуск уменьшения life или отказ от отсеивания.
Эффекты подчиняются геймплею, а не наоборот: взрыв, закрывающий врага на полсекунды, стоит игроку жизни. Частицы — украшение поверх честного состояния из прошлого урока: очки начислены в Update, частицы только празднуют событие. Случайность не обязана быть воспроизводимой: если захочется повторных взрывов один в один — сидируйте генератор на эффект, но для аркады чистый rand честнее и проще. Полезна и отладочная клавиша, рожающая взрыв под курсором: баланс превращается из правок кода в игру, а замороженный ttl делает траектории видимыми линиями, по которым читаются и drag, и гравитация.
Эмиттер урока — двадцать строк на рождение, десять на обновление, десять на отрисовку, и этого хватает всем взрывам курса. Дальше эффекты обрастут деталями: звук взрыва придёт в уроке про звук, а принцип не изменится — событие, рой, затухание. Домашнее задание вешает взрывы на коллизии вашего проекта и след — на движение героя. Частицы — лучший первый ответ на вопрос, как сделать игру живой: сотня дешёвых прямоугольников, честно умирающих по таймеру, выглядит дороже анимации, потому что каждый кадр чуть-чуть другая. Константы эффекта правятся на глаз — это нормально: эффекты меняют числа, а не архитектуру.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Частица — struct с позицией, скоростью, life, ttl и цветом; различие эффектов — параметры рождения.
- Спавн — событие в Update: взрыв за один вызов, след — пара частиц за тик.
- Update двигает частицы: drag тормозит множителем, гравитация тянет слагаемым, life тает на тик.
- Альфа считается из доли life к ttl: цвет тает вместе с частицей.
- Draw только читает срез: частицы не рождаются и не умирают в отрисовке.
- Пул — фиксированный срез с переиспользованием ячеек: ноль аллокаций, сборщик мусора молчит.
- Таблица параметров описывает взрыв, след, искры и дым одной структурой без новых типов.
Домашнее задание
Добавьте в проект срез частиц и структуру Particle из урока. Повесьте spawnExplosion на попадание снаряда во врага из урока про коллизии: двадцать частиц, скорость от единицы до четырёх, ttl от тридцати до шестидесяти. Сделайте след героя: одна частица за тик позади кормы при движении, ttl пятнадцать, гравитация ноль. В Draw рисуйте прямоугольники с альфой по доле жизни и следите за счётчиком живых — после серии взрывов он обязан возвращаться к нулю. Затем замените append фиксированным пулом на тысячу ячеек с компоновкой живых на месте и сравните поведение на серии из сотни взрывов. В конце подберите параметры под таблицу урока: каждый эффект должен отличаться от соседнего на глаз, без подписей.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему float32, а не float64?
Пакет vector принимает координаты float32: DrawFilledRect и DrawFilledCircle ждут именно его, и конвертации в каждом кадре не нужны. Точности float32 для частиц хватает с запасом: значения порядка сотен пикселей с долями дают погрешность в копейки за всю короткую жизнь частицы. float64 оставьте большим мирам и камере, где накопление ошибки заметнее.
Как сделать взрыв объёмным?
Два слоя частиц с разными параметрами: быстрое яркое ядро с коротким ttl и медленный тёмный дым с длинным. Второй приём — размер вместо альфы: частица растёт и бледнеет одновременно, глаз читает это как остывающий газ. Оба варианта не добавляют новых типов — только вторую горсть констант рождения в тот же spawnExplosion. Третий слой — composite-режимы смешивания из урока про примитивы: аддитивное свечение красиво, но это опция, а не необходимость первого эмиттера.
Нужны ли частицам тесты?
Логике — да: спавн и обновление — чистые функции над срезом, и тест без окна проверяет, что взрыв рождает заявленное число частиц, а после ttl тиков срез пуст. Рендер тестировать бессмысленно: это цикл по срезу и один вызов vector. Пул заслуживает отдельного теста на переполнение: породите больше максимума и убедитесь, что игра не падает, а старые ячейки переиспользуются.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript