[Unity] 엑셀 대화 정보들을 대화 이름으로 묶어서 가져오기

PenguinGod·2021년 10월 21일
3
post-thumbnail

Intro

케이디 님의 "단간론파를 유니티로 구현하기" 강의를 보는 중 대화 내용을 엑셀에서 불러오는 내용을 공부하고 있었습니다. 텍스트를 라인(줄)으로 가져오는 방식으로 구현했는데 보다가 이런 생각이 들었습니다. "분량이 적으면 모르겠는데 대화 양이 많아지면 몇 번째 줄이 무슨 대화였는지 일일이 기억할 수 있나?".
그래서 텍스트들을 하나의 대화로 묶어서 이벤트 이름으로 가져오는 방법을 나름대로 구현해봤고 그 과정을 말해보겠습니다.

참고로 제가 본 케이디님의 강의 주소입니다.
[유니티 강좌] 단간론파를 유니티로 구현하기 Part 4 - 1 데이터 파싱 및 엑셀 관리
단순 비주얼 노벨이 아니라 단간론파의 기능을 구현하는 것이니 단간론파 해보신 분들은 보시면 재미있을 겁니다.
꼭 단간론파를 해보신게 아니여도 유니티로 비주얼 노벨 게임을 만들 생각이 있으신 분들은 도움이 될 겁니다.
많이 봐주셨으면 좋겠습니다. 그래야 강의 2편이 나오거든요.

목표 설정

들어가기 전에 저희의 목표를 한번 보도록 하죠. 우선 저는 캐릭터 이름이나 대사 등 대화 정보를 이벤트 이름이로 가져올 수 있는 딕셔너리를 만들 겁니다.

// 딕셔너리 안에 자료형은 이해를 위해 임의로 지은 이름 
// 실제 코드에서는 <string, struct> 형식으로 들어감
private static Dictionary<이벤트 이름, 대화 정보> DialogueDictionary;
public static 대화 정보 GetDialogue(이벤트 이름)
{
    return DialogueDictionary[이벤트 이름];	
}

위의 코드처럼 딕셔너리를 선언 후 함수를 이용해 대화 정보를 가져올 수 있도록 하겠습니다. 함수와 딕셔너리는 static으로 선언해서 어디에서든지 접근할 수 있도록 할겁니다. 굳이 함수를 통해 값을 전달하는 이유는 외부에서 수정하는 것을 막기 위함입니다.

저걸 만들어가지고 뭘 할거냐 하면, 간단하게 오브젝트를 클릭하면 오브젝트가 가지고 있는 대화 정보(캐릭터 이름, 대사 등)를 로그창에 띄우는 걸로 하죠

엑셀에서 데이터 가져오기

엑셀 구조

우선 엑셀이가 어떻게 생겼는지 보겠습니다.

보시면 A열에는 이벤트 이름, B는 캐릭터 이름, C에는 대사가 나와있습니다.
주목할 점은 대사가 끝난 후에 A열에 있는 end 입니다. 저는 이벤트 이름으로 대화 정보를 구분할 건데 저 end가 기준점이 되어 줄 것입니다.
참고로 대사가 뭔가 이상한 이유는 강의 따라하다가 심심해서 제가 직접 대사를 넣어서 그렇습니다.

대화 내용을 담을 틀 만들기

강의에서는 대화 내용을 저장할 때 캐릭터 이름과 대사 내용을 배열로 관리하였습니다.

public struct TalkData
{
    public string name; // 대사 치는 캐릭터 이름
    public string[] contexts; // 대사 내용
}

캐릭터 한 명이 대사를 여러 번 말할 수 있으니 대사 내용을 배열로 설정한 것입니다. 그리고 저는 캐릭터가 아니라 하나의 "대화" 를 기준으로 데이터를 가져오는 것이 목적입니다.
대화는 혼자 하는게 아니죠. 심지어 친구가 없어서 가상의 인물 또는 자기 자신과 대화를 한다고 해도 게임에서는 텍스트 폰트, 색깔, 캐릭터 이름 등 서로 다른 사람이 말하는 것처럼 구현해야 합니다.

참고 : 아래의 유명한 짤처럼 자기 자신과의 대화라 하더라도 게임에서는 서로 다른 두 캐릭터가 이야기하는 것처럼 구현해야 합니다.

그렇기에 구조체 부분을 한번 더 배열로 만들어야 합니다. 말로만 들으면 어려우니 코드를 보면서 알아보죠.

using UnityEngine;

[System.Serializable] // 구조체가 인스펙터 창에 보이게 하기 위한 작업
public struct TalkData
{
    public string name; // 대사 치는 캐릭터 이름
    public string[] contexts; // 대사 내용
}

public class Dialogue : MonoBehaviour
{
    // 대화 이벤트 이름
    [SerializeField] string eventName;
    // 위에서 선언한 TalkData 배열 
    [SerializeField] TalkData[] talkDatas;
}

캐릭터 한 명이 치는 대사는 TalkData 를 이용하고 대사를 치는 캐릭터가 바뀌면 talkDatas의 다음 인덱스로 넘어가는 그런 식입니다.
예를 들어 talkDatas[0] 에는 주인공의 대화 정보가, talkDatas[1] 에는 주인공과 대화 중인 npc의 대화 정보를 가지고 있을 수 있겠죠.

딕셔너리 만들기

DialogueParse 스크립트 생성

대화 내용을 담을 틀을 만들었으니 이제 엑셀 파일을 기반으로 대화를 담을 파싱 스크립트를 만들어야 합니다.

제가 처음 말했던 것처럼 대화 정보를 가지는 딕셔너리와 반환할 스크립트를 만들겠습니다. 저는 DialogueParse 라는 이름으로 했습니다.

using System.Collections.Generic;
using UnityEngine;

public class DialogueParse : MonoBehaviour
{
    private static Dictionary<string, TalkData[]> DialoueDictionary = 
     				new Dictionary<string, TalkData[]>();
                    
    public static TalkData[] GetDialogue(string eventName)
    {
        return TalkDictionary[eventName];
    }
}

달라진 점은 자료형이 구체화된것과 구조체가 아니라 구조체 배열을 반환한다는 점이 바뀌었군요.

csv파일 생성

이제 엑셀 파일을 가져옵시다. 근데 사실 엑셀을 그대로 가져오는것은 안됩니다. csv파일로 저장 후 만들어야 합니다.
먼저 엑셀 파일을 다른 이름으로 저장하고

csv를 찾아서 저장해줍시다.

csv파일을 Assets 폴더에 넣습니다.

그럼 이제 스크립트를 열어봅시다. 우선 csv 파일을 담을 변수가 있어야 합니다.

// [SerializeField] 는 private로 선언된 변수도 인스펙터 창에 노출되게 해줍니다.
[SerializeField] private TextAsset csvFile = null;

인스펙터 창에 csv파일을 넣어야 합니다.

들어가기 전 구조 설명

자 그리고 이제 본격적으로 코드를 작성해야 하는데 여기서 무려 "3중 반복문" 을 사용해야 합니다. 아 잠깐 뒤로가기 누르지 말고 읽어봐요. 사실 앞에서 이미 복선은 있었습니다. 저희가 위에서 만든 TalkData 구조체를 다시 한번 보죠

public struct TalkData
{
    public string name; // 대사 치는 캐릭터 이름
    public string[] contexts; // 대사 내용
}

이렇습니다. 일단 저기 contexts가 string 배열입니다. 하나의 캐릭터가 대사를 2번 이상 말할 수 있기 때문이죠 그럼 일단 반복문 한번.
그리고 저는 "대화" 를 기준으로 데이터를 구분하고 싶었습니다. 그래서 만든 스크립트가

public class Dialogue : MonoBehaviour
{
    // 대화 이벤트 이름
    [SerializeField] string eventName;
    // 위에서 선언한 TalkData 배열 
    [SerializeField] TalkData[] talkDatas;
}

이겁니다. TalkData가 배열로 선언된 talkDatas가 있습니다.
그렇죠 대화 한번에 여러 캐릭터가 번갈아가면서 대화를 할테니 당연히 배열로 선언해야 합니다. 그러니 반복문 2번

그리고 위에서 설명한 자료들은 csv 파일을 한 줄씩 반복하면서 채워줘야 합니다. 그래서 깔끔한 3번!!! 한국인은 삼세판이니 괞찮지 않나요?

조용하라구요? 죄송합니다. 그냥 하겠습니다. 고백하자면 사실 저도 이거 만들면서 인생 처음으로 3중 반복문을 구현했습니다. 동시에 현재까지는 마지막입니다. 뭐 결론적으로 하고 싶은 말은 다중 반복문에 익숙하시지 않으셔도 의외로 쉽다는 겁니다. 그리고 보시면 알겠지만 루프 횟수가 엄청나게 뻥튀기되는 반복문은 아닙니다. 그거는 직접 보면서 알아보시죠. 드갑시다.

예열 작업

우선 csv 파일을 루프하기 전에 예열 작업이 필요합니다. 2줄

// 맨 아래 한 줄 빼기
string csvText = csvFile.text.Substring(0, csvFile.text.Length - 1);
// 줄바꿈(한 줄)을 기준으로 csv 파일을 쪼개서 string배열에 줄 순서대로 담음
string[] rows = csvText.Split(new char[] { '\n' });

첫 번째 줄부터 설명하겠습니다. 그냥 맨 아래 한 줄 빼는 겁니다. 엑셀이나 csv파일을 쓸 때 공식처럼 사용하는 코드인데 이유를 알 수 있는 방법은 간단합니다. csv 파일과 그걸 열 에디터만 있으면 됩니다(메모장도 가능).

에디터나 메모장에 csv파일을 드래그해보면 이런 모습이 나옵니다.

아 위에를 찍었네요 밑을 봐야되는데
참고로 대사 뒤에 쭉 이상한 데이터가 보이는데 그건 강의 진행하면서 넣은 값입니다. 이 포스팅은 대화 내용을 가져오는게 주제이기 때문에 따로 설명하지는 않지만 혹시 궁금하신 분은 제가 위에 올려둔 링크로 들어가서 케이디 님 강의를 보시면 재밌을 겁니다.

자 아래를 보시면 마지막 줄인 40번째 줄이 비어있는 것을 볼 수 있습니다. 엑셀 파일을 메모장에 복붙하거나 csv 파일로 저장하면 저렇게 한줄이 빕니다.
사람이 직접 마지막 줄을 지울 수도 있지만 대사 하나 바꿀때마다 굳이 저 파일을 열어서 바꾸는 게 귀찮기 때문에 코드로 하는 겁니다.

그리고 저 이미지에서 건질 수 있는 정보는 저거 하나가 아닙니다. 보시면
뭔가 , (쉼표) 가 되게 많은 것을 알 수 있습니다. 저거는 의미없이 있는게 아니라 셀의 구분점입니다. 즉 셀을 나누는 기준이 저 콤마인 것이고 콤마로 값을 나누는 것이 CSV (Comma Separated Values) 의 특징입니다.

string csvText = csvFile.text.Substring(0, csvFile.text.Length - 1);
string[] rows = csvText.Split(new char[] { '\n' });

위에서 이런 코드를 작성했는데 csvText는 마지막 줄이 지워진 것 빼고 저희가 에디터에서 본 바로 그 모습 그대로의 값을 가지고 있을 것입니다.

그것을 통째로 사용할 수는 없으니 몇 가지 작업을 해서 저희가 원하는 값인 셀로 쪼개야 하는데 그 과정에서 Split() 함수를 사용합니다.

2번째 줄을 보시면 '\n' 을 기준으로 나눈다고 되있는데 아시는 분들은 아시겠지만 '\n' 은 이스케이프 문자로 줄바꿈을 의미합니다. Split()은 말 그대로 나누는 함수고요 그러면 rows는 변수명 그대로 csv 파일을 한 줄씩 쪼개서 row들이 들어간 배열이 됩니다.
(\n이 코드창에 있는거랑 모습이 다르지만 같은 글자가 맞습니다.)

에디터의 텍스트를 보면 저의 경우에는 rows의 0번째는
"EventName,캐릭터 이름,대화 조건,대사,",
1번째는 "봉란 첫인사,봉란,안녕 방금 처음 왔나보네 이름이 뭐야?,"
뭐 이런식의 데이터를 가진 배열이 되겠죠.

for문 시작

위에서 만든 rows는 아직 데이터가 뭉쳐 있습니다. 간단히 말해서 셀 값이 아니죠. 때문에 한번 더 나눠야합니다. 그런데 정말 좋은 기준점인 , 가 보이는군요 저걸로 Split() 함수를 사용하면 딱 셀의 값이 될거같은 기분이 듭니다. 사실 기분이 아니라 맞습니다. 다음 작업의 시작이 그겁니다.

