PART 12 · 강의 3/8

전투 시스템 구현

GAS 기반 콤보 시스템과 데미지 처리

01

GAS 기반 전투 시스템

GameplayAbilitySystem을 활용한 전투 구현

C++
// CombatAbility_MeleeAttack.h #pragma once #include "CoreMinimal.h" #include "Abilities/GameplayAbility.h" #include "CombatAbility_MeleeAttack.generated.h" /** * 근접 공격 어빌리티 * 콤보 시스템 지원 */ UCLASS() class OPENWORLDRPG_API UCombatAbility_MeleeAttack : public UGameplayAbility { GENERATED_BODY() public: UCombatAbility_MeleeAttack(); 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(BlueprintReadOnly, Category = "Combo") int32 CurrentComboIndex = 0; // 콤보 몽타주 배열 UPROPERTY(EditDefaultsOnly, Category = "Combo") TArray<TObjectPtr<UAnimMontage>> ComboMontages; // 콤보 데미지 배율 UPROPERTY(EditDefaultsOnly, Category = "Combo") TArray<float> ComboDamageMultipliers; // 콤보 윈도우 시간 UPROPERTY(EditDefaultsOnly, Category = "Combo") float ComboWindowTime = 0.5f; // 데미지 GameplayEffect 클래스 UPROPERTY(EditDefaultsOnly, Category = "Damage") TSubclassOf<UGameplayEffect> DamageEffectClass; // 히트 감지 수행 UFUNCTION() void PerformHitDetection(); // 콤보 진행 UFUNCTION() void AdvanceCombo(); // 몽타주 종료 핸들러 UFUNCTION() void OnMontageEnded(UAnimMontage* Montage, bool bInterrupted); }; // CombatAbility_MeleeAttack.cpp UCombatAbility_MeleeAttack::UCombatAbility_MeleeAttack() { InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor; NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted; ComboDamageMultipliers = { 1.0f, 1.2f, 1.5f, 2.0f }; } void UCombatAbility_MeleeAttack::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; } // 콤보 몽타주 재생 if (ComboMontages.IsValidIndex(CurrentComboIndex)) { UAnimMontage* Montage = ComboMontages[CurrentComboIndex]; if (UAbilityTask_PlayMontageAndWait* MontageTask = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy( this, NAME_None, Montage, 1.0f)) { MontageTask->OnCompleted.AddDynamic(this, &UCombatAbility_MeleeAttack::OnMontageEnded); MontageTask->OnInterrupted.AddDynamic(this, &UCombatAbility_MeleeAttack::OnMontageEnded); MontageTask->ReadyForActivation(); } } } void UCombatAbility_MeleeAttack::PerformHitDetection() { AActor* AvatarActor = GetAvatarActorFromActorInfo(); if (!AvatarActor) return; // 무기 위치에서 스윕 FVector Start = AvatarActor->GetActorLocation(); FVector End = Start + AvatarActor->GetActorForwardVector() * 200.0f; TArray<FHitResult> HitResults; FCollisionShape Shape = FCollisionShape::MakeSphere(50.0f); if (AvatarActor->GetWorld()->SweepMultiByChannel( HitResults, Start, End, FQuat::Identity, ECC_Pawn, Shape)) { for (const FHitResult& Hit : HitResults) { if (Hit.GetActor() == AvatarActor) continue; // 데미지 적용 ApplyDamageToTarget(Hit.GetActor()); } } } void UCombatAbility_MeleeAttack::ApplyDamageToTarget(AActor* Target) { if (!DamageEffectClass || !Target) return; FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(DamageEffectClass); if (SpecHandle.IsValid()) { // 콤보 데미지 배율 적용 float DamageMultiplier = ComboDamageMultipliers.IsValidIndex(CurrentComboIndex) ? ComboDamageMultipliers[CurrentComboIndex] : 1.0f; SpecHandle.Data->SetSetByCallerMagnitude( FGameplayTag::RequestGameplayTag(FName("Data.Damage")), BaseDamage * DamageMultiplier); // 타겟에 효과 적용 if (IAbilitySystemInterface* ASI = Cast<IAbilitySystemInterface>(Target)) { ASI->GetAbilitySystemComponent()->ApplyGameplayEffectSpecToSelf( *SpecHandle.Data.Get()); } } }
02

데미지 계산 시스템

속성 기반 데미지 처리

