Пользовательский интерфейс в Unity.

Привет! Сегодня хочу поделиться опытом, как я реализовывал пользовательский интерфейс в своём первом проекте на Unity. Про сам проект можете почитать в профиле)

Тема пользовательского интерфейса никогда не была мне близка. А применительно к игре она меня просто пугает. Сейчас моя цель — сделать вертикальный срез, поэтому долго возиться с меню и кнопками я не планирую. Но в то же время я постарался заложить возможность масштабировать решение на готовую игру в будущем.

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

Итак, я начал с того, что сделал набросок UX в первом приближении:

Нужна возможность сохраняться, выбирать мир, а внутри мира — выбирать миссии.
Нужна возможность сохраняться, выбирать мир, а внутри мира — выбирать миссии.

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

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

abstract public class UIElement : MonoBehaviour { protected string id; protected Text textComponent; private void Awake() { id = gameObject.name; InitializeRequireComponent(); InitializeTextComponent(); Messenger.AddListener(LocalizationEvent.LANGUAGE_CHANGED, OnLanguageChanged); } private void Start() { CommonUIElementStart(); } protected void CommonUIElementStart() { SetLocalText(); } protected void OnLanguageChanged() { SetLocalText(); } protected void SetLocalText() { if (textComponent != null) { string localID = GetComplexID(); string localText = Root.localization.GetLocalString(localID); textComponent.text = localText; } } protected string GetComplexID() { return quot;UI_{id}_{GetElementType()}"; } protected abstract void InitializeTextComponent(); protected abstract void InitializeRequireComponent(); protected abstract string GetElementType(); }

В основном этот класс отвечает за взаимодействие с сервисом локализации. Почти у каждого элемента интерфейса есть заголовок. И наследование от UIElement позволяет не думать об отдельной реализации для кнопок, слайдеров и т. д.

В поле textComponent как раз и хранится элемент заголовка, который нужно локализовать, а метод InitializeTextComponent() отвечает за то, чтобы каждый из потомков по-своему его определял. Логика инициализации этого поля может отличаться в зависимости от вида элемента.

Поле id хранит идентификатор компонента, который нужен как для локализации, так и для внутреннего взаимодействия с ServiceOfUserInterface.

Вот пример того, как InitializeTextComponent() и GetElementType() работают в UIButton, в дальнейшем приводить их реализацию в коде не буду.

public class UIButton : UIElement { protected override string GetElementType() { return "BUTTON"; } protected override void InitializeTextComponent() { textComponent = gameObject.transform.Find("Title").GetComponent<Text>(); } }

Ниже я приведу диаграмму с UIElement и всеми его наследниками, а потом поговорим про каждого в отдельности.

вот они слева направо
вот они слева направо

Почти все элементы UI так или иначе взаимодействует с ServiceOfUserInterface. Чтобы уменьшить количество зависимостей, я использовал Messenger для их связи.

Объекты UIElement отправляют сообщения в сервис о различных событиях. Сервис внутри уже решает, как обрабатывать эти события. Классы, которые наследуют UIElement, ничего не знают о том, как устроен ServiceOfUserInterface. В свою очередь, сам сервис не завязан на внутреннюю логику UIElement.

Если я решу в будущем реализовать селектор вместо дропдауна через список или вообще через 3D объекты сцены, мне нужно будет переделать только логику селектора — на класс ServiceOfUserInterface это не повлияет.

Список событий UI, которые сейчас добавлены в проект
Список событий UI, которые сейчас добавлены в проект

UIButton — простой пример такого взаимодействия. Поле button содержит в себе соответствующий элемент Unity. В методе Start() мы назначаем для button наш метод OnClick, в котором рассылаем сообщение события BUTTON_ON_CLICK с id кнопки, которую нажали. ServiceOfUserInterface в момент нажатия кнопки уже подписан на событие BUTTON_ON_CLICK и, получив его, вызывает соответствующий обработчик.

Пользовательский интерфейс в Unity.
[RequireComponent(typeof(Button))] public class UIButton : UIElement { Button button; private void Start() { button.onClick.AddListener(OnClick); CommonUIElementStart(); } public void OnClick() { Messenger<string>.Broadcast(UIEvents.BUTTON_ON_CLICK, id, MessengerMode.DONT_REQUIRE_LISTENER); } protected override void InitializeRequireComponent() { button = gameObject.GetComponent<Button>(); } }
public class ServiceOfUserInterface : Service { private readonly Dictionary<ButtonID, Action> buttonHandlerMap = new(); void Service.Startup() { status = ServiceStatus.Initializing; InitButtonHandlers(); Messenger<string>.AddListener(UIEvents.BUTTON_ON_CLICK, OnButtonClick); status = ServiceStatus.Started; } private void InitButtonHandlers() { buttonHandlerMap.Add(ButtonID.SETTINGS_APPLY, ApplySettings); //buttonHandlerMap.Add(ButtonID.MAIN_START, OpenHangar); //buttonHandlerMap.Add(ButtonID.MAIN_SETTINGS, OpenSettingsView); //buttonHandlerMap.Add(ButtonID.MAIN_QUIT, QuitGame); //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, BackToMenu); } private void OnButtonClick(string id) { var buttonID = (ButtonID)Enum.Parse(typeof(ButtonID), id); if (buttonHandlerMap.TryGetValue(buttonID, out var handler)) handler?.Invoke(); else Debug.Log(quot;No handler for {buttonID}"); } private void ApplySettings() { Root.gameSettings.ApplyIntentSettings(); } }

Как пример, приведена обработка нажатия кнопки применения настроек. В ApplySettings() мы вызываем соответствующий публичный метод сервиса настроек.

UISelector — здесь ситуация немного сложнее. Нам нужно знать, какой есть список элементов для выбора. Поэтому для начала селектор запрашивает инициализацию через рассылку события SELECTOR_ON_INITIATE. А при выборе какого-то из элементов рассылает событие SELECTOR_ON_CHANGED со своим id и выбранным вариантом.

Пользовательский интерфейс в Unity.
[RequireComponent (typeof(Dropdown))] public class UISelector : UIElement { Dropdown dropdown; private void Start() { Messenger<string, UISelector>.Broadcast(UIEvents.SELECTOR_ON_INITIATE, id, this); dropdown.onValueChanged.AddListener(OnValueChanged); CommonUIElementStart(); } private void OnValueChanged(int value) { Messenger<string, int>.Broadcast(UIEvents.SELECTOR_ON_CHANGED, id, value); } public void SetOptions(List<Enum> options, Enum defaulOption) { int i = 0; int defaultValueIndex = 0; foreach (var curOpt in options) { if (defaulOption.Equals(curOpt)) { defaultValueIndex = i; } var optionData = new Dropdown.OptionData(); optionData.text = Root.localization.GetLocalString(curOpt.ToString()); dropdown.options.Add(optionData); i++; } dropdown.value = defaultValueIndex; } }
public class ServiceOfUserInterface : Service { private readonly Dictionary<SelectorID, SelectorContainer> selectorMap = new(); void Service.Startup() { status = ServiceStatus.Initializing; Messenger<string, UISelector>.AddListener(UIEvents.SELECTOR_ON_INITIATE, OnSelectorInitiate); Messenger<string, int>.AddListener(UIEvents.SELECTOR_ON_CHANGED, OnSelectorChanged); status = ServiceStatus.Started; } private void OnSelectorInitiate(string id, UISelector selector) { var selectorID = (SelectorID)Enum.Parse(typeof(SelectorID), id); InitSelector(selectorID, selector); } private void InitSelector(SelectorID id, UISelector selector) { var selectorContainer = new SelectorContainer(); List<Enum> options = new List<Enum>(); ; Enum defaulOption = null; switch (id) { case SelectorID.LANGUAGE: options = Root.gameSettings.GetLanguageOptions().Cast<Enum>().ToList(); defaulOption = Root.gameSettings.GetLanguage(); selectorContainer.onChangedValueAction = OnLanguageChanged; break; case SelectorID.AIRCRAFT_SELECTOR: options = Root.progress.GetAvaliableAircrafts().Cast<Enum>().ToList(); defaulOption = Root.progress.GetCurrentAircraft(); selectorContainer.onChangedValueAction = OnAircraftChanged; break; default: break; } selector.SetOptions(options, defaulOption); selectorContainer.valueMapping = new(); int i = 0; foreach (var curOpt in options) { selectorContainer.valueMapping.Add(i, curOpt); i++; } if (!selectorMap.ContainsKey(id)) selectorMap.Add(id, selectorContainer); } private void OnSelectorChanged(string id, int value) { var currentDropdown = (SelectorID)Enum.Parse(typeof(SelectorID), id); if (selectorMap.TryGetValue(currentDropdown, out var selectorContainer)) selectorContainer.onChangedValueAction.Invoke(value); else Debug.Log(quot;No handler for {id}"); } private void OnLanguageChanged(int value) { var currentContainer = selectorMap.GetValueOrDefault(SelectorID.LANGUAGE); Root.gameSettings.SetIntentLanguage((Language)currentContainer.valueMapping[value]); } private void OnAircraftChanged(int value) { var currentContainer = selectorMap.GetValueOrDefault(SelectorID.AIRCRAFT_SELECTOR); Root.progress.SetCurrentAircraft((AircraftID)currentContainer.valueMapping[value]); } }

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

UIView представляет собой конкретное окно интерфейса. К примеру, главное меню или окно с настройками. На сцене это просто картинка, которая содержит подчинённые кнопки, селекторы и слайдеры. Данный элемент я использую, чтобы управлять видимостью окон в сцене.

Пользовательский интерфейс в Unity.

Сам по себе UIView никак с сервисом интерфейса не взаимодействует. Для управления окнами я добавил ещё один класс — ViewManager. Объект ViewManager находится на каждой загружаемой сцене. Он хранит в себе список окон для текущей сцены и управляет стеком активности окон.

Пользовательский интерфейс в Unity.

Когда сцена стартует, ViewManager инициализирует внутри себя viewMap и рассылает событие VIEW_ON_INITIATE.

ServiceOfUserInterface по этому событию пушит в стек менеджеров текущий ViewManager (который отправил событие). После этого открывается стартовое окно ViewManager, если оно имеется. Теперь ServiceOfUserInterface может через публичные методы ViewManager показывать и скрывать окна.

Пользовательский интерфейс в Unity.
public class ViewManager : MonoBehaviour { [SerializeField] ViewID startView; [SerializeField] private List<UIView> views; private readonly Dictionary<ViewID, UIView> viewMap = new(); private Stack<UIView> viewStack = new(); private void Start() { InitViewMap(); Messenger<ViewManager>.Broadcast(UIEvents.VIEW_ON_INITIATE, this); } private void InitViewMap() { foreach (var view in views) { view.gameObject.SetActive(false); viewMap.Add(view.viewID, view); } } public void ShowStartView() { if (startView != ViewID.NONE) ShowView(startView); } public void ShowView(ViewID id, bool hideOther = true) { if (!viewMap.ContainsKey(id)) { Debug.LogWarning(quot;ViewManager: view {id} not found!"); return; } if (hideOther) foreach (var curMenu in viewStack) curMenu.gameObject.SetActive(false); viewStack.Push(viewMap[id]); viewStack.Peek().gameObject.SetActive(true); } public void HideCurrentView() { if (viewStack.Count == 0) return; viewStack.Peek().gameObject.SetActive(false); viewStack.Pop(); if (viewStack.Count > 0) viewStack.Peek().gameObject.SetActive(true); else ShowStartView(); } public bool LastViewOnScreen() { return (viewStack.Count == 1); } }
public class ServiceOfUserInterface : Service { private Stack<ViewManager> viewManagerStack = new(); private Stack<string> sceneStack = new(); void Service.Startup() { status = ServiceStatus.Initializing; Messenger<ViewManager>.AddListener(UIEvents.VIEW_ON_INITIATE, OnViewInitiated); status = ServiceStatus.Started; } private void OnViewInitiated(ViewManager viewManager) { viewManagerStack.Push(viewManager); viewManager.ShowStartView(); } private void StartGame() { CloseCurrentScene(); LoadScene("Prototype"); } void LoadScene(string sceneName) { sceneStack.Push(sceneName); SceneManager.LoadScene(sceneName, LoadSceneMode.Additive); } private void CloseCurrentScene() { CloseScene(sceneStack.Peek()); } private void CloseScene(string sceneName) { viewManagerStack.Pop(); sceneStack.Pop(); UnityEngine.SceneManagement.Scene currentScene = SceneManager.GetSceneByName(sceneName); if (currentScene.isLoaded) { SceneManager.UnloadSceneAsync(currentScene); } } }

В данном случае при вызове StartGame() текущая сцена закрывается(при этом извлекается из стека), и запускается новая сцена "Prototype". При открытии этой сцены уже её ViewManager заявит о себе и будет добавлен в стек.

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

Спасибо за внимание!

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

1
2 комментария