본문 바로가기
스파르타 내배캠

스파르타 내배캠 Unity 3기 - 52

by LemongO 2024. 3. 26.

Excel Importer

 

데이터를 관리하기 위해 선택한 방법은
Excel로 작성된 시트를 Excel Importer 패키지를 통해
Scriptable Object로 변환하여 사용하는 방식을 채택했다.

 

사용한 방법은 다음과 같다.

 

1. 엑셀로 시트를 만들어 준다.

 

 

2. 시트와 양식을 같게 직렬화 가능한 클래스를 만들어준다. (사용법이 익숙치 않아 한 곳에 모든 데이터를 몰아놨다...)

 

 

3. Excel 파일을 프로젝트 폴더에 넣어준다.

 

 

4. Excel Importer 를 실제로 사용할 클래스를 만든다.

ExcelAssetScript 를 사용하면 같은 이름의 PartDbSheet.cs가 만들어지지만 코드를 보다시피
BaseDbSheet<T> 를 상속받는데 각 시트는 XXDbSheet 에서 생성되는 것을 사용하지만

실제 데이터를 로드할 때 하나의 메서드로 모든 데이터시트를 다루기 위해 제너릭을 사용하기 때문에

BaseDbSheet<T>를 따로 만들어 상속 받도록한다.

 

 

 

5. SO 파일을 만든다

 

엑셀파일을 우클릭 후 Reimport를 사용하면 위에서 PartDbSheet 어트리뷰트에 적혀있는 폴더에 SO가 생성된다.

 

 

6. 이제 데이터를 사용하기 위해 데이터 매니저를 만든다.

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DataManager
{
    private Dictionary<int, PartData> _partsDict = new Dictionary<int, PartData>();
    //private Dictionary<int, WeaponData> _weaponDict = new Dictionary<int, WeaponData>();

    public void Init()
    {
        LoadAllPartDatas(_partsDict);
        //LoadAllWeaponDatas(_weaponDict);
    }

    private void LoadAllDatas<T1, T2>(Dictionary<int, T2> dict, string fileName = null) where T1 : BaseDbSheet<T2> where T2 : IEntity
    {
        if (string.IsNullOrEmpty(fileName)) // 파일이름이 공란이면 T1의 이름과 같다. (Reimport 할 때 Class & SO 이름)
            fileName = typeof(T1).Name;

        string path = $"Data/{fileName}";

        var dataSheet = Resources.Load<T1>(path); // SO Load
        var dataSO = Object.Instantiate(dataSheet); // SO 복제(참조)
        var entities = dataSO.Entities;

        if (entities == null || entities.Count <= 0)
        {
            Debug.LogWarning($"불러올 데이터가 존재하지 않습니다. DataSheet : {dataSheet.name}");
            return;
        }

        int entityCount = entities.Count;
        for (int i = 0; i < entityCount; ++i)
        {
            var entity = entities[i];

            if (dict.ContainsKey(entity.Dev_ID)) // 있으면 안 되지 않나?
                dict[entity.Dev_ID] = entity;
            else
                dict.Add(entity.Dev_ID, entity);
        }
    }

    private void LoadAllPartDatas(Dictionary<int, PartData> dict) => LoadAllDatas<PartDbSheet, PartData>(dict);
    //private void LoadAllWeaponDatas(Dictionary<int, WeaponData> dict) => LoadAllDatas<WeaponDbSheet, WeaponData>(dict);

    private T GetData<T>(int id, Dictionary<int, T> dict) where T : IEntity
    {
        if (dict.ContainsKey(id))
            return dict[id];

        return default;
    }

    public PartData GetPartData(int id) => GetData(id, _partsDict);
}

 

데이터를 사용하기 위해 Dictionary를 사용한다. 각 데이터는 고유 ID를 가지고 있기 때문에 Key는 ID를 사용한다.

Value는 해당 데이터시트의 내용을 사용할 것이기 때문에 앞서 만들어놓은 Data 클래스인 PartData를 사용한다.

private Dictionary<int, PartData> _partsDict = new Dictionary<int, PartData>();

현재는 PartData 뿐이지만 다른 데이터가 있다면 그만큼 Dictionary 를 만들어주자.

 

게임이 시작되면 모든 데이터들을 로드 시켜야하니 LoadAllDatas 메서드를 호출한다.

