말감로그

[Unity] Valley - 인벤토리 저장 & 로드 시스템 본문

TIL

[Unity] Valley - 인벤토리 저장 & 로드 시스템

habbn 2024. 11. 12. 04:11
728x90

드디어 몇일동안 감을 못잡고 헤매기만 했던 인벤토리 저장&로드 시스템을 구현했다..

 

우선 저장과 로드를 생각했을 때 제일 먼저 든 생각이 PlayerPrefs를 사용하여 시스템을 구현하자였다.

그래서 PlayerPrefs로 인벤토리를 저장하려고하니, 인벤토리와 툴바의 슬롯에는 각 슬롯의 정보(이름, 아이콘, 갯수, PlantData 등) 많은 정보가 담겨있고 각 인벤토리의 슬롯 리스트를 담는 것에는 무리가 있다고 생각했다.

그리고 PlayerPrefs으로 구현이 가능하다고 한다 해도 다른 방식을 사용하고 싶다는 생각도 있었다.

 

두번째로 Json을 활용해서 데이터를 직렬화해서 저장한 후 역직렬화를 통해 데이터를 로드하고자하였다. 그래서 

현재 인벤토리를 받아 인벤토리의 데이터를 JsonUtility.ToJson()을 통해 직렬화를 하고 JsonUtility.FromJson()을 통해 역직렬화하여 저장하도록 했다.

저장은 using System.IO; 네임스페이스를 사용하여 .json 파일을 만든 후 그 파일에 저장하도록 했다.

파일에 저장하는 함수는 File.WriteAllText(파일 경로, json) 이다.

public void SaveGameData(PlayerData playerData)
{
    string json = JsonUtility.ToJson(playerData, true);
    File.WriteAllText(filePath, json);
}

 

마찬가지로 로드할때는 파일에 있는 데이터를 읽어와야 하니까 ReadAllText(파일 경로) 함수를 사용하고, 읽은 데이터를 다시 PlayerData에 담아주어 리턴하도록 하였다.

 public PlayerData LoadGameData()
{
    if (File.Exists(filePath))
    {
        string json = File.ReadAllText(filePath);
        PlayerData playerData = JsonUtility.FromJson<PlayerData>(json);
        return playerData;
    }
    else
    {
        Debug.LogWarning("Save file not found");
        return new PlayerData();
    }
}

 

그러나 이렇게 했을 경우 itemName 과 count 와 같은 string, int 데이터는 저장과 로드가 잘 되었지만, itemIcon인 Sprite과 Prefab인 GameObject가 직렬화되지 않는 문제가 있었다. 당연하게 Json은 string, int 타입의 값을 저장하기 때문이였다.

그리고 빨간 에러가 너무 떴어서 멘탈 붕괴로 인해 GPT를 하염없이 괴롭히고 서칭을 계속하면서 머리가 터지기 일보직전이었어서 휴식과 함께 다른 방법이 있을까 계속 찾았었다.

에러 메시지는 계속해서 직렬화할 수 없는 값이 있어! 였다.

 

 

그러다 유튜브를 보다 BinaryFormatter를 사용하여 이진 형식으로 데이터를 저장하고 불러오는 방법으로 인벤토리 저장 &로드 시스템을 구현한 영상을 발견해서 보고 따라해봤다.

대충 코드는 이런식이었다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

public static class InventorySave
{
    public static string path = Directory.GetCurrentDirectory() + "/inventoryfile.inv";

    public static void SaveInventory(List<Inventory.Slot> inventory)
    {
        BinaryFormatter formatter = new BinaryFormatter();          // 데이터 직렬화
        FileStream stream = new FileStream(path, FileMode.Create);  // 파일 생성

        Debug.Log("Save");

        formatter.Serialize(stream, inventory);   // 이진 데이터로 변환하여 파일에 저장
        stream.Close();  //스트림 닫음
    }

    public static List<Inventory.Slot> LoadInventory()
    {
        if (HasInventory())
        {
            BinaryFormatter formatter = new BinaryFormatter();
            FileStream stream = new FileStream(path, FileMode.Open);

            List<Inventory.Slot> inventory = formatter.Deserialize(stream) as List<Inventory.Slot>;
            stream.Close();

            Debug.Log("Load");

            return inventory;
        }
        else
        {
            Debug.LogWarning(message: "Inventory Save not found");
            return null;
        }
    }

    public static bool HasInventory()
    {
        if (File.Exists(path))
        {
            return true;
        }
        else
        {
            return false;
        }
    }
}

 

이진 형식으로 인벤토리 슬롯의 데이터를 직렬화하고, 파일을 생성해 이진 데이터로 변환하여 파일에 저장하는 방식이었다. 

그리고 저장된 인벤토리 데이터를 FileStream을 사용하여 파일을 열고 역직렬화한 후 스트림 닫고 데이터를 반환하는 방식이었다.

 

