Class GameDev* SheepAdult

[Unreal Engine 5] 멀티 플레이 환경에서의 Game Ability System을 활용한 자기장(bluezone)기능 구현 - gas를 활용한 데미지 본문

Unreal Engine

[Unreal Engine 5] 멀티 플레이 환경에서의 Game Ability System을 활용한 자기장(bluezone)기능 구현 - gas를 활용한 데미지

SheepAdult 2023. 12. 25. 00:57

먼저 자기장 구현보다 gas를 통한 캐릭터에게 데미지를 주는 것에 대한 글을 작성하려 한다.

아직 gas에 대한 공부가 부족한 상태이며, 실력 또한 부족하여 글에 부족한 부분이 있을 수 있다.

 

먼저, gas환경 하에서 플레이어에게 데미지를 주기 위해서는 데미지를 주는 액터가 UAbilitySystemComponent와 UAttributeSet을 가지고 있어야 한다. UAttributeSet은 체력 혹은 마나 등의 속성 값을 등록하여 사용하기 위한 용도이며, UAbilitySystemComponent는 AttributeSet에 변화를 주기 위한 용도로 사용할 것이다.

 

AttributeSet

먼저 AttributeSet 클래스를 하나 만든 후, Health, MaxHealth 속성을 추가했다. 그리고, 멀티플레이 환경이므로 Health를 replicated시켜 동기화할 수 있게 해 주었다.

더보기
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
	GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
	GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

// P1AttributeSet.h
UCLASS()
class PROTOTYPE_API UP1AttributeSet : public UAttributeSet
{
	GENERATED_BODY()
	
public:
	UP1AttributeSet();
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

	UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health, Category = "Vital Attributes")
	FGameplayAttributeData Health;
	ATTRIBUTE_ACCESSORS(UP1AttributeSet, Health); // ATTRIBUTE_ACCESSORS 매크로 사용

	UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_MaxHealth, Category = "Vital Attributes")
	FGameplayAttributeData MaxHealth;
	ATTRIBUTE_ACCESSORS(UP1AttributeSet, MaxHealth);
	
	UFUNCTION()
	void OnRep_Health(const FGameplayAttributeData& OldHealth) const;

	UFUNCTION()
	void OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth) const;
};

여기서 ATTRIBUTE_ACCESSORS는 AttributeData에 대한 변수에 대해 자동으로 getter, setter, initter 함수를 만들어주는 매크로이다. AttributeSet.h의 416번 라인을 보면 위와 같이 작성되어 있다. 아래 코드를 복사해서 AttributeSet 헤더에 붙여 넣기 한 후, 해당 매크로를 사용해서 함수를 만들어주면 된다.

더보기
 /*
 .
 .
 .
 *	#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
 *	GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
 *	GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
 *	GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
 *	GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)
 * 
 *	ATTRIBUTE_ACCESSORS(UMyHealthSet, Health)
 */

cpp파일에서는 ATTRIBUTE_ACCESSORS로 만들어진 Initter를 사용해서 값을 초기화시켜준 후, replicated 된 변수를 등록(?)하고 RepNotify함수를 통해 값이 변경될 때마다 자동으로 해당 호출되게끔 해주면 된다. 이것 또한 gas플러그인에서 제공하는 매크로를 사용하면 된다.

더보기
// P1AttributeSet.cpp
UP1AttributeSet::UP1AttributeSet()
{
	InitHealth(100.f);
	InitMaxHealth(100.f);
}

void UP1AttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME_CONDITION_NOTIFY(UP1AttributeSet, Health, COND_None, REPNOTIFY_Always);
	DOREPLIFETIME_CONDITION_NOTIFY(UP1AttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
}

void UP1AttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth) const
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UP1AttributeSet, Health, OldHealth);
}

void UP1AttributeSet::OnRep_MaxHealth(const FGameplayAttributeData& OldMaxHealth) const
{
	GAMEPLAYATTRIBUTE_REPNOTIFY(UP1AttributeSet, MaxHealth, OldMaxHealth);
}
Character

이제 Character를 볼 차례이다. Character는 AbilitySystemComponent와 AttributeSet을 기본적으로 PlayerState에서 가져올 것이다.

더보기
// AP1PlayerState.cpp
AP1PlayerState::AP1PlayerState()
{
	AbilitySystemComponent = CreateDefaultSubobject<UP1AbilitySystemComponent>("AbilitySystemComponent");
	AbilitySystemComponent->SetIsReplicated(true);
	AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

	AttributeSet = CreateDefaultSubobject<UP1AttributeSet>("AttributeSet");

	NetUpdateFrequency = 100.f;
}

UAbilitySystemComponent* AP1PlayerState::GetAbilitySystemComponent() const
{
	return AbilitySystemComponent;
}

Character 클래스에서 서버는 PossessedBy(), 클라이언트는 OnRep_PlayerState()가 호출될 때 두 정보를 가져올 것이다. 두 함수 모두 virtual 함수로 override하여 사용하면 된다.

더보기
// P1Character.cpp
void AP1Character::PossessedBy(AController* NewController)
{
	Super::PossessedBy(NewController);

	InitAbilityActorInfo();
}

void AP1Character::OnRep_PlayerState()
{
	Super::OnRep_PlayerState();

	InitAbilityActorInfo();
}

void AP1Character::InitAbilityActorInfo()
{
	AP1PlayerState* P1PlayerState = GetPlayerState<AP1PlayerState>();
	check(P1PlayerState);
	AbilitySystemComponent = P1PlayerState->GetAbilitySystemComponent();
	AttributeSet = P1PlayerState->GetAttributeSet();
}

참고로 여기 보이는 P1PlayerState->GetAbilitySystemComponent()는 플러그인에 이미 포함되어 있는 IAbilitySystemInterface인 인터페이스에 선언된 함수이다(달랑 저거 하나 선언되어 있다).

이제 BlueZone(이하 자기장)을 볼 것인데, 자기장이 시간이 지남에 따라 범위가 줄어드는 기능은 BlueZone.cpp의 고유 특성이니 거기에 내버려두고, 데미지를 주는 방식은 재활용 될 수 있으니 따로 Actor 클래스를 상속받는 GameplayEffectActor라는 클래스를 만들어서 상속시켰다. 

 

데미지 주는 방식

 그럼 데미지를 주는 방식을 정해보자.

 나는 첫 번째로 자기장 안으로 들어오면 데미지를 입지 않고 밖으로 나가면 일정 시간 주기로 데미지를 주는 방식으로 구현했다. 자기장에 EndOverlap되면 Game Effect 방식을 Infinite로(일정 시간 동안 데미지를 주는 방식) 정하여 구현했다.

 하지만 문제점이 있었다. 빠른 속도로 자기장을 들어갔다 나왔다 하면, 일정 주기보다 더 빠른 속도로 데미지를 받을 수 있다는 문제점이 생겼고, 만약 자기장 줄어드는 속도와 캐릭터의 움직임 속도가 비슷한 경우 줄어드는 자기장 경계에서 자기장 중심으로 이동할 때 타이밍이 맞는다면 말도 안되는 속도로 데미지를 입을 수 있는 문제점이 있었다. 또한, 모든 캐릭터가 같은 타이밍에 데미지를 입게하고 싶었지만 이런 경우 각자 다른 타이밍에 데미지를 입는 문제점이 있었다.

 

 그래서 간단하게 변경했다. 자기장 밖의 액터를 TArray에 담아 일정한 주기로 데미지를 주는 방식으로 바꾸었다. 자기장 자체적으로 시간을 재기 때문에 Game Effect는 Infinite에서 Instant방식(한 번만 데미지 주는 것)으로 바꾸었다.

 

GameplayEffectActor

해당 클래스에서 작성할 건, Instant를 사용할 것이므로 데미지를 가하는 함수 하나만 작성하면 된다. 함수 이름은 ApplyEffectToTarget으로 했으며, 매개변수는 데미지를 줄 Target, GameEffect 종류를 담을 UGameplayEffect이다.

