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

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

Комментарии (9)


  1. Tr0sT
    00.00.0000 00:00
    +2

    А если по ходу игру нужно инстанциировать какой-нибудь объект с монобехом, который должен реагировать на паузу игры? Как в него прокинуть GameMachine, чтобы вызвать GameMachine.AddListner(this)? Получается какая-то циклическая зависимость.


    1. StarKRE
      00.00.0000 00:00
      +1

      Привет! Просто делаешь фабрику для этого объекта и в момент спауна подключаешь к системе GameMachine.AddListner(instantiatedObject)


      1. Tr0sT
        00.00.0000 00:00

        Раз уж у GameMachine нет интерфейса, и значит мокать для тестов его не планируют, то не проще ли вместо кучи фабрик(а в фабрики ведь тоже нужно как-то этот GameMachine прокидывать) сделать этот GameMachine синглтоном, ну или получать его через какой-нибудь сервис локатор?

        Вообще, даже для текущей реализации напрашивается улучшения - если количество ивентов у GameMachine останется таким же, то логично уйти от кастов типов, и сделать List<IFinishGameListener> _finishGameListeners и т. п.

        Ну и обращение к приватным методам и переменным через this. - режет глаз. Понятно, что это вкусовщина, и можно даже найти какие-то аргументы за использование this, но так не принято. Лучше придерживаться более распространённых конвенций в обучающих статьях.


        1. StarKRE
          00.00.0000 00:00
          +1

          1. Интерфейс будет в третьей части, когда причесывать будем, синглтона не будет)

          2. Можно сделать для каждого интерфейса свой лист List<IFinishGameListener>. Почему я же так не сделал, просто эти события старта и завершения будут вызываться один раз, поэтому тут больше выигрыш в памяти чем в производительности, потому что каст дешевый

          3. this — вкусовщина. Писать его или нет — зависит от конвенций в проекте/команде


  1. DizzyJump
    00.00.0000 00:00
    +4

    Не могли бы вы в следующей статье уделить больше внияния сравнению разных видов архитектур? Ладно зенжект, допустим мы не хотим тащить di-фреймворк в проект, но и без него можно композишен рут организовать, вынести модель из монобехов оставив там только представление и события движка, сервисную модель организовать и т.д. Вы как-то сходу в карьер начали мемную архитектуру на монобехах задвигать, сделайте шаг назад и объясните почему так, в чем преимущества и ограничения этого варианта (они конечно есть как и у любого решения в программировании) по сравнению с другими подходами ну хотя бы обзорно. Опять же было бы неплохо рассказать зачем вообще нужна архитектура, кубики ж можно и без неё двигать, на какие важные вопросы она должна давать ответы, где и какие профиты даёт с точки зрения разработки, где и за счёт чего экономия/ускорение разработки происходит. Ну и т.д.


  1. redHurt69
    00.00.0000 00:00
    +3

    Статья стала бы гораздо интереснее, если бы архитектура явно решала бы какие-то задачи, а не "как двигать кубик но сделаем вид что мы архитекторы". Тогда хотя бы можно было обсуждать, хорошая она или нет и по каким причинам.
    Надеюсь, на курсах вы хотя бы объясняете pros & cons тех или иных подходов, а не также беспричинно строите воздушные замки...


  1. Strangecoder
    00.00.0000 00:00

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

    В нашем деле очень важную роль играет контекст. Если он именно такой, то в глазах новичка это все будет выглядеть излишним. Он может заставить двигаться куб в одном скрипте, зачем идти такими огородами?

    Не было контекста кроссплатформенности (что сразу лишает нас необходимости вводить какую-либо прослойку ввода), не было инстанса объектов в рантайме, сохранений/загрузки и т.д.

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

    Да и порядка инициализации не появилось, о котором заявляется в самом начале, игра стартует из контекстного меню, серьезно?

    Если в игре будет больше одной сцены, то при таком подходе получится запуститься сразу со второй? Вряд ли! Придется таскать по всем сценам инсталлер, его зависимости, игрока и все остальное.

    Извините, но, когда сеньор с 5-летним опытом выдает архитектуру на монобехах, то ценность статьи падает до нуля.


    1. StarKRE
      00.00.0000 00:00
      +1

      Привет! Спасибо большое за фидбэк)

      Я же написал, что будет продолжение :)

      В первой части я показал, что обязательно нужно сделать механизм обработки событий (на монобехах он или нет — не суть важно)

      Во второй части будем уходить от зависимостей и внедрять простой DI

      В третьей — сделаем оптимизацию архитектуры по памяти и производительности: уйдем от монобехов и повысим производительность в апдейтах. Там же прикрутим порядок инициализации

      Понимаю, что по первой части не все понятно, есть вопросы, и архитектура реально выглядит мемной ???? Но в конце статьи увидите, что из этого получиться :)

      Просто статья большая, и я не могу ее всю сразу выкатить, поэтому пока так, по кусочкам. Надеюсь, что развеял ваши сомнения)


      1. Strangecoder
        00.00.0000 00:00

        Ну, да. Первая часть довольно смешная в плане архитектуры, тогда подожду конец статьи, чтоб лучше понять замысел