[C#, 유니티] Unity3D_RPG Scriptable 응용 1 (선택지, 퀘스트 생성)

2022. 8. 29. 15:39코딩 2막 <C#개념편>/코딩 2막 <C#응용편>

728x90

Scriptable

저번 시간 스크립터블를 배웠다

오늘은 이것을 가지고 대화를 통해 선택지를 만들고 퀘스트를 주는 RPG를 만들어보겠습니다

유니티의 특징을 이용하여 해당 NPC마다 대화에 대해서 관리를 해주기 위해

애초부터 컴포넌트를 갖고 있도록 하는 방식이 편할것이다

그러나 하나의 NPC만 하더라도 대화가 한 두개가 아닐것이다

그래서 컴포넌트처럼 스크립트에 대화를 할당하고싶지만 게임오브젝트에 스크립트를 붙이고 싶지않다

관리가 어려울 뿐더러 매번 사용할때마다 붙이는것이 보기 좋지않다

 

그렇다면 어떤 방식이 좋을까?

컴포넌트를 타일형태의 데이터를 저장하는 방법, 바로 'Scriptable'을 이용하자는 것이다

기존 클래스들은 MonoBehaviour 클래스를 상속받고 있었다

컴포넌트를 타일형태로 저장하는 클래스는 ScriptableObject로 만든다

 

스크립터블오브젝트의 주의점이 있다!!

스크립터블 오브젝트는 파일로 저장하기때문에 레퍼런스이다

하나의 수정만 바뀌더라도 나머지 스크립트를 레퍼런스하는 모든 오브젝트들도 변경된다는 점이다

그리고 게임도중에 내용을 변경하면 그 변경사항이 그대로 유지된다

그래서 보통 데이터를 읽는 용도로 사용한다

이는 수정과 변경에 주의점이 필요하므로 읽기전용으로 사용하길 바란다


스크립터블 오브젝트 생성방법

다음은 스크립터블 오브젝트를 적용시켰을 때 npc가 대화하는 장면이다

 

결과

다음은 컴포넌트에서도 사용될수있는데 인스펙터의 창에서 변경하는데 도움이 될수있는 방법에 대해서 소개하겠습니다

 

클래스의 내용을 직렬화 하는 방식인데 바로 [System.Serializable]을 쓰면 됩니다

Talk라는 클래스를 갖는다면 대화를 할때 이름(talkername)을 붙여넣는다던가, 초상화(proprait)를 넣는다던 등

다양한 형태로 변경이 가능하여 게임의 확장성이 늘어나는 장점이 있습니다


1. 선택지 만들기

가지치기를 통한 선택지 선택

스크립트

 

선택지를 만들기 위해 총 6가지의 스크립트를 수정 및 구현 해주었습니다

 

1. UIManager.cs

대화컨트롤러와 선택지컨트롤러를 관리해주는 스크립트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
 
public class UIManager : Singleton<UIManager>
{
    [SerializeField]
    private ConversationController _conversationController;
    public ConversationController conversation { get { return _conversationController; } }
 
    [SerializeField]
    private DialogueController _dialogueController;
    public DialogueController dialogueController { get { return _dialogueController; } }
}
 
cs

2. Conversation - SriptableObject

상호작용을 통해 대화를 하는 NPC에게 파일형태의 스크립터블오브젝트를 넣어주기위한 Conversation.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
[CreateAssetMenu]
public class Conversation : ScriptableObject
{
    [System.Serializable]
    public class Talk
    {
        public string talkerName;
        public string content;
        public Sprite portrait;
    }
 
    [SerializeField]
    private Talk[] _talks;
    public Talk[] talks { get { return _talks; } }
 
    [SerializeField]
    private Conversation _nextConversation;
    public Conversation nextConversation { get { return _nextConversation; } }
 
    [SerializeField]
    private Dialogue _nextDialogue;
    public Dialogue nextDialogue { get { return _nextDialogue; } }
}
 
cs

3. ConversationController.cs

NPC와의 대화를 컨트롤하는 ConversationController.cs

대화시작, 대화과정, 대화끝의 메소드를 갖으며 확장성을 위해 talkerName과 대화content를 관리함

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
 
public class ConversationController : MonoBehaviour
{
    [SerializeField]
    private GameObject conversationUI;
    [SerializeField]
    private TextMeshProUGUI conversationTalkerName;
    [SerializeField]
    private TextMeshProUGUI conversationContent;
 
    [SerializeField]
    private Conversation curConversation;
    private int activeIndex = 0;
 
    public void StartConversation(Conversation conversation)
    {
        conversationUI.SetActive(true);
 
        if (curConversation != null)
        {
            Debug.Log("기존 대화 이어나감");
        }
        else
        {
            Debug.Log("새로운 대화 시작");
            curConversation = conversation;
        }
        Debug.Log("현재 대화 : " + curConversation.name);
        Debug.Log("현재 순서 : " + activeIndex);
        ProgressConversation();
    }
 
    private void ProgressConversation()
    {
        if (curConversation == nullreturn;
 
        Cursor.lockState = CursorLockMode.None;
        if (activeIndex < curConversation.talks.Length)
        {
            conversationTalkerName.text = curConversation.talks[activeIndex].talkerName;
            conversationContent.text = curConversation.talks[activeIndex].content;
            if (curConversation.talks[activeIndex].portrait != null)
            {
                // 초상화 설정
            }
 
            activeIndex++;
        }
        else
        {
            // Conversation scriptable object에 데이터를 추가하여 이후에 있을 행동 추가
            if (curConversation.nextConversation != null)
            {
                Debug.Log("다음 대화로");
                activeIndex = 0;
                curConversation = curConversation.nextConversation;
                StartConversation(curConversation.nextConversation);
            }
            else if (curConversation.nextDialogue != null)
            {
                Debug.Log("선택지 표시");
                UIManager.Instance.dialogueController.SetDialogue(curConversation.nextDialogue);
                EndConversation();
                UIManager.Instance.dialogueController.StartDialogue();
            }
            else
            {
                EndConversation();
            }
        }
    }
 
    private void EndConversation()
    {
        Debug.Log("대화 종료");
        Cursor.lockState = CursorLockMode.Locked;
        conversationUI.SetActive(false);
        curConversation = null;
        activeIndex = 0;
    }
}
 
cs

4. Dialogue - ScriptableObject

NPC와의 대화 혹은 퀘스트를 위한 선택지를 관리하고 생성할 수 있는 스크립터블 오브젝트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
[CreateAssetMenu (menuName = "ScriptableObject/Dialogue")]
public class Dialogue : ScriptableObject
{
    [System.Serializable]
    public class Choice
    {
        public string text;
        public Conversation conversation;
    }
 
    [SerializeField]
    private string _description;
    public string description { get { return _description; } }
 
    [SerializeField]
    private Choice[] _choices;
    public Choice[] choices { get { return _choices; } }
}
cs

5. DialogueController.cs

버튼을 통해 선택지를 선택하고 선택지가 생성되고 파괴되는 것들을 관리하는 DialogueController.cs

 

여기서는 상황에 맞게 장단점을 알고 사용해야하는 자료구조를 적용시켜보겠습니다

리스트 : 갯수가 정해지지 않고 갯수의 추가와 삭제가 좋다는 장점!
    배열 : 갯수가 정해진 상황에서 연결적으로 되어있기 때문에 접근이 빠르다는 장점!

 

지금은 선택지를 추가하고 삭제해야하기 때문에 배열보다는 리스트가 좋다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
 
public class DialogueController : MonoBehaviour
{
    private Dialogue curDialogue;
 
    private List<ChoiceController> choiceButtons = new List<ChoiceController>();
 
    [SerializeField]
    private GameObject dialogueUI;
    [SerializeField]
    private TextMeshProUGUI descriptionText;
 
    // 버튼은 프리팹을 사용하기위해 유니티엔진.UI를 불러와야함
    [SerializeField]
    private ChoiceController choiceButton;
    [SerializeField]
    private Transform choiceButtonPosition;
    [SerializeField]
    private float choiceButtonSpacing;
 
    public void SetDialouge(Dialogue dialogue)
    {
        if (dialogue == null)
            return;
 
        curDialogue = dialogue;
 
    }
    public void StartDialogue()
    {
        Cursor.lockState = CursorLockMode.None;
 
        dialogueUI.SetActive(true);
        descriptionText.text = curDialogue.description;
        MakeChoiceButton(curDialogue);
    }
    public void EndDialogue()
    {
        curDialogue = null;
        Cursor.lockState = CursorLockMode.Locked;
        dialogueUI.SetActive(false);
        DestroyButton();
    }
    private void MakeChoiceButton(Dialogue dialogue)
    {
        for (int i = 0; i < dialogue.choices.Length; i++)
        {
            // 위치 조정
            ChoiceController c = Instantiate(choiceButton);
            c.transform.SetParent(choiceButtonPosition);
            c.transform.localPosition = Vector3.down * choiceButtonSpacing * i;
            c.SetChoice(this, dialogue.choices[i], i);
            choiceButtons.Add(c);
        }
    }
    private void DestroyButton()
    {
        for (int i = 0; i < choiceButtons.Count; i++)
        {
            Destroy(choiceButtons[i].gameObject);
        }
        choiceButtons.Clear();
    }
}
 
cs

6. ChoiceController.cs

생성된 버튼을 선택하여 상황이 진행될 수 있도록 컨트롤하는 ChoiceController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
 
public class ChoiceController : MonoBehaviour
{
    private DialogueController dialogueController;
    private Dialogue.Choice choice;
    private int index;
 
    [SerializeField]
    private Button button;
    [SerializeField]
    private TextMeshProUGUI content;
 
    public void SetChoice(DialogueController dialogueController, Dialogue.Choice choice, int index)
    {
        this.dialogueController = dialogueController;
        this.choice = choice;
        this.index = index;
 
        content.text = choice.text;
        button.onClick.AddListener(OnClick);
    }
 
    // 버튼이 눌렸을때
    private void OnClick()
    {
        Debug.Log(choice.text + "이 선택됨");
        dialogueController.EndDialogue();
        UIManager.Instance.conver.StartConversation(choice.conversation);
    }
}
 
cs

 

Dialogue Scriptable을 통해 퀘스트로 넘어가도록 하겠습니다


2. (서브) 퀘스트 만들기

스크립터블 오브젝트를 통해 퀘스트를 만들어보겠습니다

보통 스크립터블 오브젝트의 경우 파일형태의 데이터를 관리하기때문에 퀘스트에 주로 사용이 됩니다

 

그런데 메인퀘스트의 경우 매우 방대한 데이터를 사용하므로 스크립터블은 적합하지 않다고 판단됩니다

메인퀘스트의 경우엔 엑셀형태를 주로 사용합니다

 

그래서 오늘은 스크립터블을 통해 서브퀘스트라는 스크립트 오브젝트를 만들겠습니다

퀘스트를 컴포넌트로 만들수도 있는데 좋은점은 퀘스트가 활성화가 되면 아이콘등으로 표시등을 해줄 수 있습니다

 

퀘스트는 UI매니저로 관리하기보다는 각각의 npc가 갖는게 좋다고 판단됩니다

그렇다면 퀘스트들은 어떤 흐름으로 진행되는게 좋을까요?

퀘스트 프로세스
1 조건 (만족하는가)
2 수락 (대화 Y or N)
3 요구사항 필요 (대화)
4 요구사항 충족 (대화)
5 보상

 

5단계의 흐름을 갖는 퀘스트를 만들어보도록 하겠습니다


인스펙터

퀘스트 정보 : 코인 5개 모아오기
NPC Dialogue


스크립트

1. Quest - ScriptableObject

퀘스트의 관한 정보를 담는 스크립터블오브젝트를 만들어주겠습니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
[CreateAssetMenu (menuName = "Scriptable/Quest")]
public class Quest : ScriptableObject
{
    public enum Type { Gather, Kill }
 
    // 퀘스트
    [Header("Quest")]
    [SerializeField]
    private Type _type;
    public Type type { get { return _type; } }
 
    [SerializeField]
    private string _title;
    public string title { get { return _title; } }
 
    [SerializeField, TextArea(2,5)]
    private string _description;
    public string description { get { return _description; } }
 
    // 요구사항
    [Header("Requirement")]
    [SerializeField]
    private string _requirementName;
    public string requirementName { get { return _requirementName; } }
 
    [SerializeField]
    private int _requirementAmount;
    public int requirementAmount { get { return _requirementAmount; } }
 
    // 보상
    [Header("Reward")]
    [SerializeField]
    private int _xp;
    public int xp { get { return _xp; } }
    [SerializeField]
    private int _gold;
    public int gold { get { return _gold; } }
 
    // 퀘스트 프로세스 - 대화
    [Header("Conversation")]
    [SerializeField]
    private Conversation _accept;
    public Conversation accept { get { return _accept; } }
    [SerializeField]
    private Conversation _progress;
    public Conversation progress { get { return _progress; } }
    [SerializeField]
    private Conversation _complete;
    public Conversation complete { get { return _complete; } }
}
 
cs

2. QuestManager.cs

퀘스트매니저는 자체적으로 퀘스트컨트롤러를 갖게 구현했으며

딕셔너리의 해싱기법을 통해 특정이름을 갖는 퀘스트를 바로 찾을 수 있게 구현합니다

딕셔너리를 사용할 경우 주의점은 '동일한 이름의 퀘스트의 경우를 삼가한다'입니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
public enum QuestState { Accept, Progress, Complete }
 
public class QuestManager : Singleton<QuestManager>
{
    public class QuestController
    {
        public Quest quest;
        public int curAmount; // 현 퀘스트는 몇개를 만족하느냐?
    }
    private Dictionary<string, QuestController> questDictionary = new Dictionary<string, QuestController>();
 
    public void StartQuest(Quest quest)
    {
        Debug.Log(quest.title + "퀘스트 시작");
        QuestController questController = new QuestController();
        questController.quest = quest;
        questController.curAmount = 0;
 
        questDictionary.Add(quest.title, questController);
    }
    public void ProgressQuest(Quest.Type type, string name, int amount)
    {
        foreach (QuestController questcontroller in questDictionary.Values)
        {
            if (questcontroller.quest.type != type)
                continue;
            if (questcontroller.quest.requirementName != name)
                continue;
 
            questcontroller.curAmount += amount;
        }
    }
    public void CompleteQuest(Quest quest)
    {
        Debug.Log(quest.title + "퀘스트 완료");
        questDictionary.Remove(quest.title);
    }
    public QuestState GetQuestState(Quest quest)
    {
        if (!questDictionary.ContainsKey(quest.title))
        {
            return QuestState.Accept;
        }
        QuestController qc = questDictionary[quest.title];
        if (qc.curAmount < quest.requirementAmount)
        {
            return QuestState.Progress;
        }
        else
        {
            return QuestState.Complete;
        }
    }
}
 
cs

3. NPC.cs

NPC와의 상호작용을 통해 퀘스트가 진행되도록 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
public class NPC : MonoBehaviour, IInteractable
{
    [SerializeField]
    private Conversation conversation;
 
    [SerializeField]
    private Quest quest;
 
    public void Interaction()
    {
        Debug.Log("플레이어와 상호작용");
        if (quest != null)
        {
            QuestState state = QuestManager.Instance.GetQuestState(quest);
            if (state == QuestState.Accept)
                UIManager.Instance.conversation.StartConversation(quest.accept);
            else if (state == QuestState.Process)
                UIManager.Instance.conversation.StartConversation(quest.progress);
            else if (state == QuestState.Complete)
                UIManager.Instance.conversation.StartConversation(quest.complete);
        }
        else
        {
            UIManager.Instance.conversation.StartConversation(conversation);
        }
    }
 
    public void OnFocused()
    {
        Debug.Log("플레이어의 대상이 됨");
    }
 
    public void OnUnFocused()
    {
        Debug.Log("플레이어의 대상에서 벗어남");
    }
}
 
cs

4. ConversationController.cs

퀘스트 스크립터블 오브젝트가 작동될 수 있도록 ConversationController.cs를 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
else
        {
            // Conversation scriptable object에 데이터를 추가하여 이후에 있을 행동 추가
            if (curConversation.nextConversation != null)
            {
                Debug.Log("다음 대화로");
                activeIndex = 0;
                curConversation = curConversation.nextConversation;
                StartConversation(curConversation.nextConversation);
            }
            else if (curConversation.nextDialogue != null)
            {
                Debug.Log("선택지 표시");
                UIManager.Instance.dialogueController.SetDialogue(curConversation.nextDialogue);
                EndConversation();
                UIManager.Instance.dialogueController.StartDialogue();
            }
            else if (curConversation.nextQuest != null)
            {
                QuestState state = QuestManager.Instance.GetQuestState(curConversation.nextQuest);
                if (state == QuestState.Accept)
                {
                    Debug.Log("퀘스트 시작");
                    QuestManager.Instance.StartQuest(curConversation.nextQuest);
                }
                else if (state == QuestState.Process)
                {
                    Debug.Log("퀘스트 진행중");
                }
                else if (state == QuestState.Complete)
                {
                    Debug.Log("퀘스트 완료");
                    QuestManager.Instance.CompleteQuest(curConversation.nextQuest);
                }
 
                EndConversation();
            }
            else
            {
                EndConversation();
            }
cs

서브 퀘스트를 만든 결과물

 

 참고자료 

 

Branching Dialogue System for Unity

A node based branching dialogue system created by me for Unity, which can visualise the dialogues in graphs.

dialoguegraphunity.carrd.co


 

공감해주셔서 감사합니다

728x90