Implementing a basic Dialog System in C++

В этом посте мы собираемся встроить базовую систему диалогов, используя C++. Давайте накидаем небольшой анализ нашей системы:
1. Игрок может инициировать (начать) разговор с “пешкой” (т.е. бот)
2. “Пешка” (бот) ответит нашему игроку, основываясь на его входном сообщении
3. Игрок может завершить разговор
Перед тем как мы погрузимся в создание нашей системы, вот конечный результат:

Отказ от ответственности: Я использовал некоторый Blueprint код, для того чтобы избежать некоторых трудностей, в случае же если вы хотите создать всю систему на С++ то советую посетить этот пост.
Вы можете найти код этого урока на моем GitHub

Gathering the necessary assets

Для этой системы нам нужны некоторые аудиозаписи. Я использовал audacity чтобы записать себя и потом импортировать записи в движок (не записывайте себя (свой голос – прим. перев.) пока что, вы поймёте это немного потом!)
В общей массе я использовал 5 записей:
1. Приветствие игрока и ответ “пешки”
2. Небольшой лёгкий диалог и ответ “пешки”
3. Прощание игрока

Setting Up our Project

Создайте Third Person C++ Template Project и добавьте следующий кейбинд:

Image

[свернуть]
Для того, чтобы определить когда наш UI должен показать субтитры или диалоги мы собираемся использовать две структуры. Первая будет включать выборку выбора игрока (это будет появляться внутри кнопок UI), соответствующий sfx, массив субтитров и логическая переменная, которая будет использована для того, чтобы определить, хотим ли мы получать ответ от “пешки”.
Вторая структура будет субтитрами сама по себе. Каждые субтитры включают связанный со временем текст, показывающийся внутри нашего UI так же, как и время в секундах, что есть время, отведённое для показа наших субтитров на экране. Это идентично тому, как субтитры работают в кинофильмах. Учтите, что время относительно – связанное время, равное 0 секундам, означает, что субтитры должны появиться, когда начнётся наш sfx. Связанное время, равное 1 секунде, означает, что субтитры должны появиться после того, как sfx начнёт проигрываться.
Так как я записывал свой голос программой audacity, я использовал её интерфейс для определения фактического времени для каждых субтитров.
Давайте взглянем на звуковую волну второй записи, содержащую следующие субтитры:
1. Эй, это довольно милое место
2. Так ?
Image

[свернуть]
Заметьте, что я выбрал время прямо после того, как вторые субтитры должны исчезнуть и audacity информирует меня (см. красную стрелку на рис.) что этот звук будет воспроизведён после 2.7 секунд. Эта величина и есть связанное время (англ. associated time). Теперь вы знаете рабочий процесс (англ. workflow), чтобы найти правильное связанное время без хардкора или отгадывания!
Я советую создать ваши записи на этом этапе, т.к. с этого момента мы будем сфокусированы на коде.
Примечание: как соглашение с этого момента я буду говорить о второй “пешке” как о ИИ. Не путайте – этот пост не содержит ИИ как такового!

Сказав это, давайте добавим следующие структуры C++:
1. Структура с названием «Dialog»
2. Структура с названием «Subtitle»

Вот код для структуры Subtitle (.h файл):

Code


USTRUCT(BlueprintType)
struct FSubtitle : public FTableRowBase
{
GENERATED_USTRUCT_BODY()

/*The subtitle that will be displayed for a specific period of time in our UI*/
UPROPERTY(EditAnywhere)
FString Subtitle;

/*The relative time in seconds, that the subtitle will appear*/
UPROPERTY(EditAnywhere)
float AssociatedTime;
};

[свернуть]
Не забудьте включить библиотеку “Engine/DataTable.h”
Код для структуры Dialog(.h файл):
Code


USTRUCT(BlueprintType)
struct FDialog : public FTableRowBase
{
GENERATED_USTRUCT_BODY()

/*The question's excerpt - this will be shown inside our UI*/
UPROPERTY(EditAnywhere)
FString QuestionExcerpt;

/*The actual SFX that we're going to play*/
UPROPERTY(EditAnywhere)
USoundBase* SFX;

/*An array of the associated subtitles*/
UPROPERTY(EditAnywhere)
TArray Subtitles;

/*True if we want to wait for the AI to respond*/
UPROPERTY(EditAnywhere)
bool bShouldAIAnswer;
};

[свернуть]
Кроме того, в структуре Dialog включите следующие строки:
Code


#include "Engine/DataTable.h"
#include "Subtitle.h"

[свернуть]
Скомпилируйте ваш код и переключитесь в редактор. Потом добавьте новую Data Table с именем PlayerLines, основанную на структуре Dialog и добавьте следующие строки:
Image

[свернуть]
Когда вы закончили с этим, создайте следующую Data Table с именем AILines, основанную на структуре Dialog и заполните её точно таким же способом, как и прошлую:
Image

[свернуть]
Убедитесь, что Player Lines и AI Lines имеют одни и те же имена строк! Это связано с кодом, которым мы будем встраивать далее.
В таблице AI вы можете не включать некоторые поля, такие как QuestionExcerpt и ShouldAnswer потому, что они нам просто не понадобятся.

Creating our dummy AI

Создайте новый C++ класс с именем AICharacter, который наследует класс character и добавьте нижеприведённый код в файл хэдера (англ. header file):

Code


private:
UFUNCTION()
void OnBoxOverlap(AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherIndex, bool bFromSweep, const FHitResult& SweepResult);

UFUNCTION()
void OnBoxEndOverlap(AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherIndex);

UFUNCTION()
void Talk(USoundBase* SFX, TArray Subs);

public:
/*Answers to the character after a specified delay*/
void AnswerToCharacter(FName PlayerLine, TArray& SubtitlesToDisplay, float delay);

/*Retrieves the corresponding character lines*/
UDataTable* GetPlayerLines() { return PlayerLines; }

protected:

/*If the player is inside this box component he will be able to initiate a conversation with the pawn*/
UPROPERTY(VisibleAnywhere)
UBoxComponent* BoxComp;

/*The audio component responsible for playing the dialog coming from this pawn*/
UPROPERTY(VisibleAnywhere)
UAudioComponent* AudioComp;

/*The player lines - each pawn can offer different dialog options for our character*/
UPROPERTY(EditAnywhere, Category = DialogSystem)
UDataTable* PlayerLines;

/*The ai lines*/
UPROPERTY(EditAnywhere, Category = DialogSystem)
UDataTable* AILines;

[свернуть]
Не забудьте включить следующие библиотеки:
Code


#include "Engine/DataTable.h"
#include "Subtitle.h"

[свернуть]
Именно сейчас определите функции (добавив в них ноль строк кода). Нормальную версию сделаем позже!
Переключитесь на исходный файл вашего AI и добавьте следующие инициализации внутрь конструктора:
Code


//Init the box and audio comps
BoxComp = CreateDefaultSubobject(FName("BoxComp"));
BoxComp->AttachTo(GetRootComponent());

AudioComp = CreateDefaultSubobject(FName("AudioComp"));
AudioComp->AttachTo(GetRootComponent());

[свернуть]
Потом измените функцию начала проигрывания, чтобы она совпадала с приведённым кодом:
Code


// Called when the game starts or when spawned
void AAICharacter::BeginPlay()
{
Super::BeginPlay();

//Register the begin and end overlap functions
BoxComp->OnComponentBeginOverlap.AddDynamic(this, &AAICharacter::OnBoxOverlap);
BoxComp->OnComponentEndOverlap.AddDynamic(this, &AAICharacter::OnBoxEndOverlap);

}

[свернуть]
Скомпилируйте и сохраните ваш код. Переключитесь в редактор и создайте blueprint, основанный на вышеупомянутом c++ классе. Потом:
1. Присвойте стандартный статичный меш и blueprint анимации по умолчанию
2. Увеличьте box comp extent до относительно больших масштабов (Я использовал 250 х 250 х 100)
Давайте пока что оставим AI, к которому мы вернёмся позже для добавления нового функционала.

Creating our UI

Найдите ваш файл [Your_Project_Name].Build.cs и добавьте следующую строку:

Code


PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "Slate", "SlateCore" });

[свернуть]
Потом отправьтесь к [Your_Project_Name].h и добавьте следующие библиотеки:
Code


#include "Runtime/UMG/Public/UMG.h"
#include "Runtime/UMG/Public/UMGStyle.h"
#include "Runtime/UMG/Public/Blueprint/UserWidget.h"
#include "Runtime/UMG/Public/Slate/SObjectWidget.h"
#include "Runtime/UMG/Public/IUMGModule.h"

[свернуть]
Далее переключитесь в ваш редактор и создайте новый c++ класс, который наследует класс UserWidget. Я назвал свой класс DialogUI. Откройте заголовок (англ. header file) и добавбте следующие строки:
Code


logUI header fileC++

public:

/*This property will be used in order to bind our subtitles
Binding will make sure to notify the UI if the content of the following
variable change.*/
UPROPERTY(BlueprintReadOnly)
FString SubtitleToDisplay;

/*Updates the displayed subtitles based on the given array*/
UFUNCTION(BlueprintCallable, Category = DialogSystem)
void UpdateSubtitles(TArray Subtitles);

/*This array will populate our buttons from within the show function*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
TArray Questions;

/*Adds the widget to our viewport and populates the buttons with the given questions*/
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = DialogSystem)
void Show();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public:

/*This property will be used in order to bind our subtitles
Binding will make sure to notify the UI if the content of the following
variable change.*/
UPROPERTY(BlueprintReadOnly)
FString SubtitleToDisplay;

/*Updates the displayed subtitles based on the given array*/
UFUNCTION(BlueprintCallable, Category = DialogSystem)
void UpdateSubtitles(TArray Subtitles);

/*This array will populate our buttons from within the show function*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
TArray Questions;

/*Adds the widget to our viewport and populates the buttons with the given questions*/
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = DialogSystem)
void Show();

[свернуть]
Кроме того, не забудьте включить Dialog.h!
Создайте простую логику для функции UpdateSubtitles для этого времени.
Скомпилируйте и сохраните ваш код!
Создайте blueprint, основанный на вышесказанном c++ классе и перенесите следующий UI:
Image

[свернуть]
Убедитесь, что переменная SubtitleToDisplay связана в середине текстбокса.
Кроме того, отметьте Q1, Q2 и Q3 текстбоксы как переменные.
Переключитесь во вкладку графов UMG редактора и создайте следующее событие:
Image

[свернуть]
Примечание: вы можете назначить свойства c++ к этим текстбоксам вполне легко. Я только использовал этот путь вместо в этом случае.
Давайте оставим UMG на данный момент и подумаем над логикой нашего персонажа!

Creating the logic for our character

Найдите заголовочный файл персонажа и добавьте код:

Code


private:
/*True if the player is currently talking with any pawn*/
bool bIsTalking;

/*True if the player is inside a valid range to start talking to a pawn*/
bool bIsInTalkRange;

/*Initiates or terminates a conversation*/
void ToggleTalking();

/*The pawn that the player is currently talking to*/
AAICharacter* AssociatedPawn;

/*A reference to our lines - retrieved from the associated pawn*/
UDataTable* AvailableLines;

/*Searches in the given row inside the specified table*/
FDialog* RetrieveDialog(UDataTable* TableToSearch, FName RowName);

public:

/*Generates the player lines*/
void GeneratePlayerLines(UDataTable& PlayerLines);

/*This array is essentially an Array of Excerpts from our dialogs!*/
UPROPERTY(BlueprintReadOnly)
TArray Questions;

/*Performs the actual talking - informs the associated pawn if necessary in order to answer
The subtitles array contain all the subtitles for this talk - it should be passed to our UI*/
UFUNCTION(BlueprintCallable, Category = DialogSystem)
void Talk(FString Excerpt, TArray& Subtitles);

/*Enables / disables our talk ability. The player can't talk if he's not in a valid range*/
void SetTalkRangeStatus(bool Status) { bIsInTalkRange = Status; }

/*Sets a new associated pawn*/
void SetAssociatedPawn(AAICharacter* Pawn) { AssociatedPawn = Pawn; }

/*Retrieves the UI reference*/
UDialogUI* GetUI() { return UI; }

protected:

/*The component responsible for playing our SFX*/
UPROPERTY(VisibleAnywhere)
UAudioComponent* AudioComp;

/*Opens or closes the conversation UI*/
UFUNCTION(BlueprintImplementableEvent, Category = DialogSystem)
void ToggleUI();

/*UI Reference*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
UDialogUI* UI;

[свернуть]
Не забудьте добавить следующие библиотеки в .generated.h:
Code


#include "AICharacter.h"
#include "DialogUI.h"

[свернуть]
Переключитесь на исходный файл вашего персонажа и внутри конструктора добавьте данный код:
Code


bIsTalking = false;
bIsInTalkRange = false;
AssociatedPawn = nullptr;

AudioComp = CreateDefaultSubobject(FName("AudioComp"));
AudioComp->AttachTo(GetRootComponent());

[свернуть]
Далее, давайте добавим бинд, который мы добавили через редактор. Найдите функцию SetupPlayerInputComponent и скопируйте код:
Code


InputComponent->BindAction("Talk", IE_Pressed, this, &ADialogSystemCharacter::ToggleTalking);

[свернуть]
Сверх этого я изменил функции MoveRight и MoveForward чтобы отключить движение игрока, когда он участвует в диалоге примерно таким способом:
Code


void ADialogSystemCharacter::MoveForward(float Value)
{
if ((Controller != NULL) && (Value != 0.0f) && !bIsTalking)
{
// find out which way is forward
const FRotator Rotation = Controller->GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0);

// get forward vector
const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
AddMovementInput(Direction, Value);
}
}

void ADialogSystemCharacter::MoveRight(float Value)
{
if ( (Controller != NULL) && (Value != 0.0f) && !bIsTalking )
{
// find out which way is right
const FRotator Rotation = Controller->GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0);

// get right vector
const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
// add movement in that direction
AddMovementInput(Direction, Value);
}
}

[свернуть]
Когда вы покончили с этим, создайте функцию Toggle Talking:
Code


void ADialogSystemCharacter::ToggleTalking()
{
if (bIsInTalkRange)
{
//If we are in talk range handle the talk status and the UI
bIsTalking = !bIsTalking;
ToggleUI();
if (bIsTalking && AssociatedPawn)
{
//The associated pawn is polite enough to face us when we talk to him!
FVector Location = AssociatedPawn->GetActorLocation();
FVector TargetLocation = GetActorLocation();

AssociatedPawn->SetActorRotation((TargetLocation - Location).Rotation());
}

}
}

[свернуть]
Прямо тут создайте пустое тело для оставшихся функций. А теперь начнём разрабатывать логику для функции ToggleUI::
Image

[свернуть]
Потом внесите следующую логику для GeneratedPlayerLines:
Code


void ADialogSystemCharacter::GeneratePlayerLines(UDataTable& PlayerLines)
{
//Get all the row names of the table
TArray PlayerOptions = PlayerLines.GetRowNames();

//For each row name try to retrieve the contents of the table
for (auto It : PlayerOptions)
{
//Retrieve the contents of the table
FDialog* Dialog = RetrieveDialog(&PlayerLines, It);

if (Dialog)
{
//We retrieved a valid row - populate the questions array with our excerpts
Questions.Add(Dialog->QuestionExcerpt);
}
}

//Make sure to create a reference of the available line for later use
AvailableLines = &PlayerLines;
}

[свернуть]
Для того, чтобы вышеприведённая функция работала, нам также нужно реализовать функцию RetrieveDialog:
Code


FDialog* ADialogSystemCharacter::RetrieveDialog(UDataTable* TableToSearch, FName RowName)
{
if(!TableToSearch) return nullptr;

//The table is valid - retrieve the given row if possible
FString ContextString;
return TableToSearch->FindRow(RowName, ContextString);
}

[свернуть]
Последним шагом сделаем логику для функции Talk:
Code


void ADialogSystemCharacter::Talk(FString Excerpt, TArray& Subtitles)
{
//Get all the row names based on our stored lines
TArray PlayerOptions = AvailableLines->GetRowNames();

for (auto It : PlayerOptions)
{
//Search inside the available lines table to find the pressed Excerpt from the UI
FDialog* Dialog = RetrieveDialog(AvailableLines, It);

if (Dialog && Dialog->QuestionExcerpt == Excerpt)
{
//We've found the pressed excerpt - assign our sfx to the audio comp and play it
AudioComp->SetSound(Dialog->SFX);
AudioComp->Play();

//Update the corresponding subtitles
Subtitles = Dialog->Subtitles;

if (UI && AssociatedPawn && Dialog->bShouldAIAnswer)
{
//Calculate the total displayed time for our subtitles
//When the subtitles end - the associated pawn will be able to talk to our character

TArray SubtitlesToDisplay;

float TotalSubsTime = 0.f;

for (int32 i = 0; i < Subtitles.Num(); i++) { TotalSubsTime += Subtitles[i].AssociatedTime; } //Just a hardcoded value in order for the AI not to answer right after our subs. //It would be better if we expose that to our editor? Sure! TotalSubsTime += 1.f; //Tell the associated pawn to answer to our character after the specified time! AssociatedPawn->AnswerToCharacter(It, SubtitlesToDisplay, TotalSubsTime);

}
else if (!Dialog->bShouldAIAnswer) ToggleTalking();
break;

}
}
}

[свернуть]
Теперь всё готово, чтобы разрабатывать логику внутри нашего ИИ.

Revisiting our dummy AI

Перейдите в исходный файл вашего ИИ и добавьте данные изменения в аналогичные функции:

Code


void AAICharacter::OnBoxOverlap(AActor * OtherActor, UPrimitiveComponent * OtherComp, int32 OtherIndex, bool bFromSweep, const FHitResult & SweepResult)
{
if (OtherActor->IsA())
{
ADialogSystemCharacter* Char = Cast(OtherActor);
Char->SetTalkRangeStatus(true);
Char->GeneratePlayerLines(*PlayerLines);
Char->SetAssociatedPawn(this);
}
}

void AAICharacter::OnBoxEndOverlap(AActor * OtherActor, UPrimitiveComponent * OtherComp, int32 OtherIndex)
{
if (OtherActor->IsA())
{
ADialogSystemCharacter* Char = Cast(OtherActor);
Char->SetTalkRangeStatus(false);
Char->SetAssociatedPawn(nullptr);
}
}

[свернуть]
Затем используйте такой код для функции AnswerToCharacter и Talk:
Code


void AAICharacter::Talk(USoundBase * SFX, TArray Subs)
{
ADialogSystemCharacter* Char = Cast(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));

//Play the corresponding sfx
AudioComp->SetSound(SFX);
AudioComp->Play();

//Tell the UI to update with the new subtitles
Char->GetUI()->UpdateSubtitles(Subs);
}

void AAICharacter::AnswerToCharacter(FName PlayerLine, TArray& SubtitlesToDisplay, float delay)
{
if (!AILines) return;

//Retrieve the corresponding line
FString ContextString;
FDialog* Dialog = AILines->FindRow(PlayerLine, ContextString);

ADialogSystemCharacter* MainChar = Cast(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));

if (Dialog && MainChar)
{
FTimerHandle TimerHandle;
FTimerDelegate TimerDel;

TimerDel.BindUFunction(this, FName("Talk"), Dialog->SFX, Dialog->Subtitles);

//Talk to the player after the delay time has passed
GetWorld()->GetTimerManager().SetTimer(TimerHandle, TimerDel, delay, false);
}
}

[свернуть]

Revisiting our UI

Последняя оставшаяся вещь – это связывание клика на кнопку с каждым ответом и,собственно, показ субтитров. Откройте граф UMG и создайте такую функцию:

Image

[свернуть]
Учтите, что Associated Text Block – это Text Reference
Потом скопируйте предложенную реализацию OnClick для каждой кнопки:
Image

[свернуть]
Последняя строчка кода немного сложна для понимания, так что перед тем, как мы напишем наш код, я объясню, почему нам нужен именно этот подход.

Multithreading in Unreal
Как пользователям, мы нуждаемся в том, чтобы каждое приложение было отзывчивым в любое данное время, даже если это приложение собирается крашиться, ведь мы должны быть проинформированы для принятия действий. Это решается использованием многопоточности.
В каждом приложении (включая игры) поток, который отвечает за UI считается главным. Используя многоядерность нашего процессора в приложении, мы можем осуществлять относительно тяжёлые вычисления в другом потоке затем, чтобы наш главный поток (UI) был отзывчив к действиям пользователя.
В этом посте сказано, что мы будем ждать некоторое количество времени перед тем, как показать субтитры, что, в общем, отлично. Но если зависнет UI, то и вся игра зависнет. Мы однозначно не хотим этого! И поэтому я использовал многопоточность для обеспечения своевременного обновления субтитров.
Я создал поток с низким приоритетом для обновления субтитров UI. Этот поток буквально останавливает его выполнение до того момента, пока не придёт время для показа новых субтитров. Так как этот поток отличается от главного потока нашей игры, то UI не будет остановлен и пользователь не заметит ничего. Нам повезло, что Unreal Engine обеспечивает лёгкий способ использовать многопоточность.

Прямо под концом класса DialogUI добавьте следующий класс:

Code


class UMGAsyncTask : public FNonAbandonableTask
{
/*The subtitles that we're going to display*/
TArray Subs;

/*UI Reference*/
UDialogUI* DialogUI;

public:

//Constructor
UMGAsyncTask(TArray& Subs, UDialogUI* DialogUI)
{
this->Subs = Subs;
this->DialogUI = DialogUI;
}

/*Function needed by the UE in order to determine what's the tasks' status*/
FORCEINLINE TStatId GetStatId() const
{
RETURN_QUICK_DECLARE_CYCLE_STAT(UMGAsyncTask, STATGROUP_ThreadPoolAsyncTasks);
}

/*This function executes each time this thread is active - UE4 searches for a function named DoWord() and executes it*/
void DoWork()
{
for (int32 i = 0; i < Subs.Num(); i++) { //Sleep means that we pause this thread for the given time FPlatformProcess::Sleep(Subs[i].AssociatedTime); //Update our subtitles after the thread comes back DialogUI->SubtitleToDisplay = Subs[i].Subtitle;
}

//Sleep 1 second to let the user read the text
FPlatformProcess::Sleep(1.f);

//Clear the subtitle
DialogUI->SubtitleToDisplay = FString("");
}
};

[свернуть]
Чтобы всё прояснить, вот скриншот моего кода:
Image

[свернуть]
Когда вы перепечатали обговоренный код, добавьте следующую логику в функцию UpdateSubtitles:
Code


void UDialogUI::UpdateSubtitles(TArray Subtitles)
{
if (!Subtitles.IsValidIndex(0)) return;

//Start a background task in a low priority thread
(new FAutoDeleteAsyncTask(Subtitles, this))->StartBackgroundTask();
}

[свернуть]
Скомпилируйте ваш код и протестите всю систему!

Original page
Перевод: Александр Ретюнский


Читайте также: