Процедурные шаблоны для использования с Tilemaps, часть 2

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

Эта запись в блоге соответствует тем же правилам, что и часть I. Напоминаем вам, что эти правила таковы:
- Мы различаем, что является плиткой, а что нет, с помощью двоичного кода. 1 - включено, 0 - выключено.
- Мы будем хранить все наши карты в двумерном целочисленном массиве, который будет возвращаться пользователю в конце каждой функции (за исключением рендеринга).
- Я буду использовать функцию массива GetUpperBound(), чтобы получить высоту и ширину карты. Это означает, что в каждой функции будет меньше переменных, что позволяет сделать код чище.
- Я часто использую Mathf.FloorToInt(), потому что система координат тайлмапа начинается слева внизу, а использование Mathf.FloorToInt() позволяет округлять числа до целого числа.
- Весь код, представленный в этой статье, написан на C#.
В предыдущей статье блога мы рассмотрели некоторые способы использования шума Perlin для создания верхних слоев. К счастью, мы также можем использовать Perlin Noise для создания пещеры. Для этого мы получаем новое значение шума Перлина, которое включает в себя параметры нашей текущей позиции, умноженные на модификатор. Модификатор представляет собой значение от 0 до 1. Чем больше значение модификатора, тем более беспорядочной будет генерация Perlin. Затем мы округляем это значение до целого числа 0 или 1, которое сохраняем в массиве map. Взгляните на реализацию:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Причина, по которой мы используем модификатор вместо семени, заключается в том, что результаты генерации Perlin выглядят лучше, когда мы умножаем значения на число от 0 до 0,5. Чем меньше значение, тем более блочным будет результат. Взгляните на некоторые результаты. Этот gif начинается со значения модификатора 0,01 и постепенно увеличивается до 0,25.

На этом рисунке видно, что генерация Перлина на самом деле просто увеличивает шаблон с каждым тиком.
В предыдущем блоге мы увидели, что для определения того, пойдет ли платформа вверх или вниз, можно использовать подбрасывание монетки. В этом посте мы будем использовать ту же идею, но с дополнительными двумя вариантами - левым и правым. Эта вариация алгоритма Random Walk позволяет нам создавать пещеры. Для этого мы получаем случайное направление, затем перемещаем свою позицию и удаляем плитку. Мы продолжаем этот процесс до тех пор, пока не достигнем необходимого количества пола, который нужно уничтожить. На данный момент мы используем только 4 направления: вверх, вниз, влево, вправо.
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Начнем с функции:
Поиск стартовой позиции
Расчет количества напольных плиток, которые необходимо удалить
Удаление плитки в начальной позиции
Добавьте одного к нашему числу
Далее мы переходим к циклу while. Это создаст для нас пещеру:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Ну, во-первых, мы решаем, в каком направлении нам двигаться, используя случайное число. Далее мы проверяем новое направление с помощью оператора switch case. В этом операторе мы проверяем, является ли данная позиция стеной. Если это не так, то мы удаляем фрагмент плитки из массива. Продолжаем делать это до тех пор, пока не достигнем требуемого количества пола. Конечный результат показан ниже:

Я также создал собственную версию этой функции, которая включает диагональные направления. Код этой функции немного длинный, поэтому если вы хотите посмотреть на него, пожалуйста, посмотрите ссылку на проект в начале этой записи.
Направленный туннель начинается в одном конце карты и ведет к противоположному концу. Мы можем управлять изгибом и шероховатостью туннеля, введя их в функцию. Мы также можем определить минимальную и максимальную длину частей туннеля. Давайте посмотрим на реализацию ниже:
public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curvyness)
{
//Это значение переходит от своего минусового аналога к положительному, в данном случае при значении ширины 1 ширина туннеля равна 3
int tunnelWidth = 1;
//Установите начальную позицию X в центр туннеля
int x = map.GetUpperBound(0) / 2;
//Устанавливаем рандом с помощью семян
System.Random rand = new System.Random(Time.time.GetHashCode());
//Создайте первую часть туннеля
for (int i = -tunnelWidth; i <= tunnelWidth; i++)
{
map[x + i, 0] = 0;
}
Сначала мы зададим значение ширины. Это значение ширины переходит от минуса к плюсу. В итоге мы получим нужный нам размер. В данном случае мы используем значение 1. Это, в свою очередь, даст нам общую ширину 3, потому что мы будем использовать значения -1, 0, 1.
Следующее, что мы сделаем, - это установим начальную x-позицию, для этого нужно получить середину ширины карты. Теперь, когда мы установили первые значения, мы можем проложить туннель к первой части карты.

Теперь перейдем к созданию остальной части карты.
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Сгенерируйте случайное число, чтобы сверить его с нашим значением шероховатости, и если оно выше этого значения, мы можем изменить ширину дорожки. Мы также проверяем, не делаем ли мы ширину слишком маленькой. С помощью следующего фрагмента кода мы прокладываем свой путь по карте, прокладывая туннели по мере продвижения. На каждом этапе мы делаем следующее:
Сгенерируйте новое случайное число, чтобы сверить его с нашим значением кривой. Как и в предыдущей проверке, если значение выше, мы можем изменить центральную точку контура. Мы также проверяем, не выходим ли мы за края карты.
Наконец, мы прокладываем туннель в новой секции, которую мы создали.
Конечные результаты этой реализации выглядят следующим образом:

Клеточный автомат использует соседство клеток, чтобы определить, включена ли текущая клетка (1) или выключена (0). В основе этих кварталов лежит случайно сгенерированная сетка ячеек. В нашем случае мы сгенерируем эту начальную сетку с помощью функции Random.Next в C#.
Поскольку у нас есть несколько разных реализаций Cellular Automata, я сделал отдельную функцию для генерации этой базовой сетки. Функция выглядит следующим образом:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
В этой функции мы также можем определить, нужны ли нам стены на нашей решетке. В остальном все относительно просто. Мы сверяем случайное число с процентом заполнения, чтобы определить, включена или выключена текущая ячейка. Взгляните на результат:

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

Правила соседства таковы:
- Проверьте все направления на наличие соседей.
- Если сосед является активной плиткой, добавьте одну плитку в окружение.
- Если сосед не является активной плиткой, ничего не делайте.
- Если клетка имеет более 4 окружающих плиток, сделайте ее активной.
- Если клетка имеет ровно 4 окружающие плитки, оставьте плитку в покое.
- Повторяйте, пока не испробуем все плитки на карте.
Функция проверки соседства Мура выглядит следующим образом:
static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* Соседство Мура выглядит следующим образом ("T" - наша плитка, "N" - наши соседи)
*
* N N N
* N T N
* N N N
*
*/
int tileCount = 0;
for(int neighbourX = x - 1; neighbourX <= x + 1; neighbourX++)
{
for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++)
{
if (neighbourX >= 0 && neighbourX < map.GetUpperBound(0) && neighbourY >= 0 && neighbourY < map.GetUpperBound(1))
{
//Мы не хотим считать плитку, окружение которой мы проверяем
if(соседX != x || соседY != y)
{
tileCount += map[neighbourX, neighbourY];
}
}
}
}
return tileCount;
}
После того как мы проверили нашу плитку, мы переходим к использованию информации в нашей функции сглаживания. Как и в случае с генерацией клеточного автомата, мы можем задать, являются ли края карты стенами.
public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls);
if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1)))
{
//Установите, что ребро является стеной, если значение edgesAreWalls равно true
map[x, y] = 1;
}
//Правило Мура по умолчанию требует более 4 соседей
else if (surroundingTiles > 4)
{
map[x, y] = 1;
}
else if (surroundingTiles < 4)
{
map[x, y] = 0;
}
}
}
}
//Возвращаем измененную карту
вернуть карту;
}
Главное, что следует отметить в этой функции, - это то, что у нас есть цикл for для сглаживания карты определенное количество раз. В результате мы получаем более красивую карту.

Мы всегда можем модифицировать этот алгоритм, соединяя комнаты друг с другом, если, например, между ними всего 2 блока.
Соседство фон Неймана - еще один популярный метод реализации клеточных автоматов. Для этого поколения мы используем более простую окрестность, чем та, которую мы использовали в поколении Мура. Район выглядит следующим образом:

Правила этого района таковы:
- Проверьте вокруг плитки прямых соседей, не включая диагонали.
- Если ячейка активна, добавьте единицу к нашему счету.
- Если ячейка неактивна, ничего не делайте.
- Если у нас более 2 соседей, сделайте активной текущую ячейку.
- Если у нас меньше 2 соседей, сделайте текущую ячейку неактивной.
- Если у нас ровно 2 соседа, не изменяйте текущую ячейку.
Второй результат основан на тех же принципах, что и первый, но расширяет область соседства.
Мы проверяем наличие соседей с помощью следующей функции:
static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* Соседство фон Неймана выглядит следующим образом ("T" - наша плитка, "N" - наш сосед)
*
* N
* N T N
* N
*
*/
int tileCount = 0;
//Сохраните края как стены
if(edgesAreWalls && (x - 1 == 0 || x + 1 == map.GetUpperBound(0) || y - 1 == 0 || y + 1 == map.GetUpperBound(1))
{
tileCount++;
}
//Убедитесь, что мы не касаемся левой стороны карты
if(x - 1 > 0)
{
tileCount += map[x - 1, y];
}
//Убедитесь, что мы не касаемся нижней части карты
если(y - 1 > 0)
{
tileCount += map[x, y - 1];
}
//Убедитесь, что мы не касаемся правой стороны карты
if(x + 1 < map.GetUpperBound(0))
{
tileCount += map[x + 1, y];
}
//Убедитесь, что мы не касаемся верхней части карты
if(y + 1 < map.GetUpperBound(1))
{
tileCount += map[x, y + 1];
}
return tileCount;
}
Получив результат о количестве соседей, мы можем перейти к сглаживанию массива. Как и раньше, у нас есть цикл for для итерации сглаживания для требуемого количества вводимых данных.
public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
//Получите окружающие плитки
int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls);
if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1)))
{
//Сохраните наши края как стены
map[x, y] = 1;
}
//von Neuemann Neighbourhood требует, чтобы только 3 или более окружающих плиток были заменены на плитку
else if (surroundingTiles > 2)
{
map[x, y] = 1;
}
else if (surroundingTiles < 2)
{
map[x, y] = 0;
}
}
}
}
//Возвращаем измененную карту
вернуть карту;
}
Конечный результат выглядит гораздо более блочным, чем в Moore Neighbourhood, как можно видеть ниже:

Как и в случае с Moore Neighbourhood, мы можем запустить дополнительный скрипт поверх генерации, чтобы обеспечить лучшую связь между областями карты.
Надеюсь, я вдохновил вас на то, чтобы начать использовать процедурные генерации в своих проектах. Если вы еще не скачали проект, вы можете получить его отсюда. Если вы хотите узнать больше о процедурной генерации карт, загляните на Procedural Generation Wiki или Roguebasin.com - это отличные ресурсы.
Если вы сделали что-то крутое с использованием процедурной генерации, не стесняйтесь оставить мне сообщение в Twitter или комментарий ниже!
Хотите узнать о нем больше и получить живую демонстрацию? Я также рассказываю о процедурных паттернах для использования с тайлмапами на Unite Berlin, в мини-театре экспо-холла 20 июня. Я буду рядом после выступления, если вы захотите пообщаться лично!
