В Unity есть хорошая система для создания пользовательского интерфейса UI Canvas. По ней написано довольно много обучающего материала, но большинство гайдов рассказывает только о том, какие кнопки нажать и какой код написать, чтобы все заработало. В качестве примеров обычно приводится небольшой интерфейс состоящий из пары окон: главное меню, настройки. Однако в играх бывает гораздо больше окон и когда их становится уже хотя бы десяток, возникает необходимость какой-либо их организации. В рамках этой статьи я хочу рассказать как я решаю эту проблему.

Для начала нужно выделить отдельные окна в вашем интерфейсе. Под окном я подразумеваю панель с некоторыми контролами.

Примеры окон






У каждого окна должен быть корневой объект, который будет содержать все контролы. Этот объект будет представлять окно как единое целое. Например есть панель на которой находятся элементы управления, логично сделать эти элементы дочерними по отношению к панели. К каждому окну прикрепляется компонент, котрый является наследником абстрактного класса Window.

Window


Window написан исходя из того, что у окон могут быть дочерние окна, т.е. те окна которые могут быть открыты из текущего окна при этом в один момент времени может быть открыто одно дочернее окно, а все остальные должны быть закрыты. С этой целью класс содержит свойство CurrentWindow в котором хранится ссылка на открытое на данный момент окно. А также есть событие OnOpen которое сообщает об открытии окна. Метод ChangeCurrentWindow() можно подписать на это событие у дочерних окон, чтобы в любой момент времени было открыто одно дочернее окно, он закрывает открытое дочернее окно и меняет ссылку на текущее открытое окно, ниже я приведу пример реализации. Также в классе есть методы SelfClose() и SelfOpen(), эти методы отвечают за то как будет открываться и закрываться окно. В методе Awake() происходит регистрация окна в UIManager, т.е. добавляется ссылка на окно.

public abstract class Window : MonoBehaviour
{
    public bool IsOpen { get; private set; }
    public Window CurrentWindow { get; protected set; } = null;

    public delegate void OpenEventHandler(Window sender);
    public event OpenEventHandler OnOpen;

    void Awake()
    {
        UIManager.Instance.Windows.Add(this.gameObject);
    }

    public void Open()
    {
        IsOpen = true;
        if (OnOpen != null)
            OnOpen(this);

        SelfOpen();
    }

    protected abstract void SelfOpen();
    
    public void Close()
    {
        IsOpen = false;

        if (CurrentWindow != null)
            CurrentWindow.Close();
        SelfClose();
    }

    protected abstract void SelfClose();

    protected void ChangeCurrentWindow(Window sender)
    {
        if (CurrentWindow != null)
            CurrentWindow.Close();
        
        CurrentWindow = sender;
    }
}

UIManager


Далее перейдём к классу UIManager, он является sigleton-классом для того чтобы все окна могли к нему обращаться при необходимости. Как видно в нём есть список окон Windows, именно в нем хранятся ссылки на все окна на сцене. InitUI() нужен чтобы что-либо инициализировать, например, если вы хотите создавать окна из префабов, то здесь вы можете сделать их инстансы.
В методе Start() вы можете открыть окна которые должны быть открыты с самого начала или наоборот закрыть ненужные. Метод Get() позволяет получить ссылку на конкретное окно.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class UIManager : MonoBehaviour
{
    public static UIManager Instance = null;

    public List<GameObject> Windows;

    void Awake()
    {
    if (Instance == null)
        Instance = this;
    else if (Instance != this)
        Destroy(gameObject);

    InitUI();
    }

    private void InitUI()
    {
        //to do
    }

    void Start()
    {
        foreach(var window in Windows)
        {
            var windowComponent = window.GetComponent<Window>();
            if (windowComponent is StartWindow)
                windowComponent.Open();
            else
                windowComponent.Close();
        }
    }

    public Window Get<T> () where T : Window
    {
        foreach(var window in Windows)
        {
            var windowComponent = window.GetComponent<Window>();
            if (windowComponent is T)
                return windowComponent;
        }
        return null;
    }
}

SettingsWindow


В качестве примера приведу свою реализацию окна настроек:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SettingsWindow : Window
{
    private VideoSettingsWindow videoSettingsWindow;
    private LanguageSettingsWindow languageSettingsWindow;
    private AudioSettingsWindow audioSettingsWindow;
    private ControlSettingsWindow controlSettingsWindow;
    
    public void Start()
    {
        videoSettingsWindow = UIManager.Instance.GetWindow<VideoSettingsWindow>();
        languageSettingsWindow = UIManager.Instance.GetWindow<LanguageSettingsWindow>();
        audioSettingsWindow = UIManager.Instance.GetWindow<AudioSettingsWindow>();
        controlSettingsWindow = UIManager.Instance.GetWindow<ControlSettingsWindow>();

        videoSettingsWindow.OnOpen += ChangeCurrentWindow;
        languageSettingsWindow.OnOpen += ChangeCurrentWindow;
        audioSettingsWindow.OnOpen += ChangeCurrentWindow;
        controlSettingsWindow.OnOpen += ChangeCurrentWindow;
    }

    protected override void SelfOpen()
    {
        this.gameObject.SetActive(true);
    }

    protected override void SelfClose()
    {
        this.gameObject.SetActive(false);
    }

    public void Apply()
    {

    }

    public void VideoSettings()
    {
        videoSettingsWindow.Open();
    }

    public void LanguageSettings()
    {
        languageSettingsWindow.Open();
    }
   
    public void AudioSettings()
    {
        audioSettingsWindow.Open();
    }

    public void ControlSettings()
    {
        controlSettingsWindow.Open();
    }
}

