유연한 게임 데이터 스크립트

정선호·2025년 12월 18일

Original

목록 보기
7/7

1. 개요

다량의 데이터가 필요하고 또 생산되는 타이쿤 게임의 서버 및 데이터 관리 직책을 맡게 되었다.
시간적 제한으로 인해 서버는 직접 개발 대신 상용 서버인 뒤끝 서버를 사용하기로 했다.

뒤끝 서버의 차트 시스템을 이용해 다량의 게임 데이터를 효과적으로 관리할 수 있게 되었지만, 이를 클라이언트에 주입하는 것은 다른 일이었다.

따라서 다음과 같은 시스템을 구현했다.
해당 시스템은 뒤끝 차트를 베이스로 작성하였지만, 다른 상용 서버나 클라이언트 내 csv파일 저장 등의 방식으로도 사용 가능하다.

이 문서에서는 뒤끝의 자세한 사용 방식과 이에 대한 스크립트는 작성하지 않고 스도코드로 대처한다.

2. 요구 조건 및 해결 방안

요구 조건

게임 데이터 스크립트의 요구조건은 다음과 같았다.

  • A. 모든 게임 데이터의 이름/설명 등 필요한 부분은 모두 다국어로 번역이 가능해야 한다.
  • B. 게임 데이터에는 화면에 표기되는 데이터뿐 아니라 레벨별 경험치 등과 같이 단순 데이터로만 존재하는 종류도 있다.
  • C. 매니저 클래스에서 어떠한 데이터든 키 값으로 접근이 가능해야 한다.
  • D. 해당 게임 데이터는 그 게임데이터와 매칭되는 아이콘 및 프리팹 등을 갖고 있어야 한다.

UI 표시를 위한 요구조건들이 주로 요구되었다.

해결 방안

A. 다국어 번역

모든 설명의 다국어 번역 및 UI 표시를 위해 데이터의 종류를 크게 두 가지로 나누었다.

  • 게임 데이터 : 재화, 소모품 등 게임 내에서 상호작용하는 데이터
  • 타입 데이터 : 각 게임 데이터를 분류하고 정의하는 데이터

유니티에서 보편적으로 다국어 번역은 Unity Localization System을 사용한다. 해당 테이블은 테이블의 이름과 테이블 내 키값을 제시하면 이에 일치하는 문자열, Sprite, 오브젝트 등을 현재 유니티에서 설정된 언어에 맞춰 반환해준다.

게임 데이터 내에 각 데이터의 이름, 설명 등은 Localization 키 값들을 보유하며 번역을 반환해올 수 있지만, 해당 데이터들의 태그와 타입 등에 대한 Localization 키까지 게임 데이터 내에서 보유하고 있는 것은 저장공간 낭비라 판단했다.
따라서 타입들만을 모아둔 타입 데이터를 만들게 되었다.

B.레벨데이터와 같은 타입의 데이터

해당 데이터는 DataId와 DataType이 존재하지 않는다. 다만 딕셔너리에 넣기 위해서 BaseData라는 게임 데이터의 부모 클래스를 만들었다.

BaseData는 자유롭게 value값을 넣을 수 있지만, string Id를 필수적으로 가진다. 이는 BaseGameData에서는 DataId와 동일한 값을 갖는다.

C.매니저 클래스에서 모든 데이터의 접근

해당 조건을 충족하기 위해 모든 게임 데이터는 다음과 같은 값을 필수로 가진다.

  • DataType : 매니저 클래스에서 해당 타입의 데이터들을 모아둔 딕셔너리에 접근하기 위한 키값
  • DataId : 해당 아이템의 고유값, 딕셔너리에서 해당 데이터를 찾기 위한 키값

위의 두 값을 가지는 최상위 부모 클래스인 BaseGameData를 만든다. GameDataBaseBaseData를 상속받으며 각각의 게임 데이터의 부모 클래스가 된다.

아이템 매니저 클래스에서는 각 ItemType의 데이터들을 ItemId를 Key로, GameDataBase를 상속받는 실제 데이터값을 Value로 하는 딕셔너리 형식으로 저장하고, 이 딕셔너리들을 Value로, 해당 아이템들의 ItemType을 키로 하는 딕셔너리에 참조하여 접근할 수 있게 한다.

D.게임데이터에 아이콘 및 프리팹 할당

아이콘과 프리팹은 유니티의 Addressable Asset 시스템을 사용해 로드할 수 있게 해놓은다.
게임 데이터를 만들 때 ItemId를 바탕으로 뒤에 _Icon, _Prefab 등을 붙여 고유한 아이콘 및 프리팹 키를 선언하여 구현하였다.

3. 스도 코드

스도코드 개요

전체적인 스도 코드는 다음과 같다.

  • GameDataManager : 모든 게임 데이터에 접근할 수 있고 일괄적으로 관리할 수 있게 해주는 싱글턴 매니저 클래스
  • BaseData : 모든 게임 데이터의 부모가 되는 베이스 클래스.GameDataManager에서 일괄적으로 관리하는 데이터의 형식이다.
  • BaseGameData : 모든 화면에 표시되는 게임 데이터의 부모가 되는 베이스 클래스. 아이콘 혹은 프리팹을 가지고 다국어 번역이 필요한 이름 혹은 설명이 있기 때문에 ItemID와 ItemType뿐 아니라 로컬라이제이션과 어드레서블 관련 데이터들도 가지고 있는다.
  • ~Data : 실제로 사용하는 데이터들을 선언하는 자식 클래스이다.
  • TypeData : 타입 및 태그 전용 데이터 클래스이다. 타입에 대한 로컬라이제이션을 지원한다.

상세 스도코드

해당 스도코드는 각 스크립트가 하는 일을 번호순으로 순차적으로 나열하여 표현한다.

서버에서 차트 데이터를 받아오는 스크립트는 Server.LoadChart(string chartName)으로 간소화하였다.

BaseData

모든 게임 데이터의 부모 클래스이다. GameDataManager에서 일괄적으로 조회 및 검색하기 위해 사용되는 클래스 형식이다.

csv와 같이 단일 셀에 리스트 형식의 데이터를 넣기 위해 이를 파싱하는 함수를 구현한다.

public abstract class BaseData
{
	// 딕셔너리 키용 ID, ObjData를 상속받으면 ObJData가 됨
    public string Id { get; protected set; } 


    // string 문자열을 List<string> 형식으로 파싱하는 함수
    public List<string> ParseString(string str)
    {
        List<string> res = new();

        str = str.Trim().TrimStart('[').TrimEnd(']');
        var parts = str.Split(',');

        foreach (var part in parts)
        {
            var clean = part.Trim().Trim('"');
            res.Add(clean);
        }

        return res;
    }
}

GameDataBase

모든 화면에 보이는 게임 데이터가 필수적으로 가져야 하는 값들을 선언 및 할당한다.

public abstract class GameDataBase : BaseData
{
    public string ItemId { get; private set; }              // 아이템 고유 ID
    public int ItemType { get; private set; }               // 아이템 타입
    public string IconPath { get; private set; }            // 아이템 아이콘 경로(어드레서블)
    public string PrefabPath { get; private set; }          // 아이템 프리팹 경로(어드레서블)
    public string LocaleTableName { get; private set;}		// 로케일 테이블 이름
    public string NameLocaleKey { get; private set; }       // 이름 로케일 키 (로컬라이제이션)
    public string DescLocaleKey { get; private set; }       // 설명 로케일 키 (로컬라이제이션)


    public Sprite Icon { get; protected set; }            	// 아이템 아이콘
    public GameObject Prefab { get; protected set; }      	// 아이템 프리팹

	
    // 생성자 클래스. 기본 값들을 할당해준다.
    public GameDataBase(string loTableName, LitJson.JsonData row)
    {
        ItemId = row["ItemID"].ToString();
        ItemType = int.Parse(row["ItemType"].ToString());

        IconPath = $"{ItemId}_Icon";
        PrefabPath = $"{ItemId}_Prefab";

		LocaleTableName = loTableName;
        NameLocaleKey = $"{ItemId}_Name";
        DescLocaleKey = $"{ItemId}_Desc";

        base.Id = ItemId;

        LoadIconSprite().Forget();
        LoadObjectPrefab().Forget();
    }


    // 이름 로컬라이제이션 스트링을 반환하는 함수
    public LocalizedString GetLocalName()
    {
    	return new LocalizedString(LocaleTableName, NameLocaleKey);
    }


    // 설명 로컬라이제이션 스트링을 반환하는 함수
    public LocalizedString GetLocalDesc()
    {
    	return new LocalizedString(LocaleTableName, DescLocaleKey);
    }


    // 아이콘을 로드하는 함수
    public async UniTask LoadIconSprite()
    {
        if (Icon != null) return;   // 이미 있으면 종료

        Icon = await AddressableManager.I.GetAssetAsync<Sprite>(IconPath, caching);
        if (Icon == null)
        {
            Debug.LogError($"Failed to load icon of {ItemType.ToString()} - {ItemId}");
        }
    }


