Все анимации движка тикаются от фиксированного шага (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), движок тикает его сам между твин-ами и логикой сцены:
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 внутри сцены (тот же класс, свой тик).
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).
Процедурное движение без кадров — для всего, что не умеет ходить: парящие сгустки, качающиеся колокольчики, пульсирующие искры, мигающие огни. Математика — чистая sampleMotion(params, t) (тестируется без Pixi), Pixi-адаптер — SpriteMotion:
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).
Вода и прочие «живые» ground-тайлы — не спрайтовые аниматоры, а один общий таймлайн на все клетки с данным id (дёшево при сотнях клеток воды):
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 перерегистрирует при перерисовке.