// 엑셀 파일 1번째 줄은 편의를 위한 분류이므로 i = 1부터 시작
for (int i = 1; i < rows.Length; i++)
{
    // A, B, C열을 쪼개서 배열에 담음 (CSV파일은 ,로 데이터를 구분하기 때문에 ,를 기준으로 짜름)
    string[] rowValues = rows[i].Split(new char[] { ',' });

    // 유효한 이벤트 이름이 나올때까지 반복
    if (rowValues[0].Trim() == "" || rowValues[0].Trim() == "end") 
        continue;


    List<TalkData> talkDataList = new List<TalkData>();
    string eventName = rowValues[0];

보시면 for의 중괄호가 끝나지 않았죠? 네 아직 반복문 2개가 남았습니다.
산 넘어 산이라는 말이 생각나지만 사실 그건 산을 넘은 사람이 할 수 있는 특권같은 말입니다. 그러니 얼른 산을 넘어서 합법적으로 불평을 해봅시다.

string[] rowValues = rows[i].Split(new char[] { ',' });

먼저 string배열인 rowValues에 엑셀의 row(행) 한 줄을 , 로 구분한 값들을 넣습니다. 그러니 rowValues는 ["이벤트 이름", "캐릭터 이름", "대사"] 이런 식의 형태를 가진 배열이 될 겁니다. 이 데이터들을 이용해 위에서 만든 TalkData 구조체를 채워 갈 것이기 때문에 중요합니다.

그리고 엑셀을 보시면 가끔 아무것도 없는 줄이 있습니다. 대화 이벤트 간의 가시성을 위해서 공백을 좀 넣은 것입니다. 하지만 코드에서는 말 그대로 쓸모가 없습니다. 저 부분을 넘겨야 할 필요가 있습니다.

if (rowValues[0].Trim() == "" || rowValues[0].Trim() == "end") 
    continue;

공백 부분을 넘기기 위해 rowValues[0] 번째의 값을 이용해 유효한 이벤트 이름이 나올 때까지 continue를 통해 건너 뜁니다.

참고로 string.Trim()은 공백을 제거하는 문법인데 가금씩 셀 값에 공백이 붙는 경우가 있어서 조건문에서 이상한 결과가 나올 때가 있어서 넣었습니다. 앞으로 값을 비교할 때 가끔씩 저 친구가 있을테니 기억해주세요.

List<TalkData> talkDataList = new List<TalkData>();

그리고 딕셔너리의 밸류에 넣을 TalkData의 List를 만듭니다. 저희는 배열을 만들어야 하지만 몇 명의 캐릭터가 몇 번 대화를 주고 받을지는 모르니 길이 변환이 자유로운 List로 만들고 후에 배열로 형병환을 하겠습니다.

string eventName = rowValues[0];

그리고 딕셔너리의 Key역할을 할 eventName에 rowValues의 0번째 값을 대입합니다. 위에서 유효한 이벤트 이름이 나올 때까지 continue를 이용해 넘겨서 rowValues의 0번째가 이벤트 이름인건 보장된 상황이기 때문에 가능한 코드입니다.

딕셔너리 세팅

그럼 다음 코드를 살펴보도록 하겠습니다. 위 코드에서 바로 다음줄로 이어지는 코드입니다. 즉 for문 안에 있다는 뜻이고 i를 사용해 현재 csv 몇 번째 줄을 돌고 있는지 알 수 있다는 뜻입니다. 이 점 숙지하고 들어갑시다.

while(rowValues[0].Trim() != "end") // talkDataList 하나를 만드는 반복문
{
    // 캐릭터가 치는 대사를 담을 변수 대사의 길이를 모르므로 리스트로 선언
    List<string> contextList = new List<string>();

    TalkData talkData; // 구조체 선언
    talkData.name = rowValues[1]; // 캐릭터 이름이 있는 B열

    do // talkData 하나를 만드는 반복문
    {
	contextList.Add(rowValues[2].ToString());
	if(++i < rows.Length) rowValues = rows[i].Split(new char[] {','});
	else break;
    } while (rowValues[1] == "" && rowValues[0] != "end");

	talkData.contexts = contextList.ToArray();
	talkDataList.Add(talkData);
} // while문 끝나는 중괄호

DialogueDictionary.Add(eventName, talkDataList.ToArray());

} // for 문 끝나는 중괄호

오 이중 반복문입니다. 그리고 마지막에 보면 for문이 끝나는 중괄호가 있습니다. 즉 여기가 끝입니다. 나이스.

우선 while의 조건을 보시죠

while(rowValues[0].Trim() != "end")

제가 초반에 A열의 "end" 가 대화의 기준점이 될 거라고 말했는데 그건 이 뜻이었습니다. 보시면 while 끝나고 바로 딕셔너리에 값을 세팅합니다.

DialogueDictionary.Add(eventName, talkDataList.ToArray());

eventName은 while문 시작 전에 for문에서 선언했고 talkDataList는 while문에서 값을 넣어줄 것입니다. 저는 대화가 끝나면 그 다음줄에 "end" 를 적어주었기 때문에 while문은 "end"를 만날 때까지 즉 하나의 대화가 끝날 때까지 루프하게 됩니다.

이제 while문 안으로 들어가 봅시다.
우선 대사를 담을 string List를 인스턴스합니다.

// 캐릭터가 치는 대사를 담을 변수 대사의 길이를 모르므로 리스트로 선언
List<string> contextList = new List<string>();

그리고 위에서 만든 talkDataList에 추가할 talkData를 선언합니다. 이 친구도 이름이 있었죠 캐릭터 이름입니다.

TalkData talkData; // 구조체 선언
talkData.name = rowValues[1]; // 캐릭터 이름이 있는 B열

캐릭터 이름은 1번째에 있었죠 넣어줍니다.

이제 캐릭터가 치는 대사를 넣어야 되는데 대사를 한 번만 칠 수도 있습니다. 하지만 대사가 없는 경우는 없겠죠 대사가 없으면 아예 엑셀 파일에 넣지를 않을 테니까요 그래서 do while문을 사용해 무조건 한번은 대사를 넣도록 하겠습니다.

 do // talkData 하나를 만드는 반복문
{
    // 내부 코드	
} while (rowValues[1] == "" && rowValues[0] != "end");

그리고 루프 조건을 보면 (rowValues[1] == "") 인데 엑셀 파일을 보시면 한 캐릭터가 연속적으로 대사를 치면 다음 캐릭터 이름 칸이 비어있는 것을 볼 수 있습니다. 그 부분을 이용한 겁니다.
&& 조건으로 따라오는 (rowValues[0] != "end") 가 있는 이유는 (rowValues[1] == "") 조건만 있을 경우에는 대화의 마지막 대사를 치는 마지막 캐릭터 이후에 row[1]은 공백이기에 대사가 끝난 것을 감지하지 못합니다. 그렇기에 대화가 끝나는 것을 알리는 end가 루프를 끝내도록 하였습니다.

벌써 3번째 보는 엑셀이지만 올라가서 보기는 귀찮으니 어쩔 수 없습니다.

자 이렇게 반복문을 사용했는데 2개 이상의 대사를 친다면 다음 대사를 넣기 위해 한가지 작업을 해야 합니다. 줄을 바꾸는 것이죠. 반복문이 돈다고 해서 마법처럼 줄이 바뀌지는 않습니다. 줄을 바꾸는 작업은 for문에서 하고 있기 때문이죠.

그래서 저는 do while문 안에서 i를 더하고 줄을 바꾸도록 하겠습니다.

contextList.Add(rowValues[2].ToString());
if(++i < rows.Length) rowValues = rows[i].Split(new char[] {','});
else break;

뭔저 대사를 더하고 조건에 따라 줄을 바꿉니다. if문의 조건은 배열에 없는 인덱스에 접근하는 것을 막기 위함입니다.
++i 는 i에 1을 더함과 동시에 i에 1을 더한 상태로 비교하게 하기 위함입니다. 사실 if문 내부에서 더해도 되는데 코드 좀 줄일려고 저렇게 했습니다.

그렇게 do while문이 끝나면 하나의 talkData가 완성되므로 talkDataList에 추가합니다.

talkData.contexts = contextList.ToArray();
talkDataList.Add(talkData);

그리고 while문도 끝나면 만든 리스트를 기반으로 딕셔너리에 값을 넣습니다.

TalkDictionary.Add(eventName, talkDataList.ToArray());

그렇게 while문이 끝나면 하나의 talkDataList가 완성됩니다. 그리고 진작에 선언한 eventName을 키로 while문이 돌아가며 만든 리스트를 밸류로 가지도록 딕셔너리에 값을 추가하고 또 다시 for문이 돕니다.

이렇게 for문까지 다 돌면 모든 이벤트 대화들의 정보를 가진 딕셔너리가 완성됩니다. 3중 반복문이지만 중간에 do while문에서 i를 더하기 때문에 루프 회수는 별로 크지 않습니다.

전체 코드

