12.03 - TIL

김혁·2025년 12월 3일

TIL

목록 보기
69/84

오늘의 코드카타

  • 억억단을 외우자
    • 구구단을 확장해서 억억단을 외우는 것을 테스트한다고 특정 범위에서 가장 많이 나오는 수를 찾는 문제이다. 근데 범위는 5백만이 최대이긴 하다...
    • 두 개의 곱으로 가장 많이 나타나는 수를 찾는 것이기 때문에 모든 수의 약수의 개수를 먼저 구했다.
    • 이후에 s부터 e까지의 수 중에서 가장 많이 나온 수를 찾는 것이기 때문에 dp를 통해 e부터 1까지의 가장 많이 나온 수를 먼저 계산했다.
    • https://school.programmers.co.kr/learn/courses/30/lessons/138475
  • 금과 은 운반하기
    • 금과 은을 각각 도시에서 가져올 때, 걸리는 시간 중에서 가장 빠른 시간을 찾는 방법
    • 가져와야 하는 금과 은의 양이 상당히 크고, 걸리는 시간까지 계산하면 최악의 경우 값이 매우 커진다.
    • 따라서 시간을 계산할 때, O(logN)의 시간복잡도를 가진 알고리즘을 사용해야 하는데, 이분탐색을 사용해서 계산을 했다.
    • 특정 시간이 조건에 맞는지 확인할 때는, 금과 은의 양을 운반했을 때의 양과, 둘 모두 동시에 운반할 수 있는 것이 아니기 때문에, 합계를 비교해서 조건을 확인했다.
    • https://school.programmers.co.kr/learn/courses/30/lessons/86053

팀 프로젝트 진행 내용

1. 파쿠르 불안정 해결

  • 리슨 서버를 기준으로 파쿠르 동작이 상당히 불안정하다는 것을 테스트로 알게 되어서 해당 부분을 수정하고자 했다.
  • 이를 해결하고, 추가적으로 물건을 들거나 특정 상황에서 파쿠르 동작을 불가능하게 하기 위해서 GAS의 GameplayAbility로 변형을 하고자 했다.
  • 기존의 파쿠르를 검사하는 로직과 Chooser Table을 통해 가능한 몽타주를 고르고 모션 매칭으로 최종적인 애니메이션을 선택하는 로직은 그대로 사용을 했다.
// AO_GameplayAbility_Traversal.h

UCLASS()
class AO_API UAO_GameplayAbility_Traversal : public UGameplayAbility
{
	GENERATED_BODY()

public:
	UAO_GameplayAbility_Traversal();

protected:
	virtual void OnAvatarSet(
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilitySpec& Spec) override;

	virtual bool CanActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayTagContainer* SourceTags = nullptr,
		const FGameplayTagContainer* TargetTags = nullptr,
		FGameplayTagContainer* OptionalRelevantTags = nullptr) const override;
	
	virtual void ActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		const FGameplayEventData* TriggerEventData) override;

	virtual void EndAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		bool bReplicateEndAbility,
		bool bWasCancelled) override;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Traversal")
	TObjectPtr<UChooserTable> AnimChooserTable;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Traversal|Debug")
	int32 DrawDebugLevel = 0;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Traversal|Debug")
	float DrawDebugDuration = 5.f;
	
private:
	FTraversalCheckResult TraversalResult;

	UPROPERTY()
	TObjectPtr<AActor> Owner = nullptr;
	UPROPERTY()
	TObjectPtr<ACharacter> Character = nullptr;
	UPROPERTY()
	TObjectPtr<UCharacterMovementComponent> CharacterMovement = nullptr;

private:
	UFUNCTION()
	void OnMontageCompleted();

	bool TryTraversal();
	bool GetTraversalCheckInputs(FTraversalCheckInput& OutTraversalInput);
	bool DetectTraversal();
	bool EvaluateTraversal(TArray<UObject*>& EvaluateObjects);
	bool SelectTraversal(const TArray<UObject*>& EvaluateObjects);

	void UpdateWarpTargets();

	bool RunCapsuleTrace(
		const FVector& StartLocation,
		const FVector& EndLocation,
		float Radius,
		float HalfHeight,
		FHitResult& OutHit,
		FColor DebugHitColor,
		FColor DebugTraceColor);
};
  • 먼저 생성자에서 해당 Ability의 다양한 속성을 지정해줬다.
  • InstancingPolicy는 액터에서 주기적으로 발생하기 때문에 InstancePerActor로 지정을 해줬고, NetExecutionPolicy는 파쿠르 애니메이션을 실행시켜주는 동작이기 때문에 UX를 위해서 LocalPredicted로 지정을 했다.
  • Ability의 태그는 Ability.Movement.Traversal로 지정했고, 캐릭터가 파쿠르를 실행 중이라면 추후에 다른 동작을 제한하기 위해서 Status.Action.Traversal 태그를 ActivationOwnedTags로 지정했다.
UAO_GameplayAbility_Traversal::UAO_GameplayAbility_Traversal()
{
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
	NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;

	const FGameplayTagContainer TraversalTag(FGameplayTag::RequestGameplayTag(FName("Ability.Movement.Traversal")));
	SetAssetTags(TraversalTag);

	ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Status.Action.Traversal")));
}
  • 기존의 파쿠르 컴포넌트에서도 캐릭터와 캐릭터 무브먼트를 캐싱해서 사용했는데, Ability에서는 BeginPlay()라는 생명주기 함수를 거치지 않았다. Ability에서는 OnAvatarSet()이라는 Ability가 캐릭터에 처음 부착될 때 실행되는 함수가 있어서 해당 함수에서 캐싱을 진행했다.
  • 기존에는 log와 null 방식으로 예외처리를 해줬는데, 앞으로는 치명적인 오류는 checkf()를 통해 예외처리를 진행하고자 한다.
void UAO_GameplayAbility_Traversal::OnAvatarSet(const FGameplayAbilityActorInfo* ActorInfo,
	const FGameplayAbilitySpec& Spec)
{
	Super::OnAvatarSet(ActorInfo, Spec);

	Owner = ActorInfo->AvatarActor.Get();
	checkf(Owner, TEXT("Failed to cast ActorInfo to AActor"));
	
	Character = Cast<ACharacter>(Owner);
	checkf(Character, TEXT("Failed to cast ActorInfo to ACharacter"));

	CharacterMovement = Character->GetCharacterMovement();
	checkf(CharacterMovement, TEXT("Failed to get CharacterMovementComponent"));
}
  • CanActivateAbility() 함수를 통해 파쿠르 Ability를 실행해도 되는지 검사를 실행했다.
  • 파쿠르를 실행하는 동안에는 파쿠르가 다시 동작되면 안 되기 때문에 IsActive()를 통해 검사를 실행했다.
  • 처음에는 ActivateAbility() 함수에서 TryTraversal()을 통해 파쿠르 가능 계산 여부와 애니메이션 선택 로직을 구현했는데, 그러다보니 실행된 것으로 결정되서 캐릭터에서 TryActivateAbilitiesByTag()를 했을 때 값이 항상 true가 반환이 되었다.
  • 이를 해결하기 위해 CanActivateAbility() 함수에서 애니메이션 선택까지 결정을 하게 변경했다.
  • CanActivateAbility()에서는 클래스의 내부 함수를 부를 때, const_cast<>(this)를 통해 캐스팅해서 함수를 실행시켜줘야 했다.
bool UAO_GameplayAbility_Traversal::CanActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags,
	const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const
{
	if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags))
	{
		return false;
	}

	if (IsActive())
	{
		return false;
	}

	if (!const_cast<UAO_GameplayAbility_Traversal*>(this)->TryTraversal())
	{
		return false;
	}
	
	return true;
}
  • ActivateAbility()에서는 기존의 몽타주를 실행시켜주는 과정을 실행했다.
  • 모션 워핑을 먼저 실행하고, 캐릭터 무브먼트의 클라 권위를 높여주고 몽타주를 실행했다.
  • 몽타주를 실행시키는 과정은 UAbilityTask_PlayMontageAndWait를 활용해서 몽타주 재생 및 동기화를 맞추고자 했다. 몽타주 재생이 종료되면 Ability를 종료시키기 위해서 바인딩도 했다.
void UAO_GameplayAbility_Traversal::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
                                                    const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
                                                    const FGameplayEventData* TriggerEventData)
{
	if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
	{
		EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
		return;
	}
    
	UpdateWarpTargets();

	if (CharacterMovement)
	{
		CharacterMovement->bIgnoreClientMovementErrorChecksAndCorrection = true;
		CharacterMovement->bServerAcceptClientAuthoritativePosition = true;
	}
	
	UAbilityTask_PlayMontageAndWait* MontageTask = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
		this,
		NAME_None,
		TraversalResult.ChosenMontage,
		TraversalResult.PlayRate,
		NAME_None,
		true,
		1.0f,
		TraversalResult.StartTime);
	
	MontageTask->OnCompleted.AddDynamic(this, &UAO_GameplayAbility_Traversal::OnMontageCompleted);
	MontageTask->OnInterrupted.AddDynamic(this, &UAO_GameplayAbility_Traversal::OnMontageCompleted);
	MontageTask->OnBlendOut.AddDynamic(this, &UAO_GameplayAbility_Traversal::OnMontageCompleted);
	MontageTask->OnCancelled.AddDynamic(this, &UAO_GameplayAbility_Traversal::OnMontageCompleted);
	
	MontageTask->ReadyForActivation();

	if (CharacterMovement)
	{
		CharacterMovement->SetMovementMode(MOVE_Flying);
	}
	
	if (ActorInfo->IsNetAuthority())
	{
		if (TObjectPtr<UCapsuleComponent> CapsuleComponent = Cast<UCapsuleComponent>(
			Owner->GetComponentByClass(UCapsuleComponent::StaticClass())))
		{
			CapsuleComponent->IgnoreComponentWhenMoving(TraversalResult.HitComponent, true);
		}
	}
}
  • 종료 로직은 EndAbility()에서 구현했고, 캐릭터 무브먼트 모드를 변경하고, 서버 권위를 다시 복구해줬다.
  • 여기서 RetriggerableDelay()를 통해 0.2초 정도를 기다린 후에 서버 권위를 복구해줬는데, 이에 대한 이유는 바로 복구해주면 서버와 클라의 몽타주 재생 이후에 위치 차이로 인해 순간 텔포하는 현상이 매우 심하게 발생했다. 이를 완화하기 위해서 0.2초 정도를 Delay했고, 이를 통해 서버와 클라의 위치 정도 차이를 보정했다.
void UAO_GameplayAbility_Traversal::EndAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
	bool bReplicateEndAbility, bool bWasCancelled)
{
	if (IsEndAbilityValid(Handle, ActorInfo))
	{
		if (ActorInfo->IsNetAuthority())
		{
			if (Character)
			{
				if (TObjectPtr<UCapsuleComponent> CapsuleComponent = Cast<UCapsuleComponent>(
						Character->GetComponentByClass(UCapsuleComponent::StaticClass())))
				{
					CapsuleComponent->IgnoreComponentWhenMoving(TraversalResult.HitComponent, false);
				}
			}
		}

		if (CharacterMovement)
		{
			if (TraversalResult.ActionType == ETraversalActionType::Vault)
			{
				CharacterMovement->SetMovementMode(MOVE_Falling);
			}
			else
			{
				CharacterMovement->SetMovementMode(MOVE_Walking);
			}

			UKismetSystemLibrary::RetriggerableDelay(this, 0.2f, FLatentActionInfo());
			
			CharacterMovement->bIgnoreClientMovementErrorChecksAndCorrection = false;
			CharacterMovement->bServerAcceptClientAuthoritativePosition = false;
		}

		Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
	}
}
  • 이외의 로직은 전에 작성했던 내용과 동일하게 작성했다.
  • 플레이어에서의 해당 Ability 활성화 로직은 아래와 같이 Tag 기반으로 작성을 했다.
  • Input에 연결을 할 때, 특히 Triggered로 작성을 할 때, 계속 Ability를 시도하기 때문에 이를 주의해야 했다.
void AAO_PlayerCharacter::StartJump()
{
	if (AbilitySystemComponent->TryActivateAbilitiesByTag(
		FGameplayTagContainer(FGameplayTag::RequestGameplayTag(FName("Ability.Movement.Traversal")))))
	{
		return;
	}

	Jump();
}

void AAO_PlayerCharacter::TriggerJump()
{
	AbilitySystemComponent->TryActivateAbilitiesByTag(
		FGameplayTagContainer(FGameplayTag::RequestGameplayTag(FName("Ability.Movement.Traversal"))));
}
  • 정상적으로 Run under one process 옵션을 꺼두어도 클라에서 정상적인 파쿠르 동작이 실행된다.


2. 정지해있는 상태에서 Sprint 키를 입력하면 스태미나가 감소하는 현상 수정

  • 정지해있는 상태(WASD 키 입력이 없을 때)에서 Sprint 키를 입력하면 스태미나가 감소하는 현상이 있었다.

  • 이를 해결하기 위해 기존의 Sprint 동작을 실행할 때, 캐릭터의 속력이 매우 작아서 0에 가깝다면 Sprint Ability가 실행이 되지 않게 CanActivateAbility() 함수에서 제어를 했다.

bool UAO_GameplayAbility_Sprint::CanActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags,
	const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const
{
	if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags))
	{
		return false;
	}

	const TObjectPtr<ACharacter> Character = Cast<ACharacter>(ActorInfo->AvatarActor.Get());
	checkf(Character, TEXT("Failed to cast AvatarActor to ACharacter"));

	const TObjectPtr<UCharacterMovementComponent> CharacterMovement = Character->GetCharacterMovement();
	checkf(CharacterMovement, TEXT("Failed to get CharacterMovementComponent"));

	if (CharacterMovement->Velocity.SizeSquared2D() < KINDA_SMALL_NUMBER)
	{
		return false;
	}

	return true;
}
  • 그리고 Sprint 동작을 하다가 멈춰서 캐릭터의 속력이 0이 되었을 때는 이미 Ability가 실행 중이어서 이를 제거해주는 로직이 별도로 필요했다.
  • ActivateAbility()는 처음에 Ability를 실행할 때만 동작하는 함수이기 때문에 해당 로직이 정상적으로 작동하지 않았다.
  • 이 Sprint 동작은 캐릭터에서 이미 실행 중이라면 InputPressed() 함수가 Trigger될 때마다 동작되는 것으로 알고 있어서 해당 함수에 속력이 0이 되면 Ability가 종료되는 로직을 작성했다.
void UAO_GameplayAbility_Sprint::InputPressed(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo)
{
	Super::InputPressed(Handle, ActorInfo, ActivationInfo);
	
	const TObjectPtr<ACharacter> Character = Cast<ACharacter>(ActorInfo->AvatarActor.Get());
	checkf(Character, TEXT("Failed to cast AvatarActor to ACharacter"));

	const TObjectPtr<UCharacterMovementComponent> CharacterMovement = Character->GetCharacterMovement();
	checkf(CharacterMovement, TEXT("Failed to get CharacterMovementComponent"));

	if (CharacterMovement->Velocity.SizeSquared2D() < KINDA_SMALL_NUMBER)
	{
		EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
	}
}
  • 아래와 같이 플레이어가 멈췄을 때도 정상적으로 스태미나가 리젠되는 것을 확인할 수 있다.


3. 공격과 피격 로직 GAS로 구현

  • 기존에는 노티파이 스테이트에서 DamageComponent로 공격하는 함수를 실행시키게끔 구현을 해서 DamageEffectClass를 통해 데미지를 가하는 로직으로 작성을 했었다.
  • 하지만 이 방식은 GAS를 쓰는 방식에서는 상당히 비효율적이고 DamageComponent에 의존적으로 작동할 것으로 보여서 Ability로 분리를 해서 구조를 다시 잡고자 했다.
  • 공격 로직
    (1) MeleeAbility를 실행시키면 공격하는 몽타주가 실행
    (2) 몽타주 내에서 노티파이 스테이트를 통해 Trace할 위치를 계산해서 데미지를 입히는 Event를 발생
    (3) 데미지를 입히는 Event가 발생하면 MeleeHitConfirm Ability 내부에서 Trace를 통해 데미지 입힐 액터를 찾고 DamageEffectClass를 통해 데미지를 가하고, 해당 액터에게 공격받았다는 Event를 발생
    (4) 공격받았다는 Event가 발생하면 HitReact Ability 내부에서 공격받은 몽타주를 실행

(1) 공격하는 몽타주를 실행시키는 Ability

  • 단순하게 UAbilityTask_PlayMontageAndWait를 활용해서 몽타주를 실행시켜주는 Ability다.
  • InstancingPolicy은 액터마다 별도로 재생이 필요하기 때문에 InstancedPerActor로 지정했고, NetExecutionPolicy은 몽타주를 실행시키는 동작은 왠만하면 LocalPredicted로 지정을 해서 UX를 향상시키고자 했다.
  • 이를 상속받아서 다양한 공격 로직을 만들 수 있을 것으로 보인다.
// AO_GameplayAbility_MeleeAttack_Base.h

/**
 * 기본 근접 공격 어빌리티.
 * - 몽타주 재생만 담당 (실제 데미지 판정은 AO_GameplayAbility_MeleeHitConfirm 에서 수행)
 */
UCLASS()
class AO_API UAO_GameplayAbility_MeleeAttack_Base : public UGameplayAbility
{
	GENERATED_BODY()

public:
	UAO_GameplayAbility_MeleeAttack_Base();

protected:
	virtual void ActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		const FGameplayEventData* TriggerEventData) override;

	virtual void EndAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		bool bReplicateEndAbility,
		bool bWasCancelled) override;

public:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Attack")
	TObjectPtr<UAnimMontage> AttackMontage;

protected:
	UFUNCTION()
	void OnMontageCompleted();

	UFUNCTION()
	void OnMontageCancelled();
};

(2) 트레이스/데미지를 가지고 있는 노티파이

  • 먼저 노티파이를 통해 트레이스와 데미지에 대한 정보를 전달해줄 것인데, GameplayEvent 기반으로 정보를 전달해야하기 때문에 별도의 구조체와 오브젝트 선언이 필요했다.
  • 트레이스 시작점과 끝점, 반지름, 데미지를 가할 GameplayEffect, 데미지의 양을 저장할 구조체를 FAO_MeleeHitTraceParams로 선언했다.
  • 해당 구조체만 가지고 있는 간단한 UObject도 구현을 해서, 해당 UObject를 이벤트 데이터에 넣어서 전달할 예정이다.
// AO_MeleeHitEventPayload.h

USTRUCT(BlueprintType)
struct AO_API FAO_MeleeHitTraceParams
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FVector TraceStart = FVector::ZeroVector;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FVector TraceEnd = FVector::ZeroVector;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float TraceRadius = 25.f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TSubclassOf<UGameplayEffect> DamageEffectClass;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float DamageAmount = 0.f;
};

UCLASS()
class AO_API UAO_MeleeHitEventPayload : public UObject
{
	GENERATED_BODY()

public:
	UPROPERTY(BlueprintReadWrite)
	FAO_MeleeHitTraceParams Params;
};
  • 기존의 UAO_NotifyState_MeleeCollision에서 DamageComponent를 통해 데미지를 가하는 로직이 아니라 GameplayEvent 기반으로 변경했다. 이를 위해 헤더에서는 실행시킬 Event의 태그를 받았다.
class AO_API UAO_NotifyState_MeleeCollision : public UAnimNotifyState
{
	GENERATED_BODY()

public:
	UAO_NotifyState_MeleeCollision();
	
    ...
    
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "GAS|Damage")
	FGameplayTag HitConfirmEventTag;
}
  • 기존에는 소켓을 무조건 가지고 있어야지만 정상적으로 동작을 했는데, 소켓이 없어도 동작을 하게끔 메시의 위치를 받아서 저장했다. 이를 통해 노티파이의 안정성을 높였다.
void UAO_NotifyState_MeleeCollision::NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
                                                 float TotalDuration, const FAnimNotifyEventReference& EventReference)
{
	...
    
	if (!SocketName.IsNone())
	{
		StartLocation = MeshComp->GetSocketLocation(SocketName);
	}
	else
	{
		StartLocation = MeshComp->GetComponentLocation();
	}
}
  • 노티파이 종료 시점에서 UAO_MeleeHitEventPayload에 트레이스/데미지 데이터를 저장하고 해당 데이터와 함께 GameplayEvent를 동작하게끔 했다.
  • 공격하는 사람이 데미지를 입히는 Ability를 발동해야 되기 때문에 Owner에게 Event를 발생하게끔 했다.
void UAO_NotifyState_MeleeCollision::NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
	const FAnimNotifyEventReference& EventReference)
{
	Super::NotifyEnd(MeshComp, Animation, EventReference);

	if (!MeshComp || !OwningActor.IsValid() || !DamageEffectClass)
	{
		return;
	}

	AActor* Owner = OwningActor.Get();
	if (!Owner || !Owner->HasAuthority())
	{
		return;
	}

	FVector EndLocation = StartLocation;
	if (!SocketName.IsNone())
	{
		EndLocation = MeshComp->GetSocketLocation(SocketName);
	}
	else
	{
		EndLocation = MeshComp->GetComponentLocation();
	}
	
	UAO_MeleeHitEventPayload* Payload = NewObject<UAO_MeleeHitEventPayload>(Owner);
	checkf(Payload, TEXT("Failed to create UAO_MeleeHitEventPayload"));

	Payload->Params.TraceStart = StartLocation;
	Payload->Params.TraceEnd = EndLocation;
	Payload->Params.TraceRadius = TraceRadius;
	Payload->Params.DamageEffectClass = DamageEffectClass;
	Payload->Params.DamageAmount = DamageAmount;

	FGameplayEventData EventData;
	EventData.EventTag = HitConfirmEventTag;
	EventData.Instigator = Owner;
	EventData.OptionalObject = Payload;
	EventData.EventMagnitude = DamageAmount;

	UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(Owner, HitConfirmEventTag, EventData);
}

(3) 히트 판정 및 데미지를 부여하는 Ability

  • 추후에 적과 플레이어의 구분이 필요할 것으로 보여서 TraceChannel을 별도로 받아서 사용하고자 했고, HitReact Event를 발생시켜야 하기 때문에 관련된 태그도 할당하게끔 했다.
// AO_GameplayAbility_MeleeHitConfirm.h

/**
 * 노티파이에서 보낸 이벤트를 받아 실제 히트 판정과 데미지 적용을 담당.
 */
UCLASS()
class AO_API UAO_GameplayAbility_MeleeHitConfirm : public UGameplayAbility
{
	GENERATED_BODY()

public:
	UAO_GameplayAbility_MeleeHitConfirm();

protected:
	virtual void ActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		const FGameplayEventData* TriggerEventData) override;

public:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Trace")
	TEnumAsByte<ECollisionChannel> TraceChannel;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Trace")
	bool bIgnoreInstigator = true;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "HitReact")
	FGameplayTag HitReactEventTag;

protected:
	void DoMeleeHitConfirm(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayEventData* EventData);
	void ApplyDamageToActor(AActor* TargetActor, const FAO_MeleeHitTraceParams& Params, const FGameplayAbilityActorInfo* ActorInfo);
	void SendHitReactEvent(AActor* TargetActor, AActor* InstigatorActor, float DamageAmount);
};
  • InstancingPolicy은 데미지를 가하는 Ability가 Actor마다 가지고 있어야 하기 때문에 InstancedPerActor로 지정했고, NetExecutionPolicy는 데미지를 가하는 로직이 서버에만 존재해야 하기 때문에 ServerOnly로 지정했다.
  • Trace 채널은 현재 별도의 적, 플레이어 구분이 없기 때문에 Pawn으로 지정했다.
  • 해당 Ability는 Event.Combat.Melee.Confirm라는 태그의 이벤트가 발생하면 작동하게끔 AbilityTriggers에 추가했고, HitReact Event 태그도 초기 값으로 Event.Combat.HitReact을 지정했다.
UAO_GameplayAbility_MeleeHitConfirm::UAO_GameplayAbility_MeleeHitConfirm()
{
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
	NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::ServerOnly;

	TraceChannel = ECC_Pawn;

	FAbilityTriggerData TriggerData;
	TriggerData.TriggerTag = FGameplayTag::RequestGameplayTag(FName("Event.Combat.Melee.Confirm"));
	TriggerData.TriggerSource = EGameplayAbilityTriggerSource::GameplayEvent;
	AbilityTriggers.Add(TriggerData);
	
	HitReactEventTag = FGameplayTag::RequestGameplayTag(FName("Event.Combat.HitReact"));
}
  • 서버에서만 실행되게 제한을 두었고, 기존의 DamageComponent에서 Trace를 통해 충돌 검사를 실시하는 로직을 그대로 작성했다.
void UAO_GameplayAbility_MeleeHitConfirm::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
	const FGameplayEventData* TriggerEventData)
{
	if (!ActorInfo->IsNetAuthority())
	{
		EndAbility(Handle, ActorInfo, ActivationInfo, false, true);
		return;
	}
	
	if (!TriggerEventData)
	{
		EndAbility(Handle, ActorInfo, ActivationInfo, false, true);
		return;
	}

	DoMeleeHitConfirm(ActorInfo, TriggerEventData);

	EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
}

void UAO_GameplayAbility_MeleeHitConfirm::DoMeleeHitConfirm(const FGameplayAbilityActorInfo* ActorInfo,
	const FGameplayEventData* EventData)
{
	checkf(ActorInfo, TEXT("Failed to get ActorInfo"));
	checkf(ActorInfo->AvatarActor.IsValid(), TEXT("Failed to get AvatarActor"));

	const UAO_MeleeHitEventPayload* Payload = Cast<UAO_MeleeHitEventPayload>(EventData->OptionalObject);
	if (!Payload)
	{
		return;
	}

	const FAO_MeleeHitTraceParams& Params = Payload->Params;
	
	UWorld* World = GetWorld();
	checkf(World, TEXT("Failed to get World"));

	TArray<FHitResult> HitResults;
	TArray<AActor*> ActorsToIgnore;

	if (bIgnoreInstigator)
	{
		ActorsToIgnore.Add(ActorInfo->AvatarActor.Get());
	}

	UKismetSystemLibrary::SphereTraceMulti(
		World,
		Params.TraceStart,
		Params.TraceEnd,
		Params.TraceRadius,
		UEngineTypes::ConvertToTraceType(TraceChannel),
		false,
		ActorsToIgnore,
		EDrawDebugTrace::ForDuration,
		HitResults,
		true);

	TSet<TWeakObjectPtr<AActor>> UniqueHitActors;

	for (const auto& Hit : HitResults)
	{
		AActor* HitActor = Hit.GetActor();
		if (!HitActor) continue;
		if (UniqueHitActors.Contains(HitActor)) continue;

		UniqueHitActors.Add(HitActor);
		ApplyDamageToActor(HitActor, Params, ActorInfo);
	}
}
  • 데미지를 가하는 로직 마지막에 HitReact Event를 보내는 로직을 추가해서 피격 대상이 React 애니메이션이 발동할 수 있게 했다.
