В данной статье я расскажу про интересные и немного неочевидные моменты разработки видеоигры в сжатые сроки: по регламенту конкурса, работоспособную демку необходимо сдать в течение недели, а релиз — в течение двух недель. Статья предназначена для тех, кто уже игрался с Unity3D, но еще не делал на этом игровом движке никаких проектов сложнее HelloWorld’а.
Картинка для привлечения внимания — скриншот игры.
Идея игры, концепт
Я обожаю игры. Как явление. И разработка видеоигр — весьма интересная спайка искусства программирования, математики и арта. К сожалению, я пока не могу себе позволить достаточно времени и ресурсов, чтобы заниматься геймдевом в полной, коммерчески-успешной мере: несмотря существенное упрощение процесса создания игр, опыт показал, что более-менее целостная игра на мобилки получает меньше внимания, чем двухдневный хелло-ворлд три-четыре года назад.
Однако, с двухнедельного конкурса игр и спрос небольшой, тут можно попробовать.
На самом первом этапе разработки, необходимо выбрать концепцию игры. Жанр, геймплей, похожие игры, чем Ваша игра будет лучше существующих. И самое главное в этот момент — не переоценить свои силы, потому что регламент очень жесткий в плане времени, да и в отрыве от конкурса очень важно адекватно оценить свои возможности и возможности своей команды — иначе разработка может затянуться, а энтузиазм потеряться.
В данном случае я выбрал довольно простой жанр — аркада с видом сверху, с элементами hack&slash. Вдохновлялся я следующими играми (я думаю, многие в них играли, видели, так или иначе знакомы):
Sword of the stars: The pit
Пошаговая игра в жанре Rogue-like, один из моих любимых рогаликов: необычайно простой, но в то же время захватывающий. Из этой игры я позаимствовал идею генерируемых лабиринтов и случайного спавна врагов.
Crimsonland
Аркада с видом сверху. Из этой игры я позаимствовал количество противников и динамику.
В итоге, я пришел к следующему геймплею: игрок, управляя персонажем, бегает по лабиринту, отстреливает врагов, собирает пауер-апы, взаимодействует с окружением уровня, ищет ключи и переходит на следующий уровень, где супостаты злее, а лабиринты запутаннее. Выбор среды разработки, инструментов, платформы.
Из чего мы можем выбирать? Существует огромное количество игровых и графических движков, но самыми популярными на данный момент являются Unity3D, UnrealEngine4, cocos2d, libGDX. Первые два используются в основном для десктопных “сложных” игр, последние два — для простых двухмерных игр под сотовые телефоны. Также для простых игр существуют мышкоориентированные конструкторы.
Несмотря на то, что я зилот C++ и бесконечно люблю этот язык, я отдаю себе отчет, что данный язык — не то, на чем следует писать игру за две недели. Unity3D предоставляет возможность написания скриптов на C#, JS и на собственном пайтон-подобном языке Boo-script. Большинство программистов используют C# ввиду очевидных причин. Да и понравился мне Юнити, на уровне ощущений.
Разработка, архитектура, интересные моменты
Уровни
Игра предполагает бег по лабиринту, а лабиринт надо сгенерировать. Сначала я решил придумать алгоритм самостоятельно, на первый взгляд дело казалось простым: разбиваю поле на две неравные части по горизонтали или по вертикали, создаю проход из одной части в другую. Затем рекурсивно прохожу по левому и правому “островку” и так же их обрабатываю: если размеры позволяют — разделяю их на две части, если не позволяют — то оставяю комнату такой, как есть.
Получается такое несбалансированное бинарное дерево со всеми вытекающими — из любой комнаты в любую другую можно пройти по одному единственному пути. Это не очень правильно с точки зрения интереса исследования данного лабиринта, да и сами лабиринты имели явный квадратно-гнездовой вид с тенденцией к вытянутым, узким комнатам, похожий чем-то на застройку спальных районов в России, как бы я не юстирован константы. К сожалению, первоначальный вариант алгоритма не сохранился, и я не могу показать его результаты.
Иллюстрация к первоначальному алгоритму генерации лабиринта
Затем, я решил искать алгоритм генерации в интернете, нашел его на сайте 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;
}
}
Затем, когда необходимое количество комнат создано, необходимо соединить их проходами: Обходим все созданные комнаты и просчитываем путь по алгоритму поиска пути А* от центра обрабатываемой комнаты к центру каждой другой комнаты. Для алгоритма 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;
}
Зелёными тайлами отмечен результат поиска пути из каждой комнаты в каждую.
Затем идет “декорация” лабиринта, поиск стен.
Алгоритм крайне неоптимизирован, на ноутбучном 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();
}
Для текстуры пола я использую алтас из четырёх разных тайлов:
Причем я заметил, что естественнее выглядит такое распределение, когда одних тайлов больше, а других — меньше. На самом деле, нет такого подзамелье, где в среднем на каждом втором квадратном метре пола раскидано аккурат по одному скелетику рыбки.
Для создания такого неравномерного распределения, я использую следующую строку:
tileIndex = Mathf.RoundToInt(Mathf.Sqrt(Random.Range(0, numTiles * numTiles)*1.0f));
Она обеспечивает коренную зависимость количества тайлов.
Затем, в список текстурных координат задаются текстурные координаты выбранного тайла. Этот блок кода работает на тайлсетах с числом тайлов равним степени двойки.
Результат виден на КДПВ — вполне естественно и непринуженно, несмотря на то, что у тайлов всё же есть стыки, и их всего четыре.
На этом этапе мы имеем геометрию уровня, но нам необходимо “оживить” его, добавив врагов, пауер-апы, катсцены и ещё какую-нибудь игровую логику, если это необходимо.
Для этого создадим синглтон Менеджера, отвечающего за уровни. Можно сделать “правильный” синглтон, но я просто добавил объект менеджера объектов на сцену, в которой непосредственно происходит геймплей и сделал поля менеджерами глобальными. Такое решение может быть академически неправильным (и имеет название индусский синглтон), однако с его помощью я добился того, что Юнити сериализует поля менеджера и позволяет редактировать их в реальном времени инспекторе объектов. Это гораздо удобнее и быстрее, чем изменять настройки каджого уровня в коде, позволяет отлаживать баланс не выключая плеер. Очень полезная фича, ей я пользовался не раз, например, для создания объектов класса, отвечающего за диалоги:
Набор игровых уровней — это массив объектов, описывающих каждый уровень. Класс, описывающый уровень таков:
Создание уровня происходит следующим образом: менеджер уровней получает сообщение Awake(), считывает значение глобальной переменной — номера уровня, который следует загрузить (первоначально оно равно нулю, увеличивается по мере продвижения протагонистом вглубь лабиринта), выбирает из массива объектов типа Level нужный уровень. Затем, если уровень процедурно генерируемый (generated == true), то запускается построение и декорация лабиринта. Построение лабиринта рассмотрено ранее, а декорация происходит за счет выполнения делегатов decorations и decorationsSize. В первый делегат я добавляю процедуры, не принимающие аргументов, например, добавление на уровень лестницы, а во второй — с аргументом, например, добавление на уровень пауер-апов (для сохранения равной плотности пауер-апов на квадратный метр лабиринта количество бонусов должно быть пропорционально квадрату линейного размера лабиринта) или врагов.
Размещает врагов следующая функция, с помощью массива структур из двух полей — прафаба противника, который следует создать, и веса этого противника. Например, если массив будет таков:
{{Mob1, 5.0f}, {Mob2, 1.0f}}
То моб типа Mob1 будет спавнить с вероятностью в пять раз больше, чем Mob2. Количество типов мобов может быть произвольным.
С помощью подобной функции можно случайно с определенной вероятностью размещать так же артефакты в сундуках, эффекты от неизвестных зелий и чего угодно!
Затем, как для генерируемого, как и для жестко заданного, мы инстанциируем префаб уровня, если таковой есть. Для жестко заданного уровня, это непосредственно уровень, со своей геометрией, персонажами. А для генерируемого это может быть диалогом или кат-сценой.
Персонажи. Скрипты. Взаимодействия
Несмотя на то, что Unity3D использует C#, предполагается работать не с привычным ООП, а с собственным паттерном, основанной на объектах, компонентах и событиях.
Если Вы хоть раз запускали Юнити, то Вам должны быть понятны эти сущности — игровые объекты имеют несколько компонентов, комноненты отвечают за те или иные функции объекта, среди которых могут быть пользовательские скрипты. Сообщения (пользовательские, или служебные, вроде Awake(), Start(), Update()) посылаются гейм объекту и доходят до каждого компонента, каждый компонент их обрабатывает по своему усмотрению. Из этого выходит привычный для Юнити паттерн: вместо наследования стоит использовать композицию компонентов, например, как показано на картинке.
Есть объект типа “Mob”, с компонентами, выполняющие спецефические задачи, названными характерными именами. Компонент EnemyScript отвечает за ИИ, посылает своему гейм обджекту сообщения Shoot(Vector3 target), чтобы выстрелить и сообщения MoveTo(Vector3 target), чтобы отправить моба к данной точке.
Компонент ShooterScript принимает сообщения “Shoot” и стреляет, MoveScript принимает сообщения MoveTo. Теперь, допустим, мы хотим сделать моба, который похож на данного, но с измененной логикой поведения, ИИ. Для этого можно просто заменить компонент со скриптом ИИ на какой-нибудь другой, например, вот так:
В привычном ООП на С++ это могло бы выглядеть вот так (в Юнити так делать не стоит):
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тую реализацию лайфбара.
Во всех играх есть противники у которых надо отбавлять HP, и почти во всех играх есть герой, который ради отбавления HP у противников, тратит свою энергию либо ману. Для моего решения понадобится дочерний геймобджект (скажем, его можно назвать Healthbar) с квадом, который будет отображать нужную полоску. У этого объекта должен быть установлен компонент MeshRenderer с материалом, которым можно увидеть на превьюшке (квадрат, у которого левая сторона зеленая, а правая — красная).
Идея такова: устанавливаем тайлинг по горизонтальной оси в 0.5, и квад будет отображать половину изображения. Когда оффсет по той же оси установлен в 0, отображается левая половина квадрата (зелёная), когда в 1 — правая, в промежуточных значениях лайфбар ведёт себя так, как и следует вести себя лайфбару. В моём случае текстура лайфбара состоит из двух пикселей, но данный ментод позволяет отображать и красивые, ручной работы лайфбары.
Компопент, отвечающий за обработку и отображение повреждений, в случае, когда ему надо обновить состояние лайфбара выполняет следующий код:
Метод для отображения маны “умнее”, поскольку оперирует ещё смещением по вертикальной оси — в этом случае используется текстура из четырех пикселей, в которой верхняя часть соответствует активной полоске, а нижняя — пассивной (например, во время перезарядки, перегрева оружия или переутомления колдуна).
Итоги
Конкурс я, к сожалению, проиграл, заняв позорное место в середине списка участников.
Ошибка, на мой взгляд, такова: очень мало контента. Потратив много времени на проектирование сферической в вакууме масштабируемости, в предпоследний день конкурса понял, что контента в игре нет вообще. Уровней, сюжета, спрайтов, врагов, бонусов и всего такого. В качестве протагониста в игре бегает плейсхолдер, вставленный ради смеха. В итоге, игра получилась на десять минут интересного, бодрого геймплея, с разнообразными врагами, с возможностью развития игры, но без истории и без графики. Нулевой уровень с квадратной платформой, подвешенной в пустоте сразу же ставит игрока в известность, что игра сырая и недоделанная. Это было не то, чего требовалось в конкурсе. Жюри хотелось читать историю, рассматривать пиксель-арт и слушать музыку, а не играть в игры.
Ещё, Юнити3д — это про ассеты. Конечно же, мне было интереснее разрабатывать велосипеды, что я и делал — ни одного ассета не использовал, все скрипты и ресурсы — свои. Но так делать не надо, если работа идет на результат, а не на интерес.
В целом, поражение несколько повлияло на мой пассионарный толчок. Надо немного отдохнуть, а потом посмотреть на проект новым взглядом: если найду энтузиазм и перспективы проекта, то, возможно, доделаю его и выставлю в гринлайт. В противном случае, выложу исходники в открытый доступ, а интересные моменты, вроде того же создания лабиринтов, оформлю ввиде платного ассета в сторе Юнити.
Надеюсь, статья была интересной и познавательной.
P.S.: Ознакомиться с игрой вы можете, скачав ее по этой ссылке.
Либо, в Яндекс браузере здесь.
Автор: HotkeyM