Архитектура игры на Unity без Zenject. Часть 1

eb725809a843375d46a211b3116d2da8.png

2c8abc1257154e783575300b258f691e.jpgАвтор статьи: Игорь Гулькин

Senior Unity Developer

Всем привет!

Меня зовут Игорь Гулькин, и я Unity разработчик. За свои 5 лет накопилось много опыта, поэтому в этой статье хотел бы поделиться принципами и подходами, с помощью которых можно реализовать архитектуру игры просто и гибко без фреймворка. Цель доклада, дать не просто готовое решение, а показать ход мыслей, как она выстраивается.  Ну что ж поехали :)

Пример

Давайте предположим, что мы делаем игру, где управляем кубиком с помощью клавиатуры. На сцене есть GameObject«ы:  

  1. Player — кубик, которым игрок управляет.

  2. KeyboardInput — пользовательский ввод с клавиатуры.

  3. MoveController — соединяет пользовательский ввод и вызывает у кубика Player.Move().

Вот начальные скрипты этих классов:

Скрипт кубика:

public sealed class Player : MonoBehaviour
    {
        [SerializeField]
        private float speed = 2.0f;
    
        public void Move(Vector3 direction)
        {
            this.transform.position += direction * this.speed * Time.deltaTime;
        }
    }

Скрипт пользовательского ввода:

public sealed class KeyboardInput : MonoBehaviour
    {
        public event Action OnMove;

        private void Update()
        {
            this.HandleKeyboard();
        }

        private void HandleKeyboard()
        {
            if (Input.GetKey(KeyCode.UpArrow))
            {
                this.Move(Vector3.forward);
            }
            else if (Input.GetKey(KeyCode.DownArrow))
            {
                this.Move(Vector3.back);
            }
            else if (Input.GetKey(KeyCode.LeftArrow))
            {
                this.Move(Vector3.left);
            }
            else if (Input.GetKey(KeyCode.RightArrow))
            {
                this.Move(Vector3.right);
            }
        }

        private void Move(Vector3 direction)
        {
            this.OnMove?.Invoke(direction);
        }
    }

Скрипт контроллера перемещения:

public sealed class MoveController : MonoBehaviour
    {
        [SerializeField]
        private KeyboardInput input;

        [SerializeField]
        private Player player;

        private void OnEnable()
        {
            this.input.OnMove += this.OnMove;
        }

        private void OnDisable()
        {
            this.input.OnMove -= this.OnMove;
        }

        private void OnMove(Vector3 direction)
        {
            this.player.Move(direction);
        }
    }

Игра работает, но есть несколько архитектурных недостатков:

  1. Нет точки входа в игру и соответственно завершения.

  2. Все зависимости на классы проставляются вручную через инспектор.

  3. Вся игровая логика привязана к монобехам (MonoBehaviour).

  4. Нет порядка инициализации игры.

Давайте улучшать нашу архитектуру по порядку.

Состояние игры

Все мы знаем, что игра — это процесс у которого есть состояния. Есть состояние загрузки игры, старта, паузы и завершения. Практически во всех играх необходимо сделать так, чтобы этим состоянием можно было управлять. Поэтому будет здорово, если KeyboardInput и MoveController будут включаться по событию старта игры, а не при запуске PlayMode в Unity.

Тогда дизайн класса KeyboardInput будет выглядит так:

public sealed class KeyboardInput : MonoBehaviour, 
        IStartGameListener,
        IFinishGameListener
    {
        public event Action OnMove;

        private bool isActive;

        void IStartGameListener.OnStartGame()
        {
            this.isActive = true;
        }

        void IFinishGameListener.OnFinishGame()
        {
            this.isActive = false;
        }
        
        private void Update()
        {
            if (this.isActive)
            {
                this.HandleKeyboard();
            }
        }

        //TODO: Rest code…
    }

А класс MoveController будет выглядеть так:

public sealed class MoveController : MonoBehaviour, 
        IStartGameListener,
        IFinishGameListener
    {
        [SerializeField]
        private KeyboardInput input;

        [SerializeField]
        private Player player;

        void IStartGameListener.OnStartGame()
        {
            this.input.OnMove += this.OnMove;
        }

        void IFinishGameListener.OnFinishGame()
        {
            this.input.OnMove -= this.OnMove;
        }

        private void OnMove(Vector3 direction)
        {
            this.player.Move(direction);
        }
    }

Здесь мы видим, что KeyboardInput и MoveController реализуют интерфейсы IStartGameListener и IFinishGameListener. Через эти интерфейсы, компоненты системы получают сигналы об изменении состояния игры. Сюда сразу же можно прикрутить еще два интерфейса: IPauseGameListener, IResumeGameListener. Они указывают, когда игра переходит в состояние паузы и наоборот. Ниже приложил код всех 4-х интерфейсов:

public interface IStartGameListener
    {
        void OnStartGame();
    }

    public interface IPauseGameListener
    {
        void OnPauseGame();
    }

    public interface IResumeGameListener
    {
        void OnResumeGame();
    }

    public interface IFinishGameListener
    {
        void OnFinishGame();
    }

Таким образом, используя принцип Interface Segregation компоненты будут обрабатывать только те состояния игры, которые они реализуют

Теперь кто-то должен сообщать интерфейсам об изменении состояния игры. Тут мы можем обратиться к паттерну Наблюдатель и реализовать класс-приемник, который будет получать сигналы об изменении фазы игры. Структура приемника будет следующей:

