В прошлой статье я показал вам, что такое ScriptableObject, теперь настало время показать основные приемы и возможности, которые можно проворачивать с ними. В этой статье мы рассмотрим основные подходы использования ScriptableObject на примере создания структуры данных инвентаря для игры.
ScriptableObject - это полноценный класс
Игровой движок Unity построен на компонентной системе, что во многом сильно отличается от стандартного ООП подхода. Поэтому если вы были первоклассным программистом на C# вне Unity, то внутри Unity, большинство приемов и техник архитектуры и организации взаимодействия не будут работать. Тоже самое касается и обратного перехода.
Основная часть Unity - это скрипты поведения объектов, класс MonoBehaviour. Они являются компонентами игровых объектов и подчиняются другим правилам взаимодействия, нежели стандартные объекты в которых легко применяются стандартные ООП подходы. Но, ScriptableObject - это исключение, это чистые данные, обернутые редактором Unity и системой хранения. И в данном случае использование любых подходов ООП становится реальным.
Рассмотрим на примере предметов инвентаря, как работают основные принципы ООП.
А подробнее вы можете почитать здесь.
Наследование
Создавать инвентарь с однотипными предметами скучно, поэтому давайте придумаем что-то интересное и разделим предметы на следующие категории: обычные предметы и коллекционируемые предметы. Попробуем описать их с помощью ООП подхода. Для этого понадобится описать общий класс данных и затем для каждой категории создать собственный с указанием родительского класса.
// Base... в названии используется специально для указания того, что этот класс является // базовым, общим для всех остальных предметов. // Здесь мы описываем общие свойства всех категорий предметов. // Если, например, потребуется добавить информацию о стоимости предмета // или об уровне качества, то это самое лучшее место public abstract class BaseItemData : ScriptableObject { // Иконка public Sprite Icon; // Имя предмета public string Title; // Описание public string Description; }
[CreateMenuItem(fileName = "newItem", menuName = "Data/Items/Simple", order = 51)] public class SimpleItemData : BaseItemData { // Простой предмет не содержит дополнительной информации, // но в будущем может содержать }
[CreateMenuItem(fileName = "newItem", menuName = "Data/Items/Collectable", order = 51] public class CollectableItemData : BaseItemData { // Коллекционируемые предметы, это те, // которые складываются в одну ячейку инвентаря // и постепенно увеличивается счетчик предметов в ячейке // Максимальное кол-во предметов в одной пачке public int MaxCollectionCount; }
И при создании класса инвентаря вам надо будет работать непосредственно с классом BaseItemData, чтобы обеспечить большую гибкость работы инвентаря. Инвентарю не важно знать конкретную информацию о каждом объекте, необходимо лишь знать что это предмет :)
Полиморфизм
Полиморфизм - это способность объекты вести по разному в зависимости от фактической реализации. Рассмотрим пример того, как предметы могут добавляться в инвентарь. Для ознакомления с подходом без полиморфизма вы можете заглянуть под кат в конце этого раздела. Для того, чтобы понимать, что такое инвентарь, мы создадим следующие классы:
// Этот класс отвечает за хранение состояния ячейки инвентаря с предметом [Serializable] public class ItemState { // Информация о хранимом предмете public BaseItemData Data; // Кол-во предметов определенного типа в ячейке public int Count; }
// Класс отвечающий за логику работы инвентаря public class InventoryController : MonoBehaviour { // Размер инвентаря по ширине public const int Width = 10; // Размер инвентаря по высоте public const int Height = 10; // Список состояний ячеек в инвентаре public ItemState[] Items; // Метод для добавления определенного кол-ва предметов в инвентарь public void AddItem(BaseItemData item, int count) { // Вызовем метод предмета, чтобы он мог использовать // свою логику добавления предмета в инвентарь. // Здесь мы передаем в метод ссылку на инвентарь, // для того, чтобы предмет мог анализировать состояние инвентаря. // Также передаем информацию о том, сколько предметов было подобрано // И функцию для реального размещения предмета в инвентаре // Подробная реализация будет описана ниже item.PutToInventory(this, count, (countToPut) => PutNewItem(item, countToPut)); } // Внутренний метод для заполнения свободной ячейки инвентаря private ItemState PutNewItem(BaseItemData item, int count) { var state = FindEmptyState(); if (state == null) { // Если не смогли получить свободную ячейку, значит инвентарь заполнен // Вы можете обрабатывать ошибки любым другим способом // Но для упрощения мы просто выведем информацию в лог Debug.Log("Inventory is full"); return null; } state.Data = item; state.Count = count; return state; } // ... }
У многих может возникнуть вопрос, зачем необходим класс ItemState? А он необходим для того, чтобы хранить информацию о состоянии конкретного предмета. Если BaseItemDate необходима для хранения описания возможностей предмета, то ItemState необходим для хранения информации о том, в каком состоянии находится описанный предмет. Это может быть кол-во предметов в пачке, или прошедшее время перезарядки использования, владелец этого предмета и т.д. При проектировании любой системы на основе ScriptableObject (а в прочем, не только при использовании ScriptableObject, но и систем в целом), стоит четко разделять границы между данными и состояниями.
Небольшое ответвление от основной статьи, о том, как отличить данные от состояния.
Достаточно задать следующие вопросы:
- Постоянны ли эти свойства? - если свойства постоянны для всех объектов, то это данные, если они могут меняться между объектами - то это - состояние
- Свойства можно менять только в редакторе? - если да, то это явно данные
- Свойства меняются со временем? - если да, то это состояние
- Свойство характеризует какую-либо цель? - если да, то это данные, если свойство реализует процесс достижение этой цели - то это состояние
Попробуйте сами определить, являются ли следующие свойства данными или состоянием
- Стоимость юнита для строительства
- Оставшееся время до следующего выстрела
- Время между выстрелами
- Необходимое время строительства юнита
- Кол-во оставшихся патрон в автомате
- Принадлежность юнита к игроку
- Стоимость юнита для строительства (данные) - задаем только в редакторе, задается для юнита, который еще только будет строится
- Оставшееся время до следующего выстрела (состояние) - свойство, отображает процесс достижения цели - выстрела
- Время между выстрелами (данные) - характеристика достижения цели - выстрела
- Необходимое время строительства юнита (данные) - аналогично со стоимостью
- Кол-во оставшихся патрон в автомате (состояние) - игрок может стрелять, уменьшая кол-во оставшихся патрон, соответственно свойство не постоянно
- Принадлежность юнита к игроку (состояние) - юнит может переходить от одного владельца к другому, или разные юниты одного типа могут принадлежать разным игрокам
Теперь нам осталось взглянуть на изменения в классах описания предметов
// ... // Добавим метод добавления предмета в базовый класс описания // Func<int, ItemState> - это описание параметра, который требует, // чтобы передавалась функция принимающая параметр типа int, // и возвращала ItemState. // В качестве параметра этого метода мы передаем только информацию // о кол-ве добавляемых предметов, // информация о том, какой фактически предмет добавляется уже содержится внутри. // Это сделано для того, чтобы предостеречь разработчика от случайно ошибки, // когда он случайно передаст информацию о другом предмете, // в таком случае будет выполнена неверная логика для размещения предмета в инвентаре. // Если же разработчик захочет добавить предмет другого типа в инвентарь, // то необходимо будет воспользоваться основным методом инвентаря - AddItem, // которая обеспечит правильную логику добавления предмета в инвентарь. // Здесь virtual означает, что данный метод может быть изменен в дочерних классах, // и дальше мы увидим как это происходит. public virtual void PutToInventory(InventoryController inventory, int count, Func<int, ItemState> putNewItem) { // Большинство предметов имеет стандартную логику поведения при добавлении в инвентарь, // поэтому просто добавляем объект в инвентарь. // Добавляем предметы, раздельно for (int i = 0; i < count; i++) { // Добавляем каждый предмет в отдельные ячейки var state = putNewItem(1); // Если мы не смогли добавить предмет в инвентарь, // то завершаем логику добавления предмета в инвентарь if (state == null) { return; } } } // ...
Теперь, когда мы будем добавлять в инвентарь пользователя любой предмет, он будет складываться в свободные ячейки инвентаря. Но изначально мы хотели создать особый тип предмета - собираемые в кучу. И это легко реализовать с таким подходом. У нас уже есть специальный класс для описания таких предметов, необходимо только переопределить логику добавления предмета в инвентарь.
// ... // Обратите внимание на override - это специальный модификатор метода, // который говорит о том, что мы хотим переопределить логику // выполнения метода в базовом классе. // Тем самым, когда мы будем добавлять предметы, // созданные на основе CollectableItemData, // они будут добавляться по логике представленной ниже, // а все остальные по логике описанной в классе BaseItemData, // если это было не определено иначе. public override void PutToInventory(InventoryController inventory, int count, Func<BaseItemData, int, ItemState> putNewItem) { // Сперва попробуем найти все незаполненные коллекции нужного типа // Вас может удивить почему это мы сравниваем типы объектов? // Как было замечено в предыдущей статье, // ссылки на один и тот же ScriptableObject могут быть разные // в зависимости от того, как они были загружены в сцену // Поэтому надежнее будет сравнивать конкретные типы и идентификаторы предметов var notFullCollections = inventory.Items.Where( state => state.Data.GetType() == GetType() && state.Data.name == Data.name && state.Count < MaxCollectionCount ).ToList(); var remainingCount = count; foreach (var state in notFullCollections) { // Сколько максимальное кол-во предметов мы можем добавить // в незаполненную ячейку инвентаря: // Это может быть либо фактически оставшееся кол-во предметов для добавления // или кол-во свободных слотов в коллекции предметов var countToPut = Math.Min(remainingCount, MaxCollectionCount - state.Count); // Добавляем предметы в коллекцию state.Count += countToPut; // Уменьшаем кол-во предметов, которые осталось распределить по ячейкам remainingCount -= countToPut; if (remainingCount <= 0) { // Завершаем логику, если все предметы были распределены return; } } // Если есть еще что распределять то, добавляем предметы, как новые в инвентарь while (remainingCount > 0) { var countToPut = Math.Min(remainingCount, MaxCollectionCount); var state = putNewItem(this, countToPut); // Если не смогли добавить предмет в инвентарь, завершаем логику добавления if (state == null) { return; } // Уменьшаем кол-во предметов, которые осталось распределить по ячейкам remainingCount -= countToPut; } } // ...
На первый взгляд может показаться, что все немного запутанно, и код выглядит слишком объемно, но это лишь из-за обильного наличия комментариев :) На самом деле логика очень простая, просто прочтите её еще раз, а если у вас все равно остались вопросы, то можете прочитать словестное описание алгоритма:
- Когда игроку выдается предмет, вызывается метод AddItem класса InventoryController
- В зависимости от типа объекта выполняется одна из логик добавления предмета в инвентарь
- Если это предмет типа CollectableItemData, то он сперва добавляется в уже существующие предметы такого же типа, а затем создаются новые, если не помещается
- Иначе предметы размещаются в инвентаре отдельно
С таким подходом можно легко менять логику добавления предметов в инвентарь, главное - фантазия разработчика. Хотите предметы, которые рассыпаются при подборе и насмехаются над игроком? Легко!
public override PutToInventory(InventoryController inventory, int count, Func<int, ItemState> putNewItem) { // Мы не вызвали метод putNewItem и предмет не будет фактически добавлен в инвентарь Debug.Log("Муха-ха-ха! Ты никогда не завладеешь предметом " + Title); }
Продолжать можно бесконечно, но для примера, этого должно хватить, чтобы понять какую мощь вы получаете при таком подходе использования ScriptableObject.
Просто сравните на сколько реализация выше, лаконичнее и гибче
public void AddItem(BaseItemData item, int count) { if (item is CollectableItemData) { // Сперва попробуем найти все незаполненные коллекции нужного типа // Вас может удивить почему это мы сравниваем типы объектов? Как было замечено в предыдущей статье, // ссылки на один и тот же ScriptableObject могут быть разные в зависимости от того, как они были загружены в сцену // Поэтому надежнее будет сравнивать типы var notFullCollections = inventory.Items.Where(state => state.Data.GetType() == GetType() && state.Count < MaxCollectionCount).ToList(); var remainingCount = count; foreach (var state in notFullCollections) { // Сколько максимальное кол-во предметов мы можем добавить в незаполненную ячейку инвентаря // Это может быть либо фактически оставшееся кол-во предметов для добавления // или кол-во свободных слотов в коллекции предметов var countToPut = Math.Min(remainingCount, MaxCollectionCount - state.Count); // Добавляем предметы в коллекцию state.Count += countToPut; // Уменьшаем кол-во предметов, которые осталось распределить по ячейкам remainingCount -= countToPut; if (remainingCount <= 0) { // Завершаем логику, если все предметы были распределены return; } } // Если есть еще что распределять то, добавляем предметы, как новые в инвентарь while (remainingCount > 0) { var countToPut = Math.Min(remainingCount, MaxCollectionCount); var state = putNewItem(this, countToPut); // Если не смогли добавить предмет в инвентарь, завершаем логику добавления if (state == null) { return; } // Уменьшаем кол-во предметов, которые осталось распределить по ячейкам remainingCount -= countToPut; } return; } // Большинство предметов имеет стандартную логику поведения при добавлении в инвентарь, поэтому просто добавляем объект в инвентарь // Добавляем предметы, раздельно for (int i = 0; i < count; i++) { // Добавляем каждый предмет в отдельные ячейки var state = putNewItem(1); // Если мы не смогли добавить предмет в инвентарь, то завершаем логику добавления предмета в инвентарь if (state == null) { return; } } }
ScriptableObject как неизменяемые данные
Как было сказано в прошлой статье, ScriptableObject используется в единственном экземпляре для многих объектов на сцене, которые их используют.
public class BaseItemData : ScriptableObject { // ... // Данные объявлены с модификатором public, что позволяет других объектам обращаться к этим данным для чтения или изменения. // Это может приводить к странным ситуациям, которые очень сложно отследить. public string Title; // ... }
Например, кто-то решил добавить новую механику и случайно переименовывать предметы в инвентаре
// ... public void Update() { var state = GetRandomSlot(); if (state != null) { // Подменяем имя у случайного предмета в инвентаре, // а на самом деле полностью подменяем данные во всей игре D: state.Data.Title = "Без имени"; } }
Проблема такого подхода, что любой, кто имеет доступ к данным о конкретном типе предмета сможет изменить его параметры. С одной стороны это может показаться отличной идеей, но это может привести к непредвиденным эффектам и последствиям. Поэтому является хорошей практикой запрещать управлять данными напрямую из кода. Либо разрешать использование только для определенного списка свойств. Этого можно добиться, созданием свойств в объекте в режиме только для чтения. Если же вам станет необходимо изменять данные для каждого предмета в отдельности, то стоит задуматься о том, чтобы вынести такие данные на уровень состояния, а не данных (смотрите выше чем отличаются данные от состояния).
public class UnitData : ScriptableObject { // Разрешаем редактирование этого поля из редактора Unity и делаем возможноть сохранения этих данных [SerializeField] // C помощью модификатора доступа private разрешаем доступ к этому полю только внутри этого класса private string _title; // Создаем свойство, доступное для всех остальных только для чтения // Для этого используется свойство, только с методом get public string Title { get { return _title; } } }
При таком способе организации данных, вы сможете быть уверенными, что данные не будут изменены во время игры внутри другого объекта по ошибке.
Динамическое создание ScriptableObject
Создавать игру с большой базой статических предметов конечно круто, но еще круче создавать динамически генерируемые предметы! К сожалению я на столько древний, что в пример могу привести только Diablo и World Of Warcraft, в этих играх большинство предметов генерируется на ходу с различными модификаторами. Как же мы можем организовать такой же подход? Т.к. мы используем ScriptableObject в качестве основы описания для наших предметов, то мы можем творить все что только угодно. Unity позволяет нам во время игры создавать собственные экземпляры данных, единственное что нуобходимо помнить - нам придется вручную их удалять из памяти, когда потребность в этих данных отпадет.
Для начала рассмотрим, как это можно сделать.
// Создаем данные о новом предмете var item = ScriptableObject.CreateInstance<SimpleItemData>(); item.Title = "New random Item #" + Random.Next().ToString(); item.Description = "Some description"; // Спустя какое-то время удаляем из памяти информацию об этом предмете Object.Destroy(item);
Один из простых способов управления динамически-созданными ScriptableObject это создание специального менеджера, например DynamicScriptableObjectsManager
public class DynamicScriptableObjectsManager : MonoBehaviour { private static List<ScriptableObject> _objects = new List<ScriptableObject>(); public static T CreateInstance<T>() where T: ScriptableObject { var instance = ScriptableObject.CreateInstance<T>(); _objects.Add(instance); return instance; } private void OnDisable() { foreach (var instance in _objects) { Object.Destroy(instance); } _objects.Clear(); } }
Останется только добавить данный скрипт на сцену, и тогда все данные будут уничтожены при выгрузке сцены. Но стоит учитывать, что когда вы будете создавать систему сохранения/загрузки в игре, вам будет необходимо также сохранять информацию о сгенерированных предметах и при загруке игры также создавать информацию о динамически созданных предметах. Вы же не хотите, чтобы каждый раз при перезагрузке игры у игрока менялось снаряжении случайным образом?) Звучит как супер фича!!!
На последок
Конечно же представленная реализация не является единственной верной или неверной. Это личное видение системы инвентаря, которая на мой взгляд может представлять удобный способ расширения геймплея, как за счет добавления большего контента, так и за счет ввода новых мехник работы предметов.
Конечно же я не затрагивал такую тему, как реализация пользовательского интерфейса, данная статья больше нацелена на демонстрацию возможностей использования ScriptableObject в Unity и демонстрацию некоторых техник проектирования, которые можно применять при разработке своих игр в игровом движке Unity.
Смотрите также:
Комментарии
Func подчеркивает красным. Подскажите какую библиотеку нужно подключить?
Loya, нужно подключить пространство имен System
using namespace System;
https://docs.microsoft.com/ru-ru/dotnet/api/system.func-2?vie...
А ничего что количество аргументов в делегатах разное и оверрайднуть нельзя из-за этого?
alexprey, помогите пожалуйста, у меня много чего подчеркивает красным, несмотря на то что код я копировал
FindEmptyState()
[CreateMenuItem(fileName = "newItem", menuName = "Data/Items/Collectable", order = 51)]
PutToInventory
var notFullCollections = inventory.Items.Where( // Подчеркивает Where
[Serializable]
Почему я нигде не могу найти нормальный туториал, где покажут как экипировать предмет (например, меч) в определенную (выделенную под оружие) ячейку???
CollectableItemData.cs
[CreateMenuItem(fileName = "newItem", menuName = "Data/Items/Collectable", order = 51]
забыли закрывающую скобку
CollectableItemData.cs
[CreateMenuItem(fileName = "newItem", menuName = "Data/Items/Collectable", order = 51]