저장을 하면 해당 파일경로에 파일이 생성되었는데 

              FAssembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null   {System.Collections.Generic.List`1[[Inventory+Slot, Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null]]   _items_size_version  Inventory+Slot[]   	   	   	             Inventory+Slot   	   	   	   	   	   		   	
   	   	   
   Inventory+Slot   itemName
spriteNamecount
maxAllowedprice
isSellable       
   Fruit       c   2            Axe   Basic_tools_and_meterials_1   c                 Hoe   Basic_tools_and_meterials_2   c                 Watering   Basic_tools_and_meterials_0   c              	   	       c        	      	   	       c        
      	   	       c              	   	       c              	   	       c

 

..이게 뭔 이상한 값들이 저장이 되었는데 신기하게도 데이터가 잘 들어갔었다. 

 

그러나 역시나 또 에러 발생.. 에러는 

SerializationException: Type 'UnityEngine.Sprite' in Assembly 'UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' is not marked as serializable.

 

역시나 Sprite를 직렬화할 수 없어였다.

 

그래서 Sprite를 직렬화할 수 있는 방법을 찾다 sprite의 이름을 저장하여 나중에 Sprite를 다시 로드할 수있도록 하는 방법이 있었다.

Resources.Load<Sprite>("Sprites/" + spriteName);

 

이런 식으로 spriteName으로 Resources에서 찾으려고 했으나, 계속해서 찾아지지 않았었다ㅠㅠ

왜인지는 아직도 모르겠다. 내가 Sprite를 multiple로 쪼개서 그런가..? 라는 생각도 들었는데 잘 모르겠다ㅠㅠ

 

그리고 [NonSerialized] 를 사용하면 해당 값을 직렬화하지 않게 할 수 있다. 

public class Inventory
{
    [System.Serializable]
    public class Slot
    {
        public string itemName;
        [NonSerialized]
        public Sprite icon;
        public string spriteName;
        public int count;
        public int maxAllowed;
        public int price;
        public bool isSellable;

 

 

마지막으로 유튜버분의 깃허브를 참고해서 구현하게 되었다.

우선 나는 계속해서 Inventory.Slot 리스트를 만들어서 해당 slot의 데이터를 저장해야겠다라는 생각이 컸다.

그러나 Inventory 클래스는 독립적인 클래스였기 때문에 Monobehaviour를 상속받지 않아 함수 사용의 제약이 컸다.

그래서 아이템에 대한 정보를 담고 있는 스크립터블 오브젝트인 ItemData의 값을 저장하는 딕셔너리를 만들면 되지 않을까 싶어 기존 추가했던 스크립트를 싹 다 지우고 깃허브를 참고해서 스크립트를 작성하였다.

 

using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class InventorySave : MonoBehaviour
{
    public static InventorySave instance;
    public Inventory inventoryToSave = null;    // 저장할 인벤토리
    private static Dictionary<int, ItemData> allItem = new Dictionary<int, ItemData>();
    private static int HashItem(ItemData item) => Animator.StringToHash(item.itemName); //아이템의 이름을 해시 값으로 변환해 고유한 키로 사용
    const char SPLIT_CHAR = '_';

 

HashItem 함수에서 아이템의 이름을 해시 값으로 변환하여 고유한 키로 사용하는 이유는 아이템을 빠르고 효율적으로 구분하고 찾기 위해서이다.

아이템을 구분하는 데 문자열을 사용하는 대신 해시 값을 사용하여 검색 성능이 크게 향상되고

각 아이템 이름에 대해 고유한 값을 생성할 수 있다.

또한 Animator.StringToHash가 뭐지..? 싶었는데 이 해시함수는 문자열을 해시 값으로 변환하는 메서드로, 문자열을 빠르게 비교할 수 있게 해준다고 한다.

void Awake()
    {
        if (!instance)
            instance = this;
        CreateItemDictionary();
    }

    // Resources 폴더에서 ItemData 객체들을 불러와서 allItem 딕셔너리에 해시 값으로 저장
    private void CreateItemDictionary()
    {
        ItemData[] allItems = Resources.FindObjectsOfTypeAll<ItemData>();

        foreach (ItemData i in allItems)
        {
            int key = HashItem(i);

            if (!allItem.ContainsKey(key))
                allItem.Add(key, i);
        }
    }

 

그리고 Resources 폴더에서 ItemData 객체들을 불러와서 allItem 딕셔너리에 해시 값으로 저장을 한다.

이러면 allItem 딕셔너리에는 각 ItemData 의 데이터들이 해시 값으로 저장되어 빠르게 찾을 수 있게 된다.

 

    public void SaveInventory(string inventoryName, Inventory inventoryToSave)
    {
        string filePath = Application.persistentDataPath + $"/{inventoryName}.txt";

        // StreamWriter : 파일에 텍스트 데이터를 쓰기 위한 클래스
        using (StreamWriter sw = new StreamWriter(filePath, false))
        {
            sw.WriteLine($"-- {inventoryName} --");
            foreach (var slot in inventoryToSave.GetSlots)
            {
                if (!slot.isEmpty)
                {
                    sw.WriteLine($"{slot.itemName}{SPLIT_CHAR}{slot.count}");
                }
            }
        }
        Debug.Log($"{inventoryName} inventory saved!");
    }
public void LoadInventory(string inventoryName)
{
    string filePath = Application.persistentDataPath + $"/{inventoryName}.txt";

    if (!File.Exists(filePath))
    {
        Debug.LogWarning("Inventory file not found!");
        return;
    }
    // StreamWriter : 파일에서 텍스트 데이터를 읽기 위한 클래스
    using (StreamReader sr = new StreamReader(filePath))
    {
        string line;
        Inventory currentInventory = null;
        while ((line = sr.ReadLine()) != null)
        {
            Debug.Log($"Reading line: {line}");

            // load할 인벤토리 구분
            if (line.StartsWith("--"))
            {
                if (line.Contains("Backpack"))
                {
                    currentInventory = InventoryManager.instance.backpack;
                    Debug.Log("Loading Backpack inventory...");
                }
                else if (line.Contains("Toolbar"))
                {
                    currentInventory = InventoryManager.instance.toolbar;
                    Debug.Log("Loading toolbar inventory...");
                }
            }
            else if (currentInventory != null)
            {
                string[] data = line.Split(SPLIT_CHAR);
                if (data.Length == 2)
                {
                    string itemName = data[0];
                    int count = int.Parse(data[1]);

                    ItemData itemData;
                    // 아이템 이름을 해시 값으로 변환하고, 딕셔너리에서 해당 아이템 데이터 찾음
                    int key = Animator.StringToHash(itemName);
                    if (allItem.TryGetValue(key, out itemData))
                    {
                        for (int i = 0; i < count; i++)
                        {
                            Item item = new Item { itemData = itemData };
                            currentInventory.Add(item);
                        }
                    }
                }
            }
        }
    }
}

 

이 코드는 해당 인벤토리의 슬롯의 값들을 직렬화해서 저장하고 역직렬화해서 로드하는 등의 방식이 아니라

해당 인벤토리의 슬롯의 데이터를 가져다 파일에 텍스트를 작성하고

그 파일에 있는 itemName을 해시 값으로 변환해서 allItem 딕셔너리를 통해 해당 아이템 데이터를 찾아서 그 아이템 데이터를 인벤토리에 추가해주는 형식이다.

 

처음엔 아 될까.. 될것같은데.. 하고 했더니 너무나도 잘 되어서 진짜 너무너무 행복했었다.

 

이 저장과 로드를 하면서 에러가 많이 발생하고 잘 작동되지 않는 문제들이 많았다.

첫번째로 toolbar에 있는 아이템만 저장되고 backpack에 있는 아이템은 저장되지 않는 문제가 있었다.

 

처음에는 drag and drop 되면서 해당 슬롯의 아이템이 drop된 inventory에 저장이 안되나..? 싶어서 해당 코드에 추가하는 Add() 함수를 넣어보곤 했지만 에러만 뜨고, 오히려 인벤토리에 저장은 잘 되고 있었다.

 

그러면 왜??? 안되는 거지 왜애애애애애 하고 gpt한테 물어봤더니

using (StreamWriter sw = new StreamWriter(filePath, true))

 

StreamWrite 생성자에서 두번째 매개변수에 true 값을 넣으세요. 해서 해봤더니

-- Backpack --
Fruit_3
Axe_1
-- Toolbar --
Hoe_1
Watering_1
-- Backpack --
Fruit_2
Axe_1
-- Toolbar --
Hoe_1
Watering_1
Fruit_1

 

이런식으로 값이 저장이 되었다.

 

알고보니 StreamWrite의 두 번째 매개변수는 파일을 덮어쓸지 아니면 추가할지를 결정하는 옵션이었고, 추가하는 옵션이라 인벤토리를 옮기고 저장할 때마다 값이 저장되는 것이었다.

 

그러나 이렇게 했을 때 로드를 하면 총 Fruit 5개가 추가되고, Axe 2개 등등 기존에 있는 데이터가 계속 추가되어서 로드되는 문제가 있었다. 

true는 기존 파일에 추가하기, false는 기존 파일 덮어쓰기였기 때문에 false로 변경을 해주었고,

 

하나의 파일에 두 개의 인벤토리를 저장하는 것이 아닌 해당 인벤토리의 이름으로 파일을 만들어 각각 따로 관리하게 하였다.

 

 

실행 결과

 

아무것도 저장하지 않은 상태에서 불러오기 버튼을 누르면 저장한 기록이 없다는 로그가 찍힌다. 

 

툴바와 인벤토리에 아이템들을 넣고 저장하기 버튼을 누르면 

 

각 파일에 데이터가 잘 들어간 것을 볼 수 있다.

 

 

그리고 게임을 껐다가 다시 키면 인벤토리와 툴바가 잘 로드되어있는 모습을 볼 수 있다.

현재는 우선 GameManger.cs의 Start 함수에서 loadInventory() 함수를 호출하도록 했다.

 

 

728x90