프로젝트를 진행하면서 퀘스트 시스템 구현한 내용을 정리하고자 합니다.
참고 자료의 내용을 토대로 퀘스트 시스템을 구축했습니다.
퀘스트 시스템을 구현하는 방법은 다양한데
위 방식을 택한 이유는 싱글톤을 기반으로 다양한 조건 처리를 비교적 쉽게 할 수 있다는 점,
규모가 크지 않은 현재 프로젝트에 적용하기에 적합하다는 판단에서였습니다.
언제 사용하면 좋을지부터 장단점까지.
참고 자료에서 자세하게 정리해 주고 있어서
한번 읽어보시면 좋을 것 같습니다.
기본적인 형태는 MVC 패턴(참고 자료 기반)에
추가로 전략 패턴을 적용하여 구현했습니다.
엄연히 따지자면 QuestBase하고 UIQuestSlot에서
직접 참조하고 있는 부분들이 있어서 순수 MVC는 아닙니다.
2024.11.07 - [디자인 패턴] - 디자인 패턴 - MVC(Model-View-Controller)패턴에 대해 알아보자(+ MVP, MVVM)
구성은 다음과 같습니다.
- Model: QuestBase
- View: UIQuestSlot
- Controller: QuestManager
작업한 엑셀 시트 (UGS 활용 중)

UGS에서는 첫 열을 딕셔너리의 키값으로 가지기에,
퀘스트ID를 키값으로 하고자 다음과 같이 만들었습니다.
지금은 데이터양이 많지 않지만 추후 확장까지 고려해서
(퀘스트ID / 10)을 통해 같은 퀘스트를 한눈에 찾고 구분할 수 있도록 하고자 했습니다.

또한 퀘스트 조건에 따라서 QuestType으로 구분하여
각 조건에 맞게 코드에서 대응할 수 있도록 다음과 같이 작성했습니다.
(전략 패턴 적용, QuestBase를 상속받는 각 타입별 퀘스트 구현)
퀘스트가 아이템/재화를 요구하는 상황이면 Consume,
특정 몬스터 처치면 Kill 이런 식입니다.
코드 일부 공개
몬스터 처치에 따른 결과 코드 중 일부입니다.
QuestManager
public void UpdateKillQuests(int enemyId, int amount = 1)
{
UpdateQuestsByType<KillQuest>(enemyId, amount);
}
public void UpdateConsumeQuests(int itemId, int amount = 1)
{
UpdateQuestsByType<ConsumeQuest>(itemId, amount);
}
public void UpdateStageQuests(int itemId, int amount = 1)
{
UpdateQuestsByType<StageQuest>(itemId, amount);
}
public void UpdateGachaQuest(int itemId, int amount = 1)
{
UpdateQuestsByType<GachaQuest>(itemId, amount);
}
public void UpdateEnforceQuests(int itemId, int amount = 1)
{
UpdateQuestsByType<EnforceQuest>(itemId, amount);
}
public void UpdateQuestClearQuest(int itemId, int amount = 1)
{
UpdateQuestsByType<QuestClearQuest>(itemId, amount);
}
private void UpdateQuestsByType<T>(int targetId, int amount = 1) where T : QuestBase
{
var currentQuests = GetCurrentQuests();
foreach (var quest in currentQuests)
{
if (quest is T typedQuest &&
(typedQuest.questData.requireConditionID == targetId || typedQuest.questData.requireConditionID == 0))
{
UpdateQuestProgress(quest.questData.ID, targetId, amount);
}
}
}
UIQuestSlot
public void UpdateQuestProgress()
{
titleText.text = _currentQuest.questData.description;
int progress = _currentQuest.GetProgress();
int requireCount = _currentQuest.questData.requireCount;
progressText.text = $"{progress}/{requireCount}";
// 진행도 바 업데이트
float progressBarText = (float)progress / requireCount;
progressBar.fillAmount = progressBarText;
// 보상 정보 표시
rewardAmountText.text = _currentQuest.questData.rewardCount.ToString();
rewardButton.interactable = _currentQuest.isCompleted;
}
// 완료 상태와 보상 버튼 관련 업데이트
public void UpdateRewardState()
{
bool isCompleted = _currentQuest.isCompleted;
bool hasReceivedReward = false;
if (GameManager.Instance.playerData.questData.ContainsKey(_currentQuest.questData.ID))
{
hasReceivedReward = GameManager.Instance.playerData.questData[_currentQuest.questData.ID].isCompleted;
QuestManager.Instance.UpdateQuestClearQuest(0);
}
else
{
QuestManager.Instance.CreateNewQuestSaveData(_currentQuest.questData.ID);
hasReceivedReward = false;
}
rewardButton.interactable = isCompleted && !hasReceivedReward;
completeMark.SetActive(hasReceivedReward);
}
public void OnRewardButtonClick()
{
if (_currentQuest != null && _currentQuest.isCompleted)
{
QuestManager.Instance.QuestCompletion(_currentQuest.questData.ID);
UpdateRewardState();
}
}
Quest Base
Quest base :
// 각 퀘스트 타입에서 자신의 condition을 초기화
protected abstract void InitializeCondition();
public virtual int GetProgress()
{
return condition.GetCurrentProgress();
}
// 데이터 로딩 후 각 퀘스트 컨디션 동기화
public void SetProgress(int progress)
{
if (condition != null)
{
condition.SetProgress(progress);
if (progress >= questData.requireCount)
{
isCompleted = true;
}
}
}
public void UpdateConditionProgress(int targetId, int count)
{
if (condition is ITargetQuset targetCondition)
{
targetCondition.UpdateProgress(targetId, count);
}
else if (condition is ICountQuest countCondition)
{
countCondition.UpdateProgress(count);
}
if (GetProgress() >= questData.requireCount)
{
isCompleted = true;
}
}
KillQuest, KillQuestCondition
public class KillQuest : QuestBase
{
public KillQuest(QuestData data) : base(data)
{
InitializeCondition();
}
protected override void InitializeCondition()
{
condition = new KillQuestCondition(questData);
}
}
public class KillQuestCondition : ITargetQuset
{
private readonly QuestData questData;
private int targetCount;
private int currentCount;
public KillQuestCondition(QuestData data)
{
this.questData = data;
this.targetCount = data.requireCount;
this.currentCount = 0;
}
public bool CheckCondition() => currentCount >= targetCount;
public int GetCurrentProgress()
{
return currentCount;
}
public void UpdateProgress(int target, int killCount)
{
if(questData.requireConditionID == target)
currentCount += killCount;
}
public void Reset()
{
currentCount = 0;
}
public void SetProgress(int progress)
{
currentCount = progress;
}
}
ITargetQuest, IQuestCondition, ICountQuest
이렇게 구분한 이유는
강화나 일부 퀘스트를 보면 아이템 ID가 필요 없기에 이렇게 구분했습니다.
public interface IQuestCondition
{
void Reset();
int GetCurrentProgress();
void SetProgress(int progress);
}
public interface ITargetQuset : IQuestCondition
{
void UpdateProgress(int targetId, int count);
}
public interface ICountQuest : IQuestCondition
{
void UpdateProgress(int count);
}
결과물

마무리
오늘은 프로젝트 기간동안 퀘스트 시스템을 구현했던 내용을 정리해봤습니다.
일단 테스트 배포가 코앞에 있어서 급하게 동작까지는 어떻게든 구현했지만
리팩토링 해야 할 부분이 많이 보여서 아마 더 작업을 진행 할 것 같습니다.
(퀘스트 슬롯 오브젝트 풀링 적용, UI 등 )
참고한 자료
[Unity] 유니티 데이터 기반 퀘스트 시스템 구현 (업적 및 주간 일간) - 달여행
퀘스트 시스템은 어떤 게임이던 필수적으로 들어가는 경우가 많습니다. 퀘스트 시스템 특성 상 게임의 여러 부분과 엮이다 보니 설계를 잘못할 경우 제작 일정에 큰 영향을 미칠 수 있습니다.
programmingdev.com