# Частицы — `$('<particles>')` и `$.particles`

Подсистема CPU-частиц — аналог `CPUParticles2D` из Godot 4. Эмиттер живёт
целиком в JS: сам хранит пул частиц, считает их движение и рисует их через
общий батч `$.gfx.push.sprite`. Движку про частицы знать не нужно, отдельных
draw call'ов они не создают.

```js
$.ready(() => {
    $('<particles>', {
        amount: 32, lifetime: [400, 900], speed: [20, 70],
        direction: -90, spread: 26, gravity: [0, -45],
        size: [12, 22], end_size: 2,
        color_ramp: [
            { t: 0, color: '#fff6c2' },
            { t: 0.4, color: '#ff9b1e' },
            { t: 1, color: '#c81900' },
        ],
        alpha_ramp: [{ t: 0, alpha: 1 }, { t: 1, alpha: 0 }],
        seed: 7,
    }).at(400, 300).appendTo($.world);
});
```

---

## 1. Создание

| Способ | Назначение |
|---|---|
| `$('<particles>', { … })` | обычное создание узла |
| `$.particles.create({ … })` | то же самое, явно читается намерение |
| `$('<particles>', $.particles.preset('fire'))` | заготовка параметров |

Узел необязательно прикреплять к `$.world`: в `ctx.nodes` он попадает сразу,
и отрисовка/симуляция работают. Но `.appendTo($.world)` делает намерение
понятнее и участвует в очистке сцены.

---

## 2. Параметры эмиттера

Все поля передаются в `opts` при создании и обновляются методом `.params()`
(частично, поверх текущих значений). Диапазон записывается как `[min, max]`,
одиночное число трактуется как `[v, v]`.

| Параметр | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `emitting` | bool | `true` | идёт ли эмиссия (живые частицы остаются) |
| `amount` | число | `32` | сколько частиц держит непрерывная эмиссия |
| `max_particles` | число | `min(4096, max(amount·4, 256))` | жёсткий потолок пула (≤ 16384) |
| `rate` | число | `amount / среднее lifetime` | частиц в секунду |
| `interval` | число (мс) | — | пауза между частицами; альтернатива `rate` |
| `lifetime` | число / `[min,max]` (мс) | `1000` | время жизни |
| `speed` | число / `[min,max]` | `100` | начальная скорость, px/с |
| `direction` | градусы | `0` | 0 — вправо (+X), 90 — вниз |
| `spread` | градусы | `0` | полный угол разброса вокруг `direction` |
| `gravity` | число / `[x,y]` / `{x,y}` | `0` | ускорение, px/с²; число — вниз по Y |
| `angle` | число / `[min,max]` (град.) | `0` | начальный поворот частицы |
| `angular_velocity` | число / `[min,max]` (град./с) | `0` | скорость вращения |
| `size` | число / `[min,max]` | `8` | стартовый размер (мировые единицы) |
| `end_size` | число / `[min,max]` | = `size` | размер к концу жизни |
| `size_ramp` | `[{ t, size }]` | — | кривая размера (сильнее, чем `end_size`) |
| `color` | цвет | `#ffffff` | базовый цвет |
| `end_color` | цвет | = `color` | цвет к концу жизни |
| `color_ramp` | `[{ t, color }]` | — | кривая цвета; перекрывает `color`/`end_color` |
| `alpha_ramp` | `[{ t, alpha }]` | константа `1` | кривая прозрачности |
| `damping` | число | `0` | экспоненциальное торможение, 1/с |
| `texture` / `src` | путь / id / `[x,y,w,h]` | `$.gfx.white` | спрайт частицы |
| `local` | bool | `true` | частицы движутся вместе с узлом |
| `global` | bool | `false` | `true` — мировые координаты (алиас `local: false`) |
| `one_shot` | bool | `false` | один залп из `amount` при старте |
| `burst` | число / массив | — | дополнительный залп(ы) при старте |
| `emit_zone` | строка / объект | `'point'` | `'point' \| 'rect' \| 'circle'` |
| `emit_zone_w` / `_h` / `_radius` | число | `0` | размеры зоны (или `{ w, h, radius }`) |
| `seed` | целое | `uid` узла | зерно генератора |
| `layer` / `depth` | число | `0` | обычные поля сортировки узла |
| `blend` | строка | режим узла | режим смешивания частиц: `alpha` \| `add` \| `multiply` \| `none` |

Формы записи зоны эмиссии равнозначны:

```js
$('<particles>', { emit_zone: 'circle', emit_zone_radius: 40 });
$('<particles>', { emit_zone: { shape: 'rect', w: 120, h: 20 } });
```

---

## 3. Методы узла

Все методы цепочные (кроме геттеров) и работают на обёртке.

| Метод | Что делает |
|---|---|
| `.start()` | включить эмиссию |
| `.stop()` | выключить эмиссию; живые частицы доживают свой срок |
| `.restart()` | очистить пул, перезапустить генератор, выдать стартовый залп снова |
| `.reset()` | очистить пул и накопитель, залп снова; состояние эмиссии сохраняется |
| `.burst(n)` | немедленный залп из `n` частиц (ограничен `max_particles`) |
| `.emitting()` / `.emitting(bool)` | геттер/сеттер эмиссии |
| `.isEmitting()` | булев геттер |
| `.count()` | сколько частиц живо (сумма по обёртке) |
| `.clear()` | убрать все частицы, эмиссию не трогая |
| `.params(spec)` | частично обновить параметры; живые частицы не меняются |
| `.params()` | снимок текущих нормализованных параметров |
| `.particleAt(i)` | частица номер `i` (объект) или `null` |

```js
const fx = $('<particles>', { amount: 40, seed: 1 }).at(400, 300).appendTo($.world);
fx.stop().clear().burst(20);      // разовый взрыв без дальнейшей эмиссии
fx.start().params({ speed: [80, 200], amount: 12 });
fx.count();                        // 20
fx.particleAt(0);                  // { x, y, vx, vy, age, life, size, … }
```

---

## 4. Рампы (кривые)

Рампа — массив стопов, отсортированных по `t ∈ [0, 1]`:

* `color_ramp`: `[{ t, color }]` — цвет интерполируется по каналам RGBA;
* `alpha_ramp`: `[{ t, alpha }]` — прозрачность;
* `size_ramp`: `[{ t, size }]` — размер (перекрывает `size`/`end_size`).

`t` — доля прожитой жизни: 0 при рождении, 1 в момент исчезновения. Если
`color_ramp` не задан, строится прямая из `color` в `end_color`; если не задан
`alpha_ramp`, прозрачность постоянна.

```js
color_ramp: [{ t: 0, color: '#fff3b0' }, { t: 0.35, color: '#ff9a2e' }, { t: 1, color: '#7a1f00' }],
alpha_ramp: [{ t: 0, alpha: 1 }, { t: 0.7, alpha: 0.6 }, { t: 1, alpha: 0 }],
```

---

## 5. Режимы `local` и `global`

* **`local` (по умолчанию).** Смещения частиц хранятся относительно узла и
  поворачиваются вместе с ним при отрисовке. Двинули эмиттер — облако поехало
  следом. Гравитация при этом действует в локальной системе узла.
* **`global`.** Частицы рождаются в мировых координатах и больше не зависят от
  узла: можно «привязать» эмиттер к движущемуся объекту, а искры останутся в
  мире.

```js
$('<particles>', { global: true, one_shot: true, amount: 30 }); // салют в мире
```

---

## 6. Пресеты

```js
$.particles.presets();
// ['explosion', 'smoke', 'sparks', 'fire', 'rain', 'dust']

$.particles.preset('fire', { amount: 8, id: 'torch' });
// копия параметров пресета, перекрытая spec
```

| Пресет | Для чего |
|---|---|
| `explosion` | разовый взрыв: тёплые искры наружу, с затуханием |
| `smoke` | поднимающийся дым с ростом размера |
| `sparks` | мелкие искры вверх под гравитацией |
| `fire` | язык пламени: жёлтый → оранжевый → красный |
| `rain` | широкий прямоугольный эмиттер, капли вниз |
| `dust` | медленная пыль вокруг точки |

## 6.1. Суб-эмиттеры: искры → дым

Параметр `on_death` заводит вложенный эмиттер, который бьёт залпом из точки,
где умерла частица. Так искры догорают в дым, дым оседает пеплом, а капли
оставляют брызги — без ручного кода на каждую частицу.

```js
$('<particles>', $.particles.preset('sparks', {
    amount: 18, lifetime: 420,
    on_death: { preset: 'smoke', amount: 2, lifetime: 650 },
})).at(0, 0).appendTo($.world);
```

Как это устроено:

* у эмиттера появляется **один** дочерний узел `<particles>` (создаётся при
  первой смерти частицы) — не по узлу на частицу, мусора нет;
* вложенный эмиттер сам не эмитит: у него `one_shot`, нулевой `amount` и
  `rate: 0`, он стреляет только залпом из `emitBurst`;
* глубина ровно **один уровень**: у вложенного эмиттера `on_death` игнорируется,
  бесконечной цепочки не будет;
* дочерний узел привязан к родителю, поэтому `.remove()` родителя убирает и
  суб-эмиттер; в `local`-режиме точка смерти переводится в мировые координаты
  с учётом поворота узла.

Поля `on_death`: `preset` или любые параметры эмиттера плюс `amount` — сколько
частиц выбросить на одну умершую (по умолчанию 2). Синонимы: `onDeath`, `sub`.

`preset()` возвращает **копию**: правки возвращённого объекта не портят
встроенный пресет; неизвестное имя даёт предупреждение и пустой (или переданный)
spec.

---

## 7. Детерминизм и производительность

* При заданном `seed` последовательность частиц полностью детерминирована
  (генератор — `makeRandom` из ядра). Два эмиттера с одним seed и одним `dt`
  эволюционируют одинаково.
* Пул частиц фиксирован `max_particles`; объекты частиц переиспользуются через
  внутренний список свободных — в установившемся режиме кадр не аллоцирует.
* Один эмиттер рисует не больше 4096 спрайтов за кадр, чтобы не занять общий
  батч (16384). Частицы вне экрана отсекаются.
* `max_particles` по умолчанию ограничен; для «тяжёлых» эффектов задавайте его
  явно и держите `amount` разумным.

---

## 8. Чистые функции (для тестов и инструментов)

Экспортируются из `src/highlevel/particles.js` и не требуют движка (кроме
цвета — `engine.rgba` из мока):

| Функция | Назначение |
|---|---|
| `installParticles($)` | подключить подсистему (зовёт `api.js`) |
| `tickParticles(dt)` | кадровый шаг всех эмиттеров (зовёт `api.js`) |
| `buildParams(spec)` | сырые опции → нормализованные параметры |
| `buildRamp(stops, kind)` | стопы → числовая рампа (`'color'` / `'value'`) |
| `sampleRamp(stops, t)` | значение рампы в точке `t` |
| `spawnParticle(params, rng)` | новая частица |
| `stepParticle(p, dt, params)` | шаг частицы (меняет `p`, ставит `p.dead`) |

---

## 9. Частицы как цели

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

| Вызов | Что возвращает |
|---|---|
| `$.particles.at(x, y, opts)` | попадания по точке: `[{ node, self, index, x, y, size, r, particle }]` |
| `$.particles.inBox(x, y, w, h, opts)` | то же по прямоугольнику с центром `(x, y)` |
| `$.particles.raycast(from, to, opts)` | ближайшая частица: `{ node, index, point, distance, fraction, size, particle }` или `null` |
| `$.particles.hit(x, y, opts)` | `{ hits, killed }` — попадания и сколько частиц умерло |
| `$.world.particlesAt(x, y, opts)` | то же, что `$.particles.at` |
| `$.world.particlesIn(x, y, w, h, opts)` | то же, что `$.particles.inBox` |

`opts`: `r` — добавочный радиус вокруг точки (размер частицы учитывается сам),
`sel` — селектор эмиттеров, `limit` — предел числа попаданий, `kill: false` —
не убивать частицы (игра сама решит их судьбу).

Мировой луч видит частицы только по явному флагу — иначе он останавливался бы
на дыме и искрах:

```js
const shot = $.world.raycast({ x: heroX, y: heroY }, { x: mx, y: my }, { particles: true });
if (shot && shot.particle) $.particles.hit(shot.point.x, shot.point.y, { r: 6 });
```

---

## 10. Ограничения

* Частицы **умеют режимы смешивания**: `blend` у эмиттера сильнее режима узла,
  а если не задан ни тот, ни другой — обычное альфа-смешивание. Аддитивные искры
  и огонь задаются как `$('<particles>', { …, blend: 'add' })`.
* Частицы не сталкиваются с миром и не участвуют в `$.world.raycast`/
  `bodyAt`/`bodiesIn` без флага `{ particles: true }` — они не тела Box2D.
  Свои запросы по ним живут в `$.particles` (§9).
* Попадание по частице — это её смерть (`hit`), а не импульс: у частиц нет
  массы и скорости отклика.
* Симуляция идёт с `dt` игрового цикла и не встаёт отдельно на паузу
  `$.time.pause()` (шаг получает уже посчитанный `dt`).
* `.color()` на узле подхватывается на лету, но произвольные правки через
  `.attr()` после создания не перестраивают параметры — используйте
  `.params({ … })`.
* Текстура резолвится один раз при первом тике эмиттера.
