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

 

💬 서론

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

 

📖 구현 내용

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

 

⚒️ 구현

  • 이번 글에서는 플레이어가 퀘스트를 이용할 때 그 내용들을 텍스트로 보여주기 위한 콘텐츠를 관리하는 기능을 다룹니다.

· ContentManager.cs

더보기
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Text;
using TMPro;

namespace Quest
{
    /// <summary>
    /// 퀘스트를 텍스트로 보여주는 기능을 수행하는 매니저
    /// </summary>
    public class QuestContentManager : Singleton<QuestContentManager>
    {
        private static bool mIsQuestDialogEnable = false;
        public static bool IsQuestDialogEnable
        {
            get
            {
                return mIsQuestDialogEnable;
            }
        }
        
        [Header("풀사이즈 퀘스트 다이얼로그 오브젝트")]
        [SerializeField] private GameObject mFullSizeQuestDialog;

        [Header("퀘스트 설명 텍스트 콘텐츠")][SerializeField] private QuestContentDataReader mContentsScriptableObject; //퀘스트 설명 텍스트
        Dictionary<int, QuestContentData> mQuestContents = new Dictionary<int, QuestContentData>(); //퀘스트 텍스트 콘텐츠

        [Space(50)]
        [Header("UI 영역")]
        [Header("컴팩트 퀘스트 창을 인스턴스 할 부모")][SerializeField] private RectTransform mQuestCompactRoot;
        [Header("컴팩트 퀘스트 창 프리팹")][SerializeField] private GameObject mQuestCompactPrefab;

        [Header("풀사이즈 퀘스트 창을 인스턴스 할 부모")][SerializeField] private RectTransform mQuestFullRoot;
        [Header("풀사이즈 퀘스트 창 프리팹")][SerializeField] private GameObject mQuestFullPrefab;
        [Header("풀사이즈 선택 콘텐츠 라벨")] [SerializeField] private TextMeshProUGUI mFullSelectedContentLabel;


        private Dictionary<int, QuestCompactContent> mCompactQuestContents = new Dictionary<int, QuestCompactContent>(); //컴팩트 영역에 보여지는 퀘스트들
        private Dictionary<int, QuestFullContent> mFullQuestContents = new Dictionary<int, QuestFullContent>(); //풀사이즈 영역에 보여지는 퀘스트들        


        private void Awake()
        {
            // 초기화시 전역 활성화상태 해제
            QuestContentManager.mIsQuestDialogEnable = false;

            //텍스트 콘텐츠 로드
            foreach (QuestContentData contentsData in mContentsScriptableObject.DataList) { mQuestContents.Add(contentsData.id, contentsData); }
        }

        private void Update()
        {
            TryOpenDialog();
        }

        private void TryOpenDialog()
        {
            //옵션이 켜져있는경우 비활성화
            if (GameMenuManager.IsOptionActive) { return; }

            if (Input.GetKeyDown(KeyManager.Instance.GetKeyCode("Quest")))
            {
                if(mFullSizeQuestDialog.activeInHierarchy)
                {
                    mFullSizeQuestDialog.SetActive(false);
                    mIsQuestDialogEnable = false;

                    UtilityManager.TryLockCursor();
                }
                else
                {
                    mFullSizeQuestDialog.SetActive(true);
                    mIsQuestDialogEnable = true;

                    UtilityManager.UnlockCursor();                    
                }
            }
        }

        public void CompleteQuest(QuestData questData)
        {
            ToggleCompactQuestContent(questData, false);
            mFullQuestContents[questData.questId].CompleteQuest();
        }

        public void ToggleCompactQuestContent(QuestData questData, bool isEnable)
        {
            if (isEnable) //활성화?
            {
                if (mCompactQuestContents.ContainsKey(questData.questId)) // 이미 퀘스트가 있는경우 > 오류
                    Debug.LogErrorFormat("{0} 퀘스트가 중복됨!", questData.questId); 
                else
                {
                    // 퀘스트 콘텐츠 프리팹 인스턴스
                    QuestCompactContent newQuestContent = Instantiate(mQuestCompactPrefab, Vector3.zero, Quaternion.identity, mQuestCompactRoot).GetComponent<QuestCompactContent>();

                    // 퀘스트 데이터 삽입
                    newQuestContent.Init(questData);

                    // 딕셔너리에 삽입
                    mCompactQuestContents.Add(questData.questId, newQuestContent);

                    // 최초 1회 퀘스트 업데이트
                    newQuestContent.UpdateCompactQuestContents(mQuestContents[questData.questId]);
                }
            }
            else 
            { 
                // 딕셔너리에 퀘스트가 있으면?
                if(mCompactQuestContents.ContainsKey(questData.questId))
                {
                    // 제거
                    Destroy(mCompactQuestContents[questData.questId].gameObject); 
                    mCompactQuestContents.Remove(questData.questId);
                }
            }

            StartCoroutine(COR_RefreshQuestCompactLayout());
        }

