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

от автора

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

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<Vector3> 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<Vector3> 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<object> 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<object> 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 в упрощенном варианте без пользовательского ввода на примере виджета монет игрока.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/725068/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *