개요

  • 대화 시스템의 각 부분을 어떻게 구현했는지 알아본다.

대화용 NPC 만들기

우선 대화를 진행할 NPC 캐릭터를 만들어봤다. 이 캐릭터는 IVCharacterBase를 상속받아서 캐릭터의 기본 구성 요소를 갖추고 있으며, 대화를 위하여 IIIVInteractableInterface를 구현하고 UIVDialogueComponent를 부착했다.

Untitled

대화 컴포넌트(UIVDialogueComponent)

// IVDialogueComponent.h
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class IVAN_API UIVDialogueComponent : public UActorComponent
{
	GENERATED_BODY()

// 기본
public:	
	UIVDialogueComponent();

protected:
	virtual void BeginPlay() override;

// 대화용 정보
public:
	/* 대화를 시작한다 */
	void StartDialogue();

protected:
	/* 가능한 대화 목록에 접근하기 위한 NPC ID */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dialogue")
	FName NPCID;
};

NPC에 부착된 대화 컴포넌트는 개체 식별을 위한 NPC ID와 대화 시작 함수를 가진다. NPC ID는 개별 블루프린트에서 설정할 수 있으며, StartDialogue()를 통해 대화 매니저에게 해당 NPC ID를 전달하는 것으로 대화를 시작할 수 있다.

대화 매니저(UIVDialogueManagerSubsystem)

대화 매니저는 대화의 핵심 기능을 담당하는 서브시스템이다. DB 매니저, 조건 확인 서브시스템, 출력을 위한 HUD의 사이에서 그 역할을 수행한다. 다른 서브시스템에 접근해야하므로 Initialize()에서 미리 캐싱을 진행한다. 이때 초기화 순서 지정을 위하여 InitializeDependency<>()를 사용했다.

void UIVDialogueManagerSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);

	// 의존성이 있는 다른 서브시스템 먼저 초기화 지정
	Collection.InitializeDependency<UIVConditionManagerSubsystem>();
	Collection.InitializeDependency<UIVDatabaseSubsystem>();

	// 서브시스템 캐싱
	ConditionManagerSubsystem = GetWorld()->GetGameInstance()->GetSubsystem<UIVConditionManagerSubsystem>();
	DatabaseSubsystem = GetWorld()->GetGameInstance()->GetSubsystem<UIVDatabaseSubsystem>();
}

1차 DB 접근 - 대화 시작 목록 가져오기

대화 시작 목록 데이터베이스 생성

Untitled

대화 매니저는 대화 컴포넌트로부터 전달받은 NPC ID를 기반으로 해당 NPC의 대화 시작 목록을 획득해야한다. 이 대화 시작 목록 데이터베이스는 다음과 같은 과정을 거쳐 만들어진다.

  • Part1에 적어둔 FDialogueEntry구조체는 기본적인 대화 시작 정보를 담고 있다.
  • UIVDialogueEntryDatabaseUDataAsset을 상속받았으며, 이FDialogueEntry의 배열을 보유한 데이터베이스 에셋이다. 이 클래스의 블루프린트를 생성한 후, 에디터를 통해 데이터를 입력한다.
  • 각 NPC는 자신만의 UIVDialogueEntryDatabase를 갖는다. 개별 데이터베이스로 관리된다.
  • DB 매니저인 UIVDatabaseSubsystem는 NPC ID와 해당 DB 경로를 묶은 구조체 배열을 바탕으로, 모든 NPC의 시작 노드를 정리하여 보관한다.

      // IVDatabaseSubsystem.cpp
      	// NPC별 대화 엔트리 데이터베이스 로드를 위한 구조체
      	struct FEntry
      	{
      		FName KeyName; // NPC ID
      		FString Path;  // 데이터베이스 경로
      	};
        
      	// 대화 엔트리 목록
      	const TArray<FEntry> Entries =
      	{
      		{ FName(TEXT("OldMan")), TEXT("/Script/IVAN.IVDialogueEntryDatabase'/Game/GameCore/Database/DA_OldManDialogue.DA_OldManDialogue'") },
      	};
        	
      	// 대화 엔트리 DB 로드
      	for (auto& Entry : Entries)
      	{
      		if (UIVDialogueEntryDatabase* Database = LoadObject<UIVDialogueEntryDatabase>(nullptr, *Entry.Path))
      		{
      			DialogueEntryDatabase.Add(Entry.KeyName, *Database);
      		}
      	}
    
  • 그렇게 모든 NPC의 시작 노드 정보를 가지는 TMap<FName, TObjectPtr<UIVDialogueEntryDatabase>> DialogueEntryDatabase;가 만들어진다.
  • NPC의 대화 목록 내에서, 각각의 시작 노드는 우선 순위를 가진다. 이를 사용하여 정렬하거나, 배열의 순서를 조정하여 검사 순서를 지정할 수 있다. 중요한 대화는 상단에, 아주 기본적인 대화는 하단에 두는 식이다.

대화 가능 목록 데이터베이스 접근

//IVDatabaseSubsystem.cpp
TArray<FDialogueEntry>& UIVDatabaseSubsystem::GetDialogueEntryDatabase(const FName& NPCID) const
{
	if (DialogueEntryDatabase.Contains(NPCID))
	{
		return DialogueEntryDatabase[NPCID]->DialogueEntries;
	}

	// 참조라서 대화 목록이 없더라도 반환하긴 해야함
	static TArray<FDialogueEntry> EmptyArray;
	return EmptyArray;
}

NPC ID와 이에 해당되는 DB는 참조 형태로 전달된다. DB는 Map구조이므로 존재 여부가 확인되면 해당 DB의 참조를 반환한다.

조건 확인 서브시스템에서 대화 결정

// IVConditionManagerSubsystem
FName UIVConditionManagerSubsystem::CheckDialogueConditions(TArray<FDialogueEntry>& Conditions)
{
	FName Result = NAME_None;

	// 대화 엔트리 목록에서 각 엔트리별 대화 시작 조건을 확인
	for (auto& Entry : Conditions)
	{
		// 조건 확인
		bool bAllConditionsTrue = true;
		for (auto& Condition : Entry.Conditions)
		{
			if (!CheckCondition(Condition)) // 대화 조건 만족 시 출력할 대화 ID 반환
			{
				bAllConditionsTrue = false;
				break;
			}
		}

		if (bAllConditionsTrue)
		{
			return Entry.DialogueID; // 이번 대화의 모든 조건을 만족하는 경우 대화 ID 반환
		}
	}
	return Result; // 만족하는 조건이 없다면 NAME_None 반환
}

조건 확인 서브시스템(UIVConditionManagerSubsystem)에 관한 내용은 Part1에서 설명했다. 대화 매니저가 조건 확인 서브시스템에 시작 가능한 대화 목록을 제공하면, 이를 순회하며 조건을 검사한다. 이때, 모든 조건을 통과한 대화가 선택되어 대화 ID를 반환한다.

2차 DB 접근 - 대화 세부 정보 가져오기

2차 DB접근에서는 주어진 대화 ID를 기반으로 전체 대화 목록에서 지정 대화 구조체를 조회한다. 1차 DB접근과 유사한 부분도 있고, 차이가 나는 부분도 있다.

Untitled

  • 실행 횟수 차이
    • 1차 DB 접근 : 대화를 시작할 때 한 번 실행.
    • 2차 DB 접근 : 다음 대화로 넘어갈 때 마다 실행.
  • DB 구성 차이
    • 1차 DB : 대화 엔트리 구조체 → NPC 별 대화 엔트리 DB → 종합 대화 엔트리 DB. (소규모 DB의 집합)
    • 2차 DB : 대화 정보 구조체 → 종합 대화 DB. (개별 요소 집합)
  • 공통 접근 방식
    • Map 내부에 해당 원소가 있는지 먼저 확인하고, 해당 요소를 반환.

위젯에 데이터 전달

Untitled

앞선 내용은 어떤 대화를 출력할지 결정하는 과정이었다. 그리고 이번에는 이 데이터를 어떻게 전달하고, 그 결과를 어떻게 다시 전달받는지에 대해 설명한다. 대화 출력 과정은 크게 한 바퀴를 도는 모습으로 진행된다.

대화 매니저 → HUD → 대화 UI→선택지

우선 출력 데이터를 하위 구조로 전달하는 과정이다. 대화 정보 구조체가 대리자 및 함수 인자로 전달된다. 최종적으로 구조체를 전달받은 대화 UI는 이를 분해하여 발화자와 대화 내용 텍스트, 선택 버튼을 업데이트한다.

// IVDialogueWidget.cpp
void UIVDialogueWidget::UpdateDialogue(const FDialogueInfo& CurrentDialogue)
{
	// 대화 내용 업데이트
	DialogueSpeakerTextBlock->SetText(CurrentDialogue.SpeakerName);
	DialogueContentTextBlock->SetText(CurrentDialogue.DialogueText);

	// 기존 버튼 초기화
	ChoiceButtonBox->ClearChildren();
	NextDialogueButton->SetVisibility(ESlateVisibility::Collapsed);

	
	int32 ChoiceCount = CurrentDialogue.DialogueOptions.Num();
	if (ChoiceCount <= 1) // 단일 선택지 = 다음 버튼
	{
		NextDialogueButton->SetVisibility(ESlateVisibility::Visible);
		FText NextButtonText = FText::FromString(TEXT("다음"));
		NextDialogueButton->InitButton(NextButtonText, CurrentDialogue.DialogueOptions[0].NextDialogueID);
	}
	else // 선택지의 수를 기반으로 버튼 생성
	{
		for (int32 i = 0; i < ChoiceCount; ++i)
		{
			UIVSimpleButton* ChoiceButton = CreateWidget<UIVSimpleButton>(GetWorld(), ChoiceButtonClass);
			if (ChoiceButton)
			{
				ChoiceButton->InitButton(CurrentDialogue.DialogueOptions[i].OptionText, CurrentDialogue.DialogueOptions[i].NextDialogueID);
				ChoiceButton->OnButtonChoicedEvent.BindDynamic(this, &UIVDialogueWidget::OnChoiceButtonClicked);
				ChoiceButtonBox->AddChildToVerticalBox(ChoiceButton);
			}
		}
	}
}

대화 정보 구조체에는 선택지 배열이 포함되어있다. 이 선택지의 갯수에 따라 버튼을 생성하고, 선택지 텍스트와 다음 대화 ID 정보를 초기화 한다. 이 버튼에는 대리자가 선언되어있으며, 버튼이 눌렸을 때 구독자에게 다음 대화 ID를 전달한다.

선택지의 갯수가 1개라는 것은 오로지 다음 대화로 넘어가는 선택지만 존재한다는 것이다. 그렇기에 별다른 선택지 제공 없이 ‘다음’ 버튼을 활성화한다. 모든 대화는 최소 1개의 선택지를 가지므로, 이보다 선택지 갯수가 적을 수 는 없다.

선택지의 갯수가 2개 이상이라면 ‘다음’ 버튼을 비활성화 하고 별도의 선택 버튼을 생성한다. 이렇게 생성된 버튼은 수직 박스에 추가 및 관리된다.

선택지→대화UI→HUD→대화 매니저

플레이어가 특정 선택지 버튼을 클릭했다면, 하위 구조인 버튼부터 상위 구조인 대화 매니저까지 역방향 전파가 발생한다. 함수처럼 return을 사용해 상위 구조에 값을 전달할 수 없으므로, 대리자를 사용해 다음 대화 ID를 전달한다. 위 그림에 나타난 것 처럼 상위 구조로 올라가는 과정에 대리자를 사용하는 이유이다.

이를 위하여 각 항목마다 대리자를 선언했다. 선택지의 대화 UI는 선택지 버튼의 생성과 함께 선택지의 대리자를 구독한다. 선택지 버튼이 눌리면 이 대리자가 실행되며 대화 UI에 정보를 전달하는 방식이다. ‘다음’ 버튼의 경우 생성 이후 삭제되지 않으므로 NativeConstruct()에서 바인딩을 진행한다. 그러나 다른 버튼들은 각 대화 노드마다 생성과 삭제가 진행되기 때문에 매번 바인딩을 진행한다.

대화 라이프 사이클

위의 과정을 거치면 대화의 시작부터 첫 대화의 출력, 그리고 선택지의 결과 반환까지 1회 사이클이 진행된다.대화를 계속 이어갈지 여부는 대리자를 통해 전달받은 다음 대화 ID에의해 결정된다.

// IVDialogueManagerSubsystem.cpp
void UIVDialogueManagerSubsystem::ContinueDialogue(const FName& DialogueID)
{
	FDialogueInfo& CurrentDialogue = DatabaseSubsystem->GetDialogueInfo(DialogueID);

	// 대화 모드 전달 
	OnDialogueModeSet.Broadcast(CurrentDialogue.DialogueID == NAME_None ? false : true);

	// 대화 내용 전달
	OnDialogueReady.Execute(CurrentDialogue);

}

반환 대리자에 의해 실행되는 ContinueDialogue()는 2차 DB접근부터 위까지의 과정을 반복한다. 만약 이어지는 대화가 있다면 2차 DB 탐사에서 해당되는 대화 구조체를 찾을 것이고, 다시 정보를 전달할 것이다.

그러나 이전 대화가 마지막이여서 DialogueID = Name_None을 전달받았다면? 2차 DB탐사에서 어떠한 결과도 얻을 수 없고, 기본 대화 구조체를 반환받는다. 이또한 대화 ID는 Name_None이다. 이럴 경우에는 대화 내용 전달 대리자와 별도의 대화 모드 전달 대리자를 통해 대화의 종료를전달받는다.

플레이어 컨트롤러는 대화 모드를 종료하여 기본 UI와 플레이어 캐릭터 입력을 복구하고, HUD는 대화 UI를 숨긴다. 대화 내용의 전달이 되기는하지만 딱히 표시될 일은 없다. 분기처리하여 진행하지 않을 수도 있다.

결과 영상