[Unity / C#] 세이브&로드, 빌드, 로그 뷰어 활용

주예성·2025년 8월 5일
post-thumbnail

📋 목차

  1. 메뉴화면
  2. 세이브
  3. 로드
  4. 빌드 앤 런
  5. 최종결과
  6. 오늘의 배운 점
  7. 다음 계획

🖥️ 메뉴화면

처음부터 이어하기 설정 메뉴가 있는 메뉴화면을 제작합시다. MenuScene이라는 이름으로 Project 창에서 씬을 제작해주세요.

1. UI 제작

2. 씬전환

시작하기를 누르면 게임으로 들어가게 하겠습니다. MenuManager 스크립트와 빈 오브젝트를 생성하고, 오브젝트 컴포넌트로 넣습니다.

// MenuManager.cs

 public void StartGame()
 {
     SceneManager.LoadScene("MainScene");
 }

해당 버튼 오브젝트의 Inspector에서 Button의 On Click()에 MenuManager 오브젝트를 넣고, StartGame 함수로 설정해주세요.

그리고 File -> Build Settings 에서 메뉴와 메인씬을 Add Open Scenes로 추가합시다. 그러면 씬으로 등록되고, 전환이 가능하게 됩니다.

3. 로딩 중 표시

게임에서 보면 시작하기를 한 후, 로딩 중...이 뜨며 현재 몇 퍼센트인지 알 수 있습니다. 조금 더 구현해볼까요?
AsyncOperation 클래스를 이용하시면 됩니다. 해당 클래스에 대한 설명은 이 게시글을 참고해주세요.

// MenuManager.cs

public GameObject loadingPanel;
public Text loadingText; 

public void StartGame()
{
    StartCoroutine(LoadGameScene());
}

IEnumerator LoadGameScene()
{
    loadingPanel.SetActive(true);

    AsyncOperation asyncLoad = SceneManager.LoadSceneAsync("MainScene");

    while (!asyncLoad.isDone)
    {
        float progress = asyncLoad.progress;
        Debug.Log(loadingText);
        loadingText.text = $"로딩 중... {progress * 100:F0}%"; 
        yield return null;
    }
}

4. 테스트

성공적으로 로딩이 완료됩니다.


✅ 세이브

게임을 저장하면 해당 데이터가 필요합니다. 저장할 데이터를 정리해봅시다. SaveData 스크립트를 만들어볼까요?

1. SaveData

일단 저장할 데이터를 간단하게 정리합시다. 플레이어 현재 상태 및 아이템, 그리고 맵 상태를 저장합시다.

// SaveData.cs

[System.Serializable]
public class SavePlayerData
{
    public PlayerStats playerStats;
    public Vector3 playerPosition;
    public Quaternion playerRotation;

    public SavedItemInstance[] inventoryItems;
    public SavedItemInstance[] equipmentItems;
    
    public bool[] unlockedItems;
    public int[] quickSlotIndexs;
    public int currentHandleItemIndex;

    public bool isOperate;
    public DateTime currentTime;
}

[System.Serializable]
public class SaveMapData
{
    public SavedItemInstance[] mapItems;
    public int semaphoreNumber;
}

2. SaveManager

저장, 로드를 담당할 스크립트를 생성합니다.

// SaveManager.cs

public class SaveManager : MonoBehaviour
{
    [SerializeField] private PlayerState playerState;
    [SerializeField] private PlayerController playerController;
    [SerializeField] private GameManager gameManager;
    [SerializeField] private UIManager uiManager;
    private string savePlayerPath = "savePlayerData.json";
    private string saveMapPath = "saveMapData.json";


    public void GoMenuScene()
    {
        Debug.Log("메인 메뉴로 이동합니다.");
        Time.timeScale = 1f;
        playerController.isPaused = false;
        GameState.IsUIOpen = false;
        SceneManager.LoadScene("MenuScene");
    }
    public virtual void SaveGame()
    {
        Debug.Log("게임 저장 시작!");
        SavePlayerData savePlayerData = CollectAllPlayerData();
        SaveMapData saveMapData = CollectAllMapData();

        string json = JsonUtility.ToJson(savePlayerData, true);
        File.WriteAllText(savePlayerPath, json);
        json = JsonUtility.ToJson(saveMapData, true);
        File.WriteAllText(saveMapPath, json);

        Debug.Log("게임 저장 완료!");
    }

    private SavePlayerData CollectAllPlayerData()
    {
        SavePlayerData saveData = new SavePlayerData();
        if (playerState)
        {
            saveData.playerStats = playerState.stats;
            saveData.playerPosition = playerState.transform.position;
            saveData.playerRotation = playerState.transform.rotation;
            saveData.quickSlotIndexs = playerState.quickSlotIndexs;
            saveData.currentHandleItemIndex = uiManager.quickSlotPanel.GetComponent<QuickPanel>().currentSlotIndex;

            // equipmentItems 배열 초기화
            saveData.equipmentItems = new SavedItemInstance[playerState.equipmentItems.Length];
            for (int i = 0; i < playerState.equipmentItems.Length; i++)
            {
                if (playerState.equipmentItems[i])
                {
                    saveData.equipmentItems[i] = playerState.equipmentItems[i].ToSaveData();
                }
                else
                {
                    saveData.equipmentItems[i] = null;
                }
            }

            saveData.inventoryItems = new SavedItemInstance[playerState.inventoryItems.Length];
            for (int i = 0; i < playerState.inventoryItems.Length; i++) 
            {
                if (playerState.inventoryItems[i])
                {
                    saveData.inventoryItems[i] = playerState.inventoryItems[i].ToSaveData();
                }
                else
                {
                    saveData.inventoryItems[i] = null;
                }
            }

            saveData.unlockedItems = playerState.unlockedItems;
            saveData.isOperate = GameState.IsOperate;
            saveData.currentTime = System.DateTime.Now;
        }
        return saveData;
    }

    private SaveMapData CollectAllMapData()
    {
        SaveMapData saveData = new SaveMapData();
        List<SavedItemInstance> worldItems = new List<SavedItemInstance>();

        foreach (ItemInstance item in FindObjectsOfType<ItemInstance>())
        {
            if (item)
            {
                if (item.isWorldItem)
                {
                    worldItems.Add(item.ToSaveData());
                }
            }
        }
        saveData.mapItems = worldItems.ToArray();
        return saveData;
    }

    private ItemData FindItemDataByName(string itemName)
    {
        for (int i = 0; i < gameManager.allItems.Length; i++)
        {
            if (gameManager.allItems[i].itemName == itemName)
            {
                return gameManager.allItems[i];
            }
        }
        return Resources.Load<ItemData>("Items/" + itemName);
    }

}

3. Esc 후 이어하기, 저장하기, 나가기

Canvas에 이어하기, 저장하기, 나가기 버튼을 추가합니다. 그리고 Esc키를 통해 메뉴가 열리고 게임이 멈추게 하겠습니다.

(1) 이어하기

// UIManager.cs

[HideInInspector] public bool isPaused = false;

public GameObject menuPanel;

public void SetEscMenu()
{
    if (!menuPanel) return;
    menuPanel.SetActive(!menuPanel.activeSelf);
    if (menuPanel.activeSelf)
    {
        Time.timeScale = 0f;
        playerController.isPaused = true;
    }
    else
    {
        Time.timeScale = 1f;
        playerController.isPaused = false;
    }
    SetMouseSate();
}

// 이어하기의 On Click()에 넣기
public void ClickContinueButton()
{
    if (!menuPanel) return;
    menuPanel.SetActive(false);
    Time.timeScale = 1f;
    playerController.isPaused = false;
    SetMouseSate();
}

// PlayerController.cs

void Update()
{
	HandleEsc();
    if (isPaused) return;
}

void HandleEsc()
{
    if (Input.GetKeyDown(KeyCode.Escape))
    {
        uiManager.SetEscMenu();
    }
}

(2) 저장하기
저장하기 버튼On ClickSaveManagerSaveGame()을 적용해주세요.
그러면 SignalHome 파일에 savePlayerData.jsonsaveMapData.json라는 세이브 파일이 생깁니다!

(3) 나가기

// SaveManager.cs

[SerializeField] private PlayerController playerController;

 public void GoMenuScene()
 {
     Debug.Log("메인 메뉴로 이동합니다.");
     Time.timeScale = 1f;
     playerController.isPaused = false;
     GameState.IsUIOpen = false;
     SceneManager.LoadScene("MenuScene");
 }

🔁 로드

저장까진 모두 해냈죠? 그럼 해당 Json파일을 게임 시작 전에 불러와 적용해야 합니다.

// SaveManager.cs


public virtual void LoadGame()
{
   if (File.Exists(savePlayerPath))
   {
       string json = File.ReadAllText(savePlayerPath);
       SavePlayerData savePlayerData = JsonUtility.FromJson<SavePlayerData>(json);
       json = File.ReadAllText(saveMapPath);
       SaveMapData saveMapData = JsonUtility.FromJson<SaveMapData>(json);
       ApplyAllGameData(savePlayerData, saveMapData);
       Debug.Log("게임 불러오기 완료!");
   }
   else
   {
       Debug.LogWarning("저장된 게임 데이터가 없습니다. 새 게임을 시작합니다.");
   }
}

private void ApplyAllGameData(SavePlayerData savePlayerData, SaveMapData saveMapData)
{
   if (playerState)
   {
       playerState.quickSlotIndexs = new int[] { -1, -1, -1, -1, -1 };
       playerState.inventoryItems = new ItemInstance[GameConstants.MAX_INVENTORY_SIZE];
       playerState.stats = savePlayerData.playerStats;
       playerState.transform.position = savePlayerData.playerPosition;
       playerState.transform.rotation = savePlayerData.playerRotation;

       // inventoryItems 복원 (Create 메서드로 통일)
       for (int i = 0; i < savePlayerData.inventoryItems.Length; i++)
       {
           if (savePlayerData.inventoryItems[i] != null)
           {
               ItemData itemData = FindItemDataByName(savePlayerData.inventoryItems[i].itemName);
               if (itemData != null)
               {
                   playerState.inventoryItems[i] = ItemInstance.Create(itemData, savePlayerData.inventoryItems[i]);
                   playerState.inventoryItems[i].gameObject.SetActive(false);
               }
           }
       }
       uiManager.inventory.items = playerState.inventoryItems;
       uiManager.UpdateItemUI();
       // equipmentItems 복원 (Create 메서드로 통일)
       playerState.equipmentItems = new ItemInstance[savePlayerData.equipmentItems.Length];
       for (int i = 0; i < savePlayerData.equipmentItems.Length; i++)
       {
           if (savePlayerData.equipmentItems[i] != null)
           {
               ItemData itemData = FindItemDataByName(savePlayerData.equipmentItems[i].itemName);
               if (itemData != null)
               {
                   playerState.equipmentItems[i] = ItemInstance.Create(itemData, savePlayerData.equipmentItems[i]);
                   playerState.equipmentItems[i].gameObject.SetActive(false);
               }
           }
       }

       playerState.quickSlotIndexs = savePlayerData.quickSlotIndexs;
       uiManager.quickSlotPanel.GetComponent<QuickPanel>().currentSlotIndex = savePlayerData.currentHandleItemIndex;

       uiManager.RefreshQuickSlotItems(playerState.quickSlotIndexs);
       uiManager.RefreshEquipmentItems(playerState.equipmentItems);

       if (uiManager.quickSlotPanel.GetComponent<QuickPanel>().currentSlotIndex >= 0)
       {
           if (playerState.inventoryItems[savePlayerData.currentHandleItemIndex])
           {
               int index = playerState.quickSlotIndexs[savePlayerData.currentHandleItemIndex];
               if (index >= 0)
               {
                   playerState.playerItemHandler.currentItem = playerState.inventoryItems[index];
                   playerState.playerItemHandler.SetHoldingItem(playerState.playerItemHandler.currentItem, playerState.playerItemHandler.rightHandBone);
               }
           }
       }

       foreach (SavedItemInstance savedItem in saveMapData.mapItems)
       {
           if (savedItem != null)
           {
               ItemData itemData = FindItemDataByName(savedItem.itemName);
               if (itemData != null)
               {
                   ItemInstance worldItem = ItemInstance.Create(itemData, savedItem);
                   worldItem.isWorldItem = true;
                   worldItem.gameObject.SetActive(true);
                   worldItem.transform.position = savedItem.position;
                   worldItem.transform.rotation = savedItem.rotation;
               }
           }
       }

       playerState.unlockedItems = savePlayerData.unlockedItems;
       GameState.IsOperate = savePlayerData.isOperate;
       GameState.currentSemaphoreNumber = saveMapData.semaphoreNumber;
       playerState.RefreshUI();
   }
}

// GameManager.cs

private void Start()
{
	// 기존 로직...
    // 게임 데이터가 없을 경우 새로운 세이브 파일을 자동 생성
    if (!File.Exists(saveManager.savePlayerPath))
    {
    	saveManager.SaveStart();
    }
    saveManager.LoadGame();
}

🛠️ 메인메뉴 수정

메인메뉴를 더 수정합시다. 아래와 같이 처음부터, 이어하기, 설정, 나가기가 있는 겁니다.

처음부터 : 현재 세이브 파일이 있는 경우, 새로운 게임을 할 것인지 물어보고 아니라면 바로 새게임을 시작합니다.
이어하기 : 현재 세이브 파일이 있는 경우에만 나타납니다.
설정 : 소리, 자막 크기 등을 설정할 수 있습니다.
나가기 : 게임을 끕니다.

// MenuManager.cs

public class MenuManager : MonoBehaviour
{
    public GameObject loadingPanel;
    public GameObject warningPanel;
    public GameObject continueButton;
    public Text loadingText;

    private string savePlayerPath => Path.Combine(Application.persistentDataPath, "savePlayerData.json");
    private string saveMapPath => Path.Combine(Application.persistentDataPath, "saveMapData.json");
    private string originMapPath => Path.Combine(Application.streamingAssetsPath, "originMapData.json");

    private void Start()
    {
        if (!File.Exists(savePlayerPath))
        {
            continueButton.SetActive(false);
        }
    }
    public void StartGame()
    {
        if (File.Exists(savePlayerPath))
        {
            warningPanel.SetActive(true);
        }
        else
        {
            File.Copy(originMapPath, saveMapPath, true);  
            StartCoroutine(LoadGameScene());
        }
    }

    public void ContinueGame()
    {
        StartCoroutine(LoadGameScene());
    }

    public void RestartGame(bool confirm)
    {
        if (confirm)
        {
            warningPanel.SetActive(false);
            File.Delete(savePlayerPath);
            File.Copy(originMapPath, saveMapPath, true);
            StartCoroutine(LoadGameScene());
        }
        else
        {
            warningPanel.SetActive(false);
        }
    }

    IEnumerator LoadGameScene()
    {
        loadingPanel.SetActive(true);

        AsyncOperation asyncLoad = SceneManager.LoadSceneAsync("MainScene");

        while (!asyncLoad.isDone)
        {
            float progress = asyncLoad.progress;
            loadingText.text = $"로딩 중... {progress * 100:F0}%"; 
            yield return null;
        }
    }

    public void QuitGame()
    {
        Application.Quit();
    }
}

▶️ 빌드 앤 런

이제 게임을 실행파일 (exe)로 한 번 뽑아볼까요?

1. 원본 맵 데이터

이미 saveMapData.json이 있기는 하나, 우리에게는 처음 맵 데이터가 필요합니다. 그것을 유니티 에디터에서 아이템을 배치한뒤, 한 번 세이브 파일이 생기게끔 합시다. 그 다음 해당 프로젝트의 파일탐색기의 AssetsStreamingAssets라는 폴더를 생성합니다. saveMapData.Json파일의 이름을 originMapData.json으로 하고 File -> Build And Run을 합니다.
SiganlHome_Build라는 파일을 따로 만들었습니다. 여기서 exe파일이 실행파일입니다!

2. 테스트 (Log Viewer Asset)

테스트를 해보는데, 혹시 자신이 의도한 것과 다르게 나오는 것이 있나요? 저는 에디터에서와 다르게 실행파일에서는 안되는 부분이 있었습니다. 에디터에서는 Debug.Log로 문제점을 파악할 수 있었는데 실행파일에서는 어떻게 할까요?
바로 Log Viewer 에셋 입니다. 해당 에셋을 다운 받은 후 상단바에 있는 Reporter -> Create를 하여 Hierarchy에 추가해주세요.

위와 같이 뜰텐데요. 해당 에셋은 마우스 클릭을 한 채로 동그라미를 그릴 경우 로그창을 보여주는 에셋입니다. 오류, 경고, 로그가 모두 뜹니다.

3. Log Viewer 활용

Build Settings에서 Develpment Build, Deep Profiling Support, Script Debugging을 true로 하고 Compression Method를 LZ4 (빠른 디버깅을 위함) 로 합시다. 그리고 다시 Build And Run을 해주세요.
실행파일에서 마우스로 동그라미를 그려주면 로그가 뜹니다. 간단하죠?

해당 로그를 클릭하면 스크립트 오류 위치 등의 정보도 볼 수 있습니다.

저의 문제는...

- Animator가 null인 문제

  1. Animator가 없을 경우 GetComponent<Animator>()을 하여 값을 할당함
  2. 적용 후 애니메이션이 보임

- PlayerState의 playerItemHandler가 null인 문제

  1. playerItemHandler가 없을 경우 GetComponent<PlayerItemHandler>()을 하여 값을 할당함

- Inventory에 아이템이 할당되지 않은 문제

  1. LoadGame을 할때 PlayerState의 inventoryItems값이 Inventory의 items에 제대로 할당되지 않음
  2. items 변수를 삭제, 그리고 모두 PlayerState의 inventoryItems로 대체함 (얕은 복사 및 동기화 활용)

모두 로드 전 해당 스크립트의 변수값이 할당되어 있지 않아 생긴 문제였습니다.


🎮 최종결과

오늘은 빌드하고 실행파일을 실행 후, 세이브&로드가 제대로 됐는지 확인하는 작업까지! 완료했네요. 실제 빌드파일이 나오니 정말 게임 같아졌습니다!

영상 링크 (구글 드라이브)


📚 오늘의 배운 점

  • 세이브&로드 방법
  • 빌드 후 exe파일 사용법
  • Log Viewer 활용

🎯 다음 계획

다음 글에서는:

  1. 오프닝 연출
  2. 자막 생성
profile
Unreal Engine & Unity 게임 개발자

0개의 댓글