image

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

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


Демонстрация основных возможностей

Вам потребуется последняя версия Unity и опыт работы с движком. Исходный код будет выложен в открытый доступ, чтобы вы могли сравнить с ним свои результаты.

Готовы? Поехали!

Как эта система используется в других играх?


Prince of Persia: The Sands of Time стала одной из первых игр со встроенной в геймплей механикой перемотки времени. Когда игрок умирает, он может не только перезапустить игру, но и перемотать её на несколько секунд назад, на момент, когда персонаж ещё жив, и сразу же попробовать снова.


Prince of Persia: The Forgotten Sands. Трилогия Sands Of Time превосходно интегрировала перемотку времени в свой геймплей. Благодаря этому игрок не прерывается на быструю загрузку и остаётся погружённым в игру.

Эта механика интегрирована не только в геймплей, но и в повествование и саму вселенную игры, и упоминается на протяжении всего сюжета.

Похожая система используется в таких играх как Braid, в которой гейплей тоже тесно связан с перемоткой времени. Героиня игры Overwatch Трейсер имеет способность, которая возвращает её на то место, где она была несколько секунд назад, то есть перематывает назад её время, даже в многопользовательской игре. В серии гоночных игр GRID тоже присутствует механика снепшотов: во время гонки у игрока есть небольшой запас перемоток, которые можно использовать, когда машина попадает в серьёзную аварию. Это избавляет игроков от раздражения, возникающего при авариях в конце гонки.


При серьёзном столкновении в GRID вы имеете возможность перемотать игру на момент до аварии

Другие примеры использования


Но эту систему можно использовать не только как замену быстрого сохранения. Ещё один способ использования — реализация «призраков» в гоночных играх и асинхронном многопользовательском режиме.

Реплеи


Это ещё один интересный способ использования функции. Он используется в таких играх как SUPERHOT, в серии Worms и в большинстве спортивных игр.

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

SUPERHOT тоже записывает движения. После прохождения уровня показывается реплей всего игрового процесса, который умещается всего в несколько секунд.

Забавны реплеи в Super Meat Boy. Пройдя уровень, игрок видит все предыдущие попытки, наложенные друг на друга.


Реплей в конце уровня Super Meat Boy. Все предыдущие попытки записываются, а затем воспроизводятся одновременно.

Призраки в гонках на время


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

Чтобы не ездить в одиночку, можно состязаться с самим собой, что делает гонки на время интереснее. Эта функция используется в большинстве гоночных игр, от серии Need for Speed до Diddy Kong Racing.


Гонка с призраком в Trackmania Nations. Это «серебряная» сложность, она означает, что игрок получит серебряную медаль, если обгонит призрака. Заметьте, что модели машин пересекаются, то есть призрак не материален и его можно проехать насквозь.

Призраки в многопользовательских режимах


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

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

Монтаж съёмки


В некоторых играх перемотка может быть просто забавным инструментом. В Team Fortress 2 есть встроенный редактор реплеев, в котором можно создавать собственные ролики.


Редактор реплеев в Team Fortress 2. Записанный бой можно просмотреть с любой точки зрения, а не только из глаз игрока.

После включения функции можно записывать и просматривать предыдущие матчи. Очень важно, что записывается всё, а не только то, что видит игрок. Это значит, что можно перемещаться по записанному игровому миру, видеть, где все находятся и управлять временем.

Как это реализовать


Для тестирования этой системы нам нужна простая игра. Давайте создадим её!

Игрок


Создайте в сцене куб, это будет персонаж игрока. Затем создайте новый скрипт C# под названием Player.cs и добавьте в функцию Update() следующее:

void Update()
{
    transform.Translate (Vector3.forward * 3.0f * Time.deltaTime * Input.GetAxis ("Vertical"));
    transform.Rotate (Vector3.up * 200.0f * Time.deltaTime * Input.GetAxis ("Horizontal"));
}

Так мы сможем управлять персонажем с помощью стрелок. Прикрепите скрипт к кубу. Теперь после нажатия на Play вы сможете передвигаться. Измените угол камеры, чтобы она смотрела на куб сверху. Наконец, создайте плоскость пола и назначьте каждому объекту свой материал, чтобы мы не двигались в пустоте. Должно получиться что-то подобное:



Попробуйте управлять кубом с помощью WSAD и клавиш со стрелками

TimeController


Теперь создадим новый скрипт C# TimeController.cs и добавлим его к новому пустому GameObject. Он будет управлять записью и перемоткой игры.

Чтобы это сработало, мы запишем движение персонажа игрока. После нажатия кнопки перемотки мы будем изменять координаты персонажа. Для начала создадим переменную, хранящую персонажа:

public GameObject player;

Назначим объект игрока в получившийся слот TimeController, чтобы он мог иметь доступ к игроку и его данным.



Затем нужно создать массив для хранения данных игрока:

public ArrayList playerPositions;

void Start()
{
    playerPositions = new ArrayList();
}

Теперь нам нужно непрерывно записывать положение игрока. У нас будет сохранённое положение игрока в последнем кадре, позиция в которой находился игрок 6 кадров назад и позиция, где был игрок 8 секунд назад (или любое назначенное вами время записи). Когда мы нажмём клавишу воспроизведения, то будем возвращаться обратно по массиву положений и назначать их кадр за кадром, в результате создав функцию перемотки времени.

Для начала давайте сохраним данные:

void FixedUpdate()
{
    playerPositions.Add (player.transform.position);
}

В функции FixedUpdate() мы записываем данные. Используется FixedUpdate(), потому что она выполняется с постоянной частотой 50 циклов в секунду (или любое выбранное значение), что позволяет нам записывать данные с фиксированным интервалом. Функция Update() же выполняется с той частотой, которую обеспечить процессор, что усложнило бы нам работу.

Этот код будет каждый кадр сохранять в массив положение игрока. Теперь нам нужно применить его!

Мы добавим проверку нажатия кнопки перемотки. Для этого нам необходимо булева переменная:

public bool isReversing = false;

И проверка в функции Update():

void Update()
{
    if(Input.GetKey(KeyCode.Space))
    {
        isReversing = true;
    }
    else
    {
        isReversing = false;
    }
}

Чтобы игра выполнялась в обратную сторону, нам нужно применить данные вместо записи. Новый код записи и применения положения игрока должен выглядеть так:

void FixedUpdate()
{
    if(!isReversing)
    {
        playerPositions.Add (player.transform.position);
    }
    else
    {
        player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1];
        playerPositions.RemoveAt(playerPositions.Count - 1);
    }
}

А весь скрипт TimeController будет выглядеть следующим образом:

using UnityEngine;
using System.Collections;

public class TimeController: MonoBehaviour
{
    public GameObject player;
    public ArrayList playerPositions;
    public bool isReversing = false;

    void Start()
    {
        playerPositions = new ArrayList();
    }

    void Update()
    {
        if(Input.GetKey(KeyCode.Space))
        {
            isReversing = true;
        }
        else
        {
            isReversing = false;
        }
    }
	
    void FixedUpdate()
    {
        if(!isReversing)
        {
            playerPositions.Add (player.transform.position);
        }
        else
        {
            player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1];
            playerPositions.RemoveAt(playerPositions.Count - 1);
        }
    }
}

Кроме того, не забудьте добавить в класс player проверку того, выполняется ли в TimeController перемотка, чтобы выполнять движение только если оно не воспроизводится. В противном случае поведение может стать странным:

using UnityEngine;
using System.Collections;

public class Player: MonoBehaviour
{
    private TimeController timeController;

    void Start()
    {
        timeController = FindObjectOfType(typeof(TimeController)) as TimeController;
    }
    
    void Update()
    {
        if(!timeController.isReversing)
        {
    	    transform.Translate (Vector3.forward * 3.0f * Time.deltaTime * Input.GetAxis ("Vertical"));
    	    transform.Rotate (Vector3.up * 200.0f * Time.deltaTime * Input.GetAxis ("Horizontal"));
        }
    }
}

Эти новые строки будут при запуске автоматически находить в сцене объект TimeController и проверять его в процессе выполнения. Мы можем управлять персонажем только тогда, когда перемотка не выполняется.

Теперь мы можем перемещаться по миру и перематывать движение назад клавишей «пробел». Можете скачать пакет по ссылке в конце статьи и открыть TimeRewindingFunctionality01, чтобы проверить работу!

Но постойте, почему наш простой игрок-кубик продолжает смотреть в последнем направлении, в котором мы его оставили? Потому что мы не догадались записывать поворот объекта!

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

using UnityEngine;
using System.Collections;

public class TimeController: MonoBehaviour
{
    public GameObject player;
    public ArrayList playerPositions;
    public ArrayList playerRotations;
    public bool isReversing = false;
    
