[Из песочницы] Скоростная разработка 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 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 ().mesh;
                collider = GetComponent();

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

© Habrahabr.ru