Unreal - 상점 Npc

2025. 12. 17. 01:06·Unreal 프로젝트 다이어리/두번째 프로젝트

구현내용

플레이어와 상호작용할수있는 Npc 

그중에 플레이어가 사용할수있거나 거래할때 사용되는 아이템을 구매할수있는

상점 내용 구현

구현목적

플레이어가 퀘스트 혹은 사냥을 통해 재화를 획득하면 재화의 사용처를 만들기 위함

포션이나 여러가지 아이템을 구매할수있게 함으로서 게임성의 상향을 위함

 

구상도 컨셉

1인 개발이다보니 항상 구상도를 미리 그려놓고 제작을 한다

Npc의 상점편 UI 초기 구상도입니다

많이 허접하지만 초기 구상도가 있어야 어떻게 구현할지 머리속에 그려지는거같습니다

구현에 사용된 클래스

사용한 클래스 사용목적
BasicNpc(캐릭터 액터) 모든 Npc가 상속받을 모든 NPC가 실행해야할 로직을 가진 
상속 모체 
ShopQuestNPC(캐릭터 액터) BasicNpc를 상속받은 상점 , 퀘스트 시스템을 담당하는 NPC
ShopQuestWidget(위젯) ShopQuestNPC가 사용하는 위젯

 

구현

모든 NPC가 상속받을 기본 BasicNPC를 구현해주었습니다

모든 NPC가 가져야할 행동은 이와같습니다

  • 플레이어와 오버랩하면 NPC의 카메라로 시점이 이동하며 UI모드로 변경됩니다
capsuleComp = Cast<UPrimitiveComponent>(mainChar->GetCapsuleComponent());

void ABasicNpc::Interact(AMainCharacterController* inController)
{
	inController->bIsInteracting = true;
	inController->SetViewTargetWithBlend(this, 0.5f, EViewTargetBlendFunction::VTBlend_Cubic);
	mainChar->SetActorHiddenInGame(true);
	mainChar->DisableInput(inController);
	inController->mainWidget->SetVisibility(ESlateVisibility::Collapsed);

	FInputModeUIOnly inputMode;
	inController->SetInputMode(inputMode);
	inController->bShowMouseCursor = true;
}

void ABasicNpc::EndInteraction(AMainCharacterController* inController)
{
	inController->bIsInteracting = false;
	inController->SetViewTargetWithBlend(mainChar, 0.5f, EViewTargetBlendFunction::VTBlend_Cubic);
	mainChar->SetActorHiddenInGame(false);
	mainChar->EnableInput(inController);
	inController->mainWidget->SetVisibility(ESlateVisibility::SelfHitTestInvisible);

	FInputModeGameOnly inputMode;
	inController->SetInputMode(inputMode);
	inController->bShowMouseCursor = false;
}

void ABasicNpc::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	if (OtherActor == mainChar && OtherComp == capsuleComp)
	{
		pc->overlappingNpc = this;
		pc->bCanInteract = true;
	}
}

void ABasicNpc::OnOverlapEnd(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
	if (pc->bIsInteracting)
		return;

	if (OtherActor == mainChar && OtherComp == capsuleComp)
	{
		pc->overlappingNpc = nullptr;
		pc->bCanInteract = false;
	}
}

 

그럼 이 BasicNPC를 상속받은 퀘스트&샵 NPC를 만들어줍니다

 

이렇게 뒤에 ABasicNpc를 붙혀 상속시켜줍니다

UCLASS()
class PORTFOLIOMS_API AShopQuestNPC : public ABasicNpc
{
	GENERATED_BODY()

public:
	AShopQuestNPC();
}

 

ShopQuestNpc가 해야할 일은 이와같습니다

  • 부모의 Interact 함수의 오버라이드
  • 위젯을 컨트롤하기 

즉 트리거 역할을 해줍니다

간단하게 부모의 역할을 사용할수있고, 위젯을 컨트롤하면 됩니다

 

UCLASS()
class PORTFOLIOMS_API AShopQuestNPC : public ABasicNpc
{
	GENERATED_BODY()

public:
	AShopQuestNPC();

protected:
	virtual void BeginPlay() override;

public:	
	virtual void Interact(AMainCharacterController* inController) override;
	virtual void EndInteraction(AMainCharacterController* inController) override;

	UFUNCTION()
	void WidgetSet(bool bShow);

	UFUNCTION()
	void PlayShopReaction(EShopNpcReaction reaction);
}
void AShopQuestNPC::Interact(AMainCharacterController* inController)
{
	Super::Interact(inController);

	if (!inController)
		return;
	mainCon = inController;

	GetWorld()->GetTimerManager().SetTimer(th_WidgetDelayTimer,
		[this]()
		{
			WidgetSet(true);

			if (mainCon->shopQuestWidget)
				mainCon->shopQuestWidget->SetShopNpc(this);

			mainCon->shopQuestWidget->StartTyping(mainCon->shopQuestWidget->firstConversation);
		},0.5f,false);

}

void AShopQuestNPC::EndInteraction(AMainCharacterController* inController)
{
	Super::EndInteraction(inController);

	WidgetSet(false);

}

 

이제 사용할 위젯을 만들어줍니다

위젯의 기능은 이와 같습니다

  • 플레이어가 특정 버튼을 누르면 그에맞는 텍스트가 타이핑되며 몽타쥬가 실행됩니다
  • 버튼에 마우스를 해당 글씨가 올리면 커지고 마우스를 내리면 다시 작아집니다
  • 상점이 구현되어있습니다
  • < 버튼을 누르면 갯수가 줄어들고 > 버튼을 누르면 갯수가 증가합니다
  • 직접 입력가능한 에딧가능한 텍스트 블럭으로 직접 갯수를 입력할수 있습니다
  • 해당 갯수만큼 구매 를 하면 아이템을 구매 할수 있습니다

타이핑 구현

저번 프로젝트를 거치면서 NPC를 구현해봤는데 그냥 텍스트가 뜨니 이야기한다 라는 느낌이 들지않아서

타이핑하는 효과를 만들고싶었습니다

 

타이핑의 컨셉은 안 -> 안녕 -> 안녕하 -> 안녕하세 -> 안녕하세요 이런 느낌입니다

 

구현방식은 이와 같습니다

전체 문장을 미리 저장해두고

타이머로 일정 시간마다 한글자씩 잘라서

TextBlock에 다시 세팅한다

 

이와같이 구현하였습니다 전체 문장은 FText로 미리 정의해두었습니다

UPROPERTY()
FText fullConversationText;

UPROPERTY()
FString fullConversationString;

UPROPERTY()
FString CurrentTypingString;

UPROPERTY()
int32 typingIdx = 0;

 

void UShopQuestWidget::StartTyping(const FText& sayText)
{
	GetWorld()->GetTimerManager().ClearTimer(th_typingMainHandle);

	fullConversationText = sayText;
	fullConversationString = sayText.ToString();

	CurrentTypingString.Empty();
	typingIdx = 0;

	TextBlock_MainConversation->SetText(FText::GetEmpty());

	GetWorld()->GetTimerManager().SetTimer(th_typingMainHandle,this,&UShopQuestWidget::TypingTick,typingInterval,true);

}

void UShopQuestWidget::TypingTick()
{
	if (typingIdx >= fullConversationString.Len())
	{
		// 타이핑 종료
		GetWorld()->GetTimerManager().ClearTimer(th_typingMainHandle);
		if (onTypingShopFinishedCallBack)
		{
			onTypingShopFinishedCallBack();
			onTypingShopFinishedCallBack = nullptr;
		}
		return;
	}

	// 한 글자씩 추가하기
	CurrentTypingString.AppendChar(fullConversationString[typingIdx]);
	typingIdx++;

	TextBlock_MainConversation->SetText(FText::FromString(CurrentTypingString));
}

타이핑 결과물

대사를 한글자씩 타이핑하며 보여준다

마우스 호버 이벤트 구현

마우스가 위에 올라가면 해당 텍스트 or 버튼이 커지고 마우스가 다시 내려가면 다시 원래대로 돌아오는 효과를 만들고싶었습니다

일일히 하나의 버튼에 한개의 함수를 바인드하지않고 맵을 만들어서 유연하게 구현해보았습니다

 

USTRUCT(BlueprintType)
struct FButtonHoverAnimSettings
{
	GENERATED_BODY()

	UPROPERTY()
	class UButton* button = nullptr;

	UPROPERTY()
	UWidgetAnimation* hoverAnim = nullptr;

	UPROPERTY()
	UWidgetAnimation* unHoverAnim = nullptr;

	FButtonHoverAnimSettings() {}

	FButtonHoverAnimSettings(UButton* InButton,UWidgetAnimation* InHover,UWidgetAnimation* InUnHover)
		: button(InButton), hoverAnim(InHover), unHoverAnim(InUnHover){}
};

UPROPERTY()
TArray<FButtonHoverAnimSettings> buttonHoverAnimSettings;

 

미리 BeginPlay에서 호버 이벤트가 필요한 버튼을 배열의 추가하여주었습니다

void UShopQuestWidget::InsertHoverEvent()
{
	buttonHoverAnimSettings.Empty();
	buttonHoverAnimSettings.Add(FButtonHoverAnimSettings(Button_MainUseShop,MainHoverUseShopAnim,MainUnHoverUseShopAnim));
	buttonHoverAnimSettings.Add(FButtonHoverAnimSettings(Button_MainQuest,MainHoverQuestAnim,MainUnHoverQuestAnim));
	buttonHoverAnimSettings.Add(FButtonHoverAnimSettings(Button_MainExit,MainHoverExitAnim,MainUnHoverExitAnim));
}

void UShopQuestWidget::BindHoverEvents()
{
	for (FButtonHoverAnimSettings& hoverAnimSettings : buttonHoverAnimSettings)
	{
		if (!hoverAnimSettings.button)
			continue;

		hoverAnimSettings.button->OnHovered.AddDynamic(this, &UShopQuestWidget::OnMainButtonHovered);
		hoverAnimSettings.button->OnUnhovered.AddDynamic(this, &UShopQuestWidget::OnMainButtonUnHovered);
	}
}

 

void UShopQuestWidget::OnMainButtonHovered()
{
	for (const FButtonHoverAnimSettings& hoveredSetting : buttonHoverAnimSettings)
	{
		if (hoveredSetting.button && hoveredSetting.button->IsHovered())
		{
			if (currentHoveredMainButton == hoveredSetting.button)
				return;

			if (currentHoveredMainButton)
			{
				for (const FButtonHoverAnimSettings& prevSetting : buttonHoverAnimSettings)
				{
					if (prevSetting.button == currentHoveredMainButton && prevSetting.unHoverAnim)
					{
						StopAnimation(prevSetting.hoverAnim);
						StopAnimation(prevSetting.unHoverAnim);
						PlayAnimation(prevSetting.unHoverAnim);
						break;
					}
				}
			}

			currentHoveredMainButton = hoveredSetting.button;

			if (hoveredSetting.hoverAnim)
			{
				StopAnimation(hoveredSetting.unHoverAnim);
				StopAnimation(hoveredSetting.hoverAnim);
				PlayAnimation(hoveredSetting.hoverAnim);
			}
			return;
		}
	}
}

void UShopQuestWidget::OnMainButtonUnHovered()
{
	if (!currentHoveredMainButton)
		return;

	for (const FButtonHoverAnimSettings& unHoveredSetting : buttonHoverAnimSettings)
	{
		if (unHoveredSetting.button == currentHoveredMainButton && unHoveredSetting.unHoverAnim)
		{
			StopAnimation(unHoveredSetting.hoverAnim);
			StopAnimation(unHoveredSetting.unHoverAnim);
			PlayAnimation(unHoveredSetting.unHoverAnim);
			break;
		}
	}

	currentHoveredMainButton = nullptr;
}

마우스 호버 이벤트 구현 결과

마우스를 가져다대면 텍스트가 자연스럽게 커지고 마우스가 위치에서 떠나면 자연스럽게 작아진다

상점구현

앞에서 만들어놓은 Loot Notification 위젯까지 멋지게 이벤트를 만들어놓고 아이템을 얻을 경로가 없어

아이템을 재화로 구매할 수 있는 상점을 구현하였습니다

이것도 정말 버튼이 많은데 함수를 줄일려고 노력했습니다

 

구현방식은 이와 같습니다

TMap<UButton* FShopBuyInfo>를 사용하여 아이템 정보를 매핑하여 컨테이너를 사용하는 방식으로 구현하였습니다

델리게이트를 사용하여 버튼 클릭을 하나의 함수로 처리하였습니다

UENUM(BlueprintType)
enum class EShopCurrencyType : uint8
{
	None,
	Gold,
	Silver
};

USTRUCT(BlueprintType)
struct FShopBuyInfo
{
	GENERATED_BODY()

	UPROPERTY()
	int32 itemID = -1;

	UPROPERTY()
	int32 itemPrice = 0;

	UPROPERTY()
	EShopCurrencyType currencyType = EShopCurrencyType::None;

	UPROPERTY()
	class UEditableTextBox* countTextBox;
};

 

UPROPERTY()
TMap<class UButton*, FShopBuyInfo> shopBuyButtonMap;

UFUNCTION()
void InsertShopBuyButtonID();

UFUNCTION()
void OnClickShopBuyButton();

 

void UShopQuestWidget::InsertShopBuyButtonID()
{
	shopBuyButtonMap.Empty();

	shopBuyButtonMap.Add(Button_ShopBuyID0, { 0, 50,  EShopCurrencyType::Silver, EditableTextBox_ShopBuyID0 });
	shopBuyButtonMap.Add(Button_ShopBuyID1, { 1, 80,  EShopCurrencyType::Silver, EditableTextBox_ShopBuyID1 });
	shopBuyButtonMap.Add(Button_ShopBuyID2, { 2, 5,   EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID2 });
	shopBuyButtonMap.Add(Button_ShopBuyID3, { 3, 8,   EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID3 });
	shopBuyButtonMap.Add(Button_ShopBuyID4, { 4, 12,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID4 });
	shopBuyButtonMap.Add(Button_ShopBuyID5, { 5, 16,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID5 });
	shopBuyButtonMap.Add(Button_ShopBuyID6, { 6, 25,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID6 });
	shopBuyButtonMap.Add(Button_ShopBuyID7, { 7, 50,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID7 });
	shopBuyButtonMap.Add(Button_ShopBuyID8, { 8, 25,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID8 });
	shopBuyButtonMap.Add(Button_ShopBuyID9, { 9, 50,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID9 });
	shopBuyButtonMap.Add(Button_ShopBuyID10, { 10,800, EShopCurrencyType::Silver, EditableTextBox_ShopBuyID10 });
	shopBuyButtonMap.Add(Button_ShopBuyID11, { 11,1500,EShopCurrencyType::Silver, EditableTextBox_ShopBuyID11 });
	shopBuyButtonMap.Add(Button_ShopBuyID12, { 12,1500,EShopCurrencyType::Silver, EditableTextBox_ShopBuyID12 });
	shopBuyButtonMap.Add(Button_ShopBuyID13, { 13,12,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID13 });
	shopBuyButtonMap.Add(Button_ShopBuyID14, { 14,45,  EShopCurrencyType::Gold,   EditableTextBox_ShopBuyID14 });
	shopBuyButtonMap.Add(Button_ShopBuyID15, { 15,300, EShopCurrencyType::Silver, EditableTextBox_ShopBuyID15 });


	for (const TPair<UButton*, FShopBuyInfo>& buyPair : shopBuyButtonMap)
	{
		if (buyPair.Key)
			buyPair.Key->OnClicked.AddDynamic(this, &UShopQuestWidget::OnClickShopBuyButton);
	}
}

void UShopQuestWidget::OnClickShopBuyButton()
{
	if (!inventoryComp)
		return;

	for (const TPair<UButton*, FShopBuyInfo>& shopBuyPair : shopBuyButtonMap)
	{
		UButton* buyButton = shopBuyPair.Key;
		const FShopBuyInfo& buyInfo = shopBuyPair.Value;

		if (buyButton->IsHovered())
		{
			int32 buyCount = GetBuyCount(buyInfo.countTextBox);
			int32 totalPrice = buyInfo.itemPrice * buyCount;

			if (buyInfo.currencyType == EShopCurrencyType::Gold)
			{
				if (inventoryComp->currentGold < totalPrice)
				{
					RandomNoMoneyConversation();
					shopNpc->PlayShopReaction(EShopNpcReaction::NoMoney);
					return;
				}
				inventoryComp->SubstractGold(totalPrice);
			}
			else if (buyInfo.currencyType == EShopCurrencyType::Silver)
			{
				if (inventoryComp->currentSilver < totalPrice)
				{
					RandomNoMoneyConversation();
					shopNpc->PlayShopReaction(EShopNpcReaction::NoMoney);
					return;
				}
				inventoryComp->SubstactSilver(totalPrice);
			}
			inventoryComp->AddItem(buyInfo.itemID, buyCount);

			if (buyInfo.countTextBox)
				buyInfo.countTextBox->SetText(FText::AsNumber(1));

			shopNpc->PlayShopReaction(EShopNpcReaction::BuySuccess);
			RandomBuyConversation();
			return;
		}
	}
}

 

이런 방식으로 Map을 사용하여 버튼에 들어가는 함수를 최소한으로 구현하였습니다

왼쪽 오른쪽 버튼도 이와 비슷하게 구현하였습니다

 

에디터블 텍스트박스에 입력된 문자열을 숫자int32형으로 안전하게 변환해주고

비어있거나 숫자가 아니면 1을 반환하고 최소값을 1로 설정해주었습니다

void UShopQuestWidget::OnClickedShopCountButton()
{
	for (auto& shopCountPair : shopCountButtonMap)
	{
		UButton* button = shopCountPair.Key;
		UEditableTextBox* textBox = shopCountPair.Value;

		if (!button->IsHovered())
			continue;

		int32 currentValue = GetBuyCount(textBox);

		if (button->GetName().Contains(TEXT("Left")))
			currentValue--;
		else if (button->GetName().Contains(TEXT("Right")))
			currentValue++;

		currentValue = FMath::Clamp(currentValue, 1, 999);
		textBox->SetText(FText::AsNumber(currentValue));
		return;
	}
}

int32 UShopQuestWidget::GetBuyCount(UEditableTextBox* textBox)
{
	if (!textBox)
		return 1;

	FString inputNum = textBox->GetText().ToString();
	if (inputNum.IsEmpty())
		return 1;

	for (TCHAR& Char : inputNum)
	{
		if (!FChar::IsDigit(Char))
			return 1;
	}

	int32 count = FCString::Atoi(*inputNum);
	return FMath::Max(count, 1);

}

 

상점 구현 결과물

종류별로 판매중인 아이템을 확인 할 수 있습니다

 

양 측 버튼을 눌러서 갯수를 증가시키거나 감소시킬수 있으며 갯수를 입력할수도 있습니다

구매하기 버튼을 눌러서 재화가 충분하다면 원하는 만큼 구매할 수 있습니다

 

재화가 부족하다면 구매할 수 없습니다

 

영상

 

글에 따로 작성하지는 않았지만 영상 초반부 보면 npc의 머리가 플레이어를 따라옵니다

열심히 만들었는데 먼가 부족한 느낌이 드는데 어디서 나오는걸까요..? 좋은 아이디어 추천해주실분 찾습니다

 

저작자표시 비영리 변경금지 (새창열림)

'Unreal 프로젝트 다이어리 > 두번째 프로젝트' 카테고리의 다른 글

Unreal - 퀘스트 시스템  (0) 2025.12.19
Unreal - 상호작용 프롬프트  (4) 2025.12.19
Unreal - Loot Notification 위젯  (0) 2025.12.14
Unreal - HitStop(히트스탑) & Hud(허드)  (1) 2025.12.12
Unreal - Blood Decal (피 튀기기)  (0) 2025.12.10
'Unreal 프로젝트 다이어리/두번째 프로젝트' 카테고리의 다른 글
  • Unreal - 퀘스트 시스템
  • Unreal - 상호작용 프롬프트
  • Unreal - Loot Notification 위젯
  • Unreal - HitStop(히트스탑) & Hud(허드)
lucodev
lucodev
언리얼 포폴개발 일기
  • lucodev
    루코 개발테이블
    lucodev
  • 전체
    오늘
    어제
    • 분류 전체보기 (236)
      • Unreal 프로젝트 다이어리 (132)
        • 첫번째 프로젝트 (73)
        • 두번째 프로젝트 (59)
      • Unreal 팁 (8)
      • Unreal 디버깅 (8)
      • C++ 프로그래머스 (52)
        • Stack,Queue (7)
        • Hash (4)
        • Heap (2)
        • Sort (5)
        • Exhaustive search (5)
        • Greedy (2)
        • BFS , DFS (7)
        • Graph (2)
        • Dynamic Programming (1)
        • C++ Math (2)
        • 기타 문제 (14)
      • C++ 백준 (5)
      • C++ 팁 (1)
      • 개인 코테 & 스타디 <비공개> (29)
        • 코드 개인보관함 (9)
        • 코딩테스트+@ (11)
        • 알고리즘 스타디 (6)
        • 알고리즘 스타디 과제 (3)
        • 비공개 (0)
  • 인기 글

  • 최근 글

  • 최근 댓글

  • 링크

  • 공지사항

  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 태그

    unreal inventory
    언리얼 인벤토리
    언리얼 parkour
    언리얼 상호작용
    unreal 파쿠르
    언리얼
    언리얼 컷씬
    언리얼 세키로
    언리얼 인터렉션
    unreal 세키로
    언리얼 behavior tree
    언리얼 파쿠르
    unreal 인벤토리
    unreal
    unreal 상호작용
    언리얼 behaviortree
    언리얼 시퀀스
    언리얼 ui
    unreal npc
    언리얼 비헤이비어트리
  • hELLO· Designed By정상우.v4.10.3
lucodev
Unreal - 상점 Npc
상단으로

티스토리툴바