멈추지 않고 끈질기게

[Unreal] 최종 프로젝트 11일차 - STT & 채팅 기능 머지 / NPC 인사 구현 본문

포트폴리오

[Unreal] 최종 프로젝트 11일차 - STT & 채팅 기능 머지 / NPC 인사 구현

sam0308 2024. 5. 27. 18:07

※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.

※ 해당 포스팅은 청년취업사관학교 교육 과정의 최종 프로젝트에 관한 내용을 포함하고 있습니다.

 

※ 해당 포스팅은 Unreal 5.4.1 버전을 기준으로 작성되었습니다.

 

 

 

0. 서론

 이번주에 중간 발표가 있는 관계로, 슬슬 저와 다른분들 작업을 머지하기로 했습니다. 단순히 브랜치 머지만이 아니라 제가 작성한 STT 및 채팅 기능을 플레이어 담당 분의 플레이어에 붙이는 작업을 했습니다. 

 

 

1. STT & 채팅 기능 머지

 우선 STT에서 마이크 입력을 wav 파일로 저장하는 방법은 전에 포스팅한대로 C++로 옮기는 데 실패하여 블루프린트로 구현하였고, 이 외의 부분 및 채팅 쪽은 나중에 병합해야 함을 고려해서 다음과 같이 컴포넌트 형태로 작성하였습니다.

// Talk Component
// Fill out your copyright notice in the Description page of Project Settings.


#include "PKH/Component/TalkComponent.h"
/* 기타 헤더들 */

UTalkComponent::UTalkComponent()
{
	PrimaryComponentTick.bCanEverTick = false;

	// Speech File
	RecordFileDir = UKismetSystemLibrary::GetProjectDirectory() + TEXT("Extras/WavFiles/");
	RecordFilePath = RecordFileDir + RecordFileName + TEXT(".wav");
}

void UTalkComponent::BeginPlay()
{
	Super::BeginPlay();

	Player = CastChecked<ACharacter>(GetOwner());
	MyGameMode = CastChecked<AFarmLifeGameMode>(GetWorld()->GetAuthGameMode());
}

#pragma region Check Nearby
void UTalkComponent::CheckNearbyObjects()
{
	const FString Input = TEXT("");
	SearchNearby(Input);
}

void UTalkComponent::CheckNearbyObjects(const FString& InputText)
{
	SearchNearby(InputText);
}

void UTalkComponent::SearchNearby(const FString& InputText)
{
	// 이미 NPC와 대화중이라면 바로 통신
	ANPCBase* CurNPC = MyGameMode->GetCurNPC();
	if(CurNPC)
	{
		if (InputText.IsEmpty())
		{
			ConversationWithNPC(CurNPC);
		}
		else
		{
			ConversationWithNPCByText(CurNPC, InputText);
		}
		return;
	}

	TArray<FOverlapResult> NPCResults;
	FCollisionQueryParams Params; 
	Params.AddIgnoredActor(Player);
	FVector Origin = Player->GetActorLocation();
	bool NPCOverlapped = GetWorld()->OverlapMultiByProfile(NPCResults, Origin, FQuat::Identity, TEXT("Pawn"), FCollisionShape::MakeSphere(300.0f), Params);
	DrawDebugSphere(GetWorld(), Origin, 300.0f, 16, FColor::Red, false, 2.0f);

	if (NPCOverlapped)
	{
		ANPCBase* TargetNPC = nullptr;
		float MinDistance = 500.0f;
		for (FOverlapResult& Res : NPCResults)
		{
			ANPCBase* NPC = Cast<ANPCBase>(Res.GetActor());
			if (nullptr == NPC)
			{
				continue;
			}

			const float CurDistance = FVector::Dist(Origin, NPC->GetActorLocation());
			if (MinDistance > CurDistance)
			{
				TargetNPC = NPC;
				MinDistance = CurDistance;
			}
		}

		if (nullptr != TargetNPC)
		{
			if(InputText.IsEmpty())
			{
				ConversationWithNPC(TargetNPC);
			}
			else
			{
				ConversationWithNPCByText(TargetNPC, InputText);
			}
			return;
		}
	}

	TArray<FOverlapResult> PlantResults;
	bool PlantOverlapped = GetWorld()->OverlapMultiByChannel(PlantResults, Origin, FQuat::Identity, ECC_Visibility, FCollisionShape::MakeSphere(300.0f), Params);

	if (PlantOverlapped)
	{
		TArray<TObjectPtr<APlantActor>> Plants;
		for (FOverlapResult& Res : PlantResults)
		{
			// TArray에 저장
			APlantActor* Plant = Cast<APlantActor>(Res.GetActor());
			if(Plant)
			{
				Plants.Add(Plant); UE_LOG(LogTemp, Log, TEXT("%s"), *Plant->GetName());
			}
		}

		if(InputText.IsEmpty())
		{
			TalkToPlant(Plants);
		}
		else
		{
			TalkToPlantByText(Plants, InputText);
		}
	}
}
#pragma endregion

