[Из песочницы] 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
А если, скажем, ловушка должна срабатывать только на бегущего персонажа:
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 проблемы.
- Мне все ещё приходится для каждого скрипта вручную указывать ссылку на хранилище.
- Неудобно назначать ссылки на объекты сцены внутри 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 enemies;
public List 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, что-то — из неофициальных. До чего-то мне пришлось доходить самому. А значит, у вышеизложенных подходов могут быть свои опасности и недостатки, которые я упустил.
А потому — обсуждение приветствуется!