Gravity Defied: мотоциклист-палочка бросает вызов гравитации
В этой статье я напишу собственный аналог Gravity Defied для браузера — легендарной мобильной игры про мотоциклиста-стикмена. Проект реализован на чистом JavaScript и Canvas без сторонних библиотек и работает как на компьютере, так и на телефоне, где управление построено на джойстике — это развитие идей из моей прошлой статьи.
Материал разделён на несколько частей. Во введении кратко расскажу, что меня подтолкнуло к этой затее, и представлю саму игру. В основной части разберу, как создавался клон: начну с того, что видит игрок, затем перейду к внутреннему устройству и трудностям, с которыми столкнулся. В финале подведу итоги и честно признаю, чем моя версия пока уступает оригиналу. Ссылки на игру и репозиторий приведены в конце.
Введение
Игра, в которую я никогда не играл
Обычно я открываю статью рассказом о том, как в детстве часами играл в очередную игру, но на этот раз такой истории не будет: в Gravity Defied я не играл вовсе. Ближе всего к ней оказался iPod touch четвёртого поколения, на котором я гонял в Hill Climb Racing: машинка, холмы и бесконечные попытки проехать хоть немного дальше, чем в прошлый раз. На мой взгляд, по духу это очень близко к Gravity Defied.
Если спросить, зачем браться за игру, в которую никогда не играл, то причина проста: недавно мы с другом собрались поиграть на PS5, и у него оказалась Trials Rising — снова мотоцикл, физика и сотни попыток пройти одну и ту же трассу. После той встречи мне стало любопытно, почему я раньше ничего не слышал об этой игре и откуда вообще взялся такой жанр. Уже первая найденная ссылка подсказала: культовым представителем жанра считается Gravity Defied.
Дальше всё сложилось само собой: раз я уже делал Flappy Bird, Pac-Man и тамагочи, почему бы не взяться за мотоцикл? Делать ремейк игры, которую ни разу не запускал, — затея необычная, но с тамагочи было то же самое, и это ничему не помешало: вместо ностальгии остаётся лишь интерес к тому, как всё устроено.
Немного истории

Игру разработала студия Codebrew Software, и начиналась она как конкурсная работа для Excitera Mobile Awards 2004. На финале в Стокгольме в мае 2004 года проект, который тогда ещё носил рабочее название A-Trial, завоевал главный приз — Best in Show. Демоверсию выпустили в конце августа того же года, и её скачали более 5 тыс. раз, а полноценный релиз состоялся 2 апреля 2005 года (Википедия, дата обращения: 14.10.2026).
Для запуска требовались лишь поддержка J2ME и примерно 80 КБ памяти, поэтому игра работала практически на любом кнопочном телефоне той эпохи. Затем у неё началась самостоятельная жизнь: с 2007 года фанаты стали выпускать редакторы трасс и модификации, в 2008-м Codebrew анонсировала вторую часть, которая так и не увидела свет, а в 2012 году вышла официальная версия для Android (Википедия, дата обращения: 14.10.2026).
Сами разработчики так вспоминают финал конкурса:
«The finals were tough with many good entries but the game A-Trial finally came out on top and won the best-in-show award».
«Финал был тяжёлым, сильных работ было много, но A-Trial в итоге вышла на первое место и получила главную награду конкурса (best-in-show)».
— Codebrew Software, раздел «About the project» на официальном сайте игры (архивная копия, дата обращения: 14.10.2026)
Получается, что скромный конкурсный проект вырос в игру, которая держится на единственной идее — мотоцикле, требующем аккуратного управления. Отсюда и цель этой статьи: понять, как может быть устроена такая игра, и воссоздать её с нуля — от картинки на экране до физики под капотом.
Основная часть
Что видит игрок
Так выглядит заезд по первой трассе:

Задача осталась прежней: добраться до финиша как можно быстрее и не удариться головой. Трассы поделены на три лиги — «Новичок», «Любитель» и «Профи». Каждая следующая трасса открывается лишь после прохождения предыдущей, а завершённая лига дарит новый мотоцикл. Лучшее время на каждой трассе сохраняется в браузере.

Во время заезда на экране остаются только таймер и две кнопки: подсказки по управлению вынесены на отдельный экран «Правила». На компьютере W отвечает за газ, S — за тормоз, A и D наклоняют мотоцикл назад и вперёд, R перезапускает трассу, а Esc ставит игру на паузу.
На телефоне вместо клавиш появляется джойстик. О нём я расскажу чуть позже (ниже).


Как устроен проект
Структура выглядит так:
index.html
css/style.css
js/
├── main.js, config.js — точка входа и все настройки
├── physics.js — векторы, точки, колёса, пружины, столкновения
├── input.js — клавиатура и джойстик
├── entities/ — мотоцикл, гонщик, авария
├── track/ — сборка трассы и уровни
├── game/ — правила игры, игровой цикл, рекорды и призрак
├── render/ — камера и вся отрисовка
├── ui/ — таймер и экраны
└── editor/ — редактор трасс
Физика ничего не знает об отрисовке, а отрисовка — о клавиатуре. Игровой слой лишь связывает эти части воедино, а все числа, определяющие поведение мотоцикла, собраны в config.js, поэтому физику можно настраивать, не трогая остальной код.
Мотоцикл из точек и пружин
Мотоцикл здесь — не спрайт и не жёсткий прямоугольник, а четыре точки с массой: два колеса, рама и голова гонщика, соединённые шестью пружинами. Там, где у настоящего мотоцикла металл, пружины жёсткие, а на месте подвески — мягкие. Голова тоже является физической точкой, поэтому отдельно проверять, не перевернулся ли мотоцикл, не нужно: коснулась головой земли — значит, авария.
Три мотоцикла, один класс
Модели различаются лишь набором параметров: есть базовая, а остальные переопределяют в ней только необходимое.
const BASE_MODEL = {
power: 3000,
topSpeed: 780,
leanTorque: 6,
flipSpeed: 9,
size: 1,
color: '#d32f2f',
trim: '#8e1c1c',
extras: [],
};
export const BIKE_MODELS = [
{ name: '100cc' },
{
name: '175cc',
power: 3600,
topSpeed: 930,
flipSpeed: 8.5,
size: 1.06,
color: '#1e88e5',
trim: '#0d47a1',
extras: ['fender'],
},
// 220cc устроен так же
].map(model => ({ ...BASE_MODEL, ...model }));
Чем мощнее мотоцикл, тем резче разгон и выше максимальная скорость: жёлтый 220cc отрывается от красного уже в первые секунды. Помимо цвета, модели отличаются размерами и деталями: у синей есть переднее крыло, у жёлтой — обтекатель. Размер влияет не только на внешний вид — вместе с ним увеличиваются колёса и рама в физической модели.

Как колесо сцепляется с землёй
Мотор ничего не толкает напрямую — он лишь раскручивает заднее колесо, а всё остальное берёт на себя трение:
applyContact() {
if (!this.contact) {
return;
}
const tangent = this.contact.perpendicular();
const rimSpeed = this.spin * this.radius;
const slip = this.velocity.dot(tangent) - rimSpeed;
const impulse = slip * CONFIG.WHEEL_GRIP / 2;
this.accelerate(tangent.scale(-impulse));
this.spin += impulse / this.radius;
}
Если обод вращается быстрее, чем колесо движется по земле, разница делится пополам: одна часть разгоняет колесо, другая — притормаживает его вращение. Именно поэтому мотоцикл буксует на старте. Тормоз просто гасит вращение колёс и останавливает мотоцикл через то же самое трение, а при резком торможении заднее колесо может даже подняться в воздух.
Шаг физики
Весь мир обновляется одним методом:
step(body, track, dt) {
const { points, springs } = body;
points.forEach(point => point.integrate(this.gravity, dt));
springs.forEach(spring => spring.prepare());
for (let i = 0; i < CONFIG.SOLVER_ITERATIONS; i++) {
springs.forEach(spring => spring.solve(dt));
points.forEach(point => {
resolveCollision(point, track, body.loopSide);
});
}
points.forEach(point => point.updateVelocity(dt));
springs.forEach(spring => spring.damp());
points.forEach(point => point.applyContact());
body.loopSide = track.loopSide(body.center(), body.loopSide);
Это упрощённая реализация Наклон и сальто
Главной причиной популярности оригинала автор обзора на Ferra.ru считает именно управление, где всё зависит от точности наклона: «Это был не минус, а величайший из плюсов, потому что малейшая неточность, крошечная ошибка в наклоне могли привести к тому, что тело главного героя коснётся земли.» — Денис Зайченко, «Gravity Defied Original – Чистопородный мобильный винтаж», Ferra.ru, 14 июня 2014 года (дата обращения: 14.10.2026) Поэтому именно наклон отнял у меня больше всего времени. В коде он выглядит так: На земле наклон работает сразу за счёт двух эффектов. Гонщик переносит вес: одна пружина от колеса до головы немного удлиняется, другая укорачивается. Одновременно весь мотоцикл получает небольшой поворот вокруг центра масс — именно благодаря ему можно встать на заднее колесо. Сложнее всего оказалось подобрать, насколько сильно гонщик смещает вес. Голова, рама и колёса образуют высокий узкий треугольник, где даже незначительное изменение длины пружин заметно сдвигает голову назад. При слишком сильном смещении мотоцикл с газом и наклоном назад опрокидывался прямо на месте, при слишком слабом — наклон почти ни на что не влиял. С сальто вышла отдельная история: в первой версии их было почти невозможно выполнить, потому что в воздухе мотоцикл вращался медленно, а трамплины подбрасывали его меньше чем на полсекунды. Теперь в полёте наклон задаёт скорость вращения: пока держишь клавишу, мотоцикл крутится, а стоит её отпустить — вращение быстро затухает. У каждого мотоцикла своя скорость вращения: синий крутится медленнее всех, жёлтый — быстрее. Рисовать трассы по точкам вручную мне не хотелось, поэтому каждый уровень описывается просто списком участков: Конструктор трасс превращает каждый участок в набор точек: холм строится по косинусу, а трамплин — по параболе, обрывающейся уступом: Трасса представляет собой ломаную из сотен коротких отрезков, и сверять каждое колесо со всеми отрезками на каждом шаге было бы слишком расточительно. Поэтому трасса заранее разбита на вертикальные полосы шириной 100 пикселей, и точка проверяет только свою полосу. Само столкновение сводится к тому, чтобы вытолкнуть точку из ближайшего отрезка на расстояние её радиуса: Строка с Когда вы устанавливаете рекорд, заезд записывается: двадцать раз в секунду сохраняется положение колёс, рамы и головы. В следующий раз рядом едет полупрозрачная копия, и сразу видно, на каком участке вы теряете время. Между записанными кадрами положение плавно досчитывается, поэтому призрак не дёргается. Клавиатура отслеживает не буквы, а физические клавиши: Если использовать На телефоне, по итогам прошлой статьи, вместо кнопок теперь джойстик. Он возвращает направление от −1 до 1 по каждой оси, а в центре предусмотрена мёртвая зона, чтобы палец, просто лежащий на джойстике, не включал газ: Клавиатура и джойстик работают одновременно: от каждого источника берётся максимальное значение. Труднее всего было не написать физику, а добиться от неё правдоподобного поведения. В первой версии пружины при нескольких проходах становились почти жёсткими, и подвеска под весом гонщика не проседала вовсе, поэтому пришлось переписать их на
lean(target, dt) {
const limit = CONFIG.LEAN_SPEED * dt;
const change = target - this.leanAmount;
this.leanAmount += clamp(change, -limit, limit);
const shift = this.leanAmount * CONFIG.LEAN_SHIFT;
this.rearToHead.stretch(1 + shift);
this.frontToHead.stretch(1 - shift);
this.rotate(this.isFlying()
? this.flipTurn(dt)
: this.leanAmount * this.model.leanTorque * dt);
}
flipTurn(dt) {
const wanted = this.leanAmount * this.model.flipSpeed;
const limit = CONFIG.FLIP_TORQUE * dt;
return clamp(wanted - this.rotationSpeed(), -limit, limit);
}
Трасса из кубиков
{
id: 'warmup',
name: 'Разминка',
sections: [
{ type: 'hill', length: 600, height: 40 },
{ type: 'flat', length: 200 },
{ type: 'waves', count: 4, length: 150, height: 10 },
{ type: 'flat', length: 200 },
{ type: 'smoothSlope', length: 500, height: 80 },
// ...
],
}
hill({ length, height }) {
this.curve(length, ratio => height * fullWave(ratio));
}
jump({ length, height }) {
this.curve(length, ratio => height * ratio * ratio);
this.lineTo(0, -height);
}

Столкновения
const isInner = ratio > 0 && ratio < 1;
const isBehind = isInner && offset.dot(segment.normal) < 0;
const normal = isBehind ? segment.normal : offset.scale(1 / distance);
point.moveTo(closest.add(normal.scale(point.radius)));
point.touch(normal, segment.isDeadly);
isInner выглядит безобидно, но появилась она после одного неприятного бага, о котором я расскажу ниже.Призрак рекорда

Управление: клавиши и джойстик
const CONTROL_KEYS = {
gas: ['KeyW', 'ArrowUp'],
brake: ['KeyS', 'ArrowDown'],
leanBack: ['KeyA', 'ArrowLeft'],
leanForward: ['KeyD', 'ArrowRight'],
};
event.key, то в русской раскладке вместо w придёт ц, и игра просто не отреагирует, тогда как event.code от раскладки не зависит.function axisValue(value) {
const deadZone = CONFIG.JOYSTICK_DEAD_ZONE;
if (value <= deadZone) {
return 0;
}
return Math.min((value - deadZone) / (1 - deadZone), 1);
Что оказалось сложным
«Киберпанк: Бегущие по краю 2» вышел на Netflix
В сетевом шутере The Finals появились динамические комментарии во время матчей на основе ИИ
PS6 за $1000? Опрос выяснил, сколько игроков готовы платить за консоль
Для Directive 8020 вышло бесплатное обновление с онлайн-кооперативом на пятерых
BIGDOG STUDIO выпустит неофициальную фигурку Алекса Мерсера из Prototype, представленного как ходячая биологическая угроза
Как удар беспилотников по «Яндексу» едва не погубил мою инди-игру
Владелец физического издания игры для Switch 2 столкнулся с проблемой, и Nintendo направила его к издателю
Княжества Юга в Total War: Warhammer 3