멈추지 않고 끈질기게

[Unreal] 최종 프로젝트 16일차 - NPC 감정표현 추가 / 선물 주기 로직 추가 본문

포트폴리오

[Unreal] 최종 프로젝트 16일차 - NPC 감정표현 추가 / 선물 주기 로직 추가

sam0308 2024. 6. 4. 18:48

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

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

 

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

 

 

 

0. 서론

 오늘은 외부 미팅과 내부 회의로 오후 시간은 많이 사용하여 작업량 자체는 많지 않습니다. 슬슬 남은 기간이 길지 않아 NPC 페르소나 및 사용할 에셋 등을 정하여 프로젝트 완성도를 높이는 작업에 들어갈 예정입니다.

 

 

 

1. NPC 감정표현 추가(인사하러 올 때)

 기존의 호감도가 높은 NPC가 플레이어에게 인사하러 오는 로직에서는 시야에 들어오자마자 바로 달려오다보니 다소 부자연스러운 모습이어서, 어제 추가한 감정 표현을 이용하여 개선하기로 했습니다. 시야에 플레이어 발견 시 말풍선 UI에 무언가를 발견한 느낌의 아이콘을 보여주며 잠깐 대기하고, 이후에 달려오도록 수정하여 좀 더 자연스러운 동작으로 구현하였습니다.

 

// NPC AI Controller
// 시야에 플레이어 발견 시 notice 감정표현 실행
void ANPCController::OnSightUpdated(AActor* Actor, FAIStimulus Stimulus)
{
	/* 기타 내용 */

	if(Stimulus.WasSuccessfullySensed())
	{
		NPC->SetNPCRun();
		NPC->SetCurEmotion(EEmotion::noticed); // 현재 감정 noticed로 설정
		NPC->PlayEmotion(true); // 감정 표현 실행(UI만)

		BBComp->SetValueAsObject(KEY_PLAYER, Actor);
		BBComp->SetValueAsBool(KEY_PLAYER_IN_SIGHT, true);

		
		GetWorldTimerManager().ClearTimer(SightHandle);
	}
	else
	{
		GetWorldTimerManager().SetTimer(SightHandle, this, &ANPCController::OnLostPlayer, TIME_LIMIT, false);
	}
}
// NPC Base
// 감정표현 실행 함수에 UIOnly 플래그 추가
void ANPCBase::PlayEmotion(bool IsUIOnly)
{
	if(CurEmotion.IsEmpty())
	{
		return;
	}

	EmotionUI->SetEmotion(CurEmotion);
	EmotionUI->SetVisibility(ESlateVisibility::Visible);
	if(false == IsUIOnly)
	{
		AnimInstance->PlayMontage_Emotion(CurEmotion);
	}
}

 

 noticed 감정의 경우 별도로 애니메이션을 실행하진 않아서, 기존의 감정표현 실행 함수에 매개변수를 하나 추가하여 말풍선 UI만 실행할 것인지 선택할 수 있도록 했습니다. 그리고 BT에는 플레이어 발견 시 Sequence의 가장 좌측에 wait 노드를 하나 추가하여, 살짝 기다리는 동안 말풍선 UI가 노출되고 이후에 다가가도록 했습니다.

 

사진 1. 수정된 BT 모습
사진 2. 플레이어 발견 시 감정표현

(용량 문제로 화질을 낮게 캡쳐한 점 양해 부탁드립니다)

 

 

 

2. 선물 주기 로직 추가

 작물 성장 시스템쪽 작업이 거의 끝나가는 것으로 보여, NPC에게 선물을 주는 로직을 추가하였습니다. 기존에 아주 러프하게 짜놓은 함수는 있었는데, NPC가 인사하는 로직처럼 단순히 기존의 대화 로직에 말을 거는 것이 아니라 별도의 프롬프트를 통해 선물에 대한 반응을 받아오도록 했습니다. 그리고 인사 로직과 마찬가지로 게임 시작 및 날짜 갱신 시에 미리 통신하여 텍스트 및 TTS까지 생성해두고, 함수 호출 시 바로 출력하도록 했습니다. 

 

 인사와 다른 점은 선호하는 아이템을 받았을 때와 아닐 때의 반응을 나누어야 해서, 기존의 데이터를 딕셔너리에 바로 저장하던 방식에서 리스트에 비선호/선호 반응을 저장한 뒤 리스트째로 딕셔너리에 저장하는 방식으로 수정하였습니다. 프롬프트도 선호 아이템 여부를 구분할 수 있도록 수정하였으나, 베이스가 되는 프롬프트가 팀원 분의 작업물이라 생략하였습니다.

 

# fastapi 웹서버 코드
# present2player() 함수는 팀원 분의 코드를 살짝 수정한 내용이라 미첨부

class Present_Data(fastapiBaseModel):
    npc_name : str
    prefer : int

@app.post("/init-present")
async def init_present(data:NPC_Greeting_Input):
    try:
        # 0 for non-prefer, 1 for prefer
        data_list = []
        data_list.append(present2player(data.npc_name, data.likeability, False))
        data_list.append(present2player(data.npc_name, data.likeability, True))
        present_data[data.npc_name] = data_list

        tts_list = []
        tts_list.append(tts(data_list[0], data.npc_name))
        tts_list.append(tts(data_list[1], data.npc_name))
        present_tts[data.npc_name] = tts_list

        return f'{data.npc_name}\'s present data initialzied'
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"{str(e)}")
    
@app.post("/post-present")
async def post_present(data:Present_Data):
    global presented_npc_name, is_prefer
    presented_npc_name = data.npc_name
    is_prefer = data.prefer
    return present_data[presented_npc_name][is_prefer]

