Всем привет. Меня зовут Гриша Дядиченко, и я технический продюсер. Почему так сложно писать про хороший код? Меня периодически спрашивают, почему я так мало пишу про архитектуру. В то же время я даже среди заказчиков встречаю мнение что “в Unity пишется только плохой код”. Чтож, давайте один раз попробуем, а точнее я попробую показать, почему это очень сложно. Разработаем вместе такую “простую вещь” как инвентарь.
![](https://habrastorage.org/getpro/habr/upload_files/4ce/661/ec9/4ce661ec9db15af3ef1bfc7ac3521286.png)
Итак, но начнём с неких общих слов. Есть такой замечательный эффект, как эффект Даннинга — Крюгера. Его суть заключается в том, что люди с низкой квалификацией всегда уверены, что они правы. А люди с высокой квалификацией наоборот же считают, что они в чём-то не до конца разбираются, могут придумать миллиард нюансов и где то, что они говорят не совсем так. И в этом плане архитектурные вопросы – это бездна. Так что давайте попробуем разработать наш идеальный инвентарь.
Что такое инвентарь?
![](https://habrastorage.org/getpro/habr/upload_files/5c0/43f/ced/5c043fced3acb7744a385edaa66fa928.png)
Давайте рассуждать пока абстрактно, но помня примеры инвентарей. По сути это хранилище предметов. Предметы бывают разные, так что давайте сразу заведём абстрактный класс BaseItem, который будет выглядеть как-то так.
using System;
[Serializable]
public class BaseItem
{
public long ID;
public string VisualName;
public string IconUrl;
}
Максимально абстрактный предмет в инвентаре по сути обладает идентификатором (для оптимизации возьмём не string, а long), путём до его иконки в инвентаре и именем. Да?
Конечно же нет. Переписываем класс, так как имени и иконки тут не должно быть. Почему? Потому что имя как сущность в игре вообще не существует, а существует у нас Alias локализации, да и иконка не относится к предмету, если мы хотим уметь играть в игру в консоли. Поэтому оставляем максимально абстрактный BaseItem.
using System;
[Serializable]
public class BaseItem
{
public long ID;
}
Хорошо. Предмет у нас есть. Теперь давайте создадим инвентарь. Представим его как просто набором предметов.
public class Inventory
{
private BaseItem[] _items;
public Inventory(int size)
{
_items = new BaseItem[size];
}
}
И вот мы создали инвентарь, теперь надо бы научиться добавлять в него предметы и вытаскивать их из него. Так как это у нас поведение, то давайте сразу без лишних прелюдий заведём интерфейс IInventoryActions.
using System.Collections.Generic;
public interface IInventoryActions
{
bool Add(int cellID, BaseItem item);
bool Remove(int cellID);
bool IsCellHasItem(int cellID);
BaseItem GetItem(int cellID);
IEnumerable<BaseItem> GetItems();
}
Для получения всех предметов используем IEnumerable так как мало ли мы захотим в инвентаре наш массив заменить на словарь или на что-то ещё. Наша реализация теперь.
using System.Collections.Generic;
public class Inventory : IInventoryActions
{
private BaseItem[] _items;
public bool Add(int cellID, BaseItem item)
{
_items[cellID] = item;
return true;
}
public bool Remove(int cellID)
{
_items[cellID] = null;
return true;
}
public BaseItem GetItem(int cellID)
{
return _items[cellID];
}
public IEnumerable<BaseItem> GetItems()
{
return _items;
}
public int GetSize()
{
return _items.Length;
}
public bool IsCellHasItem(int cellID)
{
return _items[cellID] != null;
}
public Inventory(int size)
{
_items = new BaseItem[size];
}
}
Add и Remove сразу выдают bool для методов валидации "а добавили ли мы предмет я в ячейку". Поверьте, это пригодится потом. И это не проверяется через критерий занятости ячейки. Совсем параноики конечно могут определить условие IsCellHasItem внутри этих методов. Но эти булы будут говорить не об этом. Итак, у нас есть что-то похожее на инвентарь. Наверное, чтобы удобнее его тестировать нам нужен к нему какой-то графический интерфейс. Пока забудем о том, что вы написали свой "самый удобный фреймворк для GUI внутри Unity" и напишем всё довольно просто и конкретно.
Визуал инвентаря
using System.Collections.Generic;
using UnityEngine;
public class UIInventoryWindow : MonoBehaviour
{
[SerializeField] private UIInventoryCell _cellTemplate;
[SerializeField] private RectTransform _cellsRoot;
private List<UIInventoryCell> _cells;
private IInventoryActions _inventory = new Inventory(20);
private void Awake()
{
_cells = new List<UIInventoryCell>();
for (int i = 0; i < _inventory.GetSize(); i++)
{
var cell = Instantiate(_cellTemplate, _cellsRoot);
_cells.Add(cell);
}
}
private void OnEnable()
{
int i = 0;
foreach (var baseItem in _inventory.GetItems())
{
_cells[i].Init(baseItem);
i++;
}
}
}
И ячейка выглядит как:
using System;
using UnityEngine;
using UnityEngine.EventSystems;
public class UIInventoryCell : MonoBehaviour, IPointerClickHandler
{
private BaseItem _item;
public event Action<BaseItem> OnClickCell;
public void Init(BaseItem item)
{
_item = item;
}
public void OnPointerClick(PointerEventData eventData)
{
OnClickCell?.Invoke(_item);
}
}
Так сказать первые монобехи. До этого играть можно было в консоли. Для теста мы создали инвентарь прям в окне, но вообще для него нужно какое-то хранилище. Например пользователь. Так что давайте заведём нашего пользователя. Он нам ещё пригодится.
public class User
{
private static User _Current;
public static User Current
{
get
{
if (_Current == null)
{
_Current = new User();
}
return _Current;
}
}
public Inventory Inventory;
private User()
{
Inventory = new Inventory(20);
}
}
В рпг и сингл плеерных играх пользователя удобно делать синглтоном. Потому что все остальные юзеры, кроме текущего, на мой взгляд это Actors или вроде того. А пользователь у нас всегда один. Если у нас игра без hotseat и т.п. Но я последнее время предпочитаю не писать часть с приватным конструктором, а просто иметь статик доступ через Current, чтобы в случае необходимости прокидывать мок юзера. Вообще "как написать грамотно юзера" — этого на ещё одну статью хватит. Ну и окно инвентаря теперь выглядит вот так.
using System.Collections.Generic;
using UnityEngine;
public class UIInventoryWindow : MonoBehaviour
{
[SerializeField] private UIInventoryCell _cellTemplate;
[SerializeField] private RectTransform _cellsRoot;
private List<UIInventoryCell> _cells;
private IInventoryActions _inventory;
private void Awake()
{
_inventory = User.Current.Inventory;
_cells = new List<UIInventoryCell>();
for (int i = 0; i < _inventory.GetSize(); i++)
{
var cell = Instantiate(_cellTemplate, _cellsRoot);
_cells.Add(cell);
}
}
private void OnEnable()
{
int i = 0;
foreach (var baseItem in _inventory.GetItems())
{
_cells[i].Init(baseItem);
i++;
}
}
}
Хранилище
Едем дальше. По хорошему предметам нужны иконки, которые мы будем отображать в ячейках. Заведём данные нескольких игровых предметов и сторажд с ними.
Начнём с поведения:
public interface IStorageActions<T>
{
T GetData(long id);
}
По сути мы только получаем данные из стораджа, при том отдельными объектами. Дальше напишем данные предмета.
using System;
using UnityEngine;
[Serializable]
public class ItemVisualData
{
[SerializeField] private long _id;
[SerializeField] private Sprite _icon;
[SerializeField] private string _visualName;
public long ID => _id;
public Sprite Icon => _icon;
public string VisualName => _visualName;
}
Сразу оговорюсь, что пусть и хранилище мы ща пишем ридонли по всем канонам, я считаю паранойей делать такую защиту от дурака. Но тем не менее это по сути статическое хранилище данных, которые в рантайме мы менять не должны, так что пусть будет иммутабельно в пределах базовых типов.
А теперь в само хранилище:
using System.Collections.Generic;
using UnityEngine;
public class ItemsVisualDataStorage : IStorageActions<ItemVisualData>
{
private const string DataPath = "ItemsVisualDataCollection";
private Dictionary<long, ItemVisualData> _data;
public void Load()
{
var data = Resources.Load<ItemVisualDataSOCollection>(DataPath).data;
_data = new Dictionary<long, ItemVisualData>();
foreach (var itemVisualData in data)
{
if (!_data.ContainsKey(itemVisualData.ID))
{
_data.Add(itemVisualData.ID, itemVisualData);
}
}
}
public ItemVisualData GetData(long id)
{
return _data[id];
}
public ItemsVisualDataStorage()
{
Load();
}
}
Его уже не хочется делать статик контейнером в самом себе, поэтому сделаем так:
public class Storages
{
public static IStorageActions<ItemVisualData> ItemsVisualDataStorege = new ItemsVisualDataStorage();
}
Ну и для загрузки данных для удобства пока используем SO без плясок с кастомной отрисовкой словарей в редакторе и их сериализацией, то есть без защиты от дурака. Хотя там тоже есть что написать.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu]
public class ItemVisualDataSOCollection : ScriptableObject
{
[SerializeField] private List<ItemVisualData> _data;
public List<ItemVisualData> data => _data;
}
Теперь наш SO с предметами выглядит как-то так:
![](https://habrastorage.org/getpro/habr/upload_files/a32/7f2/971/a327f29715ade51d8c15019010945cd0.png)
Добавив пока для теста в конструктор пользователя несколько предметов, мы увидим, что всё работает:
public User()
{
Inventory = new Inventory(20);
Inventory.Add(0, new BaseItem()
{
ID = 0
});
Inventory.Add(1, new BaseItem()
{
ID = 1
});
Inventory.Add(2, new BaseItem()
{
ID = 2
});
}
![](https://habrastorage.org/getpro/habr/upload_files/fe3/05f/7b6/fe305f7b61c3e112a2c3229f7822124f.png)
Небольшая ремарка. Почему инвентарь != storage. Ведь по сути инвентарь можно было бы воспринимать, как некую форму Storage. Я не очень люблю совсем общие обобщения всего подряд. Так как Storage в моём понимании рид-онли в рантайме. И так в разы проще отслеживать баги, да и править, когда сущности разделены.
Действия с инвентарём
Теперь предметы хотелось бы допустим перекладывать и удалять. Для иллюстрации концепта, удалять мы будем через клик, а перемещать через драг. И оставим пока наши тест предметы.
Для начала сделаем удаление. Чуть изменим наши классы графического интерфейса.
using System.Collections.Generic;
using UnityEngine;
public class UIInventoryWindow : MonoBehaviour
{
[SerializeField] private UIInventoryCell _cellTemplate;
[SerializeField] private RectTransform _cellsRoot;
private List<UIInventoryCell> _cells;
private IInventoryActions _inventory;
private void Awake()
{
_inventory = User.Current.Inventory;
_cells = new List<UIInventoryCell>();
for (int i = 0; i < _inventory.GetSize(); i++)
{
var cell = Instantiate(_cellTemplate, _cellsRoot);
cell.Init(i);
_cells.Add(cell);
}
}
private void OnEnable()
{
int i = 0;
foreach (var baseItem in _inventory.GetItems())
{
_cells[i].SetItem(baseItem);
i++;
}
}
}
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class UIInventoryCell : MonoBehaviour, IPointerClickHandler
{
[SerializeField] private Image _icon;
private BaseItem _item;
private int _cellID;
public void Init(int cellID)
{
_cellID = cellID;
SetItem(null);
}
public void SetItem(BaseItem item)
{
_item = item;
if (item != null)
{
_icon.enabled = true;
_icon.sprite = Storages.ItemsVisualDataStorege.GetData(_item.ID).Icon;
}
else
{
_icon.enabled = false;
}
}
public void OnPointerClick(PointerEventData eventData)
{
User.Current.Inventory.Remove(_cellID);
SetItem(null);
}
}
И удаление работает. С драгом же чуть посложнее. Давайте заведём DragContext.
using System;
public class DragContext<T>
{
private T _currentDraggable;
private IDroppable<T> _container;
public event Action<T> OnStartDrag;
public event Action<T> OnDrag;
public event Action<T> OnEndDrag;
public void StartDrag(T draggable)
{
_currentDraggable = draggable;
OnStartDrag?.Invoke(_currentDraggable);
}
public void EndDrag()
{
OnEndDrag?.Invoke(_currentDraggable);
if (_container != null)
{
_container.OnDrop(_currentDraggable);
}
_currentDraggable = default(T);
}
public void ProcessDrag()
{
OnDrag?.Invoke(_currentDraggable);
}
public void EnterContainer(IDroppable<T> container)
{
_container = container;
}
public void ExitContainer(IDroppable<T> container)
{
if (_container == container)
{
_container = null;
}
}
}
public interface IDroppable<T>
{
void OnDrop(T item);
}
Зачем нужен контекст? В идеале с Drag&Drop удобно когда есть некий контекст драг энд дропа, чтобы нельзя было, да и не надо было обрабатывать, что если у нас есть две панели с драг энд дропом и ошибки, так как мы кидаем из одной в другую. Потому что они не взаимодействуют между друг другом благодаря разным контекстам.
Теперь же напишем контейнер для нашего контекста:
using System;
using UnityEngine;
public class UIInventoryDragContainer : MonoBehaviour
{
private static DragContext<Tuple<UIInventoryCell, BaseItem>> _context;
public static DragContext<Tuple<UIInventoryCell, BaseItem>> Context => _context;
[SerializeField] private RectTransform _dragContainer;
private GameObject _visualDraggableObject;
private Camera _camera;
private void Awake()
{
_camera = Camera.main;
_context = new DragContext<Tuple<UIInventoryCell, BaseItem>>();
_context.OnStartDrag += ContextStartDrag;
_context.OnEndDrag += ContextOnEndDrag;
_context.OnDrag += ContextOnDrag;
}
private void ContextOnDrag(Tuple<UIInventoryCell, BaseItem> data)
{
if (_visualDraggableObject != null)
{
_visualDraggableObject.transform.position = Input.mousePosition;
}
}
private void ContextStartDrag(Tuple<UIInventoryCell, BaseItem> data)
{
_visualDraggableObject = Instantiate(data.Item1.gameObject, _dragContainer);
}
private void ContextOnEndDrag(Tuple<UIInventoryCell, BaseItem> data)
{
Destroy(_visualDraggableObject);
}
}
Создавать кнопку по внешнем контейнере с огромным Z-index пока мне показалось из всех способов реализации драга удобнее всего. Тут же мы заводим нужный нам контекст, который будет реализовывать наш переброс предметов внутри инвентаря. И осталось сделать реализацию в самой ячейке инвентаря.
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class UIInventoryCell : MonoBehaviour, IPointerClickHandler, IBeginDragHandler, IEndDragHandler, IDragHandler,
IPointerEnterHandler, IPointerExitHandler, IDroppable<Tuple<UIInventoryCell, BaseItem>>
{
[SerializeField] private Image _icon;
private BaseItem _item;
private int _cellID;
public void Init(int cellID)
{
_cellID = cellID;
SetItem(null);
}
public void SetItem(BaseItem item)
{
_item = item;
if (item != null)
{
_icon.enabled = true;
_icon.sprite = Storages.ItemsVisualDataStorage.GetData(_item.ID).Icon;
}
else
{
_icon.enabled = false;
}
}
public void OnPointerClick(PointerEventData eventData)
{
User.Current.Inventory.Remove(_cellID);
SetItem(null);
}
public void OnDrop(Tuple<UIInventoryCell, BaseItem> data)
{
if (!User.Current.Inventory.IsCellHasItem(_cellID))
{
if (this != data.Item1)
{
User.Current.Inventory.Remove(data.Item1._cellID);
data.Item1.SetItem(null);
}
User.Current.Inventory.Add(_cellID, data.Item2);
SetItem(data.Item2);
}
}
public void OnBeginDrag(PointerEventData eventData)
{
if(_item == null) return;
UIInventoryDragContainer.Context.StartDrag(new Tuple<UIInventoryCell, BaseItem>(this, _item));
}
public void OnDrag(PointerEventData eventData)
{
UIInventoryDragContainer.Context.ProcessDrag();
}
public void OnEndDrag(PointerEventData eventData)
{
UIInventoryDragContainer.Context.EndDrag();
}
public void OnPointerEnter(PointerEventData eventData)
{
UIInventoryDragContainer.Context.EnterContainer(this);
}
public void OnPointerExit(PointerEventData eventData)
{
UIInventoryDragContainer.Context.ExitContainer(this);
}
}
И вот спустя некоторое время Drag & Drop работает в нашем инвентаре.
Сохранение состояния
Чтож инвентарь есть, мы можем двигать в нём предметы. Теперь сохраним состояние инвентаря между запусками, как финальный штрих.
Для этого создадим динамический Storage и начнём как всегда с интерфейса:
public interface IDynamicStorageActions<T>
{
void Save(T data);
T Load();
}
Теперь реализуем наш конкретный Storage:
using Newtonsoft.Json;
using UnityEngine;
public class UserDataJsonStorage : IDynamicStorageActions<User>
{
private const string PrefsUserKey = "CURRENT_USER";
public void Save(User user)
{
PlayerPrefs.SetString(PrefsUserKey, JsonConvert.SerializeObject(user));
PlayerPrefs.Save();
}
public User Load()
{
if (PlayerPrefs.HasKey(PrefsUserKey))
{
return JsonConvert.DeserializeObject<User>(PlayerPrefs.GetString(PrefsUserKey));
}
else
{
return null;
}
}
}
И прокинем его в пользователя. Плюс создадим событие на изменение инвентаря, чтобы сохранятся скажем при каждом изменении:
using System;
using Newtonsoft.Json;
[Serializable]
public class User
{
private static User _Current;
public static User Current
{
get
{
if (_Current == null)
{
if (!Load())
{
CreateUser();
}
}
return _Current;
}
}
[JsonProperty] private Inventory _inventory;
public Inventory Inventory => _inventory;
private static bool Load()
{
var data = Storages.Dynamic.UserStorage.Load();
if (data == null)
{
return false;
}
else
{
_Current = data;
_Current._inventory.OnChange += Save;
return true;
}
}
public static void Save()
{
Storages.Dynamic.UserStorage.Save(_Current);
}
public static void CreateUser()
{
_Current = new User();
_Current._inventory = new Inventory(20);
_Current._inventory.Add(0, new BaseItem()
{
ID = 0
});
_Current._inventory.Add(1, new BaseItem()
{
ID = 1
});
_Current._inventory.Add(2, new BaseItem()
{
ID = 2
});
Save();
_Current._inventory.OnChange += Save;
}
}
public class Storages
{
public static IStorageActions<ItemVisualData> ItemsVisualDataStorage = new ItemsVisualDataStorage();
public class Dynamic
{
public static IDynamicStorageActions<User> UserStorage = new UserDataJsonStorage();
}
}
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
[Serializable]
public class Inventory : IInventoryActions
{
[JsonProperty] private BaseItem[] _items;
public event Action OnChange;
public bool Add(int cellID, BaseItem item)
{
_items[cellID] = item;
OnChange?.Invoke();
return true;
}
public bool Remove(int cellID)
{
_items[cellID] = null;
OnChange?.Invoke();
return true;
}
public BaseItem GetItem(int cellID)
{
return _items[cellID];
}
public IEnumerable<BaseItem> GetItems()
{
return _items;
}
public int GetSize()
{
return _items.Length;
}
public bool IsCellHasItem(int cellID)
{
return _items[cellID] != null;
}
public Inventory(int size)
{
_items = new BaseItem[size];
}
}
Теперь наш инвентарь умеет сохранять состояние предметов пользователя, запоминая что и куда мы положили. Мок инициализацию я пока оставил чисто для демонстрации, чтобы не делать полноценный мок. Ну вроде базовый инвентарь готов, его можно расширять и делать что-то. А теперь наконец-то поговорим про код. Весь репозиторий можно посмотреть тут.
Хороший ли это код?
Ну я считаю довольно неплохой, но тут есть множество вопросов. Всё классно, а что если у нас асинхронный сторадж (скажем сохранения по сети?) и почему не написать всё через Task сразу? А что есть у нас WebGL и Task там адекватно не работает и нужно юзать Uni Task? А что если у нас инвентарь имеет форму, а предметы размер как в некоторых RPG. Тогда для правильной синхронизации графический интерфейс должен отражать эту форму, а модель данных должна валидировать это всё (и придётся очень многое переписывать).
Так же тут заложен потенциальный риск бага, который возможно не все сразу заметили, что при подобном подходе к отрисовке и изменению состояния ячеек, есть риск того, что состояние на экране не будет соответствовать состоянию модели данных. Так как интерфейс перерисовывается по действиям, а не реактивно, как я больше люблю. То есть когда мы изменили "стейт" — перерисуй всё.
Сторадж для визуальных данных предмета. Зачем его делать отдельно? Ведь можно все данные о предмете собрать в один SO и там удобно будет отредактировать. А можно сделать один SO и распихивать данные по куче стораджей из него, при этом гуй не поменяется, но зато провайдеры данных будут разделены. И скажем иконки могут весить много и уехать на CDN, а может нам нужна кор механика, а поменять только названия и иконки.
И это только я сам к себе так могу придраться. Поэтому очень тяжело писать про хороший код, так как у каждого своё понятие хорошего кода, исходя из опыта. Есть как бы норм код, и не норм. А остальное вкусы и вопрос контекста задачи. Я считаю, что не бывает универсальных решений, если это не супер мелкая задача.
Спасибо за внимание, надеюсь эта статья была вам полезна и вы узнали из неё что-то новое. Полный проект с инвентарём можно найти тут.