3D RPG 만들기! (9) 퀘스트
퀘스트
이전 아이템과는 반대로 QuestData를 만들고, QuestUI를 만들었다.
퀘스트는 3가지 종류로 구분했다.
특정 NPC와 대화하기 Talk
해당 위치까지 이동하기 Move
아이템을 모으기 Collect
그리고 퀘스트의 상태는 시작가능, 진행중, 완료가능, 완료 4가지로 나눴다.
퀘스트는 이벤트로 발생하는 퀘스트와 NPC에게 받는 퀘스트 2가지가 있다.
시작할때와 보스를 처치하러갈때 이벤트 퀘스트로 자동으로 받아지며,
나머지는 NPC와 대화를 통해 NPC가 주는 퀘스트다.
QuestData는 퀘스트와 관련된 여러가지 정보를 가진다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public enum QuestType
{
Talk,
Move,
Collect
}
[CreateAssetMenu(fileName = "QuestData", menuName = "Data/Quest data")]
public class QuestData : ScriptableObject
{
// 퀘스트를 받아야 하는 NPC
// 제목
// 퀘스트 내용(TEXT)
// 필요한 조건(아이템)
// 보상
// 다음 퀘스트 NPC
public int id; // 퀘스트 고유번호
public InteractableType questGiverNpc; // 퀘스트를 주는 NPC;
public QuestType questType; // 퀘스트 종류
public QuestState questState = QuestState.Startable; // 퀘스트 상태
[Space(20f)]
[TextArea(1, 1)]
public string title; // 제목
[TextArea(2, 6)]
public string content, completableContent; // 퀘스트 수행 중 표시될 내용과 완료 후 표시될 내용
[Space(20f)]
public QuestObjective questObjective; // 목표
public Reward reward; // 보상(아이템,경험치)
}
[Serializable]
public class QuestObjective
{
// Talk, Move, Collect 3가지 경우.
// Talk인 경우 NPC 필요
// Move인 경우 Position 필요
// Collect인 경우 Item, count 필요
// Items을 다 모으면 QuestType에 따라 Pos나 Npc에게 가면 완료
public InteractableType nPC; // NPC 종류 (완료 시 만나야 할 NPC)
public Vector3 pos; // 목표 위치
public Item[] items; // 목표 수집 아이템
}
[Serializable]
public class Reward
{
public Item[] items;
public int Exp;
}
클래스로 구분지어서 여러개를 만들어놓고 사용하면 Inspector에서 보기 쉽다.
몇몇 정보들은 게임이 시작되면서 초기화하도록 만들었다.
예를들면 NPC위치같은 정보는 일일이 넣진 않았다.
QuestUI
왼쪽엔 현재 진행중인 퀘스트를 확인가능하다.
클릭하면 오른쪽의 퀘스트 정보들이 바뀐다.
우측엔 퀘스트 내용이 적혀있고
우측 아래엔 Inveontory를 만들때 쓰던 SlotUI를 가져와서 썼다.
QuestUI
UI에 표시되는 부분을 담당한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class QuestUI : MonoBehaviour
{
public TMP_Text titleText;
public Transform QuestsInProgressContent; //
public TMP_Text contentText; // content, completeContent를 표시
public Transform targetItemContent;
public TMP_Text stateText;
[Space(20f)]
[Header("Prefab")]
[SerializeField] private GameObject slotUiPrefab;
public GameObject QuestInProgressPrefab;
[SerializeField] List<QuestInProcessingUI> questInProcessingUIs = new List<QuestInProcessingUI>();
public List<SlotUI> slotUIs = new List<SlotUI>();
// NPC에게 퀘스트를 받음 or 이벤트 퀘스트
public void AddQuest(QuestData _questData)
{
GameObject questInProgress = Instantiate(QuestInProgressPrefab);
// RectTransform 설정
RectTransform questRT = questInProgress.GetComponent<RectTransform>();
questRT.SetParent(QuestsInProgressContent);
questRT.localPosition = new Vector3(questRT.position.x, questRT.position.y, 0);
int id = _questData.id;
QuestInProcessingUI questInProcessingUI = questRT.GetComponent<QuestInProcessingUI>();
questInProcessingUI.id = id;
questInProcessingUIs.Add(questInProcessingUI);
questRT.localScale = Vector3.one;
// Button설정
Button btn = questInProgress.GetComponent<Button>();
btn.onClick.AddListener(() => ChangeQuest(id));
TMP_Text tmp = questRT.GetChild(0).GetComponent<TMP_Text>();
tmp.text = _questData.title;
}
// 퀘스트 제거
public void RemoveQuestInProcessingUI(QuestData questData)
{
for (int i = 0; i < questInProcessingUIs.Count; i++)
{
if (questInProcessingUIs[i].id == questData.id)
{
Destroy(questInProcessingUIs[i].gameObject);
questInProcessingUIs.RemoveAt(i);
break;
}
}
}
// 클릭시 퀘스트 변경
public void ChangeQuest(int _id)
{
QuestManager.Instance.ChangeQuest(_id);
}
// 현재 보이는 퀘스트를 변경
public void SetCurQuestUI(QuestData _questData)
{
if (_questData == null)
{
stateText.text = "";
contentText.text = "현재 퀘스트 없음";
titleText.text = "";
RemoveSlot(); //targetItemSlot을 제거
return;
}
QuestState _questState = _questData.questState;
switch (_questState)
{
// UI도 변경
case QuestState.InProgress:
stateText.text = "진행중";
contentText.text = _questData.content;
break;
case QuestState.Completable:
stateText.text = "완료 가능";
contentText.text = _questData.completableContent;
break;
}
titleText.text = _questData.title;
// rewardList
}
// 필요한 아이템이 있는 퀘스트인 경우, 슬롯을 만듬
public void CreateSlot(Item[] _targetItems)
{
RemoveSlot();
for (int i = 0; i < _targetItems.Length; i++)
{
SlotUI _slotUI = CloneSlot();
_slotUI.SetItem(_targetItems[i].data.sprite);
slotUIs.Add(_slotUI);
_slotUI.gameObject.SetActive(true);
}
}
// 슬롯 제거
public void RemoveSlot()
{
foreach (SlotUI slotUI in slotUIs)
{
Destroy(slotUI.gameObject);
}
slotUIs.Clear();
}
// 클론슬롯 생성
private SlotUI CloneSlot()
{
GameObject slotObj = Instantiate(slotUiPrefab);
RectTransform rt = slotObj.GetComponent<RectTransform>();
rt.SetParent(targetItemContent);
rt.localScale = Vector3.one;
return slotObj.GetComponent<SlotUI>();
}
// 슬롯에 아이템 설정
public void SetTargetItemsUI(int[] _collectedAmounts, Item[] _targetItems)
{
for (int i = 0; i < _targetItems.Length; i++)
{
slotUIs[i].SetAmount(_collectedAmounts[i], _targetItems[i].amount);
}
}
}
QuestManager
플레이어의 퀘스트를 담당한다.
현재 진행중인 퀘스트 정보를 가지고있고, 현재 퀘스트를 따로 구분해서 가지고있다.
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics;
using UnityEngine;
public enum QuestState
{
Startable,
InProgress,
Completable,
Complete
}
public class QuestManager : Singleton<QuestManager>
{
public List<QuestData> questDatas = new List<QuestData>();
[Space(10f)]
[Header("현재 퀘스트")]
public QuestData curQuest; // 시작할때 바로 실행되는 퀘스트를 가지고있음
public QuestState questState //퀘스트 상태
{
get { return curQuest.questState; }
set
{
curQuest.questState = value;
questUI.SetCurQuestUI(curQuest);
}
}
public InteractableType targetNpc; // 완료 시 만나야 할 NPC
public Vector3 targetPos; // 목표 위치
public Item[] targetItems; // 목표 아이템과 개수
[SerializeField] private int[] collectedAmounts; // 목표 아이템을 모은 개수
private int _clearQuestCount = 0; // 클리어 한 퀘스트 개수 (7개가 되면 보스방 열림)
public int clearQuestCount
{
get {return _clearQuestCount;}
set
{
_clearQuestCount = value;
if(_clearQuestCount>=7)
{
OpenBossRoom();
}
}
}
[Space(10f)]
[Header("이벤트 퀘스트")]
// 1. 시작 퀘스트
// 2. 보스 퀘스트
[SerializeField] private QuestData[] eventQuestDatas;
[Space(10f)]
[Header("외부 오브젝트")]
[SerializeField] private Inventory inventory;
[SerializeField] private GuideLine guideLine;
public QuestUI questUI;
public GameObject questPanel;
[SerializeField] private GameObject clearText;
[Space(10f)]
[Header("NPC")]
public Transform bakerHouseGirlPos;
public Transform witchHouseGirlPos;
public Transform windmillGirlPos;
public Transform mushroomHouseGirlPos;
public Dictionary<InteractableType, Vector3> nPCPos = new Dictionary<InteractableType, Vector3>();
// 기타
private WaitForSeconds wfsDropItem = new WaitForSeconds(0.1f);
protected override void Awake()
{
base.Awake();
// nPCPos 설정
nPCPos.Add(InteractableType.Object, Vector3.zero);
nPCPos.Add(InteractableType.BakerHouseGirl, QuestManager.Instance.bakerHouseGirlPos.position);
nPCPos.Add(InteractableType.MushroomHouseGirl, QuestManager.Instance.mushroomHouseGirlPos.position);
nPCPos.Add(InteractableType.WindmillGirl, QuestManager.Instance.windmillGirlPos.position);
nPCPos.Add(InteractableType.WitchHouseGirl, QuestManager.Instance.witchHouseGirlPos.position);
// 퀘스트 설정
// inventory와 마찬가지로 questPanel을 켰다가 꺼줘야 정상적으로 동작함
questPanel.SetActive(true);
questPanel.SetActive(false);
}
//컷씬이 진행될 때 이벤트 퀘스트 시작 (마을로가기, 보스잡기)
public void StartQuest(CutSceneType _cutSceneType)
{
StartQuest(eventQuestDatas[(int)_cutSceneType]);
}
// 퀘스트 시작
public void StartQuest(QuestData _curQuest)
{
curQuest = _curQuest;
questDatas.Add(_curQuest);
questUI.AddQuest(_curQuest);
ChangeQuest(_curQuest);
}
// 현재 진행중인 퀘스트를 변경 - UI를 클릭해서 바꾸는 경우
public void ChangeQuest(int _id)
{
foreach (QuestData questData in questDatas)
{
if (questData.id == _id)
{
ChangeQuest(questData);
return;
}
}
}
// 현재 진행중인 퀘스트를 변경
public void ChangeQuest(QuestData _curQuest)
{
curQuest = _curQuest;
if (curQuest == null)
{
questUI.SetCurQuestUI(curQuest);
return;
}
targetItems = _curQuest.questObjective.items;
collectedAmounts = new int[targetItems.Length];
targetPos = _curQuest.questObjective.pos; //guide line에 사용
targetNpc = _curQuest.questObjective.nPC;
questState = _curQuest.questState;
questUI.CreateSlot(targetItems);
UpdateItem();
}
// 아이템을 줍거나 버릴 때 실행
public void UpdateItem()
{
if (curQuest == null) return; // 퀘스트가 없을때 아이템을 버리면 에러가 발생해서 추가
for (int i = 0; i < targetItems.Length; i++)
{
collectedAmounts[i] = inventory.FindItemCount(targetItems[i]);
}
questUI.SetTargetItemsUI(collectedAmounts, targetItems);
if (IsCompletedCollection()) CompleteCollection();
else collecting();
}
// 모은 개수가 목표개수보다 모자란게 있다면 false
public bool IsCompletedCollection()
{
for (int i = 0; i < targetItems.Length; i++)
{
if (collectedAmounts[i] < targetItems[i].amount) return false;
}
return true;
}
// 아이템 수집을 수집 완료했을 때 (targetPos로 이동)
public void CompleteCollection()
{
questState = QuestState.Completable;
// 길안내 시작
guideLine.StartGuide(targetPos);
}
// 아이템을 모으는 중일 때
public void collecting()
{
questState = QuestState.InProgress;
//길안내 멈춤 (다모았다가 템을 다시 버리는 경우 등)
guideLine.StopGuide();
}
// 현재 진행중인 완료가능한 퀘스트중 targetNPC와 대화를 한 경우
public void IsCompletedTalk(InteractableType _myInteractableType)
{
for (int i = 0; i < questDatas.Count; i++)
{
if (questDatas[i].questType != QuestType.Talk) return;
if (questDatas[i].questObjective.nPC == _myInteractableType)
{
Clear(questDatas[i]);
}
}
}
// 퀘스트 완료
public void Clear(QuestData _questData)
{
guideLine.StopGuide();
// 아이템 회수
foreach (var item in _questData.questObjective.items)
{
inventory.RemoveItem(item);
}
// 보상을 targetPos에 뿌리고 퀘스트 종료
_questData.questState = QuestState.Complete;
StartCoroutine("DropRewardItem", _questData);
GameManager.Instance.IncreaseExp(_questData.reward.Exp);
StartCoroutine("PlayClearEffect");
// 현재 보고있던 퀘스트가 클리어 된거라면, 다른 진행중인 퀘스트로 UI변경
questDatas.Remove(_questData);
questUI.RemoveQuestInProcessingUI(_questData);
if (curQuest == _questData)
{
ChangeQuest(questDatas.Any() ? questDatas[0] : null);
}
clearQuestCount++;
}
// 퀘스트를 클리어하면 아이템을 뿌림
private IEnumerator DropRewardItem(QuestData _questData)
{
foreach (Item item in _questData.reward.items)
{
for (int i = 0; i < item.amount; i++)
{
DropItemManager.Instance.DropItem(_questData.questObjective.pos, item.data);
yield return wfsDropItem;
}
}
}
// 클리어했다는 글자가 표시됨
private IEnumerator PlayClearEffect()
{
clearText.SetActive(true);
yield return new WaitForSeconds(2f);
clearText.SetActive(false);
}
// 해당위치로 이동하는 퀘스트인 경우
public void UpdatePlayerPos(Vector3 playerPos)
{
foreach (QuestData questData in questDatas)
{
if (questData.questState != QuestState.Completable) continue;
if (questData.questType == QuestType.Move && Vector3.Distance(playerPos, questData.questObjective.pos) < 3f)
{
Clear(questData);
break; //break를 없애지 않으면 for문 도중에 배열의 요소가 삭제되서 오류남
}
}
}
// 모든 퀘스트를 마치면 보스방에 가는 컷씬 진행(보스 처치 퀘스트도 추가됨)
public void OpenBossRoom()
{
CutSceneManager.Instance.StartCutScene(CutSceneType.BossRoom);
}
}
QuestInProcessingUI
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class QuestInProcessingUI : MonoBehaviour
{
// Text가 너무 길면 자동으로 움직이도록.
public int id; // 해당 UI가 가르키는 퀘스트 아이디
}
현재 진행중인 퀘스트들을 클릭하면 퀘스트 정보를 바꿔서 볼 수 있도록 id를 부여한 것이다.
NPC
퀘스트를 추가하면서 NPC도 추가할 부분이 많아졌다.
단순 대화만이 아니라 대화를 하면서 퀘스트를 클리어하거나 퀘스트를 진행해야 하는 부분이 생겼기 때문이다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 처음 만나면 무조건 Talk
// 다음 만날때부턴 Quest상태.
// Quest상태에서 대화를 진행하면 QuestInProgress상태
// QuestInProgress에서 퀘스트를 완료하면 Quest상태
// 만약 모든 퀘스트를 완료하면 Hint상태
public class NPC : Interactable
{
public TalkData talkData;
public InteractableState curTalkState; //현재 상태. NPC의 경우 quest에 따라 값이 달라짐
public Sprite[] portrait;
bool isVisit = false; // NPC를 만난적 없다면 TALK를 말함
bool isCompletedAllQuests = false;
[SerializeField] private QuestData[] questDatas;
void Start()
{
foreach (QuestData questData in questDatas)
{
if (questData.questType != QuestType.Move)
{
questData.questObjective.pos = QuestManager.Instance.nPCPos[questData.questObjective.nPC];
}
questData.questState = QuestState.Startable;
}
}
public override void Interact()
{
CheckCurTalkState();
CheckTalkQuest(myInteractableType);
TryTalk();
}
public void CheckCurTalkState()
{
foreach (QuestData questData in questDatas)
{
if (CheckQuestCompletedAndStart(questData))
{
isCompletedAllQuests = true;
}
else
{
isCompletedAllQuests = false;
break;
}
}
if (isCompletedAllQuests)
{
curTalkState = InteractableState.Hint;
}
if (!isVisit)
{
// 처음 한번은 무조건 Talk상태
isVisit = true;
curTalkState = InteractableState.Talk;
}
}
public void CheckTalkQuest(InteractableType _myInteractableType)
{
QuestManager.Instance.IsCompletedTalk(_myInteractableType);
}
public void TryTalk()
{
foreach (var _talkContent in talkData.talkContent)
{
if (_talkContent.state == curTalkState)
{
TalkManager.Instance.OpenTalkUI(_talkContent.content, portrait);
break;
}
}
}
// 퀘스트를 완료했는지 확인 (완료했다면 true)
private bool CheckQuestCompletedAndStart(QuestData _questData)
{
if (!isVisit) return false;
switch (_questData.questType)
{
case QuestType.Talk:
return CheckTalkQuest(_questData);
case QuestType.Move:
return CheckMoveQuest(_questData);
default: // Collect
return CheckCollectQuest(_questData);
}
}
private bool CheckTalkQuest(QuestData _questData)
{
switch (_questData.questState)
{
case QuestState.Startable:
_questData.questState = QuestState.InProgress;
curTalkState = InteractableState.TalkQuestStart;
QuestManager.Instance.StartQuest(_questData);
return false;
case QuestState.Complete:
return true;
default: // InProgress, Completable
curTalkState = InteractableState.TalkQuestInProgress;
return false;
}
}
private bool CheckMoveQuest(QuestData _questData)
{
switch (_questData.questState)
{
case QuestState.Startable:
_questData.questState = QuestState.InProgress;
curTalkState = InteractableState.MoveQuestStart;
QuestManager.Instance.StartQuest(_questData);
return false;
case QuestState.Complete:
return true;
default: // InProgress, Completable
curTalkState = InteractableState.MoveQuestInProgress;
return false;
}
}
private bool CheckCollectQuest(QuestData _questData)
{
switch (_questData.questState)
{
case QuestState.Startable:
_questData.questState = QuestState.InProgress;
curTalkState = InteractableState.CollectQuestStart;
QuestManager.Instance.StartQuest(_questData);
return false;
case QuestState.InProgress:
curTalkState = InteractableState.CollectQuestInProgress;
return false;
case QuestState.Completable:
if (_questData.questObjective.nPC == myInteractableType)
{
print("클리어");
curTalkState = InteractableState.CollectQuestComplete;
QuestManager.Instance.Clear(_questData);
}
return false;
default: //QuestState.complete
return true;
}
}
}
만들면서 수정했던 것들
1. 옵저버패턴을 사용할까 말까?
옵저버패턴으로 목표아이템이 있을 때, 인벤토리에서 아이템 개수를 가져와서 쓰는게 좋지않을까 생각했다.
그런데 옵저버패턴을 쓰는 것 보다 UpdateItem()에서 inventory의 FindItemCount를 쓰는걸로 해결했다.
결합도 부분에서 많이 고민했지만, 옵저버패턴으로 만드니 인벤토리 내부에서 위치를 옮기는 부분에서 개수가 무한증식해버리는 이상한 버그가 있었다.
원인은 찾았지만, 고치고 옵저버패턴을 쓰려고 새로 다 만드는것보다, 원래 만들어둔 FIndItemCount하나만 써도 원하는 구현은 다 할 수 있어서 다 만들었다가 지워버렸다.
2. ScriptableObject로 QuestData를 만들고 게임중 수정을 했더니 영구적으로 바뀌는걸 생각 못했다.
targetAmount는 scriptableOjbect의 값을 그대로 들고와서 쓰는데,
모아야 할 개수를 다른 변수에 옮기고 ScriptableObject의 값을 그대로 들고있는 targetAmount의 개수를 바꿨더니
ScriptableObject에 적어둔 모아야할 개수가 게임을 종료하면 바껴버린다.
게임을 종료하면 게임중 인스펙터에서 바꾼 값이 원래대로 돌아가지만,
SCriptableObject의 경우는 게임을 종료하더라도 원래대로 돌아가질 않더라...
그래서 중간에 싹다 수정했던적이 있다.
팁
Quest를 만들면서 다른 스크립트에 뭔가 추가하고 빼고 수정할일이 엄청 많았다.
왜냐하면 Quest를 어떤식으로 만들지 머리로 그리면서 이정도로 만들면 쓰기쉽겠지... 했던게 많았기때문.
그중에 팁처럼 쓰일 바꾼요소를 적어두자면
1. 인스펙터 배열의 Element num 이름바꾸기
TalkData에 적어둔 InteractableState를 바꿨다.
public enum InteractableState
{
Default = 0, // 오브젝트
Talk = 1, // 처음 만났을 때 하는 대사
Hint = 2, // NPC의 모든 퀘스트를 끝낸 상태. 퀘스트가 남아있는 NPC의 위치를 알려줌
QuestStart = 3, // Quest를 시작
QuestInProgress = 4, // 퀘스트 진행중 대사
QuestComplete = 5, // 퀘스트 완료
}
이걸 바꾸면서 엄청 늘어났는데, 배열에선 현재 상태를 알아보려면 enum을 확인해야한다.
TalkContent는 위 바뀐 Enum을 순서대로 넣어야하는데 Element 0 이렇게 표시되니 데이터를 넣을 때 혼란스럽다.
인스펙터에 Enum요소를 그대로 배열로 사용하는 그런방법이 있으면 좋으련만... 없는것같다.
이때 이름을 바꿀수 있는데 위처럼 name변수를 하나 추가한다.
[Serializable]
public class talkContent
{
public string name;
public InteractableState state;
public Content[] content;
}
위에 사진처럼 name가 들어가있는데 이름을 바꾸면 Element도 이름으로 바뀐다
그리고 이렇게두면 Name가 인스펙터에 계속 보이고 마음에 안든다면 지우면 된다.
[Serializable]
public class talkContent
{
[HideInInspector] public string name;
public InteractableState state;
public Content[] content;
}
와우! 이게 되네!
그리고 여러가지 해봤는데 [SerializeField] private로 만들었다가 [SerializeField]를 지우는건 안된다.
이거 신기하다고 알아내놓고 사실 안쓴다.
열심히 이렇게 수정해놓고 내가 talkContent에 State를 넣어놓고 이걸로 체크하도록 만들었기때문...