우선 지난번 회고를 토대로, TitanCharacter(플레이어 캐릭터)와 BossCharacter 간 중복되는 코드를 묶어 CharacterBase로 정리해주었다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "AbilitySystemInterface.h"
#include "GameplayTagContainer.h"
#include "GameplayTagAssetInterface.h"
#include "GameplayAbilitySpecHandle.h"
#include "Titan/Characters/TitanAttributeSet.h"
#include "CharacterBase.generated.h"
class AHitbox;
UCLASS()
class TITAN_API ACharacterBase : public ACharacter, public IAbilitySystemInterface, public IGameplayTagAssetInterface
{
GENERATED_BODY()
public:
// Sets default values for this character's properties
ACharacterBase();
UFUNCTION()
virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;
UFUNCTION(Category = "Abilites|Melee")
bool ActivateMeleeAbility(bool AllowRemoteActivation = true);
UFUNCTION()
void SetMeleeAbility();
UFUNCTION()
void GetOwnedGameplayTags(FGameplayTagContainer& TagContainer) const override;
UFUNCTION(Category = "Attributes")
virtual float GetHealth() const;
UFUNCTION(Category = "Attributes")
virtual float GetMaxHealth() const;
UFUNCTION(Category = "Attributes")
virtual float GetSkillGuage() const;
UFUNCTION(Category = "Attributes")
virtual float GetMaxSkillGuage() const;
UPROPERTY()
AHitbox* Hitbox_L;
UPROPERTY()
AHitbox* Hitbox_R;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
virtual void PossessedBy(AController* NewController) override;
UPROPERTY(EditDefaultsOnly)
UTitanAttributeSet* AttributeSet;
UPROPERTY(VisibleDefaultsOnly, BlueprintReadOnly, Category = "Abilities")
UAbilitySystemComponent* AbilitySystemComponent;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Abilities|Melee")
TSubclassOf<class UGameplayAbility> MeleeAbility;
UPROPERTY()
FGameplayAbilitySpecHandle MeleeAbilitySpecHandle;
UPROPERTY(EditAnywhere, Category = "Weapon")
TSubclassOf<AHitbox> HitboxClass;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Attributes")
TSubclassOf<class UGameplayEffect> DefaultAttributeEffects;
float CharacterLevel = 0.f;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// Called to bind functionality to input
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
};
// Fill out your copyright notice in the Description page of Project Settings.
#include "CharacterBase.h"
#include "AbilitySystemComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Titan/Utils/Debug.h"
#include "AIController.h"
#include "Kismet/GameplayStatics.h"
#include "Titan/Combat/Hitbox.h"
#include "Titan/Abilities/TitanAbilitySystemComponent.h"
// Sets default values
ACharacterBase::ACharacterBase()
{
// Set this character to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
AbilitySystemComponent = CreateDefaultSubobject<UTitanAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
AbilitySystemComponent->SetIsReplicated(true);
AttributeSet = CreateDefaultSubobject<UTitanAttributeSet>(TEXT("AttributeSet"));
CharacterLevel = 1;
}
// Called when the game starts or when spawned
void ACharacterBase::BeginPlay()
{
Super::BeginPlay();
SetMeleeAbility();
if (HitboxClass)
{
Hitbox_L = GetWorld()->SpawnActor<AHitbox>(HitboxClass);
if (Hitbox_L)
{
Hitbox_L->AttachMeshToSocket(GetMesh(), TEXT("Hitbox_L"));
Hitbox_L->SetOwner(this);
Hitbox_L->SetInstigator(this);
}
Hitbox_R = GetWorld()->SpawnActor<AHitbox>(HitboxClass);
if (Hitbox_R)
{
Hitbox_R->AttachMeshToSocket(GetMesh(), TEXT("Hitbox_R"));
Hitbox_R->SetOwner(this);
Hitbox_R->SetInstigator(this);
}
}
FGameplayEffectContextHandle EffectContext = AbilitySystemComponent->MakeEffectContext();
EffectContext.AddSourceObject(this);
D("health : %f", AttributeSet->GetHealth());
D("max health : %f", AttributeSet->GetMaxHealth());
D("skill guage : %f", AttributeSet->GetSkillGuage());
D("max skill guage : %f", AttributeSet->GetMaxSkillGuage());
}
void ACharacterBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
if (!AbilitySystemComponent)
return;
FGameplayEffectContextHandle EffectContext = AbilitySystemComponent->MakeEffectContext();
EffectContext.AddSourceObject(this);
FGameplayEffectSpecHandle NewHandle = AbilitySystemComponent->MakeOutgoingSpec(DefaultAttributeEffects, CharacterLevel, EffectContext);
if (NewHandle.IsValid())
{
FActiveGameplayEffectHandle ActiveHandle =
AbilitySystemComponent->ApplyGameplayEffectSpecToTarget(*NewHandle.Data.Get(), AbilitySystemComponent);
}
}
// Called every frame
void ACharacterBase::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
UAbilitySystemComponent* ACharacterBase::GetAbilitySystemComponent() const
{
return AbilitySystemComponent;
}
void ACharacterBase::SetMeleeAbility()
{
if (!AbilitySystemComponent)
return;
MeleeAbilitySpecHandle = AbilitySystemComponent->GiveAbility(FGameplayAbilitySpec(MeleeAbility));
}
bool ACharacterBase::ActivateMeleeAbility(bool AllowRemoteActivation)
{
if (!AbilitySystemComponent || !MeleeAbilitySpecHandle.IsValid())
{
return false;
}
//D("Enemy TryActivateAbility");
return AbilitySystemComponent->TryActivateAbility(MeleeAbilitySpecHandle);
}
// Called to bind functionality to input
void ACharacterBase::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
}
void ACharacterBase::GetOwnedGameplayTags(FGameplayTagContainer& TagContainer) const
{
if (AbilitySystemComponent)
AbilitySystemComponent->GetOwnedGameplayTags(TagContainer);
}
float ACharacterBase::GetHealth() const
{
if (!AttributeSet)
return 0.f;
return AttributeSet->GetHealth();
}
float ACharacterBase::GetMaxHealth() const
{
if (!AttributeSet)
return 0.f;
return AttributeSet->GetMaxHealth();
}
float ACharacterBase::GetSkillGuage() const
{
if (!AttributeSet)
return 0.f;
return AttributeSet->GetSkillGuage();
}
float ACharacterBase::GetMaxSkillGuage() const
{
if (!AttributeSet)
return 0.f;
return AttributeSet->GetMaxSkillGuage();
}
플레이어와 보스 모두 GAS에 기반하여 공격을 수행할 것이며, HP와 같은 각종 어트리뷰트는 AttributeSet 클래스에 의해 관리될 것이다.
다음으로, 데미지 시스템을 구현하였다.
데미지 시스템 역시 기존의 ApplyDamage 함수를 활용한 방식이 아닌, GAS 특유의 방식으로 구현해 보았다.
우선, 공격 판정을 담당하는 Hitbox 클래스의 구현은 다음과 같다.
// Fill out your copyright notice in the Description page of Project Settings.
#include "Hitbox.h"
#include "Components/BoxComponent.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Components/SceneComponent.h"
#include "HitInterface.h"
#include "Kismet/GameplayStatics.h"
#include "Titan/Utils/Debug.h"
#include "AbilitySystemBlueprintLibrary.h"
#include "GameplayTagContainer.h"
#include "GameplayAbilitySpec.h"
#include "GameplayTagAssetInterface.h"
#include "Titan/Characters/CharacterBase.h"
// Sets default values
AHitbox::AHitbox()
{
//...
}
//...
void AHitbox::OnCollisionBoxBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
//...
FHitResult BoxHit;
BoxTrace(BoxHit);
if (BoxHit.GetActor())
{
if (IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor()))
{
//...
SendDamageEvent(BoxHit.GetActor(), BoxHit);
//...
}
}
}
void AHitbox::SendDamageEvent(AActor* HitActor, const FHitResult& Hit)
{
FGameplayEventData Payload;
Payload.Instigator = this->GetOwner();
Payload.Target = HitActor;
if (ACharacterBase* ThisCharacter = GetOwner<ACharacterBase>())
{
ThisCharacter->GetOwnedGameplayTags(Payload.InstigatorTags);
}
if (ACharacterBase* Target = Cast<ACharacterBase>(HitActor))
{
Target->GetOwnedGameplayTags(Payload.TargetTags);
}
Payload.TargetData = UAbilitySystemBlueprintLibrary::AbilityTargetDataFromActor(HitActor);
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(
this->GetOwner(), FGameplayTag::RequestGameplayTag(TEXT("Damage.Melee")), Payload);
}
void AHitbox::BoxTrace(FHitResult& BoxHit)
{
const FVector Start = BoxTraceStart->GetComponentLocation();
const FVector End = BoxTraceEnd->GetComponentLocation();
TArray<AActor*> ActorsToIgnore;
ActorsToIgnore.AddUnique(this);
ActorsToIgnore.AddUnique(GetOwner());
for (AActor* Actor : IgnoreActors)
{
ActorsToIgnore.AddUnique(Actor);
}
UKismetSystemLibrary::BoxTraceSingle(
this,
Start,
End,
BoxTraceSize,
BoxTraceStart->GetComponentRotation(),
ETraceTypeQuery::TraceTypeQuery1,
false,
ActorsToIgnore,
EDrawDebugTrace::ForDuration,
BoxHit,
true
);
IgnoreActors.AddUnique(BoxHit.GetActor());
}
//...
- WeaponCollisionBox->OnComponentBeginOverlap이 트리거될 시 OnCollisionBoxBeginOverlap이 호출됨
- OnCollisionBoxOverlap에서 BoxTrace 수행, 성공 시 SendDamageEvent 호출
- SendDamageEvent에서는 GameplayEventData에 Instigator, Target 등의 정보를 저장 후, 해당 정보를 GameplayTag(Damage.Melee)와 함께 액터에게 날려줌
이 SendDamageEvent 부분이 바로 기존의 방식과는 다른, GAS를 활용하여 데미지 시스템을 구현하는 방식이다.
이렇게 보낸 이벤트는 공격을 담당하는 GameplayAbility인 GA_Melee가 수신하여, 적에게 데미지를 주게 된다.

이 부분은 예외적으로 블루프린트 쪽이 C++에 비해 구현이 확연히 간편하여, 블루프린트로 구현하였다.
ActivateAbility, 즉 공격을 수행한 직후 Damage.Melee 태그가 붙은 이벤트를 기다리며,
만약 Hitbox가 해당 이벤트를 쏴주었다면, GameplayEffect를 타겟에게 적용한다. (ApplyGameplayEffectToTarget)
해당 GameplayEffect가 적에게 데미지를 주는 효과를 의미한다.


BT/EQS를 디버그할 때와 마찬가지로, 어퍼스트로피 키를 눌러 Attribute의 변화를 직접 확인하며 데미지 시스템이 제대로 구현된 것을 확인하였다.
GAS를 통한 데미지 구현에 대한 회고:
- 좋았던 점
- AttributeSet과 GameplayEvent를 통한 데미지 시스템의 모듈화
- 직접 ApplyDamage를 호출하지 않고, 단지 SendMessage를 수행하면 데미지 처리는 GameplayEvent가 수행
- 스크립트 레벨에서 직접 체력의 증감을 조절하는 것에 비해 모듈화가 되었다고 할 수 있음
- 직접 ApplyDamage를 호출하지 않고, 단지 SendMessage를 수행하면 데미지 처리는 GameplayEvent가 수행
- GameplayTag를 통해 클래스 간 의존성이 없도록 함
- GA_Melee에선 공격 실행 직후 Damage.Melee 태그가 달린 메시지를 기다리고, Hitbox는 자기 나름대로 타격 판정 성공 시 Damage.Melee 태그가 달린 메시지를 전송
- 여기서 GA_Melee와 Hitbox는 서로에 대해 의존성을 갖지 않고, ASC가 달린 액터를 통해 해당 메시지가 처리되기 때문에 클래스 간 의존성이 없음
- AttributeSet과 GameplayEvent를 통한 데미지 시스템의 모듈화
- 아쉬웠던 점
- 복잡성
- 사실 AttributeSet 대신 Actor Attribute 클래스를, GameplayEvent 대신 ApplyDamage 함수를 사용했다면 훨씬 구현이 간편했을 것
- 대형 RPG 게임 / MOBA 게임과 같이 캐릭터 레벨 별 최대체력 증가, 최대 스태미너 증가, 매우 다양한 스킬 등 복잡한 프로젝트였다면 이번과 같은 복잡성을 감수할 가치가 있었을 것 같지만, 레벨 시스템이 없고 보스전 하나 뿐인 본 프로젝트에서는 살짝 오버엔지니어링처럼 느껴졌음
- 다음 프로젝트에서 비슷한 작업을 하게 된다면, 레벨 증가에 따라 Attribute가 바뀌는지, 스킬의 개수가 많은지 등에 따라 GAS를 도입할지 말지 신중하게 검토할 듯
- 복잡성
https://github.com/sys010611/Titan/commit/b5ef4754da04176c16e8954537bc89d11552a374
데미지 판정 구현 · sys010611/Titan@b5ef475
Some content is hidden Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
github.com
'언리얼 엔진 5 > 액션RPG 게임' 카테고리의 다른 글
| [UE5] 시네마틱 보스배틀 게임 개발일지 (5) - 플레이어 준비 + 콤보 공격 구현(모션 매칭, GAS) (1) | 2025.09.25 |
|---|---|
| [UE5] 시네마틱 보스배틀 게임 개발일지 (4) - 보스 공격 구현(GAS) (0) | 2025.09.03 |
| [UE5] 시네마틱 보스배틀 게임 개발일지 (3) - 보스 움직임 구현(BT + EQS) (2) | 2025.08.30 |
| [UE5] 시네마틱 보스배틀 게임 개발일지 (2) - 레벨, 보스 에셋 준비 (0) | 2025.08.27 |
| [UE5] 시네마틱 보스배틀 게임 개발일지 (1) - 시작 (0) | 2025.08.26 |