Unity3D: архитектура игры, ScriptableObjects, синглтоны

Сегодня речь пойдет о том, как хранить, получать и передавать данные внутри игры. О замечательной вещи под названием ScriptableObject, и почему она замечательна. Немного затронем пользу от синглтонов при организации сцен и переходов между ними.

Данная статья описывает частичку долгого и мучительного пути разработки игры, различные примененные в процессе подходы. Скорее всего, здесь будет много полезной информации для новичков и ничего нового для «ветеранов».

Связи между скриптами и объектами


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

Самый простой способ — указать ссылку на класс напрямую:

public class MyScript : MonoBehaviour
{
	public OtherScript otherScript;
}

А затем — вручную привязать скрипт через инспектор.

У этого подхода как минимум один существенный недостаток — когда количество скриптов переваливает за несколько десятков, и каждый из них требует две-три ссылки на друг друга, игра быстро превращается в паутину. Одного взгляда на неё достаточно, чтобы вызвать головную боль.

Гораздо лучше (на мой взгляд) организовать систему сообщений и подписок, внутри которой наши объекты будут получать нужную им информацию — и только её! — не требуя при этом полудюжины ссылок друг на друга.

Однако, прощупав тему, я выяснил, что готовые решения в Unity ругают все, кому не лень. Писать с нуля подобную систему для себя мне показалось задачей нетривиальной, а потому я иду искать более простые решения.

ScriptableObject


Знать о ScriptableObject надо, по сути, две вещи:

  • Они — часть реализованного внутри Unity функционала, как MonoBehaviour.
  • В отличие от MonoBehaviour, они не привязаны к объектам сцены, а существуют в виде отдельных ассетов и способны хранить и переносить данные между игровыми сессиями.

Я сразу полюбил их горячей любовью. Они, в каком-то роде, стали моей панацеей от любой проблемы:

  • Нужно хранить настройки игры? ScriptableObject!
  • Создать инвентарь? ScriptableObject!
  • Написать ИИ? ScriptableObject!
  • Записать информацию о персонаже, враге, предмете? ScriptableObject никогда не подведет!

Недолго думая, я создал несколько классов типа ScriptableObject, а потом — и хранилище для них:

public class Database: ScriptableObject
{
	public PlayerData playerData;
	public GameSettings gameSettings;
	public SpellController spellController;
}

Каждый из которых хранит в себе всю полезную информацию и, возможно, ссылки на другие объекты. Каждый из них достаточно один раз привязать через инспектор — больше они никуда не денутся.

Теперь мне не нужно указывать бесконечное количество ссылок между скриптами! Для каждого скрипта я могу один раз указать ссылку на моё хранилище — и он получит всю информацию оттуда.

Таким образом, вычисление скорости персонажа принимает весьма элегантный вид:

// Получаем скорость
float speed = database.playerData.speed;
// Проверяем заклинание ускорения
if (database.spellController.haste.active)
	speed = speed * database.spellController.haste.speedModifier;
// Проверяем, не ранен ли персонаж
if (database.playerData.health<database.playerData.healthThreshold)
	speed = speed * database.playerData.woundedModifier;

А если, скажем, ловушка должна срабатывать только на бегущего персонажа:

if (database.playerData.isSprinting)
	Activate();

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

Но почти сразу я сталкиваюсь с проблемой. ScriptableOnject'ы не умеют хранить в себе ссылки на объекты сцены напрямую. Иными словами, я не могу создать ссылку на игрока, привязать её через инспектор и забыть про вопрос координат игрока навсегда.

И если подумать, это имеет смысл! Ассеты существуют вне сцены и могут быть доступны в любой из сцен. А что произойдет, если оставить внутри ассета ссылку на объект, находящийся в другой сцене?

Ничего хорошего.

Долгое время у меня работал костыль: создается публичная ссылка в хранилище, а затем каждый объект, ссылку на который нужно запомнить, эту ссылку заполнял:

public class PlayerController : MonoBehaviour {
	void Awake() {
		database.playerData.player = this.gameObject;
	}
}

