Chapter 12-8. 기타 : Json으로 Save & Load (+ 직렬화)

Date:     Updated:

카테고리:

태그:

인프런에 있는 케이디님의 [유니티 3D] 실전! 생존게임 만들기 - Advanced 강의를 듣고 정리한 필기입니다. 😀
🌜 강의 들으러 가기 Click

🚀 저장하고 불러오기

이 포스트에서 저장은 인벤토리 상태, 퀵슬롯 상태, 플레이어의 위치, 플레이어의 회전 값 이렇게 4 가지만 할 것이다. 좀 더 발전시켜서 Hierarchy 상의 여러 오브젝트들의 위치와 유무, 게임 저장할 때의 게임 시간대 등등 또한 저장할 수 있을 것이다.

📜Inventory

    [SerializeField] private Item[] items;

    public Slot[] GetSlots() { return slots; }
    public Slot[] GetQuickSlots() { return quickSlots; }

    public void LoadToInven(int _arrayNum, string _itemName, int _itemNum)
    {
        for (int i = 0; i < items.Length; i++)
            if (items[i].itemName == _itemName)
                slots[_arrayNum].AddItem(items[i], _itemNum);
    }

    public void LoadToQuickSlot(int _arrayNum, string _itemName, int _itemNum)
    {
        for (int i = 0; i < items.Length; i++)
            if (items[i].itemName == _itemName)
                quickSlots[_arrayNum].AddItem(items[i], _itemNum);
    }
  • 저장할 때 필요
    • 📜Inventory로부터 인벤토리 슬롯 slots와 퀵슬롯 quickSlots 정보를 받아서 저장해야 하므로 GetSlots, GetQuickSlots 함수를 만들어줌
  • 로딩할 때 필요
    • 파일에 저장했던 인벤토리, 퀵슬롯 데이터들을 다시 현재의 인벤토리 슬롯들과 퀵슬롯에 저장했던 데이터 그대로 넣어주어야 한다.
      • items 👉 이 게임에 존재하는 모든 아이템 에셋 배열(ScriptableObject 로 저장했던 Item 에셋들) 필요
      • LoadToInven
        • 하나의 아이템을 인수로 받아 인벤토리에 넣는다. 외부에서 파일로 로딩 하여 받은 데이터를 인수로 넘겨줄 것이다.
          • 한 아이템의 슬롯 상에서의 위치 _arryNum, 아이템의 이름 _itemName, 아이템의 갯수 _itemNum을 인수로 받는다.
          • 이름으로 items에서 일치하는 아이템을 찾는다.
          • 인벤토리 _arryNum 인덱스 슬롯에 일치하는 아이템이었던 items[i] 아이템을 _itemNum 갯수만큼 넣어준다.
      • LoadToQuickSLot
        • 하나의 아이템을 인수로 받아 퀵슬롯에 넣는다.

image

아이템 종류들을 items 배열에 모두 넣어준다. 예를 들어 _itemName 매개변수로 “Axe” 아이템을 받았다면 Axe 아이템과 일치하므로 이 아이템을 인벤토리에 넣어주면 될 것이다.


📜SaveAndLoad

데이터들을 로컬 파일로 저장하고 로딩하는 일을 하는 스크립트다.

  • 1️⃣ “GameTitle” 타이틀 메뉴 씬의 Canvas에 붙인다.
    • 타이틀 메뉴에서도 “Load” 버튼이 있으므로
  • 2️⃣ “GameStage” 씬의 Game Manager에도 붙인다.
    • 일시정지 메뉴의 “Save”, “Load” 버튼에 필요

✈ 직렬화

[System.Serializable] // 직렬화 해야 한 줄로 데이터들이 나열되어 저장 장치에 읽고 쓰기가 쉬워진다.
public class SaveData
{
    public Vector3 playerPos;
    public Vector3 playerRot;

    // 슬롯은 직렬화가 불가능. 직렬화가 불가능한 애들이 있다.
    public List<int> invenArrayNumber = new List<int>();
    public List<string> invenItemName = new List<string>(); 
    public List<int> invenItemNumber = new List<int>();

    // 슬롯은 직렬화가 불가능. 직렬화가 불가능한 애들이 있다.
    public List<int> quickSlotArrayNumber = new List<int>();
    public List<string> quickSlotItemName = new List<string>(); 
    public List<int> quickSlotItemNumber = new List<int>();
}

직렬화 유니티 공식문서 참고 https://docs.unity3d.com/kr/530/Manual/script-Serialization.html

  • 이렇게 직렬화를 하면 데이터들을 일렬로 나열된 한 줄의 바이너리 스트림 형태로 파일에 저장이 되기 때문에, 저장 장치로부터 읽고 쓰기가 쉬워진다.
    • [System.Serializable]을 클래스에 붙여 객체를 저장하면 모든 인스턴스 멤버 변수들의 값을 일렬로 직렬화해 파일로 저장한겠다는 의미다.
    • 객체가 아무리 복잡해도 이렇게 객체들을 바이너리 형태로 직렬화해 저장하게 되면 파일 또는 네트워크로 스트림 통신이 가능해진다는 것이다.
직렬화가 가능하려면

image

직렬화가 안되는 데이터 타입도 있다.

직렬화가 안되는 타입

image

위와 같은 데이터 타입이 아니라면 직렬화 할 수 없다. 인벤토리 슬롯과 퀵슬롯들 정보도 저장할 것이기 때문에 Slot 타입의 객체를 저장하는 것도 필요한데, 이런 [Serializable]가 아닌 사용자 정의 타입 객체인 Slot 타입의 객체는 이 SaveData 클래스의 멤버 변수가 될 수 없다. Slot 클래스는 [Serializable]가 아니라서 직렬화로 저장될 수가 없어서! 따라서 List 로 따로 저장했다. 배열이나 List<T>도 직렬화가 가능하다. 단, 다차원 배열, 가변 배열 등의 중첩 타입의 컨테이너는 직렬화를 할 수 없다.

SaveData 클래스의 멤버 변수들에 게임에 실시간으로 쓰이는 여러 데이터들을 SaveData 객체 한 곳에 모아 저장한다. 그리고 이 SaveData 객체는 직렬화 되어 파일로 저장되기 때문에 I/O 가 편해진다.

  • 게임의 여러 데이터들을 SaveData 타입의 인스턴스 멤버 변수들에 저장하여 직렬화 하여 파일에 저장한다. 즉, SaveData 타입의 객체 하나에 전부 묶어 스트림 형태로 저장됨
    • thePlayer.transform.position 👉 playerPos에 저장
    • thePlayer.transform.rotation.eulerAngle 👉 playerRot에 저장
    • 인벤토리 슬롯에 있던 아이템들, 퀵슬롯에 있던 아이템들
      • 몇 번재 슬롯에 있었는지 그 위치와 아이템 이름과 아이템 갯수까지 각각 리스트 멤버에 보관

✈ 파일 읽기

using System.IO; // ⭐⭐⭐⭐⭐🌼✨✨✨✨✨

    private SaveData saveData = new SaveData();

    private string SAVE_DATA_DIRECTORY;  // 저장할 폴더 경로
    private string SAVE_FILENAME = "/SaveFile.txt"; // 파일 이름

    private PlayerController thePlayer; // 플레이어의 위치, 회전값 가져오기 위해 필요
    private Inventory theInventory; // 인벤토리, 퀵슬롯 상태 가져오기 위해 필요

    void Start()
    {
        SAVE_DATA_DIRECTORY = Application.dataPath + "/Save/";

        if (!Directory.Exists(SAVE_DATA_DIRECTORY)) // 해당 경로가 존재하지 않는다면
            Directory.CreateDirectory(SAVE_DATA_DIRECTORY); // 폴더 생성(경로 생성)
    }
  • SaveData 타입의 객체 인스턴스를 바로 생성해준 후 이 인스턴스의 멤버 변수에 저장할 데이터들을 저장할 것.
    private SaveData saveData = new SaveData();
    
  • Application.dataPath
    • 게임의 현재 폴더 📂Asset 경로
    • 읽기 전용이다.
  • using System.IO 👉 C# 에서 지원하는 것으로, 파일과 데이터 스트림에 대한 읽기 및 쓰기를 허용하는 형식과 기본 파일 및 디렉터리 지원을 제공하는 형식이 포함되어 있다.
    • Directory 👉 디렉터리와 하위 디렉터리에서 만들기 등등과 관련된 함수들 제공
      • Directory.Exists(String) 👉 인수로 넘긴 파일 경로(string 문자열)가 존재한다면 True, 존재하지 않는다면 False
      • Directory.CreateDirectory(String) 👉 이미 존재하지 않는 한 지정된 경로에 모든 디렉터리와 하위 디렉터리를 만듭니다.

📂Asset/📂Save 경로가 생성된다.

✈ 저장하기(Json)

    public void SaveData()
    {
        thePlayer = FindObjectOfType<PlayerController>();
        theInventory = FindObjectOfType<Inventory>();

        // 플레이어 위치, 회전값 저장
        saveData.playerPos = thePlayer.transform.position;
        saveData.playerRot = thePlayer.transform.rotation.eulerAngles;

        // 인벤토리 정보 저장
        Slot[] slots = theInventory.GetSlots();
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item != null)
            {
                saveData.invenArrayNumber.Add(i);
                saveData.invenItemName.Add(slots[i].item.itemName);
                saveData.invenItemNumber.Add(slots[i].itemCount);
            }
        }

        // 퀵슬롯 정보 저장
        Slot[] quickSlots = theInventory.GetQuickSlots();
        for (int i = 0; i < quickSlots.Length; i++)
        {
            if (quickSlots[i].item != null)
            {
                saveData.quickSlotArrayNumber.Add(i);
                saveData.quickSlotItemName.Add(quickSlots[i].item.itemName);
                saveData.quickSlotItemNumber.Add(quickSlots[i].itemCount);
            }
        }

        // 최종 전체 저장
        string json = JsonUtility.ToJson(saveData); // 제이슨화

        File.WriteAllText(SAVE_DATA_DIRECTORY + SAVE_FILENAME, json);

        Debug.Log("저장 완료");
        Debug.Log(json);
    }
  • 저장 👉 saveData 인스턴스의 멤버 변수들에 각각 저장한다.
    string json = JsonUtility.ToJson(saveData); // 제이슨화

    File.WriteAllText(SAVE_DATA_DIRECTORY + SAVE_FILENAME, json);

saveData 인스턴스의 멤버 변수들에 데이터를 모두 저장했으면 최종적으로 Json 포맷의 텍스트(json)로 📂Asset/📂Save 경로에 있는 “SaveFile.txt” 파일에 저장된다.

  • JSON은 텍스트로 되어 있어서 사람이 이해하기 쉽고 직렬화 되어 저장되기 때문에 통신에 있어 데이터를 스트림으로 주고받기 쉬운 포맷이다.
  • JsonUtility.ToJson
    • 인수로 넘긴 객체를 Json 포맷(문자열 텍스트)으로 리턴한다.
      • Json 은 직렬화 하여 저장되는 포맷이므로 인수로 넘긴 객체는 직렬화 가능한 객체여야 한다.
        • SaveData 는 직렬화한 클래스라 가능
  • File.WriteAllText(String, String)
    • C#의 System.IO에 속한 함수로, 첫 번째 인수인 경로 + 파일에 두 번째 인수인 텍스트 정보를 쓴다.
      • 즉, 📂Asset/📂Save 경로에 있는 📄SaveFile.txt 파일에 json 문자열을 기록한다.
  • 참고하기 Json 직렬화 유니티 문서

image

📂Asset/📂Save 경로가 생겼고 그 안에 📄SaveFile.txt 파일이 이렇게 생긴다.

image

{"playerPos":{"x":29.538721084594728,"y":1.0000001192092896,"z":30.098257064819337},"playerRot":{"x":0.0,"y":112.68692779541016,"z":0.0},"invenArrayNumber":[1],"invenItemName":["Yellow Potion"],"invenItemNumber":[2],"quickSlotArrayNumber":[0],"quickSlotItemName":["Log"],"quickSlotItemNumber":[3]}

📄SaveFile.txt 파일에 요렇게 saveData 객체의 멤버 변수들 값이 JSon 포맷으로 저장이 된다.

