
로스트아크를 플레이하다보면 게임 내내 대부분 다음과 같은 User Widget을 봤을 것이다. 쉽게 말해 퀵 슬롯이라고 이름을 지었는데 왼쪽에는 스킬 창에서 스킬을 등록할 수 있는 스킬 퀵 슬롯 / 오른쪽에는 아이템 및 기타를 등록할 수 있는 아이템 퀵 슬롯이라고 생각하면 된다
가장 먼저 모든 스킬의 Base 클래스인 LKBaseSkill 클래스를 만든다.
UCLASS(Blueprintable)
class LOSTKINGDOM_API ULKBaseSkill : public UObject
{
GENERATED_BODY()
public:
virtual void Use(class ALKCharacterBase* Caster);
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
TObjectPtr<class ULKSkillData> Data;
};
현재 스킬을 그렇게 구체적으로 개발하지는 않을 것이라 단순히 스킬을 사용하는 Use 함수(현재는 빈 함수)와 스킬의 정보를 담고 있는 DataAsset 타입의 Data 변수를 뒀다(추후에 변경 가능)
스킬 하나 하나마다 블루프린트로 만들고 싶어서 UCLASS 매크로 안에 Blueprintable을 추가함
이제 콘텐츠 브라우저에서 블루프린트 클래스를 만들 때 LKBaseSkill을 부모로 한 클래스를 만들 수 있다

가장 먼저 만들 스킬은 이동기 스킬이다. 이동기는 이전에 플레이어 컨트롤러에서 Input Action을 받아 처리했는데 통일성을 위해 이동기도 스킬로 만들겠다고 말한 적이 있었다
블루프린트 클래스를 만들고 이름을 정한 뒤 더블 클릭 해 들어가보면 다음 창이 전부다

아까 만든 스킬의 정보 데이터 애셋을 등록해줘야 한다
UCLASS()
class LOSTKINGDOM_API ULKSkillData : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// 스킬의 이름
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Skill")
FName SkillName;
// 스킬의 아이콘 이미지
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Skill")
UTexture2D* SkillIcon;
// 스킬 사용 시 플레이할 애니메이션 몽타주
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Skill")
UAnimMontage* SkillMontage;
// 스킬을 찍는데 필요한 레벨
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Skill")
int32 RequiredLevel;
// 스킬의 쿨타임
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Skill")
float CooldownTime;
// 스킬 시전 중 취소할 수 있는지
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Skill")
bool bCanCancel;
};
정말 단순히 데이터를 관리할 수 있는 애셋이다. 부모 클래스로 UPrimaryDataAsset을 지정해줘서 C++ 클래스를 생성해야 한다
각 변수에 대해서는 주석이 있으니 넘어가도록(후에 추가할 데이터가 있을 수 있음)
이렇게 만든 C++ 클래스를 다시 애셋을 만들어야겠지?
콘텐츠 브라우저에서 우클릭 후 다음과 같은 경로를 통해 데이터 애셋을 만들 수 있다

C++ 클래스를 부모로 지정해준 다음 창을 열어보면

이대로 데이터를 채워주면 스킬 데이터가 완성되고 이 애셋을 아까 스킬 블루프린트 클래스에 등록해주면 된다. 이제 스킬의 정보를 고칠 때 블루프린트로 갈 필요 없이 데이터 애셋만 조작해서 값을 변경할 수 있다는 점
이제 스킬을 등록해줄 퀵 슬롯을 알아보자
저저번 포스트에 LKBaseQuickSlot에 대해 올리긴 했는데 좀 변경된 점들이 있어서 짚고 넘어가려 한다. 지난번에 다룬 건 제외하고 바뀌거나 추가된 것만
UCLASS()
class LOSTKINGDOM_API ULKBaseQuickSlot : public ULKUserWidget
{
GENERATED_BODY()
public:
ULKBaseQuickSlot(const FObjectInitializer& ObjectInitializer);
protected:
virtual void NativeConstruct() override;
protected:
FORCEINLINE virtual void OnKeyInput() { UseSlot(); }
virtual void UpdateSlot();
virtual bool UseSlot();
virtual void SetImage();
virtual void OnCooldownEnd();
void SetMappedKey();
// Input
protected:
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;
UPROPERTY(meta=(BindWidget))
TObjectPtr<class ULKRoundProgressbar> CoolDownProgressBar;
// Cool Down
protected:
FTimerHandle CooldownTimerHandle;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "CoolDown")
float CooldownTime;
uint8 bIsCoolDown : 1;
};
#include "UI/LKBaseQuickSlot.h"
#include "Components/TextBlock.h"
#include "InputMappingContext.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "Player/LKPlayerController.h"
#include "UI/LKRoundProgressbar.h"
ULKBaseQuickSlot::ULKBaseQuickSlot(const FObjectInitializer& ObjectInitializer)
{
bIsCoolDown = false;
CooldownTime = -1.0f;
}
void ULKBaseQuickSlot::UpdateSlot()
{
}
bool ULKBaseQuickSlot::UseSlot()
{
bIsCoolDown = true;
GetWorld()->GetTimerManager().SetTimer(CooldownTimerHandle, this, &ULKBaseQuickSlot::OnCooldownEnd, CooldownTime, false);
CoolDownProgressBar->SetVisibility(ESlateVisibility::Visible);
CoolDownProgressBar->SetCoolDown(CooldownTime);
return true;
}
void ULKBaseQuickSlot::SetImage()
{
}
void ULKBaseQuickSlot::OnCooldownEnd()
{
bIsCoolDown = false;
CoolDownProgressBar->SetVisibility(ESlateVisibility::Hidden);
}
간단히 설명해보겠다
LKUserWidget을 상속받게 바꾸었다UpdateSlot은 슬롯에 무언가 변경사항이 있으면 호출(이것도 자식 클래스에서 정의)UseSlot은 부모 클래스에서는 스킬의 쿨타임과 관련된 로직을 처리한다. SetImage는 스킬이나 아이템에 접근해야 이미지를 불러올 수 있기에 자식 클래스에서 처리이제 스킬을 등록할 수 있는 퀵 슬롯을 살펴보자
UCLASS()
class LOSTKINGDOM_API ULKSkillQuickSlot : public ULKBaseQuickSlot
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() 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;
protected:
virtual void SetImage() override;
virtual void UpdateSlot() override;
virtual bool UseSlot() override;
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
TSubclassOf<class ULKBaseSkill> SkillClass;
UPROPERTY()
TObjectPtr<class ULKBaseSkill> Skill;
};
#include "UI/LKSkillQuickSlot.h"
#include "Blueprint/DragDropOperation.h"
#include "Blueprint/WidgetBlueprintLibrary.h"
#include "Character/LKCharacterBase.h"
#include "Components/Image.h"
#include "Skill/LKSkillData.h"
#include "Skill/LKBaseSkill.h"
void ULKSkillQuickSlot::NativeConstruct()
{
Super::NativeConstruct();
if (SkillClass)
{
Skill = NewObject<ULKBaseSkill>(this, SkillClass);
}
UpdateSlot();
}
bool ULKSkillQuickSlot::NativeOnDrop(const FGeometry& InGeometry, const FDragDropEvent& InDragDropEvent, UDragDropOperation* InOperation)
{
if (InOperation && InOperation->DefaultDragVisual && !bIsCoolDown)
{
ULKSkillQuickSlot* QuickSlot = Cast<ULKSkillQuickSlot>(InOperation->Payload);
if (QuickSlot)
{
// Replace Skill between QuickSlots
ULKBaseSkill* TempSkill = Skill;
Skill = QuickSlot->Skill;
QuickSlot->Skill = TempSkill;
UpdateSlot();
QuickSlot->UpdateSlot();
}
return true;
}
return Super::NativeOnDrop(InGeometry, InDragDropEvent, InOperation);
}
FReply ULKSkillQuickSlot::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
if (Skill && !bIsCoolDown)
{
if (InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton))
{
return UWidgetBlueprintLibrary::DetectDragIfPressed(InMouseEvent, this, EKeys::LeftMouseButton).NativeReply;
}
}
return FReply::Unhandled();
}
void ULKSkillQuickSlot::NativeOnDragDetected(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent, UDragDropOperation*& OutOperation)
{
if (Image)
{
UDragDropOperation* DragOperation = NewObject<UDragDropOperation>();
DragOperation->DefaultDragVisual = Image;
DragOperation->Payload = this;
OutOperation = DragOperation;
}
}
void ULKSkillQuickSlot::SetImage()
{
if (Skill)
{
Image->SetVisibility(ESlateVisibility::Visible);
Image->SetBrushFromTexture(Skill->Data->SkillIcon);
}
else
{
Image->SetVisibility(ESlateVisibility::Hidden);
}
}
void ULKSkillQuickSlot::UpdateSlot()
{
if (Skill)
{
CooldownTime = Skill->Data->CooldownTime;
}
else
{
CooldownTime = -1.0f;
}
SetImage();
}
bool ULKSkillQuickSlot::UseSlot()
{
if (Skill && !bIsCoolDown)
{
ALKCharacterBase* Caster = Cast<ALKCharacterBase>(Owner);
if (Caster && Caster->UseSkill(Skill))
{
return Super::UseSlot();
}
}
return false;
}
처음부터 살펴보면 먼저 NativeConstruct에서 초기화될 때 SkillClass가 null이 아닐 때 스킬을 생성하는 걸로 보이는데 이건 그냥 디버그 용이다. 원래는 스킬 창에 있는 스킬을 등록하는 것임. 그래서 초기화될 때 UpdateSlot을 한 번 해준다
NativeOnDrop / NativeOnMouseButtonDown / NativeOnDragDetected는 모두 마우스 입력과 관련된 함수이다.
NativeOnDropInOperation->Payload이며 이는 UObject를 반환한다. 그래서 나는 캐스팅을 해주었음NativeOnMouseButtonDownUWidgetBlueprintLibrary::DetectDragIfPressed(InMouseEvent, this, EKeys::LeftMouseButton).NativeReply;는 마우스 드래그 동작을 감지해주는 함수이다NativeOnDragDetectedDragOperation->DefaultDragVisual = Image; 부분은 드래그할 때 현재 슬롯의 Image가 마우스를 따라다니게 만듦DragOperation->Payload = this;는 드래그 한 객체에 자신을 대입하는 코드임. 수동으로 설정해줘야 한다고 함그 외에 SetImage 함수는 스킬의 아이콘 이미지를 슬롯에 반영하는 함수고 UpdateSlot는 스킬의 쿨타임을 설정한다
UseSlot은 쿨타임이 아닐 때 호출 가능하며 Owner를 캐릭터로 캐스팅해 캐릭터의 UseSkill 함수를 호출한다
이제 캐릭터 클래스에 스킬 사용 관련 변수, 함수를 추가해보자
// Skill Section
public:
virtual bool UseSkill(class ULKBaseSkill* Skill);
virtual void OnSkillStart(class UAnimMontage* TargetMontage);
virtual void OnSkillEnd(class UAnimMontage* TargetMontage, bool IsProperlyEnded);
protected:
uint8 bUseSkill : 1; // Check if the skill is being used
void ALKCharacterBase::ProcessCombo()
{
if (bUseSkill) return;
if (CurrentCombo == 0)
{
ComboAttackBegin();
return;
}
HasNextComboInput = true;
}
bool ALKCharacterBase::UseSkill(ULKBaseSkill* Skill)
{
if (Skill)
{
// If you are using the skill, you will return false, but if the skill can be canceled, proceed as it is
if (bUseSkill && Skill->Data->bCanCancel == false)
{
return false;
}
Skill->Use(this);
LookAt();
UAnimMontage* Montage = Skill->Data->SkillMontage;
OnSkillStart(Montage);
}
return true;
}
void ALKCharacterBase::OnSkillStart(UAnimMontage* TargetMontage)
{
GetController()->StopMovement();
AnimInstance->Montage_Play(TargetMontage);
bUseSkill = true;
FOnMontageEnded EndDelegate;
EndDelegate.BindUObject(this, &ALKCharacterBase::OnSkillEnd);
AnimInstance->Montage_SetEndDelegate(EndDelegate, TargetMontage);
}
void ALKCharacterBase::OnSkillEnd(UAnimMontage* TargetMontage, bool IsProperlyEnded)
{
bUseSkill = false;
}
UseSkillOnSkillStart 함수를 호출OnSkillStartStopMovement를 호출해 움직임을 멈추고(ex. 길을 가다가 멈춤) bUseSkill를 true로 설정 후 스킬 애니메이션 몽타주가 끝날 때 OnSkillEnd가 호출되게 해서 bUseSkill를 false로 바꾸게 만든다bUseSkill를 이용해 기본 공격이나 스킬 사용 시 애니메이션이 실행 중이더라도 입력을 못받게 할 수 있다
이제 아까 넘어갔던 원형 프로그레스바를 보자
흔히 스킬을 사용하고 나면 남은 쿨타임 동안 시계방향으로 현재 쿨타임을 알리는 것들이 있는데 이를 따라 구현해봤다
이 유저 위젯은 누가 소유하고 있는 지 중요하지 않기 때문에 그냥 UserWidget을 상속받아 만들었음
UCLASS()
class LOSTKINGDOM_API ULKRoundProgressbar : public UUserWidget
{
GENERATED_BODY()
public:
void SetCoolDown(float InCoolDownTime);
protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UImage> CoolDownImage;
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UTextBlock> CoolDownText;
virtual void NativeConstruct() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
float MaxCoolDownTime;
float CurrentCoolDownTime;
};
#include "UI/LKRoundProgressbar.h"
#include "Components/Image.h"
#include "Components/TextBlock.h"
void ULKRoundProgressbar::SetCoolDown(float InCoolDownTime)
{
MaxCoolDownTime = InCoolDownTime;
CurrentCoolDownTime = InCoolDownTime;
if (CoolDownImage)
{
UMaterialInstanceDynamic* DynamicMaterial = CoolDownImage->GetDynamicMaterial();
if (DynamicMaterial)
{
DynamicMaterial->SetScalarParameterValue(FName("Percent"), 1.0f);
}
}
}
void ULKRoundProgressbar::NativeConstruct()
{
Super::NativeConstruct();
SetVisibility(ESlateVisibility::Hidden);
}
void ULKRoundProgressbar::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
if (CurrentCoolDownTime > 0.0f)
{
CurrentCoolDownTime -= InDeltaTime;
float Progress = FMath::Clamp(CurrentCoolDownTime / MaxCoolDownTime, 0.0f, 1.0f);
if (CoolDownImage)
{
UMaterialInstanceDynamic* DynamicMaterial = CoolDownImage->GetDynamicMaterial();
if (DynamicMaterial)
{
DynamicMaterial->SetScalarParameterValue(FName("Percent"), CurrentCoolDownTime / MaxCoolDownTime);
}
}
// 남은 쿨타임을 초 단위로 계산하여 텍스트로 표시
int32 RemainingTime = FMath::CeilToInt(CurrentCoolDownTime);
FString CooldownTextString = FString::Printf(TEXT("%ds"), RemainingTime);
if (CoolDownText)
{
CoolDownText->SetText(FText::FromString(CooldownTextString));
}
}
}
헤더 파일에는 별로 어려울 게 없다. 그냥 프로그레스바로 쓸 이미지랑 몇 초 남았는지를 보여주는 텍스트와 쿨타임의 정보만 있으면 된다
NativeConstruct에서는 초기화할 때 프로그레스바 이미지는 안보여야 되기 때문에 Visibility를 Hidden으로 설정했다. 이제 외부에서 스킬을 사용할 때 SetCoolDown 함수를 호출하면 쿨타임 시간이 정해지고 이미지의 머티리얼의 Percent 값을 처음에는 1로 조정해 시계 방향으로 감소시킬 것이다
UserWidget의 경우 Visibility가 Hidden으로 되어 있을 경우 Tick 함수가 호출되지 않는다
외부에서 프로그레스바의 Visibility를 보이게 설정했다면 Tick 함수가 호출되면서 프로그레스바와 텍스트가 알아서 조정된다
시계 방향으로 라운드 프로그레스바를 만들 때 좀 어려움이 있었는데 다음의 유튜브를 찾아서 조금의 변형을 거쳐 완성했다
유튜브
여기부분은 솔직히 설명할 자신이 없어서 이미지로 대체하겠다 ㅠㅠ

이렇게 머티리얼 그래프를 만든 다음에 머티리얼 인스턴스를 만든다

그리고 인스턴스로 들어가 Percent 값을 조정한 다음 이 머티리얼을 라운드 프로그레스바의 이미지에 넣으면 끝
C++로 스킬 퀵 슬롯의 자식 클래스로 이동기를 하나 더 만들었다. 왜냐면 로스트아크에서 이동기 스킬은 스킬을 사용하기 전에는 안 보이다가 스킬을 사용할 때 쿨타임동안 보이고 다시 사라지기 때문임
그냥 SetVisibility만 사용해서 간단하게 처리해서 따로 올리진 않음
여기까지의 결과를 한 번 살펴보자. 테스트를 위해 빈 스킬 하나를 더 만들었다

처음에 스킬을 가진 채로 시작하게 해서 현재 Q 슬롯에 스킬이 등록되어 있음. 이를 W로 옮겨보자


이 외에도 스킬이 쿨다운 중이면 사용할 수 없고 드래그 앤 드랍도 불가능하다. 여기까지 퀵 슬롯을 구현해봤고 다음에는 뭘 할지 한 번 생각해보겠음
재훈님 정말 열심히 살고 계시네요
자극받고 갑니다