C++
// DamageExecutionCalculation.h #pragma once #include "CoreMinimal.h" #include "GameplayEffectExecutionCalculation.h" #include "DamageExecutionCalculation.generated.h" UCLASS() class OPENWORLDRPG_API UDamageExecutionCalculation : public UGameplayEffectExecutionCalculation { GENERATED_BODY() public: UDamageExecutionCalculation(); virtual void Execute_Implementation( const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const override; protected: // 캡처할 속성 정의 FGameplayEffectAttributeCaptureDefinition AttackPowerDef; FGameplayEffectAttributeCaptureDefinition ArmorDef; FGameplayEffectAttributeCaptureDefinition CriticalChanceDef; FGameplayEffectAttributeCaptureDefinition CriticalDamageDef; }; // DamageExecutionCalculation.cpp UDamageExecutionCalculation::UDamageExecutionCalculation() { // 소스 속성 캡처 AttackPowerDef.AttributeToCapture = UCombatAttributeSet::GetAttackPowerAttribute(); AttackPowerDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Source; AttackPowerDef.bSnapshot = true; RelevantAttributesToCapture.Add(AttackPowerDef); CriticalChanceDef.AttributeToCapture = UCombatAttributeSet::GetCriticalChanceAttribute(); CriticalChanceDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Source; CriticalChanceDef.bSnapshot = true; RelevantAttributesToCapture.Add(CriticalChanceDef); // 타겟 속성 캡처 ArmorDef.AttributeToCapture = UCombatAttributeSet::GetArmorAttribute(); ArmorDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target; ArmorDef.bSnapshot = false; RelevantAttributesToCapture.Add(ArmorDef); } void UDamageExecutionCalculation::Execute_Implementation( const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const { // 속성 값 가져오기 float AttackPower = 0.0f; float Armor = 0.0f; float CritChance = 0.0f; ExecutionParams.AttemptCalculateCapturedAttributeMagnitude( AttackPowerDef, FAggregatorEvaluateParameters(), AttackPower); ExecutionParams.AttemptCalculateCapturedAttributeMagnitude( ArmorDef, FAggregatorEvaluateParameters(), Armor); ExecutionParams.AttemptCalculateCapturedAttributeMagnitude( CriticalChanceDef, FAggregatorEvaluateParameters(), CritChance); // SetByCaller 데미지 float BaseDamage = 0.0f; ExecutionParams.GetOwningSpec().GetSetByCallerMagnitude( FGameplayTag::RequestGameplayTag(FName("Data.Damage")), false, BaseDamage); // 크리티컬 체크 bool bCritical = FMath::FRand() < CritChance; float CritMultiplier = bCritical ? 2.0f : 1.0f; // 최종 데미지 계산 float FinalDamage = (BaseDamage + AttackPower) * CritMultiplier; // 방어력 적용 (데미지 감소) float DamageReduction = Armor / (Armor + 100.0f); FinalDamage *= (1.0f - DamageReduction); // 최소 데미지 보장 FinalDamage = FMath::Max(FinalDamage, 1.0f); // 출력 OutExecutionOutput.AddOutputModifier( FGameplayModifierEvaluatedData( UCombatAttributeSet::GetHealthAttribute(), EGameplayModOp::Additive, -FinalDamage )); }
03

히트 판정 시스템

무기 트레일 기반 정밀 히트 감지

RPG 전투에서 히트 판정의 정밀도는 전투감에 직결됩니다. 단순한 Sphere Sweep 대신 무기 메시의 소켓을 기반으로 프레임마다 트레일을 생성하여 정밀한 히트 판정을 수행하면, 무기의 실제 궤적에 맞는 자연스러운 충돌 결과를 얻을 수 있습니다.

