Плавные переходы (tween) и функции плавности на JavaScript: ease-in, ease-out и платформа по маршруту

Коротко

Твин меняет число от начального значения к конечному за заданное время, а функция плавности задаёт характер движения: равномерно, с разгоном, с торможением или с перелётом. Считайте значение из прошедшего времени, а не прибавляйте шаг каждый кадр: тогда результат одинаков на 60 и 144 Гц. Ниже — формулы пяти функций плавности, таблица выбора, код твина и платформы по маршруту с пассажиром, проверенный в Node.js 24.

Содержание

  1. Формула твина: от прошедшего времени, а не от прошлого значения
  2. Функции плавности: пять формул
  3. Те же кривые в CSS: cubic-bezier
  4. Таблица: какую плавность брать в игре
  5. Платформа по маршруту: постоянная скорость по длине пути
  6. Пассажир на платформе
  7. Код на JavaScript
  8. Практический сценарий: ворота и лифт за вечер
  9. Когда вам это не подойдёт

Формула твина: от прошедшего времени, а не от прошлого значения

Значение твина в любой момент вычисляется из прошедшего времени по одной формуле:

t        = min(прошло / длительность, 1)
значение = начало + (конец − начало) × плавность(t)

Частая ошибка — прибавлять к значению «шаг за кадр»: при 144 Гц твин длиной 0,4 с занимает 57,6 кадра, шагов получается 58, и ворота уезжают на 96,67 пикселя вместо 96. Формула от времени даёт точный конец, потому что при t = 1 любая функция плавности возвращает 1. Время ограничивают длительностью, а событие «твин закончился» вызывают ровно один раз.

Функции плавности: пять формул

Функция плавности получает долю времени t от 0 до 1 и возвращает долю пути. Пяти функций хватает для большинства игровых задач:

linear(t)    = t
inQuad(t)    = t²                              — разгон
outQuad(t)   = 1 − (1 − t)²                    — торможение
inOutQuad(t) = 2t², если t < 0,5;
               иначе 1 − (−2t + 2)² / 2        — разгон и торможение
outBack(t)   = 1 + 2,70158 × (t − 1)³ + 1,70158 × (t − 1)²   — с перелётом

В середине времени (t = 0,5) они дают 0,5, 0,25, 0,75, 0,5 и 1,088 соответственно. Значение больше 1 у outBack — это и есть перелёт: объект проскакивает цель максимум на 10% пути (при t ≈ 0,58) и возвращается. Сборник взаимозаменяемых функций плавности публикует Роберт Пеннер; формулы выше — квадратичный вариант и функция с перелётом, остальные подключаются так же — заменой одной функции.

Те же кривые в CSS: cubic-bezier

В CSS функции плавности задаются кривыми Безье, и ключевые слова — это готовые кривые. По MDN, ease-in равно cubic-bezier(0.42, 0, 1, 1), ease-out — cubic-bezier(0, 0, 0.58, 1), ease-in-out — cubic-bezier(0.42, 0, 0.58, 1), а ease — cubic-bezier(0.25, 0.1, 0.25, 1). Есть и ступенчатая функция steps(), которая делит анимацию на равные интервалы.

Для интерфейса поверх холста, свёрстанного в HTML, этого достаточно. Для объектов на холсте CSS не работает, и формулы выше считают в коде игры. Квадратичные функции близки к CSS-ключевым словам по характеру, но не совпадают с ними точно.

Таблица: какую плавность брать в игре

Выбор зависит от того, что должен почувствовать игрок: старт, остановку или удар.

ЗадачаФункцияДлительностьПочему
Окно или счётчик выезжаетoutQuad0,2–0,3 сБыстрая реакция, мягкая остановка
Окно уходит за крайinQuad0,15–0,25 сНе задерживает игрока
Ворота или дверь открываютсяoutQuad0,3–0,5 сВидно начало, нет удара в конце
Лифт между двумя этажамиinOutQuadзависит от высотыПлавный старт и остановка, игрок не подпрыгивает
Монета летит в счётчик, значок награды «пружинит»outBack0,3–0,4 сПерелёт даёт ощущение «упругости»
Платформа по длинному маршрутуlinear по расстояниюскорость в пикс/сПредсказуемая скорость для прыжка

