게임에서 저장 및 불러오기 기능은 유저들이 게임을 중단하고 나중에 다시 이어서 플레이할 수 있도록 해줍니다. 이는 게임 플레이 경험을 끊김 없이 유지할 수 있으며, 유저들이 게임을 더욱 쉽게 접근할 수 있도록 도와줍니다. 또한 게임을 재미있게 플레이하는 유저들의 만족도를 높이는 데에도 중요한 역할을 합니다. 게임 저장 시스템을 구현하고 이를 정리하였습니다.

 

📺 미리보기

 

💬 서론

  • 본 기능은 게임을 저장하고 불러오는 기능으로 저장할 데이터들은 게임의 스타일 및 제작자의 의도에 따라 다를 수 있습니다.
  • 본 글에서는 게임 저장 시스템을 어떤 의도로 구현하였는지 요약하여 정리하였습니다.
  • 모든 내용이 들어가 있지 않으며, 게임 저장 및 불러오기에 대한 대략적인 아이디어를 정리하였습니다.

 

📖 구현 내용

  • 게임을 저장하고 불러옵니다.
  • 플레이어의 위치, 바라보는 방향, 체력, 스탯 등 플레이 진척 사항을 데이터로 저장합니다.
  • 저장한 데이터를 불러오고 저장 직전의 상태와 동일하게 불러옵니다.
  • 필수적인 요소가 아닌 외적 요소 또한 유동적으로 저장 기능에 추가하거나 제거할 수 있습니다.

 

✅ 구현

  • 이번 글에서는 구현한 모든 저장 및 불러오기 기능들을 한 번에 제어하는 매니저를 구현하였습니다.
  • 본 내용에서는 다루지 않은 많은 저장 및 불러오기 기능이 포함되어 있습니다. 이전 글에서 그중 하나의 예시를 들었으며, 대부분의 기능이 비슷하게 구성되어 있습니다.

· Script

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

namespace GameSave
{
    public class GameDataSaveLoadManager : Singleton<GameDataSaveLoadManager>
    {
        [Header("게임을 저장하는 역할별 매니저들")]
        [SerializeField] private PlayerGameDataManager mPlayerGameDataManager;
        [SerializeField] private QuestGameDataManager mQuestGameDataManager;
        [SerializeField] private SceneGameDataManager mSceneGameDataManager;
        [SerializeField] private NpcGameDataManager mNpcGameDataManager;
        [SerializeField] private ShopGameDataManager mShopGameDataManager;

        private static bool mIsSaveButtonLocked = false;
        /// <summary>
        /// 현재 저장 버튼이 비활성화 되어있는가?
        /// </summary>
        public static bool IsSaveButtonLocked
        {
            get
            {
                return mIsSaveButtonLocked;
            }
        }

        private static int mCurrentSlotId = -1;
        /// <summary>
        /// 현재 선택한 슬롯의 ID
        /// </summary>
        public static int CurrentSlotId
        {
            get
            {
                return mCurrentSlotId;
            }
        }

        private static readonly string mFileName = "GameData.data"; // 파일 이름
        public static string _FILE_PATH
        {
            get
            {
                return $"{Application.persistentDataPath}/{mFileName}";
            }
        }           

        private void Awake()
        {
            if(FindObjectsOfType<GameDataSaveLoadManager>().Length != 1)
            {
                Destroy(gameObject);
                return;
            }

            transform.SetParent(null);
            DontDestroyOnLoad(gameObject);
        }

        /// <summary>
        /// 인덱스 번호에 해당하는 세이브 파일이 있는가?
        /// </summary>
        public bool FileExists(int index)
        {
            return File.Exists(_FILE_PATH + index);
        }

