2-7강 캐릭터 스탯과 위젯

Ryan Ham·2024년 7월 3일
0

이득우 Unreal

목록 보기
10/23
post-thumbnail

강의 목표

  • Actor Component를 활용한 Actor 기능 확장 방법의 이해
  • Unreal Delegate를 활용한 발행 구독 모델의 학습
  • Actor의 초기화 단계와 Widget 초기화 과정의 이해. 독립적으로 구현된 Stat ComponentWidget Component를 서로 연동하는 방법

Actor Component란?

Actor Component는 Actor에 부착할 수 있는 component들 중에서 transform 기능이 없는 component들이다. Actor가 가진 기능을 확장할 때 component로 분리해서 모듈화 할 수 있기 때문에 편리하게 사용할 수 있다.

우리는 2개의 Actor Component을 사용해서 캐릭터에 부착할 것이다. 하나는 Stat Component, 다른 하나는 UI Widget Component이다. Stat Component는 Actor Component를 바로 상속해서 만들고, UI Widget Component는 UWidgetComponent를 상속해서 만든다.

Stat Component 쪽에서는 사용자의 stat 정보를 관리하고, Delegate를 사용해서 여기에 변화가 생기면 자동으로 이 Delegate에 사전에 등록한 UI Widget에게 변경 사실을 알려준다. UI Widget Component는 Transform 정보를 가지고 있어 UI Widget를 Actor 위에 보여주는 기능을 하는 껍데기일 뿐이다.


Delegate를 통해 갱신

Push 형태의 알림(Notification)을 구현하는데 적합한 design pattern. 이러한 design pattern을 발행-구독 모델이라고 한다. Stat이 변경되면 가운데 위치한 Delegate에 연결된 component에 알림을 보내 데이터를 갱신한다. Delegate를 통해서 event를 알릴 것이기 때문에 Tick 안에 구현할 때보다 훨씬 세련되게 코드를 작성할 수 있다. 또한, Delegate를 사용하면 Stat componentUI Widget component는 서로 직접적으로 참조하지 않아도 된다(의존성 분리).

Stat Component쪽

  • Delegate를 자기 쪽에 선언해준다.
  • Stat이 변경되면 직접적으로 UI widget component에 전달 X. Delegate에 정보를 전달한다.

UI Widget Component 쪽

  • Delegate에 등록한다.
  • Delegate에서 알림이 오면 자신의 component을 update한다.

코드를 통해 알아보는 Delegate 쓰는 방법 A to Z

Delegate를 선언하는 부분

// StatComponent.h

// Hp가 0인 이벤트만 주면 되므로 인자가 0인 Delegate. 다수의 구독자가 받을 수 있데 MULTICAST로 설정. 
DECLARE_MULTICAST_DELEGATE(FOnHpZeroDelegate);
// 현재 Hp를 전달하여야 하기 때문에 인자가 1개인 Delegate. 다수의 구독자가 받을 수 있데 MULTICAST로 설정.
DECLARE_MULTICAST_DELEGATE_OneParam(FOnHpChangedDelegate, float /*CurrentHp*/);
class ARENABATTLE_API URyanStatComponent : public UActorComponent
{
...
public:	

	FOnHpZeroDelegate OnHpZero;
	FOnHpChangedDelegate OnHpChanged;
}

Actor Component를 상속해 C++로 Stat Component를 만들어준다. Stat Component Header파일에 Delegate를 선언해 주는데, 다수의 객체들에게 뿌릴 것이므로 DECLARE_MULTICAST_DELEGATE_~로 만든다. Delegate에 넘겨줄 parameter가 몇개 인지에 따라 ~ 안에 적히는 값이 달라지게 된다.

// StatComponent.cpp

float URyanStatComponent::ApplyDamage(float InDamage)
{
...
OnHpZero.Broadcast();
}
void URyanStatComponent::SetHp(float NewHp)
{
...
OnHpChanged.Broadcast(CurrentHp);
}

Stat Component C++에는 event가 발생했을때 Delegate를 통해서 Broadcast하는 로직을 작성해준다. Hp가 0이 되어 죽는 상태가 되었을때 인자 없이 상황만 알려주는OnHpZero라는 delegate와, 데미지를 받았을때 받은 데미지를 OnHpChanged라는 delegate에 인자로 넘기는 부분에 대한 로직을 작성. 다수의 대상(multi라 선언했으므로)에게 broadcast해야 한다.

생성된 Delegate에 함수를 등록하는 부분

// CharacterBase.cpp

void ARyanCharacterBase::SetupCharacterWidget(URyanUserWidget* InUserWidget) 
{
	URyanHpBarWidget* HpBarWidget = Cast<URyanHpBarWidget>(InUserWidget);
	if (HpBarWidget)
	{
		HpBarWidget->SetMaxHp(Stat->GetMaxHp());
		HpBarWidget->UpdateHpBar(Stat->GetCurrentHp());
		// UpdateHpBar함수를 Stat Component에서 생성된 OnHpChanged라는 Delegate에 등록
		Stat->OnHpChanged.AddUObject(HpBarWidget, &URyanHpBarWidget::UpdateHpBar);
	}
}