Значения длительности — наши стартовые, их подбирают на глаз в игре.

Платформа по маршруту: постоянная скорость по длине пути

Для маршрута из нескольких точек позицию считают по пройденному расстоянию, а не по номеру отрезка. Если каждому отрезку давать одинаковое время, на длинном отрезке платформа ускорится, и игрок промахнётся при прыжке. Правило:

пройдено = скорость × время  (туда-обратно: по модулю 2 × длина маршрута)
найти отрезок, где лежит «пройдено», и интерполировать внутри него

Для маршрута 300 пикселей вправо и 400 вверх длина 700; при скорости 100 пикс/с через 5 с платформа на середине вертикального отрезка, а через 9 с — там же, но на обратном пути.

Пассажир на платформе

Игроку, стоящему на движущейся платформе, каждый кадр добавляют её смещение за этот кадр. Порядок в кадре: сдвинуть платформу, запомнить смещение, сдвинуть стоящих на ней, затем применить собственное движение игрока и проверить столкновения. Если делать наоборот, игрок «соскальзывает» или проваливается сквозь платформу, которая едет вверх.

Код на JavaScript

Код содержит пять функций плавности, твин от прошедшего времени, маршрут с постоянной скоростью и перенос пассажира. Он проверен в Node.js 24, числа в комментариях совпадают с выводом.

// Функции плавности: t от 0 до 1 → доля пути от 0 до 1
const ease = {
  linear: (t) => t,
  inQuad: (t) => t * t,
  outQuad: (t) => 1 - (1 - t) * (1 - t),
  inOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
  outBack: (t) => {
    const c1 = 1.70158, c3 = c1 + 1;
    return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
  },
};

// Твин считает значение от прошедшего времени, а не прибавляет шаг к значению
function tween(target, key, to, duration, fn, onDone) {
  return { target, key, from: target[key], to, duration, fn, onDone, elapsed: 0, done: false };
}

function updateTween(tw, dt) {
  if (tw.done) return;
  tw.elapsed = Math.min(tw.elapsed + dt, tw.duration);
  const k = tw.fn(tw.elapsed / tw.duration);
  tw.target[tw.key] = tw.from + (tw.to - tw.from) * k;
  if (tw.elapsed === tw.duration) { tw.done = true; if (tw.onDone) tw.onDone(); }
}

// Платформа по маршруту: постоянная скорость по длине пути, туда и обратно
function makePath(points) {
  const lens = [];
  let total = 0;
  for (let i = 1; i < points.length; i++) {
    const l = Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
    lens.push(l);
    total += l;
  }
  return { points, lens, total };
}

function pointAt(path, dist) {
  let d = Math.max(0, Math.min(dist, path.total));
  for (let i = 0; i < path.lens.length; i++) {
    if (d <= path.lens[i]) {
      const a = path.points[i], b = path.points[i + 1], k = d / path.lens[i];
      return { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k };
    }
    d -= path.lens[i];
  }
  return { ...path.points[path.points.length - 1] };
}

// Пройденное расстояние за время t при скорости v и движении туда-обратно
function pingPongDist(path, v, t) {
  const cycle = 2 * path.total;
  const s = (v * t) % cycle;
  return s <= path.total ? s : cycle - s;
}

// Пассажир: игрок, стоящий на платформе, получает её смещение за кадр
function carry(player, before, after) {
  player.x += after.x - before.x;
  player.y += after.y - before.y;
}

// Проверки
console.log(['linear', 'inQuad', 'outQuad', 'inOutQuad', 'outBack']
  .map((n) => n + ' ' + ease[n](0.5).toFixed(3)).join(', '));
// linear 0.500, inQuad 0.250, outQuad 0.750, inOutQuad 0.500, outBack 1.088