public void Init()
    {
        LoadAllPartDatas(_partsDict);
        //LoadAllWeaponDatas(_weaponDict);
    }

    private void LoadAllDatas<T1, T2>(Dictionary<int, T2> dict, string fileName = null) where T1 : BaseDbSheet<T2> where T2 : IEntity
    {
        if (string.IsNullOrEmpty(fileName)) // 파일이름이 공란이면 T1의 이름과 같다. (Reimport 할 때 Class & SO 이름)
            fileName = typeof(T1).Name;

        string path = $"Data/{fileName}";

        var dataSheet = Resources.Load<T1>(path); // SO Load
        var dataSO = Object.Instantiate(dataSheet); // SO 복제(참조)
        var entities = dataSO.Entities;

        if (entities == null || entities.Count <= 0)
        {
            Debug.LogWarning($"불러올 데이터가 존재하지 않습니다. DataSheet : {dataSheet.name}");
            return;
        }

        int entityCount = entities.Count;
        for (int i = 0; i < entityCount; ++i)
        {
            var entity = entities[i];

            if (dict.ContainsKey(entity.Dev_ID)) // 있으면 안 되지 않나?
                dict[entity.Dev_ID] = entity;
            else
                dict.Add(entity.Dev_ID, entity);
        }
    }

 

여기서 앞서말했던 DbSheet들을 BaseDbSheet<T> 로 상속받은 이유가 나온다.

 

먼저 LoadAllDatas 메서드의 내부구조를 살펴보자면

 

1. Resources.Load<T1>(path) 로 만들어둔 SO를 불러온다. (var dataSheet)

2. 하지만 SO를 불러온 것 만으로는 데이터에 접근을 해도 데이터가 뽑아지지 않는다.
그러므로 Instantiate를 통해 SO 를 복제한다. (var dataSO)

3. 실제 데이터들이 들어있는 리스트를 entities에 할당한다.

 

여기까지 내용으로 알 수 있는 것은 T1은 SO를 의미하고 이 SO들은 모두 BaseDbSheet를 상속받는다.

제너릭으로 사용하기 때문에 LoadAllDatas 입장에선 내가 불러오고 싶은 데이터가 PartDbSheet 인지 알 길이 없다.

그렇기 때문에 앞서 BaseDbSheet<T> 를 상속받아 Entities가 자신이 가지고 있는 Data 리스트임을 알게 해줬다.

 

public abstract class BaseDbSheet<T> : ScriptableObject
{
    public abstract List<T> Entities { get; }
}
[ExcelAsset(AssetPath = "Resources/Data")]
public class PartDbSheet : BaseDbSheet<PartData>
{
    public List<PartData> Parts_SO; // public으로 하지 않으면 직렬화가 불가능 [serializeField] 소용없음

    public override List<PartData> Entities => Parts_SO;
}

 

이렇게 제너릭을 사용함으로써

LoadAllDatas 하나의 메서드만으로 PartDbBase SO 또는 그 외의 XXDbBase SO 어떤 것을 사용해도 되도록 만들어줬다.

 

다음으로 불러온 List<T2> entities 를 순회하며 매개변수로 가져온 dict에 entity.Dev_ID (Key), entity (Value) 를 추가한다.

 

여기서 다시 봐야하는 것은 T2는 각각의 데이터를 의미한다. 지금은 PartData를 의미하는데

문제는 PartData를 불러왔다한들, 안에 있는 내용물이 뭐가있는지 마찬가지로 LoadAllDatas는 알 수 없다.

그렇기 때문에 각 데이터들을 IEntity 인터페이스를 상속받게한다.

그 어떤 데이터라도 고유 ID를 가지게 할 것이기 때문에 IEntity에는 Dev_ID만 들어가도록 한다.

public interface IEntity
{
    int Dev_ID { get; }
}
public class PartData : IEntity

 

 

그리고 T2는 IEntity 를 상속받은 것으로 제한하면

현재 T2인 entity는 어떤 데이터인지는 모르지만 ID는 가지고 있다는 것을 알기 때문에
Dev_ID 를 가져와 Key로 사용 가능하고 Value로 데이터 자체를 넣게되면

실제 로드함수를 사용하는 쪽은 자신이 어떤 데이터를 넣는지 알기 때문에 유연하게 사용가능하다.

 

public void Init()
{
    LoadAllPartDatas(_partsDict);    
}
private void LoadAllPartDatas(Dictionary<int, PartData> dict) => LoadAllDatas<PartDbSheet, PartData>(dict);

 

 

 

그렇게 각 ID를 Key로 가진 Data들을 보관한 _partsDict를 GetData 메서드로 원하는 데이터를 가져올 수 있다.

private T GetData<T>(int id, Dictionary<int, T> dict) where T : IEntity
{
    if (dict.ContainsKey(id))
        return dict[id];

    return default;
}

public PartData GetPartData(int id) => GetData(id, _partsDict);