GAS: 멀티플레이 & 예측 구조

김혁·2025년 10월 17일

챌린지

목록 보기
14/14

네트워크 기초와 GAS의 접근법

클라이언트 예측의 원리

  • 서버 응답을 기다리지 말고 일단 보여주기
// GAS의 예측 방식
void FireSkill_WithPrediction()
{
    if (IsLocallyControlled())
    {
        // 예측 윈도우 열기
        FScopedPredictionWindow PredictionWindow(ASC, true);

        // 즉시 시각 피드백
        PlayAnimation();
        ShowSkillEffect();

        // GameplayEffect로 예측 적용
        FGameplayEffectSpecHandle CostSpec = MakeOutgoingSpec(CostEffect);
        FGameplayEffectSpecHandle CooldownSpec = MakeOutgoingSpec(CooldownEffect);

        ASC->ApplyGameplayEffectSpecToSelf(*CostSpec.Data.Get());
        ASC->ApplyGameplayEffectSpecToSelf(*CooldownSpec.Data.Get());
    }

    // 동시에 서버 요청
    SendToServer_UseSkill();
}

롤백 메커니즘

  • 예측이 틀렸을 때의 처리 과정
// 롤백 과정
ClientState = {
    Mana: 100 → 50       // 예측: 마나 감소
    Animation: "Casting" // 애니메이션 시작
    Effect: "Fire"       // 이펙트 표시
    Cooldown: 5.0s       // 쿨다운 시작
}

// 서버 응답: "마나 부족!"
ServerResponse = {
    Success: false,
    Reason: "Not enough mana",
    ActualMana: 30
}

// 클라이언트 롤백
RollbackProcess = {
    RemoveEffect("Fire");
    StopAnimation();
    Mana: 50 → 30;
    Cooldown: Cancel();
    ShowMessage("마나가 부족합니다!");
}

Prediction Key

  • GAS의 예측 추적 시스템
struct FPredictionKey
{
	int16 Current;			  // 현재 예측 번호
    int16 Base;				  // 서버가 확인한 마지막 번호
    bool bIsServerInitiated;  // 서버가 시작한 액션인지
};
  • int16 자료형을 쓰는 이유
    • 메모리 효율
    • 네트워크 최적화 (작은 패킷)

예측 키 사용 예시

void ActivateFireballAbility()
{
    // 1. 새 예측 키 생성
    FPredictionKey PredictionKey = ASC->GetPredictionKeyForNewAction();

    // 2. 예측 윈도우 열기
    FScopedPredictionWindow Window(ASC, PredictionKey);

    // 3. 예측 실행 (모두 #42로 태그됨)
    ApplyGameplayEffect(CostEffect);      // 마나 -50 (예측 #42)
    ApplyGameplayEffect(CooldownEffect);  // 쿨다운 5초 (예측 #42)
    ApplyGameplayEffect(PowerUpEffect);   // 공격력 +10 (예측 #42)

    // 4. 서버에 키와 함께 전송
    ServerRPC_ActivateAbility(PredictionKey);
}

void ServerRPC_ActivateAbility(FPredictionKey PredictionKey)
{
    if (PlayerHasEnoughMana() && 
        AbilityNotOnCooldown() && 
        PlayerNotStunned())
    {
        ASC->ReplicatedPredictionKeyMap.Add(PredictionKey, true);
        BroadcastToOtherPlayers("이 플레이어가 파이어볼 썼음");
    }
    else
    {
        ASC->ReplicatedPredictionKeyMap.Add(PredictionKey, false);
        SendFailureReason("Not enough mana");
    }
}

Ability Activation 정책 심화

NetExecutionPolicy

enum class EGameplayAbilityNetExecutionPolicy : uint8
{
    LocalOnly,          // 로컬 전용
    LocalPredicted,     // 클라이언트 예측
    ServerOnly,         // 서버만 실행
    ServerInitiated     // 서버가 시작
};
정책용도예시
LocalOnly네트워크 무관UI 조작, 카메라 이동
LocalPredicted즉각 반응 필요기본 공격, 이동 스킬
ServerOnly보안 중요아이템 구매, 레벨업
ServerInitiated서버 이벤트보스 패턴, 환경 피해

Local Predicted 심화

