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

в 9:13, , рубрики: C#, game development, Gamedev, 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.: Ознакомиться с игрой вы можете, скачав ее по этой ссылке.
Либо, в Яндекс браузере здесь.

Автор: HotkeyM

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js