대화 시스템 Part1 - 구조 설계와 조건 확인 서브시스템
RPG 장르에서 사용되는 대화 구조 설계하기
https://github.com/Reitbe/IVAN
개요
- RPG 장르에서 사용할 대화 시스템의 구조에 대해 설명한다.
- 대화와 퀘스트에서 사용하는 조건 확인 시스템에 대해 설명한다.
대화 시스템
게임에는 역시 대화 시스템이 필요하다. 이는 단순히 사람과 사람간의 대화를 넘어서, 아이템의 정보를 제공하거나 중요한 단서를 전달하는 상호작용 수단이기 때문이다.
대화 시스템의 형태는 게임 장르와 방식, 개성에 따라 다양하다. 화면 한켠에 채팅창이 존재하거나, 캐릭터 머리 위에 말풍선을 띄운다거나, 대화에 좀 더 집중해야 하는 장르라면 별도의 대화 UI를 사용할 수도 있다.
내가 만들고자 하는 대화 시스템은 우측 하단 ‘동물의 숲’ 대화 UI와 비슷한 형태이다. 화면 하단부에 공용 대화창을 가지고, 화자와 발화 내용을 함께 출력하는 방식이다.
기능은 어떠한가? 우선, 대화는 한 곳에서 집중적으로 관리되어야한다. 그리고 대화는 연결 리스트처럼 구성되어, 시작 지점만 알면 다음 대사로 자연스럽게 이어질 수 있어야한다. 선택지가 존재할 경우, 선택지에 따라 이어지는 대사가 달라지기에, 이를 반영하여 분기 처리를 해야한다.
이러한 요구사항을 기반으로 아래와 같이 흐름을 구성했다.
시스템 흐름
- 대화 컴포넌트(DialogueComponent)
- 플레이어와 대화할 상대에게 부착되는 컴포넌트이며, 플레이어는 이를 가지지 않는다. 독백의 경우 트리거 액터에 부착된다.
- 컴포넌트는 부착 대상의 고유 ID인 NPC ID를 가지며, 이를 기반으로 가능한 대화 목록을 탐색한다.
- 대화는 이 컴포넌트의 오너가 대화 매니저에 NPC ID를 전달하면서 시작된다.
- 대화 매니저(DialogueManagerSubsystem)
- 대화 시스템의 중심 역할을 담당한다.
- DB 매니저에 두 차례 접근하여 데이터를 가져온다.
- NPC ID를 기반으로 가능한 대화 시작 목록 접근
- 대화 ID를 기반으로 세부 대화 정보 구조체 접근
- 대화 시작 목록은 조건 확인 서브시스템에 전달되어 최종 출력할 대화 ID를 결정한다.
- DB 매니저(DatabaseSubsystem)
- 게임에 사용되는 다양한 데이터베이스를 저장 및 관리하는 서브시스템이다.
- 대화에 관련된 DB는 두 종류로 구성된다.
- NPC별 대화 시작 목록 DB. NPC는 각자의 대화 엔트리 DB를 가지며, DB매니저는 이를 종합한 DB를 가진다.
- 전체 대화 흐름을 저장하는 마스터 대화 DB.
- 조건 확인 서브시스템(ConditionManagerSubsystem)
- 스테이지 진행, 아이템 보유, 퀘스트, 플레이어 상태 등의 다양한 조건을 종합적으로 판단하기 위한 서브시스템.
- Type-Operation-Value 형태의
FConditionInfo
구조체를 기반으로 조건을 판단한다.
- HUD & 대화 UI
- HUD는 대화 UI를 소유하며, 뷰포트 관리 및 정보 갱신을 담당한다. 대화 매니저와 대리자로 통신한다.
- 대화 UI는 발화자, 대화 내용, 선택지의 출력을 담당한다.
- 선택지가 1개인 경우 : ‘다음’ 버튼만 활성화
- 선택지가 2개 이상인 경우 : 개별 선택지 활성화
데이터 중심 연결
위의 흐름을 만드는데 있어서 중요하게 생각한 것은 데이터 중심 연결 구조이다. 아무래도 복잡한 연산을 수행하기보다는, 필요한 데이터를 제공하는 것에 목적이 있었기 때문에 이런 방식으로 구성하게 되었다. 이를 위하여 다양한 열거형과 구조체를 활용했다.
열거형
기존에는 열거형을 주로 플레이어나 NPC의 상태 전이에 사용했다면, 대화 시스템에서는 주로 타입 구분에 초점을 맞춰 사용했다.
텍스트 기반으로 정보를 전달하는 것은 줄이고 싶었고, 선택지를 제한하고자 하는 의도도 있었다. 에디터에서 DB를 편집할 때, 토글 형태로 선택지를 표시할 수 있기를 원했기에 열거형이 적합하다고 판단했다. 특히 조건 확인 서브시스템에서 이 열거형을 많이 활용했다. 자세한 내용은 해당 부분에서 설명한다.
대화 정보 구조체
// IVGenericStructs.h
// 대화에 필요한 정보를 담은 대화 구조체
USTRUCT(BlueprintType)
struct FDialogueInfo
{
GENERATED_USTRUCT_BODY()
/* 대화 ID */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FName DialogueID;
/* 발화자 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FText SpeakerName;
/* 대화 내용 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FText DialogueText;
/* 대화 선택지 목록 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
TArray<FDialogueOption> DialogueOptions;
... // 기본 생성자, 생성자
};
// 대화의 선택지와 다음 대화 ID를 담는 구조체
USTRUCT(BlueprintType)
struct FDialogueOption
{
GENERATED_USTRUCT_BODY()
/* 선택지 내용 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FText OptionText;
/* 다음 대화 ID */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FName NextDialogueID;
... // 기본 생성자, 생성자
};
대화 정보 구조체는 식별을 위한 대화 ID
를 중심으로, UI에 출력할 발화자와 대화내용, 그리고 선택지 목록을 가진다.
하나의 대화에는 여러 개의 선택지가 따라올 수 있다. 이 선택에 따라 이후 진행될 대화가 결정되므로, 선택지 텍스트와 다음 대화 ID를 하나의 구조체로 묶어서 관리했다. 대화 정보 구조체는 는 이 선택지의 목록 또한 가진다.
대화 매니저는 대리자를 사용해 해당 대화 구조체의 참조를 HUD에 전달한다. 반대로 HUD는 사용자의 선택에 따라 다음 대화로 이어질 대화 ID만 추출하여 대리자를 통해 다시 전달한다.
대화 엔트리 구조체
// IVGenericStructs.h
// NPC가 대화 시작을 위해 사용하는 대화 정보 구조체
USTRUCT(BlueprintType)
struct FDialogueEntry
{
GENERATED_USTRUCT_BODY()
/* NPC ID - 발화자 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FName NPCID;
/* 대화 ID - 발화 내용 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
FName DialogueID;
/* 대화 시작 조건 목록 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
TArray<FConditionInfo> Conditions;
/* 우선 순위 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
int32 Priority;
... // 기본 생성자, 생성자
};
NPC별로 시작 가능한 대화 목록을 저장하는 구조체는 위와 같다. 이 구조체 하나가 하나의 시작 노드에 해당된다. 하나의 대화는 여러 개의 조건을 가질 수 있으며, 이 조건들을 모두 만족하는 경우에만 해당 노드가 선택된다. 이러한 조건 정보는 별도의 구조체로 관리된다.
조건 확인 서브시스템
조건 확인 서브시스템은 대화 시스템뿐 아니라 퀘스트 시스템에서도 사용된다. 따라서 입력 형식을 공통으로 가져야하며, 다양한 조건을 유연하게 판단할 수 있도록 설계해야 했다. 그렇기에 자주 사용되는 조건들을 미리 정의해두고, 이를 조합하여 활용할 수 있도록 했다.
이런 구조는 언리얼 엔진 행동트리의 블랙보드 조건 제어에서 볼 수 있다. 블랙보드에는 키, 키 쿼리, 키 값이 있으며, 이들을 조합해 조건 판단을 수행한다.
조건 구조체와 열거형
// IVGenericStructs.h
// 상태 조건 연산용 구조체
USTRUCT(BlueprintType)
struct FConditionInfo
{
GENERATED_USTRUCT_BODY()
/* 조건 타입 */
UPROPERTY(EditAnywhere)
EConditionType ConditionType;
/* 조건 연산자 */
UPROPERTY(EditAnywhere)
EConditionOperator ConditionOperator;
/* 조건 값 - Stage 상태에 접근하기 위함 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "ConditionType==EConditionType::Stage"))
EStageState StageStateValue;
/* 조건 값 - Quest 상태에 접근하기 위함 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "ConditionType==EConditionType::Quest"))
EQuestState QuestStateValue;
/* 조건 값 - Stat 상태에 접근하기 위함 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "ConditionType==EConditionType::Stat"))
EStatState StatTypeValue;
/* 조건 값 - Quest, Item에서 사용할 ID */
UPROPERTY(EditAnywhere, meta = (EditCondition = "ConditionType==EConditionType::Quest || ConditionType==EConditionType::Item"))
FName PrimaryID;
/* 조건 값 - Item 수량, Stat 값 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "ConditionType==EConditionType::Stat || ConditionType==EConditionType::Item"))
float NumericValue;
... // 기본 생성자, 생성자
};
이 구조를 구현하기 위해, 조건 구조체를 Type
- Operation
- Value
의 세 부분으로 구성했다.
Type과 Operation은 각각 열거형을 사용하여 지정된 값 만 에디터에 노출되도록 하였다. Type에 따라 허용되는 Operation의 종류와 처리 방식은 달라질 수 있다. 따라서 Operation은 단순히 연산자 형태를 표현할 뿐이고, 실제 조건 비교 과정은 조건 확인 서브시스템 내부에서 처리된다.
Value는 단일타입의 값이 아니다! 처음에는 FName타입의 Value 하나를 사용했지만, 두 개 이상의 값이 필요한 조건이 많아서 분리해두었다. 퀘스트의 상태를 나타낸다거나 아이템의 수량을 비교하는 등의 조건이 이에 해당된다.
또한, EditContiton 키워드를 사용하여 Type에 따라 편집 가능한 필드만 노출되도록 설정했다. 위처럼 Stage타입인 경우에는 특정 스테이지값(Enum)만 선택 가능하고, 퀘스트나 아이템 관련 필드는 지정할 수 없도록 막아두었다.
// IVGameProgressEnms.h
// 비교 조건
UENUM(BlueprintType)
enum class EConditionType : uint8
{
None UMETA(DisplayName = "None"),
Stage UMETA(DisplayName = "Stage"),
Quest UMETA(DisplayName = "Quest"),
Item UMETA(DisplayName = "Item"),
Stat UMETA(DisplayName = "Stat"),
};
// 비교 연산자
UENUM(BlueprintType)
enum class EConditionOperator : uint8
{
None UMETA(DisplayName = "None"),
Equal UMETA(DisplayName = "Equal"),
NotEqual UMETA(DisplayName = "NotEqual"),
Greater UMETA(DisplayName = "Greater"),
Less UMETA(DisplayName = "Less"),
GreaterEqual UMETA(DisplayName = "GreaterEqual"),
LessEqual UMETA(DisplayName = "LessEqual"),
Has UMETA(DisplayName = "Has"),
NotHas UMETA(DisplayName = "NotHas"),
};
// 퀘스트 진행 상태
UENUM(BlueprintType)
enum class EQuestState : uint8
{
None UMETA(DisplayName = "None"),
NotStarted UMETA(DisplayName = "NotStarted"),
InProgress UMETA(DisplayName = "InProgress"),
Completed UMETA(DisplayName = "Completed"),
};
// 스테이지 상태
UENUM(BlueprintType)
enum class EStageState : uint8
{
None UMETA(DisplayName = "None"),
FirstStage UMETA(DisplayName = "FirstStage"),
SecondStage UMETA(DisplayName = "SecondStage"),
};
열거형에는 Type, Operation 이외에도 Value평가에 필요한 상태들 또한 정의해두었다.
조건 확인 동작
조건 확인 시스템은 다음과 같은 순서로 동작한다.
- 대화 매니저 혹은 퀘스트 매니저에서 조건 확인을 원하는 구조체를 제공한다. 이 구조체는
FConditionInfo
구조체 배열을 포함한다. - 시스템은 배열의 각
FConditionInfo
요소에 대하여 순차적으로 조건 검사를 수행한다. - 검사는 타입별로 분기하여 처리된다. 타입에 따라 가져와야 할 정보, 연산 대상이 다르기 때문이다. 검사 결과는 모두 bool로 반환된다.
- 조건 하나가 만족되면 다음 조건으로 넘어간다. 하지만 하나라도 조건을 만족하지 못하면 검사를 중단하고 해당 매니저에게 검사 실패 결과를 전달한다.