Всем доброго времени суток! Хотелось бы рассказать о том, как я реализовывал систему игрового UI в небольшом игровом проекте. Данный подход показался мне самым оптимальным и удобным во всех требуемых аспектах.
Вся система является довольно тривиальным представлением недетерминированного конечного автомата.
Для реализации нам понадобится: набор состояний, набор представлений состояний, стейт-свитчер, переключающая эти состояния.
Реализация сервиса управления меню
Как я уже сказал, вся система может быть описана 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 : UIServiceState;
void GoToScreenOfType<T>(params object[] parameters) where T : UIServiceState;
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 : UIServiceState
{
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());
}
Заключение
В итоге получается довольно практичная, на мой взгляд, и легко расширяемая система управления пользовательским интерфейсом.
Спасибо за внимание. Буду рад замечаниям и предложениям, способным улучшить описаный в статье способ.
Автор: isnotaname