[ 플레이어 데이터 ]
데이터가 저장되어 있는 변수를 바로 사용하는 것이 아니라, 직렬화하려는 데이터들만 따로 별도의 class나 struct로 만든다.
public class Character : MonoBehaviour
{
public UserData data;
private void Start()
{
data.nickname = "식냥";
data.lv = 7;
data.hp = 100;
}
private void Update()
{
// JSON 직렬화
if (Input.GetKeyUp(KeyCode.Q))
{
var jsonData = JsonUtility.ToJson(data); // 데이터가 저장된 data 객체를 한 번에 직렬화
File.WriteAllText(Application.persistentDataPath + "/fileName.txt", json); // 특정 경로에 파일을 생성해서 저장
// Application.persistentDataPath: 사용하고 있는 기기의 운영 체제에 따른 적정 저장 경로를 자동으로 찾아줌
}
// JSON 역직렬화
if (Input.GetKeyUp(KeyCode.W))
{
var jsonLoad = File.ReadAllText(Application.persistentDataPath + "/fileName.txt"); // 특정 경로에 있는 파일을 string으로 불러옴
data = JsonUtility.FromJson<UserData>(jsonLoad); // 불러온 데이터를 UserData 타입으로 변환해서 data 변수에 저장
}
}
}
[System.Serializable] // 있어야 직렬화됨
public class UserData // 저장하려는 데이터 묶음
{
public string nickname;
public int lv;
public float hp;
public List<string> friends = new List<string>();
}
주의할 점은 캐릭터의 위치를 저장하려 할 때, transform은 직렬화가 안 되지 않는다. 따라서 직렬화가 가능한 vector, Quaternion으로 저장해야 한다.
- public Vector3 pos;
- public Quaternion rot;
그리고 JSON 파일이 사용자의 로컬 경로에 있기 때문에, 파일을 수정하면 게임에 그대로 반영이 되어 보안이 취약하다.
예를 들어 5레벨로 저장이 됐는데, 50레벨로 바꿔버리면 실제 게임에서도 50레벨로 변경된다. 그래서 실무에서는 중요 정보를 서버에 저장하지만, 서버가 없을 때에는 암호화하여 사용자가 수정하지 못하도록 한다.
Q 키를 누르면 데이터가 저장된 data 객체를 한 번에 직렬화하여 jsonData에 할당한다. 그리고 파일에 데이터를 저장한다.
W키를 누르면 파일을 불러와서 jsonLoad에 데이터를 할당한다. 그리고 data 변수에 값을 넣어준다.
따라서 jsonData와 jsonLoad는 객체는 다르지만 데이터는 같다.
[Serializable]
public class PlayerData
{
public ConditionDatas conditionDatas;
public Vector3 pos;
}
[System.Serializable]
public struct ConditionDatas
{
public ConditionData Health;
public ConditionData Hunger;
public ConditionData Stamina;
}
[System.Serializable]
public struct ConditionData
{
public float curValue;
public float maxValue;
public float startValue;
}
public class Condition : MonoBehaviour
{
public ConditionData data;
public float curValue { get => data.curValue; set => data.curValue = value; }
public float maxValue { get => data.maxValue; set => data.maxValue = value; }
public float startValue { get => data.startValue; set => data.startValue = value; }
}
기존에 사용하던 코드를 변경하는 경우에는 프로퍼티로 수정하는 수고를 줄일 수 있다.
직렬화가 필요한 Condition 클래스 타입의 객채가 여러 개라면, 이 객체들을 모아놓은 클래스나 구조체도 필요하다.
또한 모든 컨디션 상태와 위치 정보까지 포함한 플레이어의 정보를 한 번에 저장하고 싶다면, 또다시 클래스나 구조체를 만들어서 단계적인 구조를 형성할 수 있다.
<위 코드 구조>
PlayerData
ㄴConditionDatas
ㄴ ConditionData
ㄴ Condition
public class PlayerCondition : MonoBehaviour
{
Condition Health { get { return uiCondition.health; } }
private void Start() // 게임이 시작할 때 불러오기
{
LoadData();
}
void SaveData() // 저장할 데이터
{
PlayerData playerData = new PlayerData();
playerData.pos = this.transform.position;
playerData.conditionDatas.Health = Health.data;
string jsonData = JsonUtility.ToJson(playerData); // 직렬화
DataManager.instance.SavePlayerData(jsonData);
}
private void LoadData() // 불러온 데이터
{
PlayerData data = DataManager.instance.LoadPlayerData();
if (data == null) // 데이터가 null이라면
{
// 초기값 세팅
this.transform.position = new Vector3(0, 0, 0);
Health.data.curValue = 100;
Health.data.maxValue = 100;
Health.data.startValue = 100;
}
this.transform.position = data.pos;
Health.data = data.conditionDatas.Health;
}
private void OnApplicationQuit() // 게임이 끝날 때 저장
{
SaveData();
}
}
실제 저장하려는 정보가 있는 클래스에서 메서드를 통해 저장, 불러오는 코드를 작성한다.
게임이 끝날 때 자동으로 저장되게 하기 위해서 유니티 생명 주기 중 하나인 OnApplicationQuit 메서드에서 SaveData 메서드를 호출해서 데이터를 저장한다.
그리고 게임이 시작될 때 자동으로 저장된 정보를 불러오기 위해서 Start 메서드에서 LoadData 메서드를 호출해서 데이터를 불러온다.
주의할 점은 비정상적인 종료로 인해 데이터 소실이 일어날 수 있으므로, 실제 게임에서는 저장 버튼을 따로 만들거나 안전한 상황에서 자동 저장이 실행되도록 해야 한다. 지금은 JSON으로 직렬화를 배우는 단계의 코드이므로 OnApplicationQuit 메서드를 사용했지만, 실제 게임에 적용하려면 다른 저장 방법을 사용해야 한다.
public class DataManager : MonoBehaviour
{
public static DataManager instance;
string savePath;
private void Awake()
{
instance = this;
DontDestroyOnLoad(gameObject);
savePath = Application.persistentDataPath;
}
public void SavePlayerData(string json) // 플레이어 데이터 저장
{
File.WriteAllText(savePath + "/PlayerData.txt", json);
Debug.Log("저장 완료: " + savePath + "/PlayerData.txt");
}
public PlayerData LoadPlayerData() // 플레이어 데이터 불러오기
{
if (!File.Exists(savePath + "/PlayerData.txt")) // 경로에 데이터 없을 때 예외 처리
return null;
string jsonData = File.ReadAllText(savePath + "/PlayerData.txt");
return JsonUtility.FromJson<PlayerData>(jsonData);
}
}
그리고 데이터를 저장, 불러오는 동작을 실행하는 데이터 매니저를 만든다. 실제 데이터가 있는 클래스에서 직렬화한 데이터를 파일에 저장하거나 불러와서 역직렬화하는 기능을 한다.
[ 인벤토리 데이터 ]
public class DataManager : MonoBehaviour
{
public void SaveInvenData(string json) // 인벤토리 데이터 저장
{
File.WriteAllText(savePath + "/InvenData.txt", json);
Debug.Log("저장 완료: " + savePath + "/InvenData.txt");
}
public InvenData LoadInvenData() // 인벤토리 데이터 불러오기
{
if (!File.Exists(savePath + "/InvenData.txt")) // 경로에 데이터 없을 때 예외 처리
return null;
string jsonData = File.ReadAllText(savePath + "/InvenData.txt");
return JsonUtility.FromJson<InvenData>(jsonData);
}
public ItemData LoadItemSOData(SlotData data)
{
// Resources 폴더에 있는 ScriptableObject/경로에서 itemName인 SO를 불러옴
return Resources.Load<ItemData>("ScriptableObject/경로" + data.itemName);
}
}
// 저장하려는 데이터 묶음
[Serializable]
public class InvenData // 슬롯들의 모음인 인벤토리
{
public List<SlotData> itemList = new List<SlotData>();
}
[Serializable]
public class SlotData // 슬롯 하나의 아이템 정보
{
public string itemName;
public int itemCount;
}
public class UIInventory : MonoBehaviour
{
private void Start() // 게임이 시작할 때 불러오기
{
LoadData();
}
void SaveData() // 저장할 데이터
{
InvenData invenData = new InvenData();
for (int i = 0; i < slots.Length; i++) // 모든 슬롯 확인
{
if (slots[i].itemData == null) // 예외 처리
{
continue;
}
SlotData slotData = new SlotData();
slotData.itemName = slots[i].itemData.name; // SO인 itemData의 이름
slotData.itemCount = slots[i].quantity; // 아이템 개수
}
var jsonData = JsonUtility.ToJson(invenData);
DataManager.instance.SaveInvenData(jsonData);
}
private void LoadData() // 불러온 데이터
{
var loadData = DataManager.instance.LoadInvenData();
for (int i = 0; i < loadData.itemList.Count; i++)
{
// 아이템을 넣는다
ItemData data = DataManager.instance.LoadInvenData(loadData.itemList[i]);
// 인벤토리 Add 메서드 로직 가져오기
}
}
private void OnApplicationQuit() // 게임이 끝날 때 저장
{
SaveData();
}
}
먼저 아이템 데이터가 있는 SO의 정보를 읽어오기 위해서 해당 파일들은 Resources 폴더로 옮겨준다. Resources는 유니티 특수 폴더로, 유니티에서 제공하는 메서드를 통해서 해당 폴더 내에 있는 에셋들을 불러올 수 있다.
아이템은 슬롯을 가지고 있고, 이런 슬롯들의 집합인 인벤토리로 구성되어 있기 때문에 리스트로 슬롯들의 정보를 하나로 묶어줘야 한다.
그리고 아이템의 이름이 SO에 있기 때문에 그냥 itemName이 아닌 itemData.name으로 가져온다. 본인의 코드 구조에 따라 해당 부분은 바뀔 수 있다.
전반적인 데이터를 저장하고 불러오는 로직은 앞에서 봤던 플레이어와 유사하다.
[ 리팩토링 ]
현재의 로직은 플레이어 함수 따로, 인벤토리 함수 따로인데, 앞으로 저장해야 할 데이터의 가짓수가 늘어나면 이 함수들도 계속해서 추가해야 한다. 이는 객체 지향적이지 않기 때문에, 어떤 데이터를 저장하고 불러오든 하나의 함수를 통해서 동작할 수 있도록 리팩토링이 필요하다.
이를 위해서 제네릭을 이용하여 저장, 불러오는 함수를 하나로 합친다.
public class UIInventory : MonoBehaviour
{
private void SaveData() // 저장할 데이터
{
InvenData invenData = new InvenData();
// 중략
DataManager.instance.SaveData(invenData); // 데이터 바로 전달
}
private void LoadData() // 불러온 데이터
{
InvenData loadData = DataManager.instance.LoadData<InvenData>(); // 역직렬화 타입 정의
// 중략
}
}
데이터 매니저에서 직렬화를 하기 때문에, 데이터를 저장하는 클래스에서는 JSON으로 직렬화하지 않고 데이터 자체를 매개변수로 보낸다.
public class DataManager : MonoBehaviour
{
public static DataManager instance;
string savePath;
private void Awake()
{
// 싱글톤
instance = this;
DontDestroyOnLoad(gameObject);
// Application.persistentDataPath: 사용하고 있는 기기의 운영 체제에 따른 적정 저장 경로를 자동으로 찾아줌
savePath = Application.persistentDataPath;
}
public void SaveData<T>(T json) // 제네릭 타입의 데이터 저장
{
File.WriteAllText(savePath + $"/{typeof(T).ToString()}.txt", JsonUtility.ToJson(json)); // JSON으로 직렬화하여 타입 명으로 파일 저장
Debug.Log("저장 완료: " + savePath + $"/{typeof(T).ToString()}.txt");
}
public T LoadData<T>() // 제네릭 타입의 데이터 불러오기
{
if (!File.Exists(savePath + $"/{typeof(T).ToString()}.txt")) // 경로에 데이터 없을 때 예외 처리
return JsonUtility.FromJson<T>(null);
string jsonData = File.ReadAllText(savePath + $"/{typeof(T).ToString()}.txt");
Debug.Log("불러오기 완료: " + savePath + $"/{typeof(T).ToString()}.txt");
return JsonUtility.FromJson<T>(jsonData);
}
public ItemSO LoadItemSOData(SlotData data)
{
// Resources 폴더에 있는 ScriptableObject/경로에서 itemName인 SO를 불러옴
return Resources.Load<ItemSO>("ScriptableObject/Data/" + data.itemName);
}
}
데이터 매니저의 최종 코드이다. 제네릭을 활용해서 모든 타입을 받아올 수 있게 한다. 파일 명은 타입 명을 string으로 형변환을 해서 정한다.
직렬화하는 코드도 데이터 매니저에서 처리함으로써 데이터를 저장하는 클래스에서 매번 직렬화를 하지 않아도 된다.
이렇게 제네릭을 활용하면 어떤 타입이 오든 해당 함수 하나로 처리할 수 있어서 관리가 용이하다.
'Coding > Unity' 카테고리의 다른 글
[내일배움캠프 50일차 TIL] 오브젝트 풀 객체 초기화 (0) | 2024.06.26 |
---|---|
[내일배움캠프 48일차 TIL] 오브젝트 풀에서 큐의 동작 원리 (0) | 2024.06.24 |
[내일배움캠프 43일차 TIL] Input System C# 제너레이트 활용 (0) | 2024.06.18 |
[내일배움캠프 42일차 TIL] 파티클 시스템, 애니메이션 이벤트, 사운드 (1) | 2024.06.14 |
[내일배움캠프 40일차 TIL] Input System 다이렉트, 임베디드, 액션 에셋, C# 제너레이트, Action Properties (0) | 2024.06.12 |