요즘 언리얼을 할 시간이 많아서 너무 행복하다. 이게 1인 개발의 맛이지
오늘은 경험치를 구현해보려고 한다
여기서 잊지 말아야 할 것은 예전에 캐릭터 스탯을 구현할 때 플레이어 캐릭터의 스탯과 적 캐릭터의 스탯에 거의 모든 것은 공통적으로 사용했지만(HP, 공격력) 경험치를 나타내는 Exp 스탯은 각각 다른 의미를 가진다
플레이어 캐릭터의 경우 Exp는 다음 레벨까지의 필요 경험치량이고 적 캐릭터의 경우 죽었을 때 플레이어가 얻을 수 있는 경험치량이다
시작해보자
저번 포스트에서 메인 HUD를 만들 때 아예 경험치 위젯을 메인 HUD에 포함했는데 클래스를 만들어서 아예 따로 관리하겠다
C++ 클래스로 경험치 위젯 클래스를 생성한다. 이 때 LKUserWidget를 상속받아 이 위젯이 플레이어 캐릭터와 느슨하게 결합되게 할 것임
UCLASS()
class LOSTKINGDOM_API ULKExpWidget : public ULKUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
public:
void UpdateExpGuage(int32 InCurrentExp, int32 InMaxExp); // 경험치바 게이지 업데이트
void UpdateExpLevel(int32 InLevel); // 현재 레벨 텍스트 업데이트
protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UProgressBar> ExpGuage;
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UTextBlock> ExpLevel;
protected:
UPROPERTY(VisibleAnywhere, Category = "Exp")
int32 CurrentExp;
UPROPERTY(VisibleAnywhere, Category = "Exp")
int32 MaxExp;
따로 설명이 필요할 것 같진 않으니 바로 구현으로
void ULKExpWidget::NativeConstruct()
{
Super::NativeConstruct();
ILKCharacterWidgetInterface* CharacterWidget = Cast<ILKCharacterWidgetInterface>(Owner);
if (CharacterWidget)
{
CharacterWidget->SetupCharacterWidget(this);
}
}
void ULKExpWidget::UpdateExpGuage(int32 InCurrentExp, int32 InMaxExp)
{
CurrentExp = InCurrentExp;
MaxExp = InMaxExp;
if (ExpGuage)
{
ExpGuage->SetPercent((float)InCurrentExp / (float)InMaxExp);
}
}
void ULKExpWidget::UpdateExpLevel(int32 InLevel)
{
if (ExpLevel)
{
ExpLevel->SetText(FText::FromString(FString::FromInt(InLevel)));
}
}
프로그레스 바, 텍스트 위젯을 업데이트 하는 부분은 그냥 매개변수 받은 거 업데이트 하는 게 다니까 설명 필요 없고
NativeConstruct를 보면 예전에 캐릭터의 체력바 부분을 구현하는 것과 똑같다
타입을 구체화하지 않게 인터페이스를 이용했다. 뒤에 설명이 들어갈 것이기 때문에 여기선 그냥 이 경험치 위젯이 초기화될 때 이 위젯의 Owner를 인터페이스로 캐스팅해 SetupCharacterWidget 함수에 자기 자신을 넘겨준다라고 생각하자
그런데 위젯의 Owner를 설정한 적이 없지 않나? 맞다. 그래서 Owner를 설정하러 간다
낯 익은 이름이다. 바로 저번 포스트에서 만든 메인 HUD임
경험치 위젯을 따로 분리했기 때문에 수정이 필요하다. 이전에 선언한 경험치 관련 위젯을 지우고 헤더 파일에 다음과 같이 바꿔준다
UPROPERTY(meta = (BindWidget))
TObjectPtr<class ULKExpWidget> ExpWidget;
MainHUD가 멤버 변수로 경험치 위젯을 들고 있기 때문에 MainHUD에서 경험치 위젯의 Owner를 세팅해줄 수 있다. 그래서 어디에서 세팅하냐? 기존에 위젯 관련 초기화를 해주던 NativeConstruct일까?
ALKPlayerCharacter* PlayerCharacter = Cast<ALKPlayerCharacter>(GetOwningPlayerPawn());
if (PlayerCharacter)
{
ExpWidget->SetOwner(PlayerCharacter);
}
만약 NativeConstruct에서 Owner를 세팅해주면 무슨 문제가 생길까?
경험치 위젯에서도 NativeConstruct에서 Owner를 이용해 캐스팅하는데 뭐가 먼저 호출될 지를 모르는 상황이다. 예를 들어 경험치 위젯의 NativeConstruct가 먼저 호출되면 Owner는 설정이 안돼서 SetupCharacterWidget를 호출할 일이 없을 것이다
이를 방지하기 위해 다른 함수가 있었는데 기억이 났다. 바로 NativeOnInitialized 함수이다
가볍게 정리하자면 NativeOnInitialized는 위젯이 CreateWidget을 통해 생성될 때 호출되고 NativeConstruct는 AddToViewPort로 화면에 렌더링될 때 호출된다
그러니까 NativeOnInitialized에서 먼저 초기값을 세팅해주는 것이 좋다
void ULKMainHUD::NativeOnInitialized()
{
Super::NativeOnInitialized();
ALKPlayerCharacter* PlayerCharacter = Cast<ALKPlayerCharacter>(GetOwningPlayerPawn());
if (PlayerCharacter)
{
ExpWidget->SetOwner(PlayerCharacter);
}
}
성공적으로 경험치 위젯의 Owner가 세팅되어 SetupCharacterWidget도 잘 호출될 것이다
Owner가 ALKPlayerCharacter 클래스니까 SetupCharacterWidget를 새로 오버라이드 해줘야 한다. 원래는 ALKCharacterBase에서 오버라이드했는데 경험치 위젯의 경우는 플레이어 캐릭터만 필요하기 때문에 플레이어 캐릭터에서 새로 오버라이드 하고 싶음
void ALKPlayerCharacter::SetupCharacterWidget(ULKUserWidget* InUserWidget)
{
Super::SetupCharacterWidget(InUserWidget);
ULKExpWidget* ExpWidget = Cast<ULKExpWidget>(InUserWidget);
if (ExpWidget)
{
ExpWidget->UpdateExpGuage(Stat->GetCurrentExp(), Stat->GetBaseStat().Exp);
ExpWidget->UpdateExpLevel(Stat->GetCurrentLevel());
Stat->OnExpChanged.AddUObject(ExpWidget, &ULKExpWidget::UpdateExpGuage);
Stat->OnLevelUp.AddUObject(ExpWidget, &ULKExpWidget::UpdateExpLevel);
}
}
매개변수로 들어온 위젯이 경험치 위젯인 경우 경험치 위젯을 한번 초기화해주고 델리게이트를 등록해준다
이제 스탯을 보자
먼저 헤더 파일의 변경 사항을 보면
...
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnExpChangeDelegate, int32 /*CurrentExp*/ , int32 /*MaxExp*/);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnLevelUpDelegate, int32 /*NewLevel*/);
...
public:
FOnExpChangeDelegate OnExpChanged;
FOnLevelUpDelegate OnLevelUp;
FORCEINLINE int32 GetCurrentExp() const { return CurrentExp; }
void AddExp(int32 InExp);
...
protected:
UPROPERTY(VisibleInstanceOnly, Category = Stat)
int32 CurrentExp;
경험치가 변경될 때 이벤트를 뿌릴 델리게이트와 레벨업했을 때 이벤트를 뿌릴 델리게이트를 선언해주고 멤버 변수로 둔다
그리고 캐릭터가 현재 가지고 있는 경험치를 나타내는 변수와 Getter 함수, 그리고 경험치를 얻을 때 쓸 함수를 선언해준다
void ULKCharacterStatComponent::AddExp(int32 InExp)
{
CurrentExp += InExp;
if (CurrentExp >= BaseStat.Exp)
{
CurrentExp = FMath::Clamp(CurrentExp - BaseStat.Exp, 0, BaseStat.Exp);
SetLevelStat(CurrentLevel + 1);
SetHP(BaseStat.MaxHP);
OnLevelUp.Broadcast(CurrentLevel);
}
OnExpChanged.Broadcast(CurrentExp, BaseStat.Exp);
}
경험치를 획득하면 현재 경험치에 추가 경험치를 더해주고 만약 현재 경험치가 BaseStat의 경험치(즉, 현재 레벨에서 최대 경험치)보다 같거나 많아지면 레벨업을 한 의미니까 현재 경험치를 재조정해주고 레벨 별 스탯 변경, 체력 최대 회복과 델리게이트를 호출한다
여기까지 하면 이제 시작하면 캐릭터의 경험치 위젯이 초기화 잘 된다. 근데 뭐 하나 빼먹었냐면 중요한 것, 바로 경험치를 획득하는 부분을 안했다
경험치 획득은 상식적으로 적 캐릭터가 죽었을 때나 어떤 이벤트인데 아직 이벤트는 안했으니 적 캐릭터가 죽었을 때만 하자
캐릭터가 현재 죽었는 지를 나타내는 변수를 하나 선언한다
uint8 bIsDead : 1;
그리고 구현에서 캐릭터가 죽었을 때 SetDead 함수를 호출했었다
void ALKCharacterBase::SetDead()
{
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
PlayDeadAnimation();
SetActorEnableCollision(false);
HUD->SetHiddenInGame(true);
bIsDead = true;
}
이제 캐릭터가 공격을 받아 죽으면 bIsDead 값을 통해 죽음을 확인할 수 있다
이제 경험치를 획득하는 부분을 찾아보자. 본인은 다음과 같은 함수를 선택했다
void ALKCharacterBase::OnAttack(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
// The more you attack in a row, the more damage you get
const float AttackDamage = Stat->GetAttack() * CurrentCombo;
UE_LOG(LogTemp, Warning, TEXT("Attack Damage: %f"), AttackDamage);
if (OtherActor)
{
ALKCharacterBase* Enemy = Cast<ALKCharacterBase>(OtherActor);
if (Enemy)
{
FDamageEvent DamageEvent;
Enemy->TakeDamage(AttackDamage, DamageEvent, GetController(), this);
// 적이 죽었을 때 경험치 획득
if (Enemy->bIsDead)
{
Stat->AddExp(Enemy->Stat->GetBaseStat().Exp);
}
}
}
}
적을 공격할 때 오버랩 이벤트인데 여기선 공격을 받은 적을 얻어오고 있으니 공격을 했을 때 그 캐릭터가 죽었는 지를 확인해 경험치를 주면 될 것 같다고 생각했다
여기서 한 가지 새로 배운 점이 있는데 bIsDead 변수에 대해서다
분명히 bIsDead 변수는 내가 protected 변수로 선언했는데 어떻게 Enemy->bIsDead가 가능한거지??
이에 대해 찾아봤더니 다음과 같은 답변이 나왔다
protected 변수는 동일한 클래스나 해당 클래스를 상속 받은 클래스에서만 접근할 수 있다(이건 안다)
여기서 중요한 점은 상속받은 클래스 내에서 해당 변수가 자기 자신뿐만 아니라, 같은 타입의 다른 객체에 대해서도 접근이 가능하다고 한다(이건 몰랐다)
즉, ALKCharacterBase 클래스가 bIsDead 변수를 protected로 선언했다면, 같은 클래스의 다른 인스턴스에서도 이 변수에 접근할 수 있다
private으로 선언했을 경우에는 위와 같이 안되니까 참고
경험치바에 마우스를 올리면 현재 경험치를 확인할 수 있는 팝업을 띄워보자. 일단 급하게 만든 거라 나중에 변경 가능성 있음
먼저 팝업용 위젯 블루프린트와 C++ 클래스를 만들자

