Коротко
Твин меняет число от начального значения к конечному за заданное время, а функция плавности задаёт характер движения: равномерно, с разгоном, с торможением или с перелётом. Считайте значение из прошедшего времени, а не прибавляйте шаг каждый кадр: тогда результат одинаков на 60 и 144 Гц. Ниже — формулы пяти функций плавности, таблица выбора, код твина и платформы по маршруту с пассажиром, проверенный в Node.js 24.
Содержание
- Формула твина: от прошедшего времени, а не от прошлого значения
- Функции плавности: пять формул
- Те же кривые в CSS: cubic-bezier
- Таблица: какую плавность брать в игре
- Платформа по маршруту: постоянная скорость по длине пути
- Пассажир на платформе
- Код на JavaScript
- Практический сценарий: ворота и лифт за вечер
- Когда вам это не подойдёт
Формула твина: от прошедшего времени, а не от прошлого значения
Значение твина в любой момент вычисляется из прошедшего времени по одной формуле:
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-ключевым словам по характеру, но не совпадают с ними точно.
Таблица: какую плавность брать в игре
Выбор зависит от того, что должен почувствовать игрок: старт, остановку или удар.
| Задача | Функция | Длительность | Почему |
|---|---|---|---|
| Окно или счётчик выезжает | outQuad | 0,2–0,3 с | Быстрая реакция, мягкая остановка |
| Окно уходит за край | inQuad | 0,15–0,25 с | Не задерживает игрока |
| Ворота или дверь открываются | outQuad | 0,3–0,5 с | Видно начало, нет удара в конце |
| Лифт между двумя этажами | inOutQuad | зависит от высоты | Плавный старт и остановка, игрок не подпрыгивает |
| Монета летит в счётчик, значок награды «пружинит» | outBack | 0,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 шагов:
- Ворота. По событию «рычаг нажат» запустите твин высоты ворот на 96 пикселей,
outQuad, 0,4 с; столкновения ворот отключайте в обработчике завершения. - Лифт. Задайте маршрут из 2–4 точек и скорость 80–120 пикс/с; позицию считайте по пройденному расстоянию.
- Пассажир. Определите, что игрок стоит на лифте (низ игрока на верхней грани лифта), и добавляйте ему смещение лифта каждый кадр.
- Проверка частоты. Запустите сцену при 60 и 144 Гц или с искусственно большим
dt: конечные позиции должны совпасть. - Отмена. Если рычаг переключают во время движения, начинайте новый твин от текущего значения, а не от исходного, иначе ворота «прыгнут».
Когда вам это не подойдёт
Твины хороши для заранее известного движения, но не везде:
- Движение, на которое влияют силы: отскоки, падение, толчки от взрыва. Здесь нужна скорость и интегрирование по времени, а не твин между двумя точками.
- Сложная анимация персонажа: десятки связанных костей или кадров удобнее делать покадровой или скелетной анимацией в специализированном редакторе.
- Плавное слежение за движущейся целью: камера за героем — это сглаживание с экспонентой, а не твин к точке, которая всё время меняется.
Мы разрабатываем J2DRU — браузерный 2D игровой движок, где сцены, спрайты и игровая логика собираются в браузере, а проект можно углублять на TypeScript. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники (проверено: ноябрь 2026): MDN — easing-function (значения cubic-bezier для ключевых слов, steps()), Robert Penner — Easing Functions (сборник сменных функций плавности). Формулы квадратичных функций и все числа примеров — наш расчёт, проверенный в Node.js 24.
Читайте также: