AFO_Refactor 개발일지 #3
GAS 도입 — 왜, 무엇을, 어떻게
Gameplay Ability System · ASC · AttributeSet · Phase 1 셋업
🎯 왜 GAS를 도입했나
팀 프로젝트 AFO에서 HP/Mana 관련 코드가 이렇게 흩어져 있었다.
// 기존 — 4곳에 분산된 HP/Mana 관리 UAFAttributeComponent ← HP 실제 저장/계산 AAFPlayerState ← HP/Mana 복제 변수 (수동 동기화) AAFPlayerCharacter ← 마나 소모 체크 UAFStatusEffectComponent ← 슬로우 타이머
문제가 명확했다.
- HP가 바뀔 때마다
SyncHealthToPlayerState()를 수동으로 호출해야 했다 - 마나 소모 검증을 서버에서 직접 처리하는 코드가 캐릭터 클래스에 하드코딩되어 있었다
- 슬로우는 타이머 기반이라 캐릭터가 죽어도 타이머가 계속 돌았다
- 새 캐릭터/스킬 추가 시 이 흩어진 코드를 전부 수정해야 했다
GAS 도입 목표: 전투 관련 수치/스킬/상태효과를 하나의 프레임워크로 통합하고, 네트워크 복제를 자동화한다.
📚 GAS란 무엇인가
Gameplay Ability System은 언리얼 공식 플러그인으로, 캐릭터의 능력(스킬), 속성(HP/마나), 상태효과(버프/디버프)를 데이터 중심으로 관리하는 프레임워크다.
핵심 구성요소 5가지:
UAbilitySystemComponent (ASC) ├── UAttributeSet ← HP, Mana, 공격력 등 수치 관리 ├── UGameplayAbility ← 스킬 (Q, E, 기본공격) ├── UGameplayEffect ← 수치 변화 (데미지, 힐, 버프) └── FGameplayTag ← 상태 태그 (공격중, 스턴, 사망)
AFO 기존 코드와 1:1로 대응하면 이렇게 된다:
| 기존 AFO | GAS 교체 후 |
|---|---|
UAFAttributeComponent |
UAttributeSet |
| PlayerState HP/Mana + 수동 OnRep | FGameplayAttributeData + 자동 복제 |
DealDamage() 직접 HP 감소 |
UGameplayEffect (Instant) |
ConsumeMana() 직접 차감 |
UGameplayEffect (Instant + SetByCaller) |
AFStatusEffectComponent 슬로우 타이머 |
UGameplayEffect (Duration) |
✅ GAS를 쓰면 뭐가 좋아지나
① 네트워크 복제 자동화
BEFORE — 수동 동기화
void UAFAttributeComponent::ApplyDamage(...)
{
Health -= Damage;
SyncHealthToPlayerState(); // 매번 수동 호출 필요
}
AFTER — 선언 한 번으로 자동 복제
UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Health) FGameplayAttributeData Health; // 엔진이 자동으로 복제 처리
② 조건 검증 내장
마나 부족 시 스킬 차단을 직접 코딩할 필요 없이, GameplayEffect Cost로 선언하면 자동으로 검증된다.
③ 상태 관리 통합
기존에 흩어져 있던 bIsAttacking, bIsUsingSkill, bIsHeavyAttacking 플래그들을 GameplayTag로 통합 관리할 수 있다.
🔧 Phase 1 — GAS 기반 셋업
Step 1 — 모듈 추가
// AFO.Build.cs PublicDependencyModuleNames.AddRange(new string[] { "GameplayAbilities", "GameplayTags", "GameplayTasks" });
Step 2 — UAFAttributeSet 생성
HP, MaxHP, Mana, MaxMana, AttackPower 5개 속성을 정의했다. ATTRIBUTE_ACCESSORS 매크로로 Getter/Setter/Initter를 자동 생성한다.
// AFAttributeSet.h #define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \ GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName) UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Health) FGameplayAttributeData Health; ATTRIBUTE_ACCESSORS(UAFAttributeSet, Health) UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_MaxHealth) FGameplayAttributeData MaxHealth; ATTRIBUTE_ACCESSORS(UAFAttributeSet, MaxHealth)
Step 3 — ASC를 PlayerState에 부착
ASC(AbilitySystemComponent)를 PlayerState에 붙이는 게 멀티플레이 표준 패턴이다. 캐릭터가 죽고 리스폰되어도 PlayerState는 유지되기 때문에 ASC와 어트리뷰트가 보존된다.
// AFPlayerState.h — IAbilitySystemInterface 구현 class AAFPlayerState : public APlayerState, public IAbilitySystemInterface { virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override; UPROPERTY(VisibleAnywhere, Category="GAS") TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent; UPROPERTY(VisibleAnywhere, Category="GAS") TObjectPtr<UAFAttributeSet> AttributeSet; };
Step 4 — InitAbilityActorInfo 호출
GAS가 "이 ASC의 소유자(Owner)는 PlayerState, 아바타(Avatar)는 Character"라는 걸 알 수 있도록 초기화해줘야 한다. 서버와 클라이언트 각각 다른 시점에 호출해야 한다.
// 서버 — PossessedBy()에서 초기화 void AAFPlayerCharacter::PossessedBy(AController* NewController) { Super::PossessedBy(NewController); if (AAFPlayerState* PS = GetPlayerState<AAFPlayerState>()) { PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this); } } // 클라이언트 — OnRep_PlayerState()에서 초기화 void AAFPlayerCharacter::OnRep_PlayerState() { Super::OnRep_PlayerState(); if (AAFPlayerState* PS = GetPlayerState<AAFPlayerState>()) { PS->GetAbilitySystemComponent()->InitAbilityActorInfo(PS, this); } }
💡 서버와 클라이언트 분리가 핵심: 서버는 PossessedBy에서, 클라이언트는 OnRep_PlayerState에서 초기화해야 한다. 어느 한쪽만 하면 반대편에서 ASC가 작동하지 않는다.
📡 ASC 리플리케이션 모드 선택
GAS에서 ASC 리플리케이션 모드는 3가지다. AFO는 Mixed를 선택했다.
| 모드 | GameplayEffect 복제 | 적합한 경우 |
|---|---|---|
| Full | 모든 클라이언트 | 싱글플레이 |
| Mixed ✅ | 소유 클라이언트만 | 플레이어 캐릭터 멀티플레이 |
| Minimal | 복제 안 함 | AI/NPC |
🏷️ GameplayTag 등록
GAS에서 데이터를 런타임에 전달하거나 상태를 표현할 때 GameplayTag를 사용한다. Config/DefaultGameplayTags.ini에 등록한다.
# Config/DefaultGameplayTags.ini [/Script/GameplayTags.GameplayTagsSettings] +GameplayTagList=(Tag="Data.Damage",DevComment="데미지 전달용") +GameplayTagList=(Tag="Data.ManaCost",DevComment="마나 소모 전달용") +GameplayTagList=(Tag="Data.SlowDuration",DevComment="슬로우 지속시간") +GameplayTagList=(Tag="Effect.Slowed",DevComment="슬로우 상태") +GameplayTagList=(Tag="State.IsDead",DevComment="사망 상태")
📌 이번 작업에서 배운 것
ASC는 PlayerState에 붙여야 한다.
캐릭터에 붙이면 사망/리스폰 시 캐릭터가 Destroy되면서 ASC도 사라진다. PlayerState는 리스폰 후에도 유지되므로 HP, 마나, 어빌리티가 보존된다. 멀티플레이에서 플레이어 캐릭터의 ASC는 반드시 PlayerState에 붙이는 것이 표준 패턴이다.
InitAbilityActorInfo는 서버/클라이언트 각각 호출해야 한다.
서버는 PossessedBy, 클라이언트는 OnRep_PlayerState. 이 두 시점을 놓치면 한쪽에서 GAS가 동작하지 않는다. 처음 GAS를 도입할 때 가장 실수하기 쉬운 부분이다.
📌 다음 포스팅
[AFO_Refactor #4] GAS 실전 적용 — 데미지/마나/슬로우 GameplayEffect 교체
ApplyDamage → AFGE_Damage, ConsumeMana → AFGE_ManaCost, 슬로우 타이머 → AFGE_Slow Duration. PostGameplayEffectExecute로 사망 처리 연결까지.