    void Start()
    {
        playerPositions = new ArrayList();
        playerRotations = new ArrayList();
    }
    
    void Update()
    {
        if(Input.GetKey(KeyCode.Space))
        {
            isReversing = true;
        }
        else
        {
            isReversing = false;
        }
    }
    
    void FixedUpdate()
    {
        if(!isReversing)
        {
            playerPositions.Add (player.transform.position);
            playerRotations.Add (player.transform.localEulerAngles);
        }
        else
        {
            player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1];
            playerPositions.RemoveAt(playerPositions.Count - 1);
    
            player.transform.localEulerAngles = (Vector3) playerRotations[playerRotations.Count - 1];
            playerRotations.RemoveAt(playerRotations.Count - 1);
        }
    }
}

Попробуйте запустить! TimeRewindingFunctionality02 — это усовершенствованная версия. Теперь наш игрок-куб может двигаться назад во времени и будет выглядеть точно так, как выглядел в соответствующий момент.

Заключение


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

Вот что нам ещё предстоит сделать:

  • Записывать только каждый 12-й кадр и интерполировать состояния между записанными кадрами, чтобы объём данных не был слишком огромным
  • Записывать только последние 75 положений и поворотов игрока, чтобы массив не стал слишком громоздким и игра не вылетала

Кроме того, мы подумаем, как расширить эту систему, чтобы она действовала не только для игрока:

  • Как записывать не только одного игрока
  • Добавим эффект, сообщающий о перемотке (типа размытия VHS-сигнала)
  • Используем для хранения положения и поворота игрока собственный класс, а не массивы

Архив проекта Unity



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

Запись меньшего объёма данных и интерполяция


На данный момент мы записываем положения и повороты игрока 50 раз в секунду. Такой объём данных быстро станет неподъёмным, и это будет особенно заметно в более сложных играх, а также на слабых мобильных устройствах.

Вместо этого мы можем выполнять запись только 4 раза в секунду и интерполировать положение и поворот между этими ключевыми кадрами. Таким образом мы сэкономим 92% производительности, а результаты будут на вид неотличимы от записей с 50 кадрами в секунду, потому что они воспроизводятся за доли секунды.

Начнём с записи ключевых кадров через каждые x кадров. Для этого нам сначала нужны новые переменные:

public int keyframe = 5;
private int frameCounter = 0;

Переменная keyframe — это кадр в методе FixedUpdate, в который мы будем записывать данные игрока. В настоящий момент ей присвоено значение 5, то есть данные будут записываться на каждом пятом цикле выполнения метода FixedUpdate. Поскольку FixedUpdate выполняется 50 раз в секунду, то за секунду будет записываться 10 кадров. Переменная frameCounter будет использоваться как счётчик кадров до следующего ключевого кадра.

Теперь изменим блок записи в функции FixedUpdate, чтобы он выглядел вот так:

if(!isReversing)
{
    if(frameCounter < keyframe)
    {
        frameCounter += 1;
    }
    else
    {
        frameCounter = 0;
        playerPositions.Add (player.transform.position);
        playerRotations.Add (player.transform.localEulerAngles);
    }
}

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

Для начала нам нужна ещё одна переменная frameCounter, чтобы не записывать данные, а воспроизводить их.

private int reverseCounter = 0;

Исправим код, восстанавливающий положение игрока, чтобы использовать его так же, как мы записываем данные. Функция FixedUpdate должна выглядеть так:

void FixedUpdate()
{
    if(!isReversing)
    {
        if(frameCounter < keyframe)
        {
            frameCounter += 1;
        }
        else
        {
            frameCounter = 0;
            playerPositions.Add (player.transform.position);
            playerRotations.Add (player.transform.localEulerAngles);
        }
    }
    else
    {
        if(reverseCounter > 0)
        {
            reverseCounter -= 1;
        }
        else
        {
            player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1];
            playerPositions.RemoveAt(playerPositions.Count - 1);

            player.transform.localEulerAngles = (Vector3) playerRotations[playerRotations.Count - 1];
            playerRotations.RemoveAt(playerRotations.Count - 1);
            
            reverseCounter = keyframe;
        }
    }
}

Теперь при перемотке времени игрок будет перескакивать к предыдущим положениям в реальном времени!

Но мы хотели не совсем этого. Нам нужно интерполировать положения между этими ключевыми кадрами, что будет немного сложнее. Во-первых, нам потребуются четыре переменные:

private Vector3 currentPosition;
private Vector3 previousPosition;
private Vector3 currentRotation;
private Vector3 previousRotation;

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

Затем нам понадобится эта функция:

void RestorePositions()
{
    int lastIndex = keyframes.Count - 1;
    int secondToLastIndex = keyframes.Count - 2;

    if(secondToLastIndex >= 0)
    { 
        currentPosition  = (Vector3) playerPositions[lastIndex];
        previousPosition = (Vector3) playerPositions[secondToLastIndex];
        playerPositions.RemoveAt(lastIndex);
        
        currentRotation  = (Vector3) playerRotations[lastIndex];
        previousRotation = (Vector3) playerRotations[secondToLastIndex];
        playerRotations.RemoveAt(lastIndex);
    }
}

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

Блок восстановления данных будет выглядеть так:

if(reverseCounter > 0)
{
    reverseCounter -= 1;
}
else
{
    reverseCounter = keyframe;
    RestorePositions();
}

if(firstRun)
{
    firstRun = false;
    RestorePositions();
}

float interpolation = (float) reverseCounter / (float) keyframe;
player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation);
player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation);

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

if(firstRun)
{
    firstRun = false;
    RestorePositions();
}

Чтобы он работал, нам также понадобится переменная firstRun:

private bool firstRun = true;

И для сброса при отпускании клавиши «пробел»:

if(Input.GetKey(KeyCode.Space))
{
    isReversing = true;
}
else
{
    isReversing = false;
    firstRun = true;
}

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

Интерполяция выполняется функцией Lerp, которой мы передаём текущее и предыдущее положение (или поворот). Затем вычисляется коэффициент интерполяции, который может иметь значения от 0 до 1. Затем игрок помещается между двумя сохранёнными точками, например, в 40% на пути к последнему ключевому кадру.

Если замедлить движение и воспроизводить его покадрово, то можно на самом деле увидеть, как персонаж движется между этими ключевыми кадрами, но в процессе игры это незаметно.

Таким образом мы значительно снизили сложность схемы перемотки времени и сделали её гораздо более стабильной.

Запись только фиксированного количества кадров


Сильно уменьшив количество сохраняемых кадров, теперь мы можем сделать так, чтобы система не сохраняла слишком много данных.

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

Чтобы исправить это, мы можем добавить код, проверяющий, не разросся ли массив больше определённого размера. Если мы будем знать, сколько кадров в секунду мы сохраняем, то можем определить, сколько секунд перематываемого времени нужно хранить, чтобы это не мешало игре и не увеличивало её сложность. В довольно сложной Prince of Persia время перемотки ограничено примерно 15 секундами, а в более технически простой игре Braid перемотка может быть бесконечной.

if(playerPositions.Count > 128)
{
    playerPositions.RemoveAt(0);
    playerRotations.RemoveAt(0);
}

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

Использование собственного класса для хранения данных игрока


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

Код собственного класса, который будет использоваться как контейнер для данных, выглядит следующим образом:

public class Keyframe
{
    public Vector3 position;
    public Vector3 rotation;

    public Keyframe(Vector3 position, Vector3 rotation)
    {
        this.position = position;
        this.rotation = rotation;
    }
}

Можно добавить его в файл TimeController.cs file перед началом объявления классов. Он создаёт контейнер для сохранения положения и поворота игрока. Конструктор позволяет создать его напрямую со всех необходимой информацией.

Остальную часть алгоритма нужно адаптировать под работу с новой системой. В методе Start необходимо инициализировать массив:

keyframes = new ArrayList();

И вместо:

playerPositions.Add (player.transform.position);
playerRotations.Add (player.transform.localEulerAngles);

мы можем сохранять непосредственно в объект Keyframe:

keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles));

Здесь мы добавляем положение и поворот игрока в один объект, который затем добавляется в единый массив, что значительно снижает сложность алгоритма.

Добавление эффекта размывания, обозначающего включенную перемотку


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

Давайте сделаем что-то в стиле Prince of Persia добавив немного размывания.


Перемотка времени в Prince of Persia: The Forgotten Sands

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

Для использования базовых эффектов их сначала нужно импортировать. Для этого зайдём в Assets > Import Package > Effects и импортируем всё, что нам предлагают.



Визуальные эффекты можно применять непосредственно к камере. Перейдите в Components > Image Effects и добавьте эффекты Blur и Bloom. Их сочетание создаёт хороший эффект, к которому мы стремимся.