✈ 로드하기

    public void LoadData()
    {
        if (File.Exists(SAVE_DATA_DIRECTORY + SAVE_FILENAME))
        {
            // 전체 읽어오기
            string loadJson = File.ReadAllText(SAVE_DATA_DIRECTORY + SAVE_FILENAME);
            saveData = JsonUtility.FromJson<SaveData>(loadJson);

            thePlayer = FindObjectOfType<PlayerController>();
            theInventory = FindObjectOfType<Inventory>();

            // 플레이어 위치, 회전 로드
            thePlayer.transform.position = saveData.playerPos;
            thePlayer.transform.eulerAngles = saveData.playerRot;

            // 인벤토리 로드
            for (int i = 0; i < saveData.invenItemName.Count; i++)
                theInventory.LoadToInven(saveData.invenArrayNumber[i], saveData.invenItemName[i], saveData.invenItemNumber[i]);

            // 퀵슬롯 로드
            for (int i = 0; i < saveData.quickSlotItemName.Count; i++)
                theInventory.LoadToQuickSlot(saveData.quickSlotArrayNumber[i], saveData.quickSlotItemName[i], saveData.quickSlotItemNumber[i]);

            Debug.Log("로드 완료");
        }
        else
            Debug.Log("세이브 파일이 없습니다.");
    }
  • 로드 👉 saveData 인스턴스의 필드 내용들을 저장해놨던 Json 텍스트 파일을 불러와 필요했던 원래 게임 변수들에게 다시 넣어준다.
    // 전체 읽어오기
    string loadJson = File.ReadAllText(SAVE_DATA_DIRECTORY + SAVE_FILENAME);
    saveData = JsonUtility.FromJson<SaveData>(loadJson);

saveData 인스턴스의 멤버 변수들에 데이터를 모두 저장했으면 최종적으로 Json 포맷의 텍스트(json)로 📂Asset/📂Save 경로에 있는 “SaveFile.txt” 파일에 저장된다.

  • File.ReadAllText(파일경로)
    • C#의 System.IO에 속한 함수로, 인수로 넘긴 경로의 텍스트 파일을 열고, 파일의 모든 텍스트를 문자열로 읽어 들여 리턴한다.
      • 즉, 📂Asset/📂Save 경로에 있는 📄SaveFile.txt 파일을 열 것인데 JSon 포맷의 텍스트가 저장되어 있으니 loadJson은 Json 포맷의 일렬의 문자열일 것이다.
  • JsonUtility.FromJson<T>(string)
    • 인수로 넘긴 Json 포맷의 문자열을 T 타입의 인스턴스로 리턴한다.
      • Json 포맷의 문자열인 loadJson에서 데이터들을 읽어 SaveData 클래스의 멤버 변수들에 저장하고 이를 인스턴스로 만들어 saveData에 리턴한다.


🚀 메뉴 버튼에 적용

📜PauseMenu (일시정지 메뉴)

    [SerializeField] private SaveAndLoad theSaveAndLoad;

    public void ClickSave()
    {
        Debug.Log("세이브");
        theSaveAndLoad.SaveData();
    }

    public void ClickLoad()
    {
        Debug.Log("로드");
        theSaveAndLoad.LoadData();
    }

theSaveAndLoad에 같은 씬 “GameStage” 안에 있는 📜SaveData 를 할당한다. 그리고 이를 통해 SaveData, LoadData 함수를 호출한다.

image


📜Title (타이틀 메뉴)

싱글톤

    public static Title instance;

    void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
            Destroy(gameObject);
    }

📜Title 은 “GameTitle” 씬안에서 실행될 스크립트다. 📜Title 은 “GameTitle” 씬의 Canvas에 붙어있기 때문이다. 로딩을 하려면 1️⃣ 먼저 “GameStage” 씬을 SceneLoad 해야 하고 2️⃣ 그 이후에 saveData 객체에 로드해와야 한다. “GameTitle” 씬에는 플레이어랑 인벤토리 슬롯 같은 것들이 없기 때문이다! 근데 1️⃣ 씬 전환만 실행해도 씬이 바뀌면서 📜Title 가 붙어있는 Canvas를 비롯한 “GameTitle” 씬안의 모든 오브젝트가 파괴되기 때문에 그렇게 되면 2️⃣가 실행되지 못하게 된다. 따라서 이 📜Title 가 붙어있는 Canvas싱글톤으로 만들고 씬이 전환되어도 파괴되지 않도록 지켜주어야 한다. 그래야지만 1️⃣ 씬 전환이 끝난 후 2️⃣ saveData 객체에 로드해오고 이를 통해 게임 여러 변수들을 세팅할 수 있게 된다.


다른 씬을 로드할 때

    public string sceneName = "GameStage";

    private SaveAndLoad theSaveAndLoad;

    public void ClickLoad()
    {
        Debug.Log("로드");
        StartCoroutine(LoadCoroutine());
    }

    IEnumerator LoadCoroutine()
    {
        //SceneManager.LoadScene(sceneName); // 싱글톤 아니였으면 여기서 Title 파괴되서 밑에 코드 실행 못함
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);

        while(!operation.isDone) // 로딩이 끝나지 않았다면.. 여기 while문에다가 operation.progress 이용해 로딩화면 만들어줘도 된다.
        {
            yield return null;
        }

        theSaveAndLoad = FindObjectOfType<SaveAndLoad>(); // 다음 씬의 📜SaveAndLoad
        theSaveAndLoad.LoadData(); 
        gameObject.SetActive(false);  // "GameTitle"의 Canvas는 잠시 비활성화
    }

그런데 또 생각해봐야 할 문제가 있다!

  • 1️⃣ “GameStage”로 씬 전환이 끝나고나면 2️⃣ 📜SaveAndLoad 의 LoadData 함수를 실행시켜야 하는데, 이 함수에서는 로딩해온 saveData 데이터 정보를 플레이어, 인벤토리, 퀵슬롯에 세팅해야 한다. “GameTitle” 씬에는 플레이어, 인벤토리, 퀵슬롯이 없으므로 “GameTitle” 씬에있는 싱글톤 📜SaveAndLoad 로는 다른씬에 있는 이 플레이어, 인벤토리, 퀵슬롯 오브젝트에 접근할 수가 없다. 따라서 "GameStage" 씬에 있는 📜SaveAndLoad 의 *LoadData* 함수를 호출해야 한다.
          theSaveAndLoad = FindObjectOfType<SaveAndLoad>(); // 다음 씬의 📜SaveAndLoad
          theSaveAndLoad.LoadData(); 
    
  • 그리고 또 하나, 씬 전환이 완료되어도 해당 씬의 오브젝트들이 생성되는데에 시간이 걸리 수 있다. 씬 전환 후 아직 전환된 씬의 오브젝트들이 생성되지도 않았는데 theSaveAndLoad.LoadData(); 이 실행해 되버리면 런타임 에러가 발생할 수 있다. 아직 플레이어, 인벤토리가 없는데 접근하려고 하기 때문이다. 따라서 씬 전환이 완벽하게 완료될 때까지, 즉 해당씬의 오브젝트들까지 다 생성되기전까지 대기 시간을 가진 후 theSaveAndLoad.LoadData() 를 실행해야 한다.
          AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 비동기적으로 로딩 
    
          while(!operation.isDone) // 위 로딩이 끝나지 않았다면.. 1프레임 정도씩 대기 시간을 가짐
          {
              yield return null;
          }
    
    • 응용하여 이 대기 시간 동안 로딩화면을 보여줄 수도 있다. (이것에 대해선 다음에 포스팅하고자 한다.)
  • SceneManager.LoadSceneAsync
    • 해당 씬을 비동기적으로 로드하며 이 로딩 작업에 대한 정보를 AsyncOperation 타입의 인스턴스로 리턴한다.
      • 비동기적 연산을 위한 코루틴을 제공한다.
  • AsyncOperation의 프로퍼티 isDone
    • AsyncOperation 인스턴스가 해당 동작이 완료되었다면 True, 아니라면 False
  • AsyncOperation의 프로퍼티 progress
    • AsyncOperation 인스턴스 작업의 진행 상태를 0~1 사이의 float 로 리턴


❤ 완강

정말 재밌게 들었던 강의였다. 완강해서 뿌듯하고 속 시원하다!



🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우 
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄

맨 위로 이동하기

Unity Lesson 3 카테고리 내 다른 글 보러가기

댓글 남기기