# Ощущение удара — `$.hits` и `$.hitFx`

Модуль `src/highlevel/hits.js`: из события контакта делает полный отклик —
искры в точке попадания, вспышку узла и экрана, тряску камеры, hit-stop,
отдачу и squash & stretch спрайта. Одна строка в игре:

```js
$('#enemy').on('hit', $.hitFx);                     // пресет по умолчанию
$.hits.on('.enemy', 'crit');                        // пресет по имени
$.hits.on('.enemy', { hitStop: 90, knockback: 300, screen: null });
```

Модуль ставится из `fx.js` (`installFx` → `installHits`, `tickFx` → `tickHits`),
а не из `api.js`: у VFX-подсистемы уже есть и установщик, и кадровый тик, и
список правок интегратора не растёт. Связанные страницы:
[particles.md](highlevel/particles) (откуда искры), [fx.md](highlevel/fx) (чем рисуется),
[time.md](highlevel/time) (чем держится hit-stop), [camera.md](highlevel/camera) (тряска).

---

## 1. Обзор

| Вызов | Что делает |
|---|---|
| `$.hitFx(what, opts)` | главный вызов: полный фидбек по событию, узлу или точке |
| `$.hits.play(what, opts)` | то же самое (алиас с явным именем) |
| `$.hits.on(selector, opts)` | подписать узлы на `hit` одной строкой; вернёт число подписанных |
| `$.hits.squash(what, opts)` | squash & stretch узла |
| `$.hits.knockback(what, opts)` | отдача: импульс телу или сдвиг узлу без тела |
| `$.hits.flash(what, color, ms)` | вспышка тона узла (обёртка над `.flash()`) |
| `$.hits.preset(name, opts)` | задать свой пресет |
| `$.hits.presets()` / `$.hits.spec(name, opts)` | имена пресетов / итоговый набор после слияния |
| `$.hits.stats()` | `{ plays, tweens, emitters }` — для отладки и тестов |
| `$.hits.clear()` | снять активные squash/отдачу и погасить пул эмиттеров |

Методы узла: `.squash(opts)` и `.knockback(opts)` — те же эффекты в цепочке.

`what` понимает всё, что даёт движок:

* объект события `hit`/`collide` (обработчик получает `event`, данные лежат в
  `event.data`): точка, нормаль, `self`, `other`;
* узел, обёртку или селектор — точка берётся из центра узла;
* `{ x, y }` — голая точка (например, для взрыва без узла).

## 2. Опции

Опции — данные: объект опций перекрывает пресет, пресет перекрывает умолчания
(см. `$.hits.spec(name, opts)`). Всё, что не задано, просто не вызывается;
`null` выключает эффект намеренно.

| Опция | Умолчание | Смысл |
|---|---|---|
| `preset` | `'hit'` | имя пресета (`hit`, `crit`, `block`, `death`, `land`) |
| `particles` | `sparks`, 14 шт. | залп частиц в точке: пресет `$.particles` + перекрытия (`amount`, `speed`, `lifetime`, `size`, `color`, `max_particles`); `false` — без искр |
| `flash` | `{color:'#fff', ms:90}` | вспышка тона узла; `null` — выключить |
| `screen` | `{color:'#fff', ms:70, alpha:0.35}` | вспышка всего экрана (`$.fx.flash`); у пресета `hit` выключена |
| `shake` / `shakeMs` | `3` / `140` | тряска камеры (`$.camera.shake`), `0` — не трясти |
| `hitStop` | `60` | мс полной заморозки игрового времени (`$.time.freeze`) |
| `slow` / `slowScale` | `0` / `0.3` | мс замедления вместо заморозки (`$.time.slow`) |
| `knockback` | `0` (у `hit` — 120) | импульс телу Box2D или сдвиг в пикселях узлу без тела |
| `squash` / `squashMs` | `0.22` / `180` | амплитуда и время squash & stretch |
| `sound` | `''` | имя для `$.sound.play`; пресеты его не выдумывают — включайте явно (`{ sound: 'hit' }`), иначе каждое попадание писало бы в лог «не удалось загрузить звук» |
| `cooldown` | `0` | мс: не чаще одного фидбека на одну и ту же цель |
| `target` | `'self'` | кому достаётся отклик: `'self'` (узел события), `'other'`, узел или селектор |
| `dir` | направление удара | свой вектор отдачи/squash, если нормаль не подходит |
| `event` | `'hit'` | какое событие слушать в `$.hits.on` |