        public void SaveGameData(int slotId)
        {
            GameDataCore gameDataCore = new GameDataCore();

            // Universal Data
            {
                gameDataCore.gameBaseInfo.dateTime = System.DateTime.Now.ToString();
                gameDataCore.gameBaseInfo.sceneName = SceneManager.GetActiveScene().name;

                gameDataCore.playerGameData = mPlayerGameDataManager.GetData();
                gameDataCore.questGameData = mQuestGameDataManager.GetData();
            }

            // Scene Individual Data
            {
                List<SceneIndividualData> sceneIndividualDatas = new List<SceneIndividualData>();

                // 기존 씬 파일 데이터가 있다면 파일 읽기 
                if (FileExists(slotId) == true)
                {
                    string fromJson = File.ReadAllText(_FILE_PATH + slotId);
                    sceneIndividualDatas = JsonUtility.FromJson<GameDataCore>(fromJson).sceneIndividualDatas.ToList();
                }

                // 현재 씬의 데이터를 가져오기
                SceneIndividualData sceneIndividualData = new SceneIndividualData();
                sceneIndividualData.sceneName = SceneManager.GetActiveScene().name;
                sceneIndividualData.npcGameData = mNpcGameDataManager.GetData();
                sceneIndividualData.sceneGameData = mSceneGameDataManager.GetData();
                sceneIndividualData.shopGameData =  mShopGameDataManager.GetData();

                // 이미 저장된 파일에서 현재 씬을 덮어쓰기가 가능하면 덮어쓰기
                for (int i = 0; i < sceneIndividualDatas.Count; ++i)
                    if (sceneIndividualDatas[i].sceneName == sceneIndividualData.sceneName)
                    {
                        sceneIndividualDatas[i] = sceneIndividualData;
                        goto Overrided;
                    }

                // 덮어쓰기를 하지 못했다면? (씬을 처음 저장하는 형태)
                sceneIndividualDatas.Add(sceneIndividualData);

                Overrided:;

                // 배열로 변환하여 저장
                gameDataCore.sceneIndividualDatas = sceneIndividualDatas.ToArray();
            }

            // 파일 저장
            string ToJsonData = JsonUtility.ToJson(gameDataCore);
            File.WriteAllText(_FILE_PATH + slotId, ToJsonData);
        }

        public void LoadGameData(int slotId, string? forceSceneName = null)
        {
            if (FileExists(slotId) == false)
                return;

            // 현재 슬롯 Id를 등록
            mCurrentSlotId = slotId;

            // 파일 읽기
            string fromJson = File.ReadAllText(_FILE_PATH + slotId);
            GameDataCore gameDataCore = JsonUtility.FromJson<GameDataCore>(fromJson);

            // Universal Data
            {
                mPlayerGameDataManager.ApplyGameData(gameDataCore.playerGameData);
                mQuestGameDataManager.ApplyGameData(gameDataCore.questGameData);
            }

            // Scene Individual Data
            {
                foreach(SceneIndividualData individualData in gameDataCore.sceneIndividualDatas)
                    if(individualData.sceneName == (forceSceneName is null ? gameDataCore.gameBaseInfo.sceneName : forceSceneName))
                        {
                            mSceneGameDataManager.ApplyGameData(individualData.sceneGameData);
                            mNpcGameDataManager.ApplyGameData(individualData.npcGameData);
                            mShopGameDataManager.ApplyGameData(individualData.shopGameData);
                        }
            }
        }

        public void RemoveGameData(int slotId)
        {
            if (FileExists(slotId) == false)
                return;

            File.Delete(_FILE_PATH + slotId);
        }

        public void TryLockSaveButtons(bool isEnable)
        {
            mIsSaveButtonLocked = isEnable;
        }
    }
}

 

[Header("게임을 저장하는 역할별 매니저들")]
[SerializeField] private PlayerGameDataManager mPlayerGameDataManager;
[SerializeField] private QuestGameDataManager mQuestGameDataManager;
[SerializeField] private SceneGameDataManager mSceneGameDataManager;
[SerializeField] private NpcGameDataManager mNpcGameDataManager;
[SerializeField] private ShopGameDataManager mShopGameDataManager;
  • 구현한 역할별 매니저들을 레퍼런스 하기 위해 사용합니다.
  • 이 매니저들에게 저장 및 불러오기를 요청하기 위해 사용합니다.

 