C++
// WeaponTraceComponent.h - 무기 트레일 히트 판정 #pragma once #include "CoreMinimal.h" #include "Components/ActorComponent.h" #include "WeaponTraceComponent.generated.h" UCLASS(ClassGroup=(Combat), meta=(BlueprintSpawnableComponent)) class OPENWORLDRPG_API UWeaponTraceComponent : public UActorComponent { GENERATED_BODY() public: UWeaponTraceComponent(); // 히트 판정 시작/종료 (AnimNotify에서 호출) UFUNCTION(BlueprintCallable, Category = "Combat") void StartTrace(); UFUNCTION(BlueprintCallable, Category = "Combat") void EndTrace(); // 히트 이벤트 델리게이트 DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( FOnWeaponHit, AActor*, HitActor, const FHitResult&, HitResult); UPROPERTY(BlueprintAssignable) FOnWeaponHit OnWeaponHit; protected: virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; // 무기 메시의 트레이스 시작/끝 소켓 UPROPERTY(EditAnywhere, Category = "Trace") FName TraceStartSocket = FName("TraceStart"); UPROPERTY(EditAnywhere, Category = "Trace") FName TraceEndSocket = FName("TraceEnd"); // 트레이스 반경 UPROPERTY(EditAnywhere, Category = "Trace") float TraceRadius = 5.0f; // 프레임 간 세분화 횟수 UPROPERTY(EditAnywhere, Category = "Trace") int32 SubStepCount = 4; // 무기 메시 참조 UPROPERTY(EditAnywhere, Category = "Trace") TObjectPtr<UMeshComponent> WeaponMesh; private: bool bIsTracing = false; FVector PrevStartPos; FVector PrevEndPos; // 이미 히트한 액터 추적 (중복 방지) TSet<TWeakObjectPtr<AActor>> HitActorsThisSwing; void PerformTrace(); }; // WeaponTraceComponent.cpp void UWeaponTraceComponent::StartTrace() { bIsTracing = true; HitActorsThisSwing.Empty(); // 초기 소켓 위치 기록 if (WeaponMesh) { PrevStartPos = WeaponMesh->GetSocketLocation(TraceStartSocket); PrevEndPos = WeaponMesh->GetSocketLocation(TraceEndSocket); } } void UWeaponTraceComponent::PerformTrace() { if (!WeaponMesh || !bIsTracing) return; FVector CurrStartPos = WeaponMesh->GetSocketLocation( TraceStartSocket); FVector CurrEndPos = WeaponMesh->GetSocketLocation( TraceEndSocket); // 프레임 간 서브스텝으로 빠른 무기 스윙도 감지 for (int32 Step = 0; Step <= SubStepCount; ++Step) { float Alpha = static_cast<float>(Step) / SubStepCount; FVector Start = FMath::Lerp(PrevStartPos, CurrStartPos, Alpha); FVector End = FMath::Lerp(PrevEndPos, CurrEndPos, Alpha); TArray<FHitResult> Hits; FCollisionShape Shape = FCollisionShape::MakeSphere(TraceRadius); if (GetWorld()->SweepMultiByChannel( Hits, Start, End, FQuat::Identity, ECC_GameTraceChannel1, Shape)) { for (const FHitResult& Hit : Hits) { AActor* HitActor = Hit.GetActor(); if (!HitActor) continue; if (HitActor == GetOwner()) continue; // 중복 히트 방지 if (!HitActorsThisSwing.Contains(HitActor)) { HitActorsThisSwing.Add(HitActor); OnWeaponHit.Broadcast(HitActor, Hit); } } } } PrevStartPos = CurrStartPos; PrevEndPos = CurrEndPos; }
AnimNotify를 활용한 히트 윈도우

AnimMontage에 AN_StartWeaponTraceAN_EndWeaponTrace AnimNotify를 배치하여 히트 판정 구간을 정밀하게 제어하세요. 이 방식은 애니메이션 타이밍과 히트 판정이 정확히 동기화되어 전투감이 크게 향상됩니다.

04

데미지 타입과 속성 시스템

원소 속성과 상태 이상을 포함한 데미지 파이프라인

RPG 전투 시스템에서는 단순한 수치 데미지 외에 원소 속성(불, 얼음, 번개 등)과 상태 이상(출혈, 중독, 기절 등)을 포함하는 복합적인 데미지 파이프라인이 필요합니다. GAS의 GameplayTag와 GameplayEffect를 결합하여 확장 가능한 데미지 타입 시스템을 구축합니다.

