게임에서 퀘스트는 방향성을 제공해 주고 게임의 목적성과 재미를 높여주며, 진행 상황을 추적하고 보상을 제공하여 게임을 보다 흥미롭게 만들어줍니다. 퀘스트 시스템을 구현하고 정리하였습니다.

 

💬 서론

  • 본 글은 게임을 모두 만든 후에 작성한 글로 핵심 주제 외 클래스 및 다른 기능에 대한 함수가 포함될 수 있습니다.

 

📖 구현 내용

  • 적 처치, 아이템 습득에 대한 퀘스트를 구현합니다.
  • 플레이어는 퀘스트를 받고 퀘스트를 완료할 수 있습니다.
  • 퀘스트 상태를 확인하여 상태별로 이벤트를 처리할 수 있습니다.
  • 스크립터블 오브젝트로 퀘스트의 데이터를 관리합니다.
  • 스크립터블 오브젝트를 프로젝트 폴더 내에서 관리하고, 이 데이터를 자동으로 불러옵니다.
  • ID를 이용하여 고유한 퀘스트의 중복 여부를 검사할 수 있습니다.
  • 하나의 퀘스트에서 여러 개의 목표 중 하나만 수행해도 퀘스트를 완료하는지에 대한 여부를 설정할 수 있습니다.
  • 퀘스트 콘텐츠를 보여주고 현재 진행 시점, 완료한 퀘스트들의 정보를 볼 수 있습니다.

 

⚒️ 구현

  • 이번 글에서는 퀘스트 데이터를 이용하여 플레이어에게 퀘스트 수주하거나, 완료하는 기능을 수행하는 매니저를 다룹니다.

· QuestManager.cs

더보기
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Quest;
using GameSave;

#if UNITY_EDITOR
using UnityEditor;
#endif

public class QuestManager : Singleton<QuestManager>
{
    [Header("인벤토리 메인(플레이어 인벤토리)")]
    [SerializeField] private InventoryMain mInventory;

    [Header("에디터에서 로드 한 퀘스트들")]
    [SerializeField] private List<QuestData> mPreloadQuests; //인스펙터에서 로드하는 퀘스트들
    [field: ShowOnly][field: SerializeField] public int QuestCount { private set; get; } = -1; //현재 로드된 총 퀘스트의 개수

    [HideInInspector] public Dictionary<int, QuestData> Quests = new Dictionary<int, QuestData>(); //로드되어 관리되는 총 퀘스트
    [HideInInspector] public List<QuestData> mReceivedQuests = new List<QuestData>();

    private void Awake()
    {
        LoadAllQuests();
    }

    public void LoadFromData(QuestGameData? questData)
    {
        if (questData is not null)
            foreach (QuestInfo questInfo in questData.Value.quests)
            {
                // 현재 퀘스트를 가져옴
                QuestData loadedQuest = Quests[questInfo.questId];

                // 퀘스트 목표들을 로드
                loadedQuest.killTargetsQuests = questInfo.killTargetsQuests;
                loadedQuest.getItemsQuests = questInfo.getItemsQuests;

                // 모든 퀘스트를 탐색하고 저장하기위한 리스트 생성
                List<QuestBase> allQuests = new List<QuestBase>();
                {
                    // 적을 처치하는 퀘스트에서 확인
                    foreach (QuestBase quest in loadedQuest.killTargetsQuests)
                        allQuests.Add(quest);

                    // 아이템을 획득하는 퀘스트에서 확인
                    foreach (QuestBase quest in loadedQuest.getItemsQuests)
                        allQuests.Add(quest);

                    // 모든 퀘스트를 배열로 저장
                    loadedQuest.allQuests = allQuests.ToArray();
                }

                // 퀘스트 상태를 로드
                loadedQuest.questState = questInfo.questState; 

                // 퀘스트 수주
                ReceiveQuest(loadedQuest.questId);
            }
    }

