멈추지 않고 끈질기게
[Unreal] 최종 프로젝트 15일차 - 언리얼 통신 수정 / NPC 감정 UI 추가 / 이슈 수정 본문
※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.
※ 해당 포스팅은 청년취업사관학교 교육 과정의 최종 프로젝트에 관한 내용을 포함하고 있습니다.
※ 해당 포스팅은 Unreal 5.4.1 버전을 기준으로 작성되었습니다.
0. 서론
주말 동안의 작업을 포함해서 드디어 서버 통신 방식을 변경하고, 서버와 언리얼 프로젝트를 별도의 노트북에서 실행하여 플레이가 가능하도록 완성했습니다. STT 및 TTs 속도 자체가 빨라진 것은 아니지만, 언리얼 쪽에서 GPU 메모리 부족 경고가 나오거나 크래시가 나던 부분은 해소될 것으로 보입니다. 내일부터는 에셋도 구매 신청해서 본격적으로 시각 품질 업그레이드 쪽으로 작업할 것 같습니다.
1. 언리얼 웹통신 코드 수정
기존의 저장된 파일 경로를 전송하는 방식으로는 서버와 프로젝트를 분리할 수가 없어서, 바이트 배열 형태로 주고받도록 통신 방식을 변경하였습니다. 언리얼 쪽 코드가 수정할 일이 많을까봐 불안했는데, 다행히 대화 UI를 바꾸면서 통신 단계를 일일히 나눠둔 덕분에 수정할 내용이 그렇게 많지는 않았습니다.
다만 인사말을 받아오던 부분은 분리해두지 않아서, 텍스트만 먼저 요청해서 반환받고 이후에 wav 파일을 바이트 배열 형태로 받아오도록 수정했습니다.
// 텍스트 및 감정, 호감도 정보만 먼저 요청
void ANewHttpActor::GetGreetingData()
{
const FString& FullURL = BaseURL + EndPoint_GetGreetingData;
// HTTP Request
TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
HttpRequest->SetVerb(TEXT("GET"));
HttpRequest->SetURL(FullURL);
HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
HttpRequest->OnProcessRequestComplete().BindUObject(this, &ANewHttpActor::GetGreetingDataComplete);
HttpRequest->ProcessRequest();
UE_LOG(LogTemp, Warning, TEXT("Get Greeting Data From %s"), *FullURL);
}
void ANewHttpActor::GetGreetingDataComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
if (bConnectedSuccessfully)
{
const FString& ResultText = Response->GetContentAsString();
FNPCResponse NPCResponse;
UJsonParserLibrary::ParseNPCResponse(ResultText, NPCResponse);
UE_LOG(LogTemp, Warning, TEXT("Get Greeting Data Complete: %s"), *NPCResponse.Answer);
// 경로 수정
MyGameMode->GreetingToPlayer(NPCResponse);
GetGreetingTTS();
}
else
{
if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
{
UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
}
}
}
// tts 데이터(wav 파일, 바이트 배열 형태로) 요청
void ANewHttpActor::GetGreetingTTS()
{
const FString& FullURL = BaseURL + EndPoint_GetGreetingTTS;
// HTTP Request
TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
HttpRequest->SetVerb(TEXT("GET"));
HttpRequest->SetURL(FullURL);
HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
HttpRequest->OnProcessRequestComplete().BindUObject(this, &ANewHttpActor::GetGreetingTTSComplete);
HttpRequest->ProcessRequest();
UE_LOG(LogTemp, Warning, TEXT("Get Greeting TTS From %s"), *FullURL);
}
void ANewHttpActor::GetGreetingTTSComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
if (bConnectedSuccessfully)
{
const TArray<uint8>& Data = Response->GetContent();
// 파일로 저장한 뒤 경로 전달
const FString FilePath = FPaths::ProjectContentDir() + TEXT("TTSFile.wav");
FFileHelper::SaveArrayToFile(Data, *FilePath);
UE_LOG(LogTemp, Warning, TEXT("Save Greeting TTS to %s "), *FilePath);
MyGameMode->PlayTTS(FilePath);
}
else
{
if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
{
UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
}
}
}
이 외에 수정한 부분은 14일차 포스트에 작성한 내용을 기존의 코드에 합치는 작업 정도라 코드 첨부는 생략하고자 합니다. 팀원 분의 노트북으로 서버를 실행하고 제 노트북에서 언리얼 프로젝트를 실행하여 기존의 기능들이 정상 동작하는 부분까지 확인하였습니다.
2. NPC 감정표현 UI(말풍선) 추가
현재 NPC 답변 시 감정에 따라 애니메이션을 실행하도록 했는데, 약간 눈에 띄지 않는 경향이 있었습니다. 그래서 약간 게임스러운 요소로 말풍선 UI에 감정 이모티콘을 넣어서 감정 변화가 한눈에 들어오도록 UI를 추가하였습니다.
UEmotionUIWidget::UEmotionUIWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
/* ConstructorHelpers 코드들 */
EmotionMap.Add(Emotion_Joy, Texture_Joy);
EmotionMap.Add(Emotion_Surprise, Texture_Surprise);
EmotionMap.Add(Emotion_Sadness, Texture_Sadness);
EmotionMap.Add(Emotion_Anger, Texture_Anger);
}
void UEmotionUIWidget::NativeConstruct()
{
Super::NativeConstruct();
OnVisibilityChanged.AddDynamic(this, &UEmotionUIWidget::OnVisible);
}
void UEmotionUIWidget::OnVisible(ESlateVisibility InVisibility)
{
if(InVisibility == ESlateVisibility::Visible)
{
PlayAnimation(Anim_Emotion);
}
}
void UEmotionUIWidget::SetEmotion(const FString& Emotion)
{
if(EmotionMap.Contains(Emotion))
{
Img_Emotion->SetBrushFromTexture(EmotionMap[Emotion]);
}
else
{
Img_Emotion->SetBrushFromTexture(Texture_Indiff);
}
}
우선 감정 4가지를 표현할 이모티콘 텍스처들을 저장해두고, 감정 문구에 따라 바꾸도록 TMap을 추가하였습니다. 애니메이션 몽타주와 마찬가지로 맵에 저장된 감정이 입력된 경우 해당 이모티콘으로 바꾸고, 그 외의 감정이 입력된 경우 별도로 저장한 텍스처로 바뀌도록 했습니다. 그리고 튀어나오는 느낌의 간단한 위젯 애니메이션을 만들어 둔 다음에, Visibility가 Visible로 바뀔 때 해당 애니메이션을 실행하도록 OnVisibilityChanged에 바인드하였습니다.
(용량을 줄이느라 색상이 다소 이상하게 캡쳐된 부분은 양해 부탁드립니다)
3. 기타 이슈 수정(인사 방식 수정 / 대화 시 NPC 회전)
원래 NPC 호감도가 일정 수치 이상일 때 인사하러 오는 로직을 임시로 NPC에게 "안녕하세요, 저한테 인사 한번 해주세요!" 라는 문구로 말을 걸고 답변을 저장하는 식으로 구현해두었습니다. 그런데 오늘 감정표현 UI 작업하느라 수십번을 실행하고 끄기를 반복했더니, NPC의 인사 문구가 "저한테 인사 해달라고 그만 요청해주세요."로 돌아오는 충격적인 경험을 했습니다.
그래서 임시로 구현해 두었던 인사 방식을 수정하고, 인사를 위한 별도의 프롬프트 및 chain을 생성하여 해당 체인의 결과 텍스트를 받아오도록 수정했습니다. 프롬프트는 팀원분께서 npc 대화를 위해 작성하신 내용에서 단순히 유저 입력을 빼고 인사해달라는 내용으로 수정했습니다.
greeting_template = # 페르소나에 맞는 인격으로 100자 내외로 인사해달라는 내용
greeting_prompt = PromptTemplate.from_template(template=greeting_template)
greeting_prompt = prompt.partial(format=parser.get_format_instructions())# 프롬프트 설정
greeting_chain = greeting_prompt | llm | parser
def greet2player(npcName:str, preperence:int):
response = greeting_chain.invoke(
{
"persona": # npc 이름에 맞는 페르소나 불러오는 내용
"request_content": # 기존의 대화 내역을 가져오는 내용
"dialogue_example": # 대화 예시를 가져오는 내용,
}
)
getChatLog(npcName).add_ai_message(response.answer) # 채팅 히스토리에 답변 추가
return response
팀원 분이 작성하신 코드가 많아 대부분 주석처리 했습니다만, 이러한 형식으로 인사말을 받아오는 모델을 별도로 생성하여 일반적인 대화와 분리했습니다. 테스트해보니 기존 대화 내용도 반영하여 잘 인사해주었습니다.
그리고 BT에서 말을 걸었을 때 플레이어 방향으로 회전시키기 위해 Rotate to face 노드를 사용하고 있었는데, NPC쪽에서 먼저 인사하러 오는 경우에만 제대로 동작하고 플레이어가 먼저 말을 걸면 제대로 회전하지 않고 해당 노드만 무한히 반복하는 이슈가 있었습니다. 알고보니 Player 키를 향해서 회전하게 했는데, 플레이어가 먼저 말을 걸때는 해당 키값을 별도로 설정해주지 않아서 생긴 문제였습니다. 대화 시작 및 종료 함수에서 해당 내용을 추가하여 해결했습니다.
// Player 키 설정 추가
void ANPCController::StartConversation()
{
BBComp->SetValueAsBool(KEY_IN_CONV, true);
ACharacter* Player = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
BBComp->SetValueAsObject(KEY_PLAYER, Player);
}
void ANPCController::EndConversation()
{
BBComp->SetValueAsBool(KEY_IN_CONV, false);
BBComp->SetValueAsObject(KEY_PLAYER, nullptr);
OnLostPlayer();
}