애트리뷰트와 UI에 대해 얘기해보자.
캐릭터의 체력이나 마나, 경험치 등과 같은 값들을 애트리뷰트로부터 받아서 유저의 뷰포트에 뿌려주는 것이 UI가 할 일이다. 따라서 UI와 애트리뷰트 사이의 관계에 대해 제대로 알고 넘어갈 필요가 있을 듯하다.
UI에 표시되어야 할 데이터들은 여러 가지 클래스에 존재할 수 있다. 예를 들어 PlayerState에서 가져와야 할 데이터도 있을 것이고 애트리뷰트 셋에서 가져와야 할 데이터도 있을 것이고, ASC, PlayerController 등 다양한 클래스에 존재할 수 있다.
그럼 UI, 즉 유저위젯은 이 클래스들에 엑세스할 수 있어야 하는데, 이를 일일이 접근할 수 있도록 하자니 너무 복잡하고, 관리해야 할 멤버가 너무 많을 것이다. 또한 확장성 측면에서도 좋지 않다.
위젯이 직접 데이터 하나하나에 접근하게 하기엔 비효율적이다. 따라서 위젯과 데이터 사이에서 이들의 인터페이스 역할을 해주는 중간자가 필요하고, 이를 Widget Controller라고 한다.
뷰에서 특정한 버튼을 클릭했을 때 발생할 이벤트를 알려줄 때, 또는 특정한 데이터를 표시해야 한다고 할 때, 직접 데이터에 접근하기보다는 위젯 컨트롤러를 통해 데이터를 얻을 수 있다면 데이터의 관리 측면에서 용이하다.
위젯 컨트롤러의 측면에서 살펴보자면, 뷰 <-> 데이터의 상호작용에서 중간다리 역할을 하므로 요구하는 데이터를 내어 주거나 또는 데이터의 변경 작업을 수행해 줄 수 있다.
각각의 영역은 서로 완벽히 독립되어 존재하며, 이는 곧 게임 구조의 확장성 측면에서 도움이 된다. 다르게 말하면 시스템을 모듈화
한다고 할 수 있다.
컨트롤러는 내가 접근해야 할 데이터의 정보를 알고 있어야 한다. 반면 데이터는 어떤 컨트롤러가 나에게 접근할지 알고 있을 필요가 없다. 같은 맥락으로, UI 뷰는 내가 어떤 컨트롤러에 접근해서 데이터를 얻어와야 하는지 알아야 하지만, 반대로 컨트롤러는 나에게 접근하여 데이터를 받는 UI 뷰가 어떤 것들이 있는지 알 필요가 없다.
따라서 뷰 -> 컨트롤러 -> 데이터
라는 단방향의 의존성 형태를 띠게 된다.
각각의 Player는 자신의 PlayerController를 통해 자신의 HUD에만 접근할 수 있고, 서버는 모든 Player의 HUD를 가질 수 없다. 즉 한 플레이어의 HUD 클래스에 접근 가능한 것은 클라이언트에서 / 본인의 플레이어 컨트롤러이다.
모든 유저위젯은 HUD 클래스를 통해 관리된다. 즉, HUD 클래스는 모든 유저위젯을 생성, 업데이트하는 역할을 수행하게 되며, 또한 유저위젯이 접근할 위젯 컨트롤러 역시 가지고 있어야 한다.
HUD.h
UCLASS()
class AURA_API AAuraHUD : public AHUD
{
GENERATED_BODY()
public:
UPROPERTY()
TObjectPtr<UAuraUserWidget> OverlayWidget;
void InitOverlay(APlayerController* PC, APlayerState* PS, UAbilitySystemComponent* ASC, UAttributeSet* AS);
UOverlayWidgetController* GetOverlayWidgetController(const FWidgetControllerParams& WCParams);
protected:
private:
// 체력, 마나, 경험치, 스킬 퀵슬롯 등의 Overlay UI를 만들기 위한 Class
UPROPERTY(EditAnywhere)
TSubclassOf<UAuraUserWidget> OverlayWidgetClass;
// Overlay Widget에서 필요한 데이터들을 얻을 수 있는 인터페이스 역할을 하는 Widget Controller를 만들기 위한 Class
UPROPERTY(EditAnywhere)
TSubclassOf<UOverlayWidgetController> OverlayWidgetControllerClass;
// Overlay Widget에서 필요한 데이터들을 얻을 수 있는 인터페이스 역할을 하는 Widget Controller. private에 존재하며 public getter를 통해 접근
UPROPERTY()
TObjectPtr<UOverlayWidgetController> OverlayWidgetController;
};
HUD.cpp
/* 싱글톤 패턴과 같이, 하나의 WidgetController 클래스는 하나의 객체만을 가진다.
* 만약 OverlayWidgetController가 nullptr라면 새로운 객체를 생성, 그렇지 않다면 그 컨트롤러 자체를 리턴.
*/
UOverlayWidgetController* AAuraHUD::GetOverlayWidgetController(const FWidgetControllerParams& WCParams)
{
if (OverlayWidgetController == nullptr)
{
OverlayWidgetController = NewObject<UOverlayWidgetController>(this, OverlayWidgetControllerClass);
OverlayWidgetController->SetWidgetControllerParams(WCParams);
}
return OverlayWidgetController;
}
// 데이터와 바인딩되어 있는 PlayerController를 얻고 (없다면 만들고), 뷰포트에 띄우는 것까지 수행
void AAuraHUD::InitOverlay(APlayerController* PC, APlayerState* PS, UAbilitySystemComponent* ASC, UAttributeSet* AS)
{
checkf(OverlayWidgetClass, TEXT("Overlay Widget Class Uninitialized, please fill out BP_AuraHUD"));
checkf(OverlayWidgetControllerClass, TEXT("Overlay Widget Controller Class Uninitialized, please fill out BP_AuraHUD"));
OverlayWidget = Cast<UAuraUserWidget>(CreateWidget<UUserWidget>(GetWorld(), OverlayWidgetClass));
const FWidgetControllerParams WidgetControllerParams(PC, PS, ASC, AS);
UOverlayWidgetController* WidgetController = GetOverlayWidgetController(WidgetControllerParams);
// 위젯 -> 컨트롤러 방향의 의존성을 만들어 준다. (위젯은 접근할 컨트롤러를 갖고 있어야 한다)
OverlayWidget->SetWidgetController(WidgetController);
OverlayWidget->AddToViewport();
}
하나의 위젯 컨트롤러 클래스의 객체는 하나만 존재해야 한다고 해서, 전체 시스템에서 위젯 컨트롤러가 단 하나만 존재해야 하는 것은 아니다. Widget Controller 클래스에서 파생된 여러 서브 클래스들이 있을 수 있고, 이 클래스는 각각의 객체를 가질 수 있다.
프로젝트에서도 하나의 WidgetController
클래스를 만들고, 거기에서 파생되는 OverlayWidgetController
등의 클래스를 추가로 만들어 구현한다.
위젯 컨트롤러는 '내가 접근해야 할 데이터 소스' 에 대한 정보를 가지고 있어야 한다. 여러 가지 종류가 있을 수 있으며 나의 프로젝트에선 PlayerController
, PlayerState
, AbilitySystemComponent
, AttributeSet
(이하 PC, PS, ASC, AS) 을 사용한다.
각각의 포인터가 하나씩 존재해야 하며, 이것을 설정할 때 하나로 묶어서 관리할 수 있는 구조체 FWidgetControllerParams
를 만들었다.
WidgetController.h
/* WidgetController 객체를 생성 및 관리함에서의 편리를 위한 구조체
* WidgetController는 PC, PS, ASC, AS 4가지 클래스 포인터가 필요하고 이를 하나로 묶는 역할
*/
USTRUCT(BlueprintType)
struct FWidgetControllerParams
{
GENERATED_BODY()
FWidgetControllerParams() {}
FWidgetControllerParams(APlayerController* PC, APlayerState* PS,
UAbilitySystemComponent* ASC, UAttributeSet* AS) :
PlayerController(PC),
PlayerState(PS),
AbilitySystemComponent(ASC),
AttributeSet(AS)
{}
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<APlayerController> PlayerController = nullptr;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<APlayerState> PlayerState = nullptr;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent = nullptr;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TObjectPtr<UAttributeSet> AttributeSet = nullptr;
};
UCLASS()
class AURA_API UAuraWidgetController : public UObject
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void SetWidgetControllerParams(const FWidgetControllerParams& WCParams);
protected:
// WidgetController에서 필요한 4가지 데이터 소스 종류. SetWidgetControllerParams() 에서 set 된다.
UPROPERTY(BlueprintReadOnly, Category = WidgetController)
TObjectPtr<APlayerController> PlayerController;
UPROPERTY(BlueprintReadOnly, Category = WidgetController)
TObjectPtr<APlayerState> PlayerState;
UPROPERTY(BlueprintReadOnly, Category = WidgetController)
TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
UPROPERTY(BlueprintReadOnly, Category = WidgetController)
TObjectPtr<UAttributeSet> AttributeSet;
};
WidgetController.cpp
void UAuraWidgetController::SetWidgetControllerParams(const FWidgetControllerParams& WCParams)
{
PlayerController = WCParams.PlayerController;
PlayerState = WCParams.PlayerState;
AbilitySystemComponent = WCParams.AbilitySystemComponent;
AttributeSet = WCParams.AttributeSet;
}
UserWidget은 인터페이스 역할을 할 Widget Controller 포인터를 갖고 있어야 하며 그 외에 자기 UserWidget에 포함된 여러 멤버를 추가로 가질 수 있다.
UserWidget.h
UCLASS()
class AURA_API UAuraUserWidget : public UUserWidget
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void SetWidgetController(UObject* InWidgetController);
// 데이터를 얻어올 수 있는, 인터페이스 역할을 할 Widget Controller
UPROPERTY(BlueprintReadOnly)
TObjectPtr<UObject> WidgetController;
};
UserWidget.cpp
void UAuraUserWidget::SetWidgetController(UObject* InWidgetController)
{
WidgetController = InWidgetController;
}
이제 이 UserWidget 클래스를 초기화하고 호출할 곳을 찾아야 한다. 그 UserWidget이 접근해야 할 데이터의 주체, 예컨대 캐릭터의 체력/마나 등을 표시하는 UI 등의 경우 그 캐릭터에서 호출하는 편이 낫다. 왜냐하면 UserWidget을 만들 때는 그 위젯이 의존하고 있는 Widget Controller를 만들어야 하고(또는 기 존재한다면 얻어야 하고), 만들 때에는 PC, PS, ASC, AS 포인터가 필요하기 때문이다.
Character 클래스에서는 ASC의 OwnerActor
와 AvatarActor
를 설정하는 과정이 PossessedBy
, OnRep_PlayerState
에서 이루어지고, 마찬가지로 이 부분에서 OverlayWidget에 대한 초기화를 호출하면 좋을 것이다.
Character.cpp
void AAuraCharacter::InitAbilityActorInfo()
{
AAuraPlayerState* AuraPlayerState = GetPlayerState<AAuraPlayerState>();
check(AuraPlayerState);
AuraPlayerState->GetAbilitySystemComponent()->InitAbilityActorInfo(AuraPlayerState, this);
AbilitySystemComponent = AuraPlayerState->GetAbilitySystemComponent();
AttributeSet = AuraPlayerState->GetAttributeSet();
// 이 캐릭터의 어트리뷰트와 바인딩될 overlay widget을 만들고, 뷰포트에 띄운다.
// 그 결과 각각의 캐릭터는 각각의 캐릭터에서 이 부분을 수행하고, 즉 자신의 캐릭터 상황에 맞는 overlay HUD를 갖게 된다.
if (AAuraPlayerController* AuraPlayerController = Cast<AAuraPlayerController>(GetController()))
{
if (AAuraHUD* AuraHUD = Cast<AAuraHUD>(AuraPlayerController->GetHUD()))
{
AuraHUD->InitOverlay(AuraPlayerController, AuraPlayerState, AbilitySystemComponent, AttributeSet);
}
}
}
위젯 컨트롤러는 어떤 유저위젯이 자신에게 바인딩되어 있는지 알 수 없다. 다만 애트리뷰트의 값이 변화할 때마다 fire할 수 있는 델리게이트를 만들 수 있고, 이 델리게이트에 콜백 함수를 엮어 애트리뷰트의 변경 내용을 위젯에게 알릴 수 있다.
블루프린트에서 사용하기 위해 Dynamic, 그리고 여러 함수를 바인드할 수 있도록 하기 위해 Multicast Delegate 타입으로 생성하고자 한다.
Widget Controller 클래스에 다음과 같이 DECLARE
매크로를 사용하여 바인딩한다. 우선은 체력(Health), 최대 체력(MaxHealth), 마나(Mana), 최대 마나(MaxMana) 4가지 애트리뷰트를 UI와 바인딩하고자 한다.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChangedSignature, float, NewHealth);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMaxHealthChangedSignature, float, NewMaxHealth);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnManaChangedSignature, float, NewMana);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMaxManaChangedSignature, float, NewMaxMana);
FOnHealthChangeSignature
와 같은 타입의 델리게이트를 생성하였고, 새로운 값만을 넘겨주면 되기 때문에 OneParam
을 사용했다.
그리고 아래와 같이 델리게이트 멤버들과 콜백 함수들을 선언한다. 콜백 함수를 델리게이트에 엮는 함수는 BindCallbacksToDependencies()
라는 이름으로 선언하였다.
UCLASS(BlueprintType, Blueprintable)
class AURA_API UOverlayWidgetController : public UAuraWidgetController
{
GENERATED_BODY()
public:
virtual void BroadcastInitialValues() override;
virtual void BindCallbacksToDependencies() override;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnHealthChangedSignature OnHealthChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnMaxHealthChangedSignature OnMaxHealthChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnManaChangedSignature OnManaChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnMaxManaChangedSignature OnMaxManaChanged;
protected:
void HealthChanged(const FOnAttributeChangeData& Data) const;
void MaxHealthChanged(const FOnAttributeChangeData& Data) const;
void ManaChanged(const FOnAttributeChangeData& Data) const;
void MaxManaChanged(const FOnAttributeChangeData& Data) const;
};
void UOverlayWidgetController::BroadcastInitialValues()
{
const UAuraAttributeSet* AuraAttributeSet = CastChecked<UAuraAttributeSet>(AttributeSet);
OnHealthChanged.Broadcast(AuraAttributeSet->GetHealth());
OnMaxHealthChanged.Broadcast(AuraAttributeSet->GetMaxHealth());
OnManaChanged.Broadcast(AuraAttributeSet->GetMana());
OnMaxManaChanged.Broadcast(AuraAttributeSet->GetMaxMana());
}
void UOverlayWidgetController::BindCallbacksToDependencies()
{
const UAuraAttributeSet* AuraAttributeSet = CastChecked<UAuraAttributeSet>(AttributeSet);
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAttributeSet->GetHealthAttribute())
.AddUObject(this, &UOverlayWidgetController::HealthChanged);
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAttributeSet->GetMaxHealthAttribute()).
AddUObject(this, &UOverlayWidgetController::MaxHealthChanged);
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAttributeSet->GetManaAttribute()).
AddUObject(this, &UOverlayWidgetController::ManaChanged);
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAttributeSet->GetMaxManaAttribute()).
AddUObject(this, &UOverlayWidgetController::MaxManaChanged);
}
void UOverlayWidgetController::HealthChanged(const FOnAttributeChangeData& Data) const
{
OnHealthChanged.Broadcast(Data.NewValue);
}
void UOverlayWidgetController::MaxHealthChanged(const FOnAttributeChangeData& Data) const
{
OnMaxHealthChanged.Broadcast(Data.NewValue);
}
void UOverlayWidgetController::ManaChanged(const FOnAttributeChangeData& Data) const
{
OnManaChanged.Broadcast(Data.NewValue);
}
void UOverlayWidgetController::MaxManaChanged(const FOnAttributeChangeData& Data) const
{
OnMaxManaChanged.Broadcast(Data.NewValue);
}
ASC에는 FOnGameplayAttributeValueChange
라는 델리게이트가 있고, 여기서 애트리뷰트의 값 변화와 콜백 함수를 서로 엮을 수 있다. AddUObject
를 사용하는 이유는 이 델리게이트가 Dynamic이 아니기 때문이다.
DECLARE_MULTICAST_DELEGATE_OneParam(FOnGameplayAttributeValueChange, const FOnAttributeChangeData&);
어떻게 확인 가능하냐면 Go To Declaration을 이용해 선언부분으로 이동해 보면 GameplayEffectTypes.h
에서 해당 델리게이트가 정의된 부분을 확인할 수 있는데 이 때 DECLARE_MULTICAST_DELEGATE
매크로를 사용하고 있음을 확인할 수 있다.
그리고 각각의 콜백 함수에서는 Broadcast
하도록 해서, 블루프린트에서 동작들을 엮을 수 있도록 했다. (BlueprintAssignable 프로퍼티 필요)
내 프로젝트의 구조는 Overlay 유저위젯이 있고 그 안에 HealthGlobe 유저위젯과 ManaGlobe 유저위젯이 존재하는 형태이다. Overlay 위젯의 위젯 컨트롤러를 Globe 위젯에게도 전달해 줘서 직접적으로 애트리뷰트를 전달할 수 있도록 한다.
SetWidgetController
함수에서 WidgetControllerSet
이라는 이름의 BlueprintImplementableEvent
를 호출하는데, 이를 통해서 Overlay 위젯에서 재차 Set Controller를 설정할 수 있다. (SetWidgetController
함수에 BlueprintCallable
UPROPERTY 필요)
WBP_HealthGlobe에서 Is Variable
을 체크해서 변수화 시켜준 뒤
Widget Controller Set
이벤트에서 재차 Globe 위젯들에 Set Widget Controller를 호출해 준다.
그 이후에는 델리게이트 이벤트 할당을 위해 유저위젯에 존재하는 Widget Controller를 우리가 만든 BP_WidgetController로 캐스팅하고,
'Assign On Health Changed' 를 연결한다.
Health와 MaxHealth 변수를 만들고 Set 해준다.
그리고 값이 변경될 때마다 Update Health Percent
함수를 호출해 주면 된다. Update Health Percent는 Health와 MaxHealth 값을 가지고 체력 ProgressBar의 Percent를 계산하여 설정하는 간단한 함수이다.
최종적으로 완성된 WBP_HealthGlobe
체력 바의 블루프린트 형태.
HUD 클래스에서 WidgetClass와 WidgetControllerClass를 연결해 주었으면
AttributeSet에서 Init
한 값대로 잘 설정되고, UI에도 잘 반영된다.
UAuraAttributeSet::UAuraAttributeSet()
{
InitHealth(70.f);
InitMaxHealth(100.f);
InitMana(50.f);
InitMaxMana(50.f);
}
(체력이 70%, 마나가 100% 차 있는 모습)
애트리뷰트 값 변경 또한 잘 되는지 확인해 보기 위해서 간단한 액터를 만들고 Overlap 이벤트에서 AttributeSet에 접근 -> SetHealth
하는 형식으로 애트리뷰트 값을 변경하도록 해 보면,
체력과 마나가 변경되고, UI에도 잘 반영되는 것을 확인할 수 있다.