Вся система является довольно тривиальным представлением недетерминированного конечного автомата.
Для реализации нам понадобится: набор состояний, набор представлений состояний, стейт-свитчер, переключающая эти состояния.
Реализация сервиса управления меню
Как я уже сказал, вся система может быть описана 3-4 классами: состоянием, визуальным представлением состояния и автоматом, который переключается между этими состояниями.
Опишем интерфейс состояния:
public interface IState
{
void OnEnter(params object[] parameters);
void OnEnter();
void OnExit();
}
Метод OnEnter будет вызываться в момент перехода в состояние, а его перегрузка создана для того, чтобы передавать в состояние набор параметров. Тем самым можно передавать объекты, которые будут использованы внутри состояния — аргументы событий или, например, делегаты, которые будут вызываться из состояния при том или ином событии. В свою очередь, OnExit будет вызван при выходе из состояния.
Представление состояния:
У каждого состояния должно быть представление. Задача представления — выводить информацию в UI элементы и уведомлять состояние о пользовательских действиях, касающихся UI (Если такие предусмотрены конкретной страницей интерфейса).
public interface IUIShowableHidable
{
void ShowUI();
void HideUI();
}
ShowUI — метод, инкапсулирующий в себе методы, реализующие отображение (активацию) UI-элементов, относящихся к текущей странице меню.
HideUI — метод, позволяющий скрыть все элементы, к примеру, перед переходом на другую страницу.
Реализация состояний и их представлений:
Подразумевается, что IState и IUIShowableHidable работают в связке — в момент вызова OnEnter в стейте уже находится заинжекченый туда IUIShowableHidable. При переходе в состояние вызывается ShowUI, при выходе — HideUI. В большинстве случаев именно так и будет работать переход между состояниями. Исключения, вроде длительных анимаций переходов, при которых требуется задержка между HideUI предыдущей страницы и ShowUI новой страницы можно решить различными способами.
Учитывая факт, описанный выше, мной было решено для удобства и скорости создания новых стейтов сделать абстрактный класс, который будет иметь поле с «вьюшкой» и инкапсулировать показ и сокрытие UI в методы переходов.
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 включены, с целью получения их инстансов.
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 — даст возможность очистить историю переходов между «скринами».
public interface IMenuService
{
void GoToScreenOfType<T>() where T : UIState;
void GoToScreenOfType<T>(params object[] parameters) where T : UIState;
void GoToPreviousScreen();
void ClearUndoStack();
}
Далее необходимо реализовать механизм, позволяющий переключаться между состояниями.
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
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)
semenyakinVS
17.09.2016 17:01Использование машин состояний и автоматов для GUI — достаточно стандартное решение. Но всё равно спасибо за статью. Ещё один вариант решения никогда не лишний. Можно и из него какие-то идеи взять. Единственное — не хватило картинок. Автоматы описываются графами переходов. Графы удобнее визуально понимать. Если бы были скрины из какого-нибудь тестового приложения с реальными менюшками — вообще идеально было бы.
P.S.: Если уж зашла речь о UI — задам вопрос. Была идея написать статью про используемую у нас на проекте объектную обёртку вокруг системы mutlytuch-событий для упрощения обработки пользовательских жестов. Как думаете, имеет смысл писать, или есть какое-то однозначное популярное решение в этой области?saw_tooth
17.09.2016 18:04Однозначно писать. Вменяемой хорошей информации по Unity на русском — крупицы. Вобще шикарно будет, если статья будет затрагивать разные подходы и ± в них.
jonic
17.09.2016 18:20+1Да в играх почти для всего Машина состояний вполне логичное решение, за некоторыми исключениями
isnotaname
17.09.2016 23:35+1Спасибо. Обязательно буду использовать иллюстрации в дальнейших статьях.
Мне кажется, что смысл писать есть. Сейчас количество информации действительно меньше, по сравнению с тем, которое я хотел бы видеть, начиная изучать Unity.
Leopotam
17.09.2016 18:39+2С unity 5.x появилась поддержка графов на базе аниматора и StateMachineBehaviour — свой огород можно больше не городить, а гонять штатный. На ноды графа теперь можно вешать компоненты с методами-реакциями на вход-выход в / из ноды, например, как тут.
DyadichenkoGA
17.09.2016 21:00+1В статье не хватает «для чего и в каких случаях это хорошо». Если конечным автоматом с состояниями представлять навигацию по главному меню скажем — ок, а вот если речь идёт уже о попапах, диалоговых окнах и т. п. то нет. И стейты далеко не всегда нужны, так как во многих задачах они будут избыточны и будут только усложнять структуру кода. Просто далеко не всегда нужна полная смена гуя, и иногда проще стейтмашину ставить независимо от интерфейса, а тут оно достаточно жёстко связано. Простой енам и выбор, какой именно гуй инстанцировать/показывать был бы ничем не хуже (хотя может я просто не вижу минусов)
А по данной реализации, она интересная, но на мой взгляд, она слишком сложная для такого функционала, и я не совсем понимаю, почему бы стейт свитчер не сделать синглтоном.isnotaname
17.09.2016 23:24если речь идёт уже о попапах, диалоговых окнах и т. п.
то, в общем-то, да, достаточно просто завести банальный статический класс, который будет менеджить и слушать колбеки со всяких DialogPopup, MessageBox и т.д.
Тут же речь идет о полноценной смене одного менюшного стейта (скрина) на другой. Можно сделать и енамом, но это другие масштабы. Когда у меня было 15 полноценных стейтов, енам из 15 элементов и 15 кейсов свитча — было менее удобно, признаться.DyadichenkoGA
17.09.2016 23:35+1Я просто себе слабо представляю 15 взаимосвязанных стейтов интерфейсов в игре, так как глубина переходов больше, чем в 3 — это уже плохо с точки зрения проектировки UI. А если они достаточно независимы, то я для этого использую модульную систему и в нужных местах вызываю необходимые модули. Не спорю, такие ситуации возможно могут возникать, просто в моём понимании они очень специфичны.
zartdinov
17.09.2016 21:00-1Насчет предложений, возможно вам понравится философия Redux (React.js), с еще одним явным компонентом — хранилищем состояний.
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; }
isnotaname
17.09.2016 23:14Да, вы правы!
Признаться честно, я не знал, что в метод с parameters можно ничего не передавать. В таком случае, метод OnEnter() действительно можно опустить.
Насчет CachableMonoBehaviour — код был выдернут из под одного проекта, который требовал использования монобеха с закэшироваными полями, вроде transform, gameObject и т.д. Все монобехи были заменены автозаменой на CachableMonoBehaviour, в т.ч. и этот.
TrySendAction — метод, который был рассчитан на то, чтобы не писать каждый раз «if» в лямбдах на кнопках.
Извиняюсь, что забыл подтереть эти упоминания.Lertmind
17.09.2016 23:45Так и подумал, про CachableMonoBehaviour и TrySendAction мне стоило написать в личку. К слову, спасибо за статью.
SadOcean
17.09.2016 23:44Очень забавно, но после множества итераций и коллективного обсуждения опыта реализации интерфейса в нескольких небольших проектах мы пришли к очень похожей схеме построения меню. Просто поразительно похожей)
Конечно в итоге система получилась немного сложнее — были добавлены штатные абстракции под загрузку ресурсов, анимированное появление меню, переключение сцен между стейтами(когда надо не только сменить состояние с меню на битву, но и загрузить для битвы сцену с картой, к примеру).
А интерфейс получил возможность отображаться как монопольно, так и слоями(для реализации диалоговых окон, переиспользования одинаковых кнопочек и т.д)
Но вообще сходство поразительное, стейты можно было реализовать и по другому.
marcor
Я недавно попытался сделать меню на основе графа. Написал граф, привязал к нему меню. Граф-то рабочий, а вот меню юзать неудобно. Много кода при создании.
Спасибо за идею, надо будет обязательно попробовать вариант со машиной состояний.
Вообще меня удивляет, что в игровых фреймворках до сих пор нет вменяемого UI-комплекса. Казалось бы, вещь, которая всем нужна.