private static bool mIsSaveButtonLocked = false;
/// <summary>
/// 현재 저장 버튼이 비활성화 되어있는가?
/// </summary>
public static bool IsSaveButtonLocked
{
    get
    {
        return mIsSaveButtonLocked;
    }
}
  • 저장 버튼을 비활성화되어 있는지 확인하는 변수입니다.
  • 특정 조건에 저장 기능을 비활성화할 수 있습니다.
  • 예시로 보스와 전투 중인 경우는 전투 전에는 저장이 가능하나, 전투 도중에는 저장이 불가능하게 설정할 수 있습니다.

 

private static int mCurrentSlotId = -1;
/// <summary>
/// 현재 선택한 슬롯의 ID
/// </summary>
public static int CurrentSlotId
{
    get
    {
        return mCurrentSlotId;
    }
}
  • 게임을 불러올 때 현재 선택한 슬롯의 번호를 저장합니다.
  • 슬롯의 번호를 이용하여 적절한 게임 저장 데이터를 불러올 수 있도록 연결해 줍니다.

 

private static readonly string mFileName = "GameData.data"; // 파일 이름
public static string _FILE_PATH
{
    get
    {
        return $"{Application.persistentDataPath}/{mFileName}";
    }
}
  • 파일을 저장하고, 불러오기 위한 이름을 약속합니다.

 

private void Awake()
{
    if(FindObjectsOfType<GameDataSaveLoadManager>().Length != 1)
    {
        Destroy(gameObject);
        return;
    }

    transform.SetParent(null);
    DontDestroyOnLoad(gameObject);
}
  • 이 매니저는 싱글톤으로 디자인되어 있으며, 씬 내에 두 개 이상 존재해서는 안됩니다.
  • 타이틀 화면(Main Title) 씬에서 해당 매니저가 존재하며, 게임을 시작하여 다시 돌아올 때 이 매니저 오브젝트가 두 개가 될 수 있습니다.
  • 두 개가 되는것을 방지하기위해 개수를 세어 두개 이상이면 Awake를 호출한(나중에 생긴) 오브젝트를 제거하도록 합니다.

 

/// <summary>
/// 인덱스 번호에 해당하는 세이브 파일이 있는가?
/// </summary>
public bool FileExists(int index)
{
    return File.Exists(_FILE_PATH + index);
}
  • 인덱스 번호에 맞는 세이브 파일이 존재하는지 확인합니다.
  • 여기서 인덱스 번호란, 저장 및 불러오기 슬롯의 번호에 해당합니다.

 

public void SaveGameData(int slotId)
{
    GameDataCore gameDataCore = new GameDataCore();

    // Universal Data
    {
        gameDataCore.gameBaseInfo.dateTime = System.DateTime.Now.ToString();
        gameDataCore.gameBaseInfo.sceneName = SceneManager.GetActiveScene().name;

        gameDataCore.playerGameData = mPlayerGameDataManager.GetData();
        gameDataCore.questGameData = mQuestGameDataManager.GetData();
    }

    // Scene Individual Data
    {
        List<SceneIndividualData> sceneIndividualDatas = new List<SceneIndividualData>();

        // 기존 씬 파일 데이터가 있다면 파일 읽기 
        if (FileExists(slotId) == true)
        {
            string fromJson = File.ReadAllText(_FILE_PATH + slotId);
            sceneIndividualDatas = JsonUtility.FromJson<GameDataCore>(fromJson).sceneIndividualDatas.ToList();
        }

        // 현재 씬의 데이터를 가져오기
        SceneIndividualData sceneIndividualData = new SceneIndividualData();
        sceneIndividualData.sceneName = SceneManager.GetActiveScene().name;
        sceneIndividualData.npcGameData = mNpcGameDataManager.GetData();
        sceneIndividualData.sceneGameData = mSceneGameDataManager.GetData();
        sceneIndividualData.shopGameData =  mShopGameDataManager.GetData();

        // 이미 저장된 파일에서 현재 씬을 덮어쓰기가 가능하면 덮어쓰기
        for (int i = 0; i < sceneIndividualDatas.Count; ++i)
            if (sceneIndividualDatas[i].sceneName == sceneIndividualData.sceneName)
            {
                sceneIndividualDatas[i] = sceneIndividualData;
                goto Overrided;
            }

        // 덮어쓰기를 하지 못했다면? (씬을 처음 저장하는 형태)
        sceneIndividualDatas.Add(sceneIndividualData);

        Overrided:;

        // 배열로 변환하여 저장
        gameDataCore.sceneIndividualDatas = sceneIndividualDatas.ToArray();
    }

    // 파일 저장
    string ToJsonData = JsonUtility.ToJson(gameDataCore);
    File.WriteAllText(_FILE_PATH + slotId, ToJsonData);
}
  • 현재 씬을 저장하여 파일로 저장합니다.
  • 모든 저장 매니저들을 호출하여 데이터를 가져옵니다.
  • Universal Data와 Scene Individual Data를 구분하여 저장합니다.

 

· Universal

  • 게임 내 씬에 관계없이 저장될 데이터입니다.
  • 예를 들어 플레이어의 체력, 공격력 등 스탯 정보와 인벤토리 아이템의 정보, 퀘스트 정보가 대상이 됩니다.

 

· Scene Individual

  • 게임 내 씬에 따라 별도로 저장이 필요한 대상입니다.
  • 씬 내에 배치되어 있는 상자의 정보, 바닥에 버려져있는 아이템들, 상점의 정보들이 대상이 됩니다.

 

public void LoadGameData(int slotId, string? forceSceneName = null)
{
    if (FileExists(slotId) == false)
        return;

    // 현재 슬롯 Id를 등록
    mCurrentSlotId = slotId;

    // 파일 읽기
    string fromJson = File.ReadAllText(_FILE_PATH + slotId);
    GameDataCore gameDataCore = JsonUtility.FromJson<GameDataCore>(fromJson);

    // Universal Data
    {
        mPlayerGameDataManager.ApplyGameData(gameDataCore.playerGameData);
        mQuestGameDataManager.ApplyGameData(gameDataCore.questGameData);
    }

    // Scene Individual Data
    {
        foreach(SceneIndividualData individualData in gameDataCore.sceneIndividualDatas)
            if(individualData.sceneName == (forceSceneName is null ? gameDataCore.gameBaseInfo.sceneName : forceSceneName))
                {
                    mSceneGameDataManager.ApplyGameData(individualData.sceneGameData);
                    mNpcGameDataManager.ApplyGameData(individualData.npcGameData);
                    mShopGameDataManager.ApplyGameData(individualData.shopGameData);
                }
    }
}
  • 게임을 로드합니다.
  • forceSceneName?을 이용하여 특정한 씬을 강제로 불러올 수 있습니다. 이 값이 null이라면 gameDataCore.gameBaseInfo.sceneName(저장 당시 현재 씬)을 불러옵니다.
  • 각 매니저들에게 ApplyGameData를 호출하여 씬에 데이터를 불러와 적용하도록 합니다.

 

✅ 결과

  • 위 이미지와 같이 지정한 변수들이 json 파일에 저장된 것을 볼 수 있습니다.
  • 하지만 이 형태는 모든 변수들이 쉽게 수정이 가능해지며, 변조될 수 있기에 암호화를 하여 변조를 하기 어렵게 만들어야 합니다.
  • 파일을 암호화하는 내용은 "[유니티] 파일 암호화 (json 암호화)"에서 확인 가능합니다.
bonnate