Многие играли в знаменитую инди-игру Braid и многие были впечатлены механикой возврата во времени. Для меня, как для программиста, это было особенно интересно, я решил попробовать повторить эту механику и вот что я узнал.
![](https://habrastorage.org/getpro/habr/upload_files/ef7/3ef/c13/ef73efc13070ce93f4933a4caa0ef598.png)
Для начала немного справки для тех, кто пропустил эту замечательную игру. Braid — инди‑проект от американца Джонатана Блоу, вышедший в 2008 году и ставший хитом, купленным более 55 тысяч раз в течение первой недели после релиза. Основная фишка игры в Rewind механике, позволяющей развернуть время вспять и «отмотать игру» назад, после чего попробовать пройти уровень заново.
Все примеры (GIF) интерактивны в оригинальной версии статьи (ссылка будет в конце)
Начнём с простого
Сперва попробуем упростить себе задачу — представим, что вся наша игра сводится к управлению точкой в одномерном пространстве. С помощью W и S мы будем двигать точку вдоль вертикальной прямой, а справа от неё будем изображать график зависимости её положения от времени. Иконки на графике будут отмечать моменты во времени, когда мы нажимали на кнопку или отменяли действие уже нажатой кнопки.
![](https://habrastorage.org/getpro/habr/upload_files/299/587/16a/29958716a82feb17e5004534b5b18b12.gif)
Обычно перемещение предмета в простых играх делается с помощью специального таймера, каждый тик которого немного меняет положение предмета на несколько пикселей. Если это происходит 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) теперь приводит мяч в движение — в отличие от предыдущего примера, где кнопки отменяли друг друга.
![](https://habrastorage.org/getpro/habr/upload_files/8d2/8e9/c5b/8d28e9c5bd0f0f9422654e5976492c09.gif)
Повернем время вспять
Теперь мы можем добавить ещё одно понятие — внутреннее время. Дело в том, что мы не можем поменять то, что возвращает нам функция now()
, но нам необязательно передавать её результат в функцию нахождения положения мяча. Если мы заменим now()
на now() / 2
, то время в игре будет идти в два раза медленнее.
Но это ещё не всё — мы ведь хотим управлять временем с помощью клавиатуры. И не только замедлять или ускорять, но и поворачивать вспять. Мы хотим сделать так, чтобы внутреннее время зависело от внешнего и от нажатий на клавиатуру примерно таким образом:
![](https://habrastorage.org/getpro/habr/upload_files/c2a/441/8b6/c2a4418b6ac6984aa7c355f496be439c.png)
Что-то напоминает, не правда ли? Ах да, это же почти точно такой же график, как тот, что мы строим для положения мяча. Внутреннее время зависит от внешнего так же, как и положение мяча зависит от внутреннего времени. Просто вместо кнопок 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);
});
![](https://habrastorage.org/getpro/habr/upload_files/912/ca8/279/912ca8279403e53921025c27a08dc87e.gif)
Получается каскад из функций — сначала из внешнего времени (времени системы) мы находим внутреннее время игры (то, что указано сверху в таймлайне) и только затем исходя из внутреннего времени игры мы находим положение мяча. И на первом и на втором этапе мы используем лог событий (нажатий на 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);
});
![](https://habrastorage.org/getpro/habr/upload_files/ad5/3f2/342/ad53f23420c0464c1ef1bba3f5411575.gif)
Ускоримся
Важный элемент, которого не хватает в нашей игре — ускорение. Дело в том, что в играх‑платформерах персонажи и объекты не движутся равномерно. Почти всегда они движутся с некоторым ускорением, например, когда падают. Обычно (в императивном подходе) это делается как‑то так:
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))
);
};
};
![](https://habrastorage.org/getpro/habr/upload_files/05a/688/46c/05a68846c20086b8eed373077df0d027.gif)
Соберем платформер
Осталось дело за малым — собрать из всего этого платформер! Движение влево и вправо будет равномерным, а падение равноускоренными. Заодно добавим платформу и специальное событие, обозначающее касание персонажем платформы.
У меня не было цели превратить это в полноценную игру, поэтому я сделал только демонстрационный минимум. Сразу покажу Вам результат:
![](https://habrastorage.org/getpro/habr/upload_files/aa0/31f/7eb/aa031f7eb271f082c7b2330e0609186f.gif)
Вот собственно и всё. Конечно, за пределами статьи остались очень сложные моменты — монстры, смерть персонажа, лазанье по лестницам, но я хотел рассказать именно о rewind‑механике. Если вам понравилась эта статья — обязательно подпишитесь и прокомментируйте.