목차
01. 목표와 구조
목표 : 무역 게임에 맞는 시세 변동 시스템을 위한 기본 구조
수요와 공급을 토대로 가격과 수량이 변동하는 시스템을 구현하고자 했다.
수십개의 아이템이 열개가 넘는 행성에서 각각 다른 가격 , 수요, 공급을 지니고 있어
체계적으로 데이터를 관리할 필요가 있었다.
관리해야 하는 데이터가 저번 게임보다 훨씬 많았기에, 데이터를 한 눈에 보고 관리할 수 있었으면 좋겠다는 생각을 했다.
따라서 CSV 파일 형식으로 보고 수정하기 위해 다음과 같은 구현을 시작하게 되었다.

전체 형식은 위 사진과 같다.
아이템에 관한 정보는 다음 세 가지로 나뉜다.
1. 변동 정보 ( 시세, 수량, 수요, 공급 )
2. 기본/고정 아이템 정보 ( 기본가, 단계, 최대가격, 최소 가격, 변동폭, 설명 )
3. 기본/고정 아이템 정보 ( icon, blockSprite, blockType )
2번은 int나 string처럼 단순한 값이기 때문에 CSV 파일에서 쉽게 조작 가능하다.
반면, 3번은 Sprite나 프로젝트 내부의 enum 타입을 포함하고 있어 CSV로는 다루기 어려운 구조이다.
더해서 2번은 혹시 특별한 이벤트가 발생하면 바뀔 수 있지만, 3번은 무슨 일이 있어도 고정이다.
따라서 3번은 SO(ScriptableObject, 이하 SO)로 만들어 관리해 주는 것이 효율적이라고 생각했다.
1번과 2번 정보는 변동 가능성이 있기 때문에 TradeManager에서 직접 관리하도록 했다.
다만, 3번 정보는 바뀔 일이 없고, 후에 아이템을 아이콘이나 블럭스프라이트를 세팅할 UI 패널에서 TradeManager를 참조하는 것은 너무 강한 결합이기 때문에 GlobalInfo에 따로 저장한다.
* 1번과 2번 정보는 '변동 가능성'이 있기 때문에 매번 UI를 세팅할 때 전달해 주는 것이 맞으나, 3번 정보는 변하지 않으므로 매 번 전달하는 것이 비효율적이라고 판단, 그렇다고 TradeManager를 참조하는 것은 강한 결합의 문제가 될 수 있다고 생각.
02. 코드와 구현
- 1 : JSON화 시키기 편한 것들
다음은 DataManager에 있는 CSV파일을 JSON으로 바꾸는 코드의 예시이다.
* enum으로 선언한 Type들이나 ItemTradeData등의 클래스는 맨 밑에 적어두었습니다.
public void LoadFromCSV_MarketStateToJSON(string csvPath)
{
string[] csvData = File.ReadAllLines(csvPath);
Dictionary<PlanetType, List<ItemTradeData>> tempDict = new();
// CSV 파일의 첫 줄은 헤더임.
foreach (string line in csvData.Skip(1))
{
string[] parts = line.Split(',');
//Enum.Parse는 이름기준으로 찾는다 => 정리 요망
PlanetType planet = Enum.Parse<PlanetType>(parts[0].Trim());
ItemType item = Enum.Parse<ItemType>(parts[1].Trim());
int price = int.Parse(parts[2]);
int amount = int.Parse(parts[3]);
int demand = int.Parse(parts[4]);
int supply = int.Parse(parts[5]);
// 매개변수 없는 초기화 / 기본생성자 필요함. ( 일단 해둠, 나중에 매개변수 넣는 것으로 바꿀 수도 ? )
ItemTradeData data = new ItemTradeData
{
itemType = item,
price = price,
amount = amount,
demand = demand,
supply = supply,
};
// 없으면 만든다.
if (!tempDict.ContainsKey(planet))
{
tempDict.Add(planet, new List<ItemTradeData>());
}
// ItemTradeData 에 이번 아이템 데이터 추가
// 이 방식은 CSV파일과 JSON Data사이에 변형 느낌입니다.
// CSV 파일은 엑셀 형식이라 같은 Planet일지라도 다 다시 써 줘야 합니다. 따라서 중복 방지를 위한 코드
tempDict[planet].Add(data);
}
//이제 모든 행성 - 아이템 딕셔너리를 묶어서 하나에서 관리해야 함.
TradeSaveData saveData = new TradeSaveData();
foreach (var kvp in tempDict)
{
// 원래 있던 형식으로 변형
PlanetMarketSaveData planetData = new PlanetMarketSaveData
{
planet = kvp.Key,
items = kvp.Value
};
saveData.planets.Add(planetData);
}
string json = JsonConvert.SerializeObject(saveData, Formatting.Indented);
#if UNITY_EDITOR
string path = _testMarketStateDataPath;
#else
string path = _realMarketStateDataPath
#endif
File.WriteAllText(path, json);
#if UNITY_EDITOR
UnityEditor.AssetDatabase.Refresh();
#endif
Debug.Log(" 변동 거래 데이터 저장 완료");
}
CSV 파일은 결국 ','로 구분지어져 있는 파일이기 때문에 순서에 맞춰 알맞은 값을 넣어줬다.
다만 CSV 파일에서는 중복되는 값들이 있어 비효율적일 수 있기 때문에 ( 예를 들어 한 행성에 수십개의 아이템이 있는데 CSV파일에서는 행성을 아이템 수만큼 반복함 ) 같은 행성이라면 같은 리스트에 넣어줬다.
이렇게 저장한 CSV 파일들은 아래의 TradeManager에서 호출하는 아래의 함수로 TradeManager의 데이터를 수정하게 된다.
public void LoadTradeDataFromJson(Dictionary<PlanetType, PlanetMarket> allPlanetMarkets, Dictionary<ItemType, DefaultItemTradeData> allDefaultItemTradeData)
{
#if UNITY_EDITOR
string path = _testMarketStateDataPath;
#else
_realMarketStateDataPath;
#endif
if (!File.Exists(path))
{
Debug.Log("변동 아이템 거래 데이터 파일이 존재하지 않음");
return;
}
string json = File.ReadAllText(path);
TradeSaveData saveData = JsonConvert.DeserializeObject<TradeSaveData>(json);
allPlanetMarkets.Clear();
foreach (var kvp in saveData.planets)
{
PlanetMarket planetMarket = new PlanetMarket();
// 안에 있는 모든 itemTradeData들을 저장해야함
foreach (var item in kvp.items)
{
planetMarket.itemTradeDatas[item.itemType] = item;
}
// 하나의 행성안에 있는 모든 아이템데이터들을 리스트에 설정했으면, 이제 행성을 설정할 차례
allPlanetMarkets.Add(kvp.planet, planetMarket);
}
Debug.Log(" 트레이드 데이터 중간저장 완료");
#if UNITY_EDITOR
path = _testDefaultItemTradeDataPath;
#else
path = _realDefaultItemTradeDataPath;
#endif
if (!File.Exists(path))
{
Debug.Log("고정 아이템 거래 데이터 파일이 존재하지 않음");
return;
}
string json2 = File.ReadAllText(path);
DefaultItemTradeDataList defaultItemTradeData = JsonConvert.DeserializeObject<DefaultItemTradeDataList>(json2);
allDefaultItemTradeData.Clear();
foreach (var kvp in defaultItemTradeData.items)
{
allDefaultItemTradeData.Add(kvp.itemType, kvp);
}
Debug.Log(" 고정 아이템 데이터 파일 저장 완료");
}
JSON파일을 각각의 형태에 맞게 읽어주고, TradeManager에서 받은 참조형 변수인 Dictinoary에 넣어준다.
* 고정 아이템 파일이라는 명칭은 혹시 바뀔 수도 있지만 대부분의 상황에서 고정이라 '고정'이라는 명칭을 붙였다.
그리고 TradeManager에서 시작할 때 데이터를 읽어오면 된다.
public void Start()
{
DataManager.instance.LoadTradeDataFromJson(allPlanetMarket, allDefaultItemTradeData);
}
이런 방식으로 관리하게 된다면, 혹시 나중에 세이브/로드 상황에서도 편리하다.
런타임에는 동적으로 TradeManager에 있는 변수들의 값을 변동시키다 저장이 필요할 때 JSON화 시켜서 저장하게 된다면
다음 게임을 시작할 때에는 자동으로 그 데이터들을 불러오게 된다.
* 참고로 JSON은 Dictionary형태를 잘 인지하지 못하기 때문에 List화 시켰다.
** 참고 : 에디터용 코드 , 인스펙터 창에서 CSV데이터를 JSON 데이터로 바꿔줄 수 있다. **
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(DataManager))]
public class DataManagerEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
DataManager dataManager = (DataManager)target;
if (GUILayout.Button("CSV -> JSON 변동 거래 데이터 파일 저장"))
{
dataManager.LoadFromCSV_MarketStateToJSON();
}
if (GUILayout.Button("CSV -> JSON 고정 아이템 데이터 파일 저장"))
{
dataManager.LoadFromCSV_DefaultItemTradeDataToJSON();
}
GUILayout.Space(30);
if (GUILayout.Button("변동 아이템 데이터 JSON 파일 삭제"))
{
dataManager.DeleteMarketData();
}
if (GUILayout.Button("고정 아이템 데이터 JSON 파일 삭제"))
{
dataManager.DeleteMarketData();
}
}
}
- 2 : JSON화 시키기 어려운 Sprite 등의 요소 - Scriptable Object
유니티 6를 사용하고 협업을 하게 되면서 개인적으로 구매한 Odin Inspector나 무료인 Serialized Dictionary를 사용하지 못하게 되었다. 따라서 인스펙터 창에서 Dictionary를 작업할 수 없게 되면서 List로 만들고, 확인하고 그 후 Dictionary로 저장하는 방법을 사용했다.
협업 환경에서 개인 에셋(Odin Inspector)을 사용할 수 없고, 무료 에셋 (Serialized Dictionary)이 Unity 6환경에서 지원되지 않아 Inspector에서 Dictionary를 직접 다루기 어려워졌다.
아이템이 프로토타입에서는 4개지만 이후 수십개로 늘어날 것을 대비해 자동으로 아이템 SO들을 생성해줬으면 좋겠다고 생각했다.
그래서 자동으로 ScriptableObject를 생성하는 Generator 코드를 작성했다.
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using System.IO;
public static class ItemDataSOGenerator
{
[MenuItem("Tools/Generate ItemData SOs")]
public static void GenerateItemData()
{
string ItemDataSOPath = "Assets/Data/ItemDataSO/SOs";
string ItemDataSOListPath = "Assets/Data/ItemDataSO/";
string resourcesFolder = "Assets/Resources/ItemDataSO";
if (!Directory.Exists(resourcesFolder))
Directory.CreateDirectory(resourcesFolder);
if (!Directory.Exists(ItemDataSOPath))
Directory.CreateDirectory(ItemDataSOPath);
List<ItemDataSO> createdItems = new List<ItemDataSO>();
foreach (ItemType type in System.Enum.GetValues(typeof(ItemType)))
{
string assetPath = $"{ItemDataSOPath}/{type}.asset";
ItemDataSO so;
if (File.Exists(assetPath))
{
so = AssetDatabase.LoadAssetAtPath<ItemDataSO>(assetPath);
}
else
{
so = ScriptableObject.CreateInstance<ItemDataSO>();
so.itemType = type;
AssetDatabase.CreateAsset(so, assetPath);
AssetDatabase.SaveAssets();
}
createdItems.Add(so);
}
Debug.Log("모든 아이템의 SO가 생성되었습니다.");
string listAssetPath = $"{resourcesFolder}/ItemDataList.asset";
ItemDataSoList listSO;
if (File.Exists(listAssetPath))
{
listSO = AssetDatabase.LoadAssetAtPath<ItemDataSoList>(listAssetPath);
}
else
{
listSO = ScriptableObject.CreateInstance<ItemDataSoList>();
AssetDatabase.CreateAsset(listSO, listAssetPath);
}
listSO.items = createdItems;
EditorUtility.SetDirty(listSO); // 변경사항 반영
AssetDatabase.SaveAssets();
Debug.Log("모든 ItemDataSO 및 ItemDataListSO가 생성되었습니다.");
}
}
#endif
모든 아이템 타입에 대한 SO를 자동으로 생성하고, 이들을 포함한 리스트(SO)도 함께 생성해 관리한다.
public void LoadItemDataSOListToDictionary()
{
Dictionary<ItemType, ItemDataSO> tempDict = new();
Debug.Log(GlobalInfo.instance.PATH_ITEMDATASOLIST);
ItemDataSoList itemDataList = Resources.Load<ItemDataSoList>(GlobalInfo.instance.PATH_ITEMDATASOLIST);
if (itemDataList == null)
{
Debug.LogError("ItemDataListSO를 Resources에서 불러올 수 없습니다.");
return;
}
foreach (var item in itemDataList.items)
{
if (item == null) continue;
if (tempDict.ContainsKey(item.itemType)) continue;
tempDict.Add(item.itemType, item);
}
Debug.Log($"아이템 {tempDict.Count}개를 Dictionary로 로드 완료");
itemDataSO = tempDict;
}
위의 데이터를 저장할 GlobalInfo에서 List만 가져와 Type을 바탕으로 한 Dictionary를 저장한다.
- 3 : 참고 - 기본 데이터 구조 ( enum, class )
public enum PlanetType
{
// None은 혹시 몰라서 넣어뒀습니다.
None,
Aretium, // 아레티움
KasarilIV, // 카사릴 4
Novarion, // 노바리온
Altair, // 알타이르
Silvarek // 실바렉
}
public enum ItemType
{
None, // 기본값 또는 알 수 없는 아이템을 나타낼 때 사용
NutrientPaste, // 영양 페이스트
CryoCompressionWater, // 저온 압축수
TritaniumAlloy, // 트리타늄 합금
NanoRepairDrone // 나노 정비 드론
}
public enum BlockType
{
None,
Block_1x1, // 가장 작은 블록
Block_2x1,
Block_L, // 2 x 1 에 한 칸이 더 붙은 블록
Block_2x2,
}
public class ItemTradeData :IComparable<ItemTradeData>
{
public ItemType itemType;
public int price;
[FormerlySerializedAs("quantity")] public int amount;
public int demand;
public int supply;
public ItemTradeData()
{
}
public ItemTradeData(ItemType itemType, int price,int amount, int demand = 0, int supply = 0)
{
this.itemType = itemType;
this.price = price;
this.amount = amount;
this.demand = demand;
this.supply = supply;
}
public int CompareTo(ItemTradeData other)
{
// itemType의 이름을 기준으로 정렬 (가나다순/사전순)
return this.itemType.ToString().CompareTo(other.itemType.ToString());
}
}
public class DefaultItemTradeData
{
public ItemType itemType;
public int defaultPrice;
public int step;
public int MaxPrice;
public int MinPrice;
public int Fluctuation;
public string Description;
}
[CreateAssetMenu(fileName = "ItemData", menuName = "Game/ItemData")]
public class ItemDataSO : ScriptableObject
{
public ItemType itemType;
public Sprite icon;
public BlockType blockType;
public Sprite blockSprite;
}
프로젝트 규모가 커지거나 무역 게임처럼 데이터 양이 많아질수록 JSON 기반 데이터 관리가 필수라고 판단했다.
이에 대비해 구조적으로 관리할 수 있도록 체계를 잡고자 했다.
특히 CSV 파일은 한눈에 데이터를 파악하고 직접 수정하기 용이해서 기획 측면에서도 효율적이었다.
조금 아쉬운 부분이 곳곳에 있긴 하지만 , 일단은 이 정도로 만족하고 다른 기능 구현에 집중하려 한다.