Хабрахабр

[Из песочницы] 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
}

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

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

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

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

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

database.spellController.light.CastSpell();

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

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

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

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

Получается, простенькая функция 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 (они все равно нам скорее всего понадобятся для сохранения состояния сцены при выходе из игры). Так как теперь я работаю с Monobehaviour, ссылки на объекты сцены в него весьма органично вписываются. Например, в Awake() игрока и врагов (и важных объектов сцены) прописывается добавление в GameController ссылки на самих себя.

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

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

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

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

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

Опасности

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

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

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

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

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

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

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

А потому — обсуждение приветствуется!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть