# `$.clip` — клипы покадровой анимации

Клип — это **ресурс**: именованный набор кадров с частотой, повтором и точкой
вращения. Он закрывает то, чего не хватало покадровой анимации: движок принимал
только линейный индекс кадра на ОДНОМ листе, поэтому игре приходилось самой
резать регионы, следить за fps и повторами, а клип с кадрами на двух листах —
склеивать в один лист.

```js
$.clip.define('walk', {
    frames: [{ src: 'art/hero.png', x: 0, y: 0, w: 32, h: 48 },
             { src: 'art/hero_extra.png', x: 64, y: 0, w: 32, h: 48 }],  // РАЗНЫЕ листы
    fps: 12, loop: true, pivot: [0.5, 1],
});

$('#hero').animate({ clip: 'walk' });   // или .animate('walk')
```

| Вызов | Что делает | Возврат |
|---|---|---|
| `$.clip.define(name, spec)` | объявить клип: `frames`, `fps`, `loop`, `pivot` | описание клипа или `null` (и причина в журнал) |
| `$.clip.get(name)` | описание клипа (копия) | объект или `null` |
| `$.clip.list()` | имена клипов | массив строк |
| `$.clip.remove(name)` | убрать клип | `true`, если он был |

Поля описания:

* `frames` — массив **спрайтов** (числа) или **регионов** `{src,x,y,w,h}`; листы
  могут быть разные — движку всё равно, откуда кадр. Регион, который не удалось
  разрешить, пропускается (в описании остаётся `skipped` — сколько пропало);
* `fps` — кадров в секунду (по умолчанию 12); `duration` считается как `frames / fps`;
* `loop` — повторять ли (по умолчанию да);
* `pivot` — точка вращения в долях кадра (`[0.5, 1]` — «ноги»).

## Границы и отличия

* **Размер узла клип НЕ меняет.** `pivot` двигает только точку вращения (как
  `.pivot()`), кадр рисуется в текущем размере узла. Это сознательная граница с
  пунктом H10 (`.frames()` перезаписывает размер): `fit: true` не делаем, пока
  H10 не закрыт.
* **Клип ≠ `$.anim`.** `$.anim.define` (`docs/highlevel/anim.md`) — клип по
  СВОЙСТВАМ (ключи, дорожки, машина состояний: `scale`, `alpha`, события).
  `$.clip` — клип по КАДРАМ (какой спрайт показывать в этот момент). Их можно
  сочетать: кадры из `$.clip`, а движение/масштаб — дорожками `$.anim`.
* `frames` без клипа работают как раньше: `.frames({src, cols, rows, cw, ch})` и
  `.frames([спрайт, …])`, а `.animate({from,to,speed,loop})` — прежняя форма.

## Проверка

```bash
python3 tests/agent/clip_pitch_test.py
```

Тест проверяет числами: клип с кадрами двух листов играет, `fps`/`loop`/`pivot`
соблюдаются, размер узла остаётся прежним, `.animate('walk')` строкой работает,
`remove()` убирает клип, неизвестный клип не роняет кадр.

## События: кадр и «доиграл»

Первичны события **узла** — на них можно подписаться в любом месте игры:

```js
$('#hero').on('anim:frame', (e) => hud.setFrame(e.data.frame_in_clip));
$('#hero').on('anim:end',   (e) => attack());     // клип дошёл до конца
```

`onFrame`/`onEnd` в описании клипа — **сахар над теми же событиями**: клип
подписывает их при применении (`.animate({clip})`), повторное применение снимает
прежние подписки, так что обработчик не сработает дважды. Второго механизма
«клип доиграл» в движке нет — один путь, одна правда.

| Событие | Когда | Поля `e.data` |
|---|---|---|
| `anim:frame` | кадр **сменился** (не на каждом тике) | `frame` (индекс в массиве кадров), `frame_in_clip` (от начала клипа), `clip` |
| `anim:end` | клип доиграл: `loop: false` — один раз за проигрывание; `loop: true` — **на каждом обороте** | `clip`, `loops` (1 для не-петли, дальше номер оборота) |

Замер (`tests/agent/clip_events_test.py`): 40 узлов × 50 кадров анимации — 415 мс
без подписок и 415 мс с подписками (разница ниже шума измерения); `off()` снимает
подписку, удаление узла не оставляет обработчиков, `loop: true` при fps=20 даёт
25 событий за 30 кадров — по одному на оборот.