Это базовые настройки. Можете настроить их в соответствии со своим проектом.

Если попробовать запустить игру сейчас, то она будет использовать эффект постоянно.



Теперь нам нужно научиться включать и отключать его. Для этого необходимо импортировать в TimeController эффекты изображения. Добавляем их в самое начало:

using UnityStandardAssets.ImageEffects;

Чтобы получить доступ к камере из TimeController, добавим эту переменную:

private Camera camera;

И присвоим ей значение в функции Start:

camera = Camera.main;

Затем добавим этот код для включения эффектов при перемотке времени:

void Update()
{
    if(Input.GetKey(KeyCode.Space))
    {
        isReversing = true;
        camera.GetComponent<Blur>().enabled = true;
        camera.GetComponent<Bloom>().enabled = true;
    }
    else
    {
        isReversing = false;
        firstRun = true;
        camera.GetComponent<Blur>().enabled = false;
        camera.GetComponent<Bloom>().enabled = false;
    }
}

При нажатии клавиши «пробел» теперь вы не только будете перематывать время в сцене, но и активируете эффект перемотки камеры, сообщая игроку о происходящем.

Весь код TimeController должен выглядеть следующим образом:

using UnityEngine;
using System.Collections;
using UnityStandardAssets.ImageEffects;

public class Keyframe
{
    public Vector3 position;
    public Vector3 rotation;

    public Keyframe(Vector3 position, Vector3 rotation)
    {
        this.position = position;
        this.rotation = rotation;
    }
}

public class TimeController: MonoBehaviour
{
    public GameObject player;
    public ArrayList keyframes;
    public bool isReversing = false;
    
    public int keyframe = 5;
    private int frameCounter = 0;
    private int reverseCounter = 0;
    
    private Vector3 currentPosition;
    private Vector3 previousPosition;
    private Vector3 currentRotation;
    private Vector3 previousRotation;
    
    private Camera camera;
    
    private bool firstRun = true;
    
    void Start()
    {
        keyframes = new ArrayList();
        camera = Camera.main;
    }
    
    void Update()
    {
        if(Input.GetKey(KeyCode.Space))
        {
            isReversing = true;
            camera.GetComponent<Blur>().enabled = true;
            camera.GetComponent<Bloom>().enabled = true;
        }
        else
        {
            isReversing = false;
            firstRun = true;
            camera.GetComponent<Blur>().enabled = false;
            camera.GetComponent<Bloom>().enabled = false;
        }
    }
    
    void FixedUpdate()
    {
        if(!isReversing)
        {
            if(frameCounter < keyframe)
            {
                frameCounter += 1;
            }
            else
            {
                frameCounter = 0;
                keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles));
            }
        }
        else
        {
            if(reverseCounter > 0)
            {
                reverseCounter -= 1;
            }
            else
            {
                reverseCounter = keyframe;
                RestorePositions();
            }
    
            if(firstRun)
            {
                firstRun = false;
                RestorePositions();
            }
    
            float interpolation = (float) reverseCounter / (float) keyframe;
            player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation);
            player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation);
        }
    
        if(keyframes.Count > 128)
        {
            keyframes.RemoveAt(0);
        }
    }
    
    void RestorePositions()
    {
        int lastIndex = keyframes.Count - 1;
        int secondToLastIndex = keyframes.Count - 2;
    
        if(secondToLastIndex >= 0)
        {
            currentPosition  = (keyframes[lastIndex] as Keyframe).position;
            previousPosition = (keyframes[secondToLastIndex] as Keyframe).position;
    
            currentRotation  = (keyframes[lastIndex] as Keyframe).rotation;
            previousRotation = (keyframes[secondToLastIndex] as Keyframe).rotation;
    
            keyframes.RemoveAt(lastIndex);
        }
    }
}

Скачайте пакет с проектом и попробуйте поэкспериментировать с ним.

Подводим итог


Наша игра с перемоткой времени стала гораздо лучше. Алгоритм значительно усовершенствован, потребляет на 90% меньше вычислительной мощности и при этом намного стабильнее. Мы добавили интересный эффект, сообщающий игроку о том, что выполняется перемотка времени.

Настало время сделать на его основе настоящую игру!
Поделиться с друзьями
-->

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


  1. Alex_ME
    19.07.2017 11:30

    Спасибо, очень интересно.


    А не лучше ли использовать LinkedList вместо ArrayList? Выполняются частые операции вставки и удаления, доступ к произвольному элементу не требуется.


    А как быть в более сложных случаях, когда есть физика? Например, игрок сталкивает коробку и она падает. Если аналогично записывать и воспроизводить положение и поворот, не возникнут ли проблемы при принудительной установке положения Rigid Body объектам? Вроде слышал, что в юнити это плохо сказывается на производительности.


    1. mitinsvyat
      19.07.2017 11:45

      Детерминированая физика явно нужна.
      В статье чет автор прям горит желанием все снапшотить.


    1. PsyHaSTe
      24.07.2017 12:55
      +1

      Да тут вообще удивительно использование в 2017 году коллекций, задеприкейченых 15 лет назад...


  1. EyeGem
    19.07.2017 13:46
    +1

    Положения и вращения это хорошо, но в Настоящих (TM) играх у объектов ещё есть состояния. Плюс события создания и удаления самих объектов.

    Для состояний т.к. их может быть много (жизни, предметы и пр.), но при этом обычно на новом «кадре» меняются лишь некоторые (а восстанавливать хочется точно), то без выборочного дельта-кодирования в битовый/байтовый поток оказывается не обойтись т.е. кодировать для каждого «кадра» только те состояния, которые изменились и тратить на это минимум памяти в зависимости от величины изменения.

    Ну и следующий логичный шаг — чтобы не заниматься сравнением конечных значений легче будет писать изменения в «поток изменений» в том же месте кода, где значение меняется. А тут уже и до сериализации в виде do/undo потоков недалеко, чтобы вообще не делать изменений напрямую.


    1. lgorSL
      19.07.2017 18:03

      Мне кажется, для такого подхода Unity вообще плохо подходит, так как там смешаны в кучу состояние объекта, его графическое представление и физическая модель.
      Помню, писал это всё вручную — физика считалась со своим фиксированным шагом, при рисовании интерполировал (или экстраполировал) состояния. Вот там перемотку времени очень просто было бы сделать.
      Правда, хранить цепочку состояний можно только для мира с небольшим количеством сущностей.


      1. EyeGem
        19.07.2017 23:37

        На Unity тоже можно сделать общий объект-менеджер состояния игры (который в т.ч. может переживать перезагрузку сцены, см. примеры в Интернете), к которому будут обращаться создаваемые на сцене объекты и связывать своё состояние с нужными данными в модели через некоторые ID'шки.

        Если объект сцены создаётся динамически, либо если объект загружается из сохраненной игры, то ему прописывают ID из менеджера в одну из компонент при его создании. Если объект загружается вместе с Unity-сценой, то ID у него будет не прописан и он может автоматически зарегистрироваться в менеджере, который присвоит ему новый ID.

        Также можно дополнительно прописывать некоторые PersistentID'шки в объектах на сцене Unity, чтобы они связывали своё состояние всегда с определенными записями в БД (скажем, для онлайн игр или игр вроде Dark Souls без нормальных сейвов).


  1. gresolio
    19.07.2017 18:50

    Rewind time in Unity от Brackeys очень похоже на первую часть статьи, только на примере Rigid Body. Вот Example Project.


  1. perfect_genius
    19.07.2017 20:49

    Можно ещё записывать только ввод от игрока, но тогда уже придётся вычислять положения.


    1. DrZlodberg
      20.07.2017 10:28

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


    1. EyeGem
      20.07.2017 10:36

      Не совсем так.

      Плавно перематывать назад по записанным данным ввода можно будет только дискретные действия, например, четкое передвижение на клетку вверх/влево/вправо/вниз, или простое ожидание. А вот всякие цепочки последствий от, в общем случае, необратимых взаимодействий таким образом перемотать сложно т.к. нужно тогда делать ключевой кадр (snapshot) в прошлом и постоянно доматывать от него до нужного кадра в будущее, что может быть слишком медленно. Это как играть видео назад в видеоплеере из упакованного инкрементального формата.

      Ввод хорошо использовать для реплеев при детерминированной механике в т… ч. идентично настроенных генераторах случайных чисел. Скажем, реплеи для игр типа Clash of Clans занимают минимум места, там просто указано в какой момент какие команды задействовал игрок, но они привязаны к версии механики игры, которую нужно тем или иным образом стараться не сломать в новых версиях (записывать версию в реплей и уметь играть механику старых версий, либо просто не играть старые реплеи).


      1. perfect_genius
        21.07.2017 20:54

        Видимо так и работает «обратка» в эмуляторах ретро-консолей.