[인벤토리 시스템] 인벤토리 서비스

Jongmin Kim·2025년 8월 13일

Lapi

목록 보기
10/14

시작

저번 글에서 정의한 Item을 토대로 이번 글에서는 인벤토리 서비스를 구현해보려고 한다. 인벤토리 서비스는 InventoryPresenterIItemSlotContext에서 제공할 메서드를 구현하는 데 사용한다.

그럼 이제 인벤토리 서비스에 대한 내용을 시작해보도록 하겠다.



아이디어

우선 인벤토리에서 제공해야 할 기본적인 기능들에 대해서 정리할 필요가 있다. 개발자마다 인벤토리에서 제공해야 할 기본적인 기능은 다르겠지만 나는 다음과 같이 정리를 했다.

  1. 아이템 획득
  2. 아이템 제거
  3. 아이템 설정 또는 스왑
  4. 아이템의 총 개수 확인
  5. 아이템 보유 여부 확인
  6. 우선순위 슬롯 반환
  7. 저장 가능한 슬롯 반환
  8. 아이템 반환
  9. 돈 갱신

이를 메서드와 시그니처를 통해서 정리하면 다음과 같다.

메서드설명
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>()
    {
		// 이전과 동일
    }
}



마무리

이번 글에서는 ItemItemData를 이용하여 인벤토리 서비스를 구현했다. 다음 글에서는 구현한 인벤토리 서비스를 기반으로 인벤토리 UI를 직접 구현해 볼 것이다.

profile
Game Client Programmer

0개의 댓글