public static Dictionary<string, TalkData[]> DialogueDictionary = 
				new Dictionary<string, TalkData[]>();
[SerializeField] private TextAsset csvFile = null;



public void SetTalkDictionary()
{
    // 아래 한 줄 빼기
    string csvText = csvFile.text.Substring(0, csvFile.text.Length - 1);
    // 줄바꿈(한 줄)을 기준으로 csv 파일을 쪼개서 string배열에 줄 순서대로 담음
    string[] datas = csvText.Split(new char[] { '\n' }); 

    // 엑셀 파일 1번째 줄은 편의를 위한 분류이므로 i = 1부터 시작
    for (int i = 1; i < rows.Length; i++)
    {
        // A, B, C열을 쪼개서 배열에 담음
        string[] rowValues = rows[i].Split(new char[] { ',' });

        // 유효한 이벤트 이름이 나올때까지 반복
        if (rowValues[0].Trim() == "" || rowValues[0].Trim() == "end") continue;

        List<TalkData> talkDataList = new List<TalkData>();
        string eventName = rowValues[0];

        while(rowValues[0].Trim() != "end") // talkDataList 하나를 만드는 반복문
        {
            // 캐릭터가 한번에 치는 대사의 길이를 모르므로 리스트로 선언
            List<string> contextList = new List<string>();

            TalkData talkData;
            talkData.name = rowValues[1]; // 캐릭터 이름이 있는 B열

            do // talkData 하나를 만드는 반복문
            {
                contextList.Add(rowValues[2].ToString());
                if(++i < rows.Length) 
                    rowValues = rows[i].Split(new char[] { ',' });
                else break;
            } while (rowValues[1] == "" && rowValues[0] != "end");

            talkData.contexts = contextList.ToArray();
            talkDataList.Add(talkData);
        }

        TalkDictionary.Add(eventName, talkDataList.ToArray());
    }
}

모아놓고 보니 함수가 굉장히 길군요. 개인적으로 20줄이 넘는 함수는 별로 선호하지 않는데, 이제 보니까 while 부분은 함수로 만들어서 파라미터로 rows와 ref i를 받고 TalkData의 배열을 반환하는 함수로 만들어서

public void SetTalkDictionary()
{
    // 엑셀 파일 1번째 줄은 편의를 위한 분류이므로 i = 1부터 시작
    for (int i = 1; i < rows.Length; i++)
    {
        // A, B, C열을 쪼개서 배열에 담음
        string[] rowValues = rows[i].Split(new char[] { ',' });

        // 유효한 이벤트 이름이 나올때까지 반복
        if (rowValues[0].Trim() == "" || rowValues[0].Trim() == "end") continue;
        
        string eventName = rowValues[0];
        TalkDdata[] talkDatas = GetTalkDatas(rows, ref i);
        
	TalkDictionary.Add(eventName, talkDatas);
    }
}


TalkData[] GetTalkDatas(string[] rows, ref int index)
{
    List<TalkData> talkDataList = new List<TalkData>();
    
    while(rowValues[0].Trim() != "end") // talkDataList 하나를 만드는 반복문
    {
        // 캐릭터가 한번에 치는 대사의 길이를 모르므로 리스트로 선언
        List<string> contextList = new List<string>();

        TalkData talkData;
        talkData.name = rowValues[1]; // 캐릭터 이름이 있는 B열

        do // talkData 하나를 만드는 반복문
        {
	    contextList.Add(rowValues[2].ToString());
	    if(++index < rows.Length) rowValues =
        		rows[i].Split(new char[] { ',' });
            else break;
        } while (rowValues[1] == "" && rowValues[0] != "end");

        talkData.contexts = contextList.ToArray();
        talkDataList.Add(talkData);
    }
    
    return talkDataList.ToArray();
}

이런식으로 만들면 좀더 보기 편하지 않았을까..... 라는 생각을 포스팅 직전에 해서 넣어봤습니다. 왜 이런 생각은 다 끝나갈 때쯤에 드는지 모르겠네요. 참고로 위의 코드도 문제없이 돌아갑니다.

ref를 모르시는 분들은 아래 링크에 가시면 꽤나 도움이 될겁니다. 자매품으로 저희가 Ray쏠 때 사용하는 out에 대해서도 알 수 있으니 한 번 봐보시길 (제가 쓴 건 아니고 제가 보고 공부한 블로그입니다.)
C# ref, out 두 한정자와 차이점, 매개변수 사용법 차이

대화 정보 출력하기

이제 만든 딕셔너리를 사용해서 오브젝트를 클릭해 대화 정보를 로그에 뜨게 하면 저희의 목표를 이루게 됩니다.

우선 위의 함수를 Awake에서 실행해서 딕셔너리를 세팅합시다.

public class DialogueParse : MonoBehaviour
{
    private void Awake()
    {
        SetTalkDictionary();
    }
}

그리고 Dialogue에 자신의 이벤트 정보를 반환하는 함수를 만듭시다.

public class Dialogue : MonoBehaviour
{
    [SerializeField] string eventName = null;
    [SerializeField] TalkData[] talkDatas = null;

    public TalkData[] GetObjectDialogue()
    {
        return DialogueParse.GetDialogue(eventName);
    }
}

오브젝트를 클릭하면 대사가 출력되게 할 것이기 때문에 이 부분을 담당하는 스크립트를 하나 만들도록 하겠습니다.

using UnityEngine;

public class Interaction : MonoBehaviour
{
    [SerializeField] Camera cam;

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Ray ray = cam.ScreenPointToRay(Input.mousePosition);
            
            if (Physics.Raycast(ray, out RaycastHit hit))
            {
            	// 대화 정보 가져오기
                TalkData[] talkDatas = hit.transform.GetComponent<Dialogue>().GetObjectDialogue();
                // 대사가 null이 아니면 대사 출력
                if(talkDatas != null) DebugDialogue(talkDatas);;
                
            }
        }
    }

    // 대화 정보 출력하는 함수
    void DebugDialogue(TalkData[] talkDatas)
    {
        for (int i = 0; i < talkDatas.Length; i++)
        {
            // 캐릭터 이름 출력
            Debug.Log(talkDatas[i].name);
            // 대사들 출력
            foreach (string context in talkDatas[i].contexts) 
            	Debug.Log(context);
        }
    }
}

이벤트 이름은 유니티 인스펙터 창에서 저희가 직접 작성해줘야 합니다. 미리 정해둔 이벤트 이름을 넣읍시다.


흐릿해서 안보일 수도 있지만 큐브 오브젝트에 Dialogue 스크립트를 부여하고 이벤트 이름에 "봉란 첫인사" 라고 적었습니다.

큐브 오브젝트를 클릭하면 이렇게

대사가 뜹니다. 성공했습니다. 예~~~

예외 처리

목표는 이뤘지만 아직 끝나지 않았습니다. 대화 정보가 이벤트 이름과 잘 매칭되었는지 확인할 수 없고 이벤트 이름을 사람이 작성하기 때문에 실수가 있을 수 있습니다. 이런부분을 방지할 작업을 몇 가지 합시다. 지금 당장은 귀찮지만 나중에 분명 빛을 보는 순간이 옵니다. 아마도요.

딕셔너리 데이터 인스펙터에서 보기

기본적으로 딕셔너리는 인스펙터 창에 노출되지 않습니다. 그래서 여러가지 꼼수를 사용하는데 저는 구조체 리스트라는 방법을 쓰겠습니다. 있는 용어는 아니고 제가 타이핑하면서 의식의 흐름대로 만든 단어입니다. 이 글을 쓰고 있는 시간 기준으로 아직 생후 1분도 안된 갓난아기죠

우선 구조체를 선언합니다. 인스펙터 창에서 봐야하니 [System.Serializable] 은 필수입니다.

[System.Serializable]
public class DebugTalkData
{
    public string eventName;
    public TalkData[] talkDatas;
    
    public DebugTalkData(string name, TalkData[] td)
    {
        eventName = name;
        talkDatas = td;
    }
}

선언은 간단합니다. 그냥 딕셔너리의 키와 밸류 자료형, 그리고 생성자만 있으면 됩니다.

제가 구조체 리스트라고 했었죠? 위에서 선언한 구조체는 딕셔너리 한 쌍입니다. 근데 딕셔너리가 몇 개 있을지는 모르죠 그래서 구조체를 리스트로 선언해야 하고 저희가 인스펙터에서 확인할 데이터도 바로 그 리스트입니다. 그래서 "구조체 리스트" 입니다.

그럼 리스트를 선언하고 값을 넣어봅시다.

[SerializeField] List<DebugTalkData> DebugTalkDataList = 
					    new List<DebugTalkData>();
                        