더보기
// AGameplayEffectActor.cpp
void AGameplayEffectActor::ApplyEffectToTarget(AActor* Target, TSubclassOf<UGameplayEffect> GameplayEffectClass)
{
	// AbilitySystemComponent를 가지고 있지 않다면 return -> 데미지를 줄 액터를 찾기 위함
	UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Target);
	if (TargetASC == nullptr) return;
	checkf(GameplayEffectClass, TEXT("GameplayEffectClass is invalid!!"));

	FGameplayEffectContextHandle EffectContextHandle = TargetASC->MakeEffectContext();
	EffectContextHandle.AddSourceObject(this);
	const FGameplayEffectSpecHandle EffectSpecHandle = TargetASC->MakeOutgoingSpec(GameplayEffectClass, 1.f, EffectContextHandle);
	TargetASC->ApplyGameplayEffectSpecToSelf(*EffectSpecHandle.Data.Get());
}

뭔가 복잡한 함수들이 나열되어 있다. 간략하게 설명하자면

  • MakeEffectContext(): 데미지를 줄, 데미지를 받을 액터들을 하나의 Context로 묶기 위함이다.
  • AddSourceObject(): 데미지의 원인을 설정한다. 해당 클래스는 데미지를 주는 액터이므로 this를 넣어준다.
  • MakeOutgoinSpec(): Context에 있는 액터들에게 줄 GameplayEffectClass에 해당하는 효과를 래핑한다.
  • ApplyGameplayEffectSpecToSelf(): 함수 호출자에게 효과를 준다.

위의 과정들을 통해 Target에 데미지를 주게 된다.

 

참고로 GameplayEffect는 아래와 같이 선언된다.

더보기
UPROPERTY(EditAnywhere, Category = "Applied Effects")
TSubclassOf<UGameplayEffect> InstantGameplayEffectClass;

 

BlueZone

자기장에 콜리전을 만들고(생략하겠다) EndOverlap 되면 TArray에 삽입하고 OnOverlap 되면 제거한다.

더보기
// ABlueZone.cpp
// TArray<AActor*> ActorsToDamage;
void ABlueZone::OnBlueZoneOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	AP1Character* Player = Cast<AP1Character>(OtherActor);
	if (Player == nullptr) return;

	ActorsToDamage.Remove(OtherActor);
}

void ABlueZone::OnBlueZoneOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
	AP1Character* Player = Cast<AP1Character>(OtherActor);
	if (Player == nullptr) return;

	ActorsToDamage.AddUnique(OtherActor);
}

그리고 특정 시간(본문에선 2초)마다 위에서 작성한 ApplyEffectToTarget() 함수로 데미지를 줄 것이다.

더보기
// ABlueZone.cpp
void ABlueZone::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
    ....
    
	TimeForDamaging += DeltaTime;
	if (TimeForDamaging > 2.f)	// 예시를 위해 2.f 하드코딩
	{
		TimeForDamaging = 0.f;
		for (AActor* ActorToDamage : ActorsToDamage)
		{
			ApplyEffectToTarget(ActorToDamage, InstantGameplayEffectClass);
		}
	}
}
블루프린트

이제 TSubclassOf<UGameplayEffect> InstantGameplayEffectClass 에 들어갈 블루프린트를 생성한다. gameplayeffect를 기반으로 블루프린트를 생성한다.

  • 우린 데미지를 한 번만 줄 것이므로 Instant 타입을 선택할 것이다.
  • Health값에 효과를 적용할 것이므로 Attriburte를 Health로 설정한다.
  • Health 값에 덧셈(- 덧셈)할 것이므로 Modifier는 Add로 설정한다.
  • 데미지인 Magnitude는 해당 글에서는 -10으로 정하겠다(-10은 하드코딩으로 데이터 시트나 커브 플롯을 통해 유연하게 설정할 수 있다. 해당 글에서는 생략하겠다).

아래와 같다.

이제 이 블루프린트를 블루존에 넣고 실행시키면 된다.

 

결과

결과는 아래와 같다. ShowDebug AbilitySystem 명령어를 통해 gas 정보를 볼 수 있다. health 값이 자기장 밖으로 나가면 변하는 것을 볼 수 있다.