        public void AddFullQuestContent(QuestData questData)
        {
            if (mFullQuestContents.ContainsKey(questData.questId)) { Debug.LogErrorFormat("{0} 퀘스트가 중복됨!", questData.questId); } //이미 퀘스트가 있는경우 > 오류
            else
            {
                // 퀘스트 콘텐츠 프리팹 인스턴스
                QuestFullContent newQuestContent = Instantiate(mQuestFullPrefab, Vector3.zero, Quaternion.identity, mQuestFullRoot).GetComponent<QuestFullContent>();

                // 퀘스트 데이터 삽입
                newQuestContent.Init(questData);

                // 딕셔너리에 삽입
                mFullQuestContents.Add(questData.questId, newQuestContent);

                // 최초 1회 퀘스트 업데이트
                newQuestContent.UpdateCompactQuestContents(mQuestContents[questData.questId]);

                // 위치를 가장 최상단으로 옮기기
                newQuestContent.transform.SetAsFirstSibling();

                // 컴팩트 퀘스트 추가
                ToggleCompactQuestContent(questData, true);
            }
        }

        public void UpdateCurrentQuestState(int questId)
        {
            if(mCompactQuestContents.ContainsKey(questId))
                mCompactQuestContents[questId].UpdateCompactQuestContents(mQuestContents[questId]);

            if(mFullQuestContents.ContainsKey(questId))                
            mFullQuestContents[questId].UpdateCompactQuestContents(mQuestContents[questId]);
        }

        public void ToggleFullQuestContent(QuestData questData, bool isEnable = true)
        {
            if(isEnable == false)
            {
                mFullSelectedContentLabel.text = "";
            }
            else
            {
                // 퀘스트 콘텐츠 데이터 획득
                QuestContentData contentData = mQuestContents[questData.questId];

                // 스트링 빌더 사용
                StringBuilder stringBuilder = new StringBuilder();

                // 문자열 구성
                stringBuilder.Append($"<b>{contentData.title}</b>\n");
                stringBuilder.Append($"<size=16>의뢰인: {contentData.receiveFrom}\n</size>");
                stringBuilder.Append($"\n<size=14>{contentData.fullContent}</size>");

                mFullSelectedContentLabel.text = stringBuilder.ToString();
            }
        }

        #region 코루틴

        private IEnumerator COR_RefreshQuestCompactLayout()
        {
            VerticalLayoutGroup compactRoot = mQuestCompactRoot.GetComponent<VerticalLayoutGroup>();

            compactRoot.reverseArrangement = true;
            yield return null;
            compactRoot.reverseArrangement = false;
        }

        #endregion
    }
}

 

private static bool mIsQuestDialogEnable = false;
public static bool IsQuestDialogEnable
{
    get
    {
        return mIsQuestDialogEnable;
    }
}
  • 별도의 UI창을 이용하기 때문에 그 다이얼로그 창이 열려있는지 확인하기 위한 변수입니다.

 

[Header("풀사이즈 퀘스트 다이얼로그 오브젝트")]
[SerializeField] private GameObject mFullSizeQuestDialog;
  • 위에서 언급한 별도의 UI창이 이 오브젝트에 해당합니다.

 

[Header("퀘스트 설명 텍스트 콘텐츠")][SerializeField] private QuestContentDataReader mContentsScriptableObject; //퀘스트 설명 텍스트
Dictionary<int, QuestContentData> mQuestContents = new Dictionary<int, QuestContentData>(); //퀘스트 텍스트 콘텐츠

 

 

public struct QuestContentData
{
    [ShowOnly] public int id;
    [TextArea] public string title;
    [TextArea] public string receiveFrom;
    [TextArea] public string compactContent;
    [TextArea] public string fullContent;
}
  • 위에서 언급한 QuestContentDataReader에서 읽어오는 데이터의 구조입니다.

 

[Header("UI 영역")]
[Header("컴팩트 퀘스트 창을 인스턴스 할 부모")][SerializeField] private RectTransform mQuestCompactRoot;
[Header("컴팩트 퀘스트 창 프리팹")][SerializeField] private GameObject mQuestCompactPrefab;

[Header("풀사이즈 퀘스트 창을 인스턴스 할 부모")][SerializeField] private RectTransform mQuestFullRoot;
[Header("풀사이즈 퀘스트 창 프리팹")][SerializeField] private GameObject mQuestFullPrefab;
[Header("풀사이즈 선택 콘텐츠 라벨")] [SerializeField] private TextMeshProUGUI mFullSelectedContentLabel;
  • 컴팩트 사이즈와 풀사이즈 UI를 제공합니다.
  • 컴팩트 사이즈는 좌측 상단에 요약된 정보를 제공하고, 풀사이즈는 생략 없는 모든 정보를 제공합니다. 차이는 다음 이미지와 같습니다.

 

컴팩트와 풀사이즈 UI 차이

 

private Dictionary<int, QuestCompactContent> mCompactQuestContents = new Dictionary<int, QuestCompactContent>(); //컴팩트 영역에 보여지는 퀘스트들
private Dictionary<int, QuestFullContent> mFullQuestContents = new Dictionary<int, QuestFullContent>(); //풀사이즈 영역에 보여지는 퀘스트들
  • 컴팩트, 풀사이즈 UI들이 보이게 되면 이 딕셔너리에 그 값들이 삽입됩니다.
  • 어떤 이유에 의해 해당 오브젝트들이 보일 필요가 없다면, 이 딕셔너리에 key값인 questId를 사용하여 접근할 수 있습니다.

 

private void Awake()
{
    // 초기화시 전역 활성화상태 해제
    QuestContentManager.mIsQuestDialogEnable = false;

    //텍스트 콘텐츠 로드
    foreach (QuestContentData contentsData in mContentsScriptableObject.DataList) { mQuestContents.Add(contentsData.id, contentsData); }
}

 

  • mContentsScriptableObject에서 모든 데이터를 읽어 사용 준비를 완료합니다.

 

private void TryOpenDialog()
{
    //옵션이 켜져있는경우 비활성화
    if (GameMenuManager.IsOptionActive) { return; }

    if (Input.GetKeyDown(KeyManager.Instance.GetKeyCode("Quest")))
    {
        if(mFullSizeQuestDialog.activeInHierarchy)
        {
            mFullSizeQuestDialog.SetActive(false);
            mIsQuestDialogEnable = false;

            UtilityManager.TryLockCursor();
        }
        else
        {
            mFullSizeQuestDialog.SetActive(true);
            mIsQuestDialogEnable = true;

            UtilityManager.UnlockCursor();                    
        }
    }
}
  • 풀사이즈 UI 다이얼로그 창을 열고 닫습니다.

 

public void CompleteQuest(QuestData questData)
{
    ToggleCompactQuestContent(questData, false);
    mFullQuestContents[questData.questId].CompleteQuest();
}
  • QuestManager에서 퀘스트 완료가 되었다면, 이 함수가 호출되어 콘텐츠에 퀘스트가 완료되었다고 알립니다.
  • 퀘스트가 완료되면 컴팩트UI에 해당 퀘스트를 더 이상 표시하지 않아도 되는 등 여러 추가 기능을 수행하기 위해 사용합니다.

 

public void ToggleCompactQuestContent(QuestData questData, bool isEnable)
{
    if (isEnable) //활성화?
    {
        if (mCompactQuestContents.ContainsKey(questData.questId)) // 이미 퀘스트가 있는경우 > 오류
            Debug.LogErrorFormat("{0} 퀘스트가 중복됨!", questData.questId); 
        else
        {
            // 퀘스트 콘텐츠 프리팹 인스턴스
            QuestCompactContent newQuestContent = Instantiate(mQuestCompactPrefab, Vector3.zero, Quaternion.identity, mQuestCompactRoot).GetComponent<QuestCompactContent>();

            // 퀘스트 데이터 삽입
            newQuestContent.Init(questData);

            // 딕셔너리에 삽입
            mCompactQuestContents.Add(questData.questId, newQuestContent);

            // 최초 1회 퀘스트 업데이트
            newQuestContent.UpdateCompactQuestContents(mQuestContents[questData.questId]);
        }
    }
    else 
    { 
        // 딕셔너리에 퀘스트가 있으면?
        if(mCompactQuestContents.ContainsKey(questData.questId))
        {
            // 제거
            Destroy(mCompactQuestContents[questData.questId].gameObject); 
            mCompactQuestContents.Remove(questData.questId);
        }
    }

    StartCoroutine(COR_RefreshQuestCompactLayout());
}
  • 컴팩트 UI는 플레이어의 기호에 따라 언제든지 비활성화될 수 있습니다.
  • isEnable 매개변수를 이용하여 questData에 해당하는 퀘스트를 비활성화하거나 활성화할 수 있습니다.

 

