Timeline

Хореография на одних часах: треки на любом числе целей — UI-элементах, 3D-узлах, источниках света, камере, 2D-узлах и спрайтах — расставленные по абсолютному времени, плюс вызовы JS, метки и паузы, всё под управлением одного хендла Animation. Собери один раз и play() при каждом появлении. Для одной цели её собственные .animateTo() / .animateFrom() принимают тот же набор пропов и возвращают тот же хендл; для свободного значения, которое применяешь сам, используй animate.

Обзор

TypeScript
const title = UIText('LEVEL 1').style({ fontSize: 40, color: 'white', opacity: 0 })
const hint = UIText('Tap to start').style({ fontSize: 18, color: '#9fb0d0', opacity: 0 })
UIScreen(title, hint).style({ alignItems: 'center', justifyContent: 'center', gap: 12 }).open()

const intro = Timeline()
  .add(title, { opacity: 1, transform: ['translateY(24px)', 'translateY(0px)'], duration: 500, easing: 'outCubic' })
  .label('shown')                                              // = текущий конец: 500 мс
  .add(hint, { opacity: [0, 1, 0.4, 1], duration: 900 }, { at: ['shown', -200] })   // стартует на 300 мс
  .call('shown', () => console.log('title in'))
intro.play()
intro.finished.then((done) => console.log('intro done', done))  // через 1200 мс

Сборка

TypeScript
Timeline(options?: {
  clock?: 'ui' | 'game',               // по умолчанию 'ui', какими бы ни были цели
  loop?: boolean | number,             // повторять весь таймлайн: true = вечно, n = циклов
  loopMode?: 'ping-pong' | 'restart',  // по умолчанию 'ping-pong'
  delay?: number,                      // мс до первого цикла
}): Timeline

tl.add(target | targets[], props, { at?, stagger? }?): this      // как target.animateTo(props)
tl.addFrom(target | targets[], props, { at?, stagger? }?): this  // как animateFrom: собственное состояние цели — последний ключ
tl.animate(options, at?): this         // трек свободного значения — опции animate()
tl.call(at, fn: () => void): this      // выполнить JS, когда воспроизведение пересекает `at`
tl.label(name, at?): this              // назвать момент времени; по умолчанию = текущий конец
tl.hold(ms): this                      // неподвижность после текущего конца
tl.labels: Record<string, number>      // именованные моменты, мс
tl.end: number                         // текущий конец, мс

type TimelinePosition = number | string | [label: string, offsetMs: number]
  • Любое время — миллисекунды от начала таймлайна. at — число, метка или [label, offset]; неизвестная метка бросает исключение. Без at трек стартует в текущем конце, так что последовательные add играют один за другим.
  • props — набор animateTo: проп → значение (одно значение твинится от текущего, массив — ключевые кадры), плюс duration (мс, по умолчанию 225), delay (мс после at), easing (по умолчанию ease-in-out, cubic-bezier(0.42, 0, 0.58, 1) — см. изинги), times, commit. loop, loopMode и clock принадлежат таймлайну, а не треку.
  • С массивом целей stagger запускает каждую на столько мс позже предыдущей.
  • call выполняет fn, когда воспроизведение пересекает его время в любом направлении (seek, отрицательный rate), в порядке времени, до завершения таймлайна; вызов на 0 выполняется при play().
Note

onComplete трека tl.animate(…) не вызывается — используй tl.call(at, fn) или tl.finished. onUpdate работает как в animate().

Что анимирует каждая цель

цель пропы
UI-элемент любой проп стиля (opacity, width, bgColor, transform, …): числа, длины и цвета интерполируются, transform — по функциям списка, прочие строки переключаются на ключевом кадре; text на текстовых элементах (переключение)
3D-узел (Mesh, Model, Node) position, scale, quaternion (slerp), eulerAngles (градусы, по осям — [0, 720, 0] делает два оборота)
Light пропы узла + intensity, color
камера (scene.camera) пропы узла + fov (градусы), near, far
Node2D x, y, position, rotation (градусы), scale
Sprite пропы 2D-узла + opacity, color

