Gravity Defied: мотоциклист-палочка бросает вызов гравитации

Прокомментировать Просмотры: 0

В этой статье я напишу собственный аналог 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

Игру разработала студия 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);

Это упрощённая реализация Наклон и сальто

Главной причиной популярности оригинала автор обзора на 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);

Клавиатура и джойстик работают одновременно: от каждого источника берётся максимальное значение.

Что оказалось сложным

Труднее всего было не написать физику, а добиться от неё правдоподобного поведения. В первой версии пружины при нескольких проходах становились почти жёсткими, и подвеска под весом гонщика не проседала вовсе, поэтому пришлось переписать их на

 

Источник

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов