내일배움캠프/Unity Final Projcet

[Unity] 퀘스트 시스템 구현 - 일일/주간/업적 관리와 UI 처리 구조 설계

danpat77 2025. 5. 9. 21:19

목표

게임 내에서 반복적인 행동 유도를 위한 퀘스트 시스템을 만들기 위해, 다음과 같은 구조를 설계하였습니다:

  • 퀘스트 데이터를 JSON으로 불러와 자동 분류
  • 일일, 주간, 업적 퀘스트 탭으로 구분된 UI 구성
  • 조건 충족 시 보상 수령 가능 여부 표시 및 완료 처리

1. 퀘스트 데이터 관리 (QuestManager)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 퀘스트 데이터 구조 정의
[System.Serializable]
public class QuestData
{
    public int key; // 고유 ID
    public string name; // 퀘스트 이름
    public int period; // 0: 일일, 1: 주간, 나머지: 업적
    public QuestConditions conditionType; // 달성 조건 타입
    public string questDesc; // 퀘스트 설명
    public int conditionCount; // 달성해야 할 수치
    public int currentConditionCount; // 현재 달성 수치
    public int rewardKey; // 보상 아이템 ID
    public int rewardAmount; // 보상 수량
    public bool isClear; // 완료 여부
}

// 퀘스트 전체를 관리하는 매니저 클래스 (싱글톤)
public class QuestManager : Singleton<QuestManager>
{
    public List<QuestData> dailyQuestList = new List<QuestData>();
    public List<QuestData> weeklyQuestList = new List<QuestData>();
    public List<QuestData> achievementQuestList = new List<QuestData>();

    public event System.Action OnQuestUpdated; // UI에 알릴 이벤트

    private void Start()
    {
        SetQuestData(); // 시작 시 퀘스트 데이터 로드
    }

    // 게임 매니저에서 퀘스트 테이블을 읽어와 리스트에 분류
    public void SetQuestData()
    {
        foreach (var data in GameManager.Instance.DataManager.QuestTableLoader.ItemsList)
        {
            QuestData questData = new QuestData
            {
                key = data.key,
                name = data.name,
                period = data.period,
                questDesc = data.conditionDesc,
                conditionType = data.condition,
                conditionCount = data.conditionCount,
                currentConditionCount = 0,
                rewardKey = data.rewardKey,
                rewardAmount = data.rewardNum,
                isClear = false
            };

            switch (data.period)
            {
                case 0: dailyQuestList.Add(questData); break;
                case 1: weeklyQuestList.Add(questData); break;
                default: achievementQuestList.Add(questData); break;
            }
        }
    }

    // 전체 퀘스트 리스트를 순회할 수 있게 제공
    public IEnumerable<QuestData> GetAllQuestList()
    {
        foreach (var q in dailyQuestList) yield return q;
        foreach (var q in weeklyQuestList) yield return q;
        foreach (var q in achievementQuestList) yield return q;
    }

    // 조건 타입에 따라 퀘스트 진행도 업데이트
    public void UpdateQuestProgress(QuestConditions type, int amount = 1)
    {
        foreach (var quest in GetAllQuestList())
        {
            if (quest.isClear) continue; // 완료된 퀘스트는 무시
            if (quest.conditionType == type)
                quest.currentConditionCount += amount;
        }

        // UI 갱신 이벤트 호출
        OnQuestUpdated?.Invoke();
    }
}

포인트

  • QuestConditions 타입으로 진행도를 판단
  • OnQuestUpdated 델리게이트를 통해 UI에 이벤트 전달

2. 퀘스트 UI 탭 및 슬롯 관리 (QuestUIHandler)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

// 퀘스트 패널을 제어하는 UI 핸들러 클래스
public class QuestUIHandler : MonoBehaviour
{
    [SerializeField] private GameObject questSlot; // 슬롯 프리팹
    private List<QuestSlot> questSlotPool = new List<QuestSlot>(); // 풀링 리스트

    [Header("일간")]
    [SerializeField] private GameObject dailyQuestPanel;
    [SerializeField] private Button dailyQuestButton;
    [SerializeField] private Transform dailyQuestTransform;

    [Header("주간")]
    [SerializeField] private GameObject weeklyQuestPanel;
    [SerializeField] private Button weeklyQuestButton;
    [SerializeField] private Transform weeklyQuestTransform;

    [Header("업적")]
    [SerializeField] private GameObject achievementableQuestPanel;
    [SerializeField] private Button achievementableQuestButton;
    [SerializeField] private Transform achievementableQuestTransform;

    private void OnEnable() => QuestManager.Instance.OnQuestUpdated += SettingQuestSlots;
    private void OnDisable() => QuestManager.Instance.OnQuestUpdated -= SettingQuestSlots;

    private void Start()
    {
        // 탭 버튼 이벤트 연결
        dailyQuestButton.onClick.AddListener(OnClickDailyQuestButton);
        weeklyQuestButton.onClick.AddListener(OnClickWeeklyQuestButton);
        achievementableQuestButton.onClick.AddListener(OnClickAchievementableQuestButton);

        // 기본으로 일일 퀘스트 탭 활성화
        dailyQuestPanel.SetActive(true);
        weeklyQuestPanel.SetActive(false);
        achievementableQuestPanel.SetActive(false);

        SettingQuestSlots(); // 슬롯 초기화
    }

