# `$.anim` — анимация клипами и машина состояний

Подсистема добавляет к `$` именованные анимационные **клипы** (аналог
`AnimationPlayer` в Godot 4) и **машину состояний** (аналог `AnimationTree`).
Она закрывает то, чего не хватало твинам: появление/смерть, атаку, двери,
UI-переходы, переключение анимаций по условию и по событию.

```js
$.ready(() => {
    $.anim.define('hit', {
        duration: 180,
        loop: 'once',
        tracks: [
            { prop: 'scale_x', keys: [{ t: 0, v: 1 }, { t: 1, v: 1.6, ease: 'quadOut' }] },
            { prop: 'scale_y', keys: [{ t: 0, v: 1 }, { t: 1, v: 1.6, ease: 'quadOut' }] },
        ],
        events: [{ at: 0.6, name: 'impact', data: { power: 10 } }],
    });

    const hero = $('<player>', { id: 'hero' }).at(100, 200).appendTo($.world);
    hero.playClip('hit');
    hero.on('key', (e) => { if (e.data.name === 'impact') $.sound.play('hit'); });
});
```

Чем клип отличается от твина:

| | Твин (`tween.js`) | Клип (`anim.js`) |
|---|---|---|
| Что это | одноразовый переход `A → B` за время | timeline с ключами и режимом |
| Длительность | задаётся в вызове | часть объявления клипа |
| Повтор | нет | `once` / `loop` / `pingpong` |
| События | нет | `events: [{ at, name }]` в процентах |
| Состояния | нет | `.stateMachine()` |
| Возврат | Promise | цепочка `$` |

Клипы и твины не конфликтуют, если трогают разные свойства: узел может
одновременно ехать `.moveTo()` и «дышать» клипом по `alpha`.

---

## `$.anim`

### `$.anim.define(name, spec)`

Объявляет (или переобъявляет) клип с именем `name`. Возвращает `$`.

`spec`:

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `duration` | число, мс | — (обязательно, `> 0`) | длина клипа |
| `loop` | `'once' \| 'loop' \| 'pingpong'` | `'once'` | режим воспроизведения |
| `speed` | число | `1` | множитель скорости по умолчанию |
| `tracks` | массив | `[]` | дорожки свойств |
| `events` | массив | `[]` | события в процентах клипа |

Некорректный `duration` или `loop` бросает исключение (`$.anim.define: у
клипа "..." нужен duration > 0 (мс)`) — лучше упасть при загрузке, чем
молча показывать неживую анимацию.

### Дорожки (`tracks`)

Три вида дорожек, различаются по полям объекта:

**1. Свойство узла — `{ prop, keys }`:**

```js
{ prop: 'alpha', keys: [{ t: 0, v: 1 }, { t: 0.5, v: 0.3 }, { t: 1, v: 1 }] }
{ prop: 'x',     keys: [{ t: 0, v: 0 }, { t: 1, v: 120, ease: 'quadOut' }] }
```

* `t` — момент в долях клипа `0..1` (сортируется автоматически, значения
  вне диапазона зажимаются);
* `v` — значение;
* `ease` — необязательная плавность на участке.

Доступные `prop` (пишутся напрямую, без физики):

`x`, `y`, `angle`, `scale_x`, `scale_y`, `alpha`, `width`, `height`,
`radius`, `intensity`. Синонимы: `rotation` → `angle`, `scaleX`/`scaleY`,
`opacity` → `alpha`, `w`/`h`. Любое другое имя пишется в `node.attrs`.

`angle` — в радианах (как `node.angle`), `scale_x`/`scale_y` — множители,
`alpha` — `0..1`. Это ровно те поля, что читает отрисовка, поэтому клип
виден на экране без единого дополнительного слоя.

**2. Произвольное свойство — `{ fn }`:**

```js
{ fn: (node, k) => { node.attrs.glow = k * 2; } }
```

`k` — прогресс клипа `0..1` (для `pingpong` он ходит вперёд-назад). Третий
аргумент — `{ clip, u, time, name }`.

**3. Кадры спрайт-листа — `{ anim }`:**

```js
// узел уже получил кадры через .frames({ src, cols, rows, cw, ch })
{ anim: true }                                  // весь лист
{ anim: { from: 2, to: 5 } }                    // диапазон кадров
```