```js
// Свой пресет: удар тяжёлой рукой.
$.hits.preset('heavy', { hitStop: 120, knockback: 380, shake: 6, screen: null });

// Точка без узла: взрыв в мире.
$.hits.play({ x: 320, y: 180 }, { preset: 'death', target: null });

// Полный контроль: без искр, с замедлением вместо стопа.
$.hits.play(info, { particles: false, hitStop: 0, slow: 400, slowScale: 0.2 });
```

## 3. Отдача и squash & stretch

**Отдача.** У узла с телом Box2D это `engine.applyImpulse` по направлению
удара — дальше работает физика (масса, трение, затухание). У узла без тела
позиция сдвигается на `force · 0.06` пикселей с затуханием за `ms`, и узел
остаётся сдвинутым: движение без физики — дело игры.

**Squash & stretch.** Узел сжимается вдоль удара и растягивается поперёк
(`stretch` — доля растяжения), огибающая `sin(π·p)` возвращает его точно к
исходным `scale_x`/`scale_y`. Базовый масштаб запоминается при старте удара,
поэтому серия попаданий не «сплющивает» спрайт навсегда.

**Направление** берётся из события: сначала вектор «от источника к цели» (он не
зависит от того, в каком порядке Box2D отдал пару тел), затем нормаль контакта,
затем запасное «вверх».

## 4. Кадр и детерминизм

* `tickHits(dt)` зовётся из `tickFx` (то есть из кадра `fx.js`) и двигает
  squash/отдачу **игровым** временем `$.time.delta()`: на заморозке удара они
  стоят вместе с миром — иначе деформация «пережила» бы hit-stop и удар
  потерял бы вес;
* частицы и вспышки живут на реальном времени: их тикают `particles.js` и
  `fx.js`, поэтому искры и заливка экрана видны даже в замороженном кадре;
* случайность — только из rng эмиттера частиц (зерно узла) и `fxRandom()`;
  отдача и squash — чистая математика от направления. При `--seed` и
  `--fixed-dt` повтор боя даёт тот же мир (`highlevel_feel_test.py`);
* эмиттеры берутся из пула на 8 слотов и переиспользуются: удар случается
  часто, а создавать по узлу на попадание — это мусор в реестре и лишние
  проходы кадра. Девятый удар переиспользует первый слот (его живые частицы
  доживают по своему времени жизни).

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

* Пул эмиттеров — 8 слотов: при больше чем восьми одновременных ударах старые
  залпы обрываются. Для плотных сцен задайте свой эмиттер и зовите
  `.burst(n)` (`particles.md`).
* Экранная вспышка заливает вид главной камеры: при сплитскрине второй
  вьюпорт она не накроет (`fx.md`, §4).
* `$.hits.on` добавляет подписку при каждом вызове — держите его вне
  кадрового цикла (в `$.ready` или при входе в сцену).
* Отдача по узлу без тела — сдвиг, а не физика: столкновений он не создаёт.
* Модуль не заменяет урон: `hp`/`damage`/зоны попадания — это `combat.js` и
  `alive.js`, `$.hits` только показывает удар.

## 6. Тесты

* `tests/js/hit_fx_test.mjs` (qjs, без движка) — слияние пресетов, разбор
  события/узла/точки, направление удара, squash с возвратом масштаба, отдача
  телу и узлу без тела, cooldown, подписка `$.hits.on`;
* `tests/agent/highlevel_feel_test.py` — живой движок: событие `hit` от Box2D
  доводит до частиц, вспышек, тряски и hit-stop одной подпиской;
* `tests/js/time_freeze_test.mjs` и `tests/js/fx_test.mjs` — время заморозки и
  вспышка экрана по отдельности.