public void AddFullQuestContent(QuestData questData)
{
    if (mFullQuestContents.ContainsKey(questData.questId)) { Debug.LogErrorFormat("{0} 퀘스트가 중복됨!", questData.questId); } //이미 퀘스트가 있는경우 > 오류
    else
    {
        // 퀘스트 콘텐츠 프리팹 인스턴스
        QuestFullContent newQuestContent = Instantiate(mQuestFullPrefab, Vector3.zero, Quaternion.identity, mQuestFullRoot).GetComponent<QuestFullContent>();

        // 퀘스트 데이터 삽입
        newQuestContent.Init(questData);

        // 딕셔너리에 삽입
        mFullQuestContents.Add(questData.questId, newQuestContent);

        // 최초 1회 퀘스트 업데이트
        newQuestContent.UpdateCompactQuestContents(mQuestContents[questData.questId]);

        // 위치를 가장 최상단으로 옮기기
        newQuestContent.transform.SetAsFirstSibling();

        // 컴팩트 퀘스트 추가
        ToggleCompactQuestContent(questData, true);
    }
}
  • 풀사이즈 UI는 플레이어가 원할 때 콘텐츠 영역을 제거할 순 없습니다. 대신 창 자체를 닫을 수 있습니다.
  • 그렇기에 toggle에 대한 속성은 없고, 퀘스트를 받을 때 추가하는 기능밖에 없습니다.

 

public void UpdateCurrentQuestState(int questId)
{
    if(mCompactQuestContents.ContainsKey(questId))
        mCompactQuestContents[questId].UpdateCompactQuestContents(mQuestContents[questId]);

    if(mFullQuestContents.ContainsKey(questId))                
    mFullQuestContents[questId].UpdateCompactQuestContents(mQuestContents[questId]);
}
  • 특정 조건에 의해 퀘스트 내용이 변경되어야 하면 호출되며 가지고 있는 모든 콘텐츠 텍스트들을 갱신합니다.

 

public void ToggleFullQuestContent(QuestData questData, bool isEnable = true)
{
    if (isEnable == false)
    {
        mFullSelectedContentLabel.text = "";
    }
    else
    {
        // 퀘스트 콘텐츠 데이터 획득
        QuestContentData contentData = mQuestContents[questData.questId];

        // 스트링 빌더 사용
        StringBuilder stringBuilder = new StringBuilder();

        // 문자열 구성
        stringBuilder.Append($"<b>{contentData.title}</b>\n");
        stringBuilder.Append($"<size=16>의뢰인: {contentData.receiveFrom}\n</size>");
        stringBuilder.Append($"\n<size=14>{contentData.fullContent}</size>");

        mFullSelectedContentLabel.text = stringBuilder.ToString();
    }
}
  • 풀사이즈 UI의 세부 콘텐츠를 보기 위해 호출하는 함수입니다.
  • 이 함수가 호출되면 풀사이즈 UI 다이얼로그 하단에 긴 텍스트가 나타나게 됩니다.
  • 그 예시는 아래 사진과 같습니다.

 

퀘스트에 대한 스토리가 길게 나타남

 

private IEnumerator COR_RefreshQuestCompactLayout()
{
    VerticalLayoutGroup compactRoot = mQuestCompactRoot.GetComponent<VerticalLayoutGroup>();

    compactRoot.reverseArrangement = true;
    yield return null;
    compactRoot.reverseArrangement = false;
}
  • 컴팩트 영역에서 사용할 VerticalLayoutGroup에서 새로운 오브젝트가 인스턴스 될 때, 자동으로 트랜스폼들을 갱신하기 위해 사용합니다.

 

· UI 설정 

 

  • 전체 구성은 다음과 같습니다.

 

  • 다음은 각 요소별 세부 설명입니다.

1. mQuestCompactRoot

  • 컴팩트 UI 오브젝트를 인스턴스 할 부모 트랜스폼입니다.
  • Vertical Layout Group을 이용하여 오브젝트가 여러 개 생성되면 수직 방향으로 쌓이게 구현하였습니다.

 

2. mQuestCompactPrefab

 

3. mQuestFullRoot

  • 풀사이즈 퀘스트 콘텐츠에서 각 슬롯 인스턴스를 할 위치가 될 트랜스폼입니다.
  • 아래 이미지의 "인스턴스 스크롤 뷰"에 해당합니다.

 

 

4. mQuestFullPrefab

 

5. mFullSelectedContentLabel

  • 풀사이즈 UI에서 인스턴스 된 각 슬롯을 선택하면 나타나는 모든 텍스트를 보여줄 텍스트 라벨입니다.
  • 위 이미지에서 "텍스트 스크롤 뷰" 콘텐츠 내부의 라벨을 사용합니다.

 

✅ 사용

bonnate