Кадр выбирается по прогрессу клипа: `index = from + floor(phase * span)`.
Скорость смены кадров задаётся `duration` клипа, а не отдельным счётчиком.

### Плавности (`ease`)

Те же кривые, что у твинов (`tween.js`), плюс короткие псевдонимы:
`linear`, `quadIn`, `quadOut`, `quadInOut`, `cubicIn`, `cubicOut`,
`cubicInOut`, `quartIn/Out/InOut`, `sineIn/Out/InOut`, `backIn/Out/InOut`,
`elasticIn/Out`, `bounceIn/Out`, `in`, `out`, `inOut`, `step` и длинные
имена (`easeInQuad`, `easeOutBounce`, …).

Плавность берётся у **ключа-назначения** (правого), а если её там нет — у
начального. Поэтому работают обе привычные записи:

```js
[{ t: 0, v: 0 }, { t: 1, v: 100, ease: 'quadOut' }]   // ease «на приезде»
[{ t: 0, v: 0, ease: 'quadIn' }, { t: 1, v: 100 }]    // ease «на выезде»
```

Если `ease` не указан вовсе — участок линейный.

### События (`events`)

```js
events: [
    { at: 0.0, name: 'start' },
    { at: 0.5, name: 'impact', data: { power: 10 } },
]
```

`at` — доля клипа `0..1`. В момент события узел получает два события:

* `'key'` с данными `{ name, data, at, clip, clipName }`;
* событие с собственным именем (`'impact'`) и теми же данными.

Событие `at: 0` срабатывает сразу в `playClip()`. Для `loop` события
повторяются каждый цикл; для `pingpong` — раз за прямой проход.

### `$.anim.get(name)`

Нормализованный клип (`{ name, duration, loop, speed, tracks, events }`) или
`null`.

### `$.anim.has(name)`

`true`, если клип объявлен.

### `$.anim.list()`

Массив имён всех клипов.

### `$.anim.remove(name)`

Удаляет клип, возвращает `true`, если он был.

### `$.anim.clear()`

Удаляет все клипы. Возвращает `$`.

---

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

### `.playClip(name, opts)`

Запускает клип. Нулевой кадр применяется сразу, ещё до первого `tickAnim`.

`opts`:

| Поле | Тип | Смысл |
|---|---|---|
| `speed` | число | множитель скорости (переопределяет `spec.speed`) |
| `loop` | строка | режим (переопределяет `spec.loop`) |
| `onEnd` | функция | вызов при завершении клипа `once`; аргумент — узел |
| `restart` | bool | `true` — перезапустить даже тот же клип |

Повторный `.playClip()` того же клипа **не** сбрасывает время: игровой код
может звать его каждый кадр без «дёрганья». Нужен сброс — `{ restart: true }`.

```js
$('#hero').playClip('run', { speed: 1.5, loop: 'loop' });
$('#hero').playClip('die', { onEnd: (node) => node.remove() });
```

Если клип не объявлен, вызов пишет подсказку в журнал и ничего не делает.

### `.stopClip()`

Останавливает клип и забывает его. Свойства узла остаются в последнем
состоянии (сброса нет — это осознанно: анимация не «телепортирует» узел).

### `.pauseClip()` / `.resumeClip()`

Ставит время клипа на паузу и снимает её. `isPlayingClip()` на паузе
остаётся `true`: клип не завершён, он ждёт.

### `.isPlayingClip()`

`true`, пока клип активен (в том числе на паузе). `false` после `once`-конца
или `.stopClip()`.

### `.clipTime()`

Текущее время внутри клипа в **миллисекундах**: `phase * duration`. Для
`loop`/`pingpong` это время внутри цикла, а не суммарное.

### `.clipProgress()`

Прогресс `0..1` (`phase`). На `once`-конце равен `1`.

### `.clipSpeed(value)`

Без аргумента — текущая скорость. С аргументом — задаёт скорость и
возвращает цепочку.

---

## Машина состояний

### `.stateMachine(spec)`

```js
$('#hero').stateMachine({
    initial: 'idle',
    states: {
        idle: { clip: 'idle-anim', loop: 'loop' },
        run:  { clip: 'run-anim',  loop: 'loop', speed: 1.2 },
        die:  { clip: 'die-anim',  loop: 'once', next: 'idle' },
    },
    transitions: [
        { from: 'idle', to: 'run',  when: (n) => Math.abs(n.velocity_cache.x) > 1 },
        { from: 'run',  to: 'idle', when: (n) => Math.abs(n.velocity_cache.x) <= 1 },
        { from: 'idle', to: 'die',  on: 'damaged' },
    ],
});
```

`spec`:

| Поле | Смысл |
|---|---|
| `initial` | имя стартового состояния (если его нет — берётся первое из `states`) |
| `states` | `{ имя: { clip, loop, speed, next } }` |
| `transitions` | массив правил перехода |

Состояние без `clip` — легальная заглушка: оно активно, ждёт перехода.

Переход `{ from, to, when, on }`:

* `from` — имя состояния или `'*'` (любое);
* `to` — имя состояния;
* `when(node)` — условие, проверяется каждый кадр;
* `on` — триггер:
  * `on: 'event'` (или отсутствует) — переход «условный», срабатывает по `when`;
  * `on: '<имя>'` — переход по событию узла с этим именем
    (`node.emit('damaged')`);
  * `on: 'signal'` + `signal: '<имя>'` — то же самое явной парой.

Событийные переходы имеют приоритет над условными в одном кадре. Первое
подходящее правило выигрывает.

`next` у состояния — «доиграл клип и дальше»: когда `once`-клип
завершается, машина сама переходит в `next`.

Событийные переходы подписываются на узел один раз при объявлении, поэтому
`.stateMachine()` не плодит обработчиков при повторных вызовах.

### `.toState(name)`

Принудительный переход (например, из игрового кода по «смерти»).

### `.state()` / `.stateTime()` / `.states()`

* `.state()` — имя текущего состояния или `null`;
* `.stateTime()` — время в состоянии в **секундах** (как `dt` в цикле);
* `.states()` — массив имён состояний.

---

## События

| Событие | Когда | `e.data` |
|---|---|---|
| `clipEnd` | `once`-клип доиграл | `{ clip, name, node }` |
| `key` | событие внутри клипа | `{ name, data, at, clip, clipName }` |
| `<имя события>` | то же, что `key`, но под своим именем | то же |
| `stateEnter` | вход в состояние (включая `initial`) | `{ state, prev, node }` |
| `stateExit` | выход из состояния | `{ state, next, node }` |

```js
$('#door').on('stateEnter', (e) => {
    if (e.data.state === 'open') $.sound.play('door');
});
$('#door').on('clipEnd', (e) => $.log('клип', e.data.name, 'закончился'));
```

---

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

Экспортируются из `src/highlevel/anim.js` и не требуют движка:

| Функция | Что делает |
|---|---|
| `normalizeKeys(keys)` | проверяет ключи, зажимает `t`, сортирует |
| `sampleKeys(keys, t)` | значение дорожки на прогрессе `t` (с учётом `ease`) |
| `chooseEase(ease)` | функция плавности по имени/функции (понимает `quadIn` и т. п.) |
| `clipPhase(u, loop)` | фаза `0..1` из «единиц длительности» `u` |
| `eventsBetween(events, fromU, toU)` | события, попавшие в интервал (с повторами) |
| `evalClip(clip, timeMs)` | `{ u, phase, done, values }` на момент времени |
| `normalizeClip(name, spec)` | проверка и нормализация объявления |
| `animEases()` | список всех имён плавностей |

Проверка без сборки движка:

```bash
build/_deps/quickjs-build/qjs tests/js/anim_test.mjs
```

---

## Ограничения и особенности

* Клипы **не трогают физику**: `x`/`y` пишутся в поля узла напрямую, тела
  Box2D не переносятся. Для движения тела используйте твины/скорость.
* Скорость клипа может быть отрицательной — время пойдёт назад; события при
  этом не срабатывают (интервал считается только вперёд).
* Событие `at: 1` в `once`-клипе успевает сработать до фиксации конца.
* Пауза клипа не останавливает машину состояний: `.stateTime()` и проверки
  `when` продолжают идти.
* `.playClip()` на узле с машиной состояний меняет клип до ближайшего
  перехода — сама машина остаётся активной.
* Имя `$.anim` и методы `.playClip()`, `.stateMachine()` и т. п. не
  пересекаются с именами ядра (`animate`, `pause`, `sequence`, …).