class LOSTKINGDOM_API ULKExpPopup : public UUserWidget
{
GENERATED_BODY()
public:
void SetExpText(int32 InCurrentExp, int32 InMaxExp);
protected:
UPROPERTY(meta = (BindWidget))
TObjectPtr<class UTextBlock> ExpText;
};
void ULKExpPopup::SetExpText(int32 InCurrentExp, int32 InMaxExp)
{
if (ExpText)
{
ExpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), InCurrentExp, InMaxExp)));
}
}
팝업의 위치를 뷰포트 내에서 보여주게 할려고 MainHUD 안에 둘 것이다

코드로는 따로 필요 없고 그냥 MainHUD 위젯 블루프린트 안에 배치만 해놓는다
// 마우스 들어옴을 감지
virtual void NativeOnMouseEnter(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override;
// 마우스 나감 감지
virtual void NativeOnMouseLeave(const FPointerEvent& InMouseEvent) override;
UPROPERTY(EditAnywhere, BlueprintReadOnly, category = Popup)
TObjectPtr<class ULKExpPopup> ExpPopup;
헤더에 다음과 같이 선언하고 MainHUD 위젯 블루프린트로 가서 ExpWidget 클릭 후 ExpPopup에 할당해준다

구현으로 돌아가면
void ULKExpWidget::NativeConstruct()
{
Super::NativeConstruct();
ILKCharacterWidgetInterface* CharacterWidget = Cast<ILKCharacterWidgetInterface>(Owner);
if (CharacterWidget)
{
CharacterWidget->SetupCharacterWidget(this);
}
if (ExpPopup)
{
ExpPopup->SetVisibility(ESlateVisibility::Hidden);
}
}
void ULKExpWidget::NativeOnMouseEnter(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
Super::NativeOnMouseEnter(InGeometry, InMouseEvent);
if (ExpPopup)
{
ExpPopup->SetExpText(CurrentExp, MaxExp);
ExpPopup->SetVisibility(ESlateVisibility::Visible);
FVector2D MousePosition = UWidgetLayoutLibrary::GetMousePositionOnViewport(GetWorld());
FVector2D FinalPosition = { MousePosition.X, MousePosition.Y - 5.0f };
UCanvasPanelSlot* CanvasSlot = Cast<UCanvasPanelSlot>(ExpPopup->Slot);
if (CanvasSlot)
{
CanvasSlot->SetPosition(FinalPosition);
}
}
}
void ULKExpWidget::NativeOnMouseLeave(const FPointerEvent& InMouseEvent)
{
Super::NativeOnMouseLeave(InMouseEvent);
if (ExpPopup)
{
ExpPopup->SetVisibility(ESlateVisibility::Hidden);
}
}
쉽게 말해 생성자에서는 처음에 팝업을 꺼주고 마우스가 들어오면 팝업을 켜주고 텍스트를 설정해준다. 마우스가 나갈 때에는 팝업을 끈다
마우스가 들어올 때 주의해야 할 점이 있는데 마우스 포지션을 구할 때 스크린 위치의 마우스 좌표와 뷰포트 위치의 마우스 좌표가 다르다. 그래서 마우스 위치를 뷰포트 위치로 변환해주는 UWidgetLayoutLibrary::GetMousePositionOnViewport 함수를 사용했고 FinalPosition의 Y 위치에 -5를 해줬다
왜냐면 MousePosition이 테스트해보니까 뷰포트 좌상단을 기준으로 알려주기 때문이다. 1920*1080일 때 마우스가 우하단에 있으면 (1920, 1080)이 뜬다. 그러니까 팝업을 위로 조금 올리기 위해 값을 빼준거다
그리고 메인 HUD로 돌아가서 경험치 팝업의 앵커를 좌상단으로 설정하고 피봇도 적당히 조절했다

여기까지 하고 결과를 확인해보자



잘 된다 마무리!