Всем доброго времени суток! Хотелось бы рассказать о том, как я реализовывал систему игрового UI в небольшом игровом проекте. Данный подход показался мне самым оптимальным и удобным во всех требуемых аспектах.

Вся система является довольно тривиальным представлением недетерминированного конечного автомата.
Для реализации нам понадобится: набор состояний, набор представлений состояний, стейт-свитчер, переключающая эти состояния.

Реализация сервиса управления меню


Как я уже сказал, вся система может быть описана 3-4 классами: состоянием, визуальным представлением состояния и автоматом, который переключается между этими состояниями.

Опишем интерфейс состояния:

IState
public interface IState
{
    void OnEnter(params object[] parameters);
    void OnEnter();
    void OnExit();
}


Метод OnEnter будет вызываться в момент перехода в состояние, а его перегрузка создана для того, чтобы передавать в состояние набор параметров. Тем самым можно передавать объекты, которые будут использованы внутри состояния — аргументы событий или, например, делегаты, которые будут вызываться из состояния при том или ином событии. В свою очередь, OnExit будет вызван при выходе из состояния.

Представление состояния:

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

IUIShowableHidable
public interface IUIShowableHidable
{
    void ShowUI();
    void HideUI();
}


ShowUI — метод, инкапсулирующий в себе методы, реализующие отображение (активацию) UI-элементов, относящихся к текущей странице меню.

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

Реализация состояний и их представлений:

Подразумевается, что IState и IUIShowableHidable работают в связке — в момент вызова OnEnter в стейте уже находится заинжекченый туда IUIShowableHidable. При переходе в состояние вызывается ShowUI, при выходе — HideUI. В большинстве случаев именно так и будет работать переход между состояниями. Исключения, вроде длительных анимаций переходов, при которых требуется задержка между HideUI предыдущей страницы и ShowUI новой страницы можно решить различными способами.

Учитывая факт, описанный выше, мной было решено для удобства и скорости создания новых стейтов сделать абстрактный класс, который будет иметь поле с «вьюшкой» и инкапсулировать показ и сокрытие UI в методы переходов.

UIState
public abstract class UIState : IState
{
    protected abstract IUIShowableHidable ShowableHidable { get; set; }
    protected abstract void Enter(params object[] parameters);
    protected abstract void Enter();
    protected abstract void Exit();

    public virtual void OnEnter()
    {
        ShowableHidable.ShowUI();
        Enter();
    }

    public virtual void OnExit()
    {
        ShowableHidable.HideUI();
        Exit();
    }

    public virtual void OnEnter(params object[] parameters)
    {
        ShowableHidable.ShowUI();
        Enter(parameters);
    }
}


Так же имеются абстрактные методы Enter и Exit, которые будут вызваны после вызова соответсвующих методов IUIShowableHidable. Фактической пользы от них нет, так как можно было при надобности обойтись простым override-ом OnEnter и OnExit, однако мне показалось удобным держать в стейте пустые методы, которые в случае надобности будут заполнены.

Для большей простоты был реализован класс UIShowableHidable, который реализует IUIShowableHidable и избавляет нас от надобности, каждый раз реализовывать ShowUI и HideUI. Так же, в Awake элемент будет деактивирован, это сделано из соображений, что изначально, все элементы UI включены, с целью получения их инстансов.

UIShowableHidable
public class UIShowableHidable :  CachableMonoBehaviour, IUIShowableHidable
{
    protected virtual void Awake()
    {
        gameObject.SetActive(false);
    }
    
    public virtual void ShowUI()
    {
        gameObject.SetActive(true);
    }

    public virtual void HideUI()
    {
        gameObject.SetActive(false);
    }

    protected bool TrySendAction(Action action)
    {
        if (action == null) return false;
        action();
        return true;
    }
}


Приступим к проектированию «сердца» игрового меню:

Нам нужны три основных метода:

  • GoToScreenOfType — метод, который будет позволять переходить в состояние, передаваемое параметром. Имеет перегрузку, которая будет передавать набор object-ов в целевое состояние.
  • GoToPreviousScreen — будет возвращать нас на предыдущий «скрин».
  • ClearUndoStack — даст возможность очистить историю переходов между «скринами».

IMenuService
public interface IMenuService
{
    void GoToScreenOfType<T>() where T : UIState;
    void GoToScreenOfType<T>(params object[] parameters) where T : UIState;
    void GoToPreviousScreen();
    void ClearUndoStack();
}


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

StateSwitcher
public class StateSwitcher
{
    private IState currentState;
    private readonly List<IState> registeredStates;
    private readonly Stack<StateSwitchCommand> switchingHistory;
    private StateSwitchCommand previousStateSwitchCommand;

