XR 플밍 - 9. UnityEngine3D 응용 프로그래밍 - 미니 프로젝트 - CSV/Json, 스크립터블 오브젝트(5/20)

이형원·2025년 5월 20일
0

XR플밍

목록 보기
79/215

1. CSV

게임을 이루는 기초 데이터 - 예시로 몬스터나 플레이어, 아이템의 기본 능력치 등의 데이터를 관리하기 위한 별도의 데이터베이스를 구성할 필요가 있다. 이런 데이터베이스를 구성하는 구조로서 활용할 수 있는 방법으로 CSV파일을 읽고 게임에 적용하는 방법을 사용할 수 있다.

1.1 CSV란?

Comma Separated Values

컴퓨터 용어로, 표 형태의 데이터를 저장하는 파일 형식이다. 주로 쓰이는 확장자는 .csv이며 MIME 형식은 text/csv이다.

예를 들어 이와 같이 엑셀 스프레드시트로 몬스터의 이름, 공격력, 방어력, 이동속도, 그리고 설명에 대한 데이터를 표로 작성했다고 해 보자.

이를 CSV 혹은 TSV로 다운로드하여 유니티에서 파싱이 가능하다.

1.2 CSV 사용의 이유

이때까지 데이터를 인스펙터 상으로 조작하고 빌드로 게임을 만들었었다. 하지만 이런 방식으로는 예를 들어 밸런스 패치를 하는 상황에서 계속해서 수치를 조정하고 다시 빌드하는 방식을 취해야 할 것이다.

따라서 사용 이유는 이와 같다고 할 수 있다.

  1. 단순 수치 조정 등의 데이터 변동 등의 상황에서, 데이터베이스를 따로 참고하는 방식으로 게임 수치 조정을 원활히 함
  2. 기획자의 경우 이와 같은 엑셀 - CSV 파일로 데이터를 다루는 경우가 익숙하여, 기획자와의 협업을 원활하게 함

다만 아래와 같이 사용하는 방식은 어디까지나 예시로, 보안 등의 문제에 취약하므로 일반적으로는 CSV 파일 자체로 바로 사용하지 않는다. (유저가 임의로 파일 변조가 가능하고, 핵의 제작 가능성이 큼)
CSV파일을 어떻게 다뤄야 하는지에 대해서 초점을 맞추고 공부해보자.

1.3 Unity CSV 파일

CSV파일을 만들었으니 해당 파일을 열어보자. CSV 파일을 열어보려고 하면 Visual Studio Code로 연결할 수 있다.
위와 같이 표로 만들었던 데이터가 어떻게 저장되어 있는지 확인하면, 아래와 같이 나온다.

표를 기준으로 저장했던 데이터가 쉼표로 구분되어 직렬화되어 있는 것을 확인할 수 있다.
이제 이 파일을 Unity에 옮겨보면 Text Asset으로 만들어지는 것을 확인할 수 있다.

1.4 CSV 파일 파싱

CSV 파일 파싱을 위한 구조를 아래와 같이 나눴다.

  • Csv 유틸 클래스(MonoBehaviour X)

    • 해당 클래스를 상속받고 데이터를 배열로 저장하는 CsvTable
    • 해당 클래스를 상속받고 데이터를 딕셔너리로 저장하는 CsvDictionary
  • 위의 두 가지 형태로 저장된 데이터를 읽는 유틸 클래스 CsvReader

  • Csv

using System.IO;
using UnityEngine;

namespace CustomUtility
{
    namespace IO
    {
        public class Csv
        {
            [SerializeField] private string _filePath;
            public string FilePath => Path.Combine(CsvReader.BasePath, _filePath);
            
            [field: SerializeField] public char SplitSymbol { get; private set; }

            protected Csv(string path, char splitSymbol)
            {
                _filePath = path;
                SplitSymbol = splitSymbol;
            }
        }
    }
}

CSV 구조를 위한 기본 클래스로, CSV 파일의 경로와 구분 기호를 처리하기 위한 공통 속성과 메서드를 제공한다.
이를 바탕으로 CsvTable과 CsvDictionary를 위한 공통 인터페이스를 제공한다.

  • CsvTable
using UnityEngine;