    // 에셋을 로드하는 함수
    public async UniTask LoadObjectPrefab(bool caching = false)
    {
        if (Prefab != null) return;   // 이미 있으면 종료

        Prefab = await AddressableManager.I.GetAssetAsync<GameObject>(PrefabPath, caching);
        if (Prefab == null)
        {
            Debug.LogError($"Failed to load prefab of {ItemType.ToString()} - {ItemId}, Loading Temp Prefab");
        }
    }
}

~Data

실제 사용되는 게임 데이터를 구현해둔 클래스이다.

public class ItemBoxData : GameDataBase
{
    public int BoxType; 			// 박스의 타입
    public List<string> BoxItemId; 	// 박스 아이템이 포함하는 아이템 ID 리스트
    public List<int> Num; 			// 박스 아이템이 포함하는 아이템 개수 리스트

    public ItemBoxData(string loTableName, LitJson.JsonData row) : base(loTableName, row)
    {
        BoxType = int.Parse(row["BoxType"].ToString());
        BoxItemId = ParseString(row["BoxItemID"].ToString());
        Num = ParseString(row["Num"].ToString()).ConvertAll(s => int.Parse(s));
    }
}

TypeData

타입들을 위한 데이터이다. enum과 유사하게 int를 키로, string을 value로 하여 타입 데이터를 지정하고, 번역을 위한 로컬라이제이션을 연결하였다.

로컬라이제이션에 해당 키가 있는지 확인하는 함수를 구현하였다.

public class TypeData
{
    private string _tableName;			// 로컬라이제이션 테이블 이름
    private string _chartName;			// 데이터 로드용 차트 이름
    
    private Dictionary<int, string> _data;		// 데이터


	// 생성자
    public TypeData(string chartName, string tableName)
    {
        _chartName = chartName;
        _tableName = tableName;
        _data = new Dictionary<int, string>();
    }

    public string GetValue(int id)
    {
        if (_data.ContainsKey(id))
        {
            return _data[id];
        }
        return null;
    }

    public Dictionary<int, string> GetValues()
    {
        return _data;
    }

	// 해당 타입의 번역을 반환하는 함수
    public LocalizedString getLocalString(int id)
    {
        if (_data.ContainsKey(id))
        {
            return new LocalizedString(_tableName, _data[id]);
        }
        return null;
    }


    // 타입 데이터 초기화 함수
    public async void InitTypes()
    {
        var rows = Server.LoadChart(_chartName);
        foreach (LitJson.JsonData row in rows)
        {
            _data.Add(int.Parse(row["Key"].ToString()), row["Value"].ToString());
        }

        await CheckKeysExistInTableAsync(_data.Values.ToList());
    }


    // 로컬라이제이션 테이블과 키 매칭 여부 함수
    private async UniTask CheckKeysExistInTableAsync(List<string> keys)
    {
        await LocalizationSettings.InitializationOperation.Task;

        // StringTableCollection이 아닌, Runtime에서 Table을 불러옴
        var table = await LocalizationSettings.StringDatabase.GetTableAsync(_tableName);

        if (table == null)
        {
            Debug.LogError($"테이블 '{_tableName}'을 찾을 수 없습니다.");
            return;
        }

        // StringTable의 ContainsKey 사용
        foreach (var key in keys)
        {
            var entry = table.GetEntry(key);
            if (entry == null)
                Debug.LogError($"{_tableName}의 {key}를 찾을 수 없습니다.");
        }
    }

GameDataManager

타입 데이터와 게임 데이터들을 관리하는 매니저 클래스이다.

로컬라이제이션 키 매칭 여부 체크 함수를 구현하여 초기화 이후 체킹해준다.

public class GameDataManager : MonoBehaviour
{
    public static GameDataManager I { get; private set; }

	// 게임 데이터 일괄 관리용 딕셔너리
    private readonly Dictionary<int, object> _dataMap = new();
    
    // 아이템박스 타입 데이터
    public TypeData ItemBoxType { get; private set; } = new("ItemBoxType", "Type_ItemBoxTypeTable");      
    
