게임 개발 메모장
UE5 게임 프로그래머 포트 폴리오 본문
적용 기술 : GAS ( Gameplay Ability System)
목차
1. 초기화 ( Ability System Component 생성 )
2. 게임 어빌리티를 활용한 플레이어 캐릭터 공격 과정
3. Enemy 캐릭터
※ 좌,우 슬라이드로 조정하지 않아도 해당 코드 블록을 마우스 왼쪽으로 클릭을 해서 키보드 ' ←' '→' 키를 사용해서 좌, 우로 코드를 볼 수 있습니다.
1. 초기화 ( Ability System Component 생성 )
Character 나 Actor에 클래스에 Component ( 구성 요소 ) 로 부착하는 방식입니다.
- AYjCharacterBase 클래스 코드 일부 ( OwnerActor )
void AYjCharacterBase::InitializeDefaultAttributes() const
{
// 서버 Section : 캐릭터의 속성들을 가지고 있는 이펙트들을 카테고리 별로 3종류로 나눔.
ApplyEffectToSelf(DefaultPrimaryAttributes, 1.f);
ApplyEffectToSelf(DefaultSecondaryAttributes, 1.f);
ApplyEffectToSelf(DefaultVitalAttributes, 1.f);
}
void AYjCharacterBase::ApplyEffectToSelf(TSubclassOf<UGameplayEffect> GameplayEffectClass, float Level) const
{
// 게임 플레이 이펙트를 자신에게 적용 시킴. ( 일종의 바인딩 작업 )
check(IsValid(GetAbilitySystemComponent()));
check(GameplayEffectClass);
FGameplayEffectContextHandle ContextHandle = GetAbilitySystemComponent()->MakeEffectContext();
ContextHandle.AddSourceObject(this);
const FGameplayEffectSpecHandle SpecHandle = GetAbilitySystemComponent()->MakeOutgoingSpec(GameplayEffectClass, Level, ContextHandle);
GetAbilitySystemComponent()->ApplyGameplayEffectSpecToTarget(*SpecHandle.Data.Get(), GetAbilitySystemComponent());
}
void AYjCharacterBase::AddCharacterAbilities()
{
UYjAbilitySystemComponent* YjASC = CastChecked<UAuraAbilitySystemComponent>(AbilitySystemComponent);
if (false == HasAuthority())
{
return;
}
// 초기 두 종류의 어빌리티를 등록한다.
YjASC->AddCharacterAbilities(StartupAbilities);
YjASC->AddCharacterPassiveAbilities(StartupPassiveAbilities);
}
- AYjCharacter 클래스 코드 일부 ( AvatarActor )
/* 빙의 함수를 호출해 캐릭터의 오너로 설정됨 (서버에서만 호출) */
void AYjCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
InitAbilityActorInfo();
LoadProgress();
AYjGameModeBase* const YjGameModeBase = Cast<AYjGameModeBase>(UGameplayStatics::GetGameMode(this));
if(YjGameModeBase == nullptr)
{
return;
}
YjGameModeBase->LoadWorldState(GetWorld());
}
/* 서버에서 저장 정보들을 플레이어 스테이트에 Load 하는 과정 */
void AYjCharacter::LoadProgress()
{
AYjGameModeBase* YjGameModeBase = Cast<AYjGameModeBase>(UGameplayStatics::GetGameMode(this));
if(YjGameModeBase == nullptr)
{
return;
}
ULoadScreenSaveGame* const SaveData = YjGameModeBase->RetrieveInGameSaveData();
if (SaveData == nullptr)
{
return;
}
if (SaveData->bFirstTimeLoadIn)
{
InitializeDefaultAttributes();
AddCharacterAbilities();
}
else
{
UYjAbilitySystemComponent* YjAbilitySystemComponent = Cast<UYjAbilitySystemComponent>(AbilitySystemComponent);
if(YjAbilitySystemComponent == nullptr)
{
return;
}
YjAbilitySystemComponent->AddCharacterAbilitiesFromSaveData(SaveData);
AYjPlayerState* YjPlayerState = Cast<AYjPlayerState>(GetPlayerState());
if (YjPlayerState == nullptr)
{
return;
}
YjPlayerState->SetLevel(SaveData->PlayerLevel);
YjPlayerState->SetXP(SaveData->XP);
YjPlayerState->SetAttributePoints(SaveData->AttributePoints);
YjPlayerState->SetSpellPoints(SaveData->SpellPoints);
UYjAbilitySystemLibrary::InitializeDefaultAttributesFromSaveData(this, AbilitySystemComponent, SaveData);
}
}
/* 클라이언트 쪽 어빌리티 액터 초기화 */
void AYjCharacter::InitAbilityActorInfo()
{
/* 어빌리티 액터 정보 초기화 */
AYjPlayerState* const YjPlayerState = GetPlayerState<AYjPlayerState>();
if(nullptr == YjPlayerState)
{
return;
}
YjPlayerState->GetAbilitySystemComponent()->InitAbilityActorInfo(YjPlayerState, this);
Cast<UYjAbilitySystemComponent>(YjPlayerState->GetAbilitySystemComponent())->AbilityActorInfoSet();
/* 어빌리티 시스템 컴포넌트와 어트리뷰트 셋은 각각 클라이언트의 게임 스테이트가 가짐.*/
AbilitySystemComponent = YjPlayerState->GetAbilitySystemComponent();
AttributeSet = YjPlayerState->GetAttributeSet();
OnAscRegistered.Broadcast(AbilitySystemComponent);
AYjPlayerController* const YjPlayerController = Cast<AYjPlayerController>(GetController());
if (nullptr == YjPlayerController)
{
return;
}
AYjHUD* const YjHUD = Cast<AYjHUD>(YjPlayerController->GetHUD());
if (nullptr == YjHUD)
{
return;
}
/* HUD 초기화 */
YjHUD->InitOverlay(YjPlayerController, YjPlayerState, AbilitySystemComponent, AttributeSet);
}
2. 게임 어빌리티를 활용한 플레이어 캐릭터 공격 과정
[ 플레이어 캐릭터의 파이어볼 발사 ]
- YjPlayerController
* 키보드 입력 바인딩 세팅 ( 마우스 오른쪽 버튼 : 파이어볼 발사 ) ,
* 오른쪽 버튼 InputTag가 입력이 되면 YjPlayerController에서
GetASC()->AbilityInputTagHeld() 호출 해서 InputTag를 넘깁니다.
void AYjPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
UYjInputComponent* const YjInputComponent = CastChecked<UYjInputComponent>(InputComponent);
YjInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AYjPlayerController::Move);
YjInputComponent->BindAction(ShiftAction, ETriggerEvent::Started, this, &AYjPlayerController::ShiftPressed);
YjInputComponent->BindAction(ShiftAction, ETriggerEvent::Completed, this, &AYjPlayerController::ShiftReleased);
YjInputComponent->BindAbilityActions(InputConfig, this, &ThisClass::AbilityInputTagPressed, &ThisClass::AbilityInputTagReleased, &ThisClass::AbilityInputTagHeld);
}
void AYjPlayerController::AbilityInputTagHeld(FGameplayTag InputTag)
{
if(nullptr == GetASC())
{
return;
}
if (true == GetASC()->HasMatchingGameplayTag(FYjGameplayTags::Get().Player_Block_InputHeld))
{
return;
}
// InputTag가 LMB(왼쪽 마우스 버튼 클릭)가 아니면
if (false == InputTag.MatchesTagExact(FYjGameplayTags::Get().InputTag_LMB))
{
GetASC()->AbilityInputTagHeld(InputTag);
return;
}
if (TargetingStatus == ETargetingStatus::TargetingEnemy || bShiftKeyDown)
{
GetASC()->AbilityInputTagHeld(InputTag);
}
else
{
FollowTime += GetWorld()->GetDeltaSeconds();
if (true == CursorHit.bBlockingHit)
{
CachedDestination = CursorHit.ImpactPoint;
}
APawn* const ControlledPawn = GetPawn();
if (nullptr == ControlledPawn)
{
return;
}
const FVector WorldDirection = (CachedDestination - ControlledPawn->GetActorLocation()).GetSafeNormal();
ControlledPawn->AddMovementInput(WorldDirection);
}
}
- YjAbilitySystemComponent
* TryActivateAbility()를 호출하여 InputTag가 있는 Ability를 활성화 시킵니다.
void UYjAbilitySystemComponent::AbilityInputTagHeld(const FGameplayTag& InputTag)
{
if (false == InputTag.IsValid())
{
return;
}
FScopedAbilityListLock ActiveScopeLoc(*this);
for (FGameplayAbilitySpec& AbilitySpec : GetActivatableAbilities())
{
if (true == AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag))
{
AbilitySpecInputPressed(AbilitySpec);
if (false == AbilitySpec.IsActive())
{
TryActivateAbility(AbilitySpec.Handle);
}
}
}
}
- AN_MontageEvent ( 몽타쥬 노티파이 )
* 해당 메시 컴포넌트를 소유한 액터에게 Event Tag를 전달할 수 있게 세팅합니다.
- AM_Cast_FireBolt (애님 몽타쥬)
: AN_MontageEvent ( 노티파이 ) 를 원하는 동작에 세팅을 하고 EventTag (Event.Montage.FireBolt) 설정
- GA_FireBolt ( 게임 어빌리티 )
발사체 클래스, 데미지 이펙트 클래스, 데미지 타입, 입력 태그 (마우스 오른쪽),
어빌리티 태그 세팅
(Ability Tags : 해당 어빌리티가 가지는 태그, 어빌리티의 식별자 역할을한다. 이 태그를 통해 어빌리티를 활성, 취소, 금지할 수 있다.
Block Abilities with Tag : 이 어빌리티가 실행되는 동안 해당 태그를 가진 어빌리티는 금지됨)
마우스 오른쪽 버튼을 클릭을 하면 GA_FireBolt 의 ActivateAbility 이벤트 가 실행 됩니다.
오른쪽 버튼을 눌렀을 때 Avatar 캐릭터가 타겟 위치로 방향을 돌려서 타겟팅 될 액터의 위치 값을 가지고 발사체를 스폰하고, 스폰될 발사체 액터는 Input SocketTag 위치에서 스폰됩니다.
[ Enemy의 데미지 이펙트 적용 ]
발사체 콜리젼과 Enemy의 콜리젼이 오버랩되면 해당 발사체의 데미지에 따라서 Damage 이펙트가 적용이되고이펙트 적용이 끝나면 PostGameplayEffectExecute() 가 호출이되면서 Enemy의 HP가 차감이 되고,
Effects_HitReact 태그에 의해서 Hit 어빌리티 활성화 이벤트가 호출됩니다.
- 발사체 콜리젼 오버랩
void AYjProjectile::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
UGameplayStatics::PlaySoundAtLocation(this, ImpactSound, GetActorLocation(), FRotator::ZeroRotator);
UNiagaraFunctionLibrary::SpawnSystemAtLocation(this, ImpactEffect, GetActorLocation());
LoopingSoundComponent->Stop();
if (true == HasAuthority()) // 서버
{
UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(OtherActor)
if (UAbilitySystemComponent* const TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(OtherActor))
{
TargetASC->ApplyGameplayEffectSpecToSelf(*DamageEffectSpecHandle.Data.Get());
}
Destroy();
}
else
{
bHit = true;
}
}
- 데미지 이펙트 실행 후 조건에 따라 HitReact 어빌리티 활성화 과정
void UYjAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
FEffectProperties Props;
SetEffectProperties(Data, Props);
if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()));
}
if (Data.EvaluatedData.Attribute == GetManaAttribute())
{
SetMana(FMath::Clamp(GetMana(), 0.f, GetMaxMana()));
}
if (Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
{
const float LocalIncomingDamage = GetIncomingDamage(); // 데미지의 경우에는 MetaAttribute이기 때문에 복제 대상이 아님 (잠깐 사용)
SetIncomingDamage(0.f);
if (LocalIncomingDamage > 0.f)
{
const float NewHealth = GetHealth() - LocalIncomingDamage;
SetHealth(FMath::Clamp(NewHealth, 0.f, GetMaxHealth()));
const bool bFatal = NewHealth <= 0.f; // HP가 0 이하로 떨어지지 않았다면 HitReact 어빌리티 활성화 시도
if (false == bFatal)
{
FGameplayTagContainer TagContainer;
TagContainer.AddTag(FYjGameplayTags::Get().Effects_HitReact);
Props.TargetASC->TryActivateAbilitiesByTag(TagContainer);
}
}
}
}
- Enemy 클래스에서 HitReact Effect가 활성화 될 때, WalkSpeed를 감소 시킴,
void AYjEnemy::BeginPlay()
{
Super::BeginPlay();
GetCharacterMovement()->MaxWalkSpeed = BaseWalkSpeed;
InitAbilityActorInfo();
// ---- 생략 -----
if (const UYjAttributeSet* AuraAS = Cast<UYjAttributeSet>(AttributeSet))
{
// ---- 생략 -----
AbilitySystemComponent->RegisterGameplayTagEvent(FYjGameplayTags::Get().Effects_HitReact, EGameplayTagEventType::NewOrRemoved).AddUObject(
this,
&AYjEnemy::HitReactTagChanged
);
}
// ---- 생략 -----
}
void AYjEnemy::HitReactTagChanged(const FGameplayTag CallbackTag, int32 NewCount)
{
bHitReacting = NewCount > 0;
GetCharacterMovement()->MaxWalkSpeed = bHitReacting ? 0.f : BaseWalkSpeed;
}
- GA_HitReact 의 ActivateAbility 이벤트가 호출되면서 HitReact 몽타쥬를 실행 시킴
- Damage Effect가 적용될 때 Enemy의 HP가 0이 되면 죽음
void UYjAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
// --------------- 생략 -----------------
if (Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
{
const float LocalIncomingDamage = GetIncomingDamage();
SetIncomingDamage(0.f);
if (LocalIncomingDamage > 0.f)
{
const float NewHealth = GetHealth() - LocalIncomingDamage;
SetHealth(FMath::Clamp(NewHealth, 0.f, GetMaxHealth()));
const bool bFatal = NewHealth <= 0.f;
if (true == bFatal)
{
ICombatInterface* const CombatInterface = Cast<ICombatInterface>(Props.TargetAvatarActor);
if(nullptr == CombatInterface)
{
return;
}
CombatInterface->Die(); // HP가 0 이하면 Die 함수를 호출
}
else
{
FGameplayTagContainer TagContainer;
TagContainer.AddTag(FYjGameplayTags::Get().Effects_HitReact);
Props.TargetASC->TryActivateAbilitiesByTag(TagContainer);
}
}
}
}
3. Enemy 캐릭터
- YjEnemy 클래스에서 서버가 해당 액터를 소유할 때, AI 컨트롤러, 블랙보드 값들을 초기화 시키고
비헤이비어 트리를 실행 시킴
void AYjEnemy::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
// * 서버 Section
if (false == HasAuthority())
{
return;
}
YjAIController = Cast<AYjAIController>(NewController);
YjAIController->GetBlackboardComponent()->InitializeBlackboard(*BehaviorTree->BlackboardAsset);
YjAIController->RunBehaviorTree(BehaviorTree);
YjAIController->GetBlackboardComponent()->SetValueAsBool(FName("HitReacting"), false);
YjAIController->GetBlackboardComponent()->SetValueAsBool(FName("RangedAttacker"), CharacterClass != ECharacterClass::Warrior);
}
- 비헤이비어 트리에서 매 틱마다 타겟을 탐색하는 서비스 구현
void UBTService_FindNearestPlayer::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APawn* const OwningPawn = AIOwner->GetPawn();
//* 소유한 폰이 플레이어인지 Enemy인지 확인 "피아 식별"
const FName TargetTag = OwningPawn->ActorHasTag(FName("Player")) ? FName("Enemy") : FName("Player");
TArray<AActor*> ActorsWithTag;
UGameplayStatics::GetAllActorsWithTag(OwningPawn, TargetTag, ActorsWithTag);
//* 타겟 액터 중 가장 가까운 액터와, 가장 가까운 거리를 구함
float ClosestDistance = TNumericLimits<float>::Max();
AActor* ClosestActor = nullptr;
for (AActor* Actor : ActorsWithTag)
{
if (IsValid(Actor) && IsValid(OwningPawn))
{
const float Distance = OwningPawn->GetDistanceTo(Actor);
if (Distance < ClosestDistance)
{
ClosestDistance = Distance;
ClosestActor = Actor;
}
}
}
//* 블랙 보드에 갱신
UBTFunctionLibrary::SetBlackboardValueAsObject(this,TargetToFollowSelector, ClosestActor);
UBTFunctionLibrary::SetBlackboardValueAsFloat(this, DistanceToTargetSelector, ClosestDistance);
}
- Enemy BehaviorTree 구현
RangeAttacker ( 범위 공격자 클래스 ), MeleeAttacker ( 근접 공격자 클래스 ) 두 가지로 분류를 했습니다.조건은 Enemy 클래스가 초기화 될 때, 종류에 따라서 ECharacterClass 변수를 초기화 시켜주는데 Warrior (근접)만 아니면 블랙보드의 bRangeAttacker 가 true가 되게 하였습니다.
void AYjEnemy::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
if (false == HasAuthority()
{
return;
}
YjAIController = Cast<AYjAIController>(NewController);
YjAIController->GetBlackboardComponent()->InitializeBlackboard(*BehaviorTree->BlackboardAsset);
YjAIController->RunBehaviorTree(BehaviorTree);
YjAIController->GetBlackboardComponent()->SetValueAsBool(FName("HitReacting"), false);
YjAIController->GetBlackboardComponent()->SetValueAsBool(FName("RangedAttacker"), CharacterClass != ECharacterClass::Warrior);
}
BT의 Selector가 RangeAttacker가 아니면 MeleeAttacker 쪽 브랜치를 실행시켜주는데 Player와 거리가 충분한지 계산을 한 뒤에 Sequence 노드가 실행이 됩니다. 타겟(Player)에 도달한 뒤에 BTT_Attack 태스크가 실행이 됩니다.
BTT_Attack 태스크가 실행이 되면 블랙보드의 TargetSelector를 현재 컨트롤되고 있는 Enemy 액터의 SetCombatTarget
함수를 호출해서 넘겨주고 현재 액터가 가지고 있는 AttackTag가 부여된 Ability를 실행 시켜줍니다.
Abilities.Attack 태그가 부여된 GA_MeleeAttack 어빌리티가 실행이됩니다.
GE_Damage 이펙트가 실행이되고 해당 객체의 Attribute의 HP에 - Damage 수치가 적용이 되어
리플리케이티드가 되게 하였습니다.