[TIL] 45일 차 - Quest 만들기 (完)

ChangBeom·2025년 3월 31일

TIL

목록 보기
46/53
post-thumbnail

이전에 진행한 팀 스크럼에서 퀘스트의 데이터 테이블을 구글 시트로 만들고,구글 시트의 데이터를 Unity로 가져와서 별도의 Scene수정 없이 퀘스트를 자동으로 생성하면 좋겠다는 이야기가 나와서 그것들을 구현 해보려고 한다.


[QuestData]

먼저 데이터 테이블에서 관리할 데이터들을 정리해보자.

  • id : 퀘스트의 키값
  • name : 퀘스트의 이름
  • description : 퀘스트 설명
  • position : 퀘스트를 생성할 위치
  • scale : 퀘스트 시작 범위
  • type : 어떤 퀘스트인지
    • type에 따른 변수 : ex) 이동 퀘스트에서 얼마나 이동하면 클리어 되는지, 적을 처치하는 퀘스트에서 몇 명을 처치해야 하는 지 등

*position과 scale은 왜 필요한지 의문일 수도 있는 데, 현재 퀘스트 로직이 퀘스트에 Trigger가 존재하고, Player가 Trigger에 충돌하면 퀘스트가 시작되기 때문에 그 Trigger의 위치와 크기라고 생각하면 된다.

이 정보들을 토대로 QuestData를 만들면 다음과 같다.

using GoogleSheetsToUnity;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEngine.Events;
using System;

public enum QuestType
{
    PLAYER_MOVE,
    USE_ITEM,
    KILL_ENEMY,
}

[Serializable]
public struct QuestInfo
{
    public int id;
    public string name;
    public string description;
    public Vector3 position;
    public Vector3 scale;
    public QuestType type;

    // PLAYER_MOVE type일 때 사용
    public float requiredDistance;

    // KILL_ENEMY type일 때 사용
    public int requiredKillEnemyCount;
}

[CreateAssetMenu(fileName = "QuestData", menuName = "Scriptable Object/New QuestData")]
public class QuestData : SheetDataReaderBase
{
    public List<QuestInfo> questInfoList = new List<QuestInfo>();

    QuestInfo questInfo;

    /// <summary>
    /// 행의 키워드를 토대로 열의 값들을 가져와서 가공하는 함수
    /// </summary>
    /// <param name="list"> 구글 시트의 열 </param>
    public void UpdateStats(List<GSTU_Cell> list)
    {
        questInfo = new QuestInfo();

        foreach (var cell in list)
        {
            switch (cell.columnId)
            {
                case "id":
                    questInfo.id = int.Parse(cell.value);
                    break;
                case "name":
                    questInfo.name = cell.value;
                    break;
                case "description":
                    questInfo.description = cell.value;
                    break;
                case "posX":
                    questInfo.position.x = float.Parse(cell.value);
                    break;
                case "posY":
                    questInfo.position.y = float.Parse(cell.value);
                    break;
                case "posZ":
                    questInfo.position.z = float.Parse(cell.value);
                    break;
                case "scaleX":
                    questInfo.scale.x = float.Parse(cell.value);
                    break;
                case "scaleY":
                    questInfo.scale.y = float.Parse(cell.value);
                    break;
                case "scaleZ":
                    questInfo.scale.z = float.Parse(cell.value);
                    break;
                case "type":
                    questInfo.type = (QuestType)Enum.Parse(typeof(QuestType), cell.value);
                    break;
                case "requiredDistance":
                    if(questInfo.type == QuestType.PLAYER_MOVE)
                    {
                        questInfo.requiredDistance = float.Parse(cell.value);
                    }
                    break;
                case "requiredKillEnemyCount":
                    if(questInfo.type == QuestType.KILL_ENEMY)
                    {
                        questInfo.requiredKillEnemyCount = int.Parse(cell.value);
                    }
                    break;
            }
        }
        questInfoList.Add(questInfo);
    }

#if UNITY_EDITOR
    [CustomEditor(typeof(QuestData))]
    public class QuestDataReaderEditor : Editor
    {
        QuestData data;

        private void OnEnable()
        {
            data = (QuestData)target;
        }

        /// <summary>
        /// 커스텀 에디터 버튼. 누르면 데이터를 읽어옴
        /// </summary>
        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();

            GUILayout.Space(20);

            if (GUILayout.Button("구글 시트 데이터 읽어오기"))
            {
                ReadSheet(UpdateData);
                data.questInfoList.Clear();
            }
        }

        /// <summary>
        /// 구글 시트의 데이터를 읽어옴
        /// </summary>
        /// <param name="callback"> 데이터를 읽어오는 함수 </param>
        /// <param name="mergedcells"> 병합된 셀을 고려할 것인지 </param>
        private void ReadSheet(UnityAction<GstuSpreadSheet> callback, bool mergedcells = false)
        {
            SpreadsheetManager.Read(new GSTU_Search(data.sheetURL, data.sheetName), callback, mergedcells);
        }