    public StateSwitcher()
    {
        registeredStates = new List<IState>();
        switchingHistory = new Stack<StateSwitchCommand>();
    }

    public void ClearUndoStack()
    {
        switchingHistory.Clear();
    }

    public void AddState(IState state)
    {
        if (registeredStates.Contains(state)) return;
        registeredStates.Add(state);
    }

    public void GoToState<T>()
    {
        GoToState(typeof(T));
    }

    public void GoToState<T>(params object[] parameters)
    {
        GoToState(typeof(T), parameters);
    }

    public void GoToState(Type type)
    {
        Type targetType = type;
        if (currentState != null)
            if (currentState.GetType() == targetType) return;
        foreach (var item in registeredStates)
        {
            if (item.GetType() != targetType) continue;
            if (currentState != null)
                currentState.OnExit();
            currentState = item;
            currentState.OnEnter();
            RegStateSwitching(targetType, null);
        }
    }

    public void GoToState(Type type, params object[] parameters)
    {
        Type targetType = type;
        if (currentState != null)
            if (currentState.GetType() == targetType) return;
        foreach (var item in registeredStates)
        {
            if (item.GetType() != targetType) continue;
            if (currentState != null)
                currentState.OnExit();
            currentState = item;
            currentState.OnEnter(parameters);
            RegStateSwitching(targetType, parameters);
        }
    }

    public void GoToPreviousState()
    {
        if (switchingHistory.Count < 1) return;
        StateSwitchCommand destination = switchingHistory.Pop();
        previousStateSwitchCommand = null;
        if (destination.parameters == null)
        {
            GoToState(destination.stateType);
        }
        else
        {
            GoToState(destination.stateType, destination.parameters);
        }
    }

    private void RegStateSwitching(Type type, params object[] parameters)
    {
        if (previousStateSwitchCommand != null)
            switchingHistory.Push(previousStateSwitchCommand);
        previousStateSwitchCommand = new StateSwitchCommand(type, parameters);
    }

    private class StateSwitchCommand
    {
        public StateSwitchCommand(Type type, params object[] parameters)
        {
            stateType = type;
            this.parameters = parameters;
        }
        public readonly Type stateType;
        public readonly object[] parameters;
    }
}


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

Осталось добавить реализацию IMenuService

MenuManager
public class MenuManager : IMenuService
{
    private readonly StateSwitcher stateSwitcher;

    public MenuManager()
    {
        stateSwitcher = new StateSwitcher();
    }

    public MenuManager(params UIState[] states) : this()
    {
        foreach (var item in states)
        {
            stateSwitcher.AddState(item);
        }
    }

    public void GoToScreenOfType<T>() where T : UIState
    {
        stateSwitcher.GoToState<T>();
    }

    public void GoToScreenOfType(Type type)
    {
        stateSwitcher.GoToState(type);
    }

    public void GoToScreenOfType<T>(params object[] parameters) where T : UIState
    {
        stateSwitcher.GoToState<T>(parameters);
    }

    public void GoToScreenOfType(Type type, params object[] parameters)
    {
        stateSwitcher.GoToState(type, parameters);
    }

    public void GoToPreviousScreen()
    {
        stateSwitcher.GoToPreviousState();
    }

    public void ClearUndoStack()
    {
        stateSwitcher.ClearUndoStack();
    }
}


Конструктор принимает набор IState'ов, которые будут использованы в вашей игре.

Использование


Простой пример использования:

Пример состояния
public sealed class GameEndState : UIState
{
    protected override IUIShowableHidable ShowableHidable { get; set; }
    private readonly GameEndUI gameEndUI;
    private Action onRestartButtonClicked;
    private Action onMainMenuButtonClicked;

    public GameEndState(IUIShowableHidable uiShowableHidable, GameEndUI gameEndUI)
    {
        ShowableHidable = uiShowableHidable;
        this.gameEndUI = gameEndUI;
    }
    
    protected override void Enter(params object[] parameters)
    {
        onRestartButtonClicked = (Action) parameters[0];
        onMainMenuButtonClicked = (Action)parameters[1];
        gameEndUI.onRestartButtonClicked += onRestartButtonClicked;
        gameEndUI.onMainMenuButtonClicked += onMainMenuButtonClicked;
        gameEndUI.SetGameEndResult((string)parameters[2]);
        gameEndUI.SetTimeText((string)parameters[3]);
        gameEndUI.SetScoreText((string)parameters[4]);
    }

    protected override void Enter()
    {
    }

    protected override void Exit()
    {
        gameEndUI.onRestartButtonClicked -= onRestartButtonClicked;
        gameEndUI.onMainMenuButtonClicked -= onMainMenuButtonClicked;
    }
}

Конструктор требует входных IUIShowableHidable и, собственно, самого GameEndUI — представления состояния.

Пример представления состояния
public class GameEndUI : UIShowableHidable
{
    public static GameEndUI Instance { get; private set; }

    [SerializeField]
    private Text gameEndResultText;
    [SerializeField]
    private Text timeText;
    [SerializeField]
    private Text scoreText;
    [SerializeField]
    private Button restartButton;
    [SerializeField]
    private Button mainMenuButton;

    public Action onMainMenuButtonClicked;
    public Action onRestartButtonClicked;

    protected override void Awake()
    {
        base.Awake();
        Instance = this;
        restartButton.onClick.AddListener(() => { if(onRestartButtonClicked != null) onRestartButtonClicked(); });
        mainMenuButton.onClick.AddListener(() => { if (onMainMenuButtonClicked != null) onMainMenuButtonClicked(); });
    }

    public void SetTimeText(string value)
    {
        timeText.text = value;
    }

    public void SetGameEndResult(string value)
    {
        gameEndResultText.text = value;
    }

    public void SetScoreText(string value)
    {
        scoreText.text = value;
    }
}


Инициализация и переходы
 private IMenuService menuService;

    private void InitMenuService()
    {
        menuService = new MenuManager
                    (
                    new MainMenuState(MainMenuUI.Instance, MainMenuUI.Instance, playmodeService, scoreSystem),
                    new SettingsState(SettingsUI.Instance, SettingsUI.Instance, gamePrefabs),
                    new AboutAuthorsState(AboutAuthorsUI.Instance, AboutAuthorsUI.Instance),
                    new GameEndState(GameEndUI.Instance, GameEndUI.Instance),
                    playmodeState
                    );
    }

   ...

    private void OnGameEnded(GameEndEventArgs gameEndEventArgs)
    {
        Timer.StopTimer();
        scoreSystem.ReportScore(score);
        PauseGame(!IsGamePaused());
        Master.GetMenuService().GoToScreenOfType<GameEndState>(
            new Action(() => { ReloadGame(); PauseGame(false); }),
            new Action(() => { UnloadGame(); PauseGame(false); }),
            gameEndEventArgs.gameEndStatus.ToString(),
            Timer.GetTimeFormatted(),
            score.ToString());
    }



Заключение


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

Спасибо за внимание. Буду рад замечаниям и предложениям, способным улучшить описаный в статье способ.
Поделиться с друзьями
-->

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


  1. marcor
    17.09.2016 16:51

    Я недавно попытался сделать меню на основе графа. Написал граф, привязал к нему меню. Граф-то рабочий, а вот меню юзать неудобно. Много кода при создании.
    Спасибо за идею, надо будет обязательно попробовать вариант со машиной состояний.

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


  1. semenyakinVS
    17.09.2016 17:01

    Использование машин состояний и автоматов для GUI — достаточно стандартное решение. Но всё равно спасибо за статью. Ещё один вариант решения никогда не лишний. Можно и из него какие-то идеи взять. Единственное — не хватило картинок. Автоматы описываются графами переходов. Графы удобнее визуально понимать. Если бы были скрины из какого-нибудь тестового приложения с реальными менюшками — вообще идеально было бы.

    P.S.: Если уж зашла речь о UI — задам вопрос. Была идея написать статью про используемую у нас на проекте объектную обёртку вокруг системы mutlytuch-событий для упрощения обработки пользовательских жестов. Как думаете, имеет смысл писать, или есть какое-то однозначное популярное решение в этой области?


    1. saw_tooth
      17.09.2016 18:04

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


    1. jonic
      17.09.2016 18:20
      +1

      Да в играх почти для всего Машина состояний вполне логичное решение, за некоторыми исключениями


    1. isnotaname
      17.09.2016 23:35
      +1

      Спасибо. Обязательно буду использовать иллюстрации в дальнейших статьях.
      Мне кажется, что смысл писать есть. Сейчас количество информации действительно меньше, по сравнению с тем, которое я хотел бы видеть, начиная изучать Unity.


  1. Leopotam
    17.09.2016 18:39
    +2

    С unity 5.x появилась поддержка графов на базе аниматора и StateMachineBehaviour — свой огород можно больше не городить, а гонять штатный. На ноды графа теперь можно вешать компоненты с методами-реакциями на вход-выход в / из ноды, например, как тут.


  1. DyadichenkoGA
    17.09.2016 21:00
    +1

    В статье не хватает «для чего и в каких случаях это хорошо». Если конечным автоматом с состояниями представлять навигацию по главному меню скажем — ок, а вот если речь идёт уже о попапах, диалоговых окнах и т. п. то нет. И стейты далеко не всегда нужны, так как во многих задачах они будут избыточны и будут только усложнять структуру кода. Просто далеко не всегда нужна полная смена гуя, и иногда проще стейтмашину ставить независимо от интерфейса, а тут оно достаточно жёстко связано. Простой енам и выбор, какой именно гуй инстанцировать/показывать был бы ничем не хуже (хотя может я просто не вижу минусов)

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


    1. isnotaname
      17.09.2016 23:24

      если речь идёт уже о попапах, диалоговых окнах и т. п.
      то, в общем-то, да, достаточно просто завести банальный статический класс, который будет менеджить и слушать колбеки со всяких DialogPopup, MessageBox и т.д.
      Тут же речь идет о полноценной смене одного менюшного стейта (скрина) на другой. Можно сделать и енамом, но это другие масштабы. Когда у меня было 15 полноценных стейтов, енам из 15 элементов и 15 кейсов свитча — было менее удобно, признаться.


      1. DyadichenkoGA
        17.09.2016 23:35
        +1

        Я просто себе слабо представляю 15 взаимосвязанных стейтов интерфейсов в игре, так как глубина переходов больше, чем в 3 — это уже плохо с точки зрения проектировки UI. А если они достаточно независимы, то я для этого использую модульную систему и в нужных местах вызываю необходимые модули. Не спорю, такие ситуации возможно могут возникать, просто в моём понимании они очень специфичны.


  1. zartdinov
    17.09.2016 21:00
    -1

    Насчет предложений, возможно вам понравится философия Redux (React.js), с еще одним явным компонентом — хранилищем состояний.


    1. isnotaname
      17.09.2016 23:15

      Спасибо! Обязательно изучу в ближайшее время.


  1. Lertmind
    17.09.2016 21:02

    Можно освободить часть кода, если убрать код связанный с вызовом Enter(), так как метод Enter(params object[] parameters) имеет возможность вызова без параметров, в этом случае передаётся пустой массив.

    Какой смысл здесь дублировать аргументы:

    new GameEndState(GameEndUI.Instance, GameEndUI.Instance),
    ...
    public GameEndState(IUIShowableHidable uiShowableHidable, GameEndUI gameEndUI)
    {
        ShowableHidable = uiShowableHidable;
        this.gameEndUI = gameEndUI;
    }
    
    если можно написать так:
    public GameEndState(GameEndUI gameEndUI)
    {
        ShowableHidable = gameEndUI;
        this.gameEndUI = gameEndUI;
    }
    

    Ничего не сказано о CachableMonoBehaviour и об этом методе:
    protected bool TrySendAction(Action action)
    {
        if (action == null) return false;
        action();
        return true;
    }
    


    1. isnotaname
      17.09.2016 23:14

      Да, вы правы!
      Признаться честно, я не знал, что в метод с parameters можно ничего не передавать. В таком случае, метод OnEnter() действительно можно опустить.
      Насчет CachableMonoBehaviour — код был выдернут из под одного проекта, который требовал использования монобеха с закэшироваными полями, вроде transform, gameObject и т.д. Все монобехи были заменены автозаменой на CachableMonoBehaviour, в т.ч. и этот.
      TrySendAction — метод, который был рассчитан на то, чтобы не писать каждый раз «if» в лямбдах на кнопках.
      Извиняюсь, что забыл подтереть эти упоминания.


      1. Lertmind
        17.09.2016 23:45

        Так и подумал, про CachableMonoBehaviour и TrySendAction мне стоило написать в личку. К слову, спасибо за статью.


  1. SadOcean
    17.09.2016 23:44

    Очень забавно, но после множества итераций и коллективного обсуждения опыта реализации интерфейса в нескольких небольших проектах мы пришли к очень похожей схеме построения меню. Просто поразительно похожей)
    Конечно в итоге система получилась немного сложнее — были добавлены штатные абстракции под загрузку ресурсов, анимированное появление меню, переключение сцен между стейтами(когда надо не только сменить состояние с меню на битву, но и загрузить для битвы сцену с картой, к примеру).
    А интерфейс получил возможность отображаться как монопольно, так и слоями(для реализации диалоговых окон, переиспользования одинаковых кнопочек и т.д)
    Но вообще сходство поразительное, стейты можно было реализовать и по другому.


  1. Incorrectfree
    19.09.2016 09:36

    А где картиночки?)


    1. demonit
      20.09.2016 12:24

      без картинок не информативно