FScopedPredictionWindow

  • 클라이언트 예측이 끝나는 시점을 명시적으로 알려주어, 예측 과정의 안전성과 효율성을 높이는 GAS의 도구
  • 예측 창을 스코프 단위로 설정해서 예측의 시작과 끝을 시스템에 알림
  • 사용 이유
    • 사용하지 않으면 GAS 시스템이 예측 종료 시점을 추정하게 되면서 오류가 발생할 수 있음
    • 비동기 작업이나 애니메이션 노티파이 등 능력의 실행 흐름을 벗어난 곳에서 상태 변경이 발생할 때, 예측 창이 닫히는 시점을 명확히 해야 예측이 올바르게 마무리됨
  • 사용법
    • FScopedPredictionWindow(ASC, true) : 새 키 생성
    • FScopedPredictionWindow(ASC, ExistingKey) : 기존 키 사용

사용 예시

void UMeleeAbility::ActivateAbility(...)
{
    // 예측 윈도우 열기
    FScopedPredictionWindow PredictionWindow(ASC, true);

    // 즉시 실행
    PlayMontage(AttackMontage);
    ExecuteGameplayCue("GameplayCue.Swing");
    PlaySound(SwingSound);

    // CommitAbility가 비용과 쿨다운 처리
    if (!CommitAbility(...))
    {
        EndAbility(...);
        return;
    }
}

서버 권위와 클라이언트 자유의 균형

// 경쟁 게임 (서버 권위 90%)
UCompetitiveAbility::UCompetitiveAbility()
{
    NetExecutionPolicy = ServerOnly;
    bServerRespectsRemoteAbilityCancellation = false;
    bReplicateInputDirectly = false;
}

// 캐주얼 게임 (클라이언트 자유 70%)
UCasualAbility::UCasualAbility()
{
    NetExecutionPolicy = LocalPredicted;
    bServerRespectsRemoteAbilityCancellation = true;
    bReplicateInputDirectly = true;
}

// 배틀로얄 (하이브리드)
void UBattleRoyaleAbility::ActivateAbility(...)
{
    float DistanceToEnemy = GetDistanceToNearestEnemy();

    if (DistanceToEnemy < 1000.0f)  // 10미터 이내
    {
        SetNetExecutionPolicy(ServerOnly);  // 근접전: 서버 권위
    }
    else
    {
        SetNetExecutionPolicy(LocalPredicted);  // 원거리: 예측
    }
}

예측형 근접 공격 예시

GameplayTag 설정

GASGameplayTags.h

#pragma once

#include "NativeGameplayTags.h"

namespace GASTags
{
	// Ability Tags
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Ability_Melee);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Ability_Melee_Light);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Ability_Melee_Heavy);

	// Event Tags
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Event_Melee_Hit);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Event_Melee_HitConfirm);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Event_Combo_Window_Open);

	// GameplayCue Tags
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Cue_Melee_Swing);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Cue_Melee_Hit);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(Cue_Melee_Hit_Blood);

	// SetByCaller Tags
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(SetByCaller_Damage);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(SetByCaller_Damage_Critical);
	UE_DECLARE_GAMEPLAY_TAG_EXTERN(SetByCaller_Force);
}

GASGameplayTags.cpp

#include "GASGameplayTags.h"

namespace GASTags
{
	// Ability Tags
	UE_DEFINE_GAMEPLAY_TAG_COMMENT(Ability_Melee, "Ability.Melee", "Base tag for all melee abilities");
	UE_DEFINE_GAMEPLAY_TAG(Ability_Melee_Light, "Ability.Melee.Light");
	UE_DEFINE_GAMEPLAY_TAG(Ability_Melee_Heavy, "Ability.Melee.Heavy");

	// Event Tags
	UE_DEFINE_GAMEPLAY_TAG(Event_Melee_Hit, "Event.Melee.Hit");
	UE_DEFINE_GAMEPLAY_TAG(Event_Melee_HitConfirm, "Event.Melee.HitConfirm");
	UE_DEFINE_GAMEPLAY_TAG(Event_Combo_Window_Open, "Event.Combo.Window.Open");

	// GameplayCue Tags
	UE_DEFINE_GAMEPLAY_TAG(Cue_Melee_Swing, "GameplayCue.Melee.Swing");
	UE_DEFINE_GAMEPLAY_TAG(Cue_Melee_Hit, "GameplayCue.Melee.Hit");
	UE_DEFINE_GAMEPLAY_TAG(Cue_Melee_Hit_Blood, "GameplayCue.Melee.Hit.Blood");

	// SetByCaller Tags
	UE_DEFINE_GAMEPLAY_TAG(SetByCaller_Damage, "SetByCaller.Damage");
	UE_DEFINE_GAMEPLAY_TAG(SetByCaller_Damage_Critical, "SetByCaller.Damage.Critical");
	UE_DEFINE_GAMEPLAY_TAG(SetByCaller_Force, "SetByCaller.Force");
}

Ability_Melee

Ability_Melee.h

#pragma once

#include "CoreMinimal.h"
#include "Abilities/GameplayAbility.h"
#include "Ability_Melee.generated.h"

UCLASS()
class GASSAMPLE_API UAbility_Melee : public UGameplayAbility
{
	GENERATED_BODY()

public:
	UAbility_Melee();

	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:
	void PerformMeleeAttack();
	void ApplyDamageToTarget(AActor* Target);
	TArray<AActor*> PerformHitDetection(const FVector& HitLocation);

	FTimerHandle HitCheckTimer;
	FTimerHandle EndAbilityTimer;

	UPROPERTY(EditDefaultsOnly, Category = "Animation")
	UAnimMontage* AttackMontage;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	TSubclassOf<UGameplayEffect> DamageEffectClass;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	float BaseDamage = 20.0f;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	float CriticalChance = 10.0f;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	float CriticalMultiplier = 2.0f;

	UPROPERTY(EditDefaultsOnly, Category = "HitDetection")
	float HitRadius = 100.0f;

	UPROPERTY(EditDefaultsOnly, Category = "HitDetection")
	float HitRange = 150.0f;

	UPROPERTY(EditDefaultsOnly, Category = "Timing")
	float HitCheckDelay = 0.4f;
};

Ability_Melee.cpp

#include "Ability_Melee.h"

#include "AbilitySystemComponent.h"
#include "AbilitySystemBlueprintLibrary.h"
#include "GameFramework/Character.h"

#include "GASSample/GASGameplayTags.h"

UAbility_Melee::UAbility_Melee()
	: AttackMontage(nullptr)
{
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerExecution;
	NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
	bServerRespectsRemoteAbilityCancellation = true;

	AbilityTags.AddTag(GASTags::Ability_Melee);
	BlockAbilitiesWithTag.AddTag(FGameplayTag::RequestGameplayTag("State.Stunned"));
	BlockAbilitiesWithTag.AddTag(FGameplayTag::RequestGameplayTag("State.Dead"));
}

void UAbility_Melee::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
	const FGameplayEventData* TriggerEventData)
{
	if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
	{
		if (IsLocallyControlled())
		{
			UE_LOG(LogTemp, Warning, TEXT("스태미나 부족함"));
		}

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

	UAbilitySystemComponent* ASC = ActorInfo->AbilitySystemComponent.Get();
	if (!ASC)
	{
		EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
		return;
	}

	// 예측 GameplayCue
	if (IsLocallyControlled() && GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalPredicted)
	{
		FScopedPredictionWindow PredictionWindow(ASC, true);

		FGameplayCueParameters CueParams;
		CueParams.SourceObject = GetAvatarActorFromActorInfo();
		CueParams.Location = GetAvatarActorFromActorInfo()->GetActorLocation();

		ASC->ExecuteGameplayCue(GASTags::Cue_Melee_Swing, CueParams);
	}

	// 몽타주 재생
	if (AttackMontage)
	{
		ACharacter* Character = CastChecked<ACharacter>(ActorInfo->AvatarActor.Get());
		Character->PlayAnimMontage(AttackMontage);

		// 히트 타이밍 체크
		FTimerDelegate HitDelegate;
		HitDelegate.BindUObject(this, &UAbility_Melee::PerformMeleeAttack);
		GetWorld()->GetTimerManager().SetTimer(HitCheckTimer, HitDelegate, HitCheckDelay, false);

		// 애니메이션 종료시 Ability 종료
		FTimerDelegate EndDelegate;
		EndDelegate.BindLambda([this, Handle, ActorInfo, ActivationInfo]()
		{
			EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
		});
		GetWorld()->GetTimerManager().SetTimer(EndAbilityTimer, EndDelegate, 1.0f, false);
	}
	else
	{
		PerformMeleeAttack();
		EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
	}
}

void UAbility_Melee::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo,
	const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{
	if (GetWorld())
	{
		GetWorld()->GetTimerManager().ClearTimer(HitCheckTimer);
		GetWorld()->GetTimerManager().ClearTimer(EndAbilityTimer);
	}

	if (bWasCancelled)
	{
		if (ACharacter* Character = Cast<ACharacter>(ActorInfo->AvatarActor.Get()))
		{
			Character->StopAnimMontage();
		}
	}
	
	Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}

void UAbility_Melee::PerformMeleeAttack()
{
	const FGameplayAbilityActorInfo* Info = GetCurrentActorInfo();
	UAbilitySystemComponent* ASC = Info ? Info->AbilitySystemComponent.Get() : nullptr;

	if (!ASC) return;

	FVector Forward = GetAvatarActorFromActorInfo()->GetActorForwardVector();
	FVector ActorLoc = GetAvatarActorFromActorInfo()->GetActorLocation();
	FVector HitLocation = ActorLoc + (Forward * HitRange);

	FGameplayCueParameters HitParams;
	HitParams.Location = HitLocation;
	ASC->ExecuteGameplayCue(GASTags::Cue_Melee_Hit, HitParams);

	if (HasAuthority(&CurrentActivationInfo))
	{
		TArray<AActor*> HitActors = PerformHitDetection(ActorLoc);
		for (AActor* Target : HitActors)
		{
			ApplyDamageToTarget(Target);
		}
	}
}

void UAbility_Melee::ApplyDamageToTarget(AActor* Target)
{
	if (!Target || !DamageEffectClass) return;

	const FGameplayAbilityActorInfo* Info = GetCurrentActorInfo();
	UAbilitySystemComponent* SourceASC = Info ? Info->AbilitySystemComponent.Get() : nullptr;
	UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Target);
	if (!SourceASC || !TargetASC) return;

	FGameplayEffectContextHandle ContextHandle = SourceASC->MakeEffectContext();
	ContextHandle.AddSourceObject(this);

	FGameplayEffectSpecHandle DamageSpec = SourceASC->MakeOutgoingSpec(DamageEffectClass, GetAbilityLevel(), ContextHandle);
	if (!DamageSpec.IsValid()) return;

	float FinalDamage = BaseDamage;
	bool bIsCritical = (FMath::RandRange(0.0f, 100.0f) < CriticalChance);

	// 크리티컬 발동 시
	if (bIsCritical)
	{
		FinalDamage *= CriticalMultiplier;

		FGameplayCueParameters CritParams;
		CritParams.Location = GetAvatarActorFromActorInfo()->GetActorLocation();
		TargetASC->ExecuteGameplayCue(FGameplayTag::RequestGameplayTag("GameplayCue.Damage.Critical"), CritParams);
	}
	
    // SetByCaller 데이터 적용
	DamageSpec.Data->SetSetByCallerMagnitude(GASTags::SetByCaller_Damage, -FinalDamage);
	SourceASC->ApplyGameplayEffectSpecToTarget(*DamageSpec.Data.Get(), TargetASC);
}

TArray<AActor*> UAbility_Melee::PerformHitDetection(const FVector& HitLocation)
{
	TArray<AActor*> HitActors;

	FCollisionQueryParams Params;
	Params.AddIgnoredActor(GetAvatarActorFromActorInfo());

	FVector Start = GetAvatarActorFromActorInfo()->GetActorLocation();
	FVector End = Start + (GetAvatarActorFromActorInfo()->GetActorForwardVector() * HitRange);

	TArray<FHitResult> HitResults;
	GetWorld()->SweepMultiByChannel(HitResults, Start, End, FQuat::Identity, ECC_Pawn, FCollisionShape::MakeSphere(HitRadius), Params);

	return HitActors;
}

Effect_Melee

Effect_Melee.cpp

#include "Effect_Melee.h"

#include "GASSample/GASGameplayTags.h"
#include "GASSample/GAS/AttributeSet/MyAttributeSet.h"

UEffect_Melee::UEffect_Melee()
{
	DurationPolicy = EGameplayEffectDurationType::Instant;

	FGameplayModifierInfo DamageModifier;
	DamageModifier.Attribute = UMyAttributeSet::GetHealthAttribute();
	DamageModifier.ModifierOp = EGameplayModOp::Additive;
	
    // SetByCaller 데이터로 데미지 설정
	FSetByCallerFloat SetByCallerMagnitude;
	SetByCallerMagnitude.DataTag = GASTags::Ability_Melee;
	DamageModifier.ModifierMagnitude = FGameplayEffectModifierMagnitude(SetByCallerMagnitude);

	Modifiers.Add(DamageModifier);
}
profile
게임 개발자를 향해..

0개의 댓글