언리얼 Damage 프레임워크

김지윤·2025년 2월 12일

UE5_MultiplayerShooter

목록 보기
10/25
post-thumbnail

처음 언리얼로 개발을 시작했을 땐 데미지를 주는 함수를 직접 작성했었다.
물론 HP만 줄이면 끝나는 간단한 소규모 프로젝트에선 그렇게 해도 되지만, 언리얼의 프레임워크는 타격 지점에 대한 추가 피해(헤드샷 등), 범위 공격 시 거리에 따른 데미지 감쇠, 속성(화염, 전기 등)에 의한 효과 같은 다양한 상황에 모두 적용할 수 있도록 제공한다.
그러니까, 안 쓸 이유가 없다.

아래는 강의를 따라간 내용이다.

void AProjectileBullet::OnHit(UPrimitiveComponent* HitComp,
	AActor* OtherActor,
	UPrimitiveComponent* OtherComp,
	FVector NormalImpulse,
	const FHitResult& Hit)
{
	if (ACharacter* InstigatorCharacter = Cast<ACharacter>(GetInstigator()))
	{
		if (AController* InstigatorController = InstigatorCharacter->Controller)
		{
			UGameplayStatics::ApplyDamage(OtherActor, Damage, InstigatorController, this, UDamageType::StaticClass());
		}
	}
	Super::OnHit(HitComp, OtherActor, OtherComp, NormalImpulse, Hit);
}

기존 Projectile을 상속받은 ProjectileBullet이 OnHit을 오버라이드했다.
안전하게 null체크를 한 뒤 GameplayStatics의 ApplyDamage를 호출한다.
ApplyDamage -> TakeDamage(Actor의 함수) -> OnTakeAnyDamage(델리게이트) 순서로 호출된다.
솔직히 언리얼 초급 개발자 입장에선 ApplyDamage와 TakeDamage의 내부 구현까지 알 필요는 없다.
ApplyDamage를 호출하면 OnTakeAnyDamage에 바인딩된 함수들이 호출된다는 사실만 기억하면 된다.
다음은 OnTakeAnyDamage에 함수를 바인딩하는 일이다.

void ABlasterCharacter::BeginPlay()
{
	Super::BeginPlay();

	UpdateHUDHealth();
	
	if (HasAuthority())
	{
		OnTakeAnyDamage.AddDynamic(this, &ABlasterCharacter::ReceiveDamage);
	}
}

void ABlasterCharacter::ReceiveDamage(AActor* DamagedActor,
	float Damage,
	const UDamageType* DamageType,
	AController* InstigatorController,
	AActor* DamageCauser)
{
	// 서버에서 실행되는 함수
	// Health는 클라이언트에 복제되어 OnRep_Health를 호출
	Health = FMath::Clamp(Health - Damage, -0.f, MaxHealth);
    
	// 서버가 보는 애니메이션 재생과 HUD 업데이트
	PlayHitReactMontage();
	if (IsLocallyControlled())
	{
		UpdateHUDHealth();
	}
}

void ABlasterCharacter::OnRep_Health()
{
	PlayHitReactMontage();
	UpdateHUDHealth();
}

OnTakeAnyDamage에 ReceiveDamage를 바인딩했고, 이는 서버에서 호출되는 함수다.
서버와 클라이언트가 조작하는 Pawn들은 모두 서버에게 권한이 있기 때문이다.
때문에 서버는 ReceiveDamage의 호출을 알 수 있고, HitReact 애니메이션 재생이 가능하다.
그리고 그 아래 OnRep_Health를 보면 알 수 있듯, Health는 Replicate되는 변수기 때문에 클라이언트도 이를 알 수 있고, 값이 변경되면 자동으로 해당 함수가 호출되어 클라이언트도 애니메이션이 재생된다.
따라서 기존 MulticastHitReact 함수는 삭제했다.

(HUD를 거쳐 HP를 ProgressBar로 보여주는 건 간단해서 생략했다.)

여기까진 강의에서 구현한 내용이고, 이 아래로는 내가 추가적으로 학습한 내용이다.
당장 내부 구현까지 알기엔 너무 복잡해보이고, 매개변수들이 어떤 역할을 하는지, 다른 Damage 프레임워크는 뭐가 있는지 찾아봤다.

// ApplyDamage의 매개변수들, 데미지를 줄 때 호출
AActor* DamagedActor,						// 피해를 받을 대상
float BaseDamage,							// 적용할 데미지
AController* EventInstigator,				// 데미지를 유발한 대상의 컨트롤러
AActor* DamageCauser,						// 실제 데미지 유발 액터(총알 등)
TSubclassOf<UDamageType> DamageTypeClass	// 적용할 데미지 타입!

// OnTakeAnyDamage의 매개변수들, 데미지를 받을 때 호출
AActor* DamagedActor,						// 피해를 받을 대상(자신)
float Damage,								// 적용할 데미지
const UDamageType* DamageType,				// 적용할 데미지 타입!
AController* InstigatorController,			// 데미지를 유발한 대상의 컨트롤러
AActor* DamageCauser						// 실제 데미지 유발 액터(총알 등)

여기서 적용할 데미지 타입, UDamageType은 눈여겨볼 만하다.
이는 데미지의 속성(화염, 전기 등)을 정의하는 클래스이며, 강의 내용처럼 UDamageType::StaticClass()로 전달 시 별다른 효과 없이 기본적인 데미지만 적용된다.
이 클래스를 상속받는 클래스를 만들어 데미지의 속성을 정의해줄 수 있다.
강의에 없는 내용이지만 화상 데미지 속성을 한 번 만들어봤다.

UCLASS()
class BLASTER_API UBaseDamageType : public UDamageType
{
    GENERATED_BODY()

public:
    virtual void ApplyCustomEffect(AActor* DamagedActor, AController* Instigator) const;
};


void UBaseDamageType::ApplyDamageTypeEffect(AActor* DamagedActor, AController* Instigator) const
{
	// 오버라이드 필요
}

UBaseDamageType을 선언, 가상 함수를 하나 선언한다.

	UPROPERTY(BlueprintReadOnly, Category="Damage")
	float BurnDamage = 3.f;

	UPROPERTY(BlueprintReadOnly, Category="Damage")
	int NumberOfTimesToBurn = 5; // 총 n번 데미지
	
	UPROPERTY(BlueprintReadOnly, Category="Damage")
	float BurnDelay = 2.f; // n초마다 데미지

	// 타이머 추적용
	UPROPERTY(BlueprintReadOnly, Category="Damage")
	mutable FTimerHandle BurnTimer;
	
	UPROPERTY(BlueprintReadOnly, Category="Damage")
	mutable int BurnCount = 0; // 현재 데미지 준 회수 추적

	virtual void ApplyDamageTypeEffect(AActor* DamagedActor, AController* Instigator);
	void BurnTimerStart(AActor* DamagedActor, AController* Instigator);
	void BurnTimerFinished(AActor* DamagedActor, AController* Instigator);

void UBurnDamageType::ApplyDamageTypeEffect(AActor* DamagedActor, AController* Instigator)
{
    if (DamagedActor && Instigator)
    {
        BurnTimerStart(DamagedActor, Instigator);
    }
}

void UBurnDamageType::BurnTimerStart(AActor* DamagedActor, AController* Instigator)
{
    if (UWorld* World = DamagedActor->GetWorld())
    {
        World->GetTimerManager().SetTimer(
            BurnTimer,
            [this, DamagedActor, Instigator]()
            {
                BurnTimerFinished(DamagedActor, Instigator);
            },
            BurnDelay,
            false
        );
    }
}

void UBurnDamageType::BurnTimerFinished(AActor* DamagedActor, AController* Instigator)
{
    // 여기선 속성 없이 데미지 부여
    UGameplayStatics::ApplyDamage(DamagedActor, BurnDamage, Instigator, Instigator, UDamageType::StaticClass());

    // Count 세면서 다시 타이머 시작
    BurnCount++;
    if (BurnCount < NumberOfTimesToBurn)
    {
        BurnTimerStart(DamagedActor, Instigator);
    }
}
void ABlasterCharacter::ReceiveDamage(AActor* DamagedActor,
	float Damage,
	const UDamageType* DamageType,
	AController* InstigatorController,
	AActor* DamageCauser)
{
	// 서버에서 실행되는 함수
	// Health는 클라이언트에 복제되어 OnRep_Health를 호출
	Health = FMath::Clamp(Health - Damage, -0.f, MaxHealth);
    
	// DamageType에 의한 추가 효과
    // const로 받기 때문에 Timer를 사용할 수 없거나, Destroy가 안 되는 등 문제가 많음
    // 그래서 const_cast를 통해 const를 떼고 사용
	if (UDamageType* MutableDamageType = const_cast<UDamageType*>(DamageType))
	{
		if (UBaseDamageType* CastedDamageType = Cast<UBaseDamageType>(MutableDamageType))
		{
			CastedDamageType->ApplyDamageTypeEffect(DamagedActor, InstigatorController);
		}
	}
    
	// 서버가 보는 애니메이션 재생과 HUD 업데이트
	PlayHitReactMontage();
	if (IsLocallyControlled())
	{
		UpdateHUDHealth();
	}
}

ReceiveDamage에서 DamageType을 BaseDamageType으로 캐스트해 ApplyDamageTypeEffect를 호출한다.
모든 DamageType에 대응할 수 있도록 만들었다.

void AProjectileBullet::OnHit(UPrimitiveComponent* HitComp,
	AActor* OtherActor,
	UPrimitiveComponent* OtherComp,
	FVector NormalImpulse,
	const FHitResult& Hit)
{
	if (ACharacter* InstigatorCharacter = Cast<ACharacter>(GetInstigator()))
	{
		if (AController* InstigatorController = InstigatorCharacter->Controller)
		{
			UGameplayStatics::ApplyDamage(OtherActor, Damage, InstigatorController, this, UBurnDamageType::StaticClass());
		}
	}
	Super::OnHit(HitComp, OtherActor, OtherComp, NormalImpulse, Hit);
}

그 다음 ProjectileBullet의 ApplyDamage호출 매개변수를 UBurnDamageType::StaticClass()로 변경했다.

좌상단 HP바를 보면 잘 작동한다.
캐릭터는 이 BaseDamageType을 멤버변수로 갖고 있다가 원하는 타이밍에 ClearTimer, Destroy하면 될 것 같다.

하지만.. const_cast를 사용했단 건 설계에 문제가 있다는 뜻이다.
언리얼이 괜히 const를 붙였을 리 없다.
DamageType의 사용법을 좀 더 알아보고 다시 포스트하겠다.

profile
공부한 거 시간 날 때 작성하는 곳

0개의 댓글