javascript

Gravity Defied: стик-человечек на мотоцикле

  • среда, 21 октября 2026 г. в 00:00:06
https://habr.com/ru/companies/ruvds/articles/1094928/

В этой статье я соберу свой аналог Gravity Defied для браузера — той самой мобильной игры про мотоциклиста-стикмена. Игра написана на чистом JavaScript и Canvas, без сторонних библиотек, и работает как на компьютере, так и на телефоне, где управление сделано через джойстик — по итогам прошлой статьи.

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

Введение

Игра, в которую я не играл

Обычно я начинаю статью с истории о том, как играл в эту игру в прошлом, но здесь у меня возникла дилемма: признаюсь сразу, в Gravity Defied в детстве я не играл — от слова совсем. Из похожего у меня был iPod touch четвёртого поколения, и на нём я играл в Hill Climb Racing: машинка, холмы и бесконечные попытки заехать чуть дальше, чем в прошлый раз. В моём понимании это очень похоже на логику Gravity Defied.

Если вы спросите, зачем тогда браться за игру, в которую не играл, то ответ такой: недавно мы с другом сели поиграть на PS5, и у него была Trials Rising — тот же мотоцикл, физика и сотня попыток на одной трассе. После нашей встречи мне стало интересно, почему я раньше ничего не слышал об этой игре и откуда у такого жанра растут ноги. Буквально первая же ссылка подсказала, что культовая игра такого типа — Gravity Defied.

Ну и мысль пришла сама собой: если я уже писал Flappy Bird, Pac-Man и тамагочи, то почему бы не попробовать и мотоцикл? Делать ремейк игры, которую ни разу не запускал, — затея странная, но с тамагочи было так же, и это ничуть не помешало: никакой ностальгии — только вопрос «а как это вообще работает».

Немного истории

Gravity Defied
Gravity Defied

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 уходит от красного уже на первых секундах. Кроме цвета, мотоциклы различаются размером и деталями: у синего есть переднее крыло, у жёлтого — обтекатель. Размер при этом меняет не только картинку, но вместе с ней растут колёса и рама в физике.

100cc, 175cc и 220cc
100cc, 175cc и 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 ООО «МТ ФИНАНС»