C++
// DamageTypeDefinition.h - 데미지 타입 정의 #pragma once #include "CoreMinimal.h" #include "GameplayTagContainer.h" #include "GameplayEffect.h" #include "DamageTypeDefinition.generated.h" /** * 데미지 타입 데이터 에셋 * 원소 속성, 상태 이상, 시각 효과를 정의 */ UCLASS(BlueprintType) class OPENWORLDRPG_API UDamageTypeDefinition : public UDataAsset { GENERATED_BODY() public: // 원소 태그 (예: Element.Fire) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Type") FGameplayTag ElementTag; // 속성 저항 태그 (예: Resistance.Fire) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Type") FGameplayTag ResistanceTag; // 적용할 상태 이상 효과 UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Effects") TSubclassOf<UGameplayEffect> StatusEffectClass; // 상태 이상 적용 확률 (0~1) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Effects", meta = (ClampMin = "0.0", ClampMax = "1.0")) float StatusEffectChance = 0.3f; // 원소 반응 테이블 (예: Fire + Ice = Steam) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Reaction") TMap<FGameplayTag, TSubclassOf<UGameplayEffect>> ElementalReactions; // 히트 GameplayCue 태그 UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "VFX") FGameplayTag HitCueTag; }; // DamageProcessor.h - 데미지 처리 파이프라인 UCLASS() class OPENWORLDRPG_API UDamageProcessor : public UBlueprintFunctionLibrary { GENERATED_BODY() public: // 최종 데미지 계산 (속성 저항 반영) UFUNCTION(BlueprintPure, Category = "Damage") static float CalculateElementalDamage( float BaseDamage, const UDamageTypeDefinition* DamageType, UAbilitySystemComponent* TargetASC) { if (!DamageType || !TargetASC) return BaseDamage; float FinalDamage = BaseDamage; // 타겟의 원소 저항 수치 확인 float Resistance = 0.0f; if (DamageType->ResistanceTag.IsValid()) { // 저항 속성에서 값 가져오기 bool bFound = false; Resistance = TargetASC ->GetGameplayAttributeValue( GetResistanceAttribute( DamageType->ResistanceTag), bFound); } // 저항 적용 (100 저항 = 50% 감소) float ReductionPct = Resistance / (Resistance + 100.0f); FinalDamage *= (1.0f - ReductionPct); // 원소 반응 체크 CheckElementalReaction(DamageType, TargetASC); return FMath::Max(FinalDamage, 1.0f); } // 상태 이상 적용 시도 UFUNCTION(BlueprintCallable, Category = "Damage") static void TryApplyStatusEffect( const UDamageTypeDefinition* DamageType, UAbilitySystemComponent* SourceASC, UAbilitySystemComponent* TargetASC) { if (!DamageType || !SourceASC || !TargetASC) return; if (!DamageType->StatusEffectClass) return; // 확률 체크 if (FMath::FRand() > DamageType->StatusEffectChance) return; // GameplayEffect 적용 FGameplayEffectContextHandle Context = SourceASC->MakeEffectContext(); Context.AddSourceObject(SourceASC->GetOwner()); FGameplayEffectSpecHandle Spec = SourceASC->MakeOutgoingSpec( DamageType->StatusEffectClass, 1, Context); if (Spec.IsValid()) { TargetASC->ApplyGameplayEffectSpecToSelf( *Spec.Data.Get()); } } };
데미지 파이프라인 처리 순서
  • 1. 기본 데미지 계산 -- 공격력, 무기 배율, 콤보 배율 적용
  • 2. 크리티컬 판정 -- 크리티컬 확률에 따른 추가 배율
  • 3. 방어력 감소 -- 타겟의 방어력에 의한 데미지 감소
  • 4. 원소 저항 적용 -- 원소 타입별 저항 수치 반영
  • 5. 원소 반응 체크 -- 기존 원소 상태와의 반응 처리
  • 6. 상태 이상 적용 -- 확률에 따른 디버프 효과 부여
GAS에서 원소 상태 추적

타겟에 현재 적용된 원소 상태를 추적하려면 GameplayTag를 활용하세요. 예를 들어 불 공격 시 State.Element.Fire 태그를 부여하고, 이후 얼음 공격이 들어오면 반응 테이블에서 Fire + Ice 조합을 확인하여 증기 폭발(Steam Explosion) 같은 원소 반응을 트리거합니다.

SUMMARY

핵심 요약

  • GAS 기반 콤보 공격 시스템 구현
  • AnimMontage와 연동된 히트 감지
  • WeaponTrace로 소켓 기반 정밀 히트 판정과 서브스텝 처리
  • DamageTypeDefinition으로 원소 속성과 상태 이상을 포함하는 데미지 파이프라인
  • ExecutionCalculation으로 복잡한 데미지 공식 및 저항 계산
PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: GAS 기반 전투 시스템 구축

AbilitySystemComponent, AttributeSet(Health, Mana, AttackPower, Defense)을 설정하고, 기본 공격 GA_MeleeAttack을 구현하세요. 히트 판정(SweepSingleByChannel)과 데미지 GE를 연결하세요.

실습 2: 스킬 시스템 구현

3-4개의 RPG 스킬(파이어볼, 힐, 버프, 범위 공격)을 GameplayAbility로 구현하세요. 각 스킬에 쿨다운 GE, 코스트(마나) GE, GameplayCue를 설정하세요.

심화 과제: 전투 밸런스 데이터 시스템

DataTable/CurveTable에서 데미지 공식 계수, 레벨별 스탯 성장, 스킬 데미지 배율을 관리하세요. 에디터에서 실시간으로 값을 변경하며 밸런싱할 수 있는 환경을 구축하세요.