public sealed class GameObservable : MonoBehaviour
    {
        private readonly List listeners = new();

        [ContextMenu("Start Game")]
        public void StartGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IStartGameListener startListener)
                {
                    startListener.OnStartGame();
                }
            }
        }

        [ContextMenu("Pause Game")]
        public void PauseGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IPauseGameListener pauseListener)
                {
                    pauseListener.OnPauseGame();
                }
            }
        }

        [ContextMenu("Resume Game")]
        public void ResumeGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IResumeGameListener resumeListener)
                {
                    resumeListener.OnResumeGame();
                }
            }
        }

        [ContextMenu("Finish Game")]
        public void FinishGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IFinishGameListener finishListener)
                {
                    finishListener.OnFinishGame();
                }
            }
        }

        public void AddListener(object listener)
        {
            this.listeners.Add(listener);
        }

        public void RemoveListener(object listener)
        {
            this.listeners.Remove(listener);
        }
    }

Отлично! Давайте теперь добавим скрипт GameObservable на сцену:

Если нажать на «три точки» рядом с этим скриптом, то можем увидеть, что у этого скрипта можно вызывать методы запуска, паузы и завершения игры.

Нажав на волшебную кнопочку «Play» в Unity, мы видим, что перемещение кубика по нажатию клавиатуры не работает. Почему, спросите вы? Да потому что компоненты KeyboardInput и MoveController не подключены к монобеху GameObservable в качестве наблюдателей.

Поэтому нам нужен класс, который зарегистрирует KeyboardInput и MoveController в приемник GameObservable. Назовем этот класс GameObservableInstaller.

public sealed class GameObservableInstaller : MonoBehaviour
    {
        [SerializeField]
        private GameObservable gameObservable;
            
        [SerializeField]
        private MonoBehaviour[] gameListeners;

        private void Awake()
        {
            foreach (var listener in this.gameListeners)
            {
                this.gameObservable.AddListener(listener);
            }
        }
    }

Тут все очень просто: инсталлер содержит в себе ссылку на приемник и массив с другими монобехами, которые реализуют интерфейсы состояний игры. В методе Awake() регистрируем все лисенеры в приемник.

Затем добавляю скрипт GameObservableInstaller на сцену и подключаю ему лисенеры:  

Теперь нужно проверить, что все работает!

  1. Запускаю PlayMode в Unity.

  2. Вызываю в контекстном меню приемника GameObservable метод StartGame.

  3. Нажимаю на клавиатуру и вижу, что «кубик поехал».

  4. Вуаля, все работает!

Дополнительным бонусом, можем проверить, что если вызвать метод GameObservable.FinishGame(), то KeyboardInput и MoveController перестанут работать.

Все хорошо, но есть пара нюансов:

  • Нет возможности узнать текущее состояние игры.

  • Можно вызывать события игры в любом порядке (типа «пауза» после «окончания» и т.д.).

Давайте доработаем наш приемник:

public enum GameState
    {
        OFF = 0,
        PLAY = 1,
        PAUSE = 2,
        FINISH = 3,
    }

public sealed class GameMachine : MonoBehaviour
    {
        public GameState GameState
        {
            get { return this.gameState; }
        }

        private readonly List listeners = new();

        private GameState gameState = GameState.OFF;
        
        [ContextMenu("Start Game")]
        public void StartGame()
        {
            if (this.gameState != GameState.OFF)
            {
                Debug.LogWarning($"You can start game only from {GameState.OFF} state!");
                return;
            }

            this.gameState = GameState.PLAY;

            foreach (var listener in this.listeners)
            {
                if (listener is IStartGameListener startListener)
                {
                    startListener.OnStartGame();
                }
            }
        }

        [ContextMenu("Pause Game")]
        public void PauseGame()
        {
            if (this.gameState != GameState.PLAY)
            {
                Debug.LogWarning($"You can pause game only from {GameState.PLAY} state!");
                return;
            }

            this.gameState = GameState.PAUSE;

            foreach (var listener in this.listeners)
            {
                if (listener is IPauseGameListener pauseListener)
                {
                    pauseListener.OnPauseGame();
                }
            }
        }

        [ContextMenu("Resume Game")]
        public void ResumeGame()
        {
            if (this.gameState != GameState.PAUSE)
            {
                Debug.LogWarning($"You can resume game only from {GameState.PAUSE} state!");
                return;
            }

            this.gameState = GameState.PLAY;

            foreach (var listener in this.listeners)
            {
                if (listener is IResumeGameListener resumeListener)
                {
                    resumeListener.OnResumeGame();
                }
            }
        }

        [ContextMenu("Finish Game")]
        public void FinishGame()
        {
            if (this.gameState != GameState.PLAY)
            {
                Debug.LogWarning($"You can finish game only from {GameState.PLAY} state!");
                return;
            }

            this.gameState = GameState.FINISH;

            foreach (var listener in this.listeners)
            {
                if (listener is IFinishGameListener finishListener)
                {
                    finishListener.OnFinishGame();
                }
            }
        }

        public void AddListener(object listener)
        {
            this.listeners.Add(listener);
        }

        public void RemoveListener(object listener)
        {
            this.listeners.Remove(listener);
        }
    }

Первым делом, думаю, вы заметили, что добавился enum GameState в котором, указано перечисление возможных состояний игры.

Во-вторых, наш замечательный скрипт GameObservable переименовался в GameMachine. Это связано с тем, что наш текущий класс занимается уже не рассылкой событий, а переключает состояние игры в целом.

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

На этом первая часть статьи закончилась, продолжение следует :)

В завершение приглашаю вас на бесплатный урок, где изучим паттерн Model-View-Adapter в упрощенном варианте без пользовательского ввода на примере виджета монет игрока.

© Habrahabr.ru