Инди за 0$ на Unity. Часть 1: Консоль
Доброго времени суток. Это первая статья из цикла статей, посвящённых разработке ретро-фпс шутера на юнити. В этой части я расскажу про базовую архитектуру игры и реализую девелоперскую консоль.
Базовая архитектура приложения
Каждый разработчик сталкивался с тем что ему нужно переключить сцену или сохранить какое-то значение, но не понятно где это сделать. На помощь разработчику приходит глобальный объект, который хранит в себе эти данные и вроде бы все счастливы. Назовём этот объект Manager. Со временем появляются ещё данные, потом ещё и ещё... После утомительной работы над очередной фичей в нашей игре мы смотрим в код Manager'а и ахаем от 1000+ строк кода и полной вакханалии. И хорошо если ещё всё работает, но факта проблемы это не отменяет. Мы сидим на часовой бомбе, которая может рвануть в любой момент.
Всем же знакома данная ситуация? Как незамысловатым движением руки наш класс превращается в God Object. Чтобы избежать эту проблему мы должны с любовью относится к нашему продукту и следовать принципам SOLID.
Ближе к делу!
Для реализации игры я спроектировал следующую архитектуру:
Всего у нас будет 4 синглтона, отвечающие за ключевые функции программы - Console, GameMaster(AppMaster), InputSystem, SaveLoadSystem. Каждый из этих синглтонов отвечает за свой функционал и является глобальным в контексте моего приложения.
Console - Класс-синглтон, отвечающий за отрисовку и обработку команд пользователя, которые сам пользователь вводит в консоль. Благодаря консоли мы сможем делает разные финты ушами, тестировать некоторый функционал, читать логи прямо из игры и просто читерить. На самом деле это один из самых важных классов, потом поймёте почему.
GameMaster aka AppMaster - Синглтон, отвечающий за загрузку сцен, общее внутри-игровое состояние и состояние приложения вообще. Сам по себе класс будет иметь мало функционала, но от этого менее важным он не становится. Тут будут храниться все конфиги пользователя, ачивки и прочее.
InputSystem - Прослойка между встроенными ивентами ввода и нашей игрой. Стандартная система ввода юнити не позволяет нам переопределять бинды в рантайме или изменять тот чувствительность мыши. Для этих целей мы реализуем свою прослойку. Если вы пишите кросплатформенную игру (например для пк, консолей и мобильников), то такая задача будет суицидом, но т.к. я пишу игру только для пк, то всё будет проще.
Ремарка: Юнити работает над новой системой ввода, но на данный момент она находится в состоянии preview и доступна в experimental пространстве имён. Инпут система это не визуал и словив баг можно нехило закопаться в дебаггинге по этому я избегаю такие превью пакеты до релиза.
SaveLoadSystem - Сериализатор\десериализатор динамических данных. В игре я собираюсь реализовать систему диалогов, инвентарь и прогресс пользователя. Всё это надо где-либо хранить и как то переносить между игровыми сессиями. Для таких целей мы реализуем эту систему и будем использовать в нашей игре.
С помощью этих модулей мы сможем держать код в чистоте, а ноги в тепле. В консоли мы сможем определять кастомные комманды, в гейммастере мы будем держать все важные данные, инпут система позволит нам определять экшены и подписываться на них, а система сериализации позволит нам определять свои сценарии для сериализации\десериализации любых объектов. У меня будет несколько сцен, в которых я уже буду реализовывать бизнес-логику игры, отделённую от основных модулей. Помимо этих модулей мне предстоит реализовать подсистему диалогов, подсистему инвентаря и т.д., но об этом пока нет смысла думать.
Структура проекта
Все свои ассеты я складываю в папку /Assets/Game. Внутри этой папки у меня стандартная структура (Scripts, Sprites, Scenes, etc).
В корне своей папки я создаю Assembly Definition, которую прямо так и называю: Game. В папке со скриптами у меня есть поддиректория Tests, в которой я храню все тесты своих скриптов.
Давайте приступим к реализации одного из самых важных модулей - Console.
Что такое консоль и как её реализовать?
Консоль - это коммандный интерфейс игры. В консоли можно изменить какие-либо значения без изменения исходного кода программы. Так же в ней можно выводить отладочную информацию, что тоже важно при разработке сложных программ.
Реализовывать простейшую консоль я буду средствами UnityUI. Для этого создадим канвас, накинем туда ScrollView вместе с простым Text, Image и InputField. Картинку я использую для заднего фона, scrollview для лога консоли и Inputfield для пользовательского ввода. Не забываем сразу поставить канвасу высокий порядок отрисовки чтобы консоль всегда была поверх остального интерфейса.
Так же добавляем Text элементу компонент ContentSizeFitter для автоматического ресайза элемента и выставляем вертикальное выравнивание текста на bottom.
Пытливый читатель может сказать "Отдельный канвас? Зачем? Я вот всё в один общий канвас сую и просто играюсь с SetActive методом у объектов внутри канваса". Это не плохой подход, но по Best Practices стоит разделять элементы на канвасы т.к. при юнити перерисовывает весь канвас при любом изменении внутри него, что может быть ресурсоёмко в некоторых случаях.
Подробнее про Canvas Best Practices: https://unity3d.com/how-to/unity-ui-optimization-tips
Итак, у нас должно получиться что-то в таком духе:
Внешний вид, шрифты и расположение объектов сугубо на усмотрение разработчика. Главное чтобы консоль была растянута по горизонтали, а положение по вертикали выбирать только вам.
Когда UI элементы будут готовы, наступает время кода. Я весь свой код всегда сую в пространство имён Game. Таким образом я всегда знаю какой код мой, какой не мой и избегаю любых конфликтов имён. Для консоли я использую подпространство Game.Console.
Создаём класс Console в пространстве имён Game.Console:
Класс Console имеет ссылку на себя в статической переменной instance. Есть статические методы ReginsterCommand\UnregisterCommand, позволяющие мне регистрировать\удалять команды из любой точки проекта.
Кидаем этот класс на наш канвас, присваиваем через инспектор ему инпут филд и лог. В инпутфилде указываем коллбек Console.ProcessInput на событие OnEndEdit для того чтобы перехватывать ввод пользователя.
Обработка ввода
Дальше нам нужно обрабатывать данные, которые пользователь вводит в консоль. По факту мы получаем String который нам нужно распарсить, вырвав оттуда название консольной команды и аргументы для её выполнения. Т.к. мы хотим иметь гибкость при объявлении команд, используя в аргументах комманды не только строки, но и другие объекты, нам нужно в рантайме определять тип аргумента и тайпкастить его строку в нужный тип.
Для этого мы напишем класс InputProcessor
Этот класс принимает в конструкторе строку и при вызове метода Run парсит её. Если что-либо идёт не так, то мы получаем Exception и прерываем выполнение кода.
Т.к. данный класс довольно критический, я решил покрыть его тестами ещё на стадии написания. Благодаря тестам я всегда уверен, что он работает и это греет мне душу, а так же позволяет спать ночами.
Рекомендую сохранить эти тесты и запускать при любом апдейте процессора дабы спать спокойно как я.
Этот тест файл я не рефакторил, рекомендую это сделать при расширении функционала процессора.
Итог
Мы разработали консоль, которая позволяет нам объявлять, удалять команды, парсит, тайпкастит аргументы комманд и перехватывает события встроенного в юнити логгера. Теперь мы можем в любом компоненте объявить комманды, которые будут доступны для вызова в консоли, например:
Таким незамысловатым способом мы можем отлаживать\тестировать нужный нам функционал и это будет быстрее чем мы будем отрисовывать кастомные InspectorGUI элементы.
Домашнее задание
- Объявить кастомный exception при ошибке парсинга аргументов в InputProcessor и отлавливать его в Console. Сейчас при фейле консоль ничего не говорит о том, что ей не удалось определить тайп аргумента. Нужно это исправить.
- Стилизация лога. Все сообщения из встроенного в юнити логгера приходят прямо в консоль как есть. Было бы круто сделать стилизацию по типу лог мессейджа.
- Тайпкастинг boolean сейчас case-sensitive. Нужно бы это исправить и дополнить тесты.
Что дальше?
В следующей главе мы создадим InputSystem для объявления кастомных экшенов и прослушивания событий по этим экшенам.