
처음부터 이어하기 설정 메뉴가 있는 메뉴화면을 제작합시다. MenuScene이라는 이름으로 Project 창에서 씬을 제작해주세요.
시작하기를 누르면 게임으로 들어가게 하겠습니다. MenuManager 스크립트와 빈 오브젝트를 생성하고, 오브젝트 컴포넌트로 넣습니다.
// MenuManager.cs
public void StartGame()
{
SceneManager.LoadScene("MainScene");
}
해당 버튼 오브젝트의 Inspector에서 Button의 On Click()에 MenuManager 오브젝트를 넣고, StartGame 함수로 설정해주세요.
그리고 File -> Build Settings 에서 메뉴와 메인씬을 Add Open Scenes로 추가합시다. 그러면 씬으로 등록되고, 전환이 가능하게 됩니다.
게임에서 보면 시작하기를 한 후, 로딩 중...이 뜨며 현재 몇 퍼센트인지 알 수 있습니다. 조금 더 구현해볼까요?
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;
}
}
성공적으로 로딩이 완료됩니다.
게임을 저장하면 해당 데이터가 필요합니다. 저장할 데이터를 정리해봅시다. 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;
}
저장, 로드를 담당할 스크립트를 생성합니다.
// 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);
}
}
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 Click에 SaveManager의 SaveGame()을 적용해주세요.
그러면 SignalHome 파일에 savePlayerData.json과 saveMapData.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)로 한 번 뽑아볼까요?
이미 saveMapData.json이 있기는 하나, 우리에게는 처음 맵 데이터가 필요합니다. 그것을 유니티 에디터에서 아이템을 배치한뒤, 한 번 세이브 파일이 생기게끔 합시다. 그 다음 해당 프로젝트의 파일탐색기의 Assets에 StreamingAssets라는 폴더를 생성합니다. saveMapData.Json파일의 이름을 originMapData.json으로 하고 File -> Build And Run을 합니다.
전 SiganlHome_Build라는 파일을 따로 만들었습니다. 여기서 exe파일이 실행파일입니다!
테스트를 해보는데, 혹시 자신이 의도한 것과 다르게 나오는 것이 있나요? 저는 에디터에서와 다르게 실행파일에서는 안되는 부분이 있었습니다. 에디터에서는 Debug.Log로 문제점을 파악할 수 있었는데 실행파일에서는 어떻게 할까요?
바로 Log Viewer 에셋 입니다. 해당 에셋을 다운 받은 후 상단바에 있는 Reporter -> Create를 하여 Hierarchy에 추가해주세요.
위와 같이 뜰텐데요. 해당 에셋은 마우스 클릭을 한 채로 동그라미를 그릴 경우 로그창을 보여주는 에셋입니다. 오류, 경고, 로그가 모두 뜹니다.
Build Settings에서 Develpment Build, Deep Profiling Support, Script Debugging을 true로 하고 Compression Method를 LZ4 (빠른 디버깅을 위함) 로 합시다. 그리고 다시 Build And Run을 해주세요.
실행파일에서 마우스로 동그라미를 그려주면 로그가 뜹니다. 간단하죠?
해당 로그를 클릭하면 스크립트 오류 위치 등의 정보도 볼 수 있습니다.
저의 문제는...
- Animator가 null인 문제
GetComponent<Animator>()을 하여 값을 할당함- PlayerState의 playerItemHandler가 null인 문제
GetComponent<PlayerItemHandler>()을 하여 값을 할당함- Inventory에 아이템이 할당되지 않은 문제
모두 로드 전 해당 스크립트의 변수값이 할당되어 있지 않아 생긴 문제였습니다.
오늘은 빌드하고 실행파일을 실행 후, 세이브&로드가 제대로 됐는지 확인하는 작업까지! 완료했네요. 실제 빌드파일이 나오니 정말 게임 같아졌습니다!
다음 글에서는: