Создание roguelike в Unity с нуля: генератор подземелий

image

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

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

Помните созданный нами класс Position? Вообще-то в Unity уже имеется встроенный класс, выполняющий точно такие же функции, но с немного более качественным управлением — его проще объявлять и обрабатывать. Этот класс называется Vector2Int. Поэтому перед началом мы удалим из MapManager.cs класс Position и заменим каждую переменную Position на переменную Vector2Int.

Это же надо проделать в нескольких местах скрипта DungeonGenerator.cs. Теперь давайте приступим к остальной части алгоритма.

Этап 7 — генерация комнаты/коридора

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

Теперь нам нужно будет передавать этой функции параметры. Во-первых функции нужно знать, какой элемент она генерирует — комнату или коридор. Мы можем просто передать строку с именем type. Далее функции нужно знать начальную точку элемента, то есть из какой стены она исходит (потому что мы всегда создаём новый элемент из стены более старого элемента), и для этого достаточно передачи в качестве аргумента Wall. Наконец, первая создаваемая комната имеет особые характеристики, поэтому нам нужна необязательная переменная bool, сообщающая, является ли элемент первой комнатой. По умолчанию она имеет значение false: bool isFirst = false. Итак заголовок функции сменится с этого:

на это:

Отлично. Следующим шагом будет изменение способа вычисления ширины и высоты элемента. Пока мы вычисляем их, получая случайное значение между значениями min и max высоты и ширины комнат — для комнат это подходит идеально, но не будет работать для коридоров. Итак, пока у нас имеется следующее:

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

Итак. мы проверяем, является ли элемент комнатой. Если да, то мы делаем так же, как и раньше
— получаем случайное число в интервале между min и max высоты и ширины. Но теперь в else того же if нужно сделать нечто немного иное. Нам нужно проверить ориентацию коридора. К счастью, при генерации стены мы сохраняем информацию о том, в какую сторону она направлена, поэтому используем её для получения ориентации коридора.

Но мы пока не объявили переменную minCorridorLength. Нужно вернуться к объявлениям переменных и объявить её, прямо над maxCorridorLength.

Теперь вернёмся к нашим условным операторам switch. Что мы здесь делаем: получаем значение направления стены, то есть, куда смотрит стена, из которой будет идти коридор. Направление может иметь всего четыре возможных значения: South, North, West и East. В случае South и North коридор будет иметь ширину 3 (две стены и пол посередине) и переменную высоту (длину). Для West и East всё будет наоборот: высота будет постоянно равна 3, а ширина иметь переменную длину. Итак, давайте сделаем это.

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

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

В else если элемент не является первой комнатой, то мы получаем случайную точку на стене, из которой генерируется элемент. Во-первых, мы должны проверить, имеет ли стена размер 3 (это будет означать, что она является конечной точкой коридора), и если это так, то всегда будет выбираться средняя точка, то есть индекс 1 массива стены (при 3 элементах массив имеет индексы 0, 1, 2). Но если размер не равен 3 (стена не является конечной точкой коридора), то мы берём случайную точку в промежутке между точкой 1 и длиной стены минус 2. Это нужно, чтобы избежать проходов, создаваемых в углу. То есть, например, на стене с длиной 6 мы исключаем индексы 0 и 5 (первый и последний), и выбираем случайную точку среди точек 1, 2, 3 и 4.

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

Итак, если стена направлена на север, то нужно, чтобы элемент начинался в одной позиции на север по оси y, но в случайном числе позиций к западу по оси x, в интервале от 1 до ширины комнаты-2. В направлении юга ось x действует так же, но начальная позиция по оси y является позицией точки на стене минус высота комнаты. Западная и восточная стены следуют той же логике, только с перевёрнутыми осями.

Но прежде чем всё это делать, нам нужно сохранить позицию точки стены в переменную Vector2Int, чтобы можно было позже ею манипулировать.

Замечательно. Давайте сделаем это.

Итак, мы сгенерировали элемент с размером и позицией, и следующим этапом будет размещение элемента на карте. Но для начала нам нужно узнать, действительно ли на карте есть пространство для этого элемента в этой позиции. Пока мы просто вызовем функцию CheckIfHasSpace(). Она будет подчёркнута красным, потому что мы пока её не реализовали. Мы сделаем это сразу же после того, как закончим то, что нужно сделать здесь, в функции GenerateFeature(). Поэтому не обращайте внимания на красное подчёркивание, и продолжайте.

