![](https://habrastorage.org/getpro/habr/upload_files/eb7/258/09a/eb725809a843375d46a211b3116d2da8.png)
![](https://habrastorage.org/getpro/habr/upload_files/2c8/abc/125/2c8abc1257154e783575300b258f691e.jpg)
Автор статьи: Игорь Гулькин
Senior Unity Developer
Всем привет!
Меня зовут Игорь Гулькин, и я Unity разработчик. За свои 5 лет накопилось много опыта, поэтому в этой статье хотел бы поделиться принципами и подходами, с помощью которых можно реализовать архитектуру игры просто и гибко без фреймворка. Цель доклада, дать не просто готовое решение, а показать ход мыслей, как она выстраивается. Ну что ж поехали :)
Пример
![](https://habrastorage.org/getpro/habr/upload_files/373/9b6/2aa/3739b62aaa6b49a7d3adf0b1bd6fea4b.png)
Давайте предположим, что мы делаем игру, где управляем кубиком с помощью клавиатуры. На сцене есть 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
на сцену:
![](https://habrastorage.org/getpro/habr/upload_files/82a/9ac/341/82a9ac341a6ad2f359dbc8a9b777ea27.png)
![](https://habrastorage.org/getpro/habr/upload_files/315/669/d5f/315669d5f0fbdd24fefce0136bd4864c.png)
Если нажать на “три точки” рядом с этим скриптом, то можем увидеть, что у этого скрипта можно вызывать методы запуска, паузы и завершения игры.
Нажав на волшебную кнопочку “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
на сцену и подключаю ему лисенеры:
![](https://habrastorage.org/getpro/habr/upload_files/c92/92d/408/c9292d40860113025d6b74d7fb225fd3.png)
Теперь нужно проверить, что все работает!
Запускаю 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
. Это связано с тем, что наш текущий класс занимается уже не рассылкой событий, а переключает состояние игры в целом.
Таким образом, у нас получился механизм, с помощью которого мы можем управлять состоянием игры и оповещать об этом компоненты системы.
![](https://habrastorage.org/getpro/habr/upload_files/a2c/9c0/6c5/a2c9c06c571ab6f5bd35372781bb034d.png)
На этом первая часть статьи закончилась. Читать вторую часть.
В завершение приглашаю вас на бесплатный урок, где изучим паттерн Model-View-Adapter в упрощенном варианте без пользовательского ввода на примере виджета монет игрока.