namespace CustomUtility
{
    namespace IO
    {
        [System.Serializable] public class CsvTable : Csv
        {
            public string[,] Table { get; set; }

            public CsvTable(string path, char splitSymbol) : base(path, splitSymbol) { }

			// 데이터 가져오기(행/열)
            public string GetData(int row, int column)
            {
                (int, int) rc = GetClampTableIndex(row, column);
                return Table[rc.Item1, rc.Item2];
            }

			// 데이터 가져오기(벡터)
            public string GetData(Vector2Int vector2)
            {
                (int, int) rc = GetClampTableIndex(vector2.y, vector2.x);
                return Table[rc.Item1, rc.Item2];
            }

			// 데이터 가져오기(특정 행의 데이터 전체)
            public string[] GetLine(int row)
            {
                int columns = Table.GetLength(1);
                string[] line = new string[columns];

                for (int c = 0; c < columns; c++)
                {
                    line[c] = Table[Mathf.Clamp(row, 0, Table.GetLength(0) - 1), c];
                }

                return line;
            }

            // 테이블 인덱스를 클램핑해 배열 범위 내로 조정한다.
            private (int, int) GetClampTableIndex(int r, int c)
            {
                return (Mathf.Clamp(r, 0, Table.GetLength(0) - 1),
                        Mathf.Clamp(c, 0, Table.GetLength(1) - 1));
            }
        }
    }
}

CSV 파일을 테이블(2차원 배열)로 표현하는 클래스. 클래스는 CSV 데이터를 행과 열 인덱스를 통해 접근할 수 있도록 한다. 행과 열 인덱스를 지정하여 데이터에 접근할 수 있다. 전체 행의 데이터를 검색하기 위한 메서드를 제공한다.

  • CsvDictionary
using System.Collections.Generic;
using UnityEngine;

namespace CustomUtility
{
    namespace IO
    {
        [System.Serializable] public class CsvDictionary : Csv
        {
            public Dictionary<string, Dictionary<string, string>> Dict { get; set; }
            public CsvDictionary(string path, char splitSymbol) : base(path, splitSymbol) { }

            // Dictionary에서 특정 셀의 데이터를 가져옴.
            public string GetData(string row, string column)
            {
                if (Dict.TryGetValue(row, out var columns) && columns.TryGetValue(column, out var value))
                {
                    return value;
                }

                Debug.LogError($"Data not found for row '{row}' and column '{column}'.");
                return null;
            }

            // 특정 행의 모든 데이터를 문자열 배열로 가져옴.
            public string[] GetLine(string row)
            {
                if (!Dict.ContainsKey(row))
                {
                    Debug.LogError($"Row '{row}' not found in the CSV data.");
                    return null;
                }

                Dictionary<string, string> rowData = Dict[row];
                string[] line = new string[rowData.Count];
                int index = 0;

                foreach (var kvp in rowData)
                {
                    line[index++] = kvp.Value;
                }

                return line;
            }
        }
    }
}

CSV 파일을 딕셔너리로 표현하는 클래스.

  • CsvReader
using System.Collections.Generic;
using System.IO;
using UnityEngine;

namespace CustomUtility
{
    namespace IO
    {
        public static class CsvReader
        {
            // 파일을 로드할 기본 경로를 가져옴.
            public static string BasePath
            {
                get
                {
#if UNITY_EDITOR
                    return Application.dataPath + Path.DirectorySeparatorChar;
#else
                    return Application.persistentDataPath + Path.DirectorySeparatorChar;
#endif
                }
            }

            // CSV 파일을 읽고 적절한 데이터 구조로 로드한다.
            public static void Read(Csv csv)
            {
                if (!IsValidPath(csv) || 
                    !IsValidEmpty(csv, out string[] lines))
                    return;

                bool isReadSuccessful;

                switch (csv)
                {
                    case CsvTable table:
                        isReadSuccessful = ReadToTable(table, lines);
                        break;
                    case CsvDictionary dictionary:
                        isReadSuccessful = ReadToDictionary(dictionary, lines);
                        break;
                    default:
                        isReadSuccessful = false;
                        break;
                }

                PrintResult(csv, isReadSuccessful);
            }