В следующей части создаются стены. Пока мы не будем её трогать, за исключением фрагмента во втором цикле for.

Во время написания этого поста я заметил, что эти конструкции if-else совершенно неверны. Например, некоторые стены в них будут получать длину 1. Так происходит потому, что когда позиция должна прибавляться, допустим, к северной стене, то если она была на углу с восточной стеной, она не добавится к восточной стене, как и должна. Это вызывало раздражающие баги алгоритма генерации. Давайте их устраним.

Исправить их довольно просто. Достаточно удалить все else, чтобы позиция проходила через все конструкции if, а не останавливалась на первой, если та вернула значение true. Затем последнюю else (ту, которая не else if) мы меняем на if, который проверяет, что позиция уже добавлена как Wall, и если это не так, добавляет её как Floor.

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

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

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

Здесь нам нужно кое-что изменить. Во-первых, тип элемента не всегда равен Room. К счастью, функции передаётся как параметр нужная переменная, а именно строка type. Так что давайте просто заменим здесь «Room» на type.

Хорошо. Теперь, чтобы генерирующий все элементы игры алгоритм работал правильно, нам нужно добавить сюда новые данные. А именно int, который подсчитывает количество созданных элементов и список всех созданных элементов. Поднимемся к месту, где мы объявляем все переменные, и объявим int с именем countFeatures, а также List элементов с именем allFeatures. Список всех элементов должен быть публичным, а счётчик int может быть и приватным.

Теперь вернёмся к функции GenerateFeature() и добавим в конец несколько строк: инкремент переменной countFeatures и добавление нового элемента в список allFeatures.

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

Этап 8 — проверяем, есть ли место

Теперь давайте создадим новую функцию сразу после завершения функции GenerateFeature(). Ей нужны два аргумента: позиция, в которой начинается элемент, и позиция, в которой он заканчивается. В качестве них можно использовать две переменные Vector2Int. Функция должна возвращать значение bool, чтобы можно было использовать его в if для проверки наличия места.

Она подчёркнута красным, потому что пока ничего не возвращает. Скоро мы это исправим, а пока не будем обращать внимания. В этой функции мы будем обходить в цикле все позиции между началом и концом элемента, и проверять, равна ли текущая позиция в MapManager.map значению null или там уже что-то есть. Если там что-то есть, то мы прекращаем выполнение функции и возвращаем false. Если нет, то продолжаем. Если функция доходит до конца цикла, не встретив заполненных мест, то возвращаем true.

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

Отлично. Теперь вернёмся к тому месту, где мы вставляем эту функцию внутрь функции GenerateFeature(). Нам нужно исправить этот вызов, потому что он не передаёт нужных аргументов.

Здесь мы хотим вставить оператор if для проверки наличия достаточного места для элемента. Если результат равен false, то на этом мы завершаем функцию, не вставляя новый элемент в MapManager.map.

Нам нужно передать требуемые аргументы, то есть две переменные Vector2Int. С первой всё просто, это позиция с координатами x и y точки начала элемента.

Со второй всё сложнее, но не намного. Это начальная точка плюс высота для y и ширина для x, с вычитанием из обоих 1 (потому что начало уже учтено).

Теперь давайте перейдём к следующему этапу — созданию алгоритма для вызова функции GenerateFeature().

Этап 9 — вызов генерируемых элементов

Вернёмся к функции GenerateDungeon(), созданной в предыдущей части статьи. Сейчас она должна выглядеть так:

Вызов FirstRoom() подчёркнут красным, потому что мы изменили имя этой функции. Поэтому давайте просто вызовем генерацию первой комнаты.

Мы передали необходимые аргументы: «Room» в качестве type, потому что первая комната всегда будет Room, new Wall(), потому что первая комната не создаётся ни из какой другой, поэтому мы просто передаём значение null, и это вполне нормально. Вместо new Wall() можно подставить null, это вопрос личных предпочтений. Последний аргумент определяет, является ли новый элемент первой комнатой, поэтому в нашем случае мы передаём true.

Теперь мы подходим к главному. Используем цикл for, который будет выполняться 500 раз — да, мы попробуем добавить элементы 500 раз. Но если количество созданных элементов (переменная countFeatures) равно максимальному заданному количеству элементов (переменная maxFeatures), то мы прерываем этот цикл.

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

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

Учтите, что у нас пока нет этой функции ChoseWall(). Давайте быстренько её напишем. Спустимся вниз, к завершению функции, и создадим её. Она должна возвращать стену, а в качестве аргумента использовать элемент, чтобы функция могла выбрать стену этого элемента.

