Создание меню для игры на Unity3D на основе State-ов

в 9:58, , рубрики: Gamedev, unity, unity3d, разработка игр

Всем доброго времени суток! Хотелось бы рассказать о том, как я реализовывал систему игрового 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 : UIServiceState;
    void GoToScreenOfType<T>(params object[] parameters) where T : UIServiceState;
    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 : 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js