void SetDebugTalkData()
{
    // 딕셔너리의 키 값들을 가진 리스트
    List<string> eventNames = 
        		new List<string>(DialogueDictionary.Keys);
    // 딕셔너리의 밸류 값들을 가진 리스트
    List<TalkData[]> talkDatasList = 
        		new List<TalkData[]>(DialogueDictionary.Values);

    // 딕셔너리의 크기만큼 추가
    for(int i = 0; i < eventNames.Count; i++)
    {
        DebugTalkData debugTalk = 
        	new DebugTalkData (eventNames[i], talkDatasList[i]);
        
        DebugTalkData.Add(debugTalk);
    }
 }

설명은 주석처리로 충분하다고 믿습니다. 사실 한 줄 한 줄 설명하는게 귀찮습니다. 죄송합니다.

저렇게 만든 함수를 Awake에서 딕셔너리 선언 후에 동작하게 넣어 주고

    private void Awake()
    {
        SetTalkDictionary();
        SetDebugTalkData();
    }

실행을 하면

이렇게 뜨는 모습을 볼 수 있습니다.

이제 인스펙터 창에서 이벤트 이름을 잘못 입력했을 때 경고창이 뜨게 하겠습니다.
전에 만든 GetDialogue() 함수를 수정해야합니다.

public static TalkData[] GetDialogue(string eventName)
{
    // 키에 매칭되는 값이 있으면 true 없으면 false
    if(DialogueDictionary.ContainsKey(eventName)
    	return DialogueDictionary[eventName];
    else
    {
        // 경고 출력하고 null 반환
        Debug.LogWarning("찾을 수 없는 이벤트 이름 : " + eventName);
        return null;
    }
}

이 코드를 적용한후 대화를 가져와 봅시다.

위에 사진에서는 없는 이벤트 이름인 "아무것도 없다" 를 넣었습니다.

TalkDatas가 비어있고 경고창이 뜨는 모습을 볼 수 있습니다.

마무리

내 진짜 끝이 났습니다. 위의 방식을 이용해 여러 가지 작업을 할 수 있을 겁니다. 저처럼 상호작용을 시도하면 대화 정보를 반환 할 수도 있습니다. 아니면 상호작용을 시도할 때 그때의 조건(예를 들어 게임 진행도)에 맞게 eventName을 바꿔 같은 오브젝트에서 상황에 따라 다른 대화를 진행할 수도 있을 것입니다.

제 방식이 마음에 들지 않으시면 자신만의 방식으로 여러분의 목적에 맞게 코드를 바꿔서 한 단계 발전한 시스템을 만들 수도 있습니다. 저도 강의에서의 내용이 마음에 들지 않아서 시도한 것이기 때문에 여러분이라고 하지 않을 이유도 못할 이유도 없습니다.

혹시 더 좋은 방법이 있으면 알려주시면 좋겠네요 특히 3중 반복문을 안 쓰는 방법이라던가 저도 나름 시도했지만 결국 찾지 못해서 사용한 거거든요.

글을 쓰다보니 내용이 너무 쓸데없이 많아져서 아쉽네요. 의식의 흐름대로 써서 설명도 많이 부족하고 내용도 부실한 거 같고요. 그래도 혹시 즐겁게 보셨거나 도움이 되었다면 정말 기분이 좋을거 같네요. 감사합니다.

profile
수강신청 망친 새내기 개발자

1개의 댓글

comment-user-thumbnail
2024년 6월 3일

보기 쉽게 이해하기 쉽게 포스팅 해주셔서 감사합니다.
잘 봤습니다. 근데 보다가 궁금하게 생겼습니다.

private static Dictionary<string, TalkData[]> DialogueDictionary =
        new Dictionary<string, TalkData[]>();

위의 코드에서 만든 DialogueDictionary와

public static TalkData[] GetDialogue(string eventName)
    {
        return TalkDictionary[eventName];
    }

에서 TalkDictionary
즉, DialogueDictionary와 TalkDictionary가 같은 것인가요?

그리고

    string[] datas = csvText.Split(new char[] { '\n' }); 

    for (int i = 1; i < rows.Length; i++)
    {
        string[] rowValues = rows[i].Split(new char[] { ',' });

위의 코드에서는 string[] datas와 rows[i]가 같은 것인지 여쭤보고 싶습니다.

코드를 이식하면서 맥락 상 두 개가 같은 것이겠다고 유추했지만 찝찝하기에 여쭤보아요.

답글 달기