UE5 Issues : 인벤토리 UI
이번 구현의 목적은 우리가 Tab키를 눌렀을 때, 인벤토리 UI가 켜지고 그 때 버튼을 클릭하여 해당 아이템을 사용하거나 장착하는 것이다.
1. 인벤토리 UI 관련 이슈
Tab 키를 누른 상태에서 UI가 켜져있고, 땐 상태에서 UI가 꺼지도록 구현을 하고 싶어서 처음에는 Triggered된 순간 ShowInventory를 실행하고, Completed된 순간 StopShowInventory 를 실행하는 로직으로 시작했다.
if (PlayerController->ShowInventoryAction)
{
EnhancedInputComponent->BindAction(PlayerController->ShowInventoryAction,
ETriggerEvent::Started, this, &APlayerCharacter::ShowInventory);
EnhancedInputComponent->BindAction(PlayerController->ShowInventoryAction,
ETriggerEvent::Completed, this, &APlayerCharacter::StopShowInventory);
}
이후 각 함수들은 PlayerController에 구현된 UI를 켜주고 꺼주는 함수와 연결하였다.
void APlayerCharacter::ShowInventory(const FInputActionValue& value)
{
//if pressed UI
if (GetController())
{
Cast<APlayerCharacterController>(GetController())->ShowInventoryUI();
}
}
void APlayerCharacter::StopShowInventory(const FInputActionValue& value)
{
if (GetController())
{
Cast<APlayerCharacterController>(GetController())->StopShowInventoryUI();
}
}
각 함수들은 UIInstance를 통해 직접적으로 UI를 켜고 끄는 로직을 구성하였다.
void APlayerCharacterController::ShowInventoryUI()
{
if (InventoryUIClass && !InventoryUIInstance)
{
InventoryUIInstance = CreateWidget<UUserWidget>(this, InventoryUIClass);
}
if (InventoryUIInstance && !InventoryUIInstance->IsInViewport())
{
InventoryUIInstance->AddToViewport();
bShowMouseCursor = true;
bIsInventoryUIOpen = true;
SetInputMode(FInputModeGameAndUI());
}
}
void APlayerCharacterController::StopShowInventoryUI()
{
if (InventoryUIInstance && InventoryUIInstance->IsInViewport())
{
InventoryUIInstance->RemoveFromParent();
InventoryUIInstance = nullptr;
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
}
}
정리
- 이 방식은 Tab키를 누르고 있는 동안 UI가 켜져있는 것은 동작하지만, 마우스로 버튼을 클릭하는 순간 UI가 꺼져버리는 문제가 발생했다.
- 마우스를 클릭하는 순간 StopShowInventory가 call 되면서 UI가 꺼져버린다.
- 이 문제를 해결하기 위해 UI가 켜진 경우 InputMode를 UIOnly로 바꾸니 Tab키를 통해 UI가 꺼지지 않는 문제가 발생하였다.
- ???
해결
- Triggered 상태와 Completed 상태 두 경우 모두 바인딩을 하지 않고, Started인 상태에서만 바인딩을 하였다.
- 이때 한번 키를 누르면 flag (bool 변수) 를 통해 현재 상태를 체크하여 ShowInventory 함수에서 ShowInventoryUI 함수와 StopShowInventoryUI 함수를 컨트롤 해 주었다.
- 이렇게 구현한 경우 UI가 toggle형식이 되지만, 게임 동작에 큰 지장은 없다고 생각되어 구현을 하였다.
코드
- 바인딩은 Started로 하나만 구현
if (PlayerController->ShowInventoryAction)
{
EnhancedInputComponent->BindAction(PlayerController->ShowInventoryAction,
ETriggerEvent::Started, this, &APlayerCharacter::ShowInventory);
}
- bIsInventoryUIOpen이라는 flag로 동작 구분
void APlayerCharacter::ShowInventory(const FInputActionValue& value)
{
//if pressed UI
if (GetController())
{
if (!Cast<APlayerCharacterController>(GetController())->bIsInventoryUIOpen)
{
Cast<APlayerCharacterController>(GetController())->ShowInventoryUI();
}
else
{
Cast<APlayerCharacterController>(GetController())->StopShowInventoryUI();
}
}
}
- Show의 경우는 bIsInventoryUIOpen을 true로 만들고, Stop의 경우는 false로 만들어준다.
void APlayerCharacterController::ShowInventoryUI()
{
if (InventoryUIClass && !InventoryUIInstance)
{
InventoryUIInstance = CreateWidget<UUserWidget>(this, InventoryUIClass);
}
if (InventoryUIInstance && !InventoryUIInstance->IsInViewport())
{
InventoryUIInstance->AddToViewport();
bShowMouseCursor = true;
bIsInventoryUIOpen = true;
SetInputMode(FInputModeGameAndUI());
}
}
void APlayerCharacterController::StopShowInventoryUI()
{
InventoryUIInstance->RemoveFromParent();
InventoryUIInstance = nullptr;
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
bIsInventoryUIOpen = false;
}
추가 이슈)
- UI가 켜졌을 때 Tab키를 누르면 자동으로 버튼이 선택되는 문제가 존재했다.
- 아래와 같이 ui가 켜졌을때 Tab키를 통해 자동으로 버튼이 선택되는 현상
- 이 문제를 해결하기 위해 UI를 여는 키를 Tab에서 Ctrl로 수정하였다.
2. 아이템 획득 시 UI 연동
아이템을 획득하면, UI에 표시를 하는 것이 목적입니다.
처음에 게임 구상할 때는 간단한 퀵슬롯 형태로 UI를 구성하려고 했다가 중간에 인벤토리 방식으로 바뀌다 보니 구현이 조금 힘들어진 경향이 있었습니다.
그래도 아이템 종류는 늘리지 않고 한정되어있었기 때문에 최대한 어색하지 않지만 쉬운 방식으로 구현하기 위해, 미리 아이템들이 UI에 자리를 차지하고 있고 아이템을 획득하면 UI가 해금되거나 수량이 늘어나는 형태로 구현하게 되었습니다.
프로토 타입으로 아래와 같은 모습을 구상하였습니다.
총기 UI
- 각 총기마다 자리를 차지하고 있으며, 이미지의 투명도를 0으로 해서 보이지 않게 구현하고
- 총기를 획득하면 투명도를 1로 바꾸도록 구현하였습니다.
아이템 UI
- 구현하는 게임의 아이템의 종류가 많지 않아서 미리 슬롯을 만들어 두고
- 아이템을 획득하면 위의 숫자를 text로 증가시켜 주는 형태로 구현하였습니다.
코드
UI를 구현하기 위해 아이템을 저장하는 인벤토리와 총기를 저장하는 인벤토리를 GameInstance에 구현하였습니다.
- 아쉬운 점은 Weapon이 BaseItem을 상속하지 않고 다른 구조로 만들어 버려서 같은 모습의 함수를 여러번 호출해야 하는 아쉬움이 있었습니다.
- InventoryItem
- 전체 총알과 healItem을 저장하는 자료구조로 Map을 사용한 이유는 아이템 갯수를 증가시켜 주기 위해서 value값으로 아이템의 양을 넣어주었습니다.
- WeaponInventoryItem
- int32값을 단순히 저장해서, FindIdx() 함수를 통해 해당 인덱스가 있다면, 해당 인덱스에 맞는 UI를 그려주는 식으로 구현하였습니다.
//for HUD ammo text ui
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "PlayerData")
int32 InventoryAmmo;
//for Inventory UI
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "PlayerData")
TMap<int32, int32> InventoryItem;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "PlayerData")
TArray<int32> WeaponInventoryItem;
- 각 아이템들은 IndexNumber를 가지고 있어서, GetNumber() 함수를 통해 어떤 아이템인지 확인할 수 있습니다.
- 아래와 같이 캐릭터의 PickUp함수에서 GameInstance와 연동해서 아이템을 추가해주었습니다.
void APlayerCharacter::PickUp(const FInputActionValue& value)
{
if (PeekingItem != nullptr)
{
if (PeekingItem->ActorHasTag("Weapon"))
{
if (GetGameInstance())
{
UDefaultGameInstance* DefaultGameInstance = Cast<UDefaultGameInstance>(GetGameInstance());
if (DefaultGameInstance)
{
int32 WeaponIdx = Cast<AWeapon>(PeekingItem)->GetWeaponNumber();
DefaultGameInstance->AddWeapon(WeaponIdx);
}
}
PeekingItem->Destroy();
}
else if(PeekingItem->ActorHasTag("Item"))
{
if (GetGameInstance())
{
UDefaultGameInstance* DefaultGameInstance = Cast<UDefaultGameInstance>(GetGameInstance());
if (DefaultGameInstance)
{
int32 ItemIdx = Cast<ABaseItem>(PeekingItem)->GetItemNumber();
int32 ItemAmount = Cast<ABaseItem>(PeekingItem)->GetItemAmount();
DefaultGameInstance->AddItem(ItemIdx, ItemAmount);
}
}
PeekingItem->Destroy();
}
}
}
실제 UI를 업데이트하는 동작은 GameState에서 이루어집니다.
- 무기는 1번에 해당하는 것이 있다면, 해당 이미지의 투명도를 1로 바꿔주고,
- 아이템의 경우 Map을 통해 해당 인덱스로 갯수를 가져오도록 하였습니다.
//Inventory
if (UUserWidget* InventoryWidget = PlayerCharacterController->GetInventoryWidget())
{
//Weapon UI
if (UImage* WeaponUI1Image = Cast<UImage>(InventoryWidget->GetWidgetFromName(TEXT("Weapon1Image"))))
{
if (UGameInstance* GameInstance = GetGameInstance())
{
if (UDefaultGameInstance* DefaultGameInstance = Cast<UDefaultGameInstance>(GameInstance))
{
if (DefaultGameInstance->FindWeaponByIdx(1))
WeaponUI1Image->SetOpacity(1.f);
}
}
}
...
//Item UI
if (UTextBlock* CurrentInventoryHealthText = Cast<UTextBlock>(InventoryWidget->GetWidgetFromName(TEXT("Item1Text"))))
{
if (UGameInstance* GameInstance = GetGameInstance())
{
if (UDefaultGameInstance* DefaultGameInstance = Cast<UDefaultGameInstance>(GameInstance))
{
CurrentInventoryHealthText->SetText(FText::FromString(FString::Printf(TEXT("%d"), DefaultGameInstance->InventoryItem[1])));
}
}
}
...
}
지금까지의 동작을 구현한 모습은 아래와 같습니다.
3. 무기 장착 및 아이템 사용
사용 및 장착 모두 UI의 BP에서 Click 이벤트를 통해 구현하였습니다.
- Equip Weapon Back과 Use Health Item은 cpp로 구현된 함수입니다.
- Equip의 경우 이미지의 투명도가 1일 때만 실행되게 하여 예외처리를 하였고
- Use Health의 경우 해당 cpp 함수 안에서 예외처리를 하였습니다.
Equip Weapon Back 함수
- 아이템 index를 받아와서 생성이 필요할 때 Spawn하는 방식으로 구현하였습니다.
- 총기가 이미 장착된 경우는 클릭한 총으로 변경해 줍니다.
- 단, 공격 모션이 아닌 상태에서만 변경이 가능합니다.
- 그러나 이때 의문점은 원래 장착된 아이템이 있는 경우의 처리 방법입니다.
- 현재 swap 하는 방식으로 구현하였는데, 완벽한 방식은 아닌 것 같습니다.
- 포인터 관련 연산 및 Destroy에 대한 내용을 공부해야 할 것 같다는 생각이 들었습니다.
- 추가적으로 이 방식은 총을 넣고 다시 꺼낸 경우 총기의 정보가 초기화 되버리기 때문에 (총을 쏘다가 넣고 다시 꺼내면 장전이 된 상태가 된다) TMap을 통해 인덱스와 총기의 포인터를 저장하는 방식으로 수정할 예정입니다.
추가)
- 총기별로 다른 스팩을 가지고 있는데, 그중 연사와 단발 기능이 있습니다.
- 연사를 타이머를 통해 구현하였기 때문에, Equip하는 함수에서 타이머 초기화를 해주어야 단발과 연사가 제대로 동작합니다.
void APlayerCharacter::EquipWeaponBack(int32 WeaponIdx)
{
//attack animation -> can't change weapon
if (bIsWeaponEquipped)
return;
GetWorldTimerManager().ClearTimer(FireRateTimerHandle);
//idle animation -> spawn weapon by index
if (WeaponIdx == 1)
{
TempWeapon = GetWorld()->SpawnActor<AWeaponAR1>();
}
else if (WeaponIdx == 2)
{
TempWeapon = GetWorld()->SpawnActor<AWeaponAR2>();
}
else if (WeaponIdx == 3)
{
TempWeapon = GetWorld()->SpawnActor<AWeaponSR>();
}
else if (WeaponIdx == 4)
{
//CurrentWeapon = GetWorld()->SpawnActor<AWeapon>();
}
//already equip weapon
if (CurrentWeapon || bIsWeaponEquippedBack)
{
TObjectPtr<AWeapon> Dummy;
Dummy = TempWeapon;
TempWeapon = CurrentWeapon;
CurrentWeapon = Dummy;
TempWeapon->Destroy();
}
else
{
CurrentWeapon = TempWeapon;
}
CurrentWeapon->SetActorEnableCollision(false);
FName WeaponSocket(TEXT("back_socket"));
CurrentWeapon->AttachToComponent(GetMesh(), FAttachmentTransformRules::SnapToTargetNotIncludingScale, WeaponSocket);
bIsWeaponEquippedBack = true;
}
Use Health Item 함수
- 단순히 GameInstance에서 체력을 올려주는 처리를 하면 됩니다.
- 이때 최대 체력보다는 커지지 않게 예외처리만 하면 됩니다.
지금까지의 동작 모습입니다.