@app.get("/get-present-data")
async def get_present_data():
    return present_data[presented_npc_name][is_prefer]

@app.get("/get-present-tts")
async def get_present_tts():
    return Response(content=present_tts[presented_npc_name][is_prefer], media_type="audio/wav")
// 언리얼 쪽 웹서버 통신 코드
// 기존의 Greeting 관련 내용과 거의 비슷
void ANewHttpActor::InitPresent(const FString& NPCName, int32 Likeability)
{
	const FString& FullURL = BaseURL + EndPoint_InitPresent;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("POST"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &ANewHttpActor::InitPresentComplete);

	// 양식 주의할 것(웹 서버쪽의 양식과 정확하게 일치해야 함)
	FString JsonBody = FString::Printf(TEXT("{\"npc_name\": \"%s\",\"likeability\": \"%d\"}"), *NPCName, Likeability);
	HttpRequest->SetContentAsString(JsonBody);

	HttpRequest->ProcessRequest();

	UE_LOG(LogTemp, Warning, TEXT("Init Present: %s"), *NPCName);
}

void ANewHttpActor::InitPresentComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		FString ResultString = Response->GetContentAsString();
		UE_LOG(LogTemp, Warning, TEXT("%s"), *ResultString);
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}

void ANewHttpActor::RequestPresent(const FString& NPCName, int32 IsPrefer)
{
	const FString& FullURL = BaseURL + EndPoint_PostPresent;

	// HTTP Request
	TSharedRef<IHttpRequest> HttpRequest = FHttpModule::Get().CreateRequest();
	HttpRequest->SetVerb(TEXT("POST"));
	HttpRequest->SetURL(FullURL);
	HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
	HttpRequest->OnProcessRequestComplete().BindUObject(this, &ANewHttpActor::RequestPresentComplete);

	// 양식 주의할 것(웹 서버쪽의 양식과 정확하게 일치해야 함)
	FString JsonBody = FString::Printf(TEXT("{\"npc_name\": \"%s\",\"prefer\": \"%d\"}"), *NPCName, IsPrefer);
	HttpRequest->SetContentAsString(JsonBody);

	HttpRequest->ProcessRequest();

	UE_LOG(LogTemp, Warning, TEXT("Request Present Response to %s"), *FullURL);
}

void ANewHttpActor::RequestPresentComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		UE_LOG(LogTemp, Warning, TEXT("Request Present Response Complete"));
		GetPresentData();
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}

void ANewHttpActor::GetPresentData()
{
	const FString& FullURL = BaseURL + EndPoint_GetPresentData;

	// 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::GetPresentDataComplete);

	HttpRequest->ProcessRequest();
	UE_LOG(LogTemp, Warning, TEXT("Get Present Data From %s"), *FullURL);
}

void ANewHttpActor::GetPresentDataComplete(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 Present Data Complete: %s"), *NPCResponse.Answer);

		// 경로 수정
		MyGameMode->ResponseToPlayerForPresent(NPCResponse);
		GetPresentTTS();
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}

void ANewHttpActor::GetPresentTTS()
{
	const FString& FullURL = BaseURL + EndPoint_GetPresentTTS;

	// 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::GetPresentTTSComplete);

	HttpRequest->ProcessRequest();
	UE_LOG(LogTemp, Warning, TEXT("Get Present TTS From %s"), *FullURL);
}

void ANewHttpActor::GetPresentTTSComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully)
{
	if (bConnectedSuccessfully)
	{
		const TArray<uint8>& Data = Response->GetContent();

		const FString FilePath = UKismetSystemLibrary::GetProjectDirectory() + TEXT("Extras/WavFiles/TTSFile.wav");
		FFileHelper::SaveArrayToFile(Data, *FilePath);
		UE_LOG(LogTemp, Warning, TEXT("Save Present TTS to %s "), *FilePath);

		MyGameMode->PlayTTS(FilePath);
	}
	else
	{
		if (Request->GetStatus() == EHttpRequestStatus::Succeeded)
		{
			UE_LOG(LogTemp, Warning, TEXT("Response Failed...%d"), Response->GetResponseCode());
		}
	}
}

 

 다만 선물관련 초기화는 호감도에 상관 없이 모든 NPC가 실행하다보니, 현재 맵에 NPC가 3명인데도 다소 시간이 소요되었습니다. NPC를 6명까지 배치할 예정이라 모든 NPC의 선물에 대한 답변 초기화가 완료될때까지 시간이 꽤 걸릴 것으로 보여, 로딩 시간을 추가할지 어떨지에 대한 고민은 필요해 보입니다.

 

 그리고 NPC에게 선물 줄 때 호출할 함수를 살짝 수정하였습니다. 하루에 한 번 선물할 수 있도록 bool 타입 변수를 추가하였고, 이미 선물을 받았다면 return하도록 했으나 테스트를 위해 잠시 주석으로 처리해두었습니다. 그리고 선호하는 아이템인지 여부는 현재 아이템 데이터에 정수형 ID는 별도로 들어가있지 않다고 하여, 일단 아이템 이름으로 구분할 수 있도록 FString 타입의 매개변수를 받도록 했습니다.

// NPC 선물 주기 함수
void ANPCBase::GivePresent(const FString& ItemName)
{
	/*if (GetPresentToday)
	{
		return;
	}

	GetPresentToday = true;*/
	int32 bIsPrefer = ItemName == PreferItemName ? 1 : 0;
	UpdateLikeability(bIsPrefer ? PreferItemValue : NormalItemValue);

	// 통신
	AFarmLifeGameMode* GameMode = CastChecked<AFarmLifeGameMode>(GetWorld()->GetAuthGameMode());
	GameMode->RequestPresentData(this, bIsPrefer);
}