Программная генерация игровых 2D-миров в Unity — описание алгоритма Статьи редакции

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

Игровой разработчик из Кэмбриджа Джош Ньюман написал для издания Gamasutra колонку о том, как при помощи движка Unity можно создавать программно генерируемые игровые 2D-миры. Ньюман подробно описал создание ландшафта, городов и процесс расселения неигровых персонажей.

Когда я впервые столкнулся с такими играми как No Man's Sky и Dwarf Fortress, меня поразила идея, что можно создавать целую «реальность», пользуясь только алгоритмами и случайными числами.

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

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

Пример миров, сгенерированных с помощью алгоритма

Генерация биома и флоры

Первое, что должен делать алгоритм, — это генерация биома и базовой геологии мира. Для их создания алгоритм генерирует множество карт по методу шума Перлина для атмосферных осадков, карт высот и температур, циклично проходя по всему массиву данных (float[,]) с помощью функции Mathf.PerlinNoise из API Unity.

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

Таким образом, биом генерируется как комбинация атмосферных осадков, высоты и температуры. Для этого был создан специальный метод с входными параметрами в виде индекса биома и его параметров. Метод работает циклично, проходя по BiomeMap (int[,,]), и если параметры биома в BiomeMap[pos] больше искомых параметров, то он заменяет все, что есть в BiomeMap[pos] индексом биома.

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

Мои параметры генерации биома
Параметры Виттакера

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

Диаграмма класса для GroundTiles и скриншот из инспектора

Если вам интересно, как класс GroundTiles может отображаться в инспекторе, этот класс сериализуется путем размещения тэга [System.Serializable] над определением класса GroundTiles. Затем массив GroundTiles создается в классах генератора уровня: public GroundTiles[] groundTiles;

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

Растения (GroundTiles.NonObstructivePlants) и деревья (GroundTiles.NaturalBarriers) располагаются на местности абсолютно случайно, хотя они всё-таки немного зависят от дождей. Затем их расположение анализируется через алгоритм клеточных автоматов (Cellular Automaton), таким образом растения немного группируются между собой.

Неигровые персонажи, здания и города

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

1. Создается шумовая карта Перлина для определения густонаселенности.

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

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

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

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

Стены внутри зданий генерируются с помощью рекурсивного метода разделения (recursive division).

Диаграмма класса для BuildingType и скриншот инспектора

Более богатые и культурные здания создаются в зонах высокой плотности населения.

6. Неигровые персонажи случайно размещаются по карте в зависимости от густонаселенности, многие из них являются частью массива npcGroupType, который настраивается в инспекторе (так же, как и массив GroundTiles) и хранит информацию о том, на какой земле может появиться данная группа.

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

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

Диаграмма класса для городов

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

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

Диаграмма класса для неигровых персонажей

Создание геймобъектов в сцене

Я быстро заметил, что мог бы создавать геймобъекты только лишь в непосредственной близости от игрока, что означало, что размер мира зависит только от того, сколько я хотел ждать, пока мир сгенерируется, и от того, какой объем памяти я мог использовать под сохраненные данные. Учитывая, что я хотел, чтобы проект можно было демонстрировать на iPhone, я решил остановиться на размере мира в 750 x 2 x 750 участков («тайлов»).

Чтобы решить, какие геймобьекты создавать и где, я сохранил данные во множестве int[,,]-массивов и разнообразных списках классов, а именно:

  • list pickupables: для ключей, зелий и других предметов, которые игрок может забрать.
  • list treasureChests: для сундуков с сокровищами, с которыми игрок может взаимодействовать.
  • list humansData: список обьектов NPCInfo для человекоподобных неигровых персонажей.
  • int[,,] BiomeMap: хранит информацию о том, какой биом должен быть создан. Из этой же карты происходит создание участка земли, когда TileTypeMap не заменяет естественный биом (в случае дорог или стен), и создается в сцене следующим образом:
  • int[,,] TileTypeMap: Информация о том, какой вид участка карты расположен в этой местности, это может быть пол, стены, земля, лестница, двери, дороги, неигровые персонажи и так далее.
  • int[,,] VariantMap: На данном этапе это вариация TileTypeMap, например, какое растение, неигровой персонаж или фрагмент стены должны здесь находиться.

Это все можно упростить до всего лишь двух массивов (VariantMap и TileTypeMap), но мне нужно было знать какой биом расположен в определенных местах, чтобы создать специфичных для этого места врагов, дороги, растения, стены и так далее.

Выбор объекта для создания в сцене зависит от значений TileTypeMap и контролируется набором простых условных переходов (псевдокод):

for (int x = pos.x -spawnDist-10; x for(int y = pos.y-spawnDist-10; y if(xpos.x+spawnDist || y>pos.y+spawnDist) { Destroy(FloorTiles(x,y));} if (x>=pos.x-spawnDist && y>=pos.y-spawnDist && x<=pos.x+spawnDist && y<=pos.y+spawnDist) { if (TileTypeMap [pos] == 14 || 20 || 26) { Instantiate(roomTiles[BiomeMap[pos]].RoomTiles[Random.Range(0,RoomTiles.Length)]; if (VariantMap[pos] < 200) { Instantiate(roadProps[VariantMap[pos]]); } if ( VariantMap[pos] == 200 || 201) { Instantiate(Keys || Potions); } } if (TileTypeMap [pos] == […] for (int i = 0; i < HumansData.Count; i++) { if (HumansData[i].x == x && HumansData[i].y == y && HumansData[i].z == z) { Instantiate(Humans[HumansData[i].spawnIndex); } } […] } }

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

0
1 комментарий
Дмитрий Ткалич

Обычно сначала пишут сюжет, потом рисуют мир, а тут какая то наоборот...

Ответить
Развернуть ветку

Комментарий удален модератором

Развернуть ветку
-2 комментариев
Раскрывать всегда