    private void LoadAllQuests()
    {
        //퀘스트 데이터 로드하고 퀘스트 데이터에 퀘스트를 등록
        foreach (QuestData questData in mPreloadQuests)
        {
            QuestData newQuest = Instantiate(questData);

            // 모든 퀘스트를 탐색하고 저장하기위한 리스트 생성
            List<QuestBase> allQuests = new List<QuestBase>();
            {
                // 적을 처치하는 퀘스트에서 확인
                foreach (QuestBase quest in newQuest.killTargetsQuests)
                    allQuests.Add(quest);

                // 아이템을 획득하는 퀘스트에서 확인
                foreach (QuestBase quest in newQuest.getItemsQuests)
                    allQuests.Add(quest);

                // 모든 퀘스트를 배열로 저장
                newQuest.allQuests = allQuests.ToArray();
            }

            Quests.Add(questData.questId, newQuest);


        }
    }

    /// <summary>
    /// 퀘스트를 플레이어에게 줌 (퀘스트 수주)
    /// </summary>
    /// <param name="questId">퀘스트 id</param>
    public void ReceiveQuest(int questId)
    {
        QuestData quest = Quests[questId];
        QuestContentManager.Instance.AddFullQuestContent(quest);

        mReceivedQuests.Add(quest);

        // 이미 클리어 한 퀘스트라면? 
        if(quest.questState == QuestState.CLEARED_PAST)
            CompleteQuest(quest.questId, false);
        else
            quest.questState = QuestState.ONGOING;

        // 퀘스트 업데이트
        QuestContentManager.Instance.UpdateCurrentQuestState(quest.questId);
    }

    public void CompleteQuest(int questId, bool isGiveAward = true)
    {
        // 퀘스트 획득
        QuestData quest = Quests[questId];

        if(isGiveAward) // 보상 지급
        {
            // 경험치 지급
            ExpManager.Instance.AddExp(quest.expAmount);
        }

        // 퀘스트 콘텐츠 매니저에서 UI 제거
        QuestContentManager.Instance.CompleteQuest(quest); // 퀘스트 콘텐츠 매니저에서 컴팩트 퀘스트UI 제거

        // 현재 진행중인 퀘스트 제거
        mReceivedQuests.Remove(quest);

        // 퀘스트를 CLEARED_PAST 상태로 변경
        quest.questState = QuestState.CLEARED_PAST;
    }

    /// <summary>
    /// 해당 퀘스트의 상태를 확인
    /// </summary>
    /// <param name="questId">확인할 퀘스트의 id</param>
    /// <returns>퀘스트의 상태</returns>
    public QuestState CheckQuestState(int questId)
    {
        if(Quests[questId].questState == QuestState.CLEARED_PAST)
            return QuestState.CLEARED_PAST;

        foreach (QuestData questData in mReceivedQuests)
        {
            if (questId == questData.questId)
            {
                // 이 퀘스트가 선택적 퀘스트 타입이라면?
                if(questData.isOptionalQuestType)
                {
                    foreach(QuestBase quest in questData.allQuests) 
                    {
                        if(quest.isPartClear)
                            return QuestState.CLEAR;
                    }

                    return QuestState.ONGOING;
                }
                else
                {
                    foreach(QuestBase quest in questData.allQuests) 
                    {
                        if(!quest.isPartClear)
                            return QuestState.ONGOING;
                    }

                    return QuestState.CLEAR;
                }
            }
        }

        // 위 조건들이 모두 아니면 'NEVER_RECEIVED'
        return QuestState.NEVER_RECEIVED;
    }

    #region 퀘스트 상태 업데이트
    /// <summary>
    /// 타겟을 처치하는 퀘스트를 갱신
    /// </summary>
    /// <param name="enemyCode">갱신을 시도할 적 대상의 코드</param>
    public void UpdateKillQuestCount(EnemyCode enemyCode) //적(타겟)이 처치되는 시점에 호출하여 처치한 대상이 퀘스트에 포함되어있는지 확인
    {
        for(int i = 0; i < mReceivedQuests.Count; ++i)
        {
            for(int j = 0; j < mReceivedQuests[i].killTargetsQuests.Length; ++j)
            {
                if(mReceivedQuests[i].killTargetsQuests[j].enemyCode == enemyCode)
                {
                    // 처치 카운트를 증가
                    ++mReceivedQuests[i].killTargetsQuests[j].currentKillCount;

                    // 퀘스트의 완료 여부를 검사
                    mReceivedQuests[i].killTargetsQuests[j].isPartClear = mReceivedQuests[i].killTargetsQuests[j].currentKillCount >= mReceivedQuests[i].killTargetsQuests[j].killCount;

                    // 컴팩트 텍스트 업데이트
                    QuestContentManager.Instance.UpdateCurrentQuestState(mReceivedQuests[i].questId);

                    return;
                }
            }
        }
    }