        /// <summary>
        /// 지정한 시작열부터 끝열까지의 데이터를 전부 가져옴
        /// </summary>
        /// <param name="sheet"> 구글 시트의 데이터 </param>
        private void UpdateData(GstuSpreadSheet sheet)
        {
            for (int i = data.startRowIndex; i <= data.endRowIndex; ++i)
            {
                data.UpdateStats(sheet.rows[i]);
            }
            EditorUtility.SetDirty(target);
        }
    }
}
#endif

위 코드는 커스텀 에디터로 만든 버튼을 통해 구글 시트에서 데이터를 가져와서 QuestData라는 ScriptableObject에 저장해두는 코드이다.

Google Sheet에서 데이터를 가져와서 가공하는 부분은 이전에 정리했던 글을 참고하자.

링크 : Google Sheet To Unity

이제 가져온 이 데이터를 토대로 QuestManager에서 Scene에 퀘스트를 추가해보자.


[QuestManager]

이전의 QuestManager는 퀘스트의 시작과 클리어를 관리하는 일만 하고 있었다면, 이젠 퀘스트를 생성하여 맵에 배치하는 역할까지 수행한다.

using System.Collections.Generic;
using UnityEngine;

public class QuestManager : MonoSingleton<QuestManager>
{
    public QuestData questData;
    private Dictionary<int, QuestBase> questDictionary;

    protected override void Awake()
    {
        base.Awake();

        InitSpawnQuest();
    }

    /// <summary>
    /// 게임이 실행되면 QuestData를 토대로 맵에 퀘스트 생성
    /// </summary>
    private void InitSpawnQuest()
    {
        questDictionary = new Dictionary<int, QuestBase>();

        for (int i = 0; i < questData.questInfoList.Count; i++)
        {
            QuestInfo info = questData.questInfoList[i];

            GameObject questObject = new GameObject($"Quest{i}");
            TypeAddComponent(questObject, info);
            QuestBase quest = questObject.GetComponent<QuestBase>();
            quest.questInfo = info;
            questDictionary[info.id] = quest;

            questObject.transform.SetParent(this.transform);
            questObject.transform.localPosition = info.position;
            questObject.transform.localScale = info.scale;
            questObject.AddComponent<BoxCollider>();
            questObject.GetComponent<BoxCollider>().isTrigger = true;
        }
    }

    /// <summary>
    /// 타입에 따라 다른 컴포넌트를 추가함
    /// </summary>
    /// <param name="questObject"></param>
    /// <param name="info"></param>
    private void TypeAddComponent(GameObject questObject, QuestInfo info)
    {
        switch (info.type)
        {
            case QuestType.PLAYER_MOVE:
                questObject.AddComponent<QuestMove>();
                break;
            case QuestType.USE_ITEM:
                questObject.AddComponent<QuestUseItem>();
                break;
            case QuestType.KILL_ENEMY:
                questObject.AddComponent<QuestKillEnemy>();
                break;
        }
    }

    /// <summary>
    /// 퀘스트 시작 시 text를 UI에 출력
    /// </summary>
    public void QuestStart(int id)
    {
        QuestBase quest = questDictionary[id];

        quest.questState = QuestState.ONGOING;

        Debug.Log($"{questDictionary[id].questInfo.name}");
        Debug.Log($"{questDictionary[id].questInfo.description}");
        Debug.Log("퀘스트 시작");
    }

    /// <summary>
    /// 퀘스트 클리어 시 text를 UI에 출력
    /// </summary>
    public void QuestClear(int id)
    {
        QuestBase quest = questDictionary[id];

        quest.questState = QuestState.CLEAR;

        Debug.Log("퀘스트 완료");
    }

    /// <summary>
    /// 모든 퀘스트를 진행 전 상태로 리셋
    /// </summary>
    public void QuestReset()
    {
        Debug.Log("퀘스트 리셋");
        for(int i = 0; i < questDictionary.Count; i++)
        {
            questDictionary[i].questState = QuestState.BEFORE;
        }
    }
}

이전과 달리 큰 변화가 있는 부분은 Awake 함수에서 InitSpawnQuest 함수를 호출하는 것이다. InitSpawnQuest 함수가 이제 게임이 시작될 때 QuestData를 토대로 Scene에 퀘스트를 생성하고 배치하는 함수이다. 작동방식은 QuestData에 저장되어 있는 모든 퀘스트 목록을 순회하며, 빈 오브젝트를 생성하고 해당 빈 오브젝트에 퀘스트 타입에 맞는 컴포넌트를 추가해주고, 위치와 크기를 세팅해준다음 마지막으로 collider를 붙혀주는 방식이다. 이렇게 되면 게임이 시작됐을 때 자동으로 퀘스트가 생성된다.

게임 시작 전
게임 시작 후

0개의 댓글