3D / 2D цель пропускает проп, который не может анимировать, с однократным предупреждением. Как твинятся UI-пропы (единицы, цвета, transform) — в Стилизации.

Note

для векторных пропов — position, scale, quaternion, eulerAngles и color на 3D / 2D целях — массив чисел это ОДНО значение; ключевые кадры — массив векторов: scale: [[1, 1, 1], [1.2, 1.2, 1.2], [1, 1, 1]]. scale: 1.5 — равномерно.

Note

трек пишет трансформ 3D-узла напрямую. Узел, который двигает физическое тело или контроллер персонажа, через его тело не проводится — анимируй обычный узел.

Воспроизведение

Таймлайн — это Animation: play(), pause(), resume(), seek(), finish(), cancel(), time, progress, rate, finished: см. хендл Animation. seek принимает и метку (tl.seek('shown')).

  • До play() ничего не выполняется. Повторный play() перезапускает с 0 и завершает finished предыдущего запуска значением false.
  • Поза в любой момент полностью определена треками: при play() каждая цель прыгает на первый ключевой кадр своего трека — даже когда этот трек стартует позже — а после конца трека его последний ключевой кадр удерживается.
  • Канал принадлежит самой новой анимации: запуск таймлайна забирает названные им пропы у любой более старой анимации на тех же целях; их остальные пропы продолжают работать.
  • Одноразовый трек фиксирует свой последний ключевой кадр при play(): с этого момента style UI-элемента, x / y / position / rotation / scale 2D-узла, opacity / color спрайта, intensity источника света и near / far камеры читают итоговое значение, пока поза ещё движется. Трансформ 3D-узла и camera.fov читают живую позу; light.color продолжает читать значение, которое было до твина.
Note

таймлайн идёт на часах 'ui', если не передать clock: 'game' — даже над 3D-узлами, чей собственный animateTo по умолчанию на 'game'. Часы 'game' следуют Time.scale / Time.paused на хосте с 3D-движком, когда существует 3D Scene; см. Часы.

TypeScript
const scene = new Scene()
const sun = Light.sun({ intensity: 20000 })
const door = Mesh.box({ material: Material.lit({ color: '#8a6' }), position: [0, 1, -6], scale: [2, 2, 0.2] })
const gems = [-2, 0, 2].map((x) => Mesh.sphere({ material: Material.lit({ color: '#4f7cff' }), position: [x, 0.5, -3], scale: 0.4 }))
scene.add(sun, door, ...gems)
scene.open()

const opening = Timeline({ clock: 'game' })                 // останавливается, пока Time.paused
  .add(scene.camera, { position: [[0, 2, 6], [0, 1.5, 1]], fov: 40, duration: 1500 }, { at: 0 })
  .add(door, { position: [0, 3.2, -6], duration: 800, easing: 'inOutCubic' }, { at: 600 })
  .add(gems, { eulerAngles: [0, 360, 0], duration: 600 }, { at: 900, stagger: 120 })   // по одному обороту каждый
  .add(sun, { intensity: 60000, color: '#ffd9a0', duration: 1200 }, { at: 300 })
  .call(1500, () => console.log('cutscene over'))
opening.play()

Подводные камни

TypeScript
// ✗ собрать и ждать — таймлайн ничего не делает, пока его не запустят
const fade = Timeline().add(card, { opacity: 1 })
// ✓ запусти его, и снова при каждом появлении
fade.play()

// ✗ числа на векторном пропе — ОДИН вектор: это масштабирует к (1, 1.2, 1)
Timeline().add(gem, { scale: [1, 1.2, 1] }).play()
// ✓ ключевые кадры векторного пропа — векторы
Timeline().add(gem, { scale: [[1, 1, 1], [1.2, 1.2, 1.2], [1, 1, 1]] }).play()

Смотрите также

  • animate — свободные значения, хендл Animation, часы
  • Изинги — имена кривых, cubicBezier, steps
  • Стилизация — .animateTo() / .animateFrom() и пропы стиля элементов
  • Time — Time.scale / Time.paused