    /// <summary>
    /// 아이템을 획득하는 퀘스트를 갱신
    /// </summary>
    public void UpdateItemQuestCount()
    {
        foreach (QuestData quest in mReceivedQuests) //현재 진행중인 퀘스트를 확인
        {
            foreach (Quest_GetItems getItemsQuest in quest.getItemsQuests) //퀘스트에서 아이템 획득 목표를 확인
            {
                //목표 개수를 현재 인벤토리에 있는 아이템 개수로 변경
                getItemsQuest.currentItemCount = mInventory.GetItemCount(getItemsQuest.itemCode);

                Debug.Log(getItemsQuest.currentItemCount);

                // 퀘스트의 완료 여부를 검사
                getItemsQuest.isPartClear = getItemsQuest.currentItemCount >= getItemsQuest.itemCount;

                // 컴팩트 텍스트 업데이트
                QuestContentManager.Instance.UpdateCurrentQuestState(quest.questId);
            }
        }
    }
    #endregion

    #region 유니티 에디터 기능
#if UNITY_EDITOR
    /// <summary>
    /// 퀘스트를 에디터에서 인스펙터 GUI로부터 로드
    /// </summary>
    /// <param name="allQuests"></param>
    public void LoadQuests(List<QuestData> allQuests)
    {
        mPreloadQuests = new List<QuestData>();

        mPreloadQuests = allQuests;
        QuestCount = allQuests == null ? -1 : allQuests.Count;
    }
#endif
    #endregion
}

#region 유니티 에디터 기능
#if UNITY_EDITOR
[CustomEditor(typeof(QuestManager))]
public class QuestManager_EditorFunctions : Editor
{
    QuestManager baseTarget; //QuestManager

    void OnEnable() { baseTarget = (QuestManager)target; }

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        GUILayout.Label("\n\n모든 퀘스트 불러오기");

        if (GUILayout.Button("불러오기"))
        {
            LoadToArray();
        }
    }

    private void LoadToArray()
    {
        bool isDuplicated = false;

        string[] guidArray = AssetDatabase.FindAssets("t:QuestData"); //Filter: type이 QuestData인 에셋을 모두 찾아 guid획득 후 배열로 저장

        List<QuestData> quests = new List<QuestData>(); //퀘스트 리스트
        Dictionary<int, QuestData> questDuplicate = new Dictionary<int, QuestData>(); //중복 검사를 위한 딕셔너리

        foreach (string guid in guidArray)
        {
            var assetPath = AssetDatabase.GUIDToAssetPath(guid); //guid를 경로로 변환
            var asset = AssetDatabase.LoadAssetAtPath<QuestData>(assetPath); //경로에서 해당 에셋 불러오기

            if (questDuplicate.ContainsKey(asset.questId))
            {
                Debug.LogErrorFormat("{0}와 {1}가 questId {2}로 겹침!", questDuplicate[asset.questId].name, asset.name, asset.questId);
                isDuplicated = true;
                break;
            }

            questDuplicate.Add(asset.questId, asset);
            quests.Add(asset);
        }

        if (!isDuplicated) { Debug.LogFormat("<color=cyan>{0}개의 퀘스트가 중복 없이 로드됨</color>", questDuplicate.Count); }
        else
        {
            questDuplicate.Clear();
            questDuplicate = null;
            quests.Clear();
            quests = null;
        }

        baseTarget.LoadQuests(quests);
    }
}
#endif
#endregion

 

[Header("인벤토리 메인(플레이어 인벤토리)")]
[SerializeField] private InventoryMain mInventory;

 

[Header("에디터에서 로드 한 퀘스트들")]
[SerializeField] private List<QuestData> mPreloadQuests; //인스펙터에서 로드하는 퀘스트들
[field: ShowOnly][field: SerializeField] public int QuestCount { private set; get; } = -1; //현재 로드된 총 퀘스트의 개수
  • 프로젝트 폴더에서 로드한 모든 퀘스트들입니다.

 

[HideInInspector] public Dictionary<int, QuestData> Quests = new Dictionary<int, QuestData>(); //로드되어 관리되는 총 퀘스트
  • 프로젝트 폴더에서 로드한 모든 퀘스트들을 딕셔너리 형태로 관리합니다.

 

[HideInInspector] public List<QuestData> mReceivedQuests = new List<QuestData>();
  • 플레이어가 현재 받은 퀘스트들을 보관하는 리스트입니다.

 

public void LoadFromData(QuestGameData? questData)
{
    if (questData is not null)
        foreach (QuestInfo questInfo in questData.Value.quests)
        {
            // 현재 퀘스트를 가져옴
            QuestData loadedQuest = Quests[questInfo.questId];

            // 퀘스트 목표들을 로드
            loadedQuest.killTargetsQuests = questInfo.killTargetsQuests;
            loadedQuest.getItemsQuests = questInfo.getItemsQuests;

            // 모든 퀘스트를 탐색하고 저장하기위한 리스트 생성
            List<QuestBase> allQuests = new List<QuestBase>();
            {
                // 적을 처치하는 퀘스트에서 확인
                foreach (QuestBase quest in loadedQuest.killTargetsQuests)
                    allQuests.Add(quest);

                // 아이템을 획득하는 퀘스트에서 확인
                foreach (QuestBase quest in loadedQuest.getItemsQuests)
                    allQuests.Add(quest);

                // 모든 퀘스트를 배열로 저장
                loadedQuest.allQuests = allQuests.ToArray();
            }

            // 퀘스트 상태를 로드
            loadedQuest.questState = questInfo.questState; 

            // 퀘스트 수주
            ReceiveQuest(loadedQuest.questId);
        }
}
  • "게임 저장 시스템"에서 호출되는 함수입니다.
  • 매개변수는 nullable로 데이터를 로드할 때 비어있는 questData(null)이 들어올 수 있습니다.
  • 데이터에서 받아온 모든 퀘스트 데이터를 가져와 현재 게임에 적용합니다.

 

private void LoadAllQuests()
{
    //퀘스트 데이터 로드하고 퀘스트 데이터에 퀘스트를 등록
    foreach (QuestData questData in mPreloadQuests)
    {
        QuestData newQuest = Instantiate(questData);

        // 모든 퀘스트를 탐색하고 저장하기위한 리스트 생성
        List<QuestBase> allQuests = new List<QuestBase>();
        {
            // 적을 처치하는 퀘스트에서 확인
            foreach (QuestBase quest in newQuest.killTargetsQuests)
                allQuests.Add(quest);

            // 아이템을 획득하는 퀘스트에서 확인
            foreach (QuestBase quest in newQuest.getItemsQuests)
                allQuests.Add(quest);

            // 모든 퀘스트를 배열로 저장
            newQuest.allQuests = allQuests.ToArray();
        }

        Quests.Add(questData.questId, newQuest);
    }
}
  • Awake()에서 호출되며 최초 1회 모든 퀘스트의 초기 데이터들을 불러옵니다.
  • Instantiate를 통해 기존의 scriptableObject의 복사본을 만들어 생성합니다.
  • killTargetsQuests과 getItemsQuests들을 모두 allQuests에 넣어 다른 함수에서 반복문으로 한 번에 순회할 수 있도록 합니다.

 

/// <summary>
/// 퀘스트를 플레이어에게 줌 (퀘스트 수주)
/// </summary>
/// <param name="questId">퀘스트 id</param>
public void ReceiveQuest(int questId)
{
    QuestData quest = Quests[questId];
    QuestContentManager.Instance.AddFullQuestContent(quest);

    mReceivedQuests.Add(quest);

    // 이미 클리어 한 퀘스트라면? 
    if(quest.questState == QuestState.CLEARED_PAST)
        CompleteQuest(quest.questId, false);
    else
        quest.questState = QuestState.ONGOING;

    // 퀘스트 업데이트
    QuestContentManager.Instance.UpdateCurrentQuestState(quest.questId);
}
  • 플레이어가 퀘스트를 받을 때 호출되는 함수입니다.
  • mReceivedQuests(받은 퀘스트 리스트)에 퀘스트를 추가합니다.
  • QuestContentManager에게 콘텐츠를 업데이트 요청을 합니다. QuestContentManager는 퀘스트 상태를 보여주는 텍스트 정보를 보여주는 기능입니다.

 

public void CompleteQuest(int questId, bool isGiveAward = true)
{
    // 퀘스트 획득
    QuestData quest = Quests[questId];

    if(isGiveAward) // 보상 지급
    {
        // 경험치 지급
        ExpManager.Instance.AddExp(quest.expAmount);
    }

    // 퀘스트 콘텐츠 매니저에서 UI 제거
    QuestContentManager.Instance.CompleteQuest(quest); // 퀘스트 콘텐츠 매니저에서 컴팩트 퀘스트UI 제거

    // 현재 진행중인 퀘스트 제거
    mReceivedQuests.Remove(quest);

    // 퀘스트를 CLEARED_PAST 상태로 변경
    quest.questState = QuestState.CLEARED_PAST;
}
  • 퀘스트 목표를 달성한 상태에서 보고를 하여 완료 처리를 위한 함수입니다.
  • 현재 퀘스트는 mReceivedQuests에서 제거하고 그 퀘스트 데이터에서 상태를 QuestState.CLEARED_PAST(이전에 완료한 상태)로 변경합니다.

 

/// <summary>
/// 해당 퀘스트의 상태를 확인
/// </summary>
/// <param name="questId">확인할 퀘스트의 id</param>
/// <returns>퀘스트의 상태</returns>
public QuestState CheckQuestState(int questId)
{
    if(Quests[questId].questState == QuestState.CLEARED_PAST)
        return QuestState.CLEARED_PAST;

    foreach (QuestData questData in mReceivedQuests)
    {
        if (questId == questData.questId)
        {
            // 이 퀘스트가 선택적 퀘스트 타입이라면?
            if(questData.isOptionalQuestType)
            {
                foreach(QuestBase quest in questData.allQuests) 
                {
                    if(quest.isPartClear)
                        return QuestState.CLEAR;
                }

                return QuestState.ONGOING;
            }
            else
            {
                foreach(QuestBase quest in questData.allQuests) 
                {
                    if(!quest.isPartClear)
                        return QuestState.ONGOING;
                }

                return QuestState.CLEAR;
            }
        }
    }

    // 위 조건들이 모두 아니면 'NEVER_RECEIVED'
    return QuestState.NEVER_RECEIVED;
}
  • questId에 해당하는 퀘스트의 현재 상태를 받아옵니다.
  • 현재 퀘스트가 받은 적이 없는지, 이미 완료한 상태인지 등 enum값을 리턴합니다.

 

public void UpdateKillQuestCount(EnemyCode enemyCode) //적(타겟)이 처치되는 시점에 호출하여 처치한 대상이 퀘스트에 포함되어있는지 확인
{
    for(int i = 0; i < mReceivedQuests.Count; ++i)
    {
        for(int j = 0; j < mReceivedQuests[i].killTargetsQuests.Length; ++j)
        {
            if(mReceivedQuests[i].killTargetsQuests[j].enemyCode == enemyCode)
            {
                // 처치 카운트를 증가
                ++mReceivedQuests[i].killTargetsQuests[j].currentKillCount;

                // 퀘스트의 완료 여부를 검사
                mReceivedQuests[i].killTargetsQuests[j].isPartClear = mReceivedQuests[i].killTargetsQuests[j].currentKillCount >= mReceivedQuests[i].killTargetsQuests[j].killCount;

                // 컴팩트 텍스트 업데이트
                QuestContentManager.Instance.UpdateCurrentQuestState(mReceivedQuests[i].questId);

                return;
            }
        }
    }
}
  • 적을 처치하면 호출되는 함수로 enemyCode를 퀘스트 목표로 가지는 퀘스트가 있다면, 그 퀘스트의 현재 개수를 1 올립니다.

 

public void UpdateItemQuestCount()
{
    foreach (QuestData quest in mReceivedQuests) //현재 진행중인 퀘스트를 확인
    {
        foreach (Quest_GetItems getItemsQuest in quest.getItemsQuests) //퀘스트에서 아이템 획득 목표를 확인
        {
            //목표 개수를 현재 인벤토리에 있는 아이템 개수로 변경
            getItemsQuest.currentItemCount = mInventory.GetItemCount(getItemsQuest.itemCode);

            Debug.Log(getItemsQuest.currentItemCount);

            // 퀘스트의 완료 여부를 검사
            getItemsQuest.isPartClear = getItemsQuest.currentItemCount >= getItemsQuest.itemCount;

            // 컴팩트 텍스트 업데이트
            QuestContentManager.Instance.UpdateCurrentQuestState(quest.questId);
        }
    }
}
  • 아이템 획득 퀘스트를 갱신합니다.
  • 인벤토리에서 아이템 상태가 변경되는 경우가 많이 있습니다. 그 경우가 발생하면 이 함수를 호출합니다.
  • 예시로, 플레이어가 아이템을 습득하거나, 아이템 사용 등 여러 가지가 그 경우가 될 수 있습니다.

 

· Editor.cs

  • 런타임 도중이 아닌 에디터에서 보다 효율적으로 관리하기 위한 기능을 가지는 클래스입니다.
#region 유니티 에디터 기능
#if UNITY_EDITOR
[CustomEditor(typeof(QuestManager))]
public class QuestManager_EditorFunctions : Editor
{
    QuestManager baseTarget; //QuestManager

    void OnEnable() { baseTarget = (QuestManager)target; }

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        GUILayout.Label("\n\n모든 퀘스트 불러오기");

        if (GUILayout.Button("불러오기"))
        {
            LoadToArray();
        }
    }

    private void LoadToArray()
    {
        bool isDuplicated = false;

        string[] guidArray = AssetDatabase.FindAssets("t:QuestData"); //Filter: type이 QuestData인 에셋을 모두 찾아 guid획득 후 배열로 저장

        List<QuestData> quests = new List<QuestData>(); //퀘스트 리스트
        Dictionary<int, QuestData> questDuplicate = new Dictionary<int, QuestData>(); //중복 검사를 위한 딕셔너리

        foreach (string guid in guidArray)
        {
            var assetPath = AssetDatabase.GUIDToAssetPath(guid); //guid를 경로로 변환
            var asset = AssetDatabase.LoadAssetAtPath<QuestData>(assetPath); //경로에서 해당 에셋 불러오기

            if (questDuplicate.ContainsKey(asset.questId))
            {
                Debug.LogErrorFormat("{0}와 {1}가 questId {2}로 겹침!", questDuplicate[asset.questId].name, asset.name, asset.questId);
                isDuplicated = true;
                break;
            }

            questDuplicate.Add(asset.questId, asset);
            quests.Add(asset);
        }

        if (!isDuplicated) { Debug.LogFormat("<color=cyan>{0}개의 퀘스트가 중복 없이 로드됨</color>", questDuplicate.Count); }
        else
        {
            questDuplicate.Clear();
            questDuplicate = null;
            quests.Clear();
            quests = null;
        }

        baseTarget.LoadQuests(quests);
    }
}
#endif
#endregion

 

foreach (string guid in guidArray)
{
    var assetPath = AssetDatabase.GUIDToAssetPath(guid); //guid를 경로로 변환
    var asset = AssetDatabase.LoadAssetAtPath<QuestData>(assetPath); //경로에서 해당 에셋 불러오기

    if (questDuplicate.ContainsKey(asset.questId))
    {
        Debug.LogErrorFormat("{0}와 {1}가 questId {2}로 겹침!", questDuplicate[asset.questId].name, asset.name, asset.questId);
        isDuplicated = true;
        break;
    }

    questDuplicate.Add(asset.questId, asset);
    quests.Add(asset);
}
  • 필터를 AssetDatabase.FindAssets("t:QuestData")로 설정한 검색 결과를 모두 순회하여 id가 겹치는지 확인합니다.
  • 만약 id가 겹친다면, 큰 문제이므로 이 사항을 알려줍니다.
  • 만약 겹치는 아이디가 하나도 없다면, 정상적으로 불러옵니다.

 

 

✅ 사용

  • 매니저 용도로 사용하기에 비활성화되지 않는 오브젝트에 컴포넌트를 삽입합니다.

 

  • 플레이어의 인벤토리 시스템을 레퍼런스 합니다.
  • 모든 퀘스트 불러오기 버튼을 눌러 생성한 퀘스트들을 불러옵니다.
  • 자동으로 모두 불러와지며, ID 중복검사를 실시합니다. 만약 ID가 중복이 있다면 다음과 같이 나타납니다.

 

  • 퀘스트가 겹친다는 로그가 출력되며, 퀘스트 리스트에는 아무것도 없는 상태가 됩니다.
bonnate