게임에서 퀘스트는 방향성을 제공해 주고 게임의 목적성과 재미를 높여주며, 진행 상황을 추적하고 보상을 제공하여 게임을 보다 흥미롭게 만들어줍니다. 퀘스트 시스템을 구현하고 정리하였습니다.
💬 서론
- 본 글은 게임을 모두 만든 후에 작성한 글로 핵심 주제 외 클래스 및 다른 기능에 대한 함수가 포함될 수 있습니다.
📖 구현 내용
- 적 처치, 아이템 습득에 대한 퀘스트를 구현합니다.
- 플레이어는 퀘스트를 받고 퀘스트를 완료할 수 있습니다.
- 퀘스트 상태를 확인하여 상태별로 이벤트를 처리할 수 있습니다.
- 스크립터블 오브젝트로 퀘스트의 데이터를 관리합니다.
- 스크립터블 오브젝트를 프로젝트 폴더 내에서 관리하고, 이 데이터를 자동으로 불러옵니다.
- 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;
- 아이템 획득 퀘스트를 검사하기 위해 플레이어의 인벤토리를 가져옵니다.
- 인벤토리 시스템은 "[유니티] 인벤토리 시스템(1) - 인벤토리 관리자"에서 확인할 수 있습니다.
[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가 중복이 있다면 다음과 같이 나타납니다.
- 퀘스트가 겹친다는 로그가 출력되며, 퀘스트 리스트에는 아무것도 없는 상태가 됩니다.
'unity game modules' 카테고리의 다른 글
[유니티] 퀘스트 시스템(4) - 컴팩트 UI (0) | 2023.04.11 |
---|---|
[유니티] 퀘스트 시스템(3) - 콘텐츠 매니저 (0) | 2023.04.11 |
[유니티] 퀘스트 시스템(1) - 퀘스트 데이터 (0) | 2023.04.11 |
[유니티] 오브젝트의 중심을 기준으로 회전 (1) | 2023.04.08 |
[유니티] 데미지 표시 (Damage Indicator) (0) | 2023.04.03 |