저번 글에서 정의한 Item을 토대로 이번 글에서는 인벤토리 서비스를 구현해보려고 한다. 인벤토리 서비스는 InventoryPresenter와 IItemSlotContext에서 제공할 메서드를 구현하는 데 사용한다.
그럼 이제 인벤토리 서비스에 대한 내용을 시작해보도록 하겠다.
우선 인벤토리에서 제공해야 할 기본적인 기능들에 대해서 정리할 필요가 있다. 개발자마다 인벤토리에서 제공해야 할 기본적인 기능은 다르겠지만 나는 다음과 같이 정리를 했다.
이를 메서드와 시그니처를 통해서 정리하면 다음과 같다.
| 메서드 | 설명 |
|---|---|
AddItem(ItemCode code, int count) | code에 해당하는 아이템을 count만큼 획득한다. |
RemoveItem(ItemCode code, int count) | code에 해당하는 아이템을 count만큼 제거한다. |
SetItem(int offset, ItemCode code, int count) | offset 위치에 code에 해당하는 아이템을 count만큼 저장한다. |
UpdateItem(int offset, int count) | offset 위치의 아이템을 count만큼 변경한다. |
Clear(int offset) | offset 위치를 비운다. |
GetItemCount(ItemCode code) | code에 해당하는 아이템의 개수를 반환한다. |
GetValidOffset(ItemCode code) | code에 해당하는 아이템을 저장할 수 있는 offset을 반환한다. |
GetPriorityOffset(ItemCode code) | code에 해당하는 아이템을 상대로 우선적으로 사용할 수 있는 offset을 반환한다. |
HasItem(ItemCode code) | code에 해당하는 아이템을 보유하고 있는지 여부를 반환한다. |
GetItem(int offset) | offset 위치의 아이템을 반환한다. |
또한 각 슬롯은 오프셋의 정보를 가진다. offset은 인벤토리에서의 슬롯 위치를 말한다.

인벤토리 서비스에서 관리할 데이터를 정의해야 한다. 우선적으로 이전 글에서 정의한 Item은 Json으로 직렬화할 수 없는 데이터 타입들이 존재한다. 이를 테면.. Sprite다.
그리고 만약 Sprite가 아니었다고 한들.. Item 자체를 직렬화하여 관리하기엔 Item의 크기가 너무나 비대하다.
결국에 필요한 것은 아이템을 식별할 수 있는 무언가와 그 아이템의 개수다. 그리고 다행히도 우리는 그것들의 정보를 이미 알 수 있다. ItemCode와 바로 int다.
ItemCode는 아이템을 식별하는 용도로 사용할 수 있고, int는 아이템의 개수로 사용할 수 있다. 즉, 이 두 개의 데이터로 인벤토리 슬롯 한 칸을 표현할 수 있다.
[System.Serializable]
public class ItemData
{
public ItemCode Code;
public int Count;
public ItemData(ItemCode code = ItemCode.NONE, int count = 0)
{
Code = code;
Count = count;
}
}
하지만 인벤토리는 돈도 관리해야 하고 슬롯도 여러 칸을 지니고 있다. 따라서 돈은 int로 슬롯들은 ItemData[]로 관리한다.
[System.Serializable]
public class InventoryData
{
public int Gold;
public ItemData[] Items;
public InventoryData()
{
Gold = 0;
Items = new ItemData[30]; // 내가 구현하려는 인벤토리는 슬롯을 30개 지니고 있다.
}
public InventoryData(int gold, ItemData[] items)
{
Gold = gold;
Items = items;
}
}
위에서 생각한 아이디어를 토대로 우선적으로 인터페이스를 정의해야 한다.
using System;
namespace InventoryService
{
public interface IInventoryService
{
int Gold { get; }
void Inject(IItemDataBase item_db); // 아이템 매니저를 주입받을 메서드
void InitializeSlot(int offset); // offset 위치의 슬롯을 초기화하는 메서드
void InitializeGold(); // 돈을 초기화하는 메서드
event Action<int> OnUpdatedGold; // 돈이 갱신된 경우에 실행될 델리게이트
void UpdateGold(int amount);
event Action<int, ItemData> OnUpdatedSlot; // 슬롯이 갱신된 경우에 실행될 델리게이트
void AddItem(ItemCode code, int count);
void RemoveItem(ItemCode code, int count);
void SetItem(int offset, ItemCode code, int count);
int UpdateItem(int offset, int count);
void Clear(int offset);
int GetItemCount(ItemCode code);
int GetValidOffset(ItemCode code);
int GetPriorityOffset(ItemCode code);
bool HasItem(ItemCode code);
ItemData GetItem(int offset);
}
}
그리고 정의한 IInventoryService 인터페이스를 토대로 LocalInventoryService를 구체화한다.
namespace InventoryService
{
public class LocalInventoryService : ISaveable, IInventoryService
{
private IItemDataBase m_item_db;
private int m_money;
private ItemData[] m_items;
public event Action<int> OnUpdatedGold;
public event Action<int, ItemData> OnUpdatedSlot;
public int Gold => m_money;
public LocalInventoryService()
{
m_money = 0;
m_items = new ItemData[30];
for (int i = 0; i < m_items.Length; i++)
{
m_items[i] = new ItemData();
}
// 디렉터리 경로가 없다면 새롭게 생성한다.
CreateDirectory();
}
private void CreateDirectory()
{
var directory_path = Path.Combine(Application.persistentDataPath, "Inventory");
if (!Directory.Exists(directory_path))
{
Directory.CreateDirectory(directory_path);
#if UNITY_EDITOR
Debug.Log($"<color=cyan>Inventory 디렉터리를 새롭게 생성합니다.</color>");
#endif
}
}
// Inject()를 통해서 아이템 매니저를 주입받는다.
public void Inject(IItemDataBase item_db)
{
m_item_db = item_db;
}
// offset에 해당하는 슬롯을 향하여 이벤트를 발생시킨다.
public void InitializeSlot(int offset)
{
OnUpdatedSlot?.Invoke(offset, m_items[offset]);
}
// 인벤토리를 처음 로드할 때 돈을 갱신하기 위해서 이벤트를 발생시킨다.
public void InitializeGold()
{
UpdateGold(0);
}
public void UpdateGold(int amount)
{
m_money += amount;
m_money = Mathf.Clamp(m_money, 0, int.MaxValue); // 돈은 int 최대 범위를 넘어설 수 없다.
OnUpdatedGold?.Invoke(m_money);
}
// 아이템을 획득할 때 사용한다.
public void AddItem(ItemCode code, int count)
{
var item = m_item_db.GetItem(code);
// 아이템이 중첩 가능하다면
if (item.Stackable)
{
// 슬롯들을 순회하면서
for (int i = 0; i < m_items.Length; i++)
{
// 아이템 코드가 일치하면서 99개 이하인 슬롯을 찾는다.
if (m_items[i].Code == code && m_items[i].Count + count <= 99)
{
m_items[i].Count += count;
OnUpdatedSlot?.Invoke(i, m_items[i]);
return;
}
}
}
// 중첩 아이템이 아니라면
for (int i = 0; i < m_items.Length; i++)
{
// 비어있는 슬롯을 찾는다.
if (m_items[i].Code == ItemCode.NONE)
{
m_items[i].Code = code;
m_items[i].Count = count;
OnUpdatedSlot?.Invoke(i, m_items[i]);
return;
}
}
}
// 아이템을 제거할 때 사용한다.
public void RemoveItem(ItemCode code, int count)
{
var item = m_item_db.GetItem(code);
// 아이템이 중첩 가능하다면
if (item.Stackable)
{
// 아무래도 뒤에서부터 순회해야 99개가 아닐 확률이 높다.
for (int i = m_items.Length - 1; i >= 0; i--)
{
// 해당 아이템이 들어있는 슬롯을 발견했다면
if (m_items[i].Code == code)
{
// count 만큼 제거할 수 있는지 확인하고
if (m_items[i].Count >= count)
{
// 제거 가능하다면 제거하고
m_items[i].Count -= count;
// 제거하려는 개수와 같아서 슬롯의 아이템 개수가 0이라면
if (m_items[i].Count == 0)
{
// 슬롯을 비운다.
Clear(i);
}
OnUpdatedSlot?.Invoke(i, m_items[i]);
return;
}
else
{
// 제거할 수 없다면 제거할 수 있는 만큼만 제거하고 슬롯을 비운다.
count -= m_items[i].Count;
Clear(i);
}
}
}
}
// 중첩 아이템이 아니라면
for (int i = 0; i < m_items.Length; i++)
{
// 아이템 코드가 일치하는 슬롯을
if (m_items[i].Code == code)
{
// 비운다.
Clear(i);
OnUpdatedSlot?.Invoke(i, m_items[i]);
return;
}
}
}
// 아이템을 원하는 위치에 설정하고 싶을 때 사용한다.
public void SetItem(int offset, ItemCode code, int count)
{
// offset 위치의 슬롯에 code와 count만큼을 채운다.
m_items[offset].Code = code;
m_items[offset].Count = count;
OnUpdatedSlot?.Invoke(offset, m_items[offset]);
}
// 원하는 위치의 아이템의 개수를 갱신하고 싶을 때 사용한다.
public int UpdateItem(int offset, int count)
{
// 슬롯의 최대 보관 개수 이하라면 -1을 반환하고,
if (m_items[offset].Count + count <= 99)
{
m_items[offset].Count += count;
OnUpdatedSlot?.Invoke(offset, m_items[offset]);
return -1;
}
else // 그게 아니라면 저장할 만큼만 저장한다.
{
var remain_count = 99 - m_items[offset].Count;
m_items[offset].Count = 99;
OnUpdatedSlot?.Invoke(offset, m_items[offset]);
return remain_count;
}
}
// 특정 위치의 슬롯을 비운다.
public void Clear(int offset)
{
m_items[offset].Code = ItemCode.NONE;
m_items[offset].Count = 0;
OnUpdatedSlot?.Invoke(offset, m_items[offset]);
}
// 특정 아이템의 총 개수를 반환한다.
public int GetItemCount(ItemCode code)
{
var total_count = 0;
foreach (var slot in m_items)
{
if (slot.Code == code)
{
total_count += slot.Count;
}
}
return total_count;
}
// 아이템을 저장할 수 있는 타당한 위치를 반환한다.
public int GetValidOffset(ItemCode code)
{
for (int offset = 0; offset < m_items.Length; offset++)
{
var item = m_item_db.GetItem(code);
if (item.Stackable)
{
if (m_items[offset].Count < 99)
{
return offset;
}
}
if (m_items[offset].Code == ItemCode.NONE)
{
return offset;
}
}
return -1;
}
// 아이템을 우선적으로 제거할 우선순위 슬롯을 반환한다.
public int GetPriorityOffset(ItemCode code)
{
for (int offset = m_items.Length - 1; offset >= 0; offset--)
{
if (m_items[offset].Code == code)
{
return offset;
}
}
return -1;
}
// 아이템 보유 여부를 반환한다.
public bool HasItem(ItemCode code)
{
foreach (var slot in m_items)
{
if (slot.Code == code)
{
return true;
}
}
return false;
}
// 특정 위치의 아이템 데이터를 반환한다.
public ItemData GetItem(int offset)
{
return m_items[offset];
}
public bool Load(int offset)
{
var local_data_path = Path.Combine(Application.persistentDataPath, "Inventory", $"InventoryData{offset}.json");
if (File.Exists(local_data_path))
{
var json_data = File.ReadAllText(local_data_path);
var inventory_data = JsonUtility.FromJson<InventoryData>(json_data);
m_money = inventory_data.Gold;
m_items = inventory_data.Items;
}
else
{
return false;
}
return true;
}
public void Save(int offset)
{
var local_data_path = Path.Combine(Application.persistentDataPath, "Inventory", $"InventoryData{offset}.json");
var inventory_data = new InventoryData(m_money, m_items);
var json_data = JsonUtility.ToJson(inventory_data, true);
File.WriteAllText(local_data_path, json_data);
}
}
}
위와 같이 인벤토리 서비스를 구현했다면 이를 서비스 로케이터에 등록하는 작업이 필요하다.
따라서 다음과 같이 서비스 로케이터에 등록한다.
// 이전과 동일
public static class ServiceLocator
{
private static Dictionary<Type, object> m_services = new();
public static IDictionary<Type, object> Services => m_services;
public static void Initialize()
{
Register<IEXPService>(new LocalEXPService());
Register<IUserService>(new LocalUserService());
Register<IInventoryService>(new LocalInventoryService());
Register<IKeyService>(new LocalKeyService());
// 이전과 동일
}
public static void Register<T>(T service)
{
// 이전과 동일
}
public static T Get<T>()
{
// 이전과 동일
}
}
이번 글에서는 Item과 ItemData를 이용하여 인벤토리 서비스를 구현했다. 다음 글에서는 구현한 인벤토리 서비스를 기반으로 인벤토리 UI를 직접 구현해 볼 것이다.