# Анимация: клипы, аниматоры, оживители, реестр тика

Все анимации движка тикаются от фиксированного шага (60 Гц) — Pixi-тикер
не используется (`sharedTicker: false`), Pixi `AnimatedSprite` не применяется.
Чистая математика (клипы, оживители, симуляция частиц) живёт отдельно от
тонких Pixi-адаптеров и тестируется в Vitest без браузера.

Модули:

```
anim/clip.ts           чистый степпер клипов (loop/once/pingpong)
anim/SpriteAnimator.ts кадры → Sprite, именованные клипы, события
anim/motion.ts         чистые «оживители» статики (bob/sway/pulse/blink)
anim/SpriteMotion.ts   Pixi-адаптер оживителей (view → колебания)
```

## Реестр тика: `engine.fx`

Эффекты, аниматоры и вспышки не требуют ручного `update(dt)` из сцены —
добавьте объект в `engine.fx` (`core/Updatables`), движок тикает его сам
между твин-ами и логикой сцены:

```ts
engine.fx.add(emitter);          // возвращает тот же объект
engine.fx.add(new SpriteMotion(view, { bob: {...} }, { seed: 7 }));
engine.fx.remove(fx);            // редко: обычно достаточно destroyed
engine.fx.clear();               // при выходе из сцены, если она не разрушает дерево
```

Самозачистка: объект с `destroyed === true` не тикается и вычищается из
реестра после
очередного прохода (Pixi `destroy()` выставляет флаг сам — эффекты,
живущие в разрушаемом дереве мира, отпадают без кода; `SpriteAnimator`
и `SpriteMotion` проксируют `destroyed` своего вью). Допускается и
локальный инстанс `Updatables` внутри сцены (тот же класс, свой тик).

## Клипы и SpriteAnimator

```ts
import { SpriteAnimator } from '@rpg/engine';

const animator = new SpriteAnimator(sprite, {
    walk_down: { frames: assets.frames('chars/hero_sheet.json', 'hero_down'), fps: 6 },
    idle:      { frames: [assets.frames('chars/hero_sheet.json', 'hero_down')[0]], loop: 'once' }
}, 'walk_down', { randomPhase: seed });   // seed — рассинхрон толпы

animator.play('walk_down', { restart: true }); // тот же клип без restart — продолжение
animator.pause();          // ... resume(), showStep(0) — заморозить на шаге
animator.onFrame = (step) => ...;    // смена шага
animator.onFinish = (clip) => ...;   // конец once/pingpong-прохода
animator.setClipFrames('walk_down', otherFrames); // кадры на лету (вариации окраса)
```

Режимы клипа (`ClipLoop`): `'loop'` — цикл; `'once'` — один раз и стоп
на последнем кадре (после конца аниматор сам паузится); `'pingpong'` —
туда-обратно без дубля крайних кадров. Математика — чистая `stepClip`
в `anim/clip.ts`: время накапливается в «шагах», dt больше периода
проматывает промежуточные шаги (сообщается только финальный) — по
аналогии с пересечением `delay` в Tween.

`sprite.texture` пишется только при смене шага. Флип и anchor — зона
владельца спрайта (см. рецепт ходьбы в `recipes.md`).

## Оживители статики: SpriteMotion

Процедурное движение без кадров — для всего, что не умеет ходить: парящие
сгустки, качающиеся колокольчики, пульсирующие искры, мигающие огни. Математика
— чистая `sampleMotion(params, t)` (тестируется без Pixi), Pixi-адаптер —
`SpriteMotion`:

```ts
import { SpriteMotion } from '@rpg/engine';

const motion = new SpriteMotion(bellView, {
    sway: { amplitude: 0.06, period: 2.2 }   // рад вокруг основания
}, { seed: tileY * 64 + tileX });            // рассинхрон кустов — детерминирован
engine.fx.add(motion);
```

Параметры (комбинируются, эффекты перемножаются):

| Параметр | Движение | Для чего |
| --- | --- | --- |
| `bob { amplitude, period }` | парение по Y (px, **округлено до целого**) | сгустки, духи, всё «невесомое» |
| `sway { amplitude, period }` | покачивание рад вокруг основания | колокольчики, цветы, трава — **укоренённое** |
| `pulse { min, max, period }` | пульс alpha и scale треугольной волной | свечение искр, магические предметы |
| `blink { period, duty? }` | строб 0/1 (duty — доля «включённого») | огни, маяки, акценты |

Правила выбора: `bob` — только для объектов, не имеющих контакта с землёй
(парящие), иначе рвётся «тень-контакт»; `sway` — для укоренённых (поворот вокруг
основания сохраняет контакт); `pulse`/`blink` — для свечения и акцентов.

`SpriteMotion` снимает базовые `position/rotation/alpha/scale` в конструкторе —
позиционируйте view **до** его создания. Важно: позиция/поворот перезаписываются
каждый тик только когда есть `bob`/`sway` — view с чистым `pulse`/`blink`
(светлячок, мигающий герой) можно двигать извне, оживитель позицию не тронет.
`params` живые: меняйте на лету (колокол качается только пока звонят).
`detach()` возвращает исходные значения (только тех полей, которые трогал).
Фаза из `seed` — детерминированная (никакого `Math.random`).

## Анимированные тайлы: setTileAnimation

Вода и прочие «живые» ground-тайлы — не спрайтовые аниматоры, а один общий
таймлайн на все клетки с данным id (дёшево при сотнях клеток воды):

```ts
map.setTileAnimation(TILES.WATER, [assets.texture('tiles/water_1'),
                                   assets.texture('tiles/water_2')], 2); // fps
// в update сцены (no-op, если анимаций нет):
map.update(dt);
```

Кадры — обычные текстуры (например, из `tiles/water_1.png`/`water_2.png`);
клетки, нарисованные до вызова, регистрируются автоматически, `setTile`
перерегистрирует при перерисовке.