            // CSV 파일을 읽어 CsvDictionary로 로드한다.
            private static bool ReadToDictionary(CsvDictionary csv, string[] lines)
            {
                string[] fieldsIndex = lines[0].Split(csv.SplitSymbol);
                int columns = fieldsIndex.Length;
                csv.Dict = new Dictionary<string, Dictionary<string, string>>();

                for (int r = 1; r < lines.Length; r++)
                {
                    string[] fields = lines[r].Split(csv.SplitSymbol);

                    if (fields.Length < columns)
                    {
                        return false;
                    }

                    string rowKey = fields[0];
                    csv.Dict[rowKey] = new Dictionary<string, string>(columns);

                    for (int c = 1; c < columns; c++)
                    {
                        csv.Dict[rowKey][fieldsIndex[c]] = fields[c];
                    }
                }

                return true;
            }

            // CSV 파일을 읽어 2차원 배열(Table) 구조로 로드한다.
            private static bool ReadToTable(CsvTable csv, string[] lines)
            {
                string[] firstLineFields = lines[0].Split(csv.SplitSymbol);
                int rows = lines.Length;
                int columns = firstLineFields.Length;

                csv.Table = new string[rows, columns];

                for (int r = 0; r < rows; r++)
                {
                    string[] fields = lines[r].Split(csv.SplitSymbol);
                    if (fields.Length < columns)
                    {
                        return false;
                    }

                    for (int c = 0; c < columns; c++)
                    {
                        csv.Table[r, c] = fields[c];
                    }
                }

                return true;
            }

            // 지정된 경로에 파일이 존재하는지 검증한다.
            private static bool IsValidPath(Csv csv)
            {
                if (!File.Exists(csv.FilePath))
                {
#if UNITY_EDITOR
                    Debug.LogError($"Error: CSV file not found at path: {csv.FilePath}");
#endif
                    return false;
                }
                return true;
            }

            // 파일의 모든 줄을 읽고 비어 있는지 확인한다.
            private static bool IsValidEmpty(Csv csv, out string[] lines)
            {
                lines = File.ReadAllLines(csv.FilePath);

                if (lines.Length == 0)
                {
#if UNITY_EDITOR
                    Debug.LogError($"Error: CSV file at path {csv.FilePath} is empty.");
#endif
                    return false;
                }
                return true;
            }

            // CSV 파일 로딩 프로세스의 결과를 로그로 출력한다.
            private static void PrintResult(Csv csv, bool result)
            {
#if UNITY_EDITOR
                if (result)
                {
                    Debug.Log($"Successfully loaded CSV file from path: {csv.FilePath}");
                }
                else
                {
                    Debug.LogError($"Error: CSV file at path {csv.FilePath} has inconsistent column lengths.");
                }
#endif
            }
        }
    }
}

CSV 파일을 읽어 다양한 데이터 구조로 로드하는 정적 클래스. 이 클래스는 CSV 데이터를 2D 배열 또는 Dictionary 구조로 로드하는 것을 지원한다.

1.5 예시 사용 방법

예시로 아래와 같이 DataManager와 코드를 만들어보자.
예외처리에 대한 부분이 필요하지만, 지금은 정확히 입력한다는 가정 하에 진행해보자.

  • DataManager
using CustomUtility.IO;
using UnityEngine;

public class DataManager : MonoBehaviour
{
    [field: SerializeField] public CsvTable MonsterCSV { get; private set; }
    [field: SerializeField] public CsvDictionary MonsterDic { get; private set; }

    private void Awake() => Init();

    private void Init()
    {
        CsvReader.Read(MonsterCSV);
        CsvReader.Read(MonsterDic);
    }
}

public enum MonsterData
{
    Name = 1,
    Atk,
    Dfe,
    Spd,
    Dsc
}
  • 출력 부분
using UnityEngine;

public class MonsterDataOutput : MonoBehaviour
{
    public DataManager Data;
    public MonsterType MonsterType;

    [SerializeField] private string _name;
    [SerializeField] private int _atk;
    [SerializeField] private int _dfe;
    [SerializeField] private int _spd;
    [SerializeField] private string _dsc;

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space)) Init(MonsterType);
    }

    private void Init(MonsterType monsterType)
    {
        _name = Data.MonsterCSV.GetData((int)monsterType, (int)MonsterData.Name);
        _atk = int.Parse(Data.MonsterCSV.GetData((int)monsterType, (int)MonsterData.Atk));
        _dfe = int.Parse(Data.MonsterCSV.GetData((int)monsterType, (int)MonsterData.Dfe));
        _spd = int.Parse(Data.MonsterCSV.GetData((int)monsterType, (int)MonsterData.Spd));
        _dsc = Data.MonsterCSV.GetData((int)monsterType, (int)MonsterData.Dsc);
    }
}

public enum MonsterType
{
    Slime = 1,
    Mushroom,
    Erdas,
    Lucid
}

이와 같이 몬스터 Enum을 설정해주고 스페이스를 누르면 데이터를 불러오는 구조를 만들어보았다.

2. JSON, Binary File

게임의 저장 기능을 구현하기 위해, 여러가지 데이터 직렬화 포맷을 사용할 수 있다.
유니티에서도 각 설정들과 오브젝트의 상태, 위치 등을 저장하기 위해 직렬화된 meta파일을 사용해 상태를 저장한다. 게임에서 플레이어의 스탯 변화를 저장하고, 다음 게임 구동시 불러오기 위해 Json을 사용해 세이브/로드 기능을 구현해 보도록 한다.

2.1 JSON 이란?

JSON은 JavaScript Object Notation 의 약자이다.
직역하면 '자바 스크립트 객체 표기법'으로 데이터를 쉽게 교환하고 저장하기 위한 텍스트 기반의 데이터 교환 표준이다.

2.2 Binary 형식이란?

이진 파일은 0과 1로 이루어진 이진 데이터를 담고 있는 파일 형식이다. 텍스트 파일과는 대조적으로 텍스트 형식이 아닌 이진 데이터로 구성되어 있다.

2.3 데이터 저장에 있어서의 JSON vs Binary

1. JSON이 많이 사용되는 이유

  • 인간 친화적이고 가독성이 좋음
    JSON은 텍스트 형식으로 저장되기 때문에 사람이 직접 파일 내용을 열어보고 이해하기 쉽다. 따라서 디버깅에도 유리하다

  • 범용성
    JSON은 다른 플랫폼이나 언어와의 호환성이 매우 뛰어나다.

  • 유니티에서 기본적으로 지원
    유니티의 JsonUtility 클래스는 JSON 직렬화 및 역직렬화를 쉽게 처리할 수 있도록 기본적으로 제공됨.
    추가 라이브러리를 설치할 필요 없이 바로 사용할 수 있어 개발이 간편함.

  • 버전 관리 용이
    JSON은 텍스트 기반이라 Git과 같은 버전 관리 시스템에서 변경된 내용을 추적하기 쉬움.
    Binary 파일은 변경된 내용을 확인하기 어렵지만, JSON은 한눈에 비교할 수 있어 협업에서 유리함.

2. Binary 방식이 JSON보다 유리한 경우

  • 속도와 파일 크기 최적화
    Binary 파일은 크기가 더 작고, 읽고 쓰는 속도가 JSON보다 빠를 수 있음.

  • 보안성
    사람이 내용을 쉽게 이해할 수 없기 때문에 데이터를 보호하는 데 유리할 수 있음.

2.4 코드 예시

public class JsonSupport
{
   // 세이브
   public void Save(object Target)
   {
       string filePath = Application.dataPath + "/Database/SaveData";
       Directory.CreateDirectory(filePath);

       filePath += "/" + Target.GetType().Name + ".json";

       string jsonString = JsonUtility.ToJson(Target);

       BinaryFormatter bf = new BinaryFormatter();
       using (FileStream fs = new FileStream(filePath, FileMode.Create))
       {
           byte[] bytes = Encoding.Default.GetBytes(jsonString);
           bf.Serialize(fs, bytes);
       }
   }
   
   // 로드
   public void Load(object Target)
   {
       string filePath = Application.dataPath + "/Database/SaveData";
       Directory.CreateDirectory(filePath);

       filePath += "/" + Target.GetType().Name + ".json";

       if(File.Exists(filePath))
       {
           BinaryFormatter bf = new BinaryFormatter();
           using(FileStream fs = new FileStream(filePath, FileMode.Open))
           {
               byte[] bytes = (byte[])bf.Deserialize(fs);
               string jsonString = Encoding.Default.GetString(bytes);
               JsonUtility.FromJsonOverwrite(jsonString, Target);
           }
       }
   }
}

3. Scriptable Object

3.1 Scriptable Object란?

ScriptableObject는 Unity에서 제공하는 데이터 저장 객체로, 게임 데이터를 관리하고 여러 인스턴스에서 공유할 수 있도록 돕는다. 이는 MonoBehaviour처럼 게임 오브젝트에 붙는 컴포넌트는 아니지만, 에디터나 런타임에서 데이터 저장소로 활용될 수 있다.

3.2 Scriptable Object를 사용하는 이유

  • 메모리 공간 절약 - MonoBehaviour보다 가볍다. 특히 프리팹에서 데이터의 변화가 없는 동일한 프리팹을 많이 만들어야 하는 경우, MonoBehaviour로 프리팹화한 프리팹보다 최적화의 효과가 있다.
  • 스크립터블 오브젝트를 통해 데이터와 로직을 분리할 수 있으며, 게임 데이터를 저장하고 테스트하기 편리하게 만든다.

3.3 Scriptable Object 사용 예시

예시로 플레이어가 포션을 취득하고 사용하는 상황을 생각해보자.

Scriptable Object를 만들기 위해서는 Scriptable 을 상속한 스크립트가 필요하다.

ItemData 추상클래스를 선언해보자.

  • ItemData
using UnityEngine;

public abstract class ItemData : ScriptableObject
{
    public string Name;

    [TextArea] public string Description;
    public Sprite Icon;
    public GameObject Prefab;

    public abstract void Use(PlayerController controller);
}

여기서 [TextArea]는 여러 줄 이상의 장문의 텍스트를 입력할 수 있도록 해 주는 기능이다.

이제 이 클래스를 상속하여, HpPotion 스크립트를 만들어보자.

  • HpPotion
using UnityEngine;

[CreateAssetMenu(fileName = "Hp Potion", menuName = "Scriptable Objects/Hp Potion", order = 1)]
public class HpPotion : ItemData
{
    public int Value;

    public override void Use(PlayerController controller)
    {
        controller.Recover(Value);
    }
}

이와 같이 스크립트를 만든 후, 스크립터블 오브젝트를 만들어보자.
Project에서 우클릭을 하면, 아래와 같이 맨 위에 Scriptable Objects가 추가된 것을 알 수 있다.

이걸 생성하면 아래와 같은 아이콘이 생성되고, 데이터를 입력해주면 된다.
작은 포션과 노말 포션을 만들어보도록 한다.

위에서도 언급했다시피 Scriptable Object는 컴포넌트로 추가할 수 없다. 따라서 아래와 같은 Scriptable Object의 정보를 가져오는 스크립트를 만들어, 프리팹으로 만든다.

using UnityEngine;

public class ItemObject : MonoBehaviour
{
    [field: SerializeField] public ItemData Data { get; private set; }
    private GameObject _childObject;

    private void OnEnable()
    {
        _childObject = Instantiate(Data.Prefab, transform);
    }

    private void OnDisable()
    {
        Destroy(_childObject);
    }
}

이제 플레이어가 아이템을 취득하고 사용할 수 있도록, 플레이어 컨트롤과 인벤토리를 만들어보자.
예외처리나 아이템 판별 등의 내용도 필요하지만, 지금은 최대한 간단하게 만들고 테스트의 용도로만 제작했다.

  • PlayerController
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private int _hp;
    private Inventory _inventory;

    private void Awake() => Init();

    private void Init()
    {
        _inventory = GetComponent<Inventory>();
    }

    private void OnTriggerEnter(Collider other)
    {
        Debug.Log("충돌");
        _inventory.GetItem(other.GetComponent<ItemObject>().Data);
        other.gameObject.SetActive(false);
    }

    private void Update()
    {
        if(Input.GetMouseButtonDown(0))
        {
            _inventory.UseItem(0);
        }
    }

    public void Recover(int value)
    {
        _hp += value;
    }
}
  • Inventory
using System.Collections.Generic;
using UnityEngine;

public class Inventory : MonoBehaviour
{
    [SerializeField] private List<ItemData> _slots = new();

    private PlayerController _controller;

    private void Awake() => Init();

    private void Init()
    {
        _controller = GetComponent<PlayerController>();
    }

    public void GetItem(ItemData itemData)
    {
        _slots.Add(itemData);
    }

    public void UseItem(int index)
    {
        _slots[index].Use(_controller);
        _slots.RemoveAt(index);
    }
}

이와 같이 만들고 세팅은 아래와 같이 하면 된다.

이와 같이 세팅하고 테스트를 해보자.

아이템을 먹고 사용하는 것까지 구현된 것을 확인할 수 있다.

profile
게임 만들러 코딩 공부중

0개의 댓글