Из окна настроек я могу открыть 4 других окна, чтобы открыто было только одно окно, я подписываю метод ChangeCurrentWindow() окна настроек на события OnOpen дочерних окон, таким образом открытые окна закрываются, при открытии других. Реализации SelfOpen() и SelfClose() просто активируют или деактивируют окно.

Таким образом получается легко расширяемая система UI, нет необходимости вручную добавлять ссылки на окна в инспекторе или где-либо еще, чтобы добавить новое окно, нужно всего лишь создать соответствующий класс и унаследовать его от Window. У такой системы есть пара минусов, это то, что чем больше окон, тем больше классов необходимо будет создать и то, что поиск ссылки на окно происходит путем перебора массива ссылок, но на мой взгляд эти недостатки окупаются преимуществами.

Ссылка на репозиторий

Комментарии (5)


  1. puyol_dev2
    23.10.2019 21:12

    А зачем переменные такими длинными именами называть? Это ухудшает читаемость и вносит путаницу, когда имя класса пересекается с именем переменной, например


  1. Brightori
    23.10.2019 21:59

    А зачем окна хранить как GameObject и потом получать с него тип через GetComponent, если можно сразу сделать

     List<Window>
    и обращаться к типу/его потомкам напрямую.

    ибо вот это супер избыточно, получается каждый раз как обращаемся мы получаем компонент с каждого экземпляра в коллекции
     public Window Get<T> () where T : Window
        {
            foreach(var window in Windows)
            {
                var windowComponent = window.GetComponent<Window>();
                if (windowComponent is T)
                    return windowComponent;
            }
            return null;
        }


    ЗЫ
    опять же данная история я так понимаю не предполагает что может быть несколько экземпляров одного типа?

    Я бы эту историю записал так:
    public T GetWindow<T>() where T: Window=> windows.OfType<T>().FirstOrDefault();

    и как раз легко можно сделать перегрузку с аргументами для опознания конкретного экземпляра данного типа


  1. sith
    23.10.2019 22:33

    Эта система содержит типичную, классическую ошибку — не соблюдается идеология (подход, методология) Unity.

    Если очень коротко, то:

    1. Не используйте Singleton UIManager (и, вообще, старайтесь не использовать Singleton в Unity). К тому же, если Вы всё таки решили, что он Вам необходим (хотя это и не так), то зачем делать его MonoBehaviour?

    2. Используйте как можно реже GetComponent.

    3. Используйте прямые ссылки на prefab и прочие GameObject в полях Unity Editor (для этого и был написан Editor), а не связи через Singleton.

    4. Если нет проблем с производительностью, то используйте UnityEvent и настраивайте его поведение в Unity Editor. Все эти Open, Close и так далее.

    5. Если все окна известны заранее и их не «сотни», то просто разместите их руками на сцене и задайте в Unity Editor связи между событиями этих окон (можно добавить на сцену общий диспетчер окон).

    6. Если связи (события) слишком сложны (много однотипных окон и/или окон со сложным поведением которые создаются runtime, то, Ok, можно сделать диспетчера как простой класс со статическими полями, но, не наследник MonoBehaviour.

    Если очень коротко code review, то:

    1.

    public event OpenEventHandler OnOpen;

    В одной и той же строчке написано и event и EventHandler? Это разные понятия. OnOpen неверное название для event. Верное просто Open (Click, Update etc).

    2.
    public delegate void OpenEventHandler(Window sender);
        public event OpenEventHandler OnOpen;

    Зачем так сложно, тем более, если всё равно не используете UnityEvent? Можно обойтись просто
    public event Action<Window> Open = delegate {};


    3.
    if (OnOpen != null)
                OnOpen(this);

    Обычно, пишут что-то вроде
    Open?.Invoke(this);

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

    4.
          videoSettingsWindow = UIManager.Instance.GetWindow<VideoSettingsWindow>();
            languageSettingsWindow = UIManager.Instance.GetWindow<LanguageSettingsWindow>();
            audioSettingsWindow = UIManager.Instance.GetWindow<AudioSettingsWindow>();
            controlSettingsWindow = UIManager.Instance.GetWindow<ControlSettingsWindow>();


    Если возможно — выкидываем всё это и просто заменяем на

    [SerializedField] private VideoSettingsWindow videoSettingsWindow;


    для всех окон

    и указываем ссылки просто в Unity Editor.

    5.
    videoSettingsWindow.OnOpen += ChangeCurrentWindow;


    Эти события меняем на UnityEvent и тоже потом настраиваем в UnityEditor.

    Итого:

    Получаем настоящий Unity Editor код, а не C# код «натянутый» на среду Unity.

    Ну а вообще, через год другой это всё станет опять не актуально, когда DOTS прикрутят и к UI тоже.


    1. GLeBaTi
      25.10.2019 15:18

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


  1. SadOcean
    24.10.2019 13:42

    Мне кажется, тут не проработан момент с открытием окон — его хотят слишком упростить, что порождает костыли типа CurrentWindow и OnOpen в окне.
    Если кратко, можно выделить 2 основных паттерна работы:
    — Состояние. Такие сущности можно назвать «экранами», с ними действует вытесняющий принцип — при открытии стартует главное меню, при начале игры — его заменяет экран загрузки, потом основным экраном становится HUD в игре.
    — Стек. Работает с «окнами», нужен, чтобы пользователь увидел все открытые для него окна. Правила могут быть разными, не обязательно показывать прямо все окна, можно только верхнее. Но самое главное — при открытии окна оно открывается поверх всего. Закрытие верхнего окна показывает окно под ним.
    Обычно все останавливаются на первом пункте и стремятся все унифицировать (у элементов похожий ЖЦ), что порождает костыли.
    По хорошему за такими высокоуровневыми правилами должен следить менеджер интерфейсов и CurrentWindow, как и стек окон должен обслуживать тоже он. У окошек хватает своих проблем.

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