Скоростная разработка Unity3D игры на конкурс

от автора

В данной статье я расскажу про интересные и немного неочевидные моменты разработки видеоигры в сжатые сроки: по регламенту конкурса, работоспособную демку необходимо сдать в течение недели, а релиз — в течение двух недель. Статья предназначена для тех, кто уже игрался с Unity3D, но еще не делал на этом игровом движке никаких проектов сложнее HelloWorld’а.

Картинка для привлечения внимания — скриншот игры.

image

Идея игры, концепт

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

Однако, с двухнедельного конкурса игр и спрос небольшой, тут можно попробовать.

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

В данном случае я выбрал довольно простой жанр — аркада с видом сверху, с элементами hack&slash. Вдохновлялся я следующими играми (я думаю, многие в них играли, видели, так или иначе знакомы):

Sword of the stars: The pit

image

Пошаговая игра в жанре Rogue-like, один из моих любимых рогаликов: необычайно простой, но в то же время захватывающий. Из этой игры я позаимствовал идею генерируемых лабиринтов и случайного спавна врагов.

Crimsonland

image

Аркада с видом сверху. Из этой игры я позаимствовал количество противников и динамику.

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

Из чего мы можем выбирать? Существует огромное количество игровых и графических движков, но самыми популярными на данный момент являются Unity3D, UnrealEngine4, cocos2d, libGDX. Первые два используются в основном для десктопных “сложных” игр, последние два — для простых двухмерных игр под сотовые телефоны. Также для простых игр существуют мышкоориентированные конструкторы.

image

Несмотря на то, что я зилот C++ и бесконечно люблю этот язык, я отдаю себе отчет, что данный язык — не то, на чем следует писать игру за две недели. Unity3D предоставляет возможность написания скриптов на C#, JS и на собственном пайтон-подобном языке Boo-script. Большинство программистов используют C# ввиду очевидных причин. Да и понравился мне Юнити, на уровне ощущений.

Разработка, архитектура, интересные моменты

Уровни

Игра предполагает бег по лабиринту, а лабиринт надо сгенерировать. Сначала я решил придумать алгоритм самостоятельно, на первый взгляд дело казалось простым: разбиваю поле на две неравные части по горизонтали или по вертикали, создаю проход из одной части в другую. Затем рекурсивно прохожу по левому и правому “островку” и так же их обрабатываю: если размеры позволяют — разделяю их на две части, если не позволяют — то оставяю комнату такой, как есть.

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

image
Иллюстрация к первоначальному алгоритму генерации лабиринта

Затем, я решил искать алгоритм генерации в интернете, нашел его на сайте gamedev.ru, написанный на языке C++.

Суть алгоритма такова: на пустом поле размещается желаемое количество — n комнат. Для каждой новой комнаты случайным образом в указанных пределах задается её размер, затем комната размещается на случайных координатах игрового поля. Если создаваемая комната пересекает ранее созданные комнаты, то её следует пересоздать в случайном месте со случайными размерами. Если комната через усновные m = 100 итераций не может найти себе место, то поле считается заполненным, и переходим к следующей комнате.

   void Generate(int roomsCount)     {         for (int i = 0; i < roomsCount; i++)         {             for (int j = 0; j < 100; j++)             {                 int w = Random.Range(3, 10);                 int h = Random.Range(3, 10);                  Room room = new Room();                 room.x = Random.Range(3, m_width - w - 3);                 room.y = Random.Range(3, m_height - h - 3);                 room.w = w;                 room.h = h;                  List<Room> inter = rooms.FindAll(room.Intersect);                 if (inter.Count == 0)                 {                     rooms.Add(room);                     break;                 }             }             return;         }     } 

image

Затем, когда необходимое количество комнат создано, необходимо соединить их проходами: Обходим все созданные комнаты и просчитываем путь по алгоритму поиска пути А* от центра обрабатываемой комнаты к центру каждой другой комнаты. Для алгоритма A* задаем вес для клетки комнаты единицу — 1, а для пустой клетки — k. Алгоритм выдаст нам набор клеток — путь от комнаты до комнаты, в этом и таится магия: увеличивая k, мы получаем более запутанный лабиринт с меньшим количеством коридоров, увеличивая — получаем “прошитый” коридорами лабиринт. Этот эффект достигается благодаря тому, что при большем весе пустой клетки, алгоритм поиска пути старается проложить путь в обход, нежели “пробить” дорогу.

void GenerateAllPassages() 	{ 		foreach (Room r in rooms) 		{ 			APoint center1 = new APoint(); 			center1.x = r.x + (r.w/2); 			center1.y = r.y + (r.h/2); 			center1.cost = 1;  			foreach (Room r2 in rooms) 			{ 				APoint center2 = new APoint(); 				center2.x = r2.x + (r2.w/2); 				center2.y = r2.y + (r2.h/2); 				center2.cost = 1; 				GeneratePassage(center1, center2); //Вызов алгоритма поиска пути 			} 		}  		for (int x = 0; x < m_width; x++) for (int y = 0; y < m_height; y++) 			if (m_data[x,y] == Tile.Path) m_data[x,y] = Tile.Floor; 	}  

Зелёными тайлами отмечен результат поиска пути из каждой комнаты в каждую.

image

Затем идет “декорация” лабиринта, поиск стен.

image

Алгоритм крайне неоптимизирован, на ноутбучном i7 генерация лабиринта 50*50 (правда, вместе с созданием игровых объектов, представляющих уровень) занимает порядка 10 секунд. К сожалению, я не могу даже примерно оценить сложность алгоритма генерации уровня, потому что сложность волнового алгоритма поиска пути зависит от коэффицента k — чем запутаннее лабиринт, тем более A* склонен к экспоненциальной сложности. Однако, он создает именно такие лабиринты, какие я хотел бы видеть в своей игре.

Теперь, у нас есть лабиринт, в виде двухмерного массива. Но игра у нас трехмерная, и необходимо как-то создать этот албиринт в трёхмерном пространстве. Самый очевидный и неправильный вариант — создаём для каждого элемента массива игровой объект — куб или плоскость. Минусом данного подхода будет тот факт, что каждый из этих объектов встроится в систему обработки событий, которая и так работает в одном потоке ЦП, существенно затормозив её. Сцена из скриншота с большим количеством комнат у меня на компьютере серьезно лагает. Может показаться, что этот подход будет нагружать видеокарту draw-call’ами — но нет, Юнити отлично батчит различные объекты с одним материалом. И всё же, так делать не стоит.

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

public void GenerateMesh(Map.Tile [,] m_data, Map.Tile type, float height = 0.0f, float scale = 1.0f) 	{ 		mesh = GetComponent<MeshFilter> ().mesh; 		collider = GetComponent<MeshCollider>();  		squareCount = 0;  		int m_width = m_data.GetLength(0); 		int m_height = m_data.GetLength(1);  		for (int x = 0; x < m_width; x++) for (int y = 0; y < m_height; y++) 		{ 			if (m_data[x,y] == type) 			{ 				newVertices.Add( new Vector3 (x*scale - 0.5f*scale  ,  height , y*scale - 0.5f*scale )); 				newVertices.Add( new Vector3 (x*scale + 0.5f*scale , height , y*scale - 0.5f*scale)); 				newVertices.Add( new Vector3 (x*scale + 0.5f*scale , height, y*scale + 0.5f*scale)); 				newVertices.Add( new Vector3 (x*scale - 0.5f*scale , height, y*scale + 0.5f*scale));  				newTriangles.Add(squareCount*4); 				newTriangles.Add((squareCount*4)+3); 				newTriangles.Add((squareCount*4)+1); 				newTriangles.Add((squareCount*4)+1); 				newTriangles.Add((squareCount*4)+3); 				newTriangles.Add((squareCount*4)+2);  				int tileIndex = 0;  const int numTiles = 4;                                      			 tileIndex = Mathf.RoundToInt(Mathf.Sqrt(Random.Range(0, numTiles * numTiles)*1.0f));  				int squareSize = Mathf.FloorToInt(Mathf.Sqrt(numTiles));   				 newUV.Add(new Vector2((tileIndex % squareSize)/squareSize, (tileIndex/squareSize)/squareSize));                     			newUV.Add(new Vector2(((tileIndex + 1) % squareSize)/squareSize, (tileIndex/squareSize)/squareSize));                     			newUV.Add(new Vector2(((tileIndex + 1) % squareSize)/ squareSize, (tileIndex / squareSize + 1)/squareSize));                     			newUV.Add(new Vector2((tileIndex % squareSize)/squareSize, (tileIndex/squareSize + 1)/squareSize));                      			 squareCount++; 			} 		}  		mesh.Clear (); 		mesh.vertices = newVertices.ToArray(); 		mesh.triangles = newTriangles.ToArray(); 		mesh.uv = newUV.ToArray(); 		mesh.Optimize (); 		mesh.RecalculateNormals ();  		Mesh phMesh = mesh;  		phMesh.RecalculateBounds(); 		collider.sharedMesh = phMesh;  		squareCount=0; 		newVertices.Clear(); 		newTriangles.Clear(); 		newUV.Clear(); 	} 

Для текстуры пола я использую алтас из четырёх разных тайлов:

image

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

tileIndex = Mathf.RoundToInt(Mathf.Sqrt(Random.Range(0, numTiles * numTiles)*1.0f)); 

Она обеспечивает коренную зависимость количества тайлов.

Затем, в список текстурных координат задаются текстурные координаты выбранного тайла. Этот блок кода работает на тайлсетах с числом тайлов равним степени двойки.

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

На этом этапе мы имеем геометрию уровня, но нам необходимо “оживить” его, добавив врагов, пауер-апы, катсцены и ещё какую-нибудь игровую логику, если это необходимо.

Для этого создадим синглтон Менеджера, отвечающего за уровни. Можно сделать “правильный” синглтон, но я просто добавил объект менеджера объектов на сцену, в которой непосредственно происходит геймплей и сделал поля менеджерами глобальными. Такое решение может быть академически неправильным (и имеет название индусский синглтон), однако с его помощью я добился того, что Юнити сериализует поля менеджера и позволяет редактировать их в реальном времени инспекторе объектов. Это гораздо удобнее и быстрее, чем изменять настройки каджого уровня в коде, позволяет отлаживать баланс не выключая плеер. Очень полезная фича, ей я пользовался не раз, например, для создания объектов класса, отвечающего за диалоги:

image

Набор игровых уровней — это массив объектов, описывающих каждый уровень. Класс, описывающый уровень таков:

image

Создание уровня происходит следующим образом: менеджер уровней получает сообщение Awake(), считывает значение глобальной переменной — номера уровня, который следует загрузить (первоначально оно равно нулю, увеличивается по мере продвижения протагонистом вглубь лабиринта), выбирает из массива объектов типа Level нужный уровень. Затем, если уровень процедурно генерируемый (generated == true), то запускается построение и декорация лабиринта. Построение лабиринта рассмотрено ранее, а декорация происходит за счет выполнения делегатов decorations и decorationsSize. В первый делегат я добавляю процедуры, не принимающие аргументов, например, добавление на уровень лестницы, а во второй — с аргументом, например, добавление на уровень пауер-апов (для сохранения равной плотности пауер-апов на квадратный метр лабиринта количество бонусов должно быть пропорционально квадрату линейного размера лабиринта) или врагов.

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

{{Mob1, 5.0f}, {Mob2, 1.0f}}

То моб типа Mob1 будет спавнить с вероятностью в пять раз больше, чем Mob2. Количество типов мобов может быть произвольным.
С помощью подобной функции можно случайно с определенной вероятностью размещать так же артефакты в сундуках, эффекты от неизвестных зелий и чего угодно!

image

Затем, как для генерируемого, как и для жестко заданного, мы инстанциируем префаб уровня, если таковой есть. Для жестко заданного уровня, это непосредственно уровень, со своей геометрией, персонажами. А для генерируемого это может быть диалогом или кат-сценой.

Персонажи. Скрипты. Взаимодействия

Несмотя на то, что Unity3D использует C#, предполагается работать не с привычным ООП, а с собственным паттерном, основанной на объектах, компонентах и событиях.

Если Вы хоть раз запускали Юнити, то Вам должны быть понятны эти сущности — игровые объекты имеют несколько компонентов, комноненты отвечают за те или иные функции объекта, среди которых могут быть пользовательские скрипты. Сообщения (пользовательские, или служебные, вроде Awake(), Start(), Update()) посылаются гейм объекту и доходят до каждого компонента, каждый компонент их обрабатывает по своему усмотрению. Из этого выходит привычный для Юнити паттерн: вместо наследования стоит использовать композицию компонентов, например, как показано на картинке.

image

Есть объект типа “Mob”, с компонентами, выполняющие спецефические задачи, названными характерными именами. Компонент EnemyScript отвечает за ИИ, посылает своему гейм обджекту сообщения Shoot(Vector3 target), чтобы выстрелить и сообщения MoveTo(Vector3 target), чтобы отправить моба к данной точке.

Компонент ShooterScript принимает сообщения “Shoot” и стреляет, MoveScript принимает сообщения MoveTo. Теперь, допустим, мы хотим сделать моба, который похож на данного, но с измененной логикой поведения, ИИ. Для этого можно просто заменить компонент со скриптом ИИ на какой-нибудь другой, например, вот так:

image

В привычном ООП на С++ это могло бы выглядеть вот так (в Юнити так делать не стоит):

class FooEnemy : public ShooterScript, public MoveScript, public MonoBehaviour {  	void Update()     {         MoveScript::Update();         ShooterScript::Update();          Shoot({ 0.0f, 0.0f, 0.0f});         MoveTo({ 0.0f, 0.0f, 0.0f});     } };  class BarEnemy : public ShooterScript, public MoveScript, public MonoBehaviour { 	void Update()     {         MoveScript::Update();         ShooterScript::Update();          Shoot{ 1.0f, 1.0f, 1.0f});         MoveTo({ 1.0f, 1.0f, 1.0f});     } }; 

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

Общение между разными объектами происходит похожим образом: когда объект пули обрабатывает служебное сообщение столкновения, он шлет сообщение “Damage(float)” тому объекту, с которым он столкнулся. Затем, компонент “Damage Receiver” принимает это сообщение, уменьшает количество хитпоинтов, и если хитпоинты ниже нуля — шлет себе сообщение “OnDeath()”.

Таким образом, мы можем получить несколько префабов противников с разнообразным поведением.

Лайфбар

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

imageimage

Во всех играх есть противники у которых надо отбавлять HP, и почти во всех играх есть герой, который ради отбавления HP у противников, тратит свою энергию либо ману. Для моего решения понадобится дочерний геймобджект (скажем, его можно назвать Healthbar) с квадом, который будет отображать нужную полоску. У этого объекта должен быть установлен компонент MeshRenderer с материалом, которым можно увидеть на превьюшке (квадрат, у которого левая сторона зеленая, а правая — красная).

image

Идея такова: устанавливаем тайлинг по горизонтальной оси в 0.5, и квад будет отображать половину изображения. Когда оффсет по той же оси установлен в 0, отображается левая половина квадрата (зелёная), когда в 1 — правая, в промежуточных значениях лайфбар ведёт себя так, как и следует вести себя лайфбару. В моём случае текстура лайфбара состоит из двух пикселей, но данный ментод позволяет отображать и красивые, ручной работы лайфбары.

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

image

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

image

image

Итоги

image

Конкурс я, к сожалению, проиграл, заняв позорное место в середине списка участников.

Ошибка, на мой взгляд, такова: очень мало контента. Потратив много времени на проектирование сферической в вакууме масштабируемости, в предпоследний день конкурса понял, что контента в игре нет вообще. Уровней, сюжета, спрайтов, врагов, бонусов и всего такого. В качестве протагониста в игре бегает плейсхолдер, вставленный ради смеха. В итоге, игра получилась на десять минут интересного, бодрого геймплея, с разнообразными врагами, с возможностью развития игры, но без истории и без графики. Нулевой уровень с квадратной платформой, подвешенной в пустоте сразу же ставит игрока в известность, что игра сырая и недоделанная. Это было не то, чего требовалось в конкурсе. Жюри хотелось читать историю, рассматривать пиксель-арт и слушать музыку, а не играть в игры.

Ещё, Юнити3д — это про ассеты. Конечно же, мне было интереснее разрабатывать велосипеды, что я и делал — ни одного ассета не использовал, все скрипты и ресурсы — свои. Но так делать не надо, если работа идет на результат, а не на интерес.

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

Надеюсь, статья была интересной и познавательной.

P.S.: Ознакомиться с игрой вы можете, скачав ее по этой ссылке.
Либо, в Яндекс браузере здесь.

ссылка на оригинал статьи https://habrahabr.ru/post/278947/


Комментарии

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

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