네트워크 기초와 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
struct FPredictionKey
{
int16 Current; // 현재 예측 번호
int16 Base; // 서버가 확인한 마지막 번호
bool bIsServerInitiated; // 서버가 시작한 액션인지
};
예측 키 사용 예시
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 설정
#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);
}
#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);
}