Отличительной особенностью игр жанра раннер является бесконечный уровень, на котором игрок должен продержаться как можно дольше и собрать при этом как можно больше очков. В этой части речь пойдёт о способе генерирования новых сегментов дорожного полотна для создания эффекта бесконечности пути.
Генератор
В прошлый раз я закончил свой рассказ на анимации движения импортированных из блендера (Blender) в Unity 3D сегментов дороги. Сегменты дороги двигаются на встречу взору игрока, исчезая позади него. Рано или поздно исчезают все сегменты, и игрок остаётся в пустом пространстве, где ничего уже не движется. Так быть не должно, вместо исчезнувших сегментов должны генерироваться новые, чтобы игрок и дальше мог двигаться по дороге, а не висеть в пустом пространстве.
Для решения этой задачи на текущий момент у нас следующие исходные данные: есть пара-тройка сегментов дороги одинаковых размеров, но с разным ландшафтом. На каждом из сегментов установлен скрипт, который сдвигает дорогу на игрока с фиксированной скоростью. Нужно придумать механизм, который будет генерировать новый сегмент дороги в заданной точке игрового пространства, по мере продвижения дороги. То есть, когда уже существующие на сцене сегменты сдвинутся на дистанцию, равную длине одного сегмента, в месте, где был самый дальний от игрока сегмент, должен появиться новый сегмент.
Создать предмет на сцене игрового движка довольно просто, для этого можно воспользоваться функцией Instantiate(). Она создаёт экземпляр переданной в неё модели. Кроме самой модели в функцию Instantiate() нужно передать координаты позиции, где должна будет появиться модель, и сведения об ориентации модели в пространстве.
Остаётся только ответить на вопрос, когда создать новый сегмент. Если сегмент появится слишком рано, то модели будут накладываться друг на друга, если позже, то между между сегментами будут образовываться щели. Здесь я вижу как минимум два способа: замер расстояния до крайнего сегмента, он же и самый дальний от игрока, и отсчёт времени.
Зная положение предыдущего сегмента дороги вычислить расстояние до него от точки, в которой должен будет появиться новый сегмент, не представляет большой сложности. Скорость движения дороги тоже известна, как и длина одного её сегмента, всех этих данных вполне достаточно для создания нового сегмента дороге в нужном месте в нужное время.
Всё бы хорошо, и можно было бы приступать к написанию кода для генерации сегментов, но есть ещё одна проблема. Если генерировать постоянно один и тот же сегмент, то пробегающий мимо однотипный пейзаж быстро наскучит даже самому непритязательному игроку. Самое простое и логичное решение - это иметь несколько сегментов, и генерировать их по очереди друг за другом. Но такой способ проблему разнообразия до конца не решит, игрок рано или поздно заметит, что дорожный пейзаж пошёл заново.
Вместо того, чтобы различные сегменты генерировать друг за другом в одном и том же порядке, их следует выбирать в произвольной последовательности. Каждый сгенерированный уровень должен быть случайно выбран из общего их количества. В общем случае, получить случайные данные в Unity 3D можно при помощи класса Random. В нашем случае можно воспользоваться методом Random.Range(), он возвращает случайное число из диапазона, переданного в него в качестве аргумента.
Но и у этого метода есть свои недостатки. Во-первых, «случайный» метод может случайно генерировать одни значения чаще других, в итоге одни сегменты будут появляться чаще, чем другие и больше запоминаться игроку. Во-вторых, Random.Range() может «случайно» выдать два одинаковых значения подряд, особенно, если диапазон значений для случайного выбора невелик.
Поэтому, кроме случайного выбора того или иного сегмента, нужно ещё вести учёт тех сегментов, которые уже были выбраны, и не давать их выбрать до тех пор, пока не будут выбраны все имеющиеся в игре сегменты, а уже затем начинать процесс заново. Значит нужна функция или алгоритм, который, принимая на вход диапазон значений, будет выдавать в случайной последовательности все значения из принятого диапазона с одинаковой частотой.
Поскольку подобный алгоритм в будущем может пригодиться не только для генерации сегментов, но и ещё где-нибудь в игре, то его стоит вынести в отдельный универсальный класс. Я решил назвать класс «RandomExtractor», название может и не до конца объясняющее суть, но зато короткое.
Сами модели сегментов будут храниться в массиве с типом данных GameObject, который будет обрабатываться в другом классе, отвечающем за вывод сегментов дороги на сцену.
Первая задача, стоящая перед классом RandomExtractor, это учёт как тех элементов массива с сегментами дороги, которые уже были созданы на сцене, так и тех, которые только предстоит создать. Вторая задача будет заключаться в том, чтобы выбрать случайный элемент из группы ещё не побывавших на сцене элементов, и передать его порядковый номер в код, управляющий генерированием дорожных сегментов.
Решить первую задачу можно создав массив из булевых значений, длина которого будет равна длине массива с сегментами дороги, таким образом элементы обоих массивов будут соответствовать друг другу. Теперь, устанавливая одно из значений «true» или «false», можно будет сказать экземпляр какого сегмента уже был создан на сцене, а какого нет.
Создать массив булевых значений лучше всего в конструкторе класс, куда нужно будет передать массив с сегментами дороги для измерения его длины. Имя массива - «extractedItems».
public RandomExtractor(V[] items)
{
extractedItems = new bool[items.Length];
}
Кроме учитывающего создание сегментов дороги массива, нужно ещё уметь регистрировать тот момент, когда все сегменты будут сгенерированы, и надо начинать заново. Тут будет достаточно одной переменной булева типа, дадим ей имя «allItemsExtracted». Если она принимает значение «true», то все сегменты на сцене. Проверяем наличие незагруженных сегментов, пробегая массив extractedItems, пока не найдём в нём первый элемент со значением «false». Наличие такого элемента скажет, что экземпляры не всех сегментов есть на сцене.
bool allItemsExtracted = true;
foreach (bool item in extractedItems)
{
if (!item)
{
allItemsExtracted = false;
break;
}
}
В обратном же случае надо будет сбросить весь массив «extractedItems».
if (allItemsExtracted)
{
for (int i = 0; i < extractedItems.Length; i++)
{
extractedItems[i] = false;
}
}
Для выбора случайного элемента массива воспользуемся упоминавшимся выше методом Random.Range, если он вернёт номер того элемента, который загружен не был, то загружаем сегмент, соответствующий этому номеру.
int itemNumber = Random.Range(0, extractedItems.Length);
if (!extractedItems[itemNumber])
{
extractedItems[itemNumber] = true;
return itemNumber;
}
Если же Random.Range вернёт номер уже загруженного элемента, тогда проведём поиск первого незагруженного элемента в массиве, и загрузим его.
itemNumber = 0;
foreach (bool spawnedLevel in extractedItems)
{
if (!spawnedLevel)
{
extractedItems[itemNumber] = true;
return itemNumber;
}
itemNumber++;
}
Код всего класса RandomExtractor целиком приведён ниже. В наследовании от класса MonoBehaviour нет необходимости, так как RandomExtractor не будет присутствовать на каких-либо элементах сцены в качестве компонента.
using UnityEngine;
/// <summary>
/// Возвращает элементы массива в случайной последовательности без повторений
/// </summary>
/// <typeparam name="V">Тип данных элементов массива</typeparam>
public class RandomExtractor<V>
{
private bool[] extractedItems; // Массив в котором отмечается какой уровень уже был загружен, а какой нет
public RandomExtractor(V[] items) // В конструктор передаём массив, элементы которого надо будет извлекать в произвольной последовательности
{
extractedItems = new bool[items.Length]; // Создаём массив для учёта уже извлечённых элементов
}
public int NextItem() // Генерирует новые секторы дороги по мере продвижения игрока
{
bool allItemsExtracted = true; // Установка флага загрузки всех уровней в игру, если он установлен, массив будет сброшен
int itemNumber = Random.Range(0, extractedItems.Length); // Получение случайного номера уровня для загрузки в игру
foreach (bool item in extractedItems) // Проверяем, все ли уровни были загружены
{
if (!item) // Проверяем, был ли загружен сегмент дороги
{
allItemsExtracted = false; // Если есть хотя бы один уровень, который не был загружен, флаг сбрасываем
break; // Прекращаем поиск
}
}
if (allItemsExtracted) // Если все уровни были загружены, сбрасываем массив
{
for (int i = 0; i < extractedItems.Length; i++) // Пробегаем весь массив вцикле
{
extractedItems[i] = false; // Сбрасываем текущий флаг загрузки уровня
}
}
if (!extractedItems[itemNumber]) // Если уровень с таким номером ещё не загружался, то загружаем его
{
extractedItems[itemNumber] = true; // Отмечаем его как уже загруженный
return itemNumber; // Возвращаем номер для загрузки в игру
}
itemNumber = 0; // Если уровень с текущим уровнем уже был загружен, то сбрасываем его номер и
foreach (bool spawnedLevel in extractedItems) // Начинаем поиск первого незагруженного
{
if (!spawnedLevel) // Как только встречается уровень, который загружен не был
{
extractedItems[itemNumber] = true; // Отмечаем его как уже загруженный
return itemNumber; // Возвращаем номер для загрузки в игру
}
itemNumber++; // Если текущий уровень тоже уже загружен, смотрим следующий
}
return itemNumber; // Если ничего не помогло, загружаем то, что есть
}
}
Со случайным выбором генерируемого сегмента, надеюсь, всё более-менее прояснилось, можно переходить к самому процессу генерации. За генерацию сегментов станет отвечать скрипт под названием «LevelSpawner». Он будет прикреплён к объекту на сцене, значит наследование от базового класса MonoBehaviour для него обязательно.
В скриптах можно создавать поля, которые будут видны во вкладке инспектора, когда на сцене выделен объект, содержащий эти скрипты. В общем, любая открытая, или публичная переменная класса, наследующего от MonoBehaviour, будет видна в инспекторе, но публичные переменные далеко не всегда удобны в силу их чрезмерной доступности в разных частях программы. Закрытые же, или приватные переменные не отображаются в инспекторе. Если же хочется отобразить закрытую переменную в инспекторе, перед ней необходимо указать атрибут «[SerializeField]». Скрыть переменную в инспекторе тоже возможно. Например, может потребоваться скрыть в инспекторе публичные переменные, хотя бы для того, чтобы не загромождать его. Если перед переменной указать атрибут «[HideInInspector]», то она не будет отображаться на вкладке инспектора.
В инспекторе нам потребуются три поля. Первое из них - это массив сегментов дороги, из которых будет в произвольном порядке выбираться очередной сегмент и генерироваться перед игроком. Второе поле - длина сегмента, а третье - стартовый сегмент. Стартовый сегмент является самым дальним от игрока из уже присутствующих на сцене сегментов, до него мы будем измерять расстояние, чтобы определить, когда нужно сгенерировать новый сегмент.
[SerializeField] private GameObject[] segments;
[SerializeField] private float segmentLength;
[SerializeField] private GameObject startSegment;
Все три поля являются закрытыми, доступ к ним будет ограничен только классом LevelSpawner. Чтобы они отображались в инспекторе, перед ними стоит атрибут [SerializeField]. Массив segments и поле startSegment имеют тип GameObject, который является базовым для трёхмерных моделей на сцене. Протяжённость одного сегмента дороги может иметь как целое значение, так и дробное, поэтому тип данных у поля segmentLength - float.
Кроме трёх полей для инспектора, потребуются ещё три переменные. Одна из них будет служить для хранения данных, корректирующих положение генерируемого сегмента относительно тех, что уже на сцене. Дадим ей имя errCorrector. Вторая будет называться segmentOrientation, она отвечает за ориентацию генерируемого сегмента в пространстве. Обе они должны иметь тип данных Vector3. Последняя же из трёх будет являться экземпляром описанного ранее класса RandomExtractor.
private Vector3 errCorrector;
private Vector3 segmentOrientation = new Vector3(0, 0, 0);
private RandomExtractor<GameObject> roadSegment;
В скрипте LevelSpawner используются два стандартных для моноскриптов метода: Start() и Update(). В первом методе, запускающемся лишь один раз в самом начале, происходит инициализация переменной roadSegment и, в случае отсутствия стартового сегмента, создание такового.
void Start()
{
roadSegment = new RandomExtractor<GameObject>(segments);
if (startSegment != null)
return;
startSegment = Instantiate(segments[roadSegment.NextItem()],
transform.position, Quaternion.Euler(segmentOrientation));
}
В методе Update() измеряется дистанция до последнего созданного сегмента, и если дистанция равна или превышает длину сегмента, то создаётся новый сегмент. В случае превышения длины сегмента, создаваемый сегмент смещается на величину превышения, чтобы между сегментами не было разрывов.
void Update()
{
Vector3 distance = startSegment.transform.position - transform.position;
if (distance.z <= -segmentLength)
{
errCorrector = transform.position;
errCorrector.z += (distance.z + segmentLength);
startSegment = Instantiate(segments[roadSegment.NextItem()], errCorrector,
Quaternion.Euler(segmentOrientation));
}
}
Код скрипта LevelSpawner полностью приведён ниже:
using UnityEngine;
public class LevelSpawner : MonoBehaviour
{
#region Private Data
private Vector3 errCorrector; // Корректор положения генерируемого сегмента дороги
private Vector3 segmentOrientation = new Vector3(0, 0, 0); // Ориентация генерируемого сегмента в пространстве
private RandomExtractor<GameObject> roadSegment; // Выбирает произвольный сегмент дороги, который будет создан в игровом пространстве
#endregion
#region Inspector Fields
[SerializeField] private GameObject[] segments; // Загружаемые в игру сегменты дороги
[SerializeField] private float segmentLength; // Длина одного сегмента
[SerializeField] private GameObject startSegment; // Сегмент до которого будет производиться измерение расстояния для коррекции положения первого сгенерированного сегмента
#endregion
#region Lifecycle Methods
void Start()
{
roadSegment = new RandomExtractor<GameObject>(segments); // Инициализируем объект, отвечающий за выбор случайного сегмента дороги
if (startSegment != null) // Если в инспекторе указан уровень который может быть использован как отправной для начала коррекции дистанции
return; // Выходим, подготовительные работы в этом случае завершены
startSegment = Instantiate(segments[roadSegment.NextItem()], transform.position, Quaternion.Euler(segmentOrientation)); // Если в инспекторе не указан стартовый уровень, генерируем его
}
void Update()
{
Vector3 distance = startSegment.transform.position - transform.position; // Измеряем дистанцию до последнего сгенерированного сегмента (длина самого сегмента + ошибка)
if (distance.z <= -segmentLength) // Как только дистанция до последнего сгенерированного сегмента эквивалентна, либо превышает длину сегмента
{
errCorrector = transform.position; // Берём позицию генератора уровней
errCorrector.z += (distance.z + segmentLength); // Вносим коррекцию, поскольку значения отрицательные, используем сложение
startSegment = Instantiate(segments[roadSegment.NextItem()], errCorrector, Quaternion.Euler(segmentOrientation)); // Генерируем сектор
}
}
#endregion
}
Возьмём сцену из прошлой части. На сцене уже находится несколько сегментов дороги, расположенные друг за другом, подробнее об импорте сегментов в Unity 3D можно почитать тут. Если запустить игру, то сегменты начнут движение в сторону камеры. Позади них на некотором расстоянии создаём пустой объект и на него перетаскиваем скрипт LevelSpawner.
Скрипт измеряет дистанцию от объекта, на котором он находится, либо до стартового сегмента, либо до последнего сгенерированного на текущий момент сегмента. Вследствие чего, расстояние на котором следует располагать объект с генерирующим сегменты скриптом не так принципиально, я расположил его на расстоянии от дороги равном длине одного дорожного сегмента.
Заполняем поля генератора уровней в инспекторе. В поля «Segments» перетаскиваем префабы сегментов дороги из папки с ассетами. Указываем длину сегмента в поле «Segment Length». В поле стартового сегмента «Start Segment» перетаскиваем самый дальний от камеры сегмент со сцены, от него начнётся отсчёт расстояния для генерирования следующего за ним сегмента. Запускаем проект и видим, что дорога теперь не заканчивается.
Фон
Дорога движется нам навстречу, её длина теперь не ограничена теми сегментами, которые уже находятся на сцене, ведь вдали появляются всё новые сегменты, делая наш путь бесконечным. Всю идиллию нарушает лишь одна деталь в центре экрана - пустое пространство за последним сгенерированным сегментом дороги. Его нужно чем-то закрыть.
Но чем это лучше сделать? Можно увеличить количество сегментов дороги перед игроком, рано или поздно дорога станет настолько длинной, что вдали уже ничего видно не будет, но подобный подход скажется на производительности игры не в лучшую сторону, ведь увеличение количества движущихся на уровне моделей приведёт к росту затрат вычислительных ресурсов. Бесконечно увеличивать угол зрения камеры тоже нельзя из-за возникающих искажений изображения пространства камерой.
Остаётся ещё один способ - использование изображения вдаль уходящей дороги. Суть заключается в том, что на некотором удалении от игрока устанавливается картинка, изображающая уходящую вдаль дорогу. Такой подход оказывает минимальное влияние на производительность игры. С одной стороны, разрешение изображения не должно быть высоким - игрок видит его вдалеке и подробности вряд ли разглядит. С другой стороны, для отображения картинки потребуется всего одна плоскость, которая ещё будет и неподвижна. За картинкой можно будет генерировать новые сегменты, таким образом сам процесс возникновения куска дороги на пустом месте будет скрыт от глаз игрока картинкой.
Картинку нарисуем в блендере. Обладать художественными навыками не нужно - от руки рисовать ничего не придётся. Всё, что нам необходимо сделать, это собрать городскую застройку из уже имеющихся моделей, и отснять её на встроенную в блендер камеру. В центре застройки должна проходить дорога, она станет продолжением той дороги, по которой будет впоследствии двигаться автомобиль, управляемый игроком.
Камеру стоит установить по центру дороги и отодвинуть от дороги на такое расстояние, чтобы все модели попали в кадр. Модели следует расставлять так, чтобы нижняя часть изображения была полностью ими закрыта.
Поскольку изображение будет находиться далеко от игрока, в самом конце уровня, то разрешение картинки слишком большим делать не имеет смысла. Разрешение изображения меняется на вкладке «Output» в группе настроек «Format».
Нажатие на клавиатуре клавиши «F12» запускает процесс отрисовки картинки. Открывается окно «Blender Render», в котором начинает формироваться снимок с установленной ранее камеры. Когда формирование изображения завершится, его можно будет сохранить на диске ПК. Текстура готова.
Картинка может отрисоваться на чёрном фоне, нам же нужен прозрачный фон, за домами заднего плана картинки должно просматриваться небо, а не область чёрного цвета. В этом случае пригодится группа настроек «Film» на вкладке «Render». В ней необходимо установить галочку рядом с настройкой «Transparent». Теперь все пустые области за моделями перестанут окрашиваться чёрным цветом и станут прозрачными.
Загружаем полученное изображение в игровой движок. Для чего в «Unity» создадим папку, назовём её «RoadBackground». В этой папке будет храниться фон и всё, что с ним связано. Перетаскиваем в только что созданную папку файл с изображением городской застройки, который мы сгенерировали в блендере и сохранили на диске ПК.
Плоскость для фонаЧтобы игрок увидел застройку на горизонте в конце дороги нужна плоскость, она будет отображать импортированную нами картинку. На сцене, где находится дорога, создаём плоскость: во вкладке иерархия нажимаем кнопку добавить и в выпадающем меню выбираем раздел трёхмерные объекты, открывается следующее меню, в котором выбираем пункт плоскость.
Созданную плоскость следует поместить в дальнем от камеры конце дороги, но так, чтобы она закрывала небольшую часть сегмента дороги. Закончив с позиционированием плоскости, перетаскиваем её в папку с текстурой фона (вкладка «Project»), создаётся префаб.
Каждый префаб содержит определённый набор компонентов. В таком наборе будет присутствовать компонент материала, в котором собраны настройки отображения модели на сцене. В компоненте материала есть группа настроек «Surface Inputs», в ней есть слот «Base Map», если в него поместить текстуру, то она отобразится на поверхности модели.
Остаётся только совместить плоскость с сегментом так, чтобы дорога на сегменте совпадала с дорогой на плоскости.
Заключение
Эта часть была посвящена генерации дороги и фону, который игрок видит на горизонте. Теперь у нас есть полноценный уровень для раннера с бесконечно движущейся дорогой, но до полноценной игры ещё далеко, предстоит решить много задач, прежде чем в игру можно будет играть.
Как минимум нужен автомобиль на котором игрок поедет по дороге, необходимо также учитывать столкновения автомобиля с препятствиями и прочими объектами на пути, если таковые произойдут. Сегменты дороги генерируются, но не уничтожаются, что приведёт к постоянному росту потребления ресурсов. После того, как игрок проедет сегмент, сегмент должен быть удалён с уровня.
Следующая глава будет посвящена автомобилю, на котором игрок движется по дороге, его управлению и взаимодействию с окружающим миром.
Игру, о которой идёт речь, можно скачать тут, так же удобно воспользоваться приложением для загрузки и установки игр с itch.io, которое скачивается здесь.