void UAO_GameplayAbility_MeleeHitConfirm::SendHitReactEvent(AActor* TargetActor, AActor* InstigatorActor,
	float DamageAmount)
{
	if (!TargetActor || !HitReactEventTag.IsValid())
	{
		return;
	}

	FGameplayEventData HitEvent;
	HitEvent.EventTag = HitReactEventTag;
	HitEvent.Instigator = InstigatorActor;
	HitEvent.Target = TargetActor;
	HitEvent.EventMagnitude = DamageAmount;

	UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(TargetActor, HitReactEventTag, HitEvent);
}

(4) 피격 리액션 Ability

  • 위에서의 공격 Ability와 매우 유사하게 피격 몽타주만 실행시켜주는 Ability이다.
// AO_GameplayAbility_HitReact.h

/**
 * 피격 리액션 전용 Ability
 * - Event.Combat.HitReact 이벤트를 트리거로 발동
 */
UCLASS()
class AO_API UAO_GameplayAbility_HitReact : public UGameplayAbility
{
	GENERATED_BODY()

public:
	UAO_GameplayAbility_HitReact();

protected:
	virtual void ActivateAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		const FGameplayEventData* TriggerEventData) override;

	virtual void EndAbility(
		const FGameplayAbilitySpecHandle Handle,
		const FGameplayAbilityActorInfo* ActorInfo,
		const FGameplayAbilityActivationInfo ActivationInfo,
		bool bReplicateEndAbility,
		bool bWasCancelled) override;

protected:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "HitReact")
	TObjectPtr<UAnimMontage> HitReactMontage;

	UFUNCTION()
	void OnMontageCompleted();

	UFUNCTION()
	void OnMontageCancelled();
};
  • 차이점으로는 해당 Ability는 Event.Combat.HitReact 태그의 Event가 발생하면 작동하게끔 AbilityTriggers에 부여했다는 점이다.
  • 이후에 여기에 더 다양한 몽타주를 받아서 다양한 React 애니메이션을 추가할 예정이다.
UAO_GameplayAbility_HitReact::UAO_GameplayAbility_HitReact()
{
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
	NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;

	FAbilityTriggerData TriggerData;
	TriggerData.TriggerTag = FGameplayTag::RequestGameplayTag(FName("Event.Combat.HitReact"));
	TriggerData.TriggerSource = EGameplayAbilityTriggerSource::GameplayEvent;
	AbilityTriggers.Add(TriggerData);
}

문제점

  • 다음과 같이 구현을 하니 서버에서는 캐릭터가 HitReact Event가 정상적으로 작동했는데, 클라에서는 캐릭터가 데미지는 입으나 HitReact Event가 작동하지 않아서 애니메이션 출력이 되지 않았다.
  • 내일은 이 문제를 해결할 것 같다.

오늘의 CS

C++의 std::array와 C 배열의 차이점

  • std::array
    • C 배열의 효율성을 유지하면서 STL의 안전성과 기능을 결합한 것
    • 타입 정보 : 크기가 타입에 포함됨
    • 경계 검사 : at() 멤버 함수를 통해 런타임 오류를 방지할 수 있음
    • STL 호환성 : iterator 기반의 STL의 알고리즘 및 이터레이터 기능을 활용할 수 있음
    • 초기화 : 명시적으로 초기화하지 않으면 기본값으로 초기화됨
  • C 스타일 배열
    • 타입 정보 : 함수에 인수로 전달되면 포인터로 열화되어 크기 정보를 잃음. 배열 크기를 별도로 전달해야 함
    • 경계 검사 : 없음. 배열의 경계를 넘으면 크래시가 발생함
    • 초기화 : 초기화되지 않은 요소는 쓰레기 값을 가질 수 있음
profile
게임 개발자를 향해..

0개의 댓글