void ARyanCharacterBase::PostInitializeComponents()
{
	Super::PostInitializeComponents();
	// SetDead함수를 Stat Component에서 생성된 OnHpZero라는 Delegate에 등록
	Stat->OnHpZero.AddUObject(this, &ARyanCharacterBase::SetDead);
}

Stat Component에서 만든 Delegate를 CharacterBase.cpp에서 등록하는 부분이다.


UI Widget Component

Stat Component는 그 자체로 Stat에 대한 정보를 관리한다. 하지만, 캐릭터의 HpBar를 만들기 위해서는 transform 정보가 필요한데, 위젯 자체로는 캐릭터에 붙일 수 없기 때문에 위젯 컴포넌트라는 것을 만들어서 붙인다.

위젯 component는 Actor위에 UI 위젯을 띄워주는 컴포넌트에 불과. 위젯 컴포넌트는 컨테이너 역할만 할 뿐, 둘은 서로 독립적으로 동작한다.

Widget BP 만들기

Widget BP도 여타 다른 BP 컨트롤과 유사하게 같은 클래스의 C++를 만들고 이를 상속하는 구조로 만들어보자. BP와 C++ 둘다 User Widget이라는 클래스를 상속해서 만든다.

step 1 : UserWidget를 상속받는 블루프린트 클래스 만들기
Vertical Box, Progress Bar를 차례대로 계층구조 형식으로 만들어주기

step 2 : User Widget을 상속하는 C++ 클래스 하나 만들고 이를 Widget BP가 부모로 삼게 한다.

  • (중요!) 우리가 지금 만들고 있는 것은 Widget이지 Widget Component가 아님을 명심! Widget은 보통 화면에 display하는 용이지 캐릭터에 부착하는 것은 이 HpBar와 같이 특수한 경우일 뿐이다. Widget Component를 쓰는 이유는 캐릭터 위에 붙이는 Transform이 필요하기 때문이라는 사실을 기억하자.

Widget과 Widget Component 연결

	// CharacterBase.cpp
	// Widget Component의 위치 설정
	HpBar = CreateDefaultSubobject<URyanWidgetComponent>(TEXT("Widget"));
	HpBar->SetupAttachment(GetMesh());
	HpBar->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f));
    
    // Widget을 Widget Component에 부착
	static ConstructorHelpers::FClassFinder<UUserWidget> HpBarWidgetRef(TEXT("/Game/ArenaBattle/UI/WBP_HpBar.WBP_HpBar_C"));
	if (HpBarWidgetRef.Class)
	{
		HpBar->SetWidgetClass(HpBarWidgetRef.Class);
        // EWidgetSpace::World로 하면 3D, EWidgetSpace::Screen로 두면 2D로 표시
		HpBar->SetWidgetSpace(EWidgetSpace::Screen);
        // 화면에 그릴 사이즈
		HpBar->SetDrawSize(FVector2D(150.0f, 15.0f));
		HpBar->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	}

CharacterBase.cpp의 생성자에서 Widget Component를 생성하고, 사전에 만들어 두었던 Widget을 Widget Component에 연결한다.


UserWidget의 초기화 함수(NativeConstruct)

NativeConstruct

일반 C++에는 생성자가 있다면 Widget에는 NativeConstruct가 존재한다. 생성자에 들어갈 로직을 이 부분에 다 작성하면 된다. 이번 코드에서는 ProgressBar에 대한 초기화 부분을 여기에 작성한다.

// HpBarWidget.cpp
void URyanHpBarWidget::NativeConstruct()
{
	Super::NativeConstruct();

	HpProgressBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("PbHpBar")));
	ensure(HpProgressBar);

	IRyanCharacterWidgetInterface* CharacterWidget = Cast<IRyanCharacterWidgetInterface>(OwningActor);
	if (CharacterWidget)
	{
		CharacterWidget->SetupCharacterWidget(this);
	}
}

Widget Component의 초기화 함수(InitWidget)

InitWidget

마찬가지로 InitWidget은 WidgetComponent에서 생성자 역할을 하는 함수이다.

// WidgetComponent.cpp
void URyanWidgetComponent::InitWidget()
{
	Super::InitWidget();

	URyanUserWidget* RyanUserWidget = Cast<URyanUserWidget>(GetWidget());
	if (RyanUserWidget)
	{
		RyanUserWidget->SetOwningActor(GetOwner());
	}
}

캐릭터에 데미지가 가해졌을때 위젯의 퍼센트가 깎이는 로직

Stat Component이랑 Widget Component는 완료했기 때문에 이를 CharacterBase에 추가해준다. 여기서, Widget이 업데이트 되기 위해서는 Actor의 LifeCycle에 대해서 알아야 한다.

Actor LifeCycle

Actor는 2가지 방법으로 초기화될 수 있다. 1번째 방법은 디스크에 저장된 레벨 정보가 로딩이 되면서. 2번째 방법은 script를 사용해서 runtime에서 생성하는 spawn 과정.

이 두 가지 방법의 중간 과정은 서로 살짝 다르지만 BeginPlay() 전에 각각 PostInitializeComponents라는 과정을 모두 거치게 되는 점은 동일하다. PostInitializeComponents에서는 Actor를 최종으로 마무리하는 로직을 넣고 이 단계가 끝나면 바로 Tick이 발동되기 시작하는 BeginPlay가 실행되게 된다.


Widget Component의 초기화 과정

Actor의 LifeCycle에 대해서 알아보았다면 이제는 Widget Component의 초기화 과정에 대해 알아보아야 한다.

Stat Component의 데이터가 업데이트 될때 자동으로 Widget이 갱신되게 되려면, Stat Component의 존재를 사전에 Widget이 알고 있어야 한다.

생성 시점을 보자면, 스탯 컴포넌트는 BeginPlay이전인 PostInitializeComponents에 생성이 완료가 되고, Widget Component는 BeginPlay 이후에 생성이 된다. 따라서, 적절한 시점에 Widget과 Stat Component를 연결시켜 주어야 한다. 생각할 수 있는 방법으로는 Widget을 가지고 있는 Widget Component를 통해 자신을 소유하고 있는 객체를 부를 수 있겠지만 애석하게도 언리얼은 이를 지원하지 않는다고 한다.

우리는 이 문제를 해결하기 위해서 위젯을 확장한 클래스를 만들어서 해결한다. 클래스를 확장했으면 Widget의 함수를 Stat Component에서 정의한 Delegate에 등록하는 일까지 야무지게 수행한다.

이걸 하는 궁극적 이유는 위젯이 자기를 소유한 Actor 정보를 알기 위함이라는 사실을 다시 한번 기억하자.


Actor 정보를 받기 위한 Widget의 확장

Widget 부분

우리는 최종 Widget을 만들기 위해 다음과 같은 중간 단계의 Widget을 만든다.

UUserWidget -> (UUserWidget을 상속한) 중간 위젯 -> (중간 위젯을 상속한) HpBar Widget

Widget Component 부분

void URyanWidgetComponent::InitWidget()
{
	Super::InitWidget();

	URyanUserWidget* RyanUserWidget = Cast<URyanUserWidget>(GetWidget());
	if (RyanUserWidget)
	{
		RyanUserWidget->SetOwningActor(GetOwner());
	}
}

Component에서 GetOwner를 불러 Widget에 구현했던 setter에 이 값을 집어 넣는다.


Interface 구현

이제 Widget의 함수를 Stat Component에서 선언한 Delegate에 등록하는 과정만 남았다. 이를 위해서는 Widget이 CharacterBase에 대한 정보를 알아야 하는데 여기서 Widget이 직접적으로 CharacterBase 클래스를 참조하게 되면 의존성 문제가 생기게 된다.

이를 해결하기 위해 Interface를 하나 만들고 이를 CharacterBase가 상속하게 만들자.

// CharacterBase.cpp
void ARyanCharacterBase::SetupCharacterWidget(URyanUserWidget* InUserWidget)
{
	URyanHpBarWidget* HpBarWidget = Cast<URyanHpBarWidget>(InUserWidget);
	if (HpBarWidget)
	{
		HpBarWidget->SetMaxHp(Stat->GetMaxHp());
		HpBarWidget->UpdateHpBar(Stat->GetCurrentHp());
		Stat->OnHpChanged.AddUObject(HpBarWidget, &URyanHpBarWidget::UpdateHpBar);
	}
}
// HpBarWidget.cpp
void URyanHpBarWidget::NativeConstruct()
{
	Super::NativeConstruct();

	HpProgressBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("PbHpBar")));
	ensure(HpProgressBar);

	IRyanCharacterWidgetInterface* CharacterWidget = Cast<IRyanCharacterWidgetInterface>(OwningActor);
	if (CharacterWidget)
	{
		CharacterWidget->SetupCharacterWidget(this);
	}
}

최종 영상


기타

KINDA_SMALL_NUMBER

KINDA_SMALL_NUMBER라는 귀여운 macro가 있다. 거의 0에 가까운 수를 표현할 때 사용.

"Category" 메타데이터

UPROPERTY 메타데이터 중 "Category"를 쓰면 이 이름으로 grouping이 되어서 Unreal Editor property panel에서 따로 모아서 볼 수 있다.

UPROPERTY의 VisibleInstanceOnly

VisibleInstanceOnly는 언리얼 엔진에서 특정 클래스의 변수나 속성을 인스턴스 편집기에서만 볼 수 있게 설정하는 데 사용되는 UPROPERTY 매크로. 이 속성을 적용하면 해당 변수는 블루프린트 에디터에서 볼 수 있지만, 이를 편집할 수는 없고, 코드나 디폴트 클래스 설정에서는 접근이 불가능하다. 주로 개발자가 클래스의 인스턴스별로 다르게 표시하고 싶지만, 외부에서의 수정은 방지하고자 할 때 사용된다. 이를 통해 개발자는 디버깅이나 인스턴스별 설정 값을 쉽게 확인할 수 있으면서도 의도치 않은 변경을 막을 수 있다.

profile
🏦KAIST EE | 🏦SNU AI(빅데이터 핀테크 전문가 과정) | 📙CryptoHipsters 저자

0개의 댓글