#pragma region Communication
void UTalkComponent::ConversationWithNPC(ANPCBase* NewNPC)
{
	MyGameMode->SendSpeech(RecordFileName, RecordFilePath, NewNPC);
}

void UTalkComponent::ConversationWithNPCByText(ANPCBase* NewNPC, const FString& InputText)
{
	MyGameMode->SendText(InputText, NewNPC);
}

void UTalkComponent::TalkToPlant(const TArray<TObjectPtr<APlantActor>>& NewPlants)
{
	MyGameMode->TalkToPlant(RecordFileName, RecordFilePath, NewPlants);
}

void UTalkComponent::TalkToPlantByText(const TArray<TObjectPtr<APlantActor>>& NewPlants, const FString& InputText)
{
	MyGameMode->TalkToPlantWithText(InputText, NewPlants);
}
#pragma endregion

 

 TalkComponent의 경우 마이크 또는 채팅 입력 후 주변에 대화 대상(NPC or 식물)이 있는지 먼저 체크하고 통신하기 위해 주변을 체크하는 SearchNearBy() 함수까지 포함시켰습니다. 또한 마이크 입력을 저장할 파일 경로를 포함하고 있으며, 이전에 포스팅한 대로 프로젝트 경로와 합치는 방식으로 수정했습니다.

 

// Chatting Component
// Fill out your copyright notice in the Description page of Project Settings.


#include "PKH/Test/TextInputComponent.h"
/* 기타 헤더들 */

UTextInputComponent::UTextInputComponent()
{
	PrimaryComponentTick.bCanEverTick = false;

	static ConstructorHelpers::FClassFinder<UChatUIWidget> ChatUIClassRef(TEXT("/Game/PKH/UI/WBP_ChatUI.WBP_ChatUI_C"));
	if(ChatUIClassRef.Class)
	{
		ChatUIClass = ChatUIClassRef.Class;
	}
}

void UTextInputComponent::BeginPlay()
{
	Super::BeginPlay();

	ChatUI = CreateWidget<UChatUIWidget>(GetWorld(), ChatUIClass);
	ensure(ChatUI);
	ChatUI->AddToViewport();
	ChatUI->SetVisibility(ESlateVisibility::Hidden);
}


#pragma region 채팅 입력
void UTextInputComponent::Chat()
{
	if(false == InChatting)
	{
		ChatUI->SetVisibility(ESlateVisibility::Visible);
		ChatUI->Focus();
		InChatting = true;
	}
	else
	{
		ChatUI->SetVisibility(ESlateVisibility::Hidden);
		InChatting = false;

		FString InputText = ChatUI->GetChatText();
		if(InputText.IsEmpty())
		{
			return;
		}
		
		UTalkComponent* STTComp = Cast<UTalkComponent>(GetOwner()->GetComponentByClass(UTalkComponent::StaticClass()));
		if(STTComp)
		{
			STTComp->CheckNearbyObjects(InputText);
		}
	}
}

bool UTextInputComponent::IsChatting() const
{
	return InChatting;
}
#pragma endregion

 

 채팅용 TextInputComponent의 경우 내부의 InChatting 값을 플래그로 하여 채팅중이 아니면 텍스트 입력 UI 오픈, 채팅중이었던 경우 채팅을 종료하고 오너(플레이어)가 STTComponent를 가지고 있다면 주변을 탐색하도록 했습니다. 플레이어 쪽에서는 Enter(혹은 다른 키) 입력 시 TextInputComponent의 Chat() 함수를 호출하기만 하면 끝나도록 구현했습니다.

 

 이렇게 병합을 최대한 고려하여 작성했는데도 불구하고, 실제로 합치는 과정에서는 다소 이슈들이 발생하였습니다. 게임모드도 제가 작성한 FarmLifeGameMode로 변경하고, 이 과정에서 다시 Directional Light를 Movable로 변경하는 등의 수정을 거쳐서 겨우 문제 없이 합칠 수 있었습니다. 쉽지는 않았지만, 컴포넌트 형태로 작성하여 다른 사람의 작업물에 붙이는 작업은 처음 해보았는데 나름 큰 문제는 없이 성공하여 만족스러웠습니다.

 

 

2. NPC 인사 구현

 NPC 행동 패턴 고도화의 한 작업으로, NPC의 호감도가 일정 수치 이상이라면 하루에 한 번 먼저 다가와서 인사하도록 구현하기로 했습니다. 그리고 제한을 두지 않으면 계속 대화를 걸 것이므로, 하루에 한 번만 실행하도록 NPCBase 쪽에 bool 값도 추가하여 대화를 걸 상태인지 반환하는 함수를 작성했습니다.

 

// NPC Base
// 헤더
protected:
	UPROPERTY(EditAnywhere)
	int32 CurLikeability = 0;

	UPROPERTY(EditDefaultsOnly)
	int32 FriendlyLikeability = 50;

	UPROPERTY(EditDefaultsOnly)
	int32 MaxLikeability = 100;

public:
	FORCEINLINE int32 GetLikeability() const { return CurLikeability; }
	bool IsFriendly() const;


// cpp
// 인삿말 초기화 함수
void ANPCBase::InitGreeting()
{
	MyGameMode->InitGreeting(NPCName, GreetingText, CurLikeability);
    HasIntendToGreeting = true;
}

// 플레이어에게 말 걸기
// 실행 후 플래그 false로 변경
void ANPCBase::GreetingToPlayer()
{
	MyGameMode->RequestGreetingData(this);
	HasIntendToGreeting = false;
}

// 호감도가 기준치 이상이고, 아직 대화를 걸지 않았다면 true / 이 외에 false
bool ANPCBase::IsFriendly() const
{
	return (CurLikeability >= FriendlyLikeability) && HasIntendToGreeting;
}

// 날짜 갱신 로직
void ANPCBase::OnDateUpdated(int32 NewDate)
{
	if(CurLikeability >= FriendlyLikeability)
	{
		InitGreeting();
	}

	SetActorLocation(HomeLoc);
}

 

 이전에 작업한 시야에 들어온 플레이어에게 다가가는 Sequence에 Decorator를 추가하여 일정 호감도 이상일 때만 동작하도록 했습니다. 해당 Decorator는 단순히 NPC의 IsFriendly() 함수를 통해 대화를 걸 상태라면 true, 아니라면 false를 반환하도록 했습니다. 그리고 Move To 노드 뒤에 플레이어에게 말을 거는 커스텀 노드를 추가하였습니다. 

 

#include "PKH/BT/BTTask_TalkToPlayer.h"
/* 기타 헤더들 */

UBTTask_TalkToPlayer::UBTTask_TalkToPlayer()
{
	NodeName = TEXT("Talk2Player");
}

EBTNodeResult::Type UBTTask_TalkToPlayer::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type SuperResult =  Super::ExecuteTask(OwnerComp, NodeMemory);

	APawn* OwnerPawn = OwnerComp.GetAIOwner()->GetPawn();
	if(nullptr == OwnerPawn)
	{
		return EBTNodeResult::Failed;
	}

	ANPCBase* NPC = Cast <ANPCBase>(OwnerPawn);
	if(nullptr == NPC)
	{
		return EBTNodeResult::Failed;
	}

	NPC->GreetingToPlayer();
	return EBTNodeResult::Succeeded;
}

 

사진 1. 수정한 BT 모습

 

 해당 데코레이터를 추가하여 구성한 BT의 현 모습입니다. 가운데에 배치된 Selector와 DoJob 노드는 특정 시간대에 위치로 이동 후, 해당 위치에서 낚시나 독서 등 NPC마다 고유의 작업을 하는 부분을 추가할 예정이라 일단 배치만 해두었습니다.  

 

 추가로 fastapi 웹서버 쪽에 인사말 초기화 및 호출용 엔드포인트를 추가하였습니다. 초기화는 특히 엔피씨마다 별도의 결과물들을 저장해야 하므로, 딕셔너리를 별도로 선언하여 npc이름으로 챗봇 답변 및 TTS 결과물(.wav)의 경로를 저장하도록 했습니다. 

#### NPC 인사 초기화
greetings = {}
requested_npc_name = ""

@app.post("/init-greeting")
async def init_greeting(data:NPC_Greeting_Input):
    try:
        greeting = talk2npc(data.npc_name, latest_speech, data.likeability)
        greeting.file_path = tts(greeting, data.npc_name)
        greetings[data.npc_name] = greeting
        return greetings[data.npc_name]
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"{str(e)}")
    
@app.post("/post-greeting")
async def post_greeting(data:NPC_Name):
    global requested_npc_name
    requested_npc_name = data.npc_name

@app.get("/get-greeting")
async def get_greeting():
    return greetings[requested_npc_name]