Создание roguelike в Unity с нуля

image

Существует не так много туториалов по созданию roguelike в Unity, поэтому я решил написать его. Не с целью похвастаться, а для того, чтобы поделиться знаниями с теми, кто находится на том этапе, на котором я был уже довольно давно.

Примечание: я не утверждаю, что это единственный способ создания roguelike в Unity. Он просто один из. Вероятно, не самый лучший и эффективный, я учился путём проб и ошибок. А некоторые вещи я буду изучать прямо в процессе создания туториала.

Будем считать, что вы знаете по крайней мере основы Unity, например, как создать префаб или скрипт, и тому подобное. Не ждите, что я буду учить вас, как создавать спрайтшиты, об этом есть множество прекрасных туториалов. Я буду делать упор не на изучение движка, а на то, как реализовать игру, которую мы будем создавать вместе. Если у вас возникнут трудности, то зайдите в одно из потрясающих сообществ в Discord и просите о помощи:

Unity Developer Community

Roguelikes

Итак, давайте приступим!

Этап 0 — планирование

Да, всё верно. Первое, что нужно создать — это план. Вам хорошо будет спланировать игру, а мне — спланировать туториал, чтобы спустя время мы не отвлеклись от темы. В функциях игры легко запутаться, прямо как в подземельях roguelike.

Мы будем писать roguelike. В основном мы будем слушаться мудрых советов разработчика Cogmind Джоша Ге, приведённых здесь. Сходите по ссылке, прочитайте пост или посмотрите видео, а потом возвращайтесь.

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

Послушавшись совета Джоша Ге, мы выстроим функции игры так, чтобы они вели нас к цели. Так мы получим каркас roguelike, который можно будет в дальнейшем расширять, добавлять собственные фишки, создавая уникальность. Или выбросить всё в корзину, воспользоваться полученным опытом и начать заново с нуля. В любом случае это будет потрясающе.

Я не буду давать вам никаких графических ресурсов. Рисуйте их сами или воспользуйтесь бесплатными тайлсетами, которые можно скачать здесь, здесь или поиском в Гугле. Только не забудьте упомянуть в игре авторов графики.

Теперь давайте перечислим все функции, которые будут в нашей roguelike в порядке их реализации:

  1. Генерация карты подземелий
  2. Персонаж игрока и его движение
  3. Область видимости
  4. Враги
  5. Поиск пути
  6. Бой, здоровье и смерть
  7. Повышение уровней игрока
  8. Предметы (оружие и зелья)
  9. Консольные читы (для тестирования)
  10. Этажи подземелья
  11. Сохранение и загрузка
  12. Финальный босс

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

Этап 1 — класс MapManager

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

Итак, создадим скрипт .cs под названием MapManager и откроем его.

Удалим ": MonoBehaviour", потому что не будет наследовать от него и не будет прикреплён ни к какому GameObject.

Удалим функции Start() и Update().

В конце класса MapManager создадим новый публичный класс под названием Tile.

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

Итак, у нас есть базовая информация тайла. Давайте создадим из этого тайла карту. Это просто, нам потребуется только двухмерный массив объектов Tile. Звучит сложно, но ничего особенного в этом нет. Достаточно просто добавить переменную Tile[,] в класс MapManager:

Вуаля! У нас есть карта!

Да, она пуста. Но это карта. Каждый раз, когда что-то будет двигаться или менять состояние на карте, информация этой карты будет обновляться. То есть, если, например, игрок попытается перейти на новый тайл, класс будет проверять адрес тайла назначения на карте, наличие в нём врага и его проходимости. Благодаря этому нам не придётся на каждом ходе проверять тысячи коллизий, и не понадобятся коллайдеры для каждого игрового объекта, что облегчит и упростит работу с игрой.

Получившийся код выглядит вот так:

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

public class MapManager 
{
    public static Tile[,] map; // the 2-dimensional map with the information for all the tiles
}

public class Tile { //Holds all the information for each tile on the map
    public int xPosition; // the position on the x axis
    public int yPosition; // the position on the y axis
    public GameObject baseObject; // the map game object attached to that position: a floor, a wall, etc.
}

Первый этап завершён, перейдём к заполнению карты. Теперь мы начнём создавать генератор подземелий.

Этап 2 — пара слов о структуре данных

Но прежде чем начать, позвольте мне поделиться советами, возникшими благодаря полученным после публикации первой части отзывам. При создании структуры данных необходимо с самого начала продумывать, как вы будете сохранять состояние игры. В противном случае позже это будет гораздо хаотичнее. Пользователь Discord st33d, разработчик Star Shaped Bagel (в эту игру можно бесплатно поиграть здесь), сказал, что поначалу создавал игру, думая, что в ней вообще не будет сохранения состояний. Постепенно игра начала становиться всё больше, и её фанат попросил добавить поддержку сохраняемой карты. Но из-за выбранного способа создания структуры данных было очень сложно сохранять данные, поэтому ему не удалось этого сделать.

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

Сохранять мы будем такие вещи, как массив переменных класса Tile, в котором хранится карта. Мы будем сохранять все эти данные, кроме переменных класса GameObject, которые находятся внутри класса Tile. Почему? Просто потому, что GameObject-ы невозможно сериализировать средствами Unity в хранимые данные.

Поэтому на самом деле нам не нужно сохранять данные, хранящиеся внутри GameObject-ов. Все данные будут храниться в таких классах, как Tile, а позже ещё и Player, Enemy и т.д. Затем у нас появятся GameObject-ы для упрощения вычисления таких вещей, как видимость и движение, а также отрисовки спрайтов на экране. Поэтому внутри классов будут переменные GameObject, но значение этих переменных не будет сохраняться и загружаться. При загрузке мы заставим генерировать GameObject заново из сохранённых данных (позиции, спрайта, и т.п.).

Тогда что же нам нужно делать прямо сейчас? Ну, просто добавим две строки в имеющийся класс Tile и одну в начало скрипта. Сначала мы добавим «using System;» в заголовок скрипта, а затем [Serializable] перед всем классом и [NonSerialized] прямо перед переменной GameObject. Вот так:

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

Этап 3 — ещё немного о структуре данных

Я получил ещё один отзыв о структуре данных, которым хочу поделиться здесь.

На самом деле существует множество способов реализации данных в игре. Первый, который использую я и который будет реализован в этом туториале: все данные тайлов находятся в классе Tile, и все они хранятся в массиве. У такого подхода много преимуществ: его проще читать, всё необходимое находится в одном месте, данными проще манипулировать и экспортировать их в файл сохранения. Но с точки зрения памяти он не так эффективен. Придётся выделять много памяти на переменные, которые никогда не будут использоваться в игре. Например, позже мы поместим в класс Tile переменную «Enemy GameObject», чтобы можно было прямо из карты указывать на GameObject врага, стоящего на этом тайле, чтобы упростить все вычисления, связанные с боем. Но это будет значить, что у каждого тайла в игре будет выделено пространство в памяти под переменную GameObject, даже если на этом тайле нет врага. Если на карте из 2500 тайлов есть 10 врагов, то будет 2490 пустых, но выделенных переменных GameObject — вы видите, сколько памяти тратится впустую.

Альтернативным методом стало бы использование структур для хранения базовых данных тайлов (например позиции и типа), а все остальные данные хранились бы в hashmap-ах, которые бы генерировались только при необходимости. Это бы сэкономило много памяти, но расплатой бы стала чуть более сложная реализация. На самом деле это было бы чуть продвинутей, чем мне хотелось бы в этом туториале, но если вам захочется, то в будущем я могу написать об этом более подробный пост.

Кроме того, если вы хотите прочитать обсуждение этой темы, то это можно сделать на Reddit .

Этап 4 — алгоритм генерации подземелий

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

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

В целом, используемый нами алгоритм работает следующим образом: допустим, у нас есть целая карта, заполненная нулевыми значениями — уровень, состоящий из камня. В начале мы вырезаем в центре комнату. Из этой комнаты мы пробиваем коридор в одном направлении, а затем добавляем другие коридоры и комнаты, всегда начиная случайным образом с уже существующей комнаты или коридора, пока не достигнем максимального количества коридоров/комнат, заданного в самом начале. Или пока алгоритм не сможет найти нового места для добавления новой комнаты/коридора, в зависимости от того, что произойдёт раньше. И так у нас получится подземелье.

Итак, давайте опишем это более похожим на алгоритм образом, шаг за шагом. Для удобства я буду называть каждую деталь карты (коридор или комнату) элементом, чтобы мне не приходилось каждый раз говорить «комната/коридор».

  1. Вырезаем комнату в центре карты
  2. Случайным образом выбираем одну из стен
  3. Пробиваем в этой стене коридор
  4. Случайным образом выбираем один из уже имеющихся элементов
  5. Случайным образом выбираем одну из стен этого элемента
  6. Если последний выбранный элемент — это комната, то генерируем коридор. Если коридор, то случайно выбираем, будет ли следующий элемент комнатой или ещё одним коридором
  7. Проверяем, достаточно ли места в выбранном направлении для создания нужного элемента
  8. Если место есть, создаём элемент, если нет, возвращаемся к этапу 4
  9. Повторяем с этапа 4

Вот и всё. У нас получится простая карта подземелья, в которой есть только комнаты и коридоры, без дверей и особых элементов, но это будет нашим началом. Позже мы заполним её сундуками, врагами и ловушками. И вы даже сможете настраивать её: мы научимся добавлять нужные вам интересные элементы.

Этап 5 — вырезаем комнату

Наконец-то приступаем к кодингу! Давайте вырежем нашу первую комнату.

Для начала создадим новый скрипт и назовём его DungeonGenerator. Он будет наследовать от Monobehaviour, поэтому позже нужно будет прикрепить его к GameObject. Затем нам нужно будет объявить в классе несколько публичных переменных, чтобы можно было задавать параметры подземелья из инспектора. Этими переменными будут ширина и высота карты, минимальная и максимальная высота и ширина комнат, максимальная длина коридоров и количество элементов, которые должны быть на карте.

Далее нам нужно инициализировать генератор подземелья. Мы делаем это для инициализации переменных, которые будут заполняться генерацией. Пока это будет только карта. А, и ещё удалим функции Start() и Update(), которые Unity генерирует для нового скрипта, они нам не понадобятся.

Здесь мы инициализировали переменную карты класса MapManager (которую мы создали на предыдущем этапе), передав ширину и высоту карты, определённые переменными выше как параметры двух размерностей массива. Благодаря этому у нас будет карта размера x по горизонтали (ширина) и размера y по вертикали (высота), и мы сможем обращаться к любой ячейке карты, вводя MapManager.map[x,y]. Это будет очень полезно при манипуляциях позицией предметов.

Теперь мы создадим функцию для отрисовки первой комнаты. Мы назовём её FirstRoom(). Мы сделали InitializeDungeon() публичной функцией, потому что она будет запускаться другим скриптом (Game Manager, который мы вскоре создадим; он централизует управление всего процесса запуска игры). Нам не нужно, чтобы к FirstRoom() имели доступ какие-либо внешние скрипты, поэтому не делаем её публичной.

Теперь для продолжения мы создадим три новых класса в скрипте MapManager, чтобы можно было создать комнату. Это классы Feature, Wall и Position. Класс Position будет содержать позиции x и y, чтобы мы могли отслеживать, где всё находится. Стена будет иметь список позиций, направление в котором она «смотрит» относительно центра комнаты (север, юг, восток или запад), длину, и наличие созданного из неё нового элемента. Элемент будет иметь список всех позиций, из которых он состоит, тип элемента, (комната или коридор), массив переменных Wall, и свою ширину и высоту.

Теперь займёмся функцией FirstRoom(). Вернёмся к скрипту DungeonGenerator и создадим функцию прямо под InitializeDungeon. Ей не нужно будет получать никаких параметров, поэтому оставим просто (). Далее, внутри функции нам нужно сначала создавать и инициализировать переменную Room и её список переменных Position. Мы делаем это так:

Теперь давайте зададим размер комнаты. Он будет получать случайное значение в промежутке между минимальной и максимальной высотой и шириной, объявленными в начале скрипта. Пока они пусты, потому что мы не задали для них значение в инспекторе, но не волнуйтесь, скоро мы это сделаем. Случайные значения мы задаём так:

Далее нам нужно объявить, где будет находиться начальная точка комнаты, то есть где в сетке карты будет располагаться точка комнаты 0,0. Мы хотим сделать так, чтобы она начиналась в центре карты (в половине ширины и половине высоты), но, возможно, не совсем точно в центре. Возможно, стоит добавить небольшой рандомайзер, чтобы она немного сдвинулась влево и вниз. Поэтому мы задаём xStartingPoint как половину ширины карты, а yStartingPoint как половину высоты карты, а затем берём только что заданные roomWidth и roomHeight, получаем случайное значение от 0 до этой ширины/высоты, и вычитаем его из начальных x и y. Вот так:

Далее, в той же функции мы добавим стены. Нам нужно инициализировать массив стен, которые находятся в только что созданной переменной комнаты, а затем инициализировать каждую переменную стены внутри этого массива. А затем инициализировать каждый список позиций, присвоить длине стены значение 0 и ввести направление, в котором будет «смотреть» каждая стена.

После инициализации массива мы обходим в цикле for() каждый элемент массива, инициализируем переменные каждой стены, а затем используем switch, дающий названия направлению каждой стены. Оно выбирается произвольно, нам нужно только помнить, что они будут обозначать.

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

Затем первым, что нужно сделать — это найти реальное значение позиции ячейки в масштабе карты из позиции комнаты. Это довольно просто: у нас есть начальные точки x и y. Они будут позицией 0,0 в сетке комнаты. Тогда если нам нужно получить реальное значение x,y из любого локального x,y, то мы сложим локальные x и y с начальными позициями x и y. Затем мы сохраним эти реальные значения x,y в переменную Position (из ранее созданного класса), а затем добавим их в List<> позиций комнаты.

Следующим шагом будет добавление этой информации на карту. Перед изменением значений не забудьте инициализировать переменную Tile.

Теперь мы внесём изменение в класс Tile. Перейдём в скрипт MapManager и добавим одну строку к определению класса Tile: «public string type;». Это позволит нам добавить класс тайла, объявив, что тайл в x,y является стеной, полом или чем-то ещё. Далее давайте вернёмся к циклу, в котором мы выполняли работу и добавим большую конструкцию if-else, которая позволит нам не только определить каждую стену, её длину и все позиции в этой стене, но и задать на глобальной карте, чем является конкретный тайл — стеной или полом.

И у нас уже кое-что получилось. Если переменная y (управление переменной во внешнем цикле) равна 0, то тайл принадлежит самой нижней строке ячеек в комнате, то есть является южной стеной. Если x (управление переменной внутреннего цикла) равна 0, то тайл принадлежит самому левому столбцу ячеек, то есть является западной стеной. А если он в самой верхней строке, то принадлежит северной стене, а в самой правой — восточной стене. Мы вычитаем 1 из переменных roomWidth и roomHeight, потому что эти значения считались начиная с 1, а переменные x и y цикла начинались с 0, поэтому нужно учесть эту разницу. А все ячейки, не удовлетворяющие условиям, не являются стенами, то есть они являются полом.

Отлично, мы почти закончили с первой комнатой. Она уже практически готова, нам только нужно поместить последние значения в созданную нами переменную Feature. Выйдем из цикла и завершим функцию так:

Отлично! У нас есть комната!

Но как нам понять, что всё работает? Нужно протестировать. Но как протестировать? Мы можем потратить время и добавить для этого ассеты, но это будет лишней тратой времени и слишком отвлечёт нас от завершения алгоритма. Хм, а ведь это можно сделать с помощью ASCII! Да, отличная идея! ASCII — это простой и малозатратный способ отрисовки карты, чтобы её можно было протестировать. Также при желании можно пропустить часть со спрайтами и визуальными эффектами, которую мы будем изучать позже, и создать свою игру целиком в ASCII. Поэтому давайте посмотрим, как это делается.

Этап 6 — отрисовка первой комнаты

Первое, о чём нужно подумать при реализации ASCII-карты — какой шрифт нам выбрать. Основной фактор, который нужно учитывать при выборе шрифта для ASCII: является ли он пропорциональным (переменной ширины) или моноширинным (фиксированной ширины). Нам нужен моноширинный шрифт, чтобы карты выглядели как нужно (см. пример ниже). По умолчанию в любом новом проекте Unity используется шрифт Arial, и он не моноширинный, поэтому нам нужно найти другой. В составе Windows 10 обычно есть моноширинные шрифты Courier New, Consolas и Lucida Console. Выберите один из этих трёх или скачайте любой другой в нужное вам место и поместите его в папку Fonts внутри папки Assets проекта.

Давайте подготовим сцену к выводу ASCII. Для начала сделаем цвет фона (Background color) основной камеры (Main Camera) сцены чёрным. Затем добавим в сцену объект Canvas, а в него добавим объект Text. Установим transform прямоугольника Text в middle center и в позицию 0,0,0. Настроим объект Text так, чтобы в нём использовался выбранный вами шрифт и белый цвет, горизонтальному и вертикальному выходу за границы (horizontal/vertical overflow) выберем Overflow, а выравнивание по вертикали и горизонтали центрируем. Затем переименуем объект Text в «ASCIITest» или нечто подобное.

Теперь вернёмся к коду. В скрипте DungeonGenerator создадим новую функцию под названием DrawMap. Мы хотим, чтобы она получала параметр, сообщающий, какую карту генерировать — ASCII или спрайтовую, поэтому создадим булев параметр и назовём его isASCII.

Затем мы будем проверять, является ли отрисоваемая карта ASCII. Если да (пока мы будем рассматривать только этот случай), то мы будем искать текстовый объект в сцене, передавать заданное ему имя как параметр, и получать его компонент Text. Но сначала нам нужно сообщить Unity, что мы хотим работать с UI. Добавим строку using UnityEngine.UI в заголовок скрипта:

Превосходно. Теперь мы можем получать компонент Text объекта. Карта будет огромной строкой, которая отражается на экране как текст. Именно поэтому она так проста в настройке. Итак давайте создадим строку и инициализируем её значением "".

Отлично. Итак, при каждом вызове DrawMap нам нужно будет сообщать, является ли карта ASCII. Если это так (а у нас пока так будет всегда, с else мы поработаем позже), то функция будет обыскивать иерархию сцены в поисках gameobject под названием «ASCIITest». Если он есть, то она будет получать его компонент Text и сохранять его в переменную screen, в которую мы потом с лёгкостью сможем записывать карту. Затем она создаёт строку, значение которой изначально пусто. Мы заполним эту строку нашей картой, обозначенной символами.

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

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

Итак, для каждой непустой ячейки мы проверяем её тип, а затем добавляем соответствующий символ. Мы хотим, чтобы стены обозначались символом "#", а полы — ".". И пока у нас есть только эти два типа. Позже, когда мы добавим игрока, монстров и ловушки, всё будет немного сложнее.

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

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

Поздравляю! Вы завершили скрипт, создающий комнату и выводящий её на экра. Теперь нам нужно всего лишь пустить эти строки в действие. Мы не используем Start() в скрипте DungeonGenerator, потому что хотим иметь отдельный скрипт для управления всем, что выполняется в начале игры, в том числе и генерацией карты, но также и настройкой игрока, врагов и т.д. Поэтому этот другой скрипт будет содержать функцию Start(), и при необходимости будет вызывать функции нашего скрипта. В скрипте DungeonGenerator есть функция Initialize, которая является публичной, а FirstRoom и DrawMap публичными не является. Initialize просто инициализирует переменные для настройки процесса генерации подземелий, поэтому нам нужна ещё одна функция, вызывающая процесс генерации, которая должна быть публичной, чтобы её можно было вызывать из других скриптов. Пока она будет только вызывать функцию FirstRoom(), а затем функцию DrawMap(), передавая ей значение true, чтобы она рисовала ASCII-карту. Ой, или нет, даже лучше — давайте создадим публичную переменную isASCII, которую можно будет включать в инспекторе, и просто будем передавать эту переменную как параметр функции. Отлично.

Так, а теперь давайте создадим скрипт GameManager. Он будет тем самым скриптом, который управляет всеми высокоуровневыми элементами игры, например, созданием карты и течением ходов. Удалим в нём функцию Update(), добавим переменную типа DungeonGenerator под названием dungeonGenerator, а в функции Start() создадим экземпляр этой переменной.

После этого мы просто вызываем функции InitializeDungeon() и GenerateDungeon() из dungeonGenerator, именно в таком порядке. Это важно — сначала нужно инициализировать переменные, и только после этого начинать на их основе строительство.

На этом часть с кодом завершена. Нам нужно создать пустой game object в панели иерархии, переименовать его в GameManager и прикрепить к нему скрипты GameManager и DungeonGenerator. А затем задать значения генератора подземелий в инспекторе. Вы можете попробовать для генератора различные схемы, а я остановился на такой:

Теперь просто нажмите на play и наблюдайте за магией! На экране игры вы должны увидеть нечто подобное:

Поздравляю, теперь у нас есть комната!

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

MapManager.cs:

using System.Collections;
using System; // So the script can use the serialization commands
using System.Collections.Generic;
using UnityEngine;

public class MapManager {
    public static Tile[,] map; // the 2-dimensional map with the information for all the tiles
}

[Serializable] // Makes the class serializable so it can be saved out to a file
public class Tile { // Holds all the information for each tile on the map
    public int xPosition; // the position on the x axis
    public int yPosition; // the position on the y axis
    [NonSerialized]
    public GameObject baseObject; // the map game object attached to that position: a floor, a wall, etc.
    public string type; // The type of the tile, if it is wall, floor, etc
}

[Serializable]
public class Position { //A class that saves the position of any cell
    public int x;
    public int y;
}

[Serializable]
public class Wall { // A class for saving the wall information, for the dungeon generation algorithm
    public List<Position> positions;
    public string direction;
    public int length;
    public bool hasFeature = false;
}

[Serializable]
public class Feature { // A class for saving the feature (corridor or room) information, for the dungeon generation algorithm
    public List<Position> positions;
    public Wall[] walls;
    public string type;
    public int width;
    public int height;
}

DungeonGenerator.cs:

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

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

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

    public int maxCorridorLength;
    public int maxFeatures;

    public bool isASCII;

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

    public void GenerateDungeon() {
        FirstRoom();
        DrawMap(isASCII);
    }

    void FirstRoom() {
        Feature room = new Feature();
        room.positions = new List<Position>();

        int roomWidth = Random.Range(widthMinRoom, widthMaxRoom);
        int roomHeight = Random.Range(heightMinRoom, heightMaxRoom);

        int xStartingPoint = mapWidth / 2;
        int yStartingPoint = mapHeight / 2;

        xStartingPoint -= Random.Range(0, roomWidth);
        yStartingPoint -= Random.Range(0, roomHeight);

        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<Position>();
            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++) {
                Position position = new Position();
                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";
                }
                else if (y == (roomHeight - 1)) {
                    room.walls[1].positions.Add(position);
                    room.walls[1].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (x == 0) {
                    room.walls[2].positions.Add(position);
                    room.walls[2].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (x == (roomWidth - 1)) {
                    room.walls[3].positions.Add(position);
                    room.walls[3].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else {
                    MapManager.map[position.x, position.y].type = "Floor";
                }
            }
        }

        room.width = roomWidth;
        room.height = roomHeight;
        room.type = "Room";
    }

    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;
        }
    }
}

GameManager.cs:

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

public class GameManager : MonoBehaviour
{
    DungeonGenerator dungeonGenerator;
    
    void Start() {
        dungeonGenerator = GetComponent<DungeonGenerator>();

        dungeonGenerator.InitializeDungeon();
        dungeonGenerator.GenerateDungeon();
    }
}

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