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

Давайте предположим, что мы делаем игру, где управляем кубиком с помощью клавиатуры. На сцене есть GameObject’ы:
-
Player— кубик, которым игрок управляет. -
KeyboardInput— пользовательский ввод с клавиатуры. -
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); } }
Игра работает, но есть несколько архитектурных недостатков:
-
Нет точки входа в игру и соответственно завершения.
-
Все зависимости на классы проставляются вручную через инспектор.
-
Вся игровая логика привязана к монобехам (MonoBehaviour).
-
Нет порядка инициализации игры.
Давайте улучшать нашу архитектуру по порядку.
Состояние игры
Все мы знаем, что игра — это процесс у которого есть состояния. Есть состояние загрузки игры, старта, паузы и завершения. Практически во всех играх необходимо сделать так, чтобы этим состоянием можно было управлять. Поэтому будет здорово, если 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 на сцену и подключаю ему лисенеры:

Теперь нужно проверить, что все работает!
-
Запускаю PlayMode в Unity.
-
Вызываю в контекстном меню приемника
GameObservableметодStartGame. -
Нажимаю на клавиатуру и вижу, что “кубик поехал”.
-
Вуаля, все работает!
Дополнительным бонусом, можем проверить, что если вызвать метод 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/
Добавить комментарий