# VFX своими руками — `$.fx`

Модуль `src/highlevel/fx.js`: ленты, молнии, ударные волны, вспышки и поля сил.
Никаких сторонних библиотек — всё рисуется тем же батчем, что и спрайты
(`$.gfx.push.triangle/line/ring/circle`), поэтому эффекты попадают в кадр сцены
и не добавляют draw call'ов.

Пост-обработка и пользовательские шейдеры — в [render.md](highlevel/render).

---

## Координаты

`$.fx.*` и `$.gfx.draw.*` работают в **мировых** координатах: движок сам
переводит их в экранные через текущую камеру (и масштабирует толщины и
радиусы). Раньше перевода не было, и VFX уезжал на расстояние камеры — если
пишете свой модуль поверх `$.gfx.push.*`, помните, что `push` ждёт **экранные**
координаты: либо считайте их сами (`$.camera.worldToScreen`), либо рисуйте из
`$.render`-хука через `$.fx`/`$.gfx.draw`.

## 1. Обзор

| Метод | Что делает |
|---|---|
| `$.fx.trail(target, opts)` | лента за целью: селектор, узел, обёртка или функция точки |
| `$.fx.ribbon(points, opts)` | разовая лента по готовым точкам: трассер, след клинка |
| `$.fx.lightning(from, to, opts)` | молния с дрожанием и ветвями |
| `$.fx.shockwave(x, y, opts)` | расширяющееся кольцо |
| `$.fx.pulse(x, y, opts)` | вспышка-круг в точке (дуло, попадание) |
| `$.fx.attractor(x, y, opts)` | поле сил: притяжение и вихрь для частиц |
| `$.fx.impact(x, y, opts)` | готовый удар: волна + тряска + микро-стоп кадра |
| `$.fx.hitStop(ms, scale)` | замедление времени на удар |
| `$.fx.stats()` | сколько чего живо сейчас |
| `$.fx.clear()` | убрать всё (обычно не нужно: сцена чистит сама) |

Ленты и поля возвращают handle с `.stop()` (у `attractor` ещё `.move(x, y)` и
`.set(opts)`, у `trail` — `.options(opts)`).

```js
// Трассер: одна лента и вспышка у дула.
$.fx.ribbon([muzzle, hitPoint], { ms: 90, width: 5, color: '#ffd27f', blend: 'add' });
$.fx.pulse(muzzle.x, muzzle.y, { radius: 28, ms: 90, color: '#ffe0a0' });

// Попадание: волна, искры (частицами) и микро-стоп.
$.fx.impact(point.x, point.y, { radius: 60, shake: 4, hitStop: 60 });

// Молния между игроком и целью.
$.fx.lightning('#hero', '#enemy', { life: 120, jitter: 12, branches: 2, color: '#9fe8ff' });

// Чёрная дыра: поле живёт 2.8 с, потом схлопывается ударной волной.
const hole = $.fx.attractor(x, y, { radius: 280, strength: 1600, swirl: 1.2, life: 2800 });
$.time.after(2800, () => $.fx.shockwave(x, y, { radius: 420, ms: 520, width: 16, color: '#c9a6ff' }));
```

---

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

**`trail`** — `{ ms, width, color, blend, minStep, alpha }`. `ms` — сколько живёт
точка ленты, `minStep` — минимальный шаг в пикселях (чтобы лента не копила
точки на месте), `width` — толщина у головы, к хвосту сужается сама.
Живая лента тянется за целью каждый кадр; когда цель исчезла (или `.stop()`),
лента доигрывает и убирается. Разовая (`ribbon`) — стареет и исчезает сама.

**`lightning`** — `{ life, segments, jitter, width, color, blend, branches, glow }`.
`jitter` — разброс середины в пикселях, `branches` — число ответвлений,
`glow` — рисовать ли широкую полупрозрачную подложку.

**`shockwave`** — `{ radius, ms, width, color, blend, ease }`. `ease` — `'out'`
(по умолчанию, быстро в начале) или `'linear'`.

**`attractor`** — `{ radius, strength, swirl, life, visual, color, edge }`.
`strength` — сила притяжения, `swirl` — доля тангенциальной составляющей
(закручивание), `visual: false` — невидимое поле.

---

## 3. Поля сил и частицы

`$.fx.attractor` действует на частицы `$.particles`, пока живёт: `particles.js`
читает общий список `ctx.fx_fields` и добавляет частице ускорение к центру
(с затуханием к краю радиуса) плюс вихрь. Тела Box2D поле не двигает — их
тянут обычными силами (`$.world` / `.applyForce`), как в демо «Типичная ночь в Мытищинском лесу».

Формально сила на частицу: `k = strength · (1 − d/R) · dt / max(16, d)`,
скорость получает `dx·k` и `−dy·swirl·k`.

---

## 4. Кадр и порядок

* `tickFx(dt)` вызывается в кадре **до** `tickParticles(dt)`: просроченное поле
  не должно успеть подействовать на частицы;
* рисование идёт из хука `ctx.gfx._fxFlush(cam)` внутри `render.js`, после
  узлов и до отправки батча: раньше нельзя (батч ещё не собирается), позже —
  он уже отправлен;
* эффекты отсекаются по камере (радиус видимой области + запас), поэтому
  далёкие волны не занимают буфер;
* случайность берётся из `fxRandom()` (`core.js`) — при `--seed` и
  фиксированном шаге картинка воспроизводима.

Смена сцены чистит эффекты: `resetFx()` вызывается из `scene.js` вместе с
очисткой мира.

---

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

* Пост-обработка и шейдеры **есть** (`$.gfx.post`, `$.gfx.defineShader`), но
  искажений кадра целиком (heat haze, линза чёрной дыры) в `$.fx` нет: их
  собирает игра из пост-обработки и пользовательского шейдера.
* Свет `<light>` — радиальный градиент из колец (мягкое пятно), а не честный
  источник с тенями; тени даёт `engine.light.visibility`.
* Поля сил действуют только на частицы, не на тела Box2D.
* Лента рисуется треугольниками без сглаживания стыков: на очень длинных
  лентах заметны грани (ограничитель — 128 точек).

---

## 6. Тесты

`tests/js/fx_test.mjs` (qjs, без движка) проверяет времена жизни, привязку
ленты к цели, поля сил, отсечение по камере и сброс. Демо-проверка —
`tests/agent/demos_test.py shooter_witch`.
