Gravity Defied: стик-человечек на мотоцикле
- среда, 21 октября 2026 г. в 00:00:06

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

Gravity Defied сделала студия 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);
Это упрощённая схема position based dynamics. Точки сначала двигаются по инерции, затем за несколько проходов пружины и столкновения поправляют их положение, и только после этого по новым позициям считаются скорости, к которым применяются амортизаторы и трение. Последняя строчка нужна для мёртвой петли, о ней ниже. Шаг при этом фиксированный — 120 раз в секунду независимо от частоты обновления экрана, потому что иначе на мониторе 144 Гц и на телефоне с 60 Гц мотоцикл вёл бы себя по-разному.
Главной причиной популярности оригинала автор обзора на Ferra.ru называет именно управление, где всё решает точность наклона:
«Это был не минус, а величайший из плюсов, потому что малейшая неточность, крошечная ошибка в наклоне могли привести к тому, что тело главного героя коснётся земли.»
— Денис Зайченко, «Gravity Defied Original – Чистопородный мобильный винтаж», Ferra.ru, 14 июня 2014 года (дата обращения: 14.10.2026)
Поэтому на наклон у меня ушло больше всего времени. В коде он выглядит так:
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); }

Трасса — это ломаная из сотен коротких отрезков, и проверять каждое колесо со всеми отрезками на каждом шаге было бы расточительно, поэтому трасса заранее разложена на вертикальные полосы шириной 100 пикселей, а точка проверяет только свою полосу. Само столкновение сводится к тому, чтобы вытолкнуть точку из ближайшего отрезка на расстояние её радиуса:
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 от раскладки не зависит.
На телефоне, по итогам прошлой статьи, вместо кнопок теперь джойстик. Он возвращает направление от −1 до 1 по каждой оси, а в центре есть мёртвая зона, чтобы палец, просто лежащий на джойстике, не включал газ:
function axisValue(value) { const deadZone = CONFIG.JOYSTICK_DEAD_ZONE; if (value <= deadZone) { return 0; } return Math.min((value - deadZone) / (1 - deadZone), 1);
Клавиатура и джойстик работают одновременно: от каждого источника берётся максимальное значение.
Сложнее всего было не написать физику, а заставить её вести себя правдоподобно. Первая версия пружин при нескольких проходах становилась почти жёсткой, и подвеска под весом гонщика не проседала вообще, поэтому пружины пришлось переписать на XPBD — вариант, где жёсткость задаётся в физических единицах и не зависит от числа проходов. Потом я долго искал в физике ошибку, из-за которой мотоцикл разбивался в первой же низине второго уровня. Ошибка оказалась в трассе: края низины изгибались так резко, что на скорости мотоцикл честно отрывался от земли и переворачивался. Был и баг на краю трамплина, где колесо, оказавшись за вертикальным отрезком обрыва, выталкивалось вперёд сквозь воздух, и мотоцикл внезапно разгонялся в разы.
С новыми возможностями пришли новые проблемы, т. к. сальто вначале не было (почти), то потом пришлось менять вращение в воздухе и переделывать трамплины, чтобы в игре можно было спокойно делать сальто. Потом появилась дрожь картинки, и она стала заметна на быстрых мотоциклах. Ушла, только когда положение начали рисовать между шагами физики. Ящики превращались в тупик, пока у них не появились скошенные боковины, а петля пересекала сама себя, пока её не разделили на две половины.

Цель была простая: разобраться, как устроена игра, которой хватало 80 КБ памяти кнопочного телефона, и собрать её заново на чистом JavaScript — для компьютера и для телефона. Получилась игра с тринадцатью трассами, тремя мотоциклами и редактором. Больше всего мне запомнился момент, когда стало понятно, что редактор работает корректно и можно создавать свою карту.
Все тринадцать трасс я прошёл сам. Вот меню с временем по каждой (чтобы не писали, что пройти нельзя):

От оригинала моя версия всё равно отличается: физику я писал с нуля, не разбирая движок Codebrew, так что управление наверняка ощущается иначе.
Если хочется именно оригинал, с родной физикой и трассами, есть порт [gravity_defied_cpp] на C++, собранный по коду оригинальной игры, а на его основе сделана браузерная версия (не реклама, просто, когда искал информацию, наткнулся на этот репозиторий и, надеюсь, автор не против).
В этой игре мне больше всего нравится, что борешься не столько с трассой, сколько с собственным мотоциклом. Никакого рандома, только твои руки и гравитация, и, похоже, именно на этом держатся и Hill Climb Racing, и Trials Rising.
Код лежит на GitHub, поиграть можно прямо в браузере.
А что вы думаете о Gravity Defied?
P.S. Если найдёте баг, придумаете, какой трассы не хватает, или просто захотите похвастаться рекордом — пишите в комментариях, как и в прошлый раз.
© 2026 ООО «МТ ФИНАНС»