Многие играли в знаменитую инди-игру 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‑механике. Если вам понравилась эта статья — обязательно подпишитесь и прокомментируйте.
Автор: Александр Чурбаков