    // 탭 전환 메서드
    private void OnClickDailyQuestButton()
    {
        dailyQuestPanel.SetActive(true);
        weeklyQuestPanel.SetActive(false);
        achievementableQuestPanel.SetActive(false);
    }

    private void OnClickWeeklyQuestButton()
    {
        dailyQuestPanel.SetActive(false);
        weeklyQuestPanel.SetActive(true);
        achievementableQuestPanel.SetActive(false);
    }

    private void OnClickAchievementableQuestButton()
    {
        dailyQuestPanel.SetActive(false);
        weeklyQuestPanel.SetActive(false);
        achievementableQuestPanel.SetActive(true);
    }

    // 퀘스트 데이터를 받아와 슬롯에 세팅
    public void SettingQuestSlots()
    {
        int index = 0;

        foreach (var data in QuestManager.Instance.GetAllQuestList())
        {
            // 퀘스트 타입에 따라 부모 오브젝트 결정
            Transform targetParent = data.period switch
            {
                0 => dailyQuestTransform,
                1 => weeklyQuestTransform,
                _ => achievementableQuestTransform
            };

            QuestSlot slot;
            if (index < questSlotPool.Count)
            {
                slot = questSlotPool[index];
                slot.gameObject.SetActive(true);
            }
            else
            {
                slot = Instantiate(questSlot, targetParent).GetComponent<QuestSlot>();
                questSlotPool.Add(slot);
            }

            slot.transform.SetParent(targetParent); // 부모 재지정
            slot.SetQuestData(data);
            index++;
        }

        // 완료된 퀘스트는 리스트의 맨 뒤로 이동
        for (int i = 0; i < questSlotPool.Count; i++)
        {
            if (questSlotPool[i].questData.isClear)
                questSlotPool[i].gameObject.transform.SetAsLastSibling();
        }

        // 사용하지 않는 슬롯은 비활성화
        for (int i = index; i < questSlotPool.Count; i++)
        {
            questSlotPool[i].gameObject.SetActive(false);
        }
    }
}

포인트

  • 슬롯 풀링을 적용하여 매 프레임마다 Instantiate를 하지 않도록 최적화
  • 퀘스트 기간(period)에 따라 부모 오브젝트를 스위칭해서 표시

3. 개별 퀘스트 슬롯 UI 처리 (QuestSlot)

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

// 개별 퀘스트 슬롯에 대한 UI 처리 담당
public class QuestSlot : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI questName; // 퀘스트 이름 텍스트
    [SerializeField] private Image rewardIcon; // 보상 아이콘 (사용 X 시 제거 가능)
    [SerializeField] private TextMeshProUGUI rewardValue; // 보상 수량 텍스트
    [SerializeField] private GameObject rewardAlertIcon; // 보상 수령 가능 표시
    [SerializeField] private Button questSlotButton; // 슬롯 클릭 버튼
    [SerializeField] private GameObject clearOverlay; // 완료 시 오버레이

    public QuestData questData;
    bool isAlert = false;

    private void Start()
    {
        // 슬롯 클릭 시 보상 수령 처리
        questSlotButton.onClick.AddListener(QuestClear);
    }

    // 슬롯에 데이터 바인딩
    public void SetQuestData(QuestData data)
    {
        this.questData = data;
        questName.text = data.name;
        rewardValue.text = data.rewardAmount.ToString();
        clearOverlay.SetActive(false);

        // 이미 완료된 퀘스트는 클릭 불가 처리
        if (data.isClear)
        {
            rewardAlertIcon.SetActive(false);
            questSlotButton.enabled = false;
            clearOverlay.SetActive(true);
            return;
        }

        // 수령 가능 여부 판단
        isAlert = data.currentConditionCount >= data.conditionCount;
        rewardAlertIcon.SetActive(isAlert);

        if (isAlert)
        {
            // 수령 가능 항목은 UI 상단에 정렬
            this.gameObject.transform.SetAsFirstSibling();
        }
    }

    // 보상 수령 처리
    public void QuestClear()
    {
        if (isAlert)
        {
            questData.isClear = true;
            this.gameObject.transform.SetAsLastSibling(); // 완료된 항목은 아래로
            rewardAlertIcon.SetActive(false);
            questSlotButton.enabled = false;
            clearOverlay.SetActive(true); // 회색 오버레이 표시
        }
    }
}

포인트

  • 보상 수령 조건 달성 시 빨간 표시 (rewardAlertIcon)
  • 수령 후에는 클릭 불가 처리 및 반투명 오버레이 표시

마무리

이번 퀘스트 시스템 구현에서 신경 썼던 부분은 다음과 같습니다.

  • 조건 기반 자동 분류 구조로 퀘스트 UI 유지 보수성 향상
  • 풀링 방식 적용으로 슬롯 인스턴스 재활용 최적화
  • 퀘스트 완료 여부에 따른 시각적 피드백 처리 (알림 아이콘, 오버레이 등)