В этом туториале мы создадим простую игру, в которой игрок может перематывать назад действия. Делать мы её будем в 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-сигнала)
- Используем для хранения положения и поворота игрока собственный класс, а не массивы
Итак, мы создали простую игру, в которой можно перематывать время до предыдущей точки. Теперь мы можем усовершенствовать эту функцию и сделать её использование гораздо интереснее.
Запись меньшего объёма данных и интерполяция
На данный момент мы записываем положения и повороты игрока 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% меньше вычислительной мощности и при этом намного стабильнее. Мы добавили интересный эффект, сообщающий игроку о том, что выполняется перемотка времени.
Настало время сделать на его основе настоящую игру!
Автор: PatientZero