function runGate(fps) {
  const gate = { y: 0 };
  let finished = 0;
  const tw = tween(gate, 'y', -96, 0.4, ease.outQuad, () => finished++);
  for (let i = 0; i < fps; i++) updateTween(tw, 1 / fps); // 1 секунда
  return gate.y + ' ' + finished;
}
console.log(runGate(60), '|', runGate(144)); // -96 1 | -96 1

const path = makePath([{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 300, y: -400 }]);
console.log(path.total); // 700
console.log(pointAt(path, pingPongDist(path, 100, 5))); // { x: 300, y: -200 }
console.log(pointAt(path, pingPongDist(path, 100, 9))); // { x: 300, y: -200 } — на обратном пути

const player = { x: 140, y: -48 };
let prev = pointAt(path, pingPongDist(path, 100, 1));
for (let i = 1; i <= 60; i++) {
  const now = pointAt(path, pingPongDist(path, 100, 1 + i / 60));
  carry(player, prev, now);
  prev = now;
}
console.log(Math.round(player.x), Math.round(player.y)); // 240 -48 — проехал вместе с платформой 100 пикс

Ворота уезжают на 96 пикселей и вызывают завершение ровно один раз и на 60, и на 144 Гц; игрок за 1 секунду проезжает с платформой 100 пикселей.

Практический сценарий: ворота и лифт за вечер

Добавить в уровень ворота и лифт можно за 5 шагов:

  1. Ворота. По событию «рычаг нажат» запустите твин высоты ворот на 96 пикселей, outQuad, 0,4 с; столкновения ворот отключайте в обработчике завершения.
  2. Лифт. Задайте маршрут из 2–4 точек и скорость 80–120 пикс/с; позицию считайте по пройденному расстоянию.
  3. Пассажир. Определите, что игрок стоит на лифте (низ игрока на верхней грани лифта), и добавляйте ему смещение лифта каждый кадр.
  4. Проверка частоты. Запустите сцену при 60 и 144 Гц или с искусственно большим dt: конечные позиции должны совпасть.
  5. Отмена. Если рычаг переключают во время движения, начинайте новый твин от текущего значения, а не от исходного, иначе ворота «прыгнут».

Когда вам это не подойдёт

Твины хороши для заранее известного движения, но не везде:

  • Движение, на которое влияют силы: отскоки, падение, толчки от взрыва. Здесь нужна скорость и интегрирование по времени, а не твин между двумя точками.
  • Сложная анимация персонажа: десятки связанных костей или кадров удобнее делать покадровой или скелетной анимацией в специализированном редакторе.
  • Плавное слежение за движущейся целью: камера за героем — это сглаживание с экспонентой, а не твин к точке, которая всё время меняется.

Мы разрабатываем J2DRU — браузерный 2D игровой движок, где сцены, спрайты и игровая логика собираются в браузере, а проект можно углублять на TypeScript. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Источники (проверено: ноябрь 2026): MDN — easing-function (значения cubic-bezier для ключевых слов, steps()), Robert Penner — Easing Functions (сборник сменных функций плавности). Формулы квадратичных функций и все числа примеров — наш расчёт, проверенный в Node.js 24.

Читайте также:

Частые вопросы

Что такое tween в играх?

Это плавное изменение одного свойства — позиции, прозрачности, масштаба — от значения А к значению Б за заданное время. Промежуточные значения между А и Б игра считает сама каждый кадр.

Какую функцию плавности выбрать?

Для появления окон и открывающихся дверей обычно берут ease-out: быстро стартует и мягко останавливается. Для ухода объекта за край — ease-in. Для движения между двумя точками туда и обратно — ease-in-out. Для «пружинящих» значков и наград — функцию с перелётом.

Почему твин на мощном мониторе заканчивается не там, где нужно?

Обычно значение увеличивают на шаг каждый кадр, и ошибки округления накапливаются. Считайте значение заново из прошедшего времени и ограничивайте время длительностью твина, тогда конечное значение будет точным при любой частоте кадров.

Почему игрок соскальзывает с движущейся платформы?

Платформа двигается, а игрок нет. Каждый кадр добавляйте игроку смещение платформы за этот кадр, пока он на ней стоит, а потом уже применяйте его собственное движение.