Недавно команда Whistling Kite Framework выпустила в релиз очередную игру, на этот раз — Змейку, написанную на Unity3D. Как и в большинстве игровых проектов, при решении вопроса о том, насколько детально нужно проектировать приложение, критическим фактором было время. В нашем случае причина проста: т.к. разработка велась в свободное от основной работы время, то идеальный подход к проектированию отложил бы релиз ещё на год. Поэтому, составив первоначальное разделение на модули, мы закончили проектирование и приступили к разработке. Под катом описание того, что из этого получилось, а также пара уроков, которые я вынес для себя.
Осторожно, картинки!
Сразу хочу вставить оговорку: все описанное ниже является лишь ретроспективой событий и попыткой анализа, что получилось хорошо, а что плохо. Статья не претендует на то, что в ней изложена непреложная истина, скорее даже наоборот — “вредные советы”, которые, возможно, уберегут кого-то от таких же ошибок.
Функциональность
Вначале пара слов, собственно, о приложении, чтобы было понятно, что именно мы проектировали и разрабатывали. Создавая змейку, мы постарались воссоздать именно старую добрую классическую змейку, в которой не было ничего кроме змеи, яблок, стен и бесконечного желания двигаться дальше. Именно поэтому мы сосредоточились на классическом геймплее, заведомо исключив из него на данном этапе все дополнительные возможности.
Одно из главных преимуществ нашей змейки — это разнообразные варианты управления, которые вы можете найти в настройках. Мы постарались предусмотреть их все:
- 4 кнопки — задать новое направление движения;
- 2 кнопки — поворот налево/направо от текущего направления;
- свайпы — провести пальцем в нужном направлении.
Планируем добавить еще два:
- гироскоп — наклон устройства задает новое направление;
- джойстик-”пузырь” — смещение пальца от первой точки касания задает новое направление.
Набранный в партии рейтинг записывается в таблицу рекордов. Рекордом можно поделиться с друзьями через смс, почту или любую социальную сеть, подключенную на смартфоне — твиттер, вконтакте, фейсбук и т.п.
Решение о старте разработки змейки мы принимали, преследуя две цели:
- Попробовав большую часть существующих под Андроид «змеек», мы не обнаружили такой, в которую хотелось поиграть нескольким участникам команды, мы решили исправить эту ситуацию;
- Мы впервые решили выйти на мобильный рынок и использовать движок Unity3D, поэтому выбрали заведомо несложную игру, чтобы освоиться с ним.
Архитектура
Этап 1. Концепция
Первый, концептуальный, вариант архитектуры создавался нами ещё до выбора unity. Он приведен на картинке ниже. В этом варианте мы выделили слои будущего приложения:
- Слой инициализации
- Слой интерфейса
- Слой логики
- Слой контроллеров
- Слой управления.
Исторически последним был выделен слой инициализации, хотя в приложении он должен был запускаться первым. Дело в том, что мы сначала заложили инициализацию объектов в сами объекты, но потом решили выделить ее централизованно.
Слои интерфейса, логики и контроллеров представляют собой почти классической паттерн MVC. При этом было желание разделить обработку этих слоёв даже на разные потоки, чтобы обеспечить максимальную плавность интерфейса.
Слой управления был вынесен отдельно, т.к. мы решили, что разнообразные варианты управления будут одной из наших основных фич, поэтому нужно было делать так, чтобы всё остальное приложение не зависело от выбранного варианта.
Все слои должны были общаться между собой посредством выделенных интерфейсов, каждый слой содержал свои объекты и выполнял свои функции, зачастую содержал свой поток обработки.
Хорошие идеи:
- Выделение слоя инициализации: в последующих версиях это потеряли, что приводило иногда к довольно запутанным последовательностями действий.
- Правильная общая структура разделения приложения на модули: в дальнейшем границы различались и понятие инкапсуляции было практически забыто(можно привести картину «там баг» с котами, только заменить на «так нельзя! Как же инкапсуляция»
Промахи:
- Раздельные слои контроллеров и управления: впоследствии от контроллеров в терминах MVC отказались в силу избыточности.
- Слишком высокоуровневое описание: непонятно, какой писать код. Хотя это и нельзя назвать чистым промахом, т.к. на момент создания этой архитектуры не был выбран ещё движок.
Следующим этапом стал выбор платформы, хотя, строго говоря, выбор делался между unity и разработкой на чистой java. Беглый обзор других существующих движков не вызвал энтузиазма в их изучении. Мы пришли к достаточно ожидаемому выводу: написать змейку проще вообще без платформы, к тому же это выглядело интереснее — кто ж не любит делать свои велосипеды? Однако, мы выбрали unity, с целью ознакомления с платформой, близкой к статусу стандарта де-факто в области гейм-дева. Да, мы получили солидный оверхед из-за того, что unity — трёхмерный движок (на момент начала разработки у unity еще не было нативной поддержки 2D), а делали мы двумерную игру, но полученный опыт того стоил.
Этап 2. Проект
Пока выбирали платформу, мы совместными усилиями писали дизайн-документ, и уже на его основе родился второй проект архитектуры, заточенный под выбранную платформу.
Эта архитектура состояла из нескольких связанных диаграмм, которые я ниже называю терминами uml, хотя, конечно, стандарту они следуют, мягко говоря, не полностью.
Сущность-связь (понятно, что это скорее относится к разряду требований к системе, чем к её архитектуре, но в контексте статьи я не могу её не упомянуть)
Нотация ERD, думаю, всем знакома. На ней представлены все объекты игровой логики и логические связи между ними. Каждый такой объект порождает один класс, обеспечивающий работу этого объекта.
Диаграммы компонентов и кооперации. Для их восприятия предварительно опишу ряд договоренностей:
Общая архитектура — носит общий концептуальный характер, являясь, по сути, продолжением диаграммы этапа 1. На ней отображены:
- Компоненты системы (прямоугольники)
- Подсистема хранения (символ БД, магнитного диска)
- Примечания (желтые “листки”)
- Потоки управления (закрашенные стрелки)
- Потоки данных (пунктирные стрелки)
Детализирующие диаграммы содержат:
- Компоненты, объекты (прямоугольники)
- События, обрабатываемые объектами (шестиугольники)
- Действия, выполняемые по событиям (эллипсы)
В целом концепция изменилась не сильно: также есть отдельный компонент для инициализации, далее выделены две основные подсистемы.
После запуска приложения первым начинает работать компонент инициализации: он запрашивает у хранилища все необходимые данные и инициирует подсистему GUI.
Подсистема GUI должна обеспечивать работу пользователя с главным меню, настройками и рекордами. При старте игры, контроль передаётся на подсистему управления.
Подсистема управления должна обеспечивать взаимодействие пользователя с игровым миром, в первую очередь это — управление непосредственно змейкой.
Отдельно выделены компоненты для логирования и расшаривания рекордов.
Игровой мир состоит из объектов, взятых с ERD. Ключевую роль играют партия и змейка.
Партия хранит ссылки на змейку и текущий экземпляр фрукта, обеспечивает инициализацию игровых объектов и их взаимодействие.
Змейка обрабатывает основные игровые события: собственное движение, поедание фрукта и столкновения со стенами или хвостом.
Экземпляр фрукта отвечает за контроль собственного времени жизни.
Хорошие идеи:
- Выделение двух основных независимых подсистем: при разработке это не было учтено, что первоначально привело к большому количеству лишних сцен.
Промахи:
- Не учитывает разделение по сценам и работу с подсистемой хранения из игровой сцены
- Не отвечает на вопрос, как реализовывать подсистему GUI и как подсистемы взаимодействуют между собой.
- Объект Партия взял на себя функции контроллера в терминах MVC.
Этап 3. Фактический результат
После старта разработки архитектуру уже никто не корректировал. Я только оставлял отдельные заметки в соответствующем разделе дизайн-документа о том, как реализованы те или иные узкие места, но основная цель была выпустить релиз. Что ж, этой цели мы достигли, и я сел за рефакторинг, чтобы подготовиться к разработке второго релиза. Уже на тот момент я понимал, что в первую очередь надо переводить игру на честный 2d, поддержка которого как раз появилась в unity. И ещё было понимание, что полученный результат далёк от идеального, в первую очередь в части разделения функций по объектам и их взаимодействию, поэтому, я поставил себе две задачи:
- Составить общую диаграмму классов
- Составить диаграммы последовательности для основных сценариев приложения.
На основе этой информации я планировал получить список того, что нужно менять. Результаты такого реверс-инжиниринга представлены ниже, в каждом разделе я сначала кратко описываю, о чем этот раздел, потом привожу одну или несколько диаграмм, иллюстрирующих построенное решение, а потом детально описываю, что и как было сделано.
Обзор созданных классов
На диаграмме ниже представлены все классы, созданные при разработке игры. На диаграмме скрыта часть методов, не несущих смысловой нагрузки или настолько тривиальных, что не стоят упоминания. Также часто под именем переменной (например, textures) скрывается целый блок переменных (в данном случае — разнообразных текстур).
Все имеющиеся классы разделены по четырем пакетам:
- Gamelogic — здесь представлены классы для игровых объектов, существующих на ирговом поле. По сути, этот паявляется ядром игры.
- Gui — здесь представлены классы, отвечающие за построение статического пользовательского интерфейса: главное меню, настройки, рекорды и кнопки поверх игрового поля, кроме кнопок непосредственного управления змейкой.
- Controllers — это изюминка нашего приложения: пакет с разнообразными способами управления змейкой. Каждый класс полностью содержит в себе все необходимое для выбранного стиля управления: от интерфейсных кнопок до логики расчета поворота.
- Providers — этот пакет содержит вспомогательные классы, обеспечивающие такие функции, как чтение и сохранение данных, возможности поделиться рекордом, логирование, аналитику и т.п.
В пакете игровой логики центральное место занимает класс party, представляющий собой одну игровую партию. Этот класс отвечает за начало и окончание игры, за начисление рейтинга, за координацию событий поедания яблока и истечения времени жизни яблока и много других вспомогательных функций. Пока писалась
эта статья, я практически переписал этот класс, оставив в нем только вызовы методов из других классов (собственно, змейки и яблока). Это больше соответствует требованиям инкапсуляции, но сделало этот класс еще более похожим на контроллер в терминах MVC.
Второй по важности класс Snake содержит логику движения змеи, включая управление зависимыми объектами класса Snakechain. Именно этому классу передают команды контроллеры управления.
Для создания экземпляров фруктов (FruitInstance) используется фабрика, т.к. в будущем планируется увеличивать количество разных типов фруктов.
Пакет с интерфейсами содержит отдельные классы, отвечающие за отображение и обработку пользовательского интерфейса в зависимости от ситуации. Там же обрабатываются нажатия железных кнопок на устройстве.
Пакет с контроллерами содержит управляющие классы. Они добавляются к змейке и взаимодействуют напрямую с объектом Snake, используя паттерн command, а змейка выполняет поступившие команды в той же последовательности, но раздельно по своим шагам. Это было сделано для корректной обработки быстрых последовательных команд, например для разворота на 180 градусов вдоль своего хвоста.
Пакет с провайдерами содержит только один класс, интересный для данной статьи — это dataProvider. Этот класс содержит набор статичных функций, являющихся оберткой над вызовами стандартных методов для работы с хранимыми свойствами. На обоих предыдущих этапах проектирования работа с сохраненными данными предполагалась только однократно: загрузить их в память и больше не обращаться к более медленным носителям. Такой подход не учитывал вопрос независимости сцен в Unity: в результате, при переходе между сценами главного меню и игрового поля, все необходимые данные приходилось перечитывать заново.
Начальные состояния
Ниже приведено описание начального состояния двух сцен, из которых и состоит игры: сцены главного меню (на рисунке ниже слева) и сцены игрового поля (на рисунке ниже справа). Начальное состояние определяется тем, что было введено в редакторе, до запуска любого исполняемого кода. Именно из этих состояний начинаются большинство диаграмм последовательности ниже.
Главное меню создается двумя классами, Player и GUInavigator, присоединенными к единственному объекту на сцене, к камере. Player отвечает за загрузку всей основной информации об игроке, а GUInavigator инициирует нужное поведение из пакета интерфейсов и обеспечивает дальнейшие переходы между интерфейсами.
Игровая сцена содержит гораздо больше объектов. Большинство из них статические, представляющие собой мир змейки: фон, игровое поле, стены. Дополнительные поведения прикрепляются только к двумя объектам: к камере (GUInavigator, Player, party, fruitfabric) и голове змейки (Snake, Controllerselector). Controllerselector отвечает за выбор управляющего контроллера в соответствии с настройками игрока.
Запуск приложения
Диаграмма ниже отражает процедуру старта приложения (в той ее части, которая контролируется разработчиком). Особое внимание можно уделить разве что альтернативному выходу из сценария и переходу к загрузке игры, если есть сохраненная незавершенная партия.
Загрузка приложения состоит из обработки двух событий: awake и start. Событие awake обрабатывает объект player: в этот момент он обращается к DataProvider-у для загрузки информации об игроке, а потом вызывает собственный метод, отвечающий за применение текущих настроек, например, отключение звука.
Событие Start обрабатывается чуть посложнее:
- GUInavigator в методе initMain инициирует все необходимые в данной сцене интерфейсы, каждому проставляя признак неактивности.
- Далее он проверяет, не было ли сохранено незавершенной игры. Это возможно, например, при прерывании игры входящим звонком.
-
- Если игра есть, то происходит загрузка игровой сцены, а данный сценарий прерывается.
- Условно одновременно с этим обрабатываются события Start для всех инициированных классов интерфейсов — в них происходит дополнительная инициализация, свойственная каждому конкретному интерфейсу в отдельности (в т.ч. интерфейс рекордов загружает данные по таблице рекордов, используя dataProvider).
- Интерфейсу главного меню присваивается признак активного, и он отображается пользователю.
Запуск игры
Далее рассмотрим диаграмму последовательности, иллюстрирующую действия при запуске игры. Точка начала — загрузка сцены с игрой.
На данной диаграмме стоит отдельно отметить альтернативный сценарий загрузки сохраненной игры — это нужно для случая прерывания игры, например, входящим звонком.
Первыми срабатывают события Awake для объектов Player (загрузка всех настроек, аналогично старту приложения) и Snake (метод Init() — инициализиция хвоста “по умолчанию” из двух сегментов). Потом срабатывает событие Start для выбора контроллера (для примера использован FourButtonController), загрузки GUI и инициализации игры. Чуть подробнее об обработке этого события:
- Controller selector в событии Start, проверяет настройки игрока и подгружает нужный контроллер управления.
- GUInavigator в методе initGame инициирует все необходимые в данной сцене интерфейсы, каждому проставляя признак неактивности. Определение, какой метод вызвать, прроисходит на основе параметра, заданного через редактор. Инициализация и включение активного интерфейса происходят аналогично запуску приложения.
- Далее событие Start обрабатывает объект party. Первым делом он проверяет, нет ли сохраненной игры.
- Если игра есть, то party вызывает dataProvider для восстановления, который, прочитав данные вызывает метод Restorebackup объекта party.
- Он, в свою очередь вызывает аналогичный метод объекта Snake, а тот по цепочке для всех звеньев.
- После восстановления данных, управление возвращается объекту party, он включает паузу и ждет действий игрока.
- Если же игры не было, то party вызывает фабрику фруктов для создания экземпляра и игра началась. Кстати, на диаграмме показано, что party отвечает и за установку ряда параметров для фрукта — сейчас это уже не так: все необходимые действия инкапсулированы либо в фабрику, либо в экземпляр фрукта.
Игровой цикл: движение змеи
Игровая логика сосредоточена вокруг обработки двух событий: update обрабатывается в змейке методом movehead, здесь происходит движение змеи и управление движением ее сегментов; и ontriggerenter, где происходит обработка столкновений головы змеи с фруктами, стенами и собственным хвостом.
Разберем событие столкновения подробнее:
- При возникновении события сначала проверяется, с кем мы столкнулись: если это яблоко, то вызывается метод Eatfruit, а если хвост или стена, то Killme. В обоих случаях информация о столкновении сбрасывается в логи.
- Случай яблока:
- Змейка выставляет флаг необходимости добавления звена и вызывает метод Eatfruit объекта party.
- Party увеличивает рейтинг партии и вызывает свой же метод createfruit, использованный также при запуске игры.
- В этом методе сначала удаляется текущий экземпляр фрукта, а затем создается новый через обращение к фабрике.
- Случай стены или хвоста:
- Змейка передает обработку события объекту party в метод Endgame.
- Party выставляет режим паузы и, через навигатор интерфейса, переводит игрока на экран с результатами партии.
Движение же змейки реализовано следующим образом:
- В событии update вызывается метод movehead
- Здесь проверяется скорость змейки: наступил момент перехода или нет
- Если да, то проверяется флаг необходимости добавления звена
- Если нужно, то создается звено, аналогичное следующему за головой
- Смещаются только голова и одно вновь созданное звено.
- Если флага нет, то смещается голова, а за ней по цепочке все звенья, методом adjust.
Заключение
За рамки данной статьи осталось достаточно много логики: это логирование, как внутреннее, так и с использованием внешних средств аналитики; это работа навигатора интерфейсов, который инкапсулирует все особенности, связанные с двумя сценами; это работа с данными, с устройством, показ рекламы и еще много других особенностей. Если кому-то будет интересно, я могу написать об этом отдельно.
Основные выявленные проблемы:
- Замена 3d на 2d. Из диаграмм это не следует, но очевидно с точки зрения предпочтительности использования инструментов по их назначению.
- Объект party слишком перегружен функциями: он управляет и яблоками, и змейкой, и обработкой завершения игры.
- Перегружен игровой интерфейс — его надо разделять на два состояния: в игре и после столкновения.
- Отсутствует фасад для работы с функциями устройства, не предусмотренными движком: в нашем случае для андроида это переопределенный метод вибрации, реализация кнопки share (вызов системного окна для выбора приложения) и реализация вызовов гугл- аналитики.
Какие выводы для будущих проектов я сделал, исходя из всего вышесказанного?
- Надо знать платформу, прежде чем браться за что-то серьезное. Это в общем -то очевидно, но на всякий случай решил это повторить, может кого-то убережет от запуска очередного убийцы топ ММО без опыта.
- Необходимо проектировать разделение приложения (даже простого) на компоненты. На этом этапе главное — минимизировать количество взаимодействий и потоков данных.
- Продумывая структуру классов, нужно сразу продумывать, как объекты будут соотносится с компонентами и какие функции (группы функций) будут они выполнять.
- С одной стороны, нужно поддерживать связь с запланированной архитектурой, но с другой стороны, нужно быть готовым ее изменять, при возникновении новых условий.
Если вы видите еще какие-то не оптимальные решения, то прошу высказываться в комментариях, с удовольствием обсудим.
На всякий случай в конце привожу все ссылки, релевантные к статье:
- Unity3D
- Диаграмма архитектуры этапа 1 нарисована в yEd.
- Диаграммы архитектуры этапа 2 нарисованы на andoroid-планшете в приложении DrawExpress Lite;
- Диаграммы архитектуры этапа 3 нарисованы в Visual Paradigm;
- Обсуждаемое приложение можно посмотреть в play.google.
- Узнать больше о нашей команде можно на нашем сайте-визитке.
P.S. За время подготовки данной статьи, часть найденных ошибок уже была исправлена, и, вероятно, были внесены новые, т.к. разработка не стоит на месте. :)
Автор: JSas