Одна из основных задач разработки игр - это хранение информации о игровом контенте. Варианты оружия, брони, различные предметы или может быть даже здания, доступные для строительства. В любом случае большинство разработчиков начинают свой путь с поиска базы данных и часто выбор падает на SQLite или загрузка данных из json файла описания. Но Unity содержит мощный встроенный инструмент для этих задач - ScriptableObject.
Что такое ScriptableObject?
ScriptableObject - это класс, который является основной частью игрового движка Unity и предоставляет возможность хранения игровых данных. В нем используются точно такие же правила сериализации данных, что при написании скриптов в MonoBehaviour. Можно подумать, что ScriptableObject ничем не будет отличаться от префабов с данными, записанными в MonoBehaviour объекте, но это не так. Важно понимать следующее: при создании нового экземпляра префаба, данные полностью копируются, позволяя переопределять для данного экземпляра некоторые значения. При использовании ScriptableObject, данные не копируются, все префабы будут использовать один и тот же ScriptableObject.
Рассмотрим простой пример хранения данных разных видов войск. Начнем с использованием MonoBehaviour и хранением всех войск как Prefab. Для этого создадим скрипт, описания юнита:
using UnityEngine; public class Unit : MonoBehaviour { #region Данные юнита public Sprite Icon; public string Title; [Multiline] public string Description; public Mesh Model; public int MoneyCost; public int FoodCost; public float Damage; public float AttackCooldown; public float MaxHealth; public float MovementSpeed; public float RotationSpeed; #endregion #region Current state public int PlayerOwnerId; public float Health; #endregion private void Start() { // Some initialization logic Health = MaxHealth; } }
Теперь, на основе этого скрипта мы можем создать нужные нам префабы, добавить этот скрипт и указать необходимые параметры. Теперь давайте представим, что мы делаем массовую стратегию, где необходимо размещать от 500 юнитов одновременно. По очень грубым расчетам, один юнит будет занимать от 600 байт данных, соответственно 500 юнитов - 306 Кб. С одной стороны в этом ничего страшного нет, но не стоит забывать, что данных о юните может быть намного больше, в данном примере было отражено лишь самые основные. В реальной ситуации их намного больше. В игре StarPlosion настройки атаки являются сложным объектом, потому что мы храним информацию о типе атаки каждого орудия на корабле: тип атаки, тип снарядов, время вращения турелей, угол обзора, приоритеты целей и многое другое.
Попробуем реализовать тот же сценарий, но с использованием ScriptableObject. Для этого нам необходимо создать новый скрипт - UnitData.cs
using UnityEngine; [CreateAssetMenu(fileName = "NewUnit", menuName = "Data/Unit", order = 51)] public class UnitData : ScriptableObject { public Sprite Icon; public string Title; [Multiline] public string Description; public int MoneyCost; public int FoodCost; public float Damage; public float AttackCooldown; public float MaxHealth; public float MovementSpeed; public float RotationSpeed; }
Мы вынесли секцию с данными юнита и унаследовали наш класс от ScriptableObject. Исправим класс Unit
using UnityEngine; public class Unit : MonoBehaviour { public UnitData Data; public int PlayerOwnerId; public float Health; }
Теперь копии юнитов будут занимать не более 32 байт, соответственно для 500 юнитов - 16 Кб, а данные будут храниться в единственном экземпляре на всю игру и занимать около 500 байт.
Теперь для того, чтобы можно было создавать данные непосредственно в редакторе Unity необходимо добавить атрибут CreateAssetMenu у класса объекта данных. Познакомимся с его параметрами, ближе:
- fileName - Имя создаваемого файла по-умолчанию
- menuName - Имя пункта меню в редакторе Unity, можно создавать вложенные, использую раделитель - /
- order - Порядок расположения в меню редактора. По-умолчанию, оно равно 0, тем самым пункт будет находится до пункта меню Folder, что не всегда удобно. Для того, чтобы определить параметр в отдельную секцию используйте значение 51. Это поможет избежать путаницы.
Теперь мы с легкостью можем добавлять новых юнитов легким движением руки.
Структура проекта и загрузка всех данных
Одна из очень важных тем - это структура проекта. Каждый в меру своего опыта использует разные структуры папок внутри проекта Unity. Грамотное планирование и используемая стратегия хранения игровых данных очень сильно сказывается на сложности дальнейшего развития вашей игры. Пока игра маленькая и простая, это играет незначительную роль. Но со временем, проект перерастает в большого монстра и без грамотной организации данных, становится трудно что-то найти. К сожалению, я не могу посоветовать идеального решения, возможно его и не существует, но поделитесь пожалуйста в комментариях, какая структура у вашего проекта.
А я расскажу основные практики, которые использую сам при создании прототипов и своих проектов.
/Prefabs - папка со всеми префабами /Units /Warrior Warrior Warrior_Texture.png Warrior_Material.png Warrior_Death.wav /Resources - данные, которые необходимо загружать на ходу /Data - здесь, хранятся все ScriptableObject /Units /Icons - иконки для динамической загрузки /Scripts - папка для всех скриптов /Core - Основные классы и скрипты, без которых нельзя жить /AsyncWorkHandler.cs - Для примера, работа с многопоточностью /EventBus.cs - Для примера, шина сообщений /Data - Описание структуры данных /UnitData.cs - Для примера, описание данных юнита /Game - Игровые механики /Units /Unit.cs /SelectableUnit.cs /UI /UnitSelectionController.cs /Scenarious - Скрипты, отвечающие за логику уровней /Campaing /Level1_Intro.cs
Resources зачем она нужна?
Папка Resources в отличии от всех других папок в структуре проекта обладает отличительным свойством. Доступ к ассетам из этой папки можно осуществить непосредственно из кода. Все данные из этой папки сохраняются при билде и могут быть использованы позже, даже если вы ни разу не использовали эти ассеты. Поэтому стоит аккуратно выбирать, какие данные вы собираетесь там хранить.
Для того, чтобы загрузить данные всех возможных юнитов непосредственно внутри игры можно воспользоваться следующим кодом:
var allUnitsData = Resources.LoadAll<UnitData>("Units");
Метод LoadAll<...>(...) позволяет загружать данные нужного типа из указанной папки, включая все вложенные под-папки. также можно загрузить и один экземпляр объекта при необходимости, используя метод Load<...>(...).
Послесловие
Надеюсь, что мне удалось вас убедить, что ScriptableObject действительно полезный инструмент и мощный инструмент при создании игр, а если нет, то тогда ждите продолжения статьи с более широким раскрытием всех возможностей ScriptableObject.
О возможностях использования ScriptableObject можно почитать в следующих статьях:
Смотрите также:
Комментарии
лёш, ты же не против если я дополню? )
скриптаблы, для понимания могут храниться на уровне ассета и на уровне сцены.
ниже про кейс со сценой.
может создаться иллюзия, что раз скриптаблы обеспечивают ссылочность, то они могут быть хорошим решением для работы внутри ваших компонентов (тем убедительнее, что юнити сами юзают их таким образом в одном их своих видео). Но это не так и использование скриптаблов таким образом это плохая практика. По мануалам юнити рекомендует (и не без оснований) использовать скриптаблы только как ассеты.
Самая большая разница в сериализации между MonoBehaviour и ScriptableObject в том как они относятся к сцене. Скриптаблы вопреки заявлению ничем не отличаются от монобех в формате юзания простого Object.Instantiate или обращения по ссылке. Более того - скриптаблы на уровне хранения это MonoBehaviour с определенным скриптом, не закрепленные за конкретным go (как бы ни было странным). Самый простой способ (пусть и чуток неверный) понять поведение скриптаблов - это представить что это компоненты, навешанные на саму сцену со всеми вытекающими. Так например, если мы сдублируем компоненты в редакторе - ссылка будет общей. Если мы попытаемся сделать перенос в другую сцену - ссылки сломаются (т.к. мы переносим GO, а скриптабл приаттачен к самой сцене).
Для случая когда нам просто нужна ссылочность есть смысл смотреть в сторону сторонних сериализаторов (привет OdinInspector)
Так же ты не упомянул несколько важных деталей про скриптейблы:
- в рамках языка это объект с ручным управлением, который нужно вычищать из памяти руками, если сорите им в рантайме. Для создания используем ScripableObject.CreateInstance<T>, для очистки всякие UnityEngine.Object.DestroyImmediate() и т.п.
- т.к. это UnityEngine.Object то для него обязательно учитывать операции Undo, помечать объект грязным при изменениях, и всё вытекающее.
И еще немного:
- скриптаблы не заменяют собой json, иногда нужно вынести конфиги за сам билд, тут в любом случае не будет никакого скриптабла.
Devion, только рад за дополнение, Спасибо)
Если мы попытаемся сделать перенос в другую сцену - ссылки сломаются (т.к. мы переносим GO, а скриптабл приаттачен к самой сцене).
хм, а вот это интересно, не пробовал никогда такой кейс.
т.к. это UnityEngine.Object то для него обязательно учитывать операции Undo, помечать объект грязным при изменениях, и всё вытекающее.
А это я так понимаю для ситуации, когда пишется кастомный редактор для них, так?
скриптаблы не заменяют собой json, иногда нужно вынести конфиги за сам билд, тут в любом случае не будет никакого скриптабла.
Все верно, но зато их можно использовать совместно с AssetBundle, конечно это не дает такую же гибкость, как и чистый json, но позволяет поставлять данные частично. Об этом я конечно хотел бы отдельно написать.
Просто на мой взгляд, ScriptableObject предоставляет гору полезных фич, причем из коробки. Да, может быть это не самое лучшее решение их использовать, но не знать об их существовании - странно.
согласен, есть кейсы которые без них вообще не делаются, не знать или избегать их определенно неправильно
А это я так понимаю для ситуации, когда пишется кастомный редактор для них, так?
Так точно, либо любое изменение в объекте на стороне редактора.
CollectableItemData.cs
[CreateMenuItem(fileName = "newItem", menuName = "Data/Items/Collectable", order = 51]