Архитектура игры в Unity.

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

Предисловие

Одна из глобальных целей моего текущего проекта — увереннее овладеть инструментами разработки. По этой причине я сознательно отказался от использования любых фреймворков (кроме Unity :D).

А ещё у меня нет опыта разработки в ООП, поэтому с паттернами проектирования я знаком лишь теоретически. С этой точки зрения для меня будет лучше написать спагетти-код, который я полностью понимаю, чем применять пресловутые паттерны, ценность которых я не смог прочувствовать.

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

Реализация

Unity я начал изучать по руководству "Unity in Action". И именно там я познакомился с паттерном "Локатор служб". По сути, это реестр сервисов, который занимается их инициализацией и раздаёт к ним доступ. За основу я решил взять именно его.

Архитектура игры в Unity.

Root — MonoBehaviour, который содержит в себе поля-сервисы. Предполагается, что он будет являть собой точку входа, и плясать мы будем от него. В Awake() происходит заполнение последовательности инициализации сервисов, после чего запускается сама инициализация.

public class Root : MonoBehaviour { public static ServiceOfGameState gameState { get; private set; } public static ServiceOfLocalization localization { get; private set; } public static ServiceOfGameSettings gameSettings { get; private set; } public static ServiceOfUserInterface userInterface { get; private set; } public static ServiceOfProgress progress { get; private set; } private List<Service> startSequence; private IEnumerator StartupManagers() { foreach (Service service in startSequence) { service.Startup(); } yield return null; int numModules = startSequence.Count; int numReady = 0; while (numModules > numReady) { int lastReady = numReady; numReady = 0; foreach (Service service in startSequence) { if (service.status == ServiceStatus.Started) { numReady++; } } if (numReady > lastReady) { yield return null; } } } void Awake() { gameState = ServiceOfGameState.Instance(); gameSettings = ServiceOfGameSettings.Instance(); localization = ServiceOfLocalization.Instance(); userInterface = ServiceOfUserInterface.Instance(); progress = ServiceOfProgress.Instance(); startSequence = new List<Service>(); startSequence.Add(gameState); startSequence.Add(gameSettings); startSequence.Add(localization); startSequence.Add(userInterface); startSequence.Add(progress); StartCoroutine(StartupManagers()); } }

Сервисы, которые хранятся в данном реестре, — это объекты классов, реализующих интерфейс Service, у которого есть поле ServiceStatus status и метод Startup().

ServiceOfGameState — этот сервис сейчас отвечает только за паузу. Возможно, в будущем его юрисдикция будет расширена, но пока у меня нет для него других задач. Метод Service.Startup() у него пока пустой. Это повод задуматься над тем, нужен ли он тут в принципе, но пока я решил его оставить.

public class ServiceOfGameState : Service { private GameState gameState; void Service.Startup() { status = ServiceStatus.Initializing; status = ServiceStatus.Started; } }

ServiceOfGameSettings — данный товарищ отвечает за хранение и изменение настроек игры. Пока это только настройки текущего языка. Установка и получение настроек предполагаются через публичные методы этого класса. В методе Service.Startup() происходит инициализация текущих настроек. Если это первый запуск — первичные настройки берутся из ScriptableObject-класса.

public class ServiceOfGameSettings : Service { private DefaultSettings defaultSettings; private SettingsContainer currentSettings; private SettingsContainer intentSettings; void Service.Startup() { status = ServiceStatus.Initializing; InitializeSettings(); status = ServiceStatus.Started; } private void InitializeSettings() { defaultSettings = Resources.Load<DefaultSettings>(quot;Service/DefaultSettings"); currentSettings = new SettingsContainer(defaultSettings); intentSettings = new SettingsContainer(currentSettings); } }

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

public class ServiceOfLocalization : Service { private Dictionary<string, string> currentDictionary; void Service.Startup() { status = ServiceStatus.Initializing; InitializeLanguage(Root.gameSettings.GetLanguage(), false); status = ServiceStatus.Started; } public void InitializeLanguage(Language language, bool notifyOnChanges) { TextAsset textFile = Resources.Load<TextAsset>(quot;Localization/{language.ToString()}"); currentDictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>(textFile.text); if (notifyOnChanges) { Messenger.Broadcast(LocalizationEvent.LANGUAGE_CHANGED, MessengerMode.DONT_REQUIRE_LISTENER); } } }

ServiceOfUserInterface — на текущий момент это самый проработанный экземпляр. Отвечает за пользовательский интерфейс: отображение различных меню, обработку нажатия кнопок и так далее. При инициализации мы заполняем соответствия для элементов UI по их внутренним идентификаторам. Для view запоминаем их объекты MonoBehaviour, чтобы потом управлять видимостью отдельных окон. Для кнопок и списков выбора назначаем обработчики событий нажатия и выбора значения соответственно.

На диаграмме видно, что у этого сервиса нет публичных методов. Взаимодействие с этим сервисом я реализовал через подписку на Messenger.

public class ServiceOfUserInterface : Service { private readonly Dictionary<SelectorID, SelectorContainer> selectorMap = new(); private readonly Dictionary<ButtonID, Action> buttonHandlerMap = new(); private Stack<ViewManager> viewManagerStack = new(); private Stack<string> sceneStack = new(); void Service.Startup() { status = ServiceStatus.Initializing; InitButtonHandlers(); Messenger<string, UISelector>.AddListener(UIEvents.SELECTOR_ON_INITIATE, OnSelectorInitiate); Messenger<ViewManager>.AddListener(UIEvents.VIEW_ON_INITIATE, OnViewInitiated); status = ServiceStatus.Started; } private void OnSelectorInitiate(string id, UISelector selector) { var selectorID = (SelectorID)Enum.Parse(typeof(SelectorID), id); InitDropdown(selectorID, selector); } private void OnViewInitiated(ViewManager viewManager) { viewManagerStack.Push(viewManager); viewManager.ShowStartView(); } private void InitButtonHandlers() { buttonHandlerMap.Add(ButtonID.MAIN_START, OpenHangar); buttonHandlerMap.Add(ButtonID.MAIN_SETTINGS, OpenSettingsView); buttonHandlerMap.Add(ButtonID.MAIN_QUIT, QuitGame); buttonHandlerMap.Add(ButtonID.SETTINGS_APPLY, ApplySettings); buttonHandlerMap.Add(ButtonID.BACK, Back); buttonHandlerMap.Add(ButtonID.HANGAR_QUIT, CloseCurrentScene); buttonHandlerMap.Add(ButtonID.START_GAME, StartGame); buttonHandlerMap.Add(ButtonID.PAUSE_RESUME, ResumeGame); buttonHandlerMap.Add(ButtonID.PAUSE_RESTART, StartGame); buttonHandlerMap.Add(ButtonID.BACK_TO_HANGAR, BackToHangar); buttonHandlerMap.Add(ButtonID.BACK_TO_MENU, CloseCurrentScene); } }

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

ServiceOfProgress - Этот сервис отвечает за хранение текущего прогресса игрока. К примеру, пройденные миссии и ачивки. Сейчас он достаточно прост и хранит текущий самолёт игрока и список всех доступных на данный момент самолётов.

public class ServiceOfProgress : Service { private ProgressContainer currentSave; void Service.Startup() { status = ServiceStatus.Initializing; var initialGameData = Resources.Load<InitialGameData>(quot;Service/InitialGameData"); currentSave = new ProgressContainer(initialGameData); status = ServiceStatus.Started; } }

Данный список не окончательный. Мы должны уметь управлять звуком, возможно, взаимодействовать с сетью, поэтому пара новых сервисов точно появится.

Сомнения

Service Locator

Я почитал несколько статей и, вообще, я совсем не уверен, что реализовал именно локатор служб :). Если я правильно понял, основная идея локатора служб — в возможности динамически добавлять в реестр классы, реализующие некий интерфейс (сервис).

В моём же случае инициализация происходит один раз при старте, а сам перечень сервисов поимённо приведён в полях класса.

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

Singleton

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

Если у меня будет желание добавить сервис, который не получится сделать одиночкой, это будет явный сигнал о том, что ему не место в Root.

К примеру, объект самолёта. Во-первых, он не нужен на протяжении всего жизненного цикла игры, а во-вторых, объектов самолёта может быть несколько. Значит, на этом уровне абстракции ему не место.

P.S. Как я уже писал ранее, для меня важно, чтобы код в первую очередь работал. Ошибки проектирования, если они есть, так или иначе меня догонят. И я уверен, что шишек на этом набить ещё успею. Но опыт, вроде как, так и зарабатывается?

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

7
7 комментариев