Таким образом, независимо от сцены, моё хранилище первым делом получает ссылку на игрока и запоминает её. Теперь любой, скажем, враг не должен хранить в себе ссылку на игрока, не должен искать его через FindWithTag() (что довольно ресурсоёмкий процесс). Всё, что он делает — обращается к хранилищу:

public Database database;
Vector3 destination;
void Update () {
	destination = database.playerData.player.transform.position;
}

Казалось бы: система идеальна! Но нет. У нас остаётся 2 проблемы.

  1. Мне все ещё приходится для каждого скрипта вручную указывать ссылку на хранилище.
  2. Неудобно назначать ссылки на объекты сцены внутри ScriptableObject.

О втором поподробней. Представим, что у игрока есть заклинание огонька. Игрок его кастует, и игра говорит хранилищу: огонек скастован!

database.spellController.light.CastSpell();

И это порождает ряд реакций:

  • Создается новый (или активируется старый) gameobject-огонек в точке курсора.
  • Запускается GUI-модуль, говорящий нам, мол, огонек активен.
  • Враги получают, скажем, временный бонус к обнаружению игрока.

Как всё это сделать?

Можно для каждого объекта, заинтересованного в огоньке, прямо в Update() и написать, мол, так и так, каждый фрейм следи за огоньком (if (database.spellController.light.isActive)), а когда зажжется — реагируй! И плевать, что 90% времени эта проверка будет работать вхолостую. На нескольких сотнях объектов.

Или организовать все это в виде готовеньких ссылок. Получается, простенькая функция CastSpell() должна иметь доступ к ссылкам и на игрока, и на огонек, и на список врагов. И это в лучшем случае. Многовато ссылок, а?

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

Singleton


Вот тут в игру вступает синглтон. По сути, это объект, который существует (и может существовать) только в единственном экземпляре.

public class GameController : MonoBehaviour {
	public static GameController Instance;

	// Ссылки на всё, что нам может быть интересно
	public Database database;
	public GameObject player;
	public GameObject GUI;
	public List<Enemy> enemies;
	public List<Spell> spells;

	void Awake () {
		if (Instance == null) {
			DontDestroyOnLoad (gameObject);
			Instance = this;
		}
		else if (Instance != this) {
			Destroy (gameObject);
		}
	}
}

Я привязываю его к пустому объекту сцены. Назовем его GameController.

Таким образом, у меня в сцене есть объект, хранящий в себе всю информацию об игре. Более того — он может перемещаться между сценами, уничтожать своих двойников (если на новой сцене уже есть другой GameController), переносить данные между сценами, а при желании — реализовать сохранение/загрузку игры.

Из всех уже написанных скриптов можно удалить ссылку на хранилище данных. Ведь теперь мне не нужно её настраивать вручную. Из хранилища удаляются все ссылки на объекты сцены и переносятся в наш GameController (они все равно нам скорее всего понадобятся для сохранения состояния сцены при выходе из игры). А дальше я заливаю в него всю необходимую информацию удобным мне способом. Например, в Awake() игрока и врагов (и важных объектов сцены) прописывается добавление в GameController ссылки на самих себя. Так как теперь я работаю с Monobehaviour, ссылки на объекты сцены в него весьма органично вписываются.

Что у нас получается?

Любой объект может получить любую информацию об игре, которая ему нужна:

if (GameController.Instance.database.playerData.isSprinting)
	ActivateTrap();

При этом совершенно не нужно настраивать ссылки между объектами, все хранится в нашем GameController.

Теперь не будет ничего сложного в сохранении состояния сцены. Ведь у нас уже есть вся необходимая информация: враги, предметы, положение игрока, хранилище данных. Достаточно выбрать ту информацию о сцене, которую нужно сохранить, и записать её в файл с помощью FileStream при выходе из сцены.

Опасности


Если вы дочитали до этого места, мне следует вас предостеречь об опасностях такого подхода.

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

Если у нас есть сохраненная переменная playerSpeed, и нам нужно, чтобы игрок двигался с разной скоростью, не следует менять playerSpeed в хранилище, следует получать её, сохранять во временную переменную и уже на неё накладывать модификаторы скорости.

Второй момент — если любой объект имеет доступ к чему угодно — это большая власть. И большая ответственность. И к ней нужно подходить с осторожностью, чтобы какой-нибудь скрипт гриба невзначай не сломал напрочь весь ваш ИИ врагов. Грамотно настроенная инкапсуляция понизит риски, но не избавит вас от них.

Также не стоит забывать о том, что синглтоны — существа нежные. Не стоит ими злоупотреблять.

На сегодня — всё.

Многое было почерпнуто из официальных туториалов по Unity, что-то — из неофициальных. До чего-то мне пришлось доходить самому. А значит, у вышеизложенных подходов могут быть свои опасности и недостатки, которые я упустил.

А потому — обсуждение приветствуется!
Поделиться публикацией
Комментарии 15
    +2
    По сути у вас получился Service Locator. Когда то пользовался подобным, но несколько извращенным подходом: вместо длинного dataBase писал A — application, данные сетал в статические поля и рефлексией чистил все при выходе из сцены. Получалось A.Player

    И все таки есть более предпочтительные решения:

    1) использование DI. Наиболее популярные github.com/modesttree/Zenject или github.com/intentor/adic. Но я бы например не стал ничего инжектить внутрь объектов которых несколько десятков на сцене, не проверив как это отразиться на перфомансе.
    2) Строгая архитектура по типу «сверху-вниз». Пример — существуют GameController'ы (или Manager'ы, называйте как хочется) которые знают о игровых персонажах, и реагируют на события порождаемые ими. Каждый контроллер на старте регистрируется в службе контроллеров — это позволяет контроллерам получать доступ друг к другу через что нибудь типа Controller < TController .>

    3) Сильно уменьшить связность могут помочь глобальные события. Одно из самых простейших решений которое я видел — unity3d.ru/distribution/viewtopic.php?f=13&t=24933 Я лично использую свой велосипед с блекджеком и визуализацией

    Подходы можно комбинировать, у каждого есть свои плюсы и минусы. Целесообразность использования варьируется от проекта, его размера (пожалуй в первую очередь), числа программистов, факта использования плейтестов и просто личных предпочтений
      0
      Спасибо за ссылки, изучим!
      0
      Наткнулся на статью случайно, но она решает давно свербившую у меня проблему.
      Спасибо!
        0

        Про огонек вопрос легко решается с помощью делегатов. Вполне в духе ООП, и работает исключительно быстро. В момент когда факел вспыхнет, все подписавшиеся на это событие узнают об этом, а там уже можно и корутину запустить, например проверять на видимость игрока мобом 10 раз в секунду на половиннм расстоянии видимости и 3 раза на максимальном. Экономия по сравнению с Update просто гигантская. Главное не забыть отписываться-подписываться при (де)активации

          0

          На дворе шёл 2018-ый год, в юнити продолжали обмазываться синглтонами.


          Давайте не будем описываемое называть архитектурой?


          1. Ничего "элегантного" в вычислении характеристик персонажа я не вижу. Один класс яростно юзает во все дыры публичные поля структуры, лежащей в публичном поле, лежащей в публичном поле, лежащей в статической переменной. Оооочень элегантно.


          2. Awake. Awake'ов много, никакой уверенности что нужный уже успел сработать на момент когда мы полезли в database, нет. Или наоборот, что игрок еще существует когда мы полезли в database


          3. уничтожать своих двойников

            А, ну да, я забыл что в юнити и синглтон сделать по-человечески не могут. Сцен-то много, а "синглтоны" в каждую распиханы.



          Как это всё потом тестировать, как наладить нормальную процедуру шатдауна сцены, с каких вообще пор класть ВСЁ состояние приложения в статическую переменную стало хорошо — адепты синглтонов скромно умалчивают.

            0
            1. согласен
            2. Это же элементарно, Ватсон! записываем по Awake, считываем в Start \o/
            3. Наверное потому что каждая сцена в Unity это скорее. независимое приложение чем часть целого. Динамически подгружать сцены без выгрузки предыдущей только недавно научились. А. уничтожать копию — так в каждой сцене свой объект исключительно для удобства отладки.


            4. как как, как обычно, OnDisable и OnExit

            Ваша неприязнь к синглтонам скорее говорит о неумении их готовить.

              –1
              1. Вхуж, одним эвентом которым можно безопасно пользоваться в нашей игре стало меньше.
              2. Или синглтон один в рамках приложения или не один и тогда это не синглтон. Не надо вот юлить и оправдывать это удобством. Просто юнити не даёт места где можно было бы заинитить всякое до загрузки сцены, хранить между сценами и уничтожить после выгрузки последней сцены, поэтому приходится городить костыли.
              3. Угу. И молиться что синглтон будет уничтожен последним (конечно нет, юнити ничего не обещает про порядок обработки сущностей)
              4. Моя неприязнь говорит о том что я видел более лучшие архитектурные решения.
                0
                1. Не понятно что вы хотели сказать.
                2. Кто вам мешает сделать сцену-контейнер в которую будут подгружаться все остальные? Пусть в ней все и хранится "между сценами". Также никто вам не мешает удалить все экземпляры синглтона со всех сцен кроме той что первой загрузится.
                3. OnExit() ЕМНИП вызывается последним
                4. А еще лучше свой движок написать с казино и танцовщицами. Видели — опишите
                  0
                  Моя неприязнь говорит о том что я видел более лучшие архитектурные решения.

                  Не поделитесь знаниями?
                  с каких вообще пор класть ВСЁ состояние приложения в статическую переменную стало хорошо

                  К сожалению, я не смог найти ничего более красивого для хранения и сохранения информации о сценах (когда на них десятки предметов и объектов, состояния которых надо помнить). Если в юнити существуют встроенные решения — мне о них неизвестно.
                  Ничего «элегантного» в вычислении характеристик персонажа я не вижу. Один класс яростно юзает во все дыры публичные поля структуры, лежащей в публичном поле, лежащей в публичном поле, лежащей в статической переменной. Оооочень элегантно.

                  Если какую-либо информацию использует только один скрипт — не спорю, лучше прописывать её в этом скрипте и не заморачиваться. Но если таких скриптов МНОГО — то приходится либо пилить систему сообщений, либо настраивать паутину ссылок, либо юзать публичные поля публичных полей публичных полей.

                  К слову, не поясните, в чем природа вашего отторжения сиглтонов и статических переменных?
              0
              Сам пришел примерно к такой же связке GameController-ScriptableObject.

              Представим, что у игрока есть заклинание огонька. Игрок его кастует, и игра говорит хранилищу: огонек скастован!

              А не лучше ли здесь просто через event реализовать?

              Написать ИИ? ScriptableObject!

              Можно пример привести?
                0
                А не лучше ли здесь просто через event реализовать?

                Лучше. Но как я понимаю, система ивентов несколько хромает в плане перформанса. Поэтому я использовал собственное решение.

                Можно пример привести?

                unity3d.com/learn/tutorials/topics/navigation/finite-state-ai-delegate-pattern

                Если кратко — пишем машину состояний, где каждое действие машины хранится в виде отдельного ScriptableObject'a. И каждый переход между состояниями.

                А потом создаем EnemyStateController, который управляет своими состояниями. Он подаёт информацию о себе в ScriptableObject'ы, они занимаются логикой и возвращают изменения обратно контроллеру.

                Таким образом, можно комбинировать различные действия для получения различных состояний, править действия состояний отдельно друг от друга, создавать разные экземпляры действий для разных типов врагов…
                Основные преимущества — модульность и удобство. Можно с написанной логикой работать прямо в инспекторе, создавая состояния и раскидывая по ним действия.
                Но и тут есть свои проблемы и особенности реализации.
                  0

                  Плохо понимаете. Система встроенных ивентов довольно медленная да, делегаты намного быстрее. Тут даже статья есть на тему: https://m.habr.com/post/353780/
                  По сути, я так понимаю, это тот же прямой вызов перебором всех подписавшихся.

                0
                Довольно интересная система, как раз использую очень похожую.

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

                public class ActionProperty<T>
                {
                    protected T mValue;
                    public event Action<T> Changed;
                
                    public virtual T Value
                    {
                        get { return mValue; }
                        set
                        {
                            if (mValue == null || !mValue.Equals(value))
                            {
                                mValue = value;
                                if (Changed != null)
                                    Changed(mValue);
                            }
                
                        }
                    }
                }
                
                public class GameController : MonoBehaviour
                {
                	public ActionProperty<MyType> MyParam;
                        ...
                }
                


                Соответственно, на объекте это будет выглядеть примерно так:
                public class MyClass: MonoBehaviour
                {
                    private void Start()
                    {
                        GameController.Instance.MyParam.Changed += OnMyParamChanged;
                    }
                
                    public void OnMyParamChanged(MyType value)
                    {
                        //Здесь происходит реакция на изменение значения параметра
                    }
                
                    private void OnDestroy()
                    {
                        GameController.Instance.MyParam.Changed -= OnMyParamChanged;
                    }
                }
                
                  0
                  Мне всегда казалось что ScriptableObject чисто для удобства работы геймдизайнеров. Разработчик написал GUI прямо в редакторе, а они сидят и клепают, например, предметы для игры, или мобов, как в конструкторе, настраивают характеристики, иконки, меши и т.д.
                  Сам использовал только раз для хранения данных об объектах из которых строился уровень, по определенному ключу (который был в сериализованном файле с данными уровня) искался объект и инстанцировался в нужной точке с опеределеным мешем, скином и другими характеристиками.
                  Да и с приходом официального ECS в юнити, нужно перестраивать немного мозги в сторону другой парадигмы.
                    0
                    Спасибо за статью, но всё же оставлю пару комментариев.

                    1. Используйте ECS.
                    2. Если есть возможность, не используйте MonoBehaviour Singleton в Unity. MonoBehaviour совсем плох для этого паттерна (а иногда и антипаттерна).
                    3. Код
                    if (GameController.Instance.database.playerData.isSprinting)
                    	ActivateTrap();
                    содержит в себе множество проблем. Не пишите так.
                    4. Ошибка в неправильно поставленой задаче:

                    Иными словами, я не могу создать ссылку на игрока

                    Зачем Вам ссылка на игрока? Вся статья про то, что Вы пытаетесь сохранить где-то ссылку, теряете её, сохраняете другим способом и т.д. Вероятнее всего, вам нужна не ссылка на игрока, а данные этого игрока и возможность вызывать методы этого игрока — ссылка на игрока для этого не обязательна.
                    5. Если нужно, чтобы данные Player можно было получить из любой точки кода и не хочется использовать какой-нибудь EventBus, то можно написать так:
                    public static Action<Player> OnPlayerInstantiated;
                    
                    void Start()
                    {
                        OnPlayerInstantiated?.Invoke(this);
                    }
                    
                    public static Action<Player, int> OnHealthChanged;
                    private int health;
                    public int Health
                    {
                        get
                        {
                            return health;
                        }
                        protected set
                        {
                            if (health != value)
                            {
                                health = value;
                                OnHealthChanged?.Invoke(this, value);
                            }
                        }
                    }
                    

                    6.
                    public List<Enemy> enemies;
                    	public List<Spell> spells;

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

                    а вот когда на переменную начинают воздействовать из разных мест — это потенциальная угроза.

                    Верно. Исключите эту возможность с помощью не публичного Setter.

                    не следует менять playerSpeed в хранилище, следует получать её, сохранять во временную

                    Просто следует сделать ей публичный Getter. Нужно писать код так, чтобы тот, кто не знает реализацию не мог использовать API неправильно.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое