
아주 긴 공백기를 끝내고(팀플) 다시 오랜만에 하는 언리얼이다
너무 오래돼서 까먹은 것들이 좀 있어서 개발일지들을 쭉 정독했다. 이런걸 기록이라도 해서 다행임,,
오늘은 가볍게 언리얼 위젯, UMG를 다뤄 볼 예정이다
지난번에 캐릭터 기본 스탯 컴포넌트인 LKCharacterBase 클래스의 멤버 변수인 LKCharacterStatComponent 클래스의 Stat 변수를 LKPlayerCharacter 클래스에서 LKPlayerCharacterStatComponent 타입으로 캐스팅해서 사용하려고 메서드에서 다음과 같이 캐스팅을 했었는데
ULKPlayerCharacterStatComponent* PlayerStat = CastChecked<ULKPlayerCharacterStatComponent>(Stat);
PlayerStat->OnBattleStatChanged.AddUObject(this, &ALKPlayerCharacter::OnBattleStatChanged);
이게 CastChecked 함수로 캐스팅을 하다 보니 캐스팅이 실패가 하면 에디터가 꺼져 버리는 현상이 발생한다. 원래는 Stat 컴포넌트를 플레이어 캐릭터의 생성자 부분에서 초기화해줘야 하는데 코드 없이 내가 캐릭터 블루프린트 클래스에서 그냥 설정했는데 이게 C++ 클래스가 있는 폴더를 여니까 에디터가 꺼져버리는 현상이 발생했다
Content Browser 창에서도 C++ 클래스들이 뭔가 에러가 날 만한 코드가 있으면 에디터가 꺼져버리니 상당히 번거로우니 웬만하면 캐스트할때 예외처리를 해줘야겠음,,
ULKPlayerCharacterStatComponent* PlayerStat = Cast<ULKPlayerCharacterStatComponent>(Stat);
if (PlayerStat)
{
PlayerStat->OnBattleStatChanged.AddUObject(this, &ALKPlayerCharacter::OnBattleStatChanged);
}

이제 로스트아크에서 캐릭터를 접속하면 가장 먼저 보이는 위젯을 만들어보자.
위젯 블루프린트를 만들거고 왼쪽 위 상단바 메뉴와 아래쪽 경험치 바 그리고 스킬, 아이템을 등록할 수 있는 UI, 체력, 마나 UI, 아덴 UI를 추가할 것이다

저번에 만든 LKUserWidget 클래스를 부모로 한 위젯 블루프린트를 만들었다
들어가서 화면 전체에 보여질 위젯이니까 Canvas Panel을 드래그해서 만들어주고 1920*1080에 맞게 세팅했다
상단바를 먼저 만들텐데 수평으로 아이콘들이 여러개 있으니까 horizontal Box를 이용해 유니티의 Horizontal Layout처럼 사용해보자. 그런데 Horizontal Box만으로는 이미지를 넣을 수 없으니까 상단바 Overlay를 만들어서 거기에 이미지를 추가할 생각임

Canvas Panel 아래에 Overlay를 추가하고 Image, HorizontalBox를 추가하자
그리고 이미지로 가서 사용할 리소스를 적용해보자.

밑줄 친 Image Size에는 원본 이미지의 사이즈가 그대로 적용이 되었지만 캔버스에서는 이상하게 보인다. 유니티에서는 Image 컴포넌트에 원본 이미지를 넣고 Set Native Size를 누르면 이미지가 그대로 원본 사이즈에 맞게 바뀌지만 언리얼에는 이 방법이 없을까?
현재 이미지의 상위 계층인 오버레이의 크기에 하위 계층들의 크기가 맞춰지기 때문에 이를 조절하려면 Overlay를 확인해봐야 한다

캔버스 패널 아래에 있는 객체들은 네모친 것처럼 Slot 카테고리가 있는데 저기 있는 Size X와 Size Y로 최종적으로 크기가 결정되는 것 같다. 그러면 매번 저 SizeX와 SizeY를 이미지 크기에 맞게 바꿔줘야 하는건가?
요즘 같은 시대에 그런 비효율적인 엔진은 없을거라고 생각해서 구글링과 GPT를 이용했는데 겨우 방법을 찾았다. 바로 저기에 있는 Size To Content가 정답이었다. 저 체크박스를 true로 바꿔주니까 하위의 객체들에 맞게 크기가 조절됐다

이제 오버레이 안에 Horizontal Box를 여러개 두어 상단바를 구현할 수 있을 것 같다
일단은 상단바 UI를 접고 펼치고 접을 수 있는 버튼과 현재 시간을 알려주는 텍스트를 만들 것인데 버튼은 그냥 리소스만 넣어두고 나중에 구현하기로

상단바 UI 배치는 왼쪽에 보이는 것과 같이 배치했다. 상단바 UI 전체를 가리키는 오버레이 아래에 배경 이미지로 쓸 이미지와 수평으로 배치할거기 때문에 Horizontal Box를 여러 개 두었음
그리고 버튼을 두고 시간을 나타낼 이미지와 텍스트 위젯을 배치했다
위젯을 배치할 때 유니티에는 Spacing이라는 개념이 있어서 컴포넌트 간 간격을 조절할 수 있었는데 언리얼도 그런 기능을 찾아보다가 언리얼은 걍 padding으로 하는게 편하다고 한다

위와 같이 버튼 옆에 간격을 padding으로 조정했는데 이때 Alignment를 잘 맞춰주고 해야 한다. 수평 가운데 정렬로 하고 오른쪽 패딩을 20만큼 주니까 이 위젯도 왼쪽으로 10, 오른쪽 공간 10 이렇게 생기는 것 같음. 자세히는 모르는데 정렬 중요시 하자
이렇게 위젯을 배치하고 시간 텍스트 위젯 같은 경우 우리가 코드 상으로 현재 시간을 받아와 계속 바꿔줘야 한다. 이 위젯을 코드 상에서 보고 싶으면 텍스트 위젯을 누르고 Details에 있는 Is Variable를 체크해주자. 그럼 코드 상에서 변수로 받아올 수 있다
이제 코드 상에서 텍스트 위젯을 변수로 사용하고 싶으니 LKMainHUD C++ 클래스로 넘어가자.
헤더 파일에 다음과 같이 선언해줬다
protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UTextBlock> Time;
void UpdateTime();
UTextBlock이 텍스트 위젯 클래스라서 이를 담을 변수를 Time으로 선언하고 현재 시간을 받아와 텍스트로 보여 줄 UpdateTime 함수이다
혹시 위에 블루프린트 메인 HUD에서 시간을 나타내는 텍스트 위젯의 이름을 봤을까? 같다고 생각하는 건 우연이 아니다. 일부러 맞춰준것임
블루프린트 위젯을 변수로 불러오고 싶은 경우 방법이 두 가지가 있다
meta = (BindWidget) 다음과 같이 지정해주면 컴파일할 때 알아서 변수에 할당된다(짱 편리하다 이거)HPProgressBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("PBHPBar/*위젯 이름*/")));그래서 둘 중 솔직히 난 계속 1번을 쓸 것 같다. 나중에 체력바도 바꿔야지
이제 시간을 업데이트하는 코드를 보면
void ULKMainHUD::NativeConstruct()
{
Super::NativeConstruct();
UpdateTime();
}
void ULKMainHUD::UpdateTime()
{
// 현재 시간을 얻어오기
FDateTime Now = FDateTime::Now();
// 현재 시간을 시간:분으로 변환
FString TimeString = Now.ToString(TEXT("%H:%M"));
// 텍스트 블록에 시간 설정
if (Time)
{
Time->SetText(FText::FromString(TimeString));
}
GetWorld()->GetTimerManager().SetTimerForNextTick(FTimerDelegate::CreateUObject(this, &ULKMainHUD::UpdateTime));
}
위젯이 초기화되면 바로 UpdateTime 함수를 호출해 현재 시간을 세팅하고 SetTimerForNextTick 함수를 통해 다음 프레임에 UpdateTime을 호출하게 한다. 그래서 매 프레임마다 시간을 확인하고 업데이트할 수 있다
게임을 실행하면 다음과 같은 결과를 얻을 수 있다. 정상적으로 시간이 업데이트 됨

사실 로스트아크에서 상단 UI는 게임에 그렇게 중요하지는 않다. 중요한 건 하단에 있는 스킬, 아이템, 아덴이다(경험치도 있긴함)
먼저 최대한 로스트아크와 비슷하게 UI를 배치하고 설명하겠음

경험치바, 왼쪽에 스킬, 가운데 아덴, 오른쪽 아이템이다
경험치바는 일단 캐릭터의 경험치와 연동해서 구현은 나중으로 미루겠음
UI 구성은 Progress Bar를 선택했다. 아무래도 경험치에 따라 수평으로 조절이 되기 때문에

코드에서 필요한 건 경험치 게이지 바와 현재 레벨을 알려주는 텍스트다. Progress Bar는 쉬우니까 여기까지 하고 넘어가겠음
언리얼은 Background와 Fill 이미지를 나눠서 참 좋다
아덴도 지금 구현한게 없어서 그냥 리소스만 박아놨다. 나중에 언급하도록
이걸 퀵 슬롯이라고 칭하는게 맞는 지 모르겠지만 걍 부르기 편한대로 부르겠다
로스트아크의 스킬, 아이템 퀵 슬롯은 스킬 창, 인벤토리에서 배운 스킬, 보유한 아이템을 드래그 해서 퀵 슬롯에 등록 후 퀵 슬롯 단축키(ex. Q, W, 1, 2)를 누르면 그 단축키에 해당하는 퀵 슬롯에 있는 스킬이나 아이템이 사용된다
일단 클래스 설계를 해보면 스킬, 아이템 슬롯을 구분할 필요는 있다. 스킬 퀵 슬롯에 아이템을 등록하는 것과 그 반대도 막아야 한다. 그리고 퀵 슬롯 사용 시 스킬과 아이템의 사용 로직은 다를테니까
그런데 공통적으로는 슬롯이라는 모양과 드래그 & 드랍이 가능하다. 그리고 스킬이나 아이템의 이미지를 받아올 수 있다
그러면 ULKBaseQuickSlot이라는 공통 기능을 하는 클래스들로 묶고 이를 상속받은 스킬 슬롯과 아이템 슬롯을 만들면 될 것 같다
코드를 살펴보자
UCLASS()
class LOSTKINGDOM_API ULKBaseQuickSlot : public UUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
protected:
// 드랍할 수 있는지(아이템이나 스킬)
UFUNCTION(BlueprintCallable, Category = "QuickSlot")
virtual bool CanDrop(UObject* DropObject) const;
// Drop 시 함수라는데 아직은 잘 모름,,
virtual bool NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation) override;
virtual void SetImage(); // 이미지 업데이트
void SetKey(); // 단축키 텍스트 업데이트
virtual void UseSlot(); // 슬롯에 있는 아이템이나 스킬 사용
// Input
protected:
// 현재 슬롯의 Input Action 객체
// 어떤 입력을 받아야 현재 슬롯이 사용될 지를 나타냄
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, meta = (AllowPrivateAccess = "true"))
class UInputAction* Action;
UPROPERTY()
FKey MappedKey; // 현재 슬롯의 단축키 정보(사실 필요한지 잘 모르겠음)
// Component
protected:
UPROPERTY(meta=(BindWidget))
TObjectPtr<class UImage> Image; // 스킬이나 아이템의 이미지를 보여줄 컴포넌트
UPROPERTY(meta=(BindWidget))
TObjectPtr<class UTextBlock> KeyText; // 현재 슬롯의 단축키가 무엇인지 보여주는 컴포넌트
protected:
uint8 bIsEmpty : 1; // 현재 슬롯이 비어있는지를 나타냄
};
#include "UI/LKBaseQuickSlot.h"
#include "Blueprint/DragDropOperation.h"
#include "Components/Image.h" // 이미지 컴포넌트 헤더
#include "Components/TextBlock.h" // 텍스트 컴포넌트 헤더
#include "InputMappingContext.h" // Input Mapping Context 헤더
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "Player/LKPlayerController.h"
// 위젯 초기화 시
void ULKBaseQuickSlot::NativeConstruct()
{
Super::NativeConstruct();
bIsEmpty = true;
SetImage();
// 이 위젯에 있는 액션과 함수를 매핑
if (UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(GetWorld()->GetFirstPlayerController()->InputComponent))
{
EnhancedInputComponent->BindAction(Action, ETriggerEvent::Started, this, &ULKBaseQuickSlot::UseSlot);
}
SetKey();
}
bool ULKBaseQuickSlot::CanDrop(UObject* DropObject) const
{
return false;
}
bool ULKBaseQuickSlot::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
if (InOperation && InOperation->DefaultDragVisual && CanDrop(InOperation->Payload))
{
// 드랍을 처리하고, 이미지 변경 등의 작업 수행
bIsEmpty = false;
SetImage();
return true;
}
return Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
}
void ULKBaseQuickSlot::SetImage()
{
if (bIsEmpty)
{
Image->SetVisibility(ESlateVisibility::Hidden);
}
else
{
Image->SetVisibility(ESlateVisibility::Visible);
}
}
void ULKBaseQuickSlot::UseSlot()
{
UE_LOG(LogTemp, Log, TEXT("UseSlot : %s"), *MappedKey.GetDisplayName().ToString());
}
void ULKBaseQuickSlot::SetKey()
{
// 내가 만든 플레이어 컨트롤러에 IMC가 있으니 그걸 받아온다
ALKPlayerController *PlayerController = Cast<ALKPlayerController>(GetWorld()->GetFirstPlayerController());
if (PlayerController)
{
// IMC에 있는 매핑 정보들을 불러온다
const TArray<FEnhancedActionKeyMapping>& Mappings = PlayerController->DefaultMappingContext->GetMappings();
// 매핑 정보들을 순회하면서 Action과 일치하다면 그게 이 퀵 슬롯의 단축키임
for (const auto& Mapping : Mappings)
{
if (Action == Mapping.Action)
{
MappedKey = Mapping.Key;
if (KeyText)
{
KeyText->SetText(MappedKey.GetDisplayName());
}
}
}
}
}
주석 외에 간단히 추가 설명하면
CanDrop 함수는 스킬 슬롯, 아이템 슬롯에서 재정의하도록 했다. 막무가내로 드랍하는 걸 방지하기 위해SetImage 함수도 스킬, 아이템마다 로직이 다를 수 있기에 재정의하도록 했고 부모 클래스에서는 그냥 현재 슬롯이 비어있는지 안비어있는지에 따라 이미지를 보이게 할 건지 안 보이게 할 건지만UseSlot도 자식에서 오버라이드해 아이템, 스킬 따로 사용하도록
이렇게 C++ 클래스를 만든 후 이 클래스를 부모로 하는 위젯 블루프린트를 만들어야 한다. 하이어라키에 위젯 배치는 단순히 이렇게 했다

프로젝트 창에서 여기서 어떻게 보이는가는 중요하지 않으니 신경쓰지 말자.
그리고 우측 상단에 Graph로 넘어가 클래스 설정을 해준다

그리고 MainHUD 블루프린트로 넘어가 우리가 만든 위젯을 배치해준다

배치한 위젯을 클릭해 Details를 살펴보면

Action을 매핑할 수 있다. 각각 퀵 슬롯에 맞는 액션을 매핑해주자
컴파일 후 게임을 실행해보면?

퀵 슬롯들에 제대로 단축키들이 표시되는 것을 확인할 수 있고 로그창에도 어떤 단축키가 눌러졌는지 확인할 수 있다
진짜 이거 하는데 엄청 오래 걸렸다;; 블로그에는 쓸 수 없을 정도로 많은 수많은 시행착오 끝에 해냈다. 다음은 좀 더 빠르게 해보자,,