
처음 언리얼로 개발을 시작했을 땐 데미지를 주는 함수를 직접 작성했었다.
물론 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의 사용법을 좀 더 알아보고 다시 포스트하겠다.