Архитектура игры на Unity без Zenject. Часть 1
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 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 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
Отлично! Давайте теперь добавим скрипт 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
Первым делом, думаю, вы заметили, что добавился enum GameState
в котором, указано перечисление возможных состояний игры.
Во-вторых, наш замечательный скрипт GameObservable
переименовался в GameMachine
. Это связано с тем, что наш текущий класс занимается уже не рассылкой событий, а переключает состояние игры в целом.
Таким образом, у нас получился механизм, с помощью которого мы можем управлять состоянием игры и оповещать об этом компоненты системы.
На этом первая часть статьи закончилась, продолжение следует :)
В завершение приглашаю вас на бесплатный урок, где изучим паттерн Model-View-Adapter в упрощенном варианте без пользовательского ввода на примере виджета монет игрока.