이번 개발일지는 캐릭터마다 HP, 공격력 등을 나타낼 스탯과 이 스탯을 기반으로 유저에게 보여지는 인터페이스를 구현해보는 작업을 할 것이다
캐릭터 스탯을 그냥 LKCharacterBase에 여러 변수를 선언해서 초기화하고 관리하고 이런 식의 방법도 있겠지만? 이렇게 하면 스파게티 코드가 만들어진다는 것을 우리 모두 잘 알 것이다
유니티 엔진에서는 캐릭터 클래스가 있을때 스탯을 따로 클래스를 만들어서 캐릭터 오브젝트에 컴포넌트로 등록해 관리할 수 있었는데 유니티도 되는데 당연히 언리얼도 되겠지?
액터에 추가할 수 있는 컴포넌트들은 보통 트랜스폼을 가지는데 액터 트랜스폼은 다음과 같은 특징을 가진다
예를 들면 캐릭터 스탯 컴포넌트를 만들어 놓으면 새로운 유형의 캐릭터가 나타나도 간단하게 붙일 수 있고 이 컴포넌트를 또 상속해서 기능을 확장할 수 있다
외부에서 이벤트가 발생해 캐릭터의 스탯이 변경될 때 스탯의 변경이 UI로 업데이트 되어야 할텐데 서로 클래스 정보를 알고 있는 건 의존성이 강해지기 때문에 발행-구독 모델을 이용해 델리게이트를 이용해 데이터를 주고 받도록 하겠다
저번에 작성했던 옵저버 패턴과 비슷한 개념이다. 어떤 객체의 델리게이트에 구독해 알림을 받아 상태를 바꾸는 식이다
캐릭터 스탯 컴포넌트를 만들어보자

LKCharacterStatComponent라는 ActorComponent의 자식 클래스를 만들어준다.
ActorComponent는 처음에 TickComponent 함수를 정의하고 있는데 다른 액터의 Tick 함수처럼 매 프레임마다 호출되는데 액터는 액터 전체 로직, 컴포넌트는 컴포넌트 전체 로직에서 동작한다
뭐든 Tick은 게임에 해롭기 때문에 삭제해준다.
전체 코드를 다 적고 설명하자
// ULKCharacterStatComponent.h
DECLARE_MULTICAST_DELEGATE(FOnHPZeroDelegate);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnHPChangedDelegate, float /*CurrentHp*/);
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class LOSTKINGDOM_API ULKCharacterStatComponent : public UActorComponent
{
GENERATED_BODY()
public:
ULKCharacterStatComponent();
protected:
virtual void InitializeComponent() override;
public:
FOnHPZeroDelegate OnHPZero;
FOnHPChangedDelegate OnHPChanged;
FORCEINLINE float GetMaxHp() { return MaxHP; }
FORCEINLINE float GetCurrentHp() { return CurrentHP; }
FORCEINLINE FText GetCharacterName() { return CharacterName; }
float ApplyDamage(float InDamage);
protected:
void SetHP(float NewHP);
UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat)
float CurrentHP;
UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat)
float MaxHP;
UPROPERTY(Transient, EditAnywhere, Category = Info)
FText CharacterName;
};
맨 위 두 줄은 델리게이트 타입을 선언하는 매크로 문이다. 피가 0일때 델리게이트를 구독한 객체들에게 알림을 보내는(1:다) 델리게이트와 HP가 변경될 때마다 구독한 객체들에게 현재 체력을 넘겨준다
UCLASS 문
CurrentHP, MaxHP 변수
Transient : 이 키워드를 사용하면 게임을 저장할 때 변수가 저장되지 않으며, 로드 시에도 초기화된다. 런타임에만 필요한 임시 데이터를 저장할 때 사용함 -> HP는 매번 게임을 시작할 때 초기화되어야 함(근데 플레이어 캐릭터의 현재 HP는 저장이 되어야 하는데 이건 뭐 게임을 저장할 때 현재 HP도 저장해서 나중에 초기화할 때 저장한 값으로 초기화하면 될 것 같다)
VisibleInstanceOnly : 변수가 어떻게 보일지 설정하는 키워드인데, 영단어 뜻 그대로 인스턴스에서만 변수를 볼 수 있음을 나타낸다. 원래 VisibleOnly하면 블루프린트 클래스 자체에서 변수의 값을 볼 수 있는데 이 키워드를 지정하면 월드에 등록되어 있는 인스턴스만 변수 값 확인이 가능하다
CharacterName : 캐릭터의 이름은 블루프린트 클래스마다 다르고 인스턴스는 다 동일해야 한다. 그래서 블루프린트에서 편집할 수 있게 키워드를 지정해준다
ULKCharacterStatComponent::ULKCharacterStatComponent()
{
MaxHP = 200.0f;
CurrentHP = MaxHP;
bWantsInitializeComponent = true;
}
void ULKCharacterStatComponent::InitializeComponent()
{
Super::InitializeComponent();
SetHP(MaxHP);
}
float ULKCharacterStatComponent::ApplyDamage(float InDamage)
{
const float PrevHP = CurrentHP;
const float ActualDamage = FMath::Clamp<float>(InDamage, 0, InDamage);
SetHP(PrevHP - ActualDamage);
if (CurrentHP <= KINDA_SMALL_NUMBER)
{
OnHPZero.Broadcast();
}
return ActualDamage;
}
void ULKCharacterStatComponent::SetHP(float NewHP)
{
CurrentHP = FMath::Clamp<float>(NewHP, 0, MaxHP);
OnHPChanged.Broadcast(CurrentHP);
}
bWantsInitializeComponent를 true로 바꾼다InitializeComponent 함수가 호출 가능하게 만들어주는 변수이다InitializeComponentApplyDamage는 캐릭터가 공격을 받으면 호출되며 현재 캐릭터의 현재 체력을 감소시키고 체력이 진짜 작은 수(0에 근접)보다 작으면 체력이 0 이하로 내려갔다는 알림을 구독자들에게 보낸다
SetHP는 캐릭터 스탯의 현재 HP를 세팅하고 HP가 변경됐다는 알림을 보낸다
이제 캐릭터 클래스가 소유할 스탯 컴포넌트를 만들었고 먼저 사용할 UI를 만들어보자
캐릭터의 상단바에 위치할 이름과 체력바를 표시할 HUD를 먼저 만들자
에디터에서 위젯 블루프린트 클래스를 만들 것임


UserWidget 선택 후 생성
로스트아크의 캐릭터 HUD를 보면 다음과 같다

그래서 위젯 블루프린트도 비슷하게 만들어봤다(Vertical Box에 Text와 ProgressBar)

참고로 여기서 보이는 크기는 별로 상관없는게 게임 캐릭터에 붙일때 크기를 조정할 수 있음
C++ 코드상에서 컨트롤을 하고 싶은 요소들(텍스트, 프로그레스바)은 요소들을 변수로 쓸건지 체크를 해줘야 코드 상에서 받아올 수 있다

Is Variable을 체크하고 왼쪽에 변수명을 적은 뒤 코드에서 저 변수명을 가진 요소를 불러올 수 있음(이따 보여줄 예정)
그리고 해당 위젯 블루프린트 클래스가 C++ 클래스를 상속받게 만들 수 있는데 큰 화면에서 오른쪽 위에 Designer / Graph로 되어 있을텐데 Graph로 바꾸고 클래스 세팅 -> Parent Class를 바꿀 수 있음

이제 이 위젯 자체를 나타낼 C++ 클래스를 만들자.
C++로 UserWidget을 부모로 하는 자식 클래스를 생성할건데 잘 모를때는 바로 HUD를 관리하는 클래스를 UserWidget의 자식으로 만들텐데 강의에서 배우고 나서는 이 위젯이 연결된 Actor와 상호 작용하려면 Owner를 보유하게 해서 접근하기 쉽게 만들 것이다
UserWidget을 상속받는 LKUserWidget 클래스를 만들고 코드를 작성한다
UCLASS()
class LOSTKINGDOM_API ULKUserWidget : public UUserWidget
{
GENERATED_BODY()
public:
FORCEINLINE void SetOwner(AActor* NewOwner) { Owner = NewOwner; }
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Actor")
TObjectPtr<AActor> Owner;
};
이 위젯의 주인인 Owner, Owner를 설정하는 SetOwner 함수가 있다. 나중에 SetOwner를 하는 부분을 보자
그다음 HUD 클래스를 만든다
// h
UCLASS()
class LOSTKINGDOM_API ULKHUDWidget : public ULKUserWidget
{
GENERATED_BODY()
public:
ULKHUDWidget(const FObjectInitializer& ObjectInitializer);
protected:
virtual void NativeConstruct() override;
public:
FORCEINLINE void SetMaxHP(float InMaxHP) { MaxHP = InMaxHP; }
void UpdateHPBar(float InCurrentHP);
void SetCharacterName(const FText& InCharacterName);
protected:
UPROPERTY()
TObjectPtr<class UProgressBar> HPProgressBar;
UPROPERTY()
TObjectPtr<class UTextBlock> CharacterName;
UPROPERTY()
float MaxHP;
};
// cpp
ULKHUDWidget::ULKHUDWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
MaxHP = -1.0f;
}
void ULKHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
HPProgressBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("PBHPBar")));
ensure(HPProgressBar);
CharacterName = Cast<UTextBlock>(GetWidgetFromName(TEXT("TXTName")));
ensure(CharacterName);
ILKCharacterWidgetInterface* CharacterWidget = Cast<ILKCharacterWidgetInterface>(Owner);
if (CharacterWidget)
{
CharacterWidget->SetupCharacterWidget(this);
}
}
void ULKHUDWidget::UpdateHPBar(float InCurrentHP)
{
ensure(MaxHP > 0.0f);
if (HPProgressBar)
{
HPProgressBar->SetPercent(InCurrentHP / MaxHP);
}
}
void ULKHUDWidget::SetCharacterName(const FText& InCharacterName)
{
if (CharacterName)
{
CharacterName->SetText(InCharacterName);
}
}
특이하게 생성자가 인수를 가진다
ProgressBar등의 다양한 컴포넌트 등을 사용하려면 모듈을 추가해줘야 함
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "NavigationSystem", "AIModule", "Niagara", "EnhancedInput", "UMG" });```아직 HUD 클래스가 프로그레스바와 텍스트가 할당이 안되었기 때문에 이걸 초기화해주는 부분이 필요한데 바로 NativeConstruct 함수이다
virtual void NativeOnInitialized();
virtual void NativePreConstruct();
virtual void NativeConstruct();
virtual void NativeDestruct();
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime);
그리고 HP 프로그레스 바를 업데이트하는 함수와 이름을 업데이트하는 함수를 작성
위젯을 만들긴 했는데 이것을 바로 캐릭터에 붙일수 있냐? NO
우리가 액터에 뭔가 붙이고 싶으면 컴포넌트로 붙인다. 이를 위해 위젯 컴포넌트가 존재하고 위젯 컴포넌트라는 껍데기를 만들고 초기화할 때 우리가 만든 위젯 클래스를 넣어주는 것이다
WidgetComponent를 부모로 하는 C++ 클래스를 만들어주자
// h
UCLASS()
class LOSTKINGDOM_API ULKWidgetComponent : public UWidgetComponent
{
GENERATED_BODY()
protected:
virtual void InitWidget() override;
};
// cpp
void ULKWidgetComponent::InitWidget()
{
Super::InitWidget();
ULKUserWidget* UserWidget = Cast<ULKUserWidget>(GetWidget());
if (UserWidget)
{
UserWidget->SetOwner(GetOwner());
}
}
일단은 인터페이스가 있기 때문에 이것도 뒤로 미뤄서 설명하겠다
한번에 요약하겠음!
캐릭터의 모든 부모 클래스인 LKCharacterBase 클래스로 가자
// h
...
// Stat Section
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Stat, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class ULKCharacterStatComponent> Stat;
// UI Widget Section
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Widget, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class ULKWidgetComponent> HUD;
virtual void SetupCharacterWidget(class ULKUserWidget* InUserWidget) override;
// cpp
...
ALKCharacterBase::ALKCharacterBase()
{
...
// Stat
Stat = CreateDefaultSubobject<ULKCharacterStatComponent>(TEXT("Stat"));
// Widget
HUD = CreateDefaultSubobject<ULKWidgetComponent>(TEXT("HUD"));
HUD->SetupAttachment(GetMesh(), TEXT("HealthBar"));
HUD->SetRelativeLocation(FVector(0.0f, 0.0f, 80.0f));
static ConstructorHelpers::FClassFinder<UUserWidget> HUDWidgetRef(TEXT("/Game/LostKingdom/UI/WBP_HUD.WBP_HUD_C"));
if (HUDWidgetRef.Class)
{
HUD->SetWidgetClass(HUDWidgetRef.Class);
HUD->SetWidgetSpace(EWidgetSpace::Screen);
HUD->SetDrawSize(FVector2D(150.0f, 50.0f));
HUD->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
}
void ALKCharacterBase::PostInitializeComponents()
{
...
Stat->OnHPZero.AddUObject(this, &ALKCharacterBase::SetDead);
}
float ALKCharacterBase::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
...
Stat->ApplyDamage(DamageAmount);
...
}
void ALKCharacterBase::SetDead()
{
...
HUD->SetHiddenInGame(true);
}
void ALKCharacterBase::SetupCharacterWidget(ULKUserWidget* InUserWidget)
{
ULKHUDWidget *HUDWidget = Cast<ULKHUDWidget>(InUserWidget);
if (HUDWidget)
{
HUDWidget->SetMaxHP(Stat->GetMaxHp());
HUDWidget->UpdateHPBar(Stat->GetCurrentHp());
HUDWidget->SetCharacterName(Stat->GetCharacterName());
Stat->OnHPChanged.AddUObject(HUDWidget, &ULKHUDWidget::UpdateHPBar);
}
}
지금까지 만들어준 액터 컴포넌트인 스탯 컴포넌트와 위젯 컴포넌트를 변수로 만든다
SetupCharacterWidget 함수는 이따 설명할 인터페이스의 가상 함수인데 우리가 사용할 HUD 위젯을 받아와 스탯 정보를 넘겨줘 초기화 후 스탯이 변했을 때 HP가 변경했음을 알림받도록 구독한다
생성자에서 CDO로 컴포넌트 껍데기를 만든 후 HUD는 위치를 조정하고 어떻게 렌더링될 것인지 정해준다
PostInitializeComponents에서 Stat의 HP가 0이 되었을 때 이벤트에 SetDead 함수를 등록해준다
TakeDamage에서 공격을 받아 Stat의 현재 체력을 감소하는 로직을 작성
SetDead에서는 죽으면 캐릭터 HUD가 게임에서 안보이게 설정
이때까지 의존성을 약화시키기 위해 인터페이스를 사용해왔고 이번에도 똑같이 인터페이스를 생성할 것이다
class LOSTKINGDOM_API ILKCharacterWidgetInterface
{
GENERATED_BODY()
// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
virtual void SetupCharacterWidget(class ULKUserWidget* InUserWidget) = 0;
};
void ULKHUDWidget::NativeConstruct()
{
...
ILKCharacterWidgetInterface* CharacterWidget = Cast<ILKCharacterWidgetInterface>(Owner);
if (CharacterWidget)
{
CharacterWidget->SetupCharacterWidget(this);
}
}
HUD 클래스의 NativeConstruct에서 인터페이스를 사용했는데 지금 이 인터페이스를 상속받은 클래스가 LKCharacterBase 클래스이므로 사실 LKCharacterBase의 SetupCharacterWidget 함수를 호출하는 것이다
근데 다른 클래스에서 특정 클래스로 구체적으로 명시하는 것은 의존성이 생긴다는 것이기 때문에 이를 인터페이스로 뺀건데
이 코드가 하는 역할은 HUD 위젯이 초기화될 때 초기화되는 캐릭터의 스탯 정보를 가져와 현재 위젯을 초기화시키는 것이다
블루프린트 클래스에서 플레이어 캐릭터는 플레이어 HUD 위젯 블루프린트 클래스를 등록해주고 적 캐릭터는 적 HUD 위젯 블루 프린트 클래스를 등록해준다

다음 부분을 바꿔주면 캐릭터마다 다른 HUD를 등록할 수 있다(C++ 클래스는 부모 맞추기)
결과 화면은 다음과 같다

대충 어떤 순서로 스탯 컴포넌트가 초기화되고 위젯 컴포넌트가 초기화되어 위젯이 어떻게 나타나는지 헷갈려서 마지막으로 정리해보려 한다
일단 다음 사진을 보자(이득우 강사님 강의 출처)

먼저 액터(캐릭터)의 PostInitializeComponents가 호출될 때 액터 컴포넌트인 스탯 컴포넌트도 같이 초기화가 됨
스탯 컴포넌트에 있는 InitializeComponent 함수가 호출됨(이 과정에서 스탯 컴포넌트의 초기화 완료)
이때 액터의 위젯 컴포넌트는 이미 생성자에서 클래스 정보를 할당하고 위젯 클래스 자체를 렌더링할 준비는 완료됨(위젯 컴포넌트에 있는 클래스 정보는 이미 들어있음)
PostInitializeComponents가 호출되면서 위젯 컴포넌트의 InitWidget을 호출하여 위젯 컴포넌트라는 껍데기 속 클래스를 확인해 Owner를 세팅해준다. 그러면서 UserWidget 인스턴스가 생성된다
위젯 인스턴스가 생성되면서 ULKHUDWidget의 NativeConstruct가 호출되어 캐릭터와 위젯을 동기화시키는 과정을 겪는다
다음과 같은 순서로 이해했다. 어렵다 어려워,,
일단 실행은 잘 되니까 다음 단계로 넘어가도 될 것 같다
하지만 언리얼은 잠시 접어두고 하던 팀 프로젝트를 마저 떠나겠다,, 보고싶을거야