📕학습 개요

유니티 캠프 15일차.

팀 프로젝트의 사실상 마지막 날. 발표는 다음주 월요일에 진행한다. 기능 구현은 전부 되긴 했는데, 뭔가 찜찜한 느낌이 계속 든다. 뭔가 심각한 버그가 터질것만 같은 느낌…

주말에 시간을 내서 코드 리팩토링 및 발표 준비를 조금이라도 할 생각이다.


📖학습 내용

오늘 한 일 정리

  1. 게임 구조 개선
  2. static 딕셔너리 직렬화 문제 이해 및 대응
  3. 퀘스트 기능 구현(어려움)
  4. 저장 기능 수정(퀘스트 진행 상황 등)

오늘 겪은 문제 및 해결 방법

게임 구조 개선

  • 기존 문제점

    • Game 클래스가 지나치게 많은 책임(플레이어 생성, 저장, 불러오기, 마을 행동 등)을 가지고 있었음
    • 단일 책임 원칙을 위반하고 있어 유지보수와 확장에 어려움이 있었음
  • 개선 방향

    • 각 기능을 따로 구현하여 역할과 책임을 분리
    • 폴더 경로도 정적 string 변수로 깔끔하게 정리
public static class PlayerCreator
{
    public static string GetPlayerName()
    {
        //이름 입력
    }
    public static Job SelectJob()
    {
       //직업 선택
    }
}
public static class SaveLoadManager
 {
     public static void SaveGame(GameSaveData saveData)
     {
        //게임 저장
     }
     public static bool TryLoadGame(out GameSaveData saveData)
     {
        //게임 로드
     }
 }
   public static class PathConstants
    {
        public const string ResourceFolder = @"..\..\..\resources";
        public static string SaveFilePath => Path.Combine(ResourceFolder, "saveData.json");
        public static string ItemConfigPath => Path.Combine(ResourceFolder, "items_config.json");
        public static string MonsterConfigPath => Path.Combine(ResourceFolder, "monster_config.json");
        public static string QuestConfigPath => Path.Combine(ResourceFolder, "quest_config.json");
    }

원래 한 곳에 몰려있던 기능들을 정리하니 훨씬 깔끔하고 보기 좋아졌다. 예전부터 Game스크립트를 정리해야겠다고 생각은 했는데 매번 미루다가 이제서야 할 일을 했다. 오랜만에 방 청소를 한 듯한 기분이 든다.

static 딕셔너리 직렬화 문제

  • 문제 상황

    • static Dictionary<int,int> 형태로 몬스터 차치 수를 관리했는데, JSON 직렬화 시 해당 데이터 저장이 안됨
  • 원인 분석
    • static 멤버는 기본적인 직렬화로는 처리되지 않는다고 한다.
    • 내가 사용한 System.Text.Json은 인스턴스의 필드/속성만 직렬화 대상으로 삼음.
  • 해결 방법
    • static 딕셔너리를 복사하여 일반 딕셔너리로 변환 후 저장
new Dictionary<int, int>(MonsterDataBase.MonsterKillCount)
  • 저장 시에는 GameSaveData에 일반 딕셔너리를 포함시키고,
  • 불러올 때는 MonsterDataBase.MonsterKillCount에 다시 복사.
if (File.Exists(Path.Combine(path, "saveData.json")))   //저장 파일이 존재하는 경우
          {   //몬스터 처치 데이터 불러오기
              if (!ConfigLoader.TryLoad<GameSaveData>(Path.Combine(path, "saveData.json"), out var countConfig))
              {
                  Console.WriteLine("몬스터 처치 수 데이터 불러오기 실패");
                  Utils.Pause(false);
                  return;
              }
              MonsterKillCount = countConfig.MonsterKillData;
          }

퀘스트 기능 구현

오늘 대부분의 시간을 쓴 부분이다. 정확한 기능 구현은 다음과 같다.

  1. 퀘스트 정보를 JSON으로 직렬화/역직렬화
  2. 퀘스트 진행상황 관리
  3. 퀘스트 진행상황 확인
  4. 퀘스트 완료 - 보상 지급

퀘스트를 데이터화 시키는 과정에서 어려움을 많이 겪었다. 퀘스트 조건 때문이다.

  • 퀘스트 조건은 두 가지 :
    • 몬스터 처치
    • 아이템 전달

이를 구분하기 위해 일단 QusetCondition 인터페이스를 만들어, 각 조건을 의미하는 클래스들이 이를 구현하게 만들었다.

    public interface IQuestCondition
    {
        bool CanComplete { get; }       //완료 가능 여부

        Dictionary<int, int> TargetCount { get; set; }
        Dictionary<int, int> CurrentCount { get; set; }

        void ShowCondition();   //퀘스트 조건 보여주기
        void OnQuestExcepted();     //퀘스트 수락 시 실행
        void OnDataLoad();      //게임 불러오기 시 실행
        void ShowProgress();    //진행 상황 표시
        void UpdateProgress(Player player);     //진행상황 업데이트
        void OnQuestComplete(Player player);    //퀘스트 완료 시 실행
    }
    public class KillMonsterCondition : IQuestCondition
    {
       //생략
    }
        public class CollectItemCondition : IQuestCondition
    {
        //생략
    }

이 후, 퀘스트 정보를 직렬화하는데 쓰이는 QuestInfo 구조체에 IQuestCondition을 포함시켜서 퀘스트 조건을 구분하는 것이 내 목표였다. 하지만 파일을 읽어오는 데 계속 실패하였다.

[Serializable]
public struct QuestInfo
{
    public string Title { get; set; }   //제목
    public string Description { get; set; } //설명
    public int Reward { get; set; }     //보상(메소)
    public bool IsAccepted { get; set; }   //수락 여부
    public bool IsCompleted { get; set; }   //완료 여부
    public QuestConditionInfo Condition;    //퀘스트 조건, 진행 상황
}
[Serializable]
public struct QuestConditionInfo
{
    public QuestType Type;
    public bool IsCompleted;
    public Dictionary<int, int> TargetCount;    // ID, 개수
    public Dictionary<int, int> CurrentCount;
}
  • 정확히는, QuestConditionInfo 내의 딕셔너리만 읽어오지 못했다.
  • 나머지 값들은 문제없이 잘 읽어오는데 말이다.

결국 튜터님께 도움을 받기로 했다. 아마 데이터 구조가 너무 복잡해서 역직렬화에 실패하는 것이라 예상하셨다.

  • 지금 읽어오지 못하는 딕셔너리는 구조체 안의 구조체 안의 딕셔너리이다.
  • 기본적인 JsonSerialization은 데이터가 복잡해질 경우 읽어오지 못하는 문제가 생긴다.
  • Newtonsoft같은 라이브러리를 사용하는 것도 방법이지만, 그냥 데이터 구조를 간단하게 바꾸기로 했다.
[Serializable]
public struct QuestInfo
{
     public string Title { get; set; }   //제목
     public string Description { get; set; } //설명
     public int Reward { get; set; }     //보상(메소)
     public bool IsAccepted { get; set; }   //수락 여부
     public bool IsCompleted { get; set; }   //완료 여부
     public QuestType Type { get; set; }  //퀘스트 타입
     public Dictionary<int, int> TargetCount { get; set; }    // ID, 개수
     public Dictionary<int, int> CurrentCount { get; set; }
}

이렇게 바꾸니 바로 문제없이 모든 딕셔너리를 읽어올 수 있었다.

애초에 퀘스트 조건과 관계없이 둘 다 TargetCount, CurrentCount 딕셔너리를 사용하가에 애초에 새로운 구조체로 빼줄 필요가 없었던 것이다. 물론 이는 현재 퀘스트의 조건이 전부 (아이템/몬스터) x 개수 로 일관되기에 가능한 해결법이었다. 만약 “특정 NPC에게 말걸기” 같은 퀘스트까지 구현한다면… 상당히 어려워질 것 같다.

저장 기능 개선

새로 추가한 퀘스트의 진행 상황을 저장하기 위해 GameSaveData에 퀘스트에 대한 정보도 넣어줬다.

   [Serializable]
    public class GameSaveData   //게임 데이터 저장용 래퍼 클래스
    {
        public PlayerData PlayerData { get; set; }  //플레이어의 정보를 담는 리스트
        public List<ItemInfo> InventoryItemData { get; set; }    //인벤토리 아이템들의 정보를 담는 리스트
        public List<ItemInfo> ShopItemData { get; set; }    //상점 아이템들의 정보를 담는 리스트
        public List<QuestInfo> QuestData { get; set; }     //퀘스트 정보(진행 상황 등) 을 담는 리스트
        public Dictionary<int, int> MonsterKillData { get; set; }   //몬스터 처치 수를 담는 딕셔너리(ID, 처치수)
  • 기본 JsonSerialization은 데이터 구조가 복잡해지면 직렬화가 안될 수 있다.
  • 코드 리팩토링을 하면 기분이 좋아진다.

🔚마무리

벌써 유니티 캠프 3주차가 끝났다. 시간이 정말 빠르게 간다. 작업에 몰두하다 보면 어느새 점심이 되고 밤이 온다.

이번주는 프로젝트에 몰두하느라 개인 공부 시간이 거의 없었다.

그래도 이번 프로젝트를 진행하면서 JSON 직렬화 이슈를 실무적으로 다뤄본 것이 큰 수확이었다.

태그: ,

업데이트:

댓글남기기