요즘은 언리얼에서 User Widget을 다루고 있다

인벤토리를 만들었다. 인벤토리 위젯 구현을 먼저 보자
UCLASS()
class LOSTKINGDOM_API ULKInventoryWidget : public ULKPopupableWidget
{
GENERATED_BODY()
protected:
virtual void NativeOnInitialized() override;
virtual void NativeConstruct() override;
FORCEINLINE virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override { return FReply::Handled(); }
FORCEINLINE virtual FReply NativeOnMouseButtonDoubleClick(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override { return FReply::Handled(); }
FORCEINLINE virtual FReply NativeOnMouseWheel(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override { return FReply::Handled(); }
protected:
UPROPERTY()
TArray<class ULKInventorySlot*> Slots;
UPROPERTY(meta = (BindWidget))
TObjectPtr<class ULKTopbar> Topbar;
};
ULKPopupableWidget 클래스를 상속받는다. 이 클래스는 활성화하고 비활성화할 수 있는 위젯들이 상속받는다. 위젯을 활성화하고 비활성화할 수 있는 메서드가 있다
위젯 블루프린트의 구성은 다음과 같은데

스크롤 뷰안에 그리드 뷰로 슬롯들을 배치해뒀다. 이 슬롯들을 C++에서 따로 사용하기 위해 다음 함수에서 초기화한다
void ULKInventoryWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
for (int32 i = 0; i < 55; i++)
{
FString SlotName = FString::Printf(TEXT("WBP_InventorySlot_%d"), i);
ULKInventorySlot* InventorySlot = Cast<ULKInventorySlot>(GetWidgetFromName(*SlotName));
if (InventorySlot)
{
Slots.Add(InventorySlot);
}
}
if(Topbar)
{
Topbar->SetTargetWidget(this);
}
}
슬롯들의 할당에 성공했으면 위젯이 뷰포트에 추가될 때 처음 슬롯들을 업데이트한다. 슬롯에 아이템이 존재하면 아이템의 이미지와 수량이 정해지고 아이템이 없으면 그냥 빈 칸으로 둔다
void ULKInventoryWidget::NativeConstruct()
{
Super::NativeConstruct();
for (int32 i = 0; i < Slots.Num(); i++)
{
Slots[i]->UpdateSlot();
}
}
UCLASS()
class LOSTKINGDOM_API ULKInventorySlot : public UUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeOnInitialized() override;
virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
virtual void NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) override;
public:
virtual void UpdateSlot();
protected:
virtual void SetVisibility(ESlateVisibility InVisibility);
protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UImage> Icon;
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UTextBlock> CountText;
protected:
UPROPERTY()
TObjectPtr<class ALKItem> Item;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
TSubclassOf<class ALKItem> ItemClass;
};
void ULKInventorySlot::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (ItemClass)
{
Item = NewObject<ALKItem>(this, ItemClass);
}
}
bool ULKInventorySlot::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
if (InOperation && InOperation->DefaultDragVisual)
{
ULKInventorySlot* InventorySlot = Cast<ULKInventorySlot>(InOperation->Payload);
if(InventorySlot)
{
// Replace Item between InventorySlots
ALKItem* TempItem = Item;
Item = InventorySlot->Item;
InventorySlot->Item = TempItem;
UpdateSlot();
InventorySlot->UpdateSlot();
}
return true;
}
return Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
}
FReply ULKInventorySlot::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (Item)
{
if (InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton))
{
return UWidgetBlueprintLibrary::DetectDragIfPressed(InMouseEvent, this, EKeys::LeftMouseButton).NativeReply;
}
}
return FReply::Handled();
}
void ULKInventorySlot::NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation)
{
if (InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton))
{
UDragDropOperation* DragOperation = NewObject<UDragDropOperation>();
DragOperation->DefaultDragVisual = Icon;
DragOperation->DefaultDragVisual->SetVisibility(ESlateVisibility::SelfHitTestInvisible);
DragOperation->Payload = this;
OutOperation = DragOperation;
}
}
void ULKInventorySlot::UpdateSlot()
{
if (Item)
{
SetVisibility(ESlateVisibility::Visible);
Icon->SetBrushFromTexture(Item->GetItemData()->ItemIcon);
}
else
{
SetVisibility(ESlateVisibility::Hidden);
}
}
void ULKInventorySlot::SetVisibility(ESlateVisibility InVisibility)
{
Icon->SetVisibility(InVisibility);
CountText->SetVisibility(InVisibility);
}
테스트를 위해 슬롯에 아이템을 직접 생성할 수 있게 해놨다. 나중에 바꿀 것
인벤토리 슬롯에 아이템이 있다면 마우스로 상호 작용이 가능해야 하기에 입력 함수를 오버라이드해 구현했다. 마우스 버튼을 누르면 NativeOnMouseButtonDown이 호출되고 현재는 왼쪽 마우스가 클릭되면 드래그를 감지해 NativeOnDragDetected이 호출된다
드래그를 담당할 DragOperation를 생성하고 이미지를 해당 슬롯의 아이템 이미지로 바꾸고 이 이미지가 처음엔 입력을 감지해서 다른 입력을 방해하므로 입력 받지 않음으로 설정하고 Payload에 아이템 클래스를 할당한다
그리고 드래그한 것을 다른 슬롯에 드랍하면 그 곳에 아이템이 있다면 원래 슬롯과 아이템을 교체하고 아이템이 없다면 그 자리에 둔다
로스트아크의 위젯들은 위젯 상단을 마우스로 드래그하면 위젯을 움직일 수 있다. 잘 살펴보니 움직일 수 있는 위젯들에는 공통적으로 상단바가 있는 것으로 보인다. 그래서 나도 상단 바라는 것을 따로 구현해 위젯에 붙이기로 했다
UCLASS()
class LOSTKINGDOM_API ULKTopbar : public UUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
virtual void NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation) override;
virtual void NativeOnDragCancelled(const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
public:
FORCEINLINE void SetTargetWidget(class UUserWidget* InTargetWidget) { TargetWidget = InTargetWidget; }
protected:
UPROPERTY()
TObjectPtr<class UUserWidget> TargetWidget;
UPROPERTY()
TObjectPtr<class UCanvasPanelSlot> CanvasSlot;
UPROPERTY(meta = (BindWidget))
TObjectPtr<class ULKCloseButton> Close;
protected:
uint8 bIsDragging : 1;
FVector2d DragOffset;
FVector2d ViewportSize;
FVector2d WidgetSize;
};
상단바의 구성은 다음과 같다. TargetWindow는 상단바를 포함하는 위젯을 참조한다. CanvasSlot은 위젯을 움직이기 위해 참조하는 것이고 Close는 닫기 버튼을 따로 클래스로 분리해뒀다. 단순히 위젯을 닫는 역할을 한다
나머지 변수들은 위젯 드래그에 사용되는 변수들인데 이건 뒤에 설명하겠다
// Fill out your copyright notice in the Description page of Project Settings.
#include "UI/LKTopbar.h"
#include "Blueprint/WidgetBlueprintLibrary.h"
#include "Blueprint/WidgetLayoutLibrary.h"
#include "Blueprint/DragDropOperation.h"
#include "Components/CanvasPanelSlot.h"
#include "UI/LKCloseButton.h"
void ULKTopbar::NativeConstruct()
{
Super::NativeConstruct();
if(TargetWidget)
{
CanvasSlot = Cast<UCanvasPanelSlot>(TargetWidget->Slot);
}
if (Close)
{
Close->SetTargetWidget(TargetWidget);
}
bIsDragging = false;
DragOffset = FVector2d::ZeroVector;
// 뷰포트 스케일을 가져와서 뷰포트 사이즈를 계산
float ViewportScale = UWidgetLayoutLibrary::GetViewportScale(GetWorld());
GetWorld()->GetGameViewport()->GetViewportSize(ViewportSize);
ViewportSize /= ViewportScale;
// NativeConstruct에서 위젯 사이즈를 가져오면 0으로 나옴
WidgetSize = FVector2d::ZeroVector;
}
FReply ULKTopbar::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton))
{
return UWidgetBlueprintLibrary::DetectDragIfPressed(InMouseEvent, this, EKeys::LeftMouseButton).NativeReply;
}
return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent);
}
void ULKTopbar::NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent,
UDragDropOperation*& OutOperation)
{
OutOperation = UWidgetBlueprintLibrary::CreateDragDropOperation(UDragDropOperation::StaticClass());
if(OutOperation)
{
bIsDragging = true;
FVector2d MousePosition = InMouseEvent.GetScreenSpacePosition();
FVector2d WidgetPosition = TargetWidget->GetCachedGeometry().GetAbsolutePosition();
DragOffset = WidgetPosition - MousePosition;
}
}
void ULKTopbar::NativeOnDragCancelled(const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
bIsDragging = false;
Super::NativeOnDragCancelled(InDragDropEvent, InOperation);
}
bool ULKTopbar::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent,
UDragDropOperation* InOperation)
{
bIsDragging = false;
return Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
}
void ULKTopbar::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
if (bIsDragging && CanvasSlot)
{
// 마우스 위치 가져오기
FVector2D MousePosition = UWidgetLayoutLibrary::GetMousePositionOnViewport(GetWorld());
FVector2D NewPosition = MousePosition + DragOffset;
if(WidgetSize.IsZero())
WidgetSize = TargetWidget->GetDesiredSize();
// 왼쪽 경계 제한
if (NewPosition.X < 0)
{
NewPosition.X = 0;
}
// 오른쪽 경계 제한 (뷰포트 너비에서 위젯 너비만큼 뺀 위치가 최대)
else if (NewPosition.X > ViewportSize.X - WidgetSize.X)
{
NewPosition.X = ViewportSize.X - WidgetSize.X;
}
// 상단 경계 제한
if (NewPosition.Y < 0)
{
NewPosition.Y = 0;
}
// CanvasSlot 위치 업데이트
CanvasSlot->SetPosition(NewPosition);
}
}
NativeConstruct에서 CanvasSlot을 초기화하고 Close에 닫을 위젯을 설정해준다. 위젯의 크기를 나타내는 WidgetSize는 이 함수에서 초기화하면 제대로 크기가 안잡혀서 나중에 NativeTick에서 한 번만 초기화한다
드래그를 감지하면 호출되는 NativeOnDragDetected에서는 DragOffset을 계산하는데 이게 뭐냐면 상단바를 마우스로 클릭할 때 위젯의 기준에서 얼마나 떨어져 있는지를 계산해서 자연스러운 위젯 드래그를 만들어준다. 저게 없으면 마우스를 클릭한 곳에 위젯이 순간이동됨
bIsDragging이 true일 때 NativeTick의 로직이 실행된다. 여기서 위젯을 드래그 할 수 있게 해주는데 로스트아크를 보면 위젯을 움직일 수 있는 제한공간이 있다. 즉 뷰포트 밖으로 못나가게 하는건데 왼쪽, 위쪽, 오른쪽 밖으로 못넘어가게 막는다. 아래쪽은 로스트아크에서도 넘어갈 수 있게 해둬서 그대로 뒀음
여기까지해서 움짤을 보자

진짜 고민 많이 한 부분인데 열고 닫을 수 있는 위젯들이 여러 개 있을텐데(인벤토리, 캐릭, 스킬 창 등..) 이것들을 한 곳에서 관리시키고 싶었다. 그래서 UObject 클래스를 상속받은 위젯 매니저를 만들었다
UENUM(BlueprintType)
enum class EPopupWidgetType : uint8
{
Inventory UMETA(DisplayName = "Inventory"),
Character UMETA(DisplayName = "Character")
};
UCLASS(Blueprintable)
class LOSTKINGDOM_API ULKWidgetManager : public UObject
{
GENERATED_BODY()
public:
void Init();
void ToggleWidget(EPopupWidgetType PopupWidgetType) const;
private:
UPROPERTY()
TObjectPtr<class ULKMainHUD> MainHUD;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Widget", meta = (AllowPrivateAccess = "true"))
TSubclassOf<ULKMainHUD> MainHUDClass;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Widget", meta = (AllowPrivateAccess = "true"))
TMap<EPopupWidgetType, TSubclassOf<ULKPopupableWidget>> PopupWidgetClasses;
UPROPERTY()
TMap<EPopupWidgetType, ULKPopupableWidget*> PopupWidgets;
};
기존에는 MainHUD를 플레이어 컨트롤러에서 관리했는데 위젯 매니저로 이동했다. PopupWidgetClasses에서 등록한 클래스 정보로 각 위젯에 대한 인스턴스를 생성해 PopupWidgets에 저장할 것임
void ULKWidgetManager::Init()
{
MainHUD = NewObject<ULKMainHUD>(this, MainHUDClass);
if(MainHUD)
{
MainHUD->AddToViewport();
}
for(const auto& WidgetClass : PopupWidgetClasses)
{
if(ULKPopupableWidget* PopupWidget = CreateWidget<ULKPopupableWidget>(GetWorld(), WidgetClass.Value))
{
MainHUD->AddWidget(PopupWidget);
PopupWidget->SetVisibility(ESlateVisibility::Hidden);
PopupWidgets.Add(WidgetClass.Key, PopupWidget);
}
}
}
void ULKWidgetManager::ToggleWidget(EPopupWidgetType PopupWidgetType) const
{
if(auto PopupWidget = PopupWidgets.Find(PopupWidgetType))
{
if(*PopupWidget)
{
if((*PopupWidget)->GetVisibility() == ESlateVisibility::Hidden)
{
(*PopupWidget)->Activate();
}
else
{
(*PopupWidget)->Deactivate();
}
}
}
}
Init에서 MainHUD를 초기화하고 뷰포트에 등록한다. 그리고 위젯들을 생성해 MainHUD에 넘겨줄텐데 이거 왜 이렇게 하냐면 어떤 위젯이 뷰포트 내에서 내가 구현한 드래그로 움직일 수 있게 하려면 캔버스 패널 아래에 있어야 한다. 그래서 메인 HUD에 따로 캔버스 패널을 두고 그 곳에 위젯을 추가할거다
ToggleWidget은 어떤 위젯 키를 입력하면 이 위젯을 인스턴스 목록에서 찾고 활성화되어 있으면 비활성화하고 비활성화되어 있으면 활성화한다
void ULKMainHUD::AddWidget(UUserWidget* InWidget) const
{
if(WidgetContainer)
{
if (UCanvasPanelSlot* CanvasSlot = WidgetContainer->AddChildToCanvas(InWidget))
{
FVector2D ViewportSize;
GEngine->GameViewport->GetViewportSize(ViewportSize); // 뷰포트 크기 가져오기
FVector2D CenterPosition = ViewportSize * 0.5f; // 뷰포트 중앙 좌표
// 현재 앵커와 정렬을 유지한 상태에서 중앙에 배치
FVector2D Offset = CenterPosition - (CanvasSlot->GetSize() * CanvasSlot->GetAlignment());
CanvasSlot->SetPosition(Offset);
}
}
}
컨테이너에 추가를 했으면 화면의 중앙에 다시 배치해주는 로직을 실행한다
그럼 이 위젯 매니저는 어디서 생성하냐? 이걸 게임 인스턴스에 넣기로 했다. 게임 인스턴스는 언리얼에서 따로 싱글톤으로 관리하는 객체라 따로 설정해줄 필요도 없고 얻어오기도 쉽다
게임 인스턴스는 처음 게임이 실행될 때 Init이 호출되는데 여기서 위젯 매니저에 대한 인스턴스를 만들고 위젯 매니저를 초기화시킨다. 그럼 게임 인스턴스는 위젯 매니저를 계속 참조하고 있다
마지막으로 특정 위젯 키의 입력을 처리해야 된다. 이거를 플레이어 컨트롤러에서 하면 코드가 길어져 가독성이 떨어질까봐 따로 분리했다. 그래서 위젯 컨트롤 클래스를 만들어서 플레이어 컨트롤러에서 인스턴스 생성 후 액션을 바인딩하기로 했다
UCLASS(Blueprintable)
class LOSTKINGDOM_API ULKWidgetController : public UObject
{
GENERATED_BODY()
public:
void Init();
void SetupInputComponent(UEnhancedInputComponent* EnhancedInputComponent);
protected:
TObjectPtr<class ULKWidgetManager> WidgetManager;
protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> InventoryAction;
protected:
void OnInventoryTriggered();
};
현재는 인벤토리만 구현해둬서 인벤토리만 있다
// Fill out your copyright notice in the Description page of Project Settings.
#include "Player/LKWidgetController.h"
#include "EnhancedInputComponent.h"
#include "Game/LKGameInstance.h"
#include "Manager/LKWidgetManager.h"
void ULKWidgetController::Init()
{
if (ULKGameInstance* GameInstance = Cast<ULKGameInstance>(GetWorld()->GetGameInstance()))
{
WidgetManager = GameInstance->GetWidgetManager();
}
}
void ULKWidgetController::SetupInputComponent(UEnhancedInputComponent* EnhancedInputComponent)
{
if (EnhancedInputComponent)
{
EnhancedInputComponent->BindAction(InventoryAction, ETriggerEvent::Triggered, this, &ULKWidgetController::OnInventoryTriggered);
}
}
void ULKWidgetController::OnInventoryTriggered()
{
if(WidgetManager)
{
WidgetManager->ToggleWidget(EPopupWidgetType::Inventory);
}
}
위젯 매니저에 대한 참조를 생성하고 특정 키 입력이 들어오면 위젯 매니저를 통해 위젯을 활성화/비활성화할 것이다
결과는?

음 사실 더 좋은 방법으로 구현하기 위해 시간이 많이 걸렸다. 예를 들면 특정 키 입력마다 함수를 따로 만들어주기 싫어서 BindAction에 매개변수를 넘겨주는 방식을 생각했는데 실패했다 ㅠ
진짜 실무에서 이거를 어떻게 구현하는 지 궁금하다