📕학습 개요

유니티 캠프 9일차.

프로젝트 제출이 내일까지여서 오늘은 TRPG 만들기에 집중했다. 도전 기능 중 게임 저장하기 기능을 만드는 데만 하루종일 걸렸다.

📖학습 내용

JSON을 활용한 데이터 관리

어제 아이템 데이터를 JSON파일로 분리시키는데 이어서, 오늘은 플레이어의 초기 설정값 정보를 따로 분류하여 데이터화 시켜보았다. 이렇게 한 이유는 게임을 처음 시작할 때, 직업마다 초기 스탯을 조금씩 다르게 설정하고 싶었기 때문이다. 애초에 전사와 마법사의 체력과 방어력이 똑같은게 이상하니까…

{
  "BaseWarriorData": {
    "BaseFullHP": 100,
    "BaseAttack": 10,
    "BaseDefense": 5,
    "Gold": 1000,
    "ExpThresholds": [1, 3, 5, 10],
    "MaxLevel": 5
  },
  "BaseMageData": {
    "BaseFullHP": 60,
    "BaseAttack": 15,
    "BaseDefense": 3,
    "Gold": 1000,
    "ExpThresholds": [1, 3, 5, 10],
    "MaxLevel": 5
  },
  "BaseArcherData": {
    "BaseFullHP": 80,
    "BaseAttack": 12,
    "BaseDefense": 4,
    "Gold": 1000,
    "ExpThresholds": [1, 3, 5, 10],
    "MaxLevel": 5
  }
}

이제 저장용 클래스를 따로 만들어준다.

internal class PlayerConfig   //각 직업별 PlayerData의 초기 설정값을 저장하는 클래스
    {
        public PlayerData BaseWarriorData { get; set; }
        public PlayerData BaseMageData { get; set; }
        public PlayerData BaseArcherData { get; set; }

    }

이제 원하는 곳에서 데이터를 불러와서 사용해주면 끝이다.

public void LoadDefaultData()
        {
            //플레이어 기본 데이터 파일 읽어오기
            if (!ConfigLoader.TryLoad<PlayerConfig>("player_config.json", out var config))
            {
                Console.WriteLine("플레이어 설정을 불러오지 못했습니다.");
                return;
            }
            PlayerData defaultData;
            switch (Job)
            {
                case Job.Warrior:   //전사
                    defaultData = config.BaseWarriorData;
                    break;
                case Job.Mage:      //마법사
                    defaultData = config.BaseMageData;
                    break;
                case Job.Archer:    //궁수
                    defaultData = config.BaseArcherData;
                    break;
                default:
                    defaultData = config.BaseWarriorData;
                    break;
            }
            MaxLevel = defaultData.MaxLevel;
            ExpThresholds = defaultData.ExpThresholds;
            BaseFullHP = defaultData.BaseFullHP;
            CurrentHP = BaseFullHP;
            BaseAttack = defaultData.BaseAttack;
            BaseDefense = defaultData.BaseDefense;
            Gold = defaultData.Gold;

        }

JSON파일을 읽어오는 기능을 재활용하기 위해 읽기 전용 클래스도 따로 만들었다.

   internal static class ConfigLoader
    {
        public static bool TryLoad<T>(string path, out T result)
        {
            try
            {
                string json = File.ReadAllText(path);
                var options = new JsonSerializerOptions
                {
                    Converters = { new JsonStringEnumConverter() }  //Enum 대응
                };
                result = JsonSerializer.Deserialize<T>(json, options);
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"게임 데이터 로드 중 오류 발생: {ex.Message}");
                result = default(T);
                return false;
            }
        }
    }

JSON파일을 사용하면서 느낀 점은 역시 정말 편하다는 점이다. 사용법도 직관적이고, 대충 만들어놓고 실행해보면 “이게 되네?” 라고 느낀 순간이 많았다. 물론 오류가 적잖게 발생하긴 했지만, 대부분 JSON 파일 데이터 타입의 이름과 코드 내 데이터 타입의 이름이 일치하지 않아서 생긴 오류였다. 데이터를 할당받을 프로퍼티를 private set으로 지정해서 데이터를 읽어오지 못하는 경우도 있었다.


게임 저장 기능 만들기

처음에는 게임 저장 기능이 구현하기 은근 쉬울 것이라 생각했다. 이제 JSON의 사용법을 조금 익혔으니 데이터 저장 파일을 하나 생성하고, 게임 시작 시 해당 파일이 없으면 새로운 게임을 시작하고 아니면 그 파일을 읽어와서 데이터를 불러오면 되는 것 까지는 알 수 있었다. 심지어 지금 게임의 유동적 정보들은 전부 플레이어 객체와 아이템 객체에 몰려있기에, 플레이어 객체와 아이템 리스트에 대한 정보만 저장해놓으면 어렵지 않게 게임을 불러올 수 있다고 생각했다.

실제로 플레이어 객체 정보를 저장할 때 까지는 문제가 없어 보였다.

string json = JsonSerializer.Serialize(player);
File.WriteAllText("save.json", json);

하지만 실행 결과는 문제가 많았다. 불러온 플레이어 객체를 읽어오는 과정에서 무수히 많은 NullException이 날라왔다. 문제는 접근 제한자에 있었다. 모든 클래스와 속성들은 public이어야 직렬화가 가능하기 때문이다.

그렇다고 그 많은 클래스들과 프로퍼티를 public으로 바꾸고 싶지는 않았다. 캡슐화 원칙에 위반되기 때문이다. 결국 오랜 시간을 검색해본 결과, 데이터만 따로 저장하는 클래스를 따로 만들어서 저장하기로 했다.

  • 플레이어 정보 저장 전용 클래스
    public class PlayerData
    {
        public string Name { get; private set; }    //이름
        public Job Job { get; private set; }    //직업
        public int Level { get; private set; } = 1; //레벨
        public int MaxLevel { get; set; }   //만랩
        public int Experience { get; private set; }   //경험치
        public int[] ExpThresholds { get; set; } //경험치 상한선
        public int BaseFullHP { get; set; }    //최대 체력
        public int CurrentHP { get; set; }      //현재 체력
        public int BaseAttack { get; set; }     //기본 공격력
        public int BaseDefense { get; set; }    //기본 방어력
        public int Gold { get; set; }   //골드

        public PlayerData(string Name, Job job, int level, int maxLevel, int experience, int[] expThresholds,
            int baseFullHP, int currentHP, int baseAttack, int baseDefense, int gold)
        {
            this.Name = Name;
            Job = job;
            Level = level;
            MaxLevel = maxLevel;
            Experience = experience;
            ExpThresholds = expThresholds;
            BaseFullHP = baseFullHP;
            CurrentHP = currentHP;
            BaseAttack = baseAttack;
            BaseDefense = baseDefense;
            Gold = gold;
        }
    }
  • 아이템 정보 저장 전용 클래스
 public class ItemData   //json 파일 아이템 리스트 저장용 클래스
    {
        public List<ItemInfo> Items { get; private set; }

        public List<ItemInfo> Equipments { get; set; } = new List<ItemInfo>();  //장비 아이템 리스트
        public List<ItemInfo> Usables { get; set; } = new List<ItemInfo>();  //소비 아이템 리스트
        public List<ItemInfo> Others { get; set; } = new List<ItemInfo>();  //기타 아이템 리스트

        public ItemData(List<ItemInfo> items)
        {
            Items = items;
            foreach (ItemInfo info in Items)
            {
                switch (info.ItemType)
                {
                    case ItemType.Equipment:
                        Equipments.Add(info);
                        break;
                    case ItemType.Usable:
                        Usables.Add(info);
                        break;
                    case ItemType.Other:
                        Others.Add(info);
                        break;
                    default:
                        break;
                }
            }
        }
    }
  public struct ItemInfo    //아이템 정보를 관리하는 구조체
    {
        //공용 필드
        public string Name { get; set; }
        public string Description { get; set; }
        public int Price { get; set; }
        public ItemType ItemType { get; set; }
        public bool IsForSale { get; set; }  //판매 여부

        //장비 전용 필드
        public EquipType EquipType { get; set; }
        public Stat? Stat { get; set; }
        public int? StatValue { get; set; }
        public bool IsEquipped { get; set; } //플레이어 착용 여부

        //장비 전용 생성자
        public ItemInfo(string name, ItemType itemType, EquipType equipType, Stat stat, int statValue, string description, int price,
            bool isForSale, bool isEquipped)
        {
            Name = name;
            EquipType = equipType;
            Stat = stat;
            StatValue = statValue;
            Description = description;
            Price = price;
            ItemType = itemType;
            IsForSale = isForSale;
            IsEquipped = isEquipped;
        }
    }

이제 게임 저장/로드 시 사용할 정보 저장 래퍼(Wrapper) 클래스를 만들어주면 된다.

  public class GameSaveData   //게임 데이터 저장용 래퍼 클래스
    {
        public PlayerData PlayerData { get; set; }
        public List<ItemInfo> ItemData { get; set; }

        public GameSaveData(PlayerData playerData, List<ItemInfo> itemData)
        {
            PlayerData = playerData;
            ItemData = itemData;
        }
    }
  • 게임 저장 과정
  1. 현재 사용중인 player 객체, 아이템 리스트를 사용해서 GameSaveData객체를 만든다.
  2. JSON 파일로 직렬화
  3. 지정된 경로로 파일 작성
 void SaveData()     //게임 저장
        {
            itemDatas = new List<ItemInfo>();
            foreach (ITradable item in itemList)
            {
                itemDatas.Add(item.GetItemInfo());
            }
            GameSaveData gameSaveData = new GameSaveData(player.GetPlayerData(), itemDatas);

            string json = JsonSerializer.Serialize(gameSaveData);
            File.WriteAllText(savePath, json);
        }
  • 게임 로드 과정
  1. 지정된 경로로 파일 읽어오기
  2. 읽어온 GameSaveData 객체를 활용하여 새로운 아이템 리스트, 플레이어 객체 생성
  3. 만들어진 객체를 사용하여 다른 필수 객체 생성, 할당
void LoadData()     //게임 불러오기
        {
            if (!ConfigLoader.TryLoad<GameSaveData>(savePath, out var config))
            {
                Console.WriteLine("저장 데이터 불러오기 실패");
                Utils.Pause(false);
                return;
            }
            inventory = new Inventory();
            foreach (ItemInfo info in config.ItemData)    //아이템 정보를 통해 객체화 실행
            {
                ITradable instance;
                switch (info.ItemType)
                {
                    case ItemType.Equipment:      //장비 아이템
                        instance = new Equipment(info);
                        break;
                    case ItemType.Usable:       //소비 아이템
                        instance = new Equipment(info);
                        break;
                    case ItemType.Other:        //기타 아이템
                        instance = new Equipment(info);
                        break;
                    default:
                        instance = new Equipment(info);
                        break;
                }

                itemList.Add(instance);
                if (!instance.IsForSale)    //플레이어의 소유인 경우
                {
                    inventory.AddItem(instance);    //인벤토리에 추가
                }
            }
            player = new Player(config.PlayerData, inventory);
            player.RestoreAfterLoad();
            shop = new Shop(player, itemList);
            dungeon = new Dungeon();
            isGameOver = false;
        }

딕셔너리로 열거형 타입 정리

게임 내 직업 같은 요소들을 열거형으로 관리하는데 있어서 불편한 점이 하나 있었다. 열거형을 영어로 작성해야 해서 값을 읽어오고 콘솔에 적을 때 한글로 바꾸는 게 좀 귀찮게 느껴진다는 점이다. 새로운 방법을 찾아본 결과, 딕셔너리를 사용하는 방식이 괜찮아 보였다.

public static Dictionary<Job, string> JobDisplayNames = new Dictionary<Job, string>
        {
            {Job.Warrior, "전사"},
            {Job.Mage, "마법사"},
            {Job.Archer, "궁수"}
        };
        public static Dictionary<Stat, string> StatDisplayNames = new Dictionary<Stat, string>
        {
            {Stat.Health, "체력"},
            {Stat.Attack, "공격력"},
            {Stat.Defense, "방어력"}
        };
        public static Dictionary<ItemType, string> ItemTypeDisplayNames = new Dictionary<ItemType, string>
        {
            {ItemType.Equipment, "장비"},
            {ItemType.Usable, "소비"},
            {ItemType.Other, "기타"}
        };

        public static Dictionary<EquipType, string> EquipTypeDisplayNames = new Dictionary<EquipType, string>
        {
            {EquipType.Armor, "방어구"},
            {EquipType.Weapon, "무기"}
        };

이제 열거형 타입을 한글로 변환할 때마다 switch문을 사용하지 않아도 된다.

string typeFormatted = Utils.PadToWidth(Utils.EquipTypeDisplayNames[EquipType], 6);
string nameFormatted = Utils.PadToWidth(Name, 15);
string statFormatted = Utils.PadToWidth($"{Utils.StatDisplayNames[Stat]} +{StatValue}", 15);
string descFormatted = Utils.PadToWidth(Description, 50);

Console.WriteLine($"{typeFormatted} | {nameFormatted} | {statFormatted} | {descFormatted}");
  • 출력 결과

img_1


🏁오늘 배운 핵심 한줄 요약

  • JSON 직렬화/역직렬화 시 주의할 점
    • 클래스와 속성의 접근 제한자
    • 열거형(enum) 사용시 public 선언
    • 데이터 타입 오타 주의
    • 직렬화 옵션 커스터마이징 - 대소문자 구분 없애기, 들여쓰기 옵션 등
  • 열거형 출력은 딕셔너리로 정리해놓으면 편하다

댓글남기기