
Where Am I?이번 프로젝트를 구상할 때 세웠던 목표 중 하나는 데이터 저장 기능을 구현해보는 것이었다. 하지만 생각보다 일정이 밀려 정해진 기간 내에 구현하기 힘들다는 결론을 내렸고, 결국 해당 기능은 포기하고 빌드 테스트를 할 수 밖에 없었다. 그런데 너무 아쉽다는 생각이 들기도 했고, 꼭 구현해보고 싶던 기능인데 마침 강사님이 특강으로 데이터 저장에 대해 알려주셔서 이를 활용해보기로 했다.
우선, 어떤 데이터를 저장할지 고민을 해야했다. 게임 플레이 중간에 저장할 수 있도록 구현한다면 맵 데이터, 미니맵, 아이템, 조합 아이템, 인벤토리, 몬스터 위치 등 생각보다 많은 데이터를 저장해야했고, 이를 위해서는 전체적인 구조 개선이 필요해보였다. 하지만 당장 모레부터 팀 프로젝트에 들어가기 때문에 단순히 아이템 봉인 단계만 저장하는 정도로 구현을 했다.
GameData데이터를 저장하기 위한 클래스로 아이템 봉인 여부를 저장할 수 있도록 bool 타입 필드를 가지도록 했다.
// 디버그를 위한 직렬화
[System. Serializable]
public class GameData
{
public bool HasBackpack;
public bool HasCompass;
public bool HasLantern;
private void Awake()
{
Init();
}
// 처음에는 전부 가지고 있는 상태로 초기화
private void Init()
{
HasBackpack = true;
HasCompass = true;
HasLantern = true;
}
}
DataManager특강에서 배운 코드를 활용하여 데이터의 저장/불러오기 기능을 담당하도록 구현한 클래스로, GameData를 참조하여 데이터를 관리한다.
public class DataManager : Singleton<DataManager>
{
// 데이터를 보관할 GameData 클래스
public GameData GameData;
// 데이터 파일의 기본 이름 설정
public const string fileName = "SaveFile";
// 디버그를 위한 전처리기 설정
// 디버그 시 저장되는 경로(Assets 폴더)
#if UNITY_EDITOR
public string path => Path.Combine(Application.dataPath, $"Data/{fileName}");
#else
// 실제 빌드 시 저장되는 경로(user local 폴더)
public string path => Path.Combine(Application.persistentDataPath, $"Data/{fileName}");
#endif
// 데이터를 저장할 때 호출되는 이벤트
public event Action OnFileChanged;
// 데이터를 불러올 때 호출되는 이벤트
public event Action OnGameLoaded;
// Singleton 초기화 메서드, 데이터 유지를 위한 DontDestroyOnLoad
protected override void Awake()
{
base.Awake();
DontDestroyOnLoad(gameObject);
}
// 경로에 파일이 있는지 확인하는 메서드
public bool ExistData(int slot)
{
// 경로에 해당하는 폴더에 {path}_{slot}라는 이름의 파일이 없는 경우
if (Directory.Exists((Path.GetDirectoryName($"{path}_{slot}"))) == false)
{
// false 반환
return false;
}
// 있는 경우 true 반환
return File.Exists($"{path}_{slot}");
}
// 데이터를 저장하는 메서드
public void SaveData(int slot)
{
파일 유무를 확인하고 없는 경우
if (!ExistData(slot))
{
// 경로에 {path}_{slot}라는 이름의 파일 신규 생성
Directory.CreateDirectory(Path.GetDirectoryName($"{path}_{slot}"));
}
// GameData에 저장된 데이터를 JsonUtility의 메서드를 활용하여 문서화
string json = JsonUtility.ToJson(GameData);
// {path}_{slot} 파일의 내용을 json으로 설정
File.WriteAllText($"{path}_{slot}",json);
// 데이터가 저장되었으니 이벤트 호출
OnFileChanged?.Invoke();
}
// 데이터를 불러오는 메서드
public bool LoadData(int slot)
{
// 데이터를 저장한 파일이 없는 경우 false 반환(스킵)
if (!ExistData(slot)) return false;
// 파일에 들어있는 텍스트를 string 변수에 저장
string json = File.ReadAllText($"{path}_{slot}");
// GameData를 JsonUtility의 메서드를 활용하여 파일에 저장된 데이터로 업데이트
GameData = JsonUtility.FromJson<GameData>(json);
// 데이터를 불러왔으니 이벤트 호출
OnGameLoaded?.Invoke();
// 데이터를 불러오는데 성공했다면 true 반환
return true;
}
}
ItemManager데이터 기능을 구현하기 전에 아이템 봉인 여부를 담당하는 클래스로 이를 없애지 않고 DataManager와 연계하여 재사용하기로 했다.
public class ItemManager : Singleton<ItemManager>
{
// 기존 아이템 봉인 여부 데이터를 담고 있던 bool 타입 Property
public Property<bool> HasLantern;
public Property<bool> HasCompass;
public Property<bool> HasBackpack;
// 데이터가 저장 중일 때 중복 호출 방지를 위해 사용할 태그
private bool _isLoadingData;
// Singleton 초기화 메서드, 데이터 유지를 위한 DontDestroyOnLoad, 자체 초기화 메서드
protected override void Awake()
{
base.Awake();
if (transform.parent == null)
{
DontDestroyOnLoad(gameObject);
}
Init();
}
// 외부 이벤트 구독은 Start에서 처리
private void Start()
{
DataManager.Instance.OnGameLoaded += LoadItemData;
}
// GameClear씬의 각 봉인 Button에 등록된 메서드
public void GetLantern()
{
HasLantern.Value = false;
}
public void GetCompass()
{
HasCompass.Value = false;
}
public void GetBackpack()
{
HasBackpack.Value = false;
}
// 데이터를 불러올 때 같이 호출되는 메서드
// 실제 게임에 적용되는 내부 데이터를 GameData로 업데이트
private void LoadItemData()
{
// 중복 호출을 방지하기 위한 태그
_isLoadingData = true;
HasBackpack.Value = DataManager.Instance.GameData.HasBackpack;
HasCompass.Value = DataManager.Instance.GameData.HasCompass;
HasLantern.Value = DataManager.Instance.GameData.HasLantern;
// 데이터 불러오기가 끝났으면 false처리
_isLoadingData = false;
}
// GameData에 게임 클리어 결과를 반영하여 업데이트
private void SaveItemData(bool value)
{
// 데이터가 로드 중일 때는 호출x
if (_isLoadingData) return;
DataManager.Instance.GameData.HasBackpack = HasBackpack.Value;
DataManager.Instance.GameData.HasCompass = HasCompass.Value;
DataManager.Instance.GameData.HasLantern = HasLantern.Value;
}
private void Init()
{
// Property 초기화
HasBackpack = new Property<bool>(true);
HasCompass = new Property<bool>(true);
HasLantern = new Property<bool>(true);
// 내부 이벤트 구독은 Awake에서 처리
HasBackpack.OnChanged += SaveItemData;
HasCompass.OnChanged += SaveItemData;
HasLantern.OnChanged += SaveItemData;
_isLoadingData = false;
}
}
SaveUI / LoadUI데이터의 저장과 불러오기를 하나의 UI가 처리하는 것이 아닌 2개로 나누어 각각의 UI에서 처리할 수 있도록 구현했다.
// 데이터 저장을 담당하는 UI
public class SaveUI : MonoBehaviour
{
[Header("Drag&Drop")]
[SerializeField] private List<Button> _slots;
[SerializeField] private List<TMP_Text> _texts;
[SerializeField] private Button _exitBtn;
private void Start()
{
Init();
}
private void Init()
{
// Button에 slot별 데이터 저장 메서드를 등록
for (int i = 0; i < _slots.Count; i++)
{
// 람다 캡처에 따른 참조 형식을 지역 변수로 캐싱하여 값 형식으로 바꾸어 사용
int slotIndex = i;
_slots[slotIndex].onClick.AddListener(()=>DataManager.Instance.SaveData(slotIndex));
}
// 버튼 옆의 문구 초기화
SetText();
// 데이터가 저장될 때 호출되는 메서드에 SetText 추가
DataManager.Instance.OnFileChanged += SetText;
// 나가기 버튼에 UI 비활성화 연동
_exitBtn.onClick.AddListener(()=> gameObject.SetActive(false));
}
// 데이터가 저장되면 출력되는 문구를 변경하는 메서드(지금보니 비효율적인 것 같다)
private void SetText()
{
for (int i = 0; i < _texts.Count; i++)
{
// 해당 형식은 람다식이 아니기 때문에 값 형식으로 작동하여 지역 변수로 캐싱할 필요x
_texts[i].text = DataManager.Instance.ExistData(i) ? "File Exist" : "Empty";
}
}
}
public class LoadUI : MonoBehaviour
{
[Header("Drag&Drop")]
[SerializeField] private List<Button> _slots;
[SerializeField] private List<TMP_Text> _texts;
[SerializeField] private Button _exitBtn;
private void Start()
{
Init();
}
private void Init()
{
for (int i = 0; i < _slots.Count; i++)
{
// 람다 캡처에 따른 참조 형식을 지역 변수로 캐싱하여 값 형식으로 바꾸어 사용
int slotIndex = i;
// 각 버튼에 데이터 로드 메서드와 게임 시작 메서드를 등록
_slots[slotIndex].onClick.AddListener(()=>
{
if (DataManager.Instance.LoadData(slotIndex))
{
GameManager.Instance.GameStart();
}
});
}
SetText();
DataManager.Instance.OnFileChanged += SetText;
_exitBtn.onClick.AddListener(()=> gameObject.SetActive(false));
}
private void SetText()
{
for (int i = 0; i < _texts.Count; i++)
{
_texts[i].text = DataManager.Instance.ExistData(slotIndex) ? "File Exist" : "Empty";
}
}
}