Как сделать рогалик на unity
Перейти к содержимому

Как сделать рогалик на unity

  • автор:

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

image

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

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

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

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

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

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

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

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

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

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

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

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

Этап 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 Listpositions; 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 Listpositions; 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(); 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(); 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(); 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.InitializeDungeon(); dungeonGenerator.GenerateDungeon(); > >

2D Roguelike

Over the course of the project you will create procedural tile-based levels, implement turn-based movement, add a hunger system, and finally add audio and mobile touch controls. This video series was filmed in Unity 5, but is compatible with Unity 4.6 as well.

Выберите вашу версию Unity
Последнее обновление: май 19, 2022
Английский

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

icon

Unity.com

icon

Новостная рассылка

icon

Asset Store

icon

Играть

icon

Unity Academic Alliance

icon

Сертификация Unity

icon

Преподаватели

© 2023 Unity Technologies

  • Правовая информация
  • Политика конфиденциальности
  • Cookie-файлы
  • Использование персональных данных
  • Настройки cookie-файлов

«Unity», логотипы Unity и другие торговые знаки Unity являются зарегистрированными торговыми знаками компании Unity Technologies или ее партнеров в США и других странах(подробнее здесь).Остальные наименования и бренды являются торговыми знаками соответствующих владельцев.

Создание 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 ListallFeatures; 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(); 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) else 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(); 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 = 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 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(); 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; > > >

Learn To Create A Roguelike Game In Unity

Learn To Create A Roguelike Game In Unity

Скачать Learn To Create A Roguelike Game In Unity вы можете на нашем сайте, который предлагает множество бесплатных Курсов и Программ для компьютерной графики, а также Плагины , Шаблоны , Скрипты и Пресеты для популярных 2D и 3D программ. CGDownload.ru представляет пользователям такие ресурсы, как 3D модели , Материалы и Текстуры , Звуковые Эффекты , LUTs , HDRI и многие другие вещи, которые помогут Вам в вашей работе.

File Name Learn To Create A Roguelike Game In Unity
Язык Английский
Категория Курсы
File Size 6,71 ГБ
Источник

Разработка игр стала проще. Изучите C# с помощью Unity и создайте свою собственную классическую ролевую игру!

ЧЕМУ ВЫ НАУЧИТЕСЬ

• Изучите C#, современный универсальный язык программирования.
• Понимание возможностей 2D-разработки в Unity.
• Развивайте сильные и передаваемые навыки решения проблем.
• Получите представление о процессе разработки игр.
• Узнайте, как объектно-ориентированное программирование работает на практике.

ОПИСАНИЕ

Узнайте, как создать и запрограммировать свою собственную игру Roguelike Dungeon Crawling с помощью Unity, стандартной программы разработки игр, используемой крупными игровыми студиями и независимыми разработчиками по всему миру.

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

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

В конце этого курса вы разовьете способность создавать такие игровые элементы, как:

• Полное движение персонажа сверху вниз с помощью Dashing
• Генерация процедурных уровней
• Полная коллекция оружия и система переключения
• Враги с разными моделями движения и стрельбы
• Несколько персонажей и переключение персонажей
• Система стрельбы
• Полная система здравоохранения
• Создание тайловых карт в Unity
• Система магазинов
• Внутриигровые карты
• Битвы с боссами
• И другое!…

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *