Привет, Хабр!

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

Данная статья является вступительной, поэтому кода здесь не будет.

Введение

Мы группа студентов, состоящая из трёх человек: 2 Unity-разработчика и 1 дизайнер. Наше название: Aristocat Games Studio.

У нас было 2 разработчика Unity, 1 дизайнер и куча свободного времени.

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

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

  2. Визуальное программирование. Даже простой коддинг, как на RenPy, может отпугивать людей от создания игр. Поэтому создание новеллы на нашем движке должно быть основано на нодах в графе, подобно системе Blueprint в Unreal Engine 4.

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

Требования к движку

Мы понимаем, что по функционалу очень тяжело догнать уже готовые движки такие как: RenPy, Tyrano Builder, Visual Novel Maker. Однако мы можем выделить базовый функционал движка, чтобы пользователи могли его уже использовать и давать свой отзыв.

Вот мы приходим к определению основного функционала нашего будущего движка:

1. Кроссплатформенность движка

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

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

2. Визуальное программирование с помощью графов

Ради визуального программирования мы вводим 3 понятия: граф, ноды, переходы.

Граф

Тут просто. Граф - это место, где будут хранится ноды и переходы.

Также у каждого графа обязательно должна быть нода точки входа (об этом далее).

Ноды

Ноды - это блоки, которые составляют граф визуальной новеллы. Они должны отвечать за логику игры. Если игрок проходит через ноду, то нода выполняет какое-либо действие.

Обязательно будут такие ноды:

  1. Нода точки входа. Данная нода ничего не делает. Просто показывает программе, откуда нужно начинать проход графа.

  2. Нода персонажа. Должна уметь показывать/скрывать персонажа. Изменять его спрайт на экране.

  3. Нода текста. Позволяет изменять текст в окне снизу.

  4. Нода звука. Позволяет проигрывать различные аудио.

  5. Нода заднего фона. Позволяет выбирать спрайт для заднего фона.

Переходы

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

  1. Простой переход. Мгновенный переход без условий. Если на пути игрока встречаются несколько мгновенных переходов, то желательно пройти через них в одном кадре (чтобы игрок не заметил, как моргают спрайты при их добавлении/скрытии).

  2. Клик-переход. Переход срабатывает, если пользователь нажал на экран. В визуальных новеллах это основной способ, чтобы перейти к следующему действию игры.

Пример того, как структурно может выглядеть финальный граф проекта:

Пример простой визуальной новеллы

Здесь новелла начинается с того, что нас встречает персонаж Anna на фоне заката (Sunset) и говорит: “Егор. Покажи себя!”. После нажатия игрока мы узнаём, сбежал ли Egor? Если сбежал, то Anna скажет: “Ох, нет Егора :c”. Иначе Egor появляется на экране.

3. Разделение графа на граф проекта и граф сцены

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

Пример того, что было бы, если весь проект нужно было поместить на один граф (данное руководство содержит граф игры VA-11 HALL-A, который сгенерировал @CyberShadow:

Для решения этой проблемы было добавлено разделение графов на граф проекта и граф сцены.

Граф проекта

Данный граф может иметь только 2 вида нод:

  1. Нода входной точки. Без неё никуда.

  2. Нода запуска сцены. Нода, которая переводит игрока на граф сцены.

Граф сцены

Здесь происходит основной функционал игры, описанный выше.

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

Если в графе проекта у ноды нет выходных переходов, то игра считается завершённой.

4. Хранилище ассетов и их обёрток

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

Заметим, что у некоторых ассетов есть дополнительные файлы с названием вида {название_ассета}.json. Это сериализованные обёртки вокруг ассетов. Они могут хранить дополнительную информацию о том, как нужно использовать ассет. Например, у спрайтов можно выбрать pivot (начало координат), у аудио можно выбрать стандартную громкость и т.д.

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

5. Система переменных

Должна быть простая система рантайм переменных примитивных типов: int, float, boolean, string.

Значения этих переменных можно использовать на переходах, как дополнительные условия. В примере выше можно заметить переменную boolean EgorRanAway, которая проверяется на переходе Click Transition.

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

Пару примечаний:

  1. Если мы попадаем в ноду, у которой не выполняются условия выходных переходов, то мы считаем это концом сцены. На картинке ниже Some Node 1 будет являться концом сцены или концом проекта (в зависимости от того, в каком графе находятся эти ноды).

После Some Node 1 условие перехода не выполняется
  1. Если мы попадаем в ноду, у которой выполняются сразу несколько условий переходов, то нужно взять в приоритет ту ноду, которая находится выше в редакторе (будто условия проходят сверху вниз). Также стоит оповестить пользователя о возможной проблеме.
    Возможно стоит ввести более явную систему приоритетов, но пока выбранный вариант не нагружает UI, хоть и является не совсем интуитивно понятным.

Произойдёт переход к Some Node 2, а не к Some Node 3

6. Локализация

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

Сами словари должны хранится в ресурсах игры.

Базовое строение проекта

Когда мы определились с главным функционалом движка, мы можем начинать продумывать архитектуру движка.

Было решено сделать 2 программы: редактор и интерпретатор игры.

Редактор будет иметь всю систему создания новеллы. Из него можно будет экспортировать сериализованный json файл игры, который можно будет позже десериализовывать в интерпретаторе игры. Также нужно понимать, что вместе с сериализованным файлом игры как-то должны поставляться все требуемые ассеты.

Также мы ранее сказали, что должна быть возможность запускать движок (как и редактор, так и интерпретатор) на сайте. Для этого придётся разделить движок на 2 версии: автономная (офлайновая) и web. Самая большая разница между этими двумя версиями - это способ поставки ассетов.

Автономная версия

Автономная версия представляется двумя исполняемыми файлами (редактор и интерпретатор), расположенными на устройстве пользователя.

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

Если пользователь работает с редактором, то он может указать путь к исходникам проекта (путь к ProjectDirectory). Эта директория может быть где угодно на диске (если позволяют права конечно).

Если пользователь уже играет в игру через интерпретатор, то нужно позаботиться о том, чтобы файлы выше лежали там, где интерпретатор посмотрит в первую очередь. Пусть это будет переменная Unity Application.dataPath. Т.е. рядом с исполняемым файлом интерпретатора.

Web версия

Боже, если бы я что-то знал про web...
С Web версией ситуация немного другая. Здесь у нас нет прямого доступа к диску. Однако есть indexedDB, которая может напоминать файловую систему. Но и тут стоит понимать, что IndexedDB имеет ограничение по памяти, поэтому просто загрузить туда все ассеты кажется плохой идеей.

Решением будет хранить ассеты на стороне сервера. Если в игре понадобился определённый ассет, то нужно сделать запрос на сервер и подождать, пока ассет в ответе не дойдёт до нас. Этот ассет сохранится на куче, предоставленной Unity (а именно в объекте WebAssembly.Memory). Здесь тоже нужно быть аккуратным, т.к. куча в Web версии имеет строгий лимит. Подробнее о памяти в Unity WebGL можно почитать здесь.

Чтобы не делать +100500 запросов на каждый отдельный ассет, то можно в начале любой сцены составлять список ассетов, которые нам понадобятся и делать 1 запрос. Или в запросе мы можем передавать только имя сцены, а сервер сам найдёт нужные ресурсы. После каждой сцены нужно очищать ассеты, которые нам больше не понадобятся.

Примерный вид взаимодействия web-интерпретатора с сервером.

Если мы говорим о редакторе, то пользователю должна быть видна библиотека ссылок ассетов, расположенных на сервере. Т.е. пользователь видит какие ассеты доступны, но не хранит их у себя на устройстве.

Загрузка новых ассетов производится POST-запросом на хранилище ресурсов новеллы.

Если пользователь хочет загрузить новую версию новеллы, то он сериализует её на своей стороне, а потом отправляет готовый json-файл игры на сервер.

Примерный вид взаимодействия web-редактора с сервером.

Создание класса новеллы

Пора создавать абстрактную часть проекта.

Было решено, что самое просто - это использовать один и тот же класс новеллы и для редактора, и для интерпретатора.

Я не смог в UML. Приблизительно такая структура должна описывать всю новеллу (стрелки означают не наследование, а наличие поля в классе):

Теперь подробнее о классах:

  1. Novel. Собственно главный класс нашей новеллы. Этот класс мы должны сериализовывать и десериализовывать.

  2. NovelConfiguration. Здесь будет хранится информация о всей новелле. Например, название новеллы и её версия.

  3. UISettings. Данный класс должен содержать какие-нибудь стили UI новеллы. На данном этапе разработки о кастомизации UI мы ещё не думали, но пусть класс будет.

  4. NovelGraph. Это и есть граф проекта.

  5. NovelGlobalData. Содержит ConstantData и RuntimeData:

    1. ConstantData. Тут лежат все сцены проекта (вместе с их графами), персонажи, другие обёртки вокруг ресурсов (они содержат относительный путь к ресурсам).

    2. RuntimeData. Здесь будет хранится словарь runtime переменных с их default значениями.

Маленькая оптимизация релизной сборки

Мы сказали ранее, что будем использовать класс Novel и для редактора, и для интерпретатора. Однако здесь мы можем заговорить о маленькой оптимизации сборки для интерпретатора.

Для редактора важно сохранять каждый нод, расположенный в редакторе. Рассмотрим на примере:

Здесь мы можем заметить, что ноды 4 и 5 не соединены с Entry Node. Значит, их бесполезно сохранять для релизной сборки. Собственно так и делаем, это может спасти нам пару байт:

И ещё одна оптимизация. Для каждой ноды мы сохраняем её позицию в редакторе. Для релизной сборки этого не нужно делать. Вот ещё пару байтов у нас в кармане.

Базовое строение проекта в Unity

Хоть мы и будем создавать 2 разных исполняемых файла, легче всего вести разработку в одном проекте Unity. Собственно в проекте Unity у нас будут 2 сцены: сцена редактора и сцена интерпретатора игры.

Если мы хотим скомпилировать редактор, то в настройках билда Unity включаем 2 сцены. У нас должна быть возможность играть в игру во время того, как мы её редактируем.

Сцены редактора и интерпретатора включены

Если мы хотим скомпилировать интерпретатор, то достаточно в настройках билда выбрать лишь сцену интерпретатора. Редактор в данном случае нам не нужен.

Включена только сцена сцены

Заключение

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

На момент написания статьи движок находится на этапе разработки автономной версии. В следующей статье планируется расписать взаимодействие менеджеров в проекте.

Если есть какие-то вопросы или предложения, то пишите в комментариях. Фидбек очень важен.