    // 아이템박스 게임 데이터
    public SerializedDictionary<string, ItemBoxData> ItemBoxData { get; private set; } = new();
    
    
    // 전체 게임 데이터 초기화 함수
    public async UniTask InitGameData()
    {
    	// 타입 데이터 초기화
    	ItemBoxType.InitTypes();
        
        // 게임 데이터 초기화
        await InitGameDatas<ItemBoxData>("ItemBoxChart", ItemBoxData, row => new ItemBoxData(row));
        
        // 초기화한 게임 데이터를 관리 딕셔너리에 매핑
        _dataMap[2] = ItemBoxData;
        
        
        // 로컬라이제이션 키 매칭 확인
#if UNITY_EDITOR
        // 타입 데이터 매칭 확인
        await CheckKeysExistInTableAsync("Type_ItemBoxTypeTable", ItemBoxType.GetValues().Values.ToList());
        // 게임 데이터 매칭 확인
		await CheckKeysExistInTableAsync("ItemBoxTable", ItemBoxData.Values.ToList());
#endif
    }
    
    
    // 특정 타입과 ID에 해당하는 게임 데이터를 반환하는 함수
    public T GetData<T>(int type, string id) where T : BaseData
    {
        if (_dataMap.TryGetValue(type, out var dict) && dict.TryGetValue(id, out var data))
            return data as T;

        Debug.LogError($"Data not found for type={type}, id={id}");
        return null;
    }
    
    
    // 게임 데이터 초기화 함수(팩토리 메서드 사용)
    private async UniTask InitGameDatas<T>(string chartName, Dictionary<string, T> target ,Func<LitJson.JsonData, T> factory) where T : BaseData
    {
        var rows = Server.LoadChart(chartName);
        foreach (LitJson.JsonData row in rows)
        {
            T data = factory(row);
            target.Add(data.Id, data);
        }
    }
    
    // 게임 데이터에 필요한 로컬라이제이션이 로컬라이제이션 테이블에 존재하는지 체크하는 함수
    private async UniTask CheckKeysExistInTableAsync<T>(string tableName, List<T> Data, bool CheckDesc = true) where T : ObjData
    {
        await LocalizationSettings.InitializationOperation.Task;

        // StringTableCollection이 아닌, Runtime에서 Table을 불러옴
        var table = await LocalizationSettings.StringDatabase.GetTableAsync(tableName);

        if (table == null)
        {
            Debug.LogError($"테이블 '{tableName}'을 찾을 수 없습니다.");
            return;
        }

        // StringTable의 ContainsKey 사용
        foreach (var data in Data)
        {
            var entry1 = table.GetEntry(data.NameLocaleKey);
            if (entry1 == null)
                Debug.LogError($"{tableName}의 {data.NameLocaleKey}를 찾을 수 없습니다.");
            if (CheckDesc)
            {
                var entry2 = table.GetEntry(data.DescLocaleKey);
                if (entry2 == null)
                    Debug.LogError($"{tableName}의 {data.DescLocaleKey}를 찾을 수 없습니다.");
            }
        }
    }
    
    
    // 타입 데이터에 필요한 로컬라이제이션이 로컬라이제이션 테이블에 존재하는지 체크하는 함수
    private async UniTask CheckKeysExistInTableAsync(string tableName, List<string> keys)
    {
        await LocalizationSettings.InitializationOperation.Task;
        // StringTableCollection이 아닌, Runtime에서 Table을 불러옴
        var table = await LocalizationSettings.StringDatabase.GetTableAsync(tableName);
        if (table == null)
        {
            Debug.LogWarning($"테이블 '{tableName}'을 찾을 수 없습니다.");
            return;
        }
        // StringTable의 ContainsKey 사용
        foreach (var key in keys)
        {
            var entry = table.GetEntry(key);
            if (entry == null)
                Debug.LogWarning($"{tableName}의 {key}를 찾을 수 없습니다.");
        }
    }
}

후기 및 추가 팁

로컬라이제이션과 어드레서블 둘 다 string key를 이용해 접근할 수 있엇던 것이 참 다행이다.

BaseGameData에서 로컬과 어드레서블에 대해 각각의 key들을 선언하고 할당해 주는데, 이들은
Id에서 파생되는 데이터이므로 그냥 각각의 Get 함수에서 하드코딩을 해 부르는 것이 메모리를 아끼는 데에 더 도움이 되었을 것 같긴 하다.

GameDataManager의 GetData 함수는 int type이라는 키가 아니라 Type형의 키를 사용해 해당 데이터 클래스를 제네릭에 추가하면 자연스럽게 id만 사용해서 가져올 수 있지 않을까 고민했는데,
csv나 엑셀 시트로 만들어지는 차트에는 typeof(Type)보다 미리 지정해 둔 int형 타입을 사용하는 것이 훨씬 편하다는 것을 깨달아 단순하게 구현하였다.

profile
학습한 내용을 빠르게 다시 찾기 위한 저장소

0개의 댓글