Почему тяжело писать про хороший код?
Всем привет. Меня зовут Гриша Дядиченко, и я технический продюсер. Почему так сложно писать про хороший код? Меня периодически спрашивают, почему я так мало пишу про архитектуру. В то же время я даже среди заказчиков встречаю мнение что «в Unity пишется только плохой код». Чтож, давайте один раз попробуем, а точнее я попробую показать, почему это очень сложно. Разработаем вместе такую «простую вещь» как инвентарь.
Итак, но начнём с неких общих слов. Есть такой замечательный эффект, как эффект Даннинга — Крюгера. Его суть заключается в том, что люди с низкой квалификацией всегда уверены, что они правы. А люди с высокой квалификацией наоборот же считают, что они в чём-то не до конца разбираются, могут придумать миллиард нюансов и где то, что они говорят не совсем так. И в этом плане архитектурные вопросы — это бездна. Так что давайте попробуем разработать наш идеальный инвентарь.
Что такое инвентарь?
Давайте рассуждать пока абстрактно, но помня примеры инвентарей. По сути это хранилище предметов. Предметы бывают разные, так что давайте сразу заведём абстрактный класс 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 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 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 _cells;
private IInventoryActions _inventory = new Inventory(20);
private void Awake()
{
_cells = new List();
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 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 _cells;
private IInventoryActions _inventory;
private void Awake()
{
_inventory = User.Current.Inventory;
_cells = new List();
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 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
{
private const string DataPath = "ItemsVisualDataCollection";
private Dictionary _data;
public void Load()
{
var data = Resources.Load(DataPath).data;
_data = new Dictionary();
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 ItemsVisualDataStorege = new ItemsVisualDataStorage();
}
Ну и для загрузки данных для удобства пока используем SO без плясок с кастомной отрисовкой словарей в редакторе и их сериализацией, то есть без защиты от дурака. Хотя там тоже есть что написать.
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu]
public class ItemVisualDataSOCollection : ScriptableObject
{
[SerializeField] private List _data;
public List data => _data;
}
Теперь наш SO с предметами выглядит как-то так:
Добавив пока для теста в конструктор пользователя несколько предметов, мы увидим, что всё работает:
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
});
}
Небольшая ремарка. Почему инвентарь != storage. Ведь по сути инвентарь можно было бы воспринимать, как некую форму Storage. Я не очень люблю совсем общие обобщения всего подряд. Так как Storage в моём понимании рид-онли в рантайме. И так в разы проще отслеживать баги, да и править, когда сущности разделены.
Действия с инвентарём
Теперь предметы хотелось бы допустим перекладывать и удалять. Для иллюстрации концепта, удалять мы будем через клик, а перемещать через драг. И оставим пока наши тест предметы.
Для начала сделаем удаление. Чуть изменим наши классы графического интерфейса.
using System.Collections.Generic;
using UnityEngine;
public class UIInventoryWindow : MonoBehaviour
{
[SerializeField] private UIInventoryCell _cellTemplate;
[SerializeField] private RectTransform _cellsRoot;
private List _cells;
private IInventoryActions _inventory;
private void Awake()
{
_inventory = User.Current.Inventory;
_cells = new List();
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
{
private T _currentDraggable;
private IDroppable _container;
public event Action OnStartDrag;
public event Action OnDrag;
public event Action 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 container)
{
_container = container;
}
public void ExitContainer(IDroppable container)
{
if (_container == container)
{
_container = null;
}
}
}
public interface IDroppable
{
void OnDrop(T item);
}
Зачем нужен контекст? В идеале с Drag&Drop удобно когда есть некий контекст драг энд дропа, чтобы нельзя было, да и не надо было обрабатывать, что если у нас есть две панели с драг энд дропом и ошибки, так как мы кидаем из одной в другую. Потому что они не взаимодействуют между друг другом благодаря разным контекстам.
Теперь же напишем контейнер для нашего контекста:
using System;
using UnityEngine;
public class UIInventoryDragContainer : MonoBehaviour
{
private static DragContext> _context;
public static DragContext> Context => _context;
[SerializeField] private RectTransform _dragContainer;
private GameObject _visualDraggableObject;
private Camera _camera;
private void Awake()
{
_camera = Camera.main;
_context = new DragContext>();
_context.OnStartDrag += ContextStartDrag;
_context.OnEndDrag += ContextOnEndDrag;
_context.OnDrag += ContextOnDrag;
}
private void ContextOnDrag(Tuple data)
{
if (_visualDraggableObject != null)
{
_visualDraggableObject.transform.position = Input.mousePosition;
}
}
private void ContextStartDrag(Tuple data)
{
_visualDraggableObject = Instantiate(data.Item1.gameObject, _dragContainer);
}
private void ContextOnEndDrag(Tuple 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>
{
[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 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(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
{
void Save(T data);
T Load();
}
Теперь реализуем наш конкретный Storage:
using Newtonsoft.Json;
using UnityEngine;
public class UserDataJsonStorage : IDynamicStorageActions
{
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(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 ItemsVisualDataStorage = new ItemsVisualDataStorage();
public class Dynamic
{
public static IDynamicStorageActions 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 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, а может нам нужна кор механика, а поменять только названия и иконки.
И это только я сам к себе так могу придраться. Поэтому очень тяжело писать про хороший код, так как у каждого своё понятие хорошего кода, исходя из опыта. Есть как бы норм код, и не норм. А остальное вкусы и вопрос контекста задачи. Я считаю, что не бывает универсальных решений, если это не супер мелкая задача.
Спасибо за внимание, надеюсь эта статья была вам полезна и вы узнали из неё что-то новое. Полный проект с инвентарём можно найти тут.