unity связь между скриптами
Unity3D: архитектура игры, ScriptableObjects, синглтоны
Сегодня речь пойдет о том, как хранить, получать и передавать данные внутри игры. О замечательной вещи под названием ScriptableObject, и почему она замечательна. Немного затронем пользу от синглтонов при организации сцен и переходов между ними.
Данная статья описывает частичку долгого и мучительного пути разработки игры, различные примененные в процессе подходы. Скорее всего, здесь будет много полезной информации для новичков и ничего нового для «ветеранов».
Связи между скриптами и объектами
Первый вопрос, встающий перед начинающим разработчиком — как связать все написанные классы вместе и настроить взаимодействия между ними.
Самый простой способ — указать ссылку на класс напрямую:
А затем — вручную привязать скрипт через инспектор.
У этого подхода как минимум один существенный недостаток — когда количество скриптов переваливает за несколько десятков, и каждый из них требует две-три ссылки на друг друга, игра быстро превращается в паутину. Одного взгляда на неё достаточно, чтобы вызвать головную боль.
Гораздо лучше (на мой взгляд) организовать систему сообщений и подписок, внутри которой наши объекты будут получать нужную им информацию — и только её! — не требуя при этом полудюжины ссылок друг на друга.
Однако, прощупав тему, я выяснил, что готовые решения в Unity ругают все, кому не лень. Писать с нуля подобную систему для себя мне показалось задачей нетривиальной, а потому я иду искать более простые решения.
ScriptableObject
Знать о ScriptableObject надо, по сути, две вещи:
Каждый из которых хранит в себе всю полезную информацию и, возможно, ссылки на другие объекты. Каждый из них достаточно один раз привязать через инспектор — больше они никуда не денутся.
Теперь мне не нужно указывать бесконечное количество ссылок между скриптами! Для каждого скрипта я могу один раз указать ссылку на моё хранилище — и он получит всю информацию оттуда.
Таким образом, вычисление скорости персонажа принимает весьма элегантный вид:
А если, скажем, ловушка должна срабатывать только на бегущего персонажа:
Причем персонажу совсем не нужно знать ничего ни о заклинаниях, ни о ловушках. Он просто получает данные из хранилища. Неплохо? Неплохо.
Но почти сразу я сталкиваюсь с проблемой. ScriptableOnject’ы не умеют хранить в себе ссылки на объекты сцены напрямую. Иными словами, я не могу создать ссылку на игрока, привязать её через инспектор и забыть про вопрос координат игрока навсегда.
И если подумать, это имеет смысл! Ассеты существуют вне сцены и могут быть доступны в любой из сцен. А что произойдет, если оставить внутри ассета ссылку на объект, находящийся в другой сцене?
Долгое время у меня работал костыль: создается публичная ссылка в хранилище, а затем каждый объект, ссылку на который нужно запомнить, эту ссылку заполнял:
Таким образом, независимо от сцены, моё хранилище первым делом получает ссылку на игрока и запоминает её. Теперь любой, скажем, враг не должен хранить в себе ссылку на игрока, не должен искать его через FindWithTag() (что довольно ресурсоёмкий процесс). Всё, что он делает — обращается к хранилищу:
Казалось бы: система идеальна! Но нет. У нас остаётся 2 проблемы.
И это порождает ряд реакций:
Можно для каждого объекта, заинтересованного в огоньке, прямо в Update() и написать, мол, так и так, каждый фрейм следи за огоньком (if (database.spellController.light.isActive)), а когда зажжется — реагируй! И плевать, что 90% времени эта проверка будет работать вхолостую. На нескольких сотнях объектов.
Или организовать все это в виде готовеньких ссылок. Получается, простенькая функция CastSpell() должна иметь доступ к ссылкам и на игрока, и на огонек, и на список врагов. И это в лучшем случае. Многовато ссылок, а?
Можно, конечно, сохранять всё важное в нашем хранилище при запуске сцены, раскидывать ссылки по ассетам, которые для этого, в общем-то, и не предназначены… Но я опять нарушаю принцип единого хранилища, превращая его в паутину ссылок.
Singleton
Вот тут в игру вступает синглтон. По сути, это объект, который существует (и может существовать) только в единственном экземпляре.
Я привязываю его к пустому объекту сцены. Назовем его GameController.
Таким образом, у меня в сцене есть объект, хранящий в себе всю информацию об игре. Более того — он может перемещаться между сценами, уничтожать своих двойников (если на новой сцене уже есть другой GameController), переносить данные между сценами, а при желании — реализовать сохранение/загрузку игры.
Из всех уже написанных скриптов можно удалить ссылку на хранилище данных. Ведь теперь мне не нужно её настраивать вручную. Из хранилища удаляются все ссылки на объекты сцены и переносятся в наш GameController (они все равно нам скорее всего понадобятся для сохранения состояния сцены при выходе из игры). А дальше я заливаю в него всю необходимую информацию удобным мне способом. Например, в Awake() игрока и врагов (и важных объектов сцены) прописывается добавление в GameController ссылки на самих себя. Так как теперь я работаю с Monobehaviour, ссылки на объекты сцены в него весьма органично вписываются.
Что у нас получается?
Любой объект может получить любую информацию об игре, которая ему нужна:
При этом совершенно не нужно настраивать ссылки между объектами, все хранится в нашем GameController.
Теперь не будет ничего сложного в сохранении состояния сцены. Ведь у нас уже есть вся необходимая информация: враги, предметы, положение игрока, хранилище данных. Достаточно выбрать ту информацию о сцене, которую нужно сохранить, и записать её в файл с помощью FileStream при выходе из сцены.
Опасности
Если вы дочитали до этого места, мне следует вас предостеречь об опасностях такого подхода.
Очень нехорошая ситуация складывается, когда много скриптов ссылаются на одну переменную внутри нашего ScriptableObject. В получении значения ничего нехорошего нет, а вот когда на переменную начинают воздействовать из разных мест — это потенциальная угроза.
Если у нас есть сохраненная переменная playerSpeed, и нам нужно, чтобы игрок двигался с разной скоростью, не следует менять playerSpeed в хранилище, следует получать её, сохранять во временную переменную и уже на неё накладывать модификаторы скорости.
Второй момент — если любой объект имеет доступ к чему угодно — это большая власть. И большая ответственность. И к ней нужно подходить с осторожностью, чтобы какой-нибудь скрипт гриба невзначай не сломал напрочь весь ваш ИИ врагов. Грамотно настроенная инкапсуляция понизит риски, но не избавит вас от них.
Также не стоит забывать о том, что синглтоны — существа нежные. Не стоит ими злоупотреблять.
Многое было почерпнуто из официальных туториалов по Unity, что-то — из неофициальных. До чего-то мне пришлось доходить самому. А значит, у вышеизложенных подходов могут быть свои опасности и недостатки, которые я упустил.
Погружение в скрипты игрового движка Unity3d, ч.2
Доброго времени суток всем читателям! Не так давно мною была опубликована статья, охватывающая некоторые часто используемые скриптовые возможности движка Unity3d. В этой части я хотел бы написать о скриптовых событиях, вызываемых при различных условиях. Все, указанные в данной части справочника, функции доступны в любом скрипте, который «повешен» на игровой объект.
Скриптовые события
При разработке на Unity3d часто необходимо привязать выполнение своих функций к конкретному событию. В Unity3d таких событий довольно большое количество, в данном разделе я постараюсь описать самые используемые из них.
Данная функция вызывается каждый раз перед отображением очередного кадра. Самая используемая для расчетов игровых параметров. Но категорически не рекомендуется использовать в ней расчет физических показателей.
Данная функция вызывается каждый раз при расчете физических показателей. Все расчеты физики следует проводить именно в ней.
Данная функция вызывается после выполнения сценария функции Update().
Зоной триггера является объект, для которого установлено свойство isTrigger в значении true.
При указании данного свойства объект игнорируется физическим движком и используется лишь в качестве инициализации событий.
Все функции, связанные с соприкосновением объектов, или пересечении объектом зоны триггера принимают в качестве параметра объект класса Collider, содержащий информацию об объекте, с которым произошло взаимодействие.
Данная функция вызывается всякий раз при входе физического тела в зону триггера.
Данная функция вызывается каждый раз при выходе физического тела из зоны триггера.
Данная функция вызывается постоянно, пока физическое тело находится внутри зоны триггера.
Данная функция вызывается каждый раз при входе одного физического тела в другое физическое тело.
Данная функция вызывается каждый раз при выходе одного физического тела из другого физического тела.
Данная функция вызывается постоянно, до тех пор пока одно физическое тело находится внутри другого физического тела.
Данная функция вызывается до инициализации всех остальных скриптов. Обычно используется для установки определенных параметров и инициализации переменных.
Данная функция вызывается перед первым запуском любых Update функций, но после выполнения функции Awake().
Данная функция вызывает сброс значений на значения по умолчанию. Используется лишь в режиме редактирования.
Данная функция вызывается при входе курсора мыши на игровой объект, или на экземпляр объекта GUIElement (игровой интерфейс).
Данная функция вызывается при уходе курсора мыши с игрового объекта или экземляра объекта GUIElement.
Данная функция вызывается постоянно, пока курсор мыши находится на игровом объекте, или на элементе игрового интерфейса.
Данная функция вызывается при нажатии кнопки мыши на игровом объекте или элементе игрового интерфейса.
Данная функция вызывается при отпускании кнопки мыши, после нажатия ее на игровом объекте или элементе игрового интерфейса.
Данная функция вызывается, если игрок уведет курсор мыши с объекта или элемента игрового интерфейса, после того, как зажал кнопку мыши.
Данная функция вызывается при нахождении курсора мыши с зажатой кнопкой на игровом объекте или элементе игрового интерфейса.
Физическая связь между объектами обычно используется для создания подвижных соединений между объектами.
Вызывается при разрыве физической связи между игровыми объектами. После разрыва связи — она автоматически удаляется из игры. Получает в качестве параметра силу, вызвавшую разрыв соединения в виде числа с плавающей точкой.
Данная функция вызывается при загрузке игрового уровня (сцены). Получает в качестве параметра порядковый номер загруженной сцены, в виде целого числа.
Данная функция вызывается если объект находится в поле зрения хотя бы одной камеры. Удобно использовать для отключения не видных игроку игровых объектов в целях оптимизации. Разумеется если нет необходимости продолжать выполнение действий даже если объект скрыт.
Данная функция вызывается если объект не находится в поле зрения ни одной камеры.
Данная функция вызывается при активации (включении) объекта.
Данная функция вызывается при отключении объекта.
Данная функция вызывается при уничтожении объекта.
Данная функция вызывается перед определением списка объектов, которые будут рендериться в кадре.
Данная функция вызывается перед рендерингом сцены в кадре.
Данная функция вызывается после рендеринга сцены в кадре.
Данная функция вызывается в момент рендеринга текущего игрового объекта в сцене.
Данная функция вызывается лишь один раз для каждой камеры при первом рендеринге игрового объекта.
Данная функция используется для отрисовки элементов игрового интерфейса и вызова событий, связанных с ним.
Данная функция позволяет отправить всем игровым объектам сигнал паузы. Принимает в качестве параметра булево значение, определяющее состояние паузы.
Данная функция вызывается при получении или потере фокуса игрового окна. Получает в качестве параметра булево значение, определяющее текущее состояние.
Данная функция вызывается при выходе из игры.
В данном справочнике охвачены далеко не все скриптовые события, имеющиеся в игровом движке. Например события, связанные с сетью я оставил на одну из следующих статей, где будет охвачено описание работы с сетью.
Описания некоторых функций я брал из Script Reference, поэтому если вам оно покажется неверным — прошу написать мне об этом, я исправлю.
Спасибо что уделили время на прочтение статьи, я буду очень рад, если она кому-то помогла.
Дмитрий Амоти
Блог обо мне для меня
Методы организации взаимодействия между скриптами в Unity3D
Даже средний Unity3D проект очень быстро наполняется большим количеством разнообразных скриптов и возникает вопрос взаимодействия этих скриптов друг с другом.
Данная статья предлагает несколько различных подходов к организации таких взаимодействий от простого до продвинутого и описывает к каким проблемам может привести каждый из подходов, а так же предложит способы решения этих проблем.
Подход 1. Назначение через редактор Unity3D
Пусть у нас в проекте есть два скрипта. Первый скрип отвечает за начисление очков в игре, а второй за пользовательский интерфейс, который, отображает количество набранных очков на экране игры.
Назовем оба скрипта менеджерами: ScoresManager и HUDManager.
Каким же образом менеджеру, отвечающему за меню экрана можно получить текущее количество очков от менеджера, отвечающего за начисление очков?
Предполагается, что в иерархии объектов(Hierarchy) сцены существуют два объекта, на один из которых назначен скрипт ScoresManager, а на другой скрипт HUDManager.
Один из подходов, содержит следующий принцип:
В скрипте UIManager определяем переменную типа ScoresManager:
Но переменную ScoresManager необходимо еще инициализировать экземпляром класса. Для этого выберем в иерархии объектов объект, на который назначен скрипт HUDManager и в настройках объекта увидим переменную ScoresManager со значением None.
Далее, из окна иерархии перетаскиваем объект, содержащий скрипт ScoresManager в область, где написано None и назначаем его объявленной переменной:
После чего, у нас появляется возможность из кода HUDManager обращаться к скрипту ScoresManager, таким образом:
Все просто, но игра, не ограничивается одними набранными очками, HUD может отображать текущие жизни игрока, меню доступных действия игрока, информацию о уровне и многое другое. Игра может насчитывать в себе десятки и сотни различных скриптов, которым нужно получать информацию друг от друга.
Чтобы получить в одном скрипте данные из другого скрипта нам каждый раз придется описывать переменную в одном скрипте и назначать (перетаскивать вручную) ее с помощью редактора, что само по себе нудная работа, которую легко можно забыть сделать и потом долго искать какая из переменных не инициализирована.
Если мы захотим что-то отрефакторить, переименовать скрипт, то все старые инициализации в иерархии объектов, связанные с переименованным скриптом, сбросятся и придется их назначать снова.
В то же время, такой механизм не работает для префабов (prefab) — динамического создания объектов из шаблона. Если какому-либо префабу нужно обращаться к менеджеру, расположенному в иерархии объектов, то вы не сможете назначить самому префабу элемент из иерархии, а придется сначала создать объект из префаба и после этого программно присвоить экземпляр менеджера переменной только что созданного объекта. Не нужная работа, не нужный код, дополнительная связанность.
Следующий подход решает все эти проблемы.
Подход 2. «Синглтоны»
Применим упрощенную классификацию возможных скриптов, которые используются при создании игры. Первый тип скриптов: «скрипты-менеджеры», второй: «скрипты-игровые-объекты».
Основное отличие одних от других в том, что «скрипты-менеджеры» всегда имеют единственный экземпляр в игре, в то время как «скрипты-игровые-объекты» могут иметь количество экземпляров больше единицы.
Примеры
Как правило, в единственном экземпляре существуют скрипты, отвечающие за общую логику пользовательского интерфейса, за проигрывание музыки, за отслеживание условий завершения уровня, за управление системой заданий, за отображение спецэффектов и так далее.
В то же время, скрипты игровых объектов существуют в большом количестве экземпляров: каждая птичка из «Angry Birds» управляется экземпляром скрипта птички со своим уникальным состоянием; для любого юнита в стратегии создается экземпляр скрипта юнита, содержащий его текущее количество жизней, позицию на поле и личную цель; поведение пяти разных иконок обеспечивается различными экземплярами одних и тех же скриптов, отвечающих за это поведение.
В примере из предыдущего шага скрипты HUDManager и ScoresManager всегда существуют в единственном экземпляре. Для их взаимодействия друг с другом применим паттерн «синглтон» (Singleton, он же одиночка).
В классе ScoresManager опишем статическое свойство типа ScoresManager, в котором будет храниться единственный экземпляр менеджера очков:
Осталось инициализировать свойство Instance экземпляром класса, который создает среда Unity3D. Так как ScoresManager наследник MonoBehaviour, то он участвует в жизненном цикле всех активных скриптов в сцене и во время инициализации скрипта у него вызывается метод Awake. В этот метод мы и поместить код инициализации свойства Instance:
После чего, использовать ScoresManager из других скриптов можно следующим образом:
Теперь нет необходимости в HUDManager описывать поле типа ScoresManager и назначать его в редакторе Unity3D, любой «скрипт-менеджер» может предоставлять доступ к себе через статическое свойство Instance, которое будет инициализировать в функции Awake.
Плюсы
— нет необходимости описывать поле скрипта и назначать его через редактор Unity3D.
— можно смело рефакторить код, если что и отвалится, то компилятор даст знать.
— к другим «скриптам-менеджерам» теперь можно обращаться из префабов, через свойство Instance.
Минусы
— подход обеспечивает доступ только к «скриптам-менеджерам», существующим в единственном экземпляре.
— сильная связанность.
На последнем «минусе» остановимся подробнее.
Пусть мы разрабатываем игру, в которой есть персонажи (unit) и эти персонажи могут погибать (die).
Где-то находится участок кода, который проверяет не погиб ли наш персонаж:
Получается, что персонаж после совей смерти должен разослать всем компонентам, которые в ней заинтересованы этот печальный факт, он должен знать о существовании этих компонентов и должен знать, что они им интересуются. Не слишком ли много знаний, для маленького юнита?
Так как игра, по логике, очень связанная структура, то и события происходящие в других компонентах интересуют третьи, юнит тут ничем не особенный.
Примеры таких событий (далеко не все):
— Условие прохождение уровня зависит от количества набранных очков, набрали 1000 очков – прошли уровень (LevelConditionManager связан с ScoresManager).
— Когда набираем 500 очков, достигаем важную стадию прохождения уровня, нужно проиграть веселую мелодию и визуальный эффект (ScoresManager связан с EffectsManager и SoundsManager).
— Когда персонаж восстанавливает здоровье, нужно проиграть эффект лечения над картинкой персонажа в панели персонажа (UnitsPanel связан с EffectsManager).
— и так далее.
В результате таких связей мы приходим к картине похожей на следующую, где все про всех все знают:
Пример со смертью персонажа немного преувеличен, сообщать о смерти (или другом событии) шести разным компонентам не так часто приходится. Но варианты, когда при каком-то событии в игре, функция, в которой произошло событие, сообщает об этом 2-3 другим компонентам встречается сплошь и рядом по всему коду.
Следующий подход пытается решает эту проблему.
Подход 3. Мировой эфир (Event Aggregator)
Введем специальный компонент «EventAggregator», основная функция которого хранить список событий, происходящих в игре.
Событие в игре — это функционал, предоставляющий любому другому компоненту возможность как подписаться на себя, так и опубликовать факт совершения этого события. Реализация функционала события может быть любой на вкус разработчика, можно использовать стандартные решения языка или написать свою реализацию.
Пример простой реализации события из прошлого примера (о смерти юнита):
Добавляем это событие в «EventAggregator»:
Теперь, функция Die из предыдущего примера с восемью строчками преобразуется в функцию с одной строчкой кода. Нам нет необходимости сообщать о том, что юнит умер всем заинтересованным компонентам и знать о этих заинтересованных. Мы просто публикуем факт свершения события:
А любой компонент, которому интересно это событие, может отреагировать на него следующим образом (на примере менеджера отвечающего за количество набранных очков):
В функции Awake менеджер подписывается на событие и передает делегат, отвечающий за обработку этого события. Сам же обработчик события, принимает в качестве параметра экземпляр умершего юнита и добавляет количество очков в зависимости от типа этого юнита.
Таким же образом, все другие компоненты, кому интересно событие смерти юнита, могут подписаться на него и обработать, когда событие произойдет.
В результате, диаграмма связей между компонентами, когда каждая компонента знала друг о друге, превращается в диаграмму, когда компоненты знают только о событиях, которые происходят в игре (только о интересующих их событиях), но им все равно, от куда эти события пришли. Новая диаграмма будет выглядеть следующим образом:
Замечание
Говоря, что никакой другой код не меняется, я конечно немножко лукавлю. Может оказаться так, что систему достижений интересуют события, которые ранее просто не публиковались в игре, потому как ни одну другую систему до этого не интересовали. И в этом случае, нам нужно будет решить какие новые события добавить в игру и кто будет их публиковать. Но в идеальной игре уже все возможные события есть и эфир наполнен ими по полной.
Плюсы
— не связанность компонентов, мне достаточно просто опубликовать событие, а кого оно интересует не имеет значение.
— не связанность компонентов, я просто подписываюсь на нужные мне события.
— можно добавлять отдельные модули без изменения в существующем функционале.
Минусы
— нужно постоянно описывать новые события и добавлять их в мир.
— нарушение функциональной атомарности.
Последний минус рассмотрим более детально
Представим, что у нас есть объект «ObjectA», в котором вызывается метод «MethodA». Метод «MethodA», состоит из трех шагов и вызывает внутри себя три других метода, которые выполняют эти шаги последовательно («MethodA1», «MethodA2» и «MethodA3»). Во втором методе «MethodA2» происходит публикация какого-то события. И тут происходит следующее: все кто подписан на это событие начнут его обрабатывать, выполняя какую-то свою логику. В этой логике тоже может произойти публикация других событий, обработка которых также может привести к публикации новых событий и так далее. Дерево публикаций и реакции в отдельных случаях может очень сильно разрастись. Такие длинные цепочки крайне тяжело отлаживать.
Но самая страшная проблема, которая тут может произойти, это когда одна из веток цепочки приводит обратно в «ObjectA» и начинает обрабатывать событие путем вызова какого-то другого метода «MethodB». Получается, что метод «MethodA» у нас еще не выполнил все шаги, так как был прерван на втором шаге, и содержит сейчас в себе не валидное состояние (в шаге 1 и 2 мы изменили состояние объекта, но последнее изменение из шага 3 еще не сделали) и при этом начинается выполняться «MethodB» в этом же объекте, имея это не валидное состояние. Такие ситуации порождают ошибки, очень сложно отлавливаются, приводят к тому, что надо контролировать порядок вызова методов и публикации событий, когда по логике этого делать нет необходимости и вводят дополнительную сложность, которую хотелось бы избежать.
Решение
Решить описанную проблему не сложно, достаточно добавить функционал отложенной реакции на событие. В качестве простой реализации такого функционала мы можем завести хранилище, в которое будем складывать произошедшие события. Когда событие произошло, мы не выполняем его немедленно, а просто сохраняем где-то у себя. И в момент наступления очереди выполнения функционала какой-то компоненты в игре (в методе Update, например) мы проверяем на наличие произошедших событий и выполняем обработку, если есть такие события.
Таким образом, при выполнении метода «MethodA» не происходит его прерывание, а опубликованное событие все заинтересованные записывают себе в специальное хранилище. И только после того как к заинтересованным подписчикам дойдет очередь, они достанут из хранилища событие и обработают его. В этот момент весь «MethodA» будет завершен и «ObjectA» будет иметь валидное состояние.
Заключение
Компьютерная игра это сложная структура с большим количеством компонентов, которые тесно взаимодействуют друг с другом. Можно придумать множество механизмов организации этого взаимодействия, я же предпочитаю механизм, описанный мною, основанный на событиях и к которому я пришел эволюционным путем прохода по всевозможным граблям. Надеюсь кому-нибудь он тоже понравится и моя статья внесет ясность и будет полезной.