Многие играли в знаменитую инди-игру Braid и многие были впечатлены механикой возврата во времени. Для меня, как для программиста, это было особенно интересно, я решил попробовать повторить эту механику и вот что я узнал.


Для начала немного справки для тех, кто пропустил эту замечательную игру. Braid — инди‑проект от американца Джонатана Блоу, вышедший в 2008 году и ставший хитом, купленным более 55 тысяч раз в течение первой недели после релиза. Основная фишка игры в Rewind механике, позволяющей развернуть время вспять и «отмотать игру» назад, после чего попробовать пройти уровень заново.

Трейлер игры

Все примеры (GIF) интерактивны в оригинальной версии статьи (ссылка будет в конце)

Начнём с простого

Сперва попробуем упростить себе задачу — представим, что вся наша игра сводится к управлению точкой в одномерном пространстве. С помощью W и S мы будем двигать точку вдоль вертикальной прямой, а справа от неё будем изображать график зависимости её положения от времени. Иконки на графике будут отмечать моменты во времени, когда мы нажимали на кнопку или отменяли действие уже нажатой кнопки.

Обычно перемещение предмета в простых играх делается с помощью специального таймера, каждый тик которого немного меняет положение предмета на несколько пикселей. Если это происходит 30 раз в секунду, то нам кажется, что объект плавно движется в нужном направлении. Это императивный подход.

const STEP_PER_TICK = 2; // 2 пикселя в 1/30 секунды
const ballPosition = DEFAULT_POSITION;

movement.on('tick', () => {
    if (keys[UP]) {
        ballPosition += STEP_PER_TICK:
    }

    if (keys[DOWN]) {
        ballPosition -= STEP_PER_TICK:
    }
});

render.on('tick', () => {
    drawCircle(ballPosition, 'black');
});

Но иногда в приложениях используется другой подход — декларативный. Вместо того, чтобы определять, как именно будет меняться положение мяча каждые 1/30 секунды, мы можем описать, как будет зависеть положение мяча от текущего времени — создать функцию ball_position (t), где t — текущее время.

Как это сделать? Очень просто — используя таймлайн событий. Мы знаем, например, что последнее событие — нажатие на кнопку W (то есть вверх), мы знаем точное время и мы знаем положение мяча на момент нажатия на кнопку. Значит, мы без проблем можем вычислить текущее положение мяча.

const getBallPosition = (t) => {
    const { position, type, time } = getLastEvent(events, t);
    
    if (type === RELEASE) {
        return position;
    }

    const change = speed * (t - time);

    return position + change * (type === UP ? 1 : -1);
};

render.on('tick', () => {
    drawCircle(getBallPosition(now()));
});

Теперь нам не нужно хранить положение мяча вообще — оно будет высчитываться заново каждый раз, когда отрисовывается кадр. Обратите внимание, что теперь у положения мяча иногда появляется дробная часть — мы не можем точно знать, что рендеринг придется на 1/30 секунды. А ещё нажатие на обе кнопки (W и S) теперь приводит мяч в движение — в отличие от предыдущего примера, где кнопки отменяли друг друга.

Повернем время вспять

Теперь мы можем добавить ещё одно понятие — внутреннее время. Дело в том, что мы не можем поменять то, что возвращает нам функция now(), но нам необязательно передавать её результат в функцию нахождения положения мяча. Если мы заменим now() на now() / 2, то время в игре будет идти в два раза медленнее.

Но это ещё не всё — мы ведь хотим управлять временем с помощью клавиатуры. И не только замедлять или ускорять, но и поворачивать вспять. Мы хотим сделать так, чтобы внутреннее время зависело от внешнего и от нажатий на клавиатуру примерно таким образом:

Что-то напоминает, не правда ли? Ах да, это же почти точно такой же график, как тот, что мы строим для положения мяча. Внутреннее время зависит от внешнего так же, как и положение мяча зависит от внутреннего времени. Просто вместо кнопок W и S, игра реагирует на нажатие пробела.

const timeEvents = []; // сюда мы складываем события нажатий на SPACE
const gameEvents = []; // сюда мы складываем события нажатий на W и S

const getInnerTime = (t) => {
    const { value, backward, time } = getLastEvent(timeEvents, t);
    const change = backward ? -0.8 : 1;
                    
    return value + change * (t - time)
};

const getBallPosition = (t) => {
    const { position, type, time } = getLastEvent(gameEvents, t);
    
    if (type === RELEASE) {
        return position;
    }

    const change = speed * (t - time);

    return position + change * (type === UP ? 1 : -1);
};

render.on('tick', () => {
    const innerTime = getInnerTime(now());
    const ballPosition = getBallPosition(innerTime);

    drawCircle(ballPosition);
});
          

Получается каскад из функций — сначала из внешнего времени (времени системы) мы находим внутреннее время игры (то, что указано сверху в таймлайне) и только затем исходя из внутреннего времени игры мы находим положение мяча. И на первом и на втором этапе мы используем лог событий (нажатий на W, S или Space), чтобы понять, как именно время или положение мяча менялись с момента последнего события.

Добавим второе измерение

Пойдём чуть дальше и усложним задачу — добавим второе измерение. Теперь наш мяч будет двигаться не только вверх или вниз, но и влево / вправо. Заодно обновим таймлайн, и будем использовать глубину для отображения времени события.

const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const mul = (a, b) => ({ x: a.x * b, y: a.y * b });

const getBallPosition = (t) => {
    const { position, directions, time } = getLastEvent(gameEvents, t);

    // теперь position это Point { x, y }

    const change = speed * (t - time);

    const direction = sum(...directions.map((dir) => ({
        return {
            up: { x: 0, y: -1 },
            down: { x: 0, y: 1 },
            left: { x: -1, y: 0 },
            right: { x: 1, y: 0 },
        }[dir];
    }));

    return add(position, mul(direction, change));
};

render.on('tick', () => {
    const innerTime = getInnerTime(now());
    const ballPosition = getBallPosition(innerTime);

    drawCircle(ballPosition);
});

Ускоримся

Важный элемент, которого не хватает в нашей игре — ускорение. Дело в том, что в играх‑платформерах персонажи и объекты не движутся равномерно. Почти всегда они движутся с некоторым ускорением, например, когда падают. Обычно (в императивном подходе) это делается как‑то так:

let gravity = 10;
let speed = 0;
let position = 100;

// ...

movement.on('tick', () => {
    speed += gravity;
    position += speed;
});

render.on('tick', () => {
    drawObject(position);
});

Но у нас нет переменных для текущей скорости или даже текущего положения — у нас есть только функция, которая возвращает нам положение мяча для определенного времени. Чтобы добавить в эту функцию ускорение, нам нужно вспомнить школьную математику, а именно равноускоренное движение.

const getBallPosition = (t) => {
    const event = getLastEvent(gameEvents, t);

    // предположим какое-то событие начинает падение

    if (event.type === 'fall') { 
        return {
            // x не меняется
            x: event.position.x, 
            // та самая формула из википедии
            y: event.position.y
                + event.velocity * (t - event.time)
                + .5 * GRAVITY * ((t - event.time) ** 2)
        };
    }
};

Как вы можете заметить, кроме position, event теперь должен хранить ещё и velocity (скорость) — значит, нам нужна функция, которая посчитает скорость для заданного внутриигрового времени. В итоге у меня получилось что-то вроде этого:

class Gameline extends RawTimeline {
    getDirections = (innerTime: number) => {
        const event = this.get(innerTime);

        return event.data.directions;
    };

    getAcceleration = (innerTime: number) => {
        return sum(
            { x: 0, y: 0 },
            ...this.getDirections(innerTime).map((dir) => ({
                up: { x: 0, y: -ACC },
                down: { x: 0, y: ACC },
                left: { x: -ACC, y: 0 },
                right: { x: ACC, y: 0 },
            }[dir] || { x: 0, y: 0 })),
        );
    };

    getVelocity = (innerTime: number) => {
        const event = this.get(innerTime);
        const acceleration = this.getAcceleration(innerTime);

        return add(
            event.data.velocity, 
            mul(acceleration, (innerTime - event.time))
        );
    };

    getPosition = (innerTime: number) => {
        const event = this.get(innerTime);

        const acceleration = this.getAcceleration(innerTime);

        return sum(
            event.data.position, 
            mul(event.data.velocity, innerTime - event.time),
            mul(acceleration, .5 * ((innerTime - event.time) ** 2))
        );
    };
};

Соберем платформер

Осталось дело за малым — собрать из всего этого платформер! Движение влево и вправо будет равномерным, а падение равноускоренными. Заодно добавим платформу и специальное событие, обозначающее касание персонажем платформы.

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

Вот собственно и всё. Конечно, за пределами статьи остались очень сложные моменты — монстры, смерть персонажа, лазанье по лестницам, но я хотел рассказать именно о rewind‑механике. Если вам понравилась эта статья — обязательно подпишитесь и прокомментируйте.

Интерактивный оригинал статьи в блоге автора

Комментарии (17)


  1. perfect_genius
    09.07.2024 23:25
    +4

    Кроме Braid время можно отматывать ещё в эмуляторах ретро-консолей и в некоторых отладчиках.

    Делаю виртуальную машину с отмоткой времени и вижу только два варианта:

    1 Сохранять данные с каждой инструкцией (операции типа сложения/вычитания можно инвертировать, а вот присвоение ячейке числа уже требует сохранять перезаписываемое значение, чтобы при отмотке его присваивать обратно)

    2 Прогонять программу с самого начала до "текущее состояние минус одна инструкция"

    Подумал, что первый вариант бьёт по производительности (лишние действия при работе кода) и требует очень много памяти, и выбрал второй вариант. Чем дальше от начала программы, тем дольше происходит перемотка на одну инструкцию назад. Но можно ускорить, делая снимки памяти и при перемотке прогонять с них. Также можно взять немного от первого варианта, сохраняя данные последних N инструкций, чтобы при перемотке назад моментально исполнялись эти сохранённые шаги, пока на фоне программа исполняется с начала/снимка, что требует время. Сохранённые шаги скрывают этот лаг.

    Наверно, тут большое поле для оптимизаций, поэтому давно ищу материал как это делается в подобных системах, но не найду. Может кто встречал?


    1. me21
      09.07.2024 23:25
      +1

      Почему-то пришло на ум кодирование видео. Там принят подход, который ты описал последним. Периодически сохраняются полные (опорные, ключевые) кадры, а дальнейший видеопоток кодируется в виде разницы между новым кадром и предыдущим.

      Я как-то делал покадровую перемотку видео назад в своей программе - надо было найти предыдущий ключевой кадр, а от него на нужное количество кадров пройти вперёд.


      1. perfect_genius
        09.07.2024 23:25

        Да, это оно. Я ещё не дошёл до снимков, так как задержка перемотки назад пока комфортная, не дошла и до секунды.


    1. Dovgaluk
      09.07.2024 23:25
      +1

      Вот статья про такой механизм в QEMU: https://habr.com/ru/articles/522378/


      1. perfect_genius
        09.07.2024 23:25
        +1

        Я там есть в комментах :) Стараюсь не пропускать все материалы на эту тему на Хабре:

        Time Travel Debugging в новом WinDbg

        Time Travel Debugging в Visual Studio Enterprise 2019

        PyTrace — Time Travel Debugger для Python


    1. domix32
      09.07.2024 23:25
      +1

      Пытаетесь сделать альтернативу rr (site)?


      1. perfect_genius
        09.07.2024 23:25
        +2

        Сейчас делаю только для своего простого байт-кода, акцент на графике и простеньких играх. Но в будущем можно и как rr отлаживать скомпилированные нативные программы. Когда-то даже промелькала идея делать это на уровне ОС, чтобы при любом глюке-баге можно было остановить программу и отмотать до момента возникновения бага в ней, чтобы исправлять так очень быстро. Посмотрим.


    1. Revolt-or-die
      09.07.2024 23:25
      +1

      Из ААА это одна из ключевых механик принцов персии от юбисофт. В 00е было топ.


      1. perfect_genius
        09.07.2024 23:25
        +1

        Кстати да, забыл про неё. Были найдены баги перемотки назад, дающие возможность прыгнуть чуть выше и проходить сквозь стены.

        Кстати, была ещё версия для GBA.


    1. Format-X22
      09.07.2024 23:25
      +1

      6 лет назад я для блокчейнов нечто такое делал. Там была задача агрегировать данные, приходящие из блоков. Они попутно обрабатывались, менялись, после складывались в MongoDB. Но был нюанс - мог произойти форк данных. Тогда нужно откатить назад несколько блоков и пойти по новой цепочке, записать другие данные.

      Решалось микро-языком эдаким - сохранялись дифы в виде инструкций - тут мы сделали инкремент на столько, тут поменяли эти данные на эти, тут добавили/удалили чего-то в массив. В случае отката для каждой операции в микро-языке была обратная инструкция. Если инкремент - делаем декремент, если замена данных… ну думаю общий принцип понятен. А по ходу устаревания - старые инструкции удалялись.

      Сам микро-язык был по суть JSON, прям в таком виде в MongoDB и хранилось, в отдельной коллекции.

      Не везде применимый, но очень практичный был способ - ни единой проблемы.

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


      1. perfect_genius
        09.07.2024 23:25
        +1

        Вспомнилось про https://en.wikipedia.org/wiki/Netcode#Rollback

        Применяется в файтингах. Вместо того, чтобы ждать данные от противника и мучиться от лагов картинки и геймплея, движок игры всё время предсказывает поведение удалённого противника и локальный игрок дерётся с этим его "призраком". Как только поступающие данные указывают, что движок не угадал, он тут же перематывает состояние игры назад и ускоренно проигрывает его (рендерить картинку не надо, только внутреннее состояние) с правильными данными. Т.е. он ещё всё время записывает и хранит ввод локального игрока, чтобы при этом проигрывании дрались два потока ввода двух игроков. В итоге локальный игрок увидит резкое изменение картинки. Обычно, это телепорт противника. Я сам ещё не играл, но это выглядит явно лучшим компромиссом, чем замедлённо-тормознутая игра.


        1. perfect_genius
          09.07.2024 23:25
          +1

          Кстати, именно с добавлением этой системы я связываю изменение геймплея последних трёх частей Mortal Kombat. Они стали каким-то вязкими, замедлёнными. Потому что такую скорость легче предсказывать и корректировать. В таком темпе в принципе невозможен внезапный резкий бег вперёд на противника как было в UMK3, потому что это большое расстояние, и если его не предсказать, то при корректировке противник телепортируется на это большое расстояние.


  1. Spyman
    09.07.2024 23:25
    +4

    Собственно, а вопрос из заголовка не раскрыт. Как устроен braid? Какой из механизмов там использован? Кликбейтный заголовок не соответствует статье.


    1. alextrof94
      09.07.2024 23:25

      Полностью согласен. Человек придумал себе реализацию отмотки времени, и думает что все работает именно так, как он себе придумал.

      Я думал увижу реальную реализацию, а не то, что смог бы сам набросать за вечер.


      1. vsviridov
        09.07.2024 23:25
        +1

        Есть много видео, где сам Блоу рассказывает о том, именно эти вещи сделаны, без кода, просто концептуально, но достаточно подробно. Недавно у него была серия стримов вместе с Кейси Муратори, как раз про Брейд


  1. Ssanina666
    09.07.2024 23:25

    Клёвая статья. Хотелось бы увидеть такой же разбор по uter wilds где вся солнечная система работает в реальном времени.


  1. kapkekes
    09.07.2024 23:25
    +1

    Наверное, одних из самых загадочных в плане «как это вообще сделали» миров в Braid — это четвёртый.
    Почитать что-то с кодом про то, как перемещение во времени вперёд привязано к передвижению Тима вправо, и каким забавным и неожиданным последствиям это приводит — было бы невероятно интересно.

    Жаль, что был взят самый простой аспект: ни объектов, которые не реагируют на перемотку, ни «теневого» параллельного мира. Комментарии разработчиков в недавнем переиздании, которыми, кажется, вдохновлялся автор, есть и про вышеупомянутые аспекты игры.