Всем доброго времени суток! Хотелось бы рассказать о том, как я реализовывал систему игрового UI в небольшом игровом проекте. Данный подход показался мне самым оптимальным и удобным во всех требуемых аспектах.
Вся система является довольно тривиальным представлением недетерминированного конечного автомата.
Для реализации нам понадобится: набор состояний, набор представлений состояний, стейт-свитчер, переключающая эти состояния.
Как я уже сказал, вся система может быть описана 3-4 классами: состоянием, визуальным представлением состояния и автоматом, который переключается между этими состояниями.
Опишем интерфейс состояния:
Метод OnEnter будет вызываться в момент перехода в состояние, а его перегрузка создана для того, чтобы передавать в состояние набор параметров. Тем самым можно передавать объекты, которые будут использованы внутри состояния — аргументы событий или, например, делегаты, которые будут вызываться из состояния при том или ином событии. В свою очередь, OnExit будет вызван при выходе из состояния.
Представление состояния:
У каждого состояния должно быть представление. Задача представления — выводить информацию в UI элементы и уведомлять состояние о пользовательских действиях, касающихся UI (Если такие предусмотрены конкретной страницей интерфейса).
ShowUI — метод, инкапсулирующий в себе методы, реализующие отображение (активацию) UI-элементов, относящихся к текущей странице меню.
HideUI — метод, позволяющий скрыть все элементы, к примеру, перед переходом на другую страницу.
Реализация состояний и их представлений:
Подразумевается, что IState и IUIShowableHidable работают в связке — в момент вызова OnEnter в стейте уже находится заинжекченый туда IUIShowableHidable. При переходе в состояние вызывается ShowUI, при выходе — HideUI. В большинстве случаев именно так и будет работать переход между состояниями. Исключения, вроде длительных анимаций переходов, при которых требуется задержка между HideUI предыдущей страницы и ShowUI новой страницы можно решить различными способами.
Учитывая факт, описанный выше, мной было решено для удобства и скорости создания новых стейтов сделать абстрактный класс, который будет иметь поле с «вьюшкой» и инкапсулировать показ и сокрытие UI в методы переходов.
Так же имеются абстрактные методы Enter и Exit, которые будут вызваны после вызова соответсвующих методов IUIShowableHidable. Фактической пользы от ��их нет, так как можно было при надобности обойтись простым override-ом OnEnter и OnExit, однако мне показалось удобным держать в стейте пустые методы, которые в случае надобности будут заполнены.
Для большей простоты был реализован класс UIShowableHidable, который реализует IUIShowableHidable и избавляет нас от надобности, каждый раз реализовывать ShowUI и HideUI. Так же, в Awake элемент будет деактивирован, это сделано из соображений, что изначально, все элементы UI включены, с целью получения их инстансов.
Приступим к проектированию «сердца» игрового меню:
Нам нужны три основных метода:
Далее необходимо реализовать механизм, позволяющий переключаться между состояниями.
Тут все просто: AddState добавляет стейт в список стейтов, GoToState проверяет наличие требуемого стейта в списке, если находит его, то выполняет выход из текущего состояния и вход в требуемое, а так же регистрирует смену состояний, представляя переход классом StateSwitchCommand, добавляя ее в стек переходов, что позволит нам возвращаться на предыдущий экран.
Осталось добавить реализацию IMenuService
Конструктор принимает набор IState'ов, которые будут использованы в вашей игре.
Простой пример использования:
В итоге получается довольно практичная, на мой взгляд, и легко расширяемая система управления пользовательским интерфейсом.
Спасибо за внимание. Буду рад замечаниям и предложениям, способным улучшить описаный в статье способ.
Вся система является довольно тривиальным представлением недетерминированного конечного автомата.
Для реализации нам понадобится: набор состояний, набор представлений состояний, стейт-свитчер, переключающая эти состояния.
Реализация сервиса управления меню
Как я уже сказал, вся система может быть описана 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'ов, которые будут использованы в вашей игре.
Использование
Простой пример использования:
Пример состояния
Конструктор требует входных IUIShowableHidable и, собственно, самого GameEndUI — представления состояния.
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()); }
Заключение
В итоге получается довольно практичная, на мой взгляд, и легко расширяемая система управления пользовательским интерфейсом.
Спасибо за внимание. Буду рад замечаниям и предложениям, способным улучшить описаный в статье способ.