Я создал её между функциями CheckIfHasSpace() и DrawMap(). Учтите, что если вы работаете в Visual Studio, которая устанавливается вместе с Unity, то можете использовать поля -/+ слева для сворачивания/разворачивания частей кода, чтобы упростить работу.

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

Теперь вернёмся к функции GenerateDungeon() и передадим исходный элемент как параметр функции ChoseWall().

Строка if (wall == null) continue; означает, что если функция поиска стены вернула false, то исходный элемент не может породить из себя новый элемент, поэтому функция продолжит (continue) цикл, то есть не она не смогла создать новый элемент и переходит к следующей итерации цикла.

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

Отлично. Теперь нам просто нужно вызвать функцию GenerateFeature(), передав ей в качестве параметров стену и тип.

И последнее — перейдите в инспектор Unity, выберите объект GameManager и измените значения на следующие:

Если теперь нажать на кнопку play, то вы уже увидите результаты!

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

Надеюсь, вам понравилось! В следующем посте мы создадим игрока, который будет перемещаться по подземелью, а потом мы превратим карту из ASCII в спрайтовую.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Post3 : MonoBehaviour {
    public int mapWidth;
    public int mapHeight;

    public int widthMinRoom;
    public int widthMaxRoom;
    public int heightMinRoom;
    public int heightMaxRoom;

    public int minCorridorLength;
    public int maxCorridorLength;
    public int maxFeatures;
    int countFeatures;

    public bool isASCII;

    public List<Feature> allFeatures;

    public void InitializeDungeon() {
        MapManager.map = new Tile[mapWidth, mapHeight];
    }

    public void GenerateDungeon() {
        GenerateFeature("Room", new Wall(), true);

        for (int i = 0; i < 500; i++) {
            Feature originFeature;

            if (allFeatures.Count == 1) {
                originFeature = allFeatures[0];
            }
            else {
                originFeature = allFeatures[Random.Range(0, allFeatures.Count - 1)];
            }

            Wall wall = ChoseWall(originFeature);
            if (wall == null) continue;

            string type;

            if (originFeature.type == "Room") {
                type = "Corridor";
            }
            else {
                if (Random.Range(0, 100) < 90) {
                    type = "Room";
                }
                else {
                    type = "Corridor";
                }
            }

            GenerateFeature(type, wall);

            if (countFeatures >= maxFeatures) break;
        }

        DrawMap(isASCII);
    }

    void GenerateFeature(string type, Wall wall, bool isFirst = false) {
        Feature room = new Feature();
        room.positions = new List<Vector2Int>();

        int roomWidth = 0;
        int roomHeight = 0;

        if (type == "Room") {
            roomWidth = Random.Range(widthMinRoom, widthMaxRoom);
            roomHeight = Random.Range(heightMinRoom, heightMaxRoom);
        }
        else {
            switch (wall.direction) {
                case "South":
                    roomWidth = 3;
                    roomHeight = Random.Range(minCorridorLength, maxCorridorLength);
                    break;
                case "North":
                    roomWidth = 3;
                    roomHeight = Random.Range(minCorridorLength, maxCorridorLength);
                    break;
                case "West":
                    roomWidth = Random.Range(minCorridorLength, maxCorridorLength);
                    roomHeight = 3;
                    break;
                case "East":
                    roomWidth = Random.Range(minCorridorLength, maxCorridorLength);
                    roomHeight = 3;
                    break;

            }
        }

        int xStartingPoint;
        int yStartingPoint;

        if (isFirst) {
            xStartingPoint = mapWidth / 2;
            yStartingPoint = mapHeight / 2;
        }
        else {
            int id;
            if (wall.positions.Count == 3) id = 1;
            else id = Random.Range(1, wall.positions.Count - 2);

            xStartingPoint = wall.positions[id].x;
            yStartingPoint = wall.positions[id].y;
        }

        Vector2Int lastWallPosition = new Vector2Int(xStartingPoint, yStartingPoint);

        if (isFirst) {
            xStartingPoint -= Random.Range(1, roomWidth);
            yStartingPoint -= Random.Range(1, roomHeight);
        }
        else {
            switch (wall.direction) {
                case "South":
                    if (type == "Room") xStartingPoint -= Random.Range(1, roomWidth - 2);
                    else xStartingPoint--;
                    yStartingPoint -= Random.Range(1, roomHeight - 2);
                    break;
                case "North":
                    if (type == "Room") xStartingPoint -= Random.Range(1, roomWidth - 2);
                    else xStartingPoint--;
                    yStartingPoint ++;
                    break;
                case "West":
                    xStartingPoint -= roomWidth;
                    if (type == "Room") yStartingPoint -= Random.Range(1, roomHeight - 2);
                    else yStartingPoint--;
                    break;
                case "East":
                    xStartingPoint++;
                    if (type == "Room") yStartingPoint -= Random.Range(1, roomHeight - 2);
                    else yStartingPoint--;
                    break;
            }
        }

         if (!CheckIfHasSpace(new Vector2Int(xStartingPoint, yStartingPoint), new Vector2Int(xStartingPoint + roomWidth - 1, yStartingPoint + roomHeight - 1))) {
            return;
        }

        room.walls = new Wall[4];

        for (int i = 0; i < room.walls.Length; i++) {
            room.walls[i] = new Wall();
            room.walls[i].positions = new List<Vector2Int>();
            room.walls[i].length = 0;

            switch (i) {
                case 0:
                    room.walls[i].direction = "South";
                    break;
                case 1:
                    room.walls[i].direction = "North";
                    break;
                case 2:
                    room.walls[i].direction = "West";
                    break;
                case 3:
                    room.walls[i].direction = "East";
                    break;
            }
        }

        for (int y = 0; y < roomHeight; y++) {
            for (int x = 0; x < roomWidth; x++) {
                Vector2Int position = new Vector2Int();
                position.x = xStartingPoint + x;
                position.y = yStartingPoint + y;

                room.positions.Add(position);

                MapManager.map[position.x, position.y] = new Tile();
                MapManager.map[position.x, position.y].xPosition = position.x;
                MapManager.map[position.x, position.y].yPosition = position.y;

                if (y == 0) {
                    room.walls[0].positions.Add(position);
                    room.walls[0].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (y == (roomHeight - 1)) {
                    room.walls[1].positions.Add(position);
                    room.walls[1].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (x == 0) {
                    room.walls[2].positions.Add(position);
                    room.walls[2].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (x == (roomWidth - 1)) {
                    room.walls[3].positions.Add(position);
                    room.walls[3].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (MapManager.map[position.x, position.y].type != "Wall") {
                    MapManager.map[position.x, position.y].type = "Floor";
                }
            }
        }

        if (!isFirst) {
            MapManager.map[lastWallPosition.x, lastWallPosition.y].type = "Floor";
            switch (wall.direction) {
                case "South":
                    MapManager.map[lastWallPosition.x, lastWallPosition.y - 1].type = "Floor";
                    break;
                case "North":
                    MapManager.map[lastWallPosition.x, lastWallPosition.y + 1].type = "Floor";
                    break;
                case "West":
                    MapManager.map[lastWallPosition.x - 1, lastWallPosition.y].type = "Floor";
                    break;
                case "East":
                    MapManager.map[lastWallPosition.x + 1, lastWallPosition.y].type = "Floor";
                    break;
            }
        }

        room.width = roomWidth;
        room.height = roomHeight;
        room.type = type;
        allFeatures.Add(room);
        countFeatures++;
    }

    bool CheckIfHasSpace(Vector2Int start, Vector2Int end) {
        for (int y = start.y; y <= end.y; y++) {
            for (int x = start.x; x <= end.x; x++) {
                if (x < 0 || y < 0 || x >= mapWidth || y >= mapHeight) return false;
                if (MapManager.map != null) return false;
            }
        }

        return true;
    }

    Wall ChoseWall(Feature feature) {
        for (int i = 0; i < 10; i++) {
            int id = Random.Range(0, 100) / 25;
            if (!feature.walls[id].hasFeature) {
                return feature.walls[id];
            }
        }
        return null;
    }

    void DrawMap(bool isASCII) {
        if (isASCII) {
            Text screen = GameObject.Find("ASCIITest").GetComponent<Text>();

            string asciiMap = "";

            for (int y = (mapHeight - 1); y >= 0; y--) {
                for (int x = 0; x < mapWidth; x++) {
                    if (MapManager.map[x, y] != null) {
                        switch (MapManager.map[x, y].type) {
                            case "Wall":
                                asciiMap += "#";
                                break;
                            case "Floor":
                                asciiMap += ".";
                                break;
                        }
                    }
                    else {
                        asciiMap += " ";
                    }

                    if (x == (mapWidth - 1)) {
                        asciiMap += "n";
                    }
                }